【架构实战】OAuth2.0授权框架:第三方登录的安全设计

【架构实战】OAuth2.0授权框架:第三方登录的安全设计

一、"一键登录"引发的安全漏洞

2022年,我们平台接入了微信和支付宝的第三方登录。上线两周后,安全扫描报告了一个严重漏洞:通过截获授权码,攻击者可以在另一台设备上登录用户的账号。

排查发现:我们错误地实现了授权码流程(Authorization Code Grant),没有绑定PKCE(Proof Key for Code Exchange),导致授权码被中间人截获后直接换取Token。

更可怕的是:Token的过期时间设了30天,权限范围是read+write——这意味着攻击者一旦获取Token,可以冒充用户在30天内操作所有功能。

OAuth2.0看似简单——调几个API就实现"微信登录",但安全实践中到处都是坑。

二、OAuth2.0核心概念

2.1 四个角色

┌──────────┐ ┌──────────────┐ │ 用户 │ │ 资源服务器 │ │ Resource │ │ Resource │ │ Owner │ │ Server │ └────┬─────┘ └──────┬───────┘ │ │ │ "我同意授权" │ "这是用户的数据" │ │ ┌────▼────────────────────────▼───────┐ │ 授权服务器 │ │ Authorization Server │ │ ┌─────────────┐ ┌───────────────┐ │ │ │ /authorize │ │ /token │ │ │ │ 获取授权码 │ │ 换取Token │ │ │ └─────────────┘ └───────────────┘ │ └──────────────────┬─────────────────┘ │ ┌─────▼─────┐ │ 客户端 │ │ Client │ │ 我们的应用 │ └───────────┘

2.2 四种授权模式

授权模式使用场景安全等级是否需要前端
Authorization Code + PKCESPA/移动端/普通Web高(推荐)
Client Credentials服务间调用
Implicit(已废弃)不安全,不推荐
Resource Owner Password遗留系统兼容
Device CodeTV/物联网设备

关键建议:所有涉及用户的授权都用 Authorization Code + PKCE,不要用Implicit模式。

三、Authorization Code + PKCE 完整流程

3.1 流程图

用户 客户端(SPA) 授权服务器 资源服务器 │ │ │ │ │ 点击"微信登录"│ │ │ ├──────────────>│ │ │ │ │ 1.生成code_verifier & challenge │ │ │ 2.重定向 /authorize │ │ ├──────────────────>│ │ │ │ │ │ │ │ 3.用户授权页面 │ │ │<─────────────────────────────────┤ │ │ │ │ │ │ 4.输入账号密码 │ │ ├──────────────────────────────────>│ │ │ │ │ │ │ │ 5.返回授权码(code) │ │ │ │<──────────────────┤ │ │ │ │ │ │ │ 6.POST /token │ │ │ │ (code + code_verifier) │ │ ├──────────────────>│ │ │ │ │ │ │ │ 7.返回access_token│ │ │ │<──────────────────┤ │ │ │ │ │ │ │ 8.API请求(带Token)│ │ │ ├─────────────────────────────────────>│ │ │ │ │ │ │ 9.返回用户数据 │ │ │ │<─────────────────────────────────────┤

3.2 代码实现

// 1. 前端生成 PKCE 参数publicclassPKCEGenerator{publicstaticPKCEParamsgenerate(){// 生成 code_verifier(43-128位随机字符串)byte[]codeVerifierBytes=newbyte[32];newSecureRandom().nextBytes(codeVerifierBytes);StringcodeVerifier=Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifierBytes);// 生成 code_challenge(SHA256(code_verifier))MessageDigestmd=MessageDigest.getInstance("SHA-256");byte[]digest=md.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));StringcodeChallenge=Base64.getUrlEncoder().withoutPadding().encodeToString(digest);returnnewPKCEParams(codeVerifier,codeChallenge);}}// 2. 构建授权请求URLpublicStringbuildAuthorizationUrl(){PKCEParamspkce=PKCEGenerator.generate();// 存储 code_verifier(用于后续换Token),不能暴露给任何人sessionStorage.set("code_verifier",pkce.getCodeVerifier());Map<String,String>params=newLinkedHashMap<>();params.put("response_type","code");params.put("client_id",CLIENT_ID);params.put("redirect_uri",REDIRECT_URI);params.put("scope","read_user_info");params.put("state",UUID.randomUUID().toString());// 防CSRFparams.put("code_challenge",pkce.getCodeChallenge());params.put("code_challenge_method","S256");returnAUTHORIZE_URL+"?"+buildQueryString(params);}// 3. 后端用授权码换取Token@PostMapping("/oauth/callback")publicTokenResponsehandleCallback(@RequestParamStringcode,@RequestParamStringstate){// 验证state,防止CSRF攻击StringsavedState=(String)request.getSession().getAttribute("oauth_state");if(!state.equals(savedState)){thrownewSecurityException("State mismatch - possible CSRF attack");}// 构建 Token 请求HttpHeadersheaders=newHttpHeaders();headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);MultiValueMap<String,String>body=newLinkedMultiValueMap<>();body.add("grant_type","authorization_code");body.add("code",code);body.add("redirect_uri",REDIRECT_URI);body.add("client_id",CLIENT_ID);body.add("client_secret",CLIENT_SECRET);// 仅在服务端使用body.add("code_verifier",codeVerifier);// PKCE验证HttpEntity<MultiValueMap<String,String>>request=newHttpEntity<>(body,headers);ResponseEntity<TokenResponse>response=restTemplate.postForEntity(TOKEN_URL,request,TokenResponse.class);returnresponse.getBody();}

