OAuth SSO 登录完整技术指南(Popup + Redirect)
本文基于 hyperhuman-frontend 项目的 SSO 实现,完整梳理 OAuth 2.0 授权码模式(Authorization Code Flow)在
Redirect与Popup两种常用模式下的前后端协作机制、落地步骤、对比与避坑。
目录
- OAuth 2.0 基础概念
- 两种前端登录模式
- Redirect 模式完整流程(前端 code 换 token)
- Popup 模式完整流程(后端直接回传 token)
- 后端缓存了什么
- 代码结构与职责划分
- 本项目的实际代码走读
- 常见问题与陷阱
- 本地开发配置清单
- 安全注意事项
1. OAuth 2.0 基础概念
1.1 角色定义
| 角色 | 英文 | 本项目对应 |
|---|---|---|
| 资源所有者 | Resource Owner | 用户本人 |
| 客户端 | Client | hyperhuman 前端 + 后端 |
| 授权服务器 | Authorization Server | Google / GitHub |
| 资源服务器 | Resource Server | Google 用户信息 API |
1.2 授权码模式(Authorization Code Flow)
这是 Web 应用最常用、最安全的 OAuth 模式,核心特点是:
- 前端从不接触
access_token,token 的获取完全在后端服务器之间完成 - 前端只拿到一个一次性的
code,后端用这个code向授权服务器换取真正的 token - 即使
code在 URL 里被截获,攻击者也无法使用(需要client_secret,只有后端有)
用户浏览器 你的后端 Google 授权服务器
│ │ │
├─── 点击登录 ─────────────►│ │
│ ├── 生成 state ───────────►│
│◄── 302 重定向 ──────────── │ (到 Google 授权页) │
│ │ │
│──── 用户同意授权 ──────────────────────────────────►│
│◄──────────────────────────────── code + state ─────│
│ │ │
│── 带 code 回调后端 ──────►│ │
│ ├── code + client_secret ►│
│ │◄── access_token ─────────│
│ ├── 查询用户信息 ───────────►│
│ │◄── email, id... ─────────│
│ ├── 创建/查找用户 │
│ ├── 生成 session_token │
│◄── session_token ────────│ │关键点:client_secret 永远不离开后端服务器,这是整个模式安全性的基础。
2. 两种前端登录模式
2.1 整页跳转(Redirect Mode)
用户点击登录
→ window.location.href = 后端 /api/auth/oauth/google/login
→ 整个页面离开,跳到 Google 授权页
→ 用户授权
→ 页面跳回 /login?oauth=google&code=xxx
→ 前端 OAuthHandler 组件读取 URL 参数
→ 调用 authorizeExternal API(前端主动用 code 换 session_token)
→ 存 token,完成登录适用场景:移动端(移动端浏览器经常拦截 popup)
authorizeExternal 在这里被调用。
2.2 弹窗(Popup Mode)——本项目默认模式
用户点击登录
→ window.open 打开小窗口
→ 用户在小窗口里完成 Google 授权
→ 后端完成 code 换 token 的全部工作
→ 后端把 session_token 塞进 URL,redirect 小窗口到 callback 页面
→ callback 页面通过 postMessage 把 token 发给主窗口
→ 主窗口存 token,完成登录
→ 小窗口关闭适用场景:PC 端,用户体验更好(不离开当前页面)
authorizeExternal 在这里完全不被调用。 token 由后端直接通过 redirect URL 参数传回。
2.3 两种模式的核心区别
| 对比项 | Redirect 模式 | Popup 模式 |
|---|---|---|
| 用户体验 | 页面会跳走再回来 | 始终留在原页面 |
| token 获取方 | 前端调 API 拿 | 后端 redirect URL 里的参数 |
authorizeExternal | 被调用 | 不被调用 |
| callback 处理组件 | OAuthHandler 路由组件 | oauth-popup-callback.html |
| 移动端兼容性 | 好 | 差(popup 常被拦截) |
| 实现复杂度 | 稍低 | 稍高(需要 postMessage 通信) |
3. Redirect 模式完整流程(前端 code 换 token)
这一节专门讲你提到的“前端拿到 code 后调用后端接口换平台 session_token”。
Step 1:前端发起整页跳转
// login 页点击 Google 登录
window.location.href = `${API_BASE}/auth/oauth/google/login?redirect_uri=${encodeURIComponent(
`${window.location.origin}/login/oauth/callback`
)}`;特点:当前页面会离开,用户先去授权站点,授权结束后再回到你的前端 callback 路由。
Step 2:后端 /login 生成 state 并 302 到 Google
后端和 Popup 模式一样,仍然要做:
- 生成一次性
state(防 CSRF) - 缓存
state -> redirect_uri(Redis,短 TTL) - 302 到 Google 授权地址(
response_type=code)
Step 3:Google 回调到后端 callback
Google 会带着 code 和 state 回调:
GET /api/auth/oauth/google/callback?code=xxx&state=yyyRedirect 模式下,后端此时不直接生成 session,而是把用户再跳回前端 callback 页面,并把 code/state 原样带回去:
302 -> https://your-frontend.com/login/oauth/callback?provider=google&code=xxx&state=yyyStep 4:前端 callback 页面读取 code 并调用 authorizeExternal
// OAuthHandler / handleSsoCallback
const params = new URLSearchParams(window.location.search);
const provider = params.get('provider');
const code = params.get('code');
const state = params.get('state');
if (!provider || !code || !state) throw new Error('MISSING_OAUTH_PARAMS');
const resp = await authorizeExternal(provider, { code, state });
const { session_token, user_uuid } = resp.data?.data || {};
await authManager.signInWithToken({ session_token, user_uuid });这里的关键是:authorizeExternal 才是“用 code 换你业务 token”的动作。
Step 5:后端 authorizeExternal 校验并换 token
后端 /auth/oauth/{provider}/callback(或等价接口)做的事:
- 校验
state是否存在、是否匹配、是否过期(并一次性删除) - 用
code + client_secret向 Google 换access_token/id_token - 获取三方用户身份(sub/email)
- 查找/创建本地用户
- 生成平台
session_token - JSON 返回给前端:
{ session_token, user_uuid }
Step 6:前端持久化登录态并进入已登录状态
- 存储
session_token(localStorage或 cookie 策略) - 初始化请求头(
Authorization: Bearer ...) - 拉取用户信息、刷新全局状态、跳转业务首页
Redirect 模式最小落地清单
- 前端有 callback 路由组件(例如
OAuthHandler) - callback 里必须处理
provider/code/state/error - 提供
authorizeExternalAPI - 后端必须做一次性
state校验和回收 - 接口需支持失败兜底(无效 code、state 过期、用户取消)
4. Popup 模式完整流程(后端直接回传 token)
Step 1:前端打开 Popup
// sso.js - startOAuthPopup()
const frontendCallbackUrl = `${window.location.origin}/sso/oauth-popup-callback.html`;
const url = `https://api.hyper3d.com/api/auth/oauth/google/login
?redirect_uri=http://localhost:5173/sso/oauth-popup-callback.html`;
const popup = window.open(url, 'sso', 'width=600,height=700,...');
// 同时,主窗口开始监听 postMessage
window.addEventListener('message', onMessage);
// 启动 2 分钟超时计时器redirect_uri 是告诉后端:"你处理完之后,把 popup 窗口 redirect 到这个地址"。
Step 2:后端处理 /login 接口
后端收到请求后做三件事:
1. 生成随机 state 字符串(防 CSRF,如 "f3a9b2c1...")
2. 把上下文存入 Redis(TTL 10 分钟):
key: oauth_state:f3a9b2c1
value: {
redirect_uri: "http://localhost:5173/sso/oauth-popup-callback.html",
referral_code: "xxx"(如果有)
}
3. 构造 Google 授权 URL 并 302 redirect:
https://accounts.google.com/o/oauth2/auth
?client_id=YOUR_CLIENT_ID
&redirect_uri=https://api.hyper3d.com/api/auth/oauth/google/callback
&state=f3a9b2c1
&scope=openid email profile
&response_type=code
&access_type=offline注意:这里 redirect_uri 是后端自己的 callback 地址(Google 配置好的),不是前端的地址。前端地址存在 state 里。
Step 3:用户在 Popup 里完成授权
用户在弹出的 Google 登录页里选账号、点同意。Google 把 popup 重定向到:
https://api.hyper3d.com/api/auth/oauth/google/callback
?code=4/0AcxxxxxxxxxxxxxxX
&state=f3a9b2c1Step 4:后端 Callback 处理(核心)
后端收到 code + state:
1. 验证 state:
- 从 Redis 查 oauth_state:f3a9b2c1
- 不存在 → 拒绝(CSRF 攻击或重放攻击)
- 存在 → 取出 { redirect_uri },并立即删除这条 Redis 记录(一次性)
2. 用 code 换 access_token(服务器间通信,用户不可见):
POST https://oauth2.googleapis.com/token
code=4/0Acxxxxxxxx
client_id=YOUR_CLIENT_ID
client_secret=YOUR_CLIENT_SECRET ← 永远不离开后端
redirect_uri=https://api.hyper3d.com/...
grant_type=authorization_code
返回:{ access_token, id_token, refresh_token }
3. 解析 id_token(JWT),获取用户信息:
{ sub: "google_user_id", email: "user@gmail.com", name: "...", picture: "..." }
4. 数据库操作:
- 根据 google_id 或 email 查找用户
- 不存在 → 创建新用户(这就是"为什么授权后就登录了")
- 存在 → 直接使用
5. 生成平台 session_token:
- 创建一条 session 记录(UUID 或 JWT)
- 存入数据库/Redis,关联 user_id
6. 302 redirect 到 redirect_uri(之前从 Redis 取出来的前端地址):
http://localhost:5173/sso/oauth-popup-callback.html
?session_token=eyJhbGci...
&user_uuid=abc-123这就是问题的答案:
用户在 Google 授权页点击"同意"之后,后端就拿到了 Google 颁发的
code,用这个code向 Google 换取了用户的身份信息(email、Google ID),并在自己的数据库里完成了账号的查找或创建。session_token是后端生成的登录凭证,代表"这个用户已经在我们平台登录了"。前端拿到这个 token 后,不需要再做任何"登录"操作——登录事实已经在后端确立。
Step 5:Popup Callback 页面执行
<!-- /sso/oauth-popup-callback.html -->
<script>
(function() {
var params = new URLSearchParams(window.location.search);
var error = params.get('error');
if (error) {
window.opener.postMessage(
{ type: 'oauth-error', error: error },
window.location.origin
);
window.close();
return;
}
var sessionToken = params.get('session_token') || params.get('token');
if (!sessionToken) {
window.opener.postMessage(
{ type: 'oauth-error', error: 'MISSING_SESSION_TOKEN' },
window.location.origin
);
window.close();
return;
}
// 通过 postMessage 把 token 发给主窗口
window.opener.postMessage(
{
type: 'oauth-success',
session_token: sessionToken,
user: Object.fromEntries(params) // 附带所有参数
},
window.location.origin // targetOrigin:只发给同源的 opener
);
window.close();
})();
</script>为什么用 postMessage 而不是直接读 localStorage?
postMessage 是浏览器提供的跨窗口安全通信 API。虽然两个窗口同源,用 localStorage 也能传,但 postMessage 更明确:
- 有明确的发送方/接收方
- 支持
targetOrigin校验,防止意外泄露 - 异步事件驱动,主窗口不需要轮询
Step 6:主窗口处理 token,完成登录
// waitForSsoMessage() 收到消息
// → startOAuthPopup() 返回 msg
// → ssoProvider._oauthLogin() 提取 session_token
// → authManager.signInWithToken({ session_token, user_uuid })
// → session.js: persistSession() 存入 localStorage
// → bootstrapUser(token):
// initNet(token) // 设置 axios 请求头
// getUserInfo() // 拉取完整用户信息
// _setLogInfo(userDataMeta) // 更新 Recoil,UI 刷新
// _setSubscriptionsInfo(...) // 更新订阅状态
// ...5. 后端缓存了什么
4.1 OAuth State(防 CSRF,临时)
存储位置:Redis
Key: oauth_state:{random_string}
Value: { redirect_uri, referral_code, created_at }
TTL: 10 分钟
生命周期:生成时写入,callback 时读取后立即删除(一次性)为什么需要 state?
假设没有 state:攻击者可以构造一个恶意链接,诱导你点击,触发一个攻击者控制的 OAuth 授权流程,最终把攻击者账号绑定到你的账号上(CSRF 攻击)。
state 的作用:每次登录请求都生成唯一随机值,callback 时验证这个值是否是本次请求发出的,确保回调是合法的。
4.2 Session Token(用户登录态,持久)
存储位置:数据库 或 Redis
Key: session:{token_value}
Value: { user_id, created_at, expires_at, device_info }
TTL: 通常 7~30 天(取决于业务)前端每次 API 请求带上这个 token(在 Authorization: Bearer xxx 头里),后端查这条记录来识别用户身份。
4.3 本项目前端的 token 存储
// session.js - persistSession()
localStorage.setItem('token', session_token);
localStorage.setItem('user_uuid', user_uuid);每次 axios 请求通过 interceptor 自动注入:
// net.js - request interceptor
config.headers.Authorization = `Bearer ${localStorage.getItem('token')}`;
config.headers['x-hyper3d-token'] = `Bearer ${token}`;6. 代码结构与职责划分
src/utils/auth/
├── sso.js # 底层工具函数(无 React 依赖,无 import)
│ # - buildOAuthLoginUrl() 构造登录 URL
│ # - openCenteredPopup() 打开居中弹窗
│ # - waitForSsoMessage() 监听 postMessage
│ # - startOAuthPopup() 完整 popup 流程
│ # - startSamlRedirect() SAML 跳转
│
├── ssoProvider.js # Provider 注册表 + 统一登录入口
│ # - PROVIDERS 配置对象 注册 google/github/saml
│ # - ssoLogin() 统一入口,自动选择 popup/redirect
│ # - handleSsoCallback() Redirect 模式的 callback 处理
│ # - getCallbackUrl() 获取 callback URL(用于后端配置)
│
├── authManager.js # 登录状态管理(React 无关的单例服务)
│ # - createAuthManager() 初始化,注入 Recoil setters
│ # - signInWithToken() 持久化 session + bootstrap 用户
│ # - bootstrapUser() 拉取用户数据,更新全局状态
│ # - signOut() 清除 session,重置 Recoil
│
└── session.js # localStorage session 读写
# - persistSession() 写入 token/user_uuid
public/sso/
└── oauth-popup-callback.html # Popup callback 页面
# 读取 URL 参数,postMessage 给主窗口调用链
UI(LoginPanel)
└── ssoLogin('google') [ssoProvider.js]
└── startOAuthPopup('google', options) [sso.js]
├── buildOAuthLoginUrl() [sso.js]
├── openCenteredPopup(url) [sso.js]
└── waitForSsoMessage() [sso.js]
│
│ (等待 postMessage)
│
◄─── oauth-success { session_token } [callback.html]
└── 返回 { session_token }
└── authManager.signInWithToken({ session_token }) [authManager.js]
├── persistSession() [session.js]
└── bootstrapUser(token) [authManager.js]
├── getUserInfo() [net.js]
├── _setLogInfo(userData) [Recoil]
└── _setSubscriptionsInfo(...) [Recoil]7. 本项目的实际代码走读
6.1 waitForSsoMessage 的工作原理
export function waitForSsoMessage({
expectedOrigin = window.location.origin,
timeoutMs = 2 * 60 * 1000,
successTypes = ['oauth-success', 'sso-success'],
errorTypes = ['oauth-error', 'sso-error'],
} = {}) {
return new Promise((resolve, reject) => {
let timeoutId = null;
const onMessage = (event) => {
// 关键安全检查:只处理来自同源的消息
if (expectedOrigin && event.origin !== expectedOrigin) return;
const { type } = event.data || {};
if (successTypes.includes(type)) {
cleanup();
resolve(event.data);
} else if (errorTypes.includes(type)) {
cleanup();
reject(Object.assign(new Error(event.data.error || 'SSO_FAILED'), { data: event.data }));
}
};
const cleanup = () => {
window.removeEventListener('message', onMessage);
if (timeoutId) window.clearTimeout(timeoutId);
};
window.addEventListener('message', onMessage);
timeoutId = window.setTimeout(() => {
cleanup();
reject(new Error('SSO_TIMEOUT'));
}, timeoutMs);
});
}这里最容易出问题的地方:
if (expectedOrigin && event.origin !== expectedOrigin) return;event.origin 是 postMessage 发送方的 origin,也就是 callback 页面的 origin。 expectedOrigin 默认是 window.location.origin,也就是主窗口的 origin。
在本地开发时:
- 主窗口:
http://localhost:5173 - callback 页面:如果后端 redirect 到了
https://hyper3d.ai/sso/...,则发送方 origin 是https://hyper3d.ai https://hyper3d.ai!==http://localhost:5173→ 消息被过滤 → 主窗口永远没有反应
这就是本地开发出现"popup 跳到线上,主页面无反应"的根本原因。
6.2 getApiBase() 的作用
function getApiBase() {
return localStorage.getItem('BASE_URL') || '/api';
}net.js 在初始化时执行 localStorage.setItem('BASE_URL', BASE_URL),BASE_URL 会根据 fallback 状态选择不同的 CDN endpoint(如 https://api.hyper3d.com/api)。
sso.js 故意不 import net.js(避免循环依赖),通过 localStorage 拿到这个值。
6.3 authorizeExternal 为什么有 Authorization: undefined
// net.js
const authorizeExternal = (provider, params, captchaToken) => {
return axiosClient.get(
`${BASE_URL}/auth/oauth/${encodeURIComponent(provider)}/callback`,
{
params,
headers: {
Authorization: undefined, // ← 为什么?
},
}
);
};因为 request interceptor 会自动给所有请求加上 Authorization: Bearer {token}:
axiosClient.interceptors.request.use((config) => {
if (config.headers.Authorization === undefined) return config; // ← 这里
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});回调时用户尚未登录,localStorage 里没有 token,但如果之前有旧 token 残留,会被意外注入。所以显式传 Authorization: undefined 来跳过自动注入。
8. 常见问题与陷阱
7.1 Popup 被浏览器拦截
现象:window.open 返回 null
原因:浏览器要求 window.open 必须在用户手势(click 事件)的同步调用链内。如果中间有 await 打断,浏览器会认为不是用户发起的,直接拦截。
错误示例:
// ❌ 错误:await 之后调用 window.open,浏览器会拦截
async function handleLogin() {
await someAsyncOperation(); // 打断了用户手势链
startOAuthPopup('google'); // 被拦截
}正确做法:
// ✅ 正确:在用户点击的同步链里直接调用
function handleLogin() {
startOAuthPopup('google').then(...).catch(...);
// 不要 await,让 window.open 在同步代码里执行
}7.2 本地开发 404 / 主页面无反应
根本原因:后端 callback 处理完后,redirect 到了写死的生产环境 URL,而不是你本地的 callback 页面。
解决方案:
前端登录 URL 传入 redirect_uri:
GET /api/auth/oauth/google/login
?redirect_uri=http://localhost:5173/sso/oauth-popup-callback.html后端需要:
- 接受
redirect_uri参数 - 存入 state(Redis),callback 时读取
- 白名单校验 origin(
localhost,hyper3d.ai, staging 域名等) - callback 完成后 redirect 到这个地址
7.3 postMessage 消息被过滤
现象:callback 页面 postMessage 发出,主窗口收不到,控制台没有报错
排查步骤:
- 检查 callback 页面的
window.location.origin - 检查主窗口的
window.location.origin - 两者必须相同,
waitForSsoMessage才能接收到消息
7.4 Popup 被用户手动关闭
现象:用户关掉了 popup 窗口,主窗口 waitForSsoMessage 一直等到超时(2 分钟)
建议修复:检测 popup 关闭事件,提前 reject:
// 在 startOAuthPopup 里加入
const pollClose = setInterval(() => {
if (popupWin.closed) {
clearInterval(pollClose);
reject(new Error('POPUP_CLOSED_BY_USER'));
}
}, 500);7.5 authorizeExternal 路径双 /api
// ❌ 错误:BASE_URL 已含 /api,又拼了 /api/auth/...
`${BASE_URL}/api/auth/oauth/${provider}/callback`
// 结果:https://api.hyper3d.com/api/api/auth/...
// ✅ 正确
`${BASE_URL}/auth/oauth/${provider}/callback`
// 结果:https://api.hyper3d.com/api/auth/...7.6 SAML 与 OAuth 路径混淆
API 文档定义:
OAuth: /api/auth/oauth/{provider}/login
/api/auth/oauth/{provider}/callback
SAML: /api/auth/saml/{provider}/login
/api/auth/saml/{provider}/callback (POST)
/api/auth/saml/{provider}/metadata注意 SAML callback 是 POST 请求(IdP 向后端发 SAML Response),不是 GET。
9. 本地开发配置清单
前端侧
✅ sso.js: buildOAuthLoginUrl 传 redirect_uri(当前 origin 的 callback URL)
✅ public/sso/oauth-popup-callback.html 存在
✅ net.js: authorizeExternal 路径不含双重 /api
后端侧
⬜ /api/auth/oauth/{provider}/login 接受 redirect_uri query 参数
⬜ login 时把 redirect_uri 存入 Redis state
⬜ callback 时从 state 取出 redirect_uri,做 301/302 redirect
⬜ redirect_uri 白名单校验(防 open redirect 攻击):
- https://hyper3d.ai
- http://localhost:3000(或你的本地端口)
- https://staging.hyper3d.ai(如有)
Google OAuth App 配置
✅ 已授权重定向 URI 包含后端的 callback 地址:
https://api.hyper3d.com/api/auth/oauth/google/callback10. 安全注意事项
9.1 Open Redirect 攻击
如果后端直接信任前端传来的 redirect_uri,攻击者可以构造:
/api/auth/oauth/google/login?redirect_uri=https://evil.com/steal-token用户授权后,session_token 被发到攻击者网站。
防御:后端维护白名单,只允许已知的前端域名。
9.2 CSRF 攻击
通过 state 参数防御,上文已详述。state 必须是随机生成的,不能是可预测的值(如用户 ID)。
9.3 token 存储安全
本项目将 session_token 存在 localStorage,存在 XSS 攻击风险(恶意脚本可以读取)。更安全的方案是使用 HttpOnly Cookie(JS 无法读取),但需要后端配合,且跨域场景需要处理 SameSite 和 CORS 配置。
9.4 postMessage targetOrigin
// ❌ 不要用 '*',任何页面都能收到
window.opener.postMessage(msg, '*');
// ✅ 指定 origin,只有同源的 opener 能收到
window.opener.postMessage(msg, window.location.origin);9.5 session_token 有效期
session_token 应该有合理的过期时间,避免长期有效的 token 被滥用。后端应实现:
- token 过期自动失效
- 用户主动登出时让 token 失效(服务端销毁)
- 支持多设备登录时的 token 管理
附:完整时序图
主窗口 (localhost:5173) popup 窗口 后端 API Google
│ │ │ │
│ window.open(login_url) │ │ │
├─────────────────────────►│ │ │
│ addEventListener(msg) │ │ │
│ │ GET /oauth/login │ │
│ ├─────────────────────►│ │
│ │ │ 存 state → Redis │
│ │ │ 构造 Google Auth URL │
│ │◄──── 302 ────────────│ │
│ │ │ │
│ │────────────────────────────────────────────►│
│ │ (Google 授权页面在 popup 里显示) │
│ │ │ │
│ │ 用户选择账号并点击"同意" │
│ │ │◄─ code + state ──────│
│ │ │ 验证 state(Redis) │
│ │ │ code → access_token │
│ │ │ 获取用户信息 │
│ │ │ 查/建 用户账号 │
│ │ │ 生成 session_token │
│ │◄─ 302 /callback.html │ │
│ │ ?session_token=xxx │ │
│ │ │ │
│ │ (执行 callback.html)│ │
│◄── postMessage ──────────│ │ │
│ { type: 'oauth-success│ │ │
│ session_token: xxx }│ window.close() │ │
│ × │ │
│ │ │ │
│ signInWithToken(token) │ │ │
│ persistSession() │ │ │
├─── GET /user/get_info ───────────────────────► │ │
│◄──────────────────────────────────── 用户数据 ──│ │
│ 更新 Recoil state │ │ │
│ UI 响应,登录完成 │ │ │