本文介绍SpringBoot项目如何整合SpringSecurity,记录使用SpringSecurity完成项目的登录、退出、以及权限管理的相关流程。
1、导包:导入Security,前后端交互用户凭证用的是JWT,需要导入jwt,另外登录需要用到验证码,验证码的存储需要用到redis;
<!-- springboot security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- 验证码 -->
<dependency>
<groupId>com.github.axet</groupId>
<artifactId>kaptcha</artifactId>
<version>0.0.9</version>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2、上一步安装好security后,启动项目会自动生成一个登录密码(可以在yml文件中配置账户/密码);客户端发起一个请求,会进入 Security 过滤器链,会跳到security默认的/login页面,输入用户名(user)/密码之后才能访问项目资源;
# 配置默认的账户/密码
spring:
security:
user:
name: user
password: 123
3、使用security实现用户认证:分为首次登录和访问资源认证
- 首次登录认证:用户名、密码和验证码完成登录
- 访问资源认证:请求头携带Jwt进行token认证
首次登录
security是通过UsernamePasswordAuthenticationFilter过滤器校验账户名/密码的,系统如果有验证码功能,则需要在UsernamePasswordAuthenticationFilter过滤器之前添加一个图片验证码过滤器CaptchaFilter
验证码相关代码
config/KaptchaConfig:定义验证码的样式
controller/AuthController:
// 获取验证码接口
@GetMapping("/captcha")
// 生成一个随机的key,将key 和验证码code存入redis,将验证码code转成Base64图片和key一起返回给前端;
使用postman访问/captcha会返回Unauthorized;security默认会阻止访问,需要在config/SecurityConfig中配置白名单;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private static final String[] URL_WHITELIST = {
"/login",
"/logout",
"/captcha",
"/favicon.ico",
};
protected void configure(HttpSecurity http) throws Exception {
http
// 登录配置
.formLogin() // 使用security默认的表单提交方式
// 配置拦截规则
.and()
.authorizeRequests()
.antMatchers(URL_WHITELIST).permitAll() // 白名单放行
.anyRequest().authenticated() // 其他接口拦截
;
}
}
CaptchaFilter:验证码认证过滤器
此时不会校验验证码是否正确,需要在SecurityConfig中添加校验验证码的过滤器CaptchaFilter
// 配置自定义的过滤器
.and()
.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class) // 配置captchaFilter过滤器在用户名密码过滤器之前
security/CaptchaFilter.java 做验证码校验的过滤器
common/exception/CaptchaException 验证码出错的时候返回异常信息
添加完过滤器后,发送/login 的post接口,此时需要添加 http.cors().and().csrf().disable(),否则只会接受到get请求,如果验证码错误抛出异常后,需要交给认证失败处理器LoginFailureHandler,否则还会走一个名为/login的get请求;无法将验证码错误异常返给前端,需要在认证失败处理器中将请求错误封装成Result返给前端;
LoginFailureHandler: 认证失败处理器
此时校验验证码错误时可以返回自定义Result错误结果给前端 {"msg":"验证码错误","code":400};同时也给用户名密码错误添加认证失败处理器,用户名密码错误也能处理返回 {"msg":"用户名或密码错误","code":400}
// config/SecurityConfig
// 登录配置
.formLogin() // 使用security默认的表单提交方式
.failureHandler(loginFailureHandler)
LoginSuccessHandler:认证(登录)成功处理器
此时用户名密码为.yml中配置的user/123,假设验证码是正确的,提交表单登录,会提醒跨域,添加config/CorsConfig并配置SecurityConfig解决跨域
// SecurityConfig
http.cors().and().csrf().disable()
解决完跨域后再次登录,虽然用户名密码正确,但还是会返回security默认的登录页,原因是:登录成功,security默认跳转到/链接,但是又会因为没有权限访问/,所有又会跳到security默认的登录页,所以我们必须取消原先默认的登录成功之后的操作,根据spring security的流程,登录成功之后会走AuthenticationSuccessHandler,因此在登录之前,我们先去自定义这个登录成功操作类LoginSuccessHandler,并在SecurityConfig中添加认证成功处理器;在LoginSuccessHandler中生成jwt(编写JwtUtils类,并在.yml中添加jwt配置信息),返回给前端,用于二次认证;此时就可以返回自定义的Result给前端了。
{
"msg":"操作成功",
"code":200,
"data":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNjg2NjQ1MTA3LCJleHAiOjE2ODcyNDk5MDd9.i4qrA6wtCM8XsS9axppTTTOoA2bmg9vGgmqDWddwzbewNV72aoAgJqOTk9K8BJHk8su-hJHgeOhtnnCIBeTt9A"}
}
此时访问其他接口还是会跳到默认的登录页
身份认证
登录成功之后,前端拿到jwt的信息,也就是token,前端封装axios,在请求header中添加token;后端进行用户身份识别的时候,通过请求头中获取jwt,然后解析出用户名,这样就可以知道是谁在访问接口了,然后判断用户是否有权限操作;
JWTAuthenticationFilter:自定义过滤器用来识别jwt
访问接口时会进入该过滤器校验token
在JWTAuthenticationFilter中,获取到用户名之后封装成UsernamePasswordAuthenticationToken,之后交给SecurityContextHolder参数传递authentication对象,这样后续security就能获取到当前登录的用户信息了,也就完成了用户认证
JwtAuthenticationEntryPoint:认证失败处理器
把认证过滤器和认证失败入口配置到SecurityConfig中
// 异常处理器
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 认证失败入口配置
// 配置自定义的过滤器
.and()
.addFilter(jwtAuthenticationFilter()) // 添加认证过滤器
此时,登录成功后,访问其他的接口需要带上token,在JwtAuthenticationFilter中校验token,如果token为null,往过滤器下一步走,跳到jwtAuthenticationEntryPoint(jwt认证失败处理器),返回错误信息给前端;此外token异常或过期,抛出JwtException;token正确时,则认证成功,返回接口数据;如果有些接口加上了权限认证,则在登录封装token的时候查询用户的权限列表,将用户权限封装到token中,访问接口时会校验token是否有权限,有才能访问,后面会实现权限认证
实现用户名密码查库登录
此时用户名和密码是默认在配置在.yml文件中,需要实现查询数据库登录
将配置文件中默认用户密码删除;使用Security内置的BCryptPasswordEncoder生成一个加密的密码;
// config/SecurityConfig 配置
@Bean
BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
在TestController中添加一个生成加密密码的接口,把/test/**添加白名单,访问接口生成加密的密码,将用户名和加密密码添加到数据库中;目前数据库中用户名密码为admin/123
要实现查询数据库登录,需要重新定义这个查用户数据的过程,需要重写UserDetailsService接口;因为security在认证用户身份的时候会调用UserDetailsService.loadUserByUsername()方法,因此我们重写了之后security就可以根据我们的流程去查库获取用户了。然后我们把UserDetailsServiceImpl配置到SecurityConfig中
@Autowired
UserDetailServiceImpl userDetailService;
/*
* 配置userDetailService注入到security中;委托security实现查询数据库账号密码登录
* */
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService);
}
security/UserDetailsServiceImpl
在UserDetailsServiceImpl类中,继承了UserDetailsService,复写了loadUserByUsername方法,首先根据用户名查询是否存在用户,没有则抛出异常;方法返回UserDetails,为了后面我们可能会调整用户的一些数据,需要自定义AccountUser去重写UserDetails,在AccountUser中可以自定义用户字段,AccountUser中封装了用户的权限信息,通过getUserAuthority(userId)方法传入用户id查询用户的权限信息,封装成List后封装到AccountUser中,该过程会自动校验密码(todo);正确则登录成功获取到token,否则返回报错信息。
return new AccountUser(sysUser.getId(), sysUser.getUsername(), sysUser.getPassword(), getUserAuthority(sysUser.getId()));
public List<GrantedAuthority> getUserAuthority(Long userId){
// 角色(ROLE_admin)、菜单操作权限 sys:user:list
String authority = sysUserService.getUserAuthorityInfo(userId); // ROLE_admin,ROLE_normal,sys:user:list,....
return AuthorityUtils.commaSeparatedStringToAuthorityList(authority);
}
// getUserAuthorityInfo方法中获取用户的角色和菜单权限,返回一个以逗号拼接的字符串
接口权限控制
用户认证成功之后,我们就知道谁在访问系统接口,这时又有一个问题,就是这个用户有没有权限来访问我们这个接口,需要在两个地方赋予用户权限
1、用户登录,调用UserDetailsService.loadUserByUsername()方法时候可以返回用户的权限信息
2、接口调用进行身份认证过滤器时候JWTAuthenticationFilter,需要返回用户权限信息
使用注解实现接口的权限控制,Security内置的权限注解:
@PreAuthorize:方法执行前进行权限检查
@PostAuthorize:方法执行后进行权限检查
@Secured:类似于 @PreAuthorize
// 需要Admin角色权限
@PreAuthorize("hasRole('admin')")
// 添加用户的操作权限
@PreAuthorize("hasAuthority('sys:user:save')")
验证权限的流程:
1、用户登录或者调用接口时候识别到用户,并获取到用户的权限信息
2、注解标识Controller中的方法需要的权限或角色
3、Security通过FilterSecurityInterceptor匹配URI和权限是否匹配
4、有权限则可以访问接口,当无权限的时候返回异常交给AccessDeniedHandler操作类处理
此时访问没有权限的接口,发现还是可以访问,原因是SecurityConfig需要添加开启注解权限校验,否则无法识别@PreAuthorize注解
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启注解权限校验
添加后重启,访问无权限的接口,返回{"code":400,"msg":"不允许访问","data":null},最后添加权限不足异常处理器AccessDeniedHandler
JwtAccessDeniedHandler:无权限处理器
config/SecurityConfig添加权限不足异常处理器
// 异常处理器
.and()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint) // 认证失败入口配置
.accessDeniedHandler(jwtAccessDeniedHandler) // 权限不足
添加后访问无权限的接口,发现并没有走到jwtAccessDeniedHandler过滤器 --- todo
用户退出
添加logoutSuccessHandler
config/SecurityConfig
// 退出配置
.and()
.logout()
.logoutSuccessHandler(jwtLogoutSuccessHandler)
编写退出逻辑jwtLogoutSuccessHandler,返回退出成功信息
完结撒花!
最近看了一篇知乎上大佬讲解SpringSecurity的文章,讲解的比较清楚,贴上链接一起学习:
https://zhuanlan.zhihu.com/p/342755411?utm_medium=social&utm_oi=1343915562263547904