- 数据库
- 灾备
【免费下载链接】databasus
PostgreSQL backup tool with Point-In-Time-Recovery and restore verification
导读
本文基于开源仓库 openspec/specs/two-factor-authentication/spec.md 中的规格说明,系统讲解 Databasus 的邮件双因子认证(Two-Factor Authentication)设计与实现。该功能允许实例所有者要求所有账号在密码登录之外再提供一个邮件验证码,同时通过"开启前前置条件校验""验证码失效与限流""主机控制台逃生命令"等机制避免管理员把自己锁在门外。读完本文,你将掌握该功能的完整开关逻辑、两步登录协议、验证码生命周期管理,以及邮件服务器故障时的恢复路径,并了解其在 backend 源码中的具体落点。
一、功能定位:实例级全局开关,默认关闭
双因子认证在 Databasus 中不是每个账号各自启用/关闭的选项,而是一个实例级(instance-wide)全局开关:
- 实例首次部署并被认领(claimed)时,开关默认处于关闭状态,此时登录仅需邮箱地址 + 密码;
- 开关一旦被管理员打开,对实例上每一个账号生效,包括初始化时记录的引导管理员(bootstrap administrator);
- 只有管理员可以修改该设置,普通成员提交的修改会被拒绝且设置保持不变;
- 打开开关不会中断已有会话:开关生效前签发的访问令牌(access token)在过期或被改密失效之前持续可用。
在数据模型层面,该开关对应users_settings表中的is_two_factor_auth_required布尔列,由迁移 backend/migrations/20260920215925_add_two_factor_auth.sql 以NOT NULL DEFAULT FALSE方式加入,保证任何已部署实例在迁移后立即处于"默认关闭"的安全初始状态。模型定义见 backend/internal/features/users/models/users_settings.go。
权限校验位于设置服务 settings_service.go 的UpdateSettings:!updatedBy.CanUpdateSettings()时直接拒绝;只有能更新设置的管理员才能翻转该开关。
二、开启前置条件:没有"可达的收件人"就拒绝开启
开启双因子认证是"可能把整个实例锁死"的动作,因此规格要求:只有当以下两个条件同时成立时,管理员才能打开开关,否则请求被拒绝,且拒绝信息必须明确点出失败的是哪个条件:
- 实例已配置邮件服务器(mail server);
- 每一个处于激活状态的(active)管理员账号都持有语法上合法的邮箱地址(syntactically valid email address)。如果某管理员地址不合法,拒绝信息必须指名是哪一个账号。
拒绝提示还会附带"如何配置邮件服务器"的文档链接;且设置界面是否展示该选项、是否提示不可用,必须与后端拒绝逻辑使用同一个判断依据(即实例自身的IsEmailConfigured答案),避免界面与后端对同一实例得出不同结论。
该逻辑在SettingsService.checkCodesCanReachEveryAdmin中实现(settings_service.go):
- 先检查
s.IsEmailConfigured()(底层为emailSender != nil && emailSender.IsConfigured()); - 再枚举所有管理员(
GetAdmins),跳过非激活账号,用validator.Var(admin.Email, "required,email")校验地址——规格特别点名的场景是:早期实例创建的登录名admin(非邮箱值)会导致开启被拒并点名该账号。
一个值得注意的细节是"关不受限制":当开关已开启而邮件服务器后来故障时,仍处于登录状态的管理员可以随时在设置界面把开关关掉,因为只有"打开"才需要前置条件校验(request.IsTwoFactorAuthRequired && !existingSettings.IsTwoFactorAuthRequired分支)。这保证了开启是唯一被防守的"锁门"动作。
三、两步登录协议:密码正确 ≠ 拿到令牌
当开关开启时,一次成功的登录被拆成两个 HTTP 步骤,路由注册见 backend/internal/features/users/controllers/user_controller.go:
| 步骤 | 端点 | 请求载荷 | 成功响应 |
|---|---|---|---|
| 1. 密码步骤 | POST /users/signin | 邮箱 + 密码 | 待处理登录标识pendingSignInId(不返回令牌) |
| 2. 验证步骤 | POST /users/verify-signin-code | pendingSignInId+ 六位码 | 访问令牌 |
在服务层,UserService.SignIn(user_services.go)依次完成:查邮箱 → 校验账号状态(邀请中/停用均拒绝)→ 确认账号有密码哈希(纯 OAuth 账号无密码,按"密码错误"同样拒绝,避免空指针)→ bcrypt 比对密码 → 读取实例设置。只有密码通过且settings.IsTwoFactorAuthRequired == true时才调用StartTwoFactorSignIn进入发码流程;否则走原有单步登录直接签发令牌。
3.1 验证码的生成与存储
验证码的生成逻辑在 backend/internal/features/users/services/two_factor_auth.go 的generateSixDigitCode:
- 从
crypto/rand(密码学安全随机源)读取 4 字节,binary.BigEndian.Uint32 % 1000000后按%06d格式化为六位数字; - 明文六位码从不落库:仅用 bcrypt(
bcrypt.GenerateFromPassword,默认 cost)保存HashedCode,数据库字段本身在 GORM 模型中标为json:"-"不外泄(见 backend/internal/features/users/models/two_factor_code.go); - 每条待处理登录在
two_factor_codes表留一行记录,字段包括user_id、password_creation_time、expires_at、is_used、failed_attempt_count、created_at,并通过外键ON DELETE CASCADE关联用户表。
3.2 邮件内容
邮件由 two_factor_email.go 渲染,主题为Sign-In Code,正文是一封 HTML 邮件,其中必须包含规格要求的三个要素:
- 六位验证码(大字、等宽、字母间距 8px 突出显示);
- "This code stops working10 minutesafter it was sent"(10 分钟后失效);
- "Requesting another code replaces this one, so only the newest code works"(重新请求会替换旧码)。
邮件还会提醒"如果你没有尝试登录,请修改密码:有人知道了你的密码"。
3.3 密码未验证前绝不发码
规格明确:必须先验证密码,才能生成或发送任何验证码。未知地址或错误密码的登录尝试必须满足三不:不发送邮件、不创建待处理登录、响应与开关关闭时完全一致——从而不泄露"该地址是否存在"或"双因子是否开启"。这一点由上述SignIn的调用顺序天然保证:所有密码校验(第 144-184 行)都发生在读取设置与调用发码逻辑(第 186 行之后)之前。
四、验证码生命周期:过期、猜测上限、重发与小时配额
这是整个规格最细致也最值得展开的部分,全部常量定义在 two_factor_auth.go:
| 规则 | 取值 | 常量/实现点 |
|---|---|---|
| 验证码有效期 | 10 分钟 | twoFactorCodeLifetime = 10 * time.Minute |
| 错误码上限 | 5 次,达到即销毁待处理登录 | MaxTwoFactorCodeAttempts = 5(two_factor_code.go) |
| 重发间隔 | 同一账号每分钟最多 1 次 | twoFactorResendWindow = time.Minute,限流 scopesignin-code-resend |
| 小时发码配额 | 同一账号每小时最多 5 个码 | maxTwoFactorCodesPerHour int64 = 5 |
| 记录保留 | 1 小时(与配额统计窗口一致) | twoFactorCodeRetention = time.Hour |
4.1 一次性使用与并发安全
规格对"一次性"的要求非常严格:同一验证码完成过一次登录后,再次提交必须被拒绝;两个携带同一正确验证码的并发请求同时到达时,恰好只有一个能拿到令牌。实现上,VerifyTwoFactorCode(two_factor_auth.go)先通过ClaimAttempt原子占用一次尝试额度(并发请求中只有一次能成功 claim),再比对 bcrypt 哈希,最后通过SpendCode原子"花掉"该码——SpendCode返回 false(已被花掉)即拒绝,从而保证"恰好一个成功"。
4.2 错误码与并发猜测
错误码上限同样具备并发安全性:refuseWrongTwoFactorCode在attemptCount达到 5 时销毁待处理登录并写入审计日志("Two-factor sign-in abandoned after too many incorrect codes")。规格要求"并发到达的错误码共享同一个 5 次上限,绝不超过 5 个码被实际检查"——这由ClaimAttempt的原子占用保证:超出额度的请求连码都不会被比对。
4.3 重发(resend)
ResendTwoFactorCode(two_factor_auth.go)的逻辑非常讲究顺序:
- 先加载当前可用的待处理登录(不存在/已用/已销毁/过期则拒绝);
- 检查"每分钟 1 次"的重发限流,超限直接拒绝且不发邮件;
- 调用
issueTwoFactorCode生成新码并尝试入库(受小时配额约束); - 只有新码确实发出/落库后,才把旧码
MarkCodeAsUsed标记失效。
代码注释明确说明这样设计的原因:"The previous code is invalidated only once the replacement is on its way, so a refused resend leaves the user with the code they already have"——被拒绝的重发不会把用户手上还能用的旧码弄失效。响应体中携带的是新待处理登录的pendingSignInId。
4.4 小时配额与"回到密码页"的友好语义
规格包含一个反直觉但很贴心的设计:重复执行密码步骤不会白费小时配额。当账号已存在一个"存活"的待处理登录(未过期、未使用、未被错误码销毁、且是当前密码创建的)时,再次提交正确密码会原样返回同一个 pendingSignInId,且不发送第二封邮件。实现上这是issueTwoFactorCode的shouldReuseLiveCode=true分支:CreateCodeUnlessLive发现存活记录则直接返回它。两个并发密码步骤之间也保证只产生一条记录、一封邮件。
但有两类情况会打破复用:
- 密码已改变:待处理登录记录了创建时的
PasswordCreationTime,用户改密后用新密码登录,会得到一个全新待处理登录(规格场景:"改密后再登录")。这正是模型注释所说的:"The password the first step accepted, so the second step can refuse a pending sign-in whose account has changed its password since"。 - 小时配额耗尽:
CreateCodeWithinHourlyCap在配额耗尽时返回ErrHourlyCodeCapReached,最终映射为ErrTooManySignInCodes,响应明确告知"请求了太多验证码",而不是把用户引向一个永远不会到达邮件的验证码页面。
4.5 过期记录清扫
SweepPendingSignInsPastRetention通过DeleteCodesCreatedBefore(now - 1h)清理超过保留窗口的记录。规格要求"待处理登录保留的时长与其配额统计的窗口一致,且不再保留之后"——表上同时建有idx_two_factor_codes_user_id与idx_two_factor_codes_created_at索引(见迁移文件),后者正是供"小时配额统计"与"定时清扫"两条路径共同读取created_at使用。注意:销毁(destroyed)的记录行会保留到清扫为止,因为小时配额仍要统计它。
五、验证步骤的二次复核:签发令牌前重新检查
规格要求:在签发访问令牌之前,实例必须重新确认待处理登录背后的账号"现在仍然可以进入":
- 账号仍然存在;
- 账号仍然处于激活状态(active);
- 密码自待处理登录创建以来没有改变。
任一条件不满足,无论提交什么验证码都拒绝。这些检查集中在loadUsablePendingSignIn(two_factor_auth.go):
if pendingCode == nil || !pendingCode.IsValid() { /* 拒绝 */ } user, err := s.userRepository.GetUserByID(ctx, pendingCode.UserID) // 账号仍存在 if !user.IsActiveUser() { /* 拒绝:账号被停用 */ } if !user.PasswordCreationTime.Truncate(time.Microsecond).Equal( pendingCode.PasswordCreationTime.Truncate(time.Microsecond)) { /* 拒绝:密码已变 */ }注释点明了设计意图:"The pending sign-in proves that a password was correct minutes ago, not that the account may be let in now"。同时,IsTwoFactorAuthRequired故意不参与二次复核:待处理登录在开关开启时创建、但验证发生在开关被关闭之后,仍然可以完成——不能让关闭开关把已经收到验证码的用户晾在半路。该行为由测试Test_VerifySignInCode_AfterTheSettingWasSwitchedOff_StillCompletes锁定。
六、第二道门同样受防滥用保护
验证码端点与密码登录、密码重置请求享有同等强度的自动化滥用防护:
- 验证与重发请求在执行任何验证码校验、发送任何邮件之前,先经过与登录一致的防护检查(包括实例配置了人机验证挑战如 Cloudflare Turnstile 时的挑战校验);
- 请求必须以实例最近一次返回的
pendingSignInId来指名待处理登录,绝不接受邮箱地址作为指名方式——因此仅知道别人的邮箱地址,无法消耗该账号的尝试次数、重发额度或小时配额。这正是规格中"用地址指名账号"场景(并发场景下无法消耗对方额度)被拒绝的原因。
实现上,VerifySignInCodeRequestDTO与ResendSignInCodeRequestDTO均以binding:"required"强约束pendingSignInId字段(见 backend/internal/features/users/dto/dto.go),而验证码校验逻辑只从GetCodeByID(pendingSignInID)出发,全程不接触"地址"这一维度的输入。
错误响应还带有稳定的错误码供前端翻译,见respondToSignInError(user_controller.go):
| 错误码 | HTTP 状态 | 含义 |
|---|---|---|
sign_in_code_incorrect | 400 | 验证码错误 |
pending_sign_in_not_usable | 410 Gone | 待处理登录已用/已销毁/已过期/账号不再可用 |
sign_in_code_not_sent | 503 | 验证码未能发送(邮件服务器故障) |
too_many_sign_in_codes | 429 | 超过小时配额 |
sign_in_code_resent_too_soon | 429 | 一分钟内重复重发 |
七、失败即关闭(Fail Closed):发不出码就不放行
规格要求:如果实例无法发送验证码,登录必须失败,响应明确说明"验证码无法发送"(code could not be sent),且不签发访问令牌。这意味着开关开启期间,如果邮件服务器停止工作,实例在密码登录路径上就不会放任何人进入。
issueTwoFactorCode的兜底逻辑(two_factor_auth.go)分两层实现:
- 入口检查:
s.emailSender == nil || !s.emailSender.IsConfigured()时直接返回ErrSignInCodeNotSent,在生成验证码之前就拒绝; - 发送失败兜底:
SendEmail返回错误时,把刚创建的待处理登录MarkCodeAsUsed标记为不可用再返回ErrSignInCodeNotSent——保证用户永远不会看到一个对应邮件不会到达的验证码页面。注释精确描述了这一设计:"Nothing is created before the instance has said it can deliver, and a send that fails leaves the row it counted behind while making it unusable"。
这一"关闭则锁死"的行为由Test_SignIn_WhenTheMailServerIsMissing_FailsClosed与Test_SignIn_WhenTheSendFails_FailsClosed两个测试用例锁定。
八、明确边界:外部身份提供商不受双因子约束
规格明确:通过 Google 或 GitHub 的第三方登录不受邮件双因子约束,无论开关是否开启,都直接签发访问令牌、不需要邮件验证码。这是有意为之的例外——这些提供商自身执行各自的多因子校验,允许它们接入的实例等于接受"双因子只守护密码登录"。相关接口与文档均不得声称双因子覆盖外部身份提供商。
路由注册中可见,OAuth 回调(POST /auth/github/callback、POST /auth/google/callback)是独立端点,不经过验证码流程;且SignIn中"账号没有密码哈希则按密码错误拒绝"的分支,正好覆盖了纯 OAuth 账号无法走密码双因子路径的情况。
九、主机逃生通道:单命令关闭开关
当邮件服务器彻底不可用、管理员又收不到任何验证码时,规格要求实例所有者仅凭一条命令就能在主机上关闭全局开关,无需手工编辑数据库:
./backend --disable-2fa对应实现位于 backend/cmd/main.go 与disableTwoFactorAuthIfRequested(backend/cmd/main.go):
- 命令行标志名刻意写作
disable-2fa(注释说明:这是被死邮件服务器锁在门外的主人会搜索的术语,"The name carries a digit, unlike every other flag here"); - 命令调用
SettingsService.DisableTwoFactorAuth(settings_service.go):设置已关闭时返回false并打印 "already off. Nothing changed."(幂等、无副作用);真正翻转时打印 "Two-factor authentication is now off." 并向审计日志写入一条记录(isTwoFactorAuthRequired: true -> false (switched off from the host console))——让"从主机移动安全开关"这一动作无法被无声完成; - 命令的信任级别与
--new-password(密码重置)、--list-admins(管理员列表)一致:只有已经能在运行实例内执行命令的人才能使用,不额外要求登录态。
这正好构成一条完整的恢复路径:实例死锁 → 主机执行--disable-2fa→ 审计日志留下痕迹 → 下一次密码登录无需验证码。
十、配套文档与测试佐证
规格的最后一条要求是"把双因子作为产品安全叙事的一部分":仓库 README 与官网安全页、首页安全问答需要在所有发布语言中声明"登录可要求邮件验证码",且不得声称覆盖外部身份提供商;--disable-2fa命令需要与其他恢复命令(密码重置等)并列文档化,让被死邮件服务器锁定的主人能在找密码恢复的地方找到它。
实现与行为的可信度由一套针对性测试保障,核心用例集中在 backend/internal/features/users/controllers/two_factor_controller_test.go,覆盖规格中的关键场景:
- 两步登录完成、验证码仅以哈希存储、邮件内容要素(
Test_SignIn_WhenTheSecondFactorIsOn_...、Test_SignIn_CodeMessage_CarriesTheCodeAndTheRulesItFollows); - 一次性使用、过期拒绝、跨待处理登录的码无效、5 次错误后正确码也失效(
Test_VerifySignInCode_...系列); - 一分钟内重发被拒、重发后旧码失效、存活待处理登录复用不发第二封邮件、小时配额耗尽提前拒绝(
Test_SignIn_WhileAPendingSignInIsStillLive_ReturnsItAndSendsNothing等); - 账号停用/改密后拒绝、并发同码仅一令牌、并发错误码不超过上限、设置关闭后验证仍可完成、拒绝错误码可被界面翻译。
总结
Databasus 的邮件双因子认证在"安全增强"与"防锁死"之间做了精心权衡:实例级开关默认关闭、开启前校验邮件服务器与管理员邮箱可达性、密码验证通过后才发码、10 分钟有效期的六位 bcrypt 哈希码、5 次错误上限与并发安全的一次性消费、1 分钟重发限流与 5 码/小时配额、签发令牌前的二次复核、发码失败即拒绝登录,外加外部 OAuth 明确豁免与主机--disable-2fa逃生命令。这套机制从规格(openspec/specs/two-factor-authentication/spec.md)到实现(two_factor_auth.go、settings_service.go、20260920215925_add_two_factor_auth.sql)再到测试层层对应,适合作为理解该功能行为契约与运维恢复路径的一手参考。
- 数据库
- 灾备
【免费下载链接】databasus
PostgreSQL backup tool with Point-In-Time-Recovery and restore verification
相关推荐
Databasus 邮件双因素认证(Email 2FA)设计解析:从失败关闭到控制台逃生通道
Databasus 邮件双因素认证(Email 2FA)设计解析:从失败关闭到控制台逃生通道 导读 本文基于 Databasus 仓库中 openspec/ch
数据库灾备Databasus 邮件二次认证(Email 2FA)完整实现指南:从全局开关到两步登录与主机逃生通道
Databasus 邮件二次认证(Email 2FA)完整实现指南:从全局开关到两步登录与主机逃生通道 本文基于仓库中 openspec/changes/arc
数据库灾备File structure of working directory {{folder}}
File structure of working directory {{folder}} this is filtered overview not ful
数据库灾备
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考