Shoptnt 安全架构揭秘:JWT 认证与分布式实时踢人方案

发布于:2025-09-12 ⋅ 阅读:(23) ⋅ 点赞:(0)

在分布式架构中,用户认证与授权是一个经典难题。尤其在集群环境下,如何保证用户状态的强一致性?例如,管理员在节点A禁用一个用户后,如何确保该用户在节点B、C上的会话立即失效?

在 Shoptnt 开源商城中,我们设计了一套结合 JWT (JSON Web Token) 和 Redis Pub/Sub 的轻量级解决方案,完美解决了分布式会话管理和实时状态同步的挑战。本文将深入剖析其核心实现。

项目地址: https://gitee.com/bbc-se/shoptnt

一、 核心架构:无状态认证与状态同步

我们的方案核心是“无状态认证 + 关键状态广播”:

  1. 无状态认证 (JWT): 使用 JWT 作为访问令牌(Access Token)。服务端不存储会话信息,所有用户身份和权限数据都编码在 Token 中,极大减轻了集群状态同步的压力,提升了扩展性。

  2. 关键状态广播 (Redis Pub/Sub): 对于极少数需要立即生效、强一致性的状态(如用户禁用),采用发布订阅模式进行集群广播。各节点监听消息,在本地维护一个轻量级的“禁用名单”缓存。

二、 JWT 的创建与解析

1. Token 创建器 (JwtTokenCreater)

java

public class JwtTokenCreater implements TokenCreater {
    private String secret; // 签名密钥
    private int accessTokenExp; // AccessToken 有效期
    private int refreshTokenExp; // RefreshToken 有效期

    @Override
    public Token create(AuthUser user) {
        // 1. 将用户对象转换为 Map,作为 JWT 的 Claims (负载)
        Map buyerMap = oMapper.convertValue(user, HashMap.class);

        // 2. 生成 Access Token
        String accessToken = Jwts.builder()
                .setClaims(buyerMap) // 用户信息作为负载
                .setSubject("user")
                .setExpiration(/* 计算过期时间 */)
                .signWith(SignatureAlgorithm.HS512, secret.getBytes()) // 签名
                .compact();

        // 3. 生成 Refresh Token (逻辑类似,有效期更长)
        // ... 
        // 4. 返回封装好的 Token 对象
        Token token = new Token();
        token.setAccessToken(accessToken);
        token.setRefreshToken(refreshToken);
        return token;
    }
}

设计要点:

  • 负载丰富: 直接将整个 AuthUser 对象(包含用户ID、角色、权限等)放入 JWT 的 Claims 中,避免了后续查库。

  • 双 Token 机制: 使用短效的 accessToken 保证安全,长效的 refreshToken 用于平滑续期,提升用户体验。

  • 灵活性: 通过链式 Setter (setAccessTokenExp) 方便地配置有效期。

2. Token 解析器 (JwtTokenParser)

java

public class JwtTokenParser implements TokenParser {
    private String secret; // 必须与创建器使用相同的密钥

    @Override
    public <T> T parse(Class<T> clz, String token) throws TokenParseException {
        try {
            // 1. 验证签名并解析 JWT
            Claims claims = Jwts.parser()
                    .setSigningKey(secret.getBytes())
                    .parseClaimsJws(token).getBody();

            // 2. 【关键】类型转换:将 Claims Map 转换回具体的用户对象 (如 Buyer、Seller)
            T t = BeanUtil.mapToBean(clz, claims);
            return t;
        } catch (Exception e) {
            throw new TokenParseException(e); // 签名无效或过期会抛出异常
        }
    }
}

设计要点:

  • 安全基石: 基于密钥的签名验证(setSigningKey)确保了 Token 不可篡改,是整套无状态认证安全的基础。

  • 泛型设计: 支持解析为不同的用户类型(Buyer.classSeller.class),复用性高。

三、 Spring Security 集成与认证流程

1. 安全配置 (BuyerSecurityConfig)

java

@Configuration
@EnableWebSecurity
@Order(3)
public class BuyerSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .cors().and().csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 禁用 Session
            .and()
            .addFilterBefore(
                new TokenAuthenticationFilter(buyerAuthenticationService), // 核心:自定义 Token 过滤器
                UsernamePasswordAuthenticationFilter.class
            )
            .authorizeRequests()
            .antMatchers("/buyer/login", "/buyer/goods/**").permitAll() // 公开接口
            .anyRequest().hasRole(Role.BUYER.name()); // 需买家角色
    }

    @Bean
    public UserDisableReceiver userDisableReceiver() {
        // 注册广播消息的接收器
        return new UserDisableReceiver(authenticationServices);
    }
}

