# 官网自定义登录（不跳页）

短信发送须先完成[滑块验证](CAPTCHA.md)，并在原发送请求中携带一次性 `captcha_token`；发布前同步升级相关客户端。

这是图奕自有产品的第一方认证扩展，不是 OAuth 密码 Grant，也不是可直接用于第三方网站的 SDK。仅当产品前端及发布供应链可信时启用；前端脚本能读取用户输入，CORS 不能防止同源 XSS。

## 选择接入方式

| 方式 | 适用场景 | 凭据边界 | 浏览器共享登录 |
|---|---|---|---|
| 标准 OIDC 跳转 | 需要统一登录、多个站点复用登录态 | 统一页面收密码；产品后端持 Secret | 支持平台中央会话 |
| 本章自定义 Web | 官网内二维码、短信或密码表单 | 浏览器直连平台；产品后端换码 | 不创建平台 Cookie，不承诺跨站静默登录 |
| Native 直接认证 | 自有桌面客户端原生界面 | 原生 HTTP，无 Secret，PKCE verifier 留客户端 | 不创建浏览器共享会话 |

自定义网站可以自由设计 UI，但不能省略协议确认、手机号绑定、错误恢复或后端验签。产品不接收公众号事件：微信仍只回调平台 `/callbacks/wechat/official-account`。

产品级“强制绑定微信”策略同样适用于本接口。已绑定用户可以继续使用短信或密码表单；未绑定用户在 `/complete` 收到 `409 wechat_binding_required` 后，前端应取消旧事务并让产品后端创建新的微信事务。事务凭据不能跨方式复用，产品也不能把短信验证成功提前当成登录成功。

## 开通与跨域

先部署包含 `012_custom_auth.sql` 的版本。在 **OIDC 客户端 → 编辑配置** 中，为 Web Client 开启“自定义网页登录”，登记精确 Origin，例如 `https://www.example.com`，按需开启密码管理。需要最近十分钟二次验证、原因和 Client ID 确认。新权限默认关闭；修改配置会取消未完成的直接认证事务。

Origin 由协议、主机和端口组成，**没有末尾斜杠或路径**。不支持 `*`、后缀匹配或 `null`。HTTPS 必需；只有开发 Client 允许 `http://localhost:端口` 或回环 IP。官网 `https://www.example.com` 与 API `https://api.example.com` 是两个 Origin；本章的产品换码接口必须通过官网同源路由提供。

请登记浏览器 `location.origin` 的规范值：主机小写，HTTPS 默认 443（HTTP 默认 80）不写端口，非默认端口范围 1–65535 且无前导零。

只有 `/api/v1/web-auth/transactions/{id}/…` 和 `/api/v1/web-auth/password-transactions/{id}/…` 有受限 CORS。预检允许 GET、POST 和 `Authorization, Content-Type`，有效期 60 秒。响应带精确 `Access-Control-Allow-Origin`、`Vary: Origin`，**不带 Allow-Credentials**。浏览器必须 `credentials: 'omit'`；不传平台 Cookie。创建事务只接受产品后端 Basic 认证，不开放浏览器 CORS。`/oauth2/token`、管理后台、短信业务及支付 API 不因此获得跨域权限。预检通过后，每个实际请求仍检查事务所属 Origin 和 Client 权限。

## 创建事务（产品后端）

`POST /api/v1/web-auth/transactions`，`Content-Type: application/json`，`Authorization: Basic base64(client_id:client_secret)`；仅 `client_secret_basic`。请求体不接受未知字段，最大 64 KiB。不传 Origin、Cookie 或 URL 参数。

| 字段 | 类型 / 必填 | 约束 |
|---|---|---|
| client_id | string / 是 | 已启用自定义登录的 Web Client，最长 128，与 Basic ID 相同 |
| browser_origin | string / 是 | 该 Client 已登记的精确 Origin |
| method | string / 是 | `wechat`、`sms`、`password` |
| redirect_uri | string / 是 | 已登记的登录回调；仅用于绑定，不会执行导航 |
| scope | string / 是 | 空格分隔，包含 openid；可选 profile、phone、offline_access，必须已授权 |
| state、nonce | string / 是 | 分别独立随机，22–128 字符 base64url |
| code_challenge | string / 是 | SHA256(verifier) 的无填充 base64url，固定 43 字符 |
| code_challenge_method | string / 是 | 固定 S256 |

不要复制“随机字符串”等文字作为参数。Go 后端生成方式：

