# 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 ```go 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_at`,legacy tombstone 覆盖 fresh expiry。 - OAuth/钱包登录在最终事务前删除身份:不得签发会话;OAuth 禁用同理。 - Security/OAuth PATCH 使用已撤销 proof:返回 `ErrTokenStale` 且设置不落库。 ### 7. Wrong vs Correct ```go // 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`)按需追加 `Login` 可用性布尔,供登录页渲染入口。