
OAuth2这个协议搞后端的人基本都绕不开。很多项目“对接第三方登录”或者“开放API给合作伙伴”第一反应就是用一个开源授权服务器或者干脆自研一套。但真到设计授权模式的时候经常会有人把四种模式混在一起授权码、简化、密码、客户端凭证不知道选哪个也不知道每种模式背后的坑在哪。这篇就把OAuth2四种授权模式掰开揉碎讲清楚从设计思路到安全细节再到Spring Security 6落地时怎么配置授权服务器一次性说明白。我默认看这篇的你是后端开发或者刚接手一个需要对接OAuth2的业务系统对协议有基础了解但没系统梳理过。文章会先把OAuth2的设计骨架讲透再逐一拆解四种模式最后补充安全实践和排障经验。不废话直接上干货。1. OAuth2设计骨架先看懂令牌与授权码1.1 四个角色分别干嘛的OAuth2整个协议本质上就干一件事让第三方应用在用户授权的前提下有限度地访问用户在某个服务上的资源。所以你首先得把四个角色记牢资源所有者就是用户本人、客户端想访问资源的应用可以是网页、手机App、后端服务、授权服务器负责认证用户并颁发令牌、资源服务器保存用户资源并根据令牌决定放不放行。这四个角色之间的交互是OAuth2的核心。我经常把授权服务器比作酒店前台资源服务器比作健身房入口客户端是借房卡的访客用户是住客本人。住客同意后前台才给访客一张限时的、只能去特定楼层的房卡健身房门口的人看卡放行。这张“房卡”就是访问令牌Access Token。理解这一点很重要OAuth2里授权服务器和资源服务器虽然是两个逻辑角色但物理上可以拆分部署也可以合并在一个服务里。授权服务器管发卡资源服务器管验卡二者通过校验令牌来配合。1.2 授权码和令牌不是一回事很多新手把授权码Authorization Code和访问令牌Access Token搞混这俩是完全不同的东西。授权码是授权服务器返回给“已授权但还没换令牌”的临时凭证有效期很短通常一两分钟而且只能换一次。访问令牌才是客户端真正拿来调资源的凭证有效期一般几十分钟到几小时。我举个实际场景你去行政那里登记领一张临时门禁卡授权码然后拿这张临时门禁卡去前台换正式工牌访问令牌。临时卡丢了还可以作废重发但正式工牌能进出更多区域管理更严格。OAuth2之所以搞出这一步中间层核心目的是“多一次安全校验的机会”尤其当客户端在浏览器这种开放环境里传递凭证时授权码模式能避免令牌直接暴露在URL里。1.3 为什么偏偏是四种授权模式OAuth2定义四种模式本质是把“客户端类型”和“用户参与程度”两个维度做组合后的答案。客户端类型主要分两类能保密的有后端服务器能持有client_secret的绝密型客户端和不能保密的纯浏览器SPA、移动App没有后端或者后端不在自己控制下的公开型客户端。用户参与程度则决定是用户直接给密码还是用户点头授权就完事。授权码模式客户端有后端用户参与授权浏览器里跳转最安全。适用于Web应用。简化模式客户端无法保密用户参与授权浏览器里跳转后直接拿令牌。早期给纯前端SPA用的现在已经不建议用了。密码模式用户把用户名密码直接给客户端客户端去换令牌。适合自家第一方应用不能给第三方用。客户端凭证模式没有用户介入客户端直接用自己的身份换令牌。适合服务端到服务端的通信。一句话总结授权码是“人授权给应用”密码模式是“人把钥匙直接给你”客户端凭证是“应用自己证明身份”简化模式是“妥协出来的精简版”。你对照这个逻辑去选型基本不会选错。2. 授权码模式事实上的标准方案2.1 完整流程与令牌交换细节授权码模式Authorization Code Grant是所有模式里最严谨的也是目前Web应用对接第三方登录时的通用方案。流程分两段第一段是拿授权码第二段是换令牌。我先给了段最直观的流程描述客户端把你的浏览器重定向到授权服务器的/authorize端点参数大致是client_id你在授权服务器注册应用时拿到的ID、redirect_uri授权完成后回跳地址、response_typecode表示我要授权码、scope申请的权限范围、state防止CSRF的随机串。用户在授权服务器上登录并点击“同意授权”。授权服务器把浏览器 302 重定向回你填的redirect_uri地址上带着codexxxx和statexxxx。客户端后端拿到code用后端通道直接向授权服务器的/token端点发请求带上grant_typeauthorization_code、code、redirect_uri、client_id和client_secret。授权服务器验证通过后返回access_token、refresh_token、expires_in等信息。客户端拿access_token调用资源服务器的API。我特别强调一个细节第4步里换令牌的请求必须是后端到后端的直连绝不能在浏览器JavaScript里发起。因为这一步需要携带client_secret一旦client_secret暴露在前端代码里就等于把保险柜钥匙贴在大门上。这也是为什么授权码模式比直接返回令牌安全的核心原因——令牌交换过程发生在安全的服务端通道里。2.2 为什么授权码比直接给令牌安全授权码模式最大的优势在于一个“中间凭证”。就算你在浏览器URL里丢了授权码攻击者拿到了code他也没有client_secret没法在授权服务器上换令牌。而且授权码有效期极短通常在30秒到2分钟之间换过一次就立即失效。攻击者就算想用“重放攻击”再换一次授权服务器查一下发现这码已经用过了直接拒绝。我用一个例子来描述这层设计好比你去银行柜台办业务柜员给你一张带编号的排队小票授权码你拿着小票去VIP窗口换正式的办理凭证令牌。小票上没写你的账户信息丢了也就别人拿到一张没用的票。但你拿着小票到了VIP窗口柜员还得验身份证client_secret才肯给你换正式凭证。另外授权码模式下redirect_uri是强制校验的。授权服务器会把请求里的redirect_uri和注册时的值做精确匹配不一样就拒绝。这就防止了“授权码被劫持跳到攻击者服务器”的场景。你要是自己实现授权服务器这一步千万不能放松匹配要全等不能只比对前缀。2.3 PKCE扩展移动端和SPA的救星PKCEProof Key for Code Exchange发音“pixy”最初是为移动App设计的后来因为SPA应用太流行OAuth2社区也把PKCE推荐为公开客户端的标准做法。它的核心思路是让客户端在发起授权请求时先本地生成一个随机字符串code_verifier然后算出一个变换后的值code_challenge传给授权服务器换令牌时再把原始的code_verifier传回去。授权服务器核对二者匹配才发令牌。这样做的价值在于即使code被攻击者截获但没有原始的code_verifier攻击者依然换不到令牌。我建议你在写SPA或者小程序的时候不要想着用简化模式直接用“授权码 PKCE”既符合安全标准又能解决公网环境下客户端无法保密的问题。code_challenge的生成方式有两种明文plain和SHA-256哈希S256。优先用S256code_verifier 随机字符串43~128位字母、数字、-._~ code_challenge BASE64URL(SHA256(code_verifier))把code_challenge放进/authorize请求换令牌的时候带上code_verifier授权服务器做校验。注意code_challenge_method默认是plain但你显式声明S256更保险这已经是各家大厂的标准姿势了。2.4 关键参数与重定向URI校验注意事项授权码模式里有几个参数容易踩坑我直接列一下帮你避雷client_id客户端标识公开的可以在URL里出现。redirect_uri必须精确匹配注册值路径要一致、参数要一致协议和端口也得一致。曾见过生产环境因为HTTP和HTTPS混用导致校验失败的情况。state客户端生成的随机串授权服务器原样带回用于防止CSRF。很多小项目嫌麻烦不传结果登录接口被刷走了用户账号这种坑真不值得踩。scope权限范围用空格分隔的字符串。不要一股脑申请all最小化授权才是正解。response_type授权码模式固定是code简化模式是token。redirect_uri校验还有一个容易忽略的点有些授权服务器支持通配符匹配比如https://example.com/*这属于高危操作。攻击者可以用开放重定向跳转到自己的域名拿到授权码。你自己实现授权服务器时宁可配全所有回调地址也千万别用通配符省事。3. 另外三种模式怎么选、怎么避坑3.1 简化模式已经被时代淘汰的“历史方案”简化模式Implicit Grant的逻辑很简单授权服务器不再返回授权码而是直接把access_token放在redirect_uri的URL片段fragment里。当初设计它是为了纯前端场景——那时候SPA没有后端没法安全地保存client_secret所以干脆省掉换令牌的一步。但这个设计在安全上有硬伤。令牌直接暴露在URL里会出现在浏览器历史记录、Referer头、代理服务器日志等各种地方泄露面大得吓人。而且SPA拿到令牌后存在哪都是问题Web Storage里存着XSS一打全完。所以后来OAuth 2.1草案和各家大厂的态度都非常一致别用隐式授权了。现在标准方案是“授权码 PKCE”前面已经讲过。如果你是老系统里从简化模式迁移过来的建议逐步改成授权码模式别觉得麻烦安全账早晚要还的。我自己在维护一个老项目时深有体会当初图省事用隐式授权后来要对接第三方安全扫描这个问题被反复拎出来点名只能花时间重构。3.2 密码模式只适合第一方应用给第三方就是在裸奔密码模式Resource Owner Password Credentials Grant是四种模式里最简单直白的一种用户把用户名密码直接传给客户端客户端拿它们去授权服务器换令牌。整个交互就一步POST /token grant_typepasswordusernamexxxpasswordyyyclient_idzzzclient_secretsss授权服务器校验用户名密码直接返回access_token和refresh_token。这个模式的致命问题在于用户把最高敏感度的密码交给了一个第三方客户端而不是授权服务器本身。如果这个客户端是不可信的或者客户端代码里有恶意的日志埋点用户的密码等于直接拱手送出。我见过很多公司做“单点登录”系统时偷懒把密码模式给多个内部系统共用结果一个系统的运维日志泄露所有账号都跟着遭殃。所以我的态度很明确密码模式仅限自家公司第一方应用而且最好还是登录页挂在授权服务器自己身上客户端只是转发参数。如果合作方是外部公司坚决不给密码模式让他们走授权码模式。安全这件事不能图省事。3.3 客户端凭证模式没有用户概念的服务间通信客户端凭证模式Client Credentials Grant是所有模式里唯一不涉及用户的客户端直接用自己的身份client_idclient_secret去换令牌。它适用于服务端到服务端的调用场景比如定时任务、批处理、微服务之间的内部API调用。请求依然是POST到令牌端点POST /token grant_typeclient_credentialsclient_idxxxclient_secretyyy授权服务器验证客户端身份后返回令牌。因为没有人参与所以不需要“用户同意授权”这一步也没有refresh_token的说法——令牌过期了重新用凭证换一个就行。我在实际项目里一般用它做内部服务间鉴权比如一个数据统计服务需要定时从一个业务系统拉数据。有些团队会纠结client_secret放在配置文件里被泄露怎么办这是另一个话题你的基础设施安全手段比如密钥管理服务、环境变量、敏感配置加密才是真正兜底的。客户端凭证模式本身只解决“这个服务有没有资格调用”不解决“密钥怎么藏”的问题。3.4 四种模式对比与选型决策表我整理了一张表方便你做决定时直接对照授权模式适用场景客户端类型是否有人参与获取令牌位置安全等级授权码模式传统Web应用、移动App、SPA配合PKCE保密型/公开型均可是后端通道换取高简化模式纯前端SPA历史遗留不推荐新用公开型是URL片段直接返回低已淘汰密码模式第一方应用、授权服务器与客户端同属一方保密型是用户直接给密码后端通道换取中仅限信任场景客户端凭证模式服务间通信、API后台任务保密型否无用户概念后端通道换取中高选型的时候第一判断条件永远是“客户端能不能安全保存密钥”。能保密且有人交互授权码模式一辈子不会错没人交互且机器对机器客户端凭证模式有人交互但客户端不能保密授权码 PKCE如果对方非要用户名密码直接换令牌先问一句“你们是第一方应用吗”不是就拒绝。4. 安全实践与Spring Security 6落地细节4.1 令牌存储与刷新令牌的处理理解令牌怎么存是实战中绕不开的问题。访问令牌access_token有效期短建议在客户端内存里保存别扔到localStorage或者sessionStorage。因为只要JavaScript能读到的位置一个XSS漏洞就能把令牌抄走。SPA里常见做法是放内存变量刷新页面后靠refresh_token换新的访问令牌重新“续命”。刷新令牌refresh_token的安全级别比访问令牌更高因为它能无限换新的访问令牌所以必须保存在更安全的地方比如HttpOnly Cookie或者后端的会话存储中。我见过一个项目把刷新令牌也放在localStorage里结果一次XSS攻击把整个账号体系击穿教训非常惨痛。还有一点刷新令牌要支持吊销revoke。用户改密码、注销登录、账号被风控都要能从授权服务器端把刷新令牌作废。只注销前端会话不吊销令牌等于门锁换了但是钥匙还在外面飘着。4.2 Scope权限粒度设计scope是OAuth2里最容易糊弄过去的部分。很多团队统一发一个all权限所有客户端要什么给什么。这么做短期内省事后面会非常痛苦一旦有一方令牌泄露攻击者的操作范围是整个系统。我在生产环境里见过最好用的scope设计是“资源动作式”的比如user.read、user.write、order.read。授权服务器在颁发令牌时把scope写进令牌声明里资源服务器拿到令牌后校验scope是否包含当前请求所需的最小权限。这样哪怕某个第三方客户端被攻击攻击者最多只能读取用户资料动不了订单数据。授权服务器实现scope校验时还可以启动“动态scope”机制客户端申请user.read和order.read但用户可以在同意页上只勾选“允许读取用户资料”这样最终令牌只下发user.read。这种“用户可勾选授权范围”的设计不仅安全也让你的授权流程看起来更专业。4.3 Spring Security 6授权服务器搭建关键点如果你用Spring生态目前标准的OAuth2授权服务器实现是Spring Authorization Server它是从Spring Security OAuth项目中迁移出来的独立项目。在Spring Security 6的体系里授权服务器的配置方式已经和以前完全不同。我举个核心配置片段这里用Java配置两个关键端点Bean Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .tokenEndpoint(tokenEndpoint - tokenEndpoint .accessTokenRequestConverter(new DelegatingAuthenticationConverter(...)) .errorResponseHandler((request, response, exception) - { ... }) ); return http.build(); } Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient webClient RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(web-client) .clientSecret({noop}web-client-secret) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri(http://localhost:8080/login/oauth2/code/web-client) .scope(user.read) .scope(order.read) .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(30)) .refreshTokenTimeToLive(Duration.ofDays(7)) .build()) .build(); return new InMemoryRegisteredClientRepository(webClient); }这里几个关键选择背后的逻辑很容易被忽略我解释一下{noop}前缀表示明文密码存储仅用于开发环境。生产环境必须换用{bcrypt}或者自定义的DelegatingPasswordEncoder否则等于把client_secret裸奔在数据库里。redirectUri一定要和实际回调地址完全一致拼写差一个字符都会报invalid_redirect_uri。accessTokenTimeToLive和refreshTokenTimeToLive设定要结合业务场景访问令牌建议30分钟到1小时刷新令牌可以长一点但7天以上就要考虑用户是否接受频繁重新登录。Spring Authorization Server默认生成的令牌是自包含的JWT格式它会在JWT声明里包含scope和client_id等信息。资源服务器可以直接通过spring-boot-starter-oauth2-resource-server来校验JWT签名不需要每次调用授权服务器验证令牌。JWT格式用起来方便但要注意令牌一旦签发在有效期内被篡改是不可能的但如果你想让某个客户端立刻失效只能缩短有效期或者维护一个黑名单。这个取舍要根据实际安全等级来。细节上还要注意Spring Boot 3对应Spring Security 6配置类名和旧版spring-security-oauth2-autoconfigure完全不同。如果你网上搜到老的EnableAuthorizationServer注解配置那基本是旧项目方案在Spring Security 6里已经移除了。没必要和自己过不去直接用新写法。4.4 写在业务系统里的安全清单不管授权服务器用什么实现业务系统接入OAuth2时这几条安全基线建议你直接抄进团队规范里禁止把access_token放入URL查询参数只能放请求头Authorization: Bearer xxx。禁止在前端代码里出现client_secret前端只能用client_id。授权码和重置密码的链接一样只允许一次性使用有效期内多传一次直接拒绝。令牌刷新不成反被记住刷新令牌被重放时建议让授权服务器把整个刷新令牌家族全部吊销防止泄露后被人持续续期。审计日志里不能记录明文令牌也不能记录用户密码。只记时间戳、IP、client_id、操作类型。这些清单看起来都是常识但生产事故往往就是常识没做到位。我接触过的风险案例里十有八九是因为某处图省事把顺序颠倒了。5. 常见问题与排查技巧实录5.1 高频问题速查表我把这几年实际调试OAuth2时踩过、也帮别人排查过的问题整理成了一张表现象最可能的原因处理方法授权页面跳转时报invalid_redirect_uri回调地址没在授权服务器注册或参数里有大小写/末尾斜杠不一致核对注册表和请求里的redirect_uri逐字符比对/token返回invalid_grant授权码过期、已使用过、redirect_uri不匹配确认授权码是否只用一次换令牌请求里的redirect_uri要和授权请求一致state校验失败前端生成state后key名搞错或者前后两次请求的state不一致检查state存储和取出逻辑用UUID防碰撞拿到令牌后调API返回401令牌过期、令牌类型不是Bearer、scope不足先看WWW-Authenticate响应头提示再查令牌声明里有没有你要的scoperefresh_token突然失效令牌轮换策略触发旧的刷新令牌作废授权服务器重启且内存存储没持久化客户端及时保存新refresh_token生产环境刷新令牌要存数据库或Redis配置了Spring Authorization Server但访问/oauth2/authorize白屏没配置登录认证授权服务器也需要用户登录先加上formLogin()配置再访问授权端点JWT验签时报签名不匹配授权服务器和资源服务器的密钥/issuer配置不一致统一JWT签发者issuer确认资源服务器用同一个JWK Set URI5.2 排障思路从日志到协议层排查OAuth2问题我最常用的套路是三层递进。第一层先看授权服务器的访问日志确认请求是不是真的到达了。很多问题卡在Nginx或网关层比如回调地址被拦截、请求头里的Authorization被网关剥掉了这些在授权服务器日志里压根看不到任何痕迹。第二层用浏览器的DevTools看整个授权跳转流程。重点观察几次302重定向的地址看code和state是否在预期位置redirect_uri有没有被浏览器加上奇怪的默认端口。SPA场景下还要注意跨域如果你用axios去换令牌/token端点通常要求Access-Control-Allow-Origin没配好CORS浏览器控制台会很明显报错但很多人愣是没往跨域方向想。第三层用抓包工具直接看协议层的报文。如果授权服务器日志正常、浏览器也正常但后端换令牌失败大概率是请求报文和授权服务器预期不一致。抓包看两点一是Content-Type是不是application/x-www-form-urlencoded二是参数名是否拼错。grant_type拼错、code重名冲突这类低级问题抓包一眼能看到。5.3 一些实际经验教训最后分享几个我个人的实操体会不一定在文档里找得到。第一个是医院走流程式的排错耗时最快的是把授权服务器和客户端的日志时间戳对齐然后按“请求-响应”的颗粒度把整个链路的报文串一遍。做OAuth2排查不能凭感觉每一步的参数和重定向都要落到日志里。很多诡异的“偶发失败”其实就是某个令牌在多台授权服务器实例之间不一致导致的这时候要检查是redis存储还是内存存储、有没有做多实例共享。第二个是对接第三方授权平台时多花点时间看他们的文档。平台的scope命名不规范很常见有的叫profile有的叫user:info真实权限含义可能完全不一样。别信对方口头承诺一切以文档为准并且接完以后实际调用一个资源API验证scope是否限制到位。第三个是OAuth2虽然叫协议但不同厂家的实现细节千差万别。你自己实现授权服务器时RFC 6749的细节不要凭印象设计所有默认行为最终都要回到“授权码必须一次有效、redirect_uri必须精确匹配、令牌发送必须走TLS”这三条铁律上。守住这三条不管代码怎么写安全下限都不会太低。我在实际项目里最深的体会是OAuth2四种模式本质是“信任模型”的设计题不是在四个选项里做个选择题。想清楚客户端能不能保密、有没有用户参与、资源服务器需要多大授权范围答案自然就浮出水面。选错了模式后面花十倍的补丁都补不回设计上的漏洞。你手头要是正打算搭授权服务从授权码模式起步把PKCE加上scope做小一点守住这几条经验基本不会跑偏。