资讯详情

从零实现OAuth2.0客户端与服务端:授权码模式全流程解析

📅 2026/10/26 3:12:21 | 华诺云谱 👁 阅读
从零实现OAuth2.0客户端与服务端:授权码模式全流程解析
OAuth2.0 这个词做过第三方登录对接的同学应该都不陌生。但真正动手从零写一套客户端和服务端和调几个现成 SDK 完全是两码事。我见过太多项目接入阶段照着文档抄一遍能跑通一旦遇到令牌过期、回调地址不一致、授权码被重复使用这些情况就开始抓瞎。问题的根源往往不在代码写错而在于对 OAuth2.0 那套授权流程的每个环节到底在交换什么、为什么这么设计缺乏一个完整的认知。这篇内容我打算把 OAuth2.0 的客户端和服务端用 Java 各实现一遍不是调库而是把授权码模式这条主线上的每个角色、每次请求、每个参数都摊开讲清楚。适合两类人看一类是正在做第三方登录对接、想搞明白底层到底发生了什么的开发者另一类是想给自己系统加一套授权体系、需要理解服务端该怎么设计的同学。哪怕你之前只停留在“知道有授权码模式”这个层面跟着走一遍也能把整条链路串起来。我会用授权码模式作为主线因为它是实际项目里用得最多、也最能体现 OAuth2.0 设计思想的一种。整篇内容围绕四个角色展开资源所有者、客户端、授权服务器、资源服务器。客户端和服务端我都会给出可运行的 Java 实现思路重点放在参数校验、令牌管理、安全边界这些真正容易出问题的地方而不是贴一堆 CRUD 代码。1. 先把四个角色和授权码流程的账算清楚在动手写代码之前必须先把 OAuth2.0 里四个角色各自负责什么、彼此之间怎么交互理清楚。很多人写代码写到一半卡住就是因为脑子里角色是混的。我习惯把这套流程类比成“去银行办业务”你是资源所有者手里有存款受保护资源客户端是帮你跑腿的中介授权服务器是银行柜台负责核实你的身份并发放凭证资源服务器是金库只认凭证不认人。1.1 授权码模式到底在防什么授权码模式的核心价值在于它让客户端的后端参与到了令牌交换环节从而避免了访问令牌直接暴露在前端浏览器里。整个流程分两步走第一步客户端把用户引导到授权服务器用户登录并同意授权后授权服务器通过浏览器重定向把授权码带回客户端第二步客户端后端拿着这个授权码通过后端通道直接向授权服务器换取访问令牌。这里有个关键设计意图授权码是经由浏览器前端传递的理论上可能被截获所以它被设计成一次性且短时效的。即便被截获攻击者没有客户端的密钥client_secret也换不出令牌。而令牌交换走的是后端直连不经过浏览器安全性就有了保障。理解这一点后面写服务端校验逻辑时你就知道为什么授权码必须用完即焚、为什么 client_secret 绝不能出现在前端。1.2 一次完整授权涉及的四次请求把流程拆细一次完整的授权码模式交互包含四次关键请求我用表格列出来方便对照步骤发起方目标核心参数返回1客户端授权服务器client_id、redirect_uri、response_typecode、scope、state授权码 code2客户端后端授权服务器grant_typeauthorization_code、code、redirect_uri、client_id、client_secretaccess_token、refresh_token3客户端资源服务器Authorization: Bearer access_token受保护资源4客户端后端授权服务器grant_typerefresh_token、refresh_token、client_id、client_secret新的 access_token这四步里第 1 步和第 2 步的redirect_uri必须完全一致这是服务端校验的重点也是实际对接中最常见的报错来源。第 3 步的 Bearer 令牌放在请求头里而不是 URL 参数里这是为了避免令牌被日志、代理记录下来。第 4 步的刷新令牌机制是为了让访问令牌可以设置较短的有效期降低泄露风险。1.3 state 参数不是可选项很多示例代码里state参数随手填个固定值甚至省略这是很危险的做法。state的作用是防 CSRF 攻击客户端在发起授权请求时生成一个随机值存到用户会话里授权服务器原样带回客户端比对一致才继续。如果没有它攻击者可以构造一个带有自己授权码的回调链接诱导用户点击从而把用户的账号和自己的第三方账号绑定这就是典型的授权码注入攻击。我在实现里会把state生成一个 UUID存进服务端 session 或者分布式缓存回调时严格比对不一致直接拒绝。这个细节看起来小但它是区分“能跑”和“能上线”的分水岭。2. 授权服务器令牌签发的中枢该怎么搭授权服务器是整个体系的核心它要负责三件事验证用户身份、管理客户端注册信息、签发和校验令牌。我用 Spring Boot 搭一个最小可用的授权服务器重点讲清楚每个接口的校验逻辑而不是堆砌框架配置。2.1 客户端注册信息的内存模型先定义一个客户端注册的数据结构。真实场景下这些信息存在数据库里但为了讲清楚字段含义我用一个内存 Map 模拟public class RegisteredClient { private String clientId; private String clientSecret; // 加密存储比对时用哈希 private SetString redirectUris; // 允许的回调地址白名单 private SetString scopes; // 允许申请的权限范围 private int accessTokenTtl; // 访问令牌有效期秒 private int refreshTokenTtl; // 刷新令牌有效期秒 }这里有几个设计要点值得说。redirectUris做成集合而不是单个字符串是因为一个客户端可能配置多个回调地址比如测试环境和生产环境各一个但校验时必须是精确匹配绝不能用前缀匹配或者包含匹配否则就给了攻击者构造恶意回调地址的空间。clientSecret存储时要做哈希比对时用哈希值比对避免数据库泄露导致密钥直接暴露。scopes是权限范围客户端申请时只能申请自己注册过的范围服务端要校验申请的 scope 是不是注册信息的子集。2.2 授权端点校验通过才发码授权端点接收的是浏览器重定向过来的 GET 请求参数包括response_type、client_id、redirect_uri、scope、state。校验顺序我总结成一条链路任何一环失败都要按规范返回错误而不是抛个 500 了事校验client_id是否存在不存在直接拒绝校验redirect_uri是否在该客户端的白名单里精确匹配校验response_type是否为code其他类型不支持校验申请的scope是否为注册范围的子集校验用户是否已登录且同意授权这一步通常跳登录页和授权确认页。全部通过后生成授权码。授权码我建议用高熵随机串比如SecureRandom生成 32 字节再 Base64 编码同时把它和client_id、redirect_uri、scope、过期时间、是否已使用这些信息一起存起来。授权码有效期设短一点60 到 300 秒足够因为用户同意授权后浏览器会立刻跳转回来。String code generateSecureCode(); authCodeStore.put(code, new AuthCode( clientId, redirectUri, scope, Instant.now().plusSeconds(120), false ));注意授权码存储一定要带“是否已使用”标记。令牌端点换令牌时先检查这个标记已使用的授权码直接拒绝并且要把该授权码关联的已签发令牌一并作废。这是防重放的关键。2.3 令牌端点授权码换令牌的完整校验令牌端点接收的是客户端后端的 POST 请求参数包括grant_type、code、redirect_uri、client_id、client_secret。这里的校验比授权端点更严格因为涉及密钥比对先做客户端认证可以用 HTTP Basic 头也可以把client_id和client_secret放表单里两种方式选一种统一即可校验grant_type是否为authorization_code取出授权码记录检查是否存在、是否过期、是否已使用校验授权码记录里的client_id和redirect_uri与本次请求是否一致全部通过后把授权码标记为已使用签发访问令牌和刷新令牌。访问令牌我用 JWT 实现好处是资源服务器可以本地校验签名不用每次都回查授权服务器。JWT 的 payload 里放sub用户标识、client_id、scope、exp过期时间、jti令牌唯一标识。签名用 HMAC-SHA256密钥只有授权服务器和资源服务器知道。String accessToken Jwts.builder() .setSubject(userId) .claim(client_id, clientId) .claim(scope, scope) .setExpiration(Date.from(Instant.now().plusSeconds(3600))) .setId(UUID.randomUUID().toString()) .signWith(hmacKey) .compact();刷新令牌则用不透明随机串存服务端因为它需要支持吊销用 JWT 反而不方便。刷新令牌有效期可以设长一些比如 30 天但每次使用刷新令牌换新令牌时建议做刷新令牌轮换旧的刷新令牌作废发一个新的。这样即便刷新令牌泄露攻击者用一次之后真正的用户就会发现异常。2.4 令牌内省与吊销接口资源服务器如果不想自己解析 JWT或者令牌是不透明串就需要一个内省端点。内省端点接收令牌返回它是否有效、对应的用户、scope、过期时间。这个端点本身要受保护只有注册过的资源服务器才能调用。吊销端点用于用户主动登出或者管理员封禁场景接收令牌并把它加入黑名单。JWT 本身无法撤销所以要么维护一个黑名单存到过期为止要么把令牌有效期设短、靠刷新令牌控制。我一般选择后者配合短有效期黑名单只作为应急手段。3. 客户端把授权码流程串成可运行的代码客户端这边分两块一块是引导用户跳转授权、处理回调的前端逻辑另一块是后端换令牌、调资源的逻辑。我用一个简单的 Servlet 或者 Spring MVC 控制器来演示重点讲清楚会话状态怎么管、令牌怎么存。3.1 发起授权请求时的参数拼装用户点击“用某账号登录”时客户端后端生成state并存入会话然后拼装授权地址重定向String state UUID.randomUUID().toString(); session.setAttribute(oauth_state, state); String authUrl authorizationEndpoint ?response_typecode client_id URLEncoder.encode(clientId, UTF-8) redirect_uri URLEncoder.encode(redirectUri, UTF-8) scope URLEncoder.encode(read profile, UTF-8) state state; response.sendRedirect(authUrl);这里所有参数都要 URL 编码尤其是redirect_uri和scope里面可能带空格和特殊字符。state存会话时最好加个前缀避免和其他业务键冲突。授权地址的域名必须是授权服务器注册过的不能动态拼接用户输入否则就是开放重定向漏洞。3.2 回调处理先验 state 再换令牌回调端点收到code和state两个参数。第一步永远是校验stateString expectedState (String) session.getAttribute(oauth_state); if (expectedState null || !expectedState.equals(state)) { // 拒绝可能是 CSRF 攻击 return; } session.removeAttribute(oauth_state); // 用完即删校验通过后用code去换令牌。这一步是后端到后端的 POST 请求client_secret放在请求体或 Basic 头里绝不能出现在前端。换到令牌后把access_token和refresh_token存到服务端会话或者加密的 Cookie 里不要明文塞进前端 localStorage那是 XSS 攻击的重灾区。HttpResponseString tokenResp httpClient.send( HttpRequest.newBuilder() .uri(URI.create(tokenEndpoint)) .header(Content-Type, application/x-www-form-urlencoded) .POST(HttpRequest.BodyPublishers.ofString( grant_typeauthorization_code code code redirect_uri URLEncoder.encode(redirectUri, UTF-8) client_id clientId client_secret clientSecret)) .build(), HttpResponse.BodyHandlers.ofString());拿到响应后要检查 HTTP 状态码和响应体里的error字段OAuth2.0 的错误响应格式是固定的比如invalid_grant表示授权码无效或过期invalid_client表示客户端认证失败。把这些错误码映射成用户能看懂的提示比直接抛异常友好得多。3.3 带令牌访问资源与自动刷新访问资源服务器时把令牌放在Authorization: Bearer头里HttpRequest request HttpRequest.newBuilder() .uri(URI.create(resourceUrl)) .header(Authorization, Bearer accessToken) .GET() .build();如果资源服务器返回 401说明令牌过期或无效这时候用刷新令牌换新的访问令牌然后重试一次。刷新逻辑要加锁或者用原子操作避免并发请求同时触发多次刷新导致刷新令牌被轮换掉。我一般用一个带过期时间的本地缓存存令牌快过期时提前刷新而不是等到 401 才被动处理这样用户体验更平滑。4. 资源服务器令牌校验的边界与性能取舍资源服务器不负责发令牌只负责验令牌。它的实现相对简单但校验的严格程度直接决定了整个体系的安全底线。4.1 JWT 本地校验的完整步骤如果访问令牌是 JWT资源服务器本地校验分四步解析令牌、验证签名、检查过期时间、检查签发者。签名验证用和授权服务器共享的密钥验证失败直接拒绝。过期时间检查要注意时钟偏移一般允许几十秒的误差。Claims claims Jwts.parserBuilder() .setSigningKey(hmacKey) .build() .parseClaimsJws(token) .getBody(); if (claims.getExpiration().before(new Date())) { // 已过期 }校验通过后从claims里取出sub和scope做权限判断。比如某个接口要求write权限就检查scope里是否包含它。这里要注意scope 的比对要按空格拆分后逐个匹配不能用字符串包含判断否则read会误匹配readwrite。4.2 不透明令牌的内省调用与缓存如果令牌不是 JWT资源服务器就得调授权服务器的内省端点。每次请求都调一次内省性能开销不小所以要在资源服务器本地做缓存缓存键是令牌的哈希值缓存时间取令牌剩余有效期和固定上限的较小值。缓存里只存校验结果和必要的用户信息不存原始令牌。内省调用本身也要做超时和降级处理。授权服务器短暂不可用时如果缓存里还有有效的校验结果可以继续放行缓存也没有就返回 503 而不是 401避免把服务故障误判成用户未授权。4.3 令牌泄露的应急处理万一发现令牌泄露处理手段有三个层次第一如果令牌是 JWT 且有效期短等它自然过期第二把令牌的jti加入资源服务器的黑名单校验时额外查一次黑名单第三通过授权服务器吊销关联的刷新令牌让攻击者无法续期。实际项目里我建议把访问令牌有效期控制在 1 小时以内刷新令牌做轮换这样即便泄露影响窗口也可控。5. 那些文档里不写、上线才暴露的坑前面讲的都是“应该怎么做”这一节讲“实际做的时候会在哪里翻车”。这些经验基本都是从对接和自建过程中踩出来的文档里通常不会提。5.1 redirect_uri 的精确匹配陷阱最常见的报错就是回调地址不匹配。表现是授权服务器返回redirect_uri_mismatch但你把两个地址复制出来逐字符比对看起来一模一样。原因通常有三类一是末尾斜杠https://a.com/callback和https://a.com/callback/在服务端看来是两个地址二是大小写域名部分不区分大小写但路径部分区分三是 URL 编码差异redirect_uri在授权请求里编码了一次在令牌请求里又编码了一次服务端解码次数不一致就会对不上。我的处理办法是注册时把回调地址规范化统一去掉末尾斜杠、统一小写域名部分校验时对两边都做一次解码再比对。另外令牌请求里的redirect_uri必须和授权请求里的完全一致这是规范要求不能省略也不能改。5.2 授权码重复使用导致的令牌作废规范里明确要求授权码一旦被使用过再次使用必须拒绝并且要把之前基于该授权码签发的令牌全部作废。我见过有实现只做了“拒绝”没做“作废”结果攻击者截获授权码后用了一次合法客户端再用时被拒绝但攻击者手里的令牌依然有效。正确的做法是在授权码记录里关联已签发的令牌标识检测到重复使用时触发吊销。5.3 刷新令牌轮换引发的并发问题刷新令牌轮换是个好机制但它和并发是天然冲突的。用户同时打开多个标签页每个页面都检测到访问令牌过期同时发起刷新请求第一个请求把旧刷新令牌用掉了后面几个请求拿着已经作废的刷新令牌去换全部失败用户被强制登出。解决办法有两个方向客户端侧做刷新请求的合并用一个 Promise 或者锁保证同一时刻只有一个刷新在飞其他请求等它的结果服务端侧给旧刷新令牌一个短暂的宽限期比如轮换后 30 秒内旧令牌仍可用但只返回同一个新令牌。我倾向于客户端合并为主、服务端宽限为辅两边都做更稳。5.4 scope 校验的粒度问题scope 校验最容易犯的错是只校验“申请的是不是注册的子集”却没校验“这个用户有没有这个权限”。比如客户端注册了admin这个 scope用户 A 只是普通用户授权时却把admin也授出去了。正确的做法是在授权确认环节根据用户实际权限过滤可授予的 scope用户没有的权限不能出现在同意页上令牌签发时也不能包含。6. 从能跑到能扛令牌体系的加固思路一套 OAuth2.0 实现跑通 Demo 只是起点要真正扛住生产环境的流量和攻击面还得在几个地方加固。这部分我按优先级从高到低说。6.1 密钥管理与签名算法选择HMAC-SHA256 适合单体或者信任域内共享密钥的场景但如果资源服务器是多方独立的用非对称签名更合适授权服务器持私钥签名资源服务器用公钥验签公钥可以公开分发。密钥本身要定期轮换JWT 头部里的kid字段用来标识用的是哪把密钥资源服务器根据kid选择对应的公钥这样轮换时新旧密钥可以并存一段时间平滑过渡。密钥绝不能硬编码在代码里也不能提交到代码仓库。用环境变量或者配置中心注入不同环境用不同密钥。我见过把密钥写在application.yml里然后推到公开仓库的等于把整个令牌体系拱手让人。6.2 令牌存储位置与传输安全客户端拿到的令牌存在哪直接关系到 XSS 和 CSRF 的风险。浏览器端存储有三个选择localStorage、sessionStorage、HttpOnly Cookie。localStorage 最方便但最危险任何 XSS 都能读走HttpOnly Cookie 能防 XSS 读取但要配合 SameSite 属性防 CSRF。我的建议是访问令牌放内存变量刷新令牌放 HttpOnly Secure SameSiteStrict 的 Cookie页面刷新时用刷新令牌换新的访问令牌。传输层面所有 OAuth2.0 相关端点必须走 HTTPS包括授权端点、令牌端点、内省端点。授权码和令牌在明文 HTTP 下传输等于把凭证直接广播出去。6.3 限流与异常监控令牌端点是被暴力攻击的重点目标攻击者可能拿大量授权码或者刷新令牌来试探。要在令牌端点和内省端点做限流按客户端维度限也按 IP 维度限。同时监控异常模式短时间内大量invalid_grant错误、同一客户端从异常地理位置发起令牌请求、授权码重复使用告警这些都值得接入监控告警。日志里绝对不能打印完整的令牌和授权码最多打印前几位加哈希。我见过排查问题时把完整令牌打进日志结果日志系统被拖库令牌全部泄露的事故。6.4 授权确认页的体验与安全平衡授权确认页是用户唯一能看清“这个应用要我的什么权限”的地方但很多实现把它做成一个默认勾选、一键同意的形式用户根本没看。从安全角度应该把申请的 scope 用用户能理解的语言展示出来敏感权限单独高亮并且默认不勾选。从体验角度别把确认页做得太复杂权限描述要短、要具体比如“读取你的昵称和头像”比“访问用户基本信息”清楚得多。我在实际项目里会把 scope 和展示文案做成映射表服务端签发令牌时用 scope 标识前端展示时查表换成文案两边解耦改文案不用动授权逻辑。7. 一套可复用的实现骨架长什么样讲了这么多细节最后落到代码组织上。一套能长期维护的 OAuth2.0 实现我倾向于按职责拆成几个模块而不是全塞在一个类里。7.1 服务端的模块划分授权服务器至少拆成四块客户端注册管理、授权码管理、令牌签发、令牌校验。客户端注册管理负责增删改查和密钥哈希授权码管理负责生成、存储、消费、过期清理令牌签发负责组装 JWT 和刷新令牌令牌校验负责内省和吊销。每块之间通过接口交互方便单独测试和替换实现。授权码和刷新令牌的存储单机可以用带过期时间的本地缓存集群必须用 Redis 这类共享存储并且要注意过期时间的设置和清理策略。授权码的过期清理可以靠存储自带的 TTL刷新令牌的轮换和吊销则需要显式的状态管理。7.2 客户端的配置抽象客户端这边把授权端点、令牌端点、client_id、client_secret、redirect_uri、scope 这些做成配置项不同环境用不同配置。换令牌和刷新的逻辑封装成一个TokenClient类对外只暴露getAccessToken()和refresh()两个方法内部处理缓存、并发、重试。这样业务代码完全不用关心 OAuth2.0 的细节只调一个方法拿令牌。public interface TokenClient { String getAccessToken(); // 自动处理缓存和刷新 void invalidate(); // 强制失效用于登出 }7.3 测试策略把异常路径也覆盖到OAuth2.0 的测试重点不在正常流程而在异常路径。我一般会覆盖这些用例授权码过期、授权码重复使用、redirect_uri 不匹配、client_secret 错误、scope 越权申请、state 不匹配、刷新令牌已吊销、令牌签名被篡改。每个用例都断言返回的错误码符合规范而不是笼统的 400。集成测试里可以用一个内存版的授权服务器和资源服务器把整条链路跑通再针对每个异常点单独构造请求。这样改代码时能快速发现回归比手工点页面靠谱得多。这套东西写下来代码量其实不大难的是把每个校验点的意图想明白。OAuth2.0 的规范读起来枯燥但每一条校验要求背后基本都对应一个真实发生过的攻击场景。把规范当“安全检查清单”来读比当“接口文档”来读收获大得多。我自己在实现时养成了一个习惯每写一个校验分支就在注释里写清楚它在防什么时间长了这份注释本身就是一份安全设计文档。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