桌面原生登录 API
短信发送已要求先完成滑块验证。直连 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 手机号,不是网云穿旧用户名。旧密码不迁移;未设置平台密码的存量用户先用短信/微信认证。首次激活仍需接受当前协议。新增的原生找回 / 设置密码须显式开启,见 密码管理。架构与失败分支见 流程图,可下载 认证 OpenAPI。
产品若开启“强制绑定微信”,短信或密码事务可以完成凭据校验,但 /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、RFC 9700 §2.4。
线上必须 HTTPS 并验证证书和固定 Issuer。AppSecret、联麓 Key、服务凭证绝不能放入安装包。本接口拒绝浏览器 Origin、Sec-Fetch-Site、Cookie 和 URL 查询参数;不要添加宽泛 CORS 白名单,使用原生 HTTP 客户端。
完整流程
- 用安全随机数生成独立的
code_verifier、state、nonce,只存本次登录内存对象。 - 创建指定登录方式的事务,保存
transaction_id和只返回一次的transaction_token。 - 扫码/原生表单验证。切换方式须取消旧事务再创建新事务。
- 展示返回的当前协议与隐私政策,用户主动同意;状态
ready时提交/complete。 - 校验返回的
state、issuer、redirect_uri,用授权码及原code_verifier请求/oauth2/token。 - 校验 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):
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:
{"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,不是凭据;每个事务请求,包括图片请求,都必须带:
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 秒串行轮询,返回:
{"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
{"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
{"phone_e164":"+8613800138000","code":"用户收到的六位数字"}
成功 200 / data.status=ready。验证码绑定手机号和当前事务,单次使用、5 分钟有效,不能从网页登录或其他事务挪用;验证最多 5 次。仍需 /complete,此时不会提前创建账号或签发 Token。
密码验证
POST /api/v1/native-auth/transactions/{id}/password
{"phone_e164":"+8613800138000","password":"用户输入的平台密码"}
仅 method=password、waiting 状态可用。成功 200 / data.status=ready;失败统一 422 invalid_credentials,不披露手机号是否存在。提交结束清空密码。平台没有旧产品密码,不能把原登录密码直接转发后声称兼容。
完成认证与 Token 兑换
POST /api/v1/native-auth/transactions/{id}/complete
{"accept_terms":true,"legal_version":"从当前事务状态取得的协议版本"}
必须由用户实际阅读并接受后提交,不得静默置 true。422 terms_changed 时重新查询事务、展示新协议并重新确认。用户激活、绑定身份、协议记录与授权码在同一数据库事务提交;冲突整体回滚,不自动合并。
成功 200:
{"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 提交表单:
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 文档。
产品后端不能只信桌面上传的 sub 或未验证 JWT;服务端验证 Token/UserInfo,用稳定 sub 映射业务用户。tuyi_product_user_id 仅用于旧账号迁移。后台禁用用户会撤销中央 Token、取消已认证的待完成事务;产品会话仍需消费 安全事件 撤销。
原生 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:
./ssoctl oidc smoke native --issuer https://platform.too1.net --client-id smoke-native
具体产品分工和放量见 WYC2026 清单。