Spring Security OAuth2.0实战:多终端统一认证与JWT续签设计

举报
江南清风起 发表于 2025/07/22 18:43:03 2025/07/22
【摘要】 Spring Security OAuth2.0实战:多终端统一认证与JWT续签设计 一、场景痛点:为什么需要“多终端统一认证+JWT续签”痛点说明多端重复登录PC、App、小程序各自为政,用户每换一个端就要重新输密码JWT无法续签传统JWT过期必须重新登录,体验差;若把有效期设得很长则不安全并发刷新竞争多终端同时刷新 token 时,容易出现“旧 token 失效、新 token 被覆盖...

Spring Security OAuth2.0实战:多终端统一认证与JWT续签设计

一、场景痛点:为什么需要“多终端统一认证+JWT续签”

痛点 说明
多端重复登录 PC、App、小程序各自为政,用户每换一个端就要重新输密码
JWT无法续签 传统JWT过期必须重新登录,体验差;若把有效期设得很长则不安全
并发刷新竞争 多终端同时刷新 token 时,容易出现“旧 token 失效、新 token 被覆盖”
登出/踢人困难 无状态JWT让服务端无法主动失效令牌

二、总体架构设计

┌──────────────┐        ┌──────────────┐
│   前端(三端) │        │  网关层(GW)  │
└─────┬────────┘        └──────┬───────┘
      │ Authorization header   │
         (access_token)       │
      └──────────┬─────────────┘
                 │ 1. 校验JWT2. 若过期→去 Auth-Service 刷新
        ┌────────┴────────┐
        │  Auth-Service   │ ←─ Redis(计时Map防并发)
        └────────┬────────┘
                 │ 3. 颁发
                 │    new_access_token
                 │    new_refresh_token

核心设计点

  • 双 Token:短有效期 access_token(15 min)+ 长有效期 refresh_token(7 d)
  • 计时 Map:30 s 内同一 refresh_token 只返回同一组 JWT,防止并发重复签发
  • 统一用户状态:所有端共用 user_id + device_id 作为 Redis key,可实现单点踢出

三、授权服务器(Authorization Server)实现

3.1 依赖 & 主启动类

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
    <version>2.2.5.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

3.2 JWT 公私钥配置

@Configuration
public class JwtKeyConfig {
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        KeyStoreKeyFactory factory = new KeyStoreKeyFactory(
                new ClassPathResource("jwt.jks"),
                "123456".toCharArray());
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(factory.getKeyPair("jwt"));
        return converter;
    }
}

3.3 授权服务器核心配置

@Configuration
@EnableAuthorizationServer
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
               .withClient("web")
               .secret(passwordEncoder().encode("web_secret"))
               .authorizedGrantTypes("password", "refresh_token")
               .scopes("all")
               .accessTokenValiditySeconds(900)       // 15 min
               .refreshTokenValiditySeconds(604800);  // 7 d
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
            .tokenStore(tokenStore())
            .accessTokenConverter(jwtAccessTokenConverter)
            .authenticationManager(authenticationManager)
            .reuseRefreshTokens(false);            // 每次刷新都产生新refresh_token
    }
}

3.4 计时 Map 防并发刷新

@Component
public class TokenRefreshGate {

    private final RedisTemplate<String, String> redisTemplate;
    private static final String REFRESH_LOCK = "refresh_lock:";
    private static final Duration LOCK_TTL = Duration.ofSeconds(30);

    public TokenRefreshGate(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public boolean enter(String refreshTokenValue) {
        String key = REFRESH_LOCK + refreshTokenValue;
        Boolean absent = redisTemplate.opsForValue()
                                      .setIfAbsent(key, "1", LOCK_TTL);
        return Boolean.TRUE.equals(absent);
    }

    public void exit(String refreshTokenValue) {
        redisTemplate.delete(REFRESH_LOCK + refreshTokenValue);
    }
}

在自定义 TokenEndpoint 中调用 TokenRefreshGate 即可实现 30 s 内幂等返回同一 JWT。

四、资源服务器(Resource Server)实现

4.1 资源服务器配置

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        resources.tokenStore(new JwtTokenStore(jwtAccessTokenConverter));
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated();
    }
}

4.2 网关层统一刷新逻辑

@Component
public class JwtRelayFilter extends GlobalFilter {

    @Autowired
    private TokenRefreshGate refreshGate;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = extractToken(exchange);
        if (token != null && JwtUtil.isExpired(token)) {
            String refreshToken = extractRefreshTokenFromCookie(exchange);
            if (refreshToken != null && refreshGate.enter(refreshToken)) {
                try {
                    TokenPair newPair = refreshToken(refreshToken);
                    addTokenToHeader(exchange, newPair.getAccessToken());
                    addRefreshCookie(exchange, newPair.getRefreshToken());
                } finally {
                    refreshGate.exit(refreshToken);
                }
            }
        }
        return chain.filter(exchange);
    }
}

网关负责无感刷新,业务微服务无感知。

五、多端统一用户状态与踢人

Redis 结构

user:{userId}:tokens:{deviceId} -> {refreshTokenHash}
  • 登录:每次颁发 refresh_token 前先写入 Redis,TTL=7 d
  • 踢人:删除该 key,再发 Pub/Sub 消息通知网关层主动失效本地缓存
  • 统计在线设备:通过 KEYS user:{userId}:tokens:* 即可列出所有端

示例代码

@Service
public class DeviceService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public void kick(Long userId, String deviceId) {
        String key = "user:" + userId + ":tokens:" + deviceId;
        redisTemplate.delete(key);
        redisTemplate.convertAndSend("kick_channel", userId + ":" + deviceId);
    }
}

六、完整的刷新时序图

Parse error on line 18: ...token; Authorization: new_access -----------------------^ Expecting 'SOLID_OPEN_ARROW', 'DOTTED_OPEN_ARROW', 'SOLID_ARROW', 'DOTTED_ARROW', 'SOLID_CROSS', 'DOTTED_CROSS', got 'TXT'

七、测试用例(curl + jq)

  1. 密码模式获取令牌
curl -s -u web:web_secret -X POST \
  http://localhost:8080/oauth/token \
  -d "grant_type=password&username=admin&password=123456&scope=all" \
  | jq '.access_token,.refresh_token'
  1. 刷新令牌
curl -s -u web:web_secret -X POST \
  http://localhost:8080/oauth/token \
  -d "grant_type=refresh_token&refresh_token=$REFRESH_TOKEN" \
  | jq '.access_token'
  1. 踢出设备
curl -X DELETE http://localhost:8080/devices/kick?deviceId=iPhone12

八、小结与展望

  • 通过“双 Token + 计时 Map”既解决了 JWT 续签,又避免了并发竞争
  • 网关统一刷新,业务零侵入
  • Redis 存储用户-设备映射,实现多端登录管理与强制下线
  • 下一步可引入 Refresh Token Rotation(每次刷新都更换 refresh_token)进一步提升安全性,并结合 Device Fingerprint 防止 token 被盗用。

image.png

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。