SpringSecurity6.0+Redis+JWT - 实现用户注册/登录/认证流程

前言

本项目Security6登录设计参考CSDN博客(侵删)

SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_springsecurity redis管理token-CSDN博客

因为SpringBoot3与Security6更新后, 部分方法被标为弃用,

经过更改后, 已全部更改为最新用法

  • 代码辅助: ChatGPT-4o/智谱清言GLM-4
  • 使用数据库: Mysql/Redis
  • 使用关键模块: MybatisPlus/java-jwt/lombok
  • 项目基础: SpringBoot3.3.3/Security6

源码地址千寻云/ChatZenBackend

语雀原文 语雀 - Security6 开发文档

接口测试文档 测试Get请求 - 千寻轻语阁(Security-1)

下图为Security默认认证流程, 我们要重写部分方法, 用于实现自己的认证方式

Spring Security原始认证流程

与上图非对应关系

Spring Security的原始认证流程通常涉及以下步骤:

  1. 用户发送登录请求。
  2. 请求被一个认证过滤器(例如 UsernamePasswordAuthenticationFilter)拦截。
  3. 认证过滤器使用 AuthenticationManager 进行认证。
  4. AuthenticationManager 使用 UserDetailsService 加载用户详情。
  5. 密码验证。
  6. 如果认证成功,创建一个认证对象并设置到 SecurityContextHolder
  7. 用户被重定向到原始请求或登录成功页面。

数据库结构

用户账号密码表

全称t_user_account

create_time 默认为 NOW() 添加时间

create table chat_zen.t_user_account
    (
      id            int unsigned auto_increment
      primary key,
      username      varchar(255)                       not null comment '用户账号-唯一',
      password_hash varchar(255)                       not null comment '用户密码-加密',
      create_time   datetime default CURRENT_TIMESTAMP not null comment '账号创建时间',
      constraint t_user_account_pk_2
      unique (username)
    );

安全配置

yml配置文件

chat-zen:
  security:
    # Security认证白名单
    permit-all-path:
      - /http-test/**
      - /auth/**
  jwt:
    # jwt加密key
    key: chatzen-key
    expiration: 172800000 # 48 hour - *ms

Security配置

  1. 配置类定义:
    • 该类使用了 @Configuration@EnableWebSecurity 注解,表示这是一个Spring Security的配置类。
    • 类中定义了多个 @Bean 方法,用于创建和配置Spring Security的相关组件。
  1. 认证异常处理器:
    • 通过 @Resource 注入了一个 AuthenticationExceptionHandler 对象,用于处理认证过程中的异常。
  1. JWT认证过滤器:
    • 同样通过 @Resource 注入了一个 JWTAuthenticationFilter 对象,这个过滤器用于拦截请求,并验证请求中的JWT令牌。
  1. 权限配置:
    • 通过 security 对象获取了不需要认证的路径(白名单路径),并在 securityFilterChain 方法中配置了这些路径。
    • 配置了其他所有请求都需要认证。
  1. CSRF保护:
    • 关闭了CSRF保护,因为该系统不使用Session,而是使用JWT令牌。
  1. 会话管理:
    • 配置了不通过Session获取SecurityContext,而是使用无状态的会话策略。
  1. 异常处理:
    • 配置了异常处理,当认证过程中出现异常时,会调用 AuthenticationExceptionHandler 进行处理。
  1. 密码编码器:
    • 配置了一个 BCryptPasswordEncoder 作为密码编码器,用于对密码进行哈希处理。
  1. 身份认证管理器:
    • 通过 AuthenticationConfiguration 创建了一个 AuthenticationManager 对象,用于处理身份认证。
// 放行接口
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
  List<String> permitAllPaths = security.getPermitAllPath();
  // 配置不需要认证的请求(这里所有的路径可以写在配置文件上修改时就不用改代码)
  if (!CollectionUtils.isEmpty(permitAllPaths)) {
    permitAllPaths.forEach(path -> {
      try {
        // 放行白名单路径
        http.authorizeHttpRequests(auth -> auth
                                   .requestMatchers(path).permitAll()); // 放行白名单路径
      } catch (Exception e) {
        log.error("放行白名单路径失败", e);
      }
    });
  }
  http    // 关闭csrf保护, 因为无需Session
  .csrf(AbstractHttpConfigurer::disable)
  // 其他所有请求都需要认证
  .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
  // 不通过Session获取SecurityContext
  .sessionManagement(session -> session
                     .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    )// 配置异常处理
  .exceptionHandling(exception -> exception
                     .authenticationEntryPoint(authenticationExceptionHandler)
                    );
  // 配置token过滤器
  http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
  return http.build();
}

// 配置密码编码器
@Bean
public PasswordEncoder passwordEncoder() {
  return new BCryptPasswordEncoder();
}

/**
 * 身份认证管理器,调用authenticate()方法完成认证
 */
