Skip to content

Core Identity

worable edited this page Oct 20, 2026 · 1 revision

身份挑战与人机验证(Identity challenge / Captcha)

实现依据:REVIEW-通知通道与注册验证.md §5。 内核拥有一份短时、单次、限次的邮箱验证码底座与一个人机验证受控契约;投递渠道与验证 提供者由插件经扩展点供给,内核不绑定任何厂商(无 SMTP、无 Turnstile/reCAPTCHA 内建实现)。

为什么验证码不走通知通道

  • 未注册用户没有可靠 userId——通知表按 userId 归属,写不进去。
  • 验证码是事务性凭证,一旦进入站内消息、SSE、通用事件、审计或应用日志就等于泄露。
  • 因此内核用独立身份挑战流程:按「规范化邮箱 + 用途」寻址,只保存摘要与盐,明文只在发送 时出现一次。

内核能力(ctx.identity)

ctx.identity 由内核提供(KernelIdentityChallengeService),插件(如 login)调用:

interface IdentityChallengeService {
  requiresEmailVerification(): boolean; // 部署策略:注册是否强制邮箱验证
  isDeliveryConfigured(): boolean;      // 是否已注册发送渠道
  senderIds(): string[];
  issue(input: { purpose; email; ip?; userAgent?; locale? }): Promise<IssuedIdentityChallenge>;
  consume(input: { purpose; email; code }): Promise<void>;
}
  • 存储:identity_challenges 表(迁移 20261017000000_identity_challenge)。字段含用途、 规范化邮箱、codeDigest/codeSalt、attempts/maxAttempts、expiresAt、consumedAt。
  • 摘要:HMAC-SHA256,密钥由内核 JWT_SECRET 派生——数据库泄露也无法离线爆破 6 位码。
  • TTL / 限次 / 限速:由环境变量控制(见下)。超时、错误次数用尽即作废并需重取。
  • 单次消费:consume 以 updateMany({ id, consumedAt: null }) 原子翻转;并发第二次消费 看到 count === 0 被拒(重放防护)。
  • 失败码:auth.code_missing / auth.code_invalid / auth.code_expired / auth.code_locked / auth.code_rate_limited / auth.code_channel_unavailable / auth.code_delivery_failed。
  • 不静默跳过:未配置发送渠道时 issue 直接抛 auth.code_channel_unavailable,绝不假装成功。

发送渠道(identity.challengeSender 扩展点)

插件注册一个 IdentityChallengeSender:

interface IdentityChallengeSender {
  readonly id: string;
  readonly name: string;
  send(message: {
    purpose: 'register' | 'email_change' | 'password_reset';
    email: string;
    code: string;      // 明文,仅此一次
    expiresAt: Date;
    locale?: string;
  }): Promise<void>;
}

内核按注册顺序尝试发送者;全部失败则作废该挑战并抛 auth.code_delivery_failed。SMTP/短信渠道 作为独立插件(建议放插件仓 StackPanel-plugins)实现此接口,同时实现 notification.channel 承担普通通知。

人机验证(ctx.captcha + captcha.provider 扩展点)

内核提供 KernelCaptchaService,策略由内核拥有(AUTH_CAPTCHA_MODE),调用方不自行分支:

type CaptchaMode = 'off' | 'optional' | 'required';

interface CaptchaService {
  mode(): CaptchaMode;
  isConfigured(): boolean;
  providerIds(): string[];
  verify(input: { token; action?; remoteIp? }): Promise<{ success; reason?; providerId? }>;
  enforce(input): Promise<void>;            // 按策略校验,失败抛确定性错误
  clientConfig(): Record<string, unknown> | null; // 前端部件公开配置(非密钥)
}
  • off 从不过问;optional 有 token 则校验、无则放行;required 无有效 token 直接拒绝, 且未配置 provider 时也拒绝(fail closed)。
  • 前端部件从 GET /auth/config 读取 captcha.mode / configured / clientConfig,把 token 随 注册与发送验证码请求一并提交。
  • provider 实现 verify()(并可选 clientConfig());前端 token 的 action/site/有效期校验属于 provider 责任。

环境变量

变量 默认 说明
AUTH_EMAIL_VERIFICATION off required 时注册必须先消费邮箱验证码,验证通过才创建 ACTIVE 账户
AUTH_CAPTCHA_MODE off off / optional / required
AUTH_CODE_TTL_SECONDS 600 验证码有效期
AUTH_CODE_MAX_ATTEMPTS 5 单个挑战的最大错误次数
AUTH_CODE_SEND_PER_HOUR 5 每邮箱每用途每小时的发送上限(0 关闭)
AUTH_CODE_SEND_PER_HOUR_PER_IP 20 每来源 IP 每小时的发送上限(0 关闭)

HTTP 接口(login 插件)

方法 路径 说明
POST /register/send-code 发送注册验证码(受 captcha 与限速约束)
POST /register 注册;AUTH_EMAIL_VERIFICATION=required 时 code 必填且先消费
GET /auth/config 公开认证策略(是否强制邮箱验证、captcha 模式与公开配置)

账户验证时间写入 users.emailVerifiedAt:仅在验证通过(或明确标记)时写入;null 不代表 「按策略未要求验证」,也覆盖历史账户与第三方免密身份。

验收边界

  • 单元测试:apps/api/tests/unit/identity-challenge.test.ts(摘要不落明文、单次消费、重放拒绝、 错误次数锁定、过期、无渠道拒绝、投递失败作废、重发作废旧码)。
  • 单元测试:apps/api/tests/unit/captcha-service.test.ts(策略、缺失 token、provider 故障不算通过、 多 provider 回落、clientConfig)。
  • 集成测试:apps/api/tests/integration/register-verification.test.ts(真实 DB:无码拒绝、发码、 错码拒绝、正码注册 ACTIVE 且写 emailVerifiedAt、重放拒绝)。
  • 未覆盖:真实 SMTP/短信投递、真实第三方 captcha 联调(本轮无具体 provider)。

Clone this wiki locally