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

133 lines
7.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`)按需追加 `<method>Login` 可用性布尔,供登录页渲染入口。