Web 统一登录接入
本文描述 Confidential Web Client。桌面客户端看Native 章节;服务调用看Service API。不要混用用户令牌与服务令牌。
1. 准备客户端
在平台产品向导开启 Web 登录,保存 client_id、client_secret 和精确的 redirect_uri。推荐回调为 https://www.example.com/auth/tuyi/callback,但必须由产品实现。
如产品开启“强制绑定微信”,平台在完成授权以及换取/刷新 Token 时都会检查账号是否已有经过验证的微信身份。中央 SSO Cookie 不能绕过此策略;prompt=none 对未绑定账号返回 login_required。统一登录页会保留当前授权事务并引导用户改用微信扫码,首次扫码可验证原手机号并绑定到已有账号,不创建第二个账号。已经绑定的用户不必每次扫码,仍可使用短信或密码完成认证。
产品启动时通过配置的 Issuer 获取发现文档,固定信任这个 Issuer,而不是从请求或未验签 Token 的 iss 字段动态选择认证服务器。使用成熟 OIDC Client,例如本仓库已有的 github.com/coreos/go-oidc/v3 与 golang.org/x/oauth2。
2. 建立一次性登录事务
用户点击“图奕账号登录”时,产品服务端生成并保存:
| 字段 | 要求 |
|---|---|
| state | 至少 32 随机字节 Base64URL;防止跨请求冒领 |
| nonce | 独立随机值;绑定 ID Token 与本次登录 |
| code_verifier | 32 随机字节 Base64URL(43 字符)或合规 43–128 字符 |
| code_challenge | BASE64URL(SHA256(ASCII(code_verifier))),无等号 |
| created_at / expires_at | 产品侧最多保留 10 分钟 |
| return_path | 产品内允许的相对路径,拒绝外站或 //host |
事务存产品服务端 Session/Redis,使用短期 Secure; HttpOnly; SameSite=Lax Cookie 绑定浏览器。多节点共享事务存储;多个浏览器标签页使用各自事务。禁止把 verifier/nonce 明文塞进可篡改的回调参数,也不要把原始 Token 写 URL。
使用库生成 PKCE:
verifier := oauth2.GenerateVerifier()
// state、nonce 各自用 crypto/rand 生成,连同 verifier 保存到一次性事务。
authorizationURL := oauthConfig.AuthCodeURL(state,
oauth2.S256ChallengeOption(verifier),
oidc.Nonce(nonce),
)
http.Redirect(w, r, authorizationURL, http.StatusSeeOther)
上面是接入片段,不是完整会话实现。oauthConfig 的 Endpoint 来自固定 Issuer 的发现文档,AuthStyle 为 oauth2.AuthStyleInHeader,Scope 配置如下。
3. 发起授权
GET /oauth2/authorize?client_id=wyc-web&response_type=code&redirect_uri=https%3A%2F%2Fwww.example.com%2Fauth%2Ftuyi%2Fcallback&scope=openid%20profile%20phone%20offline_access&state=RANDOM_STATE&nonce=RANDOM_NONCE&code_challenge=S256_CHALLENGE&code_challenge_method=S256
Host: platform.too1.net
Scope 用空格分隔:openid 必需;profile 请求资料;phone 请求手机号;需要刷新令牌才申请 offline_access。平台只允许授权码模式,所有用户客户端要求 S256 PKCE、state、nonce。不支持 implicit、password grant、动态客户端注册。
平台处理微信/短信/手机号密码认证、首次手机号绑定、协议接受;产品不直接调用微信接口。
4. 接收回调并换码
成功回调:
https://www.example.com/auth/tuyi/callback?code=ONE_TIME_CODE&state=RANDOM_STATE
处理顺序:
- 根据产品浏览器事务找到 state,检查过期并常量时间比较。缺失、不同或重复回调立即拒绝。
- 原子取出/消费该事务;即使后续失败,也不能把回调再次使用。
- 若回调含
error,显示取消/失败结果;不要继续换码,不直接输出原始error_description到页面。 - 用该事务原始 redirect_uri 与 verifier 换码。授权码默认 60 秒、单次消费;网络结果未知时不要重放旧码,重新发起登录。
POST /oauth2/token
Host: platform.too1.net
Authorization: Basic BASE64_CLIENT_ID_COLON_SECRET
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&client_id=wyc-web&code=ONE_TIME_CODE&redirect_uri=https%3A%2F%2Fwww.example.com%2Fauth%2Ftuyi%2Fcallback&code_verifier=ORIGINAL_VERIFIER
这里的 Basic 占位内容应由 OAuth 库生成;不要硬编码示例值。Token 响应使用标准 OAuth JSON,不包在 data 字段中:
{"access_token":"OPAQUE_USER_TOKEN","token_type":"Bearer","expires_in":600,"id_token":"SIGNED_ID_TOKEN","refresh_token":"ROTATING_REFRESH_TOKEN"}
示例仅说明结构;字段存在性、有效期以实际响应为准。Access Token 是不透明令牌,不能按 JWT 解码。
5. 验证 ID Token 与 UserInfo
必须检查 RS256 签名/JWKS、Issuer、audience=本 Client ID、有效期、nonce;多 audience 时也要检查授权方 azp。不能只 Base64 解码 payload 后信任 sub。对 alg=none、未知算法、验签失败、过期和错误 nonce 一律拒绝。
// 回调处理函数内;failLogin 只返回安全文案和请求编号,不输出 Token。
// token 来自 oauthConfig.Exchange(ctx, code, oauth2.VerifierOption(savedVerifier)),
// 且已检查换码错误。provider 在应用启动时通过固定 issuer 建立。
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
failLogin(w, "missing_id_token")
return
}
idToken, err := provider.Verifier(&oidc.Config{
ClientID: clientID,
SupportedSigningAlgs: []string{"RS256"},
}).Verify(ctx, rawIDToken)
if err != nil || idToken.Subject == "" {
failLogin(w, "invalid_id_token")
return
}
if savedNonce == "" || subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(savedNonce)) != 1 {
failLogin(w, "nonce_mismatch")
return
}
var claims struct { AuthorizedParty string `json:"azp"` }
if err := idToken.Claims(&claims); err != nil ||
(len(idToken.Audience) > 1 && claims.AuthorizedParty != clientID) ||
(claims.AuthorizedParty != "" && claims.AuthorizedParty != clientID) {
failLogin(w, "authorized_party_mismatch")
return
}
info, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
if err != nil || info.Subject != idToken.Subject {
failLogin(w, "userinfo_subject_mismatch")
return
}
// 至此才可以用 idToken.Subject 建立本产品映射与业务会话。
以上为回调内的验证片段,需接入产品自己的 failLogin、事务消费和会话存储;HTTP 客户端设置超时。本仓库 ssoctl oidc smoke 提供可运行验收客户端,见末节。
请求 UserInfo:
GET /oauth2/userinfo
Authorization: Bearer OPAQUE_USER_TOKEN
返回按获准 Scope 过滤,例如:
{"sub":"00000000-0000-4000-8000-000000000001","name":"示例用户","phone_number":"+8613800000000","phone_number_verified":true,"tuyi_product_code":"wyc","tuyi_product_user_id":"123456"}
不要假设所有字段都存在;没有预导入映射就不会返回两个 tuyi_product_* 字段。手机号是可变属性,不能当永久主键。
6. 建立产品会话
以 (issuer, sub) 建立唯一映射;已有 tuyi_product_user_id 时按照迁移规则绑定原业务用户。禁止仅凭客户端传来的 sub 或手机号建立绑定。
产品在自己的事务中完成映射,然后轮换产品 Session ID 并签发自己的业务 Cookie/Token。后续访问网云穿 API 使用网云穿自己的权限和会话,不每个请求在线查询平台。平台短时不可用时,已登录业务会话应按产品原策略继续工作。
桌面提交的 ID Token 若由产品服务端验证,必须配置期望的 Native Client ID,不得把任意图奕产品的 Token 都接受为本产品登录。产品还要绑定自己发起的一次性登录挑战,避免将任意可重放 ID Token 当通用登录口令。
7. 刷新、退出与禁用
默认用户 Access/ID Token 10 分钟,Refresh Token 30 天,中央浏览器会话空闲 7 天、绝对 30 天;最终以响应及当前平台策略为准。
POST /oauth2/token
Authorization: Basic BASE64_CLIENT_ID_COLON_SECRET
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=CURRENT_REFRESH_TOKEN
同一登录会话只能有一个刷新操作在途;多节点用共享锁或 compare-and-swap 保存新的 Refresh Token,再销毁旧值。旧 Token 重放会撤销整个 family。超时不知道是否已轮换时不得无限重试旧令牌,应要求重新登录。
POST /oauth2/revoke
Authorization: Basic BASE64_CLIENT_ID_COLON_SECRET
Content-Type: application/x-www-form-urlencoded
token=CURRENT_REFRESH_TOKEN&token_type_hint=refresh_token
产品退出首先清理自己的业务会话;撤销失败不能让本机继续保持“已登录”。产品若选择同时退出当前中央浏览器会话,应直接发起标准 OIDC Logout,不再让用户到平台二次确认。中央登出端点为 /oauth2/logout;使用成熟 OIDC Client 传递 id_token_hint、已登记的 post_logout_redirect_uri 及独立 logout state,不发送 Refresh/Access Token 到 URL,不记录登出 URL 查询串。Web Client 的登录回调与退出回调分别在后台维护;退出回调必须填写到“退出回调地址”,不能只登记为登录回调。
请区分三种退出范围:
| 入口 | 实际范围 | 产品侧要求 |
|---|---|---|
| 产品自己的退出 API | 由产品撤销业务 Session,清除业务 Cookie/本地凭据 | 成功后停留在退出结果页;不要自动再次发起 SSO |
平台账号中心 /logout |
仅撤销当前浏览器的中央会话;不撤销其他设备和各产品已有的业务会话 | GET 显示确认页,POST 需要平台 CSRF;不作为产品 OIDC 回跳入口 |
OIDC /oauth2/logout |
合法 hint 对应用户、当前 Client 的平台 Access/Refresh Token,以及随请求携带的当前中央浏览器会话 | 接收方核验独立 logout state;其他 Client 及产品自己的业务 Session 不会自动退出 |
平台在数据库撤销事务提交成功后才清除中央 Cookie。OIDC 退出还会取消该用户、该 Client 已关联的待完成认证事务和旧授权码;已加载的旧授权证明也不能在撤销提交后签发新 Token。无效 hint、未登记回跳地址或 hint 与当前浏览器账号不符时拒绝请求,不误清其他会话;数据库失败返回错误,不跳转“退出成功”。已撤销或不存在的浏览器会话可重复退出。未携带中央 Cookie 的 OIDC 退出不能定位另一个浏览器中的中央会话,不应宣称“所有设备退出”。
产品退出请求若超时或返回 5xx,显示“退出结果未确认”,暂停展示受保护页面,并提供手动重试。清除 JavaScript 状态或 localStorage 不等于撤销 HttpOnly Cookie;必须由产品服务端撤销 Session 并清除 Cookie。退出结果页刷新后也不能自动重新登录;用户明确点击“重新登录”时才发起新的登录事务。
产品会话检查遇到 429/5xx 不等于未登录:保留可恢复错误,按 Retry-After 等待并手动重试,不触发循环跳转。登录发起、OIDC 回调、会话读取和退出应使用独立限流桶;合法 CORS 预检不计入认证次数。不要简单调大限流阈值或信任任意 X-Forwarded-For 来掩盖请求循环。
中央禁用与撤销不能自动销毁已签发的产品本地业务会话;产品必须消费用户安全事件并撤销本地会话。详情见Webhook。
8. 可运行验收
创建专用测试 Web Client 并精确登记 http://127.0.0.1:9090/callback,只对此测试 Client 开启开发回调。使用发布包里的 CLI:
./ssoctl oidc smoke web --issuer https://platform.too1.net --client-id smoke-web --secret-file smoke-web.secret
按命令提示打开授权地址,完成真实测试账号登录。工具校验签名、nonce、state、UserInfo、刷新、重放和撤销,不输出原始 Token。它会消耗/撤销自己的测试令牌,不能拿业务用户会话运行。通过此命令不代表网云穿自己实现的回调已经验收通过。