Skip to content

feat(auth): 登录系统收尾——provider 抽象 + M2b 绑定 + identity-first + OTP 注册 + 安全加固 - #57

Merged
longsizhuo merged 17 commits into
mainfrom
feat/auth-provider-registry-m2b
Oct 18, 2026
Merged

longsizhuo merged 17 commits into
mainfrom
feat/auth-provider-registry-m2b

Conversation

@longsizhuo

@longsizhuo longsizhuo commented Jul 26, 2026 •

Copy link
Copy Markdown
Member

登录 / 身份体系(RFC #42、ADR-001)的收尾,Discord 全量开放之前要做完的部分都在这一个 PR 里。

内容

  • provider 抽象 + M2b 绑定:AuthProviderRegistry 取代散落的 switch。绑定入口改为 POST /oauth/bind/{provider},靠登录态鉴权,返回授权地址;不再用 cookie 鉴权的 GET,避免 CSRF。服务端保存一次性 intent,回调时复核 session。bind marker 的有效期为 30 分钟。
  • identity-first 登录:先按 user_identities 查。用户名兜底只给"一行身份都没有"的老账号用,解绑或换绑后旧身份不会复活。身份写入撞了唯一约束时拒绝登录,不再默默放行。
  • 新账号一律先验证邮箱(OTP 接线):loginByProvider 不再建号。新用户会被 302 到 /login/verify,pendingId 只放在 httpOnly 的 ih_reg_pending cookie 里;然后走 /auth/register/{pending,send-otp,verify}。验证出的邮箱如果已属于某个账号,返回 409,不做合并。建号、挂身份、登入在同一个事务里完成。
  • 邮箱锚点:只有 provider 已验证的邮箱或 OTP 验证过的邮箱能写进 user_accounts.email。自动挂靠不会挂进已经有同一 provider 身份的账号。这样就堵住了 review 复现的"绑未验证邮箱 → 受害者首次登录落进攻击者账号"攻击链。
  • 资料刷新:只有建号用的那家 provider 登录时才覆盖展示名和头像,其它 provider 登录只补空字段。email 和 github_id 只在为空时才写入。
  • 登录 nonce 回显(login CSRF):/oauth/render/{p}?n= 会把 nonce 记进 ih_login_nonce cookie,回调时以 #token=…&n=… 原样带回,由前端逐次比对(配套 frontend #394)。
  • OTP 限额:码只在送达之后才生效。发信按(邮箱, 身份)、按邮箱、按身份三层限额。每个邮箱每天的错码总数有上限。provider 已验证过该邮箱的主人豁免按邮箱的全局限额。详见 INV-012(包括有意保留的残留风险)。
  • 回调兜底:登录回调遇到账号被禁用时跳 /login?error=account_disabled,其它异常跳 oauth_failed。绑定回调的任何异常都跳 /settings?bind_error=oauth_failed,并撤销 provider token。
  • 机器人桥接接口拒收经代理的请求(INV-013):带 X-Forwarded-For 的请求一律 404。
  • schema.sql 不再 seed 可登录账号(INV-011):admin、alice、auditor 改为只在本地 init.sql 和测试里存在。
  • 文档:SECURITY.md INV-007/008/011/012/013,ADR-001 迁移表(OTP、M2b、identity-first 标为完成,M4 仍待做),usercenter README,README 和 .env.example 的本地端口改为 3000,dev1.md。另外删除了无引用的 OAuthAccount 模型。

验证

  • ./mvnw -o test:420 个全部通过。关键防护都做过变异检查:去掉防护,对应测试就会失败。
  • 经过三轮独立对抗式 review,确认的问题都已修复;第一轮发现的阻塞级攻击链有端到端的回归测试。

上线顺序

先上本 PR(后端),再上 frontend #394(前端在拿不到 n 时会拒收 token)。前端上线之后再清空 AUTH_DISCORD_ALLOWLIST(Discord GA)。

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings July 26, 2026 12:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

longsizhuo added a commit that referenced this pull request Oct 7, 2026
按三方协议实现后端的两步(新增 INV-010):

- POST /oauth/sso/code(需要登录):IH 前端 /sso/authorize 页代当前用户签码。
  client 必须在 sso.clients 表里且已启用,redirectUri 与登记值逐字相等,只收 S256
  和 43 位 base64url 的 challenge,state 1–512;不过就 400,账号被禁用 403。
  码是 32 字节随机数的 base64url,存进程内 Caffeine,60 秒过期;返回
  {redirect: redirectUri?code=…&state=<URL 编码>}。
- POST /internal/sso/token(加进 notMatch,controller 自己鉴权):client secret 常量
  时间比较,不过就 401 invalid_client,并且不碰码(拿不到 secret 的人烧不掉别人的码);
  asMap().remove 取码即作废;clientId / redirectUri / verifier 任一不符、或账号已被
  禁用,都是 400 invalid_grant,码不可重试。只返回 sub/username/displayName/avatarUrl,
  不给 email、角色权限,也不给任何 IH token。

client 按表配置(sso.clients.<id>.secret / .redirect-uri),代码里不认具体 client 名。
secret 或 redirect-uri 为空即未启用、两个接口都拒绝(空 redirect-uri 也拒:否则请求传
空串就能"逐字相等");启动日志逐个播报 client 是否启用,不打 secret。docker-compose
和 .env.example 补 SSO_HOLOCARD_SECRET。

新代码都在 sso 包,没碰 OAuthController / AuthService(#57 在改)。

测试:SsoControllerIntegrationTests 走真登录 → 签码 → 换码,覆盖成功路径和各条拒绝
路径;SsoServiceTests 用假时钟测 60 秒过期。对签码/换码的 10 处核心校验各做一次变异,
测试全部能抓到。全量 346 个测试通过。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHv6oQ6cYWQvdBV6FFRhF
longsizhuo added a commit that referenced this pull request Oct 7, 2026
* feat(sso): IH 通行证服务端——授权码 + PKCE,让 HoloCard 用 IH 账号登录

按三方协议实现后端的两步(新增 INV-010):

- POST /oauth/sso/code(需要登录):IH 前端 /sso/authorize 页代当前用户签码。
  client 必须在 sso.clients 表里且已启用,redirectUri 与登记值逐字相等,只收 S256
  和 43 位 base64url 的 challenge,state 1–512;不过就 400,账号被禁用 403。
  码是 32 字节随机数的 base64url,存进程内 Caffeine,60 秒过期;返回
  {redirect: redirectUri?code=…&state=<URL 编码>}。
- POST /internal/sso/token(加进 notMatch,controller 自己鉴权):client secret 常量
  时间比较,不过就 401 invalid_client,并且不碰码(拿不到 secret 的人烧不掉别人的码);
  asMap().remove 取码即作废;clientId / redirectUri / verifier 任一不符、或账号已被
  禁用,都是 400 invalid_grant,码不可重试。只返回 sub/username/displayName/avatarUrl,
  不给 email、角色权限,也不给任何 IH token。

client 按表配置(sso.clients.<id>.secret / .redirect-uri),代码里不认具体 client 名。
secret 或 redirect-uri 为空即未启用、两个接口都拒绝(空 redirect-uri 也拒:否则请求传
空串就能"逐字相等");启动日志逐个播报 client 是否启用,不打 secret。docker-compose
和 .env.example 补 SSO_HOLOCARD_SECRET。

新代码都在 sso 包,没碰 OAuthController / AuthService(#57 在改)。

测试:SsoControllerIntegrationTests 走真登录 → 签码 → 换码,覆盖成功路径和各条拒绝
路径;SsoServiceTests 用假时钟测 60 秒过期。对签码/换码的 10 处核心校验各做一次变异,
测试全部能抓到。全量 346 个测试通过。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHv6oQ6cYWQvdBV6FFRhF

* feat(sso): 给 HoloCard 的 staging 单独登记一个 client(holocard-staging)

staging 跑着没合并的 PR,secret 和回跳地址都和线上分开:它拿到的码换不了线上的会话,反过来也一样。
SSO_HOLOCARD_STAGING_SECRET 留空就不启用;加一条测试确认带横线的 client id 能从 application.properties 原样读进 client 表。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHv6oQ6cYWQvdBV6FFRhF

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
longsizhuo added a commit that referenced this pull request Oct 18, 2026
## 为什么

loginByProvider 仍按 "{provider}_{id}" 用户名主查账号,user_identities 只是双写。
#57 加了用户主动绑定之后,身份所属账号的用户名不再是 "{provider}_{id}":

- 绑了一个邮箱不同的 Discord → Discord 登录按用户名 miss、按邮箱也 miss → 分叉出新账号
- 非 github_ 账号绑了 GitHub(bind 已写 github_id)→ GitHub 登录 miss → 建号撞
  UNIQUE(github_id) → 500
- 已验证邮箱挂靠后,第三方那边换了邮箱 → 下次登录同样分叉

## 改动

- loginByProvider:先查 user_identities(provider, providerUserId),命中即登入该身份
  所属账号(仍检查 enabled);miss 才回退用户名 → 已验证邮箱挂靠 → 建号。
  身份查询失败直接抛,不吞掉回退——否则一次 DB 抖动就可能分叉。
- updateProfile 改 COALESCE 只补空列(NULLIF 把历史空串也当空):换个 provider
  登录不再覆盖展示名/头像,null 不再抹掉 email/github_id。非 github provider 本就
  传 null githubId,补了一条数字 snowflake 的回归测试锁住它。
- ADR-001 迁移表补"登录主查翻转"一行,M2b 标为 #57。

## 测试

AuthServiceTests +4(绑定邮箱不同的 Discord 登入原账号 / 非 github_ 账号绑 GitHub
后登录不建号 / 挂靠后换邮箱不分叉 / Discord 数字 id 不写 github_id),
loginByGithubUpdatesProfile… 改为补空语义;JdbcUserAccountRepositoryTests +2。
这些新测试在旧代码上均失败(GitHub 那条正是 DuplicateKey UNIQUE(github_id))。
@longsizhuo
longsizhuo force-pushed the feat/auth-provider-registry-m2b branch from 5503a3a to f82f16c Compare October 18, 2026 07:48
生产 SPRING_SQL_INIT_MODE=always,schema.sql 每次启动都跑,里面的
admin/alice/auditor seed 带着 README 公开的默认密码落进了生产库。

- schema.sql 删掉这三个账号的 INSERT,discord-bridge('!' + enabled=FALSE)保留
- seed 本来就在 docker/init-db/init.sql 和 test-schema.sql,本地 docker 和测试不受影响
- SECURITY.md 新增 INV-011 + SecurityInvariantsTests 回归测试:schema.sql 里不能出现
  PasswordService.matches 认的哈希(bcrypt / 64 位十六进制 SHA-256)
- AGENTS.md 三处同步规则注明 seed 是例外;README 说明 seed 只在本地

生产库里已有的三行不会被这次改动删除,需要另外处理。
## 为什么

接一个新登录 provider 原本要改 6 处,其中 3 处是散落的 switch(provider):
authRequestFor / isProviderEmailVerified / redirectUriOf。漏改任一处会得到
"能跳转但邮箱不被信任"这种半死状态——登录看着是成功的,只是悄悄多建了个账号。
接 Google 之前必须先把这个漂移源头堵掉。

## provider 抽象

新增 AuthProvider 接口(key / newRequest / redirectUri / isEmailVerified /
revokeToken)与 AuthProviderRegistry(按 key 收集所有 bean,重复 key 启动即失败,
不留"随机命中一个"的余地)。GithubAuthProvider、DiscordAuthProvider 各自封装
自己的配置与邮箱信任判据。三个 switch 全部删除。

接新 provider = 新增一个类 + 三行配置,不改任何已有文件。

## M2b 绑定流程

/oauth/bind/{provider}(@SaCheckLogin)→ 授权 → 回调挂到当前账号,不建号、
不换会话、不发新 token。与登录共用回调端点,走哪条由服务端意图决定,不由请求参数。

INV-007 红线:绑定目标 userId 绝不进 state。userId 取自发起时已校验的会话,
以随机 state 为 key 存服务端内存(5min TTL、一次性),回调时二次核对当前会话
仍是同一人。state 只是不可猜测的查找键。

冲突给可辨识 code 而非 500:bind_taken(已绑他人)/ bind_duplicate(本账号已绑
同类)/ bind_already_yours / bind_session。绑定 github 补写 github_id,与解绑
时清空对称。

绑定不经过 Discord 灰度闸——闸保护的是"建新号",绑定不建号。

**为什么绑定必须早于 GA**:UNIQUE(provider, provider_user_id) 意味着一个第三方
身份只能绑一个账号。先放开 → 用户被分叉 → 新账号占住该身份 → 本尊补绑撞约束,
从"插一行"变成"跨账号迁移 posts/chat/follows"。顺序错了成本差一个数量级。

## 前端

设置页加"连接"按钮。可绑列表来自新端点 GET /identities/providers(后端已注册的
provider),前端不再维护写死列表——接新 provider 按钮会自动出现。绑定回调结果
(?bind=ok / ?bind_error=)给出可区分文案。用 Suspense 包裹(useSearchParams)。

## 文档

usercenter/README.md:接入新 provider 的完整契约、邮箱信任为何是安全判据而非展示
字段、两条 OAuth 流程、绑定为何不能把 userId 放进 state、账号 vs 身份的关系。
SECURITY.md INV-007 补上绑定流程的实现与两条回归测试。

全量 313 测试通过(301 → +12)。前端 72 通过 / typecheck / lint 0 error /
build 通过,login 仍为 ● SSG(settings 改动前后都是 ƒ,非本次引入)。
## 为什么

loginByProvider 仍按 "{provider}_{id}" 用户名主查账号,user_identities 只是双写。
#57 加了用户主动绑定之后,身份所属账号的用户名不再是 "{provider}_{id}":

- 绑了一个邮箱不同的 Discord → Discord 登录按用户名 miss、按邮箱也 miss → 分叉出新账号
- 非 github_ 账号绑了 GitHub(bind 已写 github_id)→ GitHub 登录 miss → 建号撞
  UNIQUE(github_id) → 500
- 已验证邮箱挂靠后,第三方那边换了邮箱 → 下次登录同样分叉

## 改动

- loginByProvider:先查 user_identities(provider, providerUserId),命中即登入该身份
  所属账号(仍检查 enabled);miss 才回退用户名 → 已验证邮箱挂靠 → 建号。
  身份查询失败直接抛,不吞掉回退——否则一次 DB 抖动就可能分叉。
- updateProfile 改 COALESCE 只补空列(NULLIF 把历史空串也当空):换个 provider
  登录不再覆盖展示名/头像,null 不再抹掉 email/github_id。非 github provider 本就
  传 null githubId,补了一条数字 snowflake 的回归测试锁住它。
- ADR-001 迁移表补"登录主查翻转"一行,M2b 标为 #57。

## 测试

AuthServiceTests +4(绑定邮箱不同的 Discord 登入原账号 / 非 github_ 账号绑 GitHub
后登录不建号 / 挂靠后换邮箱不分叉 / Discord 数字 id 不写 github_id),
loginByGithubUpdatesProfile… 改为补空语义;JdbcUserAccountRepositoryTests +2。
这些新测试在旧代码上均失败(GitHub 那条正是 DuplicateKey UNIQUE(github_id))。
绑定意图存在进程内存(5min TTL、一次性消费),过期/重放/重启都会丢。丢了之后
回调只能当登录处理:按该第三方身份建号,或登进身份所属的别的账号。

/oauth/bind 种 state cookie 的同时种 ih_oauth_bind=1(/oauth/render 发起登录时清掉,
避免放弃绑定后的登录被误判)。回调里标记在而意图缺失 → 在 state 校验之后、换 token
之前弹回 /settings?bind_error=bind_session。标记每次回调用完即清。

UserIdentityService.bind 的预检挡不住并发;github_id 列也可能被一个尚无身份行的老
账号占着(UNIQUE(github_id))。insert 与 github_id 回填外包 DataIntegrityViolation
→ IdentityAlreadyBoundException("bind_taken"),抛出即事务回滚,不留半绑定。

OAuthControllerIntegrationTests 注册一个不发网络请求的假 provider,端到端走绑定回调:
同会话 → bind=ok 且不建号;登出 / 换号 → bind_session;重放 state → bind_session
(换个 code 也不建号);发起登录清残留标记。UserIdentityServiceTests +2(github_id
被占 → bind_taken 且回滚;insert 竞态 → bind_taken)。

SECURITY.md INV-007 补意图丢失的处理与 4 条回归测试;usercenter/README.md 补绑定
标记、登录查找顺序,INV 引用去掉写死的 INV-001..INV-008 范围,改指向 SECURITY.md。
## 为什么

loginByProvider 对新用户直接建号:Discord 只能靠灰度闸挡着,GitHub 新号拿到的是
provider 给的邮箱(多数为空)。规范邮箱缺位时,"先 GitHub 后 Discord"的人只能靠
运气不分叉。RegistrationService 已经写好但没有接线。

## 改动

- loginByProvider 不再建号:身份 → 用户名 → 已验证邮箱挂靠都 miss 时返回
  NeedsRegistration(pendingId)。只有 AuthService.completeRegistration(OTP 通过后)
  建号,建号 + 挂身份 + 登入同一事务。
- 回调对新用户 302 /login/verify,pendingId 只进 httpOnly SameSite=Lax 的
  ih_reg_pending cookie(Max-Age 900),绝不进 URL——URL 里的 pendingId 可被转发,
  受害者在攻击者的会话里验证自己的邮箱即构成预注册劫持。
- /auth/register/{pending,send-otp,verify},公开(SaTokenConfigure 放行
  /auth/register/*,原来那条 /auth/register 指向不存在的端点),cookie 是唯一凭据。
  verify 成功返回与 POST /auth/login 同形的 LoginResponse;邮箱已属于已有账号 → 409,
  什么都不写(不合并);会话没了 → 410。
- 限流:每个收件邮箱每天 5 封、每个第三方身份每天 10 封(叠加原有的会话内 5 封 /
  60s 冷却);第 5 次错码即作废会话。不按 IP:后端拿不到可信客户端 IP。
- updateProfile 不再收到未验证的 provider 邮箱:user_accounts.email 是自动挂靠的锚点。
- 删掉没有调用方的 loginByGithub。

## 测试

RegistrationFlowIntegrationTests(新):回调 → 验证页 → 发码 → 验证 → token 端到端;
已有账号邮箱 409 且不建号不挂身份;错码 4 次 400 后 410;无 cookie / 伪造 cookie 410。
AuthServiceTests:建号路径改为断言进注册、不建号;补 completeRegistration 与
"回访登录不写未验证邮箱"。RegistrationServiceTests:跨会话日限额、锁死即作废。
全量 386 通过。
## 登录查找

- 已验证邮箱自动挂靠不再挂进"已有同家身份"的账号:新身份挂不上去
  (UNIQUE(user_id, provider)),同家第二个号报了同一个邮箱也不是同一个人的证据
  (邮箱回收转手、锚点来路不正)。按无匹配处理,进注册。
- 用户名回退只认一行身份都没有的账号(真正的 M0 前老账号,生产为 0)。有身份行的
  账号按 "{provider}_{id}" 命中只可能是该身份已解绑或换绑:原来会静默登进去并把
  身份补回来,换绑后的旧 GitHub 照样能登录,设置页看不到、撤不掉。
- completeRegistration 撞上被占的 "{provider}_{id}" 用户名时加 6 位随机后缀。
- 登录路径上 ensureIdentity 撞唯一约束改为拒绝登录,不再吞掉后照常发 token。
- 资料刷新:用账号原始 provider(用户名前缀)登录时展示名/头像用非空新值覆盖,
  别家登录只补空;email / github_id 永远只补空,email 只来自已验证来源。

## 绑定入口与回调

- 绑定发起改为 POST /oauth/bind/{provider},返回 {url},并种 state + 标记 cookie;
  GET 不再接受。GET 能被跨站顶级导航触发,配合被种进浏览器的别人的 satoken cookie
  (Lax 照样随 GET 导航发送)就能把受害者的第三方身份绑进攻击者账号。
- 绑定标记 cookie 寿命 30 分钟,与 5 分钟的 state cookie 脱钩:在 provider 页停太久的
  绑定回来报 /settings?bind_error=oauth_state,而不是被当成登录失败送去 /login。
- 回调兜住 loginByProvider:禁用账号 → /login?error=account_disabled,其它异常记日志
  → /login?error=oauth_failed(顶级导航,不兜就是裸 500 白页)。
- GlobalExceptionHandler:Spring MVC 自己判定的 4xx(405/404/缺参数)保留原状态码,
  不再被兜底成 500。

## 测试

假 provider 支持带邮箱与 verified 标记、增加第二家。端到端:未验证邮箱经绑定植入 →
攻击者邮箱仍为空、受害者首登进注册;同家第二个号不挂靠、另一家照常挂靠;解绑后旧
身份进注册;换绑 GitHub 后旧号进注册、新号登入;无身份老账号仍按用户名登入;
绑定发起 GET 405 / 未登录 401 / 登录 200 带 url 与 cookie / 未知 provider 400;
过期绑定回设置页;禁用账号与意外异常的跳转。单测补挂靠/回退/撞约束/用户名后缀/
资料刷新。全量 402 通过。
SharedLinkInternalController 的三个接口唯一合法调用方是 ChatBot,走 127.0.0.1:8080
直连、不带 X-Forwarded-For。但 api.involutionhell.com 的 Caddy 兜底和前端
/api/community/links/:path* rewrite 都会把公网请求转到这里,唯一的门只剩
X-Internal-Key——key 一旦泄漏,谁都能以任意 Discord 身份投稿、读审核队列。

checkKey 最先检查 X-Forwarded-For:带了就 404,key 对也不行,也不暴露"接口配没配"
(503)。Spring 没配 forward-headers-strategy,这个头原样到得了 controller。

测试:三个接口带 X-Forwarded-For 均 404(提交不落库;按 id 读同一条,不带头 200、
带头 404)。
- INV-012(新):新账号只在邮箱 OTP 验证后由 completeRegistration 建;只有已验证邮箱进
  user_accounts.email;新号角色恰好 user;provider token 不落库。列出保护点与回归测试,
  新增 SecurityInvariantsTests#账号表和身份表不存第三方token(扫 schema.sql 两张表的
  CREATE/ALTER),注册端到端测试补断言角色/权限。
- INV-013(新):机器人桥接接口带 X-Forwarded-For 即 404,并写明依赖"没配
  forward-headers-strategy"这一前提。
- INV-007:绑定发起改 POST 及原因、标记 cookie 30 分钟必须长于 state cookie,补 4 条测试。
- INV-008:OTP 接线已上线;GA = AUTH_DISCORD_ALLOWLIST 不设或留空(不能写 "" / ,),
  GA 于 2026-10-18 执行;闸保留一个版本作回滚开关,之后经 superadmin review 退役。
- README、.env.example:本地 OAuth 回调与 AUTH_URL 端口 3010 → 3000(前端 pnpm dev
  即 next dev,默认 3000);更正"不配 AUTH_DISCORD_ID 就不显示 Discord 按钮"——按钮
  照样显示,点了回 /login?error=oauth_provider。
- usercenter/README.md:登录流程图补 OTP 注册分支与 /auth/register/* 状态码表;绑定入口
  改 POST、标记 30 分钟;解绑即撤销登录、资料刷新规则;bind_taken 的建议改成找管理员合并
  (那个账号多半解绑不了);灰度闸已 GA、保留作回滚开关;RegistrationService 已接线。
- ADR-001:documents.endpoints 换成真实的 POST /oauth/bind/{provider} 与 /auth/register/*
  (从没建过的 /api/auth/link/.../start|confirm 删掉);绑定流程、回跳契约、规范邮箱、
  资料刷新按实现改写;迁移表 OTP 注册、Discord GA(2026-10-18)标完成,M4 仍待一个稳定版本。
对应 Prisma 那张废弃的 NextAuth accounts 表,后端从未读写过它(git grep 无引用)。
留着只会让人以为 provider token 有地方落库——INV-012 恰恰要求不落库。
render 收 ?n=(^[A-Za-z0-9_-]{16,64}$)种进 ih_login_nonce httpOnly cookie,
回调登录成功跳 /#token=<T>&n=<n>,回调任何出口都清掉该 cookie。
缺 cookie 时退回不带 n 的 #token=,旧前端在后端先上线期间照常登录。
堵住 wrap-up review 的 opener 改写 tab 地址种入攻击者 token 的登录 CSRF。
handleBind 只接了 IdentityAlreadyBoundException,连接池耗尽、查询超时等
DataAccessException 会落到 GlobalExceptionHandler,顶级导航停在一页 JSON 上,
provider token 也没撤。与 handleLogin 对齐:log.error、撤 token、
跳 /settings?bind_error=oauth_failed。
pendingId 是 /auth/register/* 的唯一凭据,Resend 故障期间它会成批落进日志,
读日志的人 15 分钟内可重放它,把受害者的第三方身份注册进自己的邮箱。
只有 registration.otp.dev-console=true 时的 DEV-OTP 行仍打印它。
security review:按收件邮箱 5 封/天的限额能被攻击者用自己的号打满,锁死受害者
24h 的注册;计数还先于另一个限额的检查和实际发信,被拒或没送达的请求也吃额度。
- 限额:(邮箱, 身份)5/天 + 邮箱总量 20/天(防轰炸天花板)+ 身份 10/天 + 会话内 5 封、60s 冷却
- 全部限额查完才在同一把锁里一起占位,Resend 失败全部退还(冷却照算)
- SEND_FAILED 作废会话里本次的码,没送达的码不能被猜
更新 INV-012。
review 发现去掉 @transactional 全部测试照绿。新增:验证前同一身份被并发绑到
别的账号,verify 返回 410 且账号数不变(不留没有身份的孤儿账号);已确认去掉
@transactional 时该测试失败。登记到 INV-012。
接着 8889468 的限额改造,处理 review 与安全检查提出的三处:
- 码只在发信成功后才挂到会话上:发信途中的校验拿不到码,失败的发信不留任何可猜的东西,
  失败退还限额也就安全了(之前途中校验能命中,退还让猜码绕开所有日限额)
- 失败只退按邮箱计的两项;身份那项是调用方自己的预算,不退(否则换会话可无限打 Resend);
  会话内 5 封也照算
- 每个收件邮箱每天至多 10 次错码(跨会话、跨身份),到顶后正确码也不放行:多开身份不能多猜
- provider 已验证的邮箱 == 发码邮箱时视为邮箱主人,免按邮箱的两项全局限额(发信天花板、错码封顶),
  别人占满也锁不住他;只是声称该邮箱(未验证)不算
- PendingRegistration 带上 providerEmailVerified
- INV-012:写清码送达后才生效、按邮箱封顶错码、邮箱主人豁免,以及有意保留的残留风险
  (provider 未验证邮箱的人可能被锁一天,防接管优先),测试清单同步
- RegistrationService 注释去掉已不成立的"20 个会话 × 5 次"猜码上限说法
- docs/dev1.md:登录要走前端按钮(nonce 回显),直接打开 /oauth/render 拿回的 #token 会被拒收
@longsizhuo
longsizhuo force-pushed the feat/auth-provider-registry-m2b branch from f82f16c to e05fd20 Compare October 18, 2026 18:26
@longsizhuo longsizhuo changed the title feat(auth): provider 抽象消灭 switch + M2b 绑定流程 + 模块 README feat(auth): 登录系统收尾——provider 抽象 + M2b 绑定 + identity-first + OTP 注册 + 安全加固 Oct 18, 2026
@longsizhuo
longsizhuo merged commit 31f3fe7 into main Oct 18, 2026
@longsizhuo
longsizhuo deleted the feat/auth-provider-registry-m2b branch October 18, 2026 18:39
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.

2 participants