如果你在项目里接过 GitHub、Google、微信这些平台的第三方登录,很快就会发现一个现象:各家授权页长得完全不一样,跳转参数、回调地址、按钮文案也各有各的脾气,但背后的协议通通叫 OAuth2.0。这篇文章我想把 OAuth2.0 第三方登录这件事讲透,重点不是复读 RFC 6749 里的名词,而是解决几个实际接入时绕不开的问题:授权码模式到底在跑什么链路、为什么回调里给的是 code 不是 token、state 参数是不是摆设、从 Demo 到生产环境到底还要补哪些东西。
适合读这篇文章的,是第一次接第三方登录的前后端同学,也包括已经在做账号体系、想搞明白"为什么别人家的登录流程要绕这么一大圈"的架构师。我尽量不堆概念,用实际能跑通的代码和踩过的坑来说话,你看完可以直接照着自己的项目改。
1. 为什么各家第三方登录长得都不一样:先搞懂OAuth2.0在解决什么问题
1.1 从"把密码交出去"到"只发一张令牌"
很多第一次接触 OAuth2.0 的人会被文档劝退,上来就是四类角色、五种授权模式,看完感觉啥也没记住。其实抛开名词,先回答一个最朴素的问题:一个第三方网站想拿到用户在某平台上的数据,技术上到底有几种做法?
最粗暴也最古老的做法,是让用户把平台账号密码直接交给第三方网站,第三方拿密码去平台上登录,爱读什么读什么。很多早期第三方工具确实是这么干的,但后果很明显:用户不敢交,密码一旦存储不当泄露就是连锁灾难;平台也没法控制第三方到底读了多少数据,用户账号被滥用之后连审计都无从谈起。
OAuth2.0 换了个思路:用户不在第三方网站输入密码,而是被引导到平台自己的授权页上,由平台的服务器验证身份,然后弹出一个明确的确认框——"某某应用请求访问你的这些数据",用户点同意,平台直接就颁发令牌给第三方应用。第三方应用拿着令牌去调平台的用户信息接口,整个过程密码只出现在平台和用户之间,第三方网站上压根没有密码可以泄露。
这也是为什么各家第三方登录的授权页长得五花八门,但流程骨架一模一样:跳转、确认、回调、换 token、拉用户信息。协议定的是骨架,页面长得像什么是平台自己的审美问题。
1.2 一个饭馆结账的比喻,把OAuth2.0的三个角色对号入座
我习惯用一个饭馆结账的比喻来解释 OAuth2.0,跟产品和刚入行的同事聊这个协议时特别好使。
你去一家餐厅吃饭,结账时服务员说可以用某银行的支付授权服务。他不会让你把银行卡密码给他,而是引导你在一个银行页面上登录并确认:"本餐厅申请读取你的会员等级和可用额度"。你点确认后,餐厅拿到一张小额代金券,再凭代金券去银行柜台兑换成真正的现金。整个过程中,你只和银行打过交道,餐厅碰不到你的密码,银行也知道餐厅到底兑走了多少钱。
对应到 OAuth2.0 里,用户是资源拥有者,第三方网站(也就是你自己的应用)是客户端,GitHub、Google 这些平台是授权服务器和资源服务器。授权页就是那个银行确认页面,授权码就是代金券,access_token 就是兑换出来的现金。后续第三方网站每次拿现金去买数据,银行都有流水记录。
这个比喻最值钱的地方,是它天然解释了为什么要有"授权码换 token"这一步。代金券和现金不是一回事,代金券只能在银行柜台当面兑换,现金则可以拿出去花。对应到 Web 场景,授权码只在后端换 token 的一次请求里出现,access_token 则会被多次用来调接口,两者的暴露面和有效期完全不同。
1.3 OAuth2.0不止第三方登录,但我们先聚焦登录场景
严格来说,OAuth2.0 是一个授权框架,用来解决"第三方应用在用户授权的前提下访问受保护资源"的问题,登录只是它最出名的一个应用场景。Google、GitHub 那些开放 API 的授权、企业内部应用的跨系统授权,本质都是同一套流程。
不过实际开发中,90% 的人接触 OAuth2.0 都是为了做第三方登录。所以我下面的内容会围绕登录场景展开,但你可以把这套理解迁移到别的授权场景里:把"获取用户信息接口"替换成"获取某个业务资源接口",流程完全一致。能把第三方登录跑通,你也就掌握了 OAuth2.0 授权码模式的核心链路。
2. 授权码模式拆解:为什么流程里必须有code和state这两样东西
2.1 授权码模式的五步链路
授权码模式(Authorization Code)是 OAuth2.0 里最经典、最安全、也是第三方登录用得最多的一种模式。整个链路分成五步:
- 用户在你的应用里点击"使用 GitHub 登录",你的后端生成一个带随机 state 的跳转链接,把浏览器 302 到 GitHub 的授权页,链接里带上 client_id、redirect_uri、scope 和 state。
- 用户在 GitHub 授权页上登录(如果已经登录则直接进入确认页),确认"是否同意该应用访问这些数据"。
- GitHub 把浏览器重定向回你提前登记过的 redirect_uri,地址栏里带着 code 和 state。
- 你的后端拿到 code 之后,再发起一次服务器到服务器的请求,拿 code 加上 client_secret 去 GitHub 的 token 端点换 access_token。这一步浏览器完全不参与。
- 你的后端接着用 access_token 调 GitHub 的用户信息接口,拿到头像、昵称、邮箱等数据,在自己的数据库里完成登录或注册,然后创建自己的会话。
很多人第一次看这个流程会觉得绕:为什么不能在第 3 步直接回调 access_token?答案在第 2.2 节。
2.2 为什么回调给的是code而不是access_token
浏览器重定向的参数是直接暴露在 URL 里的,会进浏览器历史、代理服务器日志、Referer 头、各种第三方统计脚本。如果把 access_token 放在这里回传,等于把这个长期有效的凭证撒得到处都是,任何一个中间环节捡到都能拿去调用户接口。
授权码存在的意义,就是把这个"高风险的长命凭证"换成"短命的、只出现一次的临时凭证"。code 本身啥也干不了,它必须配合 client_secret 在后端发起一次请求才能换到 token。浏览器这个不可信环境里就算拿到了 code,没有 client_secret 也换不了 token,风险面一下小了很多。
| 对比维度 | 直接在回调URL里返回access_token | 先返回code再后端换token |
|---|---|---|
| 凭证有效期 | 通常较长,可反复使用 | 短,且只能兑换一次 |
| 泄露面 | 浏览器历史、日志、Referer均可能记录 | 只有后端一次请求涉及,且需要secret |
| 可撤销性 | 泄露后必须手动吊销或等过期 | 兑换失败即可阻断 |
| 对客户端的保密要求 | token在任何环境都能用 | secret只在服务器端持有 |
这也是为什么授权码模式比隐式模式(Implicit)更推荐在生产环境使用。隐式模式当年为了简单,直接在回调给 token,现在官方也不鼓励了,尤其移动端和单页应用,基本都要求走授权码加 PKCE。
2.3 state参数到底在防什么
state 是授权请求里一个很容易被新手当成"随便填个值"的参数,但它在防护一类叫 CSRF(跨站请求伪造)的攻击上起着关键作用。
想象一个场景:攻击者自己先走了一遍 GitHub 授权,拿到了一个合法的 code,然后构造一个链接诱导受害者点击,受害者浏览器带着这个 code 访问你的回调地址。如果没有 state,你的后端收到 code 后会正常去换 token,但换来的用户信息是攻击者的账号,你后端就这么稀里糊涂地把一个攻击者账号登录成了受害者的会话。如果受害者之前在你这儿绑定过 GitHub 账号,攻击者账号就绑上了,后续登录态直接乱套。
有了 state,你的后端在发起授权跳转之前会生成一个随机字符串,存进自己的会话里,同时把它拼到授权链接上。用户从授权页跳回来时,回调参数里的 state 必须和你存的那个值完全一致,不一致就直接拒绝。这样攻击者构造的回调链接里带的 state 是他自己发起的,跟你存的值对不上,整条攻击链路就被断掉了。
state 还有一个容易被忽略的作用:你可以在 state 里编码一些授权上下文,比如用户发起登录时所在的页面、设备信息、或者本次授权意图,减少后续额外查询。当然,前提是 state 本身要足够随机,别用固定字符串。
2.4 移动端/SPA为什么一定要加PKCE
如果你做的是移动端 App 或者纯前端单页应用,会发现没有安全的地方能保存 client_secret——App 里反编译能翻出来,浏览器里塞进前端代码等于公开。这种情况下还硬走授权码模式,第 4 步里"code 加 secret 换 token"这个动作就变成"code 不加 secret 也能换 token",安全性大打折扣。
PKCE(RFC 7636)就是为这个场景设计的。客户端在发起授权前自己生成一个随机的 code_verifier(一串长随机字符串),同时算出它的变换值 code_challenge,跟着授权请求一起发给平台。用户授权跳回来之后,客户端拿 code 和原始 code_verifier 去换 token,平台校验 code_verifier 变换后是否等于之前收到的 code_challenge。
这相当于给 code 加了一把只有客户端自己知道的钥匙。就算 code 被中途截获,没有 code_verifier 也换不了 token。现在主流平台基本上都支持 PKCE,新项目里就算你是传统后端渲染的服务端应用,我也建议顺手把 PKCE 加上,防御纵深没坏处。
3. 快速跑通一个可用Demo:以GitHub OAuth App为例
3.1 在GitHub后台创建OAuth App的完整配置
理论讲再多,不如亲手把链路跑通。GitHub 的 OAuth 接入体验在同类平台里算很友好的,而且开发者文档写得很清楚,我建议所有人都先用它做第一次实操。
创建入口:GitHub 右上角头像 → Settings → Developer settings → OAuth Apps → New OAuth App。
需要填的东西主要这几个:
- Application name:应用名字,用户授权页上会看到。
- Homepage URL:应用主页地址,本地开发可以先填
http://localhost:3000,GitHub 只要求它是一个合法 URL。 - Authorization callback URL:这是最关键的一项,GitHub 会把授权后的跳转转到这里。本地开发填
http://localhost:3000/auth/callback,注意端口必须带,否则回调匹配不上。
创建成功后,页面会显示 Client ID,以及一个需要自己点开查看的 Client Secret。Client Secret 只显示一次,忘了就重新生成。这两个值存进后端环境变量,别写死在代码里,也别提交到 Git 仓库。
3.2 最小可运行的后端代码:发起跳转、回调换token、拉取用户信息
我用 Node.js + Express 写一个最小可跑的版本,流程和注释直接照着看。Node 18 以上自带 fetch,就不额外引 axios 了。
// 依赖:npm install express cookie-parser // 环境变量:GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET const express = require('express'); const cookieParser = require('cookie-parser'); const crypto = require('crypto'); const app = express(); app.use(cookieParser()); const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; const GITHUB_API_URL = 'https://api.github.com/user'; const CALLBACK_URL = 'http://localhost:3000/auth/callback'; // 第一步:用户点击"使用GitHub登录",后端生成跳转链接 app.get('/auth/github', (req, res) => { const state = crypto.randomBytes(16).toString('hex'); // state存到httpOnly cookie里,后面回调时比对 res.cookie('oauth_state', state, { httpOnly: true, sameSite: 'lax', maxAge: 10 * 60 * 1000, }); const params = new URLSearchParams({ client_id: process.env.GITHUB_CLIENT_ID, redirect_uri: CALLBACK_URL, scope: 'read:user user:email', state: state, }); res.redirect(`${GITHUB_AUTHORIZE_URL}?${params.toString()}`); }); // 第三步:GitHub授权后跳转回来,code在query里 app.get('/auth/callback', async (req, res) => { const { code, state } = req.query; // 先校验state,不一致就拒绝,防CSRF if (!code || !state || state !== req.cookies.oauth_state) { return res.status(400).send('state不匹配或code缺失,授权失败'); } // 第四步:后端拿code换token const tokenRes = await fetch(GITHUB_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', // 这个头很关键,不加会返回URL编码格式 }, body: JSON.stringify({ client_id: process.env.GITHUB_CLIENT_ID, client_secret: process.env.GITHUB_CLIENT_SECRET, code: code, }), }); const tokenData = await tokenRes.json(); const accessToken = tokenData.access_token; if (!accessToken) { return res.status(400).send(`换token失败: ${JSON.stringify(tokenData)}`); } // 第五步:拿token拉GitHub用户信息 const userRes = await fetch(GITHUB_API_URL, { headers: { Authorization: `Bearer ${accessToken}`, 'User-Agent': 'oauth-demo', }, }); const githubUser = await userRes.json(); console.log('GitHub用户数据:', githubUser); // 到这里,你应该在数据库里查一下githubUser.id是否存在 // 不存在就创建用户,存在就直接登录 // 然后签发自己的session或JWT给前端,access_token不返回给浏览器 res.json({ login: githubUser.login, id: githubUser.id, email: githubUser.email, avatar_url: githubUser.avatar_url, }); }); app.listen(3000, () => console.log('demo running at http://localhost:3000'));这段代码去掉错误处理和用户落库,只为展示链路,实际项目里 code 换 token 的请求还要加超时、重试和日志。但核心动作都在了:发起跳转、回调校验 state、后端换 token、拿 token 调用户接口。
3.3 跑通后怎么自测,以及首次接入最容易漏掉的JSON请求头
跑起来后在浏览器访问http://localhost:3000/auth/github,会先跳转到 GitHub。登录后在授权页上看到你的应用名,点确认,浏览器带着 code 跳回http://localhost:3000/auth/callback,控制台应该打印出用户数据。
我第一次接 GitHub 时栽过一个特别细的跟头:换 token 的接口如果不带Accept: application/json,GitHub 返回的不是 JSON,而是access_token=xxx&scope=xxx&token_type=bearer这种 URL 编码格式。我当时用res.json()解析,结果拿到一个字符串,差点以为接口坏了。后来查文档才看到,这个接口默认返回 form 格式,必须用Accept: application/json才给你 JSON。这个细节你接其他平台时也留意一下,每个平台的默认返回格式不一样,文档里通常写得很隐蔽。
跑通后的自测可以多试几个路径:重复点击授权页面上的"同意"然后跳回,看后端日志里是否每次都拿到新 code;直接在浏览器里篡改 state 再访问回调,看后端是否正常拒绝;拿同一个 code 换两次 token,GitHub 会报 token 已被使用。这几条都验证过了,你对授权码模式的理解才算真正落地。
4. 接入第三方登录真正踩过的坑:从现象到根因的完整排查链路
4.1 redirect_uri不匹配:肉眼逐字符比对
第三方登录报错里出现频率最高的一类,就是回调地址不匹配。GitHub 的报错会直接说redirect_uri mismatch,但你光看报错看不出哪里不对,得自己拿着浏览器地址栏里实际跳转的 URL,和后台登记的 Authorization callback URL 做一个字符级的对比。
最容易出问题的点集中在三处:一是协议不一致,后台填了http://localhost:3000/auth/callback,但你的应用强制跳转到https://,直接不匹配;二是端口被吞了,很多人后台填了http://localhost:3000/auth/callback,实际跳转却少了:3000,这类情况通常发生在你用了反向代理或负载均衡透传的时候;三是回调地址里带了查询参数。
第三点特别坑。GitHub 这类平台要求授权回调 URL 必须精确匹配,如果你实际回调地址是http://localhost:3000/auth/callback?from=profile,后台登记时就必须把这个带 query 的完整地址填进去,而不是只填路径。我见过同事在这个问题上排查了一下午,一直以为是代码问题,最后发现是后台配置漏了查询串。
排查这类问题,我建议第一件事先打印回调接口收到的完整 URL,拿它跟后台配置逐字符比对。别肉眼比对,用文本对比工具或者就直接复制到编辑器里做 diff,协议、域名、端口、路径、查询参数五位一体对齐,问题一眼就出来了。
4.2 state过期或丢失:Cookie方案怎么选
state 相关的坑排在第二位,现象是用户授权完跳回你的回调页,后端却报 state 不匹配。排查链路通常是这样:先看浏览器 cookie 里到底有没有 oauth_state,再看回调 URL 里的 state 和 cookie 里的值是否对得上,然后发现对不上甚至根本没值。
几个高频原因。第一,用户点登录后没有马上跳转,而是先在授权页停留了很久,你给 state 设置的 cookie 过期了;GitHub 授权页一般不会停留太久,但总有人开了页面去干别的,回来再确认。第二,多标签页同时登录,两个标签页各自生成了新的 state cookie,后者覆盖前者,前面那个标签页跳回来时 state 就全乱了。第三,部分隐私模式下第三方 cookie 被浏览器拦掉,本地用 127.0.0.1 和 localhost 混用也会导致存储上下文的区分。
我的建议是后端把 state 存服务端,比如 Redis 或内存里,以 state 本身为 key,value 放生成时间、用户会话标识、过期时间,而不是只靠 cookie。发起授权时把 state 写进 cookie 只是为了回调时定位是哪次授权,真正的校验依据是后端存储。过期时间设 10 分钟足够,过期就让用户重新发起登录,千万不要因为嫌麻烦就放松 state 校验。state 这层防线平时看起来没用,真被撞上就是账号绑定类漏洞,值得认真对待。
4.3 换token接口的响应格式坑:GitHub的Accept头
这个坑我在 3.3 里提过,但因为它太容易踩,值得放进排查清单里说完整一点。现象是你把 code 发到 GitHub 的 token 端点,res.json()直接报错,或者打印出来是一串key=value&key=value的字符串,而不是预期的 JSON。
排查思路不是先怀疑自己的请求体,而是先看响应头的Content-Type。GitHub 默认返回application/x-www-form-urlencoded,只有请求头里带Accept: application/json才返回 JSON。这个问题踩过一次之后,我对所有平台的 token 接口都会先看文档里对 Accept 和 Content-Type 的要求,因为不同平台默认行为真的不一样。
防止这类"接口能通但格式不对"的问题,还有个习惯:开发阶段把每次调平台接口的请求和响应原始报文都打一遍日志,包括 headers 和 body。这样格式问题根本不用猜,日志里一眼就能看到响应头写的什么 Content-Type。
4.4 401、403、限流:三种报错的定位思路
接入第三方登录后,调用户信息接口时最常遇到三类报错:401、403 和限流。它们看起来都是"拿不到数据",但处理方式完全不一样。
401 基本是 token 本身的问题。常见原因是 Bearer 前缀没写对或 token 复制时发生了截断,另一个可能原因是这个 token 对应的授权已经被用户撤销了,或者平台检测到异常自动吊销了。排查时就先确认 Authorization 头的完整值,再确认用户在那个平台侧的授权状态,不要一上来就怀疑代码。
403 的语义更复杂一些,在 GitHub 上最常见的是 scope 不足,比如你只申请了read:user却去调用需要user:email权限的接口,平台会返回类似Requires authentication或明确列出 missing scope 的提示。这时候要回头核对授权请求里到底申请了哪些 scope,以及已授权应用的那次授权是不是发生在 scope 调整之前。GitHub 有个特点:已授权过的用户,如果你在应用后台改了 scope,用户不会自动获得新权限,必须重新走一次授权流程。
限流则会返回 403 但响应体里会提示你 rate limit,GitHub 的响应头里有X-RateLimit-Remaining,你请求一多就能看到这个值在往下掉。这个问题在本地调试时几乎不会遇上,但一旦上线,如果每次请求都拿长期有效的 token 去调平台接口,很容易打到限流阈值。所以生产环境要做缓存,用户信息这种更新不频繁的数据,给个 5 到 10 分钟的缓存完全合理。
我把以上三个方向的排查思路整理成一张表,方便你遇到报错时快速定位方向:
| 报错现象 | 可能原因 | 排查重点 |
|---|---|---|
| 401 Unauthorized | token无效、已撤销、格式错误 | 检查Authorization头、平台侧授权状态 |
| 403 Forbidden | scope不足、被平台拒绝 | 对照授权申请的scope与接口权限要求 |
| 403 + rate limit提示 | 接口调用频率超限 | 看响应头X-RateLimit-Remaining,加缓存 |
| redirect_uri mismatch | 回调地址配置不一致 | 将完整回调URL与后台配置逐字符diff |
5. 从Demo到生产:Token存储、会话设计与账号体系的工程化
5.1 平台token别落地前端,自己体系里的session才是主角
Demo 里我直接把用户信息res.json()还给了浏览器,甚至没有展示 access_token 怎么处理,这是故意的。生产环境里最重要的一条边界就是:平台发给你的 access_token 属于后端凭证,应该留在后端,绝对不能下发到浏览器或 App 端。
原因有两个。第一,access_token 是调平台用户信息接口的钥匙,前端拿到了就意味着任何能访问前端的代码都能代用户去调平台接口,包括拿到用户的邮箱、仓库列表这些敏感数据。第二,token 一旦落到前端,你就基本失去了管理和撤销它的能力,用户改了密码、撤销了授权、或者你发现 token 泄露,都只能被动等过期。
正确做法是:后端用平台返回的用户唯一 ID 去自己数据库里查或建用户,随后签发你自己体系内的会话凭证,也就是一个你自己控制的 session 或 JWT。之后前端所有请求只带这个自有会话凭证,后端按需再拿平台 token 去更新用户资料。你在 GitHub 授权那一步获得的 access_token,正常情况下除了拉第一次用户信息,后续不应该再频繁出现在调用链里。
5.2 access_token、refresh_token与没有refresh_token的平台怎么处理
不同平台对 access_token 生命周期的定义差异非常大。Google、微信这类平台给的是短期 token,一般一两个小时就过期,同时提供一个 refresh_token,后端可以用 refresh_token 静默换取新的 access_token,不需要用户重新点授权。GitHub 是另一个极端,access_token 默认长期有效,没有 refresh_token,除非用户主动撤销,否则能一直用。
没有 refresh_token 的平台,实现上更简单,但麻烦在于"用户撤销授权"这个动作。只要用户在 GitHub 设置里移除了对你的授权,你的 token 就立刻失效,下次拿它调接口会返回 401。这时候你只能引导用户重新走一遍授权流程。为了避免反复打扰用户,应用要对 token 失效做兜底:调用户信息接口遇到明确的 401 时,跳回授权页并判断用户此前是否绑定过该平台账号,绑定过就静默续上授权,没绑定过再展示登录页。
有 refresh_token 的平台,要注意几个细节。refresh_token 本身也是敏感凭证,存储时建议加密,不要明文落库;刷新 token 的请求也要做失败处理,最常见的失败原因是用户撤销授权或 token 被刷新过太多次导致旧的 refresh_token 失效。刷新接口的报错信息通常比较抽象,最好把平台返回的原始错误和 HTTP 状态码一起记进日志,后面排查才知道是撤销、过期还是频率限制。
5.3 别拿邮箱当用户唯一ID
这个是我特别想提醒的一点,因为几乎所有第一次做第三方登录的人都会踩。用户信息接口里最显眼的字段往往是邮箱,很多人图省事,直接把邮箱作为用户表的主键或唯一键去建账号,看起来没毛病,后面会出大问题。
邮箱不适合当唯一标识的第一个原因是它不稳定。用户绑定的邮箱可能变更,平台返回的邮箱也可能和用户实际使用的邮箱不一致(比如 GitHub 允许隐藏真实邮箱),一旦邮箱变了,你的用户身份就断了。第二个原因是邮箱并非所有平台的唯一字段,两个不同的第三方账号可能返回相同邮箱,更极端的情况是用户在 A 平台用a@example.com注册,在 B 平台也用a@example.com注册,你无法判断这是同一个人还是两个不同的人,合并也难,关联也难。
正确做法是用"平台类型 + 平台用户ID"作为第三方账号的唯一键,比如github:123456和google:987654是两个独立的登录凭证,它们在你这边的业务用户表里可以关联到同一个自然人或不同用户。业务用户表用自增ID或 UUID 做主键,第三方账号绑定表单独存 provider、provider_user_id、access_token、refresh_token 等字段,这样多平台绑定、解绑、换绑都变得清晰可控。邮箱只当作普通资料字段,最多用来做登录后的资料回填或找回密码辅助,不要承载身份标识的职责。
5.4 生产环境补丁:HTTPS、最小scope、审计日志与多端登录
从 Demo 到上线,安全相关的补丁其实是同一套东西,我按优先级列一下,都是被生产环境事故教育出来的。
第一,全链路强制 HTTPS。授权回调涉及 code、state、cookie 这些敏感数据,明文传输等于把凭证拱手送人。本地开发可以用 http,上线必须全站 HTTPS,并且在 cookie 上设置secure和sameSite相关属性。
第二,scope 坚持最小化。GitHub 登录只需要基本资料,就只申请read:user;要拿邮箱确认用户身份,再单独申请user:email。不要图省事申请超出当前功能的权限,授权页上多列一个敏感权限,用户拒绝率就高一分,你的安全责任也大一分。
第三,登录日志必须做。记录用户 ID、来源平台、登录时间、IP、User-Agent、授权方式,这些数据平时没人看,一旦发生账号盗用或公关争议,是你唯一能依赖的审计线索。日志记得只加不改,存至少 90 天。
第四,多端登录和踢人逻辑要提前设计。用户在你的应用里登录后,平台 token 是你后端持有的,原本不存在"前端登出"这个动作。用户退出登录,你只需要清掉自己的 session,要不要同时撤销平台 token 取决于产品需求:如果想做"退出所有设备",就需要在用户中心里维护每个会话绑定的 token,退出时逐个吊销,这涉及你把 token 和相关用户标识关联存储,属于账号体系的一部分,而不是第三方登录的附属功能。
第五,所有对平台接口的请求都加超时和失败兜底。GitHub 接口偶尔会慢,更会限流,你的登录接口不能因为平台接口超时就一直转圈。给外部请求设 5 秒超时,失败时返回友好提示,并在日志里记清楚是哪一步失败的,能在半夜被叫起来排查时省一半时间。
最后分享一个我自己的习惯。我会在项目里维护一张第三方登录配置表,记录每个平台的 authorizeEndpoint、tokenEndpoint、userInfoEndpoint、默认 scope、回调 URL 拼装方式、是否支持 refresh_token、token 默认过期策略。新平台要接入的时候,不是重新翻文档,而是照着配置表补一行,然后做联调和测试。把协议骨架固定下来之后,OAuth2.0 对你来说就不是一堆名词,而是一套可以反复使用的流程了。