diff --git a/.env.example b/.env.example index 24a3c6a..a589c89 100644 --- a/.env.example +++ b/.env.example @@ -48,12 +48,12 @@ POSTGRES_PASSWORD=change_me # 你需要**自己**在 GitHub 注册一个 OAuth App(不要用社区共享 key,回调 URL 不会匹配): # 1. 打开 https://github.com/settings/developers → New OAuth App # 2. Application name:随便填,如 "involution-hell-dev (你的名字)" -# 3. Homepage URL:http://localhost:3010 -# 4. **Authorization callback URL:http://localhost:3010/api/auth/callback/github** +# 3. Homepage URL:http://localhost:3000 +# 4. **Authorization callback URL:http://localhost:3000/api/auth/callback/github** # 这个 URL 必须与 AUTH_URL 拼出来的一致,否则 GitHub 会拒绝并报 # "redirect_uri is not associated with this application" # 5. 创建后页面会显示 Client ID,再点 "Generate a new client secret" 得到 Secret -# 6. 把这两个值填到下面,AUTH_URL 保持 http://localhost:3010 +# 6. 把这两个值填到下面,AUTH_URL 保持 http://localhost:3000 # # 生产环境用另一个 prod App(已注册线上域名为回调),由部署机的 .env 提供, # 跟你本地这套完全独立,不会互相污染。 @@ -63,7 +63,8 @@ AUTH_SECRET= # Discord 登录(可选)。要开就到 https://discord.com/developers/applications 建个 App, # OAuth2 → Redirects 加 ${AUTH_URL}/api/auth/callback/discord,勾 identify + email scope, -# 把 Client ID / Secret 填这里。留空则站点不显示 Discord 登录、后端也不报错。 +# 把 Client ID / Secret 填这里。留空后端也不报错,但登录页的 Discord 按钮照样显示, +# 点了会回到 /login?error=oauth_provider。 AUTH_DISCORD_ID= AUTH_DISCORD_SECRET= # Discord 登录灰度白名单(逗号分隔的 Discord user id)。留空 = Discord 对所有人开放; @@ -94,10 +95,10 @@ OPENAI_MODEL=glm-4.6v-flash SPRING_APPLICATION_NAME=backend SERVER_PORT=8080 # 前端域名,用于 GitHub OAuth 回调重定向和 Sa-Token 会话重定向 -# 本地开发:http://localhost:3010(端口与前端 dev 一致;OAuth 回调拼成 +# 本地开发:http://localhost:3000(端口与前端 dev 一致;OAuth 回调拼成 # ${AUTH_URL}/api/auth/callback/github,必须和 GitHub OAuth App 注册的回调一致) # 生产部署:https://involutionhell.com -AUTH_URL=http://localhost:3010 +AUTH_URL=http://localhost:3000 # --- Actuator --- MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE=health,info,metrics diff --git a/AGENTS.md b/AGENTS.md index 185a6d4..2fa0aed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,10 @@ `column "x" does not exist`。init.sql 的每张表列集必须与 schema.sql 逐列一致 (这条正是 INV-004 `user_follows` 事故的教训扩展)。 + **唯一例外:可登录的 seed 账号**(admin/alice/auditor)只放 `init.sql` 和 + `test-schema.sql`,**绝不能同步进 `schema.sql`**——它在生产每次启动都跑, + 会把 README 公开的默认密码写进生产库(INV-011,2026-10 真实发生过)。 + 2. **schema.sql 里的任何 DML 必须幂等**:它每次启动都重跑。回填用 `INSERT ... ON CONFLICT DO NOTHING`,seed 用 `ON CONFLICT DO NOTHING/UPDATE`。 非幂等语句会在第二次启动 `spring.sql.init` 报错、后端拒绝启动。 diff --git a/README.md b/README.md index ca90fa8..bf3af12 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ cp .env.example .env ```bash # 启动 PostgreSQL 18 -# 此操作会自动创建 Schema 并初始化种子数据 (一致性与 NEON 保持同步) +# 此操作会自动创建 Schema 并初始化本地种子账号(只在本地库首次建卷时写入,生产不会有) docker compose up -d postgres ``` @@ -107,12 +107,12 @@ docker compose up -d postgres 注册步骤: 1. 打开 [GitHub Settings → Developer settings → OAuth Apps](https://github.com/settings/developers) → **New OAuth App** -2. **Authorization callback URL** 必须填 `http://localhost:3010/api/auth/callback/github`(端口跟前端 dev 一致;后端会拼 `${AUTH_URL}/api/auth/callback/github`,两边要对齐) +2. **Authorization callback URL** 必须填 `http://localhost:3000/api/auth/callback/github`(端口跟前端 dev 一致;后端会拼 `${AUTH_URL}/api/auth/callback/github`,两边要对齐) 3. 创建后把 Client ID 和(点 Generate 出来的)Client Secret 填到 `.env`: ``` AUTH_GITHUB_ID=Ov23li... AUTH_GITHUB_SECRET=xxxx - AUTH_URL=http://localhost:3010 + AUTH_URL=http://localhost:3000 ``` > [!TIP] @@ -123,14 +123,14 @@ docker compose up -d postgres 同 GitHub,每个开发者用自己的 Discord App: 1. [Discord Developer Portal](https://discord.com/developers/applications) → **New Application** -2. **OAuth2 → Redirects** 加 `http://localhost:3010/api/auth/callback/discord`(与 `${AUTH_URL}/api/auth/callback/discord` 对齐) +2. **OAuth2 → Redirects** 加 `http://localhost:3000/api/auth/callback/discord`(与 `${AUTH_URL}/api/auth/callback/discord` 对齐) 3. OAuth2 → 复制 **Client ID** 和 **Client Secret**,填到 `.env`: ``` AUTH_DISCORD_ID=... AUTH_DISCORD_SECRET=... ``` -不填就不显示 Discord 登录、后端也不报错(`/oauth/render/discord` 会重定向到 `?error=oauth_provider`)。scope 由后端固定为 `identify email`,无需在 Portal 额外设置。 +不填后端也不报错,但登录页的 Discord 按钮照样显示,点了会回到 `/login?error=oauth_provider`(`/oauth/render/discord` 判定未配置)。scope 由后端固定为 `identify email`,无需在 Portal 额外设置。 ### 4. 启动后端服务 按以下任一方式启动: @@ -166,7 +166,8 @@ Get-Content .env | Where-Object { $_ -match '^[^#].+=' } | ForEach-Object { 默认接口入口:`http://127.0.0.1:8080` ### 5. 调用示例接口 -内置种子账号: +本地种子账号(来自 `docker/init-db/init.sql`,只存在于本地 docker 库和测试库; +生产跑的 `schema.sql` 不 seed 任何可登录账号,见 SECURITY.md INV-011): - `admin / Admin@123456` - `alice / Alice@123456` diff --git a/SECURITY.md b/SECURITY.md index e1764a6..66fd352 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -61,8 +61,7 @@ - `usercenter/service/PasswordService#hash`(始终输出 bcrypt) - `usercenter/service/PasswordService#matches`(dual-mode:bcrypt + legacy SHA-256) - `usercenter/service/AuthService#login`(lazy upgrade:登录成功后把 legacy hash 就地升级) - - `src/main/resources/schema.sql`(seed bcrypt 哈希) - - `docker/init-db/init.sql`(seed bcrypt 哈希) + - `docker/init-db/init.sql`(seed bcrypt 哈希;schema.sql 不再 seed,见 INV-011) - `src/test/resources/test-schema.sql`(seed bcrypt 哈希) - **测试**: - `PasswordServiceTests#hashGeneratesBcryptOutput` @@ -131,18 +130,62 @@ - **保护点**:`OAuthController#login`(`/api/auth/callback/{provider}`)的 state 双提交校验——URL 里的 `state` 必须等于本次 `renderAuth` 种下的 `ih_oauth_state` httpOnly cookie,缺失/不匹配即在换 token 前拒绝。 + 登录成功的 `#token=` 必须带回发起登录那个 tab 的 nonce:前端(SignInButton)生成随机 `n` + 存进 sessionStorage,`GET /oauth/render/{provider}?n=` 把它种进 `ih_login_nonce` httpOnly cookie + (`^[A-Za-z0-9_-]{16,64}$` 才种,Max-Age 同 state cookie 300s),回调成功跳 + `/#token=&n=`;回调的每个出口都清掉这个 cookie。前端(`lib/use-auth.tsx`)只收 `n` 与本 tab + 存的值相等的 token。 - **测试**: - `OAuthControllerIntegrationTests#callbackWithoutStateCookieIsRejectedBeforeTokenExchange` - `OAuthControllerIntegrationTests#callbackWithMatchingStateCookieProceedsPastStateCheck`(反向) - `OAuthControllerIntegrationTests#renderSetsStateCookie`(前置条件) + - `OAuthControllerIntegrationTests#renderWithValidNonceSetsLoginNonceCookie` + - `OAuthControllerIntegrationTests#renderWithInvalidOrMissingNonceSetsNone` + - `OAuthControllerIntegrationTests#loginSuccessEchoesNonceFromCookieAndClearsIt` + - `OAuthControllerIntegrationTests#loginSuccessWithoutNonceCookieRedirectsWithPlainToken`(老前端兼容) + - `OAuthControllerIntegrationTests#nonceCookieIsClearedOnErrorAndRegistrationRedirects` + - `OAuthControllerIntegrationTests#bindEntryRequiresLogin`(绑定入口鉴权) + - `OAuthControllerIntegrationTests#bindEntryRequiresLoginEvenForUnknownProvider` + - `OAuthControllerIntegrationTests#bindStartIsNotAcceptedViaGet` + - `OAuthControllerIntegrationTests#bindStartReturnsAuthorizeUrlAndSetsStateAndMarkerCookies`(反向) + - `OAuthControllerIntegrationTests#bindStartWithUnknownProviderIsJson4xxAndSetsNoCookies` + - `OAuthControllerIntegrationTests#bindCallbackInSameSessionAttachesIdentityWithoutCreatingAccount`(反向) + - `OAuthControllerIntegrationTests#bindCallbackAfterLogoutIsRejected` + - `OAuthControllerIntegrationTests#bindCallbackUnderAnotherUsersSessionIsRejected` + - `OAuthControllerIntegrationTests#replayedBindCallbackIsRejectedInsteadOfFallingIntoLogin` + - `OAuthControllerIntegrationTests#bindThatOutlivesStateCookieReturnsToSettingsNotLogin` - **为什么**:callback 是 provider 发起的顶级 GET,不带 Authorization header;若 直接信任 URL `state`(尤其未来绑定流程把 loginId 塞进 state),攻击者可发起 流程拿到合法 state 诱导受害者授权,把受害者的第三方身份绑/登进攻击者预期的 账号(登录 CSRF / 绑定劫持)。防线:state 必须回证到"发起本次流程的同一浏览器" ——即 render 时种下、callback 时比对的 cookie,攻击者无法向受害者浏览器种此 - cookie。绑定目标账号(M2)同理只能来自服务端校验过的当前会话,绝不取自 state。 + cookie。 + + **绑定流程(M2b,已实现)同理**:`POST /oauth/bind/{provider}` 带 `@SaCheckLogin`, + 返回 provider 授权地址(`{url}`)由前端跳转;**不接受 GET**——GET 能被跨站顶级导航触发, + 配合被种进受害者浏览器的攻击者 satoken cookie(SameSite=Lax 照样随 GET 导航发送), + 就能把受害者的第三方身份绑进攻击者账号。userId 取自服务端已校验的会话;以随机 state 为 key 把它存进服务端内存 + (`OAuthController#bindIntents`,5 分钟 TTL、一次性消费),回调时取回并**二次核对 + 当前会话仍是同一个人**(中途登出/换号即拒绝)。state 在此仅作不可猜测的查找键, + 不承载任何可信信息——把 userId 写进 state 就等于把绑定劫持焊死进流程。 + 意图丢失(过期/重放/进程重启)时回调**不得**落进登录分支——否则会按该第三方身份 + 建号或登进别的账号。发起绑定时同时种 `ih_oauth_bind` 标记 cookie(发起登录时清掉), + 回调见标记而无意图即在换 token 前弹回 `/settings?bind_error=bind_session`。 + 标记寿命 30 分钟,**必须长于** 5 分钟的 state cookie:两者同时过期的话,在 provider 页 + 停太久的绑定回来什么都不剩,会被送去 `/login?error=oauth_state`,用户再点登录就分叉出新账号; + 标记还在则回到 `/settings?bind_error=oauth_state`。 + **nonce 回显**补的是 state 管不到的另一半:state 只证明"回调属于发起它的浏览器",挡不住攻击者用 + 自己的账号走完登录、再把受害者的 tab 导航到 `https://involutionhell.com/#token=<攻击者 token>` + (`window.open` 拿到的句柄、provider 授权页都不切断 opener)。受害者就登进了攻击者的账号,之后的 + AI 对话、投稿、甚至设置页里的"连接"都落到攻击者名下。攻击者拿不到受害者 tab 里的 nonce, + 带不出能对上的 `n`。nonce 放 cookie 而不是 state:state 进 provider 的 URL,cookie 只在本浏览器。 + cookie 缺失时退回不带 `n` 的 `#token=`,只为让旧前端在后端先上线期间照常登录;新前端拒收它。 - **历史**:2026-07-19 随多 provider 身份体系 M1 引入(RFC #42 / ADR-001)。 编号说明:INV-006 已被"付费 LLM 端点限流"占用,按流水规则用 INV-007。 + 2026-10-18 #57 review:绑定发起由 cookie 鉴权的 GET 改为 POST(review 复现了"种 token + + 顶级导航"把受害者 Discord 绑进攻击者账号的链路),标记寿命与 state cookie 脱钩。 + 同日 wrap-up review:前端只在 sessionStorage 存时间戳的 nonce 挡不住 opener 改写 tab 地址, + 改为 render 种 `ih_login_nonce`、回调回显 `&n=`(后端先上线,再上前端)。 ## INV-008 · Discord 灰度闸只放行白名单与已有账号,且不得静默失效 @@ -154,8 +197,8 @@ - **测试**:`OAuthControllerAllowlistTests` (解析畸形取值 / 未配即全开 / 配了却解析不出 id 时拒绝所有人 / 空 uuid 拒绝 / 已有 identity 放行 / identity 查询失败不放行) -- **为什么**:灰度要拦的是**建新号**——新用户的"验证邮箱→建号"(OTP wiring)尚未 - 完成,此时放任新用户从 Discord 进来会把已有 GitHub 用户分叉成第二个账号。 +- **为什么**:灰度要拦的是**建新号**——新用户的"验证邮箱→建号"(OTP wiring,INV-012) + 上线之前,放任新用户从 Discord 进来会把已有 GitHub 用户分叉成第二个账号。 两个方向都必须防住: 1. **不得静默 fail-open**。闸由一个 env 驱动,缺失/拼错时值为空,行为与"故意 GA" 完全一致。因此启动时必须播报当前模式(闸启用 N 个 id / 闸关闭全开放), @@ -165,10 +208,15 @@ "没人能登"(立刻可见、无损失),不能选"所有人能登"(正是灰度要防的事)。 另外闸**不能**卡住已有账号的回访登录,否则会把用户锁在自己的账号外面,而提示 只说"灰度中",既不告知账号存在也无恢复路径。 -- **GA 流程**:OTP wiring 上线后清空 `AUTH_DISCORD_ALLOWLIST` 即全量开放; - 在那之前清空它等于提前放开分叉风险。 +- **GA 流程**:OTP wiring 已上线(INV-012:新号一律先验证邮箱,验证出的邮箱已属于别的 + 账号即 409 不建号)。GA = `AUTH_DISCORD_ALLOWLIST` 不设或留空(`AUTH_DISCORD_ALLOWLIST=`), + 启动日志应为"闸关闭,Discord 登录对所有人开放"。**不要**写成 `""`、`''` 或 `,`:配了值却 + 解析不出 id 是失败关闭(新 Discord 用户全被拒,已有身份不受影响),启动日志报 ERROR。 + **GA 于 2026-10-18 执行。** 闸的代码保留一个版本、处于惰性状态,作为回滚开关: + 要重新关上 Discord 注册,设 `AUTH_DISCORD_ALLOWLIST=` 后重建容器即可(已有 Discord + 身份照常登录)。稳定一个版本后,经 superadmin review 的 PR 退役本条(标 retired)并删掉闸。 - **历史**:2026-07-24 随 Discord 灰度(#53 / involutionhell#389)引入, - 2026-07-25 按 xhigh review(#54)补齐本不变量与测试。 + 2026-07-25 按 xhigh review(#54)补齐本不变量与测试。2026-10-18 OTP 注册接线、GA。 ## INV-009 · docker-compose 的端口映射一律只绑 127.0.0.1 @@ -231,3 +279,117 @@ IH 的 satoken 绝不出 IH——给出去,HoloCard 一旦被攻破就能以用户身份操作 IH。 - **历史**:2026-10-07 随 HoloCard 接入 IH 通行证引入(授权码 + PKCE S256)。client 表:`holocard`(线上)、 `holocard-staging`(HoloCard 的 staging 跑着没合并的 PR,单独的 secret 和回跳地址,拿到的码换不了线上的会话)。 + +## INV-011 · schema.sql 不得 seed 可登录的账号 + +- **保护点**: + - `src/main/resources/schema.sql`:不含任何 seed 账号的密码哈希;唯一的系统账号 `discord-bridge` + 用 `password_hash='!'` + `enabled=FALSE`,登录不了 + - `docker/init-db/init.sql`、`src/test/resources/test-schema.sql`:admin/alice/auditor 只在这两处 seed +- **测试**:`SecurityInvariantsTests#schema_sql里不允许seed带密码哈希的账号`(扫 schema.sql 里 + `PasswordService.matches` 认的两种格式:bcrypt `$2[aby]$NN$` 和 64 位十六进制的 legacy SHA-256,注释也扫) +- **为什么**:生产 `SPRING_SQL_INIT_MODE=always`,schema.sql 每次启动都跑。seed 写在这里, + 生产库就有 admin/alice/auditor 三个启用账号,密码是 README 里公开的默认值,admin 还带 admin 角色, + 而 `/auth/login` 公网可达、没有限流。AGENTS.md 的"三处 schema 同步"规则在这里是例外: + seed 不能"同步"回 schema.sql。 +- **历史**:2026-10-18 审计发现生产库这三个账号处于启用状态,只读核对确认哈希对得上公开密码。 + 本不变量把 seed 移出 schema.sql,只保证以后的启动不再写入;生产里已有的三行要另外处理, + 删掉 seed 不会动它们。 + +## INV-012 · 新账号只在邮箱 OTP 验证之后建;只有已验证的邮箱进 user_accounts.email + +- **保护点**: + - `usercenter/service/AuthService#loginByProvider`:**从不建号**。身份 → 用户名(仅无身份行的老账号) + → 已验证邮箱挂靠都没命中时返回 `NeedsRegistration`。`updateProfile` 只收 provider 已验证的邮箱 + (`AuthProvider#isEmailVerified`),未验证的传 null;邮箱挂靠只用已验证邮箱,且不挂进已有同家身份的账号。 + - `usercenter/service/AuthService#completeRegistration`:**唯一的建号入口**,只吃 + `RegistrationService.VerifiedRegistration`。email 写本次 OTP 验证过的地址(不是 provider 预填); + 角色恰好 `{user}`、无额外权限;建号 + 挂身份 + 登入同一事务。验证出的邮箱已属于任何账号 + (LOWER 匹配)→ `EmailTakenException` → 409,什么都不写、不合并。 + - `usercenter/controller/OAuthController#handleLogin`:新用户 302 `/login/verify`,pendingId 只进 + httpOnly、SameSite=Lax 的 `ih_reg_pending` cookie(Max-Age 900),**绝不进 URL**。 + - `usercenter/controller/AuthController` `/auth/register/{pending,send-otp,verify}`:公开 + (`SaTokenConfigure` notMatch `/auth/register/*`),cookie 是唯一凭据。 + - `usercenter/service/RegistrationService`: + - 发信:会话内至多 5 封、间隔 ≥60s;每个(收件邮箱, 第三方身份)每天 5 封、每个收件邮箱每天 20 封、 + 每个第三方身份每天 10 封。先查完全部限额才一起占位(被一个限额拒掉的请求不吃别的限额)。 + - 码只在**发信成功之后**才挂到会话上:发信途中、发信失败都没有可校验的码。失败只退还按邮箱计的 + 两项,会话与身份两项照算(调用方自己的预算),冷却照算。 + - 校验:累计 5 次错码即作废会话;每个收件邮箱每天至多 10 次错码(跨会话、跨身份),到顶后对它的 + 校验一律失败(正确码也不放行)。 + - 邮箱主人(provider 已验证的邮箱 == 发码邮箱)免按邮箱的两项全局限额(发信天花板、错码封顶)。 + - OTP 10 分钟、会话 15 分钟。pendingId 不进日志(只有 `registration.otp.dev-console=true` 时的 + DEV-OTP 行打印它)。 + - `src/main/resources/schema.sql`:`user_accounts` / `user_identities` 没有存 provider token 的列; + OAuth 换到的 token 只用来取一次资料,拒绝/失败时 `OAuthController#revokeQuietly` 撤销。 +- **测试**: + - `AuthServiceTests#newGithubUserStartsRegistrationInsteadOfCreatingAccount` + - `AuthServiceTests#unverifiedEmailDoesNotAutoLinkAndStartsRegistration` + - `AuthServiceTests#returningLoginNeverFillsAnUnverifiedEmail` + - `AuthServiceTests#verifiedEmailDoesNotAutoLinkIntoAccountThatAlreadyHasThisProvider` + - `AuthServiceTests#completeRegistrationCreatesAccountWithVerifiedEmailAndIdentity`(email 取 OTP 地址、角色恰好 user) + - `AuthServiceTests#completeRegistrationRefusesEmailOfExistingAccountWithoutLinking` + - `RegistrationFlowIntegrationTests#newUserIsSentToVerifyPageWithPendingIdOnlyInHttpOnlyCookie` + - `RegistrationFlowIntegrationTests#fullRegistrationCreatesAccountWithVerifiedEmailAndLogsIn`(端到端,含角色/权限) + - `RegistrationFlowIntegrationTests#verifiedEmailOfExistingAccountIsNotMergedAndCreatesNothing` + - `RegistrationFlowIntegrationTests#identityTakenBeforeVerifyIs410AndLeavesNoOrphanAccount`(建号 + 挂身份同一事务;去掉 `@Transactional` 即失败) + - `RegistrationFlowIntegrationTests#wrongCodeIs400UntilLockThen410` + - `RegistrationFlowIntegrationTests#endpointsArePublicButNeedTheCookie` + - `RegistrationServiceTests#fifthWrongCodeKillsTheSession_noResendAfterLock` + - `RegistrationServiceTests#anotherIdentityExhaustingItsSendsToAnEmailDoesNotLockOutTheOwner` + - `RegistrationServiceTests#sendsToOneEmailHaveAGlobalCeilingAcrossIdentities` + - `RegistrationServiceTests#requestsRejectedByOneCapDoNotConsumeTheOthers`(4 个号;改回逐项先加后查即失败) + - `RegistrationServiceTests#ownerIsExemptFromThePerEmailCeiling` + - `RegistrationServiceTests#failedSendsRefundTheEmailQuotaButNotTheCallersOwnBudget` + - `RegistrationServiceTests#failedSendsStillCountWithinTheSession` + - `RegistrationServiceTests#codeOfAFailedSendCannotBeVerified` + - `RegistrationServiceTests#codeIsNotVerifiableWhileTheSendIsInFlight` + - `RegistrationServiceTests#verifyFailuresAgainstOneEmailAreCappedAcrossSessionsAndIdentities` + - `RegistrationServiceTests#sendsFromOneIdentityAreCappedAcrossSessions` + - `OAuthControllerIntegrationTests#unverifiedEmailPlantedViaBindNeverCapturesAVictimsFirstLogin`(攻击链端到端) + - `OAuthControllerIntegrationTests#verifiedEmailNeverAutoLinksIntoAccountThatAlreadyHasThatProvider` + - `SecurityInvariantsTests#账号表和身份表不存第三方token` +- **为什么**:`user_accounts.email` 是已验证邮箱自动挂靠的锚点——谁的 provider 已验证邮箱命中它, + 谁就登进这个账号。锚点里混进一个没人验证过的邮箱,就是给"谎称别人邮箱"的第三方号留了接管入口: + #57 review 复现过"攻击者绑一个报了受害者邮箱的 Discord → 登录把它写进自己的空 email → + 受害者第一次用 GitHub 登录就落进攻击者账号"。所以: + 1. 建号前必须由我们自己的 OTP 证明邮箱归属,且这一步只有一个入口,provider 登录路径上不留第二个建号分支。 + 2. pendingId 只在发起人浏览器的 httpOnly cookie 里:放进 URL 就能转发给受害者,让受害者在攻击者的 + 注册会话里验证自己的邮箱(预注册劫持)。不要放宽成 SameSite=None。 + 3. 验证出的邮箱属于已有账号时只报 409,不合并(ADR-001 红线"禁止静默合并");合并账号的唯一路径是 + 登录后显式绑定(INV-007)。409 出现在 OTP 通过之后,不能拿来探测哪些邮箱注册过。 + 4. 新号角色恰好 `user`:注册入口是公开的,任何角色/权限漂移都直接是越权。 + 5. provider token 不落库:落了,拖库就从"拿到一堆资料"升级成"拿着用户的授权去调对方 API"。 + 6. 猜码总量必须按目标邮箱封顶:猜中 = 用受害者邮箱建号 = 受害者之后首登被已验证邮箱挂靠进攻击者 + 账号(同第 1 条的接管)。按会话、按身份封顶都挡不住"多开身份多猜",所以每个邮箱每天至多 10 次错码 + (约 1e-5/天)。码只在送达后生效:否则发信途中、或配额耗尽导致的失败发信,都能被拿来白猜。 + 7. 限额尽量不变成锁死别人的工具:单个号对单个邮箱的发信额度按(邮箱, 身份)算;按邮箱只留防轰炸的 + 天花板(20);失败退还按邮箱计的额度。邮箱主人(provider 已验证该邮箱)豁免按邮箱的全局限额—— + 攻击者拿不到别人邮箱的"已验证"副本,豁免不开口子。 + **已知残留**:provider 没验证过该邮箱的人(GitHub 隐藏邮箱很常见),可能被知道其邮箱的人用 2 个第三方号 + 打满错码封顶、锁住当天用这个邮箱注册。这是有意的取舍:被锁一天(可换邮箱、次日再来、找管理员)远轻于 + 账号被接管。要消除得换成更强的凭据(如魔法链接),不是放宽封顶。 + 8. pendingId 是注册会话的唯一凭据,日志里出现就等于能被读日志的人重放:拿去发码到自己邮箱、验证, + 受害者的第三方身份就挂进了攻击者建的账号。 +- **历史**:2026-10-18 随 OTP 注册接线与 Discord GA 引入(#57 review 的未验证邮箱植入攻击链)。 + 同日 wrap-up review:按邮箱 5 封/天的限额可被用来锁死受害者注册、计数先于检查与发信,改成三层限额 + + 先查后占;"OTP 发信失败"日志去掉 pendingId。随后的复核发现"失败全部退还"让发信途中可校验的码绕开所有 + 日限额猜码、身份限额退还让 Resend 调用不再有上限,改成码送达后才生效 + 只退按邮箱的限额 + 按邮箱封顶错码 + + 邮箱主人豁免。 + +## INV-013 · 机器人桥接接口不能经代理访问 + +- **保护点**:`community/controller/SharedLinkInternalController#checkKey`(`/api/community/links/internal` + 的全部三个接口):请求带 `X-Forwarded-For` 即 404,排在 key 校验与"未配置 503"之前。 +- **测试**: + - `SharedLinkInternalControllerIntegrationTests#submit_viaProxy_returns404` + - `SharedLinkInternalControllerIntegrationTests#readEndpoints_viaProxy_return404`(含不带头 200 的反向) +- **为什么**:唯一合法调用方 ChatBot 走 127.0.0.1:8080 直连,不带 `X-Forwarded-For`。但 + api.involutionhell.com 的 Caddy 兜底和前端 `/api/community/links/:path*` rewrite 都会把公网请求转到 + 这里,二者都会加 `X-Forwarded-For`。不挡的话唯一的门就是 `X-Internal-Key`:key 一旦泄漏(日志、 + ChatBot 配置),谁都能以任意已绑定 Discord 的身份投稿、读审核队列。返回 404 而不是 403, + 从公网看这组接口就不存在。 + 依赖的前提:Spring **没有**配 `server.forward-headers-strategy`。配成 `framework` 或 `native` 后, + `X-Forwarded-For` 会在到达 controller 前被消费掉,本检查静默失效——要开就得同时把这里改成按 + 真实对端地址(loopback)判断。 +- **历史**:2026-10-18 随 #57 后续加固引入。 diff --git a/docker/init-db/init.sql b/docker/init-db/init.sql index c0ce141..a650539 100644 --- a/docker/init-db/init.sql +++ b/docker/init-db/init.sql @@ -19,7 +19,8 @@ CREATE TABLE IF NOT EXISTS user_accounts ( preferences JSONB NOT NULL DEFAULT '{}'::jsonb ); --- Default seeds for user_accounts +-- Default seeds for user_accounts —— 仅本地开发库(数据卷首次创建时写入一次)。 +-- 生产 schema.sql 不 seed 这些账号(INV-011),别把它们"同步"回 schema.sql。 -- admin / Admin@123456 -- alice / Alice@123456 -- auditor / Audit@123456 diff --git a/docs/dev1.md b/docs/dev1.md index c473868..935f841 100644 --- a/docs/dev1.md +++ b/docs/dev1.md @@ -5,7 +5,7 @@ | 说明 | 路径 | |------|------| | 后端监听 | `http://localhost:8080` | -| 发起 GitHub 登录(浏览器直跳) | `http://localhost:8080/oauth/render/github` | +| 发起登录 | 走前端 `localhost:3000/login` 的按钮(它生成一次性 nonce 带给 `/oauth/render/{provider}?n=`;直接打开 render 地址拿回的 `#token` 会被前端拒收) | | GitHub 回调(经 Next.js rewrite 代理) | `localhost:3000/api/auth/callback/github` → `localhost:8080/api/auth/callback/github` | | 获取当前用户 | `GET /auth/me`(需 `satoken` header) | | 退出登录 | `POST /auth/logout`(需 `satoken` header) | @@ -89,11 +89,13 @@ ``` 用户点击登录 - → 前端跳转 /oauth/render/github(后端直接重定向 GitHub) + → 前端生成一次性 nonce 存 sessionStorage,跳转 /oauth/render/github?n= + (后端把 nonce 记进 ih_login_nonce cookie,直接重定向 GitHub) → GitHub 回调 /api/auth/callback/github(经 Next.js rewrite 转发给后端) - → 后端 JustAuth 解析 AuthUser,查找或创建 user_accounts 记录 + → 后端 JustAuth 解析 AuthUser:身份命中 / 老账号 / 已验证邮箱挂靠 → 登入; + 都没命中 → 302 /login/verify 走邮箱 OTP 注册(见 usercenter/README.md) → StpUtil.login(userId) 建立 Sa-Token 会话 - → 后端重定向到前端首页,URL 携带 ?token=xxx - → 前端 AuthProvider 读取 token 存入 localStorage,清除 URL 参数 + → 后端重定向到前端首页,fragment 携带 #token=xxx&n= + → 前端 AuthProvider 核对 n 与 sessionStorage 里的 nonce,一致才把 token 存入 localStorage,清除 fragment → 后续请求通过 x-satoken header 或 satoken header 传递 token ``` diff --git a/docs/wiki/adr/001-multi-provider-identity.md b/docs/wiki/adr/001-multi-provider-identity.md index f41caff..d60b881 100644 --- a/docs/wiki/adr/001-multi-provider-identity.md +++ b/docs/wiki/adr/001-multi-provider-identity.md @@ -14,14 +14,18 @@ documents: endpoints: - GET /oauth/render/{provider} - GET /api/auth/callback/{provider} - - POST /api/auth/link/{provider}/start - - POST /api/auth/link/{provider}/confirm + - POST /oauth/bind/{provider} + - GET /auth/register/pending + - POST /auth/register/send-otp + - POST /auth/register/verify symbols: - src/main/resources/schema.sql (user_identities 表 DDL + 回填) - src/main/java/com/involutionhell/backend/usercenter/model/UserIdentity.java - src/main/java/com/involutionhell/backend/usercenter/repository/UserIdentityRepository.java - - src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java (loginByGithub → M1 loginByProvider) - - src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java (render/callback) + - src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java (loginByProvider / completeRegistration) + - src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java (注册 OTP) + - src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java (render/bind/callback) + - src/main/java/com/involutionhell/backend/usercenter/controller/AuthController.java (/auth/register/*) --- # ADR-001:多 Provider 身份体系与 OAuth state 防护 @@ -66,22 +70,22 @@ state 里的用户身份**(登记为安全不变量 INV-007,随 M1 落进 Se INV-006 已被"付费 LLM 端点限流"占用,编号按 SECURITY.md 流水规则永不复用)。 真实信息挂在服务端 intent 记录上(nonce 为 key,Caffeine 存储,5 分钟 TTL)。 -- **登录**(无会话):`/oauth/render/{provider}` 生成 nonce → 存 intent{mode=login} → - 种 `oauth_flow=` cookie(httpOnly + **SameSite=Lax**,Strict 会把跨站顶级 +- **登录**(无会话):`/oauth/render/{provider}` 生成 nonce → + 种 `ih_oauth_state=` cookie(httpOnly + **SameSite=Lax**,Strict 会把跨站顶级 导航的 cookie 剥掉)→ 跳 provider。callback 核对 URL state == cookie nonce, 防登录 CSRF:攻击者无法向受害者浏览器种自己的 cookie。 -- **绑定**(已登录,从设置页发起):satoken 在 localStorage,callback(provider 发起的 - 顶级 GET)拿不到会话,所以把"你是谁"提前到发起时捕获—— - 1. `POST /api/auth/link/{provider}/start`(fetch 带 satoken,可认证)→ 服务端记 - intent{mode=bind, userId=当前会话} → 种 cookie → 返回授权链接; - 2. callback 核对 state==cookie,把 provider 身份暂存进 intent,跳回 - `settings?link_confirm=`; - 3. 前端确认页 → `POST .../confirm`(再带 satoken)→ 服务端二次核对 - 当前会话 == intent.userId → 插入 identity。 +- **绑定**(已登录,从设置页发起):把"你是谁"提前到发起时捕获—— + 1. `POST /oauth/bind/{provider}`(fetch 带 satoken,`@SaCheckLogin`)→ 服务端以 state 为 key + 记 intent{userId=当前会话}(内存,5 分钟、一次性)→ 种 `ih_oauth_state` + `ih_oauth_bind` + 标记 cookie → 返回 `{url}`,前端跳转。**不用 GET**:GET 能被跨站顶级导航触发,配合被种进 + 浏览器的别人的 satoken cookie 就是绑定劫持。 + 2. callback 核对 state==cookie,取回 intent,二次核对当前会话 == intent.userId → 直接插入 + identity(没有单独的确认页)。intent 丢了但标记还在 → 回设置页报错,绝不落进登录分支。 绑定劫持(攻击者发起流程诱导受害者授权)被 cookie 那关挡住:受害者浏览器没有 - 攻击者的 `oauth_flow` cookie。 -- 绑定回跳契约:`settings?linked={provider}` / `?link_error=identity_taken`(撞 - UNIQUE 是必然出现的用户可见错误,不混进 oauth_failed)。 + 攻击者的 state cookie。 +- 绑定回跳契约:`/settings?bind=ok` / `?bind_error=`(`bind_taken` / `bind_duplicate` / + `bind_already_yours` / `bind_session` / `oauth_*`;撞 UNIQUE 是必然出现的用户可见错误, + 不混进 oauth_failed)。 ## 其他已定结论 @@ -98,20 +102,21 @@ INV-006 已被"付费 LLM 端点限流"占用,编号按 SECURITY.md 流水规 登录方式"的判定中,不可用密码不算登录方式,否则 OAuth 用户解绑唯一身份后永久锁死。 - **新用户 username 生成**:provider login 转 slug + 冲突短随机后缀;**禁纯数字** (撞 `/u/` 路由"纯数字=github_id"的解析约定)、禁 provider 前缀。存量 - `github_` 用户名永不强迁。 -- **资料刷新只填空缺字段**:多 provider 下 last-login-wins 会互相覆盖头像、 - 用未验证邮箱覆盖 email。 + `github_` 用户名永不强迁。(现状:slug 方案未做,仍是 `{provider}_{id}`, + 被已解绑该身份的老账号占着时加 6 位随机后缀。) - **邮箱关联只信"provider 已验证"的邮箱**。第三方登录时 identity 查不到,若 provider 的**已验证**邮箱唯一匹配到已有账号 → 自动挂靠(防分叉,主路径,见 - `AuthService.autoLinkByVerifiedEmailOrCreate`)。**禁止用未验证邮箱合并**—— + `AuthService.autoLinkByVerifiedEmail`)。**禁止用未验证邮箱合并**—— 攻击者拿受害者邮箱注册的第三方号无法把邮箱标 verified(要点验证链接=控制邮箱), - 所以已验证邮箱自动关联不增加攻击面(Auth0/Keycloak 同款)。多个匹配时保守建新号。 -- **规范邮箱(用户自有 OTP)** 是后续独立子系统:注册时用我们自己的验证码验一次邮箱, - 使 email 成为账号的规范已验证身份,覆盖"provider 没给邮箱/未验证"的情况。 - **前置**:需要事务邮件服务 + `noreply@involutionhell.com` 发信域名——不能用 - ChatBot 那个个人 Gmail(额度/投递率/隐私都不行)。待发信基础设施定了再做。 -- **资料刷新只填空缺字段**:多 provider 下 last-login-wins 会互相覆盖头像、 - 用未验证邮箱覆盖 email。自动关联挂靠时**不覆盖**已有账号资料。 + 所以已验证邮箱自动关联不增加攻击面(Auth0/Keycloak 同款)。前提是锚点 + `user_accounts.email` 本身只来自已验证来源(INV-012)。多个匹配、或目标账号已有 + 同家身份时不挂靠,进 OTP 注册。 +- **规范邮箱(用户自有 OTP)**:任何 provider 的新账号都先用我们自己的验证码验一次邮箱 + (Resend,`noreply@involutionhell.com`),验过的邮箱才写进账号,覆盖"provider 没给邮箱/ + 未验证"的情况。验证出的邮箱已属于已有账号 → 409,不合并,让用户登录原账号后走 M2b 绑定。 +- **资料刷新**:email / github_id 只补空缺字段,email 只收已验证来源(未验证邮箱覆盖锚点 + 就是接管入口);展示名/头像在账号的原始 provider(用户名前缀)登录时刷新,别家登录只补空—— + 多 provider 下 last-login-wins 会互相覆盖。自动关联挂靠时**不覆盖**已有账号资料。 ## 迁移阶段 @@ -123,6 +128,8 @@ INV-006 已被"付费 LLM 端点限流"占用,编号按 SECURITY.md 流水规 | 防分叉 | 已验证邮箱自动关联(登录时挂靠而非分叉) | ✅ 本 PR | | M3 后端 | Discord provider(自定义 AuthSource)+ OAuth 端点泛化为 {provider} | ✅ #48 | | M3 前端 | 登录页 Discord 按钮 + 设置页身份 UI | ✅ #382/#383 | -| OTP 注册 | 自有验证码验邮箱→规范身份(**待事务邮件基础设施**) | 待做 | -| M2b 绑定 | 设置页"连接新 provider"(可选,次要路径) | 待做 | -| M4 | identities 稳定一个版本后删 `github_id` 列(单独拆期,保回滚路径) | 待做 | +| OTP 注册 | 自有验证码验邮箱→规范身份;新账号只经 `completeRegistration` 建(INV-012) | ✅ #57(2026-10-18) | +| M2b 绑定 | 设置页"连接新 provider"(可选,次要路径) | ✅ #57 | +| 登录主查翻转 | `loginByProvider` 改 identity-first:先查 `user_identities`,miss 才回退 username → 已验证邮箱挂靠 → 建号;`updateProfile` 只补空列(不覆盖、不写 NULL、非 github 不写 `github_id`)。**必须与 M2b 同时或更早上线**:绑定后身份所属账号的用户名不再是 `{provider}_{id}`,username 主查会 miss → 邮箱不同即分叉,GitHub 则撞 `UNIQUE(github_id)` | ✅ #57 | +| Discord GA | OTP 注册上线后清空 `AUTH_DISCORD_ALLOWLIST`;闸保留一个版本作回滚开关(INV-008) | ✅ 2026-10-18 | +| M4 | identities 稳定一个版本后删 `github_id` 列(单独拆期,保回滚路径) | 待做(等一个稳定版本) | diff --git a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java index ea660e8..271a149 100644 --- a/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java +++ b/src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java @@ -19,7 +19,7 @@ public void addInterceptors(InterceptorRegistry registry) { SaRouter .match("/**") // 拦截所有路由 .notMatch("/auth/login") // 账号密码登录 - .notMatch("/auth/register") // 注册 + .notMatch("/auth/register/*") // 第三方登录后的邮箱验证注册(凭 httpOnly cookie 里的 pendingId) .notMatch("/oauth/render/*") // OAuth 授权发起(github / discord / ...,未登录才需要) .notMatch("/api/auth/callback/*") // OAuth 回调(github 路径与 OAuth App 注册保持一致) .notMatch("/analytics/top-docs") // 文档热榜公开接口 diff --git a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java index b6d0e1f..2e04526 100644 --- a/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java +++ b/src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java @@ -12,6 +12,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindException; +import org.springframework.web.ErrorResponse; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -130,6 +131,12 @@ public ResponseEntity> handleBusiness(Exception exception) { */ @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpected(Exception exception) { + // Spring MVC 自己判定的 4xx(方法不允许 / 路由不存在 / 缺参数……)保留原状态码, + // 否则全被兜成 500,客户端分不清"调错了"和"服务器坏了" + if (exception instanceof ErrorResponse framework) { + return ResponseEntity.status(framework.getStatusCode()) + .body(ApiResponse.fail(framework.getBody().getDetail())); + } // 走 SLF4J 统一进日志管道(含 trace id / 采集到 Loki 等),不再用 printStackTrace 污染 stdout log.error("未处理的异常", exception); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) diff --git a/src/main/java/com/involutionhell/backend/community/controller/SharedLinkInternalController.java b/src/main/java/com/involutionhell/backend/community/controller/SharedLinkInternalController.java index 095eb11..491c52d 100644 --- a/src/main/java/com/involutionhell/backend/community/controller/SharedLinkInternalController.java +++ b/src/main/java/com/involutionhell/backend/community/controller/SharedLinkInternalController.java @@ -28,8 +28,10 @@ * 机器人桥接渠道的提交接口。 * * 设计目标: - * - ChatBot 走 127.0.0.1:8080 直连;但 api.involutionhell.com 的 Caddy 兜底和前端 - * /api/community/links/:path* rewrite 都会转发到这里,公网可达,唯一的门是下面的 key + * - 只给本机直连:ChatBot 走 127.0.0.1:8080,不带 X-Forwarded-For。api.involutionhell.com + * 的 Caddy 兜底和前端 /api/community/links/:path* rewrite 也会把请求转到这里,但都会加 + * X-Forwarded-For——带这个头的一律 404,当这组接口不存在(INV-013)。Spring 没配 + * forward-headers-strategy,这个头原样到得了 controller。 * - 仅接受带正确 X-Internal-Key 的请求,密钥通过 env INTERNAL_API_KEY 注入。 * 持 key 者能指定 discordUserId,即决定链接挂在哪个已绑定 Discord 的账号名下 * - 用 SharedLinkService.submitInternal,跳过 24h 限频;分享人 Discord 已绑定站内账号时 @@ -56,16 +58,22 @@ public SharedLinkInternalController( } /** - * 统一校验 X-Internal-Key header。 + * 统一校验来源与 X-Internal-Key header。 * * 返回: * - null:校验通过 - * - 非 null:直接 return 这个 response(503 未配置 / 403 缺失或错误) + * - 非 null:直接 return 这个 response(404 经过代理 / 503 未配置 / 403 缺失或错误) * + * 代理检查排最前:从公网来的请求连"接口配没配"都不该看出来,key 再对也不行—— + * key 一旦泄漏(日志、ChatBot 配置),公网可达就等于谁都能以任意 Discord 身份投稿。 * 密钥比较用 {@link MessageDigest#isEqual} 走常量时间,避免 timing side-channel * 泄漏密钥前缀(即便 loopback 上威胁很低,代价为零就顺手做)。 */ - private ResponseEntity> checkKey(String providedKey) { + private ResponseEntity> checkKey(String providedKey, String forwardedFor) { + if (forwardedFor != null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ApiResponse<>(false, "not found", null)); + } if (expectedKey == null || expectedKey.isBlank()) { log.error("internal.api-key 未配置,拒绝请求"); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) @@ -99,9 +107,10 @@ private static ResponseEntity> cast(ResponseEntity> submit( @RequestHeader(value = "X-Internal-Key", required = false) String providedKey, + @RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor, @RequestBody InternalShareRequest req) { - ResponseEntity> authFail = checkKey(providedKey); + ResponseEntity> authFail = checkKey(providedKey, forwardedFor); if (authFail != null) return cast(authFail); if (req == null || req.url() == null || req.url().trim().isEmpty()) { @@ -144,9 +153,10 @@ public ResponseEntity> submit( @GetMapping("/summary") public ResponseEntity> summary( @RequestHeader(value = "X-Internal-Key", required = false) String providedKey, + @RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor, @RequestParam(value = "sampleLimit", defaultValue = "5") int sampleLimit) { - ResponseEntity> authFail = checkKey(providedKey); + ResponseEntity> authFail = checkKey(providedKey, forwardedFor); if (authFail != null) return cast(authFail); int safeSample = Math.max(0, Math.min(sampleLimit, 20)); @@ -165,9 +175,10 @@ public ResponseEntity> summary( @GetMapping("/{id}") public ResponseEntity> getById( @RequestHeader(value = "X-Internal-Key", required = false) String providedKey, + @RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor, @PathVariable Long id) { - ResponseEntity> authFail = checkKey(providedKey); + ResponseEntity> authFail = checkKey(providedKey, forwardedFor); if (authFail != null) return cast(authFail); return service.findById(id) diff --git a/src/main/java/com/involutionhell/backend/usercenter/README.md b/src/main/java/com/involutionhell/backend/usercenter/README.md new file mode 100644 index 0000000..a354550 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/README.md @@ -0,0 +1,208 @@ +# usercenter — 登录 / 身份 / 账号 + +这个包管三件事:**认证**(你是谁)、**身份绑定**(哪些第三方账号属于同一个人)、 +**账号**(资料/角色/关注)。接入新的登录方式(Google、Microsoft…)之前先读完这篇。 + +> 安全不变量见 `backend/SECURITY.md`(编号持续新增,以那份为准),每条都有回归测试。 +> 改 auth/角色/密码/关注逻辑前必须先读那份,本文不重复。 + +--- + +## 1. 接入一个新的登录 provider + +**只需要新增一个类**,不改任何已有文件: + +```java +@Component +public class GoogleAuthProvider implements AuthProvider { + // 构造器用 @Value 注入 client-id / secret / redirect-uri + + @Override public String key() { return "google"; } // 小写,与 URL 段、DB 列一致 + + @Override public AuthRequest newRequest() { + AuthProviders.requireConfigured(key(), clientId, clientSecret); + return new AuthGoogleRequest(AuthConfig.builder()...build()); // JustAuth 内置就直接用 + } + + @Override public String redirectUri() { return redirectUri; } + + @Override public boolean isEmailVerified(AuthUser user) { + // ⚠️ 安全判据,见下面第 2 节。拿不准就 return false + return user.getRawUserInfo().getBooleanValue("email_verified"); + } +} +``` + +`AuthProviderRegistry` 会自动收集所有 `AuthProvider` bean,**登录、绑定、邮箱信任判定 +三条路径立刻全部生效**。 + +### 为什么是这个形状 + +历史上 provider 名散落在三个 `switch` 里(`authRequestFor` / `isProviderEmailVerified` / +`redirectUriOf`),接一个新 provider 要改 6 处,漏改一处就得到"能跳转但邮箱不被信任" +这种半死状态——排查起来极其痛苦,因为登录看起来是成功的,只是**悄悄多建了一个账号**。 +接口把这些收敛成一个类。 + +### 剩下必须手动改的地方(共 3 处,无法再收敛) + +| 位置 | 改什么 | 不改会怎样 | +|---|---|---| +| `application.properties` | `justauth.type..client-id/secret/redirect-uri` | provider 被判为"未配置",跳 `error=oauth_provider` | +| `frontend/messages/{zh,en}.json` | `login.` 按钮文案 | 按钮显示原始 key | +| `frontend/app/components/SignInButton.tsx` | `provider` 联合类型加一项 | typecheck 失败 | + +前端**不需要**再维护"有哪些 provider"的列表:`GET /api/user-center/identities/providers` +返回后端已注册的全部 key,设置页据此渲染"连接"按钮。 + +### 上线前自查 + +- [ ] provider 的 OAuth App 回调 URL 与 `redirect-uri` 完全一致(含 scheme 和结尾斜杠) +- [ ] `isEmailVerified` 的判据查过该 provider 官方文档,不是猜的 +- [ ] `next.config.mjs` 的 rewrite 覆盖 `/oauth/*` 与 `/api/auth/callback/*`(已覆盖,新增 provider 无需改) +- [ ] 本地跑通:登录 → 验证邮箱建号 → 登出 → 再登录命中同一账号 → 设置页能看到该身份 + (没配 Resend 就开 `REGISTRATION_OTP_DEV_CONSOLE=true`,验证码打在后端日志里) + +--- + +## 2. 邮箱信任是安全判据,不是展示字段 + +`isEmailVerified` 决定**是否允许把这个第三方身份自动挂靠到一个已有账号上**。 + +- 返回 `true` → provider 断言"这个邮箱确实属于本人",匹配到唯一已有账号就直接挂靠, + 用户无感,不会被分叉出第二个账号。 +- 返回 `false` → 不挂靠,进 OTP 注册(用户自己验证一个邮箱后建新号)。 + +**返回 true 的门槛是"provider 保证用户控制该邮箱"**。如果一个 provider 允许用户填任意 +邮箱而不验证,那么返回 true 就等于把账号接管漏洞焊死进登录流程:攻击者注册一个第三方号、 +邮箱填成受害者的,登录即接管。新 provider 拿不准就是 `false`——代价只是用户多验证一次邮箱, +而不是账号被偷。 + +同一个判据还决定**这个邮箱能不能写进 `user_accounts.email`**(它就是挂靠的锚点):未验证的 +provider 邮箱只拿来预填验证页,永远不落库(SECURITY.md INV-012)。 + +已有判据: + +| provider | 判据 | 依据 | +|---|---|---| +| github | 恒 `true` | JustAuth 取的是 primary email,GitHub 强制 primary 已验证 | +| discord | `/users/@me` 的 `verified` 布尔 | Discord 官方字段 | + +--- + +## 3. 两条 OAuth 流程 + +两条流程**共用同一个回调端点** `/api/auth/callback/{provider}`,走哪条由**服务端**的 +绑定意图决定,不由任何请求参数决定。 + +``` +登录 GET /oauth/render/{provider} → provider 授权页 → 回调 → 身份行 → 用户名(仅无身份行的老账号) → 已验证邮箱挂靠 + ├ 命中 → 302 /#token=&n=(INV-007) + └ 新用户 → 302 /login/verify + ih_reg_pending cookie → OTP 注册(见下) +绑定 POST /oauth/bind/{provider} → 返回 {url},前端跳转 → provider 授权页 → 回调 → 挂到当前账号 + (需已登录) 不建号 / 不换会话 / 不发新 token +``` + +回调里 `loginByProvider` 抛异常时:账号禁用 → `/login?error=account_disabled`,其它 → `/login?error=oauth_failed`。 + +### 新用户注册(OTP) + +`loginByProvider` **从不建号**。建号只有 `AuthService#completeRegistration` 一个入口,只在邮箱 +验证码通过之后(SECURITY.md INV-012)。三个端点都公开,`ih_reg_pending` httpOnly cookie 是唯一凭据: + +| 端点 | 成功 | 失败 | +|---|---|---| +| `GET /auth/register/pending` | 200 预填资料(provider、展示名、头像、provider 给的邮箱) | 410 会话没了 | +| `POST /auth/register/send-otp {email}` | 200 | 400 邮箱格式 / 410 / 429 限流 / 503 发信失败可重试 | +| `POST /auth/register/verify {email, code}` | 200,与 `POST /auth/login` 同形的 token | 400 码不对(会话还在) / 409 邮箱已属于已有账号 / 410 | + +- pendingId **只进 cookie、不进 URL**:URL 能被转发,受害者在攻击者的注册会话里验证自己的邮箱, + 攻击者的第三方身份就拿到了受害者的已验证邮箱(预注册劫持)。 +- 409 不合并:让用户用原来的方式登录,再到设置页绑定这个 provider。 +- 限流按(收件邮箱, 第三方身份)(5 封/天)、收件邮箱(20 封/天)与第三方身份(10 封/天),叠加会话内 5 封、 + 60s 冷却;码送达后才生效,发信失败只退按邮箱计的额度;累计 5 次错码作废会话,每个邮箱每天至多 10 次错码。 + provider 已验证该邮箱的人(邮箱主人)免按邮箱的两项全局限额。细节与取舍见 SECURITY.md INV-012。 + 不按 IP:后端看不到可信的客户端 IP。 +- 用户名沿用 `{provider}_{id}`;被一个已解绑这个身份的老账号占着时加 6 位随机后缀。 + +### 绑定流程为什么不能把 userId 放进 state(INV-007) + +`state` 是客户端可见、可伪造的。把"绑定到哪个账号"写进 state,攻击者就能构造一个 +"绑定到我的账号"的 state 诱导受害者走完授权,把受害者的第三方身份绑到攻击者账号上。 + +正确做法(当前实现): + +1. `POST /oauth/bind/{provider}` 上有 `@SaCheckLogin`,userId 取自**服务端已校验的会话**。 + 必须是 POST:GET 能被跨站顶级导航触发,带着被种进浏览器的别人的 satoken cookie 就能发起绑定 +2. 以随机 `state` 为 key,把 userId 存进服务端内存(`bindIntents`,5 分钟 TTL,一次性消费) +3. 回调时用 state 取回意图,并**二次核对当前会话仍是同一个人**(中途登出/换号则拒绝) +4. `state` 同时走 `ih_oauth_state` httpOnly cookie 双提交校验(防登录 CSRF) +5. 发起绑定还种 `ih_oauth_bind` 标记(30 分钟,长于 5 分钟的 state cookie);回调见标记却取不到 + 意图(过期/重放/重启)就弹回 `/settings?bind_error=...`,**绝不落进登录分支**——否则会按该 + 第三方身份进注册或登进别的账号 + +`state` 在这里只是**不可猜测的查找键**,不承载任何可信信息。 + +### 绑定冲突 + +对应 `user_identities` 的两条唯一约束,都返回可辨识的 code 而非 500: + +| code | 含义 | 用户该怎么办 | +|---|---|---| +| `bind_taken` | 该第三方账号已绑到**别的**账号 | 那个账号多半只有这一种登录方式、解绑不了,找管理员合并 | +| `bind_duplicate` | 本账号已绑过同类 provider | 先解绑再绑新的 | +| `bind_already_yours` | 已经绑在你自己账号上了 | 无需操作 | +| `bind_session` | 从发起到回调之间会话变了,或绑定意图已失效(过期/重放) | 重新发起 | + +> **为什么绑定入口必须早于"对所有人开放新 provider"** +> `UNIQUE (provider, provider_user_id)` 意味着一个第三方身份只能绑一个账号。 +> 先开放 → 用户被分叉出新账号 → 新账号**占住**了那个身份 → 本尊再想补绑就撞约束, +> 从"插一行"变成"跨账号迁移 posts/chat/follows 的数据合并"。 +> 顺序错了,成本差一个数量级。 + +--- + +## 4. 账号 vs 身份 + +``` +user_accounts ← 人。opaque BigInt id,不受任何 provider 控制,永不变 + ▲ ▲ ▲ + │ │ └── user_identities(google, ) + │ └───── user_identities(discord, ) + └──────── user_identities(github, ) +``` + +- **主键是我们自己的 surrogate id**,不是邮箱、也不是任何 provider 的 id。 + 邮箱只是"自动挂靠的锚",会变、且不是每个 provider 都给。 +- `user_accounts.github_id` 是**双写期遗留**(贡献归属与 `/u/{githubId}` 还依赖它)。 + 绑定 github 时补写、解绑时清空,两侧必须对称——否则 `schema.sql` 的启动回填会按 + 残留列值把身份静默复活。M4 会移除该列。 +- 解绑不能移除**最后一种**登录方式(否则用户永久锁死),前后端都要拦。 +- **解绑即撤销登录**:`{provider}_{id}` 用户名回退只认一行身份都没有的老账号。否则解绑/换绑掉的 + 身份按用户名还能登回来,并被静默补回身份行,设置页看不到、撤不掉。 +- 资料刷新:用账号的原始 provider(用户名前缀)登录时刷新展示名/头像,别家登录只补空; + email、github_id 永远只补空。 + +--- + +## 5. 灰度闸(Discord 当前状态) + +`auth.discord.allowlist` 非空即启用:只放行名单内的 Discord id **以及已有账号的回访登录**。 +闸保护的是**建新号**,不是把老用户锁在自己账号外面;绑定流程同理不经过闸(绑定不建号)。 + +新用户建号已走 OTP 注册,2026-10-18 清空该变量 GA。闸的代码保留一个版本当回滚开关:要重新关上 +Discord 注册,设回 `AUTH_DISCORD_ALLOWLIST=` 重建容器即可(见 SECURITY.md INV-008)。 + +--- + +## 6. 目录速查 + +``` +controller/ AuthController(密码登录/登出/me/注册 OTP) · OAuthController(登录+绑定) · IdentityController(查看/解绑/可绑列表) + UserCenterController · UserPreferencesController +oauth/ AuthProvider(接口) · AuthProviderRegistry(单一真相源) · GithubAuthProvider · DiscordAuthProvider + DiscordAuthSource / AuthDiscordRequest(JustAuth 没内置 Discord,自己实现) +service/ AuthService(登录/自动挂靠/建号) · UserIdentityService(绑定/解绑) · PasswordService(INV-003) + RegistrationService(注册 OTP 引擎) · UserCenterService(账号) +repository/ Jdbc*(裸 JDBC,无 ORM;表结构以 schema.sql 为准) +follows/ 关注关系(社交,与认证无关) +``` diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/AuthController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/AuthController.java index 284aca7..1c82ab1 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/controller/AuthController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/AuthController.java @@ -6,6 +6,11 @@ import com.involutionhell.backend.usercenter.dto.LoginResponse; import com.involutionhell.backend.usercenter.dto.UserView; import com.involutionhell.backend.usercenter.service.AuthService; +import com.involutionhell.backend.usercenter.service.RegistrationService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -18,14 +23,15 @@ public class AuthController { private final AuthService authService; + private final RegistrationService registrationService; - /** - * 创建认证控制器并注入认证服务。 - */ - public AuthController(AuthService authService) { + public AuthController(AuthService authService, RegistrationService registrationService) { this.authService = authService; + this.registrationService = registrationService; } + record OtpRequest(String email, String code) {} + /** * 校验用户名密码并返回登录后的 token 信息。 */ @@ -52,4 +58,54 @@ public ApiResponse logout() { public ApiResponse currentUser() { return ApiResponse.ok(authService.currentUser()); } -} \ No newline at end of file + + // ---------- 新用户注册:第三方登录后验证邮箱(pendingId 只认 httpOnly cookie) ---------- + // 状态码即错误码,前端按它选文案:400 邮箱格式/验证码不对,409 邮箱已属于已有账号, + // 410 注册会话没了(过期/锁死/已用)→ 重新登录,429 发太多,503 发信失败可重试。 + + /** 验证页预填:provider、展示名、头像、provider 给的邮箱(都是用户自己刚授权的资料)。 */ + @GetMapping("/register/pending") + public ResponseEntity> pending( + @CookieValue(name = OAuthController.REG_COOKIE, defaultValue = "") String pendingId) { + return registrationService.getPending(pendingId) + .>>map(p -> ResponseEntity.ok(ApiResponse.ok(p))) + .orElseGet(() -> fail(HttpStatus.GONE, "注册会话已失效,请重新登录")); + } + + @PostMapping("/register/send-otp") + public ResponseEntity> sendOtp( + @CookieValue(name = OAuthController.REG_COOKIE, defaultValue = "") String pendingId, + @RequestBody OtpRequest req) { + return switch (registrationService.sendOtp(pendingId, req.email())) { + case SENT -> ResponseEntity.ok(ApiResponse.okMessage("验证码已发送")); + case INVALID_EMAIL -> fail(HttpStatus.BAD_REQUEST, "邮箱格式不正确"); + case RATE_LIMITED -> fail(HttpStatus.TOO_MANY_REQUESTS, "发送太频繁,请稍后再试"); + case SESSION_EXPIRED -> fail(HttpStatus.GONE, "注册会话已失效,请重新登录"); + case SEND_FAILED -> fail(HttpStatus.SERVICE_UNAVAILABLE, "验证码发送失败,请稍后重试"); + }; + } + + @PostMapping("/register/verify") + public ResponseEntity> verify( + @CookieValue(name = OAuthController.REG_COOKIE, defaultValue = "") String pendingId, + @RequestBody OtpRequest req) { + var verified = registrationService.verifyAndConsume(pendingId, req.email(), req.code()); + if (verified.isEmpty()) { + return registrationService.getPending(pendingId).isPresent() + ? fail(HttpStatus.BAD_REQUEST, "验证码错误或已过期") + : fail(HttpStatus.GONE, "注册会话已失效,请重新登录"); + } + try { + return ResponseEntity.ok(ApiResponse.ok("注册成功", authService.completeRegistration(verified.get()))); + } catch (AuthService.EmailTakenException e) { + return fail(HttpStatus.CONFLICT, "该邮箱已属于一个已有账号:请用那个账号登录,再到设置里连接这个登录方式"); + } catch (DataIntegrityViolationException e) { + // 同一第三方身份被并发注册/绑定了:重新登录会按身份直接登进去 + return fail(HttpStatus.GONE, "注册会话已失效,请重新登录"); + } + } + + private static ResponseEntity> fail(HttpStatus status, String message) { + return ResponseEntity.status(status).body(ApiResponse.fail(message)); + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java index edd2572..bb0869b 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/IdentityController.java @@ -22,9 +22,12 @@ public class IdentityController { private final UserIdentityService userIdentityService; + private final com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry providers; - public IdentityController(UserIdentityService userIdentityService) { + public IdentityController(UserIdentityService userIdentityService, + com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry providers) { this.userIdentityService = userIdentityService; + this.providers = providers; } /** 列出当前用户已绑定的登录身份。 */ @@ -34,6 +37,16 @@ public ApiResponse> list() { return ApiResponse.ok(userIdentityService.listForUser(StpUtil.getLoginIdAsLong())); } + /** + * 后端已注册的全部登录方式。前端据此渲染"连接"按钮——接入新 provider 时 + * 按钮会自动出现,不需要改前端的写死列表(少一处维护漂移)。 + */ + @SaCheckLogin + @GetMapping("/providers") + public ApiResponse> supportedProviders() { + return ApiResponse.ok(providers.keys().stream().sorted().toList()); + } + /** 解绑指定 provider。返回解绑后剩余身份列表。 */ @SaCheckLogin @DeleteMapping("/{provider}") diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java index 8cd57e5..efe8427 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/OAuthController.java @@ -1,49 +1,54 @@ package com.involutionhell.backend.usercenter.controller; +import cn.dev33.satoken.annotation.SaCheckLogin; +import cn.dev33.satoken.stp.StpUtil; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.involutionhell.backend.common.api.ApiResponse; import com.involutionhell.backend.usercenter.dto.LoginResponse; -import com.involutionhell.backend.usercenter.oauth.AuthDiscordRequest; +import com.involutionhell.backend.usercenter.oauth.AuthProvider; +import com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry; import com.involutionhell.backend.usercenter.service.AuthService; -import me.zhyd.oauth.config.AuthConfig; +import com.involutionhell.backend.usercenter.service.RegistrationService; +import com.involutionhell.backend.usercenter.service.UserIdentityService; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; import me.zhyd.oauth.model.AuthCallback; import me.zhyd.oauth.model.AuthResponse; import me.zhyd.oauth.model.AuthUser; -import me.zhyd.oauth.request.AuthGithubRequest; import me.zhyd.oauth.request.AuthRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Set; /** - * 第三方 OAuth 登录入口。provider 无关(github 内置 / discord 自定义 source); - * github 的特殊性下沉到 AuthService 的业务层。端点路径带 {provider},github 的 - * /api/auth/callback/github 与 OAuth App 注册的回调 URL 保持一致。 + * 第三方 OAuth 的两条流程,共用同一个回调端点: + * 1. **登录**:/oauth/render/{provider} → 授权 → 回调 → 登入已有账号发 token; + * 新用户不建号,带 ih_reg_pending cookie 去 /login/verify 验证邮箱(AuthController /auth/register/*) + * 2. **绑定(M2b)**:POST /oauth/bind/{provider}(需已登录,返回授权地址)→ 授权 → 回调 → + * 把该身份挂到**当前账号**,不建号、不换会话 + * + * provider 全部经 {@link AuthProviderRegistry} 查找,本类不含任何 provider 名的 switch; + * 接入新 provider 见 usercenter/README.md。 */ @RestController public class OAuthController { private static final Logger log = LoggerFactory.getLogger(OAuthController.class); - @Value("${justauth.type.github.client-id:}") - private String githubClientId; - @Value("${justauth.type.github.client-secret:}") - private String githubClientSecret; - @Value("${justauth.type.github.redirect-uri:}") - private String githubRedirectUri; - - @Value("${justauth.type.discord.client-id:}") - private String discordClientId; - @Value("${justauth.type.discord.client-secret:}") - private String discordClientSecret; - @Value("${justauth.type.discord.redirect-uri:}") - private String discordRedirectUri; - // Discord 登录灰度白名单:逗号分隔的 Discord user id。配了值=只放行名单内 id // (以及已有账号的回访登录),其余人在回调处被弹回 /login?error=discord_canary; // 完全不配=闸关闭,对所有人开放(GA 就是清空它)。 @@ -59,39 +64,28 @@ public class OAuthController { private String frontEndUrl; private final AuthService authService; - - public OAuthController(AuthService authService) { - this.authService = authService; - } + private final UserIdentityService userIdentityService; + private final AuthProviderRegistry providers; /** - * 按 provider 造 AuthRequest。未知或未配置 → IllegalArgumentException, - * 由调用方兜底重定向到错误页(不 500)。 + * 绑定意图:state → 发起绑定的 userId。 + * + * INV-007 的红线是"绑定目标账号绝不能取自 state"——攻击者可以伪造 state 内容。 + * 所以这里只把 state 当**不可猜测的查找键**,真正的 userId 来自发起绑定那一刻 + * 服务端已校验的会话(@SaCheckLogin),存在服务端内存里,攻击者改不了。 + * TTL 与 state cookie 同为 5 分钟,一次性消费。 */ - private AuthRequest authRequestFor(String provider) { - return switch (provider) { - case "github" -> { - requireConfigured("github", githubClientId, githubClientSecret); - yield new AuthGithubRequest(AuthConfig.builder() - .clientId(githubClientId).clientSecret(githubClientSecret) - .redirectUri(githubRedirectUri).build()); - } - case "discord" -> { - requireConfigured("discord", discordClientId, discordClientSecret); - yield new AuthDiscordRequest(AuthConfig.builder() - .clientId(discordClientId).clientSecret(discordClientSecret) - .redirectUri(discordRedirectUri).build()); - } - default -> throw new IllegalArgumentException("不支持的 OAuth provider: " + provider); - }; - } + private final Cache bindIntents = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(STATE_COOKIE_MAX_AGE_SECONDS)) + .maximumSize(10_000) + .build(); - // client-id 与 secret 都要有:只配一半时提前挡在 oauth_provider(配置问题), - // 而不是让 token 交换阶段以 oauth_failed 失败——后者会误导成"provider 侧拒绝"。 - private void requireConfigured(String provider, String clientId, String clientSecret) { - if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) { - throw new IllegalArgumentException(provider + " OAuth 未配置(缺 client-id 或 secret)"); - } + public OAuthController(AuthService authService, + UserIdentityService userIdentityService, + AuthProviderRegistry providers) { + this.authService = authService; + this.userIdentityService = userIdentityService; + this.providers = providers; } /** @@ -138,8 +132,10 @@ static Set parseAllowlist(String raw) { /** * 灰度放行判定。闸未启用=全放行(GA)。启用时放行两类人:白名单内的 id,以及 - * **已经有账号的回访用户**——灰度要挡的是"建新号"(OTP wiring 未完成,新用户 - * 可能被分叉出第二个账号),不是把已有账号的人锁在自己账号外面。 + * **已经有账号的回访用户**——灰度要挡的是"新 Discord 用户进注册",不是把已有账号的人 + * 锁在自己账号外面。OTP 注册上线后闸已 GA(不配即全开),保留它作为一键收回的回滚开关。 + * + * 绑定流程(/oauth/bind)不经过这里:绑定不建号,不产生分叉风险。 */ boolean discordAllowed(String discordUserId) { if (!discordGateActive) { @@ -154,59 +150,89 @@ boolean discordAllowed(String discordUserId) { return authService.hasIdentity("discord", discordUserId); } + // OAuth state 双提交 cookie 名。INV-007:callback 校验 URL state 必须等于此 cookie, + // 二者都由本次 render 生成——防登录 CSRF(攻击者无法向受害者浏览器种此 cookie)。 + static final String STATE_COOKIE = "ih_oauth_state"; + // 绑定流程标记。意图存在内存里,过期/重放/重启都会丢——没有这个标记,回调只能把 + // 丢了意图的绑定当成登录处理,按该第三方身份建号或登进别的账号。 + static final String BIND_COOKIE = "ih_oauth_bind"; + private static final int STATE_COOKIE_MAX_AGE_SECONDS = 300; + // 标记必须比 state cookie 活得久:两者同时过期的话,在 provider 页停留超过 5 分钟的绑定 + // 回来时什么都不剩,会被当成登录失败送去 /login(登录页再点 Discord 就是分叉出新账号)。 + static final int BIND_COOKIE_MAX_AGE_SECONDS = 1800; + // 待注册会话(OTP)的 pendingId。AuthController 的 /auth/register/* 读它。 + public static final String REG_COOKIE = "ih_reg_pending"; + // 登录 nonce 回显(INV-007):前端发起时生成随机 n 存 sessionStorage,render 把它种进 + // httpOnly cookie,登录成功时回显成 #token=..&n=..,前端只认与本 tab 存的 n 相等的 token。 + // 攻击者能把受害者 tab 导航到自己的 #token=,但拿不到受害者的 n。 + static final String LOGIN_NONCE_COOKIE = "ih_login_nonce"; + private static final Pattern LOGIN_NONCE_RE = Pattern.compile("^[A-Za-z0-9_-]{16,64}$"); + /** - * 拒绝后撤销刚换到的 access token。用户已经在 Discord 上点过授权、我们已经拿到 - * 他的邮箱和 token,既然不让他登录,就别把用不上的授权和 token 留着。 - * 失败不阻断——撤销只是清理,不能反过来把拒绝流程搞崩。 + * 发起登录:跳 provider 授权页。n 不合法或缺失就不种 nonce cookie——老前端照常能登, + * 回调回落成不带 n 的 #token=(新前端会拒收它,见 INV-007)。 */ - private void revokeDiscordToken(AuthRequest authRequest, AuthUser authUser) { - if (!(authRequest instanceof AuthDiscordRequest discord) || authUser == null) { - return; - } - try { - discord.revokeToken(authUser.getToken()); - } catch (Exception e) { - log.warn("[Discord 灰度] 撤销 token 失败(不影响拒绝流程): {}", e.getClass().getSimpleName()); + @GetMapping("/oauth/render/{provider}") + public void renderAuth(@PathVariable String provider, + @RequestParam(required = false) String n, + HttpServletResponse response) throws IOException { + Optional url = startOAuth(provider, null, response); + if (url.isPresent() && n != null && LOGIN_NONCE_RE.matcher(n).matches()) { + response.addHeader("Set-Cookie", buildCookie(LOGIN_NONCE_COOKIE, n, STATE_COOKIE_MAX_AGE_SECONDS)); } + response.sendRedirect(url.orElse(frontEndUrl + "/login?error=oauth_provider")); } - // 仅用于排查日志:redirect_uri 是公开信息,不含密钥。 - private String redirectUriOf(String provider) { - return switch (provider) { - case "github" -> githubRedirectUri; - case "discord" -> discordRedirectUri; - default -> "(n/a)"; - }; + /** + * 发起绑定(M2b):把 {provider} 挂到**当前登录账号**,返回授权地址由前端跳转。 + * @SaCheckLogin 保证 userId 来自服务端校验过的会话,而不是任何客户端可控输入。 + * 必须是 POST:GET 能被跨站顶级导航触发,配合浏览器里被种下的别人的 satoken cookie + * (SameSite=Lax 照样随 GET 导航发送),就能把受害者的第三方身份绑进攻击者账号。 + */ + @SaCheckLogin + @PostMapping("/oauth/bind/{provider}") + public ResponseEntity> startBind(@PathVariable String provider, HttpServletResponse response) { + return startOAuth(provider, StpUtil.getLoginIdAsLong(), response) + .>>map(url -> ResponseEntity.ok(ApiResponse.ok(Map.of("url", url)))) + .orElseGet(() -> ResponseEntity.badRequest().body(ApiResponse.fail("不支持的登录方式"))); } - // OAuth state 双提交 cookie 名。INV-007:callback 校验 URL state 必须等于此 cookie, - // 二者都由本次 render 生成——防登录 CSRF(攻击者无法向受害者浏览器种此 cookie)。 - static final String STATE_COOKIE = "ih_oauth_state"; - private static final int STATE_COOKIE_MAX_AGE_SECONDS = 300; - /** - * 构建授权链接并重定向到 provider。前端跳到 /oauth/render/{provider} 发起登录。 + * 登录与绑定共用的发起逻辑;bindUserId 非空即为绑定流程。 + * 返回 provider 授权地址并种好 cookie;provider 未知/未配置时返回 empty,什么都不种。 */ - @GetMapping("/oauth/render/{provider}") - public void renderAuth(@PathVariable String provider, HttpServletResponse response) throws IOException { + private Optional startOAuth(String provider, Long bindUserId, HttpServletResponse response) { + Optional found = providers.find(provider); + if (found.isEmpty()) { + log.warn("[OAuth] 未知 provider={}", provider); + return Optional.empty(); + } + AuthProvider p = found.get(); AuthRequest authRequest; try { - authRequest = authRequestFor(provider); + authRequest = p.newRequest(); } catch (IllegalArgumentException e) { - log.warn("[OAuth] render 未知/未配置 provider={}: {}", provider, e.getMessage()); - response.sendRedirect(frontEndUrl + "/login?error=oauth_provider"); - return; + log.warn("[OAuth] provider={} 未配置: {}", provider, e.getMessage()); + return Optional.empty(); } - log.info("[OAuth] render provider={}, redirect_uri={}", provider, redirectUriOf(provider)); String state = me.zhyd.oauth.utils.AuthStateUtils.createState(); + if (bindUserId != null) { + bindIntents.put(state, bindUserId); + } + log.info("[OAuth] {} provider={}, redirect_uri={}", + bindUserId != null ? "bind" : "render", provider, p.redirectUri()); // state 同时种进 httpOnly cookie。SameSite=Lax 是关键:callback 是 provider 发起的 // 跨站顶级导航,Strict 会剥掉 cookie;Lax 恰好在顶级 GET 导航时携带。 - response.addHeader("Set-Cookie", buildStateCookie(state, STATE_COOKIE_MAX_AGE_SECONDS)); - response.sendRedirect(authRequest.authorize(state)); + response.addHeader("Set-Cookie", buildCookie(STATE_COOKIE, state, STATE_COOKIE_MAX_AGE_SECONDS)); + // 登录发起时清掉可能残留的绑定标记,否则放弃绑定后的登录会被误判成绑定 + response.addHeader("Set-Cookie", bindUserId != null + ? buildCookie(BIND_COOKIE, "1", BIND_COOKIE_MAX_AGE_SECONDS) + : buildCookie(BIND_COOKIE, "", 0)); + return Optional.of(authRequest.authorize(state)); } - private String buildStateCookie(String value, int maxAgeSeconds) { - return org.springframework.http.ResponseCookie.from(STATE_COOKIE, value) + private String buildCookie(String name, String value, int maxAgeSeconds) { + return org.springframework.http.ResponseCookie.from(name, value) .httpOnly(true) .secure(frontEndUrl.startsWith("https")) // 本地 http 下不置 Secure,否则浏览器不回传 .sameSite("Lax") @@ -216,17 +242,17 @@ private String buildStateCookie(String value, int maxAgeSeconds) { .toString(); } - private String readStateCookie(jakarta.servlet.http.HttpServletRequest request) { + private String readCookie(jakarta.servlet.http.HttpServletRequest request, String name) { if (request.getCookies() == null) return null; for (jakarta.servlet.http.Cookie c : request.getCookies()) { - if (STATE_COOKIE.equals(c.getName())) return c.getValue(); + if (name.equals(c.getName())) return c.getValue(); } return null; } /** - * OAuth 回调。github 的路径 /api/auth/callback/github 与 OAuth App 注册一致; - * discord 走 /api/auth/callback/discord。 + * OAuth 回调,登录与绑定共用。走哪条路由由服务端的绑定意图决定(见 bindIntents), + * 不由任何请求参数决定。 */ @GetMapping("/api/auth/callback/{provider}") public void login(@PathVariable String provider, @@ -234,29 +260,53 @@ public void login(@PathVariable String provider, @RequestParam(required = false) String state, jakarta.servlet.http.HttpServletRequest request, HttpServletResponse response) throws IOException { + // 意图在最前面取出并消费(一次性),后续所有错误分支才知道该跳登录页还是设置页。 + Long bindUserId = state == null ? null : bindIntents.asMap().remove(state); + boolean isBind = bindUserId != null; + // 浏览器说"我在绑定"但服务端没有意图(过期/重放/重启):绝不能落进登录分支 + boolean bindMarker = "1".equals(readCookie(request, BIND_COOKIE)); + boolean bindFlow = isBind || bindMarker; + response.addHeader("Set-Cookie", buildCookie(BIND_COOKIE, "", 0)); // 用完即清 + String loginNonce = readCookie(request, LOGIN_NONCE_COOKIE); + response.addHeader("Set-Cookie", buildCookie(LOGIN_NONCE_COOKIE, "", 0)); // 同上,任何出口都清 + // 参数缺失(用户直接访问 / provider 异常回调)统一兜底到前端错误页。 if (code == null || state == null) { log.warn("[OAuth] {} callback missing code/state (direct access?), redirecting", provider); - response.sendRedirect(frontEndUrl + "/login?error=oauth_failed"); + response.sendRedirect(errorUrl(bindFlow, "oauth_failed")); return; } // INV-007:state 必须等于本次 render 种下的 cookie(双提交校验)。缺失/不匹配 // 即拒绝,且在换 token 之前——不给伪造 state 触发登录的机会,也不白打 provider。 - String cookieState = readStateCookie(request); - response.addHeader("Set-Cookie", buildStateCookie("", 0)); // 用完即清 + String cookieState = readCookie(request, STATE_COOKIE); + response.addHeader("Set-Cookie", buildCookie(STATE_COOKIE, "", 0)); // 用完即清 if (cookieState == null || !cookieState.equals(state)) { log.warn("[OAuth] {} state 与 cookie 不匹配(CSRF 或 cookie 丢失),拒绝", provider); - response.sendRedirect(frontEndUrl + "/login?error=oauth_state"); + response.sendRedirect(errorUrl(bindFlow, "oauth_state")); + return; + } + + // 同样在换 token 之前:丢了意图的绑定不值得再打一次 provider + if (!isBind && bindMarker) { + log.warn("[OAuth] {} 绑定标记在但意图缺失(过期/重放/重启),拒绝且不当作登录", provider); + response.sendRedirect(frontEndUrl + "/settings?bind_error=bind_session"); return; } + Optional found = providers.find(provider); + if (found.isEmpty()) { + log.warn("[OAuth] callback 未知 provider={}", provider); + response.sendRedirect(errorUrl(isBind, "oauth_provider")); + return; + } + AuthProvider p = found.get(); AuthRequest authRequest; try { - authRequest = authRequestFor(provider); + authRequest = p.newRequest(); } catch (IllegalArgumentException e) { - log.warn("[OAuth] callback 未知/未配置 provider={}: {}", provider, e.getMessage()); - response.sendRedirect(frontEndUrl + "/login?error=oauth_provider"); + log.warn("[OAuth] callback provider={} 未配置: {}", provider, e.getMessage()); + response.sendRedirect(errorUrl(isBind, "oauth_provider")); return; } @@ -265,22 +315,116 @@ public void login(@PathVariable String provider, callback.setState(state); AuthResponse authResponse = authRequest.login(callback); - if (authResponse.ok()) { - AuthUser authUser = (AuthUser) authResponse.getData(); - // Discord 灰度:不放行的 id 在此弹回(换 token 已发生,但不建号/不登入)。 - // 直连 /oauth/render/discord 绕过前端按钮的人也一并挡在这里。 - if ("discord".equals(provider) && !discordAllowed(authUser.getUuid())) { - log.info("[OAuth] discord 灰度:uuid={} 不在放行范围,拒绝登录", authUser.getUuid()); - revokeDiscordToken(authRequest, authUser); - response.sendRedirect(frontEndUrl + "/login?error=discord_canary"); - return; - } - LoginResponse loginResponse = authService.loginByProvider(provider, authUser); - // token 放 URL fragment(#token=),不进服务器日志/Referer;前端读入 localStorage - response.sendRedirect(frontEndUrl + "/#token=" + loginResponse.tokenValue()); + if (!authResponse.ok()) { + log.warn("[OAuth] {} 授权失败: {}", provider, authResponse.getMsg()); + response.sendRedirect(errorUrl(isBind, "oauth_failed")); + return; + } + + AuthUser authUser = (AuthUser) authResponse.getData(); + if (isBind) { + handleBind(p, authUser, bindUserId, response); } else { - log.warn("[OAuth] {} 登录失败: {}", provider, authResponse.getMsg()); + handleLogin(p, authUser, loginNonce, response); + } + } + + /** 登录:灰度闸 → 登入已有账号发 token / 新用户去验证邮箱。 */ + private void handleLogin(AuthProvider p, AuthUser authUser, String loginNonce, HttpServletResponse response) + throws IOException { + // Discord 灰度:不放行的 id 在此弹回(换 token 已发生,但不建号/不登入)。 + // 直连 /oauth/render/discord 绕过前端按钮的人也一并挡在这里。 + if ("discord".equals(p.key()) && !discordAllowed(authUser.getUuid())) { + log.info("[OAuth] discord 灰度:uuid={} 不在放行范围,拒绝登录", authUser.getUuid()); + revokeQuietly(p, authUser); + response.sendRedirect(frontEndUrl + "/login?error=discord_canary"); + return; + } + AuthService.ProviderLogin result; + try { + result = authService.loginByProvider(p.key(), authUser); + } catch (AuthService.AccountDisabledException e) { + log.info("[OAuth] {} uuid={} 命中的账号已禁用,拒绝登录", p.key(), authUser.getUuid()); + revokeQuietly(p, authUser); + response.sendRedirect(frontEndUrl + "/login?error=account_disabled"); + return; + } catch (Exception e) { + // 不兜住就是一个裸 500 白页(这是顶级导航,不是 fetch) + log.error("[OAuth] {} 登录处理失败", p.key(), e); + revokeQuietly(p, authUser); response.sendRedirect(frontEndUrl + "/login?error=oauth_failed"); + return; } + switch (result) { + // token 放 URL fragment(#token=),不进服务器日志/Referer;前端核对 n 后读入 localStorage + case AuthService.LoggedIn(LoginResponse r) -> response.sendRedirect(frontEndUrl + "/#token=" + enc(r.tokenValue()) + + (loginNonce == null ? "" : "&n=" + enc(loginNonce))); + case AuthService.NeedsRegistration(String pendingId) -> { + // pendingId 只进 httpOnly cookie、绝不进 URL:URL 里的 pendingId 可以被转发给别人, + // 受害者在攻击者的注册会话里验证自己的邮箱 → 攻击者的第三方身份拿到受害者的已验证邮箱 + // → 受害者日后用 GitHub 登录按邮箱自动挂靠进攻击者账号。cookie 种不进别人的浏览器。 + response.addHeader("Set-Cookie", buildCookie(REG_COOKIE, pendingId, + (int) RegistrationService.SESSION_TTL.toSeconds())); + response.sendRedirect(frontEndUrl + "/login/verify"); + } + } + } + + /** + * 绑定:把该第三方身份挂到发起绑定的账号上。不建号、不换会话、不发新 token。 + * + * 二次核对当前会话仍是发起人——意图虽然存在服务端,但从发起到回调之间用户可能 + * 已经登出或换号登录,那时把身份绑到旧 userId 上就是错的账号。 + */ + private void handleBind(AuthProvider p, AuthUser authUser, long bindUserId, HttpServletResponse response) + throws IOException { + if (!StpUtil.isLogin() || StpUtil.getLoginIdAsLong() != bindUserId) { + log.warn("[OAuth] bind provider={} 会话与发起人不一致,拒绝绑定", p.key()); + revokeQuietly(p, authUser); + response.sendRedirect(frontEndUrl + "/settings?bind_error=bind_session"); + return; + } + try { + userIdentityService.bind(bindUserId, p.key(), authUser.getUuid(), + authUser.getEmail(), displayNameOf(authUser)); + log.info("[OAuth] bind 成功 provider={} userId={}", p.key(), bindUserId); + response.sendRedirect(frontEndUrl + "/settings?bind=ok"); + } catch (UserIdentityService.IdentityAlreadyBoundException e) { + // 该第三方身份已经属于另一个账号(UNIQUE(provider, provider_user_id))。 + // 这正是"先 GA 再做 M2b"会陷进去的死局:分叉账号占着身份,本尊补绑不进来。 + log.info("[OAuth] bind 冲突 provider={}: {}", p.key(), e.getMessage()); + revokeQuietly(p, authUser); + response.sendRedirect(frontEndUrl + "/settings?bind_error=" + e.errorCode()); + return; + } catch (Exception e) { + // 同 handleLogin:顶级导航,不兜住就是一个裸 JSON 500 页,token 也不撤 + log.error("[OAuth] bind provider={} 处理失败", p.key(), e); + revokeQuietly(p, authUser); + response.sendRedirect(frontEndUrl + "/settings?bind_error=oauth_failed"); + } + } + + private static String enc(String v) { + return URLEncoder.encode(v, StandardCharsets.UTF_8); + } + + private static String displayNameOf(AuthUser authUser) { + return authUser.getNickname() != null ? authUser.getNickname() : authUser.getUsername(); + } + + /** 拒绝/失败后撤销刚换到的 token——不让用户白授权还留着我们用不上的凭据。失败不阻断。 */ + private void revokeQuietly(AuthProvider p, AuthUser authUser) { + if (authUser == null) { + return; + } + try { + p.revokeToken(authUser.getToken()); + } catch (Exception e) { + log.warn("[OAuth] 撤销 {} token 失败(不影响主流程): {}", p.key(), e.getClass().getSimpleName()); + } + } + + private String errorUrl(boolean isBind, String code) { + return frontEndUrl + (isBind ? "/settings?bind_error=" : "/login?error=") + code; } } diff --git a/src/main/java/com/involutionhell/backend/usercenter/model/OAuthAccount.java b/src/main/java/com/involutionhell/backend/usercenter/model/OAuthAccount.java deleted file mode 100644 index 14e5121..0000000 --- a/src/main/java/com/involutionhell/backend/usercenter/model/OAuthAccount.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.involutionhell.backend.usercenter.model; - -/** - * 对应 Prisma 管理的 accounts 表(Auth.js OAuth 关联账号)。 - * - *
- * CREATE TABLE accounts (
- *   id                  SERIAL PRIMARY KEY,
- *   "userId"            INT,
- *   type                VARCHAR(255),
- *   provider            VARCHAR(255),
- *   "providerAccountId" VARCHAR(255),
- *   refresh_token       TEXT,
- *   access_token        TEXT,
- *   expires_at          BIGINT,
- *   id_token            TEXT,
- *   scope               TEXT,
- *   session_state       TEXT,
- *   token_type          TEXT
- * );
- * 
- */ -public record OAuthAccount( - Integer id, - Integer userId, - String type, - String provider, - String providerAccountId, - String refreshToken, - String accessToken, - Long expiresAt, - String idToken, - String scope, - String sessionState, - String tokenType -) {} diff --git a/src/main/java/com/involutionhell/backend/usercenter/model/PendingRegistration.java b/src/main/java/com/involutionhell/backend/usercenter/model/PendingRegistration.java index f4ad46a..bb6f6eb 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/model/PendingRegistration.java +++ b/src/main/java/com/involutionhell/backend/usercenter/model/PendingRegistration.java @@ -11,6 +11,7 @@ public record PendingRegistration( String displayName, String avatarUrl, String providerEmail, // provider 给的邮箱(预填用),用户可在验证步改成别的 - Long githubId + Long githubId, + boolean providerEmailVerified // provider 已验证 providerEmail:发到这个地址的人就是邮箱主人,免按邮箱的全局限额 ) { } diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProvider.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProvider.java new file mode 100644 index 0000000..3317888 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProvider.java @@ -0,0 +1,45 @@ +package com.involutionhell.backend.usercenter.oauth; + +import me.zhyd.oauth.model.AuthToken; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; + +/** + * 一个第三方登录 provider 的完整契约。**接入新 provider 只需新增一个本接口的实现**, + * 不要再往控制器/服务里加 `switch (provider)` —— 那正是维护漂移的来源: + * 曾经同一个 provider 名散落在 authRequestFor / isProviderEmailVerified / redirectUriOf + * 三个 switch 里,漏改一处就得到"能跳转但邮箱不被信任"这种半死状态。 + * + * 实现类声明成 Spring bean 即可,{@link AuthProviderRegistry} 会自动按 {@link #key()} 收集。 + * 详细接入步骤见 usercenter/README.md。 + */ +public interface AuthProvider { + + /** provider 标识,必须小写,与 URL 路径段、user_identities.provider 列一致。 */ + String key(); + + /** + * 构造本次授权用的 JustAuth 请求。 + * 未配置 client-id / secret 时抛 {@link IllegalArgumentException}, + * 由调用方兜底重定向到 error=oauth_provider(配置问题,不该是 500)。 + */ + AuthRequest newRequest(); + + /** 回调地址,仅用于排查日志(公开信息,不含密钥)。 */ + String redirectUri(); + + /** + * 该 provider 是否**已验证**这个邮箱。只有 true 才允许按邮箱自动关联到已有账号, + * 否则攻击者用一个谎称受害者邮箱的第三方号就能撞进别人账号(ADR-001 红线)。 + * 拿不准就返回 false —— 代价只是多建一个账号,而不是账号被接管。 + */ + boolean isEmailVerified(AuthUser user); + + /** + * 撤销已换到的 access token。用于"换完 token 才决定拒绝该用户"的场景(灰度闸)。 + * provider 不支持就保持默认空实现。 + */ + default void revokeToken(AuthToken token) { + // 默认不做事:多数 provider 的 token 会自然过期,撤销只是礼貌性清理 + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistry.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistry.java new file mode 100644 index 0000000..31045c8 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistry.java @@ -0,0 +1,49 @@ +package com.involutionhell.backend.usercenter.oauth; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * provider 名 → {@link AuthProvider} 的唯一映射。Spring 注入所有 AuthProvider bean, + * 按 key 建索引;新增 provider 不需要改这个类。 + * + * 这是"provider 名"在整个后端的**单一真相源**:控制器和服务一律经此查找, + * 不再各自维护 switch。 + */ +@Component +public class AuthProviderRegistry { + + private static final Logger log = LoggerFactory.getLogger(AuthProviderRegistry.class); + + private final Map byKey; + + public AuthProviderRegistry(List providers) { + this.byKey = providers.stream().collect(Collectors.toUnmodifiableMap( + p -> p.key().toLowerCase(Locale.ROOT), + Function.identity(), + (a, b) -> { + // 同名两个实现 = 行为不确定,启动即失败好过线上随机命中一个 + throw new IllegalStateException( + "重复的 AuthProvider key: " + a.key() + "(" + a.getClass() + " / " + b.getClass() + ")"); + })); + log.info("[Auth] 已注册 {} 个登录 provider: {}", byKey.size(), byKey.keySet()); + } + + /** 找不到返回空(未知 provider),由调用方兜底到错误页而不是 500。 */ + public Optional find(String key) { + return key == null ? Optional.empty() + : Optional.ofNullable(byKey.get(key.toLowerCase(Locale.ROOT))); + } + + /** 已注册的 provider 名,供前端展示"可绑定的登录方式"。 */ + public java.util.Set keys() { + return byKey.keySet(); + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviders.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviders.java new file mode 100644 index 0000000..e48b6c2 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/AuthProviders.java @@ -0,0 +1,18 @@ +package com.involutionhell.backend.usercenter.oauth; + +/** AuthProvider 实现共用的小工具。 */ +final class AuthProviders { + + private AuthProviders() { + } + + /** + * client-id 与 secret 都要有:只配一半时提前挡在 oauth_provider(配置问题), + * 而不是让 token 交换阶段以 oauth_failed 失败——后者会误导成"provider 侧拒绝"。 + */ + static void requireConfigured(String provider, String clientId, String clientSecret) { + if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) { + throw new IllegalArgumentException(provider + " OAuth 未配置(缺 client-id 或 secret)"); + } + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/DiscordAuthProvider.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/DiscordAuthProvider.java new file mode 100644 index 0000000..ed2aa26 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/DiscordAuthProvider.java @@ -0,0 +1,62 @@ +package com.involutionhell.backend.usercenter.oauth; + +import com.alibaba.fastjson.JSONObject; +import me.zhyd.oauth.config.AuthConfig; +import me.zhyd.oauth.model.AuthToken; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Discord 登录。JustAuth 1.16.6 没有内置 Discord source,自定义实现见 + * {@link DiscordAuthSource} / {@link AuthDiscordRequest}。 + */ +@Component +public class DiscordAuthProvider implements AuthProvider { + + private final String clientId; + private final String clientSecret; + private final String redirectUri; + + public DiscordAuthProvider( + @Value("${justauth.type.discord.client-id:}") String clientId, + @Value("${justauth.type.discord.client-secret:}") String clientSecret, + @Value("${justauth.type.discord.redirect-uri:}") String redirectUri) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + } + + @Override + public String key() { + return "discord"; + } + + @Override + public AuthRequest newRequest() { + AuthProviders.requireConfigured(key(), clientId, clientSecret); + return new AuthDiscordRequest(AuthConfig.builder() + .clientId(clientId).clientSecret(clientSecret).redirectUri(redirectUri).build()); + } + + @Override + public String redirectUri() { + return redirectUri; + } + + /** Discord /users/@me 的 "verified" 布尔表示该账号邮箱已验证。 */ + @Override + public boolean isEmailVerified(AuthUser user) { + JSONObject raw = user == null ? null : user.getRawUserInfo(); + return raw != null && raw.getBooleanValue("verified"); + } + + @Override + public void revokeToken(AuthToken token) { + if (token == null) { + return; + } + ((AuthDiscordRequest) newRequest()).revokeToken(token); + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/oauth/GithubAuthProvider.java b/src/main/java/com/involutionhell/backend/usercenter/oauth/GithubAuthProvider.java new file mode 100644 index 0000000..66a7f17 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/oauth/GithubAuthProvider.java @@ -0,0 +1,49 @@ +package com.involutionhell.backend.usercenter.oauth; + +import me.zhyd.oauth.config.AuthConfig; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthGithubRequest; +import me.zhyd.oauth.request.AuthRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** GitHub 登录。JustAuth 内置 source,直接包一层。 */ +@Component +public class GithubAuthProvider implements AuthProvider { + + private final String clientId; + private final String clientSecret; + private final String redirectUri; + + public GithubAuthProvider( + @Value("${justauth.type.github.client-id:}") String clientId, + @Value("${justauth.type.github.client-secret:}") String clientSecret, + @Value("${justauth.type.github.redirect-uri:}") String redirectUri) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + } + + @Override + public String key() { + return "github"; + } + + @Override + public AuthRequest newRequest() { + AuthProviders.requireConfigured(key(), clientId, clientSecret); + return new AuthGithubRequest(AuthConfig.builder() + .clientId(clientId).clientSecret(clientSecret).redirectUri(redirectUri).build()); + } + + @Override + public String redirectUri() { + return redirectUri; + } + + /** JustAuth 取的是 GitHub 的 primary email,而 GitHub 要求 primary 必须已验证,故信任。 */ + @Override + public boolean isEmailVerified(AuthUser user) { + return true; + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java index 1cad3c6..e9631fd 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java @@ -137,10 +137,20 @@ public UserAccount insert(UserAccount userAccount) { } @Override - public UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId) { - // 每次 GitHub 用户登录时刷新其展示名称、头像、邮箱等资料 - jdbc.update( - "UPDATE user_accounts SET display_name = ?, avatar_url = ?, email = ?, github_id = ? WHERE id = ?", + public UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId, + boolean refreshNameAndAvatar) { + // email / github_id 只补空列,永不覆盖、永不被 null 抹掉:email 是自动挂靠的锚点, + // github_id 撑着贡献归属与 /u/{githubId}。 + // 展示名/头像:账号的原始 provider 登录时用新值刷新(null/空串不抹),否则一改名就永远冻住; + // 别家 provider 登录只补空——不然换个 provider 登录就把资料改成那家的。 + // NULLIF(col, ''):历史上写进去的空串也算"空",下次登录照样补。 + String nameAndAvatar = refreshNameAndAvatar + ? "display_name = COALESCE(NULLIF(?, ''), display_name), avatar_url = COALESCE(NULLIF(?, ''), avatar_url)," + : "display_name = COALESCE(NULLIF(display_name, ''), ?), avatar_url = COALESCE(NULLIF(avatar_url, ''), ?),"; + jdbc.update("UPDATE user_accounts SET " + nameAndAvatar + """ + email = COALESCE(NULLIF(email, ''), ?), + github_id = COALESCE(github_id, ?) + WHERE id = ?""", displayName, avatarUrl, email, githubId, userId); return findById(userId) .orElseThrow(() -> new IllegalArgumentException("用户不存在: " + userId)); diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java index ffd11f8..2e3f268 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java @@ -50,9 +50,12 @@ public interface UserAccountRepository { UserAccount insert(UserAccount userAccount); /** - * 更新 GitHub 用户的个人资料(展示名、头像、邮箱、GitHub ID),每次登录时刷新。 + * 第三方登录时补齐资料(展示名、头像、邮箱、GitHub ID):邮箱与 GitHub ID **只填仍为空的列, + * 不覆盖已有值**;展示名/头像在 refreshNameAndAvatar 时用非空新值覆盖,否则同样只补空。 + * 传 null 即保持原值。调用方只能在 github 登录时传非空 githubId,只能传已验证的邮箱。 */ - UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId); + UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId, + boolean refreshNameAndAvatar); /** * 就地更新指定用户的 password_hash。 diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java index 225c83b..62c87ba 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java @@ -4,6 +4,7 @@ import com.involutionhell.backend.usercenter.dto.LoginRequest; import com.involutionhell.backend.usercenter.dto.LoginResponse; import com.involutionhell.backend.usercenter.dto.UserView; +import com.involutionhell.backend.usercenter.model.PendingRegistration; import com.involutionhell.backend.usercenter.model.UserAccount; import com.involutionhell.backend.usercenter.model.UserIdentity; import com.involutionhell.backend.usercenter.repository.UserAccountRepository; @@ -11,8 +12,10 @@ import me.zhyd.oauth.model.AuthUser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -25,6 +28,23 @@ public class AuthService { private final PasswordService passwordService; private final UserAccountRepository userAccountRepository; private final UserIdentityRepository userIdentityRepository; + private final com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry providers; + private final RegistrationService registrationService; + + /** 第三方登录的两种结局:登进已有账号,或(新用户)先去验证邮箱。 */ + public sealed interface ProviderLogin permits LoggedIn, NeedsRegistration {} + public record LoggedIn(LoginResponse response) implements ProviderLogin {} + public record NeedsRegistration(String pendingId) implements ProviderLogin {} + + /** 验证通过的邮箱已属于某个已有账号。不合并(ADR-001 红线),让用户登录那个账号后去绑定。 */ + public static class EmailTakenException extends RuntimeException {} + + /** 第三方登录命中的账号已被禁用。回调据此给出 account_disabled,而不是笼统的 oauth_failed。 */ + public static class AccountDisabledException extends IllegalStateException { + AccountDisabledException() { + super("账号已被禁用"); + } + } /** * 创建认证服务并注入用户与密码服务。 @@ -32,11 +52,15 @@ public class AuthService { public AuthService(UserCenterService userCenterService, PasswordService passwordService, UserAccountRepository userAccountRepository, - UserIdentityRepository userIdentityRepository) { + UserIdentityRepository userIdentityRepository, + com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry providers, + RegistrationService registrationService) { this.userCenterService = userCenterService; this.passwordService = passwordService; this.userAccountRepository = userAccountRepository; this.userIdentityRepository = userIdentityRepository; + this.providers = providers; + this.registrationService = registrationService; } /** @@ -76,22 +100,20 @@ public LoginResponse login(LoginRequest request) { } /** - * GitHub 授权登录(薄委托)。历史入口,保留供 OAuthController 调用。 - */ - public LoginResponse loginByGithub(AuthUser githubUser) { - return loginByProvider("github", githubUser); - } - - /** - * 第三方 provider 授权登录(M1 统一流程)。 - * 不存在则自动注册,已存在则刷新资料;无论哪条路径都维护一行 user_identities。 + * 第三方 provider 授权登录(M1 统一流程)。只登入已有账号,**从不建号**: + * 找到账号则补齐资料并维护一行 user_identities;新用户返回 {@link NeedsRegistration}, + * 验证邮箱后由 {@link #completeRegistration} 建号。 * - * 双写期语义(ADR-001,M1-M3):账号仍按 "{provider}_{providerUserId}" 用户名主查, - * user_identities 作为并行写入的第二真相源(M3 才翻转成主查)。github 的 github_id - * 列同样双写(createUser/updateProfile 已写)。identity 缺失时惰性补齐(自愈), - * 兜住 M0-M1 窗口内注册、回填尚未覆盖的账号。 + * 查找顺序(identity-first,ADR-001 迁移表"登录主查翻转"): + * 1. user_identities 命中 (provider, providerUserId) → 登入该身份所属账号; + * 2. 未命中 → 按 "{provider}_{providerUserId}" 用户名查,**仅限一行身份都没有的账号** + * (M0 回填前的老账号,见 legacyAccountByUsername); + * 3. 仍未命中 → 已验证邮箱自动关联 → 都不中则进 OTP 注册。 + * 必须身份优先:M2b 绑定后,身份所属账号的用户名不再是 "{provider}_{id}",按用户名查 + * 会 miss → 走邮箱关联(邮箱不同即分叉)或建号(github 撞 UNIQUE(github_id) 直接 500)。 + * identity 缺失时由 ensureIdentity 惰性补齐(自愈)。 */ - public LoginResponse loginByProvider(String provider, AuthUser authUser) { + public ProviderLogin loginByProvider(String provider, AuthUser authUser) { String providerUserId = authUser.getUuid(); // 保留 "{provider}_{id}" 用户名约定(github 即 "github_{id}",与历史一致)。 String username = provider + "_" + providerUserId; @@ -99,104 +121,142 @@ public LoginResponse loginByProvider(String provider, AuthUser authUser) { String displayName = authUser.getNickname() != null ? authUser.getNickname() : authUser.getUsername(); String avatarUrl = authUser.getAvatar(); String email = authUser.getEmail(); - // github 的 uuid 就是数字用户 ID;非数字(极罕见)时置 null。 - // 非 github provider 不写 github_id 列。 - Long parsedGithubId = null; + // 只有 provider 已验证的邮箱才配写进 user_accounts.email:它是自动挂靠的锚点, + // 写进未验证邮箱 = 给"谎称别人邮箱"的第三方号留了一个挂靠入口。 + String verifiedEmail = isProviderEmailVerified(provider, authUser) ? email : null; + Long githubId = null; if ("github".equals(provider)) { try { - parsedGithubId = Long.parseLong(providerUserId); + githubId = Long.parseLong(providerUserId); } catch (NumberFormatException e) { - parsedGithubId = null; + githubId = null; } } - final Long githubId = parsedGithubId; + final Long gid = githubId; - UserAccount userAccount = userCenterService.findByUsername(username).map(existing -> - userCenterService.updateProfile(existing.id(), displayName, avatarUrl, email, githubId) - ).orElseGet(() -> - autoLinkByVerifiedEmailOrCreate(provider, authUser, username, displayName, avatarUrl, email, githubId) - ); + // 身份查询失败直接抛:吞掉再回退到用户名/邮箱路径,一次 DB 抖动就可能分叉出新账号。 + Optional found = userIdentityRepository.findByProviderAndProviderUserId(provider, providerUserId) + .flatMap(identity -> userAccountRepository.findById(identity.userId())) + .or(() -> legacyAccountByUsername(username)) + // 用这家 provider 建的号(用户名前缀)跟着它刷新展示名/头像;别家登录只补空 + .map(account -> userCenterService.updateProfile(account.id(), displayName, avatarUrl, + verifiedEmail, gid, account.username().startsWith(provider + "_"))) + .or(() -> autoLinkByVerifiedEmail(provider, verifiedEmail, gid)); + if (found.isEmpty()) { + // 新用户:不在这里建号,先验证邮箱(RegistrationService),验证通过由 completeRegistration 建号。 + return new NeedsRegistration(registrationService.begin(new PendingRegistration( + provider, providerUserId, displayName, avatarUrl, email, gid, verifiedEmail != null))); + } + UserAccount userAccount = found.get(); if (!userAccount.enabled()) { - throw new IllegalStateException("账号已被禁用"); + throw new AccountDisabledException(); } ensureIdentity(userAccount.id(), provider, providerUserId, email, displayName); - return executeLogin(userAccount); + return new LoggedIn(executeLogin(userAccount)); } /** - * 该 provider 首次登录、账号还不存在时: - * 1. provider 的**已验证**邮箱唯一匹配到某个已有账号 → 挂靠到那个账号(不建新号), - * 防止已有 GitHub 账号的人用 Discord 登录被分叉出第二个账号; - * 2. 否则(邮箱缺失 / 未验证 / 无匹配 / 多个匹配)→ 建新账号。 - * - * 安全前提:只信"provider 已验证"的邮箱。攻击者拿受害者邮箱注册的第三方号无法把 - * 邮箱标成 verified(要点验证链接=控制邮箱),所以自动关联不增加攻击面(ADR-001)。 - * 挂靠时**不覆盖**已有账号的展示名/头像/github_id——只由 ensureIdentity 挂上新身份。 - * - * ponytail: 这是"信任 provider verified 邮箱"的即时防分叉;用户自有 OTP 的规范邮箱 - * 体系(注册时验证)是后续独立子系统,与本逻辑兼容。 + * OTP 验证通过后建号 + 挂身份 + 登入,同一事务(任一步失败都不留半个账号)。 + * 邮箱已属于已有账号时抛 {@link EmailTakenException},什么都不写。 + * 撞唯一约束(同一身份并发注册)抛 DataIntegrityViolationException,由调用方当作会话失效。 + * ponytail: StpUtil.login 在提交前执行;提交失败时 token 不会返回给任何人, + * 内存里那条会话指向一个永不复用的序列 id,30 天后自然过期。 */ - private UserAccount autoLinkByVerifiedEmailOrCreate(String provider, AuthUser authUser, String username, - String displayName, String avatarUrl, - String email, Long githubId) { - if (email != null && !email.isBlank() && isProviderEmailVerified(provider, authUser)) { - java.util.List matches = userAccountRepository.findByEmail(email); - if (matches.size() == 1) { - UserAccount target = matches.get(0); - log.info("verified-email 自动关联:provider={} 的已验证邮箱匹配到已有账号 id={},挂靠不建新号", - provider, target.id()); - // 反向场景(先 Discord 注册、后 GitHub 登录挂靠):目标账号 github_id 可能为空。 - // M1-M3 双写期贡献归属 / /u/{githubId} 都靠这列,补上(仅当为空,不覆盖)。 - // 写失败不阻断登录:撞 UNIQUE(github_id) 等极端情况记日志即可。 - if ("github".equals(provider) && githubId != null && target.githubId() == null) { - try { - userAccountRepository.setGithubIdIfAbsent(target.id(), githubId); - } catch (Exception e) { - log.warn("挂靠时补写 github_id 失败(userId={})", target.id(), e); - } - } - return target; - } - if (matches.size() > 1) { - // 多账号共用同一邮箱(不应发生):保守建新号,绝不猜挂给谁 - log.warn("verified-email 命中多个账号({} 个),保守建新号避免挂错", matches.size()); - } + @org.springframework.transaction.annotation.Transactional + public LoginResponse completeRegistration(RegistrationService.VerifiedRegistration verified) { + if (!userAccountRepository.findByEmail(verified.verifiedEmail()).isEmpty()) { + throw new EmailTakenException(); } - UserAccount newUser = new UserAccount( + PendingRegistration p = verified.pending(); + String username = p.provider() + "_" + p.providerUserId(); + if (userCenterService.findByUsername(username).isPresent()) { + // 被一个已经解绑/换绑掉这个身份的老账号占着(它不再是这个身份的家,见 legacyAccountByUsername)。 + // ponytail: 6 位随机后缀;再撞由 UNIQUE(username) 兜底成 410,重新登录即可。 + username += "_" + UUID.randomUUID().toString().substring(0, 6); + } + UserAccount created = userCenterService.createUser(new UserAccount( null, username, // 第三方用户不用密码登录,塞随机超长密码占位(password_hash NOT NULL) passwordService.hash(UUID.randomUUID().toString()), - displayName, + p.displayName(), true, Set.of("user"), Set.of(), - avatarUrl, - email, - githubId, + p.avatarUrl(), + verified.verifiedEmail(), + p.githubId(), null - ); - return userCenterService.createUser(newUser); + )); + userIdentityRepository.insert(new UserIdentity( + null, created.id(), p.provider(), p.providerUserId(), p.providerEmail(), p.displayName(), null, null)); + return executeLogin(created); + } + + /** + * 按 "{provider}_{id}" 用户名找账号,只认**一行身份都没有**的账号——M0 回填之前的老账号。 + * 有身份行的账号按用户名命中,只可能是这个身份已经被解绑或换绑:登进去等于解绑失效, + * 换绑后的旧 GitHub 还能登录且设置页看不到、撤不掉。那种情况走注册(用户名另取)。 + */ + private Optional legacyAccountByUsername(String username) { + return userCenterService.findByUsername(username) + .filter(account -> userIdentityRepository.findByUserId(account.id()).isEmpty()); + } + + /** + * 该 provider 首次登录、账号还不存在时:provider 的**已验证**邮箱唯一匹配到某个已有账号 → + * 挂靠到那个账号(不建新号),防止已有 GitHub 账号的人用 Discord 登录被分叉出第二个账号。 + * 邮箱缺失 / 未验证 / 无匹配 / 多个匹配 → empty,交给 OTP 注册。 + * + * 安全前提:只信"provider 已验证"的邮箱(调用方已过滤)。攻击者拿受害者邮箱注册的第三方号 + * 无法把邮箱标成 verified,所以自动关联不增加攻击面(ADR-001)。 + * 挂靠时**不覆盖**已有账号的展示名/头像/github_id——只由 ensureIdentity 挂上新身份。 + */ + private Optional autoLinkByVerifiedEmail(String provider, String verifiedEmail, Long githubId) { + if (verifiedEmail == null || verifiedEmail.isBlank()) { + return Optional.empty(); + } + java.util.List matches = userAccountRepository.findByEmail(verifiedEmail); + if (matches.size() > 1) { + // 多账号共用同一邮箱(不应发生):绝不猜挂给谁 + log.warn("verified-email 命中多个账号({} 个),不自动关联", matches.size()); + } + if (matches.size() != 1) { + return Optional.empty(); + } + UserAccount target = matches.get(0); + // 目标账号已有这家的身份:新身份挂不上去(UNIQUE(user_id, provider)),而"同家第二个号 + // 报了同一个邮箱"也不是同一个人的证据(邮箱回收转手、或锚点本身来路不正)。不挂靠。 + if (userIdentityRepository.findByUserId(target.id()).stream().anyMatch(i -> provider.equals(i.provider()))) { + log.warn("verified-email 命中的账号 id={} 已有 {} 身份,不自动关联", target.id(), provider); + return Optional.empty(); + } + log.info("verified-email 自动关联:provider={} 的已验证邮箱匹配到已有账号 id={},挂靠不建新号", + provider, target.id()); + // 反向场景(先 Discord 注册、后 GitHub 登录挂靠):目标账号 github_id 可能为空。 + // M1-M3 双写期贡献归属 / /u/{githubId} 都靠这列,补上(仅当为空,不覆盖)。 + if ("github".equals(provider) && githubId != null && target.githubId() == null) { + try { + userAccountRepository.setGithubIdIfAbsent(target.id(), githubId); + } catch (Exception e) { + log.warn("挂靠时补写 github_id 失败(userId={})", target.id(), e); + } + } + return Optional.of(target); } /** * provider 是否已验证该邮箱。只有 true 才允许按邮箱自动关联。 - * - discord:/users/@me 的 "verified" 布尔(Discord 已验证账号邮箱) - * - github:JustAuth 取的是 primary email,GitHub 要求 primary 已验证,信任 - * - 其它未知 provider:保守返回 false,不自动关联 + * 判定下沉到各 AuthProvider 实现(接新 provider 不用改这里); + * 未注册的 provider 保守返回 false,绝不自动关联。 */ private boolean isProviderEmailVerified(String provider, AuthUser authUser) { - return switch (provider) { - case "discord" -> { - com.alibaba.fastjson.JSONObject raw = authUser.getRawUserInfo(); - yield raw != null && raw.getBooleanValue("verified"); - } - case "github" -> true; - default -> false; - }; + return providers.find(provider) + .map(p -> p.isEmailVerified(authUser)) + .orElse(false); } /** @@ -221,6 +281,8 @@ public boolean hasIdentity(String provider, String providerUserId) { * 只更新时间戳的话设置页永远显示不出账号名。 * 写失败不阻断登录——与 INV-003 lazy upgrade 同策略,记日志后继续, * 下次登录还会再试,绝不让 identity 写入把用户挡在门外。 + * 例外是撞唯一约束:身份注定挂不上这个账号(该账号已有同家身份 / 身份刚被别人占走), + * 放行就是"身份不在账号上却能登进去",解绑撤不掉。拒绝这次登录。 */ private void ensureIdentity(long userId, String provider, String providerUserId, String email, String displayName) { @@ -231,6 +293,8 @@ private void ensureIdentity(long userId, String provider, String providerUserId, existing -> userIdentityRepository.recordLogin(existing.id(), email, displayName), () -> userIdentityRepository.insert(new UserIdentity( null, userId, provider, providerUserId, email, displayName, null, null))); + } catch (DataIntegrityViolationException e) { + throw new IllegalStateException("该登录方式无法关联到这个账号", e); } catch (Exception e) { log.warn("user_identities 双写失败(provider={} userId={}),不阻断登录", provider, userId, e); } diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java b/src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java index 45283f3..e47d5ef 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/RegistrationService.java @@ -7,9 +7,11 @@ import com.involutionhell.backend.usercenter.model.PendingRegistration; import java.security.SecureRandom; import java.time.Duration; +import java.util.List; import java.util.Locale; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,7 +19,7 @@ /** * 注册时的邮箱 OTP 引擎(ADR-001 的"规范邮箱"子系统)。职责聚焦:待注册态 + - * 验证码生成/发信/校验,全进程内不落库。账号建号/关联落地由调用方(wiring)在 + * 验证码生成/发信/校验,全进程内不落库。建号由 AuthService#completeRegistration 在 * OTP 通过后做。只在"第三方登录首次、且未被已验证邮箱自动关联"时进入,不是每次登录。 * * 安全模型(review 后重做): @@ -25,14 +27,21 @@ * (不被重发刷新),pending 与 OTP 同生共死,避免两缓存 TTL 错配。 * - verifyAttempts 累积计在会话上、**不被重发重置**:整个会话至多 5 次校验尝试, * 6 位码的爆破面恒为 5/1e6,堵住"重发归零 attempts 无限爆破"。 - * - 发信限流:会话内至多 5 封、两封间隔 ≥60s,防邮件轰炸。 + * - 发信限流:会话内至多 5 封、两封间隔 ≥60s;跨会话每个(收件邮箱, 第三方身份)每天 5 封、 + * 每个收件邮箱每天 20 封、每个第三方身份每天 10 封,防邮件轰炸。先查完所有限额再一起占位; + * 发信失败只退还按邮箱计的两项(护住邮箱主人),身份那项是调用方自己的预算,不退。 + * - 码只在**送达之后**才生效:发信期间会话里没有可校验的码,失败的发信不留任何可猜的东西。 + * - 猜码总量按收件邮箱封顶:每个邮箱每天至多 10 次错码(跨会话、跨身份),到顶后对它的校验一律失败。 + * 第 5 次错码即作废会话。 + * - 邮箱主人豁免按邮箱的两项全局限额:provider 已验证过的邮箱 == 发码邮箱,说明调用方本来就控制 + * 这个收件箱,攻击者拿不到别人邮箱的"已验证"副本,豁免不给攻击者开口子。 * - 校验+消费在会话锁内原子完成,双击/并发只成功一次,杜绝重复建号。 * - 校验通过返回**本次实际验证通过的 email**(不是 provider 预填的未验证地址)。 * * ponytail: 进程内 Caffeine,靠单实例 / sticky session 成立;上多实例或 GraalVM * native 时会话态要迁 Redis(同 Sa-Token session、JustAuth state,见 ADR-001)。 - * 针对特定 pendingId 的 attempts 耗尽型 DoS 由"pendingId 是随机 UUID + 15min 短命 + - * 控制层按 IP 限流"共同兜底,引擎层不额外处理。 + * 针对特定 pendingId 的 attempts 耗尽型 DoS 无从下手:pendingId 是随机 UUID,只在 + * 发起人浏览器的 httpOnly cookie 里,15min 后过期。 */ @Service public class RegistrationService { @@ -43,7 +52,22 @@ public class RegistrationService { private static final int MAX_SENDS = 5; private static final long RESEND_COOLDOWN_NANOS = Duration.ofSeconds(60).toNanos(); private static final long OTP_TTL_NANOS = Duration.ofMinutes(10).toNanos(); - private static final Duration SESSION_TTL = Duration.ofMinutes(15); + /** 会话寿命;OAuthController 的 ih_reg_pending cookie Max-Age 与之同源。 */ + public static final Duration SESSION_TTL = Duration.ofMinutes(15); + // 跨会话日限额:每个 pendingId 都要一次真实 OAuth 才能拿到,但同一个第三方号可以反复拿。 + // 按(收件邮箱, 第三方身份)限 = 一个号轰炸不了某个邮箱,也锁不死邮箱主人自己的注册—— + // 只按邮箱限 5 的话,攻击者拿自己的号对受害者邮箱发满 5 封,受害者 24h 内就注册不了; + // 按收件邮箱总量限 = 多个号合伙轰炸同一收件箱的天花板(猜码总量另由下面的错码封顶管); + // 按第三方身份限 = 防一个号把我们的发信域名当垃圾邮件中继。 + // ponytail: 不按 IP —— 后端看不到可信客户端 IP(Vercel 出口 IP 被所有人共享, + // X-Real-IP 由 Vercel 填、Caddy 原样透传,直连 api 子域即可伪造)。 + private static final int MAX_SENDS_PER_EMAIL_IDENTITY_PER_DAY = 5; + private static final int MAX_SENDS_PER_EMAIL_PER_DAY = 20; + private static final int MAX_SENDS_PER_IDENTITY_PER_DAY = 10; + // 按收件邮箱的错码总量:封顶针对某个邮箱的猜码(10 / 1e6 每天),否则多开身份就能多猜, + // 猜中 = 用受害者邮箱建号,之后受害者首登会被已验证邮箱自动挂靠进去。 + // ponytail: 代价是邮箱 provider 未验证的人可能被别人用错码锁一天(防接管优先于防锁死)。 + private static final int MAX_VERIFY_FAILURES_PER_EMAIL_PER_DAY = 10; // 粗校验邮箱格式,挡住明显非法/注入型输入;真实有效性由"能收到码"证明。 private static final Pattern EMAIL_RE = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"); @@ -57,6 +81,7 @@ public record VerifiedRegistration(PendingRegistration pending, String verifiedE private final Ticker ticker; private final boolean devConsoleOtp; private final Cache sessions; + private final Cache dailyCounts; @org.springframework.beans.factory.annotation.Autowired public RegistrationService( @@ -76,6 +101,47 @@ public RegistrationService( .expireAfterWrite(SESSION_TTL) .maximumSize(10_000) .build(); + this.dailyCounts = Caffeine.newBuilder() + .ticker(ticker) + .expireAfterWrite(Duration.ofDays(1)) + .maximumSize(100_000) + .build(); + } + + private record DailyCap(String key, int cap) {} + + /** + * 固定 24h 窗口计数(同 OpenAiStreamRateLimiter 的写法)。所有限额都没满才一起占一格, + * 满一个就一格都不占——不能让被拒的请求白白吃掉别的限额。检查与占位在同一把锁里, + * 并发会话冲不过上限;发信失败由 {@link #refundDaily} 退还。 + * ponytail: 全局锁,临界区只是几次内存读写;发信量远到不了要分段锁的程度。 + */ + private boolean reserveDaily(List caps) { + synchronized (dailyCounts) { + for (DailyCap c : caps) { + if (dailyCount(c.key()) >= c.cap()) { + return false; + } + } + caps.forEach(c -> dailyCounts.get(c.key(), k -> new AtomicInteger()).incrementAndGet()); + return true; + } + } + + private int dailyCount(String key) { + AtomicInteger n = dailyCounts.getIfPresent(key); + return n == null ? 0 : n.get(); + } + + private void refundDaily(List caps) { + synchronized (dailyCounts) { + caps.forEach(c -> { + AtomicInteger n = dailyCounts.getIfPresent(c.key()); + if (n != null) { + n.decrementAndGet(); + } + }); + } } /** 一个注册会话的可变状态。所有读改在会话锁内完成。 */ @@ -91,6 +157,16 @@ private static final class RegSession { RegSession(PendingRegistration reg) { this.reg = reg; } + + /** provider 已验证的邮箱就是 target:调用方本来就控制这个收件箱。 */ + boolean ownsEmail(String target) { + return reg.providerEmailVerified() && reg.providerEmail() != null + && reg.providerEmail().trim().equalsIgnoreCase(target); + } + } + + private static String emailKey(String email) { + return "email:" + email.toLowerCase(Locale.ROOT); } /** 建一个待注册会话,返回 pendingId。会话 15min 后自然过期(不被重发刷新)。 */ @@ -107,7 +183,8 @@ public Optional getPending(String pendingId) { } /** - * 给指定邮箱发 6 位验证码。限流:会话至多 {@value #MAX_SENDS} 封、间隔 ≥60s。 + * 给指定邮箱发 6 位验证码。限流:会话至多 {@value #MAX_SENDS} 封、间隔 ≥60s,加上跨会话日限额。 + * 发信失败退还按邮箱计的限额(会话、身份两项照算,冷却照算),码只在送达后生效。 * verifyAttempts 不在此重置。 */ public SendResult sendOtp(String pendingId, String email) { @@ -119,8 +196,16 @@ public SendResult sendOtp(String pendingId, String email) { if (!EMAIL_RE.matcher(normalized).matches()) { return SendResult.INVALID_EMAIL; } - String code; - String to; + String emailKey = emailKey(normalized); + String identityKey = "id:" + s.reg.provider() + ":" + s.reg.providerUserId(); + DailyCap pairCap = new DailyCap(emailKey + "|" + identityKey, MAX_SENDS_PER_EMAIL_IDENTITY_PER_DAY); + DailyCap emailCap = new DailyCap(emailKey, MAX_SENDS_PER_EMAIL_PER_DAY); + DailyCap identityCap = new DailyCap(identityKey, MAX_SENDS_PER_IDENTITY_PER_DAY); + boolean owner = s.ownsEmail(normalized); + List caps = owner ? List.of(pairCap, identityCap) : List.of(pairCap, emailCap, identityCap); + // 失败只退按邮箱计的:它们护的是邮箱主人;身份那项是调用方自己的预算,退了就等于不限 Resend 调用 + List refundOnFailure = owner ? List.of(pairCap) : List.of(pairCap, emailCap); + String code = String.format(Locale.ROOT, "%06d", RANDOM.nextInt(1_000_000)); synchronized (s) { long now = ticker.read(); if (s.sends >= MAX_SENDS) { @@ -129,13 +214,12 @@ public SendResult sendOtp(String pendingId, String email) { if (s.lastSendAtNanos != Long.MIN_VALUE && now - s.lastSendAtNanos < RESEND_COOLDOWN_NANOS) { return SendResult.RATE_LIMITED; } - code = String.format(Locale.ROOT, "%06d", RANDOM.nextInt(1_000_000)); - s.otpEmail = normalized; - s.otpCode = code; - s.otpExpiresAtNanos = now + OTP_TTL_NANOS; + if (!reserveDaily(caps)) { + return SendResult.RATE_LIMITED; + } s.sends++; s.lastSendAtNanos = now; - to = normalized; + s.otpCode = null; // 旧码随重发作废;新码等送达后才挂上 } // 开发兜底:验证码直接打到控制台,让贡献者不配 Resend 也能跑通完整注册流 // (Django/Rails 的 console email backend 同款)。 @@ -146,23 +230,35 @@ public SendResult sendOtp(String pendingId, String email) { if (devConsoleOtp && !emailService.isConfigured()) { log.warn("[DEV-OTP] 控制台兜底(registration.otp.dev-console=true),未真实发信: pendingId={} code={}", pendingId, code); + armCode(s, normalized, code); return SendResult.SENT; } String html = "

你的 InvolutionHell 注册验证码是:

" + "

" + code + "

" + "

10 分钟内有效。如果不是你本人操作,忽略即可。

"; - boolean sent = emailService.sendHtml(to, "InvolutionHell 注册验证码", html); - if (!sent) { - log.warn("OTP 发信失败 pendingId={}", pendingId); // 不落收件邮箱(PII) + if (!emailService.sendHtml(normalized, "InvolutionHell 注册验证码", html)) { + refundDaily(refundOnFailure); + // 不落收件邮箱(PII),也不落 pendingId(它是注册会话的唯一凭据) + log.warn("OTP 发信失败 provider={}", s.reg.provider()); return SendResult.SEND_FAILED; } + armCode(s, normalized, code); return SendResult.SENT; } + /** 送达后才让码可校验:发信途中、发信失败都没有可猜的码。 */ + private void armCode(RegSession s, String email, String code) { + synchronized (s) { + s.otpEmail = email; + s.otpCode = code; + s.otpExpiresAtNanos = ticker.read() + OTP_TTL_NANOS; + } + } + /** * 校验验证码。成功则**原子**消费整个会话,返回注册意图 + 本次已验证的规范邮箱。 * 失败返回空(会话过期 / 未发码 / 码过期 / 超次 / 邮箱或码不符)。邮箱必须与发码 - * 对象一致,防换邮箱绕过。attempts 累积计数,超 {@value #MAX_VERIFY_ATTEMPTS} 即锁死会话。 + * 对象一致,防换邮箱绕过。attempts 累积计数,第 {@value #MAX_VERIFY_ATTEMPTS} 次错码即作废会话。 */ public Optional verifyAndConsume(String pendingId, String email, String code) { RegSession s = sessions.getIfPresent(pendingId); @@ -184,9 +280,21 @@ public Optional verifyAndConsume(String pendingId, String return Optional.empty(); // 已锁死 } String normalized = email == null ? "" : email.trim(); + boolean owner = s.ownsEmail(s.otpEmail); + String failKey = "verify-fail:" + emailKey(s.otpEmail); + if (!owner && dailyCount(failKey) >= MAX_VERIFY_FAILURES_PER_EMAIL_PER_DAY) { + return Optional.empty(); // 针对这个邮箱的猜码额度今天已用完,正确码也不放行 + } boolean ok = s.otpEmail.equalsIgnoreCase(normalized) && s.otpCode.equals(code); if (!ok) { - s.verifyAttempts++; + if (!owner) { + synchronized (dailyCounts) { + dailyCounts.get(failKey, k -> new AtomicInteger()).incrementAndGet(); + } + } + if (++s.verifyAttempts >= MAX_VERIFY_ATTEMPTS) { + sessions.invalidate(pendingId); // 锁死即作废:调用方据此提示重新登录,也不再能发信 + } return Optional.empty(); } sessions.invalidate(pendingId); // 一次性消费,锁内完成 diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java b/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java index 6ac5a9b..b87ee1e 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java @@ -71,10 +71,11 @@ public UserAccount createUser(UserAccount userAccount) { } /** - * 刷新 GitHub 用户的个人资料(展示名、头像、邮箱、GitHub ID),每次登录时调用。 + * 第三方登录时补齐资料(展示名、头像、邮箱、GitHub ID),覆盖规则见 repository。 */ - public UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId) { - return userAccountRepository.updateProfile(userId, displayName, avatarUrl, email, githubId); + public UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId, + boolean refreshNameAndAvatar) { + return userAccountRepository.updateProfile(userId, displayName, avatarUrl, email, githubId, refreshNameAndAvatar); } /** diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java b/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java index 5df76dd..5dffd93 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/UserIdentityService.java @@ -8,15 +8,19 @@ import java.util.List; import java.util.Locale; import java.util.Optional; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * 登录身份的读取与解绑(M2a)。绑定(新建第二 provider)走 M2b 的 OAuth 流程。 + * 登录身份的读取、绑定(M2b)与解绑(M2a)。 + * 绑定由 OAuthController 的 /oauth/bind/{provider} 流程走完 OAuth 后调用。 */ @Service public class UserIdentityService { + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(UserIdentityService.class); + private final UserIdentityRepository userIdentityRepository; private final UserAccountRepository userAccountRepository; @@ -32,6 +36,78 @@ public List listForUser(long userId) { .toList(); } + /** 绑定冲突。带一个前端可辨识的 code,便于给出"该账号已被占用"之类的具体文案。 */ + public static class IdentityAlreadyBoundException extends RuntimeException { + private final String errorCode; + + IdentityAlreadyBoundException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public String errorCode() { + return errorCode; + } + } + + /** + * 绑定一个第三方身份到已有账号(M2b)。**不建号、不改会话**——调用方必须已经从 + * 服务端校验过的会话拿到 userId。 + * + * 两种冲突分别对应 schema 里的两条唯一约束,都要给出可区分的提示而不是 500: + * - UNIQUE(provider, provider_user_id):该第三方身份已绑到别的账号 + * - UNIQUE(user_id, provider) :本账号已经绑过同类 provider + * + * github 额外补写 user_accounts.github_id(仅当为空)——贡献归属与 /u/{githubId} + * 都依赖该列,与 unbind 时清空它是对称的。 + */ + @Transactional + public List bind(long userId, String provider, String providerUserId, + String email, String displayName) { + String normalized = provider == null ? null : provider.toLowerCase(Locale.ROOT); + if (normalized == null || normalized.isBlank() || providerUserId == null || providerUserId.isBlank()) { + throw new IllegalArgumentException("provider 与 providerUserId 不能为空"); + } + + userIdentityRepository.findByProviderAndProviderUserId(normalized, providerUserId) + .ifPresent(existing -> { + if (existing.userId() == userId) { + throw new IdentityAlreadyBoundException("bind_already_yours", + "该登录方式已经绑定在你的账号上了"); + } + throw new IdentityAlreadyBoundException("bind_taken", + "该第三方账号已绑定到另一个账号,请先在那个账号里解绑"); + }); + + boolean sameProviderBound = userIdentityRepository.findByUserId(userId).stream() + .anyMatch(i -> i.provider().equals(normalized)); + if (sameProviderBound) { + throw new IdentityAlreadyBoundException("bind_duplicate", + "你已经绑定过 " + normalized + " 了,一个账号同一登录方式只能绑一个"); + } + + // 上面的预检挡不住并发:两个账号同时绑同一身份都能过预检,后到的撞 UNIQUE。 + // github_id 列同理(UNIQUE(github_id),被一个尚无身份行的老账号占着)。 + // 都归为 bind_taken 而不是 500;异常抛出即回滚,身份行不会单独留下。 + try { + userIdentityRepository.insert(new UserIdentity( + null, userId, normalized, providerUserId, email, displayName, null, null)); + + if ("github".equals(normalized)) { + try { + userAccountRepository.setGithubIdIfAbsent(userId, Long.parseLong(providerUserId)); + } catch (NumberFormatException e) { + // github 的 uuid 就是数字用户 ID;非数字(极罕见)时跳过,不阻断绑定 + log.warn("绑定 github 时 providerUserId 非数字,跳过 github_id 回填: userId={}", userId); + } + } + } catch (DataIntegrityViolationException e) { + throw new IdentityAlreadyBoundException("bind_taken", + "该第三方账号已绑定到另一个账号,请先在那个账号里解绑"); + } + return listForUser(userId); + } + /** * provider 身份 → 已启用账号的 id。没绑定或账号被禁用返回 empty。 * 给机器人渠道把 Discord 分享归到本人名下用,不涉及登录。 diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6647f43..484c230 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -17,19 +17,9 @@ CREATE TABLE IF NOT EXISTS user_accounts ( -- 偏好设置列(JSONB 顶层合并,前端可自由扩展 key) ALTER TABLE user_accounts ADD COLUMN IF NOT EXISTS preferences JSONB NOT NULL DEFAULT '{}'::jsonb; --- 默认种子账号(已存在则跳过) --- admin / Admin@123456 --- alice / Alice@123456 --- auditor / Audit@123456 --- --- 哈希格式:bcrypt(cost=10) —— INV-003 起点。老库里仍可能保留裸 SHA-256 哈希, --- 由 PasswordService.matches 兼容识别 + AuthService 在登录成功后做 lazy upgrade --- 就地迁移为 bcrypt。生产首次部署的新库直接使用 bcrypt seed,不再有 SHA-256 裸值。 -INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions) -VALUES ('admin', '$2b$10$Bfnw8v.BsXeZVPbre94sJeokgEfKuCLsdH7ckJxzxn5nxirHJHmP.', 'Admin', TRUE, 'admin', 'user:profile:read,user:center:read,user:center:manage'), - ('alice', '$2b$10$BmtVOmPK8Os/xreOTImsdec1fAA8Y9iTm1D823swYucSI2NDFdk.q', 'Alice', TRUE, 'user', 'user:profile:read'), - ('auditor', '$2b$10$/1OfzhrA6CITrjJsDbzk.uMLq6cHa/iOP./wL2BAPo9t7QRq7Ca5W', 'Auditor', TRUE, 'auditor', 'user:profile:read,user:center:read') -ON CONFLICT (username) DO NOTHING; +-- 这里不放可登录的 seed 账号(INV-011):本文件在生产每次启动都会执行, +-- 带默认密码的 admin/alice/auditor 会原样落进生产库。本地开发的 seed 在 +-- docker/init-db/init.sql,测试的 seed 在 src/test/resources/test-schema.sql。 -- Discord 桥接系统账号(不可登录)。 -- 双重禁用: diff --git a/src/test/java/com/involutionhell/backend/community/controller/SharedLinkInternalControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/community/controller/SharedLinkInternalControllerIntegrationTests.java index e78e49f..b9e5712 100644 --- a/src/test/java/com/involutionhell/backend/community/controller/SharedLinkInternalControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/community/controller/SharedLinkInternalControllerIntegrationTests.java @@ -133,6 +133,39 @@ void submit_missingUrl_returns400() throws Exception { .andExpect(jsonPath("$.message").value("url is required")); } + // ── 只认本机直连(INV-013)────────────────────────────────────────────── + // 经 Caddy / Vercel rewrite 转来的请求都带 X-Forwarded-For;key 正确也当接口不存在 + + @Test + @DisplayName("POST /internal 带 X-Forwarded-For → 404,key 对也不行,不落库") + void submit_viaProxy_returns404() throws Exception { + String url = "https://arxiv.org/abs/2610.10900"; + mockMvc.perform(post("/api/community/links/internal") + .header(KEY_HEADER, KEY) + .header("X-Forwarded-For", "203.0.113.7") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"url\":\"" + url + "\"}")) + .andExpect(status().isNotFound()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM shared_links WHERE url = ?", Integer.class, url)) + .isZero(); + } + + @Test + @DisplayName("GET /internal/summary 与 /internal/{id} 带 X-Forwarded-For → 404") + void readEndpoints_viaProxy_return404() throws Exception { + mockMvc.perform(get("/api/community/links/internal/summary") + .header(KEY_HEADER, KEY).header("X-Forwarded-For", "203.0.113.7")) + .andExpect(status().isNotFound()); + String url = "https://arxiv.org/abs/2610.10901"; + Integer id = com.jayway.jsonpath.JsonPath.read(submitAs(url, null).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(), "$.data.id"); + mockMvc.perform(get("/api/community/links/internal/" + id) + .header(KEY_HEADER, KEY).header("X-Forwarded-For", "203.0.113.7")) + .andExpect(status().isNotFound()); + mockMvc.perform(get("/api/community/links/internal/" + id).header(KEY_HEADER, KEY)) + .andExpect(status().isOk()); + } + // ── 分享人归属(discordUserId)────────────────────────────────────────── // H2 库在整个测试进程内共享,用户名 / Discord id / URL 都取本类专用的值,避免撞别的测试 diff --git a/src/test/java/com/involutionhell/backend/security/SecurityInvariantsTests.java b/src/test/java/com/involutionhell/backend/security/SecurityInvariantsTests.java index 15727fb..73d2b3e 100644 --- a/src/test/java/com/involutionhell/backend/security/SecurityInvariantsTests.java +++ b/src/test/java/com/involutionhell/backend/security/SecurityInvariantsTests.java @@ -265,7 +265,7 @@ class SecurityInvariantsTests extends AbstractWebIntegrationTest { /** * INV-003:seed 账号能用明文密码登录 = bcrypt seed 与 dual-mode matches 端到端联通。 * - * 防回归:如果有人误把 schema.sql / test-schema.sql 的 password_hash 回退到 + * 防回归:如果有人误把 init.sql / test-schema.sql 的 password_hash 回退到 * 不可用的格式(错的 bcrypt / 错的明文),AbstractWebIntegrationTest 的 * loginAndGetToken 会在 jsonPath 断言阶段抛 AssertionError,本测试随之红。 * @@ -382,6 +382,64 @@ class SecurityInvariantsTests extends AbstractWebIntegrationTest { .isEmpty(); } + // ============================================================ + // INV-011 · schema.sql 不得 seed 可登录的账号 + // 见 SECURITY.md / schema.sql + // ============================================================ + + /** + * INV-011:schema.sql 里不能出现任何 PasswordService.matches 会接受的哈希字面量。 + * + * schema.sql 在生产每次启动都跑(SPRING_SQL_INIT_MODE=always),里面的 seed 账号 + * 会带着 README 公开的默认密码落进生产库。matches 只认两种格式:bcrypt + * ($2[aby]$NN$...)和 legacy 裸 SHA-256(64 位十六进制),两种都扫,注释也扫。 + * 不可登录的系统账号(discord-bridge,password_hash='!')不受影响。 + * 本地/测试的 seed 只放 docker/init-db/init.sql 和 test-schema.sql。 + */ + @Test + void schema_sql里不允许seed带密码哈希的账号() throws Exception { + String schema = new String( + getClass().getResourceAsStream("/schema.sql").readAllBytes(), + java.nio.charset.StandardCharsets.UTF_8); + Pattern loginableHash = Pattern.compile("\\$2[aby]\\$\\d{2}\\$|\\b[0-9a-fA-F]{64}\\b"); + java.util.List hits = loginableHash.matcher(schema).results() + .map(java.util.regex.MatchResult::group) + .toList(); + Assertions.assertThat(hits) + .as("schema.sql 在生产每次启动都会执行,不能带可登录的密码哈希;" + + "本地/测试 seed 放 docker/init-db/init.sql 与 test-schema.sql。检测到: %s", hits) + .isEmpty(); + } + + // ============================================================ + // INV-012 · 新账号只经 OTP 验证建号;provider token 不落库 + // 见 SECURITY.md / AuthService#completeRegistration + // ============================================================ + + /** + * INV-012:账号表与身份表不能有存 provider access/refresh token 的列。 + * + * 我们只用 token 换一次用户资料(拒绝/失败时还会立刻撤销),之后再也用不上; + * 落库只会把"拖库"升级成"拿着用户的 GitHub/Discord 授权去调对方 API"。 + * 扫 schema.sql(唯一真相源)里这两张表的 CREATE TABLE 与 ALTER TABLE,注释不算。 + */ + @Test + void 账号表和身份表不存第三方token() throws Exception { + String schema = new String( + getClass().getResourceAsStream("/schema.sql").readAllBytes(), + java.nio.charset.StandardCharsets.UTF_8) + .replaceAll("--[^\\n]*", ""); + java.util.List ddl = Pattern.compile( + "(?is)(CREATE TABLE IF NOT EXISTS|ALTER TABLE)\\s+(user_accounts|user_identities)\\b.*?;") + .matcher(schema).results() + .map(java.util.regex.MatchResult::group) + .toList(); + Assertions.assertThat(ddl).as("应能找到两张表的 DDL").hasSizeGreaterThanOrEqualTo(2); + Assertions.assertThat(ddl) + .as("user_accounts / user_identities 不能有 token/secret 列") + .noneMatch(stmt -> Pattern.compile("(?i)token|secret").matcher(stmt).find()); + } + private static int indentOf(String line) { return line.length() - line.stripLeading().length(); } diff --git a/src/test/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java index e73148d..7cb7129 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/UserIdentityServiceTests.java @@ -107,4 +107,105 @@ void unbindEndpointRejectsAnonymous() throws Exception { mockMvc.perform(delete("/api/user-center/identities/github")) .andExpect(status().isUnauthorized()); } + + // ===================== M2b 绑定 ===================== + + @Test + void bindAttachesIdentityToExistingAccountWithoutCreatingOne() { + long userId = createUser(null); + addIdentity(userId, "github", "gh-" + userId); + + var after = service.bind(userId, "discord", "dc-" + userId, "a@e.com", "Nick"); + + assertThat(after).hasSize(2); + assertThat(after).extracting(v -> v.provider()).containsExactlyInAnyOrder("github", "discord"); + // 绑定不建号 + assertThat(jdbc.queryForObject( + "SELECT count(*) FROM user_accounts WHERE username LIKE 'ident-svc-%'", Integer.class)).isEqualTo(1); + } + + @Test + void bindRejectsIdentityAlreadyOwnedByAnotherAccount() { + long owner = createUser(null); + addIdentity(owner, "github", "gh-owner-" + owner); + addIdentity(owner, "discord", "shared-discord-id"); + + long other = createUser(null); + addIdentity(other, "github", "gh-other-" + other); + + // 这正是"先 GA 再做 M2b"的死局:分叉账号占着身份,本尊补绑不进来。 + // 必须给出可辨识的 code,而不是 500。 + assertThatThrownBy(() -> service.bind(other, "discord", "shared-discord-id", null, null)) + .isInstanceOf(UserIdentityService.IdentityAlreadyBoundException.class) + .satisfies(e -> assertThat( + ((UserIdentityService.IdentityAlreadyBoundException) e).errorCode()).isEqualTo("bind_taken")); + } + + @Test + void bindRejectsSecondIdentityOfSameProvider() { + long userId = createUser(null); + addIdentity(userId, "github", "gh-" + userId); + addIdentity(userId, "discord", "dc-a-" + userId); + + assertThatThrownBy(() -> service.bind(userId, "discord", "dc-b-" + userId, null, null)) + .isInstanceOf(UserIdentityService.IdentityAlreadyBoundException.class) + .satisfies(e -> assertThat( + ((UserIdentityService.IdentityAlreadyBoundException) e).errorCode()).isEqualTo("bind_duplicate")); + } + + @Test + void rebindingOwnIdentityIsReportedDistinctly() { + long userId = createUser(null); + addIdentity(userId, "github", "gh-" + userId); + addIdentity(userId, "discord", "dc-" + userId); + + assertThatThrownBy(() -> service.bind(userId, "discord", "dc-" + userId, null, null)) + .isInstanceOf(UserIdentityService.IdentityAlreadyBoundException.class) + .satisfies(e -> assertThat( + ((UserIdentityService.IdentityAlreadyBoundException) e).errorCode()) + .isEqualTo("bind_already_yours")); + } + + @Test + void bindingGithubBackfillsGithubIdColumn() { + // 与 unbind 清空 github_id 对称:贡献归属和 /u/{githubId} 都依赖这列 + long userId = createUser(null); + addIdentity(userId, "discord", "dc-" + userId); + + service.bind(userId, "github", "114514", "a@e.com", "Nick"); + + assertThat(jdbc.queryForObject( + "SELECT github_id FROM user_accounts WHERE id = ?", Long.class, userId)).isEqualTo(114514L); + } + + @Test + void bindReportsTakenWhenGithubIdColumnHeldByAnotherAccountAndRollsBack() { + // 老账号占着 github_id=777 但没有身份行(M0 回填前的窗口),预检看不见它 + createUser(777L); + long userId = createUser(null); + addIdentity(userId, "discord", "dc-" + userId); + + assertThatThrownBy(() -> service.bind(userId, "github", "777", null, null)) + .isInstanceOf(UserIdentityService.IdentityAlreadyBoundException.class) + .satisfies(e -> assertThat( + ((UserIdentityService.IdentityAlreadyBoundException) e).errorCode()).isEqualTo("bind_taken")); + // 身份行随事务回滚,不留半绑定状态 + assertThat(service.listForUser(userId)).extracting("provider").containsExactly("discord"); + } + + @Test + void bindRaceOnIdentityInsertIsReportedAsTakenNot500() { + // 两个账号并发绑同一身份:都过了预检,后到的 insert 撞 UNIQUE(provider, provider_user_id) + var repo = org.mockito.Mockito.mock( + com.involutionhell.backend.usercenter.repository.UserIdentityRepository.class); + org.mockito.Mockito.when(repo.insert(org.mockito.ArgumentMatchers.any())) + .thenThrow(new org.springframework.dao.DuplicateKeyException("uq_provider_user")); + var raced = new UserIdentityService(repo, org.mockito.Mockito.mock( + com.involutionhell.backend.usercenter.repository.UserAccountRepository.class)); + + assertThatThrownBy(() -> raced.bind(1L, "discord", "dc-race", null, null)) + .isInstanceOf(UserIdentityService.IdentityAlreadyBoundException.class) + .satisfies(e -> assertThat( + ((UserIdentityService.IdentityAlreadyBoundException) e).errorCode()).isEqualTo("bind_taken")); + } } diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java index 69f9917..c4ec30c 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerAllowlistTests.java @@ -19,7 +19,10 @@ class OAuthControllerAllowlistTests { private final AuthService authService = mock(AuthService.class); - private final OAuthController controller = new OAuthController(authService); + private final OAuthController controller = new OAuthController( + authService, + mock(com.involutionhell.backend.usercenter.service.UserIdentityService.class), + new com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry(java.util.List.of())); private OAuthController withAllowlist(String raw) { controller.configureDiscordAllowlist(raw); @@ -87,7 +90,7 @@ void blankUuidIsRejected() { @Test void existingIdentityPassesEvenWhenNotAllowlisted() { - // 灰度要拦的是新用户建号(OTP wiring 未完成);已经有账号的人再登录不该被拦, + // 灰度要拦的是新用户进注册;已经有账号的人再登录不该被拦, // 否则他会被锁在自己的账号外面,且提示只说"敬请期待"。 when(authService.hasIdentity("discord", "old-user")).thenReturn(true); assertThat(withAllowlist("111").discordAllowed("old-user")).isTrue(); diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java index 0ad4677..d907704 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/OAuthControllerIntegrationTests.java @@ -1,13 +1,38 @@ package com.involutionhell.backend.usercenter.controller; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.involutionhell.backend.support.AbstractWebIntegrationTest; +import com.involutionhell.backend.usercenter.oauth.AuthProvider; +import com.involutionhell.backend.usercenter.service.AuthService; +import com.involutionhell.backend.usercenter.service.UserIdentityService; +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import me.zhyd.oauth.enums.AuthResponseStatus; +import me.zhyd.oauth.model.AuthCallback; +import me.zhyd.oauth.model.AuthResponse; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MvcResult; /** @@ -21,8 +46,8 @@ * 2. callback 失败路径 —— 携带无效 state/code 时,JustAuth 返回失败响应, * 控制器重定向至前端错误页。 * - * callback 成功路径(需要真实 GitHub code + state)超出集成测试范围, - * 该路径的业务逻辑已由 AuthServiceTests.loginByGithub*() 系列单元测试覆盖。 + * 真实 GitHub 的 callback 成功路径(需要真实 code + state)超出集成测试范围; + * FakeProviderConfig 注册两个不发网络请求的假 provider,登录/绑定回调的成功路径用它们端到端走通。 * * 强制 discord 未配置:否则本机 .env 里若有真实 AUTH_DISCORD_ID(被 source 进环境), * "未配置 discord → oauth_provider" 这条断言会因 discord 实际已配置而失败。 @@ -31,6 +56,7 @@ "justauth.type.discord.client-id=", "justauth.type.discord.client-secret=" }) +@Import(OAuthControllerIntegrationTests.FakeProviderConfig.class) class OAuthControllerIntegrationTests extends AbstractWebIntegrationTest { // ============================================= @@ -156,4 +182,452 @@ void callbackWithoutParametersRedirectsToFrontendErrorPage() throws Exception { .isNotNull() .endsWith("/login?error=oauth_failed"); } + + // ============================================= + // POST /oauth/bind/{provider} — 绑定发起(M2b) + // ============================================= + + @Test + void bindEntryRequiresLogin() throws Exception { + // INV-007:绑定目标账号只能来自服务端校验过的会话。未登录就发起绑定必须被拒, + // 否则"绑定到哪个账号"就只能取自客户端可控输入 = 绑定劫持。 + mockMvc.perform(post("/oauth/bind/github")) + .andExpect(status().isUnauthorized()); + } + + @Test + void bindEntryRequiresLoginEvenForUnknownProvider() throws Exception { + // 鉴权必须先于 provider 解析,否则未登录者能通过响应差异探测支持哪些 provider + mockMvc.perform(post("/oauth/bind/myspace")) + .andExpect(status().isUnauthorized()); + } + + @Test + void bindStartIsNotAcceptedViaGet() throws Exception { + // GET 能被跨站顶级导航触发,带上浏览器里被种下的别人的 satoken cookie 就能发起绑定 + MockHttpServletResponse res = mockMvc.perform(get("/oauth/bind/" + FAKE).header("satoken", loginAsAlice())) + .andExpect(status().isMethodNotAllowed()) + .andReturn().getResponse(); + assertThat(res.getCookie(OAuthController.STATE_COOKIE)).isNull(); + assertThat(res.getCookie(OAuthController.BIND_COOKIE)).isNull(); + } + + @Test + void bindStartReturnsAuthorizeUrlAndSetsStateAndMarkerCookies() throws Exception { + MockHttpServletResponse res = mockMvc.perform(post("/oauth/bind/" + FAKE).header("satoken", loginAsAlice())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andReturn().getResponse(); + String url = JsonPath.read(res.getContentAsString(), "$.data.url"); + Cookie state = res.getCookie(OAuthController.STATE_COOKIE); + Cookie marker = res.getCookie(OAuthController.BIND_COOKIE); + + assertThat(url).isEqualTo("https://fake.example/authorize?state=" + state.getValue()); + assertThat(state.isHttpOnly()).isTrue(); + assertThat(state.getMaxAge()).isEqualTo(300); + assertThat(marker.getValue()).isEqualTo("1"); + assertThat(marker.getMaxAge()).as("标记比 state 活得久,过期的绑定才能回到设置页") + .isEqualTo(OAuthController.BIND_COOKIE_MAX_AGE_SECONDS); + } + + @Test + void bindStartWithUnknownProviderIsJson4xxAndSetsNoCookies() throws Exception { + MockHttpServletResponse res = mockMvc.perform(post("/oauth/bind/myspace").header("satoken", loginAsAlice())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andReturn().getResponse(); + assertThat(res.getCookie(OAuthController.STATE_COOKIE)).isNull(); + assertThat(res.getCookie(OAuthController.BIND_COOKIE)).isNull(); + } + + // ============================================= + // 绑定回调(M2b)—— 用 FakeProviderConfig 的假 provider 走通换 token + // ============================================= + + @Autowired + private JdbcTemplate jdbc; + + @AfterEach + void cleanupFakeIdentities() { + jdbc.update("DELETE FROM user_identities WHERE provider IN (?, ?)", FAKE, FAKE_GH); + // 回归失败时(回调误入登录分支)会建出 fakeoauth_* 账号,别让它污染其它测试 + jdbc.update("DELETE FROM user_accounts WHERE username LIKE ? OR username LIKE ?", FAKE + "_%", FAKE_GH + "_%"); + jdbc.update("UPDATE user_accounts SET email = NULL WHERE username = 'alice'"); + } + + /** 以 token 身份发起绑定,返回回调要带回的 state + 绑定标记 cookie。 */ + private Cookie[] startBind(String token) throws Exception { + return startBind(FAKE, token); + } + + private Cookie[] startBind(String provider, String token) throws Exception { + MockHttpServletResponse res = mockMvc.perform(post("/oauth/bind/" + provider).header("satoken", token)) + .andExpect(status().isOk()) + .andReturn().getResponse(); + Cookie state = res.getCookie(OAuthController.STATE_COOKIE); + Cookie marker = res.getCookie(OAuthController.BIND_COOKIE); + assertThat(marker).as("发起绑定必须种下绑定标记").isNotNull(); + assertThat(marker.getValue()).isEqualTo("1"); + return new Cookie[]{state, marker}; + } + + private String callback(String code, Cookie[] cookies, String token) throws Exception { + return callback(FAKE, code, cookies, token); + } + + private String callback(String provider, String code, Cookie[] cookies, String token) throws Exception { + var req = get("/api/auth/callback/" + provider) + .param("code", code) + .param("state", cookies[0].getValue()) + .cookie(cookies); + if (token != null) { + req.header("satoken", token); + } + return mockMvc.perform(req).andExpect(status().is3xxRedirection()) + .andReturn().getResponse().getRedirectedUrl(); + } + + /** 走一次登录(非绑定):render 拿 state cookie → 回调。返回重定向地址。 */ + private String login(String provider, String code) throws Exception { + return login(provider, code, null).getRedirectedUrl(); + } + + /** 同上,render 带 ?n=(null 即不带),把 render 种下的 cookie 全数带回回调。 */ + private MockHttpServletResponse login(String provider, String code, String n) throws Exception { + var render = get("/oauth/render/" + provider); + if (n != null) { + render.param("n", n); + } + // 浏览器不会回传 Max-Age=0 的清除型 cookie + Cookie[] cookies = java.util.Arrays.stream(mockMvc.perform(render).andReturn().getResponse().getCookies()) + .filter(c -> c.getMaxAge() != 0).toArray(Cookie[]::new); + Cookie state = java.util.Arrays.stream(cookies) + .filter(c -> c.getName().equals(OAuthController.STATE_COOKIE)).findFirst().orElseThrow(); + return mockMvc.perform(get("/api/auth/callback/" + provider) + .param("code", code).param("state", state.getValue()).cookie(cookies)) + .andExpect(status().is3xxRedirection()) + .andReturn().getResponse(); + } + + private int accountCount() { + return jdbc.queryForObject("SELECT count(*) FROM user_accounts", Integer.class); + } + + private java.util.List ownersOf(String providerUserId) { + return ownersOf(FAKE, providerUserId); + } + + private java.util.List ownersOf(String provider, String providerUserId) { + return jdbc.queryForList("SELECT user_id FROM user_identities WHERE provider = ? AND provider_user_id = ?", + Long.class, provider, providerUserId); + } + + /** 建一个账号(密码同 alice),可选挂若干身份 {provider, providerUserId}。 */ + private long account(String username, String[]... identities) { + jdbc.update(""" + INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions) + SELECT ?, password_hash, ?, TRUE, 'user', '' FROM user_accounts WHERE username = 'alice'""", + username, username); + long id = jdbc.queryForObject("SELECT id FROM user_accounts WHERE username = ?", Long.class, username); + for (String[] i : identities) { + jdbc.update("INSERT INTO user_identities (user_id, provider, provider_user_id) VALUES (?, ?, ?)", id, i[0], i[1]); + } + return id; + } + + @Test + void bindCallbackInSameSessionAttachesIdentityWithoutCreatingAccount() throws Exception { + String alice = loginAsAlice(); + int before = accountCount(); + + String location = callback("same-session", startBind(alice), alice); + + assertThat(location).endsWith("/settings?bind=ok"); + assertThat(ownersOf("same-session")).containsExactly(2L); // alice + assertThat(accountCount()).as("绑定不建号").isEqualTo(before); + } + + @Test + void bindCallbackAfterLogoutIsRejected() throws Exception { + Cookie[] cookies = startBind(loginAsAlice()); + + assertThat(callback("logged-out", cookies, null)).endsWith("/settings?bind_error=bind_session"); + assertThat(ownersOf("logged-out")).isEmpty(); + } + + @Test + void bindCallbackUnderAnotherUsersSessionIsRejected() throws Exception { + Cookie[] cookies = startBind(loginAsAlice()); + + assertThat(callback("other-user", cookies, loginAsAdmin())).endsWith("/settings?bind_error=bind_session"); + assertThat(ownersOf("other-user")).isEmpty(); + } + + @Test + void replayedBindCallbackIsRejectedInsteadOfFallingIntoLogin() throws Exception { + String alice = loginAsAlice(); + Cookie[] cookies = startBind(alice); + assertThat(callback("replayed", cookies, alice)).endsWith("/settings?bind=ok"); + int before = accountCount(); + + // 意图已被一次性消费。没有绑定标记的话,这次会被当成登录:按假身份登录/建号。 + assertThat(callback("replayed", cookies, alice)).endsWith("/settings?bind_error=bind_session"); + // 换个 code 也一样——不能让"丢了意图"的回调建号 + assertThat(callback("replayed-new", cookies, null)).endsWith("/settings?bind_error=bind_session"); + + assertThat(accountCount()).isEqualTo(before); + assertThat(ownersOf("replayed")).containsExactly(2L); + assertThat(ownersOf("replayed-new")).isEmpty(); + } + + @Test + void bindThatOutlivesStateCookieReturnsToSettingsNotLogin() throws Exception { + // 在 provider 页停了 5 分钟以上:意图和 state cookie 都过期了,只剩标记。 + // 必须回到设置页报绑定错误,而不是 /login(登录页再点一次就是分叉出新账号)。 + String alice = loginAsAlice(); + Cookie marker = startBind(alice)[1]; + String location = mockMvc.perform(get("/api/auth/callback/" + FAKE) + .param("code", "late").param("state", "expired-state") + .cookie(marker).header("satoken", alice)) + .andExpect(status().is3xxRedirection()) + .andReturn().getResponse().getRedirectedUrl(); + + assertThat(location).endsWith("/settings?bind_error=oauth_state"); + assertThat(ownersOf("late")).isEmpty(); + } + + @Test + void loginRenderClearsStaleBindMarker() throws Exception { + // 放弃绑定后再走登录:残留标记必须被清掉,否则登录回调会被误判成绑定 + MockHttpServletResponse res = mockMvc.perform(get("/oauth/render/github")) + .andExpect(status().is3xxRedirection()) + .andReturn().getResponse(); + Cookie marker = res.getCookie(OAuthController.BIND_COOKIE); + assertThat(marker).isNotNull(); + assertThat(marker.getMaxAge()).isZero(); + } + + // ============================================= + // 登录 nonce 回显(INV-007):#token= 只回给发起登录的那个 tab + // ============================================= + + private static final String NONCE = "AbCdEfGhIjKlMnOp_-0123"; + + @Test + void renderWithValidNonceSetsLoginNonceCookie() throws Exception { + MockHttpServletResponse res = mockMvc.perform(get("/oauth/render/" + FAKE).param("n", NONCE)) + .andExpect(status().is3xxRedirection()) + .andReturn().getResponse(); + Cookie nonce = res.getCookie(OAuthController.LOGIN_NONCE_COOKIE); + assertThat(nonce).isNotNull(); + assertThat(nonce.getValue()).isEqualTo(NONCE); + assertThat(nonce.isHttpOnly()).isTrue(); + assertThat(nonce.getMaxAge()).isEqualTo(300); + assertThat(nonce.getPath()).isEqualTo("/"); + assertThat(res.getHeaders("Set-Cookie")) + .anySatisfy(h -> assertThat(h).startsWith(OAuthController.LOGIN_NONCE_COOKIE + "=").contains("SameSite=Lax")); + } + + @Test + void renderWithInvalidOrMissingNonceSetsNone() throws Exception { + for (String n : new String[]{"short", "a".repeat(65), "has space in it!!", "AbCdEfGh;Path=/x;"}) { + assertThat(mockMvc.perform(get("/oauth/render/" + FAKE).param("n", n)).andReturn().getResponse() + .getCookie(OAuthController.LOGIN_NONCE_COOKIE)).as(n).isNull(); + } + assertThat(mockMvc.perform(get("/oauth/render/" + FAKE)).andReturn().getResponse() + .getCookie(OAuthController.LOGIN_NONCE_COOKIE)).isNull(); + } + + @Test + void loginSuccessEchoesNonceFromCookieAndClearsIt() throws Exception { + account(FAKE + "_n1", new String[]{FAKE, "n1"}); + MockHttpServletResponse res = login(FAKE, "n1", NONCE); + + assertThat(res.getRedirectedUrl()).matches(".*/#token=[^&]+&n=" + NONCE); + assertThat(res.getCookie(OAuthController.LOGIN_NONCE_COOKIE).getMaxAge()).isZero(); + } + + @Test + void loginSuccessWithoutNonceCookieRedirectsWithPlainToken() throws Exception { + account(FAKE + "_n2", new String[]{FAKE, "n2"}); + + assertThat(login(FAKE, "n2", null).getRedirectedUrl()).matches(".*/#token=[^&]+"); + } + + @Test + void nonceCookieIsClearedOnErrorAndRegistrationRedirects() throws Exception { + MockHttpServletResponse reg = login(FAKE, "n-new", NONCE); + assertThat(reg.getRedirectedUrl()).as("注册跳转不带 n").endsWith("/login/verify"); + assertThat(reg.getCookie(OAuthController.LOGIN_NONCE_COOKIE).getMaxAge()).isZero(); + + MockHttpServletResponse err = mockMvc.perform(get("/api/auth/callback/" + FAKE) + .cookie(new Cookie(OAuthController.LOGIN_NONCE_COOKIE, NONCE))) + .andReturn().getResponse(); + assertThat(err.getRedirectedUrl()).endsWith("/login?error=oauth_failed"); + assertThat(err.getCookie(OAuthController.LOGIN_NONCE_COOKIE).getMaxAge()).isZero(); + } + + // ============================================= + // 账号归属:未验证邮箱、自动挂靠、解绑/换绑之后的登录 + // ============================================= + + private String emailOf(String username) { + return jdbc.queryForObject("SELECT email FROM user_accounts WHERE username = ?", String.class, username); + } + + @Test + void unverifiedEmailPlantedViaBindNeverCapturesAVictimsFirstLogin() throws Exception { + // 攻击者(email 为空)绑一个报了受害者邮箱、但没验证过的第三方号,再用它登录 + String attacker = loginAsAlice(); + assertThat(callback("evil|victim@x.com|false", startBind(attacker), attacker)).endsWith("/settings?bind=ok"); + assertThat(login(FAKE, "evil|victim@x.com|false")).contains("/#token="); + assertThat(emailOf("alice")).as("未验证邮箱不能进 user_accounts.email(它是自动挂靠的锚点)").isNull(); + + // 受害者第一次用另一家登录,已验证邮箱就是 victim@x.com:不能落进攻击者账号 + assertThat(login(FAKE_GH, "victim-gh|victim@x.com|true")).endsWith("/login/verify"); + assertThat(ownersOf(FAKE_GH, "victim-gh")).isEmpty(); + } + + @Test + void verifiedEmailNeverAutoLinksIntoAccountThatAlreadyHasThatProvider() throws Exception { + jdbc.update("UPDATE user_accounts SET email = 'shared@x.com' WHERE username = 'alice'"); + String alice = loginAsAlice(); + assertThat(callback(FAKE_GH, "gh-1", startBind(FAKE_GH, alice), alice)).endsWith("/settings?bind=ok"); + + // 同家第二个号报了同一个已验证邮箱:alice 已有这家身份,挂不上,进注册 + assertThat(login(FAKE_GH, "gh-2|shared@x.com|true")).endsWith("/login/verify"); + assertThat(ownersOf(FAKE_GH, "gh-2")).isEmpty(); + // 反向:alice 还没有的那家,同一个已验证邮箱照常挂靠 + assertThat(login(FAKE, "dc-1|shared@x.com|true")).contains("/#token="); + assertThat(ownersOf(FAKE, "dc-1")).containsExactly(2L); + } + + @Test + void unboundIdentityCannotLogBackInThroughTheUsername() throws Exception { + account(FAKE_GH + "_g1", new String[]{FAKE_GH, "g1"}, new String[]{FAKE, "d1"}); + String token = loginAndGetToken(FAKE_GH + "_g1", "Alice@123456"); + mockMvc.perform(delete("/api/user-center/identities/" + FAKE_GH).header("satoken", token)) + .andExpect(status().isOk()); + + assertThat(login(FAKE_GH, "g1")).endsWith("/login/verify"); + assertThat(ownersOf(FAKE_GH, "g1")).as("解绑不能被下一次登录静默补回来").isEmpty(); + } + + @Test + void switchingGithubViaBindLocksTheOldOneOut() throws Exception { + long id = account(FAKE_GH + "_g1", new String[]{FAKE_GH, "g1"}, new String[]{FAKE, "d1"}); + String token = loginAndGetToken(FAKE_GH + "_g1", "Alice@123456"); + mockMvc.perform(delete("/api/user-center/identities/" + FAKE_GH).header("satoken", token)) + .andExpect(status().isOk()); + assertThat(callback(FAKE_GH, "g2", startBind(FAKE_GH, token), token)).endsWith("/settings?bind=ok"); + + assertThat(login(FAKE_GH, "g1")).endsWith("/login/verify"); + assertThat(login(FAKE_GH, "g2")).contains("/#token="); + assertThat(ownersOf(FAKE_GH, "g2")).containsExactly(id); + assertThat(ownersOf(FAKE_GH, "g1")).isEmpty(); + } + + @Test + void legacyAccountWithoutAnyIdentityStillLogsInByUsername() throws Exception { + // M0 回填之前的老账号(一行身份都没有):用户名回退照旧,并补上身份行 + long id = account(FAKE_GH + "_g9"); + assertThat(login(FAKE_GH, "g9")).contains("/#token="); + assertThat(ownersOf(FAKE_GH, "g9")).containsExactly(id); + } + + // ============================================= + // 登录回调兜底:账号禁用 / 意外异常不能是裸 500 + // ============================================= + + @MockitoSpyBean + private AuthService authService; + + @Test + void disabledAccountIsToldSoInsteadOfOauthFailed() throws Exception { + long id = account(FAKE + "_dis", new String[]{FAKE, "dis"}); + jdbc.update("UPDATE user_accounts SET enabled = FALSE WHERE id = ?", id); + + assertThat(login(FAKE, "dis")).endsWith("/login?error=account_disabled"); + } + + @Test + void unexpectedLoginFailureRedirectsToOauthFailed() throws Exception { + doThrow(new RuntimeException("db down")).when(authService).loginByProvider(eq(FAKE), any()); + + assertThat(login(FAKE, "boom")).endsWith("/login?error=oauth_failed"); + } + + @MockitoSpyBean + private UserIdentityService userIdentityService; + + @Test + void unexpectedBindFailureRedirectsToSettingsInsteadOfRaw500() throws Exception { + String alice = loginAsAlice(); + doThrow(new DataAccessResourceFailureException("db down")) + .when(userIdentityService).bind(anyLong(), eq(FAKE), eq("bind-boom"), any(), any()); + + assertThat(callback("bind-boom", startBind(alice), alice)).endsWith("/settings?bind_error=oauth_failed"); + assertThat(ownersOf("bind-boom")).isEmpty(); + } + + static final String FAKE = "fakeoauth"; + /** 第二个假 provider:测"同家第二个号""另一家登录"这类需要两家的场景。 */ + static final String FAKE_GH = "fakegh"; + + /** + * 假 provider:authorize 不发请求,login 把 code 解析成第三方用户返回。 + * code 形如 "uuid" 或 "uuid|email|verified"(verified 即该 provider 是否担保这个邮箱)。 + * 每个测试用不同 uuid,互不撞 UNIQUE(provider, provider_user_id)。 + */ + @TestConfiguration + static class FakeProviderConfig { + @Bean + AuthProvider fakeOAuthProvider() { + return fake(FAKE); + } + + @Bean + AuthProvider fakeGithubProvider() { + return fake(FAKE_GH); + } + + private static AuthProvider fake(String key) { + return new AuthProvider() { + @Override + public String key() { + return key; + } + + @Override + public String redirectUri() { + return "https://example.com/api/auth/callback/" + key; + } + + @Override + public boolean isEmailVerified(AuthUser user) { + return user.getRawUserInfo().getBooleanValue("verified"); + } + + @Override + public AuthRequest newRequest() { + return new AuthRequest() { + @Override + public String authorize(String state) { + return "https://fake.example/authorize?state=" + state; + } + + @Override + public AuthResponse login(AuthCallback cb) { + String[] parts = cb.getCode().split("\\|"); + com.alibaba.fastjson.JSONObject raw = new com.alibaba.fastjson.JSONObject(); + raw.put("verified", parts.length > 2 && Boolean.parseBoolean(parts[2])); + return new AuthResponse<>(AuthResponseStatus.SUCCESS.getCode(), null, + AuthUser.builder().uuid(parts[0]).nickname("fake-" + parts[0]) + .email(parts.length > 1 ? parts[1] : null).rawUserInfo(raw).build()); + } + }; + } + }; + } + } } diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/RegistrationFlowIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/RegistrationFlowIntegrationTests.java new file mode 100644 index 0000000..5acae3c --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/RegistrationFlowIntegrationTests.java @@ -0,0 +1,164 @@ +package com.involutionhell.backend.usercenter.controller; + +import static com.involutionhell.backend.usercenter.controller.OAuthControllerIntegrationTests.FAKE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.involutionhell.backend.common.email.ResendEmailService; +import com.involutionhell.backend.support.AbstractWebIntegrationTest; +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@Import(OAuthControllerIntegrationTests.FakeProviderConfig.class) +class RegistrationFlowIntegrationTests extends AbstractWebIntegrationTest { + + @Autowired private JdbcTemplate jdbc; + @MockitoBean private ResendEmailService email; + + @BeforeEach + void resendOk() { + when(email.isConfigured()).thenReturn(true); + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true); + } + + @AfterEach + void cleanup() { + jdbc.update("DELETE FROM user_identities WHERE provider = ?", FAKE); + jdbc.update("DELETE FROM user_accounts WHERE username LIKE ?", FAKE + "_%"); + jdbc.update("UPDATE user_accounts SET email = NULL WHERE username = 'alice'"); + } + + private int accounts() { + return jdbc.queryForObject("SELECT count(*) FROM user_accounts", Integer.class); + } + + /** 走一次假 provider 的登录回调,返回 response(重定向地址 + cookie)。 */ + private MockHttpServletResponse oauthLogin(String uuid) throws Exception { + Cookie state = mockMvc.perform(get("/oauth/render/" + FAKE)).andReturn().getResponse() + .getCookie(OAuthController.STATE_COOKIE); + return mockMvc.perform(get("/api/auth/callback/" + FAKE).param("code", uuid) + .param("state", state.getValue()).cookie(state)) + .andExpect(status().is3xxRedirection()).andReturn().getResponse(); + } + + private String sendAndCaptureCode(Cookie reg, String to) throws Exception { + mockMvc.perform(post("/auth/register/send-otp").cookie(reg) + .contentType(MediaType.APPLICATION_JSON).content("{\"email\":\"" + to + "\"}")) + .andExpect(status().isOk()); + ArgumentCaptor html = ArgumentCaptor.forClass(String.class); + Mockito.verify(email, Mockito.atLeastOnce()).sendHtml(eq(to), anyString(), html.capture()); + Matcher m = Pattern.compile("(\\d{6})").matcher(html.getValue()); + assertThat(m.find()).isTrue(); + return m.group(1); + } + + private org.springframework.test.web.servlet.ResultActions verify(Cookie reg, String to, String code) throws Exception { + return mockMvc.perform(post("/auth/register/verify").cookie(reg).contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + to + "\",\"code\":\"" + code + "\"}")); + } + + @Test + void newUserIsSentToVerifyPageWithPendingIdOnlyInHttpOnlyCookie() throws Exception { + int before = accounts(); + MockHttpServletResponse res = oauthLogin("new-1"); + + assertThat(res.getRedirectedUrl()).endsWith("/login/verify"); // 不带 pendingId、不带邮箱 + Cookie reg = res.getCookie(OAuthController.REG_COOKIE); + assertThat(reg).isNotNull(); + assertThat(reg.isHttpOnly()).isTrue(); + assertThat(accounts()).as("验证前不建号").isEqualTo(before); + } + + @Test + void fullRegistrationCreatesAccountWithVerifiedEmailAndLogsIn() throws Exception { + int before = accounts(); + Cookie reg = oauthLogin("new-2").getCookie(OAuthController.REG_COOKIE); + mockMvc.perform(get("/auth/register/pending").cookie(reg)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.provider").value(FAKE)); + + String code = sendAndCaptureCode(reg, "me@example.com"); + String body = verify(reg, "me@example.com", code).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + String token = JsonPath.read(body, "$.data.tokenValue"); + + mockMvc.perform(get("/auth/me").header("satoken", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.email").value("me@example.com")) + .andExpect(jsonPath("$.data.username").value(FAKE + "_new-2")); + assertThat(accounts()).isEqualTo(before + 1); + assertThat(jdbc.queryForMap("SELECT roles, permissions FROM user_accounts WHERE username = ?", FAKE + "_new-2")) + .as("新账号只有 user 角色、没有任何额外权限(INV-012)") + .containsEntry("roles", "user").containsEntry("permissions", ""); + + // 回访:身份已在,直接发 token,不再进验证页、不再建号 + assertThat(oauthLogin("new-2").getRedirectedUrl()).contains("/#token="); + assertThat(accounts()).isEqualTo(before + 1); + // 一次性:同一 pendingId 不能再建第二个号 + verify(reg, "me@example.com", code).andExpect(status().isGone()); + } + + @Test + void verifiedEmailOfExistingAccountIsNotMergedAndCreatesNothing() throws Exception { + jdbc.update("UPDATE user_accounts SET email = 'alice@example.com' WHERE username = 'alice'"); + int before = accounts(); + Cookie reg = oauthLogin("new-3").getCookie(OAuthController.REG_COOKIE); + + String code = sendAndCaptureCode(reg, "Alice@Example.com"); + verify(reg, "Alice@Example.com", code).andExpect(status().isConflict()); + + assertThat(accounts()).isEqualTo(before); + assertThat(jdbc.queryForObject("SELECT count(*) FROM user_identities WHERE provider = ?", Integer.class, FAKE)) + .as("不挂到 alice 名下,也不建新号").isZero(); + } + + @Test + void identityTakenBeforeVerifyIs410AndLeavesNoOrphanAccount() throws Exception { + Cookie reg = oauthLogin("race-1").getCookie(OAuthController.REG_COOKIE); + // 验证前同一身份被并发绑到了别的账号:建号之后挂身份撞 UNIQUE(provider, provider_user_id) + jdbc.update("INSERT INTO user_identities (user_id, provider, provider_user_id) " + + "SELECT id, ?, ? FROM user_accounts WHERE username = 'alice'", FAKE, "race-1"); + int before = accounts(); + + String code = sendAndCaptureCode(reg, "race@example.com"); + verify(reg, "race@example.com", code).andExpect(status().isGone()); + + assertThat(accounts()).as("建号 + 挂身份同一事务:不能留下没有身份的孤儿账号").isEqualTo(before); + } + + @Test + void wrongCodeIs400UntilLockThen410() throws Exception { + Cookie reg = oauthLogin("new-4").getCookie(OAuthController.REG_COOKIE); + sendAndCaptureCode(reg, "x@example.com"); + for (int i = 0; i < 4; i++) { + verify(reg, "x@example.com", "000000").andExpect(status().isBadRequest()); + } + verify(reg, "x@example.com", "000000").andExpect(status().isGone()); + } + + @Test + void endpointsArePublicButNeedTheCookie() throws Exception { + mockMvc.perform(get("/auth/register/pending")).andExpect(status().isGone()); + mockMvc.perform(post("/auth/register/send-otp").contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"a@b.co\"}")).andExpect(status().isGone()); + verify(new Cookie(OAuthController.REG_COOKIE, "forged"), "a@b.co", "123456").andExpect(status().isGone()); + } +} diff --git a/src/test/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistryTests.java b/src/test/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistryTests.java new file mode 100644 index 0000000..4642ad8 --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/oauth/AuthProviderRegistryTests.java @@ -0,0 +1,75 @@ +package com.involutionhell.backend.usercenter.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import me.zhyd.oauth.model.AuthUser; +import me.zhyd.oauth.request.AuthRequest; +import org.junit.jupiter.api.Test; + +/** + * provider 注册表的契约。这是"provider 名"在后端的单一真相源, + * 接入新 provider 只应新增一个 AuthProvider 实现,不改任何已有文件。 + */ +class AuthProviderRegistryTests { + + /** 最小实现,只用来验证注册表本身的行为。 */ + private record StubProvider(String key, boolean verified) implements AuthProvider { + @Override public AuthRequest newRequest() { + throw new UnsupportedOperationException("测试不发起真实 OAuth"); + } + @Override public String redirectUri() { + return "http://cb/" + key; + } + @Override public boolean isEmailVerified(AuthUser user) { + return verified; + } + } + + @Test + void findsRegisteredProviderCaseInsensitively() { + var registry = new AuthProviderRegistry(List.of(new StubProvider("discord", true))); + assertThat(registry.find("discord")).isPresent(); + assertThat(registry.find("DISCORD")).as("URL 路径段大小写不该影响查找").isPresent(); + } + + @Test + void unknownProviderReturnsEmptyInsteadOfThrowing() { + // 调用方据此重定向到错误页;抛异常会变成 500 + var registry = new AuthProviderRegistry(List.of(new StubProvider("github", true))); + assertThat(registry.find("myspace")).isEmpty(); + assertThat(registry.find(null)).isEmpty(); + } + + @Test + void duplicateKeysFailAtStartupRatherThanPickingOneAtRandom() { + // 两个同名实现 = 行为不确定(谁生效取决于 bean 顺序)。启动即炸好过线上随机命中。 + assertThatThrownBy(() -> new AuthProviderRegistry( + List.of(new StubProvider("discord", true), new StubProvider("discord", false)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("重复的 AuthProvider key"); + } + + @Test + void realProvidersAreRegisteredWithExpectedEmailTrust() { + var registry = new AuthProviderRegistry(List.of( + new GithubAuthProvider("id", "secret", "http://cb/github"), + new DiscordAuthProvider("id", "secret", "http://cb/discord"))); + + assertThat(registry.keys()).containsExactlyInAnyOrder("github", "discord"); + // GitHub 的 primary email 必然已验证 → 可用于自动关联 + assertThat(registry.find("github").orElseThrow().isEmailVerified(AuthUser.builder().build())).isTrue(); + // Discord 未带 verified 标记时必须保守判为未验证,否则是账号接管向量 + assertThat(registry.find("discord").orElseThrow().isEmailVerified(AuthUser.builder().build())).isFalse(); + } + + @Test + void unconfiguredProviderRejectsRequestCreation() { + // 只配一半(有 id 无 secret)也要在发起阶段就挡住,而不是到 token 交换才失败 + var provider = new DiscordAuthProvider("id", "", "http://cb/discord"); + assertThatThrownBy(provider::newRequest) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("未配置"); + } +} diff --git a/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java b/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java index 428a978..d229aab 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java @@ -33,6 +33,9 @@ class JdbcUserAccountRepositoryTests { @Autowired private JdbcUserAccountRepository repository; + @Autowired + private org.springframework.jdbc.core.JdbcTemplate jdbc; + // ============================================= // findById // ============================================= @@ -177,22 +180,65 @@ void updateAuthorizationThrowsWhenUserMissing() { // ============================================= @Test - void updateProfileUpdatesGithubFields() { + void updateProfileFillsBlankColumnsButKeepsExistingDisplayName() { + // 种子 alice:display_name='Alice',头像/邮箱/github_id 都为空 UserAccount updated = repository.updateProfile( - 2L, "Alice Updated", "https://new-avatar.com", "alice@github.com", 12345L + 2L, "Alice Updated", "https://new-avatar.com", "alice@github.com", 12345L, false ); assertThat(updated.username()).isEqualTo("alice"); - assertThat(updated.displayName()).isEqualTo("Alice Updated"); + assertThat(updated.displayName()).as("已有值不被覆盖").isEqualTo("Alice"); assertThat(updated.avatarUrl()).isEqualTo("https://new-avatar.com"); assertThat(updated.email()).isEqualTo("alice@github.com"); assertThat(updated.githubId()).isEqualTo(12345L); } + @Test + void updateProfileNeverOverwritesOrNullsExistingValues() { + repository.updateProfile(2L, null, "https://a.png", "alice@github.com", 12345L, false); + + // 换个 provider 登录:null 不能抹掉 email/github_id,新值也不能覆盖旧值 + UserAccount afterNulls = repository.updateProfile(2L, null, null, null, null, false); + UserAccount afterOthers = repository.updateProfile(2L, "Other", "https://b.png", "other@x.com", 999L, false); + + for (UserAccount u : List.of(afterNulls, afterOthers)) { + assertThat(u.displayName()).isEqualTo("Alice"); + assertThat(u.avatarUrl()).isEqualTo("https://a.png"); + assertThat(u.email()).isEqualTo("alice@github.com"); + assertThat(u.githubId()).isEqualTo(12345L); + } + } + + @Test + void originProviderRefreshesNameAndAvatarButEmailAndGithubIdStayFillBlank() { + repository.updateProfile(2L, null, "https://a.png", "alice@github.com", 12345L, false); + + UserAccount refreshed = repository.updateProfile(2L, "Alice Renamed", "https://b.png", "other@x.com", 999L, true); + assertThat(refreshed.displayName()).isEqualTo("Alice Renamed"); + assertThat(refreshed.avatarUrl()).isEqualTo("https://b.png"); + assertThat(refreshed.email()).as("邮箱是挂靠锚点,原始 provider 登录也不覆盖").isEqualTo("alice@github.com"); + assertThat(refreshed.githubId()).isEqualTo(12345L); + + // 新值为 null / 空串时不抹掉 + UserAccount afterBlank = repository.updateProfile(2L, "", null, null, null, true); + assertThat(afterBlank.displayName()).isEqualTo("Alice Renamed"); + assertThat(afterBlank.avatarUrl()).isEqualTo("https://b.png"); + } + + @Test + void updateProfileTreatsEmptyStringAsBlank() { + jdbc.update("UPDATE user_accounts SET display_name = '', email = '' WHERE id = 2"); + + UserAccount updated = repository.updateProfile(2L, "Alice", null, "alice@github.com", null, false); + + assertThat(updated.displayName()).isEqualTo("Alice"); + assertThat(updated.email()).isEqualTo("alice@github.com"); + } + @Test void updateProfileHandlesNullEmailAndGithubId() { // 邮箱私密、uuid 无法解析 → 允许 null - UserAccount updated = repository.updateProfile(2L, "Alice", null, null, null); + UserAccount updated = repository.updateProfile(2L, "Alice", null, null, null, false); assertThat(updated.username()).isEqualTo("alice"); assertThat(updated.displayName()).isEqualTo("Alice"); @@ -203,7 +249,7 @@ void updateProfileHandlesNullEmailAndGithubId() { @Test void updateProfileThrowsWhenUserMissing() { - assertThatThrownBy(() -> repository.updateProfile(999L, "Name", null, null, null)) + assertThatThrownBy(() -> repository.updateProfile(999L, "Name", null, null, null, false)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("用户不存在: 999"); } diff --git a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java index 7de10f7..9889165 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java @@ -44,9 +44,31 @@ class AuthServiceTests { @Mock private com.involutionhell.backend.usercenter.repository.UserIdentityRepository userIdentityRepository; - @InjectMocks private AuthService authService; + @Mock + private RegistrationService registrationService; + + /** 登入已有账号的结局;新用户结局(NeedsRegistration)在这里抛 ClassCastException。 */ + private LoginResponse loginAs(String provider, AuthUser u) { + return ((AuthService.LoggedIn) authService.loginByProvider(provider, u)).response(); + } + + /** + * 用**真实**的 AuthProviderRegistry + 真实 provider 实例,而不是 mock: + * "该 provider 的邮箱算不算已验证"是自动关联的安全判据,这里要测真逻辑。 + * client-id/secret 随便给,本测试不发起真实 OAuth 请求。 + */ + @org.junit.jupiter.api.BeforeEach + void wireAuthService() { + var registry = new com.involutionhell.backend.usercenter.oauth.AuthProviderRegistry(java.util.List.of( + new com.involutionhell.backend.usercenter.oauth.GithubAuthProvider("id", "secret", "http://cb/github"), + new com.involutionhell.backend.usercenter.oauth.DiscordAuthProvider("id", "secret", "http://cb/discord"))); + authService = new AuthService(userCenterService, passwordService, + userAccountRepository, userIdentityRepository, registry, registrationService); + org.mockito.Mockito.lenient().when(registrationService.begin(any())).thenReturn("pid-1"); + } + /** * identity 双写默认:缺行(Optional.empty)→ ensureIdentity 走 insert 路径。 * lenient 因为账号密码登录相关测试不触及 identity 分支。 @@ -68,7 +90,9 @@ void stubIdentityLookupEmpty() { * 造 Discord AuthUser(带 rawUserInfo.verified,供已验证邮箱自动关联测试)。 */ private AuthUser discordUser(String uuid, String email, boolean emailVerified) { - AuthUser user = mock(AuthUser.class); + // lenient:身份命中路径不验邮箱,rawUserInfo 的 stub 用不到 + AuthUser user = mock(AuthUser.class, + org.mockito.Mockito.withSettings().strictness(org.mockito.quality.Strictness.LENIENT)); when(user.getUuid()).thenReturn(uuid); when(user.getNickname()).thenReturn("dc-" + uuid); when(user.getAvatar()).thenReturn(null); @@ -238,115 +262,71 @@ void loginDoesNotUpgradeWhenHashIsAlreadyBcrypt() { } // ============================================= - // loginByGithub() - GitHub OAuth 登录 + // loginByProvider() - 新用户进注册 / 老用户登入 // ============================================= - @Test - void loginByGithubAutoRegistersNewUser() { - // GitHub UUID 为纯数字,可被解析为 Long - AuthUser ghUser = githubUser("12345", "GitHubNick", "https://avatar.url", "user@github.com"); - - UserAccount createdAccount = enabledUser(10L, "github_12345", "random-hash"); - when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); - when(passwordService.hash(any())).thenReturn("random-hash"); - when(userCenterService.createUser(any())).thenReturn(createdAccount); - + /** 新用户结局:断言没建号、没挂身份、没登入,返回交给 RegistrationService 的注册意图。 */ + private com.involutionhell.backend.usercenter.model.PendingRegistration expectRegistration(String provider, AuthUser u) { try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { - stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); - stpUtil.when(StpUtil::getTokenValue).thenReturn("token-xyz"); - - LoginResponse response = authService.loginByGithub(ghUser); - - // 应调用 createUser 而非 updateProfile - verify(userCenterService).createUser(any()); - assertThat(response.user().username()).isEqualTo("github_12345"); + assertThat(authService.loginByProvider(provider, u)) + .isEqualTo(new AuthService.NeedsRegistration("pid-1")); + stpUtil.verifyNoInteractions(); } + verify(userCenterService, org.mockito.Mockito.never()).createUser(any()); + verify(userIdentityRepository, org.mockito.Mockito.never()).insert(any()); + var cap = org.mockito.ArgumentCaptor.forClass(com.involutionhell.backend.usercenter.model.PendingRegistration.class); + verify(registrationService).begin(cap.capture()); + return cap.getValue(); } @Test - void loginByGithubSetsCorrectGithubIdOnNewUser() { - AuthUser ghUser = githubUser("99999", "Nick", null, null); - - when(userCenterService.findByUsername("github_99999")).thenReturn(Optional.empty()); - when(passwordService.hash(any())).thenReturn("hash"); - when(userCenterService.createUser(any())).thenAnswer(inv -> { - UserAccount arg = inv.getArgument(0); - // 验证 githubId 被正确解析为 Long - assertThat(arg.githubId()).isEqualTo(99999L); - return enabledUser(10L, "github_99999", "hash"); - }); + void newGithubUserStartsRegistrationInsteadOfCreatingAccount() { + AuthUser ghUser = githubUser("12345", "GitHubNick", "https://avatar.url", "user@github.com"); + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); - try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { - stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); - stpUtil.when(StpUtil::getTokenValue).thenReturn("token"); - authService.loginByGithub(ghUser); - } + assertThat(expectRegistration("github", ghUser)).isEqualTo(new com.involutionhell.backend.usercenter.model.PendingRegistration( + "github", "12345", "GitHubNick", "https://avatar.url", "user@github.com", 12345L, true)); } @Test - void loginByGithubSetsNullGithubIdWhenUuidIsNotNumeric() { - // GitHub UUID 非纯数字(极罕见,但代码中有 try-catch 处理) + void nonNumericGithubUuidGivesNullGithubIdInPending() { AuthUser ghUser = githubUser("not-a-number", "Nick", null, null); - when(userCenterService.findByUsername("github_not-a-number")).thenReturn(Optional.empty()); - when(passwordService.hash(any())).thenReturn("hash"); - when(userCenterService.createUser(any())).thenAnswer(inv -> { - UserAccount arg = inv.getArgument(0); - // UUID 无法解析为数字时,githubId 应为 null - assertThat(arg.githubId()).isNull(); - return enabledUser(10L, "github_not-a-number", "hash"); - }); - try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { - stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); - stpUtil.when(StpUtil::getTokenValue).thenReturn("token"); - authService.loginByGithub(ghUser); - } + assertThat(expectRegistration("github", ghUser).githubId()).isNull(); } @Test - void loginByGithubUsesUsernameAsFallbackWhenNicknameIsNull() { - // GitHub 用户昵称为 null 时,回退使用 username 字段作为 displayName + void pendingDisplayNameFallsBackToLoginWhenNicknameIsNull() { AuthUser ghUser = githubUser("12345", null, null, null); - when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); - when(passwordService.hash(any())).thenReturn("hash"); - when(userCenterService.createUser(any())).thenAnswer(inv -> { - UserAccount arg = inv.getArgument(0); - // displayName 应来自 getUsername(),即 "github-login-12345" - assertThat(arg.displayName()).isEqualTo("github-login-12345"); - return enabledUser(10L, "github_12345", "hash"); - }); - try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { - stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); - stpUtil.when(StpUtil::getTokenValue).thenReturn("token"); - authService.loginByGithub(ghUser); - } + assertThat(expectRegistration("github", ghUser).displayName()).isEqualTo("github-login-12345"); } @Test - void loginByGithubUpdatesProfileWhenUserAlreadyExists() { + void originProviderLoginRefreshesNameAndAvatar() { AuthUser ghUser = githubUser("12345", "UpdatedNick", "https://new-avatar.url", "new@github.com"); UserAccount existing = enabledUser(10L, "github_12345", "hash"); + // 账号是 github 建的(用户名前缀)→ github 登录刷新展示名/头像(SQL 见 JdbcUserAccountRepositoryTests) UserAccount afterUpdate = new UserAccount( 10L, "github_12345", "hash", "UpdatedNick", true, Set.of("user"), Set.of("user:profile:read"), "https://new-avatar.url", "new@github.com", 12345L, null ); when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(existing)); - when(userCenterService.updateProfile(10L, "UpdatedNick", "https://new-avatar.url", "new@github.com", 12345L)) + when(userCenterService.updateProfile(10L, "UpdatedNick", "https://new-avatar.url", "new@github.com", 12345L, true)) .thenReturn(afterUpdate); try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); stpUtil.when(StpUtil::getTokenValue).thenReturn("token-xyz"); - LoginResponse response = authService.loginByGithub(ghUser); + LoginResponse response = loginAs("github", ghUser); // 已有用户应走 updateProfile 而非 createUser - verify(userCenterService).updateProfile(10L, "UpdatedNick", "https://new-avatar.url", "new@github.com", 12345L); + verify(userCenterService).updateProfile(10L, "UpdatedNick", "https://new-avatar.url", "new@github.com", 12345L, true); assertThat(response.user().displayName()).isEqualTo("UpdatedNick"); assertThat(response.user().githubId()).isEqualTo(12345L); } @@ -359,60 +339,19 @@ void loginByGithubThrowsWhenExistingAccountIsDisabled() { UserAccount disabledAccount = disabledUser(10L, "github_12345"); when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(disabledAccount)); // updateProfile 仍会被调用(刷新资料),但返回的账号仍是禁用状态 - when(userCenterService.updateProfile(any(), any(), any(), any(), any())) + when(userCenterService.updateProfile(any(), any(), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean())) .thenReturn(disabledAccount); - assertThatThrownBy(() -> authService.loginByGithub(ghUser)) - .isInstanceOf(IllegalStateException.class) + assertThatThrownBy(() -> loginAs("github", ghUser)) + .isInstanceOf(AuthService.AccountDisabledException.class) .hasMessage("账号已被禁用"); } - @Test - void loginByGithubThrowsWhenNewlyRegisteredAccountIsDisabled() { - // 理论上不会发生(新注册账号默认启用),但防御性测试 - AuthUser ghUser = githubUser("12345", "Nick", null, null); - - UserAccount disabledAccount = disabledUser(10L, "github_12345"); - when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); - when(passwordService.hash(any())).thenReturn("hash"); - when(userCenterService.createUser(any())).thenReturn(disabledAccount); - - assertThatThrownBy(() -> authService.loginByGithub(ghUser)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("账号已被禁用"); - } - - // ============================================= - // loginByProvider() - identity 双写(M1) - // ============================================= - - @Test - void newUserGetsIdentityInserted() { - AuthUser ghUser = githubUser("12345", "Nick", null, null); - when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); - when(passwordService.hash(any())).thenReturn("hash"); - when(userCenterService.createUser(any())).thenReturn(enabledUser(10L, "github_12345", "hash")); - - try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { - stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); - stpUtil.when(StpUtil::getTokenValue).thenReturn("token"); - authService.loginByGithub(ghUser); - } - - org.mockito.ArgumentCaptor cap = - org.mockito.ArgumentCaptor.forClass(com.involutionhell.backend.usercenter.model.UserIdentity.class); - verify(userIdentityRepository).insert(cap.capture()); - assertThat(cap.getValue().userId()).isEqualTo(10L); - assertThat(cap.getValue().provider()).isEqualTo("github"); - assertThat(cap.getValue().providerUserId()).isEqualTo("12345"); - } - @Test void existingIdentityRefreshesLastLoginInsteadOfInserting() { AuthUser ghUser = githubUser("12345", "Nick", null, null); - when(userCenterService.findByUsername("github_12345")) - .thenReturn(Optional.of(enabledUser(10L, "github_12345", "hash"))); - when(userCenterService.updateProfile(any(), any(), any(), any(), any())) + when(userAccountRepository.findById(10L)).thenReturn(Optional.of(enabledUser(10L, "github_12345", "hash"))); + when(userCenterService.updateProfile(any(), any(), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean())) .thenReturn(enabledUser(10L, "github_12345", "hash")); // 该 provider 身份已存在 → 不应再 insert,只刷新 last_login_at when(userIdentityRepository.findByProviderAndProviderUserId("github", "12345")) @@ -422,7 +361,7 @@ void existingIdentityRefreshesLastLoginInsteadOfInserting() { try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); stpUtil.when(StpUtil::getTokenValue).thenReturn("token"); - authService.loginByGithub(ghUser); + loginAs("github", ghUser); } // 已有 identity 行:刷新登录时间的同时把本次拿到的邮箱/名字传下去补空 @@ -433,25 +372,20 @@ void existingIdentityRefreshesLastLoginInsteadOfInserting() { @Test void identityWriteFailureDoesNotBlockLogin() { AuthUser ghUser = githubUser("12345", "Nick", null, null); - when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.empty()); - when(passwordService.hash(any())).thenReturn("hash"); - when(userCenterService.createUser(any())).thenReturn(enabledUser(10L, "github_12345", "hash")); - // identity 写入炸掉——不能阻断登录(与 INV-003 lazy upgrade 同策略) + // 老账号(M0 回填没覆盖到):按用户名找到,补身份行时 DB 炸掉——不能阻断登录 + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(enabledUser(10L, "github_12345", "hash"))); + when(userCenterService.updateProfile(any(), any(), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(enabledUser(10L, "github_12345", "hash")); org.mockito.Mockito.doThrow(new RuntimeException("simulated identity write failure")) .when(userIdentityRepository).insert(any()); try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); stpUtil.when(StpUtil::getTokenValue).thenReturn("token-xyz"); - LoginResponse response = authService.loginByGithub(ghUser); + LoginResponse response = loginAs("github", ghUser); assertThat(response.tokenValue()).isEqualTo("token-xyz"); } } - // ============================================= - // loginByProvider() - 已验证邮箱自动关联(防分叉) - // ============================================= - @Test void verifiedEmailAutoLinksToExistingAccountInsteadOfForking() { // Discord 首次登录,邮箱已验证且匹配到已有账号 → 挂靠,不建新号 @@ -464,7 +398,7 @@ void verifiedEmailAutoLinksToExistingAccountInsteadOfForking() { try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); stpUtil.when(StpUtil::getTokenValue).thenReturn("tok"); - LoginResponse res = authService.loginByProvider("discord", dc); + LoginResponse res = loginAs("discord", dc); assertThat(res.user().username()).isEqualTo("github_12345"); // 登入的是已有账号 } @@ -478,56 +412,92 @@ void verifiedEmailAutoLinksToExistingAccountInsteadOfForking() { } @Test - void unverifiedEmailDoesNotAutoLinkAndCreatesNewAccount() { - // 邮箱未验证 → 绝不按邮箱关联,建新号 + void unverifiedEmailDoesNotAutoLinkAndStartsRegistration() { AuthUser dc = discordUser("snow-2", "alice@example.com", false); when(userCenterService.findByUsername("discord_snow-2")).thenReturn(Optional.empty()); - when(passwordService.hash(any())).thenReturn("hash"); - when(userCenterService.createUser(any())).thenReturn(enabledUser(20L, "discord_snow-2", "hash")); - try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { - stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); - stpUtil.when(StpUtil::getTokenValue).thenReturn("tok"); - authService.loginByProvider("discord", dc); - } - - // 未验证:不查邮箱关联,直接建新号 - verify(userCenterService).createUser(any()); + // 未验证邮箱只用来预填验证页,绝不拿去查挂靠 + assertThat(expectRegistration("discord", dc).providerEmail()).isEqualTo("alice@example.com"); verify(userAccountRepository, org.mockito.Mockito.never()).findByEmail(any()); } @Test - void verifiedEmailWithNoMatchCreatesNewAccount() { + void verifiedEmailWithNoMatchStartsRegistration() { AuthUser dc = discordUser("snow-3", "new@example.com", true); when(userCenterService.findByUsername("discord_snow-3")).thenReturn(Optional.empty()); when(userAccountRepository.findByEmail("new@example.com")).thenReturn(java.util.List.of()); - when(passwordService.hash(any())).thenReturn("hash"); - when(userCenterService.createUser(any())).thenReturn(enabledUser(30L, "discord_snow-3", "hash")); - try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { - stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); - stpUtil.when(StpUtil::getTokenValue).thenReturn("tok"); - authService.loginByProvider("discord", dc); - } - verify(userCenterService).createUser(any()); + expectRegistration("discord", dc); } @Test void ambiguousEmailMatchDoesNotAutoLink() { - // 同邮箱命中多个账号(异常)→ 保守建新号,绝不猜挂 + // 同邮箱命中多个账号(异常)→ 绝不猜挂,交给注册(验证后会因邮箱已占用而 409) AuthUser dc = discordUser("snow-4", "dup@example.com", true); when(userCenterService.findByUsername("discord_snow-4")).thenReturn(Optional.empty()); when(userAccountRepository.findByEmail("dup@example.com")) .thenReturn(java.util.List.of(enabledUser(1L, "a", "h"), enabledUser(2L, "b", "h"))); + + expectRegistration("discord", dc); + } + + @Test + void returningLoginNeverFillsAnUnverifiedEmail() { + // 邮箱是自动挂靠的锚点:账号 email 为空时也不能用未验证邮箱补 + AuthUser dc = discordUser("snow-5", "victim@example.com", false); + when(userIdentityRepository.findByProviderAndProviderUserId("discord", "snow-5")) + .thenReturn(Optional.of(new com.involutionhell.backend.usercenter.model.UserIdentity( + 9L, 10L, "discord", "snow-5", null, null, null, null))); + when(userAccountRepository.findById(10L)).thenReturn(Optional.of(enabledUser(10L, "u", "h"))); + when(userCenterService.updateProfile(any(), any(), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(enabledUser(10L, "u", "h")); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + loginAs("discord", dc); + } + verify(userCenterService).updateProfile(eq(10L), any(), any(), org.mockito.ArgumentMatchers.isNull(), any(), + org.mockito.ArgumentMatchers.anyBoolean()); + } + + @Test + void completeRegistrationCreatesAccountWithVerifiedEmailAndIdentity() { + var pending = new com.involutionhell.backend.usercenter.model.PendingRegistration( + "discord", "snow-6", "Nick", "https://a", "prefill@example.com", null, false); when(passwordService.hash(any())).thenReturn("hash"); - when(userCenterService.createUser(any())).thenReturn(enabledUser(40L, "discord_snow-4", "hash")); + when(userCenterService.createUser(any())).thenAnswer(inv -> { + UserAccount a = inv.getArgument(0); + assertThat(a.username()).isEqualTo("discord_snow-6"); + assertThat(a.email()).as("写的是本次验证过的邮箱,不是 provider 预填").isEqualTo("real@example.com"); + assertThat(a.roles()).containsExactly("user"); + return new UserAccount(60L, a.username(), a.passwordHash(), a.displayName(), true, + a.roles(), a.permissions(), a.avatarUrl(), a.email(), a.githubId(), null); + }); try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { - stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); stpUtil.when(StpUtil::getTokenValue).thenReturn("tok"); - authService.loginByProvider("discord", dc); + assertThat(authService.completeRegistration( + new RegistrationService.VerifiedRegistration(pending, "real@example.com")).tokenValue()).isEqualTo("tok"); + stpUtil.verify(() -> StpUtil.login(60L)); + } + var cap = org.mockito.ArgumentCaptor.forClass(com.involutionhell.backend.usercenter.model.UserIdentity.class); + verify(userIdentityRepository).insert(cap.capture()); + assertThat(cap.getValue().userId()).isEqualTo(60L); + assertThat(cap.getValue().providerUserId()).isEqualTo("snow-6"); + } + + @Test + void completeRegistrationRefusesEmailOfExistingAccountWithoutLinking() { + when(userAccountRepository.findByEmail("alice@example.com")).thenReturn(java.util.List.of(enabledUser(2L, "alice", "h"))); + var pending = new com.involutionhell.backend.usercenter.model.PendingRegistration( + "discord", "snow-7", "Nick", null, null, null, false); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + assertThatThrownBy(() -> authService.completeRegistration( + new RegistrationService.VerifiedRegistration(pending, "alice@example.com"))) + .isInstanceOf(AuthService.EmailTakenException.class); + stpUtil.verifyNoInteractions(); } - verify(userCenterService).createUser(any()); + verify(userCenterService, org.mockito.Mockito.never()).createUser(any()); + verify(userIdentityRepository, org.mockito.Mockito.never()).insert(any()); } @Test @@ -543,13 +513,190 @@ void githubLoginAutoLinksToNonGithubAccountAndBackfillsGithubId() { try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); stpUtil.when(StpUtil::getTokenValue).thenReturn("tok"); - authService.loginByProvider("github", gh); + loginAs("github", gh); } verify(userCenterService, org.mockito.Mockito.never()).createUser(any()); verify(userAccountRepository).setGithubIdIfAbsent(50L, 555L); } + // ============================================= + // loginByProvider() - identity-first(M2b 绑定后不能按用户名/邮箱找人) + // ============================================= + + private static com.involutionhell.backend.usercenter.model.UserIdentity identity( + long userId, String provider, String providerUserId) { + return new com.involutionhell.backend.usercenter.model.UserIdentity( + 7L, userId, provider, providerUserId, null, null, null, null); + } + + @Test + void boundDiscordIdentityWithDifferentEmailLogsIntoOriginalAccount() { + // github 账号绑了一个邮箱不同的 Discord。按用户名 discord_snow-b 查不到、按邮箱也匹配不上, + // 身份优先之前这里会建第二个账号。 + AuthUser dc = discordUser("snow-b", "other@example.com", true); + when(userIdentityRepository.findByProviderAndProviderUserId("discord", "snow-b")) + .thenReturn(Optional.of(identity(10L, "discord", "snow-b"))); + when(userAccountRepository.findById(10L)).thenReturn(Optional.of(enabledUser(10L, "github_12345", "hash"))); + // 跨 provider 登录(github 建的号、discord 登录):展示名/头像只补空,不刷新 + when(userCenterService.updateProfile(eq(10L), any(), any(), eq("other@example.com"), org.mockito.ArgumentMatchers.isNull(), eq(false))) + .thenReturn(enabledUser(10L, "github_12345", "hash")); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("tok"); + LoginResponse res = loginAs("discord", dc); + + stpUtil.verify(() -> StpUtil.login(10L)); + assertThat(res.user().username()).isEqualTo("github_12345"); + } + verify(userCenterService, org.mockito.Mockito.never()).createUser(any()); + verify(userCenterService, org.mockito.Mockito.never()).findByUsername(any()); + verify(userAccountRepository, org.mockito.Mockito.never()).findByEmail(any()); + } + + @Test + void discordLoginNeverWritesGithubIdEvenWithNumericSnowflake() { + // Discord id 也是纯数字,误当 github_id 写进去就会把别人的 /u/{githubId} 指过来 + String snowflake = "123456789012345678"; + when(userIdentityRepository.findByProviderAndProviderUserId("discord", snowflake)) + .thenReturn(Optional.of(identity(10L, "discord", snowflake))); + when(userAccountRepository.findById(10L)).thenReturn(Optional.of(enabledUser(10L, "github_12345", "hash"))); + when(userCenterService.updateProfile(eq(10L), any(), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean())) + .thenReturn(enabledUser(10L, "github_12345", "hash")); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + loginAs("discord", discordUser(snowflake, null, false)); + } + verify(userCenterService).updateProfile(eq(10L), any(), any(), any(), + org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.anyBoolean()); + } + + @Test + void githubLoginAfterBindOnNonGithubAccountLandsThereWithoutCreatingAccount() { + // 非 github_ 账号绑了 GitHub(bind 已写 github_id=555)。旧逻辑按 github_555 查 miss → + // 建号 → 撞 UNIQUE(github_id)。这里让 createUser 抛约束异常,证明根本没走到它。 + AuthUser gh = githubUser("555", "Nick", null, null); + when(userIdentityRepository.findByProviderAndProviderUserId("github", "555")) + .thenReturn(Optional.of(identity(50L, "github", "555"))); + when(userAccountRepository.findById(50L)).thenReturn(Optional.of(new UserAccount(50L, "discord_snow-x", "hash", + "显示名", true, Set.of("user"), Set.of(), null, null, 555L, null))); + when(userCenterService.updateProfile(50L, "Nick", null, null, 555L, false)) + .thenReturn(new UserAccount(50L, "discord_snow-x", "hash", "显示名", true, + Set.of("user"), Set.of(), null, null, 555L, null)); + org.mockito.Mockito.lenient().when(userCenterService.createUser(any())) + .thenThrow(new org.springframework.dao.DuplicateKeyException("UNIQUE(github_id)")); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("tok"); + LoginResponse res = loginAs("github", gh); + + stpUtil.verify(() -> StpUtil.login(50L)); + assertThat(res.user().username()).isEqualTo("discord_snow-x"); + } + verify(userCenterService, org.mockito.Mockito.never()).createUser(any()); + } + + @Test + void autoLinkedIdentitySurvivesEmailChange() { + // 第一次:Discord 已验证邮箱命中 github 账号 → 挂靠并写入身份行。 + // 第二次:Discord 那边换了邮箱 → 必须靠身份行找回同一账号,而不是再走邮箱匹配建新号。 + var stored = new java.util.concurrent.atomic.AtomicReference< + com.involutionhell.backend.usercenter.model.UserIdentity>(); + org.mockito.Mockito.doAnswer(inv -> { + com.involutionhell.backend.usercenter.model.UserIdentity i = inv.getArgument(0); + stored.set(identity(i.userId(), i.provider(), i.providerUserId())); + return stored.get(); + }).when(userIdentityRepository).insert(any()); + when(userIdentityRepository.findByProviderAndProviderUserId("discord", "snow-c")) + .thenAnswer(inv -> Optional.ofNullable(stored.get())); + + UserAccount original = enabledUser(10L, "github_12345", "hash"); + when(userAccountRepository.findByEmail("alice@example.com")).thenReturn(java.util.List.of(original)); + when(userAccountRepository.findById(10L)).thenReturn(Optional.of(original)); + when(userCenterService.updateProfile(eq(10L), any(), any(), eq("alice-new@example.com"), org.mockito.ArgumentMatchers.isNull(), eq(false))) + .thenReturn(original); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + stpUtil.when(StpUtil::getTokenName).thenReturn("satoken"); + stpUtil.when(StpUtil::getTokenValue).thenReturn("tok"); + + loginAs("discord", discordUser("snow-c", "alice@example.com", true)); + loginAs("discord", discordUser("snow-c", "alice-new@example.com", true)); + + stpUtil.verify(() -> StpUtil.login(10L), org.mockito.Mockito.times(2)); + } + verify(userCenterService, org.mockito.Mockito.never()).createUser(any()); + verify(userIdentityRepository).insert(any()); // 只在第一次挂靠时插一行 + } + + // ============================================= + // 解绑/换绑不复活、自动挂靠不越过同家身份、撞约束拒登 + // ============================================= + + @Test + void usernameFallbackSkipsAccountsThatAlreadyHaveIdentities() { + // github_12345 解绑了 github(或换绑成别的 GitHub),只剩 discord:旧 GitHub 再登录不能回到这个号 + AuthUser gh = githubUser("12345", "Nick", null, null); + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(enabledUser(10L, "github_12345", "h"))); + when(userIdentityRepository.findByUserId(10L)).thenReturn(java.util.List.of(identity(10L, "discord", "d-1"))); + + expectRegistration("github", gh); + verify(userCenterService, org.mockito.Mockito.never()).updateProfile(any(), any(), any(), any(), any(), + org.mockito.ArgumentMatchers.anyBoolean()); + } + + @Test + void verifiedEmailDoesNotAutoLinkIntoAccountThatAlreadyHasThisProvider() { + // 同一个邮箱出现在第二个 GitHub 号上:目标账号已有 github 身份,新身份挂不上去,不挂靠 + AuthUser gh = githubUser("555", "Nick", null, "alice@example.com"); + when(userCenterService.findByUsername("github_555")).thenReturn(Optional.empty()); + when(userAccountRepository.findByEmail("alice@example.com")) + .thenReturn(java.util.List.of(enabledUser(10L, "discord_d-1", "h"))); + when(userIdentityRepository.findByUserId(10L)).thenReturn(java.util.List.of(identity(10L, "github", "444"))); + + expectRegistration("github", gh); + verify(userAccountRepository, org.mockito.Mockito.never()).setGithubIdIfAbsent(any(), any()); + } + + @Test + void identityConflictOnLoginRefusesInsteadOfLoggingIn() { + // 身份挂不上(撞 UNIQUE)却照样登入 = 身份不在账号上也能登录,解绑撤不掉 + AuthUser gh = githubUser("12345", "Nick", null, null); + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(enabledUser(10L, "github_12345", "h"))); + when(userCenterService.updateProfile(any(), any(), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean())) + .thenReturn(enabledUser(10L, "github_12345", "h")); + when(userIdentityRepository.insert(any())) + .thenThrow(new org.springframework.dao.DuplicateKeyException("UNIQUE(user_id, provider)")); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + assertThatThrownBy(() -> authService.loginByProvider("github", gh)).isInstanceOf(IllegalStateException.class); + stpUtil.verifyNoInteractions(); + } + } + + @Test + void completeRegistrationSuffixesUsernameHeldByAnotherAccount() { + // github_12345 还被一个已经解绑掉这个 GitHub 的老账号占着 + when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(enabledUser(10L, "github_12345", "h"))); + when(passwordService.hash(any())).thenReturn("hash"); + when(userCenterService.createUser(any())).thenAnswer(inv -> { + UserAccount a = inv.getArgument(0); + return new UserAccount(61L, a.username(), a.passwordHash(), a.displayName(), true, + a.roles(), a.permissions(), a.avatarUrl(), a.email(), a.githubId(), null); + }); + var pending = new com.involutionhell.backend.usercenter.model.PendingRegistration( + "github", "12345", "Nick", null, null, 12345L, false); + + try (MockedStatic stpUtil = mockStatic(StpUtil.class)) { + authService.completeRegistration(new RegistrationService.VerifiedRegistration(pending, "me@example.com")); + } + var cap = org.mockito.ArgumentCaptor.forClass(UserAccount.class); + verify(userCenterService).createUser(cap.capture()); + assertThat(cap.getValue().username()).startsWith("github_12345_").hasSize("github_12345_".length() + 6); + } + // ============================================= // logout() 和 currentUser() // ============================================= diff --git a/src/test/java/com/involutionhell/backend/usercenter/service/RegistrationServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/service/RegistrationServiceTests.java index 2b31a90..c55ad8c 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/service/RegistrationServiceTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/service/RegistrationServiceTests.java @@ -51,7 +51,7 @@ void resendConfigured() { } private PendingRegistration reg(String providerEmail) { - return new PendingRegistration("discord", "snow-1", "Nick", null, providerEmail, null); + return new PendingRegistration("discord", "snow-1", "Nick", null, providerEmail, null, false); } /** 发一次码并抓出邮件正文里最新的 6 位验证码(sendHtml 已 mock 成 true)。 */ @@ -97,18 +97,173 @@ void emailMustMatchTheOneCodeWasSentTo() { } @Test - void resendDoesNotResetAttempts_bruteForceCapped() { + void fifthWrongCodeKillsTheSession_noResendAfterLock() { String pid = svc.begin(reg("a@e.com")); - sendAndCaptureCode(pid, "a@e.com"); - // 打满 5 次错码 + String code = sendAndCaptureCode(pid, "a@e.com"); for (int i = 0; i < 5; i++) { assertThat(svc.verifyAndConsume(pid, "a@e.com", "111111")).isEmpty(); } - // 过冷却后重发(新码),但 attempts 不重置 + // 锁死即作废:正确码也不行,也不能再借这个会话发信(调用方据 getPending 为空提示重新登录) + assertThat(svc.verifyAndConsume(pid, "a@e.com", code)).isEmpty(); + assertThat(svc.getPending(pid)).isEmpty(); ticker.advance(Duration.ofSeconds(61)); - String newCode = sendAndCaptureCode(pid, "a@e.com"); - // 即便拿到正确的新码,也因 attempts 已耗尽而失败——这就是堵住"重发无限爆破" - assertThat(svc.verifyAndConsume(pid, "a@e.com", newCode)).isEmpty(); + assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SESSION_EXPIRED); + } + + /** 一次真实 OAuth = 一个新会话;identity 即第三方 user id。 */ + private String session(String identity) { + return svc.begin(new PendingRegistration("discord", identity, "N", null, null, null, false)); + } + + @Test + void anotherIdentityExhaustingItsSendsToAnEmailDoesNotLockOutTheOwner() { + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true); + for (int i = 0; i < 5; i++) { + // 换会话不能绕过(邮箱, 身份)的日限额 + assertThat(svc.sendOtp(session("attacker"), "Victim@X.com")).isEqualTo(SendResult.SENT); + } + assertThat(svc.sendOtp(session("attacker"), "victim@x.com")).as("大小写不同也算同一邮箱") + .isEqualTo(SendResult.RATE_LIMITED); + // 邮箱主人自己的身份照常能发——只按邮箱限 5 封时,这里是 24h 的注册锁死 + assertThat(svc.sendOtp(session("victim"), "victim@x.com")).isEqualTo(SendResult.SENT); + ticker.advance(Duration.ofDays(1).plusSeconds(1)); + assertThat(svc.sendOtp(session("attacker"), "victim@x.com")).isEqualTo(SendResult.SENT); + } + + @Test + void sendsToOneEmailHaveAGlobalCeilingAcrossIdentities() { + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true); + for (int i = 0; i < 20; i++) { + assertThat(svc.sendOtp(session("snow-" + i / 5), "victim@x.com")).isEqualTo(SendResult.SENT); + } + String fresh = session("snow-fresh"); + assertThat(svc.sendOtp(fresh, "VICTIM@x.com")).isEqualTo(SendResult.RATE_LIMITED); + assertThat(svc.sendOtp(fresh, "other@x.com")).isEqualTo(SendResult.SENT); + } + + @Test + void requestsRejectedByOneCapDoNotConsumeTheOthers() { + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true); + // 4 个号各自把身份限额(10)用在别的邮箱上,再各对受害者邮箱试 5 次:全被身份限额拒掉。 + // 被拒的请求若顺手占了按邮箱的格子,4 × 5 = 20 正好把受害者邮箱的全局天花板占满。 + for (int a = 0; a < 4; a++) { + for (int i = 0; i < 10; i++) { + assertThat(svc.sendOtp(session("spammer-" + a), "e" + a + "-" + i + "@x.com")).isEqualTo(SendResult.SENT); + } + for (int i = 0; i < 5; i++) { + assertThat(svc.sendOtp(session("spammer-" + a), "victim@x.com")).isEqualTo(SendResult.RATE_LIMITED); + } + } + assertThat(svc.sendOtp(session("bystander"), "victim@x.com")).isEqualTo(SendResult.SENT); + } + + @Test + void ownerIsExemptFromThePerEmailCeiling() { + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true); + for (int i = 0; i < 20; i++) { + assertThat(svc.sendOtp(session("snow-" + i / 5), "victim@x.com")).isEqualTo(SendResult.SENT); + } + // provider 已验证过这个邮箱 = 调用方就是邮箱主人:别人占满天花板也锁不住他 + String owner = svc.begin(new PendingRegistration("discord", "owner", "N", null, "Victim@X.com", null, true)); + assertThat(svc.sendOtp(owner, "victim@x.com")).isEqualTo(SendResult.SENT); + // 只是"声称"这个邮箱(provider 未验证)不算 + String claimant = svc.begin(new PendingRegistration("discord", "claimant", "N", null, "victim@x.com", null, false)); + assertThat(svc.sendOtp(claimant, "victim@x.com")).isEqualTo(SendResult.RATE_LIMITED); + } + + @Test + void failedSendsRefundTheEmailQuotaButNotTheCallersOwnBudget() { + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(false); + for (int i = 0; i < 10; i++) { + assertThat(svc.sendOtp(session("failing"), "victim@x.com")).isEqualTo(SendResult.SEND_FAILED); + } + // 身份限额是调用方自己的预算,失败也照算:不然换会话就能无限打 Resend + assertThat(svc.sendOtp(session("failing"), "fresh@x.com")).isEqualTo(SendResult.RATE_LIMITED); + + // 按邮箱计的限额全数退还:失败的 10 次没占受害者邮箱的格子,别人照样还有 20 封的额度 + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true); + for (int i = 0; i < 20; i++) { + assertThat(svc.sendOtp(session("snow-" + i / 5), "victim@x.com")).isEqualTo(SendResult.SENT); + } + assertThat(svc.sendOtp(session("snow-late"), "victim@x.com")).isEqualTo(SendResult.RATE_LIMITED); + } + + @Test + void failedSendsStillCountWithinTheSession() { + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(false); + String pid = svc.begin(reg(null)); + for (int i = 0; i < 5; i++) { + assertThat(svc.sendOtp(pid, "a" + i + "@e.com")).isEqualTo(SendResult.SEND_FAILED); + assertThat(svc.sendOtp(pid, "a" + i + "@e.com")).as("失败也走冷却").isEqualTo(SendResult.RATE_LIMITED); + ticker.advance(Duration.ofSeconds(61)); + } + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true); + assertThat(svc.sendOtp(pid, "ok@e.com")).isEqualTo(SendResult.RATE_LIMITED); + } + + @Test + void codeIsNotVerifiableWhileTheSendIsInFlight() { + String pid = svc.begin(reg(null)); + AtomicInteger inFlightHits = new AtomicInteger(); + when(email.sendHtml(eq("a@e.com"), anyString(), anyString())).thenAnswer(inv -> { + Matcher m = Pattern.compile("(\\d{6})").matcher(inv.getArgument(2)); + assertThat(m.find()).isTrue(); + if (svc.verifyAndConsume(pid, "a@e.com", m.group(1)).isPresent()) { + inFlightHits.incrementAndGet(); + } + return false; // 发信最终失败 + }); + assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SEND_FAILED); + assertThat(inFlightHits).as("发信途中码还没生效").hasValue(0); + assertThat(svc.getPending(pid)).as("途中的校验也没把会话消费掉").isPresent(); + } + + @Test + void verifyFailuresAgainstOneEmailAreCappedAcrossSessionsAndIdentities() { + // 两个号各开一个会话对受害者邮箱错码 5 次:合计 10 次,到顶 + for (String attacker : new String[] {"guesser-1", "guesser-2"}) { + String pid = session(attacker); + sendAndCaptureCode(pid, "victim@x.com"); + for (int i = 0; i < 5; i++) { + assertThat(svc.verifyAndConsume(pid, "victim@x.com", "000000")).isEmpty(); + } + } + // 第三个号就算拿到正确码也不放行:否则多开身份就能多猜 + String third = session("guesser-3"); + String code = sendAndCaptureCode(third, "victim@x.com"); + assertThat(svc.verifyAndConsume(third, "victim@x.com", code)).isEmpty(); + // 邮箱主人不受这项限制 + String owner = svc.begin(new PendingRegistration("discord", "owner", "N", null, "victim@x.com", null, true)); + String ownerCode = sendAndCaptureCode(owner, "victim@x.com"); + assertThat(svc.verifyAndConsume(owner, "victim@x.com", ownerCode)).isPresent(); + // 一天后额度恢复 + ticker.advance(Duration.ofDays(1).plusSeconds(1)); + String later = session("guesser-4"); + String laterCode = sendAndCaptureCode(later, "victim@x.com"); + assertThat(svc.verifyAndConsume(later, "victim@x.com", laterCode)).isPresent(); + } + + @Test + void codeOfAFailedSendCannotBeVerified() { + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(false); + String pid = svc.begin(reg("a@e.com")); + assertThat(svc.sendOtp(pid, "a@e.com")).isEqualTo(SendResult.SEND_FAILED); + ArgumentCaptor html = ArgumentCaptor.forClass(String.class); + verify(email).sendHtml(eq("a@e.com"), anyString(), html.capture()); + Matcher m = Pattern.compile("(\\d{6})").matcher(html.getValue()); + assertThat(m.find()).isTrue(); + + assertThat(svc.verifyAndConsume(pid, "a@e.com", m.group(1))).as("没送达的码不能拿来猜").isEmpty(); + } + + @Test + void sendsFromOneIdentityAreCappedAcrossSessions() { + when(email.sendHtml(anyString(), anyString(), anyString())).thenReturn(true); + for (int i = 0; i < 10; i++) { + String pid = svc.begin(reg(null)); // 同一个 discord snow-1 反复走 OAuth + assertThat(svc.sendOtp(pid, "e" + i + "@e.com")).isEqualTo(SendResult.SENT); + } + assertThat(svc.sendOtp(svc.begin(reg(null)), "e10@e.com")).isEqualTo(SendResult.RATE_LIMITED); } @Test diff --git a/src/test/resources/test-schema.sql b/src/test/resources/test-schema.sql index 3b38716..45f6d79 100644 --- a/src/test/resources/test-schema.sql +++ b/src/test/resources/test-schema.sql @@ -13,8 +13,9 @@ CREATE TABLE IF NOT EXISTS user_accounts ( preferences VARCHAR(4000) NOT NULL DEFAULT '{}' ); --- 种子账号(与生产保持一致)逐行插入,H2 兼容写法 --- 哈希格式:bcrypt(cost=10) —— INV-003 起点;明文与生产一致 (Admin@123456 等) +-- 种子账号(仅测试用,与 docker/init-db/init.sql 一致)逐行插入,H2 兼容写法。 +-- 生产 schema.sql 不 seed 可登录账号(INV-011)。 +-- 哈希格式:bcrypt(cost=10) —— INV-003 起点;明文见 README (Admin@123456 等) MERGE INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions) KEY (username) VALUES ('admin', '$2b$10$Bfnw8v.BsXeZVPbre94sJeokgEfKuCLsdH7ckJxzxn5nxirHJHmP.', 'Admin', TRUE, 'admin', 'user:profile:read,user:center:read,user:center:manage');