@Bean
public AuthenticationManager authenticationManager
            (AuthenticationConfiguration authenticationConfiguration) throws Exception {
  return authenticationConfiguration.getAuthenticationManager();
}

认证流程

登录接口流程

  1. 客户端发起登录请求:
    • 客户端通过HTTP POST请求发送用户名和密码到服务器的 /auth/login 端点。

  1. 认证端点处理请求 (AuthController.java):
    • AuthControllerlogin 方法接收请求。
    • 方法从请求体中提取用户名和密码。
    • 如果用户名或密码为空,返回错误响应。
@RestController
@RequestMapping("/auth")
public class AuthController {
  @Resource
  private IAuthService authService;

  @PostMapping("/login")
  public Result<String > login(@RequestBody JSONObject params){
    String username  = params.getString("username");
    String password = params.getString("password");
    if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) {
      return Result.fail(500, "用户名或密码为空!",null);
    }
    String token = authService.login(username, password); // 执行登录验证流程
    return Result.succ(token);
  }
}
  1. 执行认证服务 (AuthServiceImpl.java):
    • login 方法使用提取的用户名和密码创建一个 UsernamePasswordAuthenticationToken
    • 调用 AuthenticationManagerauthenticate 方法进行认证。
@Override
public String login(String username, String password) {
  UsernamePasswordAuthenticationToken authenticationToken = 
      new UsernamePasswordAuthenticationToken(username, password);
  // 调用AuthenticationManager的authenticate方法进行认证
  // 该方法会调用UserDetailsService的loadUserByUsername方法进行认证
  Authentication authenticate = 
      authenticationManager.authenticate(authenticationToken);
  // 认证完成后会返回Authentication对象, 该对象中包含了认证成功的用户信息
  // 如果认证失败, 则authenticate方法会抛出AuthenticationException异常
  // 我已经配置了处理异常的类
  if (null == authenticate) {
    log.error("认证失败 {username:{}, password:{}}", username, password);
    return null;
  }
  /**
  认证成功后续流程
  **/
}
  1. 用户详情加载 (UserDetailsServiceImpl.java):
    • 如果 AuthenticationManager 需要用户详情,它会调用 UserDetailsServiceloadUserByUsername 方法。
    • UserDetailsServiceImpl 从数据库加载与用户名关联的用户详情。
@Override
public UserDetails loadUserByUsername(String username) 
                                      throws UsernameNotFoundException {
  // 通过MybatisPlus查询用户信息
  QueryWrapper<UserAccount> wrapper = new QueryWrapper<>();
  wrapper.lambda().eq(UserAccount::getUsername, username);
  UserAccount userAccount = userAccountService.getOne(wrapper);
  // 如果用户不存在则抛出异常
  if (userAccount == null) {
    throw new UsernameNotFoundException("用户名不存在");
  }
  // 返回封装UserDetails对象
  return new SecurityUser(userAccount);
}
  1. 密码验证:
    • AuthenticationManager 使用 UserDetailsService 提供的用户详情来验证密码。
  1. 生成JWT令牌 (AuthServiceImpl.java):
    • 如果认证成功,AuthServiceImpl 使用 JwtUtil 生成一个JWT令牌。
    • 令牌包含用户ID作为主题,并设置一个过期时间。
/**
 * 生成JWT Token
 * @param userId 用户ID
 * @return 生成的JWT Token
 */
public String generateToken(String userId) {
return JWT.create()
                .withSubject(userId) // 设置主题 - 用户id
                .withIssuedAt(new Date())   // 设置签发时间 - 现在
                // 设置过期时间 - 配置文件
                .withExpiresAt(new Date(System.currentTimeMillis()+EXPIRATION_TIME))
                .sign(algorithm); // 设置算法与签名秘钥 - 配置文件
}
  1. 存储令牌和用户信息到Redis (AuthServiceImpl.java):
    • 生成令牌后,将其与用户信息一起存储在Redis中,并设置过期时间。
@Override
public String login(String username, String password) {
  /**
  认证流程
  **/
  // 获取从loadUserByUsername方法获取的用户对象
  SecurityUser user = (SecurityUser) authenticate.getPrincipal();
  UserAccount userEntity = user.getUserAccount();
  // 生成token
  String token = jwtUtil.generateToken(userEntity.getId().toString());
  // 存入Redis
  redisTemplate.opsForValue().set(
      String.format(Const.REDIS_KEY, userEntity.getId()), // 设置Redis Key - 常量
      JSON.toJSONString(user),    // 存入Redis的值 - SecurityUser封装对象
      jwtUtil.getEXPIRATION_TIME(),   // 设置数据过期时间 - jwt过期时间
      TimeUnit.MILLISECONDS); // 设置过期时间的单位 - ms
  return token;
}
  1. 返回JWT令牌 (AuthController.java):
    • AuthServiceImpl 将生成的JWT令牌返回给 AuthController
    • AuthController 将令牌包装在成功响应中返回给客户端。
