Bytebase 邮箱验证码登录全解析:6 位一次性验证码的无密码认证与密码重置设计
【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase
本篇文章以 docs/superpowers/specs/2026-04-14-email-code-signin-design.md 设计文档为主体,结合仓库中已经落地的源码实现进行交叉印证。你将看到 Bytebase 如何借鉴 Slack / Linear / Notion 的 Magic-Code 体验,让用户仅凭邮箱 + 6 位数字验证码完成登录/注册,并把密码重置流程统一迁移到同一套验证码机制上;同时深入
email_verification_code数据表、HMAC 哈希、原子条件更新、限流与反枚举等安全设计,最终理解从 Proto 定义到前端交互的完整链路。
Bytebase 的登录体系长期以来由「密码」与「SSO / IDP」两条路径构成。为了降低用户门槛并统一密码重置体验,Bytebase 设计并实现了「邮箱 + 6 位一次性验证码」的无密码认证能力:用户只输入邮箱,收到一封带 6 位数字验证码的邮件,填入即可完成登录(未知邮箱自动注册),密码重置也从原来的 JWT 链接改为同一套验证码机制。本文从设计文档出发,完整还原这一功能的决策、数据模型、API 面、后端实现、前端交互与安全边界,并对照仓库中已落地的代码给出可验证的实现依据。
背景与目标:向 Slack / Linear / Notion 的 Magic-Code 看齐
设计文档开篇即明确了本功能的核心目标:让用户只使用邮箱地址 + 一次性 6 位数字验证码即可完成登录(以及注册),镜像 Slack、Linear、Notion 等产品的 Magic-Code 认证体验,并将密码重置流程统一到同一套验证码机制上。
关键点在于"验证码"而不是"魔法链接"(Magic Link)——设计文档在 Non-goals 中明确排除了可点击链接形式的登录:
- 仅做验证码登录,不做 Magic-Link 点击登录;
- 不做按工作区自定义邮件模板的能力;
- 不做开发/测试模式下的限流豁免;
- 不做投递回执(delivery receipts)或退信处理;
- 不改动联邦 SSO 相关能力。
从仓库现状看,该设计已完整落地:核心实现位于 backend/api/v1/auth_service_email_code.go,涵盖SendEmailLoginCode、ResetPassword、RequestPasswordReset、验证码校验、生成与哈希等全部逻辑;存储层在 backend/store/email_verification_code.go;数据表迁移见 backend/migrator/migration/3.18/0000##add_email_verification_code.sql。
关键决策总览:一张表读懂设计取舍
设计文档在头脑风暴阶段收敛出 15 项关键决策,它们是整个功能的设计骨架:
| 决策项 | 选择 | 理由 |
|---|---|---|
| 设置项名称 | allow_email_code_signin | 意图最清晰(不是"两步验证",因为它替代密码而非增强密码) |
| 定位 | 密码的替代方案而非替代品 | 同一登录页、独立 Tab,与密码和 SSO 共存 |
| 未知邮箱注册 | 是,自动创建 principal | Magic-Code 语义:拥有邮箱即证明所有权 |
| 新用户形态 | name默认取邮箱 local-part;随机 bcrypt 密码 | 用户日后可在个人资料中自行设置 |
| 工作区归属 | 与密码注册一致:加入被预邀请的工作区或新建 | 与既有流程对称 |
| 验证码有效期 | 10 分钟 | 平衡邮件投递延迟与安全 |
| 重发冷却 | 60 秒 | 防止邮件轰炸;登录与密码重置共用 |
| 单码最大尝试次数 | 5 次 | 与既有 MFA 锁定阈值一致 |
| 每邮箱发送速率 | 60 秒冷却 ⇒ 约 60 封/小时 | 比基于审计日志计数更简单(审计中间件在无工作区时会跳过未认证请求) |
| 验证码存储 | 专用数据表email_verification_code | 高可用安全;attempts/cooldown 等有状态语义无法用 JWT 表达 |
| 密码重置 | 从 JWT 迁移到验证码(共享同一张表) | 高可用正确的冷却/重试限制;统一 UX |
| 2FA 交互 | 用户已绑定 TOTP 则仍需二次验证 | 纵深防御,不静默降级 |
| RPC 形态 | 在既有LoginRequest上新增email_code字段 | 复用工作区解析 / MFA / token 管道 |
| SaaS 可开关性 | 与disallow_password_signin一样只读 | 设置了EMAIL_CONFIG时在工作区创建时自动启用 |
配置开关:allow_email_code_signin的前世今生
Store Proto 与 V1 Proto 双镜像
设计文档要求在存储层和 API 层同时新增该布尔字段。仓库中的实际定义位于 proto/store/store/setting.proto 的WorkspaceProfileSetting:
// Allow signin/signup using email + a 6-digit one-time verification code. // Requires the EMAIL setting to be configured on the workspace. bool allow_email_code_signin = 22;同时在 proto/v1/v1/auth_service.proto 的Restriction消息中镜像为输出只读字段,供未认证的登录页决定是否展示"邮箱验证码"Tab(与disallow_password_signin同一套管道):
message Restriction { bool disallow_signup = 1 [(google.api.field_behavior) = OUTPUT_ONLY]; bool disallow_password_signin = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; WorkspaceProfileSetting.PasswordRestriction password_restriction = 3 [(google.api.field_behavior) = OUTPUT_ONLY]; // Whether email + 6-digit code signin is enabled for this workspace. bool allow_email_code_signin = 4 [(google.api.field_behavior) = OUTPUT_ONLY]; // Whether password reset via email is available for this workspace. bool password_reset_enabled = 5 [(google.api.field_behavior) = OUTPUT_ONLY]; }设计文档要求更新getAccountRestriction从setting.AllowEmailCodeSignin填充新字段,并指出大概率无需 License 门控(因为发信本身已要求配置 EMAIL 设置)。这与实现中getAccountRestriction的既有职责一致:在 backend/api/v1/auth_service_email_code.go 中,SendEmailLoginCode和注册分支都通过getAccountRestriction读取该开关,并在关闭时返回FailedPrecondition。
SaaS 与自托管的差异化行为
- SaaS 工作区创建时:如果设置了
EMAIL_CONFIG环境变量,通过getAdditionalWorkspaceSettings()自动注入allow_email_code_signin = true; UpdateSetting校验:SaaS 模式下该字段只读,任何修改尝试返回InvalidArgument;- 自托管:工作区管理员可以切换开关;置为
true时要求工作区已配置 EMAIL 设置,否则以FailedPrecondition拒绝。
从源码看,resolvePreLoginEmailSetting(同一文件中)正是按此双通道解析邮件配置:优先取调用方传入workspaceID对应工作区的 EMAIL 设置,其次回退到部署级EMAIL_CONFIG环境变量——后者覆盖了 SaaS 全新用户尚无工作区上下文的场景。
数据模型:一张专表承载有状态语义
表结构与设计要点
设计文档给出的建表语句在实现中落为 backend/migrator/migration/3.18/0000##add_email_verification_code.sql,实际表结构如下(实现相比设计文档额外增加了workspace列,用于在校验时做门禁判断与新建工作区归属):
CREATE TABLE email_verification_code ( email text NOT NULL, -- Stored as EmailVerificationCodePurpose enum name (proto/store/store/auth.proto) purpose text NOT NULL, code_hash text NOT NULL, attempts int NOT NULL DEFAULT 0, expires_at timestamptz NOT NULL, last_sent_at timestamptz NOT NULL, -- Workspace context captured at send time. Used at verify time for gate checks -- (disallow_signup, allow_email_code_signin) and for provisionWorkspaceForNewUser. -- NULL for SaaS brand-new signup (no workspace exists yet — provision creates one). workspace text, PRIMARY KEY (email, purpose) ); CREATE INDEX idx_email_verification_code_expires_at ON email_verification_code (expires_at);设计要点:
- 主键
(email, purpose)——每个邮箱每种用途同时只有一个有效验证码,重发通过 UPSERT 覆盖; - 无工作区列(设计阶段)——验证码按身份(identity)作用域隔离,与
principal、web_refresh_token一致;实现中补充的workspace列仅记录发送时刻的工作区上下文,用于校验时的门禁判断; code_hash使用 HMAC-SHA256——以服务端auth_secret作为密钥对 6 位验证码做 HMAC 而非裸 SHA-256,防止数据库被攻破后对 10^6 规模的验证码空间做离线爆破(攻击者还需拿到 auth secret 才能验证候选码);expires_at索引——支撑后台清理任务。
实现中对应的哈希函数在 backend/api/v1/auth_service_email_code.go:
// hashEmailCode returns HMAC-SHA256(code) hex-encoded, keyed with the server's auth secret. func hashEmailCode(secret, code string) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(code)) return hex.EncodeToString(mac.Sum(nil)) }而 6 位数字验证码的生成使用crypto/rand(而非可预测的伪随机源),将随机字节映射到"0123456789"字符集:
func generateEmailCode() (string, error) { const digits = "0123456789" b := make([]byte, emailCodeLength) if _, err := rand.Read(b); err != nil { return "", err } for i := range b { b[i] = digits[int(b[i])%len(digits)] } return string(b), nil }Purpose 枚举
设计文档建议新建proto/store/store/email_verification_code.proto;从仓库现状看,EmailVerificationCodePurpose枚举最终落在 proto/store/store/auth.proto(LOGIN = 1、PASSWORD_RESET = 2),purpose列存枚举字符串名,与policy.resource_type等既有模式一致。
Store 方法:从设计到实现的演进
设计文档规划了 5 个 Store 方法,实现中(backend/store/email_verification_code.go)做了几处关键演进,值得注意:
| 设计文档中的方法 | 实现中的对应方法 | 差异说明 |
|---|---|---|
UpsertEmailVerificationCode(重置 attempts、覆盖 hash) | UpsertEmailVerificationCodeIfCooldownExpired | 把"冷却判断"与"写入"合并为一条原子 SQL,防止并发重发穿透冷却 |
GetEmailVerificationCode | GetEmailVerificationCode | 无行时返回(nil, nil) |
IncrementEmailVerificationCodeAttempts(原子 +1) | 由verifyEmailCode中的claimLoginAttempt(ctx, email, LoginAttemptKind_EMAIL_CODE)承担 | 设计文档提到的"匹配 MFA 锁定阈值"最终通过login_attempt表实现(见 backend/migrator/migration/3.22/0012##login_attempt.sql),attempts 列保留但不再是唯一的尝试限制 |
DeleteEmailVerificationCode(一次性失效) | ConsumeEmailVerificationCode+DeleteEmailVerificationCodeIfMatch | 删除时匹配code_hash,避免并发请求误删新码;消费语义通过DELETE ... RETURNING保证恰好一次 |
DeleteExpiredEmailVerificationCodes | DeleteExpiredEmailVerificationCodes | 已接入后台清理任务(见下文) |
其中最核心的是原子冷却判断的实现,这是防 TOCTOU 竞争的关键:
// UpsertEmailVerificationCodeIfCooldownExpired inserts or updates the row for (email, purpose), // but ONLY if no row exists OR the existing row's last_sent_at is older than the cooldown. // Atomic check-and-set: uses INSERT ... ON CONFLICT DO UPDATE ... WHERE ... RETURNING 1 func (s *Store) UpsertEmailVerificationCodeIfCooldownExpired(ctx context.Context, msg *EmailVerificationCodeMessage, cooldown time.Duration) (bool, error) { q := qb.Q().Space(` INSERT INTO email_verification_code (email, purpose, code_hash, expires_at, last_sent_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (email, purpose) DO UPDATE SET code_hash = EXCLUDED.code_hash, expires_at = EXCLUDED.expires_at, last_sent_at = EXCLUDED.last_sent_at WHERE email_verification_code.last_sent_at < EXCLUDED.last_sent_at - make_interval(secs => ?) RETURNING 1 `, msg.Email, msg.Purpose.String(), msg.CodeHash, msg.ExpiresAt, msg.LastSentAt, cooldown.Seconds()) ... }如果先读后写,两个并发请求可能都通过冷却检查、都发出邮件;而单条INSERT ... ON CONFLICT DO UPDATE ... WHERE ... RETURNING 1语句把"检查 + 写入"合二为一,只有last_sent_at早于冷却窗口的请求才能成功写入并继续发信。注释特别说明使用RETURNING而非RowsAffected:Postgres 在DO UPDATE的WHERE过滤掉更新时,受影响行数不可靠。
另外,sendEmailVerificationCode在 SMTP 发送失败时会调用DeleteEmailVerificationCodeIfMatch删除刚写入的行并匹配code_hash,这样冷却不会阻塞立即重试,同时不会误删并发请求写入的更新验证码。消费侧ConsumeEmailVerificationCode用DELETE ... WHERE code_hash = ? AND expires_at > NOW() RETURNING email,两个并发请求提交同一验证码时恰好只有一个观察到consumed=true,保证一个验证码真的只能花一次。
后台清理
设计文档要求把DeleteExpiredEmailVerificationCodes挂到既有 sweeper(与DeleteExpiredWebRefreshTokens同处)。实现确认位于 backend/runner/cleaner/data_cleaner.go,与DeleteExpiredVCSProviderUsers、DeleteExpiredOAuth2AuthorizationCodes、DeleteExpiredOAuth2RefreshTokens、DeleteExpiredOAuth2Clients、DeleteExpiredWebRefreshTokens等清理任务并列执行。
API 表面:最小侵入的 RPC 扩展
修改LoginRequest:新增email_code字段
设计文档要求复用既有登录管道,仅新增一个可选字段。仓库中 proto/v1/v1/auth_service.proto 的实际定义:
// 6-digit code from email for passwordless login/signup. // Pairs with `email`. Mutually exclusive with `password` and `idp_name`. optional string email_code = 9 [ (bytebase.v1.audit_behavior) = SENSITIVE, (buf.validate.field).string.max_len = 64 ];注意两点增强:audit_behavior = SENSITIVE保证验证码不进入审计日志明文;max_len = 64在边缘即拒绝超大输入(登录尝试锁定以 email 为键,超大输入可被边界拦截)。
新增SendEmailLoginCode
// Sends a 6-digit verification code to the email for login/signup. // Always returns success (no email enumeration). Enforces 60-sec resend cooldown. // Permissions required: None rpc SendEmailLoginCode(SendEmailLoginCodeRequest) returns (google.protobuf.Empty) { option (google.api.http) = { post: "/v1/auth:sendEmailLoginCode" body: "*" }; option (bytebase.v1.allow_without_credential) = true; option (bytebase.v1.audit) = true; } message SendEmailLoginCodeRequest { string email = 1; }allow_without_credential = true使其对未认证用户开放,audit = true让该操作进入审计日志。
改造ResetPassword:JWT Token → 验证码
message ResetPasswordRequest { string email = 1; string code = 2; string new_password = 3; }RequestPasswordReset
签名不变,但行为通过新的数据库行获得 60 秒冷却能力(以及"只给真实存在的活跃用户发信"的存在性检查,详见下文)。
后端服务层:发送、校验、登录与重置的完整流程
常量定义
设计文档给出了 4 个常量;实际实现(backend/api/v1/auth_service_email_code.go)中略有演进——emailCodeMaxAttempts被login_attempt锁定机制取代,同时新增了发送预算相关常量:
const ( emailCodeLength = 6 emailCodeExpiry = 10 * time.Minute emailCodeResendCooldown = 60 * time.Second // How much sign-in-code mail this deployment may generate per window. emailCodeSendWindow = time.Hour emailCodeSendPerWindow = 1000 errMsgInvalidEmailCode = "invalid or expired code" )设计文档还要求从 backend/api/auth/tokens.go 移除passwordResetTokenDuration(原 15 分钟 JWT)、GeneratePasswordResetToken与GetEmailFromPasswordResetToken,因为密码重置已完全切换为验证码机制。
SendEmailLoginCode发送流程
实现与设计文档的步骤大体一致,但有一个显著差异:实现选择同步发送,以便调用方感知"邮件设置缺失 / SMTP 不可达"等可操作的失败——这不会造成枚举风险,因为 LOGIN 用途对任何邮箱都尝试发送(注册发生在校验阶段):
- 规范化邮箱并校验格式,非法则返回
InvalidArgument; - 若调用方传了
workspace,先做域名白名单校验(validateEmailWithDomains),否则做通用邮箱格式校验(validateEndUserEmail); - 通过
getAccountRestriction检查allow_email_code_signin,关闭时直接FailedPrecondition("先确认工作区会接受这个验证码,再浪费一封邮件"); - 领取发送预算(
ClaimAttempt,键signin-code,窗口 1 小时 1000 次,整部署共享),预算耗尽返回ResourceExhausted(errSendBudgetExhausted);设计文档中"60 秒冷却 ≈ 60 封/小时"是每收件人的下限,发送预算则是对整部署发信声誉的保护; - 生成 6 位验证码,通过
UpsertEmailVerificationCodeIfCooldownExpired原子写入哈希(附带 60 秒冷却判断),冷却中静默跳过返回成功; - 创建 mailer 发送邮件;失败时按
code_hash回滚删除行,返回Internal。
设计文档中"fire-and-forget goroutine(context.WithoutCancel)"的异步形态最终被同步发送替代,这是实现层面的明确取舍,原因在代码注释中写明:同步才能让调用方在邮件设置缺失或 SMTP 故障时得到明确错误。注意RequestPasswordReset仍保留"吞掉错误"的静默行为以避免邮箱枚举(见下)。
Login的新分支:authenticateEmailCodeLogin
设计文档给出的分发优先级(MFA → IDP → email_code → password)在实现中对应authenticateEmailCodeLogin(backend/api/v1/auth_service_email_code.go):
第一步:互斥校验。若同时携带password或idp_name,返回InvalidArgument:"email_code is mutually exclusive with password and idp_name"。
第二步:校验验证码。调用共享的verifyEmailCode(见下)。
第三步:按邮箱查用户,分两条路径。
- 已有用户:直接返回用户,交给下游管道继续。
allow_email_code_signin与域名白名单的检查被推迟到validateLoginPermissions,针对实际解析出的登录工作区执行——这对多工作区用户很重要:resolveWorkspaceForLogin优先LastLoginWorkspace,可能与首次被预邀请的工作区不同; - 新用户(注册):工作区级门禁在创建用户之前执行,防止孤儿账号:
- 解析目标工作区(预邀请的工作区或自托管单例;全新 SaaS 用户两者皆无则走 SaaS 默认);
- 目标工作区存在时:
allow_email_code_signin = false→FailedPrecondition;disallow_signup = true(仅非 SaaS)→ 拒绝;域名白名单校验失败 → 拒绝; - 先
provisionResolvedWorkspace再创建用户——若用户创建失败,下次重试可通过FindWorkspace(email)找到已供应的工作区自愈;反向顺序会留下没有工作区的用户,且后续重试会因GetUserByEmail提前返回而永远不重跑供应逻辑; - 生成 32 字节随机密码并 bcrypt 哈希(用户永远不可能知道这个随机密码,验证码是其唯一入口,之后可在个人资料中设置真实密码);
- 创建
END_USER类型 principal,name取邮箱 local-part(email.split("@")[0]); - 返回新用户,进入下游管道。
共享校验助手:verifyEmailCode
实现相比设计文档多了两步保护,但核心语义一致(backend/api/v1/auth_service_email_code.go):
- 邮箱格式非法直接返回
Unauthenticated("垃圾输入不写行"); - 领取
EMAIL_CODE类型的登录尝试额度(claimLoginAttempt)——猜测在验证码加载之前就被按身份限流,锁定状态不会成为"是否有待校验验证码"的预言机;这也正是设计文档中"max attempts = 5,匹配 MFA 锁定阈值"的落地形态; - 读取行,无行 →
Unauthenticated"invalid or expired code"; expires_at已过 → 按 hash 匹配删除 →Unauthenticated"invalid or expired code";- 常数时间比较HMAC 摘要(
subtle.ConstantTimeCompare)——与既有challengeRecoveryCode一致,避免时序侧信道;不匹配 →Unauthenticated"invalid or expired code",行保留(保证重发冷却始终有行可评估); - 匹配 → 按 hash 删除(一次性使用)→ 清除登录尝试记录 → 返回成功。
所有验证失败都返回统一的 "invalid or expired code",唯一的例外是登录尝试额度耗尽会暴露为可操作的错误,提示用户去申请新验证码。
密码重置的两条路径
ResetPassword(backend/api/v1/auth_service_email_code.go):
- 参数校验(email / code / new_password 必填);
verifyEmailCode(ctx, email, PASSWORD_RESET, code)——验证码证明了对邮箱的控制权;- 按邮箱查用户(
GetUserByEmail),不存在则NotFound; - 通过用户自身成员关系解析工作区(绝不信任调用方传参),校验新密码符合密码策略(
validatePasswordWithRestriction); - bcrypt 哈希新密码并
UpdateUser; - 吊销该用户全部 web refresh token(
DeleteWebRefreshTokensByUser),强制重新登录; - 清除该邮箱的 PASSWORD 登录尝试记录——用户已通过邮箱控制权证明 + 新密码重置,自己猜出来的锁定不应在重置后继续生效。
RequestPasswordReset:签名未变,但共享的sendEmailVerificationCode助手为PASSWORD_RESET用途内置两道防护:
- principal 存在性检查:
eligibleForPasswordReset要求账户存在、类型为END_USER且未删除,否则静默返回(不写行、不发信),防止该端点被滥用来向任意地址投递重置邮件; - 60 秒冷却:与 LOGIN 路径相同的
last_sent_at原子判断。
RPC 本身无论成功与否都返回Empty(不可枚举)。LOGIN用途刻意跳过存在性检查——它同时承担注册职责(未知邮箱 → 创建 principal)。
Post-auth:validateLoginPermissions的两处更新
设计文档指出 SaaS 模式下默认DisallowPasswordSignin = true,会挡住所有非 IDP 登录,因此必须做两处更新(实现已确认):
- 豁免 email-code 登录:
isEmailCodeLogin分支跳过DisallowPasswordSignin检查——验证码登录是独立的认证方法,不是密码; - 在解析出的工作区上强制
allow_email_code_signin:对 email-code 登录,校验resolveWorkspaceForLogin解析出的workspaceID确实开启了该开关,存储错误时 fail-closed。这正确覆盖了"用户的 LastLoginWorkspace 与首次预邀请工作区不同"的多工作区场景。
限流设计:冷却 + 发送预算双层防线
- 每收件人 60 秒冷却:由
last_sent_at列的原子条件 UPSERT 保证,防 TOCTOU(见 Store 层); - 整部署发送预算:
signin-code桶,1 小时窗口 1000 次(emailCodeSendWindow/emailCodeSendPerWindow),通过ClaimAttempt领取。设计文档中"审计日志计数对无工作区的未认证请求无效"的顾虑,最终以独立的发送预算表解决; - 登录尝试锁定:
EMAIL_CODE类型接入login_attempt机制(backend/migrator/migration/3.22/0012##login_attempt.sql),猜码被按身份限流,与 MFA 的阈值语义对齐。
前端设计:第三 Tab + 两步交互 + 重置页重写
登录页Signin.vue:新增第三个 Tab
在 "Standard"(密码)与 IDP Tab 旁新增 "Email code" Tab(t("auth.sign-in.email-code-tab")),可见性由 actuator 返回的serverInfo?.allowEmailCodeSignin === true控制(即 proto/v1/v1/auth_service.proto 中Restriction.allow_email_code_signin)。
Tab 内是两步交互:
Step 1 (初始): 邮箱输入框 [ 发送验证码 ] 按钮 Step 2 (点击发送后): 邮箱输入框(禁用,"修改"链接可返回第一步) 6 位验证码输入框(NInputOtp,自动聚焦) [ 重新发送 ] 按钮(60 秒倒计时) [ 验证 ] 按钮(未满 6 位时禁用;输满自动提交)前端 store 与提交流程
auth.ts新增sendEmailLoginCode(email);既有login()只需透传emailCode字段;- 提交:
authStore.login({ email, emailCode, web: true }),下游与密码登录完全一致——响应含mfaTempToken则跳转MultiFactor.vue完成 TOTP,否则fetchCurrentUser()后进入工作台; - 倒计时:点 "Send code" / "Resend" 后启动客户端 60 秒计时器,按钮显示
t("auth.sign-in.resend-in", { seconds: 45 });服务端无论客户端如何都会强制执行同一窗口。
密码重置页:从链接到验证码的完整重写
PasswordForgot.vue(小改):同样的邮箱表单,文案从"链接"改为"验证码",复用 "Send code" + 冷却 UX;成功后在 URL 中携带email跳转password-reset?email=...;PasswordReset.vue(完整重写):- 移除
?token=查询参数处理,移除基于 token 的"登录后强制重置"流程(该流程与验证码正交,保留在既有UpdateUser路径); - 表单:邮箱(从 query 预填)+ 6 位验证码 + 新密码/确认密码 + [重新发送](60 秒冷却)+ [重置密码];
- 提交:
authServiceClientConnect.resetPassword({ email, code, newPassword })→ 跳转登录页;
- 移除
- 预填 UX:登录页点 "Forgot password?" 时携带邮箱:
password-forgot?email=...→password-reset?email=...; - 前端移除:
auth/password-reset?token=xxx处理、邮件中的GeneratePasswordResetToken重置链接构造。
i18n 文案(en-US 并同步 zh-CN / ja-JP / es-ES / vi-VN)
"auth.sign-in.email-code-tab": "Email code", "auth.sign-in.send-code": "Send code", "auth.sign-in.resend-code": "Resend code", "auth.sign-in.resend-in": "Resend in {seconds}s", "auth.sign-in.code-sent-hint": "We've sent a 6-digit code to {email}", "auth.password-reset.code-label": "Verification code"错误处理矩阵:统一语义,绝不枚举
设计文档给出了完整的错误映射,实现与之一一对应:
| 场景 | 响应 |
|---|---|
SendEmailLoginCode邮箱非法 | InvalidArgument"invalid email" |
SendEmailLoginCode处于 60s 冷却 | Empty成功(静默) |
SendEmailLoginCode无 EMAIL 设置且无EMAIL_CONFIG | Empty成功,记录警告(实现改为同步返回Internal以暴露配置问题) |
SendEmailLoginCodeSMTP 发送失败 | Empty成功,记录警告(实现改为同步返回Internal) |
Loginemail_code 与 password/idp_name 同时携带 | InvalidArgument"mutually exclusive" |
Loginemail_code 无对应行 | Unauthenticated"invalid or expired code" |
Loginemail_code 已过期 | 删除行,Unauthenticated"invalid or expired code" |
Loginemail_code 尝试次数超限 | 删除行,Unauthenticated"too many attempts"(实现经 login_attempt 锁定,且行保留以支撑冷却判断) |
Loginemail_code 不匹配 | 记录尝试,Unauthenticated"invalid or expired code" |
Loginemail_code 未知邮箱且预邀请工作区disallow_signup=true | Unauthenticated"account not found"(通用文案,不泄露原因) |
Loginemail_code 预邀请工作区allow_email_code_signin=false | FailedPrecondition"email code login is not enabled for this workspace"(在任何状态变更之前检查,防止孤儿账号) |
ResetPassword各类验证码错误 | 与 Login 分支相同语义 |
RequestPasswordReset处于 60s 冷却 | Empty成功(静默) |
三条安全原则(实现均已确认):
- 无邮箱枚举:
SendEmailLoginCode与RequestPasswordReset对未知邮箱与已知邮箱的响应不可区分; - 统一验证失败文案:一律 "invalid or expired code",仅 "too many attempts" 会暴露,让用户知道应重新申请验证码;
- 常数时间比较:验证码哈希比较使用
subtle.ConstantTimeCompare,与既有challengeRecoveryCode保持一致。
测试与上线策略
单元测试
设计文档规划了 backend/store/email_verification_code_test.go 与 backend/api/v1/auth_service_email_code_test.go 两组测试。后者在仓库中已存在,覆盖发送预算相关行为,例如:
TestSendEmailLoginCodeBudgetsEverySender:发送预算对每个发送方计数;TestSendEmailLoginCodeBudgetsWorkspacelessSends:无工作区上下文的发送同样纳入预算;TestSendEmailLoginCodeBudgetKeepsExistingCode:预算耗尽时保留收件人已有的验证码("先领预算再写行"顺序的直接验证)。
设计文档要求的其他用例(Upsert→Get 全字段一致、二次 Upsert 重置 attempts、双 purpose 并存、原子递增、删除、过期清理、verifyEmailCode的 happy/expired/attempts-exceeded/mismatch/missing-row、authenticateLogin的已有用户/新用户/互斥拒绝)均在集成层有对应覆盖。
集成测试(backend/tests/auth_test.go)
SendEmailLoginCode+Login(email_code)快乐路径 → 返回 token,新用户自动创建;- 60s 内重发 → 静默忽略,存储行不变;
- 5 次错误验证码 → 第 6 次返回 "too many attempts",行删除;
- 过期验证码(mock clock)→ 登录失败;
- 密码重置全链路:
RequestPasswordReset→ResetPassword(email, code, new_password)→ 可用新密码登录;旧 refresh token 全部吊销; - 工作区
allow_email_code_signin = false→ email-code 登录被拒; - 工作区
disallow_signup = true+ 未知邮箱 → 登录被拒 "account not found";既有用户不受影响; - 2FA 交互:启用 TOTP 的用户 → email-code 登录返回
mfa_temp_token→ 走常规 MFA 流程完成。
迁移与发布
- 迁移文件已落地为 backend/migrator/migration/3.18/0000##add_email_verification_code.sql,同步更新
LATEST.sql; - Proto 生成流程:
cd proto && buf format -w . && buf lint && buf generate; - 无独立 feature flag:由工作区设置
allow_email_code_signin门控,自托管默认false;SaaS 在配置EMAIL_CONFIG时自动为工作区置true; - 后台清理:
DeleteExpiredEmailVerificationCodes已接入 backend/runner/cleaner/data_cleaner.go; - Breaking change:
ResetPassword签名从(token, new_password)变为(email, code, new_password),PR 标注breaking; - 审计日志:
SendEmailLoginCode、Login、RequestPasswordReset、ResetPassword均声明option (bytebase.v1.audit) = true,且email_code字段标注SENSITIVE不进审计明文。
设计到实现:文档与代码的对照总结
| 设计文档要点 | 落地情况 |
|---|---|
allow_email_code_signin工作区设置 | 已在 proto/store/store/setting.proto(字段 22)与 v1Restriction(字段 4)中定义 |
email_verification_code专表 | 已建表并带expires_at索引,另增workspace列用于门禁判断 |
| HMAC-SHA256 存哈希 | 已实现hashEmailCode,校验走subtle.ConstantTimeCompare |
| 60s 冷却防 TOCTOU | 已实现为UpsertEmailVerificationCodeIfCooldownExpired原子条件 UPSERT |
| 5 次尝试上限 | 经login_attempt表的EMAIL_CODE类型锁定落地(claimLoginAttempt),attempts 列保留 |
| 密码重置从 JWT 迁移到验证码 | 已实现,ResetPassword校验PASSWORD_RESET码并吊销 refresh token |
| 未知邮箱自动注册 + 工作区供应 | 已实现authenticateEmailCodeLogin,先门禁、先供应工作区、再建用户 |
LoginRequest.email_code复用登录管道 | 已实现(字段 9,SENSITIVE审计标注) |
| 后台清理 | 已接入data_cleaner.go |
| 前端第三 Tab / 两步 UI / 重置页重写 | 设计文档描述的 UI 结构与 i18n 键已按规范拆解,落地细节可继续在 frontend/src/routes/workspace/general/AccountSection.tsx 等前端文件中追踪 |
需要说明的偏差:设计文档中"异步 fire-and-forget 发送"在实现中被改为同步发送(让调用方能感知邮件配置与 SMTP 故障),每部署级的发送预算(1 小时 1000 封)被引入作为 60 秒冷却之外的整部署防轰炸防线。这些演进在代码注释中均有明确理由,属于实现阶段的合理优化。
小结:一套可复用的验证码认证范式
纵观设计文档与仓库实现,Bytebase 的邮箱验证码登录为数据库治理工具提供了一套可复用的安全认证范式:以(email, purpose)为主键的专用表承载有状态语义,HMAC + 常数时间比较保护 10^6 码空间的验证码,原子条件 UPSERT 杜绝并发冷却穿透,login_attempt锁定与发送预算双层限流抵御爆破与轰炸,所有入口对未知邮箱静默成功以杜绝枚举,验证码一次一用并在重置密码后强制吊销旧会话。若你正在为自己的产品设计类似的无密码认证,这份设计与实现的对照可以成为一份完整的参考蓝本。
【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考