Skip to content

feat(accounts): GitHub 与 Google 登录,关闭普通账号口令登录的开关 - #185

Merged
Owlbay merged 9 commits into
mainfrom
feat/oauth-login
Nov 1, 2026
Merged

Owlbay merged 9 commits into
mainfrom
feat/oauth-login

Conversation

@Owlbay

@Owlbay Owlbay commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

变更摘要 / Summary

Go 控制面实现 GitHub 与 Google 登录,并加开关 ARMADRA_CLOUD_PASSWORD_AUTH 关闭普通账号的口令登录(设计依据:M2 实施设计草稿 §4.0、§4.3 的登录 / 注册部分;绑定到已登录账号、passkey、两步验证等 M2 其余内容不在本 PR)。

  • 迁移 0011:account_credentials.external_id,两个部分唯一索引(一个第三方身份只属于一个账号;一个账号在同一提供方至多一个身份),kind = 'oauth' 行必须带 <提供方>:<主体>。库里不存任何令牌。
  • 配置:ARMADRA_CLOUD_OAUTH_GITHUB_CLIENT_ID / _CLIENT_SECRET_FILE、ARMADRA_CLOUD_OAUTH_GOOGLE_*、ARMADRA_CLOUD_PASSWORD_AUTH(on 缺省 / off);提供方地址可由测试覆盖(Deps.OAuthEndpoints,生产不设)。
  • auth.oauthStart:redirectUri 严格等于 APP_ORIGIN/login/oauth/<provider>;state 与 PKCE(S256)校验串存 Redis(键 sha256(state),10 分钟,GETDEL 一次性);Cookie 传输下发 __Host-armadra-oauth(SameSite=Lax),Bearer 传输不发;按 IP 限流 oauth.start.ip。
  • auth.oauthFinish:state 不存在 / 过期 / 提供方对不上 → 401 credentials_invalid;Cookie 传输或发起时下发过随机数就要求 Cookie 一致(定时安全比较),用后清除;访问令牌只用一次、不落库不进日志;GitHub 只认 primary && verified,Google 只认 email_verified。已绑定登录 / 无同邮箱账号建号(邮箱已验证、无口令)/ 同邮箱账号:off 时绑定并登录,on 时 409 conflict(details.reason = "account_exists")/ 邮箱未验证 403 email_unverified / 注册暂停 503 / 停用 403 account_disabled。会话经新抽出的 createSession,与 auth.login 同一条路径(停用检查、新设备提醒、security.login,另写 security.oauthLogin;Cookie 传输答 csrfToken)。
  • auth.oauthUnlink:解绑后至少留一种登录方式(口令关闭时只看第三方),否则 409;未绑定 404。
  • 口令开关 off:auth.register、非运营 auth.login、auth.changePassword 答 403 forbidden;auth.passwordResetStart 照常答成功、做同样的库写入但只插占位发件(不发信);scope: "operator" 登录不受影响。
  • 能力位:platform.Capabilities 按配置组装(auth.oauth、auth.oauth.github / auth.oauth.google;口令关闭时不报 auth.password、auth.password-change)。
  • 协议包:PLATFORM_CAPABILITIES 加两个值;auth.register 错误表加 forbidden;auth.oauthFinish 加 registrations_paused,输出会话分支换成 cookieSessionResultSchema(加法)。契约 §29、运维手册、deploy/saas/compose.yml(两个可选 secret 与环境变量)、CHANGELOG 同步。

实现时的两处补充(设计草稿没写,请审):

  1. 无口令账号用 identity.NoPassword()(password_json 里 {"kdf":"none"})占位,VerifyPassword 对它也做一次等价派生再答否,时延不暴露「这个账号没有口令」。
  2. off 时绑定到一个邮箱还没验证过的本地账号:先前可能是别人抢注的,所以绑定时把邮箱记成已验证、清掉口令并撤销它的全部设备(经撤销钩子)。已验证的账号保留原口令记录。

