简介:Spring Security是一个专注于为Java应用程序提供身份认证和授权的框架,它的强大之处在于它可以轻松扩展以满足自定义的需求。
Spring Security底层是基于Filter实现的,Filter与 DispatcherServlet的关系类似于Interceptor与Controller的关系。

特征:
使用(以登录验证为例)
导入依赖spring-boot-starter-security,添加完该依赖以后,spring security就已经生效了,重启项目,再次打开首页,会自动跳转到一个登录页面,这个登录页面是Spring security自带的,我们可以用自己写的登录页面替换它。Spring security会自动生成一个用户和密码,用于默认登录页面的验证。
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
对实体类进行处理,让跟登录相关的实体类实现UserDetails接口中的一些方法,(isAccountNonExpired:判断账号是否过期;isAccountNonLocked:判断账号是否锁定;isCredentialsNonExpired:判断凭证是否过期;isEnabled:判断账号是否可用;getAuthorities:获得用户拥有的权限)
// 示例
public class User implements UserDetails {
private int id;
private String username;
private String password;
private String salt;
private String email;
private int type;
private int status;
private String activationCode;
private String headerUrl;
private Date createTime;
// 此处省略 get方法、set方法、toString方法
// true : 账号未过期
@Override
public boolean isAccountNonExpired() {
return true;
}
// true : 账号未锁定
@Override
public boolean isAccountNonLocked() {
return true;
}
// true: 凭证未过期
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// true: 账号可用
@Override
public boolean isEnabled() {
return true;
}
// 权限
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> list = new ArrayList<>();
list.add(new GrantedAuthority() {
@Override
public String getAuthority() {
switch (type){
case 1:
return "ADMIN";
default:
return "USER";
}
}
});
return list;
}
}
将业务层的相关类实现UserDetailsService接口中的loadUserByUsername方法。
// 示例
@Service
public class UserService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
public User findUserByName(String username) {
return userMapper.selectByName(username);
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return this.findUserByName(username);
}
}
配置SecurityConfig配置类,该类继承WebSecurityConfigurerAdapter,重写3个config方法。包括认证的逻辑(自定义认证规则),授权的逻辑(登录相关的配置,退出相关的配置,授权配置,增加Filter,添加记住我功能等)。
// 示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserService userService;
@Override
public void configure(WebSecurity web) throws Exception {
// 忽略静态资源的访问
web.ignoring().antMatchers("/resources/**");
}
// 认证的逻辑
// AuthenticationManager:认证的核心接口。
// AuthenticationManagerBuilder:用于构建AuthenticationManager对象的工具。
// ProviderManager:AuthenticationManager接口的默认实现类。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 内置的认证规则
// auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("12345"));
// 自定义认证规则
// AuthenticationProvider:ProviderManager持有一组AuthenticationProvider,每个AuthenticationProvider负责一种认证。
// 委托模式:ProviderManager将认证委托给AuthenticationProvider。
auth.authenticationProvider(new AuthenticationProvider() {
// Authentication:用于封装认证信息的接口,不同的实现类代表不同类型的认证信息。
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = (String) authentication.getCredentials();
User user = userService.findUserByName(username);
if(user == null){
throw new UsernameNotFoundException("账号不存在!");
}
password = CommunityUtil.md5(password+user.getSalt());
if(!user.getPassword().equals(password)){
throw new BadCredentialsException("密码不正确!");
}
// principal:认证的主要信息; credentials:证书; authorities:权限
return new UsernamePasswordAuthenticationToken(user,user.getPassword(),user.getAuthorities());
}
//当前的AuthenticationProvider支持哪种类型的认证。
@Override
public boolean supports(Class<?> aClass) {
// UsernamePasswordAuthenticationToken:Authentication接口的常用的实现类,账号密码认证。
return UsernamePasswordAuthenticationToken.class.equals(aClass);
}
});
}
// 授权的逻辑
@Override
protected void configure(HttpSecurity http) throws Exception {
// 登录相关的配置
http.formLogin()
.loginPage("/loginpage")
.loginProcessingUrl("/login")
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 重定向
response.sendRedirect(request.getContextPath()+"/index");
}
})
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
// 转发
request.setAttribute("error",e.getMessage());
request.getRequestDispatcher("/loginpage").forward(request,response);
}
});
// 退出相关配置
http.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(new LogoutSuccessHandler() {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.sendRedirect(request.getContextPath() + "/index");
}
});
// 授权配置
http.authorizeRequests()
.antMatchers("/letter").hasAnyAuthority("USER","ADMIN")
.antMatchers("/admin").hasAnyAuthority("ADMIN")
.and().exceptionHandling().accessDeniedPage("/denied");
// 增加Filter,处理验证码
http.addFilterBefore(new Filter() {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
if(request.getServletPath().equals("/login")){
String verifyCode = request.getParameter("verifyCode");
System.out.println("验证码:" + verifyCode);
if(verifyCode == null || !verifyCode.equalsIgnoreCase("1234")){
request.setAttribute("error","验证码错误!");
request.getRequestDispatcher("/loginpage").forward(request,response);
return ;
}
}
// 让请求继续向下执行
filterChain.doFilter(request,response);
}
}, UsernamePasswordAuthenticationFilter.class);
// 记住我
http.rememberMe()
.tokenRepository(new InMemoryTokenRepositoryImpl())
.tokenValiditySeconds(3600 * 24)
.userDetailsService(userService);
}
}
注意⚠️
认证成功后,结果会通过SecurityContextHolder存入SecurityContext中,访问相关信息的时候,需要SecurityContextHolder。
Security规定退出登录功能的请求方式必须是post。
记住我功能前端代码部分name必须是 remember-me。
重定向与转发示意图:

