发布 v0.8.3
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
# 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` 可用性布尔,供登录页渲染入口。
|
||||
Reference in New Issue
Block a user