#886·go-admin

登录二次验证(TOTP)/ Two-factor login (TOTP)

Author: wenjianzhangCreated Aug 30, 2026Updated Aug 30, 2026

go-admin authenticates with one factor. A password that leaks — reused elsewhere, phished, sitting in someone's shell history — is the whole login. For a panel whose admin role bypasses the permission check entirely (common/middleware/permission.go), that is the whole system.

The bar I would hold this to: a user opens their profile, scans a QR code with any authenticator app, enters one code to confirm, and from then on their login asks for six digits after the password. An administrator can require it for a role, so an organisation can turn it on for the accounts that matter without forcing it on everyone. Nobody has to install an SMS provider or pay anyone.

That means TOTP (RFC 6238) first. It needs no gateway, no per-message cost, no phone number on file, and it works with Google Authenticator, 1Password, Authy and everything else people already have. Design so a second factor type can be added later; do not build SMS now — it costs money per login, and a panel that only sends codes when someone is signing in is the least valuable thing you can buy from a gateway.

Where it does not fit today

Authenticator in common/middleware/handler/auth.go binds the request, checks the captcha, compares the password, and returns the claims. jwtauth.LoginHandler then signs a token from whatever came back. There is no way to say "the password was right, but we are not done" — the function either returns claims or an error, and an error is an unauthorized response.

So the first design question is not TOTP. It is how a login becomes two steps without changing the contract every existing client depends on. My reading: POST /api/v1/login keeps returning a token for accounts without a second factor, and for accounts with one it returns a short-lived challenge instead, which POST /api/v1/login/verify exchanges for the real token along with the code. That leaves jwtauth alone — step one stops going through LoginHandler, step two calls it.

A challenge is a credential. Short expiry, single use, bound to the account it was issued for, and worth nothing on its own.

The parts that get skipped

Rate limiting is the feature, not a nicety. Six digits is a million combinations and a code stays valid for about ninety seconds. Without a lockout per account, guessing beats the factor. Failed attempts have to be counted and the account has to stop answering — that counter is the difference between two-factor authentication and the appearance of it.

A code must not work twice. TOTP is valid for a window, so a code caught in transit can be replayed inside it. The last accepted step gets recorded per user and anything at or below it is refused.

Skew, but not much. One step either side. Wider windows are how a ninety-second code quietly becomes a five-minute one.

No bypass by mode. The captcha is skipped when mode: dev (config.ApplicationConfig.Mode != "dev"). Do not do that here. An authentication factor that a config value turns off is a backdoor with a changelog entry. Local development stays easy because 2FA is off until a user enrols, not because the check is compiled out.

Recovery, and who holds it. People lose phones. Single-use backup codes, shown once at enrolment and stored hashed like passwords, cover the user. Someone still has to be able to reset an account that lost both — and that is an administrator disabling another user's second factor, which is a privilege escalation path if it is not itself gated and logged. It belongs in sys_login_log alongside the login it protects, and the entry says which factor failed and never what was entered.

The secret is not ordinary user data. It is a shared secret: whoever reads it can generate valid codes forever. Encrypted at rest, never logged, never returned by any endpoint after enrolment, and absent from the user list and export. sys_user marshals Password and Salt as json:"-" — the same applies here, and it is not enough on its own, because the risk is a database read rather than a serialised struct.

Scope

Two new columns' worth of state on the user (enrolled, secret, last accepted step), a table for backup codes, a flag on the role for "required", two endpoints for enrolment and two for login. The frontend needs an enrolment page that renders the QR and a second step on the login page, so this spans go-admin-ui as well.

I would rather this arrived without SMS, without email codes, and without WebAuthn than arrive with all four and a rate limiter someone means to add later.

If you have run TOTP enrolment in an admin panel and hit something that is not in the list above, please say so — the failure modes people actually meet are worth more than the ones I can predict.


go-admin 现在只有一层认证。密码一旦泄露——在别处复用、被钓走、留在谁的 shell history 里——登录就整个没了。而这个面板的 admin 角色是直接跳过权限校验的(common/middleware/permission.go),所以丢的不是登录,是整个系统。