登录检查:之前采用拦截器实现了登录检查,这是简单的权限管理方案,现将其废弃。
实现:将WebMvcConfig配置类中跟登录检查相关的代码注释掉。
授权配置:对当前系统内包含的所有的请求,分配访问权限(普通用户user,版主moderator,管理员admin)。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant {
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/resources/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 授权
http.authorizeRequests()
.antMatchers(
"/user/setting",
"/user/upload",
"/discuss/add",
"/comment/add/**",
"/letter/**",
"/notice/**",
"/like",
"/follow",
"/unfollow"
).hasAnyAuthority(
AUTHORITY_USER,
AUTHORITY_ADMIN,
AUTHORITY_MODERATOR
).antMatchers(
"/discuss/top",
"/discuss/wonderful"
)
.hasAnyAuthority(
AUTHORITY_MODERATOR
).antMatchers(
"/discuss/delete",
"/data/**",
"/actuator/**"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
.anyRequest().permitAll()
.and().csrf().disable();
// 权限不够时的处理
http.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPoint() {
// 没有登录时的处理
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
String xRequestedWith = request.getHeader("x-requested-with");
if("XMLHttpRequest".equals(xRequestedWith)){
// 异步时的处理
response.setContentType("application/plain;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(403,"你还没有登录哦!"));
}else{
response.sendRedirect(request.getContextPath() + "/login");
}
}
})
.accessDeniedHandler(new AccessDeniedHandler() {
// 权限不足时的处理
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
String xRequestedWith = request.getHeader("x-requested-with");
if("XMLHttpRequest".equals(xRequestedWith)){
// 异步时的处理
response.setContentType("application/plain;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(CommunityUtil.getJSONString(403,"你没有访问此功能的权限!"));
}else{
response.sendRedirect(request.getContextPath() + "/denied");
}
}
});
// Security底层默认会拦截/logout请求,进行退出处理.
// 覆盖它默认的逻辑,才能执行我们自己的退出代码。
http.logout().logoutUrl("/securitylogout");
}
}
认证方案:绕过Security认证流程,采用系统原来的认证方案。
在UserService业务层代码中对user进行授权
public Collection<? extends GrantedAuthority> getAuthorities(int userId){
User user = this.findUserById(userId);
List<GrantedAuthority> list = new ArrayList<>();
list.add(new GrantedAuthority() {
@Override
public String getAuthority() {
switch (user.getType()){
case 1:
return AUTHORITY_ADMIN;
case 2:
return AUTHORITY_MODERATOR;
default:
return AUTHORITY_USER;
}
}
});
return list;
}
因为绕过了Security的认证流程(Serucity认证时会将认证结果存入SecurityContext中),所要构建用户认证的结果,并存入SecurityContext,以便于Security进行授权。在检查登录凭证的拦截器preHandle()方法中处理这一块逻辑。
// 构建用户认证的结果,并存入SecurityContext,以便于Security进行授权。
Authentication authentication = new UsernamePasswordAuthenticationToken(
user,user.getPassword(),userService.getAuthorities(user.getId()));
SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
请求结束时,将SecurityContext中保存的权限进行清理,实现:在拦截器afterCompletion()方法以及退出登录时,删除用户认证结果。
SecurityContextHolder.clearContext();
CSRF配置:防止CSRF攻击的基本原理,以及表单、AJAX相关的配置。
CSRF:Cross Site Request Forgery( 跨站请求伪造)
**CSRF攻击:**大部分的网站应用都是采用cookie或session的方式进行登入验证,当通过登入验证之后,网站就会给你一个通行证存在cookie或seesion中,代表之后的动作中都不需要重复验证身份了。当在登录一个网站以后,中途去逛了其他网页,刚好遇到了恶意网页,窃取了cookies,那么它就可以凭借该身份访问网站,这就是CSRF攻击。
防御CSRF的方式有两种,1.检查referer栏位,http协定中就有一个referer栏位记录着这个请求是从哪个网站发出来的,从而确认请求来源。2.加入验证token,这个token由服务端产生,加密存在session中,无法仿造。Spring Security就是采用的第二种方法。
⚠️:Spring Security默认防御CSRF攻击,但是对于异步请求不可以,因为异步请求根本不提交表单数据,因此,我们需要单独处理。
发布帖子就是一个异步请求,以此为例:
通过标签要求服务器把csrf凭证生成在header里,发请求的时候直接从header里取。
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">
发送AJAX请求之前,将CSRF令牌设置到请求消息头中。
// 发送AJAX请求之前,将CSRF令牌设置到请求消息头中.
var token = $("meta[name='_csrf']").attr("content");
var header = $("meta[name='_csrf_header']").attr("content");
$(document).ajaxSend(function (e,xhr,options){
xhr.setRequestHeader(header,token);
});
当Spring Security启用防御CSRF后,对于所有的异步请求都需要处理。也可以禁用security防御CSRF攻击的功能,在授权时加上 .and().csrf().disable(); 即可。