Go control plane gains GitHub and Google sign-in plus a switch, ARMADRA_CLOUD_PASSWORD_AUTH, that turns off password sign-in for regular accounts (design basis: M2 implementation draft §4.0 and §4.3, sign-in / sign-up part only; linking to a signed-in account, passkeys and two-step verification are out of scope).

  • Migration 0011 adds account_credentials.external_id with two partial unique indexes (a third-party identity belongs to one account; an account has at most one identity per provider). No token is ever stored.
  • New config for the two providers and ARMADRA_CLOUD_PASSWORD_AUTH; provider endpoints are overridable by tests only (Deps.OAuthEndpoints).
  • auth.oauthStart / auth.oauthFinish / auth.oauthUnlink as described above (strict redirectUri, PKCE S256, one-shot state in Redis keyed by sha256(state), browser-binding cookie __Host-armadra-oauth for the cookie transport, per-IP rate limits, verified-email-only rules, same-email handling that depends on the password switch).
  • Sessions go through the new shared createSession, the same path as auth.login.
  • With password sign-in off, auth.register, regular auth.login and auth.changePassword answer 403 forbidden, password-reset start answers success without mailing, and operator sign-in is unaffected.
  • Capabilities are assembled from configuration; the protocol package gains the two capability values, forbidden on auth.register, registrations_paused on auth.oauthFinish and a cookieSessionResultSchema session branch (additive).

Two choices the draft does not spell out, please review: accounts created by OAuth have a placeholder "no password" record that still costs a derivation to verify; and when password sign-in is off, binding to a local account whose email was never verified marks it verified, clears its password and revokes all its devices (it may have been squatted).

关联 issue / Related issues

Closes #184

改动范围 / Scope

  • Go 实现(中继与控制面)/ Go implementation (relay and control plane)(go/)
  • 协议包 / protocol package(packages/platform-protocol)
  • 中继内核 / relay kernel(packages/relay-kernel)
  • 中继 Workers 运行时 / relay Workers runtime(packages/relay-runtime-workers、apps/relay-workers)
  • 控制核 / control core(packages/control-core)
  • cloud-shared(packages/cloud-shared)
  • deploy / 镜像 / images(deploy/、go/Dockerfile、go/Dockerfile.cloud)
  • docs
  • tools / scripts / CI

契约与协议变化 / Contract and protocol changes

  • 契约节号 / Sections: §29(第三方登录与关闭口令登录两条说明);§2 的错误码表由生成块更新
  • 协议版本 / Protocol version: PROTOCOL_VERSION 不变(1.4);全是加法(能力位、错误码表项、可选的 csrfToken);协议包版本由维护者发版时定。deploy/compat.json 不动。Armadra 控制台需按能力位 auth.oauth.github / auth.oauth.google 与 csrfToken 对接,回调页路径是 /login/oauth/<provider>
  • 生成物 / Generated files: 夹具(auth.oauthStart / auth.oauthFinish)、cloud-openapi.json(两份)、cloud-api.md 生成块已重新生成;Go gen/ 无变化

数据库迁移 / Database migrations

  • 编号 / Number: 0011(main 上最大号 0010 + 1)
  • migrations.lock: 已同步;go generate 拷到 Go 的 embed 目录;带测试(空库、停在 0010 的旧库升级、唯一索引与 CHECK、编译进 compat 与 CLI 的计数)

测试 / Testing

  • 单元测试 / Unit tests(pnpm test): 通过
  • 集成测试 / Integration tests(pnpm test:integration,需 pnpm dev:up): 未跑;Go 的 PostgreSQL / Redis / mailpit 测试用本机临时容器跑过(pnpm go:check)
  • 跨仓端到端 / Cross-repo e2e(pnpm e2e --mode personal): 未跑
  • 镜像 / Images(pnpm images:build): 未跑
  • pnpm check 与 / and pnpm repo:test: 通过;pnpm go:check(gofmt、vet、staticcheck、-race 测试、生成物新鲜度)通过

新增用例(假提供方 httptest + 真实 PostgreSQL / Redis):授权地址与 redirectUri 校验、Cookie 下发属性、state 一次性 / 过期 / 提供方对不上、Cookie 随机数缺失 / 不符 / 重复 / 传输不匹配、邮箱未验证(GitHub 非主邮箱 / 未验证,Google email_verified = false)、新建账号(库里形状、安全日志、无口令、令牌不落库不进日志、提供方只被访问一次)、已绑定登录、同邮箱在 on / off 下的行为(含未验证本地账号被清口令撤设备)、同一身份不能绑两个账号、同提供方第二个身份冲突、停用账号、注册暂停、口令关闭后各过程的答复、运营登录不受影响、找回口令不发信、能力位组装、解绑规则、限流、提供方 5xx 答 503 且日志不含秘密。

