Files
wangdefa 23b2820101
CI / test (push) Successful in 22s
Release / release (push) Successful in 44s
发布 v0.8.3
2026-07-30 12:44:14 +08:00

7.2 KiB
Raw Permalink Blame History

Auth Login Methods(登录方式接入契约)

面板现有登录方式:密码(+可选 TOTP)、OIDC / GitHub(OAuth)、通行密钥(Passkey / WebAuthn)、Web3 钱包(EIP-4361)。 新增或修改任何登录方式时,以下契约是安全红线,缺一不可。

1. 登录守卫(防爆破)

公开登录端点的失败必须计入 loginGuard:guardKey(clientIP, <账号标识>),账号未知用固定占位名(如 passkey 的 __passkey__),已知用其标识(如钱包地址小写)。锁定检查在校验之前;失败经 auth.failLogin(自带阈值转锁定与告警推送),成功 guard.success。阈值/时长取安全设置,不硬编码。

2. 系统日志留痕

公开登录端点不经系统日志中间件,handler 必须显式 logs.Record 成败(参考 authHandler.recordLogin / passkeyHandler.recordLogin / walletHandler.recordWallet);登录失败是安全关键事件,不允许无痕。JWT 组内的绑定/解绑由中间件自动留痕,无需额外埋点。

3. 令牌版本(TokenVersion)

绑定、解绑、凭据及认证因子变更均属敏感变更:必须在事务内 bumpTokenVersionTx(或 RevokeSessions)使旧 JWT 失效,并按第 6 节携带 TokenProof、接续操作者会话。因子写入与版本递增不得拆成两个独立提交。

4. 「至少一种登录方式」不变量

密码登录禁用要求至少一种在当前 EffectiveAppURL可实际登录的免密方式 (usablePasswordlessTx);地址为空时 Passkey、钱包和 OAuth 均不可用。禁用期间移除 最后一种免密方式被拒:解绑身份走 ensureNotLastLogin,删通行密钥走对称检查。 这些检查都在事务内先 lockUserForAuthChange 锁用户行,防并发绕过。新登录方式要 明确决策如何参与该不变量:

  • 复用 UserIdentity(如钱包,provider+subject 唯一)→ 走 identityCountTx 自动参与,零改动;
  • 独立表(如 Passkey 的 UserPasskey)→ 参照 passkeyCountTx 增加计数入口,并在开启门槛(hasAnyPasswordless)与两个 ensureNot* 检查里对称接入;api 层把 ErrLastIdentity 映射 409。

5. 挑战/状态的一次性消费

挑战-响应类流程(OAuth state、WebAuthn challenge、SIWE nonce)一律进程内 map + mutex + TTL + gc,取用即删(参考 oauthPending / passkeyPending / walletPending);不落库、不引 Redis。TTL 5-10 分钟。

6. 场景:已鉴权敏感事务的提交屏障

1. Scope / Trigger

凡请求先通过 JWT 中间件、随后仍会读取 body、等待外部回调或写入认证/安全配置, 都必须防止请求处理期间的 Logout、定点撤销或撤销全部会话被旧请求绕过。

2. Signatures

type TokenProof struct {
    Ver uint
    Jti string
}

func tokenProofOf(c *gin.Context) service.TokenProof
func (s *AuthService) ensureTokenCurrentTx(
    tx *gorm.DB, user *model.User, proof TokenProof,
) error
func (s *AuthService) RenewToken(
    ctx context.Context, username, oldToken string, meta SessionMeta,
) (string, time.Time, error)

持久化契约使用 user_sessions.token_id 表示逻辑会话;敏感换发不改变该 JTI。

