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..e261940 --- /dev/null +++ b/docs/captcha-security.md @@ -0,0 +1,192 @@ +# 验证码接入与安全切换 + +业务后端通过 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/@loginByCode', { + channel: 'sms', account: phone, key, code, autoRegister: true, + }); +} +``` + +创建与发送仍是两次调用。供应商确定拒绝发送时,可调用 `DELETE /captchas/${issued.key}` 撤销本次验证码;发送超时、网络中断等结果不确定的情况不得自动再次发送。不要记录完整请求、验证码或供应商异常对象。 + +登录响应包含访问令牌 `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/@loginByCode`,提交 `channel=email、account=实际邮箱、key、code`。 + +| 认证接口 | auth 固定的验证码上下文 | +| --- | --- | +| `@loginByCode` / `@loginByPhone` / `@loginByEmail` | `sms/email + login + 实际账号`;`autoRegister` 仍使用 `login` | +| `@registerByCode` / `@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..d7f45ff 100644 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "hash": "ef6200b85a5a096b716c3bc8e84e84d57e7c515a9a8c4e3e24ee300bd1079bdc", + "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", @@ -576,17 +640,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 +678,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": "服务端确定的用途,例如 login、register、reset_password、send_sms。", + "schema": { + "type": "string" + } + }, + { + "name": "subject", "required": false, "in": "query", - "description": "key", + "description": "与账号查找规则一致的手机号、邮箱,或可信后端签发的匿名会话 ID。", "schema": { "type": "string" } @@ -646,11 +728,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 +752,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 +784,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": "服务端确定的用途,例如 login、register、reset_password、send_sms。", + "schema": { + "type": "string" + } + }, + { + "name": "subject", "required": false, "in": "query", - "description": "key", + "description": "与账号查找规则一致的手机号、邮箱,或可信后端签发的匿名会话 ID。", "schema": { "type": "string" } @@ -725,11 +834,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 +865,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 +908,7 @@ ], "responses": { "200": { - "description": "The captcha with expected id.", + "description": "", "content": { "application/json": { "schema": { @@ -778,48 +916,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 +946,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 +958,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 +1001,7 @@ }, "responses": { "200": { - "description": "Check if the captcha is valid.", + "description": "", "content": { "application/json": { "schema": { @@ -871,9 +1009,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 +3556,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 +3723,6 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "201": { "description": "The third party.", "content": { "application/json": { @@ -3628,16 +3762,6 @@ }, "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "201": { "description": "The third party has been successfully updated.", "content": { "application/json": { @@ -3667,17 +3791,7 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "201": { - "description": "The third party has been successfully deleted.", + "description": "The third party has been successfully deleted.", "content": { "application/json": { "schema": { @@ -3716,16 +3830,6 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "201": { "description": "The third party.", "content": { "application/json": { @@ -3765,16 +3869,6 @@ ], "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "201": { "description": "The third party.", "content": { "application/json": { @@ -6035,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": { @@ -6431,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": { @@ -6543,42 +6773,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 +6872,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 +6961,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 +7059,6 @@ "type": "string", "description": "收件者" }, - "subject": { - "type": "string", - "description": "主题" - }, - "content": { - "type": "string", - "description": "内容" - }, "sentAt": { "format": "date-time", "type": "string", @@ -6739,9 +7068,7 @@ "required": [ "status", "from", - "to", - "subject", - "content" + "to" ] }, "EmailRecord": { @@ -6763,14 +7090,6 @@ "type": "string", "description": "收件者" }, - "subject": { - "type": "string", - "description": "主题" - }, - "content": { - "type": "string", - "description": "内容" - }, "sentAt": { "format": "date-time", "type": "string", @@ -6803,8 +7122,6 @@ "status", "from", "to", - "subject", - "content", "id" ] }, @@ -6827,14 +7144,6 @@ "type": "string", "description": "收件者" }, - "subject": { - "type": "string", - "description": "主题" - }, - "content": { - "type": "string", - "description": "内容" - }, "sentAt": { "format": "date-time", "type": "string", @@ -7411,10 +7720,6 @@ "type": "string", "description": "模板" }, - "params": { - "type": "string", - "description": "参数" - }, "account": { "type": "string", "description": "火山引擎消息组 ID" @@ -7455,10 +7760,6 @@ "type": "string", "description": "模板" }, - "params": { - "type": "string", - "description": "参数" - }, "account": { "type": "string", "description": "火山引擎消息组 ID" @@ -7522,10 +7823,6 @@ "type": "string", "description": "模板" }, - "params": { - "type": "string", - "description": "参数" - }, "account": { "type": "string", "description": "火山引擎消息组 ID" @@ -8552,25 +8849,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 +8874,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..fd504c6 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'; @@ -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,38 +247,7 @@ export class AuthController { }) @Post('@loginByEmail') async loginByEmail(@Body() dto: LoginByEmailDto): Promise { - 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 (!(await this.captchaService.consume(dto.key, dto.code))) { - 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 }); } /** @@ -285,25 +261,51 @@ export class AuthController { }) @Post('@loginByPhone') async loginByPhone(@Body() dto: LoginByPhoneDto): Promise { - let user = await this.userService.findByPhone(dto.phone); + return this.loginWithCode({ ...dto, channel: CodeAuthChannel.SMS, account: dto.phone }); + } - if (!user && !dto.autoRegister) { - throw new UnauthorizedException({ - code: ErrorCodes.AUTH_FAILED, - message: `phone or captcha code wrong`, - }); - } + /** 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); + } - if (!(await this.captchaService.consume(dto.key, dto.code))) { - throw new UnauthorizedException({ + 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: `phone or captcha code wrong`, + message: `${field} or captcha code wrong`, }); + + if ( + !(await this.captchaService.consume({ + key: dto.key, + code: dto.code, + kind: isSms ? CaptchaKind.SMS : CaptchaKind.EMAIL, + purpose: 'login', + subject: dto.account, + })) + ) { + throw authFailed(); } + let user = isSms + ? await this.userService.findByPhone(dto.account) + : await this.userService.findByEmail(dto.account); + + 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, @@ -312,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); @@ -423,23 +428,11 @@ export class AuthController { }) @Post('@registerByPhone') async registerByPhone(@Body() dto: RegisterbyPhoneDto): Promise { - const user = await this.userService.findByPhone(dto.phone); - if (user) { - throw new ConflictException({ - code: ErrorCodes.USER_ALREADY_EXISTS, - message: `phone ${dto.phone} already exists.`, - }); - } - - 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, + return this.registerWithCode({ + channel: CodeAuthChannel.SMS, + account: dto.phone, + key: dto.key, + code: dto.code, ns: dto.ns, inviter: dto.inviter, labels: dto.labels, @@ -460,29 +453,69 @@ export class AuthController { }) @Post('@registerByEmail') async registerByEmail(@Body() dto: RegisterByEmailDto): Promise { - const user = await this.userService.findByEmail(dto.email); - if (user) { - throw new ConflictException({ - code: ErrorCodes.USER_ALREADY_EXISTS, - message: `email ${dto.email} already exists.`, - }); - } + 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, + }); + } - if (!(await this.captchaService.consume(dto.key, dto.code))) { + /** 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: isSms ? CaptchaKind.SMS : CaptchaKind.EMAIL, + purpose: 'register', + subject: dto.account, + })) + ) { throw new BadRequestException({ code: ErrorCodes.CAPTCHA_INVALID, message: 'captcha invalid.', }); } + 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: `${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 }), }); } @@ -615,6 +648,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 +671,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 +682,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 +705,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/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/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..1a23d85 100644 --- a/test/captcha.e2e-spec.ts +++ b/test/captcha.e2e-spec.ts @@ -1,286 +1,470 @@ -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 { exceptionFactory } from 'src/common/exception-factory'; 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(); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); - app.useGlobalInterceptors(new MongoErrorsInterceptor()); + 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, exceptionFactory })); + 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), + 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, + }).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); + } + ); + + 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', }; - 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([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, - 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(); + 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(`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(); + 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') + .patch(`/captchas/${issued.id}`) .set('x-api-key', auth.apiKey) - .set('Accept', 'application/json') - .expect(201); - - // 错误的手机号 + .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') - .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') + .delete(`/captchas/${issued.key}`) .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';