分布式认证中心第六集 集群

OAuth2集群:从"薛定谔的登录"到"稳稳的幸福"

一个负载均衡引发的血案,以及我是怎么用"玄学"参数把它治好的

前情提要:我们挖了个"集群"的坑

上回书说到,我们整了个 Gateway 做路径分发,配置文件里有一行神秘的代码:

java 复制代码
uri: lb://login

这个 lb 就是 Load Balancer(负载均衡)的缩写。既然有了负载均衡,那咱不得把 OAuth2 授权服务器也搞个集群玩玩?说干就干!

开局:复制一个OAuth2副本,启动!

我麻溜地创建了一个 OAuth2 副本,端口设成 8128,原服务是 8129。两台服务齐活,启动!

然后美滋滋地开始测试:

密码模式:偶尔成功,偶尔失败 ------ 这叫"薛定谔的认证"

第一次请求 → ✅ 成功!

第二次请求 → ❌ 失败!

第三次请求 → ✅ 又成功了!

第四次请求 → ❌ 又失败了!

好家伙,这登录跟抛硬币似的!

授权码模式:直接崩盘,无限鬼打墙

授权码模式更惨,登录之后一直在登录页 无限跳转,就像走进了"鬼打墙":

登录 → 跳转登录页 → 再登录 → 再跳转登录页 → 循环到天荒地老

第一回合:密码模式的"翻车"真相

问题出在哪?

我扒开代码一看,发现了罪魁祸首:

java 复制代码
private KeyPair generateRsaKeyPair() {
    try {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
        return keyPairGenerator.generateKeyPair();
    } catch (Exception e) {
        throw new RuntimeException("生成 RSA 密钥对失败", e);
    }
}

看懂了吗?

8129 服务 启动时 → 随机生成 RSA 密钥对 A(私钥 A 签名,公钥 A 验证)

8128 服务 启动时 → 随机生成 RSA 密钥对 B(私钥 B 签名,公钥 B 验证)

两个服务的密钥对完全不同 !就像两把锁配了不同的钥匙,你拿A的钥匙去开B的门,能开就怪了

更要命的是 Gateway:

java 复制代码
private String tokenKeyUrl = "http://localhost:8129/oauth/token_key"; // 写死 8129!

Gateway 启动时只从 8129 拉取公钥 A,用来验证所有 JWT Token。

现在问题就清晰了:

请求打到 8129 → Token用A签发 → Gateway用A验证 → ✅ 通过

请求打到 8128 → Token用B签发 → Gateway用A验证 → ❌ 不通过

成功失败交替?那可不就是负载均衡轮流分配嘛!

解决方案:把密钥"写死"到配置里

既然随机生成会打架,那我们就自己生成一对固定的公钥和私钥,放到 Nacos 配置里,让 OAuth2 和 Gateway 都用同一套!

  1. 在配置文件中添加:
java 复制代码
oauth2:
  jwt:
    private-key: MIIEvgIBADANBgkqhkiG9w0BA...
    public-key: MIIBIjANBgkqhkiG9w0BAQEFAA...

注:gateway只需要公钥,可以不写private-key,但 oauth2 两个都得有!

  1. 改造 OAuth2 的密钥加载逻辑:
java 复制代码
@Value("${oauth2.jwt.private-key}")
private String privateKeyBase64;

@Value("${oauth2.jwt.public-key}")
private String publicKeyBase64;
java 复制代码
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();

    // ✅ 2. 生成 RSA 密钥对
    KeyPair keyPair = loadKeyPairFromConfig();

    converter.setKeyPair(keyPair);

    return converter;
}

private KeyPair loadKeyPairFromConfig() {
    try {
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        byte[] privBytes = Base64.getDecoder().decode(privateKeyBase64);
        byte[] pubBytes = Base64.getDecoder().decode(publicKeyBase64);
        java.security.PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(privBytes));
        java.security.PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(pubBytes));
        return new KeyPair(publicKey, privateKey);
    } catch (Exception e) {
        throw new RuntimeException("从配置加载 RSA 密钥对失败", e);
    }
}
  1. Gateway 也改造,干掉写死的 URL:
java 复制代码
@Configuration
public class JwtConfig {
    @Value("${oauth2.jwt.public-key}")
    private String publicKeyBase64;
//    private String tokenKeyUrl = "http://localhost:8129/oauth/token_key";
    @Bean
    public ReactiveJwtDecoder reactiveJwtDecoder() {
        try{
            byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
            PublicKey publicKey = KeyFactory.getInstance("RSA")
                    .generatePublic(new X509EncodedKeySpec(keyBytes));
            return NimbusReactiveJwtDecoder.withPublicKey((RSAPublicKey)publicKey).build();
        }
        catch (Exception e){
            throw new RuntimeException("获取JWT公钥失败", e);
        }
    }
}