四、安全设计最佳实践

4.1 Token安全

// JWT Token 设计{"iss":"https://auth.example.com",// 签发者"sub":"user_12345",// 用户标识"aud":"api.example.com",// 受众(限制Token使用范围)"exp":1625097600,// 过期时间(绝对时间)"iat":1625000000,// 签发时间"jti":"unique-token-id-xxxxx",// Token唯一ID(用于撤销)"scope":"read:profile write:orders",// 权限范围"client_id":"spa-app"// 使用的客户端}// 安全配置accessTokenExpiry:15分钟// 短期,即便泄露窗口也小refreshTokenExpiry:7// 可撤销refreshTokenRotation:true// 每次刷新换新的refreshToken

4.2 安全威胁与防御

威胁攻击方式防御措施
CSRF伪造授权请求state参数验证
授权码拦截中间人截获codePKCE + code一次性使用
Token泄露XSS获取TokenHttpOnly Cookie + short TTL
重定向劫持伪造redirect_uri服务端白名单校验
暴力破解穷举client_secret速率限制 + 强密钥
权限膨胀客户端请求过多scope最小权限原则

4.3 服务端关键校验

@ServicepublicclassOAuth2SecurityService{publicAuthorizationCodevalidateAuthorizationRequest(AuthorizationRequestrequest){// 1. 校验redirect_uri白名单if(!isValidRedirectUri(request.getRedirectUri())){thrownewInvalidGrantException("redirect_uri not whitelisted");}// 2. 校验scope在允许范围内Set<String>requestedScopes=request.getScopes();Set<String>allowedScopes=clientService.getAllowedScopes(request.getClientId());if(!allowedScopes.containsAll(requestedScopes)){thrownewInvalidScopeException("Requested scope exceeds allowed scope");}// 3. 校验client_idClientclient=clientService.findByClientId(request.getClientId());if(client==null){thrownewInvalidClientException("Unknown client");}// 4. 生成一次性授权码(5分钟过期,用后即删)AuthorizationCodecode=AuthorizationCode.builder().code(UUID.randomUUID().toString()).clientId(request.getClientId()).userId(currentUser.getId()).redirectUri(request.getRedirectUri()).scopes(requestedScopes).codeChallenge(request.getCodeChallenge()).codeChallengeMethod(request.getCodeChallengeMethod()).expiresAt(Instant.now().plus(5,ChronoUnit.MINUTES)).used(false).build();returnauthorizationCodeRepository.save(code);}}

五、微服务中的OAuth2.0

5.1 API网关统一鉴权

请求流程: Client ──Authorization: Bearer xxx──> API Gateway │ 1. 解析JWT 2. 验证签名 3. 检查过期 4. 提取用户信息 5. 构造内部Header │ ┌────▼────┐ │ 订单服务 │ │ (不感知 │ │ OAuth) │ └─────────┘ // Gateway配置(Spring Cloud Gateway) @Bean public GlobalFilter authFilter() { return (exchange, chain) -> { String token = exchange.getRequest() .getHeaders() .getFirst("Authorization"); if (token == null || !token.startsWith("Bearer ")) { return unauthorized(exchange); } // 验证Token Claims claims = tokenService.validateToken(token.substring(7)); // 构造内部请求头,传递给下游服务 ServerHttpRequest request = exchange.getRequest().mutate() .header("X-User-Id", claims.getSubject()) .header("X-User-Roles", String.join(",", claims.getRoles())) .build(); return chain.filter(exchange.mutate().request(request).build()); }; }

六、总结

OAuth2.0是一个授权框架而非认证协议——它告诉API"这个客户端有权代表用户做什么",而不是"这个用户是谁"。很多团队混淆了这两个概念。

核心安全原则

  1. 所有用户授权场景必须使用 Authorization Code + PKCE
  2. state参数是防CSRF的唯一防线,必须验证
  3. redirect_uri必须做白名单严格匹配
  4. access_token有效期不超过15分钟,配合refresh_token轮换
  5. 授权码一次性使用,一旦换取Token立即失效
  6. scope最小权限原则 —— 宁少勿多
  7. Token在服务端验证,不依赖客户端传来的用户信息

血的教训:OAuth2.0的规范只定义了流程,安全细节需要自己实现。别把client_secret写在客户端代码里,别用Implicit模式,别把Token有效期设成30天。


个人观点,仅供参考