# 桌面原生登录 API

短信发送已要求先完成[滑块验证](CAPTCHA.md)。直连 API 的客户端须增加拼图展示并提交一次性 `captcha_token`，部署本版前同步适配；标准浏览器 OIDC 可复用托管登录页。

## 当前能力和上线前提

本版新增**第一方原生认证扩展**：客户端内公众号扫码、短信验证码或手机号密码登录，无需打开系统浏览器，也不依赖 WebView、网页登录 Cookie、HTML 表单或 Client Secret。原有标准浏览器 OIDC 继续兼容。

**代码支持不等于线上已经升级。** 先部署含 `011_native_auth.sql` 的版本，在「账号中心 → OIDC 客户端 → Native 客户端行菜单」开启「原生登录」，再联调。默认关闭，开启需要管理员二次验证、操作原因及 Client ID 确认。产品向导生成的 `wyc-desktop` 也须显式开启；仅限图奕自有可信桌面应用，Web/Service Client 不能使用。

| 登录方式 | 平台行为 | 客户端行为 |
|---|---|---|
| 公众号扫码 | 生成带参数二维码，验签解密 subscribe/SCAN | 显示图片、轮询；新身份继续绑手机号 |
| 短信验证码 | 创建认证挑战、校验验证码 | 原生输入 E.164 手机号及六位验证码 |
| 手机号密码 | 校验图奕统一认证密码 | 原生输入手机号及统一账号密码，不是旧产品密码 |
| 标准浏览器 OIDC | 原有授权码 + PKCE | 仍可独立接入，不受原生开关影响 |

短信首期仅支持 `+86`。密码登录中的「账号」是 **E.164 手机号**，不是网云穿旧用户名。旧密码不迁移；未设置平台密码的存量用户先用短信/微信认证。首次激活仍需接受当前协议。新增的原生找回 / 设置密码须显式开启，见 [密码管理](PASSWORDS.md)。架构与失败分支见 [流程图](FLOWS.md)，可下载 [认证 OpenAPI](/docs/openapi.json)。

产品若开启“强制绑定微信”，短信或密码事务可以完成凭据校验，但 `/complete` 只会向已绑定微信的账号签发授权结果。收到 `409 wechat_binding_required` 后应取消旧事务并新建 `method=wechat` 的扫码事务；首次扫码继续验证原账号手机号即可原子绑定，禁止客户端自行创建账号或自动合并冲突身份。

## 安全边界

这不是新的 OAuth Grant；`/oauth2/token` **不支持 `grant_type=password`**。原生验证后只交付一次性授权码，最后仍通过现有授权码 + S256 PKCE 签发 OIDC Token，稳定 `sub`、产品旧 ID 映射、Scope、刷新轮换和撤销保持一致。