检查清单 / Checklist

  • app 不 import Armadra 源码,与 Armadra 只经 @armadra/platform-protocol 与镜像往来 / apps do not import Armadra sources, and everything shared with Armadra goes through @armadra/platform-protocol and images
  • @orpc/* 只出现在允许的门面文件里;协议包不 import node:*(夹具除外) / @orpc/* appears only in the allowed facade files; the protocol package does not import node:* (fixtures excepted)
  • 流正文、终端输出、画布内容、口令与令牌明文不进入日志、持久化或响应 / Stream bodies, terminal output, canvas content, passwords and plaintext tokens stay out of logs, persistence and responses
  • 令牌校验保留 aud、exp、jti;中继只把流转给令牌 src 指定的源 / Token checks keep aud, exp and jti; the relay only forwards to the source named by the token's src
  • personal 模式与其入口不依赖 PostgreSQL / Redis / personal mode and its entry points do not depend on PostgreSQL / Redis
  • 迁移只新增,编号连续,migrations.lock 已同步;没有自动清库、重建或跳过损坏账本的逻辑 / Migrations are append-only and contiguous, migrations.lock updated; no auto-wipe, rebuild or skipping of a corrupt ledger
  • 错误码取自协议包注册表,线上形状为 { code, message, requestId?, details? },JSON 为 camelCase / Error codes come from the protocol package registry, the wire shape is { code, message, requestId?, details? }, JSON is camelCase
  • 改了形状的同时改了协议包、cloud-api.md、OpenAPI、夹具与身份向量(pnpm protocol:check、pnpm contract:check 通过) / Shape changes update the protocol package, cloud-api.md, OpenAPI, fixtures and identity vectors (pnpm protocol:check and pnpm contract:check pass)
  • 依赖写精确版本;改了生产依赖已 pnpm notices 并提交 THIRD_PARTY_NOTICES.md / Dependencies use exact versions; production dependency changes include a regenerated THIRD_PARTY_NOTICES.md(无新依赖 / no new dependencies)
  • CLI 与日志用简体中文短句,变量进字段;新文档已登记到 docs/README.md / CLI and log messages are short Simplified Chinese sentences with variables in fields; new docs are registered in docs/README.md(无新文档,运维手册已有索引行 / no new document; the runbook already has an index row)
  • 用户可见的变化已记进 CHANGELOG.md 的 Unreleased / User-visible changes are recorded under Unreleased in CHANGELOG.md

需要维护者提供的外部条件 / External prerequisites

开启前要在 GitHub 建 OAuth App、在 Google Cloud 建 OAuth 客户端(步骤与回调地址 https://app.<域>/login/oauth/<provider> 见运维手册「第三方登录与口令开关」),把密钥写进 secrets/oauth_github_secret / secrets/oauth_google_secret,再在 .env 填变量。控制台的 /login/oauth/<provider> 回调页由 Armadra 仓提供,发布顺序是先控制台、后开配置、最后才设 PASSWORD_AUTH=off。本地只用假提供方验证,没有对真实的 GitHub / Google 跑过。Verification here uses fake providers only; the real GitHub and Google endpoints have not been exercised.

已知限制 / Known limitations

  • 不含:绑定到已登录账号(intent: "link")、第三方登录后的 MFA 挑战(普通账号的用户侧两步验证还没有)、me.security、无口令账号的注销 / 改邮箱(me.delete、me.emailChange 仍要口令)、绑定与解绑要求提权。
  • 口令关闭时 auth.changePassword 对运营账号同样答 403,运营账号的口令靠命令行维护。
  • 无口令账号的 auth.stepUp 只能用 TOTP / passkey,普通无口令账号暂时没有可用的提权方式(M2 内容)。
  • Out of scope: linking to a signed-in account, an MFA challenge after third-party sign-in, me.security, account deletion / email change for password-less accounts, and step-up requirements for link / unlink. With password sign-in off, operators also cannot use auth.changePassword.

…gister 加 forbidden、oauthFinish 加 registrations_paused 且会话分支带 csrfToken,契约 §29 说明(Refs #184)
…uth.login 与第三方登录共用 createSession(Refs #184)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitHub 与 Google 登录,关闭普通账号的口令登录 / GitHub and Google sign-in, password sign-in off for regular accounts

1 participant