Ohhnews

分类导航

$ cd ..
DZone Java原文

基于Spring Boot 3和Redis Sentinel的无状态JWT认证微服务架构

#微服务架构#无状态认证#jwt#spring boot 3#redis sentinel

在本文中,我将讨论一种使用 Spring Boot 3 和 Spring Security 6 开发的高可用解决方案,用于解决现代微服务生态系统中常见的“集中式认证方法”问题。我们并非简单地迁移到一个“授权服务”;我们还将深入探讨缓存优先模式(减少数据库使用)以及 Redis Sentinel 增强方案,以确保系统的持久性。

为什么需要独立的认证服务?

虽然在微服务中,将安全逻辑嵌入每个服务是一种可选方案,但我始终认为使用集中的认证服务与 API 网关组合更为合理。

  • DRY(不要重复自己):在多个服务中使用令牌认证逻辑会增加额外的维护成本。
  • 隔离性:业务服务仅关注业务逻辑,无需处理“此令牌是否有效?”之类的问题。
  • 性能:借助 Redis 连接,我们无需每次请求都访问数据库,而是可以在毫秒级内通过缓存完成验证。
[客户端] ──► [API 网关] ──► [认证服务:验证令牌]
                              │ (有效)
                          [后端微服务]

缓存优先策略:降低数据库负载

在经典工作流中,每次登录请求都会给数据库带来压力。采用缓存优先策略后,通过 POST /auth/signin 请求的处理流程如下:

首先检查 Redis,如果存在有效且未过期的用户令牌,则直接返回。若缓存缺失,则触发 AuthManager.authenticate(),执行数据库查询并进行 BCrypt 校验。登录成功后,使用 JJWT(HS256)生成令牌。该令牌连同我们的变更和 TTL(例如 24 分钟)写入 Redis,并转换为个性化的响应。这样,即使在暴力破解或高强度登录密码攻击下,也能保护我们的主数据库。

POST /auth/signin
┌──────────────────────────────┐
│ Token 存在于 Redis 中?      │── 是 ──► 返回 token(0 次 DB 查询)
└──────────────────────────────┘
      │ 否
┌──────────────────────────────┐
│ AuthManager.authenticate()   │
│ (DB 查询 + BCrypt 验证)       │
└──────────────────────────────┘
┌──────────────────────────────┐
│ 生成 JWT (JJWT HS256)        │
└──────────────────────────────┘
┌──────────────────────────────┐
│ 写入 Redis (TTL: 24 分钟)    │
└──────────────────────────────┘
      返回 token

实现细节

User 实体与 UserDetails 集成

在大多数项目中,User 实体与 Spring Security 期望的 UserDetails 对象之间会进行不必要的映射。为降低复杂度,User 实体直接实现了 UserDetails 接口。这样代码更简洁,并且符合 Spring Security 的“原生”方式。

$ java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "T_APP_USER")
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_user_gen")
    @SequenceGenerator(name = "seq_user_gen", sequenceName = "SEQ_APP_USER", allocationSize = 1)
    @Column(name = "idx")
    private Long idx;

    @Column(name = "firstname")
    private String firstName;

    @Column(name = "lastname")
    private String lastName;

    @Column(unique = true, name = "email")
    private String email;

    @Column(name = "accesskey")
    private String accessKey; // BCrypt 哈希

    @Column(name = "role")
    @Enumerated(EnumType.STRING)
    private Role role;

    @Override
    public Collection getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role.name()));
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public String getPassword() {
        return accessKey;
    }

    @Override
    public boolean isAccountNonExpired() { return true; }

    @Override
    public boolean isAccountNonLocked() { return true; }

    @Override
    public boolean isCredentialsNonExpired() { return true; }

    @Override
    public boolean isEnabled() { return true; }
}

JWT 过滤器:安全之门