```go
random := func() (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil { return "", err }
    return base64.RawURLEncoding.EncodeToString(b), 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 }
hash := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(hash[:])
```

verifier、state、nonce 保存于绑定当前浏览器业务会话的服务端临时记录，五分钟过期。浏览器不获得 verifier 或 Web Secret。创建请求返回 HTTP 201，外层格式与 Native 相同：

```json
{"data":{"transaction_id":"73688356-98eb-45fa-9230-b3ab939a7aca","transaction_token":"S1DKP9IMhrGNXQ6bcDpkzh_FSPjxQt4IskbgTsX2mGA","status":"waiting","expires_in":300,"expires_at":"2026-09-03T09:05:00Z","poll_interval":2,"legal":{"version":"2026-09","terms_url":"https://platform.too1.net/legal/terms","privacy_url":"https://platform.too1.net/legal/privacy"}}}
```

示例响应为虚构数据，不能作为凭据使用。只有微信事务附带 `qr_url`。五分钟固定到期，轮询不续期。后端将此结果返回原浏览器；事务 Token 仅保留浏览器内存，不入 URL、localStorage、日志或分析埋点。

## 浏览器直连接口

令 `base=/api/v1/web-auth/transactions/{id}`。所有请求带 `Authorization: WebAuth <transaction_token>` 和浏览器自动生成的 Origin。POST 为 JSON；不再提交可变 Client、Scope 或回调。成功格式 `{"data":…}`；失败格式见错误表。

| 方法 / 路径 | 请求 JSON | 成功响应 / 状态 |
|---|---|---|
| GET base | 无 | 200 `data.status, expires_at, poll_interval, legal, phone_masked`；状态 waiting/phone_required/ready/completed/cancelled/error |
| GET base/qr | 无 | 200 `image/png` 或 `image/jpeg`，只限微信事务；最大 512 KiB |
| POST base/sms/send | `{"phone_e164":"+8613800138000"}` | 201 `{"data":{"status":"accepted","message":"验证码已发送","retry_after":60}}` |
| POST base/sms/verify | `{"phone_e164":"+8613800138000","code":"123456"}` | 200 `{"data":{"status":"ready"}}` |
| POST base/password | `{"phone_e164":"+8613800138000","password":"仅作字段说明"}` | 200 `{"data":{"status":"ready"}}` |
| POST base/complete | `{"accept_terms":true,"legal_version":"返回的当前版本"}` | 200 一次性授权码，见下节 |
| POST base/cancel | `{}` | 200 `{"data":{"status":"cancelled"}}` |

短信只接受大陆 E.164；六位数字验证码。新微信身份必须扫码后验证手机号；既有 UnionID 直接进入 ready。冲突返回 409，不自动合并。手机号/验证码/密码输入直接提交上述平台 API，禁止产品后端中转。

```js
const res = await fetch(issuer + '/api/v1/web-auth/transactions/' + tx.id + '/sms/verify', {
  method: 'POST', credentials: 'omit', cache: 'no-store',
  headers: {'Content-Type': 'application/json', Authorization: 'WebAuth ' + tx.token},
  body: JSON.stringify({phone_e164: phoneInput.value, code: codeInput.value}),
  signal: AbortSignal.timeout(12000)
});
// 读取 HTTP 状态和 JSON error；不要对短信/完成接口自动重试。
```

二维码不能直接使用无认证的 `<img src>`：先带 WebAuth 头 `fetch` 图片，创建 `URL.createObjectURL(blob)` 再显示；关闭弹窗时 `URL.revokeObjectURL`。每两秒串行查询状态，响应完成后才安排下一轮；关闭、切换方式、到期或结束后停止。

短信渠道超时返回 **202**：

```json
{"data":{"status":"unknown","message":"发送结果未知，请勿自动重发；至少等待 60 秒后手动操作","retry_after":60}}
```

unknown 不代表受理成功，挑战未激活，不自动重发。HTTP 请求取消也不等于服务端未执行。密码/验证码字段在提交后清空，不能输出到日志。

## 完成与产品业务会话

`/complete` 只有 ready 且协议当前时成功。返回：

```json
{"data":{"status":"completed","code":"UozIeHZLdbyLGiqYmDmczA50wlZVEKj16ka7dbBgPLs","state":"创建时的state","redirect_uri":"https://www.example.com/auth/tuyi/callback","issuer":"https://platform.too1.net","expires_in":60,"token_endpoint":"https://platform.too1.net/oauth2/token"}}
```