设计要点:

  • 无状态 (STATELESS): 明确告知 Spring Security 不创建和使用 Session。

  • 自定义过滤器: 在标准的安全过滤器链中插入我们自己的 TokenAuthenticationFilter,它是认证的入口。

  • 角色权限控制: 使用 .hasRole(Role.BUYER.name()) 进行细粒度的接口授权。

2. 认证服务 (BuyerAuthenticationService 与 AbstractAuthenticationService)

认证的核心逻辑在抽象基类 AbstractAuthenticationService 中。

java

public abstract class AbstractAuthenticationService implements AuthenticationService {
    @Autowired
    protected TokenManager tokenManager; // 持有 JwtTokenParser
    @Autowired
    private Cache cache; // 通常是 RedisCache

    public void auth(HttpServletRequest req, ...) {
        String token = this.getToken(req); // 从 Header 提取 Token
        if (StringUtil.notEmpty(token)) {
            // 1. 解析 Token 并获取认证信息
            Authentication authentication = getAuthentication(token);
            if (authentication != null) {
                // 2. 将认证信息存入 SecurityContext,完成授权
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(req, response);
    }

    protected Authentication getAuthentication(String token) {
        try {
            // 1. 调用子类实现的 parseToken 方法
            AuthUser user = parseToken(token); // 例如:tokenManager.parse(Buyer.class, token)
            // 2. 【核心检查】检查用户是否被禁用!
            checkUserDisable(user.getRole(), user.getUid());
            // 3. 构建 Spring Security 的 Authentication 对象
            List<GrantedAuthority> auths = ... // 从 user.getRoles() 转换而来
            return new UsernamePasswordAuthenticationToken("user", null, auths);
        } catch (Exception e) {
            return null; // 解析失败或用户被禁用,返回未认证状态
        }
    }

    protected void checkUserDisable(Role role, long uid) {
        // 查询本地缓存,检查用户是否在禁用名单中
        Integer isDisable = cache.get(getKey(role, uid));
        if (isDisable != null && isDisable == 1) {
            throw new RuntimeException("用户已经被禁用"); // 抛出异常,认证失败
        }
    }
}

流程梳理:

  1. 拦截请求: TokenAuthenticationFilter 拦截到需要认证的请求。

  2. 提取 Token: 从 Authorization Header 中取出 JWT。

  3. 解析与验证: 调用 JwtTokenParser 验证签名并解析出用户信息。

  4. 状态检查(关键): 在认证的最后一步,查询本地缓存,确认该用户是否已被禁用。这是连接 JWT 无状态认证和状态同步的桥梁

  5. 完成认证: 如果一切正常,将用户信息放入 SecurityContext,后续的授权拦截器 (@PreAuthorize) 便可正常工作。

四、 分布式踢人:状态的实时同步

这是整个设计的精髓。当管理员禁用用户时:

  1. 事件发布: 用户服务在更新数据库后,通过 BroadcastMessageSender 发布一条 UserDisableMsg 消息到 Redis 的特定频道。

  2. 事件接收: 每个节点的 UserDisableReceiver 都订阅了这个频道,会收到这条消息。

  3. 更新缓存: 接收器遍历本节点所有的 AuthenticationService,调用其 userDisableEvent 方法。

java

// 在 AbstractAuthenticationService 中
@Override
public void userDisableEvent(UserDisableMsg userDisableMsg) {
    String key = getKey(userDisableMsg.getRole(), userDisableMsg.getUid());
    if (UserDisableMsg.ADD.equals(userDisableMsg.getOperation())) {
        // 禁用:将用户ID写入本地缓存,并设置一个合适的TTL
        cache.put(key, 1);
    } else {
        // 解禁:从缓存中移除
        cache.remove(key);
    }
}
  1. 立即生效: 此后,任何来自该用户的请求,在 checkUserDisable 步骤都会失败,从而实现实时踢人下线

总结与优势

这套方案巧妙地平衡了无状态架构的扩展性和关键状态的一致性需求:

  • 高性能: 绝大部分请求只需验证 JWT 签名,无需查库或远程调用。

  • 高扩展: 轻松水平扩展节点,无需担心会话复制问题。

  • 强一致性: 对于“用户禁用”这类关键操作,通过 Pub/Sub 保证所有节点状态瞬间一致。

  • 解耦清晰: 认证、授权、状态同步各司其职,代码结构清晰,易于维护。

欢迎访问 Shoptnt 项目源码,深入了解这一设计并在你的项目中实践:
https://gitee.com/bbc-se/shoptnt

思考题:
在你的项目中,还有哪些场景适合使用这种“无状态+广播”的模式?欢迎在评论区讨论!


网站公告

今日签到

点亮在社区的每一天
去签到