测试结果:稳了!

重启两个 OAuth2 服务,清空控制台。

第一次登录 → 8128 出现日志 ✅

第二次登录 → 8129 出现日志 ✅

第三次登录 → 8128 出现日志 ✅

密码模式集群,完美收官!

第二回合:授权码模式的"鬼打墙"

问题出在哪?

授权码模式比密码模式复杂得多,流程是这样的:

浏览器跳转到 /oauth/authorize(第一步)

没登录 → 跳转到 /login.html(第二步)

提交登录表单 → 请求 /login(第三步)

认证成功 → 带着 code 跳回回调地址(第四步)

这一套流程,每一步都要经过 Gateway,每一次都可能被分发到不同的 OAuth2 服务!

场景还原:

第一步打到 8129 → 8129 把回调地址存到了自己的 session

第二步打到 8128 → 8128 一看:我 session 里没东西啊!于是跳转到 localhost:8010/

这就是"授权码模式无限跳转"的真相! 一个流程走多个服务,session 不共享,回调地址丢失了。

方案一:Redis Session 共享(常规做法)

把 session 放到 Redis 里共享,这样不管打到哪台 OAuth2,都能拿到同一个 session。

这个方案没毛病,就是有点"重",得引入 Redis,还得配置 Session 共享。

方案二:Gateway 粘性路由(我选这个)

我寻思着:一个授权码登录流程,在多个服务之间跳来跳去,这行为怎么看怎么"不优雅"。

于是我想了个"轻量级"方案:同一个登录流程,全部走同一台 OAuth2 服务!

说白了就是:第一次分到 8129,后面的请求也全都去 8129,别换!

第一次尝试:基于 IP 的粘性路由

我写了个 StickySessionFilter,让同一个 IP 的请求都去同一台 OAuth2。

java 复制代码
String clientIp = getClientIp(exchange);
int index = Math.abs(hash(clientIp)) % instances.size();
// 选中一台,后面的请求都用这个 index

测试登录页 → ✅ 没问题!

测试登录提交 → ✅ 也没问题!

结果到了 callback 用 code 换 token 的环节:

炸了!

为什么?因为 callback 请求是 login 服务发起的,不是浏览器!

两个 IP 不一样! 基于 IP 的粘性路由在这里彻底失效了。

第二次尝试:基于 state 参数的"精准制导"

这时候我突然想起了一个被大多数人忽略的参数 ------ state。

OAuth2 授权码模式里有个 state 参数,用来防止 CSRF 攻击。它会随着整个流程自动传递:

前端生成 → 带到 /oauth/authorize → 跳到登录页 → 登录提交 → 回调时原样返回 → code换token时又传回来

整个流程都带着它! 这不就是天然的"流程标识符"吗?

完美方案:用 state 做粘性路由

  1. Gateway 过滤器用 state 做 hash
java 复制代码
@Component
public class StickySessionFilter implements GlobalFilter, Ordered {

    private static final Logger log = LoggerFactory.getLogger(StickySessionFilter.class);

    private final DiscoveryClient discoveryClient;

    public StickySessionFilter(DiscoveryClient discoveryClient) {
        this.discoveryClient = discoveryClient;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // RouteToRequestUrlFilter (order=10000) 已将 lb://oauth2/path 存入此属性
        URI requestUrl = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR);
        if (requestUrl == null || !"lb".equals(requestUrl.getScheme())) {
            return chain.filter(exchange);
        }

        String serviceId = requestUrl.getHost();
        if (!"oauth2".equalsIgnoreCase(serviceId)) {
            return chain.filter(exchange);
        }

        List<ServiceInstance> instances = discoveryClient.getInstances(serviceId);
        if (instances == null || instances.isEmpty()) {
            return chain.filter(exchange);
        }

        // 按 host:port 排序,保证每次实例列表顺序一致
        List<ServiceInstance> sorted = instances.stream()
                .sorted(Comparator.comparing(i -> i.getHost() + ":" + i.getPort()))
                .collect(Collectors.toList());

