【架构实战】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 + PKCE | SPA/移动端/普通Web | 高(推荐) | 是 |
| Client Credentials | 服务间调用 | 高 | 否 |
| Implicit(已废弃) | 不安全,不推荐 | 低 | 是 |
| Resource Owner Password | 遗留系统兼容 | 低 | 否 |
| Device Code | TV/物联网设备 | 中 | 否 |
关键建议:所有涉及用户的授权都用 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// 每次刷新换新的refreshToken4.2 安全威胁与防御
| 威胁 | 攻击方式 | 防御措施 |
|---|---|---|
| CSRF | 伪造授权请求 | state参数验证 |
| 授权码拦截 | 中间人截获code | PKCE + code一次性使用 |
| Token泄露 | XSS获取Token | HttpOnly 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"这个客户端有权代表用户做什么",而不是"这个用户是谁"。很多团队混淆了这两个概念。
核心安全原则:
- 所有用户授权场景必须使用 Authorization Code + PKCE
- state参数是防CSRF的唯一防线,必须验证
- redirect_uri必须做白名单严格匹配
- access_token有效期不超过15分钟,配合refresh_token轮换
- 授权码一次性使用,一旦换取Token立即失效
- scope最小权限原则 —— 宁少勿多
- Token在服务端验证,不依赖客户端传来的用户信息
血的教训:OAuth2.0的规范只定义了流程,安全细节需要自己实现。别把client_secret写在客户端代码里,别用Implicit模式,别把Token有效期设成30天。
个人观点,仅供参考