• 基于Spring Boot 3 + Spring Security6 + JWT + Redis实现登录、token身份认证


    基于Spring Boot3实现Spring Security6 + JWT + Redis实现登录、token身份认证。

    • 用户从数据库中获取。
    • 使用RESTFul风格的APi进行登录。
    • 使用JWT生成token。
    • 使用Redis进行登录过期判断。
    • 所有的工具类和数据结构在源码中都有。

    系列文章指路👉
    系列文章-基于SpringBoot3创建项目并配置常用的工具和一些常用的类

    项目源码👉
    /shijizhe/boot-test

    依赖版本

    • Spring Boot 3.0.6
    • Spring 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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    登录

    1.登录API:AuthController.login

    我们使用RESTFul风格的API来代替表单进行登录。这个接口只是提供一个Swagger调用登录接口的入口,实际逻辑由Filter控制。
    在这里插入图片描述

    2. 登录过滤器:继承UsernamePasswordAuthenticationFilter

    拦截指定的登录请求,交给AuthenticationProvider处理。对Provider返回的登录结果进行处理。

    • 通过指定filterProcessesUrl,指定登录接口的路径。
    • 登录失败,将异常信息返回前端。
    • 登录成功,通过JwtUtils生成token,放入响应header中。并将token用户信息(json字符串)存入Redis中。
    • 通过JwtUtils生成token设置为永不过期,存入Redis的token过期时间设置为30分钟,以便后边做登录过期的判断。
    /**
     * 

    * 拦截登陆过滤器 *

    * * @author Ya Shi * @since 2024/3/21 16:20 */
    @Slf4j public 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"); } @SneakyThrows @Override public 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()) ); } @Override protected 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 则会调用这个方法 */ @Override protected 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())); }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65

    3.身份认证:实现AuthenticationProvider

    调用UserDetailsService查询用户的账户、权限信息与登录接口输入的账户、密码对比。认证通过则返回用户信息。

    /**
     * 

    * 自定义认证 *

    * * @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); } }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    4.从数据库中查询用户信息:实现UserDetailsService

    从数据库中查询出用户的信息,供AuthenticationProvider登录认证时使用。

    • 用户权限这块,目前还没用到,可以忽略。用户鉴权可能后边会单独补上。
    • 为什么这里没先从Redis取用户信息?
      1. 如果权限或者用户信息变更这里取不到
      2. Redis里不建议存储用户密码。
    /**
     * 

    * 继承UserDetailsService,实现自定义登陆认证 *

    * * @author Ya Shi * @since 2024/3/19 11:32 */
    @Service public class YaUserDetailService implements UserDetailsService { @Autowired UserService userService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { YaUser user = userService.getUserById(username); if(Objects.isNull(user)){ throw new UsernameNotFoundException("User not Found."); } List<YaUserRole> roles = userService.listRoleById(username); List<GrantedAuthority> authorities = new ArrayList<>(roles.size()); roles.forEach( role -> authorities.add(new SimpleGrantedAuthority(role.getRoleId()))); return new User(username, user.getUserPassword(), authorities); } }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    5. Security配置: 使用注解@EnableWebSecurity

    • 注意:Spring Security 6 配置不再继承adapterextends WebSecurityConfigurerAdapter,而是使用@EnableWebSecurity
    • YaTokenFilter是token身份认证过滤器,每次请求都会拦截,然后校验请求header中的token,这个下面会讲。
    • 配置了身份认证过滤器以后,每个请求都会被拦截,即使是在过滤链中配置了permitAll(),还是会返回请求403.
      1. 因此,针对匿名请求、静态资源和swagger请求,在WebSecurityCustomizer中配置WebSecurity.ignoring,相当于直接绕过所有的Filter
      2. 针对登录和注册请求,在身份过滤器中额外配置白名单,单独放行。
    • 自己学习的过程中,很多文章没有按照代码执行顺序去讲,登录和身份认证也是混着讲的,导致整个登录认证的流程理解起来有些困难。
    /**
     * 

    * Spring Security 配置文件 *

    * * @author Ya Shi * @since 2024/2/29 11:27 */
    @Configuration @EnableWebSecurity // 开启网络安全注解 public class YaSecurityConfig { @Autowired private AuthenticationConfiguration authenticationConfiguration; @Autowired private RedisUtils redisUtils; @Value("${ya-app.auth.jwt.expiration:1800}") private Long expiration; @Bean public 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(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } @Bean public WebSecurityCustomizer webSecurityCustomizer() { return (web) -> web.ignoring() .requestMatchers("/webjars/**") .requestMatchers("/swagger-ui/**", "/doc.html/**", "/v3/api-docs/**") .requestMatchers("/anon/**"); } /** * 使用BCrypt加密密码 */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79

    token身份认证

    1. token身份认证过滤器: OncePerRequestFilter

    • 对于注册、登录请求,直接放行。
    • 认证失败的几种情况:
      1. 未登录: 未携带token
      2. 凭证异常: 携带错误token
      3. 登录过期: 携带正确的token,但是token在Redis中不存在
      4. 账号在别处登录: 携带正确的token,但是token与Redis中的token不一致。
    • token认证成功后,重新设置Redis中的token的有效时间,实现token续期。查询Redis中的用户信息,如果没有,使用UserDetailsService的服务重新查询出信息,存入缓存中。
      *调用 SecurityContextHolder.getContext().setAuthentication()将用户信息存入Security上下文中,完成身份认证。
    /**
     * 

    * 每次请求过滤token *

    * * @author Ya Shi * @since 2024/3/21 16:52 */
    @Slf4j public class YaTokenFilter extends OncePerRequestFilter { private final RedisUtils redisUtils; private final Long expiration; private static final Set<String> WHITE_LIST = Stream.of( "/auth/register", "/auth/login" ).collect(Collectors.toSet()); public YaTokenFilter(RedisUtils redisUtils, Long expiration) { this.redisUtils = redisUtils; this.expiration = expiration; } @Override protected 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"); List<GrantedAuthority> authorities = new ArrayList<>(authArray.size()); for(int i=0; i< authArray.size();i++){ JSONObject authObj = authArray.getJSONObject(i); authorities.add(new SimpleGrantedAuthority(authObj.getString("authority"))); } return new User(userId, "[PROTECTED]", authorities); } }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106

    UserAuthUtils

    已经登录的用户,可以从Security的上下文中获取用户的账号、基本信息、权限等。可以将其封装为工具类。因为练手的用户表较为简单,也没有部分、员工、角色、权限等概念,因此仅封装了getUserId做抛砖引玉的作用。可以根据实际使用自己封装更多的方法。

    getUserId

    public 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();
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    用户登出

    JWT本身是无状态的,但是我们后端将jwt存到redis里,相当于手动使JWT变得有状态了。那么我们在登出时就需要清空Redis中的jwt。

    实现LogoutSuccessHandler

    /**
     * 

    * 登出成功 *

    * * @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("退出登录成功")); } }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    修改Security配置 : YaSecurityConfig

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    	http  ... // 前面的配置忽略
    	.logout().logoutUrl("/auth/logout").logoutSuccessHandler(new YaLogoutSuccessHandler(redisUtils));
    	return http.build();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    下一步的计划

    • 用户鉴权
    • 排查permitAll()失效的问题。
    • 做一个练手用的用户中心,提供统一的注册、登录、认证、鉴权服务,供其他的应用调用。
    • 把前期已经实现的基础的配置和工具类封装为jar包,供以后的程序使用。

    参考文章

  • 相关阅读:
    [ 常用工具篇 ] kali 忘记 root 密码 -- 修改 root 密码
    超级实用的电脑小技巧大全
    欧拉路径与欧拉回路
    NoSQL之 Redis配置与优化
    Spring 中Bean的生命周期
    Yolov5的tensorRT加速(python)
    从代码逻辑到场景实战,百度高级工程师带你解密PP-ChatOCR!
    js三角形
    【同源跨域】---解决ajax跨域问题
    精通Nginx(05)-http工作机制、指令和内置变量
  • 原文地址:https://blog.csdn.net/shijizhe1/article/details/137079755