系统请求会经过 OncePerRequestFilter。这里使用 JwtAuthenticationFilter,解析每个请求中的令牌,并填充 SecurityContext。通过使用 Spring Security 6 引入的 SecurityFilterChain bean,我们禁用了 CSRF,并使会话管理完全无状态。

令牌生成与验证

$ java
public interface JwtService {
    String extractUserName(String token);
    String generateToken(UserDetails userDetails);
    boolean isTokenValid(String token, UserDetails userDetails);
}

@Service
public class JwtServiceImpl implements JwtService {
    @Value("${token.signing.key}")
    private String jwtSigningKey; // Base64 编码的密钥

    @Override
    public String extractUserName(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    @Override
    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
                .setClaims(new HashMap<>())
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    @Override
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String userName = extractUserName(token);
        return userName.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        return claimsResolver.apply(
                Jwts.parserBuilder()
                        .setSigningKey(getSigningKey())
                        .build()
                        .parseClaimsJws(token)
                        .getBody()
        );
    }

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private Key getSigningKey() {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSigningKey));
    }
}

高可用:Redis Sentinel

使用单个 Redis 实例意味着认证服务存在“单点故障”。如果 Redis 崩溃,所有人都无法访问系统。我们通过 Redis Sentinel 来规避此风险。得益于 Sentinel 结构:

  • 如果主节点宕机,从节点会通过故障转移自动提升为主节点。
  • 在应用端,我们使用 Lettuce 驱动程序持续管理这些转换。

技术栈与需求

[LOADING...]

Redis Sentinel 配置:

$ java
@Configuration
public class RedisConfig {
    @Value("${spring.redis.sentinel.master}")
    private String master;

    @Value("${spring.redis.sentinel.nodes}")
    private String sentinelNodes;

    @Value("${spring.redis.password}")
    private String password;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisSentinelConfiguration sentinelConfig = new RedisSentinelConfiguration()
                .master(master);
        for (String node : sentinelNodes.split(",")) {
            String[] hostPort = node.split(":");
            sentinelConfig.sentinel(hostPort[0], Integer.parseInt(hostPort[1]));
        }
        sentinelConfig.setPassword(RedisPassword.of(password));
        return new LettuceConnectionFactory(sentinelConfig);
    }
}
$ config
env:
  - name: spring.redis.sentinel.master
    valueFrom:
      secretKeyRef:
        name: redis-user-secret
        key: username
  - name: spring.redis.password
    valueFrom:
      secretKeyRef:
        name: redis-user-secret
        key: password

令牌缓存服务:

$ java
@Service
public class TokenCacheServiceImpl {
    private final RedisTemplate redisTemplate;

    public TokenCacheServiceImpl(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheToken(String username, String token, long duration, TimeUnit unit) {
        redisTemplate.opsForValue().set(username, token, duration, unit);
    }

    @Cacheable(value = "tokens", key = "#username")
    public String getToken(String username) {
        return redisTemplate.opsForValue().get(username);
    }
}

认证服务:注册与登录

$ java
@Service
@RequiredArgsConstructor
public class AuthenticationServiceImpl implements AuthenticationService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;
    private final TokenCacheServiceImpl tokenCacheService;

    @Override
    public JwtAuthenticationResponse signup(SignUpRequest request) {
        var user = User.builder()
                .firstName(request.getFirstName())
                .lastName(request.getLastName())
                .email(request.getEmail())
                .accessKey(passwordEncoder.encode(request.getAccessKey())) // BCrypt
                .role(Role.USER)
                .build();
        userRepository.save(user);
        var jwt = jwtService.generateToken(user);
        return JwtAuthenticationResponse.builder().token(jwt).build();
    }

