# Web 统一登录接入

本文描述 Confidential Web Client。桌面客户端看[Native 章节](NATIVE.md)；服务调用看[Service API](PLATFORM_API.md)。不要混用用户令牌与服务令牌。

## 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：

```go
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. 发起授权

```http
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. 接收回调并换码

成功回调：

```text
https://www.example.com/auth/tuyi/callback?code=ONE_TIME_CODE&state=RANDOM_STATE
```

处理顺序：

1. 根据产品浏览器事务找到 state，检查过期并常量时间比较。缺失、不同或重复回调立即拒绝。
2. 原子取出/消费该事务；即使后续失败，也不能把回调再次使用。
3. 若回调含 `error`，显示取消/失败结果；不要继续换码，不直接输出原始 `error_description` 到页面。
4. 用该事务原始 redirect_uri 与 verifier 换码。授权码默认 60 秒、单次消费；网络结果未知时不要重放旧码，重新发起登录。

```http
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` 字段中**：

```json
{"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 一律拒绝。

```go
// 回调处理函数内；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：

```http
GET /oauth2/userinfo
Authorization: Bearer OPAQUE_USER_TOKEN
```

返回按获准 Scope 过滤，例如：

```json
{"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` 时按照[迁移规则](MIGRATION.md)绑定原业务用户。禁止仅凭客户端传来的 sub 或手机号建立绑定。

产品在自己的事务中完成映射，然后轮换产品 Session ID 并签发自己的业务 Cookie/Token。后续访问网云穿 API 使用网云穿自己的权限和会话，不每个请求在线查询平台。平台短时不可用时，已登录业务会话应按产品原策略继续工作。

桌面提交的 ID Token 若由产品服务端验证，必须配置期望的 Native Client ID，不得把任意图奕产品的 Token 都接受为本产品登录。产品还要绑定自己发起的一次性登录挑战，避免将任意可重放 ID Token 当通用登录口令。

## 7. 刷新、退出与禁用

默认用户 Access/ID Token 10 分钟，Refresh Token 30 天，中央浏览器会话空闲 7 天、绝对 30 天；最终以响应及当前平台策略为准。

```http
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。超时不知道是否已轮换时不得无限重试旧令牌，应要求重新登录。

```http
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](WEBHOOKS.md)。

## 8. 可运行验收

创建专用测试 Web Client 并精确登记 `http://127.0.0.1:9090/callback`，只对此测试 Client 开启开发回调。使用发布包里的 CLI：

```bash
./ssoctl oidc smoke web --issuer https://platform.too1.net --client-id smoke-web --secret-file smoke-web.secret
```

按命令提示打开授权地址，完成真实测试账号登录。工具校验签名、nonce、state、UserInfo、刷新、重放和撤销，不输出原始 Token。它会消耗/撤销自己的测试令牌，不能拿业务用户会话运行。通过此命令不代表网云穿自己实现的回调已经验收通过。
