From 6325d26a876872c35a8b0a393996c7f2758d4bec Mon Sep 17 00:00:00 2001 From: zzswang Date: Sat, 12 Sep 2026 20:58:50 +0800 Subject: [PATCH 1/2] feat(captcha)!: bind challenges to context and consume atomically Add server-generated issuance keys, scoped verification, attempt limits and Redis rolling quotas. Consume challenges atomically and expose answers only in issuance responses while retaining plaintext database storage. Keep SMS and email records metadata-only, remove sensitive logs, and add migration tooling, integration coverage, API documentation and SDK checks. BREAKING CHANGE: captcha creation and verification require kind, purpose and subject. Verification consumes the challenge, DELETE uses the issuance key, and PATCH is removed. Callers and auth must migrate together and invalidate legacy challenges. --- .github/workflows/ci.yml | 12 +- docs/captcha-security.md | 158 +++++ env.example | 4 +- openapi.json | 583 ++++++++++-------- package.json | 9 +- scripts/gen-sdk.ts | 4 +- scripts/migrate-captcha-security.ts | 32 + src/auth/auth.controller.ts | 122 ++-- src/captcha/captcha-migration.spec.ts | 89 +++ src/captcha/captcha-migration.ts | 40 ++ src/captcha/captcha-policy.service.spec.ts | 30 + src/captcha/captcha-policy.service.ts | 99 +++ .../captcha-rate-limit.service.spec.ts | 143 +++++ src/captcha/captcha-rate-limit.service.ts | 101 +++ src/captcha/captcha.controller.ts | 123 ++-- src/captcha/captcha.module.ts | 4 +- src/captcha/captcha.service.spec.ts | 285 ++++++--- src/captcha/captcha.service.ts | 254 ++++++-- src/captcha/dto/create-captcha.dto.ts | 40 +- src/captcha/dto/get-captcha.dto.ts | 5 - src/captcha/dto/list-captchas.dto.ts | 23 +- src/captcha/dto/update-captcha.dto.ts | 5 - src/captcha/dto/upsert-captcha.dto.ts | 5 - src/captcha/dto/verify-captcha.dto.ts | 19 +- src/captcha/entities/captcha.entity.ts | 73 ++- src/captcha/index.ts | 1 + src/common/all-exceptions.filter.ts | 4 + src/common/delivery-security.spec.ts | 182 ++++++ src/common/route-logger.middleware.ts | 8 +- src/config/config.ts | 4 +- src/constants.ts | 2 + src/email/email-record.service.ts | 24 +- src/email/email.controller.ts | 16 +- src/email/entities/email-record.entity.ts | 16 - src/sms/entities/sms-record.entity.ts | 8 - src/sms/sms-record.service.ts | 28 +- src/sms/sms.controller.ts | 9 +- src/sms/sms.service.ts | 14 +- src/third-party/third-party.controller.ts | 14 +- test/auth-login-logout.e2e-spec.ts | 15 +- test/captcha.e2e-spec.ts | 437 ++++++------- test/jest-e2e.json | 11 +- test/setup-env.ts | 4 + 43 files changed, 2104 insertions(+), 955 deletions(-) create mode 100644 docs/captcha-security.md create mode 100644 scripts/migrate-captcha-security.ts create mode 100644 src/captcha/captcha-migration.spec.ts create mode 100644 src/captcha/captcha-migration.ts create mode 100644 src/captcha/captcha-policy.service.spec.ts create mode 100644 src/captcha/captcha-policy.service.ts create mode 100644 src/captcha/captcha-rate-limit.service.spec.ts create mode 100644 src/captcha/captcha-rate-limit.service.ts delete mode 100644 src/captcha/dto/get-captcha.dto.ts delete mode 100644 src/captcha/dto/update-captcha.dto.ts delete mode 100644 src/captcha/dto/upsert-captcha.dto.ts create mode 100644 src/common/delivery-security.spec.ts create mode 100644 test/setup-env.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb971d6..c5287a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,8 +90,13 @@ jobs: # env: # NODE_ENV: test + - name: Captcha security tests + run: pnpm test:security + env: + NODE_ENV: test + - name: Integration Test - run: pnpm test:e2e + run: pnpm test:e2e --runInBand env: NODE_ENV: test @@ -100,6 +105,11 @@ jobs: pnpm build NODE_ENV=development node ./bin/generate-swagger.js + - name: Generate and typecheck SDK + run: | + pnpm gen:sdk + pnpm exec tsc --noEmit --incremental false -p sdk/tsconfig.json + - name: Check if openapi.json match the code run: | if git diff --name-only | grep openapi.json; then diff --git a/docs/captcha-security.md b/docs/captcha-security.md new file mode 100644 index 0000000..6e0e7fc --- /dev/null +++ b/docs/captcha-security.md @@ -0,0 +1,158 @@ +# 验证码接入与安全切换 + +业务后端通过 auth 签发和消费图形、短信、邮箱验证码。auth 仅供可信后端调用,所有请求携带 `x-api-key`;浏览器和 App 只接收验证码 key、有效期或图形,不能接收答案或 API key。 + +## 配置与策略 + +验证码以明文保存在数据库的 `code` 字段,不需要额外密钥配置。只有签发响应返回答案,查询接口仅返回元数据,成功消费时原子清除答案。图形答案统一转为大写后保存和校验;数据库读取权限可以访问尚未清除的验证码。 + +默认使用以下策略;`CAPTCHA_POLICY_JSON` 和 `CAPTCHA_REDIS_PREFIX` 均可省略。 + +| 类型 | 答案 | 有效期 | 单条错误次数 | 同一对象签发限制 | 同一对象校验请求限制 | +| --- | --- | --- | --- | --- | --- | +| `image` | 默认 4 位英数字;可传 4~8 位英数字,不区分大小写 | 120 秒 | 3 | 间隔 1 秒,滚动 1 分钟 20 次 | 滚动 1 分钟 30 次 | +| `sms` | 服务端生成 6 位数字,保留前导零 | 300 秒 | 5 | 间隔 60 秒,滚动 1 小时 5 次、24 小时 10 次 | 滚动 10 分钟 20 次 | +| `email` | 服务端生成 6 位数字,保留前导零 | 600 秒 | 5 | 间隔 60 秒,滚动 1 小时 5 次、24 小时 10 次 | 滚动 10 分钟 20 次 | + +限流按 `kind + subject` 共享,不随用途、重发或撤销重置;获准进入校验流程的正确和错误请求均占用额度,超过额度的请求直接拒绝。签发与校验分别计数,下游失败不返还额度。 + +`CAPTCHA_POLICY_JSON` 可覆盖每类策略的 `expiresInS、maxAttempts、issueIntervalS、issueLimits、verifyLimits`,未提供字段沿用表中默认值。例如: + +```dotenv +CAPTCHA_POLICY_JSON={"sms":{"maxAttempts":3,"issueLimits":[{"windowS":3600,"limit":4},{"windowS":86400,"limit":8}]}} +CAPTCHA_REDIS_PREFIX=auth:captcha +``` + +策略中的有效期限制为 1~600 秒,单条错误次数为 1~10,签发间隔为 1~86400 秒。每组限流规则包含 1~3 个窗口,窗口为 1~86400 秒,额度为 1~10000。非法配置拒绝启动。所有实例的策略及 Redis 前缀保持一致;任意更换前缀会丢失原有限流上下文。 + +短信发送需配置真实 `SMS_PROVIDER` 及供应商凭据;默认 `blackhole` 不发送短信。邮件默认 `EMAIL_TRANSPORTER=blackhole`,同样不投递。 + +## 接口 + +以下路径相对于实际部署的 `PREFIX`。请求和响应使用 JSON。 + +| API | 输入 | 输出与语义 | +| --- | --- | --- | +| `POST /captchas` | 必填 `kind、purpose、subject`;仅 `image` 可选传 `code` | `201`;返回 `id、key、kind、purpose、subject、expireAt、maxAttempts、failedAttempts、status、createdAt、updatedAt`,并且仅在本次响应中包含 `code` | +| `POST /captchas/@verifyCaptcha` | 必填 `key、code、kind、purpose、subject` | `200 {"success":true/false}`;成功即消费,不可再次使用 | +| `GET /captchas` | 可按 `key、kind、purpose、subject` 筛选,支持 `_limit、_offset、_sort` | 元数据数组;不提供答案或按答案筛选 | +| `GET /captchas/:captchaId` | 文档 id | 元数据;不存在返回 `404 CAPTCHA_NOT_FOUND` | +| `POST /captchas/@count` | query 与列表相同 | `{"count": number}` | +| `DELETE /captchas/:key` | 本次签发的 key,注意不是文档 id | 幂等 `204`;旧 key 不会撤销新码 | + +`kind` 是 `image / sms / email`;`purpose` 为以小写字母开头的 1~64 位业务标识,可包含小写字母、数字、下划线、点、冒号和连字符。`subject` 是最多 320 字符的非空字符串,由业务后端确定:短信使用实际账号手机号,邮箱使用实际账号邮箱,图形使用服务端签发的匿名会话 ID。 + +手机号、邮箱严格沿用现有账号查找规则,本次不做手机号格式或邮箱大小写归一化。签发时的 `subject` 必须与提交给认证接口的账号值完全一致,调用方应复用账号系统已有的标准值。 + +OpenAPI 和 SDK 将 GET/DELETE 的路径参数统一命名为 `identifier`,避免同层级重复模板路径:GET 传文档 id,DELETE 传本次签发 key。实际 URL 路径不变。 + +创建时指定 `key、expireAt` 不再生效;没有绑定字段的旧请求返回 `400`。`PATCH /captchas/:captchaId` 已删除。错误、过期、已使用、次数耗尽或场景不符均验证失败;认证接口继续使用原有的 `AUTH_FAILED` 或 `CAPTCHA_INVALID` 业务错误。累计限流返回 `429 CAPTCHA_RATE_LIMITED`,`Retry-After` 是需要等待的秒数。依赖不可用或超时返回 `503 CAPTCHA_UNAVAILABLE`。 + +## 手机号登录示例 + +所有调用在业务后端执行。下面的 `fetch` 代码适用于 Node.js 22;`AUTH_BASE_URL` 包含实际的路由前缀,`AUTH_API_KEY` 仅保存在业务后端。 + +```js +async function authRequest(path, body, method = 'POST') { + const response = await fetch(`${process.env.AUTH_BASE_URL}${path}`, { + method, + headers: { + 'content-type': 'application/json', + 'x-api-key': process.env.AUTH_API_KEY, + }, + ...(body !== undefined && { body: JSON.stringify(body) }), + }); + if (!response.ok) { + const error = new Error(`Auth returned ${response.status}`); + error.status = response.status; + error.retryAfter = response.headers.get('retry-after'); + throw error; + } + return response.status === 204 ? undefined : response.json(); +} + +// 收到用户的“发送验证码”请求后,由业务后端确定手机号和用途。 +const phone = '13800138000'; +const issued = await authRequest('/captchas', { + kind: 'sms', purpose: 'login', subject: phone, +}); +await authRequest('/sms/@sendSms', { + phone, + sign: process.env.SMS_SIGN, + template: process.env.SMS_TEMPLATE, + params: { code: issued.code }, +}); +// 发给前端的响应仅包含 key、expireAt,禁止返回 issued.code。 +const frontendResponse = { key: issued.key, expireAt: issued.expireAt }; + +// 用户输入短信答案后,由业务后端转调;保持 code 为字符串。 +async function login(phone, key, code) { + return authRequest('/auth/@loginByPhone', { + phone, key, code, autoRegister: true, + }); +} +``` + +创建与发送仍是两次调用。供应商确定拒绝发送时,可调用 `DELETE /captchas/${issued.key}` 撤销本次验证码;发送超时、网络中断等结果不确定的情况不得自动再次发送。不要记录完整请求、验证码或供应商异常对象。 + +登录响应包含访问令牌 `token、tokenExpireAt` 和会话信息;会话的 `key` 可作为刷新令牌,区别于验证码 key。 + +## 图形、邮箱与其他认证流程 + +图形验证和对应操作在同一次业务请求中完成。例如,后端签发 `{kind:"image", purpose:"send_sms", subject:匿名会话ID}`,使用创建响应中的答案绘制图片,仅将图片及 key 返回前端。用户提交图形答案并请求短信时,后端从可信会话上下文取得 `subject`,调用 `@verifyCaptcha`;成功后才执行短信签发与发送。取消“先预校验、后用原码提交操作”的调用流程。 + +邮箱登录使用 `kind=email、purpose=login、subject=实际邮箱`,通过通用邮件接口发送后,调用 `/auth/@loginByEmail`,提交 `email、key、code`。 + +| 认证接口 | auth 固定的验证码上下文 | +| --- | --- | +| `@loginByPhone` / `@loginByEmail` | `sms/email + login + 实际账号`;`autoRegister` 仍使用 `login` | +| `@registerByPhone` / `@registerByEmail` | `sms/email + register + 实际账号` | +| `@resetPasswordByPhone` / `@resetPasswordByEmail` | `sms/email + reset_password + 实际账号` | + +auth 自行确定认证用途及验证对象,不使用客户端传入的场景值。认证接口先校验并消费验证码,再查询、创建或修改账号。成功消费后,即使账号不存在、业务操作失败或客户端没收到响应,也不恢复验证码;用户需重新获取。 + +同一 `kind + purpose + subject` 重发时原子轮换 key,以数据库写入顺序为准,旧 key 立即失效。不同用途不能混用验证码。过期清理由 MongoDB TTL 异步执行,是否可用始终由校验条件决定。 + +业务后端还需落实真实 IP、匿名会话、实际短信/邮件发送次数和供应商费用配额限制。auth 的签发限流只约束验证码签发,不代表通用发送接口的实际发送配额。 + +## 发送记录变化 + +所有消息只记录元数据,不区分是否为验证码: + +- 短信记录保留手机号、签名、模板、消息组、状态及时间,不保存 `params`。 +- 邮件记录保留发件人、收件人、状态及时间,不保存 `subject、content`。 +- 发送请求仍接受完整短信参数和邮件标题/正文,用于实际发送。 +- 记录 CRUD 不再接受这些内容字段,查询也不返回尚未迁移的历史内容。 + +排查使用记录 id、状态、时间及供应商控制台。auth 的结构化事件包含 `captcha_issued、captcha_verified、captcha_invalid、captcha_rate_limited、captcha_dependency_failed、sms_sent、sms_send_failed、email_sent、email_send_failed`,不包含答案或原始请求。 + +## 同步切换与迁移 + +这是一次不兼容升级。更新业务后端和对应 SDK 后,在同一窗口切换;旧验证码需要重新获取。 + +1. 准备适配后的业务后端及 auth,确认生产限流策略;先生成、核对 OpenAPI 和 SDK。 +2. 在目标环境运行只读统计:`pnpm migrate:captcha-security`。连接使用该环境的 `MONGO_URL`;输出只包含计数。 +3. 在业务入口暂停验证码签发、认证及相关发送,停止旧版本实例写入。 +4. 显式执行迁移:`CAPTCHA_MAINTENANCE_MODE=true pnpm migrate:captcha-security --execute`。该环境变量只确认维护状态,不会替你暂停流量。 +5. 同步部署新 auth 与调用方,验证图形、短信、邮箱链路,再恢复入口。 + +执行模式删除缺少绑定信息或仍保存旧 `codeHash` 的验证码,清除历史短信 `params` 和邮件 `subject、content`,建立作用域唯一索引和到期 TTL。脚本只操作这三个集合,可重复执行;保留绑定完整的新格式明文验证码和已消费记录。新应用也会同步验证码索引,因此必须先完成旧数据迁移,再启动新实例。 + +切换失败时保持验证码入口暂停,修复后再开放;不恢复旧验证码或历史消息内容。 + +上线后统计验证码成功率、无效比例、429 比例、依赖故障及发送失败率,确认调用方没有遗留预校验、错误用途或旧删除参数。 + +## 验证与 SDK + +```sh +pnpm test:security +pnpm test:e2e --runInBand +pnpm lint +pnpm build +NODE_ENV=development node bin/generate-swagger.js +pnpm gen:sdk +``` + +运行全量 e2e 前,将 `MONGO_URL` 和 `MONGO_TEST_BASE_URL` 指向专用测试数据库服务;已有认证/用户测试会清空其连接的测试数据库。 + +生成文档时使用隔离的数据库配置。Jest 发送器使用 blackhole;安全测试使用临时 MongoDB 和专用 Redis 键,不清空共享 Redis。CI 要求安全单测、集成测试、构建、lint 及 OpenAPI 一致性检查通过,并通过既有工作流发布对应 SDK。 diff --git a/env.example b/env.example index 4db9043..89de9ba 100644 --- a/env.example +++ b/env.example @@ -31,8 +31,8 @@ # ROOT_SESSION_KEY= # ============ 验证码 ============ -# CAPTCHA_EXPIRES_IN_S=300 -# CAPTCHA_CODE_LENGTH=6 +# CAPTCHA_POLICY_JSON={} +# CAPTCHA_REDIS_PREFIX=auth:captcha # ============ 邮件 ============ # 默认 blackhole(不发真实邮件);生产环境请设 nodemailer 或 postmark diff --git a/openapi.json b/openapi.json index d8420ac..7f86df1 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "ef6200b85a5a096b716c3bc8e84e84d57e7c515a9a8c4e3e24ee300bd1079bdc", + "hash": "b5f4359ab556c2605eeff8f479fe2cc7321ca32509a5d03fd01dcc921e818a79", "openapi": "3.0.0", "paths": { "/hello": { @@ -576,17 +576,36 @@ }, "responses": { "201": { - "description": "The captcha has been successfully created.", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Captcha" + "$ref": "#/components/schemas/IssuedCaptcha" + } + } + } + }, + "429": { + "description": "CAPTCHA_RATE_LIMITED", + "headers": { + "Retry-After": { + "description": "Seconds until retry is allowed.", + "schema": { + "type": "integer" } } } + }, + "503": { + "description": "CAPTCHA_UNAVAILABLE" } }, - "summary": "Create captcha", + "security": [ + { + "ApiKey": [] + } + ], + "summary": "Issue a captcha; plaintext is returned only to the trusted backend.", "tags": [ "captcha" ] @@ -595,36 +614,35 @@ "operationId": "listCaptchas", "parameters": [ { - "name": "_sort", + "name": "kind", "required": false, "in": "query", - "description": "排序参数", "schema": { - "type": "string", - "enum": [ - "createdAt", - "-createdAt", - "updatedAt", - "-updatedAt", - "expireAt", - "-expireAt" - ] + "$ref": "#/components/schemas/CaptchaKind" } }, { - "name": "code", + "name": "key", "required": false, "in": "query", - "description": "验证码", "schema": { "type": "string" } }, { - "name": "key", + "name": "purpose", "required": false, "in": "query", - "description": "key", + "description": "服务端确定的用途,例如 login、register、reset_password、send_sms。", + "schema": { + "type": "string" + } + }, + { + "name": "subject", + "required": false, + "in": "query", + "description": "与账号查找规则一致的手机号、邮箱,或可信后端签发的匿名会话 ID。", "schema": { "type": "string" } @@ -646,11 +664,20 @@ "schema": { "type": "number" } + }, + { + "name": "_sort", + "required": false, + "in": "query", + "description": "排序字段", + "schema": { + "type": "string" + } } ], "responses": { "200": { - "description": "A paged array of captchas.", + "description": "", "content": { "application/json": { "schema": { @@ -661,9 +688,28 @@ } } } + }, + "429": { + "description": "CAPTCHA_RATE_LIMITED", + "headers": { + "Retry-After": { + "description": "Seconds until retry is allowed.", + "schema": { + "type": "integer" + } + } + } + }, + "503": { + "description": "CAPTCHA_UNAVAILABLE" } }, - "summary": "List captchas", + "security": [ + { + "ApiKey": [] + } + ], + "summary": "", "tags": [ "captcha" ] @@ -674,36 +720,35 @@ "operationId": "countCaptchas", "parameters": [ { - "name": "_sort", + "name": "kind", "required": false, "in": "query", - "description": "排序参数", "schema": { - "type": "string", - "enum": [ - "createdAt", - "-createdAt", - "updatedAt", - "-updatedAt", - "expireAt", - "-expireAt" - ] + "$ref": "#/components/schemas/CaptchaKind" } }, { - "name": "code", + "name": "key", "required": false, "in": "query", - "description": "验证码", "schema": { "type": "string" } }, { - "name": "key", + "name": "purpose", "required": false, "in": "query", - "description": "key", + "description": "服务端确定的用途,例如 login、register、reset_password、send_sms。", + "schema": { + "type": "string" + } + }, + { + "name": "subject", + "required": false, + "in": "query", + "description": "与账号查找规则一致的手机号、邮箱,或可信后端签发的匿名会话 ID。", "schema": { "type": "string" } @@ -725,11 +770,20 @@ "schema": { "type": "number" } + }, + { + "name": "_sort", + "required": false, + "in": "query", + "description": "排序字段", + "schema": { + "type": "string" + } } ], "responses": { "200": { - "description": "The count of captchas.", + "description": "", "content": { "application/json": { "schema": { @@ -747,22 +801,42 @@ } } } + }, + "429": { + "description": "CAPTCHA_RATE_LIMITED", + "headers": { + "Retry-After": { + "description": "Seconds until retry is allowed.", + "schema": { + "type": "integer" + } + } + } + }, + "503": { + "description": "CAPTCHA_UNAVAILABLE" } }, - "summary": "Count captchas", + "security": [ + { + "ApiKey": [] + } + ], + "summary": "", "tags": [ "captcha" ] } }, - "/captchas/{captchaId}": { + "/captchas/{identifier}": { "get": { "operationId": "getCaptcha", "parameters": [ { - "name": "captchaId", + "name": "identifier", "required": true, "in": "path", + "description": "Captcha document id, as returned by creation.", "schema": { "type": "string" } @@ -770,7 +844,7 @@ ], "responses": { "200": { - "description": "The captcha with expected id.", + "description": "", "content": { "application/json": { "schema": { @@ -778,48 +852,28 @@ } } } - } - }, - "summary": "Find captcha by id", - "tags": [ - "captcha" - ] - }, - "patch": { - "operationId": "updateCaptcha", - "parameters": [ - { - "name": "captchaId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateCaptchaDto" - } - } - } - }, - "responses": { - "200": { - "description": "The captcha updated.", - "content": { - "application/json": { + }, + "429": { + "description": "CAPTCHA_RATE_LIMITED", + "headers": { + "Retry-After": { + "description": "Seconds until retry is allowed.", "schema": { - "$ref": "#/components/schemas/Captcha" + "type": "integer" } } } + }, + "503": { + "description": "CAPTCHA_UNAVAILABLE" } }, - "summary": "Update captcha", + "security": [ + { + "ApiKey": [] + } + ], + "summary": "", "tags": [ "captcha" ] @@ -828,9 +882,10 @@ "operationId": "deleteCaptcha", "parameters": [ { - "name": "captchaId", + "name": "identifier", "required": true, "in": "path", + "description": "Issuance key to revoke; NOT the document id.", "schema": { "type": "string" } @@ -839,9 +894,28 @@ "responses": { "204": { "description": "No content." + }, + "429": { + "description": "CAPTCHA_RATE_LIMITED", + "headers": { + "Retry-After": { + "description": "Seconds until retry is allowed.", + "schema": { + "type": "integer" + } + } + } + }, + "503": { + "description": "CAPTCHA_UNAVAILABLE" } }, - "summary": "Delete captcha", + "security": [ + { + "ApiKey": [] + } + ], + "summary": "Revoke this issuance by key; a stale key cannot revoke a replacement.", "tags": [ "captcha" ] @@ -863,7 +937,7 @@ }, "responses": { "200": { - "description": "Check if the captcha is valid.", + "description": "", "content": { "application/json": { "schema": { @@ -871,9 +945,28 @@ } } } + }, + "429": { + "description": "CAPTCHA_RATE_LIMITED", + "headers": { + "Retry-After": { + "description": "Seconds until retry is allowed.", + "schema": { + "type": "integer" + } + } + } + }, + "503": { + "description": "CAPTCHA_UNAVAILABLE" } }, - "summary": "verify captcha", + "security": [ + { + "ApiKey": [] + } + ], + "summary": "Verify AND consume; successful verification is never reusable.", "tags": [ "captcha" ] @@ -3399,19 +3492,6 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object" - } - } - } - } - }, - "201": { "description": "The third party record list.", "content": { "application/json": { @@ -3579,16 +3659,6 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "201": { "description": "The third party.", "content": { "application/json": { @@ -3628,16 +3698,6 @@ }, "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "201": { "description": "The third party has been successfully updated.", "content": { "application/json": { @@ -3667,16 +3727,6 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "201": { "description": "The third party has been successfully deleted.", "content": { "application/json": { @@ -3716,16 +3766,6 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "201": { "description": "The third party.", "content": { "application/json": { @@ -3765,16 +3805,6 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "201": { "description": "The third party.", "content": { "application/json": { @@ -6543,42 +6573,91 @@ "password" ] }, + "CaptchaKind": { + "type": "string", + "enum": [ + "image", + "sms", + "email" + ] + }, "CreateCaptchaDto": { "type": "object", "properties": { + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/CaptchaKind" + } + ] + }, "code": { "type": "string", - "description": "验证码" + "description": "仅 image 可指定答案;手机和邮箱验证码始终由 auth 生成。", + "pattern": "^[A-Za-z0-9]{4,8}$" }, - "expireAt": { - "format": "date-time", + "purpose": { "type": "string", - "description": "过期时间" + "description": "服务端确定的用途,例如 login、register、reset_password、send_sms。" }, - "key": { + "subject": { "type": "string", - "description": "key" + "description": "与账号查找规则一致的手机号、邮箱,或可信后端签发的匿名会话 ID。" } }, "required": [ - "key" + "kind", + "purpose", + "subject" ] }, - "Captcha": { + "CaptchaStatus": { + "type": "string", + "enum": [ + "pending", + "used", + "locked" + ] + }, + "IssuedCaptcha": { "type": "object", "properties": { + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/CaptchaKind" + } + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/CaptchaStatus" + } + ] + }, "code": { "type": "string", - "description": "验证码" + "description": "Returned once to the trusted issuing backend. Never forward to the frontend." + }, + "key": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "subject": { + "type": "string" }, "expireAt": { "format": "date-time", - "type": "string", - "description": "过期时间" + "type": "string" }, - "key": { - "type": "string", - "description": "key" + "maxAttempts": { + "type": "number" + }, + "failedAttempts": { + "type": "number" }, "id": { "type": "string", @@ -6593,20 +6672,81 @@ "format": "date-time", "type": "string", "description": "Entity updated at when" + } + }, + "required": [ + "kind", + "status", + "code", + "key", + "purpose", + "subject", + "expireAt", + "maxAttempts", + "failedAttempts", + "id" + ] + }, + "Captcha": { + "type": "object", + "properties": { + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/CaptchaKind" + } + ] }, - "createdBy": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/CaptchaStatus" + } + ] + }, + "key": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "expireAt": { + "format": "date-time", + "type": "string" + }, + "maxAttempts": { + "type": "number" + }, + "failedAttempts": { + "type": "number" + }, + "id": { "type": "string", - "description": "Entity created by who" + "description": "Entity id" }, - "updatedBy": { + "createdAt": { + "format": "date-time", "type": "string", - "description": "Entity updated by who" + "description": "Entity created at when" + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "description": "Entity updated at when" } }, "required": [ - "code", - "expireAt", + "kind", + "status", "key", + "purpose", + "subject", + "expireAt", + "maxAttempts", + "failedAttempts", "id" ] }, @@ -6621,47 +6761,44 @@ "count" ] }, - "UpdateCaptchaDto": { + "VerifyCaptchaDto": { "type": "object", "properties": { - "code": { - "type": "string", - "description": "验证码" + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/CaptchaKind" + } + ] }, - "expireAt": { - "format": "date-time", - "type": "string", - "description": "过期时间" + "code": { + "type": "string" }, "key": { + "type": "string" + }, + "purpose": { "type": "string", - "description": "key" - } - } - }, - "VerifyCaptchaDto": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "验证码" + "description": "服务端确定的用途,例如 login、register、reset_password、send_sms。" }, - "key": { + "subject": { "type": "string", - "description": "验证码 key" + "description": "与账号查找规则一致的手机号、邮箱,或可信后端签发的匿名会话 ID。" } }, "required": [ + "kind", "code", - "key" + "key", + "purpose", + "subject" ] }, "VerifyCaptchaResultDto": { "type": "object", "properties": { "success": { - "type": "boolean", - "description": "是否验证成功" + "type": "boolean" } }, "required": [ @@ -6722,14 +6859,6 @@ "type": "string", "description": "收件者" }, - "subject": { - "type": "string", - "description": "主题" - }, - "content": { - "type": "string", - "description": "内容" - }, "sentAt": { "format": "date-time", "type": "string", @@ -6739,9 +6868,7 @@ "required": [ "status", "from", - "to", - "subject", - "content" + "to" ] }, "EmailRecord": { @@ -6763,14 +6890,6 @@ "type": "string", "description": "收件者" }, - "subject": { - "type": "string", - "description": "主题" - }, - "content": { - "type": "string", - "description": "内容" - }, "sentAt": { "format": "date-time", "type": "string", @@ -6803,8 +6922,6 @@ "status", "from", "to", - "subject", - "content", "id" ] }, @@ -6827,14 +6944,6 @@ "type": "string", "description": "收件者" }, - "subject": { - "type": "string", - "description": "主题" - }, - "content": { - "type": "string", - "description": "内容" - }, "sentAt": { "format": "date-time", "type": "string", @@ -7411,10 +7520,6 @@ "type": "string", "description": "模板" }, - "params": { - "type": "string", - "description": "参数" - }, "account": { "type": "string", "description": "火山引擎消息组 ID" @@ -7455,10 +7560,6 @@ "type": "string", "description": "模板" }, - "params": { - "type": "string", - "description": "参数" - }, "account": { "type": "string", "description": "火山引擎消息组 ID" @@ -7522,10 +7623,6 @@ "type": "string", "description": "模板" }, - "params": { - "type": "string", - "description": "参数" - }, "account": { "type": "string", "description": "火山引擎消息组 ID" @@ -8552,25 +8649,23 @@ "ListCaptchasQuery": { "type": "object", "properties": { - "_sort": { - "type": "string", - "description": "排序参数", - "enum": [ - "createdAt", - "-createdAt", - "updatedAt", - "-updatedAt", - "expireAt", - "-expireAt" + "kind": { + "allOf": [ + { + "$ref": "#/components/schemas/CaptchaKind" + } ] }, - "code": { + "key": { + "type": "string" + }, + "purpose": { "type": "string", - "description": "验证码" + "description": "服务端确定的用途,例如 login、register、reset_password、send_sms。" }, - "key": { + "subject": { "type": "string", - "description": "key" + "description": "与账号查找规则一致的手机号、邮箱,或可信后端签发的匿名会话 ID。" }, "_limit": { "type": "number", @@ -8579,6 +8674,10 @@ "_offset": { "type": "number", "description": "分页偏移" + }, + "_sort": { + "type": "string", + "description": "排序字段" } } }, diff --git a/package.json b/package.json index db06d01..66d3af0 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "NODE_ENV=test jest --passWithNoTests --config ./test/jest-e2e.json", "prepare": "husky install", - "github-secret": "ts-node scripts/github-action-secret.ts" + "github-secret": "ts-node scripts/github-action-secret.ts", + "migrate:captcha-security": "ts-node scripts/migrate-captcha-security.ts", + "test:security": "jest --runInBand --testPathPattern=\"(captcha|delivery-security|sms.service|all-exceptions|route-logger).*spec\"" }, "dependencies": { "@alicloud/sms-sdk": "^1.1.6", @@ -140,7 +142,10 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "setupFiles": [ + "/test/setup-env.ts" + ] }, "pnpm": { "overrides": { diff --git a/scripts/gen-sdk.ts b/scripts/gen-sdk.ts index 4d54e9a..8185a12 100644 --- a/scripts/gen-sdk.ts +++ b/scripts/gen-sdk.ts @@ -22,7 +22,9 @@ async function main(outputDir = `${rootDir}/sdk`) { }); writePackageJson(outputDir + '/package.json'); fs.copyFileSync(rootDir + '/tsconfig.json', outputDir + '/tsconfig.json'); - fs.copyFileSync(rootDir + '/.npmrc', outputDir + '/.npmrc'); + if (fs.existsSync(rootDir + '/.npmrc')) { + fs.copyFileSync(rootDir + '/.npmrc', outputDir + '/.npmrc'); + } console.log('generate sdk success'); } diff --git a/scripts/migrate-captcha-security.ts b/scripts/migrate-captcha-security.ts new file mode 100644 index 0000000..d46089e --- /dev/null +++ b/scripts/migrate-captcha-security.ts @@ -0,0 +1,32 @@ +import { Command } from 'commander'; +import { MongoClient } from 'mongodb'; + +import { migrateCaptchaSecurity } from '../src/captcha/captcha-migration'; +import * as config from '../src/config'; + +async function main() { + const program = new Command() + .description('Count legacy captcha/message fields; --execute removes them and updates indexes.') + .option('--execute', 'apply migration after pausing old writers') + .parse(); + const { execute = false } = program.opts(); + if (execute && process.env.CAPTCHA_MAINTENANCE_MODE !== 'true') { + throw new Error( + 'Pause captcha/related delivery traffic and old writers, then set CAPTCHA_MAINTENANCE_MODE=true.' + ); + } + const client = new MongoClient(config.mongo.url, { serverSelectionTimeoutMS: 5000 }); + try { + await client.connect(); + console.log(JSON.stringify(await migrateCaptchaSecurity(client.db(), execute), null, 2)); + } finally { + await client.close(); + } +} + +main().catch(() => { + console.error( + 'Captcha migration failed. Check maintenance mode, connection and index permissions; no sensitive data is logged.' + ); + process.exitCode = 1; +}); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 1229933..bbd7482 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -21,7 +21,7 @@ import { get, isEqual } from 'lodash'; import { JwtPayload } from 'src/auth'; import { PhoneQuickAuthService } from 'src/auth/phone-quick-auth.service'; -import { CaptchaService } from 'src/captcha'; +import { CaptchaKind, CaptchaService } from 'src/captcha'; import * as config from 'src/config'; import { ErrorCodes } from 'src/constants'; import { assertHttp } from 'src/lib/lang/assert'; @@ -240,16 +240,24 @@ export class AuthController { }) @Post('@loginByEmail') async loginByEmail(@Body() dto: LoginByEmailDto): Promise { - let user = await this.userService.findByEmail(dto.email); - - if (!user && !dto.autoRegister) { + if ( + !(await this.captchaService.consume({ + key: dto.key, + code: dto.code, + kind: CaptchaKind.EMAIL, + purpose: 'login', + subject: dto.email, + })) + ) { throw new UnauthorizedException({ code: ErrorCodes.AUTH_FAILED, message: `email or captcha code wrong`, }); } - if (!(await this.captchaService.consume(dto.key, dto.code))) { + let user = await this.userService.findByEmail(dto.email); + + if (!user && !dto.autoRegister) { throw new UnauthorizedException({ code: ErrorCodes.AUTH_FAILED, message: `email or captcha code wrong`, @@ -285,16 +293,24 @@ export class AuthController { }) @Post('@loginByPhone') async loginByPhone(@Body() dto: LoginByPhoneDto): Promise { - let user = await this.userService.findByPhone(dto.phone); - - if (!user && !dto.autoRegister) { + if ( + !(await this.captchaService.consume({ + key: dto.key, + code: dto.code, + kind: CaptchaKind.SMS, + purpose: 'login', + subject: dto.phone, + })) + ) { throw new UnauthorizedException({ code: ErrorCodes.AUTH_FAILED, message: `phone or captcha code wrong`, }); } - if (!(await this.captchaService.consume(dto.key, dto.code))) { + let user = await this.userService.findByPhone(dto.phone); + + if (!user && !dto.autoRegister) { throw new UnauthorizedException({ code: ErrorCodes.AUTH_FAILED, message: `phone or captcha code wrong`, @@ -423,6 +439,21 @@ export class AuthController { }) @Post('@registerByPhone') async registerByPhone(@Body() dto: RegisterbyPhoneDto): Promise { + if ( + !(await this.captchaService.consume({ + key: dto.key, + code: dto.code, + kind: CaptchaKind.SMS, + purpose: 'register', + subject: dto.phone, + })) + ) { + throw new BadRequestException({ + code: ErrorCodes.CAPTCHA_INVALID, + message: 'captcha invalid.', + }); + } + const user = await this.userService.findByPhone(dto.phone); if (user) { throw new ConflictException({ @@ -431,13 +462,6 @@ export class AuthController { }); } - if (!(await this.captchaService.consume(dto.key, dto.code))) { - throw new BadRequestException({ - code: ErrorCodes.CAPTCHA_INVALID, - message: 'captcha invalid.', - }); - } - return this.userService.create({ phone: dto.phone, ns: dto.ns, @@ -460,6 +484,21 @@ export class AuthController { }) @Post('@registerByEmail') async registerByEmail(@Body() dto: RegisterByEmailDto): Promise { + if ( + !(await this.captchaService.consume({ + key: dto.key, + code: dto.code, + kind: CaptchaKind.EMAIL, + purpose: 'register', + subject: dto.email, + })) + ) { + throw new BadRequestException({ + code: ErrorCodes.CAPTCHA_INVALID, + message: 'captcha invalid.', + }); + } + const user = await this.userService.findByEmail(dto.email); if (user) { throw new ConflictException({ @@ -468,13 +507,6 @@ export class AuthController { }); } - if (!(await this.captchaService.consume(dto.key, dto.code))) { - throw new BadRequestException({ - code: ErrorCodes.CAPTCHA_INVALID, - message: 'captcha invalid.', - }); - } - return this.userService.create({ email: dto.email, ns: dto.ns, @@ -615,6 +647,21 @@ export class AuthController { @HttpCode(HttpStatus.NO_CONTENT) @Post('@resetPasswordByPhone') async resetPasswordByPhone(@Body() dto: ResetPasswordByPhoneDto): Promise { + if ( + !(await this.captchaService.consume({ + key: dto.key, + code: dto.code, + kind: CaptchaKind.SMS, + purpose: 'reset_password', + subject: dto.phone, + })) + ) { + throw new BadRequestException({ + code: ErrorCodes.CAPTCHA_INVALID, + message: 'captcha invalid.', + }); + } + const user = await this.userService.findByPhone(dto.phone); if (!user) { throw new NotFoundException({ @@ -623,13 +670,6 @@ export class AuthController { }); } - if (!(await this.captchaService.consume(dto.key, dto.code))) { - throw new BadRequestException({ - code: ErrorCodes.CAPTCHA_INVALID, - message: 'captcha invalid.', - }); - } - await this.userService.updatePassword(user.id, dto.password); await this.invalidateUserCache(user.id); } @@ -641,6 +681,21 @@ export class AuthController { @HttpCode(HttpStatus.NO_CONTENT) @Post('@resetPasswordByEmail') async resetPasswordByEmail(@Body() dto: ResetPasswordByEmailDto): Promise { + if ( + !(await this.captchaService.consume({ + key: dto.key, + code: dto.code, + kind: CaptchaKind.EMAIL, + purpose: 'reset_password', + subject: dto.email, + })) + ) { + throw new BadRequestException({ + code: ErrorCodes.CAPTCHA_INVALID, + message: 'captcha invalid.', + }); + } + const user = await this.userService.findByEmail(dto.email); if (!user) { throw new NotFoundException({ @@ -649,13 +704,6 @@ export class AuthController { }); } - if (!(await this.captchaService.consume(dto.key, dto.code))) { - throw new BadRequestException({ - code: ErrorCodes.CAPTCHA_INVALID, - message: 'captcha invalid.', - }); - } - await this.userService.updatePassword(user.id, dto.password); await this.invalidateUserCache(user.id); } diff --git a/src/captcha/captcha-migration.spec.ts b/src/captcha/captcha-migration.spec.ts new file mode 100644 index 0000000..97dbfdf --- /dev/null +++ b/src/captcha/captcha-migration.spec.ts @@ -0,0 +1,89 @@ +import { MongoClient } from 'mongodb'; +import { MongoMemoryServer } from 'mongodb-memory-server'; + +import { migrateCaptchaSecurity } from './captcha-migration'; + +describe('Captcha security migration', () => { + it('supports read-only reporting, clears only legacy secrets, and is idempotent', async () => { + const mongod = await MongoMemoryServer.create(); + const client = await MongoClient.connect(mongod.getUri()); + try { + const db = client.db('migration'); + await db.collection('captchas').insertMany([ + { key: 'legacy', code: '123456', expireAt: new Date(Date.now() + 300000) }, + { + key: 'new', + kind: 'sms', + purpose: 'login', + subject: 'subject', + code: '654321', + status: 'pending', + expireAt: new Date(Date.now() + 300000), + }, + { + key: 'legacy-hash', + kind: 'sms', + purpose: 'login', + subject: 'legacy-subject', + codeHash: 'hash', + status: 'pending', + expireAt: new Date(Date.now() + 300000), + }, + { + key: 'new-used', + kind: 'sms', + purpose: 'login', + subject: 'used-subject', + status: 'used', + expireAt: new Date(Date.now() + 300000), + }, + ]); + await db.collection('captchas').createIndex({ expireAt: 1 }, { expireAfterSeconds: 604800 }); + await db + .collection('smsrecords') + .insertOne({ phone: 'subject', params: 'secret-sms', status: 'sent' }); + await db.collection('emailrecords').insertOne({ + to: 'subject', + subject: 'secret-title', + content: 'secret-body', + status: 'sent', + }); + await db.collection('users').insertOne({ username: 'untouched' }); + const report = await migrateCaptchaSecurity(db); + expect(report).toMatchObject({ + execute: false, + legacyCaptchas: 2, + smsWithParams: 1, + emailsWithContent: 1, + }); + expect(await db.collection('captchas').countDocuments({})).toBe(4); + expect(await db.collection('smsrecords').findOne({})).toHaveProperty('params'); + await migrateCaptchaSecurity(db, true); + expect(await db.collection('captchas').countDocuments({})).toBe(2); + expect(await db.collection('captchas').findOne({ key: 'new' })).toHaveProperty( + 'code', + '654321' + ); + expect(await db.collection('captchas').findOne({ key: 'new-used' })).toHaveProperty( + 'status', + 'used' + ); + expect(await db.collection('smsrecords').findOne({})).not.toHaveProperty('params'); + expect(await db.collection('emailrecords').findOne({})).not.toHaveProperty('content'); + expect(await db.collection('emailrecords').findOne({})).not.toHaveProperty('subject'); + expect(await db.collection('users').findOne({})).toHaveProperty('username', 'untouched'); + const indexes = await db.collection('captchas').indexes(); + expect(indexes.find((index) => index.key.expireAt === 1).expireAfterSeconds).toBe(0); + expect(indexes.find((index) => index.key.subject === 1).unique).toBe(true); + expect(await migrateCaptchaSecurity(db, true)).toMatchObject({ + legacyCaptchas: 0, + smsWithParams: 0, + emailsWithContent: 0, + }); + expect(await db.collection('captchas').countDocuments({})).toBe(2); + } finally { + await client.close(); + await mongod.stop(); + } + }); +}); diff --git a/src/captcha/captcha-migration.ts b/src/captcha/captcha-migration.ts new file mode 100644 index 0000000..aa09706 --- /dev/null +++ b/src/captcha/captcha-migration.ts @@ -0,0 +1,40 @@ +import { Db } from 'mongodb'; + +const legacyCaptcha = { + $or: [ + { codeHash: { $exists: true } }, + { kind: { $exists: false } }, + { purpose: { $exists: false } }, + { subject: { $exists: false } }, + ], +}; + +/** Operates only on captcha and delivery-record collections; never reads message contents. */ +export async function migrateCaptchaSecurity(db: Db, execute = false) { + const captchas = db.collection('captchas'); + const sms = db.collection('smsrecords'); + const email = db.collection('emailrecords'); + const report = { + legacyCaptchas: await captchas.countDocuments(legacyCaptcha), + smsWithParams: await sms.countDocuments({ params: { $exists: true } }), + emailsWithContent: await email.countDocuments({ + $or: [{ subject: { $exists: true } }, { content: { $exists: true } }], + }), + execute, + }; + if (!execute) return report; + await captchas.deleteMany(legacyCaptcha); + await sms.updateMany({ params: { $exists: true } }, { $unset: { params: '' } }); + await email.updateMany( + { $or: [{ subject: { $exists: true } }, { content: { $exists: true } }] }, + { $unset: { subject: '', content: '' } } + ); + await captchas.createIndex({ key: 1 }, { unique: true }); + await captchas.createIndex({ kind: 1, purpose: 1, subject: 1 }, { unique: true }); + const ttl = (await captchas.listIndexes().toArray()).find( + (index) => Object.keys(index.key).length === 1 && index.key.expireAt === 1 + ); + if (ttl && ttl.expireAfterSeconds !== 0) await captchas.dropIndex(ttl.name); + await captchas.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 }); + return report; +} diff --git a/src/captcha/captcha-policy.service.spec.ts b/src/captcha/captcha-policy.service.spec.ts new file mode 100644 index 0000000..4db85a5 --- /dev/null +++ b/src/captcha/captcha-policy.service.spec.ts @@ -0,0 +1,30 @@ +import * as config from 'src/config'; + +import { CaptchaPolicyService, DEFAULT_CAPTCHA_POLICIES } from './captcha-policy.service'; + +describe('Captcha policy startup validation', () => { + const original = { ...config.captcha }; + afterEach(() => Object.assign(config.captcha, original)); + it('starts with default policies without a separate secret', () => { + config.captcha.policyJson = '{}'; + expect(new CaptchaPolicyService().policies).toEqual(DEFAULT_CAPTCHA_POLICIES); + }); + it.each([ + '{', + 'null', + '{"sms":null}', + '{"sms":{"toString":123}}', + '{"sms":{"maxAttempts":0}}', + '{"sms":{"expiresInS":601}}', + '{"sms":{"verifyLimits":[]}}', + ])('rejects invalid policy', (json) => { + config.captcha.policyJson = json; + expect(() => new CaptchaPolicyService()).toThrow('CAPTCHA_POLICY_JSON'); + }); + it('merges valid server overrides and preserves the other defaults', () => { + config.captcha.policyJson = '{"sms":{"maxAttempts":3}}'; + const service = new CaptchaPolicyService(); + expect(service.policies.sms.maxAttempts).toBe(3); + expect(service.policies.email.expiresInS).toBe(600); + }); +}); diff --git a/src/captcha/captcha-policy.service.ts b/src/captcha/captcha-policy.service.ts new file mode 100644 index 0000000..589fb5f --- /dev/null +++ b/src/captcha/captcha-policy.service.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; + +import * as config from 'src/config'; + +export enum CaptchaKind { + IMAGE = 'image', + SMS = 'sms', + EMAIL = 'email', +} + +export interface RateWindow { + windowS: number; + limit: number; +} + +export interface CaptchaPolicy { + expiresInS: number; + maxAttempts: number; + issueIntervalS: number; + issueLimits: RateWindow[]; + verifyLimits: RateWindow[]; +} + +const deliveryPolicy = { + maxAttempts: 5, + issueIntervalS: 60, + issueLimits: [ + { windowS: 3600, limit: 5 }, + { windowS: 86400, limit: 10 }, + ], + verifyLimits: [{ windowS: 600, limit: 20 }], +}; + +export const DEFAULT_CAPTCHA_POLICIES: Record = { + image: { + expiresInS: 120, + maxAttempts: 3, + issueIntervalS: 1, + issueLimits: [{ windowS: 60, limit: 20 }], + verifyLimits: [{ windowS: 60, limit: 30 }], + }, + sms: { ...deliveryPolicy, expiresInS: 300 }, + email: { ...deliveryPolicy, expiresInS: 600 }, +}; + +@Injectable() +export class CaptchaPolicyService { + readonly policies: Record; + readonly redisPrefix = config.captcha.redisPrefix; + + constructor() { + try { + const overrides = JSON.parse(config.captcha.policyJson || '{}'); + if ( + !overrides || + Array.isArray(overrides) || + typeof overrides !== 'object' || + Object.keys(overrides).some( + (key) => !Object.values(CaptchaKind).includes(key as CaptchaKind) + ) + ) + throw new Error(); + this.policies = {} as Record; + for (const kind of Object.values(CaptchaKind)) { + const override = overrides[kind] === undefined ? {} : overrides[kind]; + if ( + !override || + typeof override !== 'object' || + Array.isArray(override) || + Object.keys(override).some( + (key) => !Object.prototype.hasOwnProperty.call(DEFAULT_CAPTCHA_POLICIES[kind], key) + ) + ) + throw new Error(); + const policy = { ...DEFAULT_CAPTCHA_POLICIES[kind], ...override }; + const positive = (n: number, max: number) => Number.isInteger(n) && n > 0 && n <= max; + if ( + !positive(policy.expiresInS, 600) || + !positive(policy.maxAttempts, 10) || + !positive(policy.issueIntervalS, 86400) + ) + throw new Error(); + for (const windows of [policy.issueLimits, policy.verifyLimits]) { + if ( + !Array.isArray(windows) || + windows.length < 1 || + windows.length > 3 || + windows.some((w) => !w || !positive(w.windowS, 86400) || !positive(w.limit, 10000)) + ) + throw new Error(); + } + this.policies[kind] = policy; + } + if (!this.redisPrefix || /[{}\s]/.test(this.redisPrefix)) throw new Error(); + } catch { + throw new Error('Invalid CAPTCHA_POLICY_JSON or CAPTCHA_REDIS_PREFIX'); + } + } +} diff --git a/src/captcha/captcha-rate-limit.service.spec.ts b/src/captcha/captcha-rate-limit.service.spec.ts new file mode 100644 index 0000000..9999de1 --- /dev/null +++ b/src/captcha/captcha-rate-limit.service.spec.ts @@ -0,0 +1,143 @@ +import { randomBytes } from 'crypto'; + +import * as config from 'src/config'; +import { createRedisClient, RedisClient } from 'src/redis/client'; + +import { CaptchaKind, CaptchaPolicyService } from './captcha-policy.service'; +import { CaptchaRateLimitService } from './captcha-rate-limit.service'; + +// Seed only this test's own sorted sets relative to Redis TIME. Never flush shared Redis. +const SEED = `local t=redis.call('TIME'); local now=tonumber(t[1])*1000+math.floor(tonumber(t[2])/1000); for i,age in ipairs(cjson.decode(ARGV[1])) do redis.call('ZADD',KEYS[1],now-age,'seed-'..i) end; redis.call('EXPIRE',KEYS[1],86400); return 1`; + +describe('Captcha Redis limits', () => { + let redis: RedisClient; + let policy: CaptchaPolicyService; + let service: CaptchaRateLimitService; + const keys = new Set(); + const subject = () => randomBytes(16).toString('hex'); + const key = (kind: CaptchaKind, id: string, action: 'issue' | 'verify') => { + const value = service.key(kind, id, action); + keys.add(value); + return value; + }; + const seed = (k: string, ages: number[]) => + redis.eval(SEED, { keys: [k], arguments: [JSON.stringify(ages)] }); + + beforeAll(async () => { + redis = await createRedisClient(config.redis.url); + }); + beforeEach(() => { + policy = new CaptchaPolicyService(); + service = new CaptchaRateLimitService(redis, policy); + }); + afterEach(async () => { + await Promise.all([...keys].map((k) => redis.del(k))); + keys.clear(); + }); + afterAll(async () => { + await redis?.disconnect(); + }); + + it.each([CaptchaKind.IMAGE, CaptchaKind.SMS, CaptchaKind.EMAIL])( + 'enforces %s issue cooldown with Retry-After', + async (kind) => { + const id = subject(); + key(kind, id, 'issue'); + await service.reserve(kind, id, 'issue'); + await expect(service.reserve(kind, id, 'issue')).rejects.toMatchObject({ + status: 429, + response: { retryAfter: policy.policies[kind].issueIntervalS }, + }); + } + ); + + it.each([CaptchaKind.SMS, CaptchaKind.EMAIL])( + 'enforces %s hour/day quotas independently of cooldown', + async (kind) => { + const id = subject(), + k = key(kind, id, 'issue'); + await seed(k, [61000, 121000, 181000, 241000, 301000]); + await expect(service.reserve(kind, id, 'issue')).rejects.toMatchObject({ status: 429 }); + await redis.del(k); + await seed( + k, + Array.from({ length: 10 }, (_, i) => 3601000 + i * 61000) + ); + await expect(service.reserve(kind, id, 'issue')).rejects.toMatchObject({ status: 429 }); + await redis.del(k); + await seed( + k, + Array.from({ length: 10 }, (_, i) => 86401000 + i * 1000) + ); + await expect(service.reserve(kind, id, 'issue')).resolves.toBeUndefined(); + } + ); + + it('enforces the image rolling minute quota', async () => { + const id = subject(), + k = key(CaptchaKind.IMAGE, id, 'issue'); + await seed( + k, + Array.from({ length: 20 }, (_, i) => 1100 + i * 1000) + ); + await expect(service.reserve(CaptchaKind.IMAGE, id, 'issue')).rejects.toMatchObject({ + status: 429, + }); + }); + + it.each([CaptchaKind.IMAGE, CaptchaKind.SMS, CaptchaKind.EMAIL])( + 'shares %s verification quota across instances and issuance', + async (kind) => { + const id = subject(); + key(kind, id, 'verify'); + key(kind, id, 'issue'); + const other = new CaptchaRateLimitService(redis, new CaptchaPolicyService()); + const limit = policy.policies[kind].verifyLimits[0].limit; + const attempts = await Promise.allSettled( + Array.from({ length: limit + 20 }, (_, i) => + (i % 2 ? service : other).reserve(kind, id, 'verify') + ) + ); + expect(attempts.filter((r) => r.status === 'fulfilled')).toHaveLength(limit); + await service.reserve(kind, id, 'issue'); + await expect(other.reserve(kind, id, 'verify')).rejects.toMatchObject({ status: 429 }); + } + ); + + it('allows verification after the rolling window expires and hides the subject in keys', async () => { + const id = 'private@example.com', + k = key(CaptchaKind.EMAIL, id, 'verify'); + expect(k).not.toContain(id); + await seed( + k, + Array.from({ length: 20 }, (_, i) => 600001 + i) + ); + await expect(service.reserve(CaptchaKind.EMAIL, id, 'verify')).resolves.toBeUndefined(); + }); + + it('fails closed on unavailable Redis without returning its error', async () => { + const unavailable = { + eval: jest.fn().mockRejectedValue(new Error('redis://secret-credentials')), + } as any; + const isolated = new CaptchaRateLimitService(unavailable, policy); + await expect(isolated.reserve(CaptchaKind.SMS, 'subject', 'verify')).rejects.toMatchObject({ + status: 503, + response: { message: 'Captcha service unavailable.' }, + }); + }); + it('returns 503 after the Redis deadline without retrying the command', async () => { + const stuck = { eval: jest.fn().mockReturnValue(new Promise(() => undefined)) } as any; + const isolated = new CaptchaRateLimitService(stuck, policy); + jest.useFakeTimers(); + try { + const rejected = expect( + isolated.reserve(CaptchaKind.SMS, 'subject', 'verify') + ).rejects.toMatchObject({ status: 503 }); + await jest.advanceTimersByTimeAsync(2001); + await rejected; + expect(stuck.eval).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/src/captcha/captcha-rate-limit.service.ts b/src/captcha/captcha-rate-limit.service.ts new file mode 100644 index 0000000..5051c9d --- /dev/null +++ b/src/captcha/captcha-rate-limit.service.ts @@ -0,0 +1,101 @@ +import { createHash, randomBytes } from 'crypto'; + +import { + HttpException, + HttpStatus, + Inject, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; + +import { ErrorCodes } from 'src/constants'; +import { RedisClient, withRedisTimeout } from 'src/redis/client'; +import { REDIS_CLIENT } from 'src/redis/redis.module'; + +import { CaptchaKind, CaptchaPolicyService, RateWindow } from './captcha-policy.service'; + +// One key per kind/subject/action: works on standalone Redis and Redis Cluster. +// Redis TIME avoids differences between application instance clocks. +export const CAPTCHA_RATE_SCRIPT = ` +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) +local interval = tonumber(ARGV[1]) +local windows = cjson.decode(ARGV[2]) +local horizon = interval +for _, w in ipairs(windows) do horizon = math.max(horizon, w.windowS * 1000) end +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - horizon) +local wait = 0 +local latest = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES') +if #latest > 0 then wait = math.max(wait, tonumber(latest[2]) + interval - now) end +for _, w in ipairs(windows) do + local cutoff = '(' .. tostring(now - w.windowS * 1000) + if redis.call('ZCOUNT', KEYS[1], cutoff, '+inf') >= w.limit then + local oldest = redis.call('ZRANGEBYSCORE', KEYS[1], cutoff, '+inf', 'WITHSCORES', 'LIMIT', 0, 1) + wait = math.max(wait, tonumber(oldest[2]) + w.windowS * 1000 - now) + end +end +if wait > 0 then return wait end +redis.call('ZADD', KEYS[1], now, ARGV[3]) +redis.call('PEXPIRE', KEYS[1], horizon) +return 0 +`; + +export class CaptchaRateLimitException extends HttpException { + constructor(retryAfter: number) { + super( + { code: ErrorCodes.CAPTCHA_RATE_LIMITED, message: 'Too many captcha requests.', retryAfter }, + HttpStatus.TOO_MANY_REQUESTS + ); + } +} + +@Injectable() +export class CaptchaRateLimitService { + private readonly logger = new Logger(CaptchaRateLimitService.name); + + constructor( + @Inject(REDIS_CLIENT) private readonly redis: RedisClient, + private readonly policy: CaptchaPolicyService + ) {} + + key(kind: CaptchaKind, subject: string, action: 'issue' | 'verify'): string { + const digest = createHash('sha256') + .update(JSON.stringify([kind, subject])) + .digest('hex'); + return `${this.policy.redisPrefix}:{${digest}}:${action}`; + } + + async reserve(kind: CaptchaKind, subject: string, action: 'issue' | 'verify'): Promise { + const policy = this.policy.policies[kind]; + const windows: RateWindow[] = action === 'issue' ? policy.issueLimits : policy.verifyLimits; + let wait: number; + try { + wait = Number( + await withRedisTimeout( + this.redis.eval(CAPTCHA_RATE_SCRIPT, { + keys: [this.key(kind, subject, action)], + arguments: [ + String(action === 'issue' ? policy.issueIntervalS * 1000 : 0), + JSON.stringify(windows), + randomBytes(16).toString('hex'), + ], + }), + 2000, + 'captcha rate limit' + ) + ); + if (!Number.isFinite(wait) || wait < 0) throw new Error(); + } catch { + this.logger.error({ event: 'captcha_dependency_failed', dependency: 'redis', kind }); + throw new ServiceUnavailableException({ + code: ErrorCodes.CAPTCHA_UNAVAILABLE, + message: 'Captcha service unavailable.', + }); + } + if (wait > 0) { + this.logger.warn({ event: 'captcha_rate_limited', kind, action }); + throw new CaptchaRateLimitException(Math.max(1, Math.ceil(wait / 1000))); + } + } +} diff --git a/src/captcha/captcha.controller.ts b/src/captcha/captcha.controller.ts index d7150a4..ea46391 100644 --- a/src/captcha/captcha.controller.ts +++ b/src/captcha/captcha.controller.ts @@ -7,142 +7,99 @@ import { HttpStatus, NotFoundException, Param, - Patch, Post, Query, + UsePipes, + ValidationPipe, } from '@nestjs/common'; import { ApiCreatedResponse, ApiNoContentResponse, ApiOkResponse, ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, ApiTags, } from '@nestjs/swagger'; import { CountResult } from 'src/common'; +import { exceptionFactory } from 'src/common/exception-factory'; import { ErrorCodes } from 'src/constants'; import { CaptchaService } from './captcha.service'; import { CreateCaptchaDto } from './dto/create-captcha.dto'; import { ListCaptchasQuery } from './dto/list-captchas.dto'; -import { UpdateCaptchaDto } from './dto/update-captcha.dto'; import { VerifyCaptchaDto, VerifyCaptchaResultDto } from './dto/verify-captcha.dto'; -import { Captcha, CaptchaDocument } from './entities/captcha.entity'; +import { Captcha, IssuedCaptcha } from './entities/captcha.entity'; @ApiTags('captcha') +@ApiSecurity('ApiKey') +@ApiResponse({ + status: 429, + description: 'CAPTCHA_RATE_LIMITED', + headers: { + 'Retry-After': { description: 'Seconds until retry is allowed.', schema: { type: 'integer' } }, + }, +}) +@ApiResponse({ status: 503, description: 'CAPTCHA_UNAVAILABLE' }) @Controller('captchas') +@UsePipes(new ValidationPipe({ whitelist: true, transform: true, exceptionFactory })) export class CaptchaController { - private readonly imgCaptcha: Captcha; + constructor(private readonly captchaService: CaptchaService) {} - constructor(private readonly captchaService: CaptchaService) { - this.imgCaptcha = new Captcha(); - } - - /** - * Create captcha - */ + /** Issue a captcha; plaintext is returned only to the trusted backend. */ @ApiOperation({ operationId: 'createCaptcha' }) - @ApiCreatedResponse({ - description: 'The captcha has been successfully created.', - type: Captcha, - }) + @ApiCreatedResponse({ type: IssuedCaptcha }) @Post() - async create(@Body() createDto: CreateCaptchaDto): Promise { - const captcha = await this.captchaService.create(createDto); - return captcha; + create(@Body() dto: CreateCaptchaDto): Promise { + return this.captchaService.create(dto); } - /** - * List captchas - */ @ApiOperation({ operationId: 'listCaptchas' }) - @ApiOkResponse({ - description: 'A paged array of captchas.', - type: [Captcha], - }) + @ApiOkResponse({ type: [Captcha] }) @Get() - list(@Query() query: ListCaptchasQuery): Promise { + list(@Query() query: ListCaptchasQuery): Promise { return this.captchaService.list(query); } - /** - * Count captchas - */ @ApiOperation({ operationId: 'countCaptchas' }) - @ApiOkResponse({ - description: 'The count of captchas.', - type: CountResult, - }) + @ApiOkResponse({ type: CountResult }) @Post('@count') async count(@Query() query: ListCaptchasQuery): Promise { - const count = await this.captchaService.count(query); - return { count }; + return { count: await this.captchaService.count(query) }; } - /** - * Find captcha by id - */ @ApiOperation({ operationId: 'getCaptcha' }) - @ApiOkResponse({ - description: 'The captcha with expected id.', - type: Captcha, - }) - @Get(':captchaId') - async get(@Param('captchaId') captchaId: string): Promise { - const captcha = await this.captchaService.get(captchaId); - if (!captcha) - throw new NotFoundException({ - code: ErrorCodes.CAPTCHA_NOT_FOUND, - message: `Captcha ${captchaId} not found.`, - }); - return captcha; - } - - /** - * Update captcha - */ - @ApiOperation({ operationId: 'updateCaptcha' }) - @ApiOkResponse({ - description: 'The captcha updated.', - type: Captcha, - }) - @Patch(':captchaId') - async update( - @Param('captchaId') captchaId: string, - @Body() updateDto: UpdateCaptchaDto - ): Promise { - const captcha = await this.captchaService.update(captchaId, updateDto); + @ApiOkResponse({ type: Captcha }) + @ApiParam({ name: 'identifier', description: 'Captcha document id, as returned by creation.' }) + @Get(':identifier') + async get(@Param('identifier') id: string): Promise { + const captcha = await this.captchaService.get(id); if (!captcha) throw new NotFoundException({ code: ErrorCodes.CAPTCHA_NOT_FOUND, - message: `Captcha ${captchaId} not found.`, + message: 'Captcha not found.', }); return captcha; } - /** - * Delete captcha - */ + /** Revoke this issuance by key; a stale key cannot revoke a replacement. */ @ApiOperation({ operationId: 'deleteCaptcha' }) @ApiNoContentResponse({ description: 'No content.' }) @HttpCode(HttpStatus.NO_CONTENT) - @Delete(':captchaId') - async delete(@Param('captchaId') captchaId: string) { - await this.captchaService.delete(captchaId); + @ApiParam({ name: 'identifier', description: 'Issuance key to revoke; NOT the document id.' }) + @Delete(':identifier') + delete(@Param('identifier') key: string): Promise { + return this.captchaService.delete(key); } - /** - * verify captcha - */ + /** Verify AND consume; successful verification is never reusable. */ @ApiOperation({ operationId: 'verifyCaptcha' }) @HttpCode(HttpStatus.OK) - @ApiOkResponse({ - description: 'Check if the captcha is valid.', - type: VerifyCaptchaResultDto, - }) + @ApiOkResponse({ type: VerifyCaptchaResultDto }) @Post('@verifyCaptcha') async verifyCaptcha(@Body() dto: VerifyCaptchaDto): Promise { - const captcha = await this.captchaService.getByKey(dto.key, { code: dto.code }); - return { success: !!captcha }; + return { success: await this.captchaService.consume(dto) }; } } diff --git a/src/captcha/captcha.module.ts b/src/captcha/captcha.module.ts index 0d57a9b..7c57e46 100644 --- a/src/captcha/captcha.module.ts +++ b/src/captcha/captcha.module.ts @@ -2,6 +2,8 @@ import { Module, OnModuleInit } from '@nestjs/common'; import { InjectModel, MongooseModule } from '@nestjs/mongoose'; import { Model } from 'mongoose'; +import { CaptchaPolicyService } from './captcha-policy.service'; +import { CaptchaRateLimitService } from './captcha-rate-limit.service'; import { CaptchaController } from './captcha.controller'; import { CaptchaService } from './captcha.service'; import { Captcha, CaptchaDocument, CaptchaSchema } from './entities/captcha.entity'; @@ -9,7 +11,7 @@ import { Captcha, CaptchaDocument, CaptchaSchema } from './entities/captcha.enti @Module({ imports: [MongooseModule.forFeature([{ name: Captcha.name, schema: CaptchaSchema }])], controllers: [CaptchaController], - providers: [CaptchaService], + providers: [CaptchaService, CaptchaPolicyService, CaptchaRateLimitService], exports: [CaptchaService], }) export class CaptchaModule implements OnModuleInit { diff --git a/src/captcha/captcha.service.spec.ts b/src/captcha/captcha.service.spec.ts index 89c7b5d..e75d0d5 100644 --- a/src/captcha/captcha.service.spec.ts +++ b/src/captcha/captcha.service.spec.ts @@ -1,126 +1,239 @@ import { getModelToken } from '@nestjs/mongoose'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import { connect, Connection, Model } from 'mongoose'; -import { nanoid } from 'nanoid'; +import { Connection, createConnection, Model } from 'mongoose'; +import { CaptchaKind, CaptchaPolicyService } from './captcha-policy.service'; +import { CaptchaRateLimitService } from './captcha-rate-limit.service'; import { CaptchaService } from './captcha.service'; -import { Captcha, CaptchaSchema } from './entities/captcha.entity'; +import { Captcha, CaptchaDocument, CaptchaSchema, CaptchaStatus } from './entities/captcha.entity'; -const mockCaptcha = (withCode = false) => ({ - key: nanoid(8), - ...(withCode && { code: nanoid(6) }), +const context = (kind = CaptchaKind.SMS, subject = '13800138000', purpose = 'login') => ({ + kind, + subject, + purpose, +}); +const answer = (captcha: any, changes = {}) => ({ + key: captcha.key, + code: captcha.code, + kind: captcha.kind, + purpose: captcha.purpose, + subject: captcha.subject, + ...changes, }); -describe('CaptchaService', () => { +describe('Captcha security', () => { let mongod: MongoMemoryServer; - let mongoConnection: Connection; + let connection: Connection; + let model: Model; let service: CaptchaService; - let captchaModel: Model; + const limiter = { reserve: jest.fn().mockResolvedValue(undefined) }; beforeAll(async () => { - mongod = await MongoMemoryServer.create(); - const uri = mongod.getUri(); - mongoConnection = (await connect(uri)).connection; - captchaModel = mongoConnection.model(Captcha.name, CaptchaSchema); - - const module: TestingModule = await Test.createTestingModule({ + mongod = await MongoMemoryServer.create({ + instance: { args: ['--setParameter', 'ttlMonitorEnabled=false'] }, + }); + connection = await createConnection(mongod.getUri()).asPromise(); + model = connection.model(Captcha.name, CaptchaSchema); + await model.syncIndexes(); + const module = await Test.createTestingModule({ providers: [ CaptchaService, - { - provide: getModelToken(Captcha.name), - useValue: captchaModel, - }, + CaptchaPolicyService, + { provide: getModelToken(Captcha.name), useValue: model }, + { provide: CaptchaRateLimitService, useValue: limiter }, ], }).compile(); - - service = module.get(CaptchaService); - await captchaModel.syncIndexes(); + service = module.get(CaptchaService); + }); + afterEach(async () => { + jest.restoreAllMocks(); + limiter.reserve.mockReset().mockResolvedValue(undefined); + await model.deleteMany({}); }); - afterAll(async () => { - await mongoConnection.close(); - await mongod.stop(); + await connection?.close(); + await mongod?.stop(); }); - afterEach(async () => { - const collections = mongoConnection.collections; - for (const key in collections) { - const collection = collections[key]; - await collection.deleteMany({}); + it.each([CaptchaKind.IMAGE, CaptchaKind.SMS, CaptchaKind.EMAIL])( + 'issues and consumes %s; reads contain only metadata', + async (kind) => { + const issued = await service.create(context(kind)); + expect(issued.key).toMatch(/^[a-f0-9]{32}$/); + expect(issued.code).toMatch(kind === CaptchaKind.IMAGE ? /^[A-Z0-9]{4}$/ : /^\d{6}$/); + const stored = await model.collection.findOne({ key: issued.key }); + expect(stored.code).toBe(issued.code); + expect(stored).not.toHaveProperty('codeHash'); + expect(await model.findOne({ key: issued.key }).lean()).not.toHaveProperty('code'); + for (const metadata of [await service.get(issued.id), ...(await service.list({}))]) { + expect(metadata).not.toHaveProperty('code'); + expect(metadata).not.toHaveProperty('codeHash'); + } + expect(await service.consume(answer(issued))).toBe(true); + expect(await service.consume(answer(issued))).toBe(false); + expect(await model.collection.findOne({ key: issued.key })).not.toHaveProperty('code'); + } + ); + + it('compares image answers without case sensitivity and refuses caller supplied OTPs', async () => { + const issued = await service.create({ ...context(CaptchaKind.IMAGE), code: 'Ab1Z' }); + expect(await service.consume(answer(issued, { code: 'aB1z' }))).toBe(true); + for (const kind of [CaptchaKind.SMS, CaptchaKind.EMAIL]) { + await expect(service.create({ ...context(kind), code: '123456' })).rejects.toMatchObject({ + status: 400, + }); } }); - describe('createCaptcha', () => { - it('should create a captcha', async () => { - const dto = mockCaptcha(); - const captcha = await service.create(dto); - expect(captcha).toBeDefined(); - expect(captcha).toMatchObject(dto); - }); + it('rejects every context mismatch without consuming the correct challenge', async () => { + const issued = await service.create(context()); + for (const change of [ + { key: 'wrong' }, + { subject: 'another-account' }, + { purpose: 'reset_password' }, + { kind: CaptchaKind.EMAIL }, + ]) { + expect(await service.consume(answer(issued, change))).toBe(false); + } + expect(await service.consume(answer(issued))).toBe(true); }); - describe('upsertCaptchaByKey', () => { - it('should upsert a captcha by key', async () => { - const dto = mockCaptcha(); - const { key, ...rest } = dto; - const captcha = await service.upsertByKey(key, rest); - - expect(captcha).toMatchObject(dto); + it('rejects expiry before physical TTL deletion', async () => { + const issued = await service.create(context()); + await model.collection.updateOne( + { key: issued.key }, + { $set: { expireAt: new Date(Date.now() - 1) } } + ); + expect(await service.consume(answer(issued))).toBe(false); + expect(await model.collection.findOne({ key: issued.key })).not.toBeNull(); + }); - const upsertDoc = { code: '234567' }; - const upserted = await service.upsertByKey(key, upsertDoc); - expect(upserted).toBeDefined(); - expect(upserted.code).toBe(upsertDoc.code); - }); + it('rotates the key, resets per-challenge attempts, and stale revocation cannot delete the replacement', async () => { + const first = await service.create(context()); + await service.consume(answer(first, { code: 'wrong' })); + const second = await service.create(context()); + expect(first.key).not.toBe(second.key); + expect(second.failedAttempts).toBe(0); + expect(await service.count({})).toBe(1); + expect(await service.consume(answer(first))).toBe(false); + await service.delete(first.key); + expect(await service.consume(answer(second))).toBe(true); + await service.delete(second.key); + await service.delete(second.key); + expect(await service.count({})).toBe(0); }); - describe('getCaptcha', () => { - it('should get a captcha', async () => { - const dto = mockCaptcha(); - const captcha = await service.create(dto); - expect(captcha).toBeDefined(); + it('accepts exactly one of 50 concurrent correct submissions', async () => { + const issued = await service.create(context()); + const results = await Promise.all( + Array.from({ length: 50 }, () => service.consume(answer(issued))) + ); + expect(results.filter(Boolean)).toHaveLength(1); + }); - const founded = await service.get(captcha.id); - expect(founded).toBeDefined(); - expect(founded).toMatchObject(captcha.toObject()); + it('counts concurrent wrong submissions only to the configured maximum', async () => { + const issued = await service.create(context()); + await Promise.all( + Array.from({ length: 50 }, () => service.consume(answer(issued, { code: 'wrong' }))) + ); + expect(await service.get(issued.id)).toMatchObject({ + failedAttempts: 5, + status: CaptchaStatus.LOCKED, }); + expect(await service.consume(answer(issued))).toBe(false); }); - describe('getCaptchaByKey', () => { - it('should get a captcha by key', async () => { - const dto = mockCaptcha(); - const captcha = await service.create(dto); - expect(captcha).toBeDefined(); - - const founded = await service.getByKey(dto.key); - expect(founded).toBeDefined(); - expect(founded).toMatchObject(captcha.toObject()); - }); + it('serializes the final wrong attempt against correct submissions', async () => { + const issued = await service.create(context(CaptchaKind.IMAGE)); + await service.consume(answer(issued, { code: 'wrong' })); + await service.consume(answer(issued, { code: 'wrong' })); + const result = await Promise.all([ + service.consume(answer(issued, { code: 'wrong' })), + ...Array.from({ length: 20 }, () => service.consume(answer(issued))), + ]); + const current = await service.get(issued.id); + expect(result.filter(Boolean).length).toBeLessThanOrEqual(1); + expect(current.failedAttempts).toBeLessThanOrEqual(3); + expect(current.status).toBe(result.some(Boolean) ? CaptchaStatus.USED : CaptchaStatus.LOCKED); }); - describe('updateCaptcha', () => { - it('should update a captcha', async () => { - const dto = mockCaptcha(); - const captcha = await service.create(dto); - expect(captcha).toBeDefined(); + it('serializes reissue against consumption and never lets an old key touch the replacement', async () => { + const first = await service.create(context()); + const [second] = await Promise.all([service.create(context()), service.consume(answer(first))]); + expect(await service.consume(answer(first))).toBe(false); + expect(await service.consume(answer(second))).toBe(true); + }); - const updateDoc = { code: '234567' }; - const updated = await service.update(captcha.id, updateDoc); - expect(updated).toBeDefined(); - expect(updated.code).toBe(updateDoc.code); + it('does not mutate the challenge when reserving quota fails', async () => { + const issued = await service.create(context()); + limiter.reserve.mockRejectedValueOnce(new Error('rate unavailable')); + await expect(service.consume(answer(issued))).rejects.toThrow(); + expect(await service.get(issued.id)).toMatchObject({ + status: CaptchaStatus.PENDING, + failedAttempts: 0, }); }); - describe('deleteCaptcha', () => { - it('should delete a captcha', async () => { - const dto = mockCaptcha(); - const captcha = await service.create(dto); - expect(captcha).toBeDefined(); - - await service.delete(captcha.id); - const found = await service.get(captcha.id); - expect(found).toEqual(null); + it('returns 503 without echoing database error content or retrying an uncertain consume', async () => { + const issued = await service.create(context()); + const exec = jest.fn().mockRejectedValue(new Error('secret-code-and-query')); + const query: any = { select: () => query, maxTimeMS: () => query, lean: () => query, exec }; + jest.spyOn(model, 'findOneAndUpdate').mockReturnValueOnce(query); + await expect(service.consume(answer(issued))).rejects.toMatchObject({ + status: 503, + response: { code: 'CAPTCHA_UNAVAILABLE' }, + }); + expect(exec).toHaveBeenCalledTimes(1); + }); + it('returns 503 on a pending consume, never retries it, and does not revive a late consumption', async () => { + const issued = await service.create(context()); + const previous = await model.collection.findOne({ key: issued.key }); + let complete: (value: any) => void; + const exec = jest.fn().mockReturnValue( + new Promise((resolve) => { + complete = resolve; + }) + ); + const query: any = { select: () => query, maxTimeMS: () => query, lean: () => query, exec }; + const spy = jest.spyOn(model, 'findOneAndUpdate').mockReturnValueOnce(query); + jest.useFakeTimers(); + try { + const pending = service.consume(answer(issued)); + const rejected = expect(pending).rejects.toMatchObject({ status: 503 }); + await jest.advanceTimersByTimeAsync(2001); + await rejected; + } finally { + jest.useRealTimers(); + } + // Simulate a write acknowledged after the HTTP deadline. No retry or rollback is allowed. + await model.collection.updateOne( + { key: issued.key }, + { $set: { status: CaptchaStatus.USED }, $unset: { code: '' } } + ); + complete(previous); + await Promise.resolve(); + expect(spy).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledTimes(1); + expect(await service.consume(answer(issued))).toBe(false); + }); + it('checks expiry at database execution time after a queued request', async () => { + const issued = await service.create(context()); + await model.collection.updateOne( + { key: issued.key }, + { $set: { expireAt: new Date(Date.now() + 200) } } + ); + const find = model.findOneAndUpdate.bind(model) as any; + jest.spyOn(model, 'findOneAndUpdate').mockImplementationOnce((...args: any[]) => { + const query = find(...args); + const exec = query.exec.bind(query); + query.exec = async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + return exec(); + }; + return query; }); + expect(await service.consume(answer(issued))).toBe(false); + expect(await service.get(issued.id)).toMatchObject({ status: CaptchaStatus.PENDING }); }); }); diff --git a/src/captcha/captcha.service.ts b/src/captcha/captcha.service.ts index 9f201ee..967d0b7 100644 --- a/src/captcha/captcha.service.ts +++ b/src/captcha/captcha.service.ts @@ -1,94 +1,220 @@ -import { Injectable } from '@nestjs/common'; +import { randomBytes, randomInt, timingSafeEqual } from 'crypto'; + +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import dayjs from 'dayjs'; import { DeleteResult } from 'mongodb'; import { Model } from 'mongoose'; -import * as config from 'src/config'; +import { ErrorCodes } from 'src/constants'; import { buildMongooseQuery } from 'src/mongo'; +import { CaptchaKind, CaptchaPolicyService } from './captcha-policy.service'; +import { CaptchaRateLimitService } from './captcha-rate-limit.service'; import { CreateCaptchaDto } from './dto/create-captcha.dto'; -import { getCaptchaByKeyDto } from './dto/get-captcha.dto'; import { ListCaptchasQuery } from './dto/list-captchas.dto'; -import { UpdateCaptchaDto } from './dto/update-captcha.dto'; -import { UpsertCaptchaDto } from './dto/upsert-captcha.dto'; -import { Captcha, CaptchaDocument } from './entities/captcha.entity'; +import { VerifyCaptchaDto } from './dto/verify-captcha.dto'; +import { Captcha, CaptchaDocument, CaptchaStatus, IssuedCaptcha } from './entities/captcha.entity'; + +const DB_TIMEOUT_MS = 2000; +const METADATA = + 'key kind purpose subject expireAt maxAttempts failedAttempts status createdAt updatedAt'; @Injectable() export class CaptchaService { - constructor(@InjectModel(Captcha.name) private readonly captchaModel: Model) {} - - create(createDto: CreateCaptchaDto) { - if (!createDto.code) { - createDto.code = this.generateCaptcha(config.captcha.codeLength); - } - const createdCaptcha = new this.captchaModel(createDto); - return createdCaptcha.save(); - } + private readonly logger = new Logger(CaptchaService.name); - count(query: ListCaptchasQuery): Promise { - const { filter } = buildMongooseQuery(query); - return this.captchaModel.countDocuments(filter).exec(); - } + constructor( + @InjectModel(Captcha.name) private readonly captchaModel: Model, + private readonly policy: CaptchaPolicyService, + private readonly limiter: CaptchaRateLimitService + ) {} - list(query: ListCaptchasQuery): Promise { - const { limit = 10, sort, offset = 0, filter } = buildMongooseQuery(query); - return this.captchaModel.find(filter).sort(sort).skip(offset).limit(limit).exec(); + private metadata(doc: any): Captcha { + return { + id: String(doc._id ?? doc.id), + key: doc.key, + kind: doc.kind, + purpose: doc.purpose, + subject: doc.subject, + expireAt: doc.expireAt, + maxAttempts: doc.maxAttempts, + failedAttempts: doc.failedAttempts, + status: doc.status, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; } - get(id: string): Promise { - return this.captchaModel.findById(id).exec(); + private async database(operation: () => Promise): Promise { + let timer: ReturnType; + try { + return await Promise.race([ + operation(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error()), DB_TIMEOUT_MS); + }), + ]); + } catch { + this.logger.error({ event: 'captcha_dependency_failed', dependency: 'mongo' }); + throw new ServiceUnavailableException({ + code: ErrorCodes.CAPTCHA_UNAVAILABLE, + message: 'Captcha service unavailable.', + }); + } finally { + clearTimeout(timer); + } } - getByKey(key: string, filter?: getCaptchaByKeyDto): Promise { - return this.captchaModel - .findOne({ - key, - ...filter, - expireAt: { $gt: dayjs().toDate() }, - }) - .exec(); + async create(dto: CreateCaptchaDto): Promise { + if ( + (dto.kind !== CaptchaKind.IMAGE && dto.code !== undefined) || + (dto.code !== undefined && !/^[A-Za-z0-9]{4,8}$/.test(dto.code)) + ) { + throw new BadRequestException({ + code: ErrorCodes.VALIDATE_FAILED, + message: 'Only image captchas accept a 4-8 character alphanumeric answer.', + }); + } + await this.limiter.reserve(dto.kind, dto.subject, 'issue'); + const policy = this.policy.policies[dto.kind]; + const alphabet = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'; + const code = + dto.kind === CaptchaKind.IMAGE + ? (dto.code?.toUpperCase() ?? + Array.from({ length: 4 }, () => alphabet[randomInt(alphabet.length)]).join('')) + : randomInt(1000000).toString().padStart(6, '0'); + const key = randomBytes(16).toString('hex'); + const scope = { kind: dto.kind, purpose: dto.purpose, subject: dto.subject }; + const payload = { + ...scope, + key, + code, + expireAt: new Date(Date.now() + policy.expiresInS * 1000), + maxAttempts: policy.maxAttempts, + failedAttempts: 0, + status: CaptchaStatus.PENDING, + }; + const doc = await this.database(async () => { + try { + return await this.captchaModel + .findOneAndUpdate(scope, { $set: payload }, { upsert: true, new: true }) + .select(METADATA) + .maxTimeMS(DB_TIMEOUT_MS) + .lean() + .exec(); + } catch (error) { + // Two initial upserts may race on the scope index. Retry only that collision. + if (error.code !== 11000 || !error.keyPattern?.subject) throw error; + return this.captchaModel + .findOneAndUpdate(scope, { $set: payload }, { new: true }) + .select(METADATA) + .maxTimeMS(DB_TIMEOUT_MS) + .lean() + .exec(); + } + }); + if (!doc) + throw new ServiceUnavailableException({ + code: ErrorCodes.CAPTCHA_UNAVAILABLE, + message: 'Captcha service unavailable.', + }); + this.logger.log({ event: 'captcha_issued', kind: dto.kind }); + return { ...this.metadata(doc), code }; } - update(id: string, updateDto: UpdateCaptchaDto): Promise { - return this.captchaModel.findByIdAndUpdate(id, updateDto, { new: true }).exec(); + async consume(dto: VerifyCaptchaDto): Promise { + await this.limiter.reserve(dto.kind, dto.subject, 'verify'); + const code = dto.kind === CaptchaKind.IMAGE ? dto.code.toUpperCase() : dto.code; + const matches = { $eq: ['$code', { $literal: code }] }; + const before = await this.database(() => + this.captchaModel + .findOneAndUpdate( + { + key: dto.key, + kind: dto.kind, + purpose: dto.purpose, + subject: dto.subject, + status: CaptchaStatus.PENDING, + $expr: { + $and: [{ $gt: ['$expireAt', '$$NOW'] }, { $lt: ['$failedAttempts', '$maxAttempts'] }], + }, + }, + [ + { + $set: { + status: { + $cond: [ + matches, + CaptchaStatus.USED, + { + $cond: [ + { $gte: [{ $add: ['$failedAttempts', 1] }, '$maxAttempts'] }, + CaptchaStatus.LOCKED, + CaptchaStatus.PENDING, + ], + }, + ], + }, + failedAttempts: { + $cond: [matches, '$failedAttempts', { $add: ['$failedAttempts', 1] }], + }, + code: { $cond: [matches, '$$REMOVE', '$code'] }, + }, + }, + ], + { new: false } + ) + .select('+code') + .maxTimeMS(DB_TIMEOUT_MS) + .lean() + .exec() + ); + const stored = Buffer.from(before?.code ?? ''); + const expected = Buffer.from(code); + const success = + stored.length > 0 && stored.length === expected.length && timingSafeEqual(stored, expected); + this.logger.log({ event: success ? 'captcha_verified' : 'captcha_invalid', kind: dto.kind }); + return success; } - delete(id: string) { - return this.captchaModel.findByIdAndDelete(id).exec(); + async count(query: ListCaptchasQuery): Promise { + const { filter } = buildMongooseQuery(query); + return this.database(() => + this.captchaModel.countDocuments(filter).maxTimeMS(DB_TIMEOUT_MS).exec() + ); } - upsertByKey(key: string, upsertDto: UpsertCaptchaDto) { - return this.captchaModel - .findOneAndUpdate( - { key }, - { - ...upsertDto, - expireAt: dayjs().add(config.captcha.expiresInS, 'second').toDate(), - }, - { upsert: true, new: true } - ) - .exec(); + async list(query: ListCaptchasQuery): Promise { + const { limit = 10, sort, offset = 0, filter } = buildMongooseQuery(query); + const docs = await this.database(() => + this.captchaModel + .find(filter) + .select(METADATA) + .sort(sort) + .skip(offset) + .limit(limit) + .maxTimeMS(DB_TIMEOUT_MS) + .lean() + .exec() + ); + return docs.map((doc) => this.metadata(doc)); } - generateCaptcha(length: number) { - const set = '0123456789'; - const setLen = set.length; - - let code = ''; - for (let i = 0; i < length; i++) { - const p = Math.floor(Math.random() * setLen); - code += set[p]; - } - return code; + async get(id: string): Promise { + if (!/^[a-fA-F0-9]{24}$/.test(id)) return null; + const doc = await this.database(() => + this.captchaModel.findById(id).select(METADATA).maxTimeMS(DB_TIMEOUT_MS).lean().exec() + ); + return doc ? this.metadata(doc) : null; } - async consume(key: string, code: string): Promise { - const found = await this.getByKey(key, { code }); - if (found) { - await this.delete(found.id); - } - return !!found; + async delete(key: string): Promise { + await this.database(() => this.captchaModel.deleteOne({ key }).maxTimeMS(DB_TIMEOUT_MS).exec()); } cleanupAllData(): Promise { diff --git a/src/captcha/dto/create-captcha.dto.ts b/src/captcha/dto/create-captcha.dto.ts index 8f8365c..6dfc174 100644 --- a/src/captcha/dto/create-captcha.dto.ts +++ b/src/captcha/dto/create-captcha.dto.ts @@ -1,22 +1,30 @@ -import { OmitType } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsDate, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; -import { CaptchaDoc } from '../entities/captcha.entity'; +import { CaptchaKind } from '../captcha-policy.service'; -export class CreateCaptchaDto extends OmitType(CaptchaDoc, ['code', 'expireAt']) { - /** - * 验证码 - */ - @IsOptional() +export class CaptchaContextDto { + @ApiProperty({ enum: CaptchaKind, enumName: 'CaptchaKind' }) + @IsEnum(CaptchaKind) + kind: CaptchaKind; + + /** 服务端确定的用途,例如 login、register、reset_password、send_sms。 */ @IsString() - code?: string; + @Matches(/^[a-z][a-z0-9_.:-]{0,63}$/) + purpose: string; + + /** 与账号查找规则一致的手机号、邮箱,或可信后端签发的匿名会话 ID。 */ + @IsString() + @IsNotEmpty() + @MaxLength(320) + subject: string; +} - /** - * 过期时间 - */ +export class CreateCaptchaDto extends CaptchaContextDto { + /** 仅 image 可指定答案;手机和邮箱验证码始终由 auth 生成。 */ + @ApiPropertyOptional({ pattern: '^[A-Za-z0-9]{4,8}$' }) @IsOptional() - @Type(() => Date) - @IsDate() - expireAt?: Date; + @IsString() + @Matches(/^[A-Za-z0-9]{4,8}$/) + code?: string; } diff --git a/src/captcha/dto/get-captcha.dto.ts b/src/captcha/dto/get-captcha.dto.ts deleted file mode 100644 index f68fe99..0000000 --- a/src/captcha/dto/get-captcha.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PickType } from '@nestjs/swagger'; - -import { UpdateCaptchaDto } from './update-captcha.dto'; - -export class getCaptchaByKeyDto extends PickType(UpdateCaptchaDto, ['code']) {} diff --git a/src/captcha/dto/list-captchas.dto.ts b/src/captcha/dto/list-captchas.dto.ts index e435f85..218ce02 100644 --- a/src/captcha/dto/list-captchas.dto.ts +++ b/src/captcha/dto/list-captchas.dto.ts @@ -1,24 +1,13 @@ -import { ApiProperty, IntersectionType, OmitType, PickType } from '@nestjs/swagger'; -import { IsOptional, IsString } from 'class-validator'; +import { IntersectionType, PartialType } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; import { QueryDto } from 'src/common'; -import { getSortParams } from 'src/lib/sort'; -import { CaptchaDoc } from '../entities/captcha.entity'; +import { CaptchaContextDto } from './create-captcha.dto'; -import { UpdateCaptchaDto } from './update-captcha.dto'; - -const sortParams = getSortParams(CaptchaDoc); - -export class ListCaptchasQuery extends IntersectionType( - PickType(UpdateCaptchaDto, ['code', 'key'] as const), - OmitType(QueryDto, ['_sort']) -) { - /** - * 排序参数 - */ +export class ListCaptchasQuery extends IntersectionType(PartialType(CaptchaContextDto), QueryDto) { @IsOptional() @IsString() - @ApiProperty({ enum: sortParams }) - _sort?: (typeof sortParams)[number]; + @MaxLength(128) + key?: string; } diff --git a/src/captcha/dto/update-captcha.dto.ts b/src/captcha/dto/update-captcha.dto.ts deleted file mode 100644 index 5819943..0000000 --- a/src/captcha/dto/update-captcha.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PartialType } from '@nestjs/swagger'; - -import { CaptchaDoc } from '../entities/captcha.entity'; - -export class UpdateCaptchaDto extends PartialType(CaptchaDoc) {} diff --git a/src/captcha/dto/upsert-captcha.dto.ts b/src/captcha/dto/upsert-captcha.dto.ts deleted file mode 100644 index 9115941..0000000 --- a/src/captcha/dto/upsert-captcha.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { OmitType, PartialType } from '@nestjs/swagger'; - -import { CreateCaptchaDto } from './create-captcha.dto'; - -export class UpsertCaptchaDto extends PartialType(OmitType(CreateCaptchaDto, ['key'])) {} diff --git a/src/captcha/dto/verify-captcha.dto.ts b/src/captcha/dto/verify-captcha.dto.ts index 74236d9..034b8f7 100644 --- a/src/captcha/dto/verify-captcha.dto.ts +++ b/src/captcha/dto/verify-captcha.dto.ts @@ -1,25 +1,20 @@ -import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsString, MaxLength } from 'class-validator'; -export class VerifyCaptchaDto { - /** - * 验证码 - */ +import { CaptchaContextDto } from './create-captcha.dto'; + +export class VerifyCaptchaDto extends CaptchaContextDto { @IsString() @IsNotEmpty() + @MaxLength(128) code: string; - /** - * 验证码 key - */ - @IsNotEmpty() @IsString() + @IsNotEmpty() + @MaxLength(128) key: string; } export class VerifyCaptchaResultDto { - /** - * 是否验证成功 - */ @IsBoolean() success: boolean; } diff --git a/src/captcha/entities/captcha.entity.ts b/src/captcha/entities/captcha.entity.ts index b013d6b..53c507e 100644 --- a/src/captcha/entities/captcha.entity.ts +++ b/src/captcha/entities/captcha.entity.ts @@ -1,45 +1,50 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { IntersectionType } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsDate, IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty, OmitType } from '@nestjs/swagger'; import { Document } from 'mongoose'; -import * as config from 'src/config'; -import { SortFields } from 'src/lib/sort'; import { helper, MongoEntity } from 'src/mongo'; -@Schema() -@SortFields(['expireAt']) -export class CaptchaDoc { - /** - * 验证码 - */ - @IsNotEmpty() - @IsString() - @Prop() - code: string; +import { CaptchaKind } from '../captcha-policy.service'; - /** - * 过期时间 - */ - @IsNotEmpty() - @Type(() => Date) - @IsDate() - @Prop({ default: () => Date.now() + config.captcha.expiresInS * 1000 }) - expireAt: Date; +export enum CaptchaStatus { + PENDING = 'pending', + USED = 'used', + LOCKED = 'locked', +} - /** - * key - */ - @IsNotEmpty() - @IsString() - @Prop() - key: string; +// Storage is deliberately independent of the response/creation DTOs. +@Schema({ bufferCommands: false }) +export class CaptchaDoc { + @Prop({ required: true }) key: string; + @Prop({ required: true, enum: CaptchaKind }) kind: CaptchaKind; + @Prop({ required: true }) purpose: string; + @Prop({ required: true }) subject: string; + @Prop({ select: false }) code?: string; + @Prop({ required: true }) expireAt: Date; + @Prop({ required: true }) maxAttempts: number; + @Prop({ required: true, default: 0 }) failedAttempts: number; + @Prop({ required: true, enum: CaptchaStatus }) status: CaptchaStatus; } export const CaptchaSchema = helper(SchemaFactory.createForClass(CaptchaDoc)); -export class Captcha extends IntersectionType(CaptchaDoc, MongoEntity) {} -export type CaptchaDocument = Captcha & Document; - CaptchaSchema.index({ key: 1 }, { unique: true }); -CaptchaSchema.index({ expireAt: 1 }, { expireAfterSeconds: 7 * 24 * 3600 }); +CaptchaSchema.index({ kind: 1, purpose: 1, subject: 1 }, { unique: true }); +CaptchaSchema.index({ expireAt: 1 }, { expireAfterSeconds: 0 }); +export type CaptchaDocument = CaptchaDoc & Document & MongoEntity; + +/** Only metadata may appear in read responses. */ +export class Captcha extends OmitType(MongoEntity, ['createdBy', 'updatedBy'] as const) { + key: string; + @ApiProperty({ enum: CaptchaKind, enumName: 'CaptchaKind' }) kind: CaptchaKind; + purpose: string; + subject: string; + expireAt: Date; + maxAttempts: number; + failedAttempts: number; + @ApiProperty({ enum: CaptchaStatus, enumName: 'CaptchaStatus' }) status: CaptchaStatus; +} + +export class IssuedCaptcha extends Captcha { + /** Returned once to the trusted issuing backend. Never forward to the frontend. */ + code: string; +} diff --git a/src/captcha/index.ts b/src/captcha/index.ts index 1207267..9e063d7 100644 --- a/src/captcha/index.ts +++ b/src/captcha/index.ts @@ -4,3 +4,4 @@ export * from './captcha.service'; export * from './dto/create-captcha.dto'; export * from './entities/captcha.entity'; export * from './dto/list-captchas.dto'; +export * from './captcha-policy.service'; diff --git a/src/common/all-exceptions.filter.ts b/src/common/all-exceptions.filter.ts index 64f0e48..22748b6 100644 --- a/src/common/all-exceptions.filter.ts +++ b/src/common/all-exceptions.filter.ts @@ -15,12 +15,16 @@ export class AllExceptionsFilter extends BaseExceptionFilter { if (code && message) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); + const retryAfter = lodash.get(cause, 'retryAfter'); + if (typeof retryAfter === 'number' && Number.isInteger(retryAfter) && retryAfter > 0) + response.setHeader('Retry-After', String(retryAfter)); response.status(exception.getStatus()).json({ status: exception.getStatus(), code, message, details, }); + return; } } diff --git a/src/common/delivery-security.spec.ts b/src/common/delivery-security.spec.ts new file mode 100644 index 0000000..07ff79c --- /dev/null +++ b/src/common/delivery-security.spec.ts @@ -0,0 +1,182 @@ +import { INestApplication, Logger, ValidationPipe } from '@nestjs/common'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test } from '@nestjs/testing'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { Connection, createConnection } from 'mongoose'; +import request from 'supertest'; + +import { EmailRecordController } from 'src/email/email-record.controller'; +import { EmailRecordService } from 'src/email/email-record.service'; +import { EmailController } from 'src/email/email.controller'; +import { EmailService } from 'src/email/email.service'; +import { EmailRecord, EmailRecordSchema } from 'src/email/entities/email-record.entity'; +import { SmsRecord, SmsRecordSchema } from 'src/sms/entities/sms-record.entity'; +import { SmsRecordController } from 'src/sms/sms-record.controller'; +import { SmsRecordService } from 'src/sms/sms-record.service'; +import { SmsController } from 'src/sms/sms.controller'; +import { SmsService } from 'src/sms/sms.service'; + +import { RouteLoggerMiddleware } from './route-logger.middleware'; + +const smsSecret = 'secret-sms-092712'; +const emailSecret = 'secret-email-title-810234'; +const bodySecret = 'secret-email-body-809123'; + +describe('Delivery metadata security', () => { + let app: INestApplication; + let mongod: MongoMemoryServer; + let connection: Connection; + const sms = { + send: jest.fn().mockResolvedValue(undefined), + resolveAccount: jest.fn().mockReturnValue('account'), + }; + const email = { sendEmail: jest.fn().mockResolvedValue(undefined) }; + const smsBody = { + phone: '13800138000', + sign: 'sign', + template: 'template', + params: { code: smsSecret }, + }; + const emailBody = { + from: 'sender@example.com', + to: 'recipient@example.com', + subject: emailSecret, + content: bodySecret, + }; + let logs: any[]; + + beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + connection = await createConnection(mongod.getUri()).asPromise(); + const fixture = await Test.createTestingModule({ + controllers: [SmsController, EmailController, SmsRecordController, EmailRecordController], + providers: [ + SmsRecordService, + EmailRecordService, + { + provide: getModelToken(SmsRecord.name), + useValue: connection.model(SmsRecord.name, SmsRecordSchema), + }, + { + provide: getModelToken(EmailRecord.name), + useValue: connection.model(EmailRecord.name, EmailRecordSchema), + }, + { provide: SmsService, useValue: sms }, + { provide: EmailService, useValue: email }, + ], + }).compile(); + app = fixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + }); + beforeEach(() => { + logs = []; + for (const level of ['log', 'error', 'warn'] as const) + jest.spyOn(Logger.prototype, level).mockImplementation((...args: any[]) => { + logs.push(args); + }); + }); + afterEach(async () => { + jest.restoreAllMocks(); + sms.send.mockClear(); + email.sendEmail.mockClear(); + await connection.collection('smsrecords').deleteMany({}); + await connection.collection('emailrecords').deleteMany({}); + }); + afterAll(async () => { + await app?.close(); + await connection?.close(); + await mongod?.stop(); + }); + + it('sends full content while persisting and returning metadata only', async () => { + await request(app.getHttpServer()).post('/sms/@sendSms').send(smsBody).expect(204); + await request(app.getHttpServer()).post('/email/@sendEmail').send(emailBody).expect(204); + expect(sms.send).toHaveBeenCalledWith(expect.objectContaining(smsBody)); + expect(email.sendEmail).toHaveBeenCalledWith(expect.objectContaining(emailBody)); + const stored = [ + await connection.collection('smsrecords').findOne({}), + await connection.collection('emailrecords').findOne({}), + ]; + const responses = [ + await request(app.getHttpServer()).get('/sms/records').expect(200), + await request(app.getHttpServer()).get('/email/records').expect(200), + ]; + const serialized = JSON.stringify({ stored, logs, responses: responses.map((r) => r.body) }); + for (const secret of [smsSecret, emailSecret, bodySecret]) + expect(serialized).not.toContain(secret); + }); + + it('prevents record CRUD from storing content and hides unmigrated historical fields', async () => { + for (const [domain, payload, collection] of [ + ['sms', { ...smsBody, params: smsSecret, status: 'sent' }, 'smsrecords'], + ['email', { ...emailBody, status: 'sent' }, 'emailrecords'], + ] as const) { + const created = await request(app.getHttpServer()) + .post(`/${domain}/records`) + .send(payload) + .expect(201); + await request(app.getHttpServer()) + .patch(`/${domain}/records/${created.body.id}`) + .send(payload) + .expect(200); + const stored = await connection.collection(collection).findOne({}); + expect(stored).not.toHaveProperty('params'); + expect(stored).not.toHaveProperty('content'); + expect(stored).not.toHaveProperty('subject'); + await connection + .collection(collection) + .updateOne( + { _id: stored._id }, + { $set: { params: smsSecret, subject: emailSecret, content: bodySecret } } + ); + const detail = await request(app.getHttpServer()) + .get(`/${domain}/records/${created.body.id}`) + .expect(200); + const list = await request(app.getHttpServer()) + .get(`/${domain}/records`) + .query({ _select: '+params +content +subject' }) + .expect(200); + for (const secret of [smsSecret, emailSecret, bodySecret]) + expect(JSON.stringify([detail.body, list.body])).not.toContain(secret); + } + }); + + it('does not expose complete provider exceptions in logs or HTTP errors', async () => { + const failure = new Error(`${smsSecret} ${emailSecret} ${bodySecret}`); + sms.send.mockRejectedValueOnce(failure); + email.sendEmail.mockRejectedValueOnce(failure); + const a = await request(app.getHttpServer()).post('/sms/@sendSms').send(smsBody).expect(500); + const b = await request(app.getHttpServer()) + .post('/email/@sendEmail') + .send(emailBody) + .expect(500); + for (const secret of [smsSecret, emailSecret, bodySecret]) + expect(JSON.stringify([a.body, b.body, logs])).not.toContain(secret); + }); + + it('does not log request credentials or query values', () => { + const middleware = new RouteLoggerMiddleware(); + let finish: () => void; + middleware.use( + { + ip: '127.0.0.1', + method: 'POST', + originalUrl: `/captchas?code=${smsSecret}`, + headers: { 'authorization': bodySecret, 'x-api-key': emailSecret }, + get: () => 'test-agent', + } as any, + { + on: (_name: string, handler: () => void) => { + finish = handler; + }, + get: () => '0', + statusCode: 200, + } as any, + () => undefined + ); + finish(); + for (const secret of [smsSecret, emailSecret, bodySecret]) + expect(JSON.stringify(logs)).not.toContain(secret); + }); +}); diff --git a/src/common/route-logger.middleware.ts b/src/common/route-logger.middleware.ts index 117f520..66b9d9e 100644 --- a/src/common/route-logger.middleware.ts +++ b/src/common/route-logger.middleware.ts @@ -1,9 +1,6 @@ import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; -import Debug from 'debug'; import { NextFunction, Request, Response } from 'express'; -const debug = Debug('app:route-logger'); - @Injectable() export class RouteLoggerMiddleware implements NestMiddleware { private logger = new Logger('HTTP'); @@ -11,10 +8,9 @@ export class RouteLoggerMiddleware implements NestMiddleware { use(request: Request, response: Response, next: NextFunction): void { const startAt = process.hrtime(); const { ip, method, originalUrl } = request; + const path = originalUrl.split('?')[0]; const userAgent = request.get('user-agent') || ''; - debug(`request header: ${JSON.stringify(request.headers)}`); - response.on('finish', () => { const userId = request['user'] ? request['user'].subject : 'anonymous'; const { statusCode } = response; @@ -22,7 +18,7 @@ export class RouteLoggerMiddleware implements NestMiddleware { const diff = process.hrtime(startAt); const responseTime = (diff[0] * 1e3 + diff[1] * 1e-6).toFixed(); this.logger.log( - `${method} ${originalUrl} ${statusCode} ${responseTime}ms ${contentLength} - ${userAgent} ${ip} ${userId}` + `${method} ${path} ${statusCode} ${responseTime}ms ${contentLength} - ${userAgent} ${ip} ${userId}` ); }); diff --git a/src/config/config.ts b/src/config/config.ts index 7d17922..823423f 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -19,8 +19,8 @@ export const auth = { }; export const captcha = { - expiresInS: toInteger(loadEnv('CAPTCHA_EXPIRES_IN_S', { default: '300' })), - codeLength: toInteger(loadEnv('CAPTCHA_CODE_LENGTH', { default: '6' })), + policyJson: loadEnv('CAPTCHA_POLICY_JSON', { default: '{}' }), + redisPrefix: loadEnv('CAPTCHA_REDIS_PREFIX', { default: 'auth:captcha' }), }; export const email = { diff --git a/src/constants.ts b/src/constants.ts index 3fafbc8..c7d062f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,6 +2,8 @@ export const ErrorCodes = { AUTH_FAILED: 'AUTH_FAILED', USER_ALREADY_EXISTS: 'USER_ALREADY_EXISTS', CAPTCHA_INVALID: 'CAPTCHA_INVALID', + CAPTCHA_RATE_LIMITED: 'CAPTCHA_RATE_LIMITED', + CAPTCHA_UNAVAILABLE: 'CAPTCHA_UNAVAILABLE', TOO_MANY_LOGIN_ATTEMPTS: 'TOO_MANY_LOGIN_ATTEMPTS', USER_NOT_FOUND: 'USER_NOT_FOUND', USER_INACTIVE: 'USER_INACTIVE', diff --git a/src/email/email-record.service.ts b/src/email/email-record.service.ts index 2780a79..b28f1fb 100644 --- a/src/email/email-record.service.ts +++ b/src/email/email-record.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; +import { pick } from 'lodash'; import { DeleteResult } from 'mongodb'; import { Model } from 'mongoose'; @@ -10,6 +11,8 @@ import { ListEmailRecordsQuery } from './dto/list-email-records.dto'; import { UpdateEmailRecordDto } from './dto/update-email-record.dto'; import { EmailRecord, EmailRecordDocument } from './entities/email-record.entity'; +const METADATA = 'from to status sentAt createdAt updatedAt'; + @Injectable() export class EmailRecordService { constructor( @@ -17,7 +20,9 @@ export class EmailRecordService { ) {} create(dto: CreateEmailRecordDto): Promise { - const createdEmailRecord = new this.emailRecordModel(dto); + const createdEmailRecord = new this.emailRecordModel( + pick(dto, ['from', 'to', 'status', 'sentAt']) + ); return createdEmailRecord.save(); } @@ -27,19 +32,28 @@ export class EmailRecordService { list(query: ListEmailRecordsQuery): Promise { const { limit = 10, sort, offset = 0, filter } = buildMongooseQuery(query); - return this.emailRecordModel.find(filter).sort(sort).skip(offset).limit(limit).exec(); + return this.emailRecordModel + .find(filter) + .select(METADATA) + .sort(sort) + .skip(offset) + .limit(limit) + .exec(); } get(id: string): Promise { - return this.emailRecordModel.findById(id).exec(); + return this.emailRecordModel.findById(id).select(METADATA).exec(); } update(id: string, dto: UpdateEmailRecordDto): Promise { - return this.emailRecordModel.findByIdAndUpdate(id, dto, { new: true }).exec(); + return this.emailRecordModel + .findByIdAndUpdate(id, pick(dto, ['from', 'to', 'status', 'sentAt']), { new: true }) + .select(METADATA) + .exec(); } delete(id: string): Promise { - return this.emailRecordModel.findByIdAndDelete(id).exec(); + return this.emailRecordModel.findByIdAndDelete(id).select(METADATA).exec(); } cleanupAllData(): Promise { diff --git a/src/email/email.controller.ts b/src/email/email.controller.ts index ac61de5..a692b67 100644 --- a/src/email/email.controller.ts +++ b/src/email/email.controller.ts @@ -4,10 +4,10 @@ import { HttpCode, HttpStatus, InternalServerErrorException, + Logger, Post, } from '@nestjs/common'; import { ApiNoContentResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import createDebug from 'debug'; import { ErrorCodes } from 'src/constants'; @@ -17,11 +17,10 @@ import { EmailRecordService } from './email-record.service'; import { EmailService } from './email.service'; import { EmailStatus } from './entities/email-record.entity'; -const debug = createDebug('auth:email'); - @ApiTags('email') @Controller('email') export class EmailController { + private readonly logger = new Logger(EmailController.name); constructor( private readonly emailRecordService: EmailRecordService, private readonly emailService: EmailService @@ -36,23 +35,22 @@ export class EmailController { @Post('@sendEmail') async sendEmail(@Body() body: SendEmailDto) { const dto: CreateEmailRecordDto = { - ...body, + from: body.from, + to: body.to, status: EmailStatus.PENDING, }; const record = await this.emailRecordService.create(dto); try { - debug('sending email to %s, subject: %s', body.to, body.subject); await this.emailService.sendEmail(body); - debug('email sent successfully to %s', body.to); - } catch (error) { - console.error('Failed to send email to %s', body.to, error); + } catch { + this.logger.error({ event: 'email_send_failed', recordId: record.id }); throw new InternalServerErrorException({ code: ErrorCodes.EMAIL_SEND_FAILED, message: 'Failed to send email', - error, }); } + this.logger.log({ event: 'email_sent', recordId: record.id }); await this.emailRecordService.update(record.id, { status: EmailStatus.SENT, sentAt: new Date(), diff --git a/src/email/entities/email-record.entity.ts b/src/email/entities/email-record.entity.ts index 7e82741..c528c4a 100644 --- a/src/email/entities/email-record.entity.ts +++ b/src/email/entities/email-record.entity.ts @@ -38,22 +38,6 @@ export class EmailRecordDoc { @Prop() to: string; - /** - * 主题 - */ - @IsNotEmpty() - @IsString() - @Prop() - subject: string; - - /** - * 内容 - */ - @IsNotEmpty() - @IsString() - @Prop() - content: string; - /** * 发送时间 */ diff --git a/src/sms/entities/sms-record.entity.ts b/src/sms/entities/sms-record.entity.ts index 2b6683a..6530db3 100644 --- a/src/sms/entities/sms-record.entity.ts +++ b/src/sms/entities/sms-record.entity.ts @@ -46,14 +46,6 @@ export class SmsRecordDoc { @Prop() template: string; - /** - * 参数 - */ - @IsOptional() - @IsString() - @Prop() - params?: string; - /** * 火山引擎消息组 ID */ diff --git a/src/sms/sms-record.service.ts b/src/sms/sms-record.service.ts index 6ec3fd0..2608ff4 100644 --- a/src/sms/sms-record.service.ts +++ b/src/sms/sms-record.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; +import { pick } from 'lodash'; import { DeleteResult } from 'mongodb'; import { Model } from 'mongoose'; @@ -10,6 +11,8 @@ import { ListSmsRecordsQuery } from './dto/list-sms-records.dto'; import { UpdateSmsRecordDto } from './dto/update-sms-record.dto'; import { SmsRecord, SmsRecordDocument } from './entities/sms-record.entity'; +const METADATA = 'phone sign template account status sentAt createdAt updatedAt'; + @Injectable() export class SmsRecordService { constructor( @@ -17,7 +20,9 @@ export class SmsRecordService { ) {} create(dto: CreateSmsRecordDto): Promise { - const createdSmsRecord = new this.smsRecordModel(dto); + const createdSmsRecord = new this.smsRecordModel( + pick(dto, ['phone', 'sign', 'template', 'account', 'status', 'sentAt']) + ); return createdSmsRecord.save(); } @@ -27,19 +32,32 @@ export class SmsRecordService { list(query: ListSmsRecordsQuery): Promise { const { limit = 10, sort, offset = 0, filter } = buildMongooseQuery(query); - return this.smsRecordModel.find(filter).sort(sort).skip(offset).limit(limit).exec(); + return this.smsRecordModel + .find(filter) + .select(METADATA) + .sort(sort) + .skip(offset) + .limit(limit) + .exec(); } get(id: string): Promise { - return this.smsRecordModel.findById(id).exec(); + return this.smsRecordModel.findById(id).select(METADATA).exec(); } update(id: string, dto: UpdateSmsRecordDto): Promise { - return this.smsRecordModel.findByIdAndUpdate(id, dto, { new: true }).exec(); + return this.smsRecordModel + .findByIdAndUpdate( + id, + pick(dto, ['phone', 'sign', 'template', 'account', 'status', 'sentAt']), + { new: true } + ) + .select(METADATA) + .exec(); } delete(id: string): Promise { - return this.smsRecordModel.findByIdAndDelete(id).exec(); + return this.smsRecordModel.findByIdAndDelete(id).select(METADATA).exec(); } cleanupAllData(): Promise { diff --git a/src/sms/sms.controller.ts b/src/sms/sms.controller.ts index cea5e09..756284d 100644 --- a/src/sms/sms.controller.ts +++ b/src/sms/sms.controller.ts @@ -4,6 +4,7 @@ import { HttpCode, HttpStatus, InternalServerErrorException, + Logger, Post, } from '@nestjs/common'; import { ApiNoContentResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; @@ -19,6 +20,7 @@ import { SmsService } from './sms.service'; @ApiTags('sms') @Controller('sms') export class SmsController { + private readonly logger = new Logger(SmsController.name); constructor( private readonly smsRecordService: SmsRecordService, private readonly smsService: SmsService @@ -38,20 +40,19 @@ export class SmsController { template: body.template, account: this.smsService.resolveAccount(body), status: SmsStatus.PENDING, - params: body.params ? JSON.stringify(body.params) : undefined, }; const record = await this.smsRecordService.create(dto); try { await this.smsService.send(body); - } catch (err) { - console.error(err); + } catch { + this.logger.error({ event: 'sms_send_failed', recordId: record.id }); throw new InternalServerErrorException({ code: ErrorCodes.SMS_SEND_FAILED, message: 'Failed to send sms', - error: err, }); } + this.logger.log({ event: 'sms_sent', recordId: record.id }); await this.smsRecordService.update(record.id, { status: SmsStatus.SENT, sentAt: new Date(), diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts index 3fe7822..d35a1ae 100644 --- a/src/sms/sms.service.ts +++ b/src/sms/sms.service.ts @@ -49,12 +49,7 @@ export class SmsService { TemplateCode: template, TemplateParam: params ? JSON.stringify(params) : undefined, }); - if (res.Code !== 'OK') { - console.error( - `Message: ${res.Message} RequestId: ${res.RequestId} BizId:${res.BizId} Code: ${res.Code}` - ); - throw new Error(res.Message); - } + if (res.Code !== 'OK') throw new Error('SMS provider rejected request.'); } private getVolcengineClient(): SMSClient { @@ -83,11 +78,6 @@ export class SmsService { PhoneNumbers: phone, TemplateParam: params ? JSON.stringify(params) : undefined, }); - if (res.ResponseMetadata.Error) { - console.error( - `Message: ${res.ResponseMetadata?.Error?.Message} RequestId: ${res.ResponseMetadata?.RquestId} Service: ${res.ResponseMetadata?.Service} Code: ${res.ResponseMetadata?.Error?.Code}` - ); - throw new Error(res.ResponseMetadata?.Error?.Message); - } + if (res.ResponseMetadata.Error) throw new Error('SMS provider rejected request.'); } } diff --git a/src/third-party/third-party.controller.ts b/src/third-party/third-party.controller.ts index f203c97..538faf6 100644 --- a/src/third-party/third-party.controller.ts +++ b/src/third-party/third-party.controller.ts @@ -10,7 +10,7 @@ import { Post, Query, } from '@nestjs/common'; -import { ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CountResult } from 'src/common'; import { ErrorCodes } from 'src/constants'; @@ -48,7 +48,7 @@ export class ThirdPartyController { * list third party */ @ApiOperation({ operationId: 'listThirdParty' }) - @ApiCreatedResponse({ + @ApiOkResponse({ description: 'The third party record list.', type: [ThirdParty], }) @@ -75,7 +75,7 @@ export class ThirdPartyController { * get third party */ @ApiOperation({ operationId: 'getThirdParty' }) - @ApiCreatedResponse({ + @ApiOkResponse({ description: 'The third party.', type: ThirdParty, }) @@ -88,7 +88,7 @@ export class ThirdPartyController { * get third party by uid */ @ApiOperation({ operationId: 'getThirdPartyByUid' }) - @ApiCreatedResponse({ + @ApiOkResponse({ description: 'The third party.', type: ThirdParty, }) @@ -104,7 +104,7 @@ export class ThirdPartyController { * get third party by tid */ @ApiOperation({ operationId: 'getThirdPartyByTid' }) - @ApiCreatedResponse({ + @ApiOkResponse({ description: 'The third party.', type: ThirdParty, }) @@ -120,7 +120,7 @@ export class ThirdPartyController { * update third party */ @ApiOperation({ operationId: 'updateThirdParty' }) - @ApiCreatedResponse({ + @ApiOkResponse({ description: 'The third party has been successfully updated.', type: ThirdParty, }) @@ -136,7 +136,7 @@ export class ThirdPartyController { * delete third party */ @ApiOperation({ operationId: 'deleteThirdParty' }) - @ApiCreatedResponse({ + @ApiOkResponse({ description: 'The third party has been successfully deleted.', type: ThirdParty, }) diff --git a/test/auth-login-logout.e2e-spec.ts b/test/auth-login-logout.e2e-spec.ts index 913f452..90c281e 100644 --- a/test/auth-login-logout.e2e-spec.ts +++ b/test/auth-login-logout.e2e-spec.ts @@ -6,7 +6,7 @@ import { Connection } from 'mongoose'; import request from 'supertest'; import { SessionWithToken } from 'src/auth'; -import { CaptchaService } from 'src/captcha'; +import { CaptchaKind, CaptchaService } from 'src/captcha'; import { auth } from 'src/config'; import { NamespaceService } from 'src/namespace'; import { UserService } from 'src/user'; @@ -170,8 +170,6 @@ describe('Web auth (e2e)', () => { const userDoc = mockUser(); const user = await userService.create(userDoc); const originalPasswordChangedAt = user.passwordChangedAt?.toISOString(); - const captchaKey = `reset-email-${user.id}`; - const captchaCode = '123456'; await request(app.getHttpServer()) .get(`/users/${user.id}`) @@ -180,17 +178,18 @@ describe('Web auth (e2e)', () => { .set('Accept', 'application/json') .expect(200); - await captchaService.create({ - key: captchaKey, - code: captchaCode, + const captcha = await captchaService.create({ + kind: CaptchaKind.EMAIL, + purpose: 'reset_password', + subject: userDoc.email, }); await request(app.getHttpServer()) .post('/auth/@resetPasswordByEmail') .send({ email: userDoc.email, - key: captchaKey, - code: captchaCode, + key: captcha.key, + code: captcha.code, password: 'Abc12345@', }) .set('Content-Type', 'application/json') diff --git a/test/captcha.e2e-spec.ts b/test/captcha.e2e-spec.ts index 91a00b5..b200191 100644 --- a/test/captcha.e2e-spec.ts +++ b/test/captcha.e2e-spec.ts @@ -1,286 +1,207 @@ -import { faker } from '@faker-js/faker'; +import { randomBytes, randomInt } from 'crypto'; + import { INestApplication, ValidationPipe } from '@nestjs/common'; -import { getConnectionToken, MongooseModule } from '@nestjs/mongoose'; -import { Test, TestingModule } from '@nestjs/testing'; -import { Connection } from 'mongoose'; +import { HttpAdapterHost } from '@nestjs/core'; +import { getConnectionToken } from '@nestjs/mongoose'; +import { Test } from '@nestjs/testing'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createConnection } from 'mongoose'; import request from 'supertest'; -import { SessionWithToken } from 'src/auth'; -import { CaptchaService, CreateCaptchaDto } from 'src/captcha'; +import { AppModule } from 'src/app.module'; +import { CaptchaKind, CaptchaService } from 'src/captcha'; +import { CaptchaRateLimitService } from 'src/captcha/captcha-rate-limit.service'; +import { AllExceptionsFilter } from 'src/common/all-exceptions.filter'; import { auth } from 'src/config'; -import { MongoErrorsInterceptor } from 'src/mongo'; - -import { AppModule } from '../src/app.module'; - -import { mongoTestBaseUrl } from './config'; +import { RedisClient } from 'src/redis/client'; +import { REDIS_CLIENT } from 'src/redis/redis.module'; +import { UserService } from 'src/user'; -const phone = '18888888888'; -const email = 'test@test.com'; +const unique = () => randomBytes(8).toString('hex'); +const phone = () => `18${randomInt(100000000, 1000000000)}`; describe('Captcha workflow (e2e)', () => { let app: INestApplication; + let mongod: MongoMemoryServer; + let users: UserService; let captchaService: CaptchaService; - - const dbName = 'captcha-e2e'; - const mongoUrl = `${mongoTestBaseUrl}/${dbName}`; + let redis: RedisClient; + let limiter: CaptchaRateLimitService; + const rateKeys = new Set(); + const post = (path: string, body: object) => + request(app.getHttpServer()).post(path).set('x-api-key', auth.apiKey).send(body); + const get = (path: string) => + request(app.getHttpServer()).get(path).set('x-api-key', auth.apiKey); + const issue = async (kind: CaptchaKind, subject: string, purpose: string, extra = {}) => { + for (const action of ['issue', 'verify'] as const) + rateKeys.add(limiter.key(kind, subject, action)); + const response = await post('/captchas', { kind, subject, purpose, ...extra }).expect(201); + return response.body; + }; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [MongooseModule.forRoot(mongoUrl), AppModule], - }).compile(); - - // prepare database before module init hooks run - const connection = moduleFixture.get(getConnectionToken()); - await connection.db.dropDatabase({ dbName }); - - app = moduleFixture.createNestApplication(); + mongod = await MongoMemoryServer.create(); + const connection = await createConnection(mongod.getUri()).asPromise(); + const fixture = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(getConnectionToken()) + .useValue(connection) + .compile(); + app = fixture.createNestApplication(); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); - app.useGlobalInterceptors(new MongoErrorsInterceptor()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(HttpAdapterHost).httpAdapter)); await app.init(); - - captchaService = moduleFixture.get(CaptchaService); - }); - + users = app.get(UserService); + captchaService = app.get(CaptchaService); + redis = app.get(REDIS_CLIENT); + limiter = app.get(CaptchaRateLimitService); + }, 30000); afterAll(async () => { - // close app - await app.close(); - }); - - // 手机验证码注册流程 - // 获取验证码 -> 通过手机号、验证码注册用户 -> 通过手机号、验证码登录 - it(`Phone register by captcha`, async () => { - const captchaDoc: CreateCaptchaDto = { - key: faker.string.alphanumeric(6), - code: faker.string.alphanumeric(6), - }; - // 获取手机验证码 - await request(app.getHttpServer()) - .post('/captchas') - .send(captchaDoc) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(201); - - // 通过手机号、验证码注册用户 - // 错误的验证码 - await request(app.getHttpServer()) - .post('/auth/@registerByPhone') - .send({ phone, code: '000000', key: '0000' }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(400); - - // 正确的验证码,但是 key 错误 - await request(app.getHttpServer()) - .post('/auth/@registerByPhone') - .send({ phone, code: captchaDoc.code, key: '0000' }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(400); - - const userResp = await request(app.getHttpServer()) - .post('/auth/@registerByPhone') - .send({ phone, ...captchaDoc }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(200); - - const user = userResp.body; - expect(user.phone).toBe(phone); - - // 此时验证码已被删除 - expect(await captchaService.getByKey(captchaDoc.key)).toBeNull(); - }); - - // 通过手机号、验证码登录 - it(`Phone login by captcha`, async () => { - const captchaDoc: CreateCaptchaDto = { - key: faker.string.alphanumeric(6), - code: faker.string.alphanumeric(6), - }; - - await request(app.getHttpServer()) - .post('/captchas') - .send({ phone, ...captchaDoc }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(201); - - // 错误的手机号 - await request(app.getHttpServer()) - .post('/auth/@loginByPhone') - .send({ phone: '13900139001', autoRegister: false, ...captchaDoc }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(401); - - // 正确登录 - const sessionResp = await request(app.getHttpServer()) - .post('/auth/@loginByPhone') - .send({ phone, ...captchaDoc }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(200); - - // 登录成功 - const session: SessionWithToken = sessionResp.body; - expect(session).toBeDefined(); + if (redis) await Promise.all([...rateKeys].map((k) => redis.del(k))); + await app?.close(); + await mongod?.stop(); }); - it(`Phone login by captcha with autoRegister`, async () => { - const autoPhone = '10123456789'; - const captchaDoc: CreateCaptchaDto = { - key: faker.string.alphanumeric(6), - code: faker.string.alphanumeric(6), - }; - - await request(app.getHttpServer()) - .post('/captchas') - .send({ phone: autoPhone, ...captchaDoc }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(201); - - const sessionResp = await request(app.getHttpServer()) - .post('/auth/@loginByPhone') - .send({ - phone: autoPhone, + it.each([CaptchaKind.SMS, CaptchaKind.EMAIL])( + 'binds %s login to the actual account and consumes exactly once', + async (kind) => { + const field = kind === CaptchaKind.SMS ? 'phone' : 'email'; + const suffix = kind === CaptchaKind.SMS ? 'Phone' : 'Email'; + const subject = kind === CaptchaKind.SMS ? phone() : `${unique()}@example.com`; + const other = kind === CaptchaKind.SMS ? phone() : `${unique()}@example.com`; + await users.create({ [field]: subject }); + await users.create({ [field]: other }); + const code = await issue(kind, subject, 'login'); + rateKeys.add(limiter.key(kind, other, 'verify')); + await post(`/auth/@loginBy${suffix}`, { + [field]: other, + key: code.key, + code: code.code, + }).expect(401); + const result = await post(`/auth/@loginBy${suffix}`, { + [field]: subject, + key: code.key, + code: code.code, + }).expect(200); + expect(result.body.token).toBeDefined(); + expect(result.body.key).toBeDefined(); + await post(`/auth/@loginBy${suffix}`, { + [field]: subject, + key: code.key, + code: code.code, + }).expect(401); + } + ); + + it.each([CaptchaKind.SMS, CaptchaKind.EMAIL])( + 'supports %s auto-registration with login purpose', + async (kind) => { + const field = kind === CaptchaKind.SMS ? 'phone' : 'email'; + const suffix = kind === CaptchaKind.SMS ? 'Phone' : 'Email'; + const subject = kind === CaptchaKind.SMS ? phone() : `${unique()}@example.com`; + const issued = await issue(kind, subject, 'login'); + await post(`/auth/@loginBy${suffix}`, { + [field]: subject, + key: issued.key, + code: issued.code, autoRegister: true, - ns: 'default', - ...captchaDoc, - }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(200); - - const session: SessionWithToken = sessionResp.body; - expect(session).toBeDefined(); - }); - - // 邮箱验证码注册流程 - it(`Email register by captcha`, async () => { - const captchaDoc: CreateCaptchaDto = { - key: faker.string.alphanumeric(6), - code: faker.string.alphanumeric(6), - }; - // 获取验证码 - await request(app.getHttpServer()) - .post('/captchas') - .send(captchaDoc) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(201); - - // 错误的验证码 - await request(app.getHttpServer()) - .post('/auth/@registerByEmail') - .send({ email, code: '000000', key: '0000' }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(400); - - // 正确的验证码,但是 key 错误 - await request(app.getHttpServer()) - .post('/auth/@registerByEmail') - .send({ email, code: captchaDoc.code, key: '0000' }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(400); - - const userResp = await request(app.getHttpServer()) - .post('/auth/@registerByEmail') - .send({ email, ...captchaDoc }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(200); - - const user = userResp.body; - expect(user.email).toBe(email); - - // 此时验证码已被删除 - expect(await captchaService.getByKey(captchaDoc.key)).toBeNull(); + }).expect(200); + } + ); + + it.each([CaptchaKind.SMS, CaptchaKind.EMAIL])( + 'supports %s register/reset and rejects wrong purpose/type', + async (kind) => { + const field = kind === CaptchaKind.SMS ? 'phone' : 'email'; + const suffix = kind === CaptchaKind.SMS ? 'Phone' : 'Email'; + const subject = kind === CaptchaKind.SMS ? phone() : `${unique()}@example.com`; + const registration = await issue(kind, subject, 'register'); + await post(`/auth/@loginBy${suffix}`, { + [field]: subject, + ...{ key: registration.key, code: registration.code }, + autoRegister: true, + }).expect(401); + await post(`/auth/@registerBy${suffix}`, { + [field]: subject, + key: registration.key, + code: registration.code, + }).expect(200); + // The Redis subject quota remains; only remove the issuance bucket here to avoid waiting 60 s in this integration fixture. + await redis.del(limiter.key(kind, subject, 'issue')); + const reset = await issue(kind, subject, 'reset_password'); + await post(`/auth/@loginBy${suffix}`, { + [field]: subject, + key: reset.key, + code: reset.code, + }).expect(401); + await post(`/auth/@resetPasswordBy${suffix}`, { + [field]: subject, + key: reset.key, + code: reset.code, + password: 'Abc12345@', + }).expect(204); + await post('/auth/@login', { login: subject, password: 'Abc12345@' }).expect(200); + } + ); + + it('consumes image verification and rejects anonymous-session mismatch', async () => { + const subject = unique(); + const image = await issue(CaptchaKind.IMAGE, subject, 'send_sms', { code: 'Ab1Z' }); + const body = { kind: 'image', subject, purpose: 'send_sms', key: image.key, code: 'aB1z' }; + rateKeys.add(limiter.key(CaptchaKind.IMAGE, `${subject}-wrong`, 'verify')); + expect( + (await post('/captchas/@verifyCaptcha', { ...body, subject: `${subject}-wrong` }).expect(200)) + .body.success + ).toBe(false); + expect((await post('/captchas/@verifyCaptcha', body).expect(200)).body.success).toBe(true); + expect((await post('/captchas/@verifyCaptcha', body).expect(200)).body.success).toBe(false); }); - // 通过邮箱、验证码登录 - it(`Email login by captcha`, async () => { - const captchaDoc: CreateCaptchaDto = { - key: faker.string.alphanumeric(6), - code: faker.string.alphanumeric(6), - }; - + it('does not accept caller-defined OTPs or legacy creation, and removes PATCH', async () => { + await post('/captchas', { key: 'legacy', code: '123456' }).expect(400); + await post('/captchas', { + kind: 'sms', + subject: phone(), + purpose: 'login', + code: '123456', + }).expect(400); + const issued = await issue(CaptchaKind.IMAGE, unique(), 'send_sms', { + key: 'ignored', + expireAt: '2099-01-01', + }); + expect(issued.key).not.toBe('ignored'); + expect(new Date(issued.expireAt).getTime()).toBeLessThan(Date.now() + 125000); await request(app.getHttpServer()) - .post('/captchas') - .send({ email, ...captchaDoc }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(201); - - // 错误的手机号 + .patch(`/captchas/${issued.id}`) + .set('x-api-key', auth.apiKey) + .send({ code: 'evil' }) + .expect(404); + for (const body of [ + (await get(`/captchas/${issued.id}`).expect(200)).body, + ...(await get('/captchas').expect(200)).body, + ]) { + expect(body).not.toHaveProperty('code'); + expect(body).not.toHaveProperty('codeHash'); + } await request(app.getHttpServer()) - .post('/auth/@loginByEmail') - .send({ email: 'aa@36node.com', autoRegister: false, ...captchaDoc }) - .set('Content-Type', 'application/json') + .delete(`/captchas/${issued.key}`) .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(401); - - // 正确登录 - const sessionResp = await request(app.getHttpServer()) - .post('/auth/@loginByEmail') - .send({ email, ...captchaDoc }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(200); - - // 登录成功 - const session: SessionWithToken = sessionResp.body; - expect(session).toBeDefined(); + .expect(204); + expect(await captchaService.get(issued.id)).toBeNull(); }); - it(`Email login by captcha with autoRegister`, async () => { - const autoEmail = `${faker.string.alphanumeric(8)}@example.com`; - const captchaDoc: CreateCaptchaDto = { - key: faker.string.alphanumeric(6), - code: faker.string.alphanumeric(6), - }; - + it('returns 429 plus Retry-After and requires the internal API key', async () => { + const subject = phone(); + await issue(CaptchaKind.SMS, subject, 'login'); + const result = await post('/captchas', { + kind: 'sms', + subject, + purpose: 'reset_password', + }).expect(429); + expect(Number(result.headers['retry-after'])).toBeGreaterThan(0); + expect(result.body.code).toBe('CAPTCHA_RATE_LIMITED'); await request(app.getHttpServer()) .post('/captchas') - .send({ email: autoEmail, ...captchaDoc }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(201); - - const sessionResp = await request(app.getHttpServer()) - .post('/auth/@loginByEmail') - .send({ - email: autoEmail, - autoRegister: true, - ns: 'default', - ...captchaDoc, - }) - .set('Content-Type', 'application/json') - .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(200); - - const session: SessionWithToken = sessionResp.body; - expect(session).toBeDefined(); + .send({ kind: 'sms', subject, purpose: 'login' }) + .expect(403); }); }); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index d0df151..ac7d3be 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -1,5 +1,9 @@ { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": "../", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", @@ -9,5 +13,8 @@ }, "transform": { "^.+\\.(t|j)s$": "ts-jest" - } + }, + "setupFiles": [ + "/test/setup-env.ts" + ] } diff --git a/test/setup-env.ts b/test/setup-env.ts new file mode 100644 index 0000000..c59f5fe --- /dev/null +++ b/test/setup-env.ts @@ -0,0 +1,4 @@ +process.env.CAPTCHA_REDIS_PREFIX = `auth:test:captcha:${process.pid}`; +process.env.CAPTCHA_POLICY_JSON = '{}'; +process.env.SMS_PROVIDER = 'blackhole'; +process.env.EMAIL_TRANSPORTER = 'blackhole'; From 9fb41019fdaaca84e447073d4d0b81c1e2591e8a Mon Sep 17 00:00:00 2001 From: zzswang Date: Sat, 12 Sep 2026 22:04:30 +0800 Subject: [PATCH 2/2] feat(auth): unify code login and registration with optional password --- docs/captcha-security.md | 44 +++++- openapi.json | 202 +++++++++++++++++++++++++- src/auth/auth.controller.ts | 175 +++++++++++----------- src/auth/dto/code-auth.dto.ts | 59 ++++++++ src/auth/dto/login.dto.ts | 8 + src/auth/dto/register.dto.ts | 18 ++- test/captcha.e2e-spec.ts | 265 +++++++++++++++++++++++++++++++++- 7 files changed, 676 insertions(+), 95 deletions(-) create mode 100644 src/auth/dto/code-auth.dto.ts diff --git a/docs/captcha-security.md b/docs/captcha-security.md index 6e0e7fc..e261940 100644 --- a/docs/captcha-security.md +++ b/docs/captcha-security.md @@ -87,8 +87,8 @@ const frontendResponse = { key: issued.key, expireAt: issued.expireAt }; // 用户输入短信答案后,由业务后端转调;保持 code 为字符串。 async function login(phone, key, code) { - return authRequest('/auth/@loginByPhone', { - phone, key, code, autoRegister: true, + return authRequest('/auth/@loginByCode', { + channel: 'sms', account: phone, key, code, autoRegister: true, }); } ``` @@ -97,16 +97,50 @@ async function login(phone, key, code) { 登录响应包含访问令牌 `token、tokenExpireAt` 和会话信息;会话的 `key` 可作为刷新令牌,区别于验证码 key。 +## 统一验证码登录与注册 + +手机号和邮箱使用同一组接口,通过 `channel` 区分渠道。两者都要求 `channel、account、key、code`:`channel` 只允许 `sms / email`;`account` 分别使用实际手机号或邮箱,必须与签发验证码的 `subject` 完全一致,不进行额外格式或大小写归一化。 + +| API | 可选字段 | 成功响应 | 账号已存在时 | +| --- | --- | --- | --- | +| `POST /auth/@loginByCode` | `autoRegister`;自动注册字段见下文 | `200 SessionWithToken`,包含会话和 token | 登录已有账号,不覆盖注册信息或密码 | +| `POST /auth/@registerByCode` | `password`;注册字段见下文 | `200 User`,不创建会话 | `409 USER_ALREADY_EXISTS`;并发创建冲突沿用现有唯一索引错误映射 | + +注册公共字段为 `ns、inviter、labels、registerIp、registerRegion、type`。登录自动注册还支持 `active、roles`,不支持设置密码。`autoRegister` 默认关闭,账号不存在时登录返回 `401 AUTH_FAILED`;开启后先创建账号再登录,禁用用户返回 `403 USER_INACTIVE`。 + +`registerByCode` 不传 `password` 时创建无密码账号。提供密码时必须是非空字符串,至少 8 位且包含大写字母、小写字母、数字、特殊字符中的至少三类;`null`、空字符串或其他非法密码返回 `400 VALIDATION_FAILED`,此时不会消费验证码。密码以现有哈希方式保存,同时记录 `passwordChangedAt`;响应不返回密码或哈希。 + +例如在业务后端通过邮箱验证码注册并设置密码: + +```js +const account = 'user@example.com'; +const issued = await authRequest('/captchas', { + kind: 'email', purpose: 'register', subject: account, +}); +// 通过通用邮件接口发送 issued.code;前端仅接收 key、expireAt。 +// 用户提交答案后执行,submittedCode 和 submittedPassword 来自该注册请求。 +const user = await authRequest('/auth/@registerByCode', { + channel: 'email', account, key: issued.key, + code: submittedCode, password: submittedPassword, +}); +``` + +注册成功后,可通过现有 `/auth/@login` 提交 `{login: account, password}` 登录;也可另行签发 `purpose=login` 的验证码,通过 `@loginByCode` 登录。注册验证码不能用于登录,注册本身不返回 token。 + +旧的 `@loginByPhone、@loginByEmail、@registerByPhone、@registerByEmail` 继续支持原有请求字段、响应和错误约定,不设移除时间。旧注册接口不新增密码字段,需要设置密码时使用 `@registerByCode`。新旧接口共享验证码的一次性消费和限流,同一个验证码不能分别在两个接口使用。 + +新增接口不需要数据库迁移:先部署支持新旧接口的服务端,再按需升级调用方与 SDK。下文的安全切换与迁移步骤仅适用于从旧验证码存储方案升级,不是本次新增接口的要求。 + ## 图形、邮箱与其他认证流程 图形验证和对应操作在同一次业务请求中完成。例如,后端签发 `{kind:"image", purpose:"send_sms", subject:匿名会话ID}`,使用创建响应中的答案绘制图片,仅将图片及 key 返回前端。用户提交图形答案并请求短信时,后端从可信会话上下文取得 `subject`,调用 `@verifyCaptcha`;成功后才执行短信签发与发送。取消“先预校验、后用原码提交操作”的调用流程。 -邮箱登录使用 `kind=email、purpose=login、subject=实际邮箱`,通过通用邮件接口发送后,调用 `/auth/@loginByEmail`,提交 `email、key、code`。 +邮箱登录使用 `kind=email、purpose=login、subject=实际邮箱`,通过通用邮件接口发送后,调用 `/auth/@loginByCode`,提交 `channel=email、account=实际邮箱、key、code`。 | 认证接口 | auth 固定的验证码上下文 | | --- | --- | -| `@loginByPhone` / `@loginByEmail` | `sms/email + login + 实际账号`;`autoRegister` 仍使用 `login` | -| `@registerByPhone` / `@registerByEmail` | `sms/email + register + 实际账号` | +| `@loginByCode` / `@loginByPhone` / `@loginByEmail` | `sms/email + login + 实际账号`;`autoRegister` 仍使用 `login` | +| `@registerByCode` / `@registerByPhone` / `@registerByEmail` | `sms/email + register + 实际账号` | | `@resetPasswordByPhone` / `@resetPasswordByEmail` | `sms/email + reset_password + 实际账号` | auth 自行确定认证用途及验证对象,不使用客户端传入的场景值。认证接口先校验并消费验证码,再查询、创建或修改账号。成功消费后,即使账号不存在、业务操作失败或客户端没收到响应,也不恢复验证码;用户需重新获取。 diff --git a/openapi.json b/openapi.json index 7f86df1..d7f45ff 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "b5f4359ab556c2605eeff8f479fe2cc7321ca32509a5d03fd01dcc921e818a79", + "hash": "f32a395936147925b611e777dcd83498bc12c4747511da95fcc02fc6d96be340", "openapi": "3.0.0", "paths": { "/hello": { @@ -293,6 +293,38 @@ ] } }, + "/auth/@loginByCode": { + "post": { + "operationId": "loginByCode", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginByCodeDto" + } + } + } + }, + "responses": { + "200": { + "description": "The session with token has been successfully created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionWithToken" + } + } + } + } + }, + "summary": "login with an SMS or email code", + "tags": [ + "auth" + ] + } + }, "/auth/@loginByPhoneQuickAuth": { "post": { "operationId": "loginByPhoneQuickAuth", @@ -446,6 +478,38 @@ ] } }, + "/auth/@registerByCode": { + "post": { + "operationId": "registerByCode", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterByCodeDto" + } + } + } + }, + "responses": { + "200": { + "description": "The user just created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "summary": "register with an SMS or email code and an optional password", + "tags": [ + "auth" + ] + } + }, "/auth/@signToken": { "post": { "operationId": "signToken", @@ -6065,6 +6129,79 @@ "code" ] }, + "LoginByCodeDto": { + "type": "object", + "properties": { + "channel": { + "description": "验证码渠道:sms 或 email", + "enum": [ + "sms", + "email" + ], + "type": "string" + }, + "account": { + "type": "string", + "description": "实际手机号或邮箱,与签发验证码时的 subject 完全一致" + }, + "key": { + "type": "string", + "description": "验证码 key" + }, + "code": { + "type": "string", + "description": "验证码答案" + }, + "autoRegister": { + "type": "boolean", + "description": "不存在用户时是否自动注册" + }, + "active": { + "type": "boolean", + "description": "自动注册时是否启用(不传则使用服务端默认)" + }, + "roles": { + "description": "自动注册时的角色(不传则使用服务端默认)", + "type": "array", + "items": { + "type": "string" + } + }, + "ns": { + "type": "string", + "description": "命名空间" + }, + "inviter": { + "type": "string", + "description": "邀请人" + }, + "labels": { + "description": "标签", + "type": "array", + "items": { + "type": "string" + } + }, + "registerIp": { + "type": "string", + "description": "注册 IP" + }, + "registerRegion": { + "type": "string", + "description": "注册地区,存地区编号" + }, + "type": { + "type": "string", + "description": "类型, 登录端" + } + }, + "required": [ + "channel", + "account", + "key", + "code" + ] + }, "LoginByPhoneQuickAuthDto": { "type": "object", "properties": { @@ -6461,6 +6598,69 @@ "code" ] }, + "RegisterByCodeDto": { + "type": "object", + "properties": { + "channel": { + "description": "验证码渠道:sms 或 email", + "enum": [ + "sms", + "email" + ], + "type": "string" + }, + "password": { + "type": "string", + "description": "可选密码;提供时必须满足现有密码强度要求", + "writeOnly": true + }, + "account": { + "type": "string", + "description": "实际手机号或邮箱,与签发验证码时的 subject 完全一致" + }, + "key": { + "type": "string", + "description": "验证码 key" + }, + "code": { + "type": "string", + "description": "验证码答案" + }, + "ns": { + "type": "string", + "description": "命名空间" + }, + "inviter": { + "type": "string", + "description": "邀请人" + }, + "labels": { + "description": "标签", + "type": "array", + "items": { + "type": "string" + } + }, + "registerIp": { + "type": "string", + "description": "注册 IP" + }, + "registerRegion": { + "type": "string", + "description": "注册地区,存地区编号" + }, + "type": { + "type": "string", + "description": "类型, 登录端" + } + }, + "required": [ + "channel", + "account", + "key", + "code" + ] + }, "SignTokenDto": { "type": "object", "properties": { diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index bbd7482..fd504c6 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -33,8 +33,10 @@ import { User, UserDocument, UserService } from 'src/user'; import { AuthService } from './auth.service'; import { GetAuthorizerQuery } from './dto/authorize-query.dto'; +import { CodeAuthChannel } from './dto/code-auth.dto'; import { GithubDto } from './dto/github.dto'; import { + LoginByCodeDto, LoginByEmailDto, LoginByPhoneDto, LoginByPhoneQuickAuthDto, @@ -43,7 +45,12 @@ import { } from './dto/login.dto'; import { OAuthDto } from './dto/oauth.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; -import { RegisterByEmailDto, RegisterbyPhoneDto, RegisterDto } from './dto/register.dto'; +import { + RegisterByCodeDto, + RegisterByEmailDto, + RegisterbyPhoneDto, + RegisterDto, +} from './dto/register.dto'; import { ResetPasswordByEmailDto, ResetPasswordByPhoneDto } from './dto/reset-password.dto'; import { SignTokenDto } from './dto/sign-token.dto'; import { Authorizer } from './entities/authorizer.entity'; @@ -240,46 +247,7 @@ export class AuthController { }) @Post('@loginByEmail') async loginByEmail(@Body() dto: LoginByEmailDto): Promise { - if ( - !(await this.captchaService.consume({ - key: dto.key, - code: dto.code, - kind: CaptchaKind.EMAIL, - purpose: 'login', - subject: dto.email, - })) - ) { - throw new UnauthorizedException({ - code: ErrorCodes.AUTH_FAILED, - message: `email or captcha code wrong`, - }); - } - - let user = await this.userService.findByEmail(dto.email); - - if (!user && !dto.autoRegister) { - throw new UnauthorizedException({ - code: ErrorCodes.AUTH_FAILED, - message: `email or captcha code wrong`, - }); - } - - if (!user) { - user = await this.userService.upsertByEmail(dto.email, { - email: dto.email, - ns: dto.ns, - inviter: dto.inviter, - labels: dto.labels, - registerIp: dto.registerIp, - registerRegion: dto.registerRegion, - type: dto.type, - ...(dto.active !== undefined && { active: dto.active }), - ...(dto.roles !== undefined && { roles: dto.roles }), - }); - } - - checkUserActive(user); - return this.authService.login(user); + return this.loginWithCode({ ...dto, channel: CodeAuthChannel.EMAIL, account: dto.email }); } /** @@ -293,33 +261,51 @@ export class AuthController { }) @Post('@loginByPhone') async loginByPhone(@Body() dto: LoginByPhoneDto): Promise { + return this.loginWithCode({ ...dto, channel: CodeAuthChannel.SMS, account: dto.phone }); + } + + /** login with an SMS or email code */ + @ApiOperation({ operationId: 'loginByCode' }) + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ + description: 'The session with token has been successfully created.', + type: SessionWithToken, + }) + @Post('@loginByCode') + async loginByCode(@Body() dto: LoginByCodeDto): Promise { + return this.loginWithCode(dto); + } + + private async loginWithCode(dto: LoginByCodeDto): Promise { + const isSms = dto.channel === CodeAuthChannel.SMS; + const field = isSms ? 'phone' : 'email'; + const authFailed = () => + new UnauthorizedException({ + code: ErrorCodes.AUTH_FAILED, + message: `${field} or captcha code wrong`, + }); + if ( !(await this.captchaService.consume({ key: dto.key, code: dto.code, - kind: CaptchaKind.SMS, + kind: isSms ? CaptchaKind.SMS : CaptchaKind.EMAIL, purpose: 'login', - subject: dto.phone, + subject: dto.account, })) ) { - throw new UnauthorizedException({ - code: ErrorCodes.AUTH_FAILED, - message: `phone or captcha code wrong`, - }); + throw authFailed(); } - let user = await this.userService.findByPhone(dto.phone); + let user = isSms + ? await this.userService.findByPhone(dto.account) + : await this.userService.findByEmail(dto.account); - if (!user && !dto.autoRegister) { - throw new UnauthorizedException({ - code: ErrorCodes.AUTH_FAILED, - message: `phone or captcha code wrong`, - }); - } + if (!user && !dto.autoRegister) throw authFailed(); if (!user) { - user = await this.userService.upsertByPhone(dto.phone, { - phone: dto.phone, + const registration = { + [field]: dto.account, ns: dto.ns, inviter: dto.inviter, labels: dto.labels, @@ -328,7 +314,10 @@ export class AuthController { type: dto.type, ...(dto.active !== undefined && { active: dto.active }), ...(dto.roles !== undefined && { roles: dto.roles }), - }); + }; + user = isSms + ? await this.userService.upsertByPhone(dto.account, registration) + : await this.userService.upsertByEmail(dto.account, registration); } checkUserActive(user); @@ -439,31 +428,11 @@ export class AuthController { }) @Post('@registerByPhone') async registerByPhone(@Body() dto: RegisterbyPhoneDto): Promise { - if ( - !(await this.captchaService.consume({ - key: dto.key, - code: dto.code, - kind: CaptchaKind.SMS, - purpose: 'register', - subject: dto.phone, - })) - ) { - throw new BadRequestException({ - code: ErrorCodes.CAPTCHA_INVALID, - message: 'captcha invalid.', - }); - } - - const user = await this.userService.findByPhone(dto.phone); - if (user) { - throw new ConflictException({ - code: ErrorCodes.USER_ALREADY_EXISTS, - message: `phone ${dto.phone} already exists.`, - }); - } - - return this.userService.create({ - phone: dto.phone, + return this.registerWithCode({ + channel: CodeAuthChannel.SMS, + account: dto.phone, + key: dto.key, + code: dto.code, ns: dto.ns, inviter: dto.inviter, labels: dto.labels, @@ -484,13 +453,42 @@ export class AuthController { }) @Post('@registerByEmail') async registerByEmail(@Body() dto: RegisterByEmailDto): Promise { + return this.registerWithCode({ + channel: CodeAuthChannel.EMAIL, + account: dto.email, + key: dto.key, + code: dto.code, + ns: dto.ns, + inviter: dto.inviter, + labels: dto.labels, + registerIp: dto.registerIp, + registerRegion: dto.registerRegion, + type: dto.type, + }); + } + + /** register with an SMS or email code and an optional password */ + @ApiOperation({ operationId: 'registerByCode' }) + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ + description: 'The user just created.', + type: User, + }) + @Post('@registerByCode') + async registerByCode(@Body() dto: RegisterByCodeDto): Promise { + return this.registerWithCode(dto); + } + + private async registerWithCode(dto: RegisterByCodeDto): Promise { + const isSms = dto.channel === CodeAuthChannel.SMS; + const field = isSms ? 'phone' : 'email'; if ( !(await this.captchaService.consume({ key: dto.key, code: dto.code, - kind: CaptchaKind.EMAIL, + kind: isSms ? CaptchaKind.SMS : CaptchaKind.EMAIL, purpose: 'register', - subject: dto.email, + subject: dto.account, })) ) { throw new BadRequestException({ @@ -499,22 +497,25 @@ export class AuthController { }); } - const user = await this.userService.findByEmail(dto.email); + const user = isSms + ? await this.userService.findByPhone(dto.account) + : await this.userService.findByEmail(dto.account); if (user) { throw new ConflictException({ code: ErrorCodes.USER_ALREADY_EXISTS, - message: `email ${dto.email} already exists.`, + message: `${field} ${dto.account} already exists.`, }); } return this.userService.create({ - email: dto.email, + [field]: dto.account, ns: dto.ns, inviter: dto.inviter, labels: dto.labels, registerIp: dto.registerIp, registerRegion: dto.registerRegion, type: dto.type, + ...(dto.password !== undefined && { password: dto.password }), }); } diff --git a/src/auth/dto/code-auth.dto.ts b/src/auth/dto/code-auth.dto.ts new file mode 100644 index 0000000..1e783c8 --- /dev/null +++ b/src/auth/dto/code-auth.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + isEmail, + IsEnum, + IsNotEmpty, + IsString, + registerDecorator, + ValidationArguments, +} from 'class-validator'; + +import { isPhone } from 'src/common/validate'; + +export enum CodeAuthChannel { + SMS = 'sms', + EMAIL = 'email', +} + +function IsCodeAccount() { + return (object: object, propertyName: string) => { + registerDecorator({ + name: 'isCodeAccount', + target: object.constructor, + propertyName, + validator: { + validate(value: unknown, args: ValidationArguments) { + const { channel } = args.object as CodeAuthDto; + if (channel === CodeAuthChannel.SMS) return isPhone(value); + return channel === CodeAuthChannel.EMAIL && typeof value === 'string' && isEmail(value); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid phone number for sms or email address for email`; + }, + }, + }); + }; +} + +export class CodeAuthDto { + /** 验证码渠道:sms 或 email */ + @ApiProperty({ enum: CodeAuthChannel }) + @IsEnum(CodeAuthChannel) + channel: CodeAuthChannel; + + /** 实际手机号或邮箱,与签发验证码时的 subject 完全一致 */ + @IsNotEmpty() + @IsString() + @IsCodeAccount() + account: string; + + /** 验证码 key */ + @IsNotEmpty() + @IsString() + key: string; + + /** 验证码答案 */ + @IsNotEmpty() + @IsString() + code: string; +} diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index a876ba5..d384685 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,7 +1,10 @@ +import { IntersectionType, OmitType } from '@nestjs/swagger'; import { IsBoolean, IsEmail, IsIP, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsNs, IsPhone } from 'src/common/validate'; +import { CodeAuthDto } from './code-auth.dto'; + export class LoginDto { /** * 可以是 username/phone/Email @@ -271,3 +274,8 @@ export class LogoutDto { @IsString() sid: string; } + +export class LoginByCodeDto extends IntersectionType( + CodeAuthDto, + OmitType(LoginByPhoneDto, ['phone', 'key', 'code'] as const) +) {} diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts index eedf4fd..9546cae 100644 --- a/src/auth/dto/register.dto.ts +++ b/src/auth/dto/register.dto.ts @@ -1,7 +1,10 @@ -import { IsEmail, IsIP, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ApiPropertyOptional, IntersectionType, OmitType } from '@nestjs/swagger'; +import { IsEmail, IsIP, IsNotEmpty, IsOptional, IsString, ValidateIf } from 'class-validator'; import { IsNs, IsPassword, IsPhone, IsUsername } from 'src/common/validate'; +import { CodeAuthDto } from './code-auth.dto'; + export class RegisterDto { /** * 用户名 @@ -189,3 +192,16 @@ export class RegisterByEmailDto { @IsString() type?: string; } + +export class RegisterByCodeDto extends IntersectionType( + CodeAuthDto, + OmitType(RegisterbyPhoneDto, ['phone', 'key', 'code'] as const) +) { + /** 可选密码;提供时必须满足现有密码强度要求 */ + @ApiPropertyOptional({ type: String, writeOnly: true }) + @ValidateIf((_object, value) => value !== undefined) + @IsNotEmpty() + @IsString() + @IsPassword() + password?: string; +} diff --git a/test/captcha.e2e-spec.ts b/test/captcha.e2e-spec.ts index b200191..1a23d85 100644 --- a/test/captcha.e2e-spec.ts +++ b/test/captcha.e2e-spec.ts @@ -12,6 +12,7 @@ import { AppModule } from 'src/app.module'; import { CaptchaKind, CaptchaService } from 'src/captcha'; import { CaptchaRateLimitService } from 'src/captcha/captcha-rate-limit.service'; import { AllExceptionsFilter } from 'src/common/all-exceptions.filter'; +import { exceptionFactory } from 'src/common/exception-factory'; import { auth } from 'src/config'; import { RedisClient } from 'src/redis/client'; import { REDIS_CLIENT } from 'src/redis/redis.module'; @@ -47,7 +48,7 @@ describe('Captcha workflow (e2e)', () => { .useValue(connection) .compile(); app = fixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, exceptionFactory })); app.useGlobalFilters(new AllExceptionsFilter(app.get(HttpAdapterHost).httpAdapter)); await app.init(); users = app.get(UserService); @@ -143,6 +144,268 @@ describe('Captcha workflow (e2e)', () => { } ); + describe.each([CaptchaKind.SMS, CaptchaKind.EMAIL])('%s code authentication', (kind) => { + const field = kind === CaptchaKind.SMS ? 'phone' : 'email'; + const suffix = kind === CaptchaKind.SMS ? 'Phone' : 'Email'; + const account = () => (kind === CaptchaKind.SMS ? phone() : `${unique()}@example.com`); + const credentials = ( + subject: string, + issued: { key: string; code: string }, + legacy = false + ) => ({ + ...(legacy ? { [field]: subject } : { channel: kind, account: subject }), + key: issued.key, + code: issued.code, + }); + const route = (operation: 'login' | 'register', legacy = false) => + `/auth/@${operation}By${legacy ? suffix : 'Code'}`; + const registrationInfo = { + ns: 'code-test', + inviter: 'inviter-test', + labels: ['code-registration'], + registerIp: '127.0.0.1', + registerRegion: '310000', + type: 'app', + }; + + it.each([false, true])( + 'registers without a password and preserves fields (legacy=%s)', + async (legacy) => { + const subject = account(); + const issued = await issue(kind, subject, 'register'); + const response = await post(route('register', legacy), { + ...credentials(subject, issued, legacy), + ...registrationInfo, + // The old registration contract must continue ignoring an extra password. + ...(legacy && { password: 'Abc12345@' }), + }).expect(200); + expect(response.body).toMatchObject({ [field]: subject, ...registrationInfo }); + expect(response.body).not.toHaveProperty('password'); + expect(response.body).not.toHaveProperty('token'); + const user = await users.get(response.body.id); + expect(user.password).toBeUndefined(); + expect(user.passwordChangedAt).toBeUndefined(); + const reused = await post( + route('register', !legacy), + credentials(subject, issued, !legacy) + ).expect(400); + expect(reused.body.code).toBe('CAPTCHA_INVALID'); + } + ); + + it('registers with a hashed password, supports both login methods, and never overwrites an existing user', async () => { + const subject = account(); + const password = 'Abc12345@'; + const issued = await issue(kind, subject, 'register'); + const response = await post(route('register'), { + ...credentials(subject, issued), + ...registrationInfo, + password, + }).expect(200); + expect(response.body).toMatchObject({ [field]: subject, ...registrationInfo }); + expect(response.body).not.toHaveProperty('password'); + expect(response.body).not.toHaveProperty('token'); + const user = await users.get(response.body.id); + expect(user.password).not.toBe(password); + expect(users.checkPassword(user.password, password)).toBe(true); + expect(user.passwordChangedAt).toBeInstanceOf(Date); + await post('/auth/@login', { login: subject, password }).expect(200); + rateKeys.add(`loginLock:${subject}`); + await post('/auth/@login', { login: subject, password: 'Wrong123@' }).expect(401); + + await redis.del(limiter.key(kind, subject, 'issue')); + const loginCode = await issue(kind, subject, 'login'); + await post(route('login'), credentials(subject, loginCode)).expect(200); + await redis.del(limiter.key(kind, subject, 'issue')); + const duplicate = await issue(kind, subject, 'register'); + const conflict = await post(route('register'), { + ...credentials(subject, duplicate), + password: 'Changed123@', + }).expect(409); + expect(conflict.body).toMatchObject({ + code: 'USER_ALREADY_EXISTS', + message: `${field} ${subject} already exists.`, + }); + await post(route('register'), credentials(subject, duplicate)).expect(400); + const unchanged = await users.get(user.id); + expect(unchanged.password).toBe(user.password); + expect(unchanged.passwordChangedAt).toEqual(user.passwordChangedAt); + }); + + it.each([false, true])( + 'shares login code consumption and auto-registration fields (legacy=%s)', + async (legacy) => { + const subject = account(); + const issued = await issue(kind, subject, 'login'); + const response = await post(route('login', legacy), { + ...credentials(subject, issued, legacy), + ...registrationInfo, + autoRegister: true, + active: true, + roles: ['code-test-role'], + password: 'Ignored123@', + }).expect(200); + expect(response.body.token).toBeDefined(); + expect(response.body.key).toBeDefined(); + const user = await users.get(response.body.subject); + expect(user).toMatchObject({ + [field]: subject, + ...registrationInfo, + active: true, + roles: ['code-test-role'], + }); + expect(user.password).toBeUndefined(); + const reused = await post( + route('login', !legacy), + credentials(subject, issued, !legacy) + ).expect(401); + expect(reused.body).toMatchObject({ + code: 'AUTH_FAILED', + message: `${field} or captcha code wrong`, + }); + await redis.del(limiter.key(kind, subject, 'issue')); + const next = await issue(kind, subject, 'login'); + const loggedIn = await post(route('login', !legacy), { + ...credentials(subject, next, !legacy), + autoRegister: true, + roles: ['must-not-replace-existing-roles'], + }).expect(200); + expect(loggedIn.body.subject).toBe(user.id); + expect((await users.get(user.id)).roles).toEqual(['code-test-role']); + } + ); + + it.each([false, true])( + 'preserves missing-user, inactive-user and duplicate errors (legacy=%s)', + async (legacy) => { + const subject = account(); + const missing = await issue(kind, subject, 'login'); + const failure = await post( + route('login', legacy), + credentials(subject, missing, legacy) + ).expect(401); + expect(failure.body).toMatchObject({ + code: 'AUTH_FAILED', + message: `${field} or captcha code wrong`, + }); + // A valid code is consumed even when the user does not exist. + await post(route('login', !legacy), { + ...credentials(subject, missing, !legacy), + autoRegister: true, + }).expect(401); + await redis.del(limiter.key(kind, subject, 'issue')); + const inactive = await issue(kind, subject, 'login'); + const blocked = await post(route('login', legacy), { + ...credentials(subject, inactive, legacy), + autoRegister: true, + active: false, + }).expect(403); + expect(blocked.body.code).toBe('USER_INACTIVE'); + const user = + kind === CaptchaKind.SMS + ? await users.findByPhone(subject) + : await users.findByEmail(subject); + expect(user.active).toBe(false); + await users.update(user.id, { active: true }); + await post(route('login', !legacy), credentials(subject, inactive, !legacy)).expect(401); + await redis.del(limiter.key(kind, subject, 'issue')); + const duplicate = await issue(kind, subject, 'register'); + const conflict = await post( + route('register', legacy), + credentials(subject, duplicate, legacy) + ).expect(409); + expect(conflict.body).toMatchObject({ + code: 'USER_ALREADY_EXISTS', + message: `${field} ${subject} already exists.`, + }); + await post(route('register', !legacy), credentials(subject, duplicate, !legacy)).expect( + 400 + ); + } + ); + + it('binds the new endpoints to the account, channel and purpose', async () => { + const subject = account(); + const other = account(); + const issued = await issue(kind, subject, 'register'); + rateKeys.add(limiter.key(kind, other, 'verify')); + await post(route('register'), credentials(other, issued)).expect(400); + await post(route('login'), { + ...credentials(subject, issued), + autoRegister: true, + purpose: 'register', + }).expect(401); + const otherKind = kind === CaptchaKind.SMS ? CaptchaKind.EMAIL : CaptchaKind.SMS; + const otherSubject = otherKind === CaptchaKind.SMS ? phone() : `${unique()}@example.com`; + rateKeys.add(limiter.key(otherKind, otherSubject, 'verify')); + await post(route('register'), { + ...credentials(subject, issued), + channel: otherKind, + account: otherSubject, + }).expect(400); + await post(route('register'), credentials(subject, issued)).expect(200); + await redis.del(limiter.key(kind, subject, 'issue')); + const login = await issue(kind, subject, 'login'); + await post(route('register'), { ...credentials(subject, login), purpose: 'login' }).expect( + 400 + ); + await post(route('login'), credentials(other, login)).expect(401); + await post(route('login'), { + ...credentials(subject, login), + channel: otherKind, + account: otherSubject, + }).expect(401); + await post(route('login'), credentials(subject, login)).expect(200); + }); + + it('rejects invalid passwords before consuming the registration code', async () => { + const subject = account(); + const issued = await issue(kind, subject, 'register'); + for (const password of [null, '', 'weak', 12345678, {}, []]) { + const result = await post(route('register'), { + ...credentials(subject, issued), + password, + }).expect(400); + expect(result.body.code).toBe('VALIDATION_FAILED'); + expect(result.body.details).toEqual( + expect.arrayContaining([expect.objectContaining({ field: 'password' })]) + ); + } + await post(route('register'), { + ...credentials(subject, issued), + password: 'Abc12345@', + }).expect(200); + }); + + it.each(['login', 'register'] as const)( + 'validates %s credentials and requires an API key', + async (operation) => { + const subject = account(); + const issued = await issue(kind, subject, operation); + const body = credentials(subject, issued); + const invalid: object[] = [ + { ...body, channel: 'image' }, + { ...body, channel: null }, + { ...body, account: kind === CaptchaKind.SMS ? 'user@example.com' : '13800138000' }, + { ...body, account: null }, + { ...body, code: 123456 }, + { ...body, key: '' }, + ]; + for (const field of ['channel', 'account', 'key', 'code']) { + const missing = { ...body }; + delete missing[field]; + invalid.push(missing); + } + for (const requestBody of invalid) { + const response = await post(route(operation), requestBody).expect(400); + expect(response.body.code).toBe('VALIDATION_FAILED'); + } + await request(app.getHttpServer()).post(route(operation)).send(body).expect(403); + await post(route(operation), { ...body, autoRegister: true }).expect(200); + } + ); + }); + it('consumes image verification and rejects anonymous-session mismatch', async () => { const subject = unique(); const image = await issue(CaptchaKind.IMAGE, subject, 'send_sms', { code: 'Ab1Z' });