授权码只领取一次；响应丢失后不能重新领取，请重建事务。浏览器通过官网 **同源 POST + CSRF** 发送 code/state/issuer/redirect_uri 给产品后端。后端从自己的会话取原记录，精确校验四项绑定（code 交由 Token 端点校验），原子消费临时记录，再用 Web Basic 和原 verifier 换 Token：

```http
POST /oauth2/token
Authorization: Basic <仅产品后端持有>
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=<一次性code>&redirect_uri=<URL编码回调>&code_verifier=<原verifier>
```

不要信任浏览器传回的 token_endpoint 或临时替换 Issuer；服务端固定配置平台域名。用正式 OIDC 验证器校验 RS256/JWKS、issuer、audience、exp、nonce，再请求 UserInfo 并检查 sub 一致。创建自己的业务会话，长期 Token 留服务端。刷新必须串行；退出先撤销 Refresh Token 并清理本地会话。参见 [OIDC](OIDC.md)。

## 错误、限流及重试

```json
{"error":{"code":"origin_not_allowed","message":"此网站未获准使用自定义登录","request_id":"诊断请求编号"}}
```

| HTTP | code | 处理 |
|---|---|---|
| 400 / 415 | invalid_request / web_request_required / json_required | 修正 JSON、禁止 Cookie/查询凭据，不重试原请求 |
| 401 | invalid_client / invalid_transaction_credential | 检查后端 Basic 或本次事务头 |
| 403 | origin_not_allowed / preflight_not_allowed / native_client_not_allowed | 检查 Web 开关、精确 Origin、回调、Scope；native_client_not_allowed 为兼容共用错误码 |
| 409 | transaction_unavailable / identity_conflict / wechat_binding_required | 重新开始；身份冲突联系管理员；要求绑定微信时改建微信扫码事务 |
| 422 | invalid_code / invalid_credentials / phone_unsupported / terms_changed | 用户修正；协议变化需重新读取并主动同意 |
| 429 | rate_limited / sms_limited | 遵守 Retry-After，不循环重试 |
| 503 | authentication_busy | 密码校验容量暂满，Retry-After 为 1 秒；用户稍后手动重试或换方式，不代表密码错误 |
| 502 / 503 | sms_failed / sms_activation_failed / storage_unavailable / channel_unavailable / login_paused | 显示安全提示与 request_id；保留“已执行但结果未知”的可能 |

Web 创建：Basic 校验前每后端来源 IP 1,200 次/分钟，校验成功后每 Web Client 600 次/分钟（登录与密码管理共用）；不套用 Native 的 30 次/5 分钟单终端限制，避免把同一产品后端出口的所有用户误当作一个用户。轮询每事务 180 次/5 分钟。

密码校验与统一页面、Native 共用三层预算：每 IP 总请求 1,200 次/分钟、每账号失败 10 次/15 分钟、每 IP 失败 300 次/15 分钟。成功不消耗失败额度，也不清空其他失败；账号预算跨 IP 生效。Redis 原子预留在途名额防止并发绕过，租约 30 秒，进程中断后自动回收。故障、取消和容量繁忙不当作错误密码；已经判定错误的尝试即使随后断线仍计入失败。单进程最多 4 个同时执行的密码校验，最多排队 500ms，超出返回 503 authentication_busy。Redis 校验/确认失败均拒绝认证。429 的 Retry-After 是最小等待建议，不保证下次成功；失败保护最长到当前 15 分钟窗口结束，禁止自动循环重试。

认证短信限额本轮未改：每手机号 60 秒一次、每来源 IP 每小时 10 次、国家前缀每分钟 100 次；这些是认证风控，不是业务模板短信额度。事务接口不使用业务 API 的 Idempotency-Key：凭据、状态机、一次性完成提供独立重放控制。

## 可运行示例与验收

仓库 `examples/custom-web` 是 Go 后端 + 原生 JavaScript，不需要 Node 构建。运行说明见该目录 README。涵盖扫码/短信/密码、找回/设置密码、业务会话、刷新、退出。此示例使用内存会话，生产替换为 Redis 等共享存储并消费撤销事件；不能把示例直接当作已验收的 WYC 改造。

下载 [认证 OpenAPI](/docs/openapi.json)，查看 [架构与时序图](FLOWS.md)。安全依据：[OAuth 2.0 Security BCP](https://www.rfc-editor.org/rfc/rfc9700.html)、[Fetch CORS 协议](https://fetch.spec.whatwg.org/#http-cors-protocol)。
