Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23b2820101 | ||
|
|
109c345e5e | ||
|
|
f1914880ac | ||
|
|
a95a8255bf | ||
|
|
8894330eba | ||
|
|
f51fb6c722 |
+1
-1
@@ -1 +1 @@
|
|||||||
0.6.6
|
0.6.10
|
||||||
@@ -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` 可用性布尔,供登录页渲染入口。
|
||||||
@@ -47,6 +47,7 @@ go func() {
|
|||||||
- 非流式长调用:`dispatcherWithTimeout` 值拷贝 client 换总超时(保留 Transport,代理链路不受影响),预算走设置项(`ai_upstream_wait_seconds`,缺省 300s)。
|
- 非流式长调用:`dispatcherWithTimeout` 值拷贝 client 换总超时(保留 Transport,代理链路不受影响),预算走设置项(`ai_upstream_wait_seconds`,缺省 300s)。
|
||||||
- 流式:总超时必须为 0;等待响应头预算用 `callWithHeaderBudget`(`context.WithCancel` + `time.AfterFunc(wait, cancel)`,响应头到达即 `timer.Stop()`),返回 `cancelReadCloser` 保证流 Close 时取消派生 ctx。流建立后的生命周期交由请求方 ctx(客户端断开自动取消)。
|
- 流式:总超时必须为 0;等待响应头预算用 `callWithHeaderBudget`(`context.WithCancel` + `time.AfterFunc(wait, cancel)`,响应头到达即 `timer.Stop()`),返回 `cancelReadCloser` 保证流 Close 时取消派生 ctx。流建立后的生命周期交由请求方 ctx(客户端断开自动取消)。
|
||||||
- SDK 的 Transport 是 `OciHTTPTransportWrapper`,**不是** `*http.Transport`,设不了 `ResponseHeaderTimeout`——用 ctx 定时取消模拟,勿依赖类型断言 Transport。
|
- SDK 的 Transport 是 `OciHTTPTransportWrapper`,**不是** `*http.Transport`,设不了 `ResponseHeaderTimeout`——用 ctx 定时取消模拟,勿依赖类型断言 Transport。
|
||||||
|
- 经租户出口的**所有** HTTP 外呼(不只 SDK 调用,含 SAML 元数据抓取这类裸 http.Client)都必须复用该租户代理;代理配置非法时**失败关闭**报错,不得静默直连暴露服务器 IP(2026-07-22 审查 #7,federation.go `metadataHTTPClient`)。
|
||||||
- 自建代理 Transport(proxyhttp.go)必须补阶段超时(Dial 30s / TLS 握手 10s,对齐 SDK 直连模板);`http.Transport` 零值这些字段=无超时,总超时一旦去掉就会裸奔。
|
- 自建代理 Transport(proxyhttp.go)必须补阶段超时(Dial 30s / TLS 握手 10s,对齐 SDK 直连模板);`http.Transport` 零值这些字段=无超时,总超时一旦去掉就会裸奔。
|
||||||
|
|
||||||
## 出站连接复用与批量删除(2026-07 删桶超时复盘)
|
## 出站连接复用与批量删除(2026-07 删桶超时复盘)
|
||||||
|
|||||||
@@ -1,75 +1,32 @@
|
|||||||
# Database Guidelines
|
# 数据库规范
|
||||||
|
|
||||||
> Database patterns and conventions for this project.
|
> GORM + SQLite(可选 MySQL/PostgreSQL)实战约定;模型在 `internal/model/`,连接与 AutoMigrate 在 `internal/database/`。
|
||||||
|
|
||||||
---
|
## 租户级数据删除
|
||||||
|
|
||||||
## Overview
|
- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
|
||||||
|
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
|
||||||
<!--
|
- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
|
||||||
Document your project's database conventions here.
|
- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
|
||||||
|
|
||||||
Questions to answer:
|
|
||||||
- What ORM/query library do you use?
|
|
||||||
- How are migrations managed?
|
|
||||||
- What are the naming conventions for tables/columns?
|
|
||||||
- How do you handle transactions?
|
|
||||||
-->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Query Patterns
|
|
||||||
|
|
||||||
<!-- How should queries be written? Batch operations? -->
|
|
||||||
|
|
||||||
### 租户级数据删除
|
|
||||||
|
|
||||||
- 租户主体与本地关联数据必须在同一 GORM transaction 中按“子记录→父记录”删除,每步错误用 `%w` 返回,不得忽略。
|
|
||||||
- JSON payload/Setting 键等非外键引用要显式枚举并改写;不能只依赖 `AutoMigrate` 或 ORM association 推断级联范围。
|
|
||||||
- 与后台任务、Webhook、解析器等并发写入交叉时,先定义全局一致的行锁顺序,并在写入前重新确认父记录存在。
|
|
||||||
- 进程内 cron/缓存只在事务提交后同步;客户端取消不应中断已提交删除的必需运行时对齐。
|
|
||||||
- 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。
|
- 批量删除/清理遇到**无法解析的 JSON payload** 时记 `log.Printf` 警告并跳过该行(原样保留),不 fail-closed 阻断整个流程——坏数据不应把删除逼到手工修库(tenantdelete.go `logSkippedTask`)。
|
||||||
|
|
||||||
### 大集合谓词用子查询,禁止展开 IN 列表
|
## 大集合谓词用子查询,禁止展开 IN 列表
|
||||||
|
|
||||||
- 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `WHERE x IN (SELECT …)` 子查询,**不要**先 Pluck ID 再 `IN ?` 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。
|
- 行数无上界的集合(日志事件、调用日志等)做关联删除/查询时,一律 `WHERE x IN (SELECT …)` 子查询,**不要**先 Pluck ID 再 `IN ?` 展开:绑定变量有硬上限(modernc SQLite 32766,MySQL/PG 65535),数万行即失败且重试无解。
|
||||||
- GORM 写法:把 `tx.Model(&T{}).Select("id").Where(...)` 作为参数传入 `Where("x IN (?)", sub)`(见 tenantdelete.go `deleteAlertHits` / `alertHitRuleIDs`)。
|
- GORM 写法:把 `tx.Model(&T{}).Select("id").Where(...)` 作为参数传入 `Where("x IN (?)", sub)`(见 tenantdelete.go `deleteAlertHits` / `alertHitRuleIDs`)。
|
||||||
|
- 例外:**同表自删**(删除条件子查询引用被删表)MySQL 报 1093,改按主键排序的固定批次循环删(aigateway.go `cleanupTable`,每批 10000,单批失败记日志退出)。
|
||||||
- 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。
|
- 若原本的 Pluck 兼有 `FOR UPDATE` 锁定语义,保留锁定 SELECT 本身,只是不再把结果拼进后续 SQL(`lockTenantEventRows`)。
|
||||||
- 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。
|
- 行数有小上界的集合(渠道、规则等配置类)可以继续用内存 ID 列表。
|
||||||
|
|
||||||
---
|
## Common Mistake: 用 `Save` 持久化在途任务的陈旧快照
|
||||||
|
|
||||||
## Migrations
|
**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。
|
||||||
|
|
||||||
<!-- How to create and run migrations -->
|
**Cause**:GORM `Save` 在 `UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。
|
||||||
|
|
||||||
(To be filled by the team)
|
**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`。
|
||||||
|
|
||||||
---
|
## Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
|
|
||||||
<!-- Table names, column names, index names -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Mistakes
|
|
||||||
|
|
||||||
<!-- Database-related mistakes your team has made -->
|
|
||||||
|
|
||||||
### Common Mistake: 用 `Save` 持久化在途任务的陈旧快照
|
|
||||||
|
|
||||||
**Symptom**:一条记录已被另一事务删除,在途任务随后执行 `Save` 却把该行重新插入,或覆盖并发更新后的 payload。
|
|
||||||
|
|
||||||
**Cause**:GORM `Save` 在 `UPDATE` 零命中时会回退到 `CREATE`/upsert,不适合持久化长时运行开始时读取的快照。
|
|
||||||
|
|
||||||
**Fix**:用 `WHERE id = ? AND updated_at = ?` 的条件 `Updates`,并严格要求 `RowsAffected == 1`;零命中表示记录已删除或版本已变,不得补做 `Create`。
|
|
||||||
|
|
||||||
### Common Mistake: gorm 读 NULL 列到已有值的结构体字段不会清零
|
|
||||||
|
|
||||||
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
|
**Symptom**:UPDATE 把可空列(如 `*time.Time`)写成 NULL 后,用**同一个结构体变量**再次 `First()` 读回,该字段仍是旧值;而新变量读取正常。断言/返回值出现「幽灵旧值」。
|
||||||
|
|
||||||
@@ -79,7 +36,7 @@ Questions to answer:
|
|||||||
|
|
||||||
**Prevention**:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 `First` 同一行也各用独立变量。另:map Updates 写 NULL 用 `gorm.Expr("NULL")` 最稳(无类型 `nil` 在部分路径下不生效)。
|
**Prevention**:凡「UPDATE 后回读返回」的服务方法,都声明新变量接收;测试中多次 `First` 同一行也各用独立变量。另:map Updates 写 NULL 用 `gorm.Expr("NULL")` 最稳(无类型 `nil` 在部分路径下不生效)。
|
||||||
|
|
||||||
### Common Mistake: 需要"条件更新多列含 NULL"时的写法
|
## Common Mistake: 需要"条件更新多列含 NULL"时的写法
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 正确:零写库条件 + 显式 NULL
|
// 正确:零写库条件 + 显式 NULL
|
||||||
@@ -88,13 +45,13 @@ db.Model(&model.AiChannel{}).
|
|||||||
Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")})
|
Updates(map[string]any{"fail_count": 0, "disabled_until": gorm.Expr("NULL")})
|
||||||
```
|
```
|
||||||
|
|
||||||
### `serializer:json` 字段走 map Updates 时手动 marshal
|
## `serializer:json` 字段走 map Updates 时手动 marshal
|
||||||
|
|
||||||
- 切片/结构体字段用 `gorm:"serializer:json;type:text"` 声明(如 `AiKey.Models []string`),Create/First/struct 路径自动序列化;
|
- 切片/结构体字段用 `gorm:"serializer:json;type:text"` 声明(如 `AiKey.Models []string`),Create/First/struct 路径自动序列化;
|
||||||
- 但 `Updates(map[string]any{...})` 路径不要依赖 GORM 对 map 值应用 serializer——把值 `json.Marshal` 成 string 放进 map(见 aigateway.go `UpdateKey`),行为版本无关且可测;
|
- 但 `Updates(map[string]any{...})` 路径不要依赖 GORM 对 map 值应用 serializer——把值 `json.Marshal` 成 string 放进 map(见 aigateway.go `UpdateKey`),行为版本无关且可测;
|
||||||
- 存储格式与 serializer 一致(JSON 文本),读回仍走自动反序列化;`nil` 切片 marshal 为 `null`,读回 nil,天然表达「空 = 不限」语义。
|
- 存储格式与 serializer 一致(JSON 文本),读回仍走自动反序列化;`nil` 切片 marshal 为 `null`,读回 nil,天然表达「空 = 不限」语义。
|
||||||
|
|
||||||
### Common Mistake: 进程内缓存键漏掉查询维度
|
## Common Mistake: 进程内缓存键漏掉查询维度
|
||||||
|
|
||||||
**Symptom**:多区间(compartment)租户在前端切换区间后,实例/卷/VCN 列表短暂显示上一个区间的数据(TTL 窗口内)。
|
**Symptom**:多区间(compartment)租户在前端切换区间后,实例/卷/VCN 列表短暂显示上一个区间的数据(TTL 窗口内)。
|
||||||
|
|
||||||
@@ -103,3 +60,7 @@ db.Model(&model.AiChannel{}).
|
|||||||
**Fix**:键值加入 `cred.CompartmentID`(空 = 租户根,天然区分)。
|
**Fix**:键值加入 `cred.CompartmentID`(空 = 租户根,天然区分)。
|
||||||
|
|
||||||
**Prevention**:缓存键必须覆盖影响回源结果的**全部**输入维度(租户、区间、区域、过滤参数);给 Credentials/查询结构体新增会改变结果的字段时,同步检查 `ckey` 调用点;隔离行为写进 `cached_test.go` 的 isolation 用例。
|
**Prevention**:缓存键必须覆盖影响回源结果的**全部**输入维度(租户、区间、区域、过滤参数);给 Credentials/查询结构体新增会改变结果的字段时,同步检查 `ckey` 调用点;隔离行为写进 `cached_test.go` 的 isolation 用例。
|
||||||
|
|
||||||
|
## GORM 列名与 map 更新
|
||||||
|
|
||||||
|
- GORM 默认命名把连续大写按公共缩写拆分:`UserOCID` → `user_oc_id`、`TenancyOCID` → `tenancy_oc_id`(不是 `user_ocid`)。用 `Updates(map[string]any{...})` 写裸列名前先确认真实列名(`schema.Parse` 或查建表 SQL),SQLite 下写错列名报 "no such column" 才暴露,MySQL/PG 同错。零值字段(如清空 `passphrase_enc`)必须走 map 更新,struct 更新会忽略零值。(2026-07-23 activate-api-key 切换签名用户)
|
||||||
|
|||||||
@@ -41,3 +41,7 @@ func sanitizeURLError(err error) error {
|
|||||||
- `ListModels` **没有字段**能事先区分 on-demand / dedicated 供给,只能在调用报错时习得;持久剔除依赖用户维护的模型黑名单(ai_model_blacklists,按模型名全局过滤,同步/探测不入库),错误消息应带模型名提示用户拉黑。
|
- `ListModels` **没有字段**能事先区分 on-demand / dedicated 供给,只能在调用报错时习得;持久剔除依赖用户维护的模型黑名单(ai_model_blacklists,按模型名全局过滤,同步/探测不入库),错误消息应带模型名提示用户拉黑。
|
||||||
|
|
||||||
**约定**:识别特定语义一律走 `internal/oci/errors.go` 的判定函数(`IsModelUnavailable` / `IsEntityNotFound` / `IsOnDemandUnsupported`),用 `errors.As` 取 `common.ServiceError` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。
|
**约定**:识别特定语义一律走 `internal/oci/errors.go` 的判定函数(`IsModelUnavailable` / `IsEntityNotFound` / `IsOnDemandUnsupported`),用 `errors.As` 取 `common.ServiceError` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。
|
||||||
|
|
||||||
|
## 外部响应体必须限长,超限报错而非静默截断
|
||||||
|
|
||||||
|
读上游/外部响应体一律 `io.LimitReader(max+1)` 再判长度:恰好读到 max+1 说明超限,返回带上限值的错误;不得截断后当成功继续(截断的 JSON/流式响应会以 200 返回坏数据,2026-07-22 审查 #13,genai_responses.go `readCompatBody`)。
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# IdP 图标上传与清理契约
|
||||||
|
|
||||||
|
## 1. Scope / Trigger
|
||||||
|
|
||||||
|
- 适用于创建 SAML IdP 前,先把图标上传到 Identity Domains 公共图片存储的流程。
|
||||||
|
- 上传先于 IdP 创建,图片在创建成功前是临时远端资源;前端做**尽力清理**,
|
||||||
|
孤儿(自己租户里一张无入口小图)可接受,不为它建所有权状态机。
|
||||||
|
|
||||||
|
## 2. Signatures
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>
|
||||||
|
DELETE /api/v1/oci-configs/{id}/idp-icons?domainId=<optional>&fileName=<required>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
uploadIdpIcon(id: number, file: File, domainId?: string): Promise<IdpIconUpload>
|
||||||
|
deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void>
|
||||||
|
|
||||||
|
interface IdpIconUpload {
|
||||||
|
url: string // 写入 IdP iconUrl 的公网地址
|
||||||
|
fileName: string // 仅用于清理的存储标识
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
上游 Identity Domains 契约(SDK 未覆盖,走 BaseClient 裸调):
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /storage/v1/Images multipart: file + fileName
|
||||||
|
DELETE /storage/v1/Images query: fileName
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Contracts
|
||||||
|
|
||||||
|
- POST 的 `file` 是唯一文件字段;文件本体最大 `1 MiB`,由 service 精确限制;
|
||||||
|
路由级 body limit 为 multipart 封装开销另留余量,两者不是同一契约。
|
||||||
|
- 客户端原始文件名只用于取扩展名。真正上传名由服务端随机生成
|
||||||
|
`idp-icon-<32 lowercase hex><lowercase ext>`:既避免并发/迟到响应下同名互相
|
||||||
|
覆盖,又让存储名成为不可猜测的清理凭据。
|
||||||
|
- 内容校验只做**扩展名白名单 + 文件头魔数嗅探**(png/jpg/jpeg/gif/webp/ico/svg),
|
||||||
|
不做深度解码与结构校验:图标由管理员为自己租户上传、由 Oracle 域名托管,
|
||||||
|
传错内容只会让自己登录页图标裂开,深度校验的复杂度与误伤承担不起
|
||||||
|
(2026-07 曾过度实现完整解码套件后精简)。
|
||||||
|
- DELETE 只接受单层 `images/idp-icon-<32 lowercase hex>.<allowed ext>`,
|
||||||
|
不得因共享 `images/` 前缀删除其他域资产;上游 404 视为幂等成功,返回 204。
|
||||||
|
- 前端上传成功时保存 `{cfgId, domainId, url, fileName}` 原始元组作 pendingIcon,
|
||||||
|
用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除,
|
||||||
|
失败静默。创建请求引用了它且返回 2xx(含 `201 + setupWarning`)即视为已采用。
|
||||||
|
- IdP 创建是多步远端事务:创建后 JIT 映射失败先回滚删除刚创建的禁用 IdP;
|
||||||
|
回滚成功按普通失败,回滚失败/无法确认返回 `201` 和
|
||||||
|
`setupWarning{code: JIT_SETUP_INCOMPLETE, resourceCreated, requestId}`;
|
||||||
|
内部原因只按 requestId 写服务端日志,不进响应。
|
||||||
|
- 前端在身份设置加载完成前禁用提交;后端创建前再次读取域设置,
|
||||||
|
`primaryEmailRequired=true` 强制 JIT 邮箱映射,查询失败时不创建 IdP。
|
||||||
|
|
||||||
|
## 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
| 条件 | HTTP |
|
||||||
|
| --- | --- |
|
||||||
|
| 缺少 multipart `file`、文件为空或文件名非法 | 400 |
|
||||||
|
| 文件本体超过 1 MiB | 413 |
|
||||||
|
| 扩展名不支持或与文件头魔数不符 | 415 |
|
||||||
|
| DELETE 缺少 `fileName` 或不是本服务生成的图标名 | 400 |
|
||||||
|
| 上游 DELETE 返回 404 | 204 |
|
||||||
|
| IdP 创建后 JIT 失败且回滚无法确认 | 201 + `setupWarning` |
|
||||||
|
| 其余上游/内部错误 | 统一错误边界,不泄露上游响应正文 |
|
||||||
|
|
||||||
|
## 5. Tests / Verification Required
|
||||||
|
|
||||||
|
- 完整路由:恰好 1 MiB → 200、1 MiB+1 → 413、空文件 → 400、伪造扩展名 → 415。
|
||||||
|
- 存储名:相同原始名连续上传得到不同随机名;熵源失败不调用上游;
|
||||||
|
DELETE 拒绝非 IdP 前缀、子目录、错误 token 长度/大小写与非允许扩展。
|
||||||
|
- 魔数:各允许格式最小样本通过,扩展名与内容不符、未知扩展名被拒。
|
||||||
|
- 多步创建:初始创建失败、JIT 失败且回滚成功、回滚失败/ID 缺失三分支;
|
||||||
|
后两者分别锁定普通失败与 `setupWarning` 契约,响应不泄露底层 cause。
|
||||||
|
- 域设置:主邮箱必填强制映射、设置查询失败零创建、关闭 JIT 跳过查询。
|
||||||
|
|
||||||
|
## 6. Wrong vs Correct
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Wrong:用清理时的 props 删旧图标——弹窗可能已切到别的租户/域。
|
||||||
|
onBeforeUnmount(() => deleteIdpIcon(props.cfgId, uploaded.fileName, props.domainId))
|
||||||
|
|
||||||
|
// Correct:上传时捕获原始元组,清理只按元组走,失败静默。
|
||||||
|
pendingIcon = { ...uploaded, cfgId, domainId }
|
||||||
|
void deleteIdpIcon(pendingIcon.cfgId, pendingIcon.fileName, pendingIcon.domainId).catch(() => {})
|
||||||
|
```
|
||||||
|
|
||||||
|
反例(勿复现):为图标这种低价值临时资源实现「冻结所有权 + 结果未知协议 +
|
||||||
|
卸载钩子」的完整状态机,或对上传内容做完整解码/结构/主动内容校验——
|
||||||
|
复杂度与威胁模型不匹配,已于 2026-07 精简移除。
|
||||||
@@ -19,9 +19,10 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
|
|||||||
| [Quality Guidelines](./quality-guidelines.md) | 工具链检查与命名规范 | 已填 |
|
| [Quality Guidelines](./quality-guidelines.md) | 工具链检查与命名规范 | 已填 |
|
||||||
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
|
| [Concurrency](./concurrency.md) | goroutine 生命周期与 context 传递 | 已填 |
|
||||||
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
|
| [Testing](./testing.md) | table-driven 测试要求 | 已填 |
|
||||||
| [Database Guidelines](./database-guidelines.md) | ORM 模式、查询、迁移 | 部分已填 |
|
| [IdP Icon Upload](./idp-icon-upload.md) | IdP 公共图标上传、校验与清理契约 | 已填 |
|
||||||
|
| [Database Guidelines](./database-guidelines.md) | GORM 实战约定与常见坑 | 已填 |
|
||||||
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
|
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
|
||||||
| [Logging Guidelines](./logging-guidelines.md) | 结构化日志、日志级别 | 待填 |
|
| [Auth Login Methods](./auth-login-methods.md) | 登录方式接入契约(守卫/留痕/令牌版本/不变量/一次性挑战) | 已填 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
|
|||||||
- [ ] 读过 [Directory Structure](./directory-structure.md),新代码放对了包(云请求只出现在 `internal/oci/`)
|
- [ ] 读过 [Directory Structure](./directory-structure.md),新代码放对了包(云请求只出现在 `internal/oci/`)
|
||||||
- [ ] 阻塞 / 远程调用函数第一个参数是 `ctx context.Context`(见 [Concurrency](./concurrency.md))
|
- [ ] 阻塞 / 远程调用函数第一个参数是 `ctx context.Context`(见 [Concurrency](./concurrency.md))
|
||||||
- [ ] 错误按 [Error Handling](./error-handling.md) 用 `%w` 包装向上返回
|
- [ ] 错误按 [Error Handling](./error-handling.md) 用 `%w` 包装向上返回
|
||||||
- [ ] 复用已有封装,不重复造轮子(见 `../guides/code-reuse-thinking-guide.md`)
|
- [ ] 复用已有封装,不重复造轮子(动手前先 grep 相似函数 / 常量 / 模式)
|
||||||
|
|
||||||
## Quality Check
|
## Quality Check
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
# Logging Guidelines
|
|
||||||
|
|
||||||
> How logging is done in this project.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Document your project's logging conventions here.
|
|
||||||
|
|
||||||
Questions to answer:
|
|
||||||
- What logging library do you use?
|
|
||||||
- What are the log levels and when to use each?
|
|
||||||
- What should be logged?
|
|
||||||
- What should NOT be logged (PII, secrets)?
|
|
||||||
-->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Log Levels
|
|
||||||
|
|
||||||
<!-- When to use each level: debug, info, warn, error -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Structured Logging
|
|
||||||
|
|
||||||
<!-- Log format, required fields -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What to Log
|
|
||||||
|
|
||||||
<!-- Important events to log -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What NOT to Log
|
|
||||||
|
|
||||||
<!-- Sensitive data, PII, secrets -->
|
|
||||||
|
|
||||||
(To be filled by the team)
|
|
||||||
@@ -17,3 +17,10 @@
|
|||||||
|
|
||||||
- 单批双预算:页数(`maxAuditPages`)+ 时间(`auditBatchTimeBudget`≈20s)。全文检索命中稀疏时大窗扫描单页可达十余秒,没有时间预算会出现 3 分钟级单请求。
|
- 单批双预算:页数(`maxAuditPages`)+ 时间(`auditBatchTimeBudget`≈20s)。全文检索命中稀疏时大窗扫描单页可达十余秒,没有时间预算会出现 3 分钟级单请求。
|
||||||
- 空窗按倍增扩窗(上限受 14 天查询窗约束);响应回传 `scannedThrough` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。
|
- 空窗按倍增扩窗(上限受 14 天查询窗约束);响应回传 `scannedThrough` 供前端展示回溯进度,前端自动补批必须封顶,由用户显式继续。
|
||||||
|
|
||||||
|
## 日志回传链路(logrelay)资源命名与描述纪律
|
||||||
|
|
||||||
|
- **命名派生**:Topic 前缀、IAM Policy 名、SCH Connector DisplayName 一律由 `relayResourceNames(tenancyOCID)`(见 `internal/oci/logrelay_names.go`)基于 `SHA-256(tenancyOCID)[:4]` 派生 `<8hex>-audit` / `<8hex>-audit-p`。**不得在 `logrelay.go` 里再引入品牌明文**(`oci-portal` / `ociportal` / `logs` 等),否则跨租户恒定字面量会成为 Oracle 内部风控识别「共用同一套 oci-portal」的强指纹;背景与证据分档见工作区任务归档 `.trellis/tasks/archive/2026-07/07-22-research-oci-caller-info/research/oci-sdk-caller-fingerprint.md`。
|
||||||
|
- **描述文本**:Topic 与 Policy 的 `Description` 用集中常量 `relayTopicDescNew` / `relayPolicyDescNew`(中性英文),不允许写 `oci-portal 日志回传:...` 等品牌/中文明文;SCH Connector 干脆不设 `Description`,避免恒定文案。
|
||||||
|
- **向后兼容 legacy 命名**:`findRelayTopic` / `findRelayPolicy` / `findRelayConnector` 支持传入多前缀/多名称,顺序为「新命名 → legacy 命名」;新增查找需求要沿用变参签名,不要单独硬编码 legacy 常量到业务函数里。命中 legacy 资源时,`refreshRelayTopicDesc` / `refreshRelayPolicyDesc` 会尽力刷新描述为中性文案,失败不阻塞主流程(权限/服务限流等场景直接忽略)。
|
||||||
|
- **测试**:命名派生、legacy 常量值、描述中性性由 `logrelay_names_test.go` table-driven 锁定;若必须调整 legacy 常量,先评估是否会让存量租户找不到既有资源导致重复创建。
|
||||||
|
|||||||
@@ -27,3 +27,7 @@
|
|||||||
- 留档「客户端请求正文」优先存**原始字节**(`json.RawMessage(raw)`),不要存解析
|
- 留档「客户端请求正文」优先存**原始字节**(`json.RawMessage(raw)`),不要存解析
|
||||||
后的结构体:直通端点未建模字段会被结构体序列化悄悄丢掉。`json.Marshal` 对
|
后的结构体:直通端点未建模字段会被结构体序列化悄悄丢掉。`json.Marshal` 对
|
||||||
RawMessage 会 compact(去空白),字段顺序与内容保持原样,符合保真要求。
|
RawMessage 会 compact(去空白),字段顺序与内容保持原样,符合保真要求。
|
||||||
|
|
||||||
|
## 多字段设置接口用字段级 PATCH,不用全量 PUT
|
||||||
|
|
||||||
|
「改一存一」交互的设置面板(安全设置、OAuth provider 等)服务端用字段级 PATCH:DTO 全指针字段,nil=沿用现值,只落库出现的字段(合并到现值后整体校验);全量 PUT 下并发编辑各自基于旧快照,后到请求会回滚他人字段、复活已清空配置(2026-07-22 审查 #18,security.go `SecurityPatch` / oauthconfig.go `UpdateOAuthInput`)。
|
||||||
|
|||||||
@@ -6,6 +6,26 @@
|
|||||||
- 断言失败时输出 `got/want` 对比,便于定位。
|
- 断言失败时输出 `got/want` 对比,便于定位。
|
||||||
- 辅助函数标注 `t.Helper()`,清理动作用 `t.Cleanup()`。
|
- 辅助函数标注 `t.Helper()`,清理动作用 `t.Cleanup()`。
|
||||||
|
|
||||||
|
## 上传接口边界测试
|
||||||
|
|
||||||
|
- “文件最大 N 字节”与 HTTP 请求体上限不是同一个契约:multipart boundary、
|
||||||
|
part header 和文件名会额外占空间。路由级 body limit 必须为封装开销留余量,
|
||||||
|
service 再对文件字节数执行精确上限。
|
||||||
|
- 新增或修改上传接口时,完整路由测试至少覆盖:恰好 N 字节成功、N+1 返回
|
||||||
|
413、空文件返回 400、伪造扩展名返回 415。
|
||||||
|
- 内容校验深度与威胁模型匹配:管理员向自己租户上传、由上游域名托管的低价值
|
||||||
|
资源,扩展名白名单 + 魔数嗅探即可,不引入完整解码/结构校验
|
||||||
|
(2026-07 IdP 图标曾过度实现后精简,见 idp-icon-upload.md)。
|
||||||
|
- 临时远端资源使用服务端随机名,测试相同客户端文件名不会复用上游对象;清理接口
|
||||||
|
只接受该功能生成的不可猜测标识,不能因共享 `images/` 前缀删除其他业务资产。
|
||||||
|
|
||||||
|
## 多步远端写入测试
|
||||||
|
|
||||||
|
- `创建 A → 配置 B` 这类流程至少覆盖:创建失败、B 失败且 A 回滚成功、B 失败且
|
||||||
|
A 回滚失败/状态未知。只有确认回滚成功才能返回普通失败。
|
||||||
|
- 部分成功响应必须有稳定机器码和关联 ID,前端据此保留已被 A 引用的临时资源;
|
||||||
|
底层上游错误只进服务端日志,测试断言不会泄露到 HTTP body。
|
||||||
|
|
||||||
## 常见坑:`:memory:` SQLite 每个连接是独立库
|
## 常见坑:`:memory:` SQLite 每个连接是独立库
|
||||||
|
|
||||||
**症状**:被测代码里有异步 goroutine 写库(如日志异步落库)时,测试偶发 `no such table: xxx`。
|
**症状**:被测代码里有异步 goroutine 写库(如日志异步落库)时,测试偶发 `no such table: xxx`。
|
||||||
@@ -20,4 +40,3 @@ sqlDB.SetMaxOpenConns(1) // :memory: 每连接独立库,异步写复用同一连
|
|||||||
```
|
```
|
||||||
|
|
||||||
**预防**:测试涉及「后台 goroutine 写库」时一律加此设置(参照 internal/api/router_test.go 与 internal/service 各测试 helper);异步写路径同时拆出同步内核函数(如 `record` / `Record`)供测试直调断言。
|
**预防**:测试涉及「后台 goroutine 写库」时一律加此设置(参照 internal/api/router_test.go 与 internal/service 各测试 helper);异步写路径同时拆出同步内核函数(如 `record` / `Record`)供测试直调断言。
|
||||||
|
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
# Code Reuse Thinking Guide
|
|
||||||
|
|
||||||
> **Purpose**: Stop and think before creating new code - does it already exist?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The Problem
|
|
||||||
|
|
||||||
**Duplicated code is the #1 source of inconsistency bugs.**
|
|
||||||
|
|
||||||
When you copy-paste or rewrite existing logic:
|
|
||||||
- Bug fixes don't propagate
|
|
||||||
- Behavior diverges over time
|
|
||||||
- Codebase becomes harder to understand
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Before Writing New Code
|
|
||||||
|
|
||||||
### Step 1: Search First
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Search for similar function names
|
|
||||||
grep -r "functionName" .
|
|
||||||
|
|
||||||
# Search for similar logic
|
|
||||||
grep -r "keyword" .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Ask These Questions
|
|
||||||
|
|
||||||
| Question | If Yes... |
|
|
||||||
|----------|-----------|
|
|
||||||
| Does a similar function exist? | Use or extend it |
|
|
||||||
| Is this pattern used elsewhere? | Follow the existing pattern |
|
|
||||||
| Could this be a shared utility? | Create it in the right place |
|
|
||||||
| Am I copying code from another file? | **STOP** - extract to shared |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Duplication Patterns
|
|
||||||
|
|
||||||
### Pattern 1: Copy-Paste Functions
|
|
||||||
|
|
||||||
**Bad**: Copying a validation function to another file
|
|
||||||
|
|
||||||
**Good**: Extract to shared utilities, import where needed
|
|
||||||
|
|
||||||
### Pattern 2: Similar Components
|
|
||||||
|
|
||||||
**Bad**: Creating a new component that's 80% similar to existing
|
|
||||||
|
|
||||||
**Good**: Extend existing component with props/variants
|
|
||||||
|
|
||||||
### Pattern 3: Repeated Constants
|
|
||||||
|
|
||||||
**Bad**: Defining the same constant in multiple files
|
|
||||||
|
|
||||||
**Good**: Single source of truth, import everywhere
|
|
||||||
|
|
||||||
### Pattern 4: Repeated Payload Field Extraction
|
|
||||||
|
|
||||||
**Bad**: Multiple consumers cast the same JSON/event fields locally:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const description = (ev as { description?: string }).description;
|
|
||||||
const context = (ev as { context?: ContextEntry[] }).context;
|
|
||||||
```
|
|
||||||
|
|
||||||
This is duplicated contract logic even when the code is only two lines. Each
|
|
||||||
consumer now has its own definition of what a valid payload means.
|
|
||||||
|
|
||||||
**Good**: Put the decoder, type guard, or projection next to the data owner:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (isThreadEvent(ev)) {
|
|
||||||
renderThreadEvent(ev);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule**: If the same untyped payload field is read in 2+ places, create a
|
|
||||||
shared type guard / normalizer / projection before adding a third reader.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## When to Abstract
|
|
||||||
|
|
||||||
**Abstract when**:
|
|
||||||
- Same code appears 3+ times
|
|
||||||
- Logic is complex enough to have bugs
|
|
||||||
- Multiple people might need this
|
|
||||||
|
|
||||||
**Don't abstract when**:
|
|
||||||
- Only used once
|
|
||||||
- Trivial one-liner
|
|
||||||
- Abstraction would be more complex than duplication
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## After Batch Modifications
|
|
||||||
|
|
||||||
When you've made similar changes to multiple files:
|
|
||||||
|
|
||||||
1. **Review**: Did you catch all instances?
|
|
||||||
2. **Search**: Run grep to find any missed
|
|
||||||
3. **Consider**: Should this be abstracted?
|
|
||||||
|
|
||||||
### Reducers Should Use Exhaustive Structure
|
|
||||||
|
|
||||||
When state is derived from action-like values (`action`, `kind`, `status`,
|
|
||||||
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// BAD - action-specific state transitions are hard to audit
|
|
||||||
if (action === "opened") { ... }
|
|
||||||
else if (action === "comment") { ... }
|
|
||||||
else if (action === "status") { ... }
|
|
||||||
|
|
||||||
// GOOD - one reducer owns the transition table
|
|
||||||
switch (event.action) {
|
|
||||||
case "opened":
|
|
||||||
...
|
|
||||||
return;
|
|
||||||
case "comment":
|
|
||||||
...
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This matters when the event log is the source of truth. A reducer is the
|
|
||||||
documented replay model; display code and commands should not duplicate pieces
|
|
||||||
of that replay model.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Checklist Before Commit
|
|
||||||
|
|
||||||
- [ ] Searched for existing similar code
|
|
||||||
- [ ] No copy-pasted logic that should be shared
|
|
||||||
- [ ] No repeated untyped payload field extraction outside a shared decoder
|
|
||||||
- [ ] Constants defined in one place
|
|
||||||
- [ ] Similar patterns follow same structure
|
|
||||||
- [ ] Reducer/action transitions live in one reducer or command dispatcher
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gotcha: Python if/elif/else Exhaustive Check
|
|
||||||
|
|
||||||
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
|
|
||||||
|
|
||||||
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
|
|
||||||
|
|
||||||
**Example** (`cli_adapter.py`):
|
|
||||||
```python
|
|
||||||
# BAD: "gemini" falls through to else, returns "claude"
|
|
||||||
@property
|
|
||||||
def cli_name(self) -> str:
|
|
||||||
if self.platform == "opencode":
|
|
||||||
return "opencode"
|
|
||||||
else:
|
|
||||||
return "claude" # gemini silently gets "claude"!
|
|
||||||
|
|
||||||
# GOOD: explicit branch for every platform
|
|
||||||
@property
|
|
||||||
def cli_name(self) -> str:
|
|
||||||
if self.platform == "opencode":
|
|
||||||
return "opencode"
|
|
||||||
elif self.platform == "gemini":
|
|
||||||
return "gemini"
|
|
||||||
else:
|
|
||||||
return "claude"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gotcha: Asymmetric Mechanisms Producing Same Output
|
|
||||||
|
|
||||||
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
|
|
||||||
|
|
||||||
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
|
|
||||||
|
|
||||||
**Prevention**:
|
|
||||||
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
|
|
||||||
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
|
|
||||||
- When migrating directory structures, search for ALL code paths that reference the old structure
|
|
||||||
|
|
||||||
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Template File Registration (Trellis-specific)
|
|
||||||
|
|
||||||
When adding new files to `src/templates/trellis/scripts/`:
|
|
||||||
|
|
||||||
**Single registration point**: `src/templates/trellis/index.ts`
|
|
||||||
|
|
||||||
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
|
|
||||||
2. Add to `getAllScripts()` Map
|
|
||||||
|
|
||||||
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
|
|
||||||
|
|
||||||
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
|
|
||||||
|
|
||||||
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
|
|
||||||
|
|
||||||
### Quick Checklist for New Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# After adding a new .py file, verify it's in getAllScripts():
|
|
||||||
grep -l "newFileName" src/templates/trellis/index.ts # Should match
|
|
||||||
```
|
|
||||||
|
|
||||||
### Template Sync Convention
|
|
||||||
|
|
||||||
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.
|
|
||||||
+20
-19
@@ -183,7 +183,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni
|
|||||||
- 1.0 Create task `[required · once]` (only after task-creation consent)
|
- 1.0 Create task `[required · once]` (only after task-creation consent)
|
||||||
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
||||||
- 1.2 Research `[optional · repeatable]`
|
- 1.2 Research `[optional · repeatable]`
|
||||||
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip)
|
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Grok, Kimi Code (sub-agent-dispatch platforms only; inline platforms skip)
|
||||||
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
||||||
- 1.5 Completion criteria
|
- 1.5 Completion criteria
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef
|
|||||||
therefore must cover every required step from implementation through
|
therefore must cover every required step from implementation through
|
||||||
commit, including Phase 3.3 spec update and Phase 3.4 commit. -->
|
commit, including Phase 3.3 spec update and Phase 3.4 commit. -->
|
||||||
|
|
||||||
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Gemini/Qoder/Copilot/ZCode/Reasonix/Trae and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions.
|
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including native Codex `SubagentStart` context injection with child-side pull fallback, class-2 Gemini/Qoder/Copilot/Reasonix/Trae/Grok/Kimi Code, hook-backed ZCode/Snow, and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions. On Grok Build, use `spawn_subagent` with `subagent_type` set to the Trellis agent name (e.g. `trellis-implement`). On Kimi Code, dispatch the built-in `coder` / `explore` sub-agent with the matching `.kimi-code/skills/trellis-<role>/SKILL.md` instructions.
|
||||||
|
|
||||||
[workflow-state:in_progress]
|
[workflow-state:in_progress]
|
||||||
Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes.
|
Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes.
|
||||||
@@ -272,13 +272,13 @@ Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first.
|
|||||||
|
|
||||||
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
|
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
- Planning or unclear requirements -> `trellis-brainstorm`.
|
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||||
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
||||||
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ Return to this step whenever requirements change and revise the relevant artifac
|
|||||||
|
|
||||||
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
|
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
Spawn the research sub-agent:
|
Spawn the research sub-agent:
|
||||||
|
|
||||||
@@ -361,11 +361,11 @@ Spawn the research sub-agent:
|
|||||||
- **Task description**: Research <specific question>
|
- **Task description**: Research <specific question>
|
||||||
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
|
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. (For `codex-inline` this avoids the `fork_turns="none"` isolation that prevents `trellis-research` sub-agents from resolving the active task path.)
|
Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. `codex-inline` is the explicit mode that keeps work in the main session.
|
||||||
|
|
||||||
[/codex-inline, Kilo, Antigravity, Devin]
|
[/codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
@@ -380,7 +380,7 @@ Brainstorm and research can interleave freely — pause to research a technical
|
|||||||
|
|
||||||
#### 1.3 Configure context `[required · once]`
|
#### 1.3 Configure context `[required · once]`
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
||||||
|
|
||||||
@@ -425,7 +425,7 @@ Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one r
|
|||||||
|
|
||||||
Skip this step only when both files already have real curated entries.
|
Skip this step only when both files already have real curated entries.
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
@@ -458,11 +458,11 @@ If `task.py start` errors with a session-identity message (no context key from h
|
|||||||
| `design.md` exists (complex tasks) | ✅ |
|
| `design.md` exists (complex tasks) | ✅ |
|
||||||
| `implement.md` exists (complex tasks) | ✅ |
|
| `implement.md` exists (complex tasks) | ✅ |
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
|
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -472,21 +472,22 @@ Goal: turn reviewed planning artifacts into code that passes quality checks.
|
|||||||
|
|
||||||
#### 2.1 Implement `[required · repeatable]`
|
#### 2.1 Implement `[required · repeatable]`
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi]
|
||||||
|
|
||||||
Spawn the implement sub-agent:
|
Spawn the implement sub-agent:
|
||||||
|
|
||||||
- **Agent type**: `trellis-implement`
|
- **Agent type**: `trellis-implement`
|
||||||
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||||
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
|
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
|
||||||
|
|
||||||
The platform hook/plugin auto-handles:
|
The platform hook/plugin auto-handles:
|
||||||
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
||||||
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
|
- For Codex, `SubagentStart` supplies native context injection; the agent profile keeps child-side loading as the fallback
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi]
|
||||||
|
|
||||||
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
|
[Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
Spawn the implement sub-agent:
|
Spawn the implement sub-agent:
|
||||||
|
|
||||||
@@ -498,7 +499,7 @@ The pull-based sub-agent definition auto-handles the context load requirement:
|
|||||||
- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present
|
- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present
|
||||||
- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding
|
- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding
|
||||||
|
|
||||||
[/codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
|
[/Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
[Kiro]
|
[Kiro]
|
||||||
|
|
||||||
@@ -526,13 +527,13 @@ The platform prelude auto-handles the context load requirement:
|
|||||||
|
|
||||||
#### 2.2 Quality check `[required · repeatable]`
|
#### 2.2 Quality check `[required · repeatable]`
|
||||||
|
|
||||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
Spawn the check sub-agent:
|
Spawn the check sub-agent:
|
||||||
|
|
||||||
- **Agent type**: `trellis-check`
|
- **Agent type**: `trellis-check`
|
||||||
- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass
|
- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass
|
||||||
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
|
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
|
||||||
|
|
||||||
The check agent's job:
|
The check agent's job:
|
||||||
- Review code changes against specs
|
- Review code changes against specs
|
||||||
@@ -540,7 +541,7 @@ The check agent's job:
|
|||||||
- Auto-fix issues it finds
|
- Auto-fix issues it finds
|
||||||
- Run lint and typecheck to verify
|
- Run lint and typecheck to verify
|
||||||
|
|
||||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||||
|
|
||||||
[codex-inline, Kilo, Antigravity, Devin]
|
[codex-inline, Kilo, Antigravity, Devin]
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,56 @@
|
|||||||
|
|
||||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||||
|
|
||||||
|
## [0.8.3]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 新增通行密钥(Passkey / WebAuthn)注册、管理与免用户名登录,要求验证器完成用户验证;新增 Web3 钱包绑定与 EIP-4361 签名登录,支持浏览器注入的 EOA 钱包
|
||||||
|
- 新增活跃会话管理:按登录方式、客户端 IP、User-Agent 与最近活跃时间查看当前账号会话,可定点撤销其他会话;敏感变更换发令牌时接续原会话,升级前签发的存量令牌保持兼容
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 租户 API Key 激活接口支持切换到租户内其他 IAM 用户,验证新密钥可用后同步更新面板签名用户与凭据
|
||||||
|
- 禁用密码登录时将通行密钥计入可用免密方式,并阻止删除最后一种可用登录方式,避免账号自锁
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 同一实例的终止与电源操作增加飞行期互斥,重复请求稳定返回 409,避免并发执行相互冲突的生命周期操作
|
||||||
|
|
||||||
|
## [0.8.2]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 租户 IAM 用户新增 API Key 管理:支持列出密钥并标注当前签名凭据、提供 OCI CLI 配置模板、由服务端生成 RSA-2048 密钥对并上传公钥(私钥仅在创建响应中返回),以及按指纹删除单把密钥;新密钥可在验证 OCI 可用后启用为面板签名凭据并加密保存,旧密钥保留,当前在用密钥禁止直接删除
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 升级 OCI Go SDK 至 v65.121.1
|
||||||
|
|
||||||
|
## [0.8.1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 身份提供商管理新增图标上传与删除:支持 PNG / JPEG / GIF / SVG / WebP / ICO,限制 1 MB,并校验扩展名与文件内容;图片存入身份域公共存储,返回可用于 `iconUrl` 的地址及后续清理所需的 `fileName`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 安全设置与 OAuth Provider 配置接口由全量 `PUT` 改为字段级 `PATCH`,仅更新请求中出现的字段,避免并发编辑时回滚其他设置;密钥字段仍支持缺省沿用、空串清除
|
||||||
|
- 对象存储桶级 PAR 新增只读 `AnyObjectRead` 与只写 `AnyObjectWrite`,有效期上限由 30 天放宽到 100 年;仅含读取能力的桶级链接开放对象列举,非法类型或有效期稳定返回 400
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复总览将不同币种成本直接相加;响应新增按币种拆分的 `series`,顶层字段保持主币种兼容口径
|
||||||
|
- 修复抢机任务编辑把剩余台数误当目标台数、丢失已完成进度或鉴权失败计数;任务更新增加乐观并发保护,状态已变化时返回 409 而不覆盖执行结果
|
||||||
|
- 修复对象内容保存实际受统一 1 MB 请求体限制、无法达到声明的 5 MB 上限;非空桶后台删除现会中止未完成分片上传,并合并同桶重复清空请求
|
||||||
|
- 修复 SAML IdP 创建与启用边界:域要求主邮箱时自动补邮箱映射,JIT 后置配置失败会尝试回滚并在无法确认时返回 `setupWarning`,首次 IdP 可正确加入默认登录策略,免 MFA 规则补齐 OCI consent 字段
|
||||||
|
- 修复 AI 网关超大非流式响应被截断后仍按成功返回、内容日志关闭后在途请求仍写入正文,以及大量 AI 日志清理 / 租户删除可能触发数据库参数上限
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- 日志回传 Topic / Policy / Connector 改为按租户派生的中性名称并移除品牌描述,降低跨租户固定指纹;旧命名资源仍可识别复用
|
||||||
|
- 租户导入在首次 OCI 请求前即校验并应用指定代理,SAML 元数据下载同样复用租户代理且非法配置失败关闭;禁用密码登录与解绑外部身份改为事务串行校验,防止并发操作导致账号失去全部登录方式
|
||||||
|
|
||||||
## [0.8.0]
|
## [0.8.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.8.0
|
v0.8.3
|
||||||
|
|||||||
@@ -13,12 +13,10 @@
|
|||||||
|
|
||||||
[界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
[界面预览](#界面预览) · [核心能力](#核心能力) · [快速开始](#快速开始) · [生产部署](#生产部署) · [AI 网关](#ai-网关) · [开发](#开发)
|
||||||
|
|
||||||
[AI 网关文档](docs/AI网关.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash)
|
[AI 网关文档](docs/AI网关.md) · [OCI 调用者指纹评估](docs/OCI调用者指纹评估.md) · [OpenAPI](docs/swagger.yaml) · [更新日志](CHANGELOG.md) · [前端仓库](https://github.com/wangdefaa/oci-portal-dash)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
OCI Portal 将多份 OCI API Key、云资源、成本与账单、自动化任务、审计事件和 OCI GenAI 渠道集中到一个管理界面。Vue 前端通过 `go:embed` 嵌入 Go 服务,Release 以单个二进制和多架构容器镜像交付。
|
|
||||||
|
|
||||||
本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。
|
本仓库为后端与发行仓库;前端源码位于 [oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)。
|
||||||
|
|
||||||
## 界面预览
|
## 界面预览
|
||||||
@@ -52,7 +50,7 @@ OCI Portal 将多份 OCI API Key、云资源、成本与账单、自动化任务
|
|||||||
| **成本与账单** | 成本 / 用量查询(小时、日、月粒度,支持服务 / SKU 复合分组);发票列表与费用明细、PDF 预览下载、付款方式查询及在线支付 |
|
| **成本与账单** | 成本 / 用量查询(小时、日、月粒度,支持服务 / SKU 复合分组);发票列表与费用明细、PDF 预览下载、付款方式查询及在线支付 |
|
||||||
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
|
| **自动化与控制台** | 抢机、租户测活、成本同步、AI 渠道探测;执行日志、重叠防护、熔断与结果通知;xterm 串行终端、noVNC 和 OCI 控制台连接两跳 SSH 隧道 |
|
||||||
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
|
| **身份与审计** | IAM 用户、MFA、API Key、密码策略、SAML、通知收件人与多 Identity Domain;通过 Service Connector Hub 与 Notifications 接收并分类推送 OCI Audit 事件 |
|
||||||
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTP;AES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 |
|
| **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTP;AES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、通行密钥(Passkey / WebAuthn,需 HTTPS 且换域名后需重新注册)、Web3 钱包签名登录(EIP-4361,仅注入钱包与 EOA)、登录锁定、IP 限速、活跃会话管理(按设备查看与定点撤销)和操作审计 |
|
||||||
| **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 |
|
| **AI 网关** | OpenAI Responses、Chat Completions、Embeddings 与 Anthropic Messages;渠道分组、加权路由、熔断探测、模型治理、密钥管理和调用日志 |
|
||||||
| **跨端体验** | 桌面与移动端响应式布局,移动端底部导航与卡片列表;PWA 可安装到主屏幕并自动更新,静态资源缓存不拦截 `/api/*`、`/ai/*` 实时请求 |
|
| **跨端体验** | 桌面与移动端响应式布局,移动端底部导航与卡片列表;PWA 可安装到主屏幕并自动更新,静态资源缓存不拦截 `/api/*`、`/ai/*` 实时请求 |
|
||||||
|
|
||||||
@@ -308,6 +306,10 @@ go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependenc
|
|||||||
- 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)
|
- 前端开发与构建:[oci-portal-dash](https://github.com/wangdefaa/oci-portal-dash)
|
||||||
- 版本变更:[`CHANGELOG.md`](CHANGELOG.md)
|
- 版本变更:[`CHANGELOG.md`](CHANGELOG.md)
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
感谢 [Yohann0617/oci-helper](https://github.com/Yohann0617/oci-helper) 为本项目提供思路。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|||||||
+6
-2
@@ -31,7 +31,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// @title OCI Portal API
|
// @title OCI Portal API
|
||||||
// @version 0.0.1
|
// @version 0.8.3
|
||||||
// @description 自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。
|
// @description 自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: Bearer 或 x-api-key)。
|
||||||
// @BasePath /
|
// @BasePath /
|
||||||
// @securityDefinitions.apikey BearerAuth
|
// @securityDefinitions.apikey BearerAuth
|
||||||
@@ -119,17 +119,21 @@ func run() error {
|
|||||||
notifier := service.NewNotifier(settings)
|
notifier := service.NewNotifier(settings)
|
||||||
auth.SetNotifier(notifier, settings)
|
auth.SetNotifier(notifier, settings)
|
||||||
oauth := service.NewOAuthService(db, settings, auth)
|
oauth := service.NewOAuthService(db, settings, auth)
|
||||||
|
passkeys := service.NewPasskeyService(db, settings, auth)
|
||||||
|
wallets := service.NewWalletService(db, settings, auth)
|
||||||
systemLogs := service.NewSystemLogService(db)
|
systemLogs := service.NewSystemLogService(db)
|
||||||
logEvents := service.NewLogEventService(db)
|
logEvents := service.NewLogEventService(db)
|
||||||
logEvents.SetRelayDeps(ociConfigs, ociClient, cfg.PublicURL)
|
logEvents.SetRelayDeps(ociConfigs, ociClient, cfg.PublicURL)
|
||||||
logEvents.SetNotifier(notifier, settings)
|
logEvents.SetNotifier(notifier, settings)
|
||||||
cleanupCtx, stopCleanup := context.WithCancel(context.Background())
|
cleanupCtx, stopCleanup := context.WithCancel(context.Background())
|
||||||
systemLogs.StartCleanup(cleanupCtx)
|
systemLogs.StartCleanup(cleanupCtx)
|
||||||
|
auth.StartSessionCleanup(cleanupCtx)
|
||||||
logEvents.StartParser(cleanupCtx)
|
logEvents.StartParser(cleanupCtx)
|
||||||
logEvents.StartCleanup(cleanupCtx)
|
logEvents.StartCleanup(cleanupCtx)
|
||||||
aiGateway := service.NewAiGatewayService(db, ociConfigs, ociClient)
|
aiGateway := service.NewAiGatewayService(db, ociConfigs, ociClient)
|
||||||
aiGateway.StartCleanup(cleanupCtx)
|
aiGateway.StartCleanup(cleanupCtx)
|
||||||
// defer 为 LIFO:先停调度与清理,再等在途通知与日志写完
|
// defer 为 LIFO:先停调度与清理,再等在途通知与日志写完
|
||||||
|
defer auth.WaitSessionCleanup()
|
||||||
defer logEvents.Wait()
|
defer logEvents.Wait()
|
||||||
defer systemLogs.Wait()
|
defer systemLogs.Wait()
|
||||||
defer notifier.Wait()
|
defer notifier.Wait()
|
||||||
@@ -150,7 +154,7 @@ func run() error {
|
|||||||
defer proxies.Wait()
|
defer proxies.Wait()
|
||||||
// 「关于」页存储指标需知数据库形态
|
// 「关于」页存储指标需知数据库形态
|
||||||
api.SetAboutRuntime(cfg.DBDriver, cfg.DBPath)
|
api.SetAboutRuntime(cfg.DBDriver, cfg.DBPath)
|
||||||
router := api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
|
router := api.NewRouter(auth, oauth, passkeys, wallets, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
|
||||||
return serveHTTP(cfg.Addr, router)
|
return serveHTTP(cfg.Addr, router)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<a id="top"></a>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="assets/logo.svg" width="88" alt="OCI Portal logo">
|
||||||
|
|
||||||
|
# OCI 调用者指纹评估
|
||||||
|
|
||||||
|
**OCI Portal 请求暴露面与多租户关联风险**
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[结论速览](#summary) · [请求暴露面](#request-signals) · [项目指纹](#portal-signals) · [关联评估](#assessment) · [核验依据](#evidence)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 本文面向架构与隐私评估,说明 OCI Portal 调用 OCI Public API 时 Oracle
|
||||||
|
> 可见的信息及跨租户关联风险。结论基于当前源码,不代表 Oracle 实际采用的
|
||||||
|
> 风控规则。
|
||||||
|
|
||||||
|
<a id="summary"></a>
|
||||||
|
|
||||||
|
## 结论速览
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> OCI Portal 的每次 API Key 请求都会在签名的 `keyId` 中携带
|
||||||
|
> `tenancy OCID / user OCID / fingerprint`。这能准确识别云身份,但不能单凭
|
||||||
|
> 一次请求证明调用方是 OCI Portal。
|
||||||
|
|
||||||
|
| 问题 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| Oracle 能识别单次请求的租户和用户吗? | **能。**签名身份、源 IP、目标服务、操作与时间均可见 |
|
||||||
|
| 通用 SDK 请求头能识别 OCI Portal 吗? | **不能。**默认 User-Agent、签名算法和 Go TLS 特征被大量客户端共享 |
|
||||||
|
| Oracle 能关联多个租户共用同一实例吗? | **具备能力。**需组合出口 IP、资源元数据、固定文本和调用序列 |
|
||||||
|
| 外部第三方能完成同等关联吗? | **通常不能。**其无法取得 Oracle 网关日志和租户内部资源元数据 |
|
||||||
|
|
||||||
|
<a id="request-signals"></a>
|
||||||
|
|
||||||
|
## 单次请求暴露面
|
||||||
|
|
||||||
|
当前项目统一通过 `common.NewRawConfigurationProvider` 使用 API Key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Signature version="1",
|
||||||
|
keyId="<tenancyOCID>/<userOCID>/<fingerprint>",
|
||||||
|
algorithm="rsa-sha256", ...
|
||||||
|
```
|
||||||
|
|
||||||
|
| 信号 | 默认形态 | 区分度 |
|
||||||
|
| --- | --- | :---: |
|
||||||
|
| 签名身份 | `tenancy / user / fingerprint` 明文位于 `keyId` | 身份强,软件弱 |
|
||||||
|
| User-Agent | 大多数 SDK 请求为 `Oracle-GoSDK/<版本> (...)` | 弱 |
|
||||||
|
| SDK 客户端信息 | SDK 生成的服务请求通常带 `opc-client-info: Oracle-GoSDK/<版本>` | 弱 |
|
||||||
|
| 请求元数据 | `Date`、`Host`、`request-target`;写请求另含 body 摘要 | 通用 |
|
||||||
|
| 网络特征 | 源 IP、TLS / ALPN、HTTP/2 行为 | 单次弱,跨租户组合后增强 |
|
||||||
|
| OCI 遥测头 | 显式启用 `OCI_INCLUDE_REQUEST_TELEMETRY_DATA=true` 时,SDK 操作可写 service / operation;直通请求未必携带 | 可选 |
|
||||||
|
|
||||||
|
项目没有构造 Instance Principal、Resource Principal 或 Session Token
|
||||||
|
鉴权,也没有主动覆盖 User-Agent 或为 `opc-request-id` 设置固定前缀。
|
||||||
|
`opc-client-info` 由 SDK 请求构造器写入,不含 OCI Portal 标记;手工构造的
|
||||||
|
请求可能不带该字段。少量手工签名请求绕过 `BaseClient.prepareRequest`,
|
||||||
|
使用 Go `net/http` 的默认 User-Agent。
|
||||||
|
|
||||||
|
<a id="portal-signals"></a>
|
||||||
|
|
||||||
|
## OCI Portal 增量指纹
|
||||||
|
|
||||||
|
| 信号 | 当前行为 | 关联强度 |
|
||||||
|
| --- | --- | :---: |
|
||||||
|
| 出口 IP | 无租户级或全局代理时,走部署环境默认出口 / NAT | **强旁证(命中时)** |
|
||||||
|
| 日志回传资源名 | 按 tenancy 派生为 `<8hex>-audit*`,描述使用中性文案 | 弱 |
|
||||||
|
| GenAI Responses 请求头 | 同时写入 `CompartmentId` 与 `opc-compartment-id` | 中 / 弱 |
|
||||||
|
| API 调用序列 | 测活、账户画像、区域与资源查询存在稳定组合 | 辅助 |
|
||||||
|
|
||||||
|
SDK 默认 User-Agent、`opc-client-info` 与固定签名格式只说明“使用 OCI Go
|
||||||
|
SDK”,不是项目专属信号。
|
||||||
|
|
||||||
|
<a id="assessment"></a>
|
||||||
|
|
||||||
|
## 多租户关联评估
|
||||||
|
|
||||||
|
| 观察视角 | 结论 | 主要依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Oracle 内部,跨租户数据 | **中** | 固定文本、出口 IP 与调用序列可组合比对 |
|
||||||
|
| Oracle 内部,单租户数据 | **低** | 能识别实现痕迹,但难证明多个租户共用同一实例 |
|
||||||
|
| 仅看 SDK 请求头 | **低** | 可识别云身份和 SDK 类型,不能可靠识别具体应用 |
|
||||||
|
| 外部第三方 | **通常不可行** | 缺少 OCI 网关日志、Sign-on Policy 与租户资源元数据 |
|
||||||
|
|
||||||
|
这些等级表达的是“可关联性”,不是 Oracle 已执行关联或据此采取处置。
|
||||||
|
同一出口 IP 也可能来自 NAT、代理或共享基础设施,必须与其他信号联合判断。
|
||||||
|
|
||||||
|
<a id="evidence"></a>
|
||||||
|
|
||||||
|
## 核验依据
|
||||||
|
|
||||||
|
### OCI 与 SDK
|
||||||
|
|
||||||
|
- [OCI Request Signatures](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm)
|
||||||
|
- [OCI API Signing Key](https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm)
|
||||||
|
- [OCI Go SDK client.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/client.go)
|
||||||
|
- [OCI Go SDK http_signer.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/http_signer.go)
|
||||||
|
- [OCI Go SDK configuration.go(v65.121.0)](https://github.com/oracle/oci-go-sdk/blob/v65.121.0/common/configuration.go)
|
||||||
|
|
||||||
|
### 项目源码
|
||||||
|
|
||||||
|
- [API Key provider](../internal/oci/client.go#L237-L246)
|
||||||
|
- [手工签名请求](../internal/oci/account.go#L83-L97)
|
||||||
|
- [租户出站代理](../internal/oci/proxyhttp.go#L45-L65)
|
||||||
|
- [日志回传资源命名](../internal/oci/logrelay_names.go#L20-L55)
|
||||||
|
- [GenAI Responses 请求头](../internal/oci/genai_responses.go#L27-L38)
|
||||||
|
- [MFA justification](../internal/oci/signon.go#L223-L236)
|
||||||
|
- [审计与日志回传约束](../.trellis/spec/backend/oci-audit.md)
|
||||||
|
|
||||||
|
<p align="right"><a href="#top">返回顶部 ↑</a></p>
|
||||||
+1105
-21
File diff suppressed because it is too large
Load Diff
+1106
-22
File diff suppressed because it is too large
Load Diff
+724
-21
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,13 @@ go 1.26.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coreos/go-oidc/v3 v3.19.0
|
github.com/coreos/go-oidc/v3 v3.19.0
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/oracle/oci-go-sdk/v65 v65.121.0
|
github.com/oracle/oci-go-sdk/v65 v65.122.0
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
@@ -35,6 +37,7 @@ require (
|
|||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
@@ -47,10 +50,13 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.2.6 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/gofrs/flock v0.10.0 // indirect
|
github.com/gofrs/flock v0.10.0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||||
@@ -66,15 +72,18 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/sony/gobreaker/v2 v2.4.0 // indirect
|
github.com/sony/gobreaker/v2 v2.4.0 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/urfave/cli/v2 v2.3.0 // indirect
|
github.com/urfave/cli/v2 v2.3.0 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||||
|
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||||
@@ -61,6 +67,12 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
|
|||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||||
|
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||||
|
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
@@ -71,11 +83,15 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
@@ -119,10 +135,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
|
|||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/oracle/oci-go-sdk/v65 v65.121.0 h1:1J+5ARgrodrx8kzFy/hxznaoUzz43jr0EestCzEaOHw=
|
github.com/oracle/oci-go-sdk/v65 v65.122.0 h1:yB587yZUGe/syyyp1CHlW74EXo38Rfye5vD3Ox4Cdq4=
|
||||||
github.com/oracle/oci-go-sdk/v65 v65.121.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
|
github.com/oracle/oci-go-sdk/v65 v65.122.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
@@ -164,12 +182,16 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs
|
|||||||
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||||
|
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||||
|
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
|||||||
//
|
//
|
||||||
// @Summary 分页查询内容日志
|
// @Summary 分页查询内容日志
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} pagedResponse[model.AiCallLog]
|
// @Success 200 {object} pagedResponse[model.AiContentLog]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-content-logs [get]
|
// @Router /api/v1/ai-content-logs [get]
|
||||||
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
||||||
@@ -400,7 +400,7 @@ func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
|
|||||||
|
|
||||||
// @Summary AI 调用日志列表
|
// @Summary AI 调用日志列表
|
||||||
// @Tags AI 管理
|
// @Tags AI 管理
|
||||||
// @Success 200 {object} pagedResponse[model.AiContentLog]
|
// @Success 200 {object} pagedResponse[model.AiCallLog]
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/ai-logs [get]
|
// @Router /api/v1/ai-logs [get]
|
||||||
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedGatewayModel 直插启用渠道与模型缓存,绕过云同步。
|
// seedGatewayModel 直插启用渠道与模型缓存,绕过云同步。
|
||||||
@@ -29,7 +30,7 @@ func seedGatewayModel(t *testing.T, db *gorm.DB, name string) {
|
|||||||
|
|
||||||
func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
||||||
r, auth, _, db := newTestRouterDB(t)
|
r, auth, _, db := newTestRouterDB(t)
|
||||||
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -125,7 +126,7 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
|
|||||||
|
|
||||||
func TestAiGatewayModelsFilteredByKey(t *testing.T) {
|
func TestAiGatewayModelsFilteredByKey(t *testing.T) {
|
||||||
r, auth, _, db := newTestRouterDB(t)
|
r, auth, _, db := newTestRouterDB(t)
|
||||||
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -172,7 +173,7 @@ func TestAiGatewayModelsFilteredByKey(t *testing.T) {
|
|||||||
|
|
||||||
func TestAiKeyModelsUpdateRoundTrip(t *testing.T) {
|
func TestAiKeyModelsUpdateRoundTrip(t *testing.T) {
|
||||||
r, auth, _, _ := newTestRouterDB(t)
|
r, auth, _, _ := newTestRouterDB(t)
|
||||||
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -34,7 +33,9 @@ type loginRequest struct {
|
|||||||
// @Param body body loginRequest true "登录凭据"
|
// @Param body body loginRequest true "登录凭据"
|
||||||
// @Success 200 {object} tokenResponse "token 与 expiresAt"
|
// @Success 200 {object} tokenResponse "token 与 expiresAt"
|
||||||
// @Failure 401 {object} errorResponse "凭据错误"
|
// @Failure 401 {object} errorResponse "凭据错误"
|
||||||
|
// @Failure 403 {object} errorResponse "密码登录已禁用"
|
||||||
// @Failure 428 {object} totpRequiredResponse "需要两步验证码(totpRequired=true)"
|
// @Failure 428 {object} totpRequiredResponse "需要两步验证码(totpRequired=true)"
|
||||||
|
// @Failure 429 {object} errorResponse "登录守卫锁定(无 code);全局 IP 限流时 code=RateLimited"
|
||||||
// @Router /api/v1/auth/login [post]
|
// @Router /api/v1/auth/login [post]
|
||||||
func (h *authHandler) login(c *gin.Context) {
|
func (h *authHandler) login(c *gin.Context) {
|
||||||
var req loginRequest
|
var req loginRequest
|
||||||
@@ -43,7 +44,7 @@ func (h *authHandler) login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
token, expires, err := h.svc.Login(c.Request.Context(), req.Username, req.Password, requestIP(c), req.TotpCode)
|
token, expires, err := h.svc.Login(c.Request.Context(), req.Username, req.Password, req.TotpCode, sessionMetaOf(c))
|
||||||
if errors.Is(err, service.ErrTotpRequired) {
|
if errors.Is(err, service.ErrTotpRequired) {
|
||||||
// 密码已通过,引导前端弹出二次验证输入;不算失败不留痕
|
// 密码已通过,引导前端弹出二次验证输入;不算失败不留痕
|
||||||
c.JSON(http.StatusPreconditionRequired, gin.H{"error": err.Error(), "totpRequired": true})
|
c.JSON(http.StatusPreconditionRequired, gin.H{"error": err.Error(), "totpRequired": true})
|
||||||
@@ -80,9 +81,8 @@ func (h *authHandler) login(c *gin.Context) {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/logout [post]
|
// @Router /api/v1/auth/logout [post]
|
||||||
func (h *authHandler) logout(c *gin.Context) {
|
func (h *authHandler) logout(c *gin.Context) {
|
||||||
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
if token := bearerToken(c); token != "" {
|
||||||
if ok && token != "" {
|
h.svc.Logout(c.Request.Context(), token)
|
||||||
h.svc.Logout(token)
|
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-34
@@ -16,9 +16,11 @@ import (
|
|||||||
|
|
||||||
// authxHandler 处理两步验证与外部身份(OAuth)接口。
|
// authxHandler 处理两步验证与外部身份(OAuth)接口。
|
||||||
type authxHandler struct {
|
type authxHandler struct {
|
||||||
auth *service.AuthService
|
auth *service.AuthService
|
||||||
oauth *service.OAuthService
|
oauth *service.OAuthService
|
||||||
logs *service.SystemLogService
|
passkeys *service.PasskeyService
|
||||||
|
wallets *service.WalletService
|
||||||
|
logs *service.SystemLogService
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- TOTP(JWT 组内) ----
|
// ---- TOTP(JWT 组内) ----
|
||||||
@@ -65,7 +67,8 @@ func (h *authxHandler) totpSetup(c *gin.Context) {
|
|||||||
// @Summary 激活两步验证
|
// @Summary 激活两步验证
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body object true "{code: 6 位验证码}"
|
// @Param body body object true "{code: 6 位验证码}"
|
||||||
// @Success 204 "已启用"
|
// @Success 200 {object} tokenResponse "已启用,返回换发的新 token"
|
||||||
|
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/totp/activate [post]
|
// @Router /api/v1/auth/totp/activate [post]
|
||||||
func (h *authxHandler) totpActivate(c *gin.Context) {
|
func (h *authxHandler) totpActivate(c *gin.Context) {
|
||||||
@@ -76,7 +79,9 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := h.auth.ActivateTotp(c.Request.Context(), c.GetString(usernameKey), req.Code)
|
token, expires, err := h.auth.ActivateTotp(
|
||||||
|
c.Request.Context(), c.GetString(usernameKey), req.Code,
|
||||||
|
bearerToken(c), sessionMetaOf(c), tokenProofOf(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrTotpInvalid) || errors.Is(err, service.ErrTotpNotSetup) {
|
if errors.Is(err, service.ErrTotpInvalid) || errors.Is(err, service.ErrTotpNotSetup) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -85,7 +90,7 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.respondFreshToken(c)
|
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
|
||||||
}
|
}
|
||||||
|
|
||||||
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
|
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
|
||||||
@@ -93,7 +98,8 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
|
|||||||
// @Summary 停用两步验证
|
// @Summary 停用两步验证
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body object true "{password 或 code 任一确认}"
|
// @Param body body object true "{password 或 code 任一确认}"
|
||||||
// @Success 204 "已停用"
|
// @Success 200 {object} tokenResponse "已停用,返回换发的新 token"
|
||||||
|
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/totp/disable [post]
|
// @Router /api/v1/auth/totp/disable [post]
|
||||||
func (h *authxHandler) totpDisable(c *gin.Context) {
|
func (h *authxHandler) totpDisable(c *gin.Context) {
|
||||||
@@ -105,7 +111,9 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := h.auth.DisableTotp(c.Request.Context(), c.GetString(usernameKey), req.Password, req.Code)
|
token, expires, err := h.auth.DisableTotp(
|
||||||
|
c.Request.Context(), c.GetString(usernameKey), req.Password, req.Code,
|
||||||
|
bearerToken(c), sessionMetaOf(c), tokenProofOf(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrTotpConfirm) {
|
if errors.Is(err, service.ErrTotpConfirm) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -114,13 +122,14 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.respondFreshToken(c)
|
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondFreshToken 敏感变更后为操作者签发新令牌返回(版本已递增,
|
// respondFreshToken 敏感变更后为操作者签发新令牌返回(版本已递增,
|
||||||
// 旧令牌全部失效);签发失败降级 204,前端按会话失效走重新登录。
|
// 旧令牌全部失效);旧令牌的会话行接续到新令牌,列表中当前设备保持连续。
|
||||||
func (h *authxHandler) respondFreshToken(c *gin.Context) {
|
// 签发失败降级 204,前端按会话失效走重新登录。
|
||||||
token, expires, err := h.auth.IssueToken(c.Request.Context(), c.GetString(usernameKey))
|
func respondFreshToken(c *gin.Context, auth *service.AuthService) {
|
||||||
|
token, expires, err := auth.RenewToken(c.Request.Context(), c.GetString(usernameKey), bearerToken(c), sessionMetaOf(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
@@ -136,7 +145,7 @@ func (h *authxHandler) respondFreshToken(c *gin.Context) {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/revoke-sessions [post]
|
// @Router /api/v1/auth/revoke-sessions [post]
|
||||||
func (h *authxHandler) revokeSessions(c *gin.Context) {
|
func (h *authxHandler) revokeSessions(c *gin.Context) {
|
||||||
token, expires, err := h.auth.RevokeSessions(c.Request.Context(), c.GetString(usernameKey))
|
token, expires, err := h.auth.RevokeSessions(c.Request.Context(), c.GetString(usernameKey), bearerToken(c), sessionMetaOf(c), tokenProofOf(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -170,7 +179,8 @@ func (h *authxHandler) getCredentials(c *gin.Context) {
|
|||||||
// @Summary 修改登录凭据
|
// @Summary 修改登录凭据
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
|
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
|
||||||
// @Success 204 "已更新,请重新登录"
|
// @Success 200 {object} tokenResponse "已更新,返回换发的新 token"
|
||||||
|
// @Success 204 "已更新但新 token 签发失败,需重新登录"
|
||||||
// @Failure 401 {object} errorResponse "当前密码错误"
|
// @Failure 401 {object} errorResponse "当前密码错误"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/credentials [put]
|
// @Router /api/v1/auth/credentials [put]
|
||||||
@@ -180,7 +190,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
finalName, err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req)
|
finalName, err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req, tokenProofOf(c))
|
||||||
if errors.Is(err, service.ErrCredentialConfirm) {
|
if errors.Is(err, service.ErrCredentialConfirm) {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -194,7 +204,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Set(usernameKey, finalName)
|
c.Set(usernameKey, finalName)
|
||||||
h.respondFreshToken(c)
|
respondFreshToken(c, h.auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
|
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
|
||||||
@@ -202,7 +212,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
|||||||
// @Summary 密码登录开关
|
// @Summary 密码登录开关
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param body body object true "{disabled: bool}"
|
// @Param body body object true "{disabled: bool}"
|
||||||
// @Success 204 "已保存"
|
// @Success 200 {object} tokenResponse "已保存,返回换发的新 token"
|
||||||
|
// @Success 204 "已保存但新 token 签发失败,需重新登录"
|
||||||
// @Failure 409 {object} errorResponse "未绑定外部身份"
|
// @Failure 409 {object} errorResponse "未绑定外部身份"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/password-login [put]
|
// @Router /api/v1/auth/password-login [put]
|
||||||
@@ -214,7 +225,7 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := h.auth.SetPasswordLoginDisabled(c.Request.Context(), c.GetString(usernameKey), *req.Disabled)
|
err := h.auth.SetPasswordLoginDisabled(c.Request.Context(), c.GetString(usernameKey), *req.Disabled, tokenProofOf(c))
|
||||||
if errors.Is(err, service.ErrNeedIdentity) {
|
if errors.Is(err, service.ErrNeedIdentity) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -223,26 +234,31 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.respondFreshToken(c)
|
respondFreshToken(c, h.auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- OAuth ----
|
// ---- OAuth ----
|
||||||
|
|
||||||
// oauthProviders 返回已配置的 provider 列表(公开,登录页展示按钮),
|
// oauthProviders 返回已配置的 provider 列表(公开,登录页展示按钮),
|
||||||
// 并附带密码登录禁用开关:开启且存在可用外部身份时,登录页隐藏密码表单;
|
// 并附带密码登录禁用开关与通行密钥登录可用性:开关开启且存在可用外部身份时,
|
||||||
// provider 清空时不下发禁用,保证界面始终留有登录入口(后端 Login 仍会拒绝)。
|
// 登录页隐藏密码表单;provider 清空时不下发禁用,保证界面始终留有登录入口
|
||||||
|
// (后端 Login 仍会拒绝)。
|
||||||
//
|
//
|
||||||
// @Summary 外部登录 provider 列表
|
// @Summary 外部登录 provider 列表
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Success 200 {object} oauthProvidersResponse "providers 与 passwordLoginDisabled"
|
// @Success 200 {object} oauthProvidersResponse "providers、passwordLoginDisabled 与 passkeyLogin"
|
||||||
// @Router /api/v1/auth/oauth/providers [get]
|
// @Router /api/v1/auth/oauth/providers [get]
|
||||||
func (h *authxHandler) oauthProviders(c *gin.Context) {
|
func (h *authxHandler) oauthProviders(c *gin.Context) {
|
||||||
providers := h.oauth.Providers(c.Request.Context())
|
providers := h.oauth.Providers(c.Request.Context())
|
||||||
|
passkey := h.passkeys != nil && h.passkeys.HasAny(c.Request.Context())
|
||||||
|
wallet := h.wallets != nil && h.wallets.HasAny(c.Request.Context())
|
||||||
disabled := false
|
disabled := false
|
||||||
if off, err := h.auth.PasswordLoginDisabled(c.Request.Context()); err == nil && off && len(providers) > 0 {
|
// 有任一免密入口(OAuth/通行密钥/钱包)才如实下发禁用状态;全部缺席时隐藏,
|
||||||
|
// 保证登录页始终留有入口(后端 Login 仍会拒绝)
|
||||||
|
if off, err := h.auth.PasswordLoginDisabled(c.Request.Context()); err == nil && off && (len(providers) > 0 || passkey || wallet) {
|
||||||
disabled = true
|
disabled = true
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"providers": providers, "passwordLoginDisabled": disabled})
|
c.JSON(http.StatusOK, gin.H{"providers": providers, "passwordLoginDisabled": disabled, "passkeyLogin": passkey, "walletLogin": wallet})
|
||||||
}
|
}
|
||||||
|
|
||||||
// oauthAuthorize 返回授权跳转 URL;mode=bind 需有效 JWT(绑定到当前账号),login 公开。
|
// oauthAuthorize 返回授权跳转 URL;mode=bind 需有效 JWT(绑定到当前账号),login 公开。
|
||||||
@@ -252,6 +268,9 @@ func (h *authxHandler) oauthProviders(c *gin.Context) {
|
|||||||
// @Param provider path string true "oidc / github"
|
// @Param provider path string true "oidc / github"
|
||||||
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
|
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
|
||||||
// @Success 200 {object} urlResponse "url"
|
// @Success 200 {object} urlResponse "url"
|
||||||
|
// @Failure 401 {object} errorResponse "bind 模式未携带有效 Bearer"
|
||||||
|
// @Failure 404 {object} errorResponse "未知 provider"
|
||||||
|
// @Failure 409 {object} errorResponse "provider 未配置 / 面板地址缺失 / 已禁用"
|
||||||
// @Router /api/v1/auth/oauth/{provider}/authorize [get]
|
// @Router /api/v1/auth/oauth/{provider}/authorize [get]
|
||||||
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
||||||
provider := c.Param("provider")
|
provider := c.Param("provider")
|
||||||
@@ -268,7 +287,7 @@ func (h *authxHandler) oauthAuthorize(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
mode, username = "bind", name
|
mode, username = "bind", name
|
||||||
}
|
}
|
||||||
authURL, err := h.oauth.AuthorizeURL(c.Request.Context(), provider, mode, username)
|
authURL, err := h.oauth.AuthorizeURL(c.Request.Context(), provider, mode, username, bearerToken(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrOAuthNotConfigured) || errors.Is(err, service.ErrOAuthNoAppURL) || errors.Is(err, service.ErrOAuthDisabled) {
|
if errors.Is(err, service.ErrOAuthNotConfigured) || errors.Is(err, service.ErrOAuthNoAppURL) || errors.Is(err, service.ErrOAuthDisabled) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
@@ -306,7 +325,7 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
provider := c.Param("provider")
|
provider := c.Param("provider")
|
||||||
token, username, mode, err := h.oauth.HandleCallback(
|
token, username, mode, err := h.oauth.HandleCallback(
|
||||||
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
|
c.Request.Context(), provider, c.Query("state"), c.Query("code"), sessionMetaOf(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
|
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
|
||||||
target := "/login"
|
target := "/login"
|
||||||
@@ -375,7 +394,8 @@ func (h *authxHandler) identities(c *gin.Context) {
|
|||||||
// @Summary 解绑外部身份
|
// @Summary 解绑外部身份
|
||||||
// @Tags 认证
|
// @Tags 认证
|
||||||
// @Param id path int true "身份 ID"
|
// @Param id path int true "身份 ID"
|
||||||
// @Success 204 "已解绑"
|
// @Success 200 {object} tokenResponse "已解绑,返回换发的新 token"
|
||||||
|
// @Success 204 "已解绑但新 token 签发失败,需重新登录"
|
||||||
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
|
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/auth/identities/{id} [delete]
|
// @Router /api/v1/auth/identities/{id} [delete]
|
||||||
@@ -385,7 +405,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.oauth.Unbind(c.Request.Context(), c.GetString(usernameKey), uint(id)); err != nil {
|
if err := h.oauth.Unbind(c.Request.Context(), c.GetString(usernameKey), uint(id), tokenProofOf(c)); err != nil {
|
||||||
if errors.Is(err, service.ErrLastIdentity) {
|
if errors.Is(err, service.ErrLastIdentity) {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -393,7 +413,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.respondFreshToken(c)
|
respondFreshToken(c, h.auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- OAuth provider 设置(JWT 组内) ----
|
// ---- OAuth provider 设置(JWT 组内) ----
|
||||||
@@ -414,21 +434,31 @@ func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.Settin
|
|||||||
c.JSON(http.StatusOK, view)
|
c.JSON(http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateOAuthSettings 保存 provider 配置;secret 缺省沿用、空串清除。
|
// updateOAuthSettings 部分更新 provider 配置;缺省字段沿用现值,
|
||||||
|
// secret 传空串清除、缺省沿用。
|
||||||
//
|
//
|
||||||
// @Summary 保存 OAuth provider 配置
|
// @Summary 部分更新 OAuth provider 配置
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.UpdateOAuthInput true "provider 配置"
|
// @Param body body service.UpdateOAuthInput true "出现的字段才会被更新"
|
||||||
// @Success 200 {object} service.OAuthProvidersView
|
// @Success 200 {object} service.OAuthProvidersView
|
||||||
|
// @Failure 400 {object} errorResponse "请求体非法"
|
||||||
|
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
|
||||||
|
// @Failure 409 {object} errorResponse "密码登录禁用期间,该变更将移除最后可用登录方式"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/oauth [put]
|
// @Router /api/v1/settings/oauth [patch]
|
||||||
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
|
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
|
||||||
var req service.UpdateOAuthInput
|
var req service.UpdateOAuthInput
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := settings.UpdateOAuth(c.Request.Context(), req); err != nil {
|
err := settings.UpdateOAuthAuthenticated(
|
||||||
|
c.Request.Context(), req, h.auth, c.GetString(usernameKey), tokenProofOf(c))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrProviderLastLogin) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-3
@@ -1,13 +1,121 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// idpIconResponse 是图标上传响应;fileName 为域存储内标识,留作后续清理。
|
||||||
|
type idpIconResponse struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type idpSetupWarning struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ResourceCreated bool `json:"resourceCreated"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createIdpResponse struct {
|
||||||
|
oci.IdentityProviderInfo
|
||||||
|
SetupWarning *idpSetupWarning `json:"setupWarning,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 上传身份提供商图标
|
||||||
|
// @Description 上传到身份域公共图片存储(/storage/v1/Images),返回可填入 iconUrl 的公网地址
|
||||||
|
// @Tags 租户 IAM
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
|
// @Param file formData file true "图标文件(png/jpg/jpeg/gif/svg/webp/ico,≤1MB)"
|
||||||
|
// @Success 200 {object} idpIconResponse
|
||||||
|
// @Failure 400 {object} map[string]string "缺文件字段、文件为空或文件名非法"
|
||||||
|
// @Failure 413 {object} map[string]string "文件超过 1MB"
|
||||||
|
// @Failure 415 {object} map[string]string "扩展名不支持或与文件内容不符"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/idp-icons [post]
|
||||||
|
func (h *ociConfigHandler) uploadIdpIcon(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
status, msg := iconFormStatus(err)
|
||||||
|
c.JSON(status, gin.H{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
data, err := io.ReadAll(file) // 请求体总量由路由级 bodyLimit 兜底
|
||||||
|
if err != nil {
|
||||||
|
status, msg := iconFormStatus(err)
|
||||||
|
c.JSON(status, gin.H{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url, fileName, err := h.svc.UploadIdpIcon(c.Request.Context(), id, c.Query("domainId"), header.Filename, data)
|
||||||
|
if err != nil {
|
||||||
|
respondIdpIconErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, idpIconResponse{URL: url, FileName: fileName})
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 删除身份提供商图标
|
||||||
|
// @Description 使用上传响应中的 fileName 删除身份域公开图片;404 按已删除处理
|
||||||
|
// @Tags 租户 IAM
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
|
// @Param fileName query string true "上传响应返回的 IdP 图标存储标识(images/idp-icon-<32hex>.<ext>)"
|
||||||
|
// @Success 204 "无内容"
|
||||||
|
// @Failure 400 {object} map[string]string "fileName 缺失或不是本服务生成的 IdP 图标标识"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/idp-icons [delete]
|
||||||
|
func (h *ociConfigHandler) deleteIdpIcon(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteIdpIcon(c.Request.Context(), id, c.Query("domainId"), c.Query("fileName")); err != nil {
|
||||||
|
respondIdpIconErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// iconFormStatus 区分请求体超限(413)与表单缺失/损坏(400)。
|
||||||
|
func iconFormStatus(err error) (int, string) {
|
||||||
|
var mbe *http.MaxBytesError
|
||||||
|
if errors.As(err, &mbe) {
|
||||||
|
return http.StatusRequestEntityTooLarge, "请求体超出上限(图标最大 1MB)"
|
||||||
|
}
|
||||||
|
return http.StatusBadRequest, "缺少文件字段 file"
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondIdpIconErr 把图标校验哨兵错误映射为语义状态码,其余走统一错误边界。
|
||||||
|
func respondIdpIconErr(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrIdpIconTooLarge):
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图标不能超过 1MB"})
|
||||||
|
case errors.Is(err, service.ErrIdpIconBadType):
|
||||||
|
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "扩展名不支持或与文件内容不符"})
|
||||||
|
case errors.Is(err, service.ErrIdpIconEmpty):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"})
|
||||||
|
case errors.Is(err, service.ErrIdpIconBadName):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "图标文件名非法"})
|
||||||
|
default:
|
||||||
|
respondError(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Federation:SAML IdP 与 sign-on 免 MFA ----
|
// ---- Federation:SAML IdP 与 sign-on 免 MFA ----
|
||||||
|
|
||||||
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
|
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
|
||||||
@@ -58,11 +166,12 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @Summary 创建身份提供商(SAML)
|
// @Summary 创建身份提供商(SAML)
|
||||||
|
// @Description 创建禁用态 IdP;若 IdP 已创建但 JIT 后置配置失败且回滚无法确认,仍返回 201,并在 setupWarning 中标明部分成功
|
||||||
// @Tags 租户 IAM
|
// @Tags 租户 IAM
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||||
// @Param body body createIdpRequest true "请求体"
|
// @Param body body createIdpRequest true "请求体"
|
||||||
// @Success 201 {object} oci.IdentityProviderInfo
|
// @Success 201 {object} createIdpResponse
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
|
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
|
||||||
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||||
@@ -89,11 +198,37 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
|||||||
JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true),
|
JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true),
|
||||||
JitMapEmail: req.JitMapEmail,
|
JitMapEmail: req.JitMapEmail,
|
||||||
})
|
})
|
||||||
if err != nil {
|
respondCreateIdpResult(c, idp, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondCreateIdpResult(c *gin.Context, idp oci.IdentityProviderInfo, err error) {
|
||||||
|
if err == nil {
|
||||||
|
c.JSON(http.StatusCreated, createIdpResponse{IdentityProviderInfo: idp})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var partial *oci.PartialIdentityProviderCreateError
|
||||||
|
if !errors.As(err, &partial) {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, idp)
|
requestID := logPartialIdpCreate(c, partial)
|
||||||
|
c.JSON(http.StatusCreated, createIdpResponse{
|
||||||
|
IdentityProviderInfo: partial.IdentityProvider,
|
||||||
|
SetupWarning: &idpSetupWarning{
|
||||||
|
Code: oci.IdpSetupWarningCode, Message: "JIT 配置未完成,自动回滚状态无法确认,请刷新列表检查",
|
||||||
|
ResourceCreated: true, RequestID: requestID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func logPartialIdpCreate(c *gin.Context, partial *oci.PartialIdentityProviderCreateError) string {
|
||||||
|
requestID := newRequestID()
|
||||||
|
detail := errors.Unwrap(partial)
|
||||||
|
if detail == nil {
|
||||||
|
detail = partial
|
||||||
|
}
|
||||||
|
log.Printf("[WARN %s] %s %s: partial IdP create: %v", requestID, c.Request.Method, requestPath(c), detail)
|
||||||
|
return requestID
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary 激活身份提供商
|
// @Summary 激活身份提供商
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"oci-portal/internal/crypto"
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newIconTestRouter(t *testing.T) (*gin.Engine, string, uint, *service.SystemLogService) {
|
||||||
|
t.Helper()
|
||||||
|
router, auth, logs, db := newTestRouterDB(t)
|
||||||
|
id := seedIconConfig(t, db)
|
||||||
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
return router, token, id, logs
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedIconConfig(t *testing.T, db *gorm.DB) uint {
|
||||||
|
t.Helper()
|
||||||
|
cipher, err := crypto.NewCipher("test-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new cipher: %v", err)
|
||||||
|
}
|
||||||
|
key, err := cipher.EncryptString("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypt key: %v", err)
|
||||||
|
}
|
||||||
|
cfg := model.OciConfig{Alias: "icons", TenancyOCID: "ocid1.tenancy.oc1..t", UserOCID: "ocid1.user.oc1..u",
|
||||||
|
Fingerprint: "aa:bb", Region: "us-ashburn-1", HomeRegionKey: "IAD", PrivateKeyEnc: key}
|
||||||
|
if err := db.Create(&cfg).Error; err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
return cfg.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func iconSVG(t *testing.T, size int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
data := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||||
|
if len(data) > size {
|
||||||
|
t.Fatalf("svg size %d exceeds target %d", len(data), size)
|
||||||
|
}
|
||||||
|
return append(data, bytes.Repeat([]byte(" "), size-len(data))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendIconUpload(t *testing.T, router *gin.Engine, token, path, fileName string, data []byte) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("file", fileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create form file: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = part.Write(data)
|
||||||
|
_ = writer.Close()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdpIconMultipartSizeBoundary(t *testing.T) {
|
||||||
|
router, token, id, logs := newIconTestRouter(t)
|
||||||
|
t.Cleanup(logs.Wait)
|
||||||
|
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
|
||||||
|
cases := []struct {
|
||||||
|
name, fileName string
|
||||||
|
data []byte
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"exactly 1MiB", "icon.svg", iconSVG(t, 1<<20), http.StatusOK},
|
||||||
|
{"one byte over", "icon.svg", iconSVG(t, 1<<20+1), http.StatusRequestEntityTooLarge},
|
||||||
|
{"empty", "icon.png", nil, http.StatusBadRequest},
|
||||||
|
{"spoofed png", "icon.png", []byte("not an image"), http.StatusUnsupportedMediaType},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
rec := sendIconUpload(t, router, token, path, tc.fileName, tc.data)
|
||||||
|
if rec.Code != tc.want {
|
||||||
|
t.Errorf("%s status = %d, want %d, body %s", tc.name, rec.Code, tc.want, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteIdpIconRoute(t *testing.T) {
|
||||||
|
router, token, id, logs := newIconTestRouter(t)
|
||||||
|
t.Cleanup(logs.Wait)
|
||||||
|
base := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
|
||||||
|
fileName := "images/idp-icon-" + strings.Repeat("ab", 16) + ".png"
|
||||||
|
query := url.Values{"fileName": {fileName}}
|
||||||
|
rec := doRequest(t, router, http.MethodDelete, base+"?"+query.Encode(), token, "")
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("delete status = %d, want 204, body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
rec = doRequest(t, router, http.MethodDelete, base+"?fileName=images/company-brand.png", token, "")
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("invalid delete status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertResponderStatus(t *testing.T, name string, err error, want int, responder func(*gin.Context, error)) {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
responder(ctx, err)
|
||||||
|
if rec.Code != want {
|
||||||
|
t.Errorf("%s status = %d, want %d", name, rec.Code, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdpIconErrorResponder(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"empty icon", service.ErrIdpIconEmpty, 400},
|
||||||
|
{"bad icon name", service.ErrIdpIconBadName, 400},
|
||||||
|
{"large icon", service.ErrIdpIconTooLarge, 413},
|
||||||
|
{"bad icon type", service.ErrIdpIconBadType, 415},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
assertResponderStatus(t, tc.name, tc.err, tc.want, respondIdpIconErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIdpOrdinaryErrorHasNoPartialSemantics(t *testing.T) {
|
||||||
|
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.New("create failed"))
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want 500; body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := body["setupWarning"]; ok || bytes.Contains(rec.Body.Bytes(), []byte("resourceCreated")) {
|
||||||
|
t.Errorf("ordinary error unexpectedly carries partial semantics: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIdpPartialErrorReturnsStableWarning(t *testing.T) {
|
||||||
|
partial := &oci.PartialIdentityProviderCreateError{
|
||||||
|
IdentityProvider: oci.IdentityProviderInfo{ID: "idp-1", Name: "test-idp"},
|
||||||
|
}
|
||||||
|
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.Join(errors.New("raw secret"), partial))
|
||||||
|
warning, ok := body["setupWarning"].(map[string]interface{})
|
||||||
|
if rec.Code != http.StatusCreated || !ok {
|
||||||
|
t.Fatalf("status = %d, warning = %#v; body %s", rec.Code, body["setupWarning"], rec.Body.String())
|
||||||
|
}
|
||||||
|
requestID, _ := warning["requestId"].(string)
|
||||||
|
if warning["code"] != oci.IdpSetupWarningCode || warning["resourceCreated"] != true || requestID == "" {
|
||||||
|
t.Errorf("warning = %#v, want stable partial-create contract", warning)
|
||||||
|
}
|
||||||
|
if bytes.Contains(rec.Body.Bytes(), []byte("secret")) {
|
||||||
|
t.Errorf("partial response leaks internal cause: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func callCreateIdpResponder(t *testing.T, idp oci.IdentityProviderInfo, err error) (*httptest.ResponseRecorder, map[string]interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, "/identity-providers", nil)
|
||||||
|
respondCreateIdpResult(ctx, idp, err)
|
||||||
|
body := map[string]interface{}{}
|
||||||
|
if decodeErr := json.Unmarshal(rec.Body.Bytes(), &body); decodeErr != nil {
|
||||||
|
t.Fatalf("decode response: %v", decodeErr)
|
||||||
|
}
|
||||||
|
return rec, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPARErrorResponder(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{"bad PAR type", service.ErrPARInvalidAccessType},
|
||||||
|
{"bad PAR expiry", service.ErrPARInvalidExpiration},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
assertResponderStatus(t, tc.name, tc.err, http.StatusBadRequest, respondPARError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePARValidationReturns400(t *testing.T) {
|
||||||
|
router, token, id, logs := newIconTestRouter(t)
|
||||||
|
t.Cleanup(logs.Wait)
|
||||||
|
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/buckets/b/pars"
|
||||||
|
cases := []struct {
|
||||||
|
name, body string
|
||||||
|
}{
|
||||||
|
{"bad access type", `{"accessType":"BucketRead","expiresHours":24}`},
|
||||||
|
{"zero expiration", `{"accessType":"ObjectRead","expiresHours":0}`},
|
||||||
|
{"excessive expiration", `{"accessType":"ObjectRead","expiresHours":876001}`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
rec := doRequest(t, router, http.MethodPost, path, token, tc.body)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s status = %d, want 400, body %s", tc.name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -202,6 +203,7 @@ func (h *ociConfigHandler) updateInstance(c *gin.Context) {
|
|||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Success 204 "无内容"
|
// @Success 204 "无内容"
|
||||||
|
// @Failure 409 {object} errorResponse "该实例的生命周期操作正在处理中"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [delete]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [delete]
|
||||||
func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
|
func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
|
||||||
@@ -209,20 +211,43 @@ func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
instanceID := c.Param("instanceId")
|
||||||
|
if !beginInstanceLifecycleOperation(instanceID) {
|
||||||
|
respondInstanceLifecycleBusy(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer endInstanceLifecycleOperation(instanceID)
|
||||||
preserve := c.Query("preserveBootVolume") == "true"
|
preserve := c.Query("preserveBootVolume") == "true"
|
||||||
if err := h.svc.TerminateInstance(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"), preserve); err != nil {
|
if err := h.svc.TerminateInstance(c.Request.Context(), id, c.Query("region"), instanceID, preserve); err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// instanceLifecycleGuard 按实例 OCID 互斥 HTTP 请求飞行期内的终止与电源操作。
|
||||||
|
var instanceLifecycleGuard sync.Map
|
||||||
|
|
||||||
|
func beginInstanceLifecycleOperation(instanceID string) bool {
|
||||||
|
_, busy := instanceLifecycleGuard.LoadOrStore(instanceID, struct{}{})
|
||||||
|
return !busy
|
||||||
|
}
|
||||||
|
|
||||||
|
func endInstanceLifecycleOperation(instanceID string) {
|
||||||
|
instanceLifecycleGuard.Delete(instanceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondInstanceLifecycleBusy(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "该实例的生命周期操作正在处理中"})
|
||||||
|
}
|
||||||
|
|
||||||
// @Summary 实例电源操作(启停/重启)
|
// @Summary 实例电源操作(启停/重启)
|
||||||
// @Tags 计算
|
// @Tags 计算
|
||||||
// @Param id path int true "配置 ID"
|
// @Param id path int true "配置 ID"
|
||||||
// @Param instanceId path string true "instanceId"
|
// @Param instanceId path string true "instanceId"
|
||||||
// @Param body body instanceActionRequest true "请求体"
|
// @Param body body instanceActionRequest true "请求体"
|
||||||
// @Success 200 {object} oci.Instance
|
// @Success 200 {object} oci.Instance
|
||||||
|
// @Failure 409 {object} errorResponse "该实例的生命周期操作正在处理中"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
|
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
|
||||||
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
|
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
|
||||||
@@ -235,7 +260,13 @@ func (h *ociConfigHandler) instanceAction(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
instance, err := h.svc.InstanceAction(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.Action)
|
instanceID := c.Param("instanceId")
|
||||||
|
if !beginInstanceLifecycleOperation(instanceID) {
|
||||||
|
respondInstanceLifecycleBusy(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer endInstanceLifecycleOperation(instanceID)
|
||||||
|
instance, err := h.svc.InstanceAction(c.Request.Context(), id, req.Region, instanceID, req.Action)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func countLifecycleWinners(instanceID string, workers int) int {
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan bool, workers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range workers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
results <- beginInstanceLifecycleOperation(instanceID)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
winners := 0
|
||||||
|
for won := range results {
|
||||||
|
if won {
|
||||||
|
winners++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return winners
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeginInstanceLifecycleOperationSingleFlight(t *testing.T) {
|
||||||
|
instanceID := "ocid1.instance.oc1..single-flight"
|
||||||
|
instanceLifecycleGuard.Delete(instanceID)
|
||||||
|
t.Cleanup(func() { instanceLifecycleGuard.Delete(instanceID) })
|
||||||
|
if got, want := countLifecycleWinners(instanceID, 16), 1; got != want {
|
||||||
|
t.Errorf("winners = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func instanceOperationRouter() *gin.Engine {
|
||||||
|
h := &ociConfigHandler{}
|
||||||
|
r := gin.New()
|
||||||
|
r.DELETE("/oci-configs/:id/instances/:instanceId", h.terminateInstance)
|
||||||
|
r.POST("/oci-configs/:id/instances/:instanceId/action", h.instanceAction)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceLifecycleOperationRejectsLaterRequest(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
instanceID := "ocid1.instance.oc1..same-physical-instance"
|
||||||
|
instanceLifecycleGuard.Store(instanceID, struct{}{})
|
||||||
|
t.Cleanup(func() { instanceLifecycleGuard.Delete(instanceID) })
|
||||||
|
r := instanceOperationRouter()
|
||||||
|
tests := []struct {
|
||||||
|
name, method, path, body string
|
||||||
|
}{
|
||||||
|
{"action先占位时跨配置terminate被拒", http.MethodDelete, "/oci-configs/2/instances/" + instanceID, ""},
|
||||||
|
{"terminate先占位时action被拒", http.MethodPost, "/oci-configs/1/instances/" + instanceID + "/action", `{"action":"STOP"}`},
|
||||||
|
{"action先占位时第二个action被拒", http.MethodPost, "/oci-configs/3/instances/" + instanceID + "/action", `{"action":"RESET"}`},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if got, want := w.Code, http.StatusConflict; got != want {
|
||||||
|
t.Errorf("status = %d, want %d, body %s", got, want, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -12,11 +13,17 @@ import (
|
|||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键。
|
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键;
|
||||||
const usernameKey = "username"
|
// tokenVerKey/tokenJtiKey 是鉴权时观察到的令牌快照,敏感事务提交前复核。
|
||||||
|
const (
|
||||||
|
usernameKey = "username"
|
||||||
|
tokenVerKey = "tokenVer"
|
||||||
|
tokenJtiKey = "tokenJti"
|
||||||
|
)
|
||||||
|
|
||||||
// RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
|
// RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
|
||||||
// 通过后把用户名放进上下文。
|
// 通过后把用户名与令牌快照(版本/jti)放进上下文——快照供敏感事务在
|
||||||
|
// 行锁下复核,防「请求挂起期间令牌被撤销,恢复后仍完成变更」的在途绕过。
|
||||||
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||||
@@ -24,16 +31,25 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
|||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
username, err := auth.ParseToken(c.Request.Context(), token)
|
username, proof, err := auth.ParseTokenProof(c.Request.Context(), token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Set(usernameKey, username)
|
c.Set(usernameKey, username)
|
||||||
|
c.Set(tokenVerKey, proof.Ver)
|
||||||
|
c.Set(tokenJtiKey, proof.Jti)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tokenProofOf 取出鉴权时的令牌快照,交给敏感 service 事务复核。
|
||||||
|
func tokenProofOf(c *gin.Context) service.TokenProof {
|
||||||
|
v, _ := c.Get(tokenVerKey)
|
||||||
|
ver, _ := v.(uint)
|
||||||
|
return service.TokenProof{Ver: ver, Jti: c.GetString(tokenJtiKey)}
|
||||||
|
}
|
||||||
|
|
||||||
// bodyLimit 给请求体套上限(S-03):超限读取由 MaxBytesReader 截断报错,
|
// bodyLimit 给请求体套上限(S-03):超限读取由 MaxBytesReader 截断报错,
|
||||||
// 绑定失败返回 400;webhook 入口另有独立 256KiB 自限,不经此中间件。
|
// 绑定失败返回 400;webhook 入口另有独立 256KiB 自限,不经此中间件。
|
||||||
func bodyLimit(n int64) gin.HandlerFunc {
|
func bodyLimit(n int64) gin.HandlerFunc {
|
||||||
@@ -45,7 +61,7 @@ func bodyLimit(n int64) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。
|
// systemLogMiddleware 把写请求(POST/PUT/PATCH/DELETE)异步记入系统日志,只读请求跳过。
|
||||||
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
|
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
|
||||||
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
|
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@@ -101,17 +117,23 @@ func errMsgOf(status int, head []byte) string {
|
|||||||
return truncateLogField(body.Error, 256)
|
return truncateLogField(body.Error, 256)
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽。
|
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽;
|
||||||
|
// 截断点回退到 rune 边界,避免产生非法 UTF-8(严格 MySQL/PG 会拒写整行)。
|
||||||
func truncateLogField(s string, max int) string {
|
func truncateLogField(s string, max int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= max {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
for max > 0 && !utf8.RuneStart(s[max]) {
|
||||||
|
max--
|
||||||
|
}
|
||||||
return s[:max]
|
return s[:max]
|
||||||
}
|
}
|
||||||
|
|
||||||
// isWriteMethod 判断是否需要留痕的写方法。
|
// isWriteMethod 判断是否需要留痕的写方法;PATCH 承载 OAuth / 安全设置等
|
||||||
|
// 敏感配置变更,必须与 POST/PUT/DELETE 一样进入审计。
|
||||||
func isWriteMethod(m string) bool {
|
func isWriteMethod(m string) bool {
|
||||||
return m == http.MethodPost || m == http.MethodPut || m == http.MethodDelete
|
return m == http.MethodPost || m == http.MethodPut ||
|
||||||
|
m == http.MethodPatch || m == http.MethodDelete
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestPath 优先取注册的路由模板(含 :id 占位符,避免把敏感 query 记入日志),
|
// requestPath 优先取注册的路由模板(含 :id 占位符,避免把敏感 query 记入日志),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PATCH 承载 OAuth / 安全设置变更,必须纳入审计;只读方法不留痕。
|
||||||
|
func TestIsWriteMethod(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
method string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{http.MethodPost, true},
|
||||||
|
{http.MethodPut, true},
|
||||||
|
{http.MethodPatch, true},
|
||||||
|
{http.MethodDelete, true},
|
||||||
|
{http.MethodGet, false},
|
||||||
|
{http.MethodHead, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := isWriteMethod(tt.method); got != tt.want {
|
||||||
|
t.Errorf("isWriteMethod(%s) = %v, want %v", tt.method, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateLogField(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
max int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "短串原样", in: "abc", max: 10, want: "abc"},
|
||||||
|
{name: "ASCII 截断", in: "abcdef", max: 4, want: "abcd"},
|
||||||
|
{name: "中文边界回退", in: "中文日志", max: 4, want: "中"},
|
||||||
|
{name: "恰好落在边界", in: "中文", max: 3, want: "中"},
|
||||||
|
{name: "emoji 拦腰", in: "a😀b", max: 3, want: "a"},
|
||||||
|
{name: "超长 UA 不产非法序列", in: strings.Repeat("界", 200), max: 255, want: strings.Repeat("界", 85)},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := truncateLogField(tt.in, tt.max)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("truncateLogField(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(got) {
|
||||||
|
t.Errorf("result %q is not valid UTF-8", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- 对象存储 ----
|
// ---- 对象存储 ----
|
||||||
@@ -363,6 +364,7 @@ type createPARRequest struct {
|
|||||||
// @Param bucket path string true "桶名"
|
// @Param bucket path string true "桶名"
|
||||||
// @Param body body createPARRequest true "请求体"
|
// @Param body body createPARRequest true "请求体"
|
||||||
// @Success 201 {object} oci.PAR "fullUrl 仅创建响应返回,请立即保存"
|
// @Success 201 {object} oci.PAR "fullUrl 仅创建响应返回,请立即保存"
|
||||||
|
// @Failure 400 {object} map[string]string "accessType 或 expiresHours 非法"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [post]
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [post]
|
||||||
func (h *ociConfigHandler) createPAR(c *gin.Context) {
|
func (h *ociConfigHandler) createPAR(c *gin.Context) {
|
||||||
@@ -379,12 +381,23 @@ func (h *ociConfigHandler) createPAR(c *gin.Context) {
|
|||||||
Name: req.Name, ObjectName: req.ObjectName, AccessType: req.AccessType, ExpiresHours: req.ExpiresHours,
|
Name: req.Name, ObjectName: req.ObjectName, AccessType: req.AccessType, ExpiresHours: req.ExpiresHours,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondPARError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, par)
|
c.JSON(http.StatusCreated, par)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func respondPARError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrPARInvalidAccessType):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "accessType 不受支持"})
|
||||||
|
case errors.Is(err, service.ErrPARInvalidExpiration):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "expiresHours 必须在 1-876000 范围内"})
|
||||||
|
default:
|
||||||
|
respondError(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// parPage 是 PAR 游标分页响应;nextPage 空串表示已到末页。
|
// parPage 是 PAR 游标分页响应;nextPage 空串表示已到末页。
|
||||||
type parPage struct {
|
type parPage struct {
|
||||||
Items []oci.PAR `json:"items"`
|
Items []oci.PAR `json:"items"`
|
||||||
@@ -424,7 +437,8 @@ func (h *ociConfigHandler) listPARs(c *gin.Context) {
|
|||||||
// @Param parId query string false "PAR ID(可含 / 等字符,故经 query 传递)"
|
// @Param parId query string false "PAR ID(可含 / 等字符,故经 query 传递)"
|
||||||
// @Param all query string false "为 1 时删除全部"
|
// @Param all query string false "为 1 时删除全部"
|
||||||
// @Param region query string false "区域"
|
// @Param region query string false "区域"
|
||||||
// @Success 204 "删除成功,链接立即失效"
|
// @Success 200 {object} map[string]int "all=1 时返回 {deleted: n}"
|
||||||
|
// @Success 204 "按 parId 删单条成功,链接立即失效"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [delete]
|
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [delete]
|
||||||
func (h *ociConfigHandler) deletePAR(c *gin.Context) {
|
func (h *ociConfigHandler) deletePAR(c *gin.Context) {
|
||||||
|
|||||||
@@ -277,6 +277,12 @@ func respondError(c *gin.Context, err error) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 请求处理期间令牌已失效(撤销/注销/并发敏感操作后到):按未认证处理,
|
||||||
|
// 前端「发送时令牌==当前令牌」快照决定是否登出
|
||||||
|
if errors.Is(err, service.ErrTokenStale) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
id := newRequestID()
|
id := newRequestID()
|
||||||
log.Printf("[ERR %s] %s %s: %v", id, c.Request.Method, requestPath(c), err)
|
log.Printf("[ERR %s] %s %s: %v", id, c.Request.Method, requestPath(c), err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误", "requestId": id})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误", "requestId": id})
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// passkeyHandler 处理通行密钥(WebAuthn)注册、登录与凭据管理接口。
|
||||||
|
type passkeyHandler struct {
|
||||||
|
svc *service.PasskeyService
|
||||||
|
auth *service.AuthService
|
||||||
|
logs *service.SystemLogService
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerBegin 生成注册 options;sessionId 需原样带回 finish。
|
||||||
|
//
|
||||||
|
// @Summary 发起通行密钥注册
|
||||||
|
// @Tags 认证
|
||||||
|
// @Success 200 {object} passkeyOptionsResponse "sessionId 与 WebAuthn options"
|
||||||
|
// @Failure 409 {object} errorResponse "面板地址未设置或数量达上限"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/auth/passkey/register/begin [post]
|
||||||
|
func (h *passkeyHandler) registerBegin(c *gin.Context) {
|
||||||
|
sid, opts, err := h.svc.BeginRegister(c.Request.Context(), c.GetString(usernameKey))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrPasskeyNoAppURL) || errors.Is(err, service.ErrPasskeyLimit) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"sessionId": sid, "options": opts})
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyFinishRequest 是注册/登录 finish 的请求体;credential 为浏览器
|
||||||
|
// navigator.credentials 返回的 WebAuthn JSON,原样透传后端解析。
|
||||||
|
type passkeyFinishRequest struct {
|
||||||
|
SessionId string `json:"sessionId" binding:"required,max=64"`
|
||||||
|
Name string `json:"name" binding:"max=64"`
|
||||||
|
Credential json.RawMessage `json:"credential" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerFinish 校验注册响应并保存凭据;属敏感变更,换发新令牌。
|
||||||
|
//
|
||||||
|
// @Summary 完成通行密钥注册
|
||||||
|
// @Tags 认证
|
||||||
|
// @Param body body passkeyFinishRequest true "sessionId、名称与凭据响应"
|
||||||
|
// @Success 200 {object} tokenResponse "已注册,返回换发的新 token"
|
||||||
|
// @Success 204 "已注册但新 token 签发失败,需重新登录"
|
||||||
|
// @Failure 400 {object} errorResponse "请求体非法、会话过期或凭据校验失败"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/auth/passkey/register/finish [post]
|
||||||
|
func (h *passkeyHandler) registerFinish(c *gin.Context) {
|
||||||
|
var req passkeyFinishRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.svc.FinishRegister(c.Request.Context(), c.GetString(usernameKey),
|
||||||
|
req.SessionId, req.Name, bytes.NewReader(req.Credential), tokenProofOf(c))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrPasskeySession) || errors.Is(err, service.ErrPasskeyVerify) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondFreshToken(c, h.auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// list 列出当前账号的通行密钥。
|
||||||
|
//
|
||||||
|
// @Summary 通行密钥列表
|
||||||
|
// @Tags 认证
|
||||||
|
// @Success 200 {object} itemsResponse[model.UserPasskey] "items"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/auth/passkeys [get]
|
||||||
|
func (h *passkeyHandler) list(c *gin.Context) {
|
||||||
|
items, err := h.svc.List(c.Request.Context(), c.GetString(usernameKey))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove 删除通行密钥;属敏感变更,换发新令牌。
|
||||||
|
// 密码登录禁用期间,最后一个免密登录方式不可删除(防自锁)。
|
||||||
|
//
|
||||||
|
// @Summary 删除通行密钥
|
||||||
|
// @Tags 认证
|
||||||
|
// @Param id path int true "凭据 ID"
|
||||||
|
// @Success 200 {object} tokenResponse "已删除,返回换发的新 token"
|
||||||
|
// @Success 204 "已删除但新 token 签发失败,需重新登录"
|
||||||
|
// @Failure 409 {object} errorResponse "密码登录已禁用且这是最后一个免密登录方式"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/auth/passkeys/{id} [delete]
|
||||||
|
func (h *passkeyHandler) remove(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Remove(c.Request.Context(), c.GetString(usernameKey), uint(id), tokenProofOf(c)); err != nil {
|
||||||
|
if errors.Is(err, service.ErrLastIdentity) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondFreshToken(c, h.auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginBegin 生成登录断言 options(公开;无凭据也正常下发,不泄露账号状态)。
|
||||||
|
//
|
||||||
|
// @Summary 发起通行密钥登录
|
||||||
|
// @Tags 认证
|
||||||
|
// @Success 200 {object} passkeyOptionsResponse "sessionId 与 WebAuthn options"
|
||||||
|
// @Failure 409 {object} errorResponse "面板地址未设置"
|
||||||
|
// @Router /api/v1/auth/passkey/login/begin [post]
|
||||||
|
func (h *passkeyHandler) loginBegin(c *gin.Context) {
|
||||||
|
sid, opts, err := h.svc.BeginLogin(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrPasskeyNoAppURL) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"sessionId": sid, "options": opts})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginFinish 校验断言并签发 JWT;凭 UV 豁免 TOTP。
|
||||||
|
//
|
||||||
|
// @Summary 完成通行密钥登录
|
||||||
|
// @Tags 认证
|
||||||
|
// @Param body body passkeyFinishRequest true "sessionId 与断言响应(name 忽略)"
|
||||||
|
// @Success 200 {object} tokenResponse "token 与 expiresAt"
|
||||||
|
// @Failure 400 {object} errorResponse "请求体非法"
|
||||||
|
// @Failure 401 {object} errorResponse "校验失败"
|
||||||
|
// @Failure 429 {object} errorResponse "连续失败已锁定"
|
||||||
|
// @Router /api/v1/auth/passkey/login/finish [post]
|
||||||
|
func (h *passkeyHandler) loginFinish(c *gin.Context) {
|
||||||
|
var req passkeyFinishRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
token, expires, username, err := h.svc.FinishLogin(
|
||||||
|
c.Request.Context(), req.SessionId, sessionMetaOf(c), bytes.NewReader(req.Credential))
|
||||||
|
if errors.Is(err, service.ErrLoginLocked) {
|
||||||
|
h.recordLogin(c, username, http.StatusTooManyRequests, "连续失败已锁定", start)
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// 统一 401 文案,不区分会话过期/校验失败,防探测
|
||||||
|
h.recordLogin(c, username, http.StatusUnauthorized, service.ErrPasskeyVerify.Error(), start)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": service.ErrPasskeyVerify.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.recordLogin(c, username, http.StatusOK, "", start)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordLogin 记录通行密钥登录成败——公开端点不经系统日志中间件,
|
||||||
|
// 登录失败是安全关键事件,必须留痕;失败时账号未知,username 为空。
|
||||||
|
func (h *passkeyHandler) recordLogin(c *gin.Context, username string, status int, errMsg string, start time.Time) {
|
||||||
|
if h.logs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logs.Record(model.SystemLog{
|
||||||
|
Username: username,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: requestPath(c),
|
||||||
|
Status: status,
|
||||||
|
DurationMs: time.Since(start).Milliseconds(),
|
||||||
|
ClientIP: requestIP(c),
|
||||||
|
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||||
|
ErrMsg: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -77,7 +77,9 @@ func IPRateMiddleware(settings *service.SettingService) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
sec := settings.SecurityCached()
|
sec := settings.SecurityCached()
|
||||||
if !lm.get(requestIP(c), sec.IPRateRPS, sec.IPRateBurst).Allow() {
|
if !lm.get(requestIP(c), sec.IPRateRPS, sec.IPRateBurst).Allow() {
|
||||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
|
// code 供前端区分全局 IP 限流与登录守卫锁定(后者不带 code,留在表单内提示)
|
||||||
|
c.AbortWithStatusJSON(http.StatusTooManyRequests,
|
||||||
|
gin.H{"error": "rate limit exceeded", "code": "RateLimited"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
+11
-4
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
// NewRouter 组装 HTTP 路由骨架:中间件链、公开/鉴权分组与各域注册;
|
// NewRouter 组装 HTTP 路由骨架:中间件链、公开/鉴权分组与各域注册;
|
||||||
// 具体路由按域拆在 routes_*.go。/auth/login 公开,业务路由要求 JWT。
|
// 具体路由按域拆在 routes_*.go。/auth/login 公开,业务路由要求 JWT。
|
||||||
func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfigs *service.OciConfigService, tasks *service.TaskService, console *service.ConsoleService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, logEvents *service.LogEventService, proxies *service.ProxyService, aiGateway *service.AiGatewayService) *gin.Engine {
|
func NewRouter(auth *service.AuthService, oauth *service.OAuthService, passkeys *service.PasskeyService, wallets *service.WalletService, ociConfigs *service.OciConfigService, tasks *service.TaskService, console *service.ConsoleService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, logEvents *service.LogEventService, proxies *service.ProxyService, aiGateway *service.AiGatewayService) *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
// 禁用 gin 内置代理信任:真实 IP 统一由 RealIPMiddleware 按安全设置解析
|
// 禁用 gin 内置代理信任:真实 IP 统一由 RealIPMiddleware 按安全设置解析
|
||||||
_ = r.SetTrustedProxies(nil)
|
_ = r.SetTrustedProxies(nil)
|
||||||
@@ -23,17 +23,24 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
|
|||||||
|
|
||||||
// 面板 API 请求体统一 1MB 上限(S-03);AI 网关按对话体量单独 10MB
|
// 面板 API 请求体统一 1MB 上限(S-03);AI 网关按对话体量单独 10MB
|
||||||
v1 := r.Group("/api/v1", bodyLimit(1<<20))
|
v1 := r.Group("/api/v1", bodyLimit(1<<20))
|
||||||
registerAuthPublic(v1, auth, oauth, systemLogs)
|
registerAuthPublic(v1, auth, oauth, passkeys, wallets, systemLogs)
|
||||||
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
|
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
|
||||||
registerAiGateway(r, aiGateway)
|
registerAiGateway(r, aiGateway)
|
||||||
|
|
||||||
// 系统日志中间件挂在鉴权之后,写请求自动留痕(webconsole ws 为 GET,天然跳过)
|
// 系统日志中间件挂在鉴权之后,写请求自动留痕(webconsole ws 为 GET,天然跳过)
|
||||||
secured := v1.Group("", RequireAuth(auth), systemLogMiddleware(systemLogs))
|
secured := v1.Group("", RequireAuth(auth), systemLogMiddleware(systemLogs))
|
||||||
registerAuthSecured(secured, auth, oauth, settings, systemLogs)
|
registerAuthSecured(secured, auth, oauth, passkeys, settings, systemLogs)
|
||||||
registerConsole(v1, secured, console, auth)
|
registerConsole(v1, secured, console, auth)
|
||||||
registerSettings(secured, settings, notifier, systemLogs, proxies)
|
registerSettings(secured, auth, settings, notifier, systemLogs, proxies)
|
||||||
registerTasksAndLogs(secured, tasks, logEvents)
|
registerTasksAndLogs(secured, tasks, logEvents)
|
||||||
registerOci(secured, ociConfigs)
|
registerOci(secured, ociConfigs)
|
||||||
|
// 上传类接口独立成组:文件本体上限 1MB,multipart 边界与字段头另占空间,
|
||||||
|
// 留在统一 1MB 的 v1 组里恰好 1MB 的文件会被截断;超限由 handler 映射 413
|
||||||
|
uploads := r.Group("/api/v1", bodyLimit(1<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs))
|
||||||
|
registerOciUploads(uploads, ociConfigs)
|
||||||
|
// 对象内容中转 PUT:5MB 文件上限 + 封装余量,精确上限由 handler/service 执行
|
||||||
|
contentUploads := r.Group("/api/v1", bodyLimit(5<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs))
|
||||||
|
registerOciContentUploads(contentUploads, ociConfigs)
|
||||||
registerAiAdmin(secured, aiGateway)
|
registerAiAdmin(secured, aiGateway)
|
||||||
registerSwagger(r)
|
registerSwagger(r)
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci
|
|||||||
return oci.SubscriptionDetail{}, nil
|
return oci.SubscriptionDetail{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (nullClient) UploadDomainImage(_ context.Context, _ oci.Credentials, _, _, fileName string, _ []byte) (string, string, error) {
|
||||||
|
return "https://images.example/" + fileName, "images/generated/" + fileName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nullClient) DeleteDomainImage(context.Context, oci.Credentials, string, string, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
|
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
r, auth, systemLogs, _ := newTestRouterDB(t)
|
r, auth, systemLogs, _ := newTestRouterDB(t)
|
||||||
@@ -78,7 +86,7 @@ func newTestRouterDB(t *testing.T) (*gin.Engine, *service.AuthService, *service.
|
|||||||
t.Fatalf("db handle: %v", err)
|
t.Fatalf("db handle: %v", err)
|
||||||
}
|
}
|
||||||
sqlDB.SetMaxOpenConns(1)
|
sqlDB.SetMaxOpenConns(1)
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{}, &model.SystemLog{}, &model.LogEvent{}, &model.Proxy{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.UserPasskey{}, &model.UserSession{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{}, &model.SystemLog{}, &model.LogEvent{}, &model.Proxy{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
|
||||||
t.Fatalf("auto migrate: %v", err)
|
t.Fatalf("auto migrate: %v", err)
|
||||||
}
|
}
|
||||||
cipher, err := crypto.NewCipher("test-key")
|
cipher, err := crypto.NewCipher("test-key")
|
||||||
@@ -96,7 +104,9 @@ func newTestRouterDB(t *testing.T) (*gin.Engine, *service.AuthService, *service.
|
|||||||
systemLogs := service.NewSystemLogService(db)
|
systemLogs := service.NewSystemLogService(db)
|
||||||
logEvents := service.NewLogEventService(db)
|
logEvents := service.NewLogEventService(db)
|
||||||
oauth := service.NewOAuthService(db, settings, auth)
|
oauth := service.NewOAuthService(db, settings, auth)
|
||||||
r := NewRouter(auth, oauth, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
|
passkeys := service.NewPasskeyService(db, settings, auth)
|
||||||
|
wallets := service.NewWalletService(db, settings, auth)
|
||||||
|
r := NewRouter(auth, oauth, passkeys, wallets, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
|
||||||
return r, auth, systemLogs, db
|
return r, auth, systemLogs, db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +148,7 @@ func TestLoginEndpoint(t *testing.T) {
|
|||||||
|
|
||||||
func TestSecuredRoutesRequireToken(t *testing.T) {
|
func TestSecuredRoutesRequireToken(t *testing.T) {
|
||||||
r, auth, _ := newTestRouter(t)
|
r, auth, _ := newTestRouter(t)
|
||||||
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -163,7 +173,7 @@ func TestSecuredRoutesRequireToken(t *testing.T) {
|
|||||||
|
|
||||||
func TestSystemLogsEndpoint(t *testing.T) {
|
func TestSystemLogsEndpoint(t *testing.T) {
|
||||||
r, auth, logs := newTestRouter(t)
|
r, auth, logs := newTestRouter(t)
|
||||||
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("login: %v", err)
|
t.Fatalf("login: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,44 @@ import (
|
|||||||
"oci-portal/internal/service"
|
"oci-portal/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registerAuthPublic 公开登录/OAuth 路由(不经 JWT)。
|
// registerAuthPublic 公开登录/OAuth/通行密钥/钱包路由(不经 JWT)。
|
||||||
func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, systemLogs *service.SystemLogService) {
|
func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, passkeys *service.PasskeyService, wallets *service.WalletService, systemLogs *service.SystemLogService) {
|
||||||
ah := &authHandler{svc: auth, logs: systemLogs}
|
ah := &authHandler{svc: auth, logs: systemLogs}
|
||||||
v1.POST("/auth/login", ah.login)
|
v1.POST("/auth/login", ah.login)
|
||||||
|
|
||||||
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
|
||||||
ax := &authxHandler{auth: auth, oauth: oauth, logs: systemLogs}
|
ax := &authxHandler{auth: auth, oauth: oauth, passkeys: passkeys, wallets: wallets, logs: systemLogs}
|
||||||
v1.GET("/auth/oauth/providers", ax.oauthProviders)
|
v1.GET("/auth/oauth/providers", ax.oauthProviders)
|
||||||
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
|
||||||
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
|
||||||
|
|
||||||
|
// 通行密钥登录:断言 options 公开下发,finish 内部接入登录守卫
|
||||||
|
pk := &passkeyHandler{svc: passkeys, auth: auth, logs: systemLogs}
|
||||||
|
v1.POST("/auth/passkey/login/begin", pk.loginBegin)
|
||||||
|
v1.POST("/auth/passkey/login/finish", pk.loginFinish)
|
||||||
|
|
||||||
|
// 钱包登录/绑定:挑战-签名两段式(bind 模式 handler 内校验 JWT)
|
||||||
|
wl := &walletHandler{svc: wallets, auth: auth, logs: systemLogs}
|
||||||
|
v1.POST("/auth/wallet/challenge", wl.challenge)
|
||||||
|
v1.POST("/auth/wallet/verify", wl.verify)
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerAuthSecured 登出、两步验证、外部身份管理(JWT 组内)。
|
// registerAuthSecured 登出、两步验证、外部身份与通行密钥管理(JWT 组内)。
|
||||||
func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, settings *service.SettingService, systemLogs *service.SystemLogService) {
|
func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, passkeys *service.PasskeyService, settings *service.SettingService, systemLogs *service.SystemLogService) {
|
||||||
ah := &authHandler{svc: auth, logs: systemLogs}
|
ah := &authHandler{svc: auth, logs: systemLogs}
|
||||||
secured.POST("/auth/logout", ah.logout)
|
secured.POST("/auth/logout", ah.logout)
|
||||||
|
|
||||||
|
pk := &passkeyHandler{svc: passkeys, auth: auth}
|
||||||
|
secured.POST("/auth/passkey/register/begin", pk.registerBegin)
|
||||||
|
secured.POST("/auth/passkey/register/finish", pk.registerFinish)
|
||||||
|
secured.GET("/auth/passkeys", pk.list)
|
||||||
|
secured.DELETE("/auth/passkeys/:id", pk.remove)
|
||||||
|
|
||||||
|
// 活跃会话:查看与定点撤销
|
||||||
|
sh := &sessionHandler{auth: auth}
|
||||||
|
secured.GET("/auth/sessions", sh.list)
|
||||||
|
secured.DELETE("/auth/sessions/:id", sh.revoke)
|
||||||
|
|
||||||
ax := &authxHandler{auth: auth, oauth: oauth}
|
ax := &authxHandler{auth: auth, oauth: oauth}
|
||||||
secured.GET("/auth/totp", ax.totpStatus)
|
secured.GET("/auth/totp", ax.totpStatus)
|
||||||
secured.POST("/auth/totp/setup", ax.totpSetup)
|
secured.POST("/auth/totp/setup", ax.totpSetup)
|
||||||
@@ -35,5 +56,5 @@ func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oa
|
|||||||
secured.DELETE("/auth/identities/:id", ax.unbindIdentity)
|
secured.DELETE("/auth/identities/:id", ax.unbindIdentity)
|
||||||
secured.POST("/auth/revoke-sessions", ax.revokeSessions)
|
secured.POST("/auth/revoke-sessions", ax.revokeSessions)
|
||||||
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
|
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
|
||||||
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
secured.PATCH("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,13 +127,21 @@ func registerOciObjectStorage(g *gin.RouterGroup, h *ociConfigHandler) {
|
|||||||
g.GET("/oci-configs/:id/buckets/:bucket/objects/detail", h.objectDetail)
|
g.GET("/oci-configs/:id/buckets/:bucket/objects/detail", h.objectDetail)
|
||||||
// 小文件中转:预览/编辑直读直写,不签发 PAR
|
// 小文件中转:预览/编辑直读直写,不签发 PAR
|
||||||
g.GET("/oci-configs/:id/buckets/:bucket/objects/content", h.getObjectContent)
|
g.GET("/oci-configs/:id/buckets/:bucket/objects/content", h.getObjectContent)
|
||||||
g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent)
|
|
||||||
g.GET("/oci-configs/:id/buckets/:bucket/pars", h.listPARs)
|
g.GET("/oci-configs/:id/buckets/:bucket/pars", h.listPARs)
|
||||||
g.POST("/oci-configs/:id/buckets/:bucket/pars", h.createPAR)
|
g.POST("/oci-configs/:id/buckets/:bucket/pars", h.createPAR)
|
||||||
// parId 经 query 传递:OCI PAR id 含 "/" 等字符,路径参数无法匹配
|
// parId 经 query 传递:OCI PAR id 含 "/" 等字符,路径参数无法匹配
|
||||||
g.DELETE("/oci-configs/:id/buckets/:bucket/pars", h.deletePAR)
|
g.DELETE("/oci-configs/:id/buckets/:bucket/pars", h.deletePAR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registerOciUploads 挂需放宽请求体上限的上传接口;router 侧以独立组绕开
|
||||||
|
// v1 统一 1MB bodyLimit(multipart 除文件本体外还有边界与字段头开销)。
|
||||||
|
// idp-icons 用独立路径,避免与 /identity-providers/:idpId 同段静态/参数混排。
|
||||||
|
func registerOciUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) {
|
||||||
|
h := &ociConfigHandler{svc: ociConfigs}
|
||||||
|
g.POST("/oci-configs/:id/idp-icons", h.uploadIdpIcon)
|
||||||
|
g.DELETE("/oci-configs/:id/idp-icons", h.deleteIdpIcon)
|
||||||
|
}
|
||||||
|
|
||||||
// registerOciCost 成本快照。
|
// registerOciCost 成本快照。
|
||||||
func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
|
func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
|
||||||
g.GET("/oci-configs/:id/costs", h.costs)
|
g.GET("/oci-configs/:id/costs", h.costs)
|
||||||
@@ -152,6 +160,10 @@ func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
|
|||||||
g.POST("/oci-configs/:id/users/:userId/reset-password", h.resetTenantUserPassword)
|
g.POST("/oci-configs/:id/users/:userId/reset-password", h.resetTenantUserPassword)
|
||||||
g.DELETE("/oci-configs/:id/users/:userId/mfa-devices", h.deleteTenantUserMfa)
|
g.DELETE("/oci-configs/:id/users/:userId/mfa-devices", h.deleteTenantUserMfa)
|
||||||
g.DELETE("/oci-configs/:id/users/:userId/api-keys", h.deleteTenantUserApiKeys)
|
g.DELETE("/oci-configs/:id/users/:userId/api-keys", h.deleteTenantUserApiKeys)
|
||||||
|
g.GET("/oci-configs/:id/users/:userId/api-keys", h.listTenantUserApiKeys)
|
||||||
|
g.POST("/oci-configs/:id/users/:userId/api-keys", h.addTenantUserApiKey)
|
||||||
|
g.DELETE("/oci-configs/:id/users/:userId/api-keys/:fingerprint", h.deleteTenantUserApiKey)
|
||||||
|
g.POST("/oci-configs/:id/activate-api-key", h.activateApiKey)
|
||||||
g.GET("/oci-configs/:id/notification-recipients", h.getNotificationRecipients)
|
g.GET("/oci-configs/:id/notification-recipients", h.getNotificationRecipients)
|
||||||
g.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients)
|
g.PUT("/oci-configs/:id/notification-recipients", h.updateNotificationRecipients)
|
||||||
g.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies)
|
g.GET("/oci-configs/:id/password-policies", h.listPasswordPolicies)
|
||||||
@@ -167,3 +179,11 @@ func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
|
|||||||
g.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption)
|
g.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption)
|
||||||
g.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption)
|
g.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registerOciContentUploads 挂对象内容保存接口(编辑器直写,5MB 文件上限);
|
||||||
|
// 必须留在独立的大 body limit 组:统一 1MB 组会先截断请求,handler 的 5MB
|
||||||
|
// 上限与 swagger 声明就成了空话(2026-07 全量审查 #11)。
|
||||||
|
func registerOciContentUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) {
|
||||||
|
h := &ociConfigHandler{svc: ociConfigs}
|
||||||
|
g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// registerSettings 挂载 /settings/* 与 /about,以及系统日志与代理管理。
|
// registerSettings 挂载 /settings/* 与 /about,以及系统日志与代理管理。
|
||||||
func registerSettings(secured *gin.RouterGroup, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, proxies *service.ProxyService) {
|
func registerSettings(secured *gin.RouterGroup, auth *service.AuthService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, proxies *service.ProxyService) {
|
||||||
secured.GET("/about", about)
|
secured.GET("/about", about)
|
||||||
secured.GET("/system-logs", (&systemLogHandler{svc: systemLogs}).list)
|
secured.GET("/system-logs", (&systemLogHandler{svc: systemLogs}).list)
|
||||||
|
|
||||||
st := &settingsHandler{svc: settings, notifier: notifier}
|
st := &settingsHandler{svc: settings, auth: auth, notifier: notifier}
|
||||||
secured.GET("/settings/telegram", st.getTelegram)
|
secured.GET("/settings/telegram", st.getTelegram)
|
||||||
secured.PUT("/settings/telegram", st.updateTelegram)
|
secured.PUT("/settings/telegram", st.updateTelegram)
|
||||||
secured.POST("/settings/telegram/test", st.testTelegram)
|
secured.POST("/settings/telegram/test", st.testTelegram)
|
||||||
@@ -26,7 +26,7 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
|
|||||||
secured.GET("/settings/task", st.getTaskSettings)
|
secured.GET("/settings/task", st.getTaskSettings)
|
||||||
secured.PUT("/settings/task", st.updateTaskSettings)
|
secured.PUT("/settings/task", st.updateTaskSettings)
|
||||||
secured.GET("/settings/security", st.getSecurity)
|
secured.GET("/settings/security", st.getSecurity)
|
||||||
secured.PUT("/settings/security", st.updateSecurity)
|
secured.PATCH("/settings/security", st.updateSecurity)
|
||||||
|
|
||||||
px := &proxyHandler{svc: proxies}
|
px := &proxyHandler{svc: proxies}
|
||||||
secured.GET("/proxies", px.list)
|
secured.GET("/proxies", px.list)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionHandler 处理活跃会话查看与定点撤销接口。
|
||||||
|
type sessionHandler struct {
|
||||||
|
auth *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearerToken 取 Authorization 头中的原始令牌串;不存在返回空串。
|
||||||
|
func bearerToken(c *gin.Context) string {
|
||||||
|
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionMetaOf 采集会话登记所需的客户端上下文;登录方式由 service 层填写。
|
||||||
|
func sessionMetaOf(c *gin.Context) service.SessionMeta {
|
||||||
|
return service.SessionMeta{
|
||||||
|
ClientIP: requestIP(c),
|
||||||
|
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// list 列出当前账号的活跃会话,最近活跃在前,当前会话带 current 标记。
|
||||||
|
//
|
||||||
|
// @Summary 活跃会话列表
|
||||||
|
// @Tags 认证
|
||||||
|
// @Success 200 {object} itemsResponse[service.SessionInfo] "items"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/auth/sessions [get]
|
||||||
|
func (h *sessionHandler) list(c *gin.Context) {
|
||||||
|
items, err := h.auth.ListSessions(c.Request.Context(), c.GetString(usernameKey), bearerToken(c))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// revoke 定点撤销一个其他会话;该会话令牌随即失效,不影响其余会话。
|
||||||
|
//
|
||||||
|
// @Summary 撤销单个会话
|
||||||
|
// @Tags 认证
|
||||||
|
// @Param id path int true "会话 ID"
|
||||||
|
// @Success 204 "已撤销"
|
||||||
|
// @Failure 400 {object} errorResponse "id 非法"
|
||||||
|
// @Failure 404 {object} errorResponse "会话不存在"
|
||||||
|
// @Failure 409 {object} errorResponse "不能撤销当前会话"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/auth/sessions/{id} [delete]
|
||||||
|
func (h *sessionHandler) revoke(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.auth.RevokeSession(c.Request.Context(), c.GetString(usernameKey), bearerToken(c), uint(id))
|
||||||
|
if errors.Is(err, service.ErrSessionCurrent) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在或已失效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
// settingsHandler 处理系统设置(Telegram 通知)相关请求。
|
// settingsHandler 处理系统设置(Telegram 通知)相关请求。
|
||||||
type settingsHandler struct {
|
type settingsHandler struct {
|
||||||
svc *service.SettingService
|
svc *service.SettingService
|
||||||
|
auth *service.AuthService
|
||||||
notifier *service.Notifier
|
notifier *service.Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,25 +317,35 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, view)
|
c.JSON(http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSecurity 保存安全设置并返回最新值;越界或非法返回 400,保存后立即生效。
|
// updateSecurity 部分更新安全设置并返回最新值;只落库请求中出现的字段,
|
||||||
|
// 越界或非法返回 400,保存后立即生效。
|
||||||
//
|
//
|
||||||
// @Summary 保存安全设置并返回最新值
|
// @Summary 部分更新安全设置并返回最新值
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Param body body service.SecuritySettings true "请求体"
|
// @Param body body service.SecurityPatch true "出现的字段才会被更新"
|
||||||
// @Success 200 {object} service.SecuritySettings
|
// @Success 200 {object} service.SecuritySettings
|
||||||
|
// @Failure 400 {object} errorResponse "字段越界或非法"
|
||||||
|
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
|
||||||
|
// @Failure 409 {object} errorResponse "密码登录禁用期间,面板地址变更将移除最后可用登录方式"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/settings/security [put]
|
// @Router /api/v1/settings/security [patch]
|
||||||
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
||||||
var req service.SecuritySettings
|
var req service.SecurityPatch
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.svc.UpdateSecurity(c.Request.Context(), req); err != nil {
|
err := h.svc.UpdateSecurityAuthenticated(
|
||||||
|
c.Request.Context(), req, h.auth, c.GetString(usernameKey), tokenProofOf(c))
|
||||||
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrInvalidSecurity) {
|
if errors.Is(err, service.ErrInvalidSecurity) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, service.ErrProviderLastLogin) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
// errorResponse 是统一错误响应外壳。
|
// errorResponse 是统一错误响应外壳。
|
||||||
type errorResponse struct {
|
type errorResponse struct {
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
|
// Code 是机器可读错误码,多数错误不携带;当前仅全局 IP 限流的 429
|
||||||
|
// 返回 "RateLimited",前端据此与登录守卫的 429(无 code)区分
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// itemsResponse 是 {"items": [...]} 列表外壳。
|
// itemsResponse 是 {"items": [...]} 列表外壳。
|
||||||
@@ -71,6 +74,20 @@ type credentialsResponse struct {
|
|||||||
type oauthProvidersResponse struct {
|
type oauthProvidersResponse struct {
|
||||||
Providers []service.ProviderInfo `json:"providers"`
|
Providers []service.ProviderInfo `json:"providers"`
|
||||||
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
|
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
|
||||||
|
PasskeyLogin bool `json:"passkeyLogin"`
|
||||||
|
WalletLogin bool `json:"walletLogin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// walletChallengeResponse 是钱包签名挑战响应;message 需原样 personal_sign。
|
||||||
|
type walletChallengeResponse struct {
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyOptionsResponse 是 WebAuthn 仪式 options 响应;sessionId 需原样带回 finish。
|
||||||
|
type passkeyOptionsResponse struct {
|
||||||
|
SessionId string `json:"sessionId"`
|
||||||
|
Options json.RawMessage `json:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// urlResponse 是跳转地址响应。
|
// urlResponse 是跳转地址响应。
|
||||||
@@ -157,6 +174,11 @@ type deletedApiKeysResponse struct {
|
|||||||
DeletedApiKeys int `json:"deletedApiKeys"`
|
DeletedApiKeys int `json:"deletedApiKeys"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userApiKeysResponse 是用户 API 签名 key 列表。
|
||||||
|
type userApiKeysResponse struct {
|
||||||
|
Items []service.UserApiKeyInfo `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
// createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。
|
// createInstancesResponse 是批量创建实例结果;部分成功仍 201,errors 为逐台失败信息。
|
||||||
type createInstancesResponse struct {
|
type createInstancesResponse struct {
|
||||||
Instances []oci.Instance `json:"instances"`
|
Instances []oci.Instance `json:"instances"`
|
||||||
|
|||||||
+16
-2
@@ -92,8 +92,10 @@ func (h *taskHandler) get(c *gin.Context) {
|
|||||||
// @Summary 更新任务
|
// @Summary 更新任务
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "任务 ID"
|
// @Param id path int true "任务 ID"
|
||||||
// @Param body body updateTaskRequest true "可更新字段"
|
// @Param body body updateTaskRequest true "可更新字段;抢机 payload 的 count 语义为目标台数"
|
||||||
// @Success 200 {object} model.Task
|
// @Success 200 {object} model.Task
|
||||||
|
// @Failure 400 {object} errorResponse "参数非法或抢机目标不大于已完成数量"
|
||||||
|
// @Failure 409 {object} errorResponse "任务被并发修改,须刷新重试"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /api/v1/tasks/{id} [put]
|
// @Router /api/v1/tasks/{id} [put]
|
||||||
func (h *taskHandler) update(c *gin.Context) {
|
func (h *taskHandler) update(c *gin.Context) {
|
||||||
@@ -113,12 +115,24 @@ func (h *taskHandler) update(c *gin.Context) {
|
|||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondUpdateTaskErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, task)
|
c.JSON(http.StatusOK, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// respondUpdateTaskErr 把任务编辑的哨兵错误映射为语义状态码,其余走统一错误边界。
|
||||||
|
func respondUpdateTaskErr(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrTaskConflict):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrSnatchTargetTooLow):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
respondError(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// @Summary 删除任务
|
// @Summary 删除任务
|
||||||
// @Tags 任务与日志回传
|
// @Tags 任务与日志回传
|
||||||
// @Param id path int true "任务 ID"
|
// @Param id path int true "任务 ID"
|
||||||
|
|||||||
@@ -371,6 +371,102 @@ func (h *ociConfigHandler) deleteTenantUserApiKeys(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"deletedApiKeys": deleted})
|
c.JSON(http.StatusOK, gin.H{"deletedApiKeys": deleted})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Summary 列出用户 API Key
|
||||||
|
// @Tags 租户 IAM
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param userId path string true "userId"
|
||||||
|
// @Success 200 {object} userApiKeysResponse
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [get]
|
||||||
|
func (h *ociConfigHandler) listTenantUserApiKeys(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keys, err := h.svc.TenantUserApiKeys(c.Request.Context(), id, c.Param("userId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": keys})
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 为用户新增 API Key
|
||||||
|
// @Description 后端生成 RSA-2048 密钥对并上传公钥;私钥仅本次响应返回,不落库。
|
||||||
|
// @Tags 租户 IAM
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param userId path string true "userId"
|
||||||
|
// @Success 200 {object} service.CreatedApiKey
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys [post]
|
||||||
|
func (h *ociConfigHandler) addTenantUserApiKey(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := h.svc.AddTenantUserApiKey(c.Request.Context(), id, c.Param("userId"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 删除用户单把 API Key
|
||||||
|
// @Description 当前配置正在使用的 key 拒删(409),避免面板失联。
|
||||||
|
// @Tags 租户 IAM
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param userId path string true "userId"
|
||||||
|
// @Param fingerprint path string true "key 指纹"
|
||||||
|
// @Success 204 "无内容"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/users/{userId}/api-keys/{fingerprint} [delete]
|
||||||
|
func (h *ociConfigHandler) deleteTenantUserApiKey(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.svc.DeleteTenantUserApiKey(c.Request.Context(), id, c.Param("userId"), c.Param("fingerprint"))
|
||||||
|
if errors.Is(err, service.ErrCurrentApiKey) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary 启用 API Key 为面板签名凭据
|
||||||
|
// @Description 将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。userId 给定且异于当前签名用户时,一并把签名用户切换为该用户。
|
||||||
|
// @Tags 租户 IAM
|
||||||
|
// @Param id path int true "配置 ID"
|
||||||
|
// @Param body body object true "请求体:fingerprint 与 privateKey,可选 userId(切换签名用户)"
|
||||||
|
// @Success 204 "无内容"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/v1/oci-configs/{id}/activate-api-key [post]
|
||||||
|
func (h *ociConfigHandler) activateApiKey(c *gin.Context) {
|
||||||
|
id, ok := pathID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
PrivateKey string `json:"privateKey"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.Fingerprint == "" || req.PrivateKey == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "fingerprint 与 privateKey 必填"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.ActivateApiKey(c.Request.Context(), id, req.UserID, req.Fingerprint, req.PrivateKey); err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 域通知收件人与密码策略 ----
|
// ---- 域通知收件人与密码策略 ----
|
||||||
|
|
||||||
// @Summary ---- 域通知收件人与密码策略 ----
|
// @Summary ---- 域通知收件人与密码策略 ----
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// walletHandler 处理钱包(SIWE)签名挑战与校验接口。
|
||||||
|
type walletHandler struct {
|
||||||
|
svc *service.WalletService
|
||||||
|
auth *service.AuthService
|
||||||
|
logs *service.SystemLogService
|
||||||
|
}
|
||||||
|
|
||||||
|
// walletChallengeRequest 是签名挑战请求;mode=bind 需有效 Bearer(绑定到当前账号)。
|
||||||
|
type walletChallengeRequest struct {
|
||||||
|
Address string `json:"address" binding:"required,max=64"`
|
||||||
|
Mode string `json:"mode" binding:"omitempty,oneof=login bind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// challenge 下发 EIP-4361 消息与一次性 nonce;前端对消息原样 personal_sign。
|
||||||
|
//
|
||||||
|
// @Summary 发起钱包签名挑战
|
||||||
|
// @Tags 认证
|
||||||
|
// @Param body body walletChallengeRequest true "钱包地址与模式(bind 需 Bearer)"
|
||||||
|
// @Success 200 {object} walletChallengeResponse "nonce 与待签名消息"
|
||||||
|
// @Failure 400 {object} errorResponse "地址格式不正确"
|
||||||
|
// @Failure 401 {object} errorResponse "bind 模式未登录"
|
||||||
|
// @Failure 409 {object} errorResponse "面板地址未设置"
|
||||||
|
// @Router /api/v1/auth/wallet/challenge [post]
|
||||||
|
func (h *walletHandler) challenge(c *gin.Context) {
|
||||||
|
var req walletChallengeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode, username := "login", ""
|
||||||
|
if req.Mode == "bind" {
|
||||||
|
name, ok := (&authxHandler{auth: h.auth}).bearerUser(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "绑定需要先登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode, username = "bind", name
|
||||||
|
}
|
||||||
|
nonce, message, err := h.svc.Challenge(c.Request.Context(), req.Address, mode, username, bearerToken(c))
|
||||||
|
if err != nil {
|
||||||
|
h.respondChallengeErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"nonce": nonce, "message": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondChallengeErr 映射挑战阶段错误。
|
||||||
|
func (h *walletHandler) respondChallengeErr(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, service.ErrWalletAddress) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrWalletNoAppURL) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// walletVerifyRequest 是签名校验请求;signature 为 65 字节 r||s||v 的 hex。
|
||||||
|
type walletVerifyRequest struct {
|
||||||
|
Nonce string `json:"nonce" binding:"required,max=64"`
|
||||||
|
Signature string `json:"signature" binding:"required,max=200"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify 校验签名:login 模式签发 JWT,bind 模式绑定后换发新令牌。
|
||||||
|
//
|
||||||
|
// @Summary 校验钱包签名
|
||||||
|
// @Tags 认证
|
||||||
|
// @Param body body walletVerifyRequest true "nonce 与签名"
|
||||||
|
// @Success 200 {object} tokenResponse "token 与 expiresAt(bind 模式为换发的新 token)"
|
||||||
|
// @Failure 400 {object} errorResponse "挑战无效已过期,或 bind 模式签名校验失败"
|
||||||
|
// @Failure 401 {object} errorResponse "签名校验失败(login 模式)"
|
||||||
|
// @Failure 403 {object} errorResponse "地址未绑定账号"
|
||||||
|
// @Failure 409 {object} errorResponse "地址已绑定过"
|
||||||
|
// @Failure 429 {object} errorResponse "连续失败已锁定"
|
||||||
|
// @Router /api/v1/auth/wallet/verify [post]
|
||||||
|
func (h *walletHandler) verify(c *gin.Context) {
|
||||||
|
var req walletVerifyRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
res, err := h.svc.Verify(c.Request.Context(), req.Nonce, req.Signature, sessionMetaOf(c))
|
||||||
|
if err != nil {
|
||||||
|
status := walletErrStatus(err)
|
||||||
|
// bind 是已登录流程:验签失败不可回 401,否则前端全局拦截会误登出有效会话
|
||||||
|
if res.Mode == "bind" && status == http.StatusUnauthorized {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
h.recordWallet(c, res.Username, status, err, start)
|
||||||
|
c.JSON(status, gin.H{"error": walletErrText(err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.recordWallet(c, res.Username, http.StatusOK, nil, start)
|
||||||
|
// login 与 bind 同构响应;bind 的 token 为版本递增后换发的新会话
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": res.Token, "expiresAt": res.ExpiresAt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// walletErrStatus 映射校验阶段错误码。
|
||||||
|
func walletErrStatus(err error) int {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrLoginLocked):
|
||||||
|
return http.StatusTooManyRequests
|
||||||
|
case errors.Is(err, service.ErrWalletChallenge):
|
||||||
|
return http.StatusBadRequest
|
||||||
|
case errors.Is(err, service.ErrWalletNotBound):
|
||||||
|
return http.StatusForbidden
|
||||||
|
case errors.Is(err, service.ErrWalletBound):
|
||||||
|
return http.StatusConflict
|
||||||
|
case errors.Is(err, service.ErrWalletSig):
|
||||||
|
return http.StatusUnauthorized
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// walletErrText 把流程错误转为用户可读文案;内部错误不透出细节。
|
||||||
|
func walletErrText(err error) string {
|
||||||
|
for _, known := range []error{service.ErrLoginLocked, service.ErrWalletChallenge, service.ErrWalletNotBound, service.ErrWalletBound, service.ErrWalletSig} {
|
||||||
|
if errors.Is(err, known) {
|
||||||
|
return known.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "校验失败,请重试"
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordWallet 记录钱包登录/绑定结果——公开端点不经系统日志中间件,须显式留痕。
|
||||||
|
func (h *walletHandler) recordWallet(c *gin.Context, username string, status int, err error, start time.Time) {
|
||||||
|
if h.logs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errMsg := ""
|
||||||
|
if err != nil {
|
||||||
|
errMsg = walletErrText(err)
|
||||||
|
}
|
||||||
|
h.logs.Record(model.SystemLog{
|
||||||
|
Username: username,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: requestPath(c),
|
||||||
|
Status: status,
|
||||||
|
DurationMs: time.Since(start).Milliseconds(),
|
||||||
|
ClientIP: requestIP(c),
|
||||||
|
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||||
|
ErrMsg: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ func buildDialector(driver, dsn, path string) (gorm.Dialector, error) {
|
|||||||
|
|
||||||
func autoMigrate(db *gorm.DB) error {
|
func autoMigrate(db *gorm.DB) error {
|
||||||
return db.AutoMigrate(
|
return db.AutoMigrate(
|
||||||
&model.User{}, &model.UserIdentity{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{},
|
&model.User{}, &model.UserIdentity{}, &model.UserPasskey{}, &model.UserSession{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{},
|
||||||
&model.CheckSnapshot{}, &model.CostSnapshot{},
|
&model.CheckSnapshot{}, &model.CostSnapshot{},
|
||||||
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
|
&model.RegionCache{}, &model.CompartmentCache{}, &model.Setting{},
|
||||||
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
|
&model.SystemLog{}, &model.LogEvent{}, &model.Proxy{},
|
||||||
|
|||||||
@@ -86,6 +86,45 @@ type UserIdentity struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserPasskey 是账号绑定的 WebAuthn 通行密钥;公钥非敏感,凭据整段 JSON 明文落库。
|
||||||
|
type UserPasskey struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"index" json:"-"`
|
||||||
|
Name string `gorm:"size:64" json:"name"`
|
||||||
|
// 凭据 ID(base64url)原文:规范允许原始 ID 达 1023 字节、编码后逾千字符,
|
||||||
|
// 存 text 不设索引;唯一约束与登录反查走下方定长哈希列
|
||||||
|
CredentialID string `gorm:"type:text" json:"-"`
|
||||||
|
// 凭据 ID 的 SHA-256 hex(定长 64):唯一索引防同一验证器重复注册
|
||||||
|
CredentialIDHash string `gorm:"size:64;uniqueIndex" json:"-"`
|
||||||
|
// 注册时的 WebAuthn origin(scheme://host):面板地址变更后按它判定
|
||||||
|
// 该凭据是否仍可登录(RP ID / origin 绑定注册时域名)
|
||||||
|
Origin string `gorm:"size:255" json:"-"`
|
||||||
|
// webauthn.Credential 完整 JSON(公钥/signCount/flags);attestation 可上 KB,
|
||||||
|
// 显式 text 防 MySQL DefaultStringSize 截断;校验后 signCount 有更新,整段重写
|
||||||
|
Credential string `gorm:"type:text" json:"-"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSession 是一次已签发登录会话(JWT)的落地记录;
|
||||||
|
// 「活跃」判定 = 未撤销 && 未过期 && token_ver == 账号当前版本。
|
||||||
|
// 升级前签发的存量令牌无对应行,校验时放行(兼容),仅不可见/不可定点撤销。
|
||||||
|
type UserSession struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"index" json:"-"`
|
||||||
|
// JWT jti;敏感变更换发时同行更新(会话接续),不产生新条目
|
||||||
|
TokenID string `gorm:"size:64;uniqueIndex" json:"-"`
|
||||||
|
TokenVer uint `json:"-"`
|
||||||
|
// 登录方式:password / oidc / github / passkey / wallet;接续换发保留原值
|
||||||
|
Method string `gorm:"size:16" json:"method"`
|
||||||
|
ClientIP string `gorm:"size:64" json:"clientIp"`
|
||||||
|
UserAgent string `gorm:"size:256" json:"userAgent"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
RevokedAt *time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
// 任务类型取值。
|
// 任务类型取值。
|
||||||
const (
|
const (
|
||||||
TaskTypeHealthCheck = "health_check" // 定时测活
|
TaskTypeHealthCheck = "health_check" // 定时测活
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ type Client interface {
|
|||||||
CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error)
|
CreateBucket(ctx context.Context, cred Credentials, region string, in CreateBucketInput) (Bucket, error)
|
||||||
UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error)
|
UpdateBucket(ctx context.Context, cred Credentials, region, name string, in UpdateBucketInput) (Bucket, error)
|
||||||
DeleteBucket(ctx context.Context, cred Credentials, region, name string) error
|
DeleteBucket(ctx context.Context, cred Credentials, region, name string) error
|
||||||
|
// AbortAllMultipartUploads 中止桶内全部未完成分片上传(清空删桶前置步骤,404 幂等)
|
||||||
|
AbortAllMultipartUploads(ctx context.Context, cred Credentials, region, bucket string) error
|
||||||
ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error)
|
ListObjects(ctx context.Context, cred Credentials, region, bucket, prefix, startWith string, limit int) (ListObjectsResult, error)
|
||||||
ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error)
|
ListObjectVersions(ctx context.Context, cred Credentials, region, bucket, page string) ([]ObjectVersion, string, error)
|
||||||
DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error
|
DeleteObjectVersion(ctx context.Context, cred Credentials, region, bucket, object, versionID string) error
|
||||||
@@ -187,6 +189,10 @@ type Client interface {
|
|||||||
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error)
|
ResetTenantUserPassword(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (string, error)
|
||||||
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error)
|
DeleteTenantUserMfaDevices(ctx context.Context, cred Credentials, homeRegion, domainID, userID string) (int, error)
|
||||||
DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error)
|
DeleteTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string, includeCurrent bool) (int, error)
|
||||||
|
// 用户 API 签名 key 管理:列出(标注当前使用)、上传公钥(返回指纹)、按指纹删单把。
|
||||||
|
ListTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string) ([]TenantUserApiKey, error)
|
||||||
|
UploadTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, publicKeyPEM string) (string, error)
|
||||||
|
DeleteTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, fingerprint string) error
|
||||||
// 域设置:通知收件人、密码策略、身份设置。
|
// 域设置:通知收件人、密码策略、身份设置。
|
||||||
GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error)
|
GetNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string) (NotificationRecipients, error)
|
||||||
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error)
|
UpdateNotificationRecipients(ctx context.Context, cred Credentials, region, domainID string, emails []string) (NotificationRecipients, error)
|
||||||
@@ -200,6 +206,10 @@ type Client interface {
|
|||||||
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error)
|
SetIdentityProviderEnabled(ctx context.Context, cred Credentials, region, domainID, idpID string, enabled bool) (IdentityProviderInfo, error)
|
||||||
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error
|
DeleteIdentityProvider(ctx context.Context, cred Credentials, region, domainID, idpID string) error
|
||||||
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error)
|
DownloadDomainSamlMetadata(ctx context.Context, cred Credentials, region, domainID string) ([]byte, error)
|
||||||
|
// UploadDomainImage 上传公开图片到身份域存储(IdP 图标用),返回公网 URL 与存储内文件名。
|
||||||
|
UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error)
|
||||||
|
// DeleteDomainImage 按存储内文件名删除身份域公开图片。
|
||||||
|
DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) error
|
||||||
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error)
|
ListConsoleSignOnRules(ctx context.Context, cred Credentials, region, domainID string) ([]SignOnRuleInfo, error)
|
||||||
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error)
|
CreateMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, idpID, ruleName string) (SignOnRuleInfo, error)
|
||||||
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error
|
DeleteMfaExemptionRule(ctx context.Context, cred Credentials, region, domainID, ruleID string) error
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// domainImagesPath 是身份域公开图片上传端点(品牌 / IdP 图标),SDK 未覆盖该操作。
|
||||||
|
const domainImagesPath = "/storage/v1/Images"
|
||||||
|
|
||||||
|
// UploadDomainImage 实现 Client:上传公开图片到身份域存储,返回公网 fileUrl
|
||||||
|
// 与域存储内文件名(后者留作后续精确清理)。借 domainsClient 的 BaseClient
|
||||||
|
// 发裸 multipart 请求,OCI 签名与代理配置直接复用。
|
||||||
|
func (c *RealClient) UploadDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string, data []byte) (string, string, error) {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
req, err := newDomainImageUploadRequest(ctx, dc.Endpoint(), fileName, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
resp, callErr := dc.BaseClient.Call(ctx, req)
|
||||||
|
return parseImageUploadResponse(resp, callErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDomainImage 实现 Client:按响应 fileName 精确删除公开图片,404 视为幂等成功。
|
||||||
|
func (c *RealClient) DeleteDomainImage(ctx context.Context, cred Credentials, region, domainID, fileName string) error {
|
||||||
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := newDomainImageDeleteRequest(ctx, dc.Endpoint(), fileName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, callErr := dc.BaseClient.Call(ctx, req)
|
||||||
|
return finishDomainImageDelete(resp, callErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDomainImageUploadRequest(ctx context.Context, endpoint, fileName string, data []byte) (*http.Request, error) {
|
||||||
|
body, contentType, err := imageMultipart(fileName, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(endpoint, "/")+domainImagesPath, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build image upload request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDomainImageDeleteRequest(ctx context.Context, endpoint, fileName string) (*http.Request, error) {
|
||||||
|
u, err := url.Parse(strings.TrimRight(endpoint, "/") + domainImagesPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build image delete URL: %w", err)
|
||||||
|
}
|
||||||
|
query := u.Query()
|
||||||
|
query.Set("fileName", fileName)
|
||||||
|
u.RawQuery = query.Encode()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build image delete request: %w", err)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageMultipart 组装上传请求体:file(二进制)与 fileName 两个 part(官方文档要求)。
|
||||||
|
func imageMultipart(fileName string, data []byte) ([]byte, string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&buf)
|
||||||
|
fw, err := w.CreateFormFile("file", fileName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(data); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.WriteField("fileName", fileName); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build image multipart: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), w.FormDataContentType(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageUploadResponse 解析上传响应,并确保任何返回路径都关闭响应体。
|
||||||
|
func parseImageUploadResponse(resp *http.Response, callErr error) (string, string, error) {
|
||||||
|
if resp != nil && resp.Body != nil {
|
||||||
|
defer drainAndClose(resp.Body)
|
||||||
|
}
|
||||||
|
if callErr != nil {
|
||||||
|
return "", "", fmt.Errorf("upload domain image: %w", callErr)
|
||||||
|
}
|
||||||
|
if resp == nil || resp.Body == nil {
|
||||||
|
return "", "", fmt.Errorf("upload domain image: empty response")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("upload domain image: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("read image upload response: %w", err)
|
||||||
|
}
|
||||||
|
if len(b) > 1<<20 {
|
||||||
|
return "", "", fmt.Errorf("upload domain image: response exceeds 1MB")
|
||||||
|
}
|
||||||
|
return decodeImageUploadResponse(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeImageUploadResponse(data []byte) (string, string, error) {
|
||||||
|
var out struct {
|
||||||
|
FileURL string `json:"fileUrl"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
return "", "", fmt.Errorf("parse image upload response: %w", err)
|
||||||
|
}
|
||||||
|
if out.FileURL == "" || out.FileName == "" {
|
||||||
|
return "", "", fmt.Errorf("upload domain image: response missing fileUrl or fileName")
|
||||||
|
}
|
||||||
|
return out.FileURL, out.FileName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishDomainImageDelete(resp *http.Response, callErr error) error {
|
||||||
|
if resp != nil && resp.Body != nil {
|
||||||
|
defer drainAndClose(resp.Body)
|
||||||
|
}
|
||||||
|
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if callErr != nil {
|
||||||
|
return fmt.Errorf("delete domain image: %w", callErr)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return fmt.Errorf("delete domain image: empty response")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
return fmt.Errorf("delete domain image: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainAndClose 读完小响应以便 HTTP 连接复用;超大异常响应限制为 64KiB。
|
||||||
|
func drainAndClose(body io.ReadCloser) {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(body, 64<<10))
|
||||||
|
_ = body.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type trackingReadCloser struct {
|
||||||
|
io.Reader
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type testMultipartFile struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *trackingReadCloser) Close() error {
|
||||||
|
r.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testImageResponse(status int, body string) (*http.Response, *trackingReadCloser) {
|
||||||
|
reader := &trackingReadCloser{Reader: strings.NewReader(body)}
|
||||||
|
return &http.Response{StatusCode: status, Body: reader}, reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMultipartFields(t *testing.T, body []byte, contentType string) (map[string]string, map[string]testMultipartFile) {
|
||||||
|
t.Helper()
|
||||||
|
_, params, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse content type: %v", err)
|
||||||
|
}
|
||||||
|
reader := multipart.NewReader(strings.NewReader(string(body)), params["boundary"])
|
||||||
|
fields, files := map[string]string{}, map[string]testMultipartFile{}
|
||||||
|
for {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("next part: %v", err)
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(part)
|
||||||
|
if part.FileName() == "" {
|
||||||
|
fields[part.FormName()] = string(data)
|
||||||
|
} else {
|
||||||
|
files[part.FormName()] = testMultipartFile{name: part.FileName(), data: data}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields, files
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageMultipartIncludesFileAndName(t *testing.T) {
|
||||||
|
fileName := "idp-icon-00112233445566778899aabbccddeeff.png"
|
||||||
|
body, contentType, err := imageMultipart(fileName, []byte("image-data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("imageMultipart: %v", err)
|
||||||
|
}
|
||||||
|
fields, files := readMultipartFields(t, body, contentType)
|
||||||
|
if got := fields["fileName"]; got != fileName {
|
||||||
|
t.Errorf("fileName = %q, want %q", got, fileName)
|
||||||
|
}
|
||||||
|
file := files["file"]
|
||||||
|
if file.name != fileName || string(file.data) != "image-data" {
|
||||||
|
t.Errorf("file part = %q, %q; want %q, image-data", file.name, file.data, fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseImageUploadResponse(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, body, wantURL, wantName string
|
||||||
|
status int
|
||||||
|
callErr error
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"complete", `{"fileUrl":"https://img/x","fileName":"images/x.png"}`, "https://img/x", "images/x.png", 201, nil, false},
|
||||||
|
{"missing name", `{"fileUrl":"https://img/x"}`, "", "", 201, nil, true},
|
||||||
|
{"missing url", `{"fileName":"images/x.png"}`, "", "", 200, nil, true},
|
||||||
|
{"bad json", `{`, "", "", 200, nil, true},
|
||||||
|
{"http error hides body", `secret-body`, "", "", 400, nil, true},
|
||||||
|
{"call error", `{}`, "", "", 500, errors.New("upstream"), true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
resp, body := testImageResponse(tc.status, tc.body)
|
||||||
|
gotURL, gotName, err := parseImageUploadResponse(resp, tc.callErr)
|
||||||
|
if gotURL != tc.wantURL || gotName != tc.wantName || (err != nil) != tc.wantErr {
|
||||||
|
t.Errorf("result = %q, %q, %v; want %q, %q, err=%v", gotURL, gotName, err, tc.wantURL, tc.wantName, tc.wantErr)
|
||||||
|
}
|
||||||
|
if !body.closed {
|
||||||
|
t.Error("response body was not closed")
|
||||||
|
}
|
||||||
|
if err != nil && strings.Contains(err.Error(), "secret-body") {
|
||||||
|
t.Errorf("error leaked response body: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainImageDeleteRequestEncodesFileName(t *testing.T) {
|
||||||
|
fileName := "images/folder/a b+%.png"
|
||||||
|
req, err := newDomainImageDeleteRequest(context.Background(), "https://id.example/", fileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newDomainImageDeleteRequest: %v", err)
|
||||||
|
}
|
||||||
|
if req.Method != http.MethodDelete || req.URL.Path != domainImagesPath {
|
||||||
|
t.Errorf("request = %s %s, want DELETE %s", req.Method, req.URL.Path, domainImagesPath)
|
||||||
|
}
|
||||||
|
if got := req.URL.Query().Get("fileName"); got != fileName {
|
||||||
|
t.Errorf("decoded fileName = %q, want %q", got, fileName)
|
||||||
|
}
|
||||||
|
if strings.Contains(req.URL.RawQuery, " ") || strings.Contains(req.URL.RawQuery, "/") {
|
||||||
|
t.Errorf("raw query is not encoded: %q", req.URL.RawQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishDomainImageDelete(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
callErr error
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"deleted", http.StatusNoContent, nil, false},
|
||||||
|
{"already gone", http.StatusNotFound, errors.New("not found"), false},
|
||||||
|
{"upstream failure", http.StatusInternalServerError, errors.New("upstream"), true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
resp, body := testImageResponse(tc.status, "response")
|
||||||
|
err := finishDomainImageDelete(resp, tc.callErr)
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Errorf("err = %v, wantErr %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
if !body.closed {
|
||||||
|
t.Error("response body was not closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-16
@@ -3,6 +3,7 @@ package oci
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -22,6 +23,11 @@ const samlMetadataPath = "/fed/v1/metadata"
|
|||||||
// idpSchema 是 IdentityProvider 资源的 SCIM schema。
|
// idpSchema 是 IdentityProvider 资源的 SCIM schema。
|
||||||
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
|
const idpSchema = "urn:ietf:params:scim:schemas:oracle:idcs:IdentityProvider"
|
||||||
|
|
||||||
|
const idpCreateRollbackWait = 15 * time.Second
|
||||||
|
|
||||||
|
// IdpSetupWarningCode 是 IdP 已创建但 JIT 后置配置未完成的稳定机器码。
|
||||||
|
const IdpSetupWarningCode = "JIT_SETUP_INCOMPLETE"
|
||||||
|
|
||||||
// IdentityProviderInfo 是一个外部身份提供者的关键字段。
|
// IdentityProviderInfo 是一个外部身份提供者的关键字段。
|
||||||
type IdentityProviderInfo struct {
|
type IdentityProviderInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -33,6 +39,27 @@ type IdentityProviderInfo struct {
|
|||||||
TimeCreated *time.Time `json:"timeCreated,omitempty"`
|
TimeCreated *time.Time `json:"timeCreated,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PartialIdentityProviderCreateError 表示 IdP 已创建且回滚无法确认。
|
||||||
|
// Error 只返回固定文案;Cause 仅保留在内部错误链,不得直接写入 HTTP 响应。
|
||||||
|
type PartialIdentityProviderCreateError struct {
|
||||||
|
IdentityProvider IdentityProviderInfo
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PartialIdentityProviderCreateError) Error() string {
|
||||||
|
return "identity provider was created but JIT setup is incomplete"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PartialIdentityProviderCreateError) Unwrap() error { return e.cause }
|
||||||
|
|
||||||
|
type federationDomainsClient interface {
|
||||||
|
ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error)
|
||||||
|
CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error)
|
||||||
|
GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error)
|
||||||
|
PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error)
|
||||||
|
DeleteIdentityProvider(context.Context, identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为:
|
// CreateIdpInput 是创建 SAML IdP 的输入;零值即控制台默认行为:
|
||||||
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
|
// 名称 ID 格式「无」、SAML 断言名称 ID 映射到用户名、JIT 开启建用户不更新、
|
||||||
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
|
// 静态分配 Administrators 组(service 层负责把缺省字段填成这些默认值)。
|
||||||
@@ -75,13 +102,19 @@ func (c *RealClient) ListIdentityProviders(ctx context.Context, cred Credentials
|
|||||||
return idps, nil
|
return idps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT。
|
// CreateSamlIdentityProvider 实现 Client:按输入创建禁用态 SAML IdP 并配置 JIT;
|
||||||
|
// JIT 失败时尝试回滚,回滚无法确认则返回 PartialIdentityProviderCreateError。
|
||||||
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Credentials, region, domainID string, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return IdentityProviderInfo{}, err
|
return IdentityProviderInfo{}, err
|
||||||
}
|
}
|
||||||
|
return createSamlIdentityProvider(ctx, dc, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSamlIdentityProvider(ctx context.Context, dc federationDomainsClient, in CreateIdpInput) (IdentityProviderInfo, error) {
|
||||||
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
|
var adminGroup *identitydomains.IdentityProviderJitUserProvAssignedGroups
|
||||||
|
var err error
|
||||||
if in.JitEnabled && in.JitAssignAdminGroup {
|
if in.JitEnabled && in.JitAssignAdminGroup {
|
||||||
if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
|
if adminGroup, err = adminGroupRef(ctx, dc); err != nil {
|
||||||
return IdentityProviderInfo{}, err
|
return IdentityProviderInfo{}, err
|
||||||
@@ -93,12 +126,31 @@ func (c *RealClient) CreateSamlIdentityProvider(ctx context.Context, cred Creden
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err)
|
return IdentityProviderInfo{}, fmt.Errorf("create identity provider %s: %w", in.Name, err)
|
||||||
}
|
}
|
||||||
if in.JitEnabled {
|
info := toIdpInfo(resp.IdentityProvider)
|
||||||
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
|
if !in.JitEnabled {
|
||||||
return toIdpInfo(resp.IdentityProvider), err
|
return info, nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return toIdpInfo(resp.IdentityProvider), nil
|
if err := ensureJitAttributeMappings(ctx, dc, resp.IdentityProvider, jitMappings(in)); err != nil {
|
||||||
|
return rollbackIncompleteIdp(ctx, dc, info, err)
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rollbackIncompleteIdp(ctx context.Context, dc federationDomainsClient, info IdentityProviderInfo, setupErr error) (IdentityProviderInfo, error) {
|
||||||
|
if info.ID == "" {
|
||||||
|
cause := errors.Join(setupErr, errors.New("created IdP response has no ID; rollback unavailable"))
|
||||||
|
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
|
||||||
|
}
|
||||||
|
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), idpCreateRollbackWait)
|
||||||
|
defer cancel()
|
||||||
|
_, rollbackErr := dc.DeleteIdentityProvider(rollbackCtx, identitydomains.DeleteIdentityProviderRequest{
|
||||||
|
IdentityProviderId: &info.ID,
|
||||||
|
})
|
||||||
|
if rollbackErr == nil {
|
||||||
|
return IdentityProviderInfo{}, fmt.Errorf("configure JIT mappings; created IdP rolled back: %w", setupErr)
|
||||||
|
}
|
||||||
|
cause := errors.Join(setupErr, fmt.Errorf("delete created IdP during rollback: %w", rollbackErr))
|
||||||
|
return info, &PartialIdentityProviderCreateError{IdentityProvider: info, cause: cause}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
|
// buildSamlIdp 按输入组装 SAML IdP(创建为禁用态,启用走 activate 接口)。
|
||||||
@@ -141,7 +193,7 @@ func buildSamlIdp(in CreateIdpInput, adminGroup *identitydomains.IdentityProvide
|
|||||||
|
|
||||||
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
|
// adminGroupRef 查询域内管理员组作为 JIT 静态分配与用户授权目标;
|
||||||
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
|
// 按 adminGroupNames 优先序取第一个存在的组,都不存在返回 nil。
|
||||||
func adminGroupRef(ctx context.Context, dc identitydomains.IdentityDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
func adminGroupRef(ctx context.Context, dc federationDomainsClient) (*identitydomains.IdentityProviderJitUserProvAssignedGroups, error) {
|
||||||
filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1])
|
filter := fmt.Sprintf("displayName eq %q or displayName eq %q", adminGroupNames[0], adminGroupNames[1])
|
||||||
count := len(adminGroupNames)
|
count := len(adminGroupNames)
|
||||||
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
resp, err := dc.ListGroups(ctx, identitydomains.ListGroupsRequest{
|
||||||
@@ -180,7 +232,7 @@ func jitMappings(in CreateIdpInput) []interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。
|
// ensureJitAttributeMappings 把 IdP 自动生成的 JIT 属性映射替换为给定映射。
|
||||||
func ensureJitAttributeMappings(ctx context.Context, dc identitydomains.IdentityDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error {
|
func ensureJitAttributeMappings(ctx context.Context, dc federationDomainsClient, idp identitydomains.IdentityProvider, mappings []interface{}) error {
|
||||||
ref := idp.JitUserProvAttributes
|
ref := idp.JitUserProvAttributes
|
||||||
if ref == nil || ref.Value == nil {
|
if ref == nil || ref.Value == nil {
|
||||||
got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id})
|
got, err := dc.GetIdentityProvider(ctx, identitydomains.GetIdentityProviderRequest{IdentityProviderId: idp.Id})
|
||||||
@@ -302,12 +354,15 @@ func updateLoginPageIdps(ctx context.Context, dc identitydomains.IdentityDomains
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。
|
// rebuildSamlIdpsReturn 重建规则 return 数组,增删 SamlIDPs 中的目标 IdP。
|
||||||
|
// 从未分配过 SAML IdP 的域,规则 return 里没有 SamlIDPs 项,添加时须补建,
|
||||||
|
// 否则静默跳过——IdP 启用了却始终不进 Default Identity Provider Policy。
|
||||||
func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) {
|
func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, show bool) ([]interface{}, bool, error) {
|
||||||
returns := make([]interface{}, 0, len(items))
|
returns := make([]interface{}, 0, len(items)+1)
|
||||||
changed := false
|
changed, seen := false, false
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
name, value := deref(item.Name), deref(item.Value)
|
name, value := deref(item.Name), deref(item.Value)
|
||||||
if name == "SamlIDPs" {
|
if name == "SamlIDPs" {
|
||||||
|
seen = true
|
||||||
next, ok, err := toggleJSONList(value, idpID, show)
|
next, ok, err := toggleJSONList(value, idpID, show)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err)
|
return nil, false, fmt.Errorf("parse SamlIDPs %q: %w", value, err)
|
||||||
@@ -316,6 +371,10 @@ func rebuildSamlIdpsReturn(items []identitydomains.RuleReturn, idpID string, sho
|
|||||||
}
|
}
|
||||||
returns = append(returns, map[string]string{"name": name, "value": value})
|
returns = append(returns, map[string]string{"name": name, "value": value})
|
||||||
}
|
}
|
||||||
|
if !seen && show {
|
||||||
|
returns = append(returns, map[string]string{"name": "SamlIDPs", "value": jsonList(idpID)})
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
return returns, changed, nil
|
return returns, changed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +415,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
body, status, err := fetchSamlMetadata(ctx, url)
|
body, status, err := fetchSamlMetadata(ctx, cred, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -366,7 +425,7 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
|||||||
if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil {
|
if err := c.enableSigningCertPublicAccess(ctx, cred, region, domainID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
body, status, err = fetchSamlMetadata(ctx, url)
|
body, status, err = fetchSamlMetadata(ctx, cred, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -376,24 +435,53 @@ func (c *RealClient) DownloadDomainSamlMetadata(ctx context.Context, cred Creden
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名。
|
// samlMetadataTimeout / samlMetadataMaxBytes 约束匿名元数据请求:限时限量,
|
||||||
func fetchSamlMetadata(ctx context.Context, url string) ([]byte, int, error) {
|
// 防异常缓慢或超大响应长期占住请求与内存。
|
||||||
|
const (
|
||||||
|
samlMetadataTimeout = 30 * time.Second
|
||||||
|
samlMetadataMaxBytes = 4 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// fetchSamlMetadata 匿名请求元数据端点;该端点只支持公开访问,不接受 OCI 签名,
|
||||||
|
// 但仍须走租户代理链路,防止本应经代理的流量直连泄露真实出口。
|
||||||
|
func fetchSamlMetadata(ctx context.Context, cred Credentials, url string) ([]byte, int, error) {
|
||||||
|
client, err := metadataHTTPClient(cred)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, samlMetadataTimeout)
|
||||||
|
defer cancel()
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+samlMetadataPath, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("build metadata request: %w", err)
|
return nil, 0, fmt.Errorf("build metadata request: %w", err)
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("download saml metadata: %w", err)
|
return nil, 0, fmt.Errorf("download saml metadata: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(io.LimitReader(resp.Body, samlMetadataMaxBytes+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("read saml metadata: %w", err)
|
return nil, 0, fmt.Errorf("read saml metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
if len(body) > samlMetadataMaxBytes {
|
||||||
|
return nil, 0, fmt.Errorf("saml metadata exceeds %d bytes", samlMetadataMaxBytes)
|
||||||
|
}
|
||||||
return body, resp.StatusCode, nil
|
return body, resp.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metadataHTTPClient 选取元数据请求的客户端:未配代理直连;配了代理但配置非法时
|
||||||
|
// 报错而非静默直连(失败关闭)。
|
||||||
|
func metadataHTTPClient(cred Credentials) (*http.Client, error) {
|
||||||
|
if cred.Proxy == nil {
|
||||||
|
return http.DefaultClient, nil
|
||||||
|
}
|
||||||
|
if hc := HTTPClientFor(cred.Proxy); hc != nil {
|
||||||
|
return hc, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("download saml metadata: invalid proxy config")
|
||||||
|
}
|
||||||
|
|
||||||
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
// enableSigningCertPublicAccess 开启域设置「访问签名证书」公开访问。
|
||||||
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
|
func (c *RealClient) enableSigningCertPublicAccess(ctx context.Context, cred Credentials, region, domainID string) error {
|
||||||
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
dc, err := c.domainsClient(ctx, cred, region, domainID)
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
|
"github.com/oracle/oci-go-sdk/v65/identitydomains"
|
||||||
|
)
|
||||||
|
|
||||||
|
type federationCreateStub struct {
|
||||||
|
created identitydomains.IdentityProvider
|
||||||
|
createErr error
|
||||||
|
patchErr error
|
||||||
|
deleteErr error
|
||||||
|
patchCalls int
|
||||||
|
deletedID string
|
||||||
|
deleteCtxErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *federationCreateStub) ListGroups(context.Context, identitydomains.ListGroupsRequest) (identitydomains.ListGroupsResponse, error) {
|
||||||
|
return identitydomains.ListGroupsResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *federationCreateStub) CreateIdentityProvider(context.Context, identitydomains.CreateIdentityProviderRequest) (identitydomains.CreateIdentityProviderResponse, error) {
|
||||||
|
return identitydomains.CreateIdentityProviderResponse{IdentityProvider: s.created}, s.createErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *federationCreateStub) GetIdentityProvider(context.Context, identitydomains.GetIdentityProviderRequest) (identitydomains.GetIdentityProviderResponse, error) {
|
||||||
|
return identitydomains.GetIdentityProviderResponse{IdentityProvider: s.created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *federationCreateStub) PatchMappedAttribute(context.Context, identitydomains.PatchMappedAttributeRequest) (identitydomains.PatchMappedAttributeResponse, error) {
|
||||||
|
s.patchCalls++
|
||||||
|
return identitydomains.PatchMappedAttributeResponse{}, s.patchErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *federationCreateStub) DeleteIdentityProvider(ctx context.Context, request identitydomains.DeleteIdentityProviderRequest) (identitydomains.DeleteIdentityProviderResponse, error) {
|
||||||
|
s.deletedID = deref(request.IdentityProviderId)
|
||||||
|
s.deleteCtxErr = ctx.Err()
|
||||||
|
return identitydomains.DeleteIdentityProviderResponse{}, s.deleteErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func createdJitIdp(id string) identitydomains.IdentityProvider {
|
||||||
|
return identitydomains.IdentityProvider{
|
||||||
|
Id: idPtr(id), PartnerName: common.String("test-idp"), Type: identitydomains.IdentityProviderTypeSaml,
|
||||||
|
JitUserProvEnabled: common.Bool(true), JitUserProvAttributes: &identitydomains.IdentityProviderJitUserProvAttributes{Value: common.String("mapping-1")},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func idPtr(value string) *string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return common.String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJitInput() CreateIdpInput {
|
||||||
|
return CreateIdpInput{Name: "test-idp", JitEnabled: true, IconURL: "https://img.example/icon.png"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSamlIdentityProviderCreateFailure(t *testing.T) {
|
||||||
|
createErr := errors.New("create rejected")
|
||||||
|
stub := &federationCreateStub{createErr: createErr}
|
||||||
|
got, err := createSamlIdentityProvider(context.Background(), stub, testJitInput())
|
||||||
|
if !errors.Is(err, createErr) {
|
||||||
|
t.Fatalf("err = %v, want create error", err)
|
||||||
|
}
|
||||||
|
if got.ID != "" || stub.patchCalls != 0 || stub.deletedID != "" {
|
||||||
|
t.Errorf("got = %+v, patchCalls = %d, deletedID = %q", got, stub.patchCalls, stub.deletedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSamlIdentityProviderJitFailureRollbackSuccess(t *testing.T) {
|
||||||
|
patchErr := errors.New("patch rejected")
|
||||||
|
stub := &federationCreateStub{created: createdJitIdp("idp-1"), patchErr: patchErr}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
got, err := createSamlIdentityProvider(ctx, stub, testJitInput())
|
||||||
|
var partial *PartialIdentityProviderCreateError
|
||||||
|
if !errors.Is(err, patchErr) || errors.As(err, &partial) {
|
||||||
|
t.Fatalf("err = %v, want ordinary wrapped patch error", err)
|
||||||
|
}
|
||||||
|
if got.ID != "" || stub.deletedID != "idp-1" || stub.deleteCtxErr != nil {
|
||||||
|
t.Errorf("got.ID = %q, deletedID = %q, deleteCtxErr = %v", got.ID, stub.deletedID, stub.deleteCtxErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSamlIdentityProviderPartialCreateContract(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, id string
|
||||||
|
deleteErr error
|
||||||
|
wantDelete bool
|
||||||
|
}{
|
||||||
|
{"rollback fails", "idp-1", errors.New("rollback secret detail"), true},
|
||||||
|
{"created id missing", "", nil, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) { assertPartialCreate(t, tc.id, tc.deleteErr, tc.wantDelete) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPartialCreate(t *testing.T, id string, deleteErr error, wantDelete bool) {
|
||||||
|
t.Helper()
|
||||||
|
stub := &federationCreateStub{created: createdJitIdp(id), patchErr: errors.New("patch secret detail"), deleteErr: deleteErr}
|
||||||
|
got, err := createSamlIdentityProvider(context.Background(), stub, testJitInput())
|
||||||
|
var partial *PartialIdentityProviderCreateError
|
||||||
|
if !errors.As(err, &partial) || partial.IdentityProvider.ID != id {
|
||||||
|
t.Fatalf("got = %+v, err = %v, partial = %+v", got, err, partial)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret") || (stub.deletedID != "") != wantDelete {
|
||||||
|
t.Errorf("unsafe err = %q or deletedID = %q, wantDelete = %v", err, stub.deletedID, wantDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleReturn(name, value string) identitydomains.RuleReturn {
|
||||||
|
return identitydomains.RuleReturn{Name: common.String(name), Value: common.String(value)}
|
||||||
|
}
|
||||||
|
|
||||||
|
type rebuildIdpReturnCase struct {
|
||||||
|
name string
|
||||||
|
items []identitydomains.RuleReturn
|
||||||
|
show bool
|
||||||
|
changed bool
|
||||||
|
want string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRebuildSamlIdpsReturn(t *testing.T) {
|
||||||
|
local := ruleReturn("LocalIDPs", `["UserNamePassword"]`)
|
||||||
|
cases := []rebuildIdpReturnCase{
|
||||||
|
{"无SamlIDPs项时添加须补建", []identitydomains.RuleReturn{local}, true, true, `["idp-1"]`},
|
||||||
|
{"无SamlIDPs项时移除无变化", []identitydomains.RuleReturn{local}, false, false, ""},
|
||||||
|
{"已有其他IdP时追加", []identitydomains.RuleReturn{local, ruleReturn("SamlIDPs", `["other"]`)}, true, true, `["other","idp-1"]`},
|
||||||
|
{"已在列表中再添加无变化", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", `["idp-1"]`)}, true, false, `["idp-1"]`},
|
||||||
|
{"移除目标IdP", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", `["idp-1","other"]`)}, false, true, `["other"]`},
|
||||||
|
{"空值项添加", []identitydomains.RuleReturn{ruleReturn("SamlIDPs", "")}, true, true, `["idp-1"]`},
|
||||||
|
}
|
||||||
|
assertRebuildIdpReturns(t, cases)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRebuildIdpReturns(t *testing.T, cases []rebuildIdpReturnCase) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
returns, changed, err := rebuildSamlIdpsReturn(tc.items, "idp-1", tc.show)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rebuildSamlIdpsReturn: %v", err)
|
||||||
|
}
|
||||||
|
if changed != tc.changed {
|
||||||
|
t.Errorf("changed = %v, want %v", changed, tc.changed)
|
||||||
|
}
|
||||||
|
got := ""
|
||||||
|
for _, r := range returns {
|
||||||
|
m := r.(map[string]string)
|
||||||
|
if m["name"] == "SamlIDPs" {
|
||||||
|
got = m["value"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("SamlIDPs = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRebuildSamlIdpsReturnBadJSON(t *testing.T) {
|
||||||
|
items := []identitydomains.RuleReturn{ruleReturn("SamlIDPs", "not-json")}
|
||||||
|
if _, _, err := rebuildSamlIdpsReturn(items, "idp-1", true); err == nil {
|
||||||
|
t.Fatal("坏 JSON 应报错而非静默覆盖")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchSamlMetadataLimitsBody 锁定匿名元数据请求的响应体上限:超限报错而非吞下。
|
||||||
|
func TestFetchSamlMetadataLimitsBody(t *testing.T) {
|
||||||
|
huge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write(bytes.Repeat([]byte("x"), samlMetadataMaxBytes+1))
|
||||||
|
}))
|
||||||
|
defer huge.Close()
|
||||||
|
if _, _, err := fetchSamlMetadata(context.Background(), Credentials{}, huge.URL); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "exceeds") {
|
||||||
|
t.Fatalf("err = %v, want 超限错误", err)
|
||||||
|
}
|
||||||
|
ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("<EntityDescriptor/>"))
|
||||||
|
}))
|
||||||
|
defer ok.Close()
|
||||||
|
body, status, err := fetchSamlMetadata(context.Background(), Credentials{}, ok.URL)
|
||||||
|
if err != nil || status != http.StatusOK || string(body) != "<EntityDescriptor/>" {
|
||||||
|
t.Fatalf("fetch = %q, %d, %v; want 正常返回", body, status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,9 +60,9 @@ func (c *RealClient) GenAiCompatResponses(ctx context.Context, cred Credentials,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
payload, err := io.ReadAll(io.LimitReader(response.Body, compatResponsesLimit))
|
payload, err := readCompatBody(response.Body, compatResponsesLimit, "compat responses")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read compat responses body: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
@@ -121,3 +121,16 @@ func (c *RealClient) GenAiCompatResponsesStream(ctx context.Context, cred Creden
|
|||||||
}
|
}
|
||||||
return callWithHeaderBudget(ctx, client, request, wait)
|
return callWithHeaderBudget(ctx, client, request, wait)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readCompatBody 读取上游响应体并施加上限;读 limit+1 判超报错——
|
||||||
|
// 静默截断的 JSON/音频配 200 会被下游当完整成功记账。
|
||||||
|
func readCompatBody(body io.Reader, limit int64, tag string) ([]byte, error) {
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(body, limit+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s body: %w", tag, err)
|
||||||
|
}
|
||||||
|
if int64(len(payload)) > limit {
|
||||||
|
return nil, fmt.Errorf("%s body exceeds %d bytes", tag, limit)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package oci
|
package oci
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -139,3 +140,31 @@ func TestCallWithHeaderBudget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReadCompatBodyRejectsOversize 锁定上游响应上限:恰好达限通过,超限报错,
|
||||||
|
// 不允许静默截断配 200 让下游当完整成功。
|
||||||
|
func TestReadCompatBodyRejectsOversize(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"under limit", 15, false},
|
||||||
|
{"exactly limit", 16, false},
|
||||||
|
{"over limit", 17, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
payload, err := readCompatBody(bytes.NewReader(make([]byte, tc.size)), 16, "test")
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||||
|
t.Fatalf("err = %v, want 超限错误", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || int64(len(payload)) != tc.size {
|
||||||
|
t.Fatalf("payload = %d bytes, %v; want %d", len(payload), err, tc.size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/oracle/oci-go-sdk/v65/common"
|
"github.com/oracle/oci-go-sdk/v65/common"
|
||||||
@@ -36,9 +35,9 @@ func (c *RealClient) GenAiCompatSpeech(ctx context.Context, cred Credentials, re
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
payload, err := io.ReadAll(io.LimitReader(response.Body, compatSpeechLimit))
|
payload, err := readCompatBody(response.Body, compatSpeechLimit, "compat speech")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("read compat speech body: %w", err)
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return payload, response.Header.Get("Content-Type"), nil
|
return payload, response.Header.Get("Content-Type"), nil
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-71
@@ -2,8 +2,6 @@ package oci
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,14 +12,11 @@ import (
|
|||||||
"github.com/oracle/oci-go-sdk/v65/sch"
|
"github.com/oracle/oci-go-sdk/v65/sch"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 日志回传链路(方案A)在租户侧的固定资源命名,按名幂等查找与创建;
|
// 日志回传链路(方案A)在租户侧的资源命名与描述由 logrelay_names.go 集中派生;
|
||||||
// 资源建在凭据默认区域,Audit 日志组含子 compartment。
|
// 资源建在凭据默认区域,Audit 日志组含子 compartment。
|
||||||
// Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用
|
// Topic 删除后名称有保留期(同名重建长时间 409 Conflict),故 Topic 用
|
||||||
// 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。
|
// 前缀+随机后缀命名、按前缀幂等查找,销毁重建不受保留期阻塞。
|
||||||
const (
|
const (
|
||||||
relayTopicPrefix = "ociportal-logs"
|
|
||||||
relayPolicyName = "ociportal-logs-sch"
|
|
||||||
relayConnectorName = "ociportal-logs"
|
|
||||||
relayAuditLogGroup = "_Audit_Include_Subcompartment"
|
relayAuditLogGroup = "_Audit_Include_Subcompartment"
|
||||||
relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限
|
relayTopicPages = 5 // 按前缀查找 Topic 的翻页上限
|
||||||
)
|
)
|
||||||
@@ -83,24 +78,36 @@ func (c *RealClient) schClient(cred Credentials) (sch.ServiceConnectorClient, er
|
|||||||
return sc, nil
|
return sc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureRelayTopic 实现 Client:按前缀返回既有 Topic 或以随机后缀新建。
|
// EnsureRelayTopic 实现 Client:按新命名前缀返回既有 Topic,fallback 到 legacy 前缀;
|
||||||
|
// 未命中则以新前缀 + 随机后缀新建。命中旧命名时顺手把描述刷新为中性文案。
|
||||||
func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) {
|
func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (RelayResource, error) {
|
||||||
cp, err := c.onsControlClient(cred)
|
cp, err := c.onsControlClient(cred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, err
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
if res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil || ok {
|
names := relayResourceNames(cred.TenancyOCID)
|
||||||
return res, err
|
res, ok, err := findRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix, legacyRelayTopicPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
name, err := relayTopicNewName()
|
if ok {
|
||||||
|
refreshRelayTopicDesc(ctx, cp, res.ID)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
return createRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRelayTopic 以指定前缀 + 4 字节随机后缀新建 ONS Topic,描述使用中性文案。
|
||||||
|
func createRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy, prefix string) (RelayResource, error) {
|
||||||
|
name, err := relayTopicNewName(prefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, err
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{
|
created, err := cp.CreateTopic(ctx, ons.CreateTopicRequest{
|
||||||
CreateTopicDetails: ons.CreateTopicDetails{
|
CreateTopicDetails: ons.CreateTopicDetails{
|
||||||
Name: &name,
|
Name: &name,
|
||||||
CompartmentId: &cred.TenancyOCID,
|
CompartmentId: &tenancy,
|
||||||
Description: common.String("oci-portal 日志回传:关键审计事件经 Connector 投递到面板"),
|
Description: common.String(relayTopicDescNew),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,28 +116,17 @@ func (c *RealClient) EnsureRelayTopic(ctx context.Context, cred Credentials) (Re
|
|||||||
return RelayResource{ID: deref(created.TopicId), State: string(created.LifecycleState), Created: true}, nil
|
return RelayResource{ID: deref(created.TopicId), State: string(created.LifecycleState), Created: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// relayTopicNewName 生成带随机后缀的 Topic 名,规避删除名称保留期。
|
// findRelayTopic 依次按传入的前缀列表在租户范围内查找存活 Topic;第一个命中即返回。
|
||||||
func relayTopicNewName() (string, error) {
|
// 支持传入新命名前缀与 legacy 前缀,实现向后兼容存量资源。
|
||||||
buf := make([]byte, 4)
|
func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string, prefixes ...string) (RelayResource, bool, error) {
|
||||||
if _, err := rand.Read(buf); err != nil {
|
|
||||||
return "", fmt.Errorf("topic name suffix: %w", err)
|
|
||||||
}
|
|
||||||
return relayTopicPrefix + "-" + hex.EncodeToString(buf), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// findRelayTopic 按名称前缀查找存活 Topic;未找到时 ok 为 false。
|
|
||||||
func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient, tenancy string) (RelayResource, bool, error) {
|
|
||||||
req := ons.ListTopicsRequest{CompartmentId: &tenancy}
|
req := ons.ListTopicsRequest{CompartmentId: &tenancy}
|
||||||
for page := 0; page < relayTopicPages; page++ {
|
for page := 0; page < relayTopicPages; page++ {
|
||||||
list, err := cp.ListTopics(ctx, req)
|
list, err := cp.ListTopics(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err)
|
return RelayResource{}, false, fmt.Errorf("list ons topics: %w", err)
|
||||||
}
|
}
|
||||||
for _, t := range list.Items {
|
if res, ok := matchRelayTopic(list.Items, prefixes); ok {
|
||||||
if strings.HasPrefix(deref(t.Name), relayTopicPrefix) &&
|
return res, true, nil
|
||||||
t.LifecycleState == ons.NotificationTopicSummaryLifecycleStateActive {
|
|
||||||
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if list.OpcNextPage == nil {
|
if list.OpcNextPage == nil {
|
||||||
break
|
break
|
||||||
@@ -140,6 +136,35 @@ func findRelayTopic(ctx context.Context, cp ons.NotificationControlPlaneClient,
|
|||||||
return RelayResource{}, false, nil
|
return RelayResource{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchRelayTopic 在一页 Topic 中挑出第一个命名前缀命中且处于 ACTIVE 的资源。
|
||||||
|
func matchRelayTopic(items []ons.NotificationTopicSummary, prefixes []string) (RelayResource, bool) {
|
||||||
|
for _, t := range items {
|
||||||
|
if t.LifecycleState != ons.NotificationTopicSummaryLifecycleStateActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := deref(t.Name)
|
||||||
|
for _, p := range prefixes {
|
||||||
|
if strings.HasPrefix(name, p) {
|
||||||
|
return RelayResource{ID: deref(t.TopicId), State: string(t.LifecycleState)}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RelayResource{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshRelayTopicDesc 尽力把 Topic 描述改为中性文案;失败不阻塞主流程(权限不足等场景直接忽略)。
|
||||||
|
func refreshRelayTopicDesc(ctx context.Context, cp ons.NotificationControlPlaneClient, topicID string) {
|
||||||
|
if topicID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = cp.UpdateTopic(ctx, ons.UpdateTopicRequest{
|
||||||
|
TopicId: &topicID,
|
||||||
|
TopicAttributesDetails: ons.TopicAttributesDetails{
|
||||||
|
Description: common.String(relayTopicDescNew),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureRelaySubscription 实现 Client:按 endpoint 返回既有 CUSTOM_HTTPS 订阅或新建;
|
// EnsureRelaySubscription 实现 Client:按 endpoint 返回既有 CUSTOM_HTTPS 订阅或新建;
|
||||||
// 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。
|
// 新建订阅处于 PENDING,待 ONS 向 endpoint 投递确认消息、面板回访后转 ACTIVE。
|
||||||
func (c *RealClient) EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) {
|
func (c *RealClient) EnsureRelaySubscription(ctx context.Context, cred Credentials, topicID, endpoint string) (RelayResource, error) {
|
||||||
@@ -196,28 +221,50 @@ func (c *RealClient) GetRelaySubscription(ctx context.Context, cred Credentials,
|
|||||||
return RelayResource{ID: deref(resp.Id), State: string(resp.LifecycleState)}, nil
|
return RelayResource{ID: deref(resp.Id), State: string(resp.LifecycleState)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureRelayPolicy 实现 Client:按名返回既有 IAM policy 或新建;
|
// EnsureRelayPolicy 实现 Client:按新命名返回既有 IAM policy,fallback 到 legacy 命名;
|
||||||
// 写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
|
// 未命中则以新命名创建。写操作必须发往 home region,授权 Service Connector 发布消息到 Topic。
|
||||||
func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) {
|
func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, homeRegion string) (RelayResource, error) {
|
||||||
ic, err := c.identityClientAt(cred, homeRegion)
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, err
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
names := relayResourceNames(cred.TenancyOCID)
|
||||||
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
|
res, ok, err := findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName)
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, fmt.Errorf("list policies: %w", err)
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
if len(list.Items) > 0 {
|
if ok {
|
||||||
return RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}, nil
|
refreshRelayPolicyDesc(ctx, ic, res.ID)
|
||||||
|
return res, nil
|
||||||
}
|
}
|
||||||
stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", cred.TenancyOCID)
|
return createRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRelayPolicy 依次按传入的名称列表在租户范围内查找 IAM Policy;第一个命中即返回。
|
||||||
|
func findRelayPolicy(ctx context.Context, ic identity.IdentityClient, tenancy string, names ...string) (RelayResource, bool, error) {
|
||||||
|
for _, name := range names {
|
||||||
|
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
||||||
|
CompartmentId: &tenancy, Name: common.String(name),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RelayResource{}, false, fmt.Errorf("list policies: %w", err)
|
||||||
|
}
|
||||||
|
if len(list.Items) > 0 {
|
||||||
|
it := list.Items[0]
|
||||||
|
return RelayResource{ID: deref(it.Id), State: string(it.LifecycleState)}, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RelayResource{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRelayPolicy 以中性描述与派生名称新建 Policy,statement 允许 Service Connector 发布消息到租户内 Topic。
|
||||||
|
func createRelayPolicy(ctx context.Context, ic identity.IdentityClient, tenancy, name string) (RelayResource, error) {
|
||||||
|
stmt := fmt.Sprintf("Allow any-user to use ons-topics in tenancy where all {request.principal.type='serviceconnector', request.principal.compartment.id='%s'}", tenancy)
|
||||||
created, err := ic.CreatePolicy(ctx, identity.CreatePolicyRequest{
|
created, err := ic.CreatePolicy(ctx, identity.CreatePolicyRequest{
|
||||||
CreatePolicyDetails: identity.CreatePolicyDetails{
|
CreatePolicyDetails: identity.CreatePolicyDetails{
|
||||||
CompartmentId: &cred.TenancyOCID,
|
CompartmentId: &tenancy,
|
||||||
Name: common.String(relayPolicyName),
|
Name: common.String(name),
|
||||||
Description: common.String("oci-portal 日志回传:允许 Service Connector 发布到 ONS Topic"),
|
Description: common.String(relayPolicyDescNew),
|
||||||
Statements: []string{stmt},
|
Statements: []string{stmt},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -227,25 +274,45 @@ func (c *RealClient) EnsureRelayPolicy(ctx context.Context, cred Credentials, ho
|
|||||||
return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil
|
return RelayResource{ID: deref(created.Id), State: string(created.LifecycleState), Created: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureRelayConnector 实现 Client:按名返回既有 Connector 或新建(_Audit 含子区间 → Topic,
|
// refreshRelayPolicyDesc 尽力把 Policy 描述改为中性文案;失败不阻塞主流程。
|
||||||
// 按 condition 过滤);新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。
|
func refreshRelayPolicyDesc(ctx context.Context, ic identity.IdentityClient, id string) {
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = ic.UpdatePolicy(ctx, identity.UpdatePolicyRequest{
|
||||||
|
PolicyId: &id,
|
||||||
|
UpdatePolicyDetails: identity.UpdatePolicyDetails{
|
||||||
|
Description: common.String(relayPolicyDescNew),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRelayConnector 实现 Client:按新命名返回既有 Connector,fallback 到 legacy DisplayName;
|
||||||
|
// 命中时对齐 Log Filter 条件,未命中则以新命名新建并轮询至 ACTIVE。
|
||||||
func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) {
|
func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials, topicID, condition string) (RelayResource, error) {
|
||||||
sc, err := c.schClient(cred)
|
sc, err := c.schClient(cred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, err
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID)
|
names := relayResourceNames(cred.TenancyOCID)
|
||||||
|
res, ok, err := findRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, legacyRelayConnectorName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RelayResource{}, err
|
return RelayResource{}, err
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
return res, reconcileRelayCondition(ctx, sc, res.ID, condition)
|
return res, reconcileRelayCondition(ctx, sc, res.ID, condition)
|
||||||
}
|
}
|
||||||
|
return createRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, topicID, condition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRelayConnector 以派生名称新建 Service Connector(_Audit 含子区间 → Topic,按 condition 过滤),
|
||||||
|
// 新建后轮询至 ACTIVE,超时返回错误但保留 Created 供上层回滚。Connector 不设 Description,避免恒定文案指纹。
|
||||||
|
func createRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy, name, topicID, condition string) (RelayResource, error) {
|
||||||
details := sch.CreateServiceConnectorDetails{
|
details := sch.CreateServiceConnectorDetails{
|
||||||
DisplayName: common.String(relayConnectorName),
|
DisplayName: common.String(name),
|
||||||
CompartmentId: &cred.TenancyOCID,
|
CompartmentId: &tenancy,
|
||||||
Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{
|
Source: sch.LoggingSourceDetails{LogSources: []sch.LogSource{{
|
||||||
CompartmentId: &cred.TenancyOCID,
|
CompartmentId: &tenancy,
|
||||||
LogGroupId: common.String(relayAuditLogGroup),
|
LogGroupId: common.String(relayAuditLogGroup),
|
||||||
}}},
|
}}},
|
||||||
Target: sch.NotificationsTargetDetails{TopicId: &topicID},
|
Target: sch.NotificationsTargetDetails{TopicId: &topicID},
|
||||||
@@ -258,7 +325,7 @@ func (c *RealClient) EnsureRelayConnector(ctx context.Context, cred Credentials,
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return RelayResource{}, fmt.Errorf("create service connector: %w", err)
|
return RelayResource{}, fmt.Errorf("create service connector: %w", err)
|
||||||
}
|
}
|
||||||
return waitRelayConnector(ctx, sc, cred.TenancyOCID)
|
return waitRelayConnector(ctx, sc, tenancy, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileRelayCondition 对齐存量 Connector 的过滤条件:关键事件清单变更
|
// reconcileRelayCondition 对齐存量 Connector 的过滤条件:关键事件清单变更
|
||||||
@@ -300,24 +367,27 @@ func relayConditionDiffers(tasks []sch.TaskDetailsResponse, want string) bool {
|
|||||||
return *rule.Condition != want
|
return *rule.Condition != want
|
||||||
}
|
}
|
||||||
|
|
||||||
// findRelayConnector 按名查找存活 Connector。
|
// findRelayConnector 依次按传入的 DisplayName 列表在租户范围内查找存活 Connector;第一个命中即返回。
|
||||||
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, bool, error) {
|
func findRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string, names ...string) (RelayResource, bool, error) {
|
||||||
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
|
for _, name := range names {
|
||||||
CompartmentId: &tenancy, DisplayName: common.String(relayConnectorName),
|
list, err := sc.ListServiceConnectors(ctx, sch.ListServiceConnectorsRequest{
|
||||||
})
|
CompartmentId: &tenancy, DisplayName: common.String(name),
|
||||||
if err != nil {
|
})
|
||||||
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
|
if err != nil {
|
||||||
}
|
return RelayResource{}, false, fmt.Errorf("list service connectors: %w", err)
|
||||||
for _, item := range list.Items {
|
}
|
||||||
if item.LifecycleState != sch.LifecycleStateDeleted && item.LifecycleState != sch.LifecycleStateDeleting {
|
for _, item := range list.Items {
|
||||||
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
|
if item.LifecycleState != sch.LifecycleStateDeleted && item.LifecycleState != sch.LifecycleStateDeleting {
|
||||||
|
return RelayResource{ID: deref(item.Id), State: string(item.LifecycleState)}, true, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return RelayResource{}, false, nil
|
return RelayResource{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitRelayConnector 轮询新建 Connector 直至 ACTIVE;超时带回已建资源信息。
|
// waitRelayConnector 轮询新建 Connector 直至 ACTIVE;超时带回已建资源信息。
|
||||||
func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy string) (RelayResource, error) {
|
// 只按新命名查找(新建资源用的就是新命名)。
|
||||||
|
func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tenancy, name string) (RelayResource, error) {
|
||||||
last := RelayResource{Created: true}
|
last := RelayResource{Created: true}
|
||||||
for i := 0; i < relayConnectorPollLimit; i++ {
|
for i := 0; i < relayConnectorPollLimit; i++ {
|
||||||
select {
|
select {
|
||||||
@@ -325,7 +395,7 @@ func waitRelayConnector(ctx context.Context, sc sch.ServiceConnectorClient, tena
|
|||||||
return last, ctx.Err()
|
return last, ctx.Err()
|
||||||
case <-time.After(relayConnectorPollTick):
|
case <-time.After(relayConnectorPollTick):
|
||||||
}
|
}
|
||||||
res, ok, err := findRelayConnector(ctx, sc, tenancy)
|
res, ok, err := findRelayConnector(ctx, sc, tenancy, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return last, err
|
return last, err
|
||||||
}
|
}
|
||||||
@@ -346,7 +416,8 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return st, err
|
return st, err
|
||||||
}
|
}
|
||||||
if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID); err != nil {
|
names := relayResourceNames(cred.TenancyOCID)
|
||||||
|
if st.Topic, _, err = findRelayTopic(ctx, cp, cred.TenancyOCID, names.TopicPrefix, legacyRelayTopicPrefix); err != nil {
|
||||||
return st, err
|
return st, err
|
||||||
}
|
}
|
||||||
if st.Topic.ID != "" {
|
if st.Topic.ID != "" {
|
||||||
@@ -361,27 +432,22 @@ func (c *RealClient) RelayState(ctx context.Context, cred Credentials, endpoint
|
|||||||
return c.relayControlState(ctx, cred, st)
|
return c.relayControlState(ctx, cred, st)
|
||||||
}
|
}
|
||||||
|
|
||||||
// relayControlState 补齐 Connector 与 Policy 两项状态。
|
// relayControlState 补齐 Connector 与 Policy 两项状态,双路径兼容 legacy 命名。
|
||||||
func (c *RealClient) relayControlState(ctx context.Context, cred Credentials, st RelayState) (RelayState, error) {
|
func (c *RealClient) relayControlState(ctx context.Context, cred Credentials, st RelayState) (RelayState, error) {
|
||||||
sc, err := c.schClient(cred)
|
sc, err := c.schClient(cred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return st, err
|
return st, err
|
||||||
}
|
}
|
||||||
if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID); err != nil {
|
names := relayResourceNames(cred.TenancyOCID)
|
||||||
|
if st.Connector, _, err = findRelayConnector(ctx, sc, cred.TenancyOCID, names.ConnectorName, legacyRelayConnectorName); err != nil {
|
||||||
return st, err
|
return st, err
|
||||||
}
|
}
|
||||||
ic, err := c.identityClientAt(cred, "")
|
ic, err := c.identityClientAt(cred, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return st, err
|
return st, err
|
||||||
}
|
}
|
||||||
list, err := ic.ListPolicies(ctx, identity.ListPoliciesRequest{
|
if st.Policy, _, err = findRelayPolicy(ctx, ic, cred.TenancyOCID, names.PolicyName, legacyRelayPolicyName); err != nil {
|
||||||
CompartmentId: &cred.TenancyOCID, Name: common.String(relayPolicyName),
|
return st, err
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return st, fmt.Errorf("list policies: %w", err)
|
|
||||||
}
|
|
||||||
if len(list.Items) > 0 {
|
|
||||||
st.Policy = RelayResource{ID: deref(list.Items[0].Id), State: string(list.Items[0].LifecycleState)}
|
|
||||||
}
|
}
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 日志回传链路资源命名与描述文案的集中定义。
|
||||||
|
// 目标:让每租户派生互异的中性资源名,避免跨租户恒定字面量成为「共用同一套 oci-portal」的指纹。
|
||||||
|
|
||||||
|
// relayNames 是按 tenancy 派生的一组资源名。
|
||||||
|
type relayNames struct {
|
||||||
|
TopicPrefix string // 例: "c3f2a1b0-audit",Topic 最终名再加短随机后缀
|
||||||
|
PolicyName string // 例: "c3f2a1b0-audit-p"
|
||||||
|
ConnectorName string // 例: "c3f2a1b0-audit"
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayResourceNames 由 tenancyOCID 派生一组稳定的中性资源名。
|
||||||
|
// SHA-256 前 4 字节做前缀,同 tenancy 每次调用结果一致(幂等查找依赖此性质)。
|
||||||
|
func relayResourceNames(tenancyOCID string) relayNames {
|
||||||
|
sum := sha256.Sum256([]byte(tenancyOCID))
|
||||||
|
prefix := hex.EncodeToString(sum[:4])
|
||||||
|
return relayNames{
|
||||||
|
TopicPrefix: prefix + "-audit",
|
||||||
|
PolicyName: prefix + "-audit-p",
|
||||||
|
ConnectorName: prefix + "-audit",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayTopicNewName 按给定前缀 + 4 字节随机后缀生成 Topic 名,规避 ONS 删除保留期。
|
||||||
|
func relayTopicNewName(prefix string) (string, error) {
|
||||||
|
buf := make([]byte, 4)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("topic name suffix: %w", err)
|
||||||
|
}
|
||||||
|
return prefix + "-" + hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 描述文案:中性英文,不含品牌明文;集中于此便于后续再调整。
|
||||||
|
const (
|
||||||
|
relayTopicDescNew = "Audit event relay endpoint"
|
||||||
|
relayPolicyDescNew = "Allow Service Connector to publish audit events to notification topic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// legacyRelay* 仅用于识别旧版本(硬编码 ociportal-logs*)创建的存量资源,不用于新建。
|
||||||
|
// 新租户完全走 relayResourceNames 派生;存量租户 fallback 命中后描述会一次性刷新为中性文案。
|
||||||
|
const (
|
||||||
|
legacyRelayTopicPrefix = "ociportal-logs"
|
||||||
|
legacyRelayPolicyName = "ociportal-logs-sch"
|
||||||
|
legacyRelayConnectorName = "ociportal-logs"
|
||||||
|
)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package oci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRelayResourceNames_Deterministic(t *testing.T) {
|
||||||
|
tenancy := "ocid1.tenancy.oc1..aaaaexampletenant"
|
||||||
|
a := relayResourceNames(tenancy)
|
||||||
|
b := relayResourceNames(tenancy)
|
||||||
|
if a != b {
|
||||||
|
t.Fatalf("同 tenancy 派生不一致: %+v vs %+v", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelayResourceNames_DistinctTenancies(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"ocid1.tenancy.oc1..aaaaaaaaaa",
|
||||||
|
"ocid1.tenancy.oc1..bbbbbbbbbb",
|
||||||
|
"ocid1.tenancy.oc1..cccccccccc",
|
||||||
|
}
|
||||||
|
seen := map[string]string{}
|
||||||
|
for _, tenancy := range cases {
|
||||||
|
n := relayResourceNames(tenancy)
|
||||||
|
if got, ok := seen[n.TopicPrefix]; ok {
|
||||||
|
t.Fatalf("前缀冲突: %s 与 %s 同为 %s", tenancy, got, n.TopicPrefix)
|
||||||
|
}
|
||||||
|
seen[n.TopicPrefix] = tenancy
|
||||||
|
assertNoBrandLeak(t, tenancy, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoBrandLeak 断言派生结果不含品牌明文,以及格式符合预期。
|
||||||
|
func assertNoBrandLeak(t *testing.T, tenancy string, n relayNames) {
|
||||||
|
t.Helper()
|
||||||
|
banned := []string{"oci-portal", "ociportal", "logs", "portal"}
|
||||||
|
fields := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{"TopicPrefix", n.TopicPrefix},
|
||||||
|
{"PolicyName", n.PolicyName},
|
||||||
|
{"ConnectorName", n.ConnectorName},
|
||||||
|
}
|
||||||
|
for _, f := range fields {
|
||||||
|
lower := strings.ToLower(f.value)
|
||||||
|
for _, b := range banned {
|
||||||
|
if strings.Contains(lower, b) {
|
||||||
|
t.Errorf("tenancy %s: %s=%q 含品牌明文 %q", tenancy, f.name, f.value, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`^[0-9a-f]{8}-audit(-p)?$`).MatchString(f.value) {
|
||||||
|
t.Errorf("tenancy %s: %s=%q 不符合 <8hex>-audit(-p)? 格式", tenancy, f.name, f.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelayTopicNewName_Suffix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefix string
|
||||||
|
}{
|
||||||
|
{name: "标准前缀", prefix: "c3f2a1b0-audit"},
|
||||||
|
{name: "legacy 前缀兼容", prefix: legacyRelayTopicPrefix},
|
||||||
|
}
|
||||||
|
pat := regexp.MustCompile(`^[0-9a-f]{8}$`)
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := relayTopicNewName(tt.prefix)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成失败: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(got, tt.prefix+"-") {
|
||||||
|
t.Errorf("前缀不符: got=%q, prefix=%q", got, tt.prefix)
|
||||||
|
}
|
||||||
|
suffix := strings.TrimPrefix(got, tt.prefix+"-")
|
||||||
|
if !pat.MatchString(suffix) {
|
||||||
|
t.Errorf("后缀非 8 hex: got=%q, suffix=%q", got, suffix)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyPrefixes_Unchanged(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "Topic legacy", got: legacyRelayTopicPrefix, want: "ociportal-logs"},
|
||||||
|
{name: "Policy legacy", got: legacyRelayPolicyName, want: "ociportal-logs-sch"},
|
||||||
|
{name: "Connector legacy", got: legacyRelayConnectorName, want: "ociportal-logs"},
|
||||||
|
}
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.got != tt.want {
|
||||||
|
t.Errorf("legacy 常量被误改: got=%q, want=%q", tt.got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelayDescriptions_Neutral(t *testing.T) {
|
||||||
|
banned := []string{"oci-portal", "ociportal", "日志回传", "面板"}
|
||||||
|
for _, desc := range []string{relayTopicDescNew, relayPolicyDescNew} {
|
||||||
|
lower := strings.ToLower(desc)
|
||||||
|
for _, b := range banned {
|
||||||
|
if strings.Contains(lower, strings.ToLower(b)) {
|
||||||
|
t.Errorf("描述含品牌明文 %q: %q", b, desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ type UpdateBucketInput struct {
|
|||||||
type CreatePARInput struct {
|
type CreatePARInput struct {
|
||||||
Name string
|
Name string
|
||||||
ObjectName string // 空 = 桶级(配合 AnyObjectReadWrite 前缀用法)
|
ObjectName string // 空 = 桶级(配合 AnyObjectReadWrite 前缀用法)
|
||||||
AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectReadWrite
|
AccessType string // ObjectRead / ObjectWrite / ObjectReadWrite / AnyObjectRead / AnyObjectWrite / AnyObjectReadWrite
|
||||||
ExpiresHours int
|
ExpiresHours int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +288,48 @@ func (c *RealClient) DeleteBucket(ctx context.Context, cred Credentials, region,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AbortAllMultipartUploads 实现 Client:中止桶内全部未完成分片上传并删除已传分片。
|
||||||
|
// 未完成分片会让「已清空」的桶仍以非空拒绝删除;404 视为桶已不存在,幂等成功。
|
||||||
|
func (c *RealClient) AbortAllMultipartUploads(ctx context.Context, cred Credentials, region, bucket string) error {
|
||||||
|
oc, err := c.osClient(cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ns, err := c.GetObjectStorageNamespace(ctx, cred, region)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for page := (*string)(nil); ; {
|
||||||
|
resp, err := oc.ListMultipartUploads(ctx, objectstorage.ListMultipartUploadsRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &bucket, Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ignoreNotFound(fmt.Errorf("list multipart uploads: %w", err))
|
||||||
|
}
|
||||||
|
for _, up := range resp.Items {
|
||||||
|
if _, err := oc.AbortMultipartUpload(ctx, objectstorage.AbortMultipartUploadRequest{
|
||||||
|
NamespaceName: &ns, BucketName: &bucket, ObjectName: up.Object, UploadId: up.UploadId,
|
||||||
|
}); err != nil {
|
||||||
|
if err := ignoreNotFound(fmt.Errorf("abort multipart upload: %w", err)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page = resp.OpcNextPage; page == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignoreNotFound 把上游 404 归一为 nil(资源已不存在,操作目的已达成)。
|
||||||
|
func ignoreNotFound(err error) error {
|
||||||
|
var se common.ServiceError
|
||||||
|
if errors.As(err, &se) && se.GetHTTPStatusCode() == 404 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// isBucketNotEmptyErr 识别「桶非空」类删除拒绝:对象/版本/分片报 BucketNotEmpty,
|
// isBucketNotEmptyErr 识别「桶非空」类删除拒绝:对象/版本/分片报 BucketNotEmpty,
|
||||||
// 仅剩活跃 PAR 时 OCI 报 409 且消息为 Active Preauthenticated Requests still exist,
|
// 仅剩活跃 PAR 时 OCI 报 409 且消息为 Active Preauthenticated Requests still exist,
|
||||||
// 两者都可经后台清空(对象版本+PAR)后重删。
|
// 两者都可经后台清空(对象版本+PAR)后重删。
|
||||||
@@ -582,21 +624,7 @@ func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return PAR{}, err
|
return PAR{}, err
|
||||||
}
|
}
|
||||||
if in.ExpiresHours <= 0 {
|
details := createPARDetails(in, time.Now())
|
||||||
in.ExpiresHours = 24
|
|
||||||
}
|
|
||||||
details := objectstorage.CreatePreauthenticatedRequestDetails{
|
|
||||||
Name: &in.Name,
|
|
||||||
AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType),
|
|
||||||
TimeExpires: &common.SDKTime{Time: time.Now().Add(time.Duration(in.ExpiresHours) * time.Hour)},
|
|
||||||
}
|
|
||||||
if in.ObjectName != "" {
|
|
||||||
details.ObjectName = &in.ObjectName
|
|
||||||
}
|
|
||||||
// 桶级 PAR(AnyObject*)必须显式桶列举动作,缺省会被 OCI 拒绝;一律放开列举便于收件人浏览
|
|
||||||
if strings.HasPrefix(in.AccessType, "AnyObject") {
|
|
||||||
details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects
|
|
||||||
}
|
|
||||||
resp, err := oc.CreatePreauthenticatedRequest(ctx, objectstorage.CreatePreauthenticatedRequestRequest{
|
resp, err := oc.CreatePreauthenticatedRequest(ctx, objectstorage.CreatePreauthenticatedRequestRequest{
|
||||||
NamespaceName: &ns, BucketName: &bucket, CreatePreauthenticatedRequestDetails: details,
|
NamespaceName: &ns, BucketName: &bucket, CreatePreauthenticatedRequestDetails: details,
|
||||||
})
|
})
|
||||||
@@ -606,6 +634,26 @@ func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bu
|
|||||||
return toPAR(resp.PreauthenticatedRequest, oc.Endpoint()), nil
|
return toPAR(resp.PreauthenticatedRequest, oc.Endpoint()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createPARDetails 集中组装 SDK 请求,避免 accessType 与桶列举权限组合回归。
|
||||||
|
func createPARDetails(in CreatePARInput, now time.Time) objectstorage.CreatePreauthenticatedRequestDetails {
|
||||||
|
hours := in.ExpiresHours
|
||||||
|
if hours <= 0 {
|
||||||
|
hours = 24
|
||||||
|
}
|
||||||
|
details := objectstorage.CreatePreauthenticatedRequestDetails{
|
||||||
|
Name: &in.Name,
|
||||||
|
AccessType: objectstorage.CreatePreauthenticatedRequestDetailsAccessTypeEnum(in.AccessType),
|
||||||
|
TimeExpires: &common.SDKTime{Time: now.Add(time.Duration(hours) * time.Hour)},
|
||||||
|
}
|
||||||
|
if in.ObjectName != "" {
|
||||||
|
details.ObjectName = &in.ObjectName
|
||||||
|
}
|
||||||
|
if in.AccessType == "AnyObjectRead" || in.AccessType == "AnyObjectReadWrite" {
|
||||||
|
details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
func toPAR(p objectstorage.PreauthenticatedRequest, endpoint string) PAR {
|
func toPAR(p objectstorage.PreauthenticatedRequest, endpoint string) PAR {
|
||||||
out := PAR{
|
out := PAR{
|
||||||
ID: deref(p.Id),
|
ID: deref(p.Id),
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package oci
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// namespace 为租户常量:缓存命中时直接返回,不构造客户端、不发起远程调用
|
// namespace 为租户常量:缓存命中时直接返回,不构造客户端、不发起远程调用
|
||||||
@@ -27,3 +30,47 @@ func TestGetObjectStorageNamespaceCached(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreatePARDetailsBucketListingAction(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
accessType string
|
||||||
|
wantList bool
|
||||||
|
}{
|
||||||
|
{"AnyObjectRead", true},
|
||||||
|
{"AnyObjectReadWrite", true},
|
||||||
|
{"AnyObjectWrite", false},
|
||||||
|
{"ObjectRead", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
details := createPARDetails(CreatePARInput{AccessType: tc.accessType, ExpiresHours: 1}, time.Unix(100, 0))
|
||||||
|
gotList := string(details.BucketListingAction) == "ListObjects"
|
||||||
|
if gotList != tc.wantList {
|
||||||
|
t.Errorf("%s listing = %q, wantList %v", tc.accessType, details.BucketListingAction, tc.wantList)
|
||||||
|
}
|
||||||
|
wire, err := json.Marshal(details)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal %s details: %v", tc.accessType, err)
|
||||||
|
}
|
||||||
|
gotWireList := strings.Contains(string(wire), `"bucketListingAction":"ListObjects"`)
|
||||||
|
if gotWireList != tc.wantList {
|
||||||
|
t.Errorf("%s wire = %s, wantList %v", tc.accessType, wire, tc.wantList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePARDetailsExpirationAndObject(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
|
||||||
|
details := createPARDetails(CreatePARInput{
|
||||||
|
Name: "share", ObjectName: "folder/a.txt", AccessType: "ObjectRead", ExpiresHours: 48,
|
||||||
|
}, now)
|
||||||
|
if got, want := details.TimeExpires.Time, now.Add(48*time.Hour); !got.Equal(want) {
|
||||||
|
t.Errorf("TimeExpires = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if details.ObjectName == nil || *details.ObjectName != "folder/a.txt" {
|
||||||
|
t.Errorf("ObjectName = %v, want folder/a.txt", details.ObjectName)
|
||||||
|
}
|
||||||
|
defaults := createPARDetails(CreatePARInput{AccessType: "ObjectRead"}, now)
|
||||||
|
if got, want := defaults.TimeExpires.Time, now.Add(24*time.Hour); !got.Equal(want) {
|
||||||
|
t.Errorf("default TimeExpires = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+11
-5
@@ -17,6 +17,9 @@ const ociConsolePolicyID = "OciConsolePolicy"
|
|||||||
// scimPatchSchema 是 SCIM PatchOp 的 schema。
|
// scimPatchSchema 是 SCIM PatchOp 的 schema。
|
||||||
const scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
const scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
||||||
|
|
||||||
|
// signOnConsentSchema 是修改 Oracle 预置 OCI Console sign-on 策略的知情同意扩展 schema。
|
||||||
|
const signOnConsentSchema = "urn:ietf:params:scim:schemas:oracle:idcs:extension:ociconsolesignonpolicyconsent:Policy"
|
||||||
|
|
||||||
// SignOnRuleInfo 是 sign-on 策略中一条规则的关键字段。
|
// SignOnRuleInfo 是 sign-on 策略中一条规则的关键字段。
|
||||||
type SignOnRuleInfo struct {
|
type SignOnRuleInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -220,15 +223,18 @@ func sortedRules(rules []identitydomains.PolicyRules) []identitydomains.PolicyRu
|
|||||||
|
|
||||||
func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient, rules []interface{}) error {
|
func patchPolicyRules(ctx context.Context, dc identitydomains.IdentityDomainsClient, rules []interface{}) error {
|
||||||
var value interface{} = rules
|
var value interface{} = rules
|
||||||
|
var consent interface{} = true
|
||||||
|
var justification interface{} = "MFA Configured in External IDP"
|
||||||
_, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{
|
_, err := dc.PatchPolicy(ctx, identitydomains.PatchPolicyRequest{
|
||||||
PolicyId: common.String(ociConsolePolicyID),
|
PolicyId: common.String(ociConsolePolicyID),
|
||||||
PatchOp: identitydomains.PatchOp{
|
PatchOp: identitydomains.PatchOp{
|
||||||
Schemas: []string{scimPatchSchema},
|
Schemas: []string{scimPatchSchema},
|
||||||
Operations: []identitydomains.Operations{{
|
// 改动 Oracle 预置策略必须附带知情同意,否则 400 Missing required attribute(s): consent
|
||||||
Op: identitydomains.OperationsOpReplace,
|
Operations: []identitydomains.Operations{
|
||||||
Path: common.String("rules"),
|
{Op: identitydomains.OperationsOpReplace, Path: common.String("rules"), Value: &value},
|
||||||
Value: &value,
|
{Op: identitydomains.OperationsOpAdd, Path: common.String(signOnConsentSchema + ":consent"), Value: &consent},
|
||||||
}},
|
{Op: identitydomains.OperationsOpAdd, Path: common.String(signOnConsentSchema + ":justification"), Value: &justification},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -760,6 +760,66 @@ func (c *RealClient) DeleteTenantUserApiKeys(ctx context.Context, cred Credentia
|
|||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TenantUserApiKey 是用户名下的一把 API 签名 key。
|
||||||
|
type TenantUserApiKey struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
TimeCreated *time.Time `json:"timeCreated"`
|
||||||
|
// IsCurrent 表示该 key 正被当前配置用于签名请求
|
||||||
|
IsCurrent bool `json:"isCurrent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTenantUserApiKeys 实现 Client:列出用户的 API 签名 key。
|
||||||
|
func (c *RealClient) ListTenantUserApiKeys(ctx context.Context, cred Credentials, homeRegion, userID string) ([]TenantUserApiKey, error) {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := ic.ListApiKeys(ctx, identity.ListApiKeysRequest{UserId: &userID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list api keys: %w", err)
|
||||||
|
}
|
||||||
|
keys := make([]TenantUserApiKey, 0, len(resp.Items))
|
||||||
|
for _, item := range resp.Items {
|
||||||
|
k := TenantUserApiKey{Fingerprint: deref(item.Fingerprint)}
|
||||||
|
if item.TimeCreated != nil {
|
||||||
|
t := item.TimeCreated.Time
|
||||||
|
k.TimeCreated = &t
|
||||||
|
}
|
||||||
|
k.IsCurrent = userID == cred.UserOCID && k.Fingerprint == cred.Fingerprint
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadTenantUserApiKey 实现 Client:为用户上传 RSA 公钥,返回 OCI 回填的指纹。
|
||||||
|
func (c *RealClient) UploadTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, publicKeyPEM string) (string, error) {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := ic.UploadApiKey(ctx, identity.UploadApiKeyRequest{
|
||||||
|
UserId: &userID,
|
||||||
|
CreateApiKeyDetails: identity.CreateApiKeyDetails{Key: &publicKeyPEM},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("upload api key: %w", err)
|
||||||
|
}
|
||||||
|
return deref(resp.Fingerprint), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTenantUserApiKey 实现 Client:按指纹删除用户的单把 API key。
|
||||||
|
func (c *RealClient) DeleteTenantUserApiKey(ctx context.Context, cred Credentials, homeRegion, userID, fingerprint string) error {
|
||||||
|
ic, err := c.identityClientAt(cred, homeRegion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = ic.DeleteApiKey(ctx, identity.DeleteApiKeyRequest{UserId: &userID, Fingerprint: &fingerprint})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete api key %s: %w", fingerprint, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。
|
// scimUserID 用经典 OCID 在 Identity Domain 中解析 SCIM 用户 ID。
|
||||||
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
|
func scimUserID(ctx context.Context, dc identitydomains.IdentityDomainsClient, classicOCID string) (string, error) {
|
||||||
u, found, err := domainUserByOcid(ctx, dc, classicOCID)
|
u, found, err := domainUserByOcid(ctx, dc, classicOCID)
|
||||||
|
|||||||
@@ -1048,6 +1048,10 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
|
|||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
on, err := contentLogStillOn(tx, entry.KeyID)
|
||||||
|
if err != nil || !on {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Create(&entry).Error
|
return tx.Create(&entry).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1055,6 +1059,20 @@ func (s *AiGatewayService) LogContent(entry model.AiContentLog) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// contentLogStillOn 写入事务内回读密钥:已删除、被停用或日志窗口已过期时放弃写入,
|
||||||
|
// 防止管理员「立即关闭」后,在途长请求仍按鉴权时的旧快照把敏感正文落库。
|
||||||
|
func contentLogStillOn(tx *gorm.DB, keyID uint) (bool, error) {
|
||||||
|
var key model.AiKey
|
||||||
|
err := tx.First(&key, keyID).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return key.Enabled && key.ContentLogUntil != nil && time.Now().Before(*key.ContentLogUntil), nil
|
||||||
|
}
|
||||||
|
|
||||||
func truncateBody(s string) string {
|
func truncateBody(s string) string {
|
||||||
if len(s) > aiContentBodyLimit {
|
if len(s) > aiContentBodyLimit {
|
||||||
return s[:aiContentBodyLimit]
|
return s[:aiContentBodyLimit]
|
||||||
@@ -1110,7 +1128,12 @@ func (s *AiGatewayService) cleanupOnce(ctx context.Context) {
|
|||||||
s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log")
|
s.cleanupTable(ctx, &model.AiContentLog{}, aiContentLogRetention, aiContentLogMaxRows, "ai content log")
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧)。
|
// cleanupBatch 是超限清理每轮删除的行数上限;远小于各数据库绑定变量硬上限
|
||||||
|
// (modernc SQLite 32766、MySQL/PG 65535),跨库安全。var 供测试注入小批次。
|
||||||
|
var cleanupBatch = 10000
|
||||||
|
|
||||||
|
// cleanupTable 按保留期与行数上限清理日志表(超限删最旧,固定批次循环,
|
||||||
|
// 任一批失败必须记日志并中断,静默失败会让日志表无界增长)。
|
||||||
func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) {
|
func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention time.Duration, maxRows int, tag string) {
|
||||||
cutoff := time.Now().Add(-retention)
|
cutoff := time.Now().Add(-retention)
|
||||||
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil {
|
if err := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(m).Error; err != nil {
|
||||||
@@ -1119,16 +1142,36 @@ func (s *AiGatewayService) cleanupTable(ctx context.Context, m any, retention ti
|
|||||||
}
|
}
|
||||||
var total int64
|
var total int64
|
||||||
if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil {
|
if err := s.db.WithContext(ctx).Model(m).Count(&total).Error; err != nil {
|
||||||
|
log.Printf("%s cleanup count: %v", tag, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if overflow := int(total) - maxRows; overflow > 0 {
|
for overflow := int(total) - maxRows; overflow > 0; {
|
||||||
var ids []uint
|
n, err := s.deleteOldestBatch(ctx, m, min(overflow, cleanupBatch))
|
||||||
s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(overflow).Pluck("id", &ids)
|
if err != nil {
|
||||||
if len(ids) > 0 {
|
log.Printf("%s cleanup overflow: %v", tag, err)
|
||||||
s.db.WithContext(ctx).Delete(m, ids)
|
return
|
||||||
}
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
overflow -= n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteOldestBatch 删除表内最旧的至多 limit 行,返回实际删除数。
|
||||||
|
func (s *AiGatewayService) deleteOldestBatch(ctx context.Context, m any, limit int) (int, error) {
|
||||||
|
var ids []uint
|
||||||
|
if err := s.db.WithContext(ctx).Model(m).Order("id ASC").Limit(limit).Pluck("id", &ids).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("pluck oldest: %w", err)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Delete(m, ids).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("delete batch: %w", err)
|
||||||
|
}
|
||||||
|
return len(ids), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Wait 等待后台清理 goroutine 退出。
|
// Wait 等待后台清理 goroutine 退出。
|
||||||
func (s *AiGatewayService) Wait() { s.wg.Wait() }
|
func (s *AiGatewayService) Wait() { s.wg.Wait() }
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"oci-portal/internal/aiwire"
|
"oci-portal/internal/aiwire"
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
|
// TestSpeechBodyNormalize 断言 TTS 请求体校验与 language 缺省注入。
|
||||||
@@ -220,3 +222,71 @@ func TestAiModerations(t *testing.T) {
|
|||||||
t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err)
|
t.Errorf("ghost 分组 err = %v, want ErrAiNoChannel", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCleanupTableBatchesOverflow 锁定超限清理的固定批次行为:
|
||||||
|
// 溢出量大于单批上限时分多轮删完,且始终删最旧的行。
|
||||||
|
func TestCleanupTableBatchesOverflow(t *testing.T) {
|
||||||
|
gw, _ := newTestGateway(t, &fakeClient{})
|
||||||
|
old := cleanupBatch
|
||||||
|
cleanupBatch = 3
|
||||||
|
t.Cleanup(func() { cleanupBatch = old })
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if err := gw.db.Create(&model.AiCallLog{Endpoint: "chat"}).Error; err != nil {
|
||||||
|
t.Fatalf("seed %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// maxRows=2:溢出 8 行,需 3 轮批次(3+3+2)
|
||||||
|
gw.cleanupTable(context.Background(), &model.AiCallLog{}, 24*365*time.Hour, 2, "test")
|
||||||
|
var rest []model.AiCallLog
|
||||||
|
if err := gw.db.Order("id").Find(&rest).Error; err != nil {
|
||||||
|
t.Fatalf("load rest: %v", err)
|
||||||
|
}
|
||||||
|
if len(rest) != 2 {
|
||||||
|
t.Fatalf("remaining = %d, want 2", len(rest))
|
||||||
|
}
|
||||||
|
for _, row := range rest {
|
||||||
|
if row.ID <= 8 {
|
||||||
|
t.Errorf("row %d survived, want oldest deleted first", row.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogContentRechecksKeyInsideTx 锁定「立即关闭」语义:写入事务内回读密钥,
|
||||||
|
// 窗口已关、密钥停用或已删时,鉴权期的旧快照不得再落敏感正文。
|
||||||
|
func TestLogContentRechecksKeyInsideTx(t *testing.T) {
|
||||||
|
gw, _ := newTestGateway(t, &fakeClient{})
|
||||||
|
if err := gw.db.AutoMigrate(&model.AiContentLog{}); err != nil {
|
||||||
|
t.Fatalf("migrate content log: %v", err)
|
||||||
|
}
|
||||||
|
future := time.Now().Add(time.Hour)
|
||||||
|
key := model.AiKey{Name: "k", KeyHash: "h", Enabled: true, ContentLogUntil: &future}
|
||||||
|
call := model.AiCallLog{Endpoint: "chat"}
|
||||||
|
if err := gw.db.Create(&key).Error; err != nil {
|
||||||
|
t.Fatalf("seed key: %v", err)
|
||||||
|
}
|
||||||
|
if err := gw.db.Create(&call).Error; err != nil {
|
||||||
|
t.Fatalf("seed call: %v", err)
|
||||||
|
}
|
||||||
|
countIs := func(want int64, note string) {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := gw.db.Model(&model.AiContentLog{}).Count(&n).Error; err != nil || n != want {
|
||||||
|
t.Fatalf("%s: count=%d (%v), want %d", note, n, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "prompt"})
|
||||||
|
countIs(1, "窗口开启时应写入")
|
||||||
|
// 管理员立即关闭窗口:在途请求携带的旧快照不得再写
|
||||||
|
if err := gw.db.Model(&model.AiKey{}).Where("id = ?", key.ID).
|
||||||
|
Update("content_log_until", gorm.Expr("NULL")).Error; err != nil {
|
||||||
|
t.Fatalf("close window: %v", err)
|
||||||
|
}
|
||||||
|
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "late"})
|
||||||
|
countIs(1, "窗口关闭后不得写入")
|
||||||
|
// 密钥删除后同样拒写
|
||||||
|
if err := gw.db.Delete(&model.AiKey{}, key.ID).Error; err != nil {
|
||||||
|
t.Fatalf("delete key: %v", err)
|
||||||
|
}
|
||||||
|
gw.LogContent(model.AiContentLog{CallLogID: call.ID, KeyID: key.ID, RequestBody: "orphan"})
|
||||||
|
countIs(1, "密钥已删后不得写入")
|
||||||
|
}
|
||||||
|
|||||||
+97
-32
@@ -39,13 +39,19 @@ type authClaims struct {
|
|||||||
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
|
// 用户不存在分支比对它以对齐耗时,防用户枚举与时序侧信道。
|
||||||
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
|
const dummyBcryptHash = "$2a$10$N9qo8uLOickgx2ZMRZoMye3xW1Wq8p1zEIfQpXCXbXyE3xY5C6P6W"
|
||||||
|
|
||||||
// AuthService 负责账号初始化、登录校验(密码 + 可选 TOTP)和 JWT 签发与验证。
|
// AuthService 负责账号初始化、登录校验(密码 + 可选 TOTP)、JWT 签发与验证,
|
||||||
|
// 以及会话落地与管理(session.go)。
|
||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
jwtSecret []byte
|
jwtSecret []byte
|
||||||
guard *loginGuard
|
guard *loginGuard
|
||||||
// revoked 是登出令牌黑名单(哈希→占位),TTL 对齐令牌剩余有效期,过期自动清出
|
// revoked 是登出令牌黑名单(哈希→占位),TTL 对齐令牌剩余有效期,过期自动清出
|
||||||
revoked *cache.Cache
|
revoked *cache.Cache
|
||||||
|
// seen 是会话最后活跃回写的节流缓存(jti→占位);撤销时清键保证即时生效
|
||||||
|
seen *cache.Cache
|
||||||
|
// revokedJti 是定点撤销的负缓存:无容量上限的专用结构,
|
||||||
|
// 既不与高频 seen 回写争容量,也不存在满载淘汰导致的令牌复活
|
||||||
|
revokedJti *jtiTombstones
|
||||||
|
|
||||||
notifier *Notifier
|
notifier *Notifier
|
||||||
settings *SettingService
|
settings *SettingService
|
||||||
@@ -53,6 +59,9 @@ type AuthService struct {
|
|||||||
|
|
||||||
totpMu sync.Mutex
|
totpMu sync.Mutex
|
||||||
totpPending map[string]pendingTotp // username → setup 暂存密钥
|
totpPending map[string]pendingTotp // username → setup 暂存密钥
|
||||||
|
|
||||||
|
// cleanupWG 追踪会话清理 goroutine;关停时 Wait 保证清理查询已结束
|
||||||
|
cleanupWG sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthService 组装依赖。
|
// NewAuthService 组装依赖。
|
||||||
@@ -61,6 +70,8 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
|
|||||||
db: db, jwtSecret: []byte(jwtSecret),
|
db: db, jwtSecret: []byte(jwtSecret),
|
||||||
guard: newLoginGuard(),
|
guard: newLoginGuard(),
|
||||||
revoked: cache.New(4096),
|
revoked: cache.New(4096),
|
||||||
|
seen: cache.New(4096),
|
||||||
|
revokedJti: newJtiTombstones(),
|
||||||
totpPending: map[string]pendingTotp{},
|
totpPending: map[string]pendingTotp{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,11 +120,12 @@ func (s *AuthService) createUser(username, password string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 校验用户名密码与可选 TOTP,成功后签发 JWT;按「IP+用户名」滑动窗口防爆破,
|
// Login 校验用户名密码与可选 TOTP,成功后签发 JWT 并落地会话;
|
||||||
// 锁定期内一律 ErrLoginLocked(正确密码同样拒绝);阈值与时长取安全设置。
|
// 按「IP+用户名」滑动窗口防爆破,锁定期内一律 ErrLoginLocked(正确密码同样拒绝);
|
||||||
// 已启用两步验证时:密码通过但缺验证码返回 ErrTotpRequired(不计失败),验证码错误计入守卫。
|
// 阈值与时长取安全设置。已启用两步验证时:密码通过但缺验证码返回
|
||||||
func (s *AuthService) Login(ctx context.Context, username, password, clientIP, totpCode string) (string, time.Time, error) {
|
// ErrTotpRequired(不计失败),验证码错误计入守卫。
|
||||||
key := guardKey(clientIP, username)
|
func (s *AuthService) Login(ctx context.Context, username, password, totpCode string, meta SessionMeta) (string, time.Time, error) {
|
||||||
|
key := guardKey(meta.ClientIP, username)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
sec := securityOf(s.settings)
|
sec := securityOf(s.settings)
|
||||||
lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute
|
lockFor := time.Duration(sec.LoginLockMinutes) * time.Minute
|
||||||
@@ -129,23 +141,24 @@ func (s *AuthService) Login(ctx context.Context, username, password, clientIP, t
|
|||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
// 与密码错误分支对齐耗时,防用户枚举
|
// 与密码错误分支对齐耗时,防用户枚举
|
||||||
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
|
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password))
|
||||||
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
|
return "", time.Time{}, s.failLogin(key, now, username, meta.ClientIP, sec)
|
||||||
}
|
}
|
||||||
return "", time.Time{}, fmt.Errorf("find user: %w", err)
|
return "", time.Time{}, fmt.Errorf("find user: %w", err)
|
||||||
}
|
}
|
||||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
||||||
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
|
return "", time.Time{}, s.failLogin(key, now, username, meta.ClientIP, sec)
|
||||||
}
|
}
|
||||||
if user.TotpSecretEnc != "" {
|
if user.TotpSecretEnc != "" {
|
||||||
if totpCode == "" {
|
if totpCode == "" {
|
||||||
return "", time.Time{}, ErrTotpRequired
|
return "", time.Time{}, ErrTotpRequired
|
||||||
}
|
}
|
||||||
if !s.verifyTotp(&user, totpCode) {
|
if !s.verifyTotp(&user, totpCode) {
|
||||||
return "", time.Time{}, s.failLogin(key, now, username, clientIP, sec)
|
return "", time.Time{}, s.failLogin(key, now, username, meta.ClientIP, sec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.guard.success(key)
|
s.guard.success(key)
|
||||||
return s.signToken(user.Username, user.TokenVersion)
|
meta.Method = "password"
|
||||||
|
return s.signSessionToken(ctx, &user, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
// failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。
|
// failLogin 记失败;达到阈值转锁定并推送告警(开关 login_lock,缺省开)。
|
||||||
@@ -176,7 +189,13 @@ func (s *AuthService) notifyLock(username, clientIP string, sec SecuritySettings
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) signToken(username string, ver uint) (string, time.Time, error) {
|
// signToken 签发 JWT,返回令牌、过期时间与 jti(会话落地用)。
|
||||||
|
func (s *AuthService) signToken(username string, ver uint) (string, time.Time, string, error) {
|
||||||
|
return s.signTokenWithJTI(username, ver, newTokenID())
|
||||||
|
}
|
||||||
|
|
||||||
|
// signTokenWithJTI 为敏感换发沿用会话 jti,使旧/新 JWT 始终指向同一会话。
|
||||||
|
func (s *AuthService) signTokenWithJTI(username string, ver uint, jti string) (string, time.Time, string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
expires := now.Add(tokenTTL)
|
expires := now.Add(tokenTTL)
|
||||||
claims := authClaims{
|
claims := authClaims{
|
||||||
@@ -184,31 +203,37 @@ func (s *AuthService) signToken(username string, ver uint) (string, time.Time, e
|
|||||||
Subject: username,
|
Subject: username,
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
ExpiresAt: jwt.NewNumericDate(expires),
|
ExpiresAt: jwt.NewNumericDate(expires),
|
||||||
// jti:同一秒签发的令牌若无唯一 ID 字节全同,登出一个会连坐全部
|
ID: jti,
|
||||||
ID: newTokenID(),
|
|
||||||
},
|
},
|
||||||
Ver: ver,
|
Ver: ver,
|
||||||
}
|
}
|
||||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", time.Time{}, fmt.Errorf("sign token: %w", err)
|
return "", time.Time{}, "", fmt.Errorf("sign token: %w", err)
|
||||||
}
|
}
|
||||||
return token, expires, nil
|
return token, expires, jti, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssueToken 按账号当前令牌版本签发新 JWT;敏感操作递增版本后用它为
|
// IssueToken 按账号当前令牌版本签发新 JWT(不落会话行);
|
||||||
// 操作者重签,避免操作者自身会话中断。
|
// 需要会话接续的换发场景用 RenewToken。
|
||||||
func (s *AuthService) IssueToken(ctx context.Context, username string) (string, time.Time, error) {
|
func (s *AuthService) IssueToken(ctx context.Context, username string) (string, time.Time, error) {
|
||||||
user, err := s.findUser(ctx, username)
|
user, err := s.findUser(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", time.Time{}, err
|
return "", time.Time{}, err
|
||||||
}
|
}
|
||||||
return s.signToken(user.Username, user.TokenVersion)
|
token, expires, _, err := s.signToken(user.Username, user.TokenVersion)
|
||||||
|
return token, expires, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// bumpTokenVersion 原子递增账号令牌版本,使所有已签发令牌立即失效。
|
// bumpTokenVersion 原子递增账号令牌版本,使所有已签发令牌立即失效。
|
||||||
func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) error {
|
func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) error {
|
||||||
err := s.db.WithContext(ctx).Model(&model.User{}).Where("username = ?", username).
|
return bumpTokenVersionTx(s.db.WithContext(ctx), username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bumpTokenVersionTx 事务内递增令牌版本:与认证因子写入同事务提交,
|
||||||
|
// 避免「因子已生效而旧令牌仍有效」的半程状态。
|
||||||
|
func bumpTokenVersionTx(tx *gorm.DB, username string) error {
|
||||||
|
err := tx.Model(&model.User{}).Where("username = ?", username).
|
||||||
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("bump token version: %w", err)
|
return fmt.Errorf("bump token version: %w", err)
|
||||||
@@ -216,12 +241,28 @@ func (s *AuthService) bumpTokenVersion(ctx context.Context, username string) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeSessions 撤销账号全部会话(版本递增),并为操作者重签新令牌。
|
// RevokeSessions 撤销账号全部会话(版本递增),并为操作者重签新令牌;
|
||||||
func (s *AuthService) RevokeSessions(ctx context.Context, username string) (string, time.Time, error) {
|
// oldToken 非空时其会话行接续到新令牌(当前设备在列表中保持连续)。
|
||||||
if err := s.bumpTokenVersion(ctx, username); err != nil {
|
// 行锁下复核 proof:请求挂起期间令牌已失效则拒绝,并发敏感操作后到者拒。
|
||||||
return "", time.Time{}, err
|
func (s *AuthService) RevokeSessions(ctx context.Context, username, oldToken string, meta SessionMeta, proof TokenProof) (string, time.Time, error) {
|
||||||
}
|
var token string
|
||||||
return s.IssueToken(ctx, username)
|
var expires time.Time
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserForAuthChange(tx, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.ensureTokenCurrentTx(tx, user, proof); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bumpTokenVersionTx(tx, username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.TokenVersion++
|
||||||
|
token, expires, err = s.renewSessionTx(tx, user, oldToken, meta)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return token, expires, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTokenID 生成 128 位随机令牌 ID(crypto/rand 自 Go 1.24 起不会失败)。
|
// newTokenID 生成 128 位随机令牌 ID(crypto/rand 自 Go 1.24 起不会失败)。
|
||||||
@@ -231,9 +272,28 @@ func newTokenID() string {
|
|||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TokenProof 是鉴权时观察到的令牌快照(版本 + jti):敏感事务在用户行锁下
|
||||||
|
// 复核它仍是账号当前状态,防「请求挂起期间用户撤销 / 注销,恢复后仍完成
|
||||||
|
// 敏感变更并换出新令牌」的在途绕过。
|
||||||
|
type TokenProof struct {
|
||||||
|
Ver uint
|
||||||
|
Jti string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTokenStale 表示请求令牌在处理期间已失效(版本已递增或会话已撤销);
|
||||||
|
// api 层映射 401,前端按「发送时令牌 == 当前令牌」决定是否登出。
|
||||||
|
var ErrTokenStale = errors.New("会话已失效,请重新登录")
|
||||||
|
|
||||||
// ParseToken 验证 JWT 签名、有效期与令牌版本,返回其中的用户名。
|
// ParseToken 验证 JWT 签名、有效期与令牌版本,返回其中的用户名。
|
||||||
// 版本落后于账号当前值(凭据等已变更)按无效处理,不区分具体原因。
|
// 版本落后于账号当前值(凭据等已变更)按无效处理,不区分具体原因。
|
||||||
func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (string, error) {
|
func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (string, error) {
|
||||||
|
username, _, err := s.ParseTokenProof(ctx, tokenString)
|
||||||
|
return username, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTokenProof 验证令牌并返回用户名与版本 / jti 快照(RequireAuth 与
|
||||||
|
// 绑定发起点用;快照随后交由敏感事务复核)。
|
||||||
|
func (s *AuthService) ParseTokenProof(ctx context.Context, tokenString string) (string, TokenProof, error) {
|
||||||
claims := &authClaims{}
|
claims := &authClaims{}
|
||||||
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
@@ -242,23 +302,27 @@ func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (strin
|
|||||||
return s.jwtSecret, nil
|
return s.jwtSecret, nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse token: %w", err)
|
return "", TokenProof{}, fmt.Errorf("parse token: %w", err)
|
||||||
}
|
}
|
||||||
if _, hit := s.revoked.Get(tokenHash(tokenString)); hit {
|
if _, hit := s.revoked.Get(tokenHash(tokenString)); hit {
|
||||||
return "", errors.New("token revoked")
|
return "", TokenProof{}, errors.New("token revoked")
|
||||||
}
|
}
|
||||||
user, err := s.findUser(ctx, claims.Subject)
|
user, err := s.findUser(ctx, claims.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("token subject: %w", err)
|
return "", TokenProof{}, fmt.Errorf("token subject: %w", err)
|
||||||
}
|
}
|
||||||
if claims.Ver != user.TokenVersion {
|
if claims.Ver != user.TokenVersion {
|
||||||
return "", errors.New("token version outdated")
|
return "", TokenProof{}, errors.New("token version outdated")
|
||||||
}
|
}
|
||||||
return claims.Subject, nil
|
if err := s.checkSession(ctx, claims.ID); err != nil {
|
||||||
|
return "", TokenProof{}, err
|
||||||
|
}
|
||||||
|
return claims.Subject, TokenProof{Ver: claims.Ver, Jti: claims.ID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout 把令牌拉进黑名单直至其自然过期;无效/已过期令牌直接视为成功(幂等)。
|
// Logout 把令牌拉进黑名单直至其自然过期,并标记对应会话行已撤销;
|
||||||
func (s *AuthService) Logout(tokenString string) {
|
// 无效/已过期令牌直接视为成功(幂等)。
|
||||||
|
func (s *AuthService) Logout(ctx context.Context, tokenString string) {
|
||||||
claims := &jwt.RegisteredClaims{}
|
claims := &jwt.RegisteredClaims{}
|
||||||
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
||||||
return s.jwtSecret, nil
|
return s.jwtSecret, nil
|
||||||
@@ -271,6 +335,7 @@ func (s *AuthService) Logout(tokenString string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.revoked.Set(tokenHash(tokenString), struct{}{}, ttl)
|
s.revoked.Set(tokenHash(tokenString), struct{}{}, ttl)
|
||||||
|
s.revokeSessionByJTI(ctx, claims.Subject, claims.ID, ttl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenHash 取令牌 SHA-256 摘要作黑名单键,不在内存长期保留原令牌串。
|
// tokenHash 取令牌 SHA-256 摘要作黑名单键,不在内存长期保留原令牌串。
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func newTestAuth(t *testing.T) *AuthService {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open in-memory sqlite: %v", err)
|
t.Fatalf("open in-memory sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.UserSession{}); err != nil {
|
||||||
t.Fatalf("auto migrate: %v", err)
|
t.Fatalf("auto migrate: %v", err)
|
||||||
}
|
}
|
||||||
return NewAuthService(db, "test-jwt-secret")
|
return NewAuthService(db, "test-jwt-secret")
|
||||||
@@ -32,7 +32,7 @@ func TestEnsureAdminCreatesUser(t *testing.T) {
|
|||||||
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
t.Fatalf("EnsureAdmin: %v", err)
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", ""); err != nil {
|
if _, _, err := auth.Login(context.Background(), "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"}); err != nil {
|
||||||
t.Errorf("Login after EnsureAdmin: %v", err)
|
t.Errorf("Login after EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,10 +45,10 @@ func TestEnsureAdminDoesNotResetPassword(t *testing.T) {
|
|||||||
if err := auth.EnsureAdmin("admin", "second"); err != nil {
|
if err := auth.EnsureAdmin("admin", "second"); err != nil {
|
||||||
t.Fatalf("EnsureAdmin twice: %v", err)
|
t.Fatalf("EnsureAdmin twice: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, err := auth.Login(context.Background(), "admin", "first", "127.0.0.1", ""); err != nil {
|
if _, _, err := auth.Login(context.Background(), "admin", "first", "", SessionMeta{ClientIP: "127.0.0.1"}); err != nil {
|
||||||
t.Errorf("Login with original password: %v", err)
|
t.Errorf("Login with original password: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, err := auth.Login(context.Background(), "admin", "second", "127.0.0.1", ""); !errors.Is(err, ErrInvalidCredentials) {
|
if _, _, err := auth.Login(context.Background(), "admin", "second", "", SessionMeta{ClientIP: "127.0.0.1"}); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
t.Errorf("Login with new password: got %v, want ErrInvalidCredentials", err)
|
t.Errorf("Login with new password: got %v, want ErrInvalidCredentials", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func TestLoginFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
_, _, err := auth.Login(context.Background(), tt.username, tt.password, "127.0.0.1", "")
|
_, _, err := auth.Login(context.Background(), tt.username, tt.password, "", SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if !errors.Is(err, ErrInvalidCredentials) {
|
if !errors.Is(err, ErrInvalidCredentials) {
|
||||||
t.Errorf("Login(%q, %q) error = %v, want ErrInvalidCredentials", tt.username, tt.password, err)
|
t.Errorf("Login(%q, %q) error = %v, want ErrInvalidCredentials", tt.username, tt.password, err)
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ func TestTokenRoundTrip(t *testing.T) {
|
|||||||
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
t.Fatalf("EnsureAdmin: %v", err)
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
token, expires, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
token, expires, err := auth.Login(context.Background(), "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Login: %v", err)
|
t.Fatalf("Login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ func TestParseTokenRejectsForged(t *testing.T) {
|
|||||||
if err := other.EnsureAdmin("admin", "pass"); err != nil {
|
if err := other.EnsureAdmin("admin", "pass"); err != nil {
|
||||||
t.Fatalf("EnsureAdmin: %v", err)
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
forged, _, err := other.Login(context.Background(), "admin", "pass", "127.0.0.1", "")
|
forged, _, err := other.Login(context.Background(), "admin", "pass", "", SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Login: %v", err)
|
t.Fatalf("Login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -143,21 +143,21 @@ func TestLogoutRevokesToken(t *testing.T) {
|
|||||||
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
t.Fatalf("EnsureAdmin: %v", err)
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
token, _, err := auth.signToken("admin", 0)
|
token, _, _, err := auth.signToken("admin", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("signToken: %v", err)
|
t.Fatalf("signToken: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := auth.ParseToken(context.Background(), token); err != nil {
|
if _, err := auth.ParseToken(context.Background(), token); err != nil {
|
||||||
t.Fatalf("ParseToken before logout: %v", err)
|
t.Fatalf("ParseToken before logout: %v", err)
|
||||||
}
|
}
|
||||||
auth.Logout(token)
|
auth.Logout(context.Background(), token)
|
||||||
if _, err := auth.ParseToken(context.Background(), token); err == nil {
|
if _, err := auth.ParseToken(context.Background(), token); err == nil {
|
||||||
t.Error("ParseToken after logout: got nil error, want revoked")
|
t.Error("ParseToken after logout: got nil error, want revoked")
|
||||||
}
|
}
|
||||||
// 幂等:重复登出与无效令牌登出都不应 panic,也不影响其他令牌
|
// 幂等:重复登出与无效令牌登出都不应 panic,也不影响其他令牌
|
||||||
auth.Logout(token)
|
auth.Logout(context.Background(), token)
|
||||||
auth.Logout("not.a.token")
|
auth.Logout(context.Background(), "not.a.token")
|
||||||
fresh, _, err := auth.signToken("admin", 0)
|
fresh, _, _, err := auth.signToken("admin", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("signToken fresh: %v", err)
|
t.Fatalf("signToken fresh: %v", err)
|
||||||
}
|
}
|
||||||
@@ -174,13 +174,13 @@ func TestTokenVersionInvalidatesOldToken(t *testing.T) {
|
|||||||
t.Fatalf("EnsureAdmin: %v", err)
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
old, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", "")
|
old, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Login: %v", err)
|
t.Fatalf("Login: %v", err)
|
||||||
}
|
}
|
||||||
finalName, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
|
finalName, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
|
||||||
NewPassword: "changed-456", CurrentPassword: "pass123",
|
NewPassword: "changed-456", CurrentPassword: "pass123",
|
||||||
})
|
}, proofOf(t, auth.db, "admin"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpdateCredentials: %v", err)
|
t.Fatalf("UpdateCredentials: %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,11 +203,11 @@ func TestRevokeSessions(t *testing.T) {
|
|||||||
t.Fatalf("EnsureAdmin: %v", err)
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
old, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", "")
|
old, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Login: %v", err)
|
t.Fatalf("Login: %v", err)
|
||||||
}
|
}
|
||||||
fresh, _, err := auth.RevokeSessions(ctx, "admin")
|
fresh, _, err := auth.RevokeSessions(ctx, "admin", "", SessionMeta{}, proofOf(t, auth.db, "admin"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RevokeSessions: %v", err)
|
t.Fatalf("RevokeSessions: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+255
-24
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
)
|
)
|
||||||
@@ -19,13 +20,16 @@ var ErrCredentialConfirm = errors.New("当前密码不正确")
|
|||||||
var ErrCredentialInvalid = errors.New("凭据输入非法")
|
var ErrCredentialInvalid = errors.New("凭据输入非法")
|
||||||
|
|
||||||
// ErrPasswordLoginDisabled 表示密码登录已被禁用;api 层映射 403。
|
// ErrPasswordLoginDisabled 表示密码登录已被禁用;api 层映射 403。
|
||||||
var ErrPasswordLoginDisabled = errors.New("密码登录已禁用,请使用外部身份登录")
|
var ErrPasswordLoginDisabled = errors.New("密码登录已禁用,请使用免密方式登录")
|
||||||
|
|
||||||
// ErrNeedIdentity 表示未绑定外部身份不可禁用密码登录;api 层映射 409。
|
// ErrNeedIdentity 表示无任何可用免密登录方式时不可禁用密码登录;api 层映射 409。
|
||||||
var ErrNeedIdentity = errors.New("至少绑定一个外部身份后才能禁用密码登录")
|
var ErrNeedIdentity = errors.New("需先有可用的免密登录方式(通行密钥、钱包或已启用的外部登录),才能禁用密码登录")
|
||||||
|
|
||||||
// ErrLastIdentity 表示密码登录禁用期间不可解绑最后一个身份(防自锁);api 层映射 409。
|
// ErrProviderLastLogin 表示密码登录禁用期间不可禁用/清空最后可用的登录方式;api 层映射 409。
|
||||||
var ErrLastIdentity = errors.New("密码登录已禁用,不能解绑最后一个外部身份;请先允许密码登录")
|
var ErrProviderLastLogin = errors.New("密码登录已禁用,该操作将移除最后可用的登录方式;请先允许密码登录")
|
||||||
|
|
||||||
|
// ErrLastIdentity 表示密码登录禁用期间不可移除最后一个免密登录方式(防自锁);api 层映射 409。
|
||||||
|
var ErrLastIdentity = errors.New("密码登录已禁用,不能移除最后一个免密登录方式;请先允许密码登录")
|
||||||
|
|
||||||
// UpdateCredentialsInput 是修改登录凭据的请求体;两项至少改一项,当前密码必填。
|
// UpdateCredentialsInput 是修改登录凭据的请求体;两项至少改一项,当前密码必填。
|
||||||
type UpdateCredentialsInput struct {
|
type UpdateCredentialsInput struct {
|
||||||
@@ -34,9 +38,23 @@ type UpdateCredentialsInput struct {
|
|||||||
CurrentPassword string `json:"currentPassword" binding:"required"`
|
CurrentPassword string `json:"currentPassword" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type authenticatedMutation struct {
|
||||||
|
auth *AuthService
|
||||||
|
username string
|
||||||
|
proof TokenProof
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *authenticatedMutation) lockAndCheck(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserForAuthChange(tx, m.username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.auth.ensureTokenCurrentTx(tx, user, m.proof)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateCredentials 修改用户名 / 密码:当前密码必验;成功后令牌版本递增
|
// UpdateCredentials 修改用户名 / 密码:当前密码必验;成功后令牌版本递增
|
||||||
// (全部旧 JWT 立即失效),返回最终用户名供调用方为操作者重签新令牌。
|
// (全部旧 JWT 立即失效),返回最终用户名供调用方为操作者重签新令牌。
|
||||||
func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in UpdateCredentialsInput) (string, error) {
|
func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in UpdateCredentialsInput, proof TokenProof) (string, error) {
|
||||||
user, err := s.findUser(ctx, username)
|
user, err := s.findUser(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -52,14 +70,40 @@ func (s *AuthService) UpdateCredentials(ctx context.Context, username string, in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
// 同一条 UPDATE 里递增令牌版本,与凭据变更保持原子
|
|
||||||
updates["token_version"] = gorm.Expr("token_version + 1")
|
updates["token_version"] = gorm.Expr("token_version + 1")
|
||||||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
if err := s.applyCredentialUpdates(ctx, username, proof, updates); err != nil {
|
||||||
return "", fmt.Errorf("update credentials: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
return finalName, nil
|
return finalName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) applyCredentialUpdates(
|
||||||
|
ctx context.Context, username string, proof TokenProof, updates map[string]any,
|
||||||
|
) error {
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
locked, err := lockUserForAuthChange(tx, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.ensureTokenCurrentTx(tx, locked, proof); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return updateCredentialsTx(tx, locked.ID, proof.Ver, updates)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCredentialsTx(tx *gorm.DB, userID uint, version uint, updates map[string]any) error {
|
||||||
|
res := tx.Model(&model.User{}).
|
||||||
|
Where("id = ? AND token_version = ?", userID, version).Updates(updates)
|
||||||
|
if res.Error != nil {
|
||||||
|
return fmt.Errorf("update credentials: %w", res.Error)
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrTokenStale
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// credentialUpdates 组装凭据变更字段并返回最终用户名。
|
// credentialUpdates 组装凭据变更字段并返回最终用户名。
|
||||||
func (s *AuthService) credentialUpdates(ctx context.Context, user *model.User, newName, newPassword string) (map[string]any, string, error) {
|
func (s *AuthService) credentialUpdates(ctx context.Context, user *model.User, newName, newPassword string) (map[string]any, string, error) {
|
||||||
updates := map[string]any{}
|
updates := map[string]any{}
|
||||||
@@ -118,37 +162,224 @@ func (s *AuthService) PasswordLoginDisabled(ctx context.Context) (bool, error) {
|
|||||||
return s.settings.PasswordLoginDisabled(ctx)
|
return s.settings.PasswordLoginDisabled(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPasswordLoginDisabled 保存开关;开启前须至少绑定一个外部身份,防止自锁。
|
// SetPasswordLoginDisabled 保存开关;开启前须至少有一种免密登录方式
|
||||||
func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool) error {
|
// (通行密钥或外部身份),防止自锁。检查与写入在同一事务内并锁定用户行,
|
||||||
|
// 防与解绑身份/删除通行密钥并发绕过「至少一种登录方式」。
|
||||||
|
func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username string, disabled bool, proof TokenProof) error {
|
||||||
if s.settings == nil {
|
if s.settings == nil {
|
||||||
return errors.New("settings unavailable")
|
return errors.New("settings unavailable")
|
||||||
}
|
}
|
||||||
|
value := ""
|
||||||
if disabled {
|
if disabled {
|
||||||
user, err := s.findUser(ctx, username)
|
value = "1"
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserForAuthChange(tx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
n, err := s.identityCount(ctx, user.ID)
|
if err := s.ensureTokenCurrentTx(tx, user, proof); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if n == 0 {
|
if err := s.ensurePasswordlessForToggle(tx, user.ID, disabled); err != nil {
|
||||||
return ErrNeedIdentity
|
return err
|
||||||
}
|
}
|
||||||
}
|
if err := saveSettingTx(tx, settingSecPasswordLoginOff, value); err != nil {
|
||||||
if err := s.settings.SetPasswordLoginDisabled(ctx, disabled); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
// 登录策略属敏感变更:版本递增与开关写入同事务提交,不留半程状态
|
||||||
// 登录策略属敏感变更:递增令牌版本,已签发会话全部失效
|
return bumpTokenVersionTx(tx, username)
|
||||||
return s.bumpTokenVersion(ctx, username)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) identityCount(ctx context.Context, userID uint) (int64, error) {
|
func (s *AuthService) ensurePasswordlessForToggle(tx *gorm.DB, userID uint, disabled bool) error {
|
||||||
|
if !disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
origin, err := effectiveOriginTx(tx, s.settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok, err := usablePasswordlessTx(tx, userID, 0, 0, origin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return ErrNeedIdentity
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockUserForAuthChange 事务内锁定用户行(SQLite 单写天然串行,MySQL/PG 靠行锁),
|
||||||
|
// 「禁用密码登录」与「解绑身份」都先过这把锁,保证不变量检查与写入不交叉。
|
||||||
|
func lockUserForAuthChange(tx *gorm.DB, username string) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
First(&user, "username = ?", username).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find user %s: %w", username, err)
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockUserByIDForAuthChange(tx *gorm.DB, userID uint) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find user: %w", err)
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureTokenCurrentTx 行锁下复核请求令牌仍是账号当前状态:版本一致
|
||||||
|
// (改密 / 撤销全部会递增),且对应会话行未被注销或定点撤销
|
||||||
|
// (Logout 联动标记 revoked_at,查行即可覆盖两者);存量令牌无行时仅校验版本。
|
||||||
|
func (s *AuthService) ensureTokenCurrentTx(tx *gorm.DB, user *model.User, proof TokenProof) error {
|
||||||
|
if user.TokenVersion != proof.Ver {
|
||||||
|
return ErrTokenStale
|
||||||
|
}
|
||||||
|
if proof.Jti == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.revokedJti.has(proof.Jti) {
|
||||||
|
return ErrTokenStale
|
||||||
|
}
|
||||||
|
var row model.UserSession
|
||||||
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Select("id", "revoked_at").Where("token_id = ?", proof.Jti).First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("find session: %w", err)
|
||||||
|
}
|
||||||
|
if row.RevokedAt != nil {
|
||||||
|
return ErrTokenStale
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockUsersForAuthChange 锁定全部用户行(单管理员面板即一行):与
|
||||||
|
// lockUserForAuthChange 竞争同一把行锁,防「改 provider / 面板地址配置」
|
||||||
|
// 与「禁用密码 / 删除最后因子」并发交错绕过登录方式不变量。
|
||||||
|
func lockUsersForAuthChange(tx *gorm.DB) error {
|
||||||
|
var users []model.User
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Find(&users).Error; err != nil {
|
||||||
|
return fmt.Errorf("lock users: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// identityProviderCountTx 统计某 provider 的身份总数(单管理员面板,不区分账号)。
|
||||||
|
func identityProviderCountTx(tx *gorm.DB, provider string) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := tx.Model(&model.UserIdentity{}).Where("provider = ?", provider).Count(&n).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count identities: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityCountTx(tx *gorm.DB, userID uint) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := s.db.WithContext(ctx).Model(&model.UserIdentity{}).
|
err := tx.Model(&model.UserIdentity{}).
|
||||||
Where("user_id = ?", userID).Count(&count).Error
|
Where("user_id = ?", userID).Count(&count).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count identities: %w", err)
|
return 0, fmt.Errorf("count identities: %w", err)
|
||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// passkeyCountTx 统计账号的通行密钥数(事务内,防自锁检查用)。
|
||||||
|
func passkeyCountTx(tx *gorm.DB, userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := tx.Model(&model.UserPasskey{}).
|
||||||
|
Where("user_id = ?", userID).Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count passkeys: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// usablePasswordlessTx 事务内判断(排除给定身份/通行密钥行后)是否仍存在
|
||||||
|
// 可实际登录的免密方式:通行密钥仅统计注册 origin 与当前面板地址一致的
|
||||||
|
// (地址变更后旧域名凭据不可登录,不得计入);钱包按地址绑定,恒可用;
|
||||||
|
// GitHub/OIDC 身份须对应 provider 已配置且未禁用才计入(端到端防自锁)。
|
||||||
|
// origin 为空时所有方式均不可用:Passkey RP、钱包 SIWE 与 OAuth 回调都依赖面板地址。
|
||||||
|
func usablePasswordlessTx(tx *gorm.DB, userID uint, excludeIdentity, excludePasskey uint, origin string) (bool, error) {
|
||||||
|
if origin == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
pk, err := passkeyCountExcludingTx(tx, userID, excludePasskey, origin)
|
||||||
|
if err != nil || pk > 0 {
|
||||||
|
return pk > 0, err
|
||||||
|
}
|
||||||
|
if n, err := identityCountByProviderTx(tx, userID, "wallet", excludeIdentity); err != nil || n > 0 {
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
for _, p := range []string{"github", "oidc"} {
|
||||||
|
n, err := identityCountByProviderTx(tx, userID, p, excludeIdentity)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok, err := oauthProviderUsableTx(tx, p); err != nil || ok {
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthProviderUsableTx 事务内判断 provider 当前可实际登录:
|
||||||
|
// clientID 与 secret 均已配置(oidc 还需 issuer)且未禁用。
|
||||||
|
func oauthProviderUsableTx(tx *gorm.DB, provider string) (bool, error) {
|
||||||
|
need := []string{settingOauthGithubClientID, settingOauthGithubClientSecret}
|
||||||
|
offKey := settingOauthGithubDisabled
|
||||||
|
if provider == "oidc" {
|
||||||
|
need = []string{settingOauthOidcClientID, settingOauthOidcClientSecret, settingOauthOidcIssuer}
|
||||||
|
offKey = settingOauthOidcDisabled
|
||||||
|
}
|
||||||
|
for _, k := range need {
|
||||||
|
v, err := settingValueTx(tx, k)
|
||||||
|
if err != nil || v == "" {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
off, err := settingValueTx(tx, offKey)
|
||||||
|
return off != "1", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// identityCountByProviderTx 统计账号某 provider 的身份数,可排除一行(解绑前判定用)。
|
||||||
|
func identityCountByProviderTx(tx *gorm.DB, userID uint, provider string, excludeID uint) (int64, error) {
|
||||||
|
q := tx.Model(&model.UserIdentity{}).Where("user_id = ? AND provider = ?", userID, provider)
|
||||||
|
if excludeID != 0 {
|
||||||
|
q = q.Where("id <> ?", excludeID)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := q.Count(&count).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("count identities: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyCountExcludingTx 统计「当前地址下可用」的通行密钥数,可排除一行;
|
||||||
|
// userID 为 0 表示全表(单管理员面板);origin 非空时仅计注册来源一致的凭据。
|
||||||
|
func passkeyCountExcludingTx(tx *gorm.DB, userID, excludeID uint, origin string) (int64, error) {
|
||||||
|
q := tx.Model(&model.UserPasskey{})
|
||||||
|
if userID != 0 {
|
||||||
|
q = q.Where("user_id = ?", userID)
|
||||||
|
}
|
||||||
|
if excludeID != 0 {
|
||||||
|
q = q.Where("id <> ?", excludeID)
|
||||||
|
}
|
||||||
|
if origin != "" {
|
||||||
|
q = q.Where("origin = ?", origin)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := q.Count(&count).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("count passkeys: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"oci-portal/internal/crypto"
|
"oci-portal/internal/crypto"
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
)
|
)
|
||||||
@@ -17,7 +19,11 @@ func newCredEnv(t *testing.T) *AuthService {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new cipher: %v", err)
|
t.Fatalf("new cipher: %v", err)
|
||||||
}
|
}
|
||||||
auth.SetNotifier(nil, NewSettingService(db, cipher))
|
settings := NewSettingService(db, cipher)
|
||||||
|
if err := settings.UpdateSecurity(context.Background(), SecurityPatch{AppURL: strPtr("https://app.example.com")}); err != nil {
|
||||||
|
t.Fatalf("seed app url: %v", err)
|
||||||
|
}
|
||||||
|
auth.SetNotifier(nil, settings)
|
||||||
return auth
|
return auth
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,16 +32,16 @@ func TestUpdateCredentials(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// 当前密码错误
|
// 当前密码错误
|
||||||
_, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "newpass-123", CurrentPassword: "wrong"})
|
_, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "newpass-123", CurrentPassword: "wrong"}, proofOf(t, auth.db, "admin"))
|
||||||
if !errors.Is(err, ErrCredentialConfirm) {
|
if !errors.Is(err, ErrCredentialConfirm) {
|
||||||
t.Fatalf("wrong current password: err = %v, want ErrCredentialConfirm", err)
|
t.Fatalf("wrong current password: err = %v, want ErrCredentialConfirm", err)
|
||||||
}
|
}
|
||||||
// 短密码 / 无变更均拒绝
|
// 短密码 / 无变更均拒绝
|
||||||
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "short", CurrentPassword: "pass123"})
|
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewPassword: "short", CurrentPassword: "pass123"}, proofOf(t, auth.db, "admin"))
|
||||||
if !errors.Is(err, ErrCredentialInvalid) {
|
if !errors.Is(err, ErrCredentialInvalid) {
|
||||||
t.Fatalf("short password: err = %v, want ErrCredentialInvalid", err)
|
t.Fatalf("short password: err = %v, want ErrCredentialInvalid", err)
|
||||||
}
|
}
|
||||||
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewUsername: "admin", CurrentPassword: "pass123"})
|
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{NewUsername: "admin", CurrentPassword: "pass123"}, proofOf(t, auth.db, "admin"))
|
||||||
if !errors.Is(err, ErrCredentialInvalid) {
|
if !errors.Is(err, ErrCredentialInvalid) {
|
||||||
t.Fatalf("no-op change: err = %v, want ErrCredentialInvalid", err)
|
t.Fatalf("no-op change: err = %v, want ErrCredentialInvalid", err)
|
||||||
}
|
}
|
||||||
@@ -43,24 +49,60 @@ func TestUpdateCredentials(t *testing.T) {
|
|||||||
// 同时改名改密
|
// 同时改名改密
|
||||||
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
|
_, err = auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
|
||||||
NewUsername: "root", NewPassword: "newpass-123", CurrentPassword: "pass123",
|
NewUsername: "root", NewPassword: "newpass-123", CurrentPassword: "pass123",
|
||||||
})
|
}, proofOf(t, auth.db, "admin"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpdateCredentials: %v", err)
|
t.Fatalf("UpdateCredentials: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrInvalidCredentials) {
|
if _, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"}); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
t.Errorf("old username login: err = %v, want ErrInvalidCredentials", err)
|
t.Errorf("old username login: err = %v, want ErrInvalidCredentials", err)
|
||||||
}
|
}
|
||||||
if _, _, err := auth.Login(ctx, "root", "newpass-123", "127.0.0.2", ""); err != nil {
|
if _, _, err := auth.Login(ctx, "root", "newpass-123", "", SessionMeta{ClientIP: "127.0.0.2"}); err != nil {
|
||||||
t.Errorf("new credentials login: %v", err)
|
t.Errorf("new credentials login: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateCredentialsRejectsRevokedJTI(t *testing.T) {
|
||||||
|
auth := newCredEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
stale := loginSession(t, auth, "10.10.0.1", "stale")
|
||||||
|
current := loginSession(t, auth, "10.10.0.2", "current")
|
||||||
|
proof := mustProof(t, auth, stale)
|
||||||
|
for _, item := range mustSessions(t, auth, current) {
|
||||||
|
if !item.Current {
|
||||||
|
if err := auth.RevokeSession(ctx, "admin", current, item.ID); err != nil {
|
||||||
|
t.Fatalf("RevokeSession: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
|
||||||
|
NewPassword: "newpass-123", CurrentPassword: "pass123",
|
||||||
|
}, proof)
|
||||||
|
if !errors.Is(err, ErrTokenStale) {
|
||||||
|
t.Fatalf("UpdateCredentials err = %v, want ErrTokenStale", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.Login(
|
||||||
|
ctx, "admin", "pass123", "", SessionMeta{ClientIP: "10.10.0.3"},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("old password should remain valid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proofOf 取账号当前令牌版本组装快照(jti 空=不校验会话行),敏感调用测试用。
|
||||||
|
func proofOf(t *testing.T, db *gorm.DB, username string) TokenProof {
|
||||||
|
t.Helper()
|
||||||
|
var user model.User
|
||||||
|
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||||
|
t.Fatalf("find user: %v", err)
|
||||||
|
}
|
||||||
|
return TokenProof{Ver: user.TokenVersion}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPasswordLoginToggle(t *testing.T) {
|
func TestPasswordLoginToggle(t *testing.T) {
|
||||||
auth := newCredEnv(t)
|
auth := newCredEnv(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// 未绑定外部身份不可禁用
|
// 未绑定外部身份不可禁用
|
||||||
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true); !errors.Is(err, ErrNeedIdentity) {
|
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true, proofOf(t, auth.db, "admin")); !errors.Is(err, ErrNeedIdentity) {
|
||||||
t.Fatalf("disable without identity: err = %v, want ErrNeedIdentity", err)
|
t.Fatalf("disable without identity: err = %v, want ErrNeedIdentity", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,36 +110,73 @@ func TestPasswordLoginToggle(t *testing.T) {
|
|||||||
if err := auth.db.Where("username = ?", "admin").First(&user).Error; err != nil {
|
if err := auth.db.Where("username = ?", "admin").First(&user).Error; err != nil {
|
||||||
t.Fatalf("find admin: %v", err)
|
t.Fatalf("find admin: %v", err)
|
||||||
}
|
}
|
||||||
ident := model.UserIdentity{UserID: user.ID, Provider: "github", Subject: "1", Display: "tester"}
|
// 用钱包身份:不依赖 provider 配置即可登录(github/oidc 身份须 provider 启用才计入)
|
||||||
|
ident := model.UserIdentity{UserID: user.ID, Provider: "wallet", Subject: "0xabc", Display: "tester"}
|
||||||
if err := auth.db.Create(&ident).Error; err != nil {
|
if err := auth.db.Create(&ident).Error; err != nil {
|
||||||
t.Fatalf("seed identity: %v", err)
|
t.Fatalf("seed identity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true); err != nil {
|
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true, proofOf(t, auth.db, "admin")); err != nil {
|
||||||
t.Fatalf("disable with identity: %v", err)
|
t.Fatalf("disable with identity: %v", err)
|
||||||
}
|
}
|
||||||
if off, err := auth.PasswordLoginDisabled(ctx); err != nil || !off {
|
if off, err := auth.PasswordLoginDisabled(ctx); err != nil || !off {
|
||||||
t.Fatalf("PasswordLoginDisabled = %v, %v; want true", off, err)
|
t.Fatalf("PasswordLoginDisabled = %v, %v; want true", off, err)
|
||||||
}
|
}
|
||||||
// 密码登录被拒且不计失败(正确密码亦拒)
|
// 密码登录被拒且不计失败(正确密码亦拒)
|
||||||
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrPasswordLoginDisabled) {
|
if _, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"}); !errors.Is(err, ErrPasswordLoginDisabled) {
|
||||||
t.Fatalf("login while disabled: err = %v, want ErrPasswordLoginDisabled", err)
|
t.Fatalf("login while disabled: err = %v, want ErrPasswordLoginDisabled", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开关开着不能解绑最后一个身份
|
// 开关开着不能解绑最后一个身份
|
||||||
oauth := NewOAuthService(auth.db, nil, auth)
|
oauth := NewOAuthService(auth.db, nil, auth)
|
||||||
if err := oauth.Unbind(ctx, "admin", ident.ID); !errors.Is(err, ErrLastIdentity) {
|
if err := oauth.Unbind(ctx, "admin", ident.ID, proofOf(t, auth.db, "admin")); !errors.Is(err, ErrLastIdentity) {
|
||||||
t.Fatalf("unbind last identity: err = %v, want ErrLastIdentity", err)
|
t.Fatalf("unbind last identity: err = %v, want ErrLastIdentity", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 恢复密码登录后可解绑、可登录
|
// 恢复密码登录后可解绑、可登录
|
||||||
if err := auth.SetPasswordLoginDisabled(ctx, "admin", false); err != nil {
|
if err := auth.SetPasswordLoginDisabled(ctx, "admin", false, proofOf(t, auth.db, "admin")); err != nil {
|
||||||
t.Fatalf("enable password login: %v", err)
|
t.Fatalf("enable password login: %v", err)
|
||||||
}
|
}
|
||||||
if err := oauth.Unbind(ctx, "admin", ident.ID); err != nil {
|
if err := oauth.Unbind(ctx, "admin", ident.ID, proofOf(t, auth.db, "admin")); err != nil {
|
||||||
t.Fatalf("unbind after enable: %v", err)
|
t.Fatalf("unbind after enable: %v", err)
|
||||||
}
|
}
|
||||||
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.3", ""); err != nil {
|
if _, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.3"}); err != nil {
|
||||||
t.Errorf("login after enable: %v", err)
|
t.Errorf("login after enable: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPasswordLoginToggleWithPasskey 验证通行密钥计入禁用密码登录的门槛:
|
||||||
|
// 仅有通行密钥即可开启;禁用期间最后一把钥匙受防自锁保护,身份可兜底解绑。
|
||||||
|
func TestPasswordLoginToggleWithPasskey(t *testing.T) {
|
||||||
|
auth := newCredEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
pk := model.UserPasskey{
|
||||||
|
UserID: 1, Name: "k", CredentialID: "cred-1",
|
||||||
|
CredentialIDHash: "hash-1", Credential: "{}", Origin: "https://app.example.com",
|
||||||
|
}
|
||||||
|
if err := auth.db.Create(&pk).Error; err != nil {
|
||||||
|
t.Fatalf("seed passkey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无外部身份、仅通行密钥:可禁用密码登录
|
||||||
|
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true, proofOf(t, auth.db, "admin")); err != nil {
|
||||||
|
t.Fatalf("disable with passkey only: %v", err)
|
||||||
|
}
|
||||||
|
// 禁用期间删除最后一把钥匙被拒(防自锁)
|
||||||
|
passkeys := NewPasskeyService(auth.db, auth.settings, auth)
|
||||||
|
if err := passkeys.Remove(ctx, "admin", pk.ID, proofOf(t, auth.db, "admin")); !errors.Is(err, ErrLastIdentity) {
|
||||||
|
t.Fatalf("remove last passkey: err = %v, want ErrLastIdentity", err)
|
||||||
|
}
|
||||||
|
// 绑定身份后钥匙可删;身份成为最后方式后解绑又被拒
|
||||||
|
ident := model.UserIdentity{UserID: 1, Provider: "wallet", Subject: "0xdef", Display: "t"}
|
||||||
|
if err := auth.db.Create(&ident).Error; err != nil {
|
||||||
|
t.Fatalf("seed identity: %v", err)
|
||||||
|
}
|
||||||
|
if err := passkeys.Remove(ctx, "admin", pk.ID, proofOf(t, auth.db, "admin")); err != nil {
|
||||||
|
t.Fatalf("remove passkey with identity fallback: %v", err)
|
||||||
|
}
|
||||||
|
oauth := NewOAuthService(auth.db, nil, auth)
|
||||||
|
if err := oauth.Unbind(ctx, "admin", ident.ID, proofOf(t, auth.db, "admin")); !errors.Is(err, ErrLastIdentity) {
|
||||||
|
t.Fatalf("unbind last identity: err = %v, want ErrLastIdentity", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
|
||||||
|
"golang.org/x/crypto/sha3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 以太坊 personal_sign(EIP-191)验签工具:仅覆盖 EOA 签名恢复,
|
||||||
|
// 不做 EIP-1271 合约钱包(需链上 RPC,超出自托管零依赖边界)。
|
||||||
|
|
||||||
|
// errEthSig 是签名格式/恢复失败的内部标记;对外统一映射 ErrWalletSig。
|
||||||
|
var errEthSig = errors.New("invalid ethereum signature")
|
||||||
|
|
||||||
|
// keccak256 计算 Keccak-256 摘要(以太坊场景的非 NIST 填充变体)。
|
||||||
|
func keccak256(data ...[]byte) []byte {
|
||||||
|
h := sha3.NewLegacyKeccak256()
|
||||||
|
for _, d := range data {
|
||||||
|
h.Write(d)
|
||||||
|
}
|
||||||
|
return h.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// personalSignDigest 计算 EIP-191 personal_sign 摘要:
|
||||||
|
// keccak256("\x19Ethereum Signed Message:\n" + 消息字节长度十进制 + 消息)。
|
||||||
|
func personalSignDigest(msg []byte) []byte {
|
||||||
|
prefix := []byte("\x19Ethereum Signed Message:\n" + strconv.Itoa(len(msg)))
|
||||||
|
return keccak256(prefix, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverEthAddress 从 65 字节签名(r||s||v)恢复签名者地址(EIP-55 格式)。
|
||||||
|
// v 兼容钱包生态的 27/28 与原始 0/1 两种取值;dcrec 的 compact 格式要求
|
||||||
|
// 恢复位打头(27+v),与以太坊的尾置布局相反,需重排。
|
||||||
|
func recoverEthAddress(digest, sig []byte) (string, error) {
|
||||||
|
if len(sig) != 65 {
|
||||||
|
return "", fmt.Errorf("%w: length %d", errEthSig, len(sig))
|
||||||
|
}
|
||||||
|
v := sig[64]
|
||||||
|
if v >= 27 {
|
||||||
|
v -= 27
|
||||||
|
}
|
||||||
|
if v > 1 {
|
||||||
|
return "", fmt.Errorf("%w: recovery id %d", errEthSig, sig[64])
|
||||||
|
}
|
||||||
|
compact := make([]byte, 65)
|
||||||
|
compact[0] = 27 + v
|
||||||
|
copy(compact[1:], sig[:64])
|
||||||
|
pub, _, err := ecdsa.RecoverCompact(compact, digest)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %v", errEthSig, err)
|
||||||
|
}
|
||||||
|
// 地址 = keccak256(未压缩公钥去掉 0x04 前缀)的后 20 字节
|
||||||
|
return eip55(keccak256(pub.SerializeUncompressed()[1:])[12:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eip55 把 20 字节地址编码为 EIP-55 混合大小写校验和格式:
|
||||||
|
// 对小写 hex 串取 keccak256,对应半字节 ≥ 8 的字母转大写。
|
||||||
|
func eip55(addr []byte) string {
|
||||||
|
lower := hex.EncodeToString(addr)
|
||||||
|
sum := keccak256([]byte(lower))
|
||||||
|
out := []byte(lower)
|
||||||
|
for i, ch := range out {
|
||||||
|
nibble := sum[i/2] >> 4
|
||||||
|
if i%2 == 1 {
|
||||||
|
nibble = sum[i/2] & 0x0f
|
||||||
|
}
|
||||||
|
if ch >= 'a' && nibble >= 8 {
|
||||||
|
out[i] = ch - 'a' + 'A'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "0x" + string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeEthAddress 校验 0x+40hex 地址并返回 EIP-55 规范形;大小写不敏感。
|
||||||
|
func normalizeEthAddress(addr string) (string, error) {
|
||||||
|
if len(addr) != 42 || !strings.HasPrefix(addr, "0x") {
|
||||||
|
return "", ErrWalletAddress
|
||||||
|
}
|
||||||
|
raw, err := hex.DecodeString(strings.ToLower(addr[2:]))
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrWalletAddress
|
||||||
|
}
|
||||||
|
return eip55(raw), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
|
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ethAddrOf 从私钥派生 EIP-55 地址(keccak256(未压缩公钥[1:]) 后 20 字节)。
|
||||||
|
func ethAddrOf(priv *secp256k1.PrivateKey) string {
|
||||||
|
return eip55(keccak256(priv.PubKey().SerializeUncompressed()[1:])[12:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// testPrivKey 构造确定性的测试私钥;seed 填入 32 字节最低位。
|
||||||
|
func testPrivKey(seed byte) *secp256k1.PrivateKey {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
raw[31] = seed
|
||||||
|
return secp256k1.PrivKeyFromBytes(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// signPersonal 用 dcrec 标准路径生成 personal_sign 签名(r||s||v 的 hex);
|
||||||
|
// legacyV 为 true 时 v 取 27/28,否则 0/1——两种取值链上钱包都会出现。
|
||||||
|
func signPersonal(priv *secp256k1.PrivateKey, message string, legacyV bool) string {
|
||||||
|
compact := ecdsa.SignCompact(priv, personalSignDigest([]byte(message)), false)
|
||||||
|
sig := make([]byte, 65)
|
||||||
|
copy(sig, compact[1:])
|
||||||
|
v := compact[0] // 27 或 28(未压缩公钥)
|
||||||
|
if !legacyV {
|
||||||
|
v -= 27
|
||||||
|
}
|
||||||
|
sig[64] = v
|
||||||
|
return "0x" + hex.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEip55Vectors 用 EIP-55 规范文档中的官方校验和向量。
|
||||||
|
func TestEip55Vectors(t *testing.T) {
|
||||||
|
vectors := []string{
|
||||||
|
"0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
|
||||||
|
"0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359",
|
||||||
|
"0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB",
|
||||||
|
"0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb",
|
||||||
|
}
|
||||||
|
for _, want := range vectors {
|
||||||
|
t.Run(want[:10], func(t *testing.T) {
|
||||||
|
for _, input := range []string{strings.ToLower(want), "0x" + strings.ToUpper(want[2:])} {
|
||||||
|
got, err := normalizeEthAddress(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeEthAddress(%s): %v", input, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("normalizeEthAddress(%s) = %s, want %s", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeEthAddressInvalid(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
}{
|
||||||
|
{name: "缺前缀", in: "5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed00"},
|
||||||
|
{name: "长度不足", in: "0x5aAeb6"},
|
||||||
|
{name: "非 hex 字符", in: "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAzz"},
|
||||||
|
{name: "空串", in: ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if _, err := normalizeEthAddress(tt.in); !errors.Is(err, ErrWalletAddress) {
|
||||||
|
t.Errorf("normalizeEthAddress(%q) err = %v, want ErrWalletAddress", tt.in, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKnownPrivKeyAddress 用公开锚点(私钥 0x…01 的以太坊地址)校验派生路径,
|
||||||
|
// 防止 keccak / 公钥序列化环节自洽但整体错误。
|
||||||
|
func TestKnownPrivKeyAddress(t *testing.T) {
|
||||||
|
got := ethAddrOf(testPrivKey(1))
|
||||||
|
want := "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("address of privkey 0x01 = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverEthAddressRoundTrip(t *testing.T) {
|
||||||
|
priv := testPrivKey(7)
|
||||||
|
addr := ethAddrOf(priv)
|
||||||
|
const msg = "demo.example.com wants you to sign in with your Ethereum account:\n0xabc"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sig func() string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "v=27/28", sig: func() string { return signPersonal(priv, msg, true) }},
|
||||||
|
{name: "v=0/1", sig: func() string { return signPersonal(priv, msg, false) }},
|
||||||
|
{name: "签名过短", sig: func() string { return "0x0102" }, wantErr: true},
|
||||||
|
{name: "v 非法", sig: func() string {
|
||||||
|
s := signPersonal(priv, msg, true)
|
||||||
|
return s[:len(s)-2] + "63" // v=99
|
||||||
|
}, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
raw, err := hex.DecodeString(strings.TrimPrefix(tt.sig(), "0x"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode sig: %v", err)
|
||||||
|
}
|
||||||
|
got, err := recoverEthAddress(personalSignDigest([]byte(msg)), raw)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("recoverEthAddress: expected error, got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recoverEthAddress: %v", err)
|
||||||
|
}
|
||||||
|
if got != addr {
|
||||||
|
t.Errorf("recovered = %s, want %s", got, addr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 篡改消息:恢复出的地址必然不同
|
||||||
|
raw, _ := hex.DecodeString(strings.TrimPrefix(signPersonal(priv, msg, true), "0x"))
|
||||||
|
got, err := recoverEthAddress(personalSignDigest([]byte(msg+"x")), raw)
|
||||||
|
if err == nil && got == addr {
|
||||||
|
t.Error("tampered message still recovered the signer address")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,36 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// idpIconMaxBytes 是 IdP 图标上传上限;idpIconExts 是允许的图片扩展名。
|
||||||
|
const (
|
||||||
|
idpIconMaxBytes = 1 << 20
|
||||||
|
idpIconRandomBytes = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
var idpIconExts = map[string]bool{
|
||||||
|
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, ".ico": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图标校验错误哨兵:api 层据此映射 400 / 413 / 415,不落统一 500 边界。
|
||||||
|
var (
|
||||||
|
ErrIdpIconEmpty = errors.New("icon file is empty")
|
||||||
|
ErrIdpIconTooLarge = errors.New("icon exceeds 1MB")
|
||||||
|
ErrIdpIconBadType = errors.New("icon file type not allowed")
|
||||||
|
ErrIdpIconBadName = errors.New("icon file name is invalid")
|
||||||
|
)
|
||||||
|
|
||||||
// IdentityProviders 列出域内 SAML 身份提供者。
|
// IdentityProviders 列出域内 SAML 身份提供者。
|
||||||
func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint, domainID string) ([]oci.IdentityProviderInfo, error) {
|
func (s *OciConfigService) IdentityProviders(ctx context.Context, id uint, domainID string) ([]oci.IdentityProviderInfo, error) {
|
||||||
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
@@ -34,7 +58,25 @@ func (s *OciConfigService) CreateIdentityProvider(ctx context.Context, id uint,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return oci.IdentityProviderInfo{}, err
|
return oci.IdentityProviderInfo{}, err
|
||||||
}
|
}
|
||||||
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, normalizeIdpInput(in))
|
in, err = s.enforceIdpDomainSettings(ctx, cred, homeRegion, domainID, normalizeIdpInput(in))
|
||||||
|
if err != nil {
|
||||||
|
return oci.IdentityProviderInfo{}, err
|
||||||
|
}
|
||||||
|
return s.client.CreateSamlIdentityProvider(ctx, cred, homeRegion, domainID, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OciConfigService) enforceIdpDomainSettings(ctx context.Context, cred oci.Credentials, region, domainID string, in oci.CreateIdpInput) (oci.CreateIdpInput, error) {
|
||||||
|
if !in.JitEnabled {
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
setting, err := s.client.GetIdentitySetting(ctx, cred, region, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return in, fmt.Errorf("get identity setting before create idp: %w", err)
|
||||||
|
}
|
||||||
|
if setting.PrimaryEmailRequired {
|
||||||
|
in.JitMapEmail = true
|
||||||
|
}
|
||||||
|
return in, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、
|
// normalizeIdpInput 填充映射字段的控制台默认值:名称 ID 格式「无」、
|
||||||
@@ -97,6 +139,135 @@ func (s *OciConfigService) DomainSamlMetadata(ctx context.Context, id uint, doma
|
|||||||
return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion, domainID)
|
return s.client.DownloadDomainSamlMetadata(ctx, cred, homeRegion, domainID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadIdpIcon 上传 IdP 图标到身份域公共图片存储,返回公网地址与存储内文件名。
|
||||||
|
func (s *OciConfigService) UploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte) (string, string, error) {
|
||||||
|
return s.uploadIdpIcon(ctx, id, domainID, fileName, data, rand.Reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OciConfigService) uploadIdpIcon(ctx context.Context, id uint, domainID, fileName string, data []byte, random io.Reader) (string, string, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconEmpty)
|
||||||
|
}
|
||||||
|
if len(data) > idpIconMaxBytes {
|
||||||
|
return "", "", fmt.Errorf("upload idp icon: %w", ErrIdpIconTooLarge)
|
||||||
|
}
|
||||||
|
fileName, err := validateIdpIcon(fileName, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("upload idp icon: %w", err)
|
||||||
|
}
|
||||||
|
fileName, err = newIdpIconStorageName(fileName, random)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("upload idp icon: %w", err)
|
||||||
|
}
|
||||||
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
url, storedName, err := s.client.UploadDomainImage(ctx, cred, homeRegion, domainID, fileName, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("upload idp icon: %w", err)
|
||||||
|
}
|
||||||
|
return url, storedName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIdpIconStorageName(fileName string, random io.Reader) (string, error) {
|
||||||
|
token := make([]byte, idpIconRandomBytes)
|
||||||
|
if _, err := io.ReadFull(random, token); err != nil {
|
||||||
|
return "", fmt.Errorf("generate storage name: %w", err)
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(path.Ext(fileName))
|
||||||
|
return "idp-icon-" + hex.EncodeToString(token) + ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIdpIcon 按上传响应中的 fileName 删除身份域公开图片。
|
||||||
|
func (s *OciConfigService) DeleteIdpIcon(ctx context.Context, id uint, domainID, fileName string) error {
|
||||||
|
if err := validateDomainImageFileName(fileName); err != nil {
|
||||||
|
return fmt.Errorf("delete idp icon: %w", err)
|
||||||
|
}
|
||||||
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.client.DeleteDomainImage(ctx, cred, homeRegion, domainID, fileName); err != nil {
|
||||||
|
return fmt.Errorf("delete idp icon: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateIdpIcon(input string, data []byte) (string, error) {
|
||||||
|
fileName := path.Base(strings.ReplaceAll(input, "\\", "/"))
|
||||||
|
if invalidIconName(fileName, 255) {
|
||||||
|
return "", ErrIdpIconBadName
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(path.Ext(fileName))
|
||||||
|
if !idpIconExts[ext] || !iconMagicOK(ext, data) {
|
||||||
|
return "", fmt.Errorf("%w: %q", ErrIdpIconBadType, ext)
|
||||||
|
}
|
||||||
|
return fileName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDomainImageFileName(fileName string) error {
|
||||||
|
if invalidIconName(fileName, 1024) || strings.Contains(fileName, "\\") {
|
||||||
|
return ErrIdpIconBadName
|
||||||
|
}
|
||||||
|
clean := path.Clean(fileName)
|
||||||
|
if clean != fileName || path.Dir(clean) != "images" || !validIdpIconStorageBase(path.Base(clean)) {
|
||||||
|
return ErrIdpIconBadName
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validIdpIconStorageBase(fileName string) bool {
|
||||||
|
ext := path.Ext(fileName)
|
||||||
|
if ext == "" || ext != strings.ToLower(ext) || !idpIconExts[ext] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
stem := strings.TrimSuffix(fileName, ext)
|
||||||
|
if !strings.HasPrefix(stem, "idp-icon-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(stem, "idp-icon-")
|
||||||
|
if len(token) != hex.EncodedLen(idpIconRandomBytes) || token != strings.ToLower(token) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := hex.DecodeString(token)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidIconName(fileName string, maxBytes int) bool {
|
||||||
|
return fileName == "" || fileName == "." || fileName == ".." ||
|
||||||
|
len(fileName) > maxBytes || strings.TrimSpace(fileName) != fileName ||
|
||||||
|
strings.IndexFunc(fileName, unicode.IsControl) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// iconMagicOK 用文件头魔数轻量核对扩展名与内容大体一致。不做深度解码:
|
||||||
|
// 图标由管理员为自己租户上传,存储与服务均在 Oracle 域名侧,传错内容只会
|
||||||
|
// 让自己登录页图标裂开,深度校验的收益承担不起其复杂度与误伤。
|
||||||
|
func iconMagicOK(ext string, data []byte) bool {
|
||||||
|
has := func(prefix string) bool {
|
||||||
|
return len(data) >= len(prefix) && string(data[:len(prefix)]) == prefix
|
||||||
|
}
|
||||||
|
switch ext {
|
||||||
|
case ".png":
|
||||||
|
return has("\x89PNG\r\n\x1a\n")
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff
|
||||||
|
case ".gif":
|
||||||
|
return has("GIF87a") || has("GIF89a")
|
||||||
|
case ".webp":
|
||||||
|
return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP"
|
||||||
|
case ".ico":
|
||||||
|
return len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0
|
||||||
|
case ".svg":
|
||||||
|
head := data
|
||||||
|
if len(head) > 512 {
|
||||||
|
head = head[:512]
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(string(head)), "<svg")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。
|
// ConsoleSignOnRules 按优先级列出 OCI Console sign-on 策略的规则。
|
||||||
func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint, domainID string) ([]oci.SignOnRuleInfo, error) {
|
func (s *OciConfigService) ConsoleSignOnRules(ctx context.Context, id uint, domainID string) ([]oci.SignOnRuleInfo, error) {
|
||||||
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
type idpIconStubClient struct {
|
||||||
|
*fakeClient
|
||||||
|
uploadedNames []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type idpCreateStubClient struct {
|
||||||
|
*fakeClient
|
||||||
|
setting oci.IdentitySettingInfo
|
||||||
|
settingErr error
|
||||||
|
settingCalls int
|
||||||
|
createCalls int
|
||||||
|
createdInput oci.CreateIdpInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *idpIconStubClient) UploadDomainImage(_ context.Context, _ oci.Credentials, _, _, fileName string, _ []byte) (string, string, error) {
|
||||||
|
c.uploadedNames = append(c.uploadedNames, fileName)
|
||||||
|
return "https://images.example/" + fileName, "images/" + fileName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *idpCreateStubClient) GetIdentitySetting(_ context.Context, _ oci.Credentials, _, _ string) (oci.IdentitySettingInfo, error) {
|
||||||
|
c.settingCalls++
|
||||||
|
return c.setting, c.settingErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *idpCreateStubClient) CreateSamlIdentityProvider(_ context.Context, _ oci.Credentials, _, _ string, in oci.CreateIdpInput) (oci.IdentityProviderInfo, error) {
|
||||||
|
c.createCalls++
|
||||||
|
c.createdInput = in
|
||||||
|
return oci.IdentityProviderInfo{Name: in.Name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingIconRandom struct{}
|
||||||
|
|
||||||
|
func (failingIconRandom) Read([]byte) (int, error) {
|
||||||
|
return 0, errors.New("entropy unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIdpIconTestService(t *testing.T) (*OciConfigService, *idpIconStubClient, uint) {
|
||||||
|
t.Helper()
|
||||||
|
base := &fakeClient{tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}}
|
||||||
|
client := &idpIconStubClient{fakeClient: base}
|
||||||
|
service := newTestService(t, client)
|
||||||
|
return service, client, importAliveConfig(t, service).ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIdpCreateTestService(t *testing.T) (*OciConfigService, *idpCreateStubClient, uint) {
|
||||||
|
t.Helper()
|
||||||
|
base := &fakeClient{tenancy: oci.TenancyInfo{Name: "t", HomeRegionKey: "FRA"}}
|
||||||
|
client := &idpCreateStubClient{fakeClient: base}
|
||||||
|
service := newTestService(t, client)
|
||||||
|
return service, client, importAliveConfig(t, service).ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateIdpInput(jitEnabled bool) oci.CreateIdpInput {
|
||||||
|
return oci.CreateIdpInput{
|
||||||
|
Name: "test-idp", Metadata: "<EntityDescriptor/>", JitEnabled: jitEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iconFixture 生成带正确文件头魔数的最小样本;精简校验只核对魔数,不做深度解码。
|
||||||
|
func iconFixture(ext string) []byte {
|
||||||
|
switch ext {
|
||||||
|
case ".png":
|
||||||
|
return append([]byte("\x89PNG\r\n\x1a\n"), 0, 0, 0, 0)
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return []byte{0xff, 0xd8, 0xff, 0xe0, 0, 0}
|
||||||
|
case ".gif":
|
||||||
|
return []byte("GIF89a\x00\x00")
|
||||||
|
case ".webp":
|
||||||
|
return []byte("RIFF\x00\x00\x00\x00WEBPVP8 ")
|
||||||
|
case ".ico":
|
||||||
|
return []byte{0, 0, 1, 0, 1, 0}
|
||||||
|
case ".svg":
|
||||||
|
return []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateIdpIconAcceptsMatchingContent(t *testing.T) {
|
||||||
|
for _, ext := range []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg"} {
|
||||||
|
t.Run(ext, func(t *testing.T) {
|
||||||
|
fileName := "icon" + ext
|
||||||
|
got, err := validateIdpIcon(fileName, iconFixture(ext))
|
||||||
|
if err != nil || got != fileName {
|
||||||
|
t.Errorf("validateIdpIcon = %q, %v; want %q, nil", got, err, fileName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIdentityProviderForcesEmailMapping(t *testing.T) {
|
||||||
|
service, client, configID := newIdpCreateTestService(t)
|
||||||
|
client.setting.PrimaryEmailRequired = true
|
||||||
|
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(true))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateIdentityProvider: %v", err)
|
||||||
|
}
|
||||||
|
if client.settingCalls != 1 || client.createCalls != 1 {
|
||||||
|
t.Fatalf("calls = setting:%d create:%d; want 1, 1", client.settingCalls, client.createCalls)
|
||||||
|
}
|
||||||
|
if !client.createdInput.JitMapEmail {
|
||||||
|
t.Error("JitMapEmail = false, want true for primary-email-required domain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIdentityProviderStopsWhenSettingFails(t *testing.T) {
|
||||||
|
service, client, configID := newIdpCreateTestService(t)
|
||||||
|
client.settingErr = errors.New("setting unavailable")
|
||||||
|
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(true))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "get identity setting") {
|
||||||
|
t.Fatalf("CreateIdentityProvider error = %v, want setting failure", err)
|
||||||
|
}
|
||||||
|
if client.settingCalls != 1 || client.createCalls != 0 {
|
||||||
|
t.Errorf("calls = setting:%d create:%d; want 1, 0", client.settingCalls, client.createCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIdentityProviderSkipsSettingWithoutJIT(t *testing.T) {
|
||||||
|
service, client, configID := newIdpCreateTestService(t)
|
||||||
|
client.settingErr = errors.New("must not be called")
|
||||||
|
_, err := service.CreateIdentityProvider(context.Background(), configID, "domain", testCreateIdpInput(false))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateIdentityProvider: %v", err)
|
||||||
|
}
|
||||||
|
if client.settingCalls != 0 || client.createCalls != 1 {
|
||||||
|
t.Errorf("calls = setting:%d create:%d; want 0, 1", client.settingCalls, client.createCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadIdpIconUsesUniqueServerNames(t *testing.T) {
|
||||||
|
service, client, configID := newIdpIconTestService(t)
|
||||||
|
data := iconFixture(".png")
|
||||||
|
randomData := append(bytes.Repeat([]byte{0x11}, idpIconRandomBytes), bytes.Repeat([]byte{0x22}, idpIconRandomBytes)...)
|
||||||
|
random := bytes.NewReader(randomData)
|
||||||
|
storedNames := make([]string, 2)
|
||||||
|
for index := range storedNames {
|
||||||
|
_, storedName, err := service.uploadIdpIcon(context.Background(), configID, "domain", "Logo.PNG", data, random)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload %d: %v", index, err)
|
||||||
|
}
|
||||||
|
storedNames[index] = storedName
|
||||||
|
}
|
||||||
|
wants := []string{"idp-icon-" + strings.Repeat("11", idpIconRandomBytes) + ".png", "idp-icon-" + strings.Repeat("22", idpIconRandomBytes) + ".png"}
|
||||||
|
for index, want := range wants {
|
||||||
|
if client.uploadedNames[index] != want || storedNames[index] != "images/"+want {
|
||||||
|
t.Errorf("upload %d = %q, %q; want %q, %q", index, client.uploadedNames[index], storedNames[index], want, "images/"+want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadIdpIconRandomFailureSkipsUpstream(t *testing.T) {
|
||||||
|
service, client, configID := newIdpIconTestService(t)
|
||||||
|
_, _, err := service.uploadIdpIcon(context.Background(), configID, "domain", "logo.png", iconFixture(".png"), failingIconRandom{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "generate storage name") {
|
||||||
|
t.Fatalf("UploadIdpIcon error = %v, want random source failure", err)
|
||||||
|
}
|
||||||
|
if len(client.uploadedNames) != 0 {
|
||||||
|
t.Errorf("upstream upload calls = %d, want 0", len(client.uploadedNames))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type iconRejectCase struct {
|
||||||
|
name, fileName string
|
||||||
|
data []byte
|
||||||
|
want error
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertIconRejected(t *testing.T, cases []iconRejectCase) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := validateIdpIcon(tc.fileName, tc.data)
|
||||||
|
if !errors.Is(err, tc.want) {
|
||||||
|
t.Errorf("err = %v, want errors.Is(%v)", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateIdpIconRejectsSpoofedContent(t *testing.T) {
|
||||||
|
assertIconRejected(t, []iconRejectCase{
|
||||||
|
{"extension mismatch", "icon.jpg", iconFixture(".png"), ErrIdpIconBadType},
|
||||||
|
{"random bytes as png", "icon.png", []byte("not an image"), ErrIdpIconBadType},
|
||||||
|
{"unsupported extension", "icon.bmp", iconFixture(".png"), ErrIdpIconBadType},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateIdpIconRejectsBadNames(t *testing.T) {
|
||||||
|
assertIconRejected(t, []iconRejectCase{
|
||||||
|
{"control name", "bad\r\n.png", iconFixture(".png"), ErrIdpIconBadName},
|
||||||
|
{"long name", strings.Repeat("a", 252) + ".png", iconFixture(".png"), ErrIdpIconBadName},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomainImageFileName(t *testing.T) {
|
||||||
|
token := strings.Repeat("ab", idpIconRandomBytes)
|
||||||
|
cases := []struct {
|
||||||
|
name, fileName string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"generated image", "images/idp-icon-" + token + ".png", true},
|
||||||
|
{"empty", "", false}, {"wrong prefix", "icons/a.png", false},
|
||||||
|
{"traversal", "images/a/../secret", false}, {"backslash", `images\a.png`, false},
|
||||||
|
{"control", "images/a\x00.png", false}, {"directory", "images/", false},
|
||||||
|
{"unrelated domain image", "images/company-brand.png", false},
|
||||||
|
{"nested image", "images/generated/idp-icon-" + token + ".png", false},
|
||||||
|
{"uppercase token", "images/idp-icon-" + strings.ToUpper(token) + ".png", false},
|
||||||
|
{"wrong token length", "images/idp-icon-ab.png", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
err := validateDomainImageFileName(tc.fileName)
|
||||||
|
if (err == nil) != tc.valid {
|
||||||
|
t.Errorf("%s: err = %v, valid = %v", tc.name, err, tc.valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -286,6 +286,8 @@ func TestParseLogEvent(t *testing.T) {
|
|||||||
wantType: "x",
|
wantType: "x",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// message 中的 ociportal-logs-sch 是历史 Policy 命名 fixture,反映升级前存量租户的审计事件形态;
|
||||||
|
// 新版命名已按 tenancy 派生,见 internal/oci/logrelay_names.go。
|
||||||
name: "Audit v2 提取操作者成败与策略描述",
|
name: "Audit v2 提取操作者成败与策略描述",
|
||||||
payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{
|
payload: `{"eventType":"com.oraclecloud.identityControlPlane.CreatePolicy","data":{
|
||||||
"identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"},
|
"identity":{"principalName":"Alfonso Garcia","ipAddress":"129.159.43.9"},
|
||||||
|
|||||||
+194
-57
@@ -41,9 +41,11 @@ const oauthPendingTTL = 10 * time.Minute
|
|||||||
// oauthPending 是一次进行中的授权流程上下文;state 一次性使用。
|
// oauthPending 是一次进行中的授权流程上下文;state 一次性使用。
|
||||||
type oauthPending struct {
|
type oauthPending struct {
|
||||||
provider string
|
provider string
|
||||||
mode string // "login" / "bind"
|
mode string // "login" / "bind"
|
||||||
username string // bind 模式的绑定目标账号
|
username string // bind 模式的绑定目标账号
|
||||||
nonce string // OIDC 防 id_token 重放
|
nonce string // OIDC 防 id_token 重放
|
||||||
|
token string // bind 模式发起时的 Bearer;回调复验,防被盗令牌撤销后仍完成绑定
|
||||||
|
proof TokenProof // 发起时的版本/jti 快照;绑定事务行锁下复核,覆盖撤销全部/注销/定点撤销
|
||||||
expires time.Time
|
expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,12 +70,17 @@ type ProviderInfo struct {
|
|||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Providers 返回可登录的 provider 列表(clientID 非空且未禁用),登录页据此显示按钮。
|
// Providers 返回可实际登录的 provider 列表,登录页据此显示按钮;
|
||||||
|
// 与不变量检查同口径:clientID 与 secret 齐备(oidc 还需 issuer)、未禁用,
|
||||||
|
// 且面板地址已设置(回调地址无从拼接时全部不可登录)——半配置不再暴露必败入口。
|
||||||
func (o *OAuthService) Providers(ctx context.Context) []ProviderInfo {
|
func (o *OAuthService) Providers(ctx context.Context) []ProviderInfo {
|
||||||
out := []ProviderInfo{}
|
out := []ProviderInfo{}
|
||||||
|
if o.settings.EffectiveAppURL() == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
for _, p := range []string{"oidc", "github"} {
|
for _, p := range []string{"oidc", "github"} {
|
||||||
id, _, _, err := o.settings.oauthClient(ctx, p)
|
id, secret, issuer, err := o.settings.oauthClient(ctx, p)
|
||||||
if err != nil || id == "" {
|
if err != nil || id == "" || secret == "" || (p == "oidc" && issuer == "") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
display, disabled, err := o.settings.oauthProviderMeta(ctx, p)
|
display, disabled, err := o.settings.oauthProviderMeta(ctx, p)
|
||||||
@@ -105,7 +112,7 @@ func (o *OAuthService) oauth2Config(ctx context.Context, provider string) (*oaut
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if clientID == "" {
|
if clientID == "" || secret == "" || (provider == "oidc" && issuer == "") {
|
||||||
return nil, nil, ErrOAuthNotConfigured
|
return nil, nil, ErrOAuthNotConfigured
|
||||||
}
|
}
|
||||||
if o.settings.EffectiveAppURL() == "" {
|
if o.settings.EffectiveAppURL() == "" {
|
||||||
@@ -128,12 +135,19 @@ func (o *OAuthService) oauth2Config(ctx context.Context, provider string) (*oaut
|
|||||||
|
|
||||||
// AuthorizeURL 构造授权跳转 URL 并登记一次性 state;mode 为 bind 时 username 必填。
|
// AuthorizeURL 构造授权跳转 URL 并登记一次性 state;mode 为 bind 时 username 必填。
|
||||||
// login 模式拒绝已禁用的 provider;bind 模式不受禁用影响(管理员仍可绑定)。
|
// login 模式拒绝已禁用的 provider;bind 模式不受禁用影响(管理员仍可绑定)。
|
||||||
func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, username string) (string, error) {
|
func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, username, bindToken string) (string, error) {
|
||||||
if mode == "login" {
|
if mode == "login" {
|
||||||
if _, disabled, err := o.settings.oauthProviderMeta(ctx, provider); err == nil && disabled {
|
if _, disabled, err := o.settings.oauthProviderMeta(ctx, provider); err == nil && disabled {
|
||||||
return "", ErrOAuthDisabled
|
return "", ErrOAuthDisabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var proof TokenProof
|
||||||
|
if mode == "bind" {
|
||||||
|
var err error
|
||||||
|
if proof, err = o.bindTokenProof(ctx, username, bindToken); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
cfg, _, err := o.oauth2Config(ctx, provider)
|
cfg, _, err := o.oauth2Config(ctx, provider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -148,7 +162,7 @@ func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, usernam
|
|||||||
}
|
}
|
||||||
o.mu.Lock()
|
o.mu.Lock()
|
||||||
o.gcPendingLocked()
|
o.gcPendingLocked()
|
||||||
o.pending[state] = oauthPending{provider: provider, mode: mode, username: username, nonce: nonce, expires: time.Now().Add(oauthPendingTTL)}
|
o.pending[state] = oauthPending{provider: provider, mode: mode, username: username, nonce: nonce, token: bindToken, proof: proof, expires: time.Now().Add(oauthPendingTTL)}
|
||||||
o.mu.Unlock()
|
o.mu.Unlock()
|
||||||
opts := []oauth2.AuthCodeOption{}
|
opts := []oauth2.AuthCodeOption{}
|
||||||
if provider == "oidc" {
|
if provider == "oidc" {
|
||||||
@@ -157,6 +171,16 @@ func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, usernam
|
|||||||
return cfg.AuthCodeURL(state, opts...), nil
|
return cfg.AuthCodeURL(state, opts...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bindTokenProof 验证 bind 发起令牌的有效性与归属,返回其版本 / jti 快照;
|
||||||
|
// 绑定事务行锁下复核该快照,发起后改密、撤销全部、注销或定点撤销均令绑定作废。
|
||||||
|
func (o *OAuthService) bindTokenProof(ctx context.Context, username, token string) (TokenProof, error) {
|
||||||
|
name, proof, err := o.auth.ParseTokenProof(ctx, token)
|
||||||
|
if err != nil || name != username {
|
||||||
|
return TokenProof{}, ErrOAuthState
|
||||||
|
}
|
||||||
|
return proof, nil
|
||||||
|
}
|
||||||
|
|
||||||
// gcPendingLocked 清理过期流程;调用方须持锁。
|
// gcPendingLocked 清理过期流程;调用方须持锁。
|
||||||
func (o *OAuthService) gcPendingLocked() {
|
func (o *OAuthService) gcPendingLocked() {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -187,27 +211,52 @@ type externalIdentity struct {
|
|||||||
|
|
||||||
// HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT;
|
// HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT;
|
||||||
// token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。
|
// token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。
|
||||||
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, username, mode string, err error) {
|
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string, meta SessionMeta) (token, username, mode string, err error) {
|
||||||
p, err := o.takeState(provider, state)
|
p, err := o.takeState(provider, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
if err := o.validateCallbackPending(ctx, provider, p); err != nil {
|
||||||
|
return "", "", p.mode, err
|
||||||
|
}
|
||||||
ident, err := o.fetchIdentity(ctx, provider, code, p.nonce)
|
ident, err := o.fetchIdentity(ctx, provider, code, p.nonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", p.mode, err
|
return "", "", p.mode, err
|
||||||
}
|
}
|
||||||
if p.mode == "bind" {
|
if p.mode == "bind" {
|
||||||
if err := o.bind(ctx, p.username, provider, ident); err != nil {
|
// 绑定不改登录方式:接续行保留原 method,旧版无行时新建 method 为空
|
||||||
return "", p.username, p.mode, err
|
// (该令牌并非经新绑定方式登录,与活跃会话设计一致)
|
||||||
}
|
token, err := o.bind(ctx, p, ident, meta)
|
||||||
// 绑定属敏感变更:版本递增使旧令牌失效,同时为操作者签新令牌随回跳带回
|
|
||||||
token, _, err := o.auth.RevokeSessions(ctx, p.username)
|
|
||||||
return token, p.username, p.mode, err
|
return token, p.username, p.mode, err
|
||||||
}
|
}
|
||||||
token, username, err = o.loginByIdentity(ctx, provider, ident)
|
meta.Method = provider
|
||||||
|
token, username, err = o.loginByIdentity(ctx, provider, ident, meta)
|
||||||
return token, username, p.mode, err
|
return token, username, p.mode, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *OAuthService) validateCallbackPending(ctx context.Context, provider string, p oauthPending) error {
|
||||||
|
if p.mode == "login" {
|
||||||
|
return o.ensureProviderLoginEnabled(ctx, provider)
|
||||||
|
}
|
||||||
|
// bind 回调换码前复验发起令牌,撤销后流程立即作废。
|
||||||
|
name, err := o.auth.ParseToken(ctx, p.token)
|
||||||
|
if err != nil || name != p.username {
|
||||||
|
return ErrOAuthState
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OAuthService) ensureProviderLoginEnabled(ctx context.Context, provider string) error {
|
||||||
|
_, disabled, err := o.settings.oauthProviderMeta(ctx, provider)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if disabled {
|
||||||
|
return ErrOAuthDisabled
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// fetchIdentity 用授权码向 provider 换取稳定 subject 与展示名。
|
// fetchIdentity 用授权码向 provider 换取稳定 subject 与展示名。
|
||||||
func (o *OAuthService) fetchIdentity(ctx context.Context, provider, code, nonce string) (externalIdentity, error) {
|
func (o *OAuthService) fetchIdentity(ctx context.Context, provider, code, nonce string) (externalIdentity, error) {
|
||||||
cfg, op, err := o.oauth2Config(ctx, provider)
|
cfg, op, err := o.oauth2Config(ctx, provider)
|
||||||
@@ -273,45 +322,110 @@ func oidcIdentity(ctx context.Context, cfg *oauth2.Config, op *oidc.Provider, to
|
|||||||
return externalIdentity{Subject: idToken.Subject, Display: display}, nil
|
return externalIdentity{Subject: idToken.Subject, Display: display}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bind 把外部身份绑定到账号;(provider, subject) 唯一,重复绑定报错。
|
// bind 在单事务内完成绑定全程:行锁下比对发起时令牌版本(发起后被撤销即作废)、
|
||||||
func (o *OAuthService) bind(ctx context.Context, username, provider string, ident externalIdentity) error {
|
// 身份写入、版本递增、原会话行接续换发;任一失败整体回滚,不留半程状态。
|
||||||
user, err := o.auth.findUser(ctx, username)
|
// 新令牌接续 p.token 的会话行(保留登录方式与创建时间),旧令牌无行时按 meta 新建。
|
||||||
if err != nil {
|
func (o *OAuthService) bind(ctx context.Context, p oauthPending, ident externalIdentity, meta SessionMeta) (string, error) {
|
||||||
|
var token string
|
||||||
|
err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserForAuthChange(tx, p.username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := o.auth.ensureTokenCurrentTx(tx, user, p.proof); err != nil {
|
||||||
|
return ErrOAuthState
|
||||||
|
}
|
||||||
|
if err := createIdentityTx(tx, user.ID, p.provider, ident.Subject, ident.Display, ErrOAuthBound); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bumpTokenVersionTx(tx, p.username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.TokenVersion++
|
||||||
|
token, _, err = o.auth.renewSessionTx(tx, user, p.token, meta)
|
||||||
return err
|
return err
|
||||||
}
|
})
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// createIdentityTx 查重后写入外部身份;(provider,subject) 已存在返回 dupErr。
|
||||||
|
func createIdentityTx(tx *gorm.DB, userID uint, provider, subject, display string, dupErr error) error {
|
||||||
var count int64
|
var count int64
|
||||||
err = o.db.WithContext(ctx).Model(&model.UserIdentity{}).
|
err := tx.Model(&model.UserIdentity{}).
|
||||||
Where("provider = ? AND subject = ?", provider, ident.Subject).Count(&count).Error
|
Where("provider = ? AND subject = ?", provider, subject).Count(&count).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check identity: %w", err)
|
return fmt.Errorf("check identity: %w", err)
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return ErrOAuthBound
|
return dupErr
|
||||||
}
|
}
|
||||||
row := model.UserIdentity{UserID: user.ID, Provider: provider, Subject: ident.Subject, Display: ident.Display}
|
row := model.UserIdentity{UserID: userID, Provider: provider, Subject: subject, Display: display}
|
||||||
if err := o.db.WithContext(ctx).Create(&row).Error; err != nil {
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
return fmt.Errorf("bind identity: %w", err)
|
return fmt.Errorf("bind identity: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginByIdentity 查绑定关系并签发面板 JWT;未绑定一律拒绝(不开放注册)。
|
// loginByIdentity 查绑定关系并签发面板 JWT(落地会话);未绑定一律拒绝(不开放注册)。
|
||||||
func (o *OAuthService) loginByIdentity(ctx context.Context, provider string, ident externalIdentity) (string, string, error) {
|
func (o *OAuthService) loginByIdentity(ctx context.Context, provider string, ident externalIdentity, meta SessionMeta) (string, string, error) {
|
||||||
|
row, err := o.findIdentity(ctx, provider, ident.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return o.loginIdentityRow(ctx, row, provider, ident.Subject, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OAuthService) loginIdentityRow(
|
||||||
|
ctx context.Context, row *model.UserIdentity, provider, subject string, meta SessionMeta,
|
||||||
|
) (string, string, error) {
|
||||||
|
var token, username string
|
||||||
|
err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserByIDForAuthChange(tx, row.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := identityStillBoundTx(tx, row.ID, user.ID, provider, subject); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok, err := oauthProviderUsableTx(tx, provider)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return ErrOAuthDisabled
|
||||||
|
}
|
||||||
|
token, _, err = o.auth.signSessionTokenTx(tx, user, meta)
|
||||||
|
username = user.Username
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return token, username, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OAuthService) findIdentity(ctx context.Context, provider, subject string) (*model.UserIdentity, error) {
|
||||||
var row model.UserIdentity
|
var row model.UserIdentity
|
||||||
err := o.db.WithContext(ctx).
|
err := o.db.WithContext(ctx).
|
||||||
Where("provider = ? AND subject = ?", provider, ident.Subject).First(&row).Error
|
Where("provider = ? AND subject = ?", provider, subject).First(&row).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, ErrOAuthNotBound
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
return nil, fmt.Errorf("find identity: %w", err)
|
||||||
return "", "", ErrOAuthNotBound
|
|
||||||
}
|
|
||||||
return "", "", fmt.Errorf("find identity: %w", err)
|
|
||||||
}
|
}
|
||||||
var user model.User
|
return &row, nil
|
||||||
if err := o.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil {
|
}
|
||||||
return "", "", fmt.Errorf("find bound user: %w", err)
|
|
||||||
|
func identityStillBoundTx(tx *gorm.DB, id, userID uint, provider, subject string) error {
|
||||||
|
var count int64
|
||||||
|
err := tx.Model(&model.UserIdentity{}).
|
||||||
|
Where("id = ? AND user_id = ? AND provider = ? AND subject = ?", id, userID, provider, subject).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("recheck identity: %w", err)
|
||||||
}
|
}
|
||||||
token, _, err := o.auth.signToken(user.Username, user.TokenVersion)
|
if count == 0 {
|
||||||
return token, user.Username, err
|
return ErrOAuthNotBound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identities 列出账号已绑定的外部身份。
|
// Identities 列出账号已绑定的外部身份。
|
||||||
@@ -329,27 +443,50 @@ func (o *OAuthService) Identities(ctx context.Context, username string) ([]model
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。
|
// Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。
|
||||||
func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error {
|
// 检查与删除在同一事务内并锁定用户行,防与禁用密码登录并发绕过「至少一种登录方式」。
|
||||||
user, err := o.auth.findUser(ctx, username)
|
func (o *OAuthService) Unbind(ctx context.Context, username string, id uint, proof TokenProof) error {
|
||||||
if err != nil {
|
err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
return err
|
user, err := lockUserForAuthChange(tx, username)
|
||||||
}
|
if err != nil {
|
||||||
n, err := o.auth.identityCount(ctx, user.ID)
|
return err
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n <= 1 {
|
|
||||||
if off, err := o.auth.PasswordLoginDisabled(ctx); err == nil && off {
|
|
||||||
return ErrLastIdentity
|
|
||||||
}
|
}
|
||||||
|
if err := o.auth.ensureTokenCurrentTx(tx, user, proof); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
origin, err := effectiveOriginTx(tx, o.settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureNotLastLogin(tx, user.ID, id, origin); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res := tx.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
|
||||||
|
if res.Error != nil {
|
||||||
|
return fmt.Errorf("unbind identity: %w", res.Error)
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
// 解绑属敏感变更:版本递增与删除同事务提交,不留「已删而旧令牌仍有效」半程
|
||||||
|
return bumpTokenVersionTx(tx, username)
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureNotLastLogin 事务内校验不变量:密码登录已禁用时,解绑该身份后
|
||||||
|
// 须仍存在可实际登录的免密方式(provider 被禁用的身份不算),否则拒绝;
|
||||||
|
// 开关读取失败按失败关闭处理(返回错误),不允许失败放行造成自锁。
|
||||||
|
func ensureNotLastLogin(tx *gorm.DB, userID, identityID uint, origin string) error {
|
||||||
|
off, err := settingValueTx(tx, settingSecPasswordLoginOff)
|
||||||
|
if err != nil || off != "1" {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
res := o.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
|
ok, err := usablePasswordlessTx(tx, userID, identityID, 0, origin)
|
||||||
if res.Error != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unbind identity: %w", res.Error)
|
return err
|
||||||
}
|
}
|
||||||
if res.RowsAffected == 0 {
|
if !ok {
|
||||||
return gorm.ErrRecordNotFound
|
return ErrLastIdentity
|
||||||
}
|
}
|
||||||
// 解绑属敏感变更:递增令牌版本,已签发会话全部失效
|
return nil
|
||||||
return o.auth.bumpTokenVersion(ctx, username)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+228
-34
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OAuth provider 配置键;client secret 以 AES-GCM 密文落库。
|
// OAuth provider 配置键;client secret 以 AES-GCM 密文落库。
|
||||||
@@ -32,18 +34,19 @@ type OAuthProvidersView struct {
|
|||||||
GithubDisabled bool `json:"githubDisabled"`
|
GithubDisabled bool `json:"githubDisabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateOAuthInput 是保存 provider 配置的输入;
|
// UpdateOAuthInput 是 provider 配置的部分更新:所有字段 nil=沿用已存值,
|
||||||
// secret 为 nil 沿用已存值,非 nil 覆盖(空串清除)。
|
// 只落库出现的字段;并发编辑不同 provider 因此互不回滚(2026-07-22 审查 #18)。
|
||||||
|
// secret 非 nil 覆盖(空串清除),绝不回读。
|
||||||
type UpdateOAuthInput struct {
|
type UpdateOAuthInput struct {
|
||||||
OidcIssuer string `json:"oidcIssuer"`
|
OidcIssuer *string `json:"oidcIssuer"`
|
||||||
OidcClientID string `json:"oidcClientId"`
|
OidcClientID *string `json:"oidcClientId"`
|
||||||
OidcClientSecret *string `json:"oidcClientSecret"`
|
OidcClientSecret *string `json:"oidcClientSecret"`
|
||||||
OidcDisplayName string `json:"oidcDisplayName"`
|
OidcDisplayName *string `json:"oidcDisplayName"`
|
||||||
OidcDisabled bool `json:"oidcDisabled"`
|
OidcDisabled *bool `json:"oidcDisabled"`
|
||||||
GithubClientID string `json:"githubClientId"`
|
GithubClientID *string `json:"githubClientId"`
|
||||||
GithubClientSecret *string `json:"githubClientSecret"`
|
GithubClientSecret *string `json:"githubClientSecret"`
|
||||||
GithubDisplayName string `json:"githubDisplayName"`
|
GithubDisplayName *string `json:"githubDisplayName"`
|
||||||
GithubDisabled bool `json:"githubDisabled"`
|
GithubDisabled *bool `json:"githubDisabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthView 返回脱敏后的 provider 配置。
|
// OAuthView 返回脱敏后的 provider 配置。
|
||||||
@@ -77,41 +80,232 @@ func boolFlag(on bool) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateOAuth 保存 provider 配置;issuer 规范化去尾斜杠,secret 加密落库。
|
// trimPtr / issuerPtr / flagPtr 把补丁字段规范化为存储值;nil 表示未出现不写。
|
||||||
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error {
|
func trimPtr(p *string) *string {
|
||||||
plain := map[string]string{
|
if p == nil {
|
||||||
settingOauthOidcIssuer: strings.TrimRight(strings.TrimSpace(in.OidcIssuer), "/"),
|
return nil
|
||||||
settingOauthOidcClientID: strings.TrimSpace(in.OidcClientID),
|
|
||||||
settingOauthOidcDisplayName: strings.TrimSpace(in.OidcDisplayName),
|
|
||||||
settingOauthOidcDisabled: boolFlag(in.OidcDisabled),
|
|
||||||
settingOauthGithubClientID: strings.TrimSpace(in.GithubClientID),
|
|
||||||
settingOauthGithubDisplayName: strings.TrimSpace(in.GithubDisplayName),
|
|
||||||
settingOauthGithubDisabled: boolFlag(in.GithubDisabled),
|
|
||||||
}
|
}
|
||||||
for key, value := range plain {
|
v := strings.TrimSpace(*p)
|
||||||
if err := s.set(ctx, key, value); err != nil {
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func issuerPtr(p *string) *string {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := strings.TrimRight(strings.TrimSpace(*p), "/")
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagPtr(p *bool) *string {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := boolFlag(*p)
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOAuth 部分更新 provider 配置:只落库非 nil 字段;issuer 规范化去尾斜杠,
|
||||||
|
// secret 加密落库(空串清除)。预检与写入在同一事务并持有认证变更共用的用户行锁:
|
||||||
|
// 密码登录禁用期间,禁止把最后可实际登录的方式禁用或清空(防自锁),
|
||||||
|
// 且不与「禁用密码 / 删除最后因子」并发交错。
|
||||||
|
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error {
|
||||||
|
return s.updateOAuth(ctx, in, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOAuthAuthenticated 在写事务持锁后复核请求令牌,防撤销后的慢请求落库。
|
||||||
|
func (s *SettingService) UpdateOAuthAuthenticated(
|
||||||
|
ctx context.Context, in UpdateOAuthInput, auth *AuthService, username string, proof TokenProof,
|
||||||
|
) error {
|
||||||
|
check := &authenticatedMutation{auth: auth, username: username, proof: proof}
|
||||||
|
return s.updateOAuth(ctx, in, check)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) updateOAuth(ctx context.Context, in UpdateOAuthInput, check *authenticatedMutation) error {
|
||||||
|
secrets, err := s.encryptOAuthSecrets(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if check != nil {
|
||||||
|
err = check.lockAndCheck(tx)
|
||||||
|
} else {
|
||||||
|
err = lockUsersForAuthChange(tx)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
origin, err := effectiveOriginTx(tx, s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureLoginRemainsTx(tx, in, origin); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeOAuthTx(tx, in, secrets)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptOAuthSecrets 预先加密补丁中的 secret(nil 沿用,空串清除),事务内直接落库。
|
||||||
|
func (s *SettingService) encryptOAuthSecrets(in UpdateOAuthInput) (map[string]*string, error) {
|
||||||
|
out := map[string]*string{}
|
||||||
|
for key, sec := range map[string]*string{
|
||||||
|
settingOauthOidcClientSecret: in.OidcClientSecret,
|
||||||
|
settingOauthGithubClientSecret: in.GithubClientSecret,
|
||||||
|
} {
|
||||||
|
if sec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := ""
|
||||||
|
if *sec != "" {
|
||||||
|
enc, err := s.cipher.EncryptString(*sec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encrypt oauth secret: %w", err)
|
||||||
|
}
|
||||||
|
v = enc
|
||||||
|
}
|
||||||
|
out[key] = &v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeOAuthTx 事务内落库补丁中出现的字段。
|
||||||
|
func writeOAuthTx(tx *gorm.DB, in UpdateOAuthInput, secrets map[string]*string) error {
|
||||||
|
writes := []struct {
|
||||||
|
key string
|
||||||
|
val *string
|
||||||
|
}{
|
||||||
|
{settingOauthOidcIssuer, issuerPtr(in.OidcIssuer)},
|
||||||
|
{settingOauthOidcClientID, trimPtr(in.OidcClientID)},
|
||||||
|
{settingOauthOidcClientSecret, secrets[settingOauthOidcClientSecret]},
|
||||||
|
{settingOauthOidcDisplayName, trimPtr(in.OidcDisplayName)},
|
||||||
|
{settingOauthOidcDisabled, flagPtr(in.OidcDisabled)},
|
||||||
|
{settingOauthGithubClientID, trimPtr(in.GithubClientID)},
|
||||||
|
{settingOauthGithubClientSecret, secrets[settingOauthGithubClientSecret]},
|
||||||
|
{settingOauthGithubDisplayName, trimPtr(in.GithubDisplayName)},
|
||||||
|
{settingOauthGithubDisabled, flagPtr(in.GithubDisabled)},
|
||||||
|
}
|
||||||
|
for _, w := range writes {
|
||||||
|
if w.val == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := saveSettingTx(tx, w.key, *w.val); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.saveOAuthSecret(ctx, settingOauthOidcClientSecret, in.OidcClientSecret); err != nil {
|
return nil
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.saveOAuthSecret(ctx, settingOauthGithubClientSecret, in.GithubClientSecret)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveOAuthSecret 加密保存 secret;nil 沿用,空串清除。
|
// ensureLoginRemainsTx 事务内校验补丁生效后仍有可实际登录的方式:密码可登直接放行;
|
||||||
func (s *SettingService) saveOAuthSecret(ctx context.Context, key string, secret *string) error {
|
// 否则须有「可登录且已绑定身份」的 provider、任一通行密钥或钱包身份
|
||||||
if secret == nil {
|
// (单管理员面板,不区分账号统计)。开关读取失败按失败关闭处理。
|
||||||
return nil
|
func ensureLoginRemainsTx(tx *gorm.DB, in UpdateOAuthInput, origin string) error {
|
||||||
|
off, err := settingValueTx(tx, settingSecPasswordLoginOff)
|
||||||
|
if err != nil || off != "1" {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if *secret == "" {
|
if origin == "" {
|
||||||
return s.set(ctx, key, "")
|
return ErrProviderLastLogin
|
||||||
}
|
}
|
||||||
enc, err := s.cipher.EncryptString(*secret)
|
view, err := oauthViewTx(tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt oauth secret: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
return s.set(ctx, key, enc)
|
if ok, err := anyBoundUsableProviderTx(tx, patchedUsable(view, in)); err != nil || ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 通行密钥兜底须当前地址下可用(origin 一致);0 表示不排除任何行
|
||||||
|
if n, err := passkeyCountExcludingTx(tx, 0, 0, origin); err != nil || n > 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := identityProviderCountTx(tx, "wallet")
|
||||||
|
if err != nil || n > 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ErrProviderLastLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthViewTx 事务内读 provider 配置视图(secret 只取「已设置」布尔)。
|
||||||
|
func oauthViewTx(tx *gorm.DB) (OAuthProvidersView, error) {
|
||||||
|
var view OAuthProvidersView
|
||||||
|
reads := []struct {
|
||||||
|
key string
|
||||||
|
set func(string)
|
||||||
|
}{
|
||||||
|
{settingOauthOidcIssuer, func(v string) { view.OidcIssuer = v }},
|
||||||
|
{settingOauthOidcClientID, func(v string) { view.OidcClientID = v }},
|
||||||
|
{settingOauthOidcClientSecret, func(v string) { view.OidcSecretSet = v != "" }},
|
||||||
|
{settingOauthOidcDisabled, func(v string) { view.OidcDisabled = v == "1" }},
|
||||||
|
{settingOauthGithubClientID, func(v string) { view.GithubClientID = v }},
|
||||||
|
{settingOauthGithubClientSecret, func(v string) { view.GithubSecretSet = v != "" }},
|
||||||
|
{settingOauthGithubDisabled, func(v string) { view.GithubDisabled = v == "1" }},
|
||||||
|
}
|
||||||
|
for _, r := range reads {
|
||||||
|
v, err := settingValueTx(tx, r.key)
|
||||||
|
if err != nil {
|
||||||
|
return view, err
|
||||||
|
}
|
||||||
|
r.set(v)
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchedOidcUsable 计算补丁生效后 OIDC 是否可登录(clientID/secret/issuer 齐备且未禁用)。
|
||||||
|
func patchedOidcUsable(view OAuthProvidersView, in UpdateOAuthInput) bool {
|
||||||
|
id, sec, iss, off := view.OidcClientID, view.OidcSecretSet, view.OidcIssuer, view.OidcDisabled
|
||||||
|
if v := trimPtr(in.OidcClientID); v != nil {
|
||||||
|
id = *v
|
||||||
|
}
|
||||||
|
if in.OidcClientSecret != nil {
|
||||||
|
sec = *in.OidcClientSecret != ""
|
||||||
|
}
|
||||||
|
if v := issuerPtr(in.OidcIssuer); v != nil {
|
||||||
|
iss = *v
|
||||||
|
}
|
||||||
|
if in.OidcDisabled != nil {
|
||||||
|
off = *in.OidcDisabled
|
||||||
|
}
|
||||||
|
return id != "" && sec && iss != "" && !off
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchedGithubUsable 计算补丁生效后 GitHub 是否可登录(clientID/secret 齐备且未禁用)。
|
||||||
|
func patchedGithubUsable(view OAuthProvidersView, in UpdateOAuthInput) bool {
|
||||||
|
id, sec, off := view.GithubClientID, view.GithubSecretSet, view.GithubDisabled
|
||||||
|
if v := trimPtr(in.GithubClientID); v != nil {
|
||||||
|
id = *v
|
||||||
|
}
|
||||||
|
if in.GithubClientSecret != nil {
|
||||||
|
sec = *in.GithubClientSecret != ""
|
||||||
|
}
|
||||||
|
if in.GithubDisabled != nil {
|
||||||
|
off = *in.GithubDisabled
|
||||||
|
}
|
||||||
|
return id != "" && sec && !off
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchedUsable 汇总补丁生效后各 provider 的可登录性。
|
||||||
|
func patchedUsable(view OAuthProvidersView, in UpdateOAuthInput) map[string]bool {
|
||||||
|
return map[string]bool{
|
||||||
|
"oidc": patchedOidcUsable(view, in),
|
||||||
|
"github": patchedGithubUsable(view, in),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyBoundUsableProviderTx 事务内判断是否存在「可登录且已有绑定身份」的 provider。
|
||||||
|
func anyBoundUsableProviderTx(tx *gorm.DB, usable map[string]bool) (bool, error) {
|
||||||
|
for p, ok := range usable {
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := identityProviderCountTx(tx, p)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// oauthClient 返回 provider 的 clientID/明文 secret/issuer(仅 oidc);未配置时 clientID 为空。
|
// oauthClient 返回 provider 的 clientID/明文 secret/issuer(仅 oidc);未配置时 clientID 为空。
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ func (s *OciConfigService) UpdateBucket(ctx context.Context, id uint, region, na
|
|||||||
return s.client.UpdateBucket(ctx, cred, region, name, in)
|
return s.client.UpdateBucket(ctx, cred, region, name, in)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteBucket 删除桶:空桶同步秒删;非空桶转后台清空(对象全部版本 + PAR)后删除,
|
// DeleteBucket 删除桶:空桶同步秒删;非空桶转后台清空(对象全部版本 + PAR +
|
||||||
// 返回 queued=true 表示已排队。
|
// 未完成分片)后删除,返回 queued=true 表示已排队。同一桶的清空任务全局单飞,
|
||||||
|
// 重复删除请求不再并发起新任务;执行权在触发方同步抢占,避免双触发窗口。
|
||||||
func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, name string) (bool, error) {
|
func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, name string) (bool, error) {
|
||||||
cred, err := s.credentialsByID(ctx, id)
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,16 +81,43 @@ func (s *OciConfigService) DeleteBucket(ctx context.Context, id uint, region, na
|
|||||||
if !errors.Is(err, oci.ErrBucketNotEmpty) {
|
if !errors.Is(err, oci.ErrBucketNotEmpty) {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
go s.purgeAndDeleteBucket(cred, region, name)
|
key := fmt.Sprintf("%d/%s/%s", id, region, name)
|
||||||
|
if !s.beginPurge(key) {
|
||||||
|
return true, nil // 已有清空任务在途,视为已排队
|
||||||
|
}
|
||||||
|
s.bindWG.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.bindWG.Done()
|
||||||
|
defer s.endPurge(key)
|
||||||
|
s.purgeAndDeleteBucket(cred, region, name)
|
||||||
|
}()
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// beginPurge / endPurge 维护桶清空在途注册表(键 cfgID/region/bucket)。
|
||||||
|
func (s *OciConfigService) beginPurge(key string) bool {
|
||||||
|
s.purgeMu.Lock()
|
||||||
|
defer s.purgeMu.Unlock()
|
||||||
|
if s.purgeInFlight[key] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.purgeInFlight[key] = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OciConfigService) endPurge(key string) {
|
||||||
|
s.purgeMu.Lock()
|
||||||
|
defer s.purgeMu.Unlock()
|
||||||
|
delete(s.purgeInFlight, key)
|
||||||
|
}
|
||||||
|
|
||||||
// purgeBucketTimeout 是后台清空删除桶的总时限;超大桶超时后可重试删除续跑。
|
// purgeBucketTimeout 是后台清空删除桶的总时限;超大桶超时后可重试删除续跑。
|
||||||
const purgeBucketTimeout = 30 * time.Minute
|
const purgeBucketTimeout = 30 * time.Minute
|
||||||
|
|
||||||
// purgeAndDeleteBucket 后台清空并删除桶;失败只记日志(删除请求已受理)。
|
// purgeAndDeleteBucket 后台清空并删除桶;失败只记日志(删除请求已受理)。
|
||||||
|
// 顺序:对象版本 → PAR → 未完成分片 → 删桶;分片不清会让空桶仍以非空拒绝删除。
|
||||||
func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, name string) {
|
func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, name string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout)
|
ctx, cancel := s.purgeContext()
|
||||||
defer cancel()
|
defer cancel()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
if err := s.purgeBucketVersions(ctx, cred, region, name); err != nil {
|
if err := s.purgeBucketVersions(ctx, cred, region, name); err != nil {
|
||||||
@@ -99,6 +127,9 @@ func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, na
|
|||||||
if err := s.purgeBucketPARs(ctx, cred, region, name); err != nil {
|
if err := s.purgeBucketPARs(ctx, cred, region, name); err != nil {
|
||||||
log.Printf("[bucket-purge] %s purge pars failed: %v", name, err)
|
log.Printf("[bucket-purge] %s purge pars failed: %v", name, err)
|
||||||
}
|
}
|
||||||
|
if err := s.client.AbortAllMultipartUploads(ctx, cred, region, name); err != nil {
|
||||||
|
log.Printf("[bucket-purge] %s abort multipart uploads failed: %v", name, err)
|
||||||
|
}
|
||||||
if err := s.client.DeleteBucket(ctx, cred, region, name); err != nil {
|
if err := s.client.DeleteBucket(ctx, cred, region, name); err != nil {
|
||||||
log.Printf("[bucket-purge] %s delete failed: %v", name, err)
|
log.Printf("[bucket-purge] %s delete failed: %v", name, err)
|
||||||
return
|
return
|
||||||
@@ -106,6 +137,21 @@ func (s *OciConfigService) purgeAndDeleteBucket(cred oci.Credentials, region, na
|
|||||||
log.Printf("[bucket-purge] %s purged and deleted in %s", name, time.Since(start).Round(time.Second))
|
log.Printf("[bucket-purge] %s purged and deleted in %s", name, time.Since(start).Round(time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// purgeContext 组装清空任务的 ctx:总超时之外叠加关服信号,
|
||||||
|
// 避免 Stop 等待在途清空长达整个超时窗口。
|
||||||
|
func (s *OciConfigService) purgeContext() (context.Context, context.CancelFunc) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), purgeBucketTimeout)
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-s.bindStop:
|
||||||
|
cancel()
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ctx, func() { close(done); cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
// purgeBucketVersions 逐页并发删除全部对象版本(未开版本控制的桶即当前对象);
|
// purgeBucketVersions 逐页并发删除全部对象版本(未开版本控制的桶即当前对象);
|
||||||
// 结束时记总量与耗时,便于判断慢在对象清空还是 PAR 清空。
|
// 结束时记总量与耗时,便于判断慢在对象清空还是 PAR 清空。
|
||||||
func (s *OciConfigService) purgeBucketVersions(ctx context.Context, cred oci.Credentials, region, name string) error {
|
func (s *OciConfigService) purgeBucketVersions(ctx context.Context, cred oci.Credentials, region, name string) error {
|
||||||
@@ -238,19 +284,34 @@ func (s *OciConfigService) PutObjectContent(ctx context.Context, id uint, region
|
|||||||
|
|
||||||
// parAccessTypes 是允许签发的 PAR 访问类型。
|
// parAccessTypes 是允许签发的 PAR 访问类型。
|
||||||
var parAccessTypes = map[string]bool{
|
var parAccessTypes = map[string]bool{
|
||||||
"ObjectRead": true, "ObjectWrite": true, "ObjectReadWrite": true, "AnyObjectReadWrite": true,
|
"ObjectRead": true, "ObjectWrite": true, "ObjectReadWrite": true,
|
||||||
|
"AnyObjectRead": true, "AnyObjectWrite": true, "AnyObjectReadWrite": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePAR 签发预签名请求;过期时长限制在 1 小时到 30 天。
|
// PAR 输入校验错误供 API 层稳定映射 400。
|
||||||
|
var (
|
||||||
|
ErrPARInvalidAccessType = errors.New("invalid PAR access type")
|
||||||
|
ErrPARInvalidExpiration = errors.New("invalid PAR expiration")
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxParHours 是 PAR 有效期安全上限(100 年):time.Duration 按小时乘会在
|
||||||
|
// ≈292 年(2562047h)溢出回绕为负,收在远低于溢出点又远超实际需求的位置。
|
||||||
|
const maxParHours = 100 * 365 * 24
|
||||||
|
|
||||||
|
// CreatePAR 签发预签名请求;过期时长 1 小时到 100 年(放开旧 30 天上限)。
|
||||||
func (s *OciConfigService) CreatePAR(ctx context.Context, id uint, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) {
|
func (s *OciConfigService) CreatePAR(ctx context.Context, id uint, region, bucket string, in oci.CreatePARInput) (oci.PAR, error) {
|
||||||
if !parAccessTypes[in.AccessType] {
|
if !parAccessTypes[in.AccessType] {
|
||||||
return oci.PAR{}, fmt.Errorf("create par: unsupported access type %q", in.AccessType)
|
return oci.PAR{}, fmt.Errorf("create par: %w: unsupported access type %q", ErrPARInvalidAccessType, in.AccessType)
|
||||||
}
|
}
|
||||||
if in.ExpiresHours < 1 || in.ExpiresHours > 30*24 {
|
if in.ExpiresHours < 1 || in.ExpiresHours > maxParHours {
|
||||||
return oci.PAR{}, fmt.Errorf("create par: expiresHours must be within 1-720")
|
return oci.PAR{}, fmt.Errorf("create par: %w: expiresHours must be within 1-%d", ErrPARInvalidExpiration, maxParHours)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(in.Name) == "" {
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
in.Name = "par-" + strings.ReplaceAll(in.ObjectName, "/", "-")
|
base := in.ObjectName
|
||||||
|
if base == "" {
|
||||||
|
base = "bucket-" + bucket
|
||||||
|
}
|
||||||
|
in.Name = "par-" + strings.ReplaceAll(base, "/", "-")
|
||||||
}
|
}
|
||||||
cred, err := s.credentialsByID(ctx, id)
|
cred, err := s.credentialsByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
"oci-portal/internal/oci"
|
"oci-portal/internal/oci"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,10 +28,19 @@ type osStubClient struct {
|
|||||||
putIfMatch string
|
putIfMatch string
|
||||||
|
|
||||||
// 后台清空删除桶的编排状态,goroutine 并发访问需加锁
|
// 后台清空删除桶的编排状态,goroutine 并发访问需加锁
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
versions []oci.ObjectVersion
|
versions []oci.ObjectVersion
|
||||||
pars []oci.PAR
|
pars []oci.PAR
|
||||||
bucketDeleted bool
|
bucketDeleted bool
|
||||||
|
multipartAborted bool
|
||||||
|
listCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *osStubClient) AbortAllMultipartUploads(ctx context.Context, cred oci.Credentials, region, bucket string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.multipartAborted = true
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *osStubClient) ListBuckets(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.Bucket, error) {
|
func (f *osStubClient) ListBuckets(ctx context.Context, cred oci.Credentials, region, compartmentID string) ([]oci.Bucket, error) {
|
||||||
@@ -62,6 +72,7 @@ func (f *osStubClient) DeleteBucket(ctx context.Context, cred oci.Credentials, r
|
|||||||
func (f *osStubClient) ListObjectVersions(ctx context.Context, cred oci.Credentials, region, bucket, page string) ([]oci.ObjectVersion, string, error) {
|
func (f *osStubClient) ListObjectVersions(ctx context.Context, cred oci.Credentials, region, bucket, page string) ([]oci.ObjectVersion, string, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
f.listCalls++
|
||||||
return append([]oci.ObjectVersion(nil), f.versions...), "", nil
|
return append([]oci.ObjectVersion(nil), f.versions...), "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,10 +181,20 @@ func TestObjectStorageValidation(t *testing.T) {
|
|||||||
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "Whatever", ExpiresHours: 2})
|
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "Whatever", ExpiresHours: 2})
|
||||||
return err
|
return err
|
||||||
}, wantErr: "unsupported access type"},
|
}, wantErr: "unsupported access type"},
|
||||||
{name: "PAR 时长越界", run: func() error {
|
{name: "PAR 时长过短", run: func() error {
|
||||||
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 800})
|
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0})
|
||||||
return err
|
return err
|
||||||
}, wantErr: "1-720"},
|
}, wantErr: "1-876000"},
|
||||||
|
// 旧 30 天上限已放开:10 年应放行
|
||||||
|
{name: "PAR 超长有效期放行", run: func() error {
|
||||||
|
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 24 * 3650})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
// 防 time.Duration 溢出的安全上限(100 年)仍要拦截
|
||||||
|
{name: "PAR 超安全上限拒绝", run: func() error {
|
||||||
|
_, err := svc.CreatePAR(ctx, cfg.ID, "r", "b", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1})
|
||||||
|
return err
|
||||||
|
}, wantErr: "1-876000"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -403,3 +424,118 @@ func TestCreatePARDefaultsName(t *testing.T) {
|
|||||||
t.Errorf("FullURL empty, want populated")
|
t.Errorf("FullURL empty, want populated")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreatePARAccessTypes 桶级 AnyObject* 必须放行(分享桶 500 复盘),非法类型拒绝。
|
||||||
|
func TestCreatePARAccessTypes(t *testing.T) {
|
||||||
|
client := &osStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
accessType string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"AnyObjectRead", false}, {"AnyObjectWrite", false}, {"AnyObjectReadWrite", false},
|
||||||
|
{"BucketRead", true}, {"", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
_, err := svc.CreatePAR(context.Background(), cfg.ID, "r", "b",
|
||||||
|
oci.CreatePARInput{AccessType: tc.accessType, ExpiresHours: 24})
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Errorf("CreatePAR(%q) err = %v, wantErr %v", tc.accessType, err, tc.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if client.createdPAR.Name != "par-bucket-b" {
|
||||||
|
t.Errorf("bucket par default name = %q, want par-bucket-b", client.createdPAR.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePARValidationSentinels(t *testing.T) {
|
||||||
|
svc := &OciConfigService{}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in oci.CreatePARInput
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"access type", oci.CreatePARInput{AccessType: "BucketRead", ExpiresHours: 1}, ErrPARInvalidAccessType},
|
||||||
|
{"expiration low", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: 0}, ErrPARInvalidExpiration},
|
||||||
|
{"expiration high", oci.CreatePARInput{AccessType: "ObjectRead", ExpiresHours: maxParHours + 1}, ErrPARInvalidExpiration},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
_, err := svc.CreatePAR(context.Background(), 1, "r", "b", tc.in)
|
||||||
|
if !errors.Is(err, tc.want) {
|
||||||
|
t.Errorf("%s err = %v, want errors.Is(%v)", tc.name, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteBucketPurgeSingleflight 锁定同桶清空单飞:任务在途时重复删除请求
|
||||||
|
// 按已排队返回,不并发起第二个清空任务。
|
||||||
|
func TestDeleteBucketPurgeSingleflight(t *testing.T) {
|
||||||
|
client := &osStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}},
|
||||||
|
}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
key := fmt.Sprintf("%d/r/b", cfg.ID)
|
||||||
|
if !svc.beginPurge(key) {
|
||||||
|
t.Fatal("beginPurge 首次抢占失败")
|
||||||
|
}
|
||||||
|
queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b")
|
||||||
|
if err != nil || !queued {
|
||||||
|
t.Fatalf("在途时 DeleteBucket = queued %v, %v, want queued", queued, err)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
client.mu.Lock()
|
||||||
|
calls := client.listCalls
|
||||||
|
client.mu.Unlock()
|
||||||
|
if calls != 0 {
|
||||||
|
t.Fatalf("在途期间起了新清空任务: listCalls=%d, want 0", calls)
|
||||||
|
}
|
||||||
|
svc.endPurge(key)
|
||||||
|
if !svc.beginPurge(key) {
|
||||||
|
t.Error("endPurge 后应可重新抢占")
|
||||||
|
}
|
||||||
|
svc.endPurge(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteBucketPurgeAbortsMultipart 锁定清空顺序包含中止未完成分片上传。
|
||||||
|
func TestDeleteBucketPurgeAbortsMultipart(t *testing.T) {
|
||||||
|
client := &osStubClient{
|
||||||
|
fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
|
||||||
|
versions: []oci.ObjectVersion{{Name: "a.txt", VersionID: "v1"}},
|
||||||
|
}
|
||||||
|
svc := newTestService(t, client)
|
||||||
|
cfg := importAliveConfig(t, svc)
|
||||||
|
if queued, err := svc.DeleteBucket(context.Background(), cfg.ID, "r", "b"); err != nil || !queued {
|
||||||
|
t.Fatalf("DeleteBucket = queued %v, %v, want queued", queued, err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
client.mu.Lock()
|
||||||
|
done := client.bucketDeleted && client.multipartAborted
|
||||||
|
client.mu.Unlock()
|
||||||
|
if done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("purge 未在期限内完成分片中止与删桶")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportRejectsMissingProxy 锁定导入防线:关联不存在的代理直接拒绝,
|
||||||
|
// 不落库也不发起绕过代理的直连测活。
|
||||||
|
func TestImportRejectsMissingProxy(t *testing.T) {
|
||||||
|
svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}})
|
||||||
|
in := trialImportInput()
|
||||||
|
missing := uint(999)
|
||||||
|
in.ProxyID = &missing
|
||||||
|
if _, err := svc.Import(context.Background(), in); err == nil {
|
||||||
|
t.Fatal("Import 关联不存在的代理应报错")
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if err := svc.db.Model(&model.OciConfig{}).Count(&n).Error; err != nil || n != 0 {
|
||||||
|
t.Fatalf("拒绝导入后配置数 = %d (%v), want 0", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ type OciConfigService struct {
|
|||||||
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
|
// auditRaw 按 configId:eventId 暂存审计原始事件(TTL 10 分钟),
|
||||||
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
|
// 列表响应剥离 raw 后详情接口据此秒开;miss 走小窗重查兜底。
|
||||||
auditRaw *cache.Cache
|
auditRaw *cache.Cache
|
||||||
// bindWG 追踪保留 IP 后台绑定 goroutine;bindStop 关服时令其提前退出。
|
// bindWG 追踪保留 IP 绑定与桶清空等后台 goroutine;bindStop 关服时令其提前退出。
|
||||||
bindWG sync.WaitGroup
|
bindWG sync.WaitGroup
|
||||||
bindStop chan struct{}
|
bindStop chan struct{}
|
||||||
|
// purgeMu/purgeInFlight 是桶清空在途注册表:同一桶(cfg/region/bucket)单飞。
|
||||||
|
purgeMu sync.Mutex
|
||||||
|
purgeInFlight map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOciConfigService 组装依赖。
|
// NewOciConfigService 组装依赖。
|
||||||
@@ -34,6 +37,7 @@ func NewOciConfigService(db *gorm.DB, cipher *crypto.Cipher, client oci.Client)
|
|||||||
return &OciConfigService{
|
return &OciConfigService{
|
||||||
db: db, cipher: cipher, client: client,
|
db: db, cipher: cipher, client: client,
|
||||||
auditRaw: cache.New(auditRawMax), bindStop: make(chan struct{}),
|
auditRaw: cache.New(auditRawMax), bindStop: make(chan struct{}),
|
||||||
|
purgeInFlight: map[string]bool{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +75,12 @@ func (s *OciConfigService) Import(ctx context.Context, in ImportInput) (*model.O
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 代理先于任何远端访问解析并挂进凭据:不存在/非法的代理直接拒绝导入,
|
||||||
|
// 避免首次测活绕过代理直连,泄露真实出口
|
||||||
|
cred.Proxy, err = s.proxySpecOf(in.ProxyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
cfg, err := s.storeConfig(in.Alias, cred)
|
cfg, err := s.storeConfig(in.Alias, cred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -427,7 +437,7 @@ func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials,
|
|||||||
return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err)
|
return oci.Credentials{}, fmt.Errorf("decrypt passphrase of config %d: %w", cfg.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
spec, err := s.proxySpecOf(cfg)
|
spec, err := s.proxySpecOf(cfg.ProxyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oci.Credentials{}, err
|
return oci.Credentials{}, err
|
||||||
}
|
}
|
||||||
@@ -442,15 +452,15 @@ func (s *OciConfigService) credentialsOf(cfg *model.OciConfig) (oci.Credentials,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// proxySpecOf 加载租户关联的出站代理;未关联返回 nil(直连)。
|
// proxySpecOf 加载关联的出站代理;未关联返回 nil(直连)。
|
||||||
// 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。
|
// 代理行缺失或解密失败时报错而非静默直连,避免期望走代理的流量泄露真实出口。
|
||||||
func (s *OciConfigService) proxySpecOf(cfg *model.OciConfig) (*oci.ProxySpec, error) {
|
func (s *OciConfigService) proxySpecOf(proxyID *uint) (*oci.ProxySpec, error) {
|
||||||
if cfg.ProxyID == nil {
|
if proxyID == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var row model.Proxy
|
var row model.Proxy
|
||||||
if err := s.db.First(&row, *cfg.ProxyID).Error; err != nil {
|
if err := s.db.First(&row, *proxyID).Error; err != nil {
|
||||||
return nil, fmt.Errorf("find proxy %d of config %d: %w", *cfg.ProxyID, cfg.ID, err)
|
return nil, fmt.Errorf("find proxy %d: %w", *proxyID, err)
|
||||||
}
|
}
|
||||||
password := ""
|
password := ""
|
||||||
if row.PasswordEnc != "" {
|
if row.PasswordEnc != "" {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
@@ -32,14 +33,23 @@ type OverviewCostDay struct {
|
|||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OverviewCost 是近 7 日成本快照聚合;金额跨配置直接相加,
|
// OverviewCost 是近 7 日成本快照聚合;同币种金额跨配置相加,跨币种拆 Series。
|
||||||
// Currency 取快照中出现的第一个币种(混合币种时以此为准展示)。
|
|
||||||
type OverviewCost struct {
|
type OverviewCost struct {
|
||||||
HasActiveTask bool `json:"hasActiveTask"`
|
HasActiveTask bool `json:"hasActiveTask"`
|
||||||
CoveredConfigs int `json:"coveredConfigs"`
|
CoveredConfigs int `json:"coveredConfigs"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
Total float64 `json:"total"`
|
Total float64 `json:"total"`
|
||||||
Days []OverviewCostDay `json:"days"`
|
Days []OverviewCostDay `json:"days"`
|
||||||
|
// Series 按币种拆分的序列(合计降序);顶层 Currency/Total/Days 恒为首个
|
||||||
|
// (主)币种,多币种租户的其余币种只出现在 Series,不与主币种相加。
|
||||||
|
Series []OverviewCostSeries `json:"series"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverviewCostSeries 是单一币种的成本序列;跨币种金额不可直接相加。
|
||||||
|
type OverviewCostSeries struct {
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
Days []OverviewCostDay `json:"days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OverviewTasks 是后台任务数量统计。
|
// OverviewTasks 是后台任务数量统计。
|
||||||
@@ -150,29 +160,53 @@ func (s *OciConfigService) overviewCost(ctx context.Context, out *Overview) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// aggregateCostDays 把逐配置逐日的快照聚合为跨租户的每日序列(snaps 须按 day 升序)。
|
// aggregateCostDays 把逐配置逐日的快照按币种聚合(snaps 须按 day 升序)。
|
||||||
|
// 不同币种金额不可直接相加:按币种拆序列,顶层 Currency/Total/Days 取合计最大的
|
||||||
|
// 主币种,其余币种只出现在 Series 里。
|
||||||
func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) {
|
func aggregateCostDays(cost *OverviewCost, snaps []model.CostSnapshot) {
|
||||||
byDay := map[string]float64{}
|
perCur := map[string]map[string]float64{}
|
||||||
|
dayOrder := map[string][]string{}
|
||||||
covered := map[uint]bool{}
|
covered := map[uint]bool{}
|
||||||
var order []string
|
|
||||||
for _, snap := range snaps {
|
for _, snap := range snaps {
|
||||||
|
byDay := perCur[snap.Currency]
|
||||||
|
if byDay == nil {
|
||||||
|
byDay = map[string]float64{}
|
||||||
|
perCur[snap.Currency] = byDay
|
||||||
|
}
|
||||||
if _, ok := byDay[snap.Day]; !ok {
|
if _, ok := byDay[snap.Day]; !ok {
|
||||||
order = append(order, snap.Day)
|
dayOrder[snap.Currency] = append(dayOrder[snap.Currency], snap.Day)
|
||||||
}
|
}
|
||||||
byDay[snap.Day] += snap.Amount
|
byDay[snap.Day] += snap.Amount
|
||||||
covered[snap.OciConfigID] = true
|
covered[snap.OciConfigID] = true
|
||||||
if cost.Currency == "" {
|
|
||||||
cost.Currency = snap.Currency
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
cost.CoveredConfigs = len(covered)
|
cost.CoveredConfigs = len(covered)
|
||||||
cost.Days = make([]OverviewCostDay, 0, len(order))
|
cost.Days = []OverviewCostDay{}
|
||||||
for _, day := range order {
|
cost.Series = costSeries(perCur, dayOrder)
|
||||||
cost.Days = append(cost.Days, OverviewCostDay{Day: day, Amount: byDay[day]})
|
if len(cost.Series) > 0 {
|
||||||
cost.Total += byDay[day]
|
cost.Currency, cost.Total, cost.Days = cost.Series[0].Currency, cost.Series[0].Total, cost.Series[0].Days
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// costSeries 组装各币种序列并按合计降序(相同合计按币种名,保证稳定输出)。
|
||||||
|
func costSeries(perCur map[string]map[string]float64, dayOrder map[string][]string) []OverviewCostSeries {
|
||||||
|
series := make([]OverviewCostSeries, 0, len(perCur))
|
||||||
|
for currency, byDay := range perCur {
|
||||||
|
item := OverviewCostSeries{Currency: currency, Days: make([]OverviewCostDay, 0, len(byDay))}
|
||||||
|
for _, day := range dayOrder[currency] {
|
||||||
|
item.Days = append(item.Days, OverviewCostDay{Day: day, Amount: byDay[day]})
|
||||||
|
item.Total += byDay[day]
|
||||||
|
}
|
||||||
|
series = append(series, item)
|
||||||
|
}
|
||||||
|
sort.Slice(series, func(i, j int) bool {
|
||||||
|
if series[i].Total != series[j].Total {
|
||||||
|
return series[i].Total > series[j].Total
|
||||||
|
}
|
||||||
|
return series[i].Currency < series[j].Currency
|
||||||
|
})
|
||||||
|
return series
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error {
|
func (s *OciConfigService) overviewTasks(ctx context.Context, out *Overview) error {
|
||||||
var tasks []model.Task
|
var tasks []model.Task
|
||||||
if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil {
|
if err := s.db.WithContext(ctx).Find(&tasks).Error; err != nil {
|
||||||
|
|||||||
@@ -170,3 +170,26 @@ func TestOverviewAggregates(t *testing.T) {
|
|||||||
t.Errorf("cost days = %d, want 1", len(out.Cost.Days))
|
t.Errorf("cost days = %d, want 1", len(out.Cost.Days))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAggregateCostDaysSplitsCurrencies 锁定多币种聚合:不同币种不相加,
|
||||||
|
// 顶层字段恒为合计最大的主币种,其余币种进 Series。
|
||||||
|
func TestAggregateCostDaysSplitsCurrencies(t *testing.T) {
|
||||||
|
snaps := []model.CostSnapshot{
|
||||||
|
{OciConfigID: 1, Day: "2026-07-20", Amount: 1.5, Currency: "USD"},
|
||||||
|
{OciConfigID: 2, Day: "2026-07-20", Amount: 9.0, Currency: "EUR"},
|
||||||
|
{OciConfigID: 1, Day: "2026-07-21", Amount: 2.5, Currency: "USD"},
|
||||||
|
{OciConfigID: 3, Day: "2026-07-21", Amount: 3.0, Currency: "USD"},
|
||||||
|
}
|
||||||
|
var cost OverviewCost
|
||||||
|
aggregateCostDays(&cost, snaps)
|
||||||
|
if cost.CoveredConfigs != 3 || len(cost.Series) != 2 {
|
||||||
|
t.Fatalf("covered=%d series=%d, want 3, 2", cost.CoveredConfigs, len(cost.Series))
|
||||||
|
}
|
||||||
|
if cost.Currency != "EUR" || cost.Total != 9.0 {
|
||||||
|
t.Errorf("主币种 = %s %.1f, want EUR 9.0(合计最大)", cost.Currency, cost.Total)
|
||||||
|
}
|
||||||
|
usd := cost.Series[1]
|
||||||
|
if usd.Currency != "USD" || usd.Total != 7.0 || len(usd.Days) != 2 || usd.Days[1].Amount != 5.5 {
|
||||||
|
t.Errorf("USD 序列 = %+v, want 两日合计 7.0 且 21 日 5.5", usd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 通行密钥流程错误;api 层映射为用户可读提示。
|
||||||
|
var (
|
||||||
|
// ErrPasskeyNoAppURL 表示面板地址缺失,RP ID 无从派生。
|
||||||
|
ErrPasskeyNoAppURL = errors.New("面板地址未设置,请先在「设置 → 安全 → 网络与地址」保存面板地址")
|
||||||
|
// ErrPasskeySession 表示挑战会话无效或已过期(一次性消费)。
|
||||||
|
ErrPasskeySession = errors.New("通行密钥会话无效或已过期,请重新发起")
|
||||||
|
// ErrPasskeyVerify 表示凭据校验失败;不透出库层细节防探测。
|
||||||
|
ErrPasskeyVerify = errors.New("通行密钥校验失败")
|
||||||
|
// ErrPasskeyLimit 表示已达单账号凭据数量上限。
|
||||||
|
ErrPasskeyLimit = errors.New("通行密钥数量已达上限,请先删除不用的")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// passkeyPendingTTL 是挑战会话有效期(WebAuthn 默认超时同量级)。
|
||||||
|
passkeyPendingTTL = 5 * time.Minute
|
||||||
|
// passkeyMaxPerUser 是单账号凭据上限,防滥用堆积。
|
||||||
|
passkeyMaxPerUser = 10
|
||||||
|
// passkeyGuardUser 是登录守卫的用户名占位:discoverable 登录失败时账号未知。
|
||||||
|
passkeyGuardUser = "__passkey__"
|
||||||
|
)
|
||||||
|
|
||||||
|
// passkeyPending 是一次进行中的 WebAuthn 仪式上下文;sessionId 一次性使用。
|
||||||
|
type passkeyPending struct {
|
||||||
|
session webauthn.SessionData
|
||||||
|
username string // 注册会话的归属账号;登录会话为空串
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyService 承接通行密钥(WebAuthn)注册、登录与凭据管理。
|
||||||
|
type PasskeyService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
settings *SettingService
|
||||||
|
auth *AuthService
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
pending map[string]passkeyPending
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPasskeyService 组装依赖。
|
||||||
|
func NewPasskeyService(db *gorm.DB, settings *SettingService, auth *AuthService) *PasskeyService {
|
||||||
|
return &PasskeyService{db: db, settings: settings, auth: auth, pending: map[string]passkeyPending{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rp 按当前面板地址构造 WebAuthn 实例(RP ID = 域名,Origin = 完整来源);
|
||||||
|
// 每次现算使「面板地址」设置变更即时生效,构造仅做配置校验、代价可忽略。
|
||||||
|
func (p *PasskeyService) rp() (*webauthn.WebAuthn, error) {
|
||||||
|
app := p.settings.EffectiveAppURL()
|
||||||
|
if app == "" {
|
||||||
|
return nil, ErrPasskeyNoAppURL
|
||||||
|
}
|
||||||
|
u, err := url.Parse(app)
|
||||||
|
if err != nil || u.Hostname() == "" {
|
||||||
|
return nil, fmt.Errorf("parse app url: %w", err)
|
||||||
|
}
|
||||||
|
return webauthn.New(&webauthn.Config{
|
||||||
|
RPID: u.Hostname(),
|
||||||
|
RPDisplayName: "OCI Portal",
|
||||||
|
RPOrigins: []string{u.Scheme + "://" + u.Host},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyUserHandle 是 WebAuthn user.id:用户主键的 8 字节大端序(改用户名不漂移)。
|
||||||
|
func passkeyUserHandle(id uint) []byte {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(b, uint64(id))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyUser 以账号与其凭据集实现 webauthn.User。
|
||||||
|
type passkeyUser struct {
|
||||||
|
user model.User
|
||||||
|
keys []model.UserPasskey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u passkeyUser) WebAuthnID() []byte { return passkeyUserHandle(u.user.ID) }
|
||||||
|
func (u passkeyUser) WebAuthnName() string { return u.user.Username }
|
||||||
|
func (u passkeyUser) WebAuthnDisplayName() string { return u.user.Username }
|
||||||
|
|
||||||
|
// WebAuthnCredentials 反序列化各行凭据 JSON;坏行跳过不阻断整体。
|
||||||
|
func (u passkeyUser) WebAuthnCredentials() []webauthn.Credential {
|
||||||
|
out := make([]webauthn.Credential, 0, len(u.keys))
|
||||||
|
for _, k := range u.keys {
|
||||||
|
var c webauthn.Credential
|
||||||
|
if json.Unmarshal([]byte(k.Credential), &c) == nil {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// exclusions 生成注册排除清单,阻止同一验证器重复注册。
|
||||||
|
func (u passkeyUser) exclusions() []protocol.CredentialDescriptor {
|
||||||
|
out := []protocol.CredentialDescriptor{}
|
||||||
|
for _, c := range u.WebAuthnCredentials() {
|
||||||
|
out = append(out, c.Descriptor())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// putPending 登记一次性挑战会话,返回下发给前端的 sessionId。
|
||||||
|
func (p *PasskeyService) putPending(session webauthn.SessionData, username string) (string, error) {
|
||||||
|
id, err := randHex(16)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.gcPasskeyLocked()
|
||||||
|
p.pending[id] = passkeyPending{session: session, username: username, expires: time.Now().Add(passkeyPendingTTL)}
|
||||||
|
p.mu.Unlock()
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// takePending 取出并消费会话(一次性);不存在或过期视为无效。
|
||||||
|
func (p *PasskeyService) takePending(id string) (passkeyPending, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
entry, ok := p.pending[id]
|
||||||
|
delete(p.pending, id)
|
||||||
|
if !ok || time.Now().After(entry.expires) {
|
||||||
|
return passkeyPending{}, ErrPasskeySession
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcPasskeyLocked 清理过期会话;调用方须持锁。
|
||||||
|
func (p *PasskeyService) gcPasskeyLocked() {
|
||||||
|
now := time.Now()
|
||||||
|
for k, e := range p.pending {
|
||||||
|
if now.After(e.expires) {
|
||||||
|
delete(p.pending, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadUser 载入账号与全部凭据。
|
||||||
|
func (p *PasskeyService) loadUser(ctx context.Context, username string) (passkeyUser, error) {
|
||||||
|
user, err := p.auth.findUser(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return passkeyUser{}, err
|
||||||
|
}
|
||||||
|
return p.attachKeys(ctx, *user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachKeys 挂载账号的凭据行。
|
||||||
|
func (p *PasskeyService) attachKeys(ctx context.Context, user model.User) (passkeyUser, error) {
|
||||||
|
keys := []model.UserPasskey{}
|
||||||
|
err := p.db.WithContext(ctx).Where("user_id = ?", user.ID).Order("id").Find(&keys).Error
|
||||||
|
if err != nil {
|
||||||
|
return passkeyUser{}, fmt.Errorf("list passkeys: %w", err)
|
||||||
|
}
|
||||||
|
return passkeyUser{user: user, keys: keys}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginRegister 生成注册 options;ResidentKey 与用户验证均必需——
|
||||||
|
// 可发现凭据是免用户名登录的前提,UV 是 Passkey 登录豁免 TOTP 的前提。
|
||||||
|
func (p *PasskeyService) BeginRegister(ctx context.Context, username string) (string, *protocol.CredentialCreation, error) {
|
||||||
|
w, err := p.rp()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
u, err := p.loadUser(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if len(u.keys) >= passkeyMaxPerUser {
|
||||||
|
return "", nil, ErrPasskeyLimit
|
||||||
|
}
|
||||||
|
opts, session, err := w.BeginRegistration(u,
|
||||||
|
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
||||||
|
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||||
|
UserVerification: protocol.VerificationRequired,
|
||||||
|
}),
|
||||||
|
webauthn.WithExclusions(u.exclusions()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("begin registration: %w", err)
|
||||||
|
}
|
||||||
|
id, err := p.putPending(*session, username)
|
||||||
|
return id, opts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishRegister 校验注册响应并落库;成功后令牌版本递增(旧会话全部失效)。
|
||||||
|
func (p *PasskeyService) FinishRegister(ctx context.Context, username, sessionID, name string, body io.Reader, proof TokenProof) error {
|
||||||
|
entry, err := p.takePending(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry.username != username {
|
||||||
|
return ErrPasskeySession
|
||||||
|
}
|
||||||
|
w, err := p.rp()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u, err := p.loadUser(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed, err := protocol.ParseCredentialCreationResponseBody(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrPasskeyVerify, err)
|
||||||
|
}
|
||||||
|
cred, err := w.CreateCredential(u, entry.session, parsed)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrPasskeyVerify, err)
|
||||||
|
}
|
||||||
|
// 凭据落库与令牌版本递增同事务提交,不留「因子已生效而旧令牌仍有效」的
|
||||||
|
// 半程状态;行锁下复核 proof,请求挂起期间令牌被撤销则整体拒绝
|
||||||
|
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserForAuthChange(tx, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.auth.ensureTokenCurrentTx(tx, user, proof); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := saveCredentialTx(tx, user.ID, name, p.currentOrigin(), cred); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return bumpTokenVersionTx(tx, username)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentOrigin 是当前面板地址的 WebAuthn origin(scheme://host);
|
||||||
|
// 注册时随凭据落库,作为该凭据可用性的判定依据。
|
||||||
|
func (p *PasskeyService) currentOrigin() string {
|
||||||
|
return originOf(p.settings.EffectiveAppURL())
|
||||||
|
}
|
||||||
|
|
||||||
|
// passkeyCredHash 是凭据 ID 的 SHA-256 hex:原始 ID 最长可达 1023 字节,
|
||||||
|
// 编码后超出可索引长度,唯一约束与登录反查一律走定长哈希列。
|
||||||
|
func passkeyCredHash(id []byte) string {
|
||||||
|
sum := sha256.Sum256(id)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveCredentialTx 序列化凭据整段落库(事务内);名称缺省给默认值。
|
||||||
|
func saveCredentialTx(tx *gorm.DB, userID uint, name, origin string, cred *webauthn.Credential) error {
|
||||||
|
raw, err := json.Marshal(cred)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal credential: %w", err)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "通行密钥"
|
||||||
|
}
|
||||||
|
row := model.UserPasskey{
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID),
|
||||||
|
CredentialIDHash: passkeyCredHash(cred.ID),
|
||||||
|
Origin: origin,
|
||||||
|
Credential: string(raw),
|
||||||
|
}
|
||||||
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
|
return fmt.Errorf("save passkey: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginLogin 生成断言 options(公开端点;无凭据也正常下发,不泄露账号状态)。
|
||||||
|
func (p *PasskeyService) BeginLogin(_ context.Context) (string, *protocol.CredentialAssertion, error) {
|
||||||
|
w, err := p.rp()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
opts, session, err := w.BeginDiscoverableLogin(
|
||||||
|
webauthn.WithUserVerification(protocol.VerificationRequired))
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("begin login: %w", err)
|
||||||
|
}
|
||||||
|
id, err := p.putPending(*session, "")
|
||||||
|
return id, opts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishLogin 校验断言并签发 JWT(落地会话),返回登录账号名供留痕;
|
||||||
|
// 失败按 IP 计入登录守卫(账号未知用占位名),锁定期内一律 ErrLoginLocked。
|
||||||
|
func (p *PasskeyService) FinishLogin(ctx context.Context, sessionID string, meta SessionMeta, body io.Reader) (string, time.Time, string, error) {
|
||||||
|
key := guardKey(meta.ClientIP, passkeyGuardUser)
|
||||||
|
now := time.Now()
|
||||||
|
sec := securityOf(p.auth.settings)
|
||||||
|
if p.auth.guard.locked(key, now, time.Duration(sec.LoginLockMinutes)*time.Minute) {
|
||||||
|
return "", time.Time{}, "", ErrLoginLocked
|
||||||
|
}
|
||||||
|
user, err := p.validateLogin(ctx, sessionID, body)
|
||||||
|
if err != nil {
|
||||||
|
if lockErr := p.auth.failLogin(key, now, passkeyGuardUser, meta.ClientIP, sec); errors.Is(lockErr, ErrLoginLocked) {
|
||||||
|
return "", time.Time{}, "", ErrLoginLocked
|
||||||
|
}
|
||||||
|
return "", time.Time{}, "", err
|
||||||
|
}
|
||||||
|
p.auth.guard.success(key)
|
||||||
|
meta.Method = "passkey"
|
||||||
|
token, expires, err := p.auth.signSessionToken(ctx, user, meta)
|
||||||
|
return token, expires, user.Username, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateLogin 消费会话并校验断言;成功后回写凭据状态。
|
||||||
|
func (p *PasskeyService) validateLogin(ctx context.Context, sessionID string, body io.Reader) (*model.User, error) {
|
||||||
|
entry, err := p.takePending(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if entry.username != "" {
|
||||||
|
return nil, ErrPasskeySession // 注册会话不可用于登录
|
||||||
|
}
|
||||||
|
w, err := p.rp()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parsed, err := protocol.ParseCredentialRequestResponseBody(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrPasskeyVerify, err)
|
||||||
|
}
|
||||||
|
var owner *model.User
|
||||||
|
handler := func(_, userHandle []byte) (webauthn.User, error) {
|
||||||
|
u, err := p.userByHandle(ctx, userHandle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
owner = &u.user
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
cred, err := w.ValidateDiscoverableLogin(handler, entry.session, parsed)
|
||||||
|
if err != nil || owner == nil {
|
||||||
|
return nil, ErrPasskeyVerify // 统一文案,不透出细节防探测
|
||||||
|
}
|
||||||
|
p.touchCredential(ctx, cred)
|
||||||
|
return owner, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userByHandle 按 userHandle(用户主键大端 8 字节)载入用户与凭据。
|
||||||
|
func (p *PasskeyService) userByHandle(ctx context.Context, handle []byte) (passkeyUser, error) {
|
||||||
|
if len(handle) != 8 {
|
||||||
|
return passkeyUser{}, ErrPasskeyVerify
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
err := p.db.WithContext(ctx).First(&user, uint(binary.BigEndian.Uint64(handle))).Error
|
||||||
|
if err != nil {
|
||||||
|
return passkeyUser{}, fmt.Errorf("find user by handle: %w", err)
|
||||||
|
}
|
||||||
|
return p.attachKeys(ctx, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchCredential 回写校验后的 signCount/flags 与最后使用时间;失败不阻断登录。
|
||||||
|
func (p *PasskeyService) touchCredential(ctx context.Context, cred *webauthn.Credential) {
|
||||||
|
raw, err := json.Marshal(cred)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.db.WithContext(ctx).Model(&model.UserPasskey{}).
|
||||||
|
Where("credential_id_hash = ?", passkeyCredHash(cred.ID)).
|
||||||
|
Updates(map[string]any{"credential": string(raw), "last_used_at": time.Now()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 列出账号的通行密钥。
|
||||||
|
func (p *PasskeyService) List(ctx context.Context, username string) ([]model.UserPasskey, error) {
|
||||||
|
u, err := p.loadUser(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u.keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove 删除通行密钥(校验归属)并递增令牌版本;
|
||||||
|
// 密码登录禁用期间通行密钥计入「至少一种登录方式」不变量,删除受防自锁保护。
|
||||||
|
func (p *PasskeyService) Remove(ctx context.Context, username string, id uint, proof TokenProof) error {
|
||||||
|
err := p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserForAuthChange(tx, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.auth.ensureTokenCurrentTx(tx, user, proof); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureNotLastPasskey(tx, user.ID, id, p.currentOrigin()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res := tx.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserPasskey{})
|
||||||
|
if res.Error != nil {
|
||||||
|
return fmt.Errorf("remove passkey: %w", res.Error)
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
// 删除属敏感变更:版本递增与删除同事务提交,不留「已删而旧令牌仍有效」半程
|
||||||
|
return bumpTokenVersionTx(tx, username)
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureNotLastPasskey 事务内校验不变量:密码登录已禁用时,删除该钥匙后
|
||||||
|
// 须仍存在可实际登录的免密方式(provider 被禁用的身份不算),否则拒绝;
|
||||||
|
// 开关读取失败按失败关闭处理,不允许失败放行。
|
||||||
|
func ensureNotLastPasskey(tx *gorm.DB, userID, passkeyID uint, origin string) error {
|
||||||
|
off, err := settingValueTx(tx, settingSecPasswordLoginOff)
|
||||||
|
if err != nil || off != "1" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok, err := usablePasswordlessTx(tx, userID, 0, passkeyID, origin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return ErrLastIdentity
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAny 报告当前面板 origin 下是否存在可用凭据;地址缺失或查询失败按无处理。
|
||||||
|
func (p *PasskeyService) HasAny(ctx context.Context) bool {
|
||||||
|
if p.settings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
origin := p.currentOrigin()
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
err := p.db.WithContext(ctx).Model(&model.UserPasskey{}).
|
||||||
|
Where("origin = ?", origin).Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"oci-portal/internal/crypto"
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestPasskey 组装内存库上的 PasskeyService(admin 账号已建,面板地址经环境回退注入)。
|
||||||
|
func newTestPasskey(t *testing.T, appURL string) *PasskeyService {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open in-memory sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.UserPasskey{}, &model.UserIdentity{}, &model.UserSession{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate: %v", err)
|
||||||
|
}
|
||||||
|
auth := NewAuthService(db, "test-jwt-secret")
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("ensure admin: %v", err)
|
||||||
|
}
|
||||||
|
cipher, err := crypto.NewCipher("test-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new cipher: %v", err)
|
||||||
|
}
|
||||||
|
settings := NewSettingService(db, cipher)
|
||||||
|
settings.SetEnvPublicURL(appURL)
|
||||||
|
return NewPasskeyService(db, settings, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyRPDerivation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
appURL string
|
||||||
|
wantErr error
|
||||||
|
wantRPID string
|
||||||
|
wantOrigin string
|
||||||
|
}{
|
||||||
|
{name: "无面板地址", appURL: "", wantErr: ErrPasskeyNoAppURL},
|
||||||
|
{name: "https 域名", appURL: "https://demo.example.com", wantRPID: "demo.example.com", wantOrigin: "https://demo.example.com"},
|
||||||
|
{name: "带端口", appURL: "https://panel.example.com:8443", wantRPID: "panel.example.com", wantOrigin: "https://panel.example.com:8443"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
p := newTestPasskey(t, tt.appURL)
|
||||||
|
w, err := p.rp()
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("rp() err = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rp(): %v", err)
|
||||||
|
}
|
||||||
|
if w.Config.RPID != tt.wantRPID {
|
||||||
|
t.Errorf("RPID = %q, want %q", w.Config.RPID, tt.wantRPID)
|
||||||
|
}
|
||||||
|
if len(w.Config.RPOrigins) != 1 || w.Config.RPOrigins[0] != tt.wantOrigin {
|
||||||
|
t.Errorf("RPOrigins = %v, want [%s]", w.Config.RPOrigins, tt.wantOrigin)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyPendingLifecycle(t *testing.T) {
|
||||||
|
p := newTestPasskey(t, "https://demo.example.com")
|
||||||
|
session := webauthn.SessionData{Challenge: "challenge-1"}
|
||||||
|
|
||||||
|
id, err := p.putPending(session, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("putPending: %v", err)
|
||||||
|
}
|
||||||
|
got, err := p.takePending(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("takePending: %v", err)
|
||||||
|
}
|
||||||
|
if got.session.Challenge != "challenge-1" || got.username != "admin" {
|
||||||
|
t.Errorf("pending = %+v, want challenge-1/admin", got)
|
||||||
|
}
|
||||||
|
// 一次性:再次消费同一 sessionId 必须失效
|
||||||
|
if _, err := p.takePending(id); !errors.Is(err, ErrPasskeySession) {
|
||||||
|
t.Errorf("second take err = %v, want ErrPasskeySession", err)
|
||||||
|
}
|
||||||
|
// 过期条目视为无效
|
||||||
|
expiredID, _ := p.putPending(session, "")
|
||||||
|
p.mu.Lock()
|
||||||
|
e := p.pending[expiredID]
|
||||||
|
e.expires = time.Now().Add(-time.Second)
|
||||||
|
p.pending[expiredID] = e
|
||||||
|
p.mu.Unlock()
|
||||||
|
if _, err := p.takePending(expiredID); !errors.Is(err, ErrPasskeySession) {
|
||||||
|
t.Errorf("expired take err = %v, want ErrPasskeySession", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyBeginRegister(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
appURL string
|
||||||
|
seedKeys int
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{name: "正常发起", appURL: "https://demo.example.com"},
|
||||||
|
{name: "无面板地址", appURL: "", wantErr: ErrPasskeyNoAppURL},
|
||||||
|
{name: "数量达上限", appURL: "https://demo.example.com", seedKeys: passkeyMaxPerUser, wantErr: ErrPasskeyLimit},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
p := newTestPasskey(t, tt.appURL)
|
||||||
|
for i := 0; i < tt.seedKeys; i++ {
|
||||||
|
seedPasskeyRow(t, p.db, uint(i+1))
|
||||||
|
}
|
||||||
|
sid, opts, err := p.BeginRegister(context.Background(), "admin")
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("BeginRegister err = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginRegister: %v", err)
|
||||||
|
}
|
||||||
|
if sid == "" || opts == nil || opts.Response.Challenge.String() == "" {
|
||||||
|
t.Errorf("BeginRegister returned empty session/options")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedPasskeyRow 直插一行合法凭据(JSON 与 CredentialID 对应)。
|
||||||
|
func seedPasskeyRow(t *testing.T, db *gorm.DB, seq uint) {
|
||||||
|
t.Helper()
|
||||||
|
credID := []byte{byte(seq), 2, 3, 4}
|
||||||
|
cred := webauthn.Credential{ID: credID, PublicKey: []byte{5, 6}}
|
||||||
|
row := model.UserPasskey{
|
||||||
|
UserID: 1,
|
||||||
|
Name: "key",
|
||||||
|
CredentialID: base64.RawURLEncoding.EncodeToString(credID),
|
||||||
|
CredentialIDHash: passkeyCredHash(credID),
|
||||||
|
Credential: mustCredJSON(t, cred),
|
||||||
|
Origin: "https://demo.example.com",
|
||||||
|
}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
t.Fatalf("seed passkey: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCredJSON(t *testing.T, cred webauthn.Credential) string {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(cred)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal credential: %v", err)
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyFinishRegisterSessionChecks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sessionUser string
|
||||||
|
finishUser string
|
||||||
|
body string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{name: "会话不存在", sessionUser: "-", finishUser: "admin", wantErr: ErrPasskeySession},
|
||||||
|
{name: "会话归属不符", sessionUser: "other", finishUser: "admin", wantErr: ErrPasskeySession},
|
||||||
|
{name: "登录会话不可注册", sessionUser: "", finishUser: "admin", wantErr: ErrPasskeySession},
|
||||||
|
{name: "凭据体不合法", sessionUser: "admin", finishUser: "admin", body: "{}", wantErr: ErrPasskeyVerify},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
p := newTestPasskey(t, "https://demo.example.com")
|
||||||
|
sid := "missing"
|
||||||
|
if tt.sessionUser != "-" {
|
||||||
|
var err error
|
||||||
|
sid, err = p.putPending(webauthn.SessionData{Challenge: "c"}, tt.sessionUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("putPending: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := p.FinishRegister(context.Background(), tt.finishUser, sid, "名称", strings.NewReader(tt.body), proofOf(t, p.db, "admin"))
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("FinishRegister err = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyCredentialRoundTrip(t *testing.T) {
|
||||||
|
p := newTestPasskey(t, "https://demo.example.com")
|
||||||
|
cred := &webauthn.Credential{ID: []byte{9, 9, 9}, PublicKey: []byte{1, 2, 3}}
|
||||||
|
cred.Authenticator.SignCount = 7
|
||||||
|
if err := saveCredentialTx(p.db, 1, "", "https://demo.example.com", cred); err != nil {
|
||||||
|
t.Fatalf("saveCredentialTx: %v", err)
|
||||||
|
}
|
||||||
|
u, err := p.loadUser(context.Background(), "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadUser: %v", err)
|
||||||
|
}
|
||||||
|
creds := u.WebAuthnCredentials()
|
||||||
|
if len(creds) != 1 || creds[0].Authenticator.SignCount != 7 {
|
||||||
|
t.Fatalf("credentials = %+v, want 1 item signCount 7", creds)
|
||||||
|
}
|
||||||
|
if u.keys[0].Name != "通行密钥" {
|
||||||
|
t.Errorf("default name = %q, want 通行密钥", u.keys[0].Name)
|
||||||
|
}
|
||||||
|
// userHandle 往返:8 字节大端 ID 找回同一账号
|
||||||
|
pu, err := p.userByHandle(context.Background(), passkeyUserHandle(u.user.ID))
|
||||||
|
if err != nil || pu.user.Username != "admin" {
|
||||||
|
t.Errorf("userByHandle = (%+v, %v), want admin", pu.user, err)
|
||||||
|
}
|
||||||
|
if _, err := p.userByHandle(context.Background(), []byte{1, 2}); !errors.Is(err, ErrPasskeyVerify) {
|
||||||
|
t.Errorf("short handle err = %v, want ErrPasskeyVerify", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyRemoveAndVersionBump(t *testing.T) {
|
||||||
|
p := newTestPasskey(t, "https://demo.example.com")
|
||||||
|
seedPasskeyRow(t, p.db, 1)
|
||||||
|
var before model.User
|
||||||
|
if err := p.db.First(&before, 1).Error; err != nil {
|
||||||
|
t.Fatalf("load user: %v", err)
|
||||||
|
}
|
||||||
|
if !p.HasAny(context.Background()) {
|
||||||
|
t.Fatal("HasAny = false, want true after seed")
|
||||||
|
}
|
||||||
|
if err := p.Remove(context.Background(), "admin", 1, proofOf(t, p.db, "admin")); err != nil {
|
||||||
|
t.Fatalf("Remove: %v", err)
|
||||||
|
}
|
||||||
|
if err := p.Remove(context.Background(), "admin", 1, proofOf(t, p.db, "admin")); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
t.Errorf("second Remove err = %v, want ErrRecordNotFound", err)
|
||||||
|
}
|
||||||
|
if p.HasAny(context.Background()) {
|
||||||
|
t.Error("HasAny = true, want false after remove")
|
||||||
|
}
|
||||||
|
var after model.User
|
||||||
|
if err := p.db.First(&after, 1).Error; err != nil {
|
||||||
|
t.Fatalf("reload user: %v", err)
|
||||||
|
}
|
||||||
|
if after.TokenVersion != before.TokenVersion+1 {
|
||||||
|
t.Errorf("TokenVersion = %d, want %d", after.TokenVersion, before.TokenVersion+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyHasAnyRequiresCurrentOrigin(t *testing.T) {
|
||||||
|
p := newTestPasskey(t, "https://demo.example.com")
|
||||||
|
seedPasskeyRow(t, p.db, 1)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
appURL string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "registered origin", appURL: "https://demo.example.com", want: true},
|
||||||
|
{name: "missing app url", appURL: ""},
|
||||||
|
{name: "different origin", appURL: "https://other.example.com"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
p.settings.SetEnvPublicURL(tt.appURL)
|
||||||
|
if got := p.HasAny(context.Background()); got != tt.want {
|
||||||
|
t.Fatalf("HasAny = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyFinishLoginGuard(t *testing.T) {
|
||||||
|
p := newTestPasskey(t, "https://demo.example.com")
|
||||||
|
ctx := context.Background()
|
||||||
|
// 无效 sessionId 反复失败:达到默认阈值后转锁定
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < securityDefaults.LoginFailLimit+1; i++ {
|
||||||
|
_, _, _, lastErr = p.FinishLogin(ctx, "bad-session", SessionMeta{ClientIP: "10.0.0.9"}, strings.NewReader("{}"))
|
||||||
|
}
|
||||||
|
if !errors.Is(lastErr, ErrLoginLocked) {
|
||||||
|
t.Fatalf("after %d failures err = %v, want ErrLoginLocked", securityDefaults.LoginFailLimit+1, lastErr)
|
||||||
|
}
|
||||||
|
// 其他 IP 不受连坐
|
||||||
|
if _, _, _, err := p.FinishLogin(ctx, "bad-session", SessionMeta{ClientIP: "10.0.0.10"}, strings.NewReader("{}")); errors.Is(err, ErrLoginLocked) {
|
||||||
|
t.Errorf("different IP got locked prematurely: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -324,3 +324,27 @@ func TestProxySetTenantsRebind(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPersistGeoSkipsDeletedProxy 锁定探测落库防线:代理已删时零命中放弃,
|
||||||
|
// 不得经 Save 回退 Create 复活已删代理。
|
||||||
|
func TestPersistGeoSkipsDeletedProxy(t *testing.T) {
|
||||||
|
svc, db := newProxyEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
row := model.Proxy{Name: "p", Type: "socks5", Host: "h", Port: 1080}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
t.Fatalf("seed proxy: %v", err)
|
||||||
|
}
|
||||||
|
svc.persistGeo(ctx, row.ID, geoResult{Country: "DE", City: "FRA"})
|
||||||
|
var fresh model.Proxy
|
||||||
|
if err := db.First(&fresh, row.ID).Error; err != nil || fresh.Country != "DE" || fresh.GeoAt == nil {
|
||||||
|
t.Fatalf("正常落库 = %+v, %v", fresh, err)
|
||||||
|
}
|
||||||
|
if err := db.Delete(&model.Proxy{}, row.ID).Error; err != nil {
|
||||||
|
t.Fatalf("delete proxy: %v", err)
|
||||||
|
}
|
||||||
|
svc.persistGeo(ctx, row.ID, geoResult{Country: "US"})
|
||||||
|
var n int64
|
||||||
|
if err := db.Model(&model.Proxy{}).Count(&n).Error; err != nil || n != 0 {
|
||||||
|
t.Fatalf("已删代理被复活: count=%d (%v), want 0", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -108,13 +109,18 @@ func (s *ProxyService) probeGeo(ctx context.Context, id uint) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var row model.Proxy
|
s.persistGeo(ctx, id, res)
|
||||||
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
}
|
||||||
return
|
|
||||||
}
|
// persistGeo 条件更新地区字段:代理已被删除时零命中放弃——整行 Save 零命中会
|
||||||
|
// 回退 Create 把删掉的代理复活;错误也不能静默吞掉。
|
||||||
|
func (s *ProxyService) persistGeo(ctx context.Context, id uint, res geoResult) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
row.Country, row.City, row.GeoAt = res.Country, res.City, &now
|
tx := s.db.WithContext(ctx).Model(&model.Proxy{}).Where("id = ?", id).
|
||||||
s.db.WithContext(ctx).Save(&row)
|
Updates(map[string]any{"country": res.Country, "city": res.City, "geo_at": &now})
|
||||||
|
if tx.Error != nil {
|
||||||
|
log.Printf("proxy geo persist %d: %v", id, tx.Error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probe 同步重测代理出口地理并返回最新视图;供手动「重测」入口调用。
|
// Probe 同步重测代理出口地理并返回最新视图;供手动「重测」入口调用。
|
||||||
|
|||||||
+175
-13
@@ -3,9 +3,12 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 安全设置键:WAF 参数 / 真实IP请求头 / 面板地址(app_url)。
|
// 安全设置键:WAF 参数 / 真实IP请求头 / 面板地址(app_url)。
|
||||||
@@ -112,28 +115,187 @@ func (s *SettingService) Security(ctx context.Context) (SecuritySettings, error)
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSecurity 校验并保存安全设置,随后刷新内存快照立即生效。
|
// SecurityPatch 是安全设置的部分更新;nil 字段沿用现值,只落库出现的字段,
|
||||||
func (s *SettingService) UpdateSecurity(ctx context.Context, in SecuritySettings) error {
|
// 并发编辑不同字段因此互不回滚(2026-07-22 审查 #18)。
|
||||||
if err := validateSecurity(&in); err != nil {
|
type SecurityPatch struct {
|
||||||
|
LoginFailLimit *int `json:"loginFailLimit"`
|
||||||
|
LoginLockMinutes *int `json:"loginLockMinutes"`
|
||||||
|
IPRateRPS *int `json:"ipRateRps"`
|
||||||
|
IPRateBurst *int `json:"ipRateBurst"`
|
||||||
|
RealIPHeader *string `json:"realIpHeader"`
|
||||||
|
AppURL *string `json:"appUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge 把非 nil 字段覆盖到 dst,返回被触碰字段的存储键集合。
|
||||||
|
func (p SecurityPatch) merge(dst *SecuritySettings) map[string]bool {
|
||||||
|
touched := map[string]bool{}
|
||||||
|
fields := []struct {
|
||||||
|
key string
|
||||||
|
set func()
|
||||||
|
on bool
|
||||||
|
}{
|
||||||
|
{settingSecLoginFailLimit, func() { dst.LoginFailLimit = *p.LoginFailLimit }, p.LoginFailLimit != nil},
|
||||||
|
{settingSecLoginLockMin, func() { dst.LoginLockMinutes = *p.LoginLockMinutes }, p.LoginLockMinutes != nil},
|
||||||
|
{settingSecIPRateRPS, func() { dst.IPRateRPS = *p.IPRateRPS }, p.IPRateRPS != nil},
|
||||||
|
{settingSecIPRateBurst, func() { dst.IPRateBurst = *p.IPRateBurst }, p.IPRateBurst != nil},
|
||||||
|
{settingSecRealIPHeader, func() { dst.RealIPHeader = *p.RealIPHeader }, p.RealIPHeader != nil},
|
||||||
|
{settingSecAppURL, func() { dst.AppURL = *p.AppURL }, p.AppURL != nil},
|
||||||
|
}
|
||||||
|
for _, f := range fields {
|
||||||
|
if f.on {
|
||||||
|
f.set()
|
||||||
|
touched[f.key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return touched
|
||||||
|
}
|
||||||
|
|
||||||
|
type securityUpdate struct {
|
||||||
|
next SecuritySettings
|
||||||
|
touched map[string]bool
|
||||||
|
values map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSecurity 供启动与内部配置使用;HTTP PATCH 使用带令牌证明的变体。
|
||||||
|
func (s *SettingService) UpdateSecurity(ctx context.Context, p SecurityPatch) error {
|
||||||
|
return s.updateSecurity(ctx, p, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSecurityAuthenticated 在写事务持锁后复核请求令牌,防撤销后的慢请求落库。
|
||||||
|
func (s *SettingService) UpdateSecurityAuthenticated(
|
||||||
|
ctx context.Context, p SecurityPatch, auth *AuthService, username string, proof TokenProof,
|
||||||
|
) error {
|
||||||
|
check := &authenticatedMutation{auth: auth, username: username, proof: proof}
|
||||||
|
return s.updateSecurity(ctx, p, check)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) updateSecurity(ctx context.Context, p SecurityPatch, check *authenticatedMutation) error {
|
||||||
|
up, err := s.prepareSecurityUpdate(ctx, p)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
kv := map[string]string{
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
settingSecLoginFailLimit: strconv.Itoa(in.LoginFailLimit),
|
return s.applySecurityUpdateTx(tx, up, check)
|
||||||
settingSecLoginLockMin: strconv.Itoa(in.LoginLockMinutes),
|
})
|
||||||
settingSecIPRateRPS: strconv.Itoa(in.IPRateRPS),
|
if err != nil {
|
||||||
settingSecIPRateBurst: strconv.Itoa(in.IPRateBurst),
|
return err
|
||||||
settingSecRealIPHeader: in.RealIPHeader,
|
|
||||||
settingSecAppURL: in.AppURL,
|
|
||||||
}
|
}
|
||||||
for key, value := range kv {
|
return s.ReloadSecurity(context.WithoutCancel(ctx))
|
||||||
if err := s.set(ctx, key, value); err != nil {
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) prepareSecurityUpdate(ctx context.Context, p SecurityPatch) (securityUpdate, error) {
|
||||||
|
cur, err := s.Security(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return securityUpdate{}, err
|
||||||
|
}
|
||||||
|
touched := p.merge(&cur)
|
||||||
|
if err := validateSecurity(&cur); err != nil {
|
||||||
|
return securityUpdate{}, err
|
||||||
|
}
|
||||||
|
values := map[string]string{
|
||||||
|
settingSecLoginFailLimit: strconv.Itoa(cur.LoginFailLimit),
|
||||||
|
settingSecLoginLockMin: strconv.Itoa(cur.LoginLockMinutes),
|
||||||
|
settingSecIPRateRPS: strconv.Itoa(cur.IPRateRPS),
|
||||||
|
settingSecIPRateBurst: strconv.Itoa(cur.IPRateBurst),
|
||||||
|
settingSecRealIPHeader: cur.RealIPHeader,
|
||||||
|
settingSecAppURL: cur.AppURL,
|
||||||
|
}
|
||||||
|
return securityUpdate{next: cur, touched: touched, values: values}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) applySecurityUpdateTx(tx *gorm.DB, up securityUpdate, check *authenticatedMutation) error {
|
||||||
|
if check != nil {
|
||||||
|
if err := check.lockAndCheck(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if up.touched[settingSecAppURL] {
|
||||||
|
if err := lockUsersForAuthChange(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.checkAppURLUpdateTx(tx, up); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for key := range up.touched {
|
||||||
|
if err := saveSettingTx(tx, key, up.values[key]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.security.Store(in)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) checkAppURLUpdateTx(tx *gorm.DB, up securityUpdate) error {
|
||||||
|
if !up.touched[settingSecAppURL] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
oldApp, err := settingValueTx(tx, settingSecAppURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.ensureAppURLKeepsLogin(tx, oldApp, up.next.AppURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureAppURLKeepsLogin 防自锁:密码登录禁用期间,面板地址是所有免密方式的
|
||||||
|
// 运行时依赖——清空(生效值)会使通行密钥/钱包/OAuth 回调全部不可用,一律拒绝;
|
||||||
|
// 域名变更会使通行密钥失效(RP ID 绑定域名),须留有钱包身份或可用外部登录兜底。
|
||||||
|
func (s *SettingService) ensureAppURLKeepsLogin(tx *gorm.DB, oldURL, newURL string) error {
|
||||||
|
off, err := settingValueTx(tx, settingSecPasswordLoginOff)
|
||||||
|
if err != nil || off != "1" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newEff := s.effectiveOf(newURL)
|
||||||
|
if newEff == "" {
|
||||||
|
return ErrProviderLastLogin
|
||||||
|
}
|
||||||
|
// origin(scheme://host)整体比较:scheme 变化同样使 WebAuthn origin 失配
|
||||||
|
if originOf(newEff) == originOf(s.effectiveOf(oldURL)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n, err := identityProviderCountTx(tx, "wallet"); err != nil || n > 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
view, err := oauthViewTx(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok, err := anyBoundUsableProviderTx(tx, patchedUsable(view, UpdateOAuthInput{}))
|
||||||
|
if err != nil || ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ErrProviderLastLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveOf 计算给定 app_url 设置值的生效地址(设置优先,回退 PUBLIC_URL 环境变量)。
|
||||||
|
func (s *SettingService) effectiveOf(v string) string {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return s.envPublicURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveOriginTx(tx *gorm.DB, settings *SettingService) (string, error) {
|
||||||
|
if settings == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
appURL, err := settingValueTx(tx, settingSecAppURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return originOf(settings.effectiveOf(appURL)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// originOf 取 URL 的 origin(scheme://host);空串或解析失败按原串返回。
|
||||||
|
func originOf(rawURL string) string {
|
||||||
|
if rawURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host
|
||||||
|
}
|
||||||
|
|
||||||
// ReloadSecurity 从库加载安全设置到内存快照;进程启动时调用一次。
|
// ReloadSecurity 从库加载安全设置到内存快照;进程启动时调用一次。
|
||||||
func (s *SettingService) ReloadSecurity(ctx context.Context) error {
|
func (s *SettingService) ReloadSecurity(ctx context.Context) error {
|
||||||
sec, err := s.Security(ctx)
|
sec, err := s.Security(ctx)
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// patchOf 把完整设置转为全字段补丁,等价旧全量保存,供既有用例复用。
|
||||||
|
func patchOf(in SecuritySettings) SecurityPatch {
|
||||||
|
return SecurityPatch{
|
||||||
|
LoginFailLimit: &in.LoginFailLimit,
|
||||||
|
LoginLockMinutes: &in.LoginLockMinutes,
|
||||||
|
IPRateRPS: &in.IPRateRPS,
|
||||||
|
IPRateBurst: &in.IPRateBurst,
|
||||||
|
RealIPHeader: &in.RealIPHeader,
|
||||||
|
AppURL: &in.AppURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSecurityReadWrite(t *testing.T) {
|
func TestSecurityReadWrite(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -21,7 +33,7 @@ func TestSecurityReadWrite(t *testing.T) {
|
|||||||
name: "写入后读回",
|
name: "写入后读回",
|
||||||
setup: func(t *testing.T, svc *SettingService) {
|
setup: func(t *testing.T, svc *SettingService) {
|
||||||
in := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com/"}
|
in := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://demo.example.com/"}
|
||||||
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
|
if err := svc.UpdateSecurity(context.Background(), patchOf(in)); err != nil {
|
||||||
t.Fatalf("UpdateSecurity: %v", err)
|
t.Fatalf("UpdateSecurity: %v", err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -76,7 +88,7 @@ func TestUpdateSecurityRejectsInvalid(t *testing.T) {
|
|||||||
svc, _ := newSettingEnv(t)
|
svc, _ := newSettingEnv(t)
|
||||||
in := base
|
in := base
|
||||||
tt.mutate(&in)
|
tt.mutate(&in)
|
||||||
if err := svc.UpdateSecurity(context.Background(), in); !errors.Is(err, ErrInvalidSecurity) {
|
if err := svc.UpdateSecurity(context.Background(), patchOf(in)); !errors.Is(err, ErrInvalidSecurity) {
|
||||||
t.Fatalf("err = %v, want ErrInvalidSecurity", err)
|
t.Fatalf("err = %v, want ErrInvalidSecurity", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -89,9 +101,7 @@ func TestSecurityCachedSnapshot(t *testing.T) {
|
|||||||
if got := svc.SecurityCached(); got != securityDefaults {
|
if got := svc.SecurityCached(); got != securityDefaults {
|
||||||
t.Errorf("cold cache = %+v, want defaults", got)
|
t.Errorf("cold cache = %+v, want defaults", got)
|
||||||
}
|
}
|
||||||
in := securityDefaults
|
if err := svc.UpdateSecurity(context.Background(), SecurityPatch{LoginFailLimit: intPtr(8)}); err != nil {
|
||||||
in.LoginFailLimit = 8
|
|
||||||
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
|
|
||||||
t.Fatalf("UpdateSecurity: %v", err)
|
t.Fatalf("UpdateSecurity: %v", err)
|
||||||
}
|
}
|
||||||
// Update 即刷新快照
|
// Update 即刷新快照
|
||||||
@@ -110,12 +120,67 @@ func TestEffectiveAppURL(t *testing.T) {
|
|||||||
if got := svc.EffectiveAppURL(); got != "https://env.example.com" {
|
if got := svc.EffectiveAppURL(); got != "https://env.example.com" {
|
||||||
t.Errorf("env fallback = %q", got)
|
t.Errorf("env fallback = %q", got)
|
||||||
}
|
}
|
||||||
in := securityDefaults
|
if err := svc.UpdateSecurity(context.Background(), SecurityPatch{AppURL: strPtr("https://app.example.com")}); err != nil {
|
||||||
in.AppURL = "https://app.example.com"
|
|
||||||
if err := svc.UpdateSecurity(context.Background(), in); err != nil {
|
|
||||||
t.Fatalf("UpdateSecurity: %v", err)
|
t.Fatalf("UpdateSecurity: %v", err)
|
||||||
}
|
}
|
||||||
if got := svc.EffectiveAppURL(); got != "https://app.example.com" {
|
if got := svc.EffectiveAppURL(); got != "https://app.example.com" {
|
||||||
t.Errorf("app_url 应优先, got %q", got)
|
t.Errorf("app_url 应优先, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUpdateSecurityPartialPatch 锁定 PATCH 语义:只写出现字段,其余不回滚。
|
||||||
|
func TestUpdateSecurityPartialPatch(t *testing.T) {
|
||||||
|
seeded := SecuritySettings{LoginFailLimit: 10, LoginLockMinutes: 30, IPRateRPS: 20, IPRateBurst: 60, RealIPHeader: "X-Client-IP", AppURL: "https://a.example.com"}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
patch SecurityPatch
|
||||||
|
want SecuritySettings
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "单字段更新不动其余",
|
||||||
|
patch: SecurityPatch{LoginFailLimit: intPtr(3)},
|
||||||
|
want: func() SecuritySettings {
|
||||||
|
w := seeded
|
||||||
|
w.LoginFailLimit = 3
|
||||||
|
return w
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{name: "空补丁不改任何值", patch: SecurityPatch{}, want: seeded},
|
||||||
|
{
|
||||||
|
name: "补丁字段规范化(AppURL 去尾斜杠)",
|
||||||
|
patch: SecurityPatch{AppURL: strPtr("https://b.example.com/")},
|
||||||
|
want: func() SecuritySettings {
|
||||||
|
w := seeded
|
||||||
|
w.AppURL = "https://b.example.com"
|
||||||
|
return w
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{name: "补丁字段越界拒绝", patch: SecurityPatch{IPRateRPS: intPtr(0)}, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
svc, _ := newSettingEnv(t)
|
||||||
|
if err := svc.UpdateSecurity(context.Background(), patchOf(seeded)); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
err := svc.UpdateSecurity(context.Background(), tt.patch)
|
||||||
|
if tt.wantErr {
|
||||||
|
if !errors.Is(err, ErrInvalidSecurity) {
|
||||||
|
t.Fatalf("err = %v, want ErrInvalidSecurity", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateSecurity: %v", err)
|
||||||
|
}
|
||||||
|
got, err := svc.Security(context.Background())
|
||||||
|
if err != nil || got != tt.want {
|
||||||
|
t.Errorf("settings = %+v (%v), want %+v", got, err, tt.want)
|
||||||
|
}
|
||||||
|
if cached := svc.SecurityCached(); cached != tt.want {
|
||||||
|
t.Errorf("cached = %+v, want %+v", cached, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrSessionCurrent 表示试图撤销当前会话;应引导用退出登录。
|
||||||
|
var ErrSessionCurrent = errors.New("不能撤销当前会话,请使用退出登录")
|
||||||
|
|
||||||
|
const (
|
||||||
|
// sessionSeenTTL 是最后活跃时间的回写节流窗口;窗口内命中缓存直接放行。
|
||||||
|
sessionSeenTTL = time.Minute
|
||||||
|
// sessionCleanupTick 是会话行清理周期。
|
||||||
|
sessionCleanupTick = time.Hour
|
||||||
|
// sessionExpiredKeep / sessionRevokedKeep 是失效行的保留期,过后物理删除。
|
||||||
|
sessionExpiredKeep = 24 * time.Hour
|
||||||
|
sessionRevokedKeep = 7 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// jtiTombstones 是定点撤销的负缓存:无容量上限,不存在「淘汰导致已撤销
|
||||||
|
// 令牌复活」;增长由 TTL 清理约束——撤销是认证后的低频人工操作,
|
||||||
|
// 集合尺寸恒小,put 时线性清理过期项即可。
|
||||||
|
type jtiTombstones struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
m map[string]time.Time // jti → 过期时刻
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJtiTombstones() *jtiTombstones {
|
||||||
|
return &jtiTombstones{m: map[string]time.Time{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// put 登记撤销标记并顺带清理过期项。
|
||||||
|
func (t *jtiTombstones) put(jti string, ttl time.Duration) {
|
||||||
|
if jti == "" || ttl <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
for k, exp := range t.m {
|
||||||
|
if now.After(exp) {
|
||||||
|
delete(t.m, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.m[jti] = now.Add(ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// has 报告 jti 是否在有效撤销标记中。
|
||||||
|
func (t *jtiTombstones) has(jti string) bool {
|
||||||
|
t.mu.RLock()
|
||||||
|
exp, ok := t.m[jti]
|
||||||
|
t.mu.RUnlock()
|
||||||
|
return ok && time.Now().Before(exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionMeta 是签发会话时的客户端上下文;Method 由各登录出口的 service 层填写,
|
||||||
|
// api 层只采集 IP/UA。零值 meta 表示不落会话行(内部/测试场景)。
|
||||||
|
type SessionMeta struct {
|
||||||
|
ClientIP string
|
||||||
|
UserAgent string
|
||||||
|
Method string // password / oidc / github / passkey / wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty 报告 meta 是否为「不落行」哨兵。
|
||||||
|
func (m SessionMeta) empty() bool { return m.ClientIP == "" && m.UserAgent == "" }
|
||||||
|
|
||||||
|
// signSessionToken 签发 JWT 并按 meta 落地会话行。
|
||||||
|
func (s *AuthService) signSessionToken(ctx context.Context, user *model.User, meta SessionMeta) (string, time.Time, error) {
|
||||||
|
return s.signSessionTokenDB(s.db.WithContext(ctx), user, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// signSessionTokenTx 是身份登录事务内的签发入口。
|
||||||
|
func (s *AuthService) signSessionTokenTx(tx *gorm.DB, user *model.User, meta SessionMeta) (string, time.Time, error) {
|
||||||
|
return s.signSessionTokenDB(tx, user, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) signSessionTokenDB(db *gorm.DB, user *model.User, meta SessionMeta) (string, time.Time, error) {
|
||||||
|
token, expires, jti, err := s.signToken(user.Username, user.TokenVersion)
|
||||||
|
if err != nil || meta.empty() {
|
||||||
|
return token, expires, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
row := model.UserSession{
|
||||||
|
UserID: user.ID, TokenID: jti, TokenVer: user.TokenVersion,
|
||||||
|
Method: meta.Method, ClientIP: meta.ClientIP, UserAgent: meta.UserAgent,
|
||||||
|
LastSeenAt: now, ExpiresAt: expires,
|
||||||
|
}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
// fail-closed:落行失败拒发令牌,否则产生列表不可见、无法定点撤销的孤儿会话
|
||||||
|
return "", time.Time{}, fmt.Errorf("record session: %w", err)
|
||||||
|
}
|
||||||
|
return token, expires, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewToken 为敏感变更后的操作者换发新令牌:旧令牌对应的会话行接续
|
||||||
|
// (沿用 jti,同行更新版本/有效期,保留登录方式与创建时间),无行则按 meta 新建。
|
||||||
|
// 旧令牌只验签名不验有效性——版本刚被递增,旧令牌语义上已失效但行仍需接续。
|
||||||
|
func (s *AuthService) RenewToken(ctx context.Context, username, oldToken string, meta SessionMeta) (string, time.Time, error) {
|
||||||
|
proof, ok := s.signedTokenProof(oldToken)
|
||||||
|
if !ok {
|
||||||
|
return "", time.Time{}, ErrTokenStale
|
||||||
|
}
|
||||||
|
var token string
|
||||||
|
var expires time.Time
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserForAuthChange(tx, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if user.TokenVersion != proof.Ver+1 {
|
||||||
|
return ErrTokenStale
|
||||||
|
}
|
||||||
|
token, expires, err = s.renewSessionTx(tx, user, oldToken, meta)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return token, expires, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// renewSessionTx 事务内签新令牌并接续会话行(RenewToken 的事务内核):
|
||||||
|
// 与认证因子写入 / 版本递增同事务提交,绑定等敏感变更全程要么全成要么全滚;
|
||||||
|
// 调用方须保证 user.TokenVersion 已是递增后的最新值。
|
||||||
|
func (s *AuthService) renewSessionTx(tx *gorm.DB, user *model.User, oldToken string, meta SessionMeta) (string, time.Time, error) {
|
||||||
|
jti := s.signedJti(oldToken)
|
||||||
|
if jti == "" {
|
||||||
|
jti = newTokenID()
|
||||||
|
}
|
||||||
|
token, expires, _, err := s.signTokenWithJTI(user.Username, user.TokenVersion, jti)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
updates := map[string]any{
|
||||||
|
"token_id": jti, "token_ver": user.TokenVersion,
|
||||||
|
"expires_at": expires, "last_seen_at": time.Now(),
|
||||||
|
}
|
||||||
|
renewed, err := s.renewExistingSessionTx(tx, user.ID, oldToken, updates)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
if renewed {
|
||||||
|
return token, expires, nil
|
||||||
|
}
|
||||||
|
if meta.empty() {
|
||||||
|
return token, expires, nil
|
||||||
|
}
|
||||||
|
err = createRenewedSessionTx(tx, user, jti, expires, meta)
|
||||||
|
return token, expires, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRenewedSessionTx(
|
||||||
|
tx *gorm.DB, user *model.User, jti string, expires time.Time, meta SessionMeta,
|
||||||
|
) error {
|
||||||
|
row := model.UserSession{
|
||||||
|
UserID: user.ID, TokenID: jti, TokenVer: user.TokenVersion,
|
||||||
|
Method: meta.Method, ClientIP: meta.ClientIP, UserAgent: meta.UserAgent,
|
||||||
|
LastSeenAt: time.Now(), ExpiresAt: expires,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
|
return fmt.Errorf("record renewed session: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renewExistingSessionTx 仅在旧行仍有效时接续;有行但已撤销或更新失败均失败关闭。
|
||||||
|
func (s *AuthService) renewExistingSessionTx(tx *gorm.DB, userID uint, oldToken string, updates map[string]any) (bool, error) {
|
||||||
|
oldJTI := s.signedJti(oldToken)
|
||||||
|
if oldJTI == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if s.revokedJti.has(oldJTI) {
|
||||||
|
return false, ErrTokenStale
|
||||||
|
}
|
||||||
|
if _, hit := s.revoked.Get(tokenHash(oldToken)); hit {
|
||||||
|
return false, ErrTokenStale
|
||||||
|
}
|
||||||
|
res := tx.Model(&model.UserSession{}).
|
||||||
|
Where("token_id = ? AND user_id = ? AND revoked_at IS NULL", oldJTI, userID).Updates(updates)
|
||||||
|
if res.Error != nil {
|
||||||
|
return false, fmt.Errorf("renew session: %w", res.Error)
|
||||||
|
}
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
s.seen.DeletePrefix("seen|" + oldJTI)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, s.ensureRenewalHasNoOldRow(tx, userID, oldJTI)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ensureRenewalHasNoOldRow(tx *gorm.DB, userID uint, oldJTI string) error {
|
||||||
|
var count int64
|
||||||
|
err := tx.Model(&model.UserSession{}).
|
||||||
|
Where("token_id = ? AND user_id = ?", oldJTI, userID).Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check renewed session: %w", err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return ErrTokenStale
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedJti 校验令牌签名并取出 jti;不验有效期与版本(换发场景旧令牌刚失效)。
|
||||||
|
// 签名必须有效,防止伪造 jti 抢占他人会话行。
|
||||||
|
func (s *AuthService) signedJti(tokenString string) string {
|
||||||
|
proof, ok := s.signedTokenProof(tokenString)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return proof.Jti
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) signedTokenProof(tokenString string) (TokenProof, bool) {
|
||||||
|
if tokenString == "" {
|
||||||
|
return TokenProof{}, false
|
||||||
|
}
|
||||||
|
claims := &authClaims{}
|
||||||
|
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method %v", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return s.jwtSecret, nil
|
||||||
|
}, jwt.WithoutClaimsValidation())
|
||||||
|
if err != nil {
|
||||||
|
return TokenProof{}, false
|
||||||
|
}
|
||||||
|
return TokenProof{Ver: claims.Ver, Jti: claims.ID}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSession 校验 jti 对应会话未被定点撤销;无行放行(存量令牌兼容)。
|
||||||
|
// 有效会话按节流窗口回写最后活跃时间;撤销动作会清掉节流缓存保证即时生效。
|
||||||
|
func (s *AuthService) checkSession(ctx context.Context, jti string) error {
|
||||||
|
if jti == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 撤销负缓存优先:防「读库通过→撤销→回写 seen」竞态让已撤销令牌复活
|
||||||
|
if s.revokedJti.has(jti) {
|
||||||
|
return errors.New("session revoked")
|
||||||
|
}
|
||||||
|
if _, hit := s.seen.Get("seen|" + jti); hit {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var row model.UserSession
|
||||||
|
err := s.db.WithContext(ctx).Select("id", "revoked_at").Where("token_id = ?", jti).First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("find session: %w", err)
|
||||||
|
}
|
||||||
|
if row.RevokedAt != nil {
|
||||||
|
return errors.New("session revoked")
|
||||||
|
}
|
||||||
|
s.seen.Set("seen|"+jti, struct{}{}, sessionSeenTTL)
|
||||||
|
s.db.WithContext(ctx).Model(&model.UserSession{}).
|
||||||
|
Where("id = ?", row.ID).UpdateColumn("last_seen_at", time.Now())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionInfo 是会话列表条目;Current 标记请求者自身会话。
|
||||||
|
type SessionInfo struct {
|
||||||
|
model.UserSession
|
||||||
|
Current bool `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions 列出账号的活跃会话(未撤销、未过期、版本为当前),最近活跃在前。
|
||||||
|
func (s *AuthService) ListSessions(ctx context.Context, username, currentToken string) ([]SessionInfo, error) {
|
||||||
|
user, err := s.findUser(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows := []model.UserSession{}
|
||||||
|
err = s.db.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND revoked_at IS NULL AND expires_at > ? AND token_ver = ?",
|
||||||
|
user.ID, time.Now(), user.TokenVersion).
|
||||||
|
Order("last_seen_at DESC").Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list sessions: %w", err)
|
||||||
|
}
|
||||||
|
currentJti := s.signedJti(currentToken)
|
||||||
|
out := make([]SessionInfo, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, SessionInfo{UserSession: r, Current: r.TokenID == currentJti})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeSession 定点撤销一个会话(校验归属);当前会话拒绝(引导登出),
|
||||||
|
// 不递增令牌版本、不影响其余会话。
|
||||||
|
func (s *AuthService) RevokeSession(ctx context.Context, username, currentToken string, id uint) error {
|
||||||
|
currentJTI := s.signedJti(currentToken)
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserForAuthChange(tx, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row, err := sessionForRevokeTx(tx, user.ID, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if row.TokenID == currentJTI {
|
||||||
|
return ErrSessionCurrent
|
||||||
|
}
|
||||||
|
return s.revokeSessionRowTx(tx, row)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionForRevokeTx(tx *gorm.DB, userID, id uint) (*model.UserSession, error) {
|
||||||
|
var row model.UserSession
|
||||||
|
err := tx.Where("id = ? AND user_id = ? AND revoked_at IS NULL", id, userID).First(&row).Error
|
||||||
|
return &row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) revokeSessionRowTx(tx *gorm.DB, row *model.UserSession) error {
|
||||||
|
res := tx.Model(&model.UserSession{}).
|
||||||
|
Where("id = ? AND revoked_at IS NULL", row.ID).UpdateColumn("revoked_at", time.Now())
|
||||||
|
if res.Error != nil {
|
||||||
|
return fmt.Errorf("revoke session: %w", res.Error)
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
s.markJTIRevoked(row.TokenID, time.Until(row.ExpiresAt))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeSessionByJTI 按 jti 标记会话撤销(登出联动);无行为无害操作。
|
||||||
|
func (s *AuthService) revokeSessionByJTI(ctx context.Context, username, jti string, ttl time.Duration) {
|
||||||
|
s.markJTIRevoked(jti, logoutJTITTL(ttl))
|
||||||
|
if jti == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := s.logoutUserID(ctx, username, jti)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.revokeJTIForUser(ctx, userID, jti)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logoutJTITTL 覆盖旧令牌剩余窗口内最晚产生的同 JTI 换发令牌。
|
||||||
|
func logoutJTITTL(ttl time.Duration) time.Duration { return ttl + tokenTTL }
|
||||||
|
|
||||||
|
func (s *AuthService) logoutUserID(ctx context.Context, username, jti string) uint {
|
||||||
|
if userID := s.sessionUserID(ctx, jti); userID != 0 {
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
if userID := s.usernameUserID(ctx, username); userID != 0 {
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
// legacy 换发行可能正提交;再读一次缩小「无行→建行」窗口。
|
||||||
|
return s.sessionUserID(ctx, jti)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) sessionUserID(ctx context.Context, jti string) uint {
|
||||||
|
var row model.UserSession
|
||||||
|
err := s.db.WithContext(ctx).Select("user_id").
|
||||||
|
Where("token_id = ?", jti).First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return row.UserID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) usernameUserID(ctx context.Context, username string) uint {
|
||||||
|
if username == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.WithContext(ctx).Select("id").
|
||||||
|
Where("username = ?", username).First(&user).Error; err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return user.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) revokeJTIForUser(ctx context.Context, userID uint, jti string) error {
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if _, err := lockUserByIDForAuthChange(tx, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.UserSession{}).
|
||||||
|
Where("token_id = ? AND user_id = ? AND revoked_at IS NULL", jti, userID).
|
||||||
|
UpdateColumn("revoked_at", time.Now()).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) markJTIRevoked(jti string, ttl time.Duration) {
|
||||||
|
s.seen.DeletePrefix("seen|" + jti)
|
||||||
|
s.revokedJti.put(jti, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSessionCleanup 启动会话行周期清理:启动即清一次,之后每小时一次,
|
||||||
|
// 随 ctx 取消退出;WaitSessionCleanup 可等待其真正结束(并发规范)。
|
||||||
|
func (s *AuthService) StartSessionCleanup(ctx context.Context) {
|
||||||
|
s.cleanupWG.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.cleanupWG.Done()
|
||||||
|
s.cleanupSessionsOnce(ctx)
|
||||||
|
ticker := time.NewTicker(sessionCleanupTick)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.cleanupSessionsOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitSessionCleanup 阻塞等待清理 goroutine 退出(取消 ctx 后调用)。
|
||||||
|
func (s *AuthService) WaitSessionCleanup() {
|
||||||
|
s.cleanupWG.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupSessionsOnce 删除保留期外的失效行;失败只记日志、不中断周期调度。
|
||||||
|
func (s *AuthService) cleanupSessionsOnce(ctx context.Context) {
|
||||||
|
now := time.Now()
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Where("expires_at < ? OR revoked_at < ?", now.Add(-sessionExpiredKeep), now.Add(-sessionRevokedKeep)).
|
||||||
|
Delete(&model.UserSession{}).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("session cleanup: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loginSession 用密码登录建一条会话,返回令牌。
|
||||||
|
func loginSession(t *testing.T, auth *AuthService, ip, ua string) string {
|
||||||
|
t.Helper()
|
||||||
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "",
|
||||||
|
SessionMeta{ClientIP: ip, UserAgent: ua})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustProof 从有效令牌解析版本/jti 快照。
|
||||||
|
func mustProof(t *testing.T, auth *AuthService, token string) TokenProof {
|
||||||
|
t.Helper()
|
||||||
|
_, proof, err := auth.ParseTokenProof(context.Background(), token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse token proof: %v", err)
|
||||||
|
}
|
||||||
|
return proof
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionRows(t *testing.T, auth *AuthService) []model.UserSession {
|
||||||
|
t.Helper()
|
||||||
|
rows := []model.UserSession{}
|
||||||
|
if err := auth.db.Order("id").Find(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("load sessions: %v", err)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionRecordedOnLogin(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
loginSession(t, auth, "10.9.0.1", "TestAgent/1.0")
|
||||||
|
rows := sessionRows(t, auth)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("sessions = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
r := rows[0]
|
||||||
|
if r.Method != "password" || r.ClientIP != "10.9.0.1" || r.UserAgent != "TestAgent/1.0" {
|
||||||
|
t.Errorf("row = %+v, want password/10.9.0.1/TestAgent", r)
|
||||||
|
}
|
||||||
|
if r.TokenID == "" || r.ExpiresAt.Before(time.Now()) {
|
||||||
|
t.Errorf("row token/expiry invalid: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionRenewContinuity(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
token := loginSession(t, auth, "10.9.0.2", "UA")
|
||||||
|
before := sessionRows(t, auth)[0]
|
||||||
|
|
||||||
|
// 敏感变更路径:版本递增 + 换发接续(RevokeSessions 即 bump+renew)
|
||||||
|
fresh, _, err := auth.RevokeSessions(ctx, "admin", token, SessionMeta{ClientIP: "10.9.0.2", UserAgent: "UA"}, mustProof(t, auth, token))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeSessions: %v", err)
|
||||||
|
}
|
||||||
|
rows := sessionRows(t, auth)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("sessions after renew = %d, want 1 (continuity, no new row)", len(rows))
|
||||||
|
}
|
||||||
|
after := rows[0]
|
||||||
|
if after.ID != before.ID || after.TokenID != before.TokenID {
|
||||||
|
t.Errorf("renew should keep row id and jti: before %+v after %+v", before, after)
|
||||||
|
}
|
||||||
|
if after.Method != "password" {
|
||||||
|
t.Errorf("method = %q, want inherited password", after.Method)
|
||||||
|
}
|
||||||
|
if _, err := auth.ParseToken(ctx, token); err == nil {
|
||||||
|
t.Error("old token still valid after version bump")
|
||||||
|
}
|
||||||
|
if _, err := auth.ParseToken(ctx, fresh); err != nil {
|
||||||
|
t.Errorf("fresh token invalid: %v", err)
|
||||||
|
}
|
||||||
|
// 列表只剩接续会话且标记 current
|
||||||
|
list, err := auth.ListSessions(ctx, "admin", fresh)
|
||||||
|
if err != nil || len(list) != 1 || !list[0].Current {
|
||||||
|
t.Errorf("ListSessions = (%+v, %v), want single current session", list, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionRevokeSingle(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
token1 := loginSession(t, auth, "10.9.0.3", "Laptop")
|
||||||
|
token2 := loginSession(t, auth, "10.9.0.4", "Phone")
|
||||||
|
// token2 已被 ParseToken 校验过(节流缓存生效)后再撤销,验证缓存被清、即时失效
|
||||||
|
if _, err := auth.ParseToken(ctx, token2); err != nil {
|
||||||
|
t.Fatalf("token2 parse: %v", err)
|
||||||
|
}
|
||||||
|
list, err := auth.ListSessions(ctx, "admin", token1)
|
||||||
|
if err != nil || len(list) != 2 {
|
||||||
|
t.Fatalf("ListSessions = (%d, %v), want 2", len(list), err)
|
||||||
|
}
|
||||||
|
var otherID uint
|
||||||
|
for _, it := range list {
|
||||||
|
if it.Current {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
otherID = it.ID
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
id uint
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{name: "撤销当前会话被拒", id: currentSessionID(t, list), wantErr: ErrSessionCurrent},
|
||||||
|
{name: "撤销其他会话成功", id: otherID},
|
||||||
|
{name: "重复撤销已不可见", id: otherID, wantErr: gorm.ErrRecordNotFound},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := auth.RevokeSession(ctx, "admin", token1, tt.id)
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if !errors.Is(err, tt.wantErr) && !(tt.wantErr == gorm.ErrRecordNotFound && err != nil) {
|
||||||
|
t.Fatalf("RevokeSession err = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeSession: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := auth.ParseToken(ctx, token2); err == nil {
|
||||||
|
t.Error("revoked session token still valid")
|
||||||
|
}
|
||||||
|
if _, err := auth.ParseToken(ctx, token1); err != nil {
|
||||||
|
t.Errorf("current token broken by revoking another session: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionRevokeTombstone(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
token1 := loginSession(t, auth, "10.9.0.7", "Laptop")
|
||||||
|
token2 := loginSession(t, auth, "10.9.0.8", "Phone")
|
||||||
|
list, err := auth.ListSessions(ctx, "admin", token1)
|
||||||
|
if err != nil || len(list) != 2 {
|
||||||
|
t.Fatalf("ListSessions = (%d, %v), want 2", len(list), err)
|
||||||
|
}
|
||||||
|
var otherID uint
|
||||||
|
for _, it := range list {
|
||||||
|
if !it.Current {
|
||||||
|
otherID = it.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := auth.RevokeSession(ctx, "admin", token1, otherID); err != nil {
|
||||||
|
t.Fatalf("RevokeSession: %v", err)
|
||||||
|
}
|
||||||
|
// 模拟「校验读库通过→撤销→校验回写 seen」的竞态:手工回写节流缓存,
|
||||||
|
// 撤销负缓存必须仍然盖过它,令牌不得复活
|
||||||
|
jti := auth.signedJti(token2)
|
||||||
|
auth.seen.Set("seen|"+jti, struct{}{}, sessionSeenTTL)
|
||||||
|
if _, err := auth.ParseToken(ctx, token2); err == nil {
|
||||||
|
t.Error("revoked token revived by racing seen-cache write")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSensitiveOpStaleProof 验证在途绕过防线:敏感请求鉴权后挂起,期间发生
|
||||||
|
// 「撤销全部」(版本递增),恢复后的敏感事务复核快照失败,拒绝执行、不发新令牌。
|
||||||
|
// 同一防线也使并发敏感操作串行化(后到者复核失败)。
|
||||||
|
func TestSensitiveOpStaleProof(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
tokenA := loginSession(t, auth, "10.9.1.1", "A")
|
||||||
|
proofA := mustProof(t, auth, tokenA)
|
||||||
|
// 另一设备撤销全部:版本递增,tokenA 的快照随之过期
|
||||||
|
tokenB := loginSession(t, auth, "10.9.1.2", "B")
|
||||||
|
if _, _, err := auth.RevokeSessions(ctx, "admin", tokenB, SessionMeta{ClientIP: "10.9.1.2"}, mustProof(t, auth, tokenB)); err != nil {
|
||||||
|
t.Fatalf("RevokeSessions: %v", err)
|
||||||
|
}
|
||||||
|
// 挂起的旧请求恢复:携带过期快照的敏感操作必须被拒
|
||||||
|
if _, _, err := auth.RevokeSessions(ctx, "admin", tokenA, SessionMeta{ClientIP: "10.9.1.1"}, proofA); !errors.Is(err, ErrTokenStale) {
|
||||||
|
t.Fatalf("stale-proof revoke err = %v, want ErrTokenStale", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSensitiveOpRevokedJtiProof 验证快照的 jti 维度:定点撤销(不递增版本)
|
||||||
|
// 同样令该令牌的在途敏感请求失效。
|
||||||
|
func TestSensitiveOpRevokedJtiProof(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
token1 := loginSession(t, auth, "10.9.2.1", "Laptop")
|
||||||
|
token2 := loginSession(t, auth, "10.9.2.2", "Phone")
|
||||||
|
proof2 := mustProof(t, auth, token2)
|
||||||
|
list, err := auth.ListSessions(ctx, "admin", token1)
|
||||||
|
if err != nil || len(list) != 2 {
|
||||||
|
t.Fatalf("ListSessions = (%d, %v), want 2", len(list), err)
|
||||||
|
}
|
||||||
|
for _, it := range list {
|
||||||
|
if !it.Current {
|
||||||
|
if err := auth.RevokeSession(ctx, "admin", token1, it.ID); err != nil {
|
||||||
|
t.Fatalf("RevokeSession: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// token2 已被定点撤销(版本未变):其在途敏感请求恢复后必须被拒
|
||||||
|
if _, _, err := auth.RevokeSessions(ctx, "admin", token2, SessionMeta{ClientIP: "10.9.2.2"}, proof2); !errors.Is(err, ErrTokenStale) {
|
||||||
|
t.Fatalf("revoked-jti proof err = %v, want ErrTokenStale", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckedSessionCannotRenewAfterTargetedRevoke(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
stale := loginSession(t, auth, "10.9.3.1", "stale")
|
||||||
|
current := loginSession(t, auth, "10.9.3.2", "current")
|
||||||
|
proof := mustProof(t, auth, stale)
|
||||||
|
assertProofCurrentTx(t, auth, proof)
|
||||||
|
staleID := otherSessionID(t, auth, current)
|
||||||
|
if err := auth.bumpTokenVersion(ctx, "admin"); err != nil {
|
||||||
|
t.Fatalf("simulate sensitive mutation: %v", err)
|
||||||
|
}
|
||||||
|
if err := auth.RevokeSession(ctx, "admin", current, staleID); err != nil {
|
||||||
|
t.Fatalf("RevokeSession: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.RenewToken(
|
||||||
|
ctx, "admin", stale, SessionMeta{ClientIP: "10.9.3.1"},
|
||||||
|
); !errors.Is(err, ErrTokenStale) {
|
||||||
|
t.Fatalf("RenewToken err = %v, want ErrTokenStale", err)
|
||||||
|
}
|
||||||
|
if got := len(sessionRows(t, auth)); got != 2 {
|
||||||
|
t.Fatalf("session rows = %d, want 2 (不得 fallback CREATE)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func otherSessionID(t *testing.T, auth *AuthService, current string) uint {
|
||||||
|
t.Helper()
|
||||||
|
for _, item := range mustSessions(t, auth, current) {
|
||||||
|
if !item.Current {
|
||||||
|
return item.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("no other session")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyLogoutBlocksInflightProofAndRenew(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
token, _, _, err := auth.signToken("admin", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signToken: %v", err)
|
||||||
|
}
|
||||||
|
proof := mustProof(t, auth, token)
|
||||||
|
assertProofCurrentTx(t, auth, proof)
|
||||||
|
auth.Logout(ctx, token)
|
||||||
|
assertProofStaleTx(t, auth, proof)
|
||||||
|
if err := auth.bumpTokenVersion(ctx, "admin"); err != nil {
|
||||||
|
t.Fatalf("simulate already committed mutation: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.RenewToken(
|
||||||
|
ctx, "admin", token, SessionMeta{ClientIP: "10.9.4.1"},
|
||||||
|
); !errors.Is(err, ErrTokenStale) {
|
||||||
|
t.Fatalf("RenewToken err = %v, want ErrTokenStale", err)
|
||||||
|
}
|
||||||
|
if got := len(sessionRows(t, auth)); got != 0 {
|
||||||
|
t.Fatalf("session rows = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type logoutAfterRenameCase struct {
|
||||||
|
name string
|
||||||
|
legacy bool
|
||||||
|
meta SessionMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogoutOldTokenAfterCredentialRename(t *testing.T) {
|
||||||
|
tests := []logoutAfterRenameCase{
|
||||||
|
{name: "recorded session", meta: SessionMeta{ClientIP: "10.9.5.1", UserAgent: "recorded"}},
|
||||||
|
{name: "legacy session without row", legacy: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
testLogoutOldTokenAfterCredentialRename(t, tt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLogoutOldTokenAfterCredentialRename(t *testing.T, tt logoutAfterRenameCase) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
old, oldExpires := tokenForRenewalTest(t, auth, tt.legacy)
|
||||||
|
fresh, expires := renewAfterCredentialRename(t, auth, old, tt.meta)
|
||||||
|
if auth.signedJti(old) != auth.signedJti(fresh) {
|
||||||
|
t.Fatal("renewal changed jti; logout lineage would be lost")
|
||||||
|
}
|
||||||
|
if tt.legacy && len(sessionRows(t, auth)) != 0 {
|
||||||
|
t.Fatal("legacy renewal unexpectedly created a session row")
|
||||||
|
}
|
||||||
|
auth.Logout(context.Background(), old)
|
||||||
|
if _, err := auth.ParseToken(context.Background(), fresh); err == nil {
|
||||||
|
t.Fatal("fresh token survived logout of its pre-renewal token")
|
||||||
|
}
|
||||||
|
if tt.legacy {
|
||||||
|
assertTombstoneCovers(t, auth, old, oldExpires, expires)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertSessionJTIRevoked(t, auth, old)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renewAfterCredentialRename(
|
||||||
|
t *testing.T, auth *AuthService, old string, meta SessionMeta,
|
||||||
|
) (string, time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
finalName, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
|
||||||
|
NewUsername: "root", CurrentPassword: "pass123",
|
||||||
|
}, mustProof(t, auth, old))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateCredentials: %v", err)
|
||||||
|
}
|
||||||
|
fresh, expires, err := auth.RenewToken(ctx, finalName, old, meta)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenewToken: %v", err)
|
||||||
|
}
|
||||||
|
return fresh, expires
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSessionJTIRevoked(t *testing.T, auth *AuthService, token string) {
|
||||||
|
t.Helper()
|
||||||
|
var row model.UserSession
|
||||||
|
err := auth.db.Where("token_id = ?", auth.signedJti(token)).First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("find session: %v", err)
|
||||||
|
}
|
||||||
|
if row.RevokedAt == nil {
|
||||||
|
t.Fatal("renamed user's session row was not revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertTombstoneCovers(
|
||||||
|
t *testing.T, auth *AuthService, token string, oldExpires, freshExpires time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
jti := auth.signedJti(token)
|
||||||
|
auth.revokedJti.mu.RLock()
|
||||||
|
tombstoneExpires, ok := auth.revokedJti.m[jti]
|
||||||
|
auth.revokedJti.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("logout jti tombstone missing")
|
||||||
|
}
|
||||||
|
requiredUntil := oldExpires.Truncate(time.Second).Add(tokenTTL)
|
||||||
|
if tombstoneExpires.Before(requiredUntil) || tombstoneExpires.Before(freshExpires) {
|
||||||
|
t.Fatalf("tombstone expires %v before required horizon %v", tombstoneExpires, requiredUntil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenForRenewalTest(t *testing.T, auth *AuthService, legacy bool) (string, time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if !legacy {
|
||||||
|
token, expires, err := auth.Login(context.Background(), "admin", "pass123", "",
|
||||||
|
SessionMeta{ClientIP: "10.9.5.1", UserAgent: "recorded"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login: %v", err)
|
||||||
|
}
|
||||||
|
return token, expires
|
||||||
|
}
|
||||||
|
token, expires, _, err := auth.signToken("admin", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signToken: %v", err)
|
||||||
|
}
|
||||||
|
return token, expires
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertProofCurrentTx(t *testing.T, auth *AuthService, proof TokenProof) {
|
||||||
|
t.Helper()
|
||||||
|
err := auth.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserForAuthChange(tx, "admin")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return auth.ensureTokenCurrentTx(tx, user, proof)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("proof should be current: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertProofStaleTx(t *testing.T, auth *AuthService, proof TokenProof) {
|
||||||
|
t.Helper()
|
||||||
|
err := auth.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, lockErr := lockUserForAuthChange(tx, "admin")
|
||||||
|
if lockErr != nil {
|
||||||
|
return lockErr
|
||||||
|
}
|
||||||
|
return auth.ensureTokenCurrentTx(tx, user, proof)
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrTokenStale) {
|
||||||
|
t.Fatalf("proof err = %v, want ErrTokenStale", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustSessions(t *testing.T, auth *AuthService, current string) []SessionInfo {
|
||||||
|
t.Helper()
|
||||||
|
items, err := auth.ListSessions(context.Background(), "admin", current)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSessions: %v", err)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTombstoneNoEviction 验证撤销负缓存无容量上限:大量撤销标记全部存活,
|
||||||
|
// 不存在「满载淘汰导致已撤销令牌复活」。
|
||||||
|
func TestTombstoneNoEviction(t *testing.T) {
|
||||||
|
ts := newJtiTombstones()
|
||||||
|
for i := 0; i < 600; i++ {
|
||||||
|
ts.put(fmt.Sprintf("jti-%d", i), time.Minute)
|
||||||
|
}
|
||||||
|
for i := 0; i < 600; i++ {
|
||||||
|
if !ts.has(fmt.Sprintf("jti-%d", i)) {
|
||||||
|
t.Fatalf("tombstone jti-%d evicted", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ts.has("jti-none") {
|
||||||
|
t.Error("unknown jti reported revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentSessionID 取列表中 current 条目的 ID。
|
||||||
|
func currentSessionID(t *testing.T, list []SessionInfo) uint {
|
||||||
|
t.Helper()
|
||||||
|
for _, it := range list {
|
||||||
|
if it.Current {
|
||||||
|
return it.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("no current session in list")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLegacyTokenAllowed(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
// 直签令牌(无会话行)模拟升级前存量令牌:仍可通过校验
|
||||||
|
token, _, _, err := auth.signToken("admin", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signToken: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := auth.ParseToken(context.Background(), token); err != nil {
|
||||||
|
t.Errorf("legacy token rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLogoutMarksRow(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
token := loginSession(t, auth, "10.9.0.5", "UA")
|
||||||
|
auth.Logout(ctx, token)
|
||||||
|
rows := sessionRows(t, auth)
|
||||||
|
if len(rows) != 1 || rows[0].RevokedAt == nil {
|
||||||
|
t.Errorf("logout should mark session revoked: %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionCleanup(t *testing.T) {
|
||||||
|
auth := newTestAuth(t)
|
||||||
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
||||||
|
t.Fatalf("EnsureAdmin: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
old := now.Add(-8 * 24 * time.Hour)
|
||||||
|
seed := []model.UserSession{
|
||||||
|
{UserID: 1, TokenID: "expired", ExpiresAt: now.Add(-25 * time.Hour), LastSeenAt: old},
|
||||||
|
{UserID: 1, TokenID: "revoked-old", ExpiresAt: now.Add(time.Hour), RevokedAt: &old, LastSeenAt: old},
|
||||||
|
{UserID: 1, TokenID: "alive", ExpiresAt: now.Add(time.Hour), LastSeenAt: now},
|
||||||
|
}
|
||||||
|
for i := range seed {
|
||||||
|
if err := auth.db.Create(&seed[i]).Error; err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auth.cleanupSessionsOnce(ctx)
|
||||||
|
rows := sessionRows(t, auth)
|
||||||
|
if len(rows) != 1 || rows[0].TokenID != "alive" {
|
||||||
|
t.Errorf("after cleanup rows = %+v, want only alive", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,8 +81,29 @@ func (s *SettingService) get(ctx context.Context, key string) (string, error) {
|
|||||||
|
|
||||||
// set 写入键值(key 为主键,Save 即 upsert)。
|
// set 写入键值(key 为主键,Save 即 upsert)。
|
||||||
func (s *SettingService) set(ctx context.Context, key, value string) error {
|
func (s *SettingService) set(ctx context.Context, key, value string) error {
|
||||||
|
if err := saveSettingTx(s.db.WithContext(ctx), key, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingValueTx / saveSettingTx 是事务内变体:供跨表不变量(如「至少保留一种
|
||||||
|
// 登录方式」)在同一事务与用户行锁下读写开关,与 get/set 同表同形。
|
||||||
|
func settingValueTx(tx *gorm.DB, key string) (string, error) {
|
||||||
|
var st model.Setting
|
||||||
|
err := tx.First(&st, "key = ?", key).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get setting %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return st.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSettingTx(tx *gorm.DB, key, value string) error {
|
||||||
st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()}
|
st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()}
|
||||||
if err := s.db.WithContext(ctx).Save(&st).Error; err != nil {
|
if err := tx.Save(&st).Error; err != nil {
|
||||||
return fmt.Errorf("set setting %s: %w", key, err)
|
return fmt.Errorf("set setting %s: %w", key, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func newSettingEnv(t *testing.T) (*SettingService, *gorm.DB) {
|
|||||||
t.Fatalf("db handle: %v", err)
|
t.Fatalf("db handle: %v", err)
|
||||||
}
|
}
|
||||||
sqlDB.SetMaxOpenConns(1)
|
sqlDB.SetMaxOpenConns(1)
|
||||||
if err := db.AutoMigrate(&model.Setting{}); err != nil {
|
if err := db.AutoMigrate(&model.Setting{}, &model.User{}, &model.UserIdentity{}, &model.UserPasskey{}); err != nil {
|
||||||
t.Fatalf("auto migrate: %v", err)
|
t.Fatalf("auto migrate: %v", err)
|
||||||
}
|
}
|
||||||
cipher, err := crypto.NewCipher("test-data-key")
|
cipher, err := crypto.NewCipher("test-data-key")
|
||||||
@@ -41,6 +41,10 @@ func newSettingEnv(t *testing.T) (*SettingService, *gorm.DB) {
|
|||||||
|
|
||||||
func strPtr(s string) *string { return &s }
|
func strPtr(s string) *string { return &s }
|
||||||
|
|
||||||
|
func intPtr(n int) *int { return &n }
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|
||||||
func TestUpdateTelegramTokenHandling(t *testing.T) {
|
func TestUpdateTelegramTokenHandling(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -202,6 +202,38 @@ func normalizeSnatchPayload(taskType string, payload json.RawMessage) json.RawMe
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeSnatchPayload 把编辑提交的 count 视为**新目标台数**:按旧 payload 的
|
||||||
|
// 已完成数换算剩余、保留连续鉴权失败计数,目标不大于已完成数时拒绝。
|
||||||
|
// 非抢机任务原样返回;旧 payload 不可解析时按新建任务归一化。
|
||||||
|
func mergeSnatchPayload(task *model.Task, incoming json.RawMessage) (json.RawMessage, error) {
|
||||||
|
if task.Type != model.TaskTypeSnatch {
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
var old, next snatchPayload
|
||||||
|
if err := json.Unmarshal([]byte(task.Payload), &old); err != nil {
|
||||||
|
return normalizeSnatchPayload(task.Type, incoming), nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(incoming, &next); err != nil {
|
||||||
|
return nil, fmt.Errorf("update task: invalid payload: %w", err)
|
||||||
|
}
|
||||||
|
oldTotal := old.TotalCount
|
||||||
|
if oldTotal <= 0 {
|
||||||
|
oldTotal = old.Count
|
||||||
|
}
|
||||||
|
done := oldTotal - old.Count
|
||||||
|
if next.Count <= done {
|
||||||
|
return nil, fmt.Errorf("%w(已完成 %d 台)", ErrSnatchTargetTooLow, done)
|
||||||
|
}
|
||||||
|
next.TotalCount = next.Count
|
||||||
|
next.Count -= done
|
||||||
|
next.AuthFailCount = old.AuthFailCount
|
||||||
|
out, err := json.Marshal(next)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update task: marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateTaskPayload 按任务类型校验参数 JSON。
|
// validateTaskPayload 按任务类型校验参数 JSON。
|
||||||
func validateTaskPayload(taskType string, payload json.RawMessage) error {
|
func validateTaskPayload(taskType string, payload json.RawMessage) error {
|
||||||
switch taskType {
|
switch taskType {
|
||||||
@@ -245,7 +277,8 @@ type UpdateTaskInput struct {
|
|||||||
Status *string
|
Status *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTask 修改任务并重新调度。
|
// UpdateTask 修改任务并重新调度;写入用 updated_at 条件更新,
|
||||||
|
// 防止陈旧快照整行覆盖并发执行刚落库的状态与进度。
|
||||||
func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInput) (*model.Task, error) {
|
func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInput) (*model.Task, error) {
|
||||||
task, err := s.GetTask(ctx, id)
|
task, err := s.GetTask(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -254,16 +287,39 @@ func (s *TaskService) UpdateTask(ctx context.Context, id uint, in UpdateTaskInpu
|
|||||||
if err := applyTaskUpdate(task, in); err != nil {
|
if err := applyTaskUpdate(task, in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.db.WithContext(ctx).Save(task).Error; err != nil {
|
fresh, err := s.persistTaskUpdate(ctx, task)
|
||||||
return nil, fmt.Errorf("update task %d: %w", id, err)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
s.unschedule(task.ID)
|
s.unschedule(fresh.ID)
|
||||||
if task.Status == model.TaskStatusActive {
|
if fresh.Status == model.TaskStatusActive {
|
||||||
if err := s.schedule(task); err != nil {
|
if err := s.schedule(fresh); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return task, nil
|
return fresh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistTaskUpdate 只写用户可编辑列;零命中说明执行侧已并发落库,返回冲突。
|
||||||
|
func (s *TaskService) persistTaskUpdate(ctx context.Context, task *model.Task) (*model.Task, error) {
|
||||||
|
res := s.db.WithContext(ctx).Model(&model.Task{}).
|
||||||
|
Where("id = ? AND updated_at = ?", task.ID, task.UpdatedAt).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"name": task.Name, "cron_expr": task.CronExpr,
|
||||||
|
"payload": task.Payload, "status": task.Status,
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return nil, fmt.Errorf("update task %d: %w", task.ID, res.Error)
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return nil, ErrTaskConflict
|
||||||
|
}
|
||||||
|
// 重读用新变量:gorm 扫描 NULL 列到已有值的结构体时会保留旧值
|
||||||
|
var fresh model.Task
|
||||||
|
if err := s.db.WithContext(ctx).First(&fresh, task.ID).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("reload task %d: %w", task.ID, err)
|
||||||
|
}
|
||||||
|
return &fresh, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error {
|
func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error {
|
||||||
@@ -280,7 +336,11 @@ func applyTaskUpdate(task *model.Task, in UpdateTaskInput) error {
|
|||||||
if err := validateTaskPayload(task.Type, in.Payload); err != nil {
|
if err := validateTaskPayload(task.Type, in.Payload); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
task.Payload = string(in.Payload)
|
merged, err := mergeSnatchPayload(task, in.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
task.Payload = string(merged)
|
||||||
}
|
}
|
||||||
if in.Status != nil {
|
if in.Status != nil {
|
||||||
if *in.Status != model.TaskStatusActive && *in.Status != model.TaskStatusPaused {
|
if *in.Status != model.TaskStatusActive && *in.Status != model.TaskStatusPaused {
|
||||||
@@ -376,6 +436,12 @@ func (s *TaskService) RunTaskNow(ctx context.Context, id uint) (*model.TaskLog,
|
|||||||
// ErrTaskRunning 表示任务已有一次执行在途,拒绝重复触发。
|
// ErrTaskRunning 表示任务已有一次执行在途,拒绝重复触发。
|
||||||
var ErrTaskRunning = errors.New("任务正在执行中,请稍候")
|
var ErrTaskRunning = errors.New("任务正在执行中,请稍候")
|
||||||
|
|
||||||
|
// ErrTaskConflict 表示任务在编辑期间被并发修改(如执行结果落库),须刷新重试。
|
||||||
|
var ErrTaskConflict = errors.New("任务状态已变化,请刷新后重试")
|
||||||
|
|
||||||
|
// ErrSnatchTargetTooLow 表示编辑抢机任务时新目标台数不大于已完成数量。
|
||||||
|
var ErrSnatchTargetTooLow = errors.New("目标台数须大于已完成数量")
|
||||||
|
|
||||||
// TriggerTask 异步触发一次任务执行并立即返回;执行结果照常落任务日志与通知,
|
// TriggerTask 异步触发一次任务执行并立即返回;执行结果照常落任务日志与通知,
|
||||||
// 由前端轮询呈现。同一任务在途时返回 ErrTaskRunning。
|
// 由前端轮询呈现。同一任务在途时返回 ErrTaskRunning。
|
||||||
func (s *TaskService) TriggerTask(ctx context.Context, id uint) error {
|
func (s *TaskService) TriggerTask(ctx context.Context, id uint) error {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -835,3 +836,93 @@ func TestTriggerTaskAsyncAndDedup(t *testing.T) {
|
|||||||
t.Error("不存在的任务应报错")
|
t.Error("不存在的任务应报错")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// snatchInstanceJSON 是编辑测试用的合法实例参数片段。
|
||||||
|
const snatchInstanceJSON = `"instance":{"displayName":"vm","shape":"VM.Standard.A1.Flex","ocpus":4,"memoryInGBs":24,"imageId":"ocid1.image.test"}`
|
||||||
|
|
||||||
|
// TestUpdateSnatchTaskCountIsTarget 锁定编辑语义:提交的 count 是目标台数,
|
||||||
|
// 按旧 payload 已完成数换算剩余、保留鉴权失败计数;目标不大于已完成数拒绝。
|
||||||
|
func TestUpdateSnatchTaskCountIsTarget(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
oldPayload string
|
||||||
|
newCount int
|
||||||
|
wantErr error
|
||||||
|
want snatchPayload
|
||||||
|
}{
|
||||||
|
{"部分成功后提高目标", `{"ociConfigId":1,"count":3,"totalCount":5,"authFailCount":2,` + snatchInstanceJSON + `}`,
|
||||||
|
6, nil, snatchPayload{Count: 4, TotalCount: 6, AuthFailCount: 2}},
|
||||||
|
{"原样保存不丢进度", `{"ociConfigId":1,"count":3,"totalCount":5,"authFailCount":2,` + snatchInstanceJSON + `}`,
|
||||||
|
5, nil, snatchPayload{Count: 3, TotalCount: 5, AuthFailCount: 2}},
|
||||||
|
{"目标不大于已完成拒绝", `{"ociConfigId":1,"count":3,"totalCount":5,` + snatchInstanceJSON + `}`,
|
||||||
|
2, ErrSnatchTargetTooLow, snatchPayload{}},
|
||||||
|
{"旧任务缺 totalCount 视为零完成", `{"ociConfigId":1,"count":3,` + snatchInstanceJSON + `}`,
|
||||||
|
5, nil, snatchPayload{Count: 5, TotalCount: 5}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tasks, _, db := newTaskEnv(t, &fakeClient{})
|
||||||
|
ctx := context.Background()
|
||||||
|
task, err := tasks.CreateTask(ctx, CreateTaskInput{
|
||||||
|
Name: "抢机", Type: model.TaskTypeSnatch, CronExpr: "* * * * *",
|
||||||
|
Payload: json.RawMessage(tt.oldPayload),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
// 直写旧 payload,绕过创建期归一化,构造「执行过若干轮」的现场
|
||||||
|
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).
|
||||||
|
Update("payload", tt.oldPayload).Error; err != nil {
|
||||||
|
t.Fatalf("seed payload: %v", err)
|
||||||
|
}
|
||||||
|
incoming := fmt.Sprintf(`{"ociConfigId":1,"count":%d,`+snatchInstanceJSON+`}`, tt.newCount)
|
||||||
|
updated, err := tasks.UpdateTask(ctx, task.ID, UpdateTaskInput{Payload: json.RawMessage(incoming)})
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateTask: %v", err)
|
||||||
|
}
|
||||||
|
var got snatchPayload
|
||||||
|
if err := json.Unmarshal([]byte(updated.Payload), &got); err != nil {
|
||||||
|
t.Fatalf("payload: %v", err)
|
||||||
|
}
|
||||||
|
if got.Count != tt.want.Count || got.TotalCount != tt.want.TotalCount || got.AuthFailCount != tt.want.AuthFailCount {
|
||||||
|
t.Errorf("payload = {count:%d total:%d authFail:%d}, want {count:%d total:%d authFail:%d}",
|
||||||
|
got.Count, got.TotalCount, got.AuthFailCount, tt.want.Count, tt.want.TotalCount, tt.want.AuthFailCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPersistTaskUpdateConflict 锁定编辑竞态防护:陈旧快照零命中返回冲突且不落库。
|
||||||
|
func TestPersistTaskUpdateConflict(t *testing.T) {
|
||||||
|
tasks, _, db := newTaskEnv(t, &fakeClient{})
|
||||||
|
ctx := context.Background()
|
||||||
|
task, err := tasks.CreateTask(ctx, CreateTaskInput{
|
||||||
|
Name: "测活", Type: model.TaskTypeHealthCheck, CronExpr: "*/10 * * * *",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
stale, err := tasks.GetTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTask: %v", err)
|
||||||
|
}
|
||||||
|
// 模拟执行侧并发落库:行的 updated_at 前移,编辑快照过期
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).Update("run_count", 1).Error; err != nil {
|
||||||
|
t.Fatalf("simulate run persist: %v", err)
|
||||||
|
}
|
||||||
|
stale.Name = "renamed"
|
||||||
|
if _, err := tasks.persistTaskUpdate(ctx, stale); !errors.Is(err, ErrTaskConflict) {
|
||||||
|
t.Fatalf("err = %v, want ErrTaskConflict", err)
|
||||||
|
}
|
||||||
|
fresh, _ := tasks.GetTask(ctx, task.ID)
|
||||||
|
if fresh.Name != "测活" || fresh.RunCount != 1 {
|
||||||
|
t.Errorf("row = {name:%s runCount:%d}, want 执行结果保留且未被改名", fresh.Name, fresh.RunCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -231,11 +231,10 @@ func deleteTenantAI(tx *gorm.DB, id uint, result *tenantDeleteResult) error {
|
|||||||
if len(channelIDs) == 0 {
|
if len(channelIDs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
callIDs, err := lockedAiCallIDs(tx, channelIDs)
|
if err := lockAiCallRows(tx, channelIDs); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("load tenant AI calls: %w", err)
|
return fmt.Errorf("load tenant AI calls: %w", err)
|
||||||
}
|
}
|
||||||
if err := deleteTenantAIRows(tx, channelIDs, callIDs); err != nil {
|
if err := deleteTenantAIRows(tx, channelIDs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result.channelsGone = true
|
result.channelsGone = true
|
||||||
@@ -253,22 +252,19 @@ func lockedAiChannelIDs(tx *gorm.DB, id uint) ([]uint, error) {
|
|||||||
return ids, err
|
return ids, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func lockedAiCallIDs(tx *gorm.DB, channelIDs []uint) ([]uint, error) {
|
// lockAiCallRows 对租户渠道的全部调用日志行加 FOR UPDATE 锁(SQLite 忽略,
|
||||||
|
// MySQL/PG 阻塞并发写入);调用日志可达数万,ID 不回传拼接 SQL,
|
||||||
|
// 内容日志删除用子查询,避免绑定变量上限。
|
||||||
|
func lockAiCallRows(tx *gorm.DB, channelIDs []uint) error {
|
||||||
var rows []model.AiCallLog
|
var rows []model.AiCallLog
|
||||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
|
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
|
||||||
Where("channel_id IN ?", channelIDs).Order("id").Find(&rows).Error
|
Where("channel_id IN ?", channelIDs).Order("id").Find(&rows).Error
|
||||||
ids := make([]uint, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
ids = append(ids, row.ID)
|
|
||||||
}
|
|
||||||
return ids, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteTenantAIRows(tx *gorm.DB, channelIDs, callIDs []uint) error {
|
func deleteTenantAIRows(tx *gorm.DB, channelIDs []uint) error {
|
||||||
if len(callIDs) > 0 {
|
callIDs := tx.Model(&model.AiCallLog{}).Select("id").Where("channel_id IN ?", channelIDs)
|
||||||
if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN ?", callIDs); err != nil {
|
if err := deleteWhere(tx, &model.AiContentLog{}, "call_log_id IN (?)", callIDs); err != nil {
|
||||||
return fmt.Errorf("delete tenant AI content logs: %w", err)
|
return fmt.Errorf("delete tenant AI content logs: %w", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
steps := []struct {
|
steps := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+66
-28
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"oci-portal/internal/model"
|
"oci-portal/internal/model"
|
||||||
)
|
)
|
||||||
@@ -79,52 +80,89 @@ func (s *AuthService) SetupTotp(ctx context.Context, username string) (secret, u
|
|||||||
return key.Secret(), key.URL(), nil
|
return key.Secret(), key.URL(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivateTotp 校验暂存密钥的验证码,通过后加密落库启用。
|
// ActivateTotp 校验暂存密钥并在同一事务内启用、递增版本、接续当前会话。
|
||||||
func (s *AuthService) ActivateTotp(ctx context.Context, username, code string) error {
|
func (s *AuthService) ActivateTotp(
|
||||||
|
ctx context.Context, username, code, oldToken string, meta SessionMeta, proof TokenProof,
|
||||||
|
) (string, time.Time, error) {
|
||||||
s.totpMu.Lock()
|
s.totpMu.Lock()
|
||||||
pending, ok := s.totpPending[username]
|
pending, ok := s.totpPending[username]
|
||||||
s.totpMu.Unlock()
|
s.totpMu.Unlock()
|
||||||
if !ok || time.Now().After(pending.expires) {
|
if !ok || time.Now().After(pending.expires) {
|
||||||
return ErrTotpNotSetup
|
return "", time.Time{}, ErrTotpNotSetup
|
||||||
}
|
}
|
||||||
if !totp.Validate(code, pending.secret) {
|
if !totp.Validate(code, pending.secret) {
|
||||||
return ErrTotpInvalid
|
return "", time.Time{}, ErrTotpInvalid
|
||||||
}
|
}
|
||||||
enc, err := s.cipher.EncryptString(pending.secret)
|
enc, err := s.cipher.EncryptString(pending.secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt totp secret: %w", err)
|
return "", time.Time{}, fmt.Errorf("encrypt totp secret: %w", err)
|
||||||
}
|
}
|
||||||
err = s.db.WithContext(ctx).Model(&model.User{}).
|
token, expires, err := s.changeTotp(ctx, username, enc, oldToken, meta, proof, nil)
|
||||||
Where("username = ?", username).Update("totp_secret_enc", enc).Error
|
if err == nil {
|
||||||
if err != nil {
|
s.deleteTotpPending(username, pending.secret)
|
||||||
return fmt.Errorf("save totp secret: %w", err)
|
|
||||||
}
|
}
|
||||||
s.totpMu.Lock()
|
return token, expires, err
|
||||||
delete(s.totpPending, username)
|
|
||||||
s.totpMu.Unlock()
|
|
||||||
// 两步验证形态变更:旧令牌全部失效
|
|
||||||
return s.bumpTokenVersion(ctx, username)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisableTotp 停用两步验证;需当前验证码或登录密码任一确认。
|
// DisableTotp 停用两步验证;需当前验证码或登录密码任一确认。
|
||||||
func (s *AuthService) DisableTotp(ctx context.Context, username, password, code string) error {
|
func (s *AuthService) DisableTotp(
|
||||||
user, err := s.findUser(ctx, username)
|
ctx context.Context, username, password, code, oldToken string, meta SessionMeta, proof TokenProof,
|
||||||
if err != nil {
|
) (string, time.Time, error) {
|
||||||
return err
|
confirm := func(user *model.User) error {
|
||||||
}
|
if user.TotpSecretEnc != "" && !s.confirmDisable(user, password, code) {
|
||||||
if user.TotpSecretEnc == "" {
|
return ErrTotpConfirm
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !s.confirmDisable(user, password, code) {
|
return s.changeTotp(ctx, username, "", oldToken, meta, proof, confirm)
|
||||||
return ErrTotpConfirm
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) changeTotp(
|
||||||
|
ctx context.Context, username, secret, oldToken string, meta SessionMeta,
|
||||||
|
proof TokenProof, confirm func(*model.User) error,
|
||||||
|
) (string, time.Time, error) {
|
||||||
|
var token string
|
||||||
|
var expires time.Time
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := lockUserForAuthChange(tx, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.ensureTokenCurrentTx(tx, user, proof); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if confirm != nil {
|
||||||
|
if err := confirm(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.persistTotpTx(tx, user, secret, oldToken, meta, &token, &expires)
|
||||||
|
})
|
||||||
|
return token, expires, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) persistTotpTx(
|
||||||
|
tx *gorm.DB, user *model.User, secret, oldToken string, meta SessionMeta,
|
||||||
|
token *string, expires *time.Time,
|
||||||
|
) error {
|
||||||
|
if err := tx.Model(user).Update("totp_secret_enc", secret).Error; err != nil {
|
||||||
|
return fmt.Errorf("save totp secret: %w", err)
|
||||||
}
|
}
|
||||||
err = s.db.WithContext(ctx).Model(&model.User{}).
|
if err := bumpTokenVersionTx(tx, user.Username); err != nil {
|
||||||
Where("username = ?", username).Update("totp_secret_enc", "").Error
|
return err
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("clear totp secret: %w", err)
|
user.TokenVersion++
|
||||||
|
var err error
|
||||||
|
*token, *expires, err = s.renewSessionTx(tx, user, oldToken, meta)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) deleteTotpPending(username, secret string) {
|
||||||
|
s.totpMu.Lock()
|
||||||
|
defer s.totpMu.Unlock()
|
||||||
|
if p, ok := s.totpPending[username]; ok && p.secret == secret {
|
||||||
|
delete(s.totpPending, username)
|
||||||
}
|
}
|
||||||
// 两步验证形态变更:旧令牌全部失效
|
|
||||||
return s.bumpTokenVersion(ctx, username)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirmDisable 校验停用凭证:验证码或密码任一通过即可。
|
// confirmDisable 校验停用凭证:验证码或密码任一通过即可。
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func newTotpEnv(t *testing.T) (*AuthService, *gorm.DB) {
|
|||||||
t.Fatalf("db handle: %v", err)
|
t.Fatalf("db handle: %v", err)
|
||||||
}
|
}
|
||||||
sqlDB.SetMaxOpenConns(1)
|
sqlDB.SetMaxOpenConns(1)
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.UserIdentity{}, &model.Setting{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.UserIdentity{}, &model.UserPasskey{}, &model.UserSession{}, &model.Setting{}); err != nil {
|
||||||
t.Fatalf("auto migrate: %v", err)
|
t.Fatalf("auto migrate: %v", err)
|
||||||
}
|
}
|
||||||
auth := NewAuthService(db, "test-secret")
|
auth := NewAuthService(db, "test-secret")
|
||||||
@@ -44,10 +44,11 @@ func newTotpEnv(t *testing.T) (*AuthService, *gorm.DB) {
|
|||||||
return auth, db
|
return auth, db
|
||||||
}
|
}
|
||||||
|
|
||||||
// enableTotp 走完整 setup→activate 流程,返回明文密钥供测试生成验证码。
|
// enableTotp 走完整 login→setup→activate 流程,返回明文密钥与接续令牌。
|
||||||
func enableTotp(t *testing.T, auth *AuthService) string {
|
func enableTotp(t *testing.T, auth *AuthService) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
oldToken := loginSession(t, auth, "127.0.0.1", "totp-test")
|
||||||
secret, uri, err := auth.SetupTotp(ctx, "admin")
|
secret, uri, err := auth.SetupTotp(ctx, "admin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SetupTotp: %v", err)
|
t.Fatalf("SetupTotp: %v", err)
|
||||||
@@ -59,10 +60,12 @@ func enableTotp(t *testing.T, auth *AuthService) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generate code: %v", err)
|
t.Fatalf("generate code: %v", err)
|
||||||
}
|
}
|
||||||
if err := auth.ActivateTotp(ctx, "admin", code); err != nil {
|
token, _, err := auth.ActivateTotp(
|
||||||
|
ctx, "admin", code, oldToken, SessionMeta{ClientIP: "127.0.0.1"}, mustProof(t, auth, oldToken))
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("ActivateTotp: %v", err)
|
t.Fatalf("ActivateTotp: %v", err)
|
||||||
}
|
}
|
||||||
return secret
|
return secret, token
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTotpLifecycle(t *testing.T) {
|
func TestTotpLifecycle(t *testing.T) {
|
||||||
@@ -72,7 +75,7 @@ func TestTotpLifecycle(t *testing.T) {
|
|||||||
if on, _ := auth.TotpStatus(ctx, "admin"); on {
|
if on, _ := auth.TotpStatus(ctx, "admin"); on {
|
||||||
t.Fatal("初始不应启用")
|
t.Fatal("初始不应启用")
|
||||||
}
|
}
|
||||||
secret := enableTotp(t, auth)
|
secret, token := enableTotp(t, auth)
|
||||||
if on, _ := auth.TotpStatus(ctx, "admin"); !on {
|
if on, _ := auth.TotpStatus(ctx, "admin"); !on {
|
||||||
t.Fatal("激活后应为启用")
|
t.Fatal("激活后应为启用")
|
||||||
}
|
}
|
||||||
@@ -89,10 +92,12 @@ func TestTotpLifecycle(t *testing.T) {
|
|||||||
t.Errorf("重复 setup err = %v, want ErrTotpAlreadyOn", err)
|
t.Errorf("重复 setup err = %v, want ErrTotpAlreadyOn", err)
|
||||||
}
|
}
|
||||||
// 停用:无凭证拒绝,密码通过
|
// 停用:无凭证拒绝,密码通过
|
||||||
if err := auth.DisableTotp(ctx, "admin", "", ""); !errors.Is(err, ErrTotpConfirm) {
|
if _, _, err := auth.DisableTotp(
|
||||||
|
ctx, "admin", "", "", token, SessionMeta{}, mustProof(t, auth, token)); !errors.Is(err, ErrTotpConfirm) {
|
||||||
t.Errorf("空凭证停用 err = %v, want ErrTotpConfirm", err)
|
t.Errorf("空凭证停用 err = %v, want ErrTotpConfirm", err)
|
||||||
}
|
}
|
||||||
if err := auth.DisableTotp(ctx, "admin", "pass123", ""); err != nil {
|
if _, _, err := auth.DisableTotp(
|
||||||
|
ctx, "admin", "pass123", "", token, SessionMeta{}, mustProof(t, auth, token)); err != nil {
|
||||||
t.Fatalf("密码停用: %v", err)
|
t.Fatalf("密码停用: %v", err)
|
||||||
}
|
}
|
||||||
if on, _ := auth.TotpStatus(ctx, "admin"); on {
|
if on, _ := auth.TotpStatus(ctx, "admin"); on {
|
||||||
@@ -104,29 +109,59 @@ func TestActivateTotpRejects(t *testing.T) {
|
|||||||
auth, _ := newTotpEnv(t)
|
auth, _ := newTotpEnv(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// 未 setup 直接激活
|
// 未 setup 直接激活
|
||||||
if err := auth.ActivateTotp(ctx, "admin", "123456"); !errors.Is(err, ErrTotpNotSetup) {
|
if _, _, err := auth.ActivateTotp(
|
||||||
|
ctx, "admin", "123456", "", SessionMeta{}, TokenProof{}); !errors.Is(err, ErrTotpNotSetup) {
|
||||||
t.Errorf("err = %v, want ErrTotpNotSetup", err)
|
t.Errorf("err = %v, want ErrTotpNotSetup", err)
|
||||||
}
|
}
|
||||||
// setup 后错误验证码
|
// setup 后错误验证码
|
||||||
if _, _, err := auth.SetupTotp(ctx, "admin"); err != nil {
|
if _, _, err := auth.SetupTotp(ctx, "admin"); err != nil {
|
||||||
t.Fatalf("SetupTotp: %v", err)
|
t.Fatalf("SetupTotp: %v", err)
|
||||||
}
|
}
|
||||||
if err := auth.ActivateTotp(ctx, "admin", "000000"); !errors.Is(err, ErrTotpInvalid) {
|
if _, _, err := auth.ActivateTotp(
|
||||||
|
ctx, "admin", "000000", "", SessionMeta{}, TokenProof{}); !errors.Is(err, ErrTotpInvalid) {
|
||||||
t.Errorf("err = %v, want ErrTotpInvalid", err)
|
t.Errorf("err = %v, want ErrTotpInvalid", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActivateTotpRejectsRevokedProof(t *testing.T) {
|
||||||
|
auth, _ := newTotpEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
staleToken := loginSession(t, auth, "127.0.0.1", "stale")
|
||||||
|
staleProof := mustProof(t, auth, staleToken)
|
||||||
|
secret, _, err := auth.SetupTotp(ctx, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetupTotp: %v", err)
|
||||||
|
}
|
||||||
|
code, err := totp.GenerateCode(secret, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateCode: %v", err)
|
||||||
|
}
|
||||||
|
current := loginSession(t, auth, "127.0.0.2", "current")
|
||||||
|
if _, _, err := auth.RevokeSessions(
|
||||||
|
ctx, "admin", current, SessionMeta{ClientIP: "127.0.0.2"}, mustProof(t, auth, current)); err != nil {
|
||||||
|
t.Fatalf("RevokeSessions: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.ActivateTotp(
|
||||||
|
ctx, "admin", code, staleToken, SessionMeta{ClientIP: "127.0.0.1"}, staleProof,
|
||||||
|
); !errors.Is(err, ErrTokenStale) {
|
||||||
|
t.Fatalf("ActivateTotp err = %v, want ErrTokenStale", err)
|
||||||
|
}
|
||||||
|
if on, err := auth.TotpStatus(ctx, "admin"); err != nil || on {
|
||||||
|
t.Fatalf("TotpStatus = %v, %v; want disabled", on, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginWithTotp(t *testing.T) {
|
func TestLoginWithTotp(t *testing.T) {
|
||||||
auth, _ := newTotpEnv(t)
|
auth, _ := newTotpEnv(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
secret := enableTotp(t, auth)
|
secret, _ := enableTotp(t, auth)
|
||||||
|
|
||||||
// 缺验证码:密码对也返回 ErrTotpRequired(不计失败)
|
// 缺验证码:密码对也返回 ErrTotpRequired(不计失败)
|
||||||
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", ""); !errors.Is(err, ErrTotpRequired) {
|
if _, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "127.0.0.1"}); !errors.Is(err, ErrTotpRequired) {
|
||||||
t.Fatalf("err = %v, want ErrTotpRequired", err)
|
t.Fatalf("err = %v, want ErrTotpRequired", err)
|
||||||
}
|
}
|
||||||
// 错误验证码:按失败处理
|
// 错误验证码:按失败处理
|
||||||
if _, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", "000000"); !errors.Is(err, ErrInvalidCredentials) {
|
if _, _, err := auth.Login(ctx, "admin", "pass123", "000000", SessionMeta{ClientIP: "127.0.0.1"}); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
t.Fatalf("err = %v, want ErrInvalidCredentials", err)
|
t.Fatalf("err = %v, want ErrInvalidCredentials", err)
|
||||||
}
|
}
|
||||||
// 正确验证码:登录成功
|
// 正确验证码:登录成功
|
||||||
@@ -134,7 +169,7 @@ func TestLoginWithTotp(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generate code: %v", err)
|
t.Fatalf("generate code: %v", err)
|
||||||
}
|
}
|
||||||
token, _, err := auth.Login(ctx, "admin", "pass123", "127.0.0.1", code)
|
token, _, err := auth.Login(ctx, "admin", "pass123", code, SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil || token == "" {
|
if err != nil || token == "" {
|
||||||
t.Fatalf("带验证码登录失败: %v", err)
|
t.Fatalf("带验证码登录失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -143,11 +178,200 @@ func TestLoginWithTotp(t *testing.T) {
|
|||||||
// fakeBindIdentity 直接把外部身份写入待测服务(绕过真实 OAuth flow)。
|
// fakeBindIdentity 直接把外部身份写入待测服务(绕过真实 OAuth flow)。
|
||||||
func fakeBindIdentity(t *testing.T, o *OAuthService, username, provider, subject, display string) {
|
func fakeBindIdentity(t *testing.T, o *OAuthService, username, provider, subject, display string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := o.bind(context.Background(), username, provider, externalIdentity{Subject: subject, Display: display}); err != nil {
|
p := fakePending(t, o, username, provider)
|
||||||
|
if _, err := o.bind(context.Background(), p, externalIdentity{Subject: subject, Display: display}, SessionMeta{}); err != nil {
|
||||||
t.Fatalf("bind: %v", err)
|
t.Fatalf("bind: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fakePending 构造与当前令牌版本一致的 bind 流程上下文(跳过外部授权码交换)。
|
||||||
|
func fakePending(t *testing.T, o *OAuthService, username, provider string) oauthPending {
|
||||||
|
t.Helper()
|
||||||
|
var user model.User
|
||||||
|
if err := o.db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||||
|
t.Fatalf("find user: %v", err)
|
||||||
|
}
|
||||||
|
return oauthPending{provider: provider, mode: "bind", username: username, proof: TokenProof{Ver: user.TokenVersion}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOAuthBindStaleToken 验证 bind 回调复验发起时令牌:撤销全部(版本递增)后,
|
||||||
|
// 已登记的绑定流程随之作废,不再触发外部换码。
|
||||||
|
func TestOAuthBindStaleToken(t *testing.T) {
|
||||||
|
o, auth := newOAuthEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
o.settings.SetEnvPublicURL("https://demo.example.com")
|
||||||
|
secret := "secret"
|
||||||
|
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{
|
||||||
|
GithubClientID: strPtr("cid"), GithubClientSecret: &secret,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
|
}
|
||||||
|
token, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "10.0.0.1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := o.AuthorizeURL(ctx, "github", "bind", "admin", token); err != nil {
|
||||||
|
t.Fatalf("AuthorizeURL(bind): %v", err)
|
||||||
|
}
|
||||||
|
var state string
|
||||||
|
o.mu.Lock()
|
||||||
|
for k := range o.pending {
|
||||||
|
state = k
|
||||||
|
}
|
||||||
|
o.mu.Unlock()
|
||||||
|
if err := auth.bumpTokenVersion(ctx, "admin"); err != nil {
|
||||||
|
t.Fatalf("bump token version: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := o.HandleCallback(ctx, "github", state, "code", SessionMeta{ClientIP: "10.0.0.1"}); !errors.Is(err, ErrOAuthState) {
|
||||||
|
t.Fatalf("stale-token callback err = %v, want ErrOAuthState", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateOAuthKeepsLogin 验证 provider 配置防自锁:密码登录禁用期间,
|
||||||
|
// 禁用或清空最后可用的登录方式被拒;有通行密钥兜底后放行。
|
||||||
|
func TestUpdateOAuthKeepsLogin(t *testing.T) {
|
||||||
|
o, auth := newOAuthEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
o.settings.SetEnvPublicURL("https://demo.example.com")
|
||||||
|
secret := "gh-secret"
|
||||||
|
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret}); err != nil {
|
||||||
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
|
}
|
||||||
|
fakeBindIdentity(t, o, "admin", "github", "77", "octo")
|
||||||
|
if err := saveSettingTx(auth.db, settingSecPasswordLoginOff, "1"); err != nil {
|
||||||
|
t.Fatalf("disable password login: %v", err)
|
||||||
|
}
|
||||||
|
off := true
|
||||||
|
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubDisabled: &off}); !errors.Is(err, ErrProviderLastLogin) {
|
||||||
|
t.Fatalf("disable last provider err = %v, want ErrProviderLastLogin", err)
|
||||||
|
}
|
||||||
|
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("")}); !errors.Is(err, ErrProviderLastLogin) {
|
||||||
|
t.Fatalf("clear last clientID err = %v, want ErrProviderLastLogin", err)
|
||||||
|
}
|
||||||
|
pk := model.UserPasskey{
|
||||||
|
UserID: 1, Name: "k", CredentialID: "c", CredentialIDHash: "h",
|
||||||
|
Credential: "{}", Origin: "https://demo.example.com",
|
||||||
|
}
|
||||||
|
if err := auth.db.Create(&pk).Error; err != nil {
|
||||||
|
t.Fatalf("seed passkey: %v", err)
|
||||||
|
}
|
||||||
|
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubDisabled: &off}); err != nil {
|
||||||
|
t.Fatalf("disable provider with passkey fallback: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateSecurityAppURLKeepsLogin 验证面板地址防自锁:密码禁用期间
|
||||||
|
// 清空地址一律拒绝;域名变更须有钱包身份兜底(通行密钥随 RP ID 失效)。
|
||||||
|
func TestUpdateSecurityAppURLKeepsLogin(t *testing.T) {
|
||||||
|
o, auth := newOAuthEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := o.settings.UpdateSecurity(ctx, SecurityPatch{AppURL: strPtr("https://a.example.com")}); err != nil {
|
||||||
|
t.Fatalf("seed app url: %v", err)
|
||||||
|
}
|
||||||
|
pk := model.UserPasskey{
|
||||||
|
UserID: 1, Name: "k", CredentialID: "c", CredentialIDHash: "h",
|
||||||
|
Credential: "{}", Origin: "https://a.example.com",
|
||||||
|
}
|
||||||
|
if err := auth.db.Create(&pk).Error; err != nil {
|
||||||
|
t.Fatalf("seed passkey: %v", err)
|
||||||
|
}
|
||||||
|
if err := saveSettingTx(auth.db, settingSecPasswordLoginOff, "1"); err != nil {
|
||||||
|
t.Fatalf("disable password login: %v", err)
|
||||||
|
}
|
||||||
|
if err := o.settings.UpdateSecurity(ctx, SecurityPatch{AppURL: strPtr("")}); !errors.Is(err, ErrProviderLastLogin) {
|
||||||
|
t.Fatalf("clear app url err = %v, want ErrProviderLastLogin", err)
|
||||||
|
}
|
||||||
|
if err := o.settings.UpdateSecurity(ctx, SecurityPatch{AppURL: strPtr("https://b.example.com")}); !errors.Is(err, ErrProviderLastLogin) {
|
||||||
|
t.Fatalf("change host err = %v, want ErrProviderLastLogin", err)
|
||||||
|
}
|
||||||
|
ident := model.UserIdentity{UserID: 1, Provider: "wallet", Subject: "0xaa", Display: "w"}
|
||||||
|
if err := auth.db.Create(&ident).Error; err != nil {
|
||||||
|
t.Fatalf("seed wallet identity: %v", err)
|
||||||
|
}
|
||||||
|
if err := o.settings.UpdateSecurity(ctx, SecurityPatch{AppURL: strPtr("https://b.example.com")}); err != nil {
|
||||||
|
t.Fatalf("change host with wallet fallback: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasswordDisableStalePasskeyOrigin 验证逆序自锁防线:地址 A 注册的
|
||||||
|
// 通行密钥在改到地址 B 后不再计入「可用免密方式」,禁用密码被拒。
|
||||||
|
func TestPasswordDisableStalePasskeyOrigin(t *testing.T) {
|
||||||
|
o, auth := newOAuthEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
auth.SetNotifier(nil, o.settings)
|
||||||
|
if err := o.settings.UpdateSecurity(ctx, SecurityPatch{AppURL: strPtr("https://a.example.com")}); err != nil {
|
||||||
|
t.Fatalf("seed app url: %v", err)
|
||||||
|
}
|
||||||
|
pk := model.UserPasskey{UserID: 1, Name: "k", CredentialID: "c", CredentialIDHash: "h",
|
||||||
|
Origin: "https://a.example.com", Credential: "{}"}
|
||||||
|
if err := auth.db.Create(&pk).Error; err != nil {
|
||||||
|
t.Fatalf("seed passkey: %v", err)
|
||||||
|
}
|
||||||
|
// 密码未禁用:改地址不受限
|
||||||
|
if err := o.settings.UpdateSecurity(ctx, SecurityPatch{AppURL: strPtr("https://b.example.com")}); err != nil {
|
||||||
|
t.Fatalf("change app url: %v", err)
|
||||||
|
}
|
||||||
|
// 旧地址的通行密钥不计入可用方式:禁用密码被拒,不再自锁
|
||||||
|
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true, proofOf(t, auth.db, "admin")); !errors.Is(err, ErrNeedIdentity) {
|
||||||
|
t.Fatalf("disable with stale-origin passkey err = %v, want ErrNeedIdentity", err)
|
||||||
|
}
|
||||||
|
// 当前地址重新注册(origin 一致)后可禁用
|
||||||
|
pk2 := model.UserPasskey{UserID: 1, Name: "k2", CredentialID: "c2", CredentialIDHash: "h2",
|
||||||
|
Origin: "https://b.example.com", Credential: "{}"}
|
||||||
|
if err := auth.db.Create(&pk2).Error; err != nil {
|
||||||
|
t.Fatalf("seed passkey2: %v", err)
|
||||||
|
}
|
||||||
|
if err := auth.SetPasswordLoginDisabled(ctx, "admin", true, proofOf(t, auth.db, "admin")); err != nil {
|
||||||
|
t.Fatalf("disable with current-origin passkey: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticatedSettingsRejectRevokedProof(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
apply func(*OAuthService, *AuthService, TokenProof) error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "security patch",
|
||||||
|
apply: func(o *OAuthService, auth *AuthService, proof TokenProof) error {
|
||||||
|
return o.settings.UpdateSecurityAuthenticated(
|
||||||
|
context.Background(), SecurityPatch{LoginFailLimit: intPtr(9)}, auth, "admin", proof)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "oauth patch",
|
||||||
|
apply: func(o *OAuthService, auth *AuthService, proof TokenProof) error {
|
||||||
|
return o.settings.UpdateOAuthAuthenticated(
|
||||||
|
context.Background(), UpdateOAuthInput{GithubDisplayName: strPtr("late")}, auth, "admin", proof)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
o, auth := newOAuthEnv(t)
|
||||||
|
proof := revokeSettingsToken(t, auth)
|
||||||
|
if err := tt.apply(o, auth, proof); !errors.Is(err, ErrTokenStale) {
|
||||||
|
t.Fatalf("patch err = %v, want ErrTokenStale", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeSettingsToken(t *testing.T, auth *AuthService) TokenProof {
|
||||||
|
t.Helper()
|
||||||
|
stale := loginSession(t, auth, "10.20.0.1", "stale")
|
||||||
|
current := loginSession(t, auth, "10.20.0.2", "current")
|
||||||
|
proof := mustProof(t, auth, stale)
|
||||||
|
for _, item := range mustSessions(t, auth, current) {
|
||||||
|
if !item.Current {
|
||||||
|
if err := auth.RevokeSession(context.Background(), "admin", current, item.ID); err != nil {
|
||||||
|
t.Fatalf("RevokeSession: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return proof
|
||||||
|
}
|
||||||
|
|
||||||
func newOAuthEnv(t *testing.T) (*OAuthService, *AuthService) {
|
func newOAuthEnv(t *testing.T) (*OAuthService, *AuthService) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
auth, db := newTotpEnv(t)
|
auth, db := newTotpEnv(t)
|
||||||
@@ -159,17 +383,86 @@ func newOAuthEnv(t *testing.T) (*OAuthService, *AuthService) {
|
|||||||
return NewOAuthService(db, settings, auth), auth
|
return NewOAuthService(db, settings, auth), auth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOAuthLoginRechecksIdentityAndProvider(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*testing.T, *OAuthService, *model.UserIdentity)
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "identity unbound",
|
||||||
|
mutate: func(t *testing.T, o *OAuthService, row *model.UserIdentity) {
|
||||||
|
if err := o.db.Delete(row).Error; err != nil {
|
||||||
|
t.Fatalf("delete identity: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
want: ErrOAuthNotBound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "provider disabled",
|
||||||
|
mutate: func(t *testing.T, o *OAuthService, _ *model.UserIdentity) {
|
||||||
|
patch := UpdateOAuthInput{GithubDisabled: boolPtr(true)}
|
||||||
|
if err := o.settings.UpdateOAuth(context.Background(), patch); err != nil {
|
||||||
|
t.Fatalf("disable provider: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
want: ErrOAuthDisabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) { testOAuthLoginRecheck(t, tt.mutate, tt.want) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOAuthLoginRecheck(
|
||||||
|
t *testing.T, mutate func(*testing.T, *OAuthService, *model.UserIdentity), want error,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
o, _ := newOAuthEnv(t)
|
||||||
|
o.settings.SetEnvPublicURL("https://demo.example.com")
|
||||||
|
secret := "secret"
|
||||||
|
if err := o.settings.UpdateOAuth(context.Background(), UpdateOAuthInput{
|
||||||
|
GithubClientID: strPtr("cid"), GithubClientSecret: &secret,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
|
}
|
||||||
|
fakeBindIdentity(t, o, "admin", "github", "10086", "octocat")
|
||||||
|
row, err := o.findIdentity(context.Background(), "github", "10086")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findIdentity: %v", err)
|
||||||
|
}
|
||||||
|
mutate(t, o, row)
|
||||||
|
_, _, err = o.loginIdentityRow(
|
||||||
|
context.Background(), row, "github", "10086", SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
|
if !errors.Is(err, want) {
|
||||||
|
t.Fatalf("login err = %v, want %v", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOAuthBindLoginUnbind(t *testing.T) {
|
func TestOAuthBindLoginUnbind(t *testing.T) {
|
||||||
o, auth := newOAuthEnv(t)
|
o, auth := newOAuthEnv(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
o.settings.SetEnvPublicURL("https://demo.example.com")
|
||||||
|
secret := "secret"
|
||||||
|
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{
|
||||||
|
GithubClientID: strPtr("cid"), GithubClientSecret: &secret,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
fakeBindIdentity(t, o, "admin", "github", "10086", "octocat")
|
fakeBindIdentity(t, o, "admin", "github", "10086", "octocat")
|
||||||
// 重复绑定同一身份拒绝
|
// 重复绑定同一身份拒绝
|
||||||
if err := o.bind(ctx, "admin", "github", externalIdentity{Subject: "10086", Display: "octocat"}); !errors.Is(err, ErrOAuthBound) {
|
if _, err := o.bind(ctx, fakePending(t, o, "admin", "github"), externalIdentity{Subject: "10086", Display: "octocat"}, SessionMeta{}); !errors.Is(err, ErrOAuthBound) {
|
||||||
t.Errorf("重复绑定 err = %v, want ErrOAuthBound", err)
|
t.Errorf("重复绑定 err = %v, want ErrOAuthBound", err)
|
||||||
}
|
}
|
||||||
|
// 发起后版本已变(改密/撤销全部):事务内比对拒绝,身份不落库
|
||||||
|
stale := fakePending(t, o, "admin", "github")
|
||||||
|
stale.proof.Ver--
|
||||||
|
if _, err := o.bind(ctx, stale, externalIdentity{Subject: "20250", Display: "x"}, SessionMeta{}); !errors.Is(err, ErrOAuthState) {
|
||||||
|
t.Errorf("stale-version bind err = %v, want ErrOAuthState", err)
|
||||||
|
}
|
||||||
// 已绑定身份可登录并拿到有效 JWT
|
// 已绑定身份可登录并拿到有效 JWT
|
||||||
token, loginUser, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "10086", Display: "octocat"})
|
token, loginUser, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "10086", Display: "octocat"}, SessionMeta{ClientIP: "127.0.0.1"})
|
||||||
if err != nil || token == "" {
|
if err != nil || token == "" {
|
||||||
t.Fatalf("loginByIdentity: %v", err)
|
t.Fatalf("loginByIdentity: %v", err)
|
||||||
}
|
}
|
||||||
@@ -180,7 +473,7 @@ func TestOAuthBindLoginUnbind(t *testing.T) {
|
|||||||
t.Errorf("token 应属 admin, got %q (%v)", username, err)
|
t.Errorf("token 应属 admin, got %q (%v)", username, err)
|
||||||
}
|
}
|
||||||
// 未绑定身份拒绝登录
|
// 未绑定身份拒绝登录
|
||||||
if _, _, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "999"}); !errors.Is(err, ErrOAuthNotBound) {
|
if _, _, err := o.loginByIdentity(ctx, "github", externalIdentity{Subject: "999"}, SessionMeta{ClientIP: "127.0.0.1"}); !errors.Is(err, ErrOAuthNotBound) {
|
||||||
t.Errorf("未绑定登录 err = %v, want ErrOAuthNotBound", err)
|
t.Errorf("未绑定登录 err = %v, want ErrOAuthNotBound", err)
|
||||||
}
|
}
|
||||||
// 列表与解绑
|
// 列表与解绑
|
||||||
@@ -188,7 +481,7 @@ func TestOAuthBindLoginUnbind(t *testing.T) {
|
|||||||
if err != nil || len(items) != 1 {
|
if err != nil || len(items) != 1 {
|
||||||
t.Fatalf("identities = %d (%v), want 1", len(items), err)
|
t.Fatalf("identities = %d (%v), want 1", len(items), err)
|
||||||
}
|
}
|
||||||
if err := o.Unbind(ctx, "admin", items[0].ID); err != nil {
|
if err := o.Unbind(ctx, "admin", items[0].ID, proofOf(t, o.db, "admin")); err != nil {
|
||||||
t.Fatalf("Unbind: %v", err)
|
t.Fatalf("Unbind: %v", err)
|
||||||
}
|
}
|
||||||
if items, _ = o.Identities(ctx, "admin"); len(items) != 0 {
|
if items, _ = o.Identities(ctx, "admin"); len(items) != 0 {
|
||||||
@@ -224,10 +517,15 @@ func TestOAuthProvidersListsConfigured(t *testing.T) {
|
|||||||
t.Fatalf("未配置时 providers = %v", got)
|
t.Fatalf("未配置时 providers = %v", got)
|
||||||
}
|
}
|
||||||
secret := "gh-secret"
|
secret := "gh-secret"
|
||||||
err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "cid", GithubClientSecret: &secret})
|
err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpdateOAuth: %v", err)
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
}
|
}
|
||||||
|
// 面板地址缺失时回调无从拼接:半配置不暴露必败入口
|
||||||
|
if got := o.Providers(ctx); len(got) != 0 {
|
||||||
|
t.Fatalf("无面板地址 providers = %v, want empty", got)
|
||||||
|
}
|
||||||
|
o.settings.SetEnvPublicURL("https://demo.example.com")
|
||||||
got := o.Providers(ctx)
|
got := o.Providers(ctx)
|
||||||
if len(got) != 1 || got[0].Provider != "github" {
|
if len(got) != 1 || got[0].Provider != "github" {
|
||||||
t.Errorf("providers = %v, want [github]", got)
|
t.Errorf("providers = %v, want [github]", got)
|
||||||
@@ -245,26 +543,34 @@ func TestOAuthAuthorizeConfigErrors(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// clientID 缺失
|
// clientID 缺失
|
||||||
if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNotConfigured) {
|
if _, err := o.AuthorizeURL(ctx, "github", "login", "", ""); !errors.Is(err, ErrOAuthNotConfigured) {
|
||||||
t.Errorf("无 clientID err = %v, want ErrOAuthNotConfigured", err)
|
t.Errorf("无 clientID err = %v, want ErrOAuthNotConfigured", err)
|
||||||
}
|
}
|
||||||
// clientID 已配但面板地址未设置
|
// 仅 clientID 的半配置仍按未配置拒绝(不能生成必败授权 URL)
|
||||||
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: "Iv1.test"}); err != nil {
|
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")}); err != nil {
|
||||||
t.Fatalf("UpdateOAuth: %v", err)
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthNoAppURL) {
|
if _, err := o.AuthorizeURL(ctx, "github", "login", "", ""); !errors.Is(err, ErrOAuthNotConfigured) {
|
||||||
|
t.Errorf("无 secret err = %v, want ErrOAuthNotConfigured", err)
|
||||||
|
}
|
||||||
|
// clientID + secret 已配但面板地址未设置
|
||||||
|
secret := "secret"
|
||||||
|
if err := o.settings.UpdateOAuth(ctx, UpdateOAuthInput{GithubClientSecret: &secret}); err != nil {
|
||||||
|
t.Fatalf("UpdateOAuth secret: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := o.AuthorizeURL(ctx, "github", "login", "", ""); !errors.Is(err, ErrOAuthNoAppURL) {
|
||||||
t.Errorf("无面板地址 err = %v, want ErrOAuthNoAppURL", err)
|
t.Errorf("无面板地址 err = %v, want ErrOAuthNoAppURL", err)
|
||||||
}
|
}
|
||||||
// 面板地址就绪后正常返回授权 URL
|
// 面板地址就绪后正常返回授权 URL
|
||||||
o.settings.SetEnvPublicURL("https://demo.example.com")
|
o.settings.SetEnvPublicURL("https://demo.example.com")
|
||||||
url, err := o.AuthorizeURL(ctx, "github", "login", "")
|
url, err := o.AuthorizeURL(ctx, "github", "login", "", "")
|
||||||
if err != nil || url == "" {
|
if err != nil || url == "" {
|
||||||
t.Fatalf("AuthorizeURL: %q, %v", url, err)
|
t.Fatalf("AuthorizeURL: %q, %v", url, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOAuthProvidersAndDisabled(t *testing.T) {
|
func TestOAuthProvidersAndDisabled(t *testing.T) {
|
||||||
o, _ := newOAuthEnv(t)
|
o, auth := newOAuthEnv(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
o.settings.SetEnvPublicURL("https://demo.example.com")
|
o.settings.SetEnvPublicURL("https://demo.example.com")
|
||||||
|
|
||||||
@@ -272,8 +578,16 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
|
|||||||
if got := o.Providers(ctx); len(got) != 0 {
|
if got := o.Providers(ctx); len(got) != 0 {
|
||||||
t.Fatalf("Providers = %v, want empty", got)
|
t.Fatalf("Providers = %v, want empty", got)
|
||||||
}
|
}
|
||||||
// 配置 github(无显示名称)→ 默认名 GitHub
|
// 配置 github(无显示名称)→ 默认名 GitHub;仅 clientID 的半配置不暴露
|
||||||
in := UpdateOAuthInput{GithubClientID: "Iv1.test"}
|
in := UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")}
|
||||||
|
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
|
||||||
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
|
}
|
||||||
|
if got := o.Providers(ctx); len(got) != 0 {
|
||||||
|
t.Fatalf("仅 clientID 半配置 Providers = %v, want empty", got)
|
||||||
|
}
|
||||||
|
ghSecret := "gh-secret"
|
||||||
|
in.GithubClientSecret = &ghSecret
|
||||||
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
|
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
|
||||||
t.Fatalf("UpdateOAuth: %v", err)
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
}
|
}
|
||||||
@@ -282,7 +596,7 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
|
|||||||
t.Fatalf("Providers = %+v, want [github/GitHub]", got)
|
t.Fatalf("Providers = %+v, want [github/GitHub]", got)
|
||||||
}
|
}
|
||||||
// 自定义显示名称
|
// 自定义显示名称
|
||||||
in.GithubDisplayName = "公司账号"
|
in.GithubDisplayName = strPtr("公司账号")
|
||||||
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
|
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
|
||||||
t.Fatalf("UpdateOAuth: %v", err)
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
}
|
}
|
||||||
@@ -290,22 +604,98 @@ func TestOAuthProvidersAndDisabled(t *testing.T) {
|
|||||||
t.Fatalf("DisplayName = %q, want 公司账号", got[0].DisplayName)
|
t.Fatalf("DisplayName = %q, want 公司账号", got[0].DisplayName)
|
||||||
}
|
}
|
||||||
// 禁用后:列表隐藏,login 模式 409,bind 模式仍可发起
|
// 禁用后:列表隐藏,login 模式 409,bind 模式仍可发起
|
||||||
in.GithubDisabled = true
|
in.GithubDisabled = boolPtr(true)
|
||||||
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
|
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
|
||||||
t.Fatalf("UpdateOAuth: %v", err)
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
}
|
}
|
||||||
if got = o.Providers(ctx); len(got) != 0 {
|
if got = o.Providers(ctx); len(got) != 0 {
|
||||||
t.Fatalf("禁用后 Providers = %v, want empty", got)
|
t.Fatalf("禁用后 Providers = %v, want empty", got)
|
||||||
}
|
}
|
||||||
if _, err := o.AuthorizeURL(ctx, "github", "login", ""); !errors.Is(err, ErrOAuthDisabled) {
|
if _, err := o.AuthorizeURL(ctx, "github", "login", "", ""); !errors.Is(err, ErrOAuthDisabled) {
|
||||||
t.Errorf("禁用 login err = %v, want ErrOAuthDisabled", err)
|
t.Errorf("禁用 login err = %v, want ErrOAuthDisabled", err)
|
||||||
}
|
}
|
||||||
if url, err := o.AuthorizeURL(ctx, "github", "bind", "admin"); err != nil || url == "" {
|
// bind 模式须携带有效令牌(发起即验),但不受 provider 禁用影响
|
||||||
|
token, _, err := auth.Login(ctx, "admin", "pass123", "", SessionMeta{ClientIP: "10.0.0.9"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
if url, err := o.AuthorizeURL(ctx, "github", "bind", "admin", token); err != nil || url == "" {
|
||||||
t.Errorf("禁用 bind = %q, %v, want 正常返回", url, err)
|
t.Errorf("禁用 bind = %q, %v, want 正常返回", url, err)
|
||||||
}
|
}
|
||||||
|
if _, err := o.AuthorizeURL(ctx, "github", "bind", "admin", ""); !errors.Is(err, ErrOAuthState) {
|
||||||
|
t.Errorf("bind 无令牌 err = %v, want ErrOAuthState", err)
|
||||||
|
}
|
||||||
// view 回读禁用态与显示名称
|
// view 回读禁用态与显示名称
|
||||||
view, err := o.settings.OAuthView(ctx)
|
view, err := o.settings.OAuthView(ctx)
|
||||||
if err != nil || !view.GithubDisabled || view.GithubDisplayName != "公司账号" {
|
if err != nil || !view.GithubDisabled || view.GithubDisplayName != "公司账号" {
|
||||||
t.Fatalf("OAuthView = %+v, %v", view, err)
|
t.Fatalf("OAuthView = %+v, %v", view, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUpdateOAuthPartialPatch 锁定 provider 配置的部分更新语义:
|
||||||
|
// 只写出现字段,另一 provider 与未出现字段不回滚,secret nil 沿用空串清除。
|
||||||
|
func TestUpdateOAuthPartialPatch(t *testing.T) {
|
||||||
|
o, _ := newOAuthEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
secret := "gh-secret"
|
||||||
|
seed := UpdateOAuthInput{GithubClientID: strPtr("cid"), GithubClientSecret: &secret, GithubDisplayName: strPtr("公司账号")}
|
||||||
|
if err := o.settings.UpdateOAuth(ctx, seed); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
steps := []struct {
|
||||||
|
name string
|
||||||
|
patch UpdateOAuthInput
|
||||||
|
check func(v OAuthProvidersView) string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "只更新 oidc 不动 github",
|
||||||
|
patch: UpdateOAuthInput{OidcIssuer: strPtr("https://sso.example.com/"), OidcClientID: strPtr("oidc-cid")},
|
||||||
|
check: func(v OAuthProvidersView) string {
|
||||||
|
if v.OidcIssuer != "https://sso.example.com" || v.OidcClientID != "oidc-cid" {
|
||||||
|
return "oidc 字段未生效或未规范化"
|
||||||
|
}
|
||||||
|
if v.GithubClientID != "cid" || !v.GithubSecretSet || v.GithubDisplayName != "公司账号" {
|
||||||
|
return "github 字段被回滚"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "单开关启停,secret nil 沿用",
|
||||||
|
patch: UpdateOAuthInput{GithubDisabled: boolPtr(true)},
|
||||||
|
check: func(v OAuthProvidersView) string {
|
||||||
|
if !v.GithubDisabled || v.GithubClientID != "cid" || !v.GithubSecretSet {
|
||||||
|
return "启停外字段被动到或 secret 丢失"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "secret 空串清除",
|
||||||
|
patch: UpdateOAuthInput{GithubClientSecret: strPtr("")},
|
||||||
|
check: func(v OAuthProvidersView) string {
|
||||||
|
if v.GithubSecretSet {
|
||||||
|
return "空串未清除 secret"
|
||||||
|
}
|
||||||
|
if v.GithubClientID != "cid" {
|
||||||
|
return "clientID 被动到"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, st := range steps {
|
||||||
|
t.Run(st.name, func(t *testing.T) {
|
||||||
|
if err := o.settings.UpdateOAuth(ctx, st.patch); err != nil {
|
||||||
|
t.Fatalf("UpdateOAuth: %v", err)
|
||||||
|
}
|
||||||
|
view, err := o.settings.OAuthView(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OAuthView: %v", err)
|
||||||
|
}
|
||||||
|
if msg := st.check(view); msg != "" {
|
||||||
|
t.Error(msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrCurrentApiKey 拒绝删除当前配置正在使用的签名 key。
|
||||||
|
var ErrCurrentApiKey = errors.New("当前配置正在使用的 API Key 不可删除,请先替换")
|
||||||
|
|
||||||
|
// apiKeyVerifyAttempts / apiKeyVerifyDelay 控制新 key 生效验证的重试节奏;
|
||||||
|
// OCI 上传公钥后生效有秒级延迟。测试将 delay 调小。
|
||||||
|
var (
|
||||||
|
apiKeyVerifyAttempts = 6
|
||||||
|
apiKeyVerifyDelay = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreatedApiKey 是新建/轮换 API key 的一次性返回;私钥仅此一次,不落库。
|
||||||
|
type CreatedApiKey struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
PrivateKey string `json:"privateKey"`
|
||||||
|
ConfigIni string `json:"configIni"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserApiKeyInfo 是列表项:key 元数据加该 key 的 CLI config 模板(预览用)。
|
||||||
|
type UserApiKeyInfo struct {
|
||||||
|
oci.TenantUserApiKey
|
||||||
|
ConfigIni string `json:"configIni"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantUserApiKeys 列出用户 API 签名 key(oci 层已标注当前使用)。
|
||||||
|
func (s *OciConfigService) TenantUserApiKeys(ctx context.Context, id uint, userID string) ([]UserApiKeyInfo, error) {
|
||||||
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys, err := s.client.ListTenantUserApiKeys(ctx, cred, homeRegion, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]UserApiKeyInfo, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
out = append(out, UserApiKeyInfo{TenantUserApiKey: k, ConfigIni: ociConfigIni(cred, userID, k.Fingerprint)})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTenantUserApiKey 生成 RSA-2048 密钥对并上传公钥;私钥仅本次返回。
|
||||||
|
func (s *OciConfigService) AddTenantUserApiKey(ctx context.Context, id uint, userID string) (CreatedApiKey, error) {
|
||||||
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedApiKey{}, err
|
||||||
|
}
|
||||||
|
privPEM, pubPEM, err := generateRsaKeyPair()
|
||||||
|
if err != nil {
|
||||||
|
return CreatedApiKey{}, err
|
||||||
|
}
|
||||||
|
fp, err := s.client.UploadTenantUserApiKey(ctx, cred, homeRegion, userID, pubPEM)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedApiKey{}, err
|
||||||
|
}
|
||||||
|
return CreatedApiKey{Fingerprint: fp, PrivateKey: privPEM, ConfigIni: ociConfigIni(cred, userID, fp)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTenantUserApiKey 删除用户单把 API key;当前签名 key 拒删以免面板失联。
|
||||||
|
func (s *OciConfigService) DeleteTenantUserApiKey(ctx context.Context, id uint, userID, fingerprint string) error {
|
||||||
|
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if userID == cred.UserOCID && fingerprint == cred.Fingerprint {
|
||||||
|
return ErrCurrentApiKey
|
||||||
|
}
|
||||||
|
return s.client.DeleteTenantUserApiKey(ctx, cred, homeRegion, userID, fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateApiKey 把刚创建的 key 设为本配置的签名凭据:验证可用后落库,不删除旧 key。
|
||||||
|
// userID 非空时以该用户身份验证并一并切换签名用户(空串沿用当前用户);
|
||||||
|
// 私钥由前端回传(仅创建时下发过);归属无需显式校验——非归属用户的 key 验证必失败。
|
||||||
|
func (s *OciConfigService) ActivateApiKey(ctx context.Context, id uint, userID, fingerprint, privateKey string) error {
|
||||||
|
cfg, err := s.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cred, err := s.credentialsOf(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newCred := cred
|
||||||
|
newCred.Fingerprint, newCred.PrivateKey, newCred.Passphrase = fingerprint, privateKey, ""
|
||||||
|
if userID != "" {
|
||||||
|
newCred.UserOCID = userID
|
||||||
|
}
|
||||||
|
if err := newCred.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.waitApiKeyUsable(ctx, newCred); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.persistSigningKey(cfg, newCred)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitApiKeyUsable 用新凭据测活,等待上传的公钥在 OCI 侧生效。
|
||||||
|
func (s *OciConfigService) waitApiKeyUsable(ctx context.Context, cred oci.Credentials) error {
|
||||||
|
var err error
|
||||||
|
for i := 0; i < apiKeyVerifyAttempts; i++ {
|
||||||
|
if _, err = s.client.ValidateKey(ctx, cred); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(apiKeyVerifyDelay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("new api key not usable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistSigningKey 加密新私钥,更新配置签名用户与指纹并清空口令密文(面板生成的 key 无口令)。
|
||||||
|
func (s *OciConfigService) persistSigningKey(cfg *model.OciConfig, newCred oci.Credentials) error {
|
||||||
|
enc, err := s.cipher.EncryptString(newCred.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypt private key: %w", err)
|
||||||
|
}
|
||||||
|
updates := map[string]any{
|
||||||
|
"user_oc_id": newCred.UserOCID, "fingerprint": newCred.Fingerprint,
|
||||||
|
"private_key_enc": enc, "passphrase_enc": "",
|
||||||
|
}
|
||||||
|
if err := s.db.Model(cfg).Updates(updates).Error; err != nil {
|
||||||
|
return fmt.Errorf("persist rotated key: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRsaKeyPair 生成 RSA-2048 密钥对,返回 PKCS#1 私钥 PEM 与 PKIX 公钥 PEM。
|
||||||
|
func generateRsaKeyPair() (privatePEM, publicPEM string, err error) {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("generate rsa key: %w", err)
|
||||||
|
}
|
||||||
|
privBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
|
||||||
|
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("marshal public key: %w", err)
|
||||||
|
}
|
||||||
|
pubBlock := &pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}
|
||||||
|
return string(pem.EncodeToMemory(privBlock)), string(pem.EncodeToMemory(pubBlock)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ociConfigIni 拼装 OCI CLI config 文本;key_file 需用户保存私钥后自行填写。
|
||||||
|
func ociConfigIni(cred oci.Credentials, userID, fingerprint string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"[DEFAULT]\nuser=%s\nfingerprint=%s\ntenancy=%s\nregion=%s\n# 保存私钥到本机后替换为实际路径\nkey_file=~/.oci/oci_api_key.pem\n",
|
||||||
|
userID, fingerprint, cred.TenancyOCID, cred.Region)
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"oci-portal/internal/model"
|
||||||
|
"oci-portal/internal/oci"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apiKeyClient 记录 API key 相关调用的 fake。
|
||||||
|
type apiKeyClient struct {
|
||||||
|
oci.Client
|
||||||
|
|
||||||
|
keys []oci.TenantUserApiKey
|
||||||
|
uploadFp string
|
||||||
|
uploadErr error
|
||||||
|
uploadedPub string
|
||||||
|
deleted []string
|
||||||
|
validateErr error
|
||||||
|
validated []string // ValidateKey 收到的指纹序列
|
||||||
|
validatedUser string // ValidateKey 最近一次使用的签名用户
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *apiKeyClient) ListTenantUserApiKeys(ctx context.Context, cred oci.Credentials, homeRegion, userID string) ([]oci.TenantUserApiKey, error) {
|
||||||
|
return f.keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *apiKeyClient) UploadTenantUserApiKey(ctx context.Context, cred oci.Credentials, homeRegion, userID, publicKeyPEM string) (string, error) {
|
||||||
|
if f.uploadErr != nil {
|
||||||
|
return "", f.uploadErr
|
||||||
|
}
|
||||||
|
f.uploadedPub = publicKeyPEM
|
||||||
|
return f.uploadFp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *apiKeyClient) DeleteTenantUserApiKey(ctx context.Context, cred oci.Credentials, homeRegion, userID, fingerprint string) error {
|
||||||
|
f.deleted = append(f.deleted, fingerprint)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *apiKeyClient) ValidateKey(ctx context.Context, cred oci.Credentials) (oci.TenancyInfo, error) {
|
||||||
|
f.validated = append(f.validated, cred.Fingerprint)
|
||||||
|
f.validatedUser = cred.UserOCID
|
||||||
|
if f.validateErr != nil {
|
||||||
|
return oci.TenancyInfo{}, f.validateErr
|
||||||
|
}
|
||||||
|
return oci.TenancyInfo{Name: "t"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const testKeyPEM = "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----"
|
||||||
|
|
||||||
|
// seedApiKeyConfig 落一条可解密的配置,当前签名 key 指纹为 aa:bb。
|
||||||
|
func seedApiKeyConfig(t *testing.T, s *OciConfigService) *model.OciConfig {
|
||||||
|
t.Helper()
|
||||||
|
enc, err := s.cipher.EncryptString(testKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encrypt: %v", err)
|
||||||
|
}
|
||||||
|
cfg := &model.OciConfig{
|
||||||
|
Alias: "t1", UserOCID: "ocid1.user.oc1..me", TenancyOCID: "ocid1.tenancy.oc1..t",
|
||||||
|
Fingerprint: "aa:bb", Region: "ap-tokyo-1", PrivateKeyEnc: enc,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(cfg).Error; err != nil {
|
||||||
|
t.Fatalf("seed config: %v", err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteTenantUserApiKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
userID string
|
||||||
|
fingerprint string
|
||||||
|
wantErr error
|
||||||
|
wantDeleted bool
|
||||||
|
}{
|
||||||
|
{"当前签名 key 拒删", "ocid1.user.oc1..me", "aa:bb", ErrCurrentApiKey, false},
|
||||||
|
{"当前用户其他指纹可删", "ocid1.user.oc1..me", "cc:dd", nil, true},
|
||||||
|
{"其他用户同指纹可删", "ocid1.user.oc1..other", "aa:bb", nil, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fc := &apiKeyClient{}
|
||||||
|
s := newTestService(t, fc)
|
||||||
|
cfg := seedApiKeyConfig(t, s)
|
||||||
|
err := s.DeleteTenantUserApiKey(context.Background(), cfg.ID, tt.userID, tt.fingerprint)
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if got := len(fc.deleted) > 0; got != tt.wantDeleted {
|
||||||
|
t.Fatalf("deleted = %v, want deleted=%v", fc.deleted, tt.wantDeleted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantUserApiKeys(t *testing.T) {
|
||||||
|
fc := &apiKeyClient{keys: []oci.TenantUserApiKey{{Fingerprint: "aa:bb", IsCurrent: true}}}
|
||||||
|
s := newTestService(t, fc)
|
||||||
|
cfg := seedApiKeyConfig(t, s)
|
||||||
|
|
||||||
|
items, err := s.TenantUserApiKeys(context.Background(), cfg.ID, "ocid1.user.oc1..me")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || !items[0].IsCurrent {
|
||||||
|
t.Fatalf("items = %+v", items)
|
||||||
|
}
|
||||||
|
if !strings.Contains(items[0].ConfigIni, "fingerprint=aa:bb") {
|
||||||
|
t.Fatalf("configIni missing fingerprint:\n%s", items[0].ConfigIni)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddTenantUserApiKey(t *testing.T) {
|
||||||
|
fc := &apiKeyClient{uploadFp: "11:22"}
|
||||||
|
s := newTestService(t, fc)
|
||||||
|
cfg := seedApiKeyConfig(t, s)
|
||||||
|
|
||||||
|
created, err := s.AddTenantUserApiKey(context.Background(), cfg.ID, "ocid1.user.oc1..other")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add: %v", err)
|
||||||
|
}
|
||||||
|
if created.Fingerprint != "11:22" {
|
||||||
|
t.Fatalf("fingerprint = %q", created.Fingerprint)
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode([]byte(created.PrivateKey))
|
||||||
|
if block == nil || block.Type != "RSA PRIVATE KEY" {
|
||||||
|
t.Fatalf("private key not PKCS#1 PEM")
|
||||||
|
}
|
||||||
|
if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err != nil {
|
||||||
|
t.Fatalf("parse private key: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fc.uploadedPub, "PUBLIC KEY") {
|
||||||
|
t.Fatalf("uploaded public key = %q", fc.uploadedPub)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"user=ocid1.user.oc1..other", "fingerprint=11:22", "tenancy=ocid1.tenancy.oc1..t", "region=ap-tokyo-1"} {
|
||||||
|
if !strings.Contains(created.ConfigIni, want) {
|
||||||
|
t.Fatalf("configIni missing %q:\n%s", want, created.ConfigIni)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivateApiKey(t *testing.T) {
|
||||||
|
apiKeyVerifyDelay = time.Millisecond
|
||||||
|
const newKey = "-----BEGIN RSA PRIVATE KEY-----\nnew\n-----END RSA PRIVATE KEY-----"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
userID string
|
||||||
|
privateKey string
|
||||||
|
validateErr error
|
||||||
|
wantErr bool
|
||||||
|
wantFp string // 期望落库指纹
|
||||||
|
wantUser string // 期望落库签名用户
|
||||||
|
}{
|
||||||
|
{"成功:验证通过后落库,不删任何 key", "", newKey, nil, false, "11:22", "ocid1.user.oc1..me"},
|
||||||
|
{"切换用户:以新用户验证并一并落库", "ocid1.user.oc1..other", newKey, nil, false, "11:22", "ocid1.user.oc1..other"},
|
||||||
|
{"验证失败:配置不动", "", newKey, errors.New("401"), true, "aa:bb", "ocid1.user.oc1..me"},
|
||||||
|
{"私钥非 PEM:直接拒绝", "", "not-a-pem", nil, true, "aa:bb", "ocid1.user.oc1..me"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fc := &apiKeyClient{validateErr: tt.validateErr}
|
||||||
|
s := newTestService(t, fc)
|
||||||
|
cfg := seedApiKeyConfig(t, s)
|
||||||
|
|
||||||
|
err := s.ActivateApiKey(context.Background(), cfg.ID, tt.userID, "11:22", tt.privateKey)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
var got model.OciConfig
|
||||||
|
if err := s.db.First(&got, cfg.ID).Error; err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
if got.Fingerprint != tt.wantFp {
|
||||||
|
t.Fatalf("fingerprint = %q, want %q", got.Fingerprint, tt.wantFp)
|
||||||
|
}
|
||||||
|
if got.UserOCID != tt.wantUser {
|
||||||
|
t.Fatalf("userOCID = %q, want %q", got.UserOCID, tt.wantUser)
|
||||||
|
}
|
||||||
|
if len(fc.deleted) != 0 {
|
||||||
|
t.Fatalf("deleted = %v, want none", fc.deleted)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 成功路径:落库私钥可解密且与回传一致,验证调用用的是新指纹与目标用户
|
||||||
|
plain, err := s.cipher.DecryptString(got.PrivateKeyEnc)
|
||||||
|
if err != nil || plain != newKey {
|
||||||
|
t.Fatalf("persisted key mismatch (err=%v)", err)
|
||||||
|
}
|
||||||
|
if len(fc.validated) == 0 || fc.validated[0] != "11:22" {
|
||||||
|
t.Fatalf("validated = %v", fc.validated)
|
||||||
|
}
|
||||||
|
if fc.validatedUser != tt.wantUser {
|
||||||
|
t.Fatalf("validatedUser = %q, want %q", fc.validatedUser, tt.wantUser)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user