原生输入密码意味着第一方程序能接触统一密码，增加泄露与仿冒风险；增加中间授权码并不能消除风险。它**不是 RFC 8252 推荐的外部浏览器隔离模式**。仅允许经审查、签名发布的自有客户端；禁止记录密码、验证码、事务凭据、授权码或 Token，禁止把平台密码发送给产品后端。Client ID 是公开标识，不能证明安装包可信。参考 [RFC 8252](https://www.rfc-editor.org/rfc/rfc8252.html)、[RFC 9700 §2.4](https://www.rfc-editor.org/rfc/rfc9700.html#section-2.4)。

线上必须 HTTPS 并验证证书和固定 Issuer。AppSecret、联麓 Key、服务凭证绝不能放入安装包。本接口拒绝浏览器 Origin、Sec-Fetch-Site、Cookie 和 URL 查询参数；不要添加宽泛 CORS 白名单，使用原生 HTTP 客户端。

## 完整流程

1. 用安全随机数生成独立的 `code_verifier`、`state`、`nonce`，只存本次登录内存对象。
2. 创建指定登录方式的事务，保存 `transaction_id` 和只返回一次的 `transaction_token`。
3. 扫码/原生表单验证。切换方式须取消旧事务再创建新事务。
4. 展示返回的当前协议与隐私政策，用户主动同意；状态 `ready` 时提交 `/complete`。
5. 校验返回的 `state`、`issuer`、`redirect_uri`，用授权码及原 `code_verifier` 请求 `/oauth2/token`。
6. 校验 RS256/JWKS、`iss`、`aud`、`exp`、`nonce` 及 UserInfo 的 `sub` 一致，产品服务端再建立自己的业务会话。

无需真的导航到回调 URI，但必须注册且精确匹配。例如 `tuyi-wyc://oauth/callback` 在本流程中是授权码绑定值，换 Token 时仍提交同一 URI。

## 创建事务

`POST https://platform.too1.net/api/v1/native-auth/transactions`

所有原生接口 POST 使用 `Content-Type: application/json`，不接受未知字段、多个 JSON 对象或超过 64 KiB 的正文。使用以下 Go 生成代码构造请求，不将说明文字作为随机数提交（需导入 crypto/rand、crypto/sha256、encoding/base64、encoding/json）：

```go
random := func() (string, error) {
    bytes := make([]byte, 32)
    if _, err := rand.Read(bytes); err != nil { return "", err }
    return base64.RawURLEncoding.EncodeToString(bytes), nil
}
verifier, err := random()
if err != nil { return err }
state, err := random()
if err != nil { return err }
nonce, err := random()
if err != nil { return err }
digest := sha256.Sum256([]byte(verifier))
body, err := json.Marshal(map[string]string{
    "client_id": "wyc-desktop", "method": "wechat",
    "redirect_uri": "tuyi-wyc://oauth/callback",
    "scope": "openid profile phone offline_access",
    "state": state, "nonce": nonce,
    "code_challenge": base64.RawURLEncoding.EncodeToString(digest[:]),
    "code_challenge_method": "S256",
})
if err != nil { return err }
// body 作为 JSON 提交。verifier/state/nonce 保留在本次原生登录内存中。
```

`method` 仅允许 `wechat`、`sms`、`password`。`state`/`nonce` 各为 22–128 个 `[A-Za-z0-9_-]` 字符，推荐分别生成 32 随机字节做无填充 base64url。`code_verifier` 同样推荐生成 32 随机字节编码，长度 43；创建事务只发其 SHA256 challenge（43 字符），换 Token 才发送 verifier。Scope 必须包含 `openid`，且全部已由管理员授权；不能包含 `sms:send` 等服务 Scope。

成功 HTTP `201`：

```json
{"data":{
  "transaction_id":"事务UUID",
  "transaction_token":"仅本次响应返回的43字符凭据",
  "status":"waiting",
  "expires_in":300,
  "expires_at":"RFC3339时间",
  "poll_interval":2,
  "legal":{"version":"当前协议版本","terms_url":"https://platform.too1.net/legal/terms","privacy_url":"https://platform.too1.net/legal/privacy"},
  "qr_url":"https://platform.too1.net/api/v1/native-auth/transactions/事务UUID/qr"
}}
```

只有微信事务返回 `qr_url`。固定 5 分钟过期，轮询和发短信不续期。凭据只存内存，不进地址、日志、剪贴板或产品后端。下文 `{id}` 是事务 UUID，不是凭据；**每个事务请求，包括图片请求，都必须带：**

```http
Authorization: Native <transaction_token>
```

它不是 Bearer、不是 Access Token。其他事务凭据、浏览器会话和服务 Token 均不能代替。后续请求不再提交可变的 Client ID、Scope 或回调。

## 二维码、状态与取消

`GET /api/v1/native-auth/transactions/{id}/qr` 返回 PNG/JPEG。使用原生 HTTP 带 Authorization 下载到内存交给图片控件，不是打开网页。未关注者扫码并关注后产生 `subscribe`，已关注者扫码产生 `SCAN`。

`GET /api/v1/native-auth/transactions/{id}` 每 **2 秒**串行轮询，返回：

```json
{"data":{"status":"phone_required","expires_at":"RFC3339时间","poll_interval":2,"legal":{"version":"当前协议版本","terms_url":"协议URL","privacy_url":"隐私URL"}}}
```

只读查询不会消耗或领取授权码。

| 状态 | 客户端处理 |
|---|---|
| `waiting` | 等待短信/密码验证或公众号扫码 |
| `phone_required` | 微信新身份或待激活身份，展示手机号验证码表单 |
| `ready` | 认证完成；用户确认当前协议后提交 `/complete` |
| `completed` | 授权码已领取，不可再领；丢失响应须重新登录 |
| `cancelled` | 停止轮询并销毁本地事务 |
| `error` | 身份不可用等终止状态，停止轮询，提示联系客服/换方式 |

超过 `expires_at` 或收到 `409 transaction_unavailable` 不能继续使用。关闭窗口、切换账号/方式时停止计时器、取消网络请求，再提交 `POST /api/v1/native-auth/transactions/{id}/cancel`，正文 `{}`。取消后的迟到扫码不会恢复事务。刷新二维码应取消旧事务、创建新微信事务。

## 短信发送与验证

`POST /api/v1/native-auth/transactions/{id}/sms/send`

```json
{"phone_e164":"+8613800138000"}
```

仅短信事务 `waiting` 或微信事务 `phone_required` 可发送。这是**平台认证挑战**，不同于产品业务模板短信 `/api/v1/sms/messages`，桌面不应调用后者完成身份认证。

- `201` / `data.status=accepted`：渠道明确受理，显示 60 秒倒计时，不表示认证成功。
- `202` / `data.status=unknown`：渠道结果未知，禁止自动重发；至少等 60 秒再由用户手动操作。未知结果验证码不激活。
- `429`：按 `Retry-After` 等待；`502/503`：显示渠道/状态错误，不循环发送。

成功或未知返回 `data.retry_after=60`。本接口永不返回验证码，即使本地开启 `SSO_DEV_EXPOSE_CODES`。

`POST /api/v1/native-auth/transactions/{id}/sms/verify`

```json
{"phone_e164":"+8613800138000","code":"用户收到的六位数字"}
```

成功 `200` / `data.status=ready`。验证码绑定手机号和当前事务，单次使用、5 分钟有效，不能从网页登录或其他事务挪用；验证最多 5 次。仍需 `/complete`，此时不会提前创建账号或签发 Token。

## 密码验证

`POST /api/v1/native-auth/transactions/{id}/password`

```json
{"phone_e164":"+8613800138000","password":"用户输入的平台密码"}
```

仅 `method=password`、`waiting` 状态可用。成功 `200` / `data.status=ready`；失败统一 `422 invalid_credentials`，不披露手机号是否存在。提交结束清空密码。平台没有旧产品密码，不能把原登录密码直接转发后声称兼容。

## 完成认证与 Token 兑换

`POST /api/v1/native-auth/transactions/{id}/complete`

```json
{"accept_terms":true,"legal_version":"从当前事务状态取得的协议版本"}
```

必须由用户实际阅读并接受后提交，不得静默置 true。`422 terms_changed` 时重新查询事务、展示新协议并重新确认。用户激活、绑定身份、协议记录与授权码在同一数据库事务提交；冲突整体回滚，不自动合并。

成功 `200`：

```json
{"data":{"status":"completed","code":"一次性授权码","state":"原state","redirect_uri":"tuyi-wyc://oauth/callback","issuer":"https://platform.too1.net","expires_in":60,"token_endpoint":"https://platform.too1.net/oauth2/token"}}
```

**不跳转 redirect_uri。** 验证响应 state、issuer、redirect_uri 与本地本次记录一致后，在 60 秒内向固定 `/oauth2/token` 提交表单：

```text
grant_type=authorization_code
client_id=wyc-desktop
redirect_uri=tuyi-wyc://oauth/callback
code=<刚领取的授权码>
code_verifier=<创建事务前生成的verifier>
```

这里使用 `application/x-www-form-urlencoded`，正确 URL 编码；不发送 Client Secret，不带 Native Authorization 头。成功返回标准 `access_token`、`id_token`、`token_type`、`expires_in`；请求且获准 `offline_access` 才有 `refresh_token`。用正式 OIDC/JWT 库验签和验证 claims，不能只解码 payload。

兑换失败也可能消耗授权码，包括 PKCE、回调和 Client ID 错误。`/complete` 响应丢失时查到 completed 不会重新返回 code，必须重建事务。Token 响应丢失也不要反复重试原码。给用户明确的可恢复错误，不要转圈或自动重发短信。

## 刷新、退出、业务用户映射

刷新沿用 `/oauth2/token` 表单：`grant_type=refresh_token&client_id=wyc-desktop&refresh_token=...`。同一会话只允许一个刷新请求，成功原子替换旧 Token；旧 Refresh Token 重放会撤销整族。用 Windows Credential Manager、macOS Keychain、Linux Secret Service 保存 Refresh Token，Access Token 尽量留内存。

退出用 `/oauth2/revoke` 撤销 Refresh Token：`token`、`token_type_hint=refresh_token`、`client_id`，并清理本地 Token 及产品业务会话。此流程不建立平台浏览器 Cookie，不能假设浏览器同时登录或退出。详细 Token 校验与撤销参数见 [OIDC 文档](OIDC.md)。

产品后端不能只信桌面上传的 `sub` 或未验证 JWT；服务端验证 Token/UserInfo，用稳定 `sub` 映射业务用户。`tuyi_product_user_id` 仅用于旧账号迁移。后台禁用用户会撤销中央 Token、取消已认证的待完成事务；产品会话仍需消费 [安全事件](WEBHOOKS.md) 撤销。

原生 ID Token 的 audience 是 `wyc-desktop`，不是 `wyc-web`。产品后端为自己的 Web/Native Client 分别配置准确的受信 audience，禁止接受其他产品 Client ID 或关闭 audience 校验。关闭「原生登录」仅停止此入口并取消待完成事务，不替代撤销已有 Token；紧急处置请使用禁用客户端/撤销会话开关。

## 错误及限流

错误统一返回 `{"error":{"code":"...","message":"安全中文提示","request_id":"排障编号"}}`。

| HTTP / code | 处理 |
|---|---|
| 400 `invalid_request` / 415 `json_required` | 修正字段、S256、JSON/Content-Type |
| 400 `native_request_required` | 删除 Cookie/Origin/查询参数，使用原生 HTTP |
| 401 `invalid_transaction_credential` | 检查 Native 头，不得换成服务密钥 |
| 403 `native_client_not_allowed` | 检查后台开关、类型、状态、回调、Scope |
| 403 `account_unavailable` | 停止流程并联系客服 |
| 409 `transaction_unavailable` | 过期、凭据不匹配、已结束或并发冲突，重新登录 |
| 409 `identity_conflict` | 转人工处理，禁止自动合并 |
| 409 `wechat_binding_required` | 取消当前事务，创建微信扫码事务；首次扫码验证原账号手机号 |
| 422 `invalid_code` / `invalid_credentials` | 保留普通字段，重新输入；不回填敏感值 |
| 422 `terms_changed` | 重新读取和接受当前协议 |
| 429 `rate_limited` / `sms_limited` | 等待，不自动持续重试 |
| 503 `authentication_busy` | 密码验证容量暂满，至少等待 Retry-After 的 1 秒后手动重试，不当作密码错误 |
| 502/503 渠道/`login_paused`/`storage_unavailable` | 展示请求编号，换方式或稍后重新发起 |

默认：每 IP 创建 30 次/5 分钟；微信二维码每 Client 120 次/分钟；每事务最多 180 次状态查询和 5 次二维码下载。密码采用每 IP 总请求 1,200 次/分钟、每账号失败 10 次/15 分钟、每 IP 失败 300 次/15 分钟；成功只消耗总量预算，不消耗或清空失败额度。30 秒原子在途预留防止并发绕过；Redis 故障失败关闭。密码校验单进程最多 4 个并行、最多排队 500ms，容量不足返回 authentication_busy，不计密码失败。429 的 Retry-After 是最小等待建议，失败保护可能持续到当前 15 分钟窗口结束，不得自动循环提交。

短信复用全局限制，本轮未调整：同手机号 60 秒一次、每 IP 每小时 10 次、号码前缀限流。浏览器和原生共享短信频控，NAT 用户量大时需隔离压测评估阈值，不能绕开频控。

## Go 和 Rust/Tauri 实施要点

Go：`crypto/rand.Read` 生成三个独立 32 字节随机值，`base64.RawURLEncoding` 编码；`sha256.Sum256([]byte(verifier))` 得 challenge。`http.Client` 不使用 CookieJar，设置连接/整体超时并拒绝重定向，防止把认证头发到其他主机；所有请求绑定随窗口取消的 context。

Rust/Tauri：在 Rust 后端用 reqwest 请求、系统 CSPRNG 生成随机数、sha2 与 URL_SAFE_NO_PAD 生成 PKCE。事务 Token、verifier、Refresh Token 留在 Rust/系统凭据库，不进入 WebView localStorage。前端只接收二维码图片和安全状态，通过限定窗口/来源的 Tauri command 提交输入；不要在 WebView fetch 原生接口。

验收顺序：新微信扫码绑手机 → 已绑定扫码 → 短信 → 平台密码 → 签名及 nonce → UserInfo sub → 刷新 → 退出 → 取消/过期 → 错误 PKCE/跨事务/重放 → 旧产品映射。假渠道通过不能替代真实公众号、短信受控联调。

## 保留的标准浏览器方式

选择外部浏览器时，仍向 `/oauth2/authorize` 提交 `response_type=code`、注册回调、openid Scope、随机 state/nonce 和 S256 challenge；平台网页认证后跳产品回调，再验 state 和换 Token。两个认证入口不要串在一起。

原有命令仍测试浏览器 OIDC，**不是新原生 UI**：

```sh
./ssoctl oidc smoke native --issuer https://platform.too1.net --client-id smoke-native
```

具体产品分工和放量见 [WYC2026 清单](WYC2026.md)。