@RestController
@RequestMapping("/auth")
public class AuthController {
  @Resource
  private IAuthService authService;

  @PostMapping("/login")
  public Result<String > login(@RequestBody JSONObject params){
    String username  = params.getString("username");
    String password = params.getString("password");
    if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) {
      return Result.fail(500, "用户名或密码为空!",null);
    }
    String token = authService.login(username, password);
    return Result.succ(token); // 验证成功, 返回响应
  }
}
  1. 客户端接收响应:
    • 客户端接收到包含JWT令牌的响应,并可以将其用于随后的请求。

需要认证的接口流程

从登录端点获取到的JwtToken

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyIiwiZXhwIjoxNzI2Mjc2OTMwLCJpYXQiOjE3MjYxMDQxMzB9.YfdxPXnCl_GGwK08vy2umicr-p1l-yakW_BjwuLdSfE

有效期48小时, 用户test, 用户Id 2

发送需要鉴权的请求时, 需要在请求头中添加AuthorizationHeaders, 格式为Bearer JwtToken

  1. 客户端发起需要认证的请求:
    • 客户端发送请求到一个需要认证的端点,并在请求头中携带JWT令牌。

  1. JWT认证过滤器拦截请求 (JWTAuthenticationFilter.java):
    • JWTAuthenticationFilter 拦截请求并从请求头中提取JWT令牌。
@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain filterChain) throws ServletException, IOException {
  // 从请求头中获取Authorization信息
  String header = request.getHeader("Authorization");

  // 判断是否是放行请求
  if (isFilterRequest(request)) {
    // 如果是放行请求,则直接放行
    logger.info("放行路径 {}", request.getRequestURI());
    filterChain.doFilter(request, response);
    return;
  }
  logger.info("拦截路径 {}", request.getRequestURI());
  if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
    String token = header.substring(7);
    /**
    JwtToken 验证流程
    **/
  }
}
  1. 验证JWT令牌 (JWTAuthenticationFilter.java):
    • JWTAuthenticationFilter 使用 JwtUtil 验证JWT令牌的有效性。
    • 如果令牌有效,JWTAuthenticationFilter 从令牌中提取用户信息,并创建一个认证对象。
    • 认证对象被设置到 SecurityContextHolder 中。
@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain filterChain) 
                                throws ServletException, IOException {
  /**
  拦截路径, 提取JwtToken流程
  **/
  if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
    String token = header.substring(7);
    try{
      // 验证JWTToken是否有效并在有效期
      boolean tokenExpired = jwtUtil.isTokenExpired(token);
      if (tokenExpired) {
        // 验证失败
        logger.error("Token已过期, 请重新登录");
        fallback("Token失效, 请重新登录", response);
        return;
      }
    } catch (Exception e){
      logger.error(e.getMessage());
      fallback("Token解析失败, 请重新获取", response);
      return;
    }
    // 验证成功
    String userId = jwtUtil.extractUserId(token);
    String cache = (String) redisTemplate.opsForValue()
                    .get(String.format(Const.REDIS_KEY, userId));
    SecurityUser securityUser = JSON.parseObject(cache, SecurityUser.class);
    if (securityUser == null) {
      fallback("用户信息解析失败, 请重新登录", response);
      return;
    }
    logger.info("用户{}验证成功", securityUser.getUserAccount().getUsername());
    // 将用户信息存入SecurityContextHolder
    UsernamePasswordAuthenticationToken authenticationToken = 
                    new UsernamePasswordAuthenticationToken(securityUser, null, null);
    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    // 放行
    filterChain.doFilter(request, response);
  }
}
  1. 访问受保护资源:
    • 如果认证成功,客户端可以访问受保护的资源。
@GetMapping("auth-test")
public Result<SecurityUser> authTest() {
  // 从SecurityContextHolder获取Authentication对象, 然后获取SecurityUser封装用户信息
  Authentication authentication = 
                          SecurityContextHolder.getContext().getAuthentication();
  SecurityUser user = (SecurityUser) authentication.getPrincipal();
  // 获取用户信息, 直接返回用户实体类型会将所有信息都返回, 包括账号密码
  return Result.succ(user);
}
  1. 客户端接收响应:
    • 客户端接收到来自服务器的响应。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值