功能实现
权限管理
按钮显示

HyperLogLog(超级日志),可以对数据统计,也可以对数据合并
// 示例
// 添加数据
redisTemplate.opsForHyperLogLog().add(redisKey,value);
// 统计数据
Long size = redisTemplate.opsForHyperLogLog().size(redisKey);
// 合并数据
redisTemplate.opsForHyperLogLog().union(unionKey,redisKey2,redisKey3,redisKey4);
Bitmap(位图),可以统计一组数据的布尔值
// 示例
// 记录
redisTemplate.opsForValue().setBit(redisKey,index,value); // value:true or false
// 查询
System.out.println(redisTemplate.opsForValue().getBit(redisKey,index));
// 统计
Object obj = redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
return connection.bitCount(redisKey.getBytes());
}
});
System.out.println(obj);
// 运算 以or为例
Object obj = redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
connection.bitOp(RedisStringCommands.BitOperation.OR,redisKey.getBytes(),
redisKey2.getBytes(),redisKey3.getBytes(),redisKey4.getBytes());
return connection.bitCount(redisKey.getBytes());
}
});
System.out.println(obj);
UV(Unique Visitor)
DAU(Daily Active User)

具体实现:
1.定义RedisKey
public class RedisKeyUtil {
private static final String SPLIT = ":";
private static final String PREFIX_UV = "uv";
private static final String PREFIX_DAU = "dau";
// 单日UV
public static String getUVKey(String date){
return PREFIX_UV + SPLIT + date;
}
// 区间UV
public static String getUVKey(String startDate,String endDate){
return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
}
// 单日活跃用户
public static String getDAUKey(String date){
return PREFIX_DAU + SPLIT + date;
}
// 区间活跃用户
public static String getDAUKey(String startDate,String endDate){
return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
}
}
2.业务层逻辑
@Service
public class DataService {
@Autowired
private RedisTemplate redisTemplate;
private SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd");
// 将指定的IP计入UV
public void recordUV(String ip){
String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
redisTemplate.opsForHyperLogLog().add(redisKey,ip);
}
// 统计指定日期范围内的UV
public long calculateUV(Date start,Date end){
if(start == null || end == null){
throw new IllegalArgumentException("参数不能为空!");
}
// 整理该日期范围内的key
List<String> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)){
String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
keyList.add(key);
calendar.add(calendar.DATE,1);
}
// 合并这些数据
String redisKey = RedisKeyUtil.getUVKey(df.format(start),df.format(end));
redisTemplate.opsForHyperLogLog().union(redisKey,keyList.toArray());
// 返回统计结果
return redisTemplate.opsForHyperLogLog().size(redisKey);
}
// 将指定用户计入DAU
public void recordDAU(int userId){
String redisKey = RedisKeyUtil.getDAUKey(df.format(new Date()));
redisTemplate.opsForValue().setBit(redisKey,userId,true);
}
// 统计指定日期范围内的DAU
public long calculateDAU(Date start,Date end){
if(start == null || end == null){
throw new IllegalArgumentException("参数不能为空!");
}
// 整理该日期范围内的key
List<byte[]> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)){
String key = RedisKeyUtil.getDAUKey(df.format(calendar.getTime()));
keyList.add(key.getBytes());
calendar.add(calendar.DATE,1);
}
// 进行OR运算
return (long) redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
String redisKey = RedisKeyUtil.getDAUKey(df.format(start),df.format(end));
connection.bitOp(RedisStringCommands.BitOperation.OR,
redisKey.getBytes(),keyList.toArray(new byte[0][0]));
return connection.bitCount(redisKey.getBytes());
}
});
}
}
3.使用拦截器进行访问数据的记录
@Component
public class DataInterceptor implements HandlerInterceptor {
@Autowired
private DataService dataService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 统计UV
String ip = request.getRemoteHost();
dataService.recordUV(ip);
// 统计DAU
User user = hostHolder.getUser();
if(user != null){
dataService.recordDAU(user.getId());
}
return true;
}
}
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private DataInterceptor dataInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(dataInterceptor)
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");
}
}
4.表现层逻辑
注意⚠️:@DateTimeFormat注解的作用是入参格式化,前台传string类型的时间字符串,此注解将字符串转换为Date类型。
@Controller
public class DataController {
@Autowired
private DataService dataService;
// 统计页面
@RequestMapping(path = "/data",method = {RequestMethod.GET,RequestMethod.POST})
public String getDataPage(){
return "/site/admin/data";
}
// 统计网站UV
@RequestMapping(path = "/data/uv",method = RequestMethod.POST)
public String getUV(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model){
long uv = dataService.calculateUV(start,end);
model.addAttribute("uvResult",uv);
model.addAttribute("uvStartDate",start);
model.addAttribute("uvEndDate",end);
return "forward:/data";
}
// 统计活跃用户
@RequestMapping(path = "/data/dau",method = RequestMethod.POST)
public String getDAU(@DateTimeFormat(pattern = "yyyy-MM-dd") Date start,
@DateTimeFormat(pattern = "yyyy-MM-dd") Date end, Model model){
long dau = dataService.calculateDAU(start,end);
model.addAttribute("dauResult",dau);
model.addAttribute("dauStartDate",start);
model.addAttribute("dauEndDate",end);
return "forward:/data";
}
}
5.权限管理SecutiryConfig
// 部分授权代码
http.authorizeRequests()
.antMatchers(
"/data/**"
)
.hasAnyAuthority(
AUTHORITY_ADMIN
)
.anyRequest().permitAll()
.and().csrf().disable();
}