背景
最近在一次多前端项目的登录体系改造中,我们将原有的本地登录流程接入到了一个第三方统一认证平台。
这次改造涉及两个前端项目:
- 门户项目:提供公开首页,未登录用户也可以访问首页内容。
- 主业务项目:承载业务功能,访问受保护页面时必须登录。
- 登录中转页:复用原有登录页路由,作为第三方认证回跳后的中转页面。
改造目标是:
- 门户首页保持匿名可访问。
- 门户首页的"登录 / 注册"入口跳转第三方认证平台。
- 主业务项目未登录访问业务页面时跳转第三方认证平台。
- 第三方认证完成后回跳登录中转页。
- 中转页使用
code换取业务 token。 - 登录成功后回跳到原始业务页面。
- 退出时同时清理业务系统登录态和第三方认证态。
这类接入表面看只是"跳转登录",但真正容易出问题的地方集中在四个点:
redirect_uri多层嵌套时如何编码。- 发起登录和换 token 时传给业务后端的
redirectUrl是否一致。 - SSO 登录跳转应该使用浏览器顶层导航,而不是 AJAX/fetch。
- 只有线上白名单回调地址时,本地如何跨项目联调。
整体登录链路
整体流程可以抽象为:
主业务系统未登录时也是类似流程,只是发起登录的入口从门户首页变成了路由守卫。
接口职责抽象
为了脱敏,下面使用抽象接口描述。
获取第三方认证跳转地址
bash
GET /api/sso/login/getRedirectUrl
前端传入:
perl
{
"redirectUrl": "http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F"
}
这个 redirectUrl 表示:第三方认证完成后,应该回跳到哪个登录中转页。
其中 redirect_uri 是业务最终希望回到的页面。
也就是说,这里有两层跳转:
rust
第三方认证平台
-> 登录中转页
-> 原始业务页面
登录中转页换 token
bash
POST /api/sso/login/login
前端传入:
perl
{
"code": "xxx",
"tenantId": "xxx",
"redirectUrl": "http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F"
}
注意这里的 redirectUrl 需要和第一步 getRedirectUrl 阶段保持一致。
第三方认证平台在换 token 时,会校验:
- 第一次发起认证时使用的
redirect_uri - 第二次使用
code换 token 时传入的redirect_uri
如果两次不一致,即使 code 是真实有效的,也可能换 token 失败。
退出登录
bash
GET /api/sso/login/logout
退出时需要同时处理两类登录态:
- 业务系统自己的 token / cookie / 用户信息
- 第三方认证平台的登录态
如果只清理业务系统登录态,没有退出第三方认证平台,下次进入登录流程时可能会被第三方平台静默登录回来。
redirect_uri 为什么容易出问题
SSO 接入里最容易踩坑的是:
URL 作为另一个 URL 的 query 参数继续传递。
例如:
bash
https://sso.example.com/login?redirect_uri=http://callback.example.com/login/?redirect_uri=http://localhost:3000/portal/
这个地址看起来可读,但实际上有明显问题。
外层 URL 的 query 是:
bash
redirect_uri=http://callback.example.com/login/?redirect_uri=http://localhost:3000/portal/
如果内层 URL 继续携带 ?、&、=,浏览器、后端框架或第三方平台在解析时,就可能把它们当成外层参数的一部分。
更复杂一点,如果旧登录体系里还残留了这些参数:
ini
client_id=xxx
scope=xxx
那么最终可能变成:
bash
https://sso.example.com/login?redirect_uri=http://callback.example.com/login/?redirect_uri=http://localhost:3000/portal/&client_id=old-client&scope=xxx
这时 client_id 和 scope 到底属于中转页,还是属于第三方认证平台,就会变得混乱。
结果可能是:
redirect_uri被截断。- 回跳地址参数丢失。
- 第三方平台回跳到了错误页面。
code换 token 时提示 redirect_uri 不一致。- 前端登录成功后无法回到原始页面。
什么情况下前端需要 encode
判断是否需要 encode,不要靠感觉,应该看 URL 的嵌套层级。
前端需要 encode 的典型情况是:
- 某个参数值本身是一个完整 URL。
- 这个 URL 会作为另一个 URL 的 query 参数。
- 这个 URL 内部包含
?、&、=等特殊字符。 - 后端只是简单字符串拼接,而不是使用标准 URL 工具构造参数。
例如业务最终要回到:
bash
http://localhost:3000/portal/
登录中转页是:
arduino
http://callback.example.com/login/
那么前端传给后端的 redirectUrl 可以构造为:
perl
http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F
也就是只把内层业务页面地址 encode:
javascript
const redirectUrl = `${callbackUrl}?redirect_uri=${encodeURIComponent(targetUrl)}`
这样中转页收到回跳后,可以从 redirect_uri 里还原出真实业务目标页。
什么情况下不需要整体 encode
不是所有场景都要把整个 redirectUrl 再 encode 一层。
如果前端传给后端的是:
perl
http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F
并且:
- 内层业务目标地址已经 encode。
- 外层中转页没有继续拼接裸露的
&client_id、&scope等参数。 - 后端能正确把这个值作为一个完整参数处理。
- 第三方平台最终收到的
redirect_uri是标准编码后的地址。
那么通常不需要前端再对整个 redirectUrl 做一次 encodeURIComponent。
也就是说,不推荐无脑写成:
scss
encodeURIComponent(callbackUrlWithRedirectUri)
因为这可能会让编码层级变复杂,排查时很难判断当前字符串到底被 encode 了几次。
更稳妥的原则是:
哪一层 URL 被当作 query 参数传递,就编码哪一层的值。不要多编,也不要漏编。
什么时候可能需要整体 encode
如果后端当前实现是裸拼第三方认证地址,例如:
arduino
ssoUrl + "?redirect_uri=" + 前端传入的redirectUrl
而不是使用类似 URLSearchParams 的方式构造参数,那么当前端传入的 redirectUrl 内部存在未编码的 ? 或 & 时,就可能破坏外层 URL 结构。
这种情况下有两种处理方式。
第一种,推荐后端修正:
csharp
const url = new URL(ssoUrl)
url.searchParams.set('redirect_uri', redirectUrl)
第二种,如果短期无法改后端,前端可以临时整体 encode:
ini
const encodedRedirectUrl = encodeURIComponent(callbackUrlWithRedirectUri)
但这种方式需要前后端明确约定:后端不能重复 encode,也不能错误 decode 后再裸拼。
否则很容易出现:
- 一次编码不够。
- 两次编码过度。
- 本地看似正常,第三方平台校验失败。
所以整体 encode 更像是兼容后端裸拼实现的临时方案,不应该成为长期默认方案。
顶层跳转与 CORS 问题
获取第三方登录地址时,很容易写成 AJAX 请求:
csharp
await fetch('/api/sso/login/getRedirectUrl')
如果后端返回的是第三方认证平台地址,甚至直接返回 302,那么前端用 fetch 调用就可能遇到 CORS 问题。
这是因为:
- AJAX 请求受浏览器跨域策略限制。
- 第三方登录页通常不会给业务前端配置 CORS。
- 登录跳转本质上应该是浏览器导航,不是接口取数。
更合理的方式是使用顶层跳转:
ini
window.location.href = loginRedirectUrl
或者如果后端接口本身会 302 到第三方平台,也可以直接跳到后端接口地址:
ini
window.location.href = `/api/sso/login/getRedirectUrl?redirectUrl=${redirectUrl}`
原则是:
SSO 登录跳转应该交给浏览器地址栏导航,不要用 AJAX 承接第三方登录页。
redirectUrl 前后一致性
这次接入里,一个关键问题是:第三方认证平台在登录校验时,会比对前后两次 redirectUrl。
第一次是发起登录时:
perl
GET /api/sso/login/getRedirectUrl?redirectUrl=http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F
第二次是中转页拿到 code 后换 token 时:
perl
{
"code": "xxx",
"tenantId": "xxx",
"redirectUrl": "http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F"
}
如果第一步使用的是:
perl
http://callback.example.com/login/?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F
第二步却传:
arduino
http://callback.example.com/login/
就可能导致换 token 失败。
因为从第三方认证平台视角看,这两次 redirect_uri 并不是同一个地址。
因此,中转页不能只关心 code,还需要根据当前回跳地址还原出本次登录使用的 redirectUrl,并在调用登录接口时一起传给后端。
登录中转页设计
第三方认证平台回跳后,用户会进入登录中转页。
这个页面不应该继续展示原来的账号密码登录表单,因为此时用户已经完成第三方认证,中转页的职责只是技术处理。
中转页应该做这些事:
- 读取 URL 上的
code。 - 读取 URL 上的
tenant_id。 - 将
tenant_id转换为后端需要的tenantId。 - 从当前地址中还原本次登录使用的
redirectUrl。 - 调用后端登录接口换取业务 token。
- 写入本地登录态。
- 跳回原始业务页面。
页面 UI 可以非常简单:
- 白底。
- 居中 loading。
- 展示"正在登录中..."。
- 不展示账号密码登录框。
- 异常时提示登录失败,并允许重新登录。
这样用户不会误以为自己还需要再次输入账号密码。
退出链路
退出链路也要区分两层状态。
如果只调用业务系统 logout,可能出现:
- 前端清掉了本地 token。
- 用户再次点击登录。
- 浏览器跳到第三方认证平台。
- 第三方认证平台发现自己仍然是登录态。
- 用户被静默登录回来。
这不一定是 bug,但如果产品预期是"彻底退出",就必须调用第三方认证平台的登出能力。
只有白名单地址时如何本地联调
很多第三方认证平台只允许配置固定回调地址,比如:
arduino
http://callback.example.com/login/
而本地开发地址通常是:
bash
http://localhost:8080/login/
第三方平台不接受 localhost 作为顶层 redirect_uri,这时不能直接把本地地址传给第三方平台。
比较实用的方案是:使用代理工具把白名单域名代理到本地服务。
例如:
ruby
http://callback.example.com/login/ -> http://127.0.0.1:8080/login/
http://callback.example.com/portal/ -> http://127.0.0.1:3000/portal/
然后浏览器访问:
arduino
http://callback.example.com/portal/
而不是访问:
bash
http://localhost:3000/portal/
这样第三方平台看到的顶层回调地址仍然是白名单地址,但实际请求会被代理到本地前端服务。
这个方案尤其适合跨项目联调:
- 门户项目运行在本地端口 A。
- 登录中转页项目运行在本地端口 B。
- 浏览器访问统一的白名单域名。
- 不同路径代理到不同本地服务。
登录后仍显示未登录态的排查
SSO 回跳成功并不代表页面一定能立刻展示登录态。
如果登录后页面右上角仍然显示未登录,可以重点检查:
- 当前访问域名是否一致。
- token 写入的 storage 是否和读取位置一致。
- cookie 的 domain / path 是否匹配。
- 业务接口是否真的带上了 token。
- 用户信息接口是否成功刷新。
- 是否一个页面在
localhost,另一个页面在白名单域名。
本地联调时,尤其要避免这样的情况:
bash
登录中转页: http://callback.example.com/login/
门户首页: http://localhost:3000/portal/
如果登录态依赖 cookie 或同域策略,两个域名不一致就可能导致页面读不到登录态。
更稳定的方式是统一使用白名单域名访问:
arduino
http://callback.example.com/portal/
再通过代理转发到本地门户项目。
常见问题 Checklist
点击登录后出现 CORS
检查是否用 fetch 或 $request 请求第三方跳转地址。
SSO 登录应使用:
ini
window.location.href = loginUrl
不要用 AJAX 承接第三方登录页。
回跳地址参数丢失
检查内层 redirect_uri 是否 encode。
错误示例:
bash
callbackUrl?redirect_uri=http://localhost:3000/portal/?a=1&b=2
正确示例:
perl
callbackUrl?redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fportal%2F%3Fa%3D1%26b%3D2
code 换 token 失败
检查 login 阶段传给后端的 redirectUrl 是否和 getRedirectUrl 阶段一致。
尤其注意:
- 是否少了
redirect_uri。 - 是否编码层级不同。
- 是否多了旧登录体系的参数。
- 是否本地和白名单域名混用。
登录成功但页面还是未登录
检查当前页面访问域名是否和登录态写入域名一致。
如果第三方只允许白名单地址,本地也建议通过白名单域名访问页面,再代理到本地服务。
退出后再次点击登录被自动登录
检查是否只调用了业务系统 logout,没有调用第三方认证 logout。
最佳实践总结
这次接入后,可以沉淀出几条经验:
- SSO 跳转使用浏览器顶层跳转,不要用 AJAX 请求第三方登录页。
redirect_uri是否 encode,取决于它是否作为另一层 URL 的 query 参数。- 不要机械地整体 encode,优先明确每一层 URL 的边界。
- 如果后端裸拼第三方认证地址,前后端必须明确编码职责。
- 发起登录和 code 换 token 时,
redirectUrl要保持一致。 - 登录中转页只做 code 换 token,不继续展示完整登录表单。
- 退出时要同时清理业务系统登录态和第三方认证态。
- 只有线上白名单地址时,本地联调可以通过代理把白名单域名转到本地服务。
- 跨项目联调时,尽量统一访问域名,避免 cookie / storage / token 域名不一致。
- 旧登录体系的
client_id、scope等参数要及时清理,避免污染新的 SSO 链路。
一句话结论
SSO 接入最关键的不是"跳过去再跳回来",而是把每一层 URL 的边界讲清楚:谁负责编码,谁负责解码,发起登录和换 token 时的 redirect_uri 是否完全一致,以及本地联调时浏览器实际访问的域名是否和登录态作用域一致。