    @Override
    public JwtAuthenticationResponse signin(SigninRequest request) {
        // 1. 先检查 Redis 缓存
        String cachedToken = tokenCacheService.getToken(request.getEmail());
        if (cachedToken != null) {
            return JwtAuthenticationResponse.builder().token(cachedToken).build();
        }

        // 2. 缓存未命中,进行认证(DB + BCrypt)
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getEmail(), request.getAccessKey())
        );
        var user = userRepository.findByEmail(request.getEmail())
                .orElseThrow(() -> new IllegalArgumentException("Invalid credentials."));

        // 3. 生成令牌并写入 Redis(TTL 24 分钟)
        var jwt = jwtService.generateToken(user);
        tokenCacheService.cacheToken(request.getEmail(), jwt, 24, TimeUnit.MINUTES);
        return JwtAuthenticationResponse.builder().token(jwt).build();
    }
}

JWT 认证过滤器:

$ java
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtService jwtService;
    private final UserService userService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");

        // 如果没有 Authorization 头或不以 Bearer 开头,直接放行
        if (StringUtils.isEmpty(authHeader) || !StringUtils.startsWith(authHeader, "Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        final String jwt = authHeader.substring(7);
        final String userEmail = jwtService.extractUserName(jwt);

        // 当 SecurityContext 中尚无认证信息时处理
        if (StringUtils.isNotEmpty(userEmail) && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userService.userDetailsService()
                    .loadUserByUsername(userEmail);

            if (jwtService.isTokenValid(jwt, userDetails)) {
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities()
                );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                context.setAuthentication(authToken);
                SecurityContextHolder.setContext(context);
            }
        }
        filterChain.doFilter(request, response);
    }
}

Spring Security 6 配置:

$ java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserService userService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable) // 无状态 → 无需 CSRF
                .authorizeHttpRequests(request -> request
                        .requestMatchers("/auth/**").permitAll() // 认证端点对所有用户开放
                        .anyRequest().authenticated()
                )
                .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS) // 无服务端会话
                )
                .authenticationProvider(authenticationProvider())
                .addFilterBefore(jwtAuthenticationFilter, // JWT 过滤器先执行
                        UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userService.userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

单元测试:

$ java
@Test
@DisplayName("登录:如果令牌已缓存,则不应查询数据库")
void testSignInWithCachedToken() {
    when(tokenCacheService.getToken(TEST_EMAIL)).thenReturn(TEST_TOKEN);
    JwtAuthenticationResponse response = authenticationService.signin(
            SigninRequest.builder().email(TEST_EMAIL).accessKey(TEST_PASSWORD).build()
    );
    assertEquals(TEST_TOKEN, response.getToken());
    verifyNoInteractions(authenticationManager); // 不应发生 DB + BCrypt 调用
    verifyNoInteractions(userRepository);
}

// 无效令牌测试 —— SecurityContext 应保持为空
@Test
@DisplayName("使用无效令牌时,SecurityContext 应保持为空")
void testDoFilterInternalInvalidToken() throws Exception {
    when(request.getHeader("Authorization")).thenReturn("Bearer " + INVALID_TOKEN);
    when(jwtService.extractUserName(INVALID_TOKEN)).thenReturn(TEST_EMAIL);
    when(userService.userDetailsService()).thenReturn(userDetailsService);
    when(userDetailsService.loadUserByUsername(TEST_EMAIL)).thenReturn(userDetails);
    when(jwtService.isTokenValid(INVALID_TOKEN, userDetails)).thenReturn(false);

    jwtAuthenticationFilter.doFilterInternal(request, response, filterChain);

    verify(filterChain).doFilter(request, response);
    assertNull(SecurityContextHolder.getContext().getAuthentication());
}

总结与结论

通过本架构,我们不仅构建了一个安全的登录界面,还构建了一个极具可扩展性、通过缓存克服数据库瓶颈、满足高可用(HA)标准的架构。特别是 Spring Boot 3 带来的现代架构,使安全层变得更加灵活。如果你正在启动一个大规模微服务项目,可以从一开始就以这种“无状态”和“缓存”的方式设计令牌管理。


本文表达的观点仅代表 DZone 贡献者个人。