基于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.登录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()));
        }
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取用户信息?

    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 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.

    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();
        }
        }

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 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);

      }

    }

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();
    }

用户登出

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配置 : YaSecurityConfig
复制代码
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
	http  ... // 前面的配置忽略
	.logout().logoutUrl("/auth/logout").logoutSuccessHandler(new YaLogoutSuccessHandler(redisUtils));
	return http.build();
}

下一步的计划

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

参考文章

相关推荐
咖啡八杯3 小时前
GoF设计模式——中介者模式
java·后端·spring·设计模式
Java陈序员8 小时前
企业级!一个基于 Java 开发的开源 AI 应用开发平台!
spring boot·agent·mcp
杨运交16 小时前
[041][公共模块]分布式唯一ID生成器设计与实现:一款灵活可扩展的雪花算法框架
spring boot
用户3074596982071 天前
Redis 延时队列详解
redis
烤代码的吐司君1 天前
Redis 数据结构 ZSet, BIT, HyperLogLog,Geo 空间数据
redis·后端
Flittly2 天前
【AgentScope Java新手村系列】(14)人机交互
java·spring boot·spring
Flynt2 天前
从Spring Boot 4.0升到4.1,我在Maven和gRPC上栽了跟头
java·spring boot·后端
掉鱼的猫4 天前
Spring Boot → Solon 注解迁移实战指南:一张对照表说清楚
java·spring boot
leeyi4 天前
Checkpoint 机制:Agent 怎么在断电后接着跑
redis·aigc·agent
人活一口气4 天前
Spring Boot与AIGC的完美结合:从零搭建智能内容生成平台
java·spring boot·aigc