从前端传递token发送请求,可以定义一个Jwt认证过滤器
这个过滤器的作用:1. 获取token 2. 解析token 3. 获取userId 4.封装Authentication对象存入SecurityContextHolder
登录
校验:
定义jwt认证过滤器
获取token
解析token获取userId
使用userId从redis中获取用户信息
存入SecurityContextHolder
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
//如果查询不到数据就通过抛出异常来给出提示
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中
//封装成UserDetails对象返回
return new LoginUser(user);
}
}
因为UserDetailsService方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,把用户信息封装在其中。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager接口:定义了认证Authentication的方法
UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。
在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。 (这样我们注入过后就能拿到实例 调用方法)
在我们调用 authenticationManager.authenticate()
的时候需要一个 Authentication 接口参数 所以我们找到一个实现类来作为参数
我们可以查看实现类
可以使用 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
来作为参数 也就是说 AuthenticationManager authenticate 进行用户认证 这里调用实现类封装成 Authentication对象作为参数,而后
authenticationManager.authenticate(usernamePasswordAuthenticationToken);
这个会经过过滤器会去调用
UserDetailsService的loadUserByUsername
进行校验
以下可以根据 AuthenticationManager.authenticate
的返回值 Authentication authenticate
中的 getPrincipal
属性得出去调用了 UserDetailsServiceImpl implements UserDetailsService
这个接口实现类的login方法 以为可以看它的返回值 UserDetails
实则是我们用LoginUser实现了的
我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。
使用userid去redis中获取对应的LoginUser对象。
然后封装Authentication对象存入SecurityContextHolder, 并且在过滤器链的FilterSecurityInterceptor中会去获取Authenticate对象判断对象是否已经认证。
@Component
// 继承 OncePerRequestFilter 只会执行这个过滤器一次 如果实现Filter可能会执行多次
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
//放行
filterChain.doFilter(request, response);
// 这里之所以return是因为当过滤器链执行了api然后响应的时候防止死循环调用
return;
}
//解析token
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
//从redis中获取用户信息
String redisKey = "login:" + userid;
LoginUser loginUser = redisCache.getCacheObject(redisKey);
if(Objects.isNull(loginUser)){
throw new RuntimeException("用户未登录");
}
//存入SecurityContextHolder
//TODO 获取权限信息封装到Authentication中
// 这里UsernamePasswordAuthenticationToken的参数之所以有三个是因为调用的方法有一个已认证,在后面的过滤器就不需要认证
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request, response);
}
}
JwtAuthenticationTokenFilter 认证示意图:
上面的代码我们只是添加了一个 JwtAuthenticationTokenFilter 并添加到Spring容器中了,但事实上 不会自动帮我们配置到SpringSecurity过滤器链中,所以我们需要自己来配置: 在SecurityConfig 中添加 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Autowired
JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//把token校验过滤器添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
当我们发送一个请求 未带token的请求
它会进入到我们的 JwtAuthenticationTokenFilter 端点中
这个请求我们没有带token ,那么它会返回403权限不足 即没有被认证
可以看到报403
即当我们携带token,那么会去redis中查询数据 并且会进行权限认证 并且将认证信息存入 SecurityContextHolder 中,但是这个SecurityContextHolder 是线程隔离的 并且只会在每次请求链中存在认证 ,当你下一次请求(即另外一次请求)它便会又重新进入 JwtAuthenticationTokenFilter 进行重新认证。
// 这里UsernamePasswordAuthenticationToken的参数之所以有三个是因为调用的方法有一个已认证,在后面的过滤器就不需要认证
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
我们只需要定义一个登陆接口,然后获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。
当我们删除了redis中的信息 , 那么下次请求过来的时候会去Redis中查询用户信息,查询不到就会抛出用户未登录的异常。那么这样就实现了用户登录。
@PostMapping("logout")
public String logout(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser)authentication.getPrincipal();
Long id = loginUser.getUser().getId();
redisCache.deleteObject("login:" + id);
return "logout successful";
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问 匿名就是有无token可以请求 有token就无法请求
.antMatchers("/login").anonymous()
// permitAll 是无论登录还是未登录都可以访问的
.antMatchers("/hello").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
// 手动配置 jwtAuthenticationTokenFilter 到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。
所以我们在项目中只需要把当前登录用户的权限信息也存入Authentication。
然后设置我们的资源所需要的权限即可。
在使用注解权限控制方案的时候我们需要先开启相关配置
@EnableGlobalMethodSecurity(prePostEnabled = true)
当开启注解权限配置的时候我们便可以使用注解来进行权限控制了
@RestController
public class HelloController {
@GetMapping("hello")
@PreAuthorize("hasAnyAuthority('test')") // 表示该请求方法需要拥有test权限才能访问
public String hello(){
return "hello....";
}
}
在这里我让login匿名访问,于是在Security中配置了
// 对于登录接口 允许匿名访问 匿名就是有无token可以请求 有token就无法请求
.antMatchers("/login").anonymous()
①因此login请求可以跳过JwtAuthenticationTokenFilter 这一层过滤器 去执行 UserDetailsServiceImpl#loadUserByUsername
方法,再次查询数据库 并且设置改用户的权限 ,具体代码如下:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
//如果查询不到数据就通过抛出异常来给出提示
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中 创建该用户权限集合
List<String> list = new ArrayList<>(Arrays.asList("test","admin"));
//封装成UserDetails对象返回
return new LoginUser(user,list);
}
②继而会执行我们正则的login登录方法 ,创建token并且存入到Redis中
@Override
public ResponseResult login(User user) {
// AuthenticationManager authenticate 进行用户认证 这里调用实现类封装成 Authentication对象
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
// authenticate 为空就是认证失败
if (Objects.isNull(authenticate)){
throw new RuntimeException("登录失败");
}
// 如果认证通过了 使用userId生成一个jwt jwt存入响应中
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
// 将信息存入redis userId 作为key
redisCache.setCacheObject("login:"+userId,jwt);
Map map = new HashMap();
map.put("token",jwt);
return new ResponseResult(200,"登录成功",map);
}
当我发送hello请求 并且 需要test权限流程
@RestController
public class HelloController {
@GetMapping("hello")
@PreAuthorize("hasAnyAuthority('test')")
public String hello(){
return "hello....";
}
}
当请求发起的时候会经过jwt过滤链, 它首先会检验token 是否存在,如果存在就从redis中查询数据
并且封装权限信息new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
且封装到authenticationToken中 然后存储到 SecurityContextHolder中 SecurityContextHolder.getContext().setAuthentication(authenticationToken);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
//放行
filterChain.doFilter(request, response);
// 这里之所以return是因为当过滤器链执行了api然后响应的时候防止死循环调用
return;
}
//解析token
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
//从redis中获取用户信息
String redisKey = "login:" + userid;
LoginUser loginUser = (LoginUser) redisCache.getCacheObject(redisKey);
if(Objects.isNull(loginUser)){
throw new RuntimeException("用户未登录");
}
//存入SecurityContextHolder
//TODO 获取权限信息封装到Authentication中
// 这里UsernamePasswordAuthenticationToken的参数之所以有三个是因为调用的方法有一个已认证,在后面的过滤器就不需要认证
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request, response);
}
创建RBAC相关权限表:
Sql语句如下:
SELECT
DISTINCT m.perms
FROM
sys_user_role ur
LEFT JOIN sys_role r ON ur.`role_id` = r.`id`
LEFT JOIN sys_role_menu rm ON ur.`role_id` = rm.`role_id`
LEFT JOIN sys_menu m ON m.id = rm.menu_id
WHERE
user_id = 2
AND r.`status` = 0
AND m.status = 0
我们在登录的时候过滤链调用UserDetailsService的实现类 UserDetailsServiceImpl #loadUserByUsername
中将用户的权限查询出来并封装到LoginUser中
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(wrapper);
//如果查询不到数据就通过抛出异常来给出提示
if(Objects.isNull(user)){
throw new RuntimeException("用户名或密码错误");
}
//TODO 根据用户查询权限信息 添加到LoginUser中 创建该用户权限集合
List Permislist = menuMapper.selectPermsByUserId(user.getId());
List list = new ArrayList<>(Permislist);
//封装成UserDetails对象返回
return new LoginUser(user,list);
}
我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。
在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。
如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。
如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。
所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。
/**
* @Author Tang
**/
// 授权异常
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
// Spring自带 HttpStatus
ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "权限不足");
String json = JSON.toJSONString(result);
WebUtils.renderString(httpServletResponse,json);
}
}
// 认证
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败请重新登录");
String json = JSON.toJSONString(result);
WebUtils.renderString(response,json);
}
}
手动配置异常到springSecurity中
// 因为需要一个AuthenticationManager 的 authenticate方法来认证 所以我们只需要注入就能拿来用
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
我们前面都是使用@PreAuthorize注解,然后在在其中使用的是hasAuthority方法进行校验。SpringSecurity还为我们提供了其它方法例如:hasAnyAuthority,hasRole,hasAnyRole等。
hasAuthority方法实际是执行到了SecurityExpressionRoot的hasAuthority,大家只要断点调试既可知道它内部的校验原理。
它内部其实是调用authentication的getAuthorities方法获取用户的权限列表。然后判断我们存入的方法参数数据在权限列表中。
hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源。
@PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')")
public String hello(){
return "hello";
}
hasRole要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
@PreAuthorize("hasRole('system:dept:list')")
public String hello(){
return "hello";
}
hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。
@PreAuthorize("hasAnyRole('admin','system:dept:list')")
public String hello(){
return "hello";
}
我们也可以定义自己的权限校验方法,在@PreAuthorize注解中使用我们的方法。
我们也可以定义自己的权限校验方法,在@PreAuthorize注解中使用我们的方法。
@Component("ex")
public class SGExpressionRoot {
public boolean hasAuthority(String authority){
//获取当前用户的权限
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
List<String> permissions = loginUser.getPermissions();
//判断用户权限集合中是否存在authority
return permissions.contains(authority);
}
}
在SPEL表达式中使用 @ex相当于获取容器中bean的名字未ex的对象。然后再调用这个对象的hasAuthority方法
@RequestMapping("/hello")
@PreAuthorize("@ex.hasAuthority('system:dept:list')")
public String hello(){
return "hello";
}
当然我也可以使用基于配置的权限控制,我们在配置类中对资源进行控制
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
.antMatchers("/testCors").hasAuthority("system:dept:list222")
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//添加过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//配置异常处理器
http.exceptionHandling()
//配置认证失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//允许跨域
http.cors();
}
实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果登录成功了是会调用AuthenticationSuccessHandler的方法进行认证成功后的处理的。AuthenticationSuccessHandler就是登录成功处理器。
我们也可以自己去自定义成功处理器进行成功后的相应处理。
@Component
public class SGSuccessHandler implements AuthenticationSuccessHandler {
// 实现了抽象类 onAuthenticationSuccess 并自定义成功认证的逻辑
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("认证成功了");
}
}
将自定义认证成功处理器配置到SpringSecurity中去
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler successHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
// 配置认证成功处理器为我们自定义的处理器
http.formLogin().successHandler(successHandler);
http.authorizeRequests().anyRequest().authenticated();
}
}
JwtAuthenticationTokenFilter
过滤器没有登录成功、退出成功 也就是 UsernamePasswordAuthenticationFilter
这个过滤器了, 原因如下:
当我们重写configure方法它会默认去调用父类的方法,而父类方法http.formLogin()则会去生成一个表单里面就有UsernamePasswordAuthenticationFilter
,在自定义的jwt过滤器链中 我们重写了configure方法 所以不会再有这个过滤器。
而使用认证成功处理器就必须要有这个表单,所以得重写父类的方法并且调用父类中的http.formLogin()方法 如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
// 配置认证成功处理器为我们自定义的处理器
http.formLogin().successHandler(successHandler);
// 配置 任意接口都需要认证才能访问 收到保护
http.authorizeRequests().anyRequest().authenticated();
}
同理,我们接下来配置认证失败处理器和登录成功处理器
实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果认证失败了是会调用AuthenticationFailureHandler的方法进行认证失败后的处理的。AuthenticationFailureHandler就是登录失败处理器。
我们也可以自己去自定义失败处理器进行失败后的相应处理。
@Component
public class SGFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println("认证失败了");
}
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler successHandler;
@Autowired
private AuthenticationFailureHandler failureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
// 配置认证成功处理器
.successHandler(successHandler)
// 配置认证失败处理器
.failureHandler(failureHandler);
http.authorizeRequests().anyRequest().authenticated();
}
}
@Component
public class SGLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("注销成功");
}
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationSuccessHandler successHandler;
@Autowired
private AuthenticationFailureHandler failureHandler;
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
// 配置认证成功处理器
.successHandler(successHandler)
// 配置认证失败处理器
.failureHandler(failureHandler);
http.logout()
//配置注销成功处理器
.logoutSuccessHandler(logoutSuccessHandler);
http.authorizeRequests().anyRequest().authenticated();
}
}
我们的认证方案:没有使用UsernamePasswordAuthenticationFilter
去进行验证,直接使用ProviderManage
调用authenticate方法进行认证,然后去调用实现了UserDetails
接口 去从数据库中进行查询