我想把标准定在这里:用户打开个人资料,用任意一个认证器 App 扫一下二维码,输一次验证码确认,之后登录就在密码之后多问六位数字。管理员可以按角色强制要求,这样一个组织能只对重要账号开启,而不必强推给所有人。谁都不用装短信网关,不用给任何人付费。

所以第一步是 TOTP(RFC 6238)。它不需要网关、没有单条费用、不用留手机号,而且 Google Authenticator、1Password、Authy 以及其他大家手里已经有的东西都能用。设计上要留出加第二种因子的余地,但现在不要做短信——它按次收费,而一个只在有人登录时才发码的面板,是你能从网关买到的最不划算的东西。

现在的代码放不下它

common/middleware/handler/auth.go 里的 Authenticator 做四件事:绑定请求、校验图形验证码、比对密码、返回 claims。然后 jwtauth.LoginHandler 拿返回值签发 token。这里没有地方表达"密码对了,但还没完"——这个函数要么返回 claims,要么返回错误,而错误就是一个未授权响应。

所以第一个设计问题不是 TOTP,是怎么在不破坏现有客户端所依赖的契约的前提下,把登录变成两步。我的判断是:POST /api/v1/login 对没开二次验证的账号照旧返回 token,对开了的账号改为返回一个短时效的 challenge,再由 POST /api/v1/login/verify 带上验证码换真正的 token。这样 jwtauth 完全不用动——第一步不再走 LoginHandler,第二步才走。

challenge 本身就是一份凭据:短过期、一次性、绑定到签发它的那个账号,单独拿着一文不值。

通常会被跳过的部分

限流才是这个功能本身,不是附赠品。 六位数是一百万种组合,而一个码大约有九十秒有效。没有按账号的锁定,爆破就能打穿这层因子。失败次数必须计数、账号必须停止应答——有没有这个计数器,是"二次验证"和"看起来像二次验证"的分界线。

同一个码不能用第二次。 TOTP 在一个时间窗内有效,被中途截获的码在窗内可以重放。每个用户要记录最后一次被接受的 step,小于等于它的一律拒绝。

允许时钟偏移,但别多。 前后各一个 step。窗口开大,是九十秒的码悄悄变成五分钟的码的方式。

不要按 mode 开后门。 图形验证码在 mode: dev 时是跳过的(config.ApplicationConfig.Mode != "dev")。这里不要这么做。一个能被配置项关掉的认证因子,是一个写进了更新日志的后门。 本地开发依然轻松,是因为用户没绑定时二次验证本来就不生效,而不是因为这段检查被编译掉了。

找回,以及谁掌握它。 手机是会丢的。一次性备份码可以覆盖用户侧:注册时只展示一次,像密码一样哈希存储。但总得有人能重置两样都丢了的账号——那就是管理员关闭另一个用户的二次验证,这本身如果不加约束和记录,就是一条提权路径。 它应该和它所保护的那次登录一起进 sys_login_log,记录是哪一层因子失败,且永远不记录输入了什么。

这个密钥不是普通用户数据。 它是共享密钥:谁读到它,谁就能永远生成有效验证码。静态加密、不打日志、注册之后任何接口都不再返回、用户列表和导出里都不出现。sys_userPasswordSalt 标成了 json:"-",这里同理——但光靠这个不够,因为真正的风险是有人直接读库,不是结构体被序列化出去。

范围

用户上大约两三个字段的状态(是否已绑定、密钥、最后接受的 step),一张备份码表,角色上一个"强制要求"的开关,注册相关两个接口、登录相关两个接口。前端需要一个渲染二维码的绑定页面和登录页的第二步,所以这件事同时涉及 go-admin-ui。

比起短信、邮件验证码、WebAuthn 四样齐全但限流"回头再加",我宁愿它只带着 TOTP 落地。

如果你在后台系统里真的跑过 TOTP 绑定,遇到过上面没列到的坑,欢迎讲——真实碰到的失败模式,比我能预想的那些值钱。