
基于Spring Boot3实现Spring Security6 JWT Redis实现登录、token身份认证。用户从数据库中获取。使用RESTFul风格的APi进行登录。使用JWT生成token。使用Redis进行登录过期判断。所有的工具类和数据结构在源码中都有。系列文章指路??系列文章-基于SpringBoot3创建项目并配置常用的工具和一些常用的类项目源码??/shijizhe/boot-test文章目录依赖版本原理代码结构security 配置用户登录、注册controller用户服务用到的工具类注册 AuthController.register登录1.登录APIAuthController.login2. 登录过滤器继承UsernamePasswordAuthenticationFilter3.身份认证实现AuthenticationProvider4.从数据库中查询用户信息实现UserDetailsService5. Security配置: 使用注解EnableWebSecuritytoken身份认证1. token身份认证过滤器 OncePerRequestFilterUserAuthUtilsgetUserId用户登出实现LogoutSuccessHandler修改Security配置 : YaSecurityConfig下一步的计划参考文章依赖版本Spring Boot 3.0.6Spring Security 6.0.3原理这张图大家已经估计已经看过很多次了。实现登录认证的过程其实就是对上述的类按照自己的需求进行自定义扩展的过程。具体不多讲了别的文章里讲得比我透彻。show you my code.代码结构security 配置用户登录、注册controller用户服务用到的工具类注册 AuthController.register将用户密码使用BCrypt加密存储。PostMapping(/register) Operation(summary register, description 用户注册) public Object register(RequestBody Valid UserRegisterDTO userRegisterDTO) { YaUser userById userService.getUserById(userRegisterDTO.getUserId()); if(Objects.nonNull(userById)){ return BaseResult.fail(用户id已存在); } try { BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); YaUser yaUser UserRegisterMapper.INSTANCE.registerToUser(userRegisterDTO); yaUser.setUserPassword(encoder.encode(userRegisterDTO.getUserPassword())); userService.insertUser(yaUser); return BaseResult.success(用户注册成功); }catch (Exception e){ return BaseResult.fail(用户注册过程中遇到异常 e); } }登录1.登录APIAuthController.login我们使用RESTFul风格的API来代替表单进行登录。这个接口只是提供一个Swagger调用登录接口的入口实际逻辑由Filter控制。2. 登录过滤器继承UsernamePasswordAuthenticationFilter拦截指定的登录请求交给AuthenticationProvider处理。对Provider返回的登录结果进行处理。通过指定filterProcessesUrl,指定登录接口的路径。登录失败将异常信息返回前端。登录成功通过JwtUtils生成token放入响应header中。并将token和用户信息(json字符串)存入Redis中。通过JwtUtils生成token设置为永不过期存入Redis的token过期时间设置为30分钟以便后边做登录过期的判断。/**拦截登陆过滤器author Ya Shisince 2024/3/21 16:20*/Slf4jpublic class YaLoginFilter extends UsernamePasswordAuthenticationFilter {private final RedisUtils redisUtils;private final Long expiration;public YaLoginFilter(AuthenticationManager authenticationManager, RedisUtils redisUtils, Long expiration) {this.expiration expiration;this.redisUtils redisUtils;super.setAuthenticationManager(authenticationManager);super.setPostOnly(true);super.setFilterProcessesUrl(“/auth/login”);super.setUsernameParameter(“userId”);super.setPasswordParameter(“userPassword”);}SneakyThrowsOverridepublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {log.info(“YaLoginFilter authentication start”);// 数据是通过 RequestBody 传输UserLoginDTO user JSON.parseObject(request.getInputStream(), StandardCharsets.UTF_8, UserLoginDTO.class);return super.getAuthenticationManager().authenticate( new UsernamePasswordAuthenticationToken(user.getUserId(), user.getUserPassword()) );}Overrideprotected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,FilterChain chain,Authentication authResult) {log.info(“YaLoginFilter authentication success: {}”, authResult);// 如果验证成功, 就生成Token并返回UserDetails userDetails (UserDetails) authResult.getPrincipal();String userId userDetails.getUsername();String token JwtUtils.generateToken(userId);response.setHeader(TOKEN_HEADER, TOKEN_PREFIX token);// 将token存入Redis中redisUtils.set(REDIS_KEY_AUTH_TOKEN userId, token, expiration);log.info(“YaLoginFilter authentication end”);// 将UserDetails存入redis中redisUtils.set(REDIS_KEY_AUTH_USER_DETAIL userId, JSON.toJSONString(userDetails), 1, TimeUnit.DAYS);ServletUtils.renderResult(response, new BaseResult(ResultEnum.SUCCESS.code, 登陆成功)); log.info(YaLoginFilter authentication end);}/**如果 attemptAuthentication 抛出 AuthenticationException 则会调用这个方法*/Overrideprotected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,AuthenticationException failed) throws IOException {log.info(“YaLoginFilter authentication failed: {}”, failed.getMessage());ServletUtils.renderResult(response, new BaseResult(ResultEnum.FAILED_UNAUTHORIZED.code, “登陆失败” failed.getMessage()));}3.身份认证实现AuthenticationProvider调用UserDetailsService查询用户的账户、权限信息与登录接口输入的账户、密码对比。认证通过则返回用户信息。/** * p * 自定义认证 * /p * * author Ya Shi * since 2024/3/21 15:00 */ Component public class YaAuthenticationProvider implements AuthenticationProvider { Autowired YaUserDetailService userDetailService; Autowired PasswordEncoder passwordEncoder; Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { // 获取用户输入的用户名和密码 String username authentication.getName(); String password authentication.getCredentials().toString(); UserDetails userDetails userDetailService.loadUserByUsername(username); boolean matches passwordEncoder.matches(password, userDetails.getPassword()); if(!matches){ throw new AuthenticationException(User password error.){}; } return new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()); } Override public boolean supports(Class? authentication) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); } }4.从数据库中查询用户信息实现UserDetailsService从数据库中查询出用户的信息供AuthenticationProvider登录认证时使用。用户权限这块目前还没用到可以忽略。用户鉴权可能后边会单独补上。为什么这里没先从Redis取用户信息如果权限或者用户信息变更这里取不到Redis里不建议存储用户密码。/**继承UserDetailsService,实现自定义登陆认证author Ya Shisince 2024/3/19 11:32*/Servicepublic class YaUserDetailService implements UserDetailsService {AutowiredUserService userService;Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {YaUser user userService.getUserById(username);if(Objects.isNull(user)){throw new UsernameNotFoundException(“User not Found.”);}List roles userService.listRoleById(username);List authorities new ArrayList(roles.size());roles.forEach( role - authorities.add(new SimpleGrantedAuthority(role.getRoleId())));return new User(username, user.getUserPassword(), authorities);}}5. Security配置: 使用注解EnableWebSecurity注意Spring Security 6 配置不再继承adapterextends WebSecurityConfigurerAdapter而是使用EnableWebSecurity。YaTokenFilter是token身份认证过滤器每次请求都会拦截然后校验请求header中的token这个下面会讲。配置了身份认证过滤器以后每个请求都会被拦截即使是在过滤链中配置了permitAll()还是会返回请求403.因此针对匿名请求、静态资源和swagger请求在WebSecurityCustomizer中配置WebSecurity.ignoring相当于直接绕过所有的Filter针对登录和注册请求在身份过滤器中额外配置白名单单独放行。自己学习的过程中很多文章没有按照代码执行顺序去讲登录和身份认证也是混着讲的导致整个登录认证的流程理解起来有些困难。/**Spring Security 配置文件author Ya Shisince 2024/2/29 11:27*/ConfigurationEnableWebSecurity // 开启网络安全注解public class YaSecurityConfig {Autowiredprivate AuthenticationConfiguration authenticationConfiguration;Autowiredprivate RedisUtils redisUtils;Value(“${ya-app.auth.jwt.expiration:1800}”)private Long expiration;Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http// 禁用basic明文验证.httpBasic().disable()// 禁用csrf保护.csrf().disable()// 禁用session.sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))// 身份认证过滤器.authenticationManager(authenticationManager(authenticationConfiguration)).authenticationProvider(new YaAuthenticationProvider()).authorizeHttpRequests(authorizeHttpRequests -authorizeHttpRequests// 允许OPTIONS请求访问.requestMatchers(HttpMethod.OPTIONS, “/“).permitAll()// 允许登录/注册接口访问.requestMatchers(HttpMethod.POST, “/auth/login”).permitAll().requestMatchers(HttpMethod.POST, “/auth/register”).permitAll()// 允许匿名接口访问.requestMatchers(”/anon/”).permitAll()// 允许swagger访问.requestMatchers(“/swagger-ui/“).permitAll().requestMatchers(”/doc.html/”).permitAll().requestMatchers(“/v3/api-docs/“).permitAll().requestMatchers(”/webjars/”).permitAll().anyRequest().authenticated()).addFilterAt(new YaLoginFilter(authenticationManager(authenticationConfiguration), redisUtils, expiration), UsernamePasswordAuthenticationFilter.class)// 让校验Token的过滤器在身份认证过滤器之前.addFilterBefore(new YaTokenFilter(redisUtils, expiration), YaLoginFilter.class)// 禁用默认登出页.logout().disable();return http.build();}Beanpublic AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {return config.getAuthenticationManager();}Beanpublic WebSecurityCustomizer webSecurityCustomizer() {return (web) - web.ignoring().requestMatchers(“/webjars/“).requestMatchers(”/swagger-ui/”, “/doc.html/, /v3/api-docs/”).requestMatchers(“/anon/**”);}/**使用BCrypt加密密码*/Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}}token身份认证1. token身份认证过滤器 OncePerRequestFilter对于注册、登录请求直接放行。认证失败的几种情况未登录 未携带token凭证异常 携带错误token登录过期 携带正确的token但是token在Redis中不存在账号在别处登录 携带正确的token但是token与Redis中的token不一致。token认证成功后重新设置Redis中的token的有效时间实现token续期。查询Redis中的用户信息如果没有使用UserDetailsService的服务重新查询出信息存入缓存中。*调用SecurityContextHolder.getContext().setAuthentication()将用户信息存入Security上下文中完成身份认证。/**每次请求过滤tokenauthor Ya Shisince 2024/3/21 16:52*/Slf4jpublic class YaTokenFilter extends OncePerRequestFilter {private final RedisUtils redisUtils;private final Long expiration;private static final Set WHITE_LIST Stream.of(“/auth/register”,“/auth/login”).collect(Collectors.toSet());public YaTokenFilter(RedisUtils redisUtils, Long expiration) {this.redisUtils redisUtils;this.expiration expiration;}Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {log.info(“YaTokenFilter doFilterInternal start”);final String authorization request.getHeader(AuthConstants.TOKEN_HEADER);log.info(“YaTokenFilter ya-auth-token: {}”, authorization);// 白名单 if (WHITE_LIST.contains(request.getServletPath())) { chain.doFilter(request, response); return; } // 1.请求头中没有携带token if (StrUtil.isBlank(authorization)) { ServletUtils.renderResult(response, new BaseResult(ResultEnum.FAILED_UNAUTHORIZED)); return; } // 携带token final String token authorization.replace(AuthConstants.TOKEN_PREFIX, ); String userId; // 2.提供的token异常 try { userId JwtUtils.extractUserId(token); }catch (Exception e){ log.error(YaTokenFilter doFilterInternal 解析jwt异常{}, e.toString()); ServletUtils.renderResult(response, new BaseResult(ResultEnum.FAILED_UNAUTHORIZED.code, 凭证异常)); return; } String redisToken redisUtils.getString(AuthConstants.REDIS_KEY_AUTH_TOKEN userId); // 3.token过期 if(StrUtil.isBlank(redisToken)){ ServletUtils.renderResult(response, new BaseResult(ResultEnum.FAILED_UNAUTHORIZED.code, 登录已过期请重新登录过期。)); return; } // 4.提供的token是合法的但是redis中的token又被使用登录功能重新刷新了一下导致不一致。 if(!Objects.equals(redisToken, token)){ ServletUtils.renderResult(response, new BaseResult(ResultEnum.FAILED_UNAUTHORIZED.code, 账号在别处登陆。)); return; } // token续期 redisUtils.set(REDIS_KEY_AUTH_TOKEN userId, token, expiration); // 获取用户信息和权限 String userDetailStr redisUtils.getString(AuthConstants.REDIS_KEY_AUTH_USER_DETAIL userId); UserDetails userDetails; if(Objects.isNull(userDetailStr)){ userDetails yaUserDetailService().loadUserByUsername(userId); redisUtils.set(REDIS_KEY_AUTH_USER_DETAIL userId, JSON.toJSONString(userDetails), 1, TimeUnit.DAYS); }else{ userDetails initUser(userDetailStr); } SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken( userDetails, userDetails.getPassword(), userDetails.getAuthorities() ) ); log.info(YaTokenFilter doFilterInternal end); chain.doFilter(request, response);}private YaUserDetailService yaUserDetailService(){return SpringContextUtils.getBean(YaUserDetailService.class);}private User initUser(String userJsonStr){JSONObject userJson JSON.parseObject(userJsonStr);String userId userJson.getString(username); JSONArray authArray userJson.getJSONArray(authorities); ListGrantedAuthority authorities new ArrayList(authArray.size()); for(int i0; i authArray.size();i){ JSONObject authObj authArray.getJSONObject(i); authorities.add(new SimpleGrantedAuthority(authObj.getString(authority))); } return new User(userId, [PROTECTED], authorities);}}UserAuthUtils已经登录的用户可以从Security的上下文中获取用户的账号、基本信息、权限等。可以将其封装为工具类。因为练手的用户表较为简单也没有部分、员工、角色、权限等概念因此仅封装了getUserId做抛砖引玉的作用。可以根据实际使用自己封装更多的方法。getUserIdpublic static String getUserId() { if (Objects.isNull(SecurityContextHolder.getContext().getAuthentication())) { return null; } UserDetails userDetails (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (Objects.isNull(userDetails)) { return null; } return userDetails.getUsername(); }用户登出JWT本身是无状态的但是我们后端将jwt存到redis里相当于手动使JWT变得有状态了。那么我们在登出时就需要清空Redis中的jwt。实现LogoutSuccessHandler/** * p * 登出成功 * /p * * author Ya Shi * since 2024/3/28 10:47 */ Slf4j public class YaLogoutSuccessHandler implements LogoutSuccessHandler { private final RedisUtils redisUtils; public YaLogoutSuccessHandler(RedisUtils redisUtils) { this.redisUtils redisUtils; } Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { final String authorization request.getHeader(AuthConstants.TOKEN_HEADER); // 1.请求头中没有携带token if (StrUtil.isBlank(authorization)) { ServletUtils.renderResult(response, BaseResult.successWithMessage(没有登录信息无需退出)); return; } // 携带token final String token authorization.replace(AuthConstants.TOKEN_PREFIX, ); String userId; // 2.提供的token异常 try { userId JwtUtils.extractUserId(token); }catch (Exception e){ log.error(YaLogoutHandler logout 解析jwt异常{}, e.toString()); ServletUtils.renderResult(response, new BaseResult(ResultEnum.FAILED_UNAUTHORIZED.code, 凭证异常)); return; } // 清空Redis redisUtils.delete(REDIS_KEY_AUTH_TOKEN userId); log.info(YaLogoutSuccessHandler onLogoutSuccess); ServletUtils.renderResult(response, BaseResult.successWithMessage(退出登录成功)); } }修改Security配置 : YaSecurityConfigBean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http ... // 前面的配置忽略 .logout().logoutUrl(/auth/logout).logoutSuccessHandler(new YaLogoutSuccessHandler(redisUtils)); return http.build(); }下一步的计划用户鉴权排查permitAll()失效的问题。做一个练手用的用户中心提供统一的注册、登录、认证、鉴权服务供其他的应用调用。把前期已经实现的基础的配置和工具类封装为jar包供以后的程序使用。参考文章SpringBoot3.0 SpringSecurity6.0JWTSpringSecurity (3) SpringBoot JWT 实现身份认证和权限验证Spring Security 6.0(spring boot 3.0) 下认证配置流程SpringSecurity的PermitAll、WebSecurityCustomizer和授权springsecurity的http.permitall与web.ignoring的区别