        ServiceInstance instance;
        if (sorted.size() == 1) {
            instance = sorted.get(0);
        } else {
            String stickyKey = exchange.getRequest().getHeaders().getFirst("X-Sticky-Key");
            if (stickyKey == null || stickyKey.isEmpty()) {
                stickyKey = exchange.getRequest().getQueryParams().getFirst("state");
            }
            String hashKey = (stickyKey != null && !stickyKey.isEmpty()) ? stickyKey : getClientIp(exchange);
            int index = Math.abs(hash(hashKey)) % sorted.size();
            instance = sorted.get(index);
            log.info("【粘性路由】hashKey={}, 实例数={}, 选中={}:{}", hashKey, sorted.size(), instance.getHost(), instance.getPort());

        }

        URI directUri = buildDirectUri(instance, requestUrl);
        log.info("【粘性路由】{} {} → {}", serviceId, requestUrl, directUri);

        // 直接修改 exchange 属性为直连 URI
        // LoadBalancerClientFilter 检测到 scheme 不是 "lb" 会自动跳过
        exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR, directUri);

        return chain.filter(exchange);
    }

    private URI buildDirectUri(ServiceInstance instance, URI originalUri) {
        return URI.create(
                "http://" + instance.getHost() + ":" + instance.getPort()
                        + originalUri.getRawPath()
                        + (originalUri.getRawQuery() != null ? "?" + originalUri.getRawQuery() : "")
        );
    }

    private String getClientIp(ServerWebExchange exchange) {
        String xff = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
        if (xff != null && !xff.isEmpty()) {
            return xff.split(",")[0].trim();
        }
        String realIp = exchange.getRequest().getHeaders().getFirst("X-Real-IP");
        if (realIp != null && !realIp.isEmpty()) {
            return realIp;
        }
        InetSocketAddress remoteAddress = exchange.getRequest().getRemoteAddress();
        return remoteAddress != null ? remoteAddress.getAddress().getHostAddress() : "127.0.0.1";
    }

    private int hash(String key) {
        int h = key.hashCode();
        h ^= (h >>> 16);
        return h;
    }

    @Override
    public int getOrder() {
        return 10099;
    }
}
  1. login 服务传递 state
java 复制代码
 @GetMapping("/oauth/authorize")
    public Map<String, String> getAuthorizeUrl(@RequestParam(value = "state", required = false) String state) {
        UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(authorizeUri)
                .queryParam("client_id", clientId)
                .queryParam("redirect_uri", redirectUri)
                .queryParam("response_type", "code")
                .queryParam("scope", "read write");

        if (state != null && !state.isEmpty()) {
            builder.queryParam("state", state);
        }
java 复制代码
@GetMapping("/callback")
public LoginResponse callback(@RequestParam("code") String code,@RequestParam("state") String state) {
    log.info("📨 收到授权码回调: {}", code);

    try {
        // 用授权码换取 Token
        LoginResponse tokenResponse = exchangeCodeForToken(code,state);
        log.info("✅ Token 换取成功");
        return tokenResponse;
    } catch (Exception e) {
        log.error("❌ 换取 Token 失败", e);
        throw new RuntimeException("授权码换取 Token 失败: " + e.getMessage());
    }
}
java 复制代码
private LoginResponse exchangeCodeForToken(String code,String state) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.setBasicAuth(clientId, clientSecret);  // 后端持有 client-secret

        // 关键:把 state 透传到 Header,网关用它做粘性路由
        if (state != null && !state.isEmpty()) {
            headers.set("X-Sticky-Key", state);
        }
  1. 前端vue项目 生成随机 state数
java 复制代码
axios.get('http://localhost:8100/api/login/oauth/authorize',{
  params: {
    state: '123456'			//这里可以随机生成state,让每一次请求走同一个oauth2服务,避免了同一个ip的场景下一直走一台oauth2服务
  }
})

在CallbackPage.vue中接收state,并放到 /callback请求参数中

java 复制代码
const state = urlParams.get('state');
const response = await fetch('http://localhost:8100/api/login/callback?code=' + code + '&state=' + state);

最终测试:一切完美!

登录 → 跳转 → 授权 → 回调 → 换 token,一气呵成!

不管怎么刷新,整个流程都稳稳地走在同一台 OAuth2 服务上。

总结:从"崩盘"到"优雅"

密码模式 密钥对不同,验证失败 密钥统一配置到 Nacos

授权码模式(基于IP) 登录流程多个服务跳转 基于 IP 粘性路由

授权码模式(IP失效) login服务IP ≠ 浏览器IP 基于 state 精准粘性路由

好了,今天的故事就到这里。如果你也在搞 OAuth2 集群,希望这篇文章能让你少踩几个坑!