3. Contracts

  • RequireAuth 必须把本次请求实际观察到的 TokenVersion + JTI 写入 Context Handler 不得事后只按用户名读取“最新版本”代替 proof。
  • 敏感事务、RevokeSession 与 Logout 的数据库路径统一先锁用户行,再检查/更新 会话行;持锁后调用 ensureTokenCurrentTx
  • proof 通过后,认证因子写入、版本递增及事务内换发必须同成同败。需要在 Handler 末尾换发的旧接口,RenewToken 也须重新锁用户并确认当前版本恰为 oldClaims.Ver + 1
  • 换发沿用旧 JWT 的合法 JTI;有旧会话行时只接续未撤销行,存在但已撤销或更新 失败必须关闭。兼容期无行 Token 可建行,但仍沿用旧 JTI,因此 Logout 的 tombstone 能同时撤销换发前后的 JWT。
  • Logout 持久撤销须按 JTI 会话行解析稳定的 user_id,不得只按旧 JWT 的可变 username/subject 找用户;JTI tombstone 至少保留“旧 JWT 剩余寿命 + 一个完整 tokenTTL”,兜住 legacy 无行换发、改名提交和持久化撤销之间的 窄窗口。
  • 外部身份登录须在用户锁内重查身份仍绑定;OAuth 登录还须在最终签发事务内重查 Provider 当前可用。
  • proof 不二次检查自然到期:请求在 JWT 未过期时通过中间件即取得本次请求的认证 边界;不得为此额外引入长事务、分布式锁或持久状态机。

4. Validation & Error Matrix

条件 结果
user.TokenVersion != proof.Ver ErrTokenStale → HTTP 401
JTI tombstone 命中或会话行已撤销 ErrTokenStale → HTTP 401
旧会话行存在但接续命中 0 行 失败关闭,不得 fallback 创建
已验签当前版本的兼容 Token 确无会话行 沿用原 JTI 创建会话行
改名后用换发前 JWT Logout 按 JTI/user_id 撤销新旧 JWT;旧 subject 不参与归属
Handler 分段换发时版本不是 oldVer + 1 ErrTokenStale,前端重新登录
登录签发前身份已解绑 / Provider 已不可用 拒绝登录,不签发 JWT

5. Good / Base / Bad Cases

  • Good:TOTP 激活事务内依次锁用户、验 proof、写因子、递增版本、沿用 JTI 换发。
  • Base:无会话行的存量 JWT 可完成一次合法变更,但换发后的 JWT 与旧 JWT 共用 JTI,后到 Logout 可同时撤销。
  • Bad:proof 普通查询通过后释放锁,定点撤销完成,再把旧行更新失败当“无行”创建 新会话;这会复活已撤销 Token。

6. Tests Required

  • TOTP 激活 × 撤销全部:旧 proof 返回 ErrTokenStale,因子与版本均不改变。
  • proof check → 定点撤销 → renew:撤销后的行不得被新建替代。
  • recorded 与 legacy 无行会话各测一次“改名后 old-token Logout”:换发 JWT 必须随同失效;recorded 行写入 revoked_atlegacy tombstone 覆盖 fresh expiry。
  • OAuth/钱包登录在最终事务前删除身份:不得签发会话;OAuth 禁用同理。
  • Security/OAuth PATCH 使用已撤销 proof:返回 ErrTokenStale 且设置不落库。

7. Wrong vs Correct

// Wrong:只按最新用户状态改因子,随后无条件签最新版本 JWT。
saveFactor()
bumpTokenVersion()
return IssueToken(ctx, username)

// Correct:请求 proof、因子写入、版本递增和会话接续共享事务与用户锁。
return db.Transaction(func(tx *gorm.DB) error {
    user, err := lockUserForAuthChange(tx, username)
    if err != nil { return err }
    if err := auth.ensureTokenCurrentTx(tx, user, proof); err != nil { return err }
    return changeFactorAndRenewTx(tx, user, oldToken)
})

其他约定

  • 面板地址依赖:RP ID / 回调 / SIWE domain 均派生自 settings.EffectiveAppURL(),为空时返回引导错误(文案对齐 ErrOAuthNoAppURL),不得回退猜测。
  • 登录失败对外文案统一、不透出内部细节(防探测);错误变量集中定义在对应 service 文件头部。
  • 新端点全量 swagger 中文注释并重新生成 docs/;providers 公开端点(/auth/oauth/providers)按需追加 <method>Login 可用性布尔,供登录页渲染入口。