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 都用同一套!
- 在配置文件中添加:
java
oauth2:
jwt:
private-key: MIIEvgIBADANBgkqhkiG9w0BA...
public-key: MIIBIjANBgkqhkiG9w0BAQEFAA...
注:gateway只需要公钥,可以不写private-key,但 oauth2 两个都得有!
- 改造 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);
}
}
- 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 做粘性路由
- 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;
}
}
- 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);
}
- 前端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 集群,希望这篇文章能让你少踩几个坑!