基于Spring Boot 3和Redis Sentinel的无状态JWT认证微服务架构
在本文中,我将讨论一种使用 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 的“原生”方式。
@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,并使会话管理完全无状态。
令牌生成与验证
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 配置:
@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);
}
}
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
令牌缓存服务:
@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);
}
}
认证服务:注册与登录
@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 认证过滤器:
@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 配置:
@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();
}
}
单元测试:
@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 贡献者个人。