4 Commits
Author SHA1 Message Date
wangdefa 23b2820101 发布 v0.8.3
CI / test (push) Successful in 22s
Release / release (push) Successful in 44s
2026-07-30 12:44:14 +08:00
wangdefa 109c345e5e 新增活跃会话管理与通行密钥、钱包登录
CI / test (push) Successful in 55s
2026-07-30 12:23:05 +08:00
wangdefa f1914880ac 发布 v0.8.2
CI / test (push) Successful in 21s
Release / release (push) Successful in 54s
2026-07-22 22:15:23 +08:00
wangdefa a95a8255bf 用户 API Key 管理:增删查、启用签名凭据;升级 SDK
CI / test (push) Successful in 48s
2026-07-22 19:38:14 +08:00
60 changed files with 7663 additions and 339 deletions
+1 -1
View File
@@ -1 +1 @@
0.6.7 0.6.10
+132
View File
@@ -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` 可用性布尔,供登录页渲染入口。
@@ -60,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 切换签名用户)
+1
View File
@@ -22,6 +22,7 @@ Gin + GORM + SQLite(纯 Go 驱动 `glebarez/sqlite`,免 CGO;默认与推荐)+ AE
| [IdP Icon Upload](./idp-icon-upload.md) | IdP 公共图标上传、校验与清理契约 | 已填 | | [IdP Icon Upload](./idp-icon-upload.md) | IdP 公共图标上传、校验与清理契约 | 已填 |
| [Database Guidelines](./database-guidelines.md) | GORM 实战约定与常见坑 | 已填 | | [Database Guidelines](./database-guidelines.md) | GORM 实战约定与常见坑 | 已填 |
| [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 | | [OCI Audit](./oci-audit.md) | 审计事件双通道数据源、检索语义与预算纪律 | 已填 |
| [Auth Login Methods](./auth-login-methods.md) | 登录方式接入契约(守卫/留痕/令牌版本/不变量/一次性挑战) | 已填 |
--- ---
+1 -1
View File
@@ -20,7 +20,7 @@
## 日志回传链路(logrelay)资源命名与描述纪律 ## 日志回传链路(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」的强指纹;背景与证据分档见调研档案 `docs/oci-sdk-caller-fingerprint.md` - **命名派生**: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`,避免恒定文案。 - **描述文本**:Topic 与 Policy 的 `Description` 用集中常量 `relayTopicDescNew` / `relayPolicyDescNew`(中性英文),不允许写 `oci-portal 日志回传:...` 等品牌/中文明文;SCH Connector 干脆不设 `Description`,避免恒定文案。
- **向后兼容 legacy 命名**:`findRelayTopic` / `findRelayPolicy` / `findRelayConnector` 支持传入多前缀/多名称,顺序为「新命名 → legacy 命名」;新增查找需求要沿用变参签名,不要单独硬编码 legacy 常量到业务函数里。命中 legacy 资源时,`refreshRelayTopicDesc` / `refreshRelayPolicyDesc` 会尽力刷新描述为中性文案,失败不阻塞主流程(权限/服务限流等场景直接忽略)。 - **向后兼容 legacy 命名**:`findRelayTopic` / `findRelayPolicy` / `findRelayConnector` 支持传入多前缀/多名称,顺序为「新命名 → legacy 命名」;新增查找需求要沿用变参签名,不要单独硬编码 legacy 常量到业务函数里。命中 legacy 资源时,`refreshRelayTopicDesc` / `refreshRelayPolicyDesc` 会尽力刷新描述为中性文案,失败不阻塞主流程(权限/服务限流等场景直接忽略)。
- **测试**:命名派生、legacy 常量值、描述中性性由 `logrelay_names_test.go` table-driven 锁定;若必须调整 legacy 常量,先评估是否会让存量租户找不到既有资源导致重复创建。 - **测试**:命名派生、legacy 常量值、描述中性性由 `logrelay_names_test.go` table-driven 锁定;若必须调整 legacy 常量,先评估是否会让存量租户找不到既有资源导致重复创建。
+20 -19
View File
@@ -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]
+26
View File
@@ -2,6 +2,32 @@
格式参考 [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] ## [0.8.1]
### Added ### Added
+1 -1
View File
@@ -1 +1 @@
v0.8.1 v0.8.3
+2 -2
View File
@@ -50,7 +50,7 @@
| **成本与账单** | 成本 / 用量查询(小时、日、月粒度,支持服务 / 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、SMTPAES-256-GCM、JWT、bcrypt、TOTP、OIDC / GitHub 登录、登录锁定、IP 限速、会话撤销和操作审计 | | **通知与安全** | Telegram、Webhook、ntfy、Bark、SMTPAES-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,7 +308,7 @@ go tool swag init -g cmd/server/main.go -o docs --parseInternal --parseDependenc
## 致谢 ## 致谢
感谢 [Yohann0617/oci-helper](https://github.com/Yohann0617/oci-helper) 项目为本项目提供思路。 感谢 [Yohann0617/oci-helper](https://github.com/Yohann0617/oci-helper) 为本项目提供思路。
## License ## License
+6 -2
View File
@@ -31,7 +31,7 @@ import (
) )
// @title OCI Portal API // @title OCI Portal API
// @version 0.8.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)
} }
+803 -6
View File
@@ -1044,11 +1044,23 @@ const docTemplate = `{
"$ref": "#/definitions/internal_api.errorResponse" "$ref": "#/definitions/internal_api.errorResponse"
} }
}, },
"403": {
"description": "密码登录已禁用",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"428": { "428": {
"description": "需要两步验证码(totpRequired=true)", "description": "需要两步验证码(totpRequired=true)",
"schema": { "schema": {
"$ref": "#/definitions/internal_api.totpRequiredResponse" "$ref": "#/definitions/internal_api.totpRequiredResponse"
} }
},
"429": {
"description": "登录守卫锁定(无 code);全局 IP 限流时 code=RateLimited",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -1079,7 +1091,7 @@ const docTemplate = `{
"summary": "外部登录 provider 列表", "summary": "外部登录 provider 列表",
"responses": { "responses": {
"200": { "200": {
"description": "providerspasswordLoginDisabled", "description": "providerspasswordLoginDisabled 与 passkeyLogin",
"schema": { "schema": {
"$ref": "#/definitions/internal_api.oauthProvidersResponse" "$ref": "#/definitions/internal_api.oauthProvidersResponse"
} }
@@ -1114,6 +1126,24 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/internal_api.urlResponse" "$ref": "#/definitions/internal_api.urlResponse"
} }
},
"401": {
"description": "bind 模式未携带有效 Bearer",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"404": {
"description": "未知 provider",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "provider 未配置 / 面板地址缺失 / 已禁用",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -1154,6 +1184,201 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/auth/passkey/login/begin": {
"post": {
"tags": [
"认证"
],
"summary": "发起通行密钥登录",
"responses": {
"200": {
"description": "sessionId 与 WebAuthn options",
"schema": {
"$ref": "#/definitions/internal_api.passkeyOptionsResponse"
}
},
"409": {
"description": "面板地址未设置",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/passkey/login/finish": {
"post": {
"tags": [
"认证"
],
"summary": "完成通行密钥登录",
"parameters": [
{
"description": "sessionId 与断言响应(name 忽略)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.passkeyFinishRequest"
}
}
],
"responses": {
"200": {
"description": "token 与 expiresAt",
"schema": {
"$ref": "#/definitions/internal_api.tokenResponse"
}
},
"400": {
"description": "请求体非法",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"401": {
"description": "校验失败",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"429": {
"description": "连续失败已锁定",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/passkey/register/begin": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "发起通行密钥注册",
"responses": {
"200": {
"description": "sessionId 与 WebAuthn options",
"schema": {
"$ref": "#/definitions/internal_api.passkeyOptionsResponse"
}
},
"409": {
"description": "面板地址未设置或数量达上限",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/passkey/register/finish": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "完成通行密钥注册",
"parameters": [
{
"description": "sessionId、名称与凭据响应",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.passkeyFinishRequest"
}
}
],
"responses": {
"200": {
"description": "已注册,返回换发的新 token",
"schema": {
"$ref": "#/definitions/internal_api.tokenResponse"
}
},
"204": {
"description": "已注册但新 token 签发失败,需重新登录"
},
"400": {
"description": "请求体非法、会话过期或凭据校验失败",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/passkeys": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "通行密钥列表",
"responses": {
"200": {
"description": "items",
"schema": {
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_model_UserPasskey"
}
}
}
}
},
"/api/v1/auth/passkeys/{id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "删除通行密钥",
"parameters": [
{
"type": "integer",
"description": "凭据 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "已删除,返回换发的新 token",
"schema": {
"$ref": "#/definitions/internal_api.tokenResponse"
}
},
"204": {
"description": "已删除但新 token 签发失败,需重新登录"
},
"409": {
"description": "密码登录已禁用且这是最后一个免密登录方式",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/password-login": { "/api/v1/auth/password-login": {
"put": { "put": {
"security": [ "security": [
@@ -1216,6 +1441,72 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/auth/sessions": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "活跃会话列表",
"responses": {
"200": {
"description": "items",
"schema": {
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_service_SessionInfo"
}
}
}
}
},
"/api/v1/auth/sessions/{id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "撤销单个会话",
"parameters": [
{
"type": "integer",
"description": "会话 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "已撤销"
},
"400": {
"description": "id 非法",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"404": {
"description": "会话不存在",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "不能撤销当前会话",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/totp": { "/api/v1/auth/totp": {
"get": { "get": {
"security": [ "security": [
@@ -1266,8 +1557,11 @@ const docTemplate = `{
"$ref": "#/definitions/internal_api.tokenResponse" "$ref": "#/definitions/internal_api.tokenResponse"
} }
}, },
"204": { "401": {
"description": "已启用但新 token 签发失败,需重新登录" "description": "请求处理期间会话已撤销",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -1301,8 +1595,11 @@ const docTemplate = `{
"$ref": "#/definitions/internal_api.tokenResponse" "$ref": "#/definitions/internal_api.tokenResponse"
} }
}, },
"204": { "401": {
"description": "已停用但新 token 签发失败,需重新登录" "description": "请求处理期间会话已撤销",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -1334,6 +1631,108 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/auth/wallet/challenge": {
"post": {
"tags": [
"认证"
],
"summary": "发起钱包签名挑战",
"parameters": [
{
"description": "钱包地址与模式(bind 需 Bearer)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.walletChallengeRequest"
}
}
],
"responses": {
"200": {
"description": "nonce 与待签名消息",
"schema": {
"$ref": "#/definitions/internal_api.walletChallengeResponse"
}
},
"400": {
"description": "地址格式不正确",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"401": {
"description": "bind 模式未登录",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "面板地址未设置",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/wallet/verify": {
"post": {
"tags": [
"认证"
],
"summary": "校验钱包签名",
"parameters": [
{
"description": "nonce 与签名",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.walletVerifyRequest"
}
}
],
"responses": {
"200": {
"description": "token 与 expiresAt(bind 模式为换发的新 token)",
"schema": {
"$ref": "#/definitions/internal_api.tokenResponse"
}
},
"400": {
"description": "挑战无效已过期,或 bind 模式签名校验失败",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"401": {
"description": "签名校验失败(login 模式)",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"403": {
"description": "地址未绑定账号",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "地址已绑定过",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"429": {
"description": "连续失败已锁定",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/console-sessions/{sessionId}": { "/api/v1/console-sessions/{sessionId}": {
"delete": { "delete": {
"security": [ "security": [
@@ -1572,6 +1971,43 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/oci-configs/{id}/activate-api-key": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。userId 给定且异于当前签名用户时,一并把签名用户切换为该用户。",
"tags": [
"租户 IAM"
],
"summary": "启用 API Key 为面板签名凭据",
"parameters": [
{
"type": "integer",
"description": "配置 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "请求体:fingerprint 与 privateKey,可选 userId(切换签名用户)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object"
}
}
],
"responses": {
"204": {
"description": "无内容"
}
}
}
},
"/api/v1/oci-configs/{id}/audit-events": { "/api/v1/oci-configs/{id}/audit-events": {
"get": { "get": {
"security": [ "security": [
@@ -3430,6 +3866,12 @@ const docTemplate = `{
"responses": { "responses": {
"204": { "204": {
"description": "无内容" "description": "无内容"
},
"409": {
"description": "该实例的生命周期操作正在处理中",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -3476,6 +3918,12 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/oci-portal_internal_oci.Instance" "$ref": "#/definitions/oci-portal_internal_oci.Instance"
} }
},
"409": {
"description": "该实例的生命周期操作正在处理中",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -5795,6 +6243,77 @@ const docTemplate = `{
} }
}, },
"/api/v1/oci-configs/{id}/users/{userId}/api-keys": { "/api/v1/oci-configs/{id}/users/{userId}/api-keys": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"租户 IAM"
],
"summary": "列出用户 API Key",
"parameters": [
{
"type": "integer",
"description": "配置 ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "userId",
"name": "userId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_api.userApiKeysResponse"
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "后端生成 RSA-2048 密钥对并上传公钥;私钥仅本次响应返回,不落库。",
"tags": [
"租户 IAM"
],
"summary": "为用户新增 API Key",
"parameters": [
{
"type": "integer",
"description": "配置 ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "userId",
"name": "userId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/oci-portal_internal_service.CreatedApiKey"
}
}
}
},
"delete": { "delete": {
"security": [ "security": [
{ {
@@ -5831,6 +6350,48 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/oci-configs/{id}/users/{userId}/api-keys/{fingerprint}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "当前配置正在使用的 key 拒删(409),避免面板失联。",
"tags": [
"租户 IAM"
],
"summary": "删除用户单把 API Key",
"parameters": [
{
"type": "integer",
"description": "配置 ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "userId",
"name": "userId",
"in": "path",
"required": true
},
{
"type": "string",
"description": "key 指纹",
"name": "fingerprint",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "无内容"
}
}
}
},
"/api/v1/oci-configs/{id}/users/{userId}/mfa-devices": { "/api/v1/oci-configs/{id}/users/{userId}/mfa-devices": {
"delete": { "delete": {
"security": [ "security": [
@@ -6909,6 +7470,24 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/oci-portal_internal_service.OAuthProvidersView" "$ref": "#/definitions/oci-portal_internal_service.OAuthProvidersView"
} }
},
"400": {
"description": "请求体非法",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"401": {
"description": "请求处理期间会话已撤销",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "密码登录禁用期间,该变更将移除最后可用登录方式",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -6960,6 +7539,24 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/oci-portal_internal_service.SecuritySettings" "$ref": "#/definitions/oci-portal_internal_service.SecuritySettings"
} }
},
"400": {
"description": "字段越界或非法",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"401": {
"description": "请求处理期间会话已撤销",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "密码登录禁用期间,面板地址变更将移除最后可用登录方式",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -7966,6 +8563,10 @@ const docTemplate = `{
"internal_api.errorResponse": { "internal_api.errorResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"code": {
"description": "Code 是机器可读错误码,多数错误不携带;当前仅全局 IP 限流的 429\n返回 \"RateLimited\",前端据此与登录守卫的 429(无 code)区分",
"type": "string"
},
"error": { "error": {
"type": "string" "type": "string"
} }
@@ -8155,6 +8756,17 @@ const docTemplate = `{
} }
} }
}, },
"internal_api.itemsResponse-oci-portal_internal_model_UserPasskey": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/oci-portal_internal_model.UserPasskey"
}
}
}
},
"internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel": { "internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8199,6 +8811,17 @@ const docTemplate = `{
} }
} }
}, },
"internal_api.itemsResponse-oci-portal_internal_service_SessionInfo": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/oci-portal_internal_service.SessionInfo"
}
}
}
},
"internal_api.logWebhookStatusResponse": { "internal_api.logWebhookStatusResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8236,6 +8859,9 @@ const docTemplate = `{
"internal_api.oauthProvidersResponse": { "internal_api.oauthProvidersResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"passkeyLogin": {
"type": "boolean"
},
"passwordLoginDisabled": { "passwordLoginDisabled": {
"type": "boolean" "type": "boolean"
}, },
@@ -8244,6 +8870,9 @@ const docTemplate = `{
"items": { "items": {
"$ref": "#/definitions/oci-portal_internal_service.ProviderInfo" "$ref": "#/definitions/oci-portal_internal_service.ProviderInfo"
} }
},
"walletLogin": {
"type": "boolean"
} }
} }
}, },
@@ -8317,6 +8946,37 @@ const docTemplate = `{
} }
} }
}, },
"internal_api.passkeyFinishRequest": {
"type": "object",
"required": [
"credential",
"sessionId"
],
"properties": {
"credential": {
"$ref": "#/definitions/oci-portal_internal_aiwire.AnyJSON"
},
"name": {
"type": "string",
"maxLength": 64
},
"sessionId": {
"type": "string",
"maxLength": 64
}
}
},
"internal_api.passkeyOptionsResponse": {
"type": "object",
"properties": {
"options": {
"$ref": "#/definitions/oci-portal_internal_aiwire.AnyJSON"
},
"sessionId": {
"type": "string"
}
}
},
"internal_api.passwordResponse": { "internal_api.passwordResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8612,6 +9272,64 @@ const docTemplate = `{
} }
} }
}, },
"internal_api.userApiKeysResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/oci-portal_internal_service.UserApiKeyInfo"
}
}
}
},
"internal_api.walletChallengeRequest": {
"type": "object",
"required": [
"address"
],
"properties": {
"address": {
"type": "string",
"maxLength": 64
},
"mode": {
"type": "string",
"enum": [
"login",
"bind"
]
}
}
},
"internal_api.walletChallengeResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"nonce": {
"type": "string"
}
}
},
"internal_api.walletVerifyRequest": {
"type": "object",
"required": [
"nonce",
"signature"
],
"properties": {
"nonce": {
"type": "string",
"maxLength": 64
},
"signature": {
"type": "string",
"maxLength": 200
}
}
},
"internal_api.webConsoleSessionResponse": { "internal_api.webConsoleSessionResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -9982,6 +10700,23 @@ const docTemplate = `{
} }
} }
}, },
"oci-portal_internal_model.UserPasskey": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"id": {
"type": "integer"
},
"lastUsedAt": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"oci-portal_internal_oci.AuditEvent": { "oci-portal_internal_oci.AuditEvent": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11330,6 +12065,20 @@ const docTemplate = `{
} }
} }
}, },
"oci-portal_internal_service.CreatedApiKey": {
"type": "object",
"properties": {
"configIni": {
"type": "string"
},
"fingerprint": {
"type": "string"
},
"privateKey": {
"type": "string"
}
}
},
"oci-portal_internal_service.ImportFail": { "oci-portal_internal_service.ImportFail": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11839,6 +12588,36 @@ const docTemplate = `{
} }
} }
}, },
"oci-portal_internal_service.SessionInfo": {
"type": "object",
"properties": {
"clientIp": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"current": {
"type": "boolean"
},
"expiresAt": {
"type": "string"
},
"id": {
"type": "integer"
},
"lastSeenAt": {
"type": "string"
},
"method": {
"description": "登录方式:password / oidc / github / passkey / wallet;接续换发保留原值",
"type": "string"
},
"userAgent": {
"type": "string"
}
}
},
"oci-portal_internal_service.TaskSettingsView": { "oci-portal_internal_service.TaskSettingsView": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11912,6 +12691,24 @@ const docTemplate = `{
"type": "string" "type": "string"
} }
} }
},
"oci-portal_internal_service.UserApiKeyInfo": {
"type": "object",
"properties": {
"configIni": {
"type": "string"
},
"fingerprint": {
"type": "string"
},
"isCurrent": {
"description": "IsCurrent 表示该 key 正被当前配置用于签名请求",
"type": "boolean"
},
"timeCreated": {
"type": "string"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {
@@ -11926,7 +12723,7 @@ const docTemplate = `{
// SwaggerInfo holds exported Swagger Info so clients can modify it // SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{ var SwaggerInfo = &swag.Spec{
Version: "0.8.1", Version: "0.8.3",
Host: "", Host: "",
BasePath: "/", BasePath: "/",
Schemes: []string{}, Schemes: []string{},
+803 -6
View File
@@ -4,7 +4,7 @@
"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)。",
"title": "OCI Portal API", "title": "OCI Portal API",
"contact": {}, "contact": {},
"version": "0.8.1" "version": "0.8.3"
}, },
"basePath": "/", "basePath": "/",
"paths": { "paths": {
@@ -1037,11 +1037,23 @@
"$ref": "#/definitions/internal_api.errorResponse" "$ref": "#/definitions/internal_api.errorResponse"
} }
}, },
"403": {
"description": "密码登录已禁用",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"428": { "428": {
"description": "需要两步验证码(totpRequired=true)", "description": "需要两步验证码(totpRequired=true)",
"schema": { "schema": {
"$ref": "#/definitions/internal_api.totpRequiredResponse" "$ref": "#/definitions/internal_api.totpRequiredResponse"
} }
},
"429": {
"description": "登录守卫锁定(无 code);全局 IP 限流时 code=RateLimited",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -1072,7 +1084,7 @@
"summary": "外部登录 provider 列表", "summary": "外部登录 provider 列表",
"responses": { "responses": {
"200": { "200": {
"description": "providerspasswordLoginDisabled", "description": "providerspasswordLoginDisabled 与 passkeyLogin",
"schema": { "schema": {
"$ref": "#/definitions/internal_api.oauthProvidersResponse" "$ref": "#/definitions/internal_api.oauthProvidersResponse"
} }
@@ -1107,6 +1119,24 @@
"schema": { "schema": {
"$ref": "#/definitions/internal_api.urlResponse" "$ref": "#/definitions/internal_api.urlResponse"
} }
},
"401": {
"description": "bind 模式未携带有效 Bearer",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"404": {
"description": "未知 provider",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "provider 未配置 / 面板地址缺失 / 已禁用",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -1147,6 +1177,201 @@
} }
} }
}, },
"/api/v1/auth/passkey/login/begin": {
"post": {
"tags": [
"认证"
],
"summary": "发起通行密钥登录",
"responses": {
"200": {
"description": "sessionId 与 WebAuthn options",
"schema": {
"$ref": "#/definitions/internal_api.passkeyOptionsResponse"
}
},
"409": {
"description": "面板地址未设置",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/passkey/login/finish": {
"post": {
"tags": [
"认证"
],
"summary": "完成通行密钥登录",
"parameters": [
{
"description": "sessionId 与断言响应(name 忽略)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.passkeyFinishRequest"
}
}
],
"responses": {
"200": {
"description": "token 与 expiresAt",
"schema": {
"$ref": "#/definitions/internal_api.tokenResponse"
}
},
"400": {
"description": "请求体非法",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"401": {
"description": "校验失败",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"429": {
"description": "连续失败已锁定",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/passkey/register/begin": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "发起通行密钥注册",
"responses": {
"200": {
"description": "sessionId 与 WebAuthn options",
"schema": {
"$ref": "#/definitions/internal_api.passkeyOptionsResponse"
}
},
"409": {
"description": "面板地址未设置或数量达上限",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/passkey/register/finish": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "完成通行密钥注册",
"parameters": [
{
"description": "sessionId、名称与凭据响应",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.passkeyFinishRequest"
}
}
],
"responses": {
"200": {
"description": "已注册,返回换发的新 token",
"schema": {
"$ref": "#/definitions/internal_api.tokenResponse"
}
},
"204": {
"description": "已注册但新 token 签发失败,需重新登录"
},
"400": {
"description": "请求体非法、会话过期或凭据校验失败",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/passkeys": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "通行密钥列表",
"responses": {
"200": {
"description": "items",
"schema": {
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_model_UserPasskey"
}
}
}
}
},
"/api/v1/auth/passkeys/{id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "删除通行密钥",
"parameters": [
{
"type": "integer",
"description": "凭据 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "已删除,返回换发的新 token",
"schema": {
"$ref": "#/definitions/internal_api.tokenResponse"
}
},
"204": {
"description": "已删除但新 token 签发失败,需重新登录"
},
"409": {
"description": "密码登录已禁用且这是最后一个免密登录方式",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/password-login": { "/api/v1/auth/password-login": {
"put": { "put": {
"security": [ "security": [
@@ -1209,6 +1434,72 @@
} }
} }
}, },
"/api/v1/auth/sessions": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "活跃会话列表",
"responses": {
"200": {
"description": "items",
"schema": {
"$ref": "#/definitions/internal_api.itemsResponse-oci-portal_internal_service_SessionInfo"
}
}
}
}
},
"/api/v1/auth/sessions/{id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"认证"
],
"summary": "撤销单个会话",
"parameters": [
{
"type": "integer",
"description": "会话 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "已撤销"
},
"400": {
"description": "id 非法",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"404": {
"description": "会话不存在",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "不能撤销当前会话",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/totp": { "/api/v1/auth/totp": {
"get": { "get": {
"security": [ "security": [
@@ -1259,8 +1550,11 @@
"$ref": "#/definitions/internal_api.tokenResponse" "$ref": "#/definitions/internal_api.tokenResponse"
} }
}, },
"204": { "401": {
"description": "已启用但新 token 签发失败,需重新登录" "description": "请求处理期间会话已撤销",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -1294,8 +1588,11 @@
"$ref": "#/definitions/internal_api.tokenResponse" "$ref": "#/definitions/internal_api.tokenResponse"
} }
}, },
"204": { "401": {
"description": "已停用但新 token 签发失败,需重新登录" "description": "请求处理期间会话已撤销",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -1327,6 +1624,108 @@
} }
} }
}, },
"/api/v1/auth/wallet/challenge": {
"post": {
"tags": [
"认证"
],
"summary": "发起钱包签名挑战",
"parameters": [
{
"description": "钱包地址与模式(bind 需 Bearer)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.walletChallengeRequest"
}
}
],
"responses": {
"200": {
"description": "nonce 与待签名消息",
"schema": {
"$ref": "#/definitions/internal_api.walletChallengeResponse"
}
},
"400": {
"description": "地址格式不正确",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"401": {
"description": "bind 模式未登录",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "面板地址未设置",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/auth/wallet/verify": {
"post": {
"tags": [
"认证"
],
"summary": "校验钱包签名",
"parameters": [
{
"description": "nonce 与签名",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_api.walletVerifyRequest"
}
}
],
"responses": {
"200": {
"description": "token 与 expiresAt(bind 模式为换发的新 token)",
"schema": {
"$ref": "#/definitions/internal_api.tokenResponse"
}
},
"400": {
"description": "挑战无效已过期,或 bind 模式签名校验失败",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"401": {
"description": "签名校验失败(login 模式)",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"403": {
"description": "地址未绑定账号",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "地址已绑定过",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"429": {
"description": "连续失败已锁定",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
}
}
}
},
"/api/v1/console-sessions/{sessionId}": { "/api/v1/console-sessions/{sessionId}": {
"delete": { "delete": {
"security": [ "security": [
@@ -1565,6 +1964,43 @@
} }
} }
}, },
"/api/v1/oci-configs/{id}/activate-api-key": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。userId 给定且异于当前签名用户时,一并把签名用户切换为该用户。",
"tags": [
"租户 IAM"
],
"summary": "启用 API Key 为面板签名凭据",
"parameters": [
{
"type": "integer",
"description": "配置 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "请求体:fingerprint 与 privateKey,可选 userId(切换签名用户)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"type": "object"
}
}
],
"responses": {
"204": {
"description": "无内容"
}
}
}
},
"/api/v1/oci-configs/{id}/audit-events": { "/api/v1/oci-configs/{id}/audit-events": {
"get": { "get": {
"security": [ "security": [
@@ -3423,6 +3859,12 @@
"responses": { "responses": {
"204": { "204": {
"description": "无内容" "description": "无内容"
},
"409": {
"description": "该实例的生命周期操作正在处理中",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -3469,6 +3911,12 @@
"schema": { "schema": {
"$ref": "#/definitions/oci-portal_internal_oci.Instance" "$ref": "#/definitions/oci-portal_internal_oci.Instance"
} }
},
"409": {
"description": "该实例的生命周期操作正在处理中",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -5788,6 +6236,77 @@
} }
}, },
"/api/v1/oci-configs/{id}/users/{userId}/api-keys": { "/api/v1/oci-configs/{id}/users/{userId}/api-keys": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"tags": [
"租户 IAM"
],
"summary": "列出用户 API Key",
"parameters": [
{
"type": "integer",
"description": "配置 ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "userId",
"name": "userId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_api.userApiKeysResponse"
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "后端生成 RSA-2048 密钥对并上传公钥;私钥仅本次响应返回,不落库。",
"tags": [
"租户 IAM"
],
"summary": "为用户新增 API Key",
"parameters": [
{
"type": "integer",
"description": "配置 ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "userId",
"name": "userId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/oci-portal_internal_service.CreatedApiKey"
}
}
}
},
"delete": { "delete": {
"security": [ "security": [
{ {
@@ -5824,6 +6343,48 @@
} }
} }
}, },
"/api/v1/oci-configs/{id}/users/{userId}/api-keys/{fingerprint}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "当前配置正在使用的 key 拒删(409),避免面板失联。",
"tags": [
"租户 IAM"
],
"summary": "删除用户单把 API Key",
"parameters": [
{
"type": "integer",
"description": "配置 ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "userId",
"name": "userId",
"in": "path",
"required": true
},
{
"type": "string",
"description": "key 指纹",
"name": "fingerprint",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "无内容"
}
}
}
},
"/api/v1/oci-configs/{id}/users/{userId}/mfa-devices": { "/api/v1/oci-configs/{id}/users/{userId}/mfa-devices": {
"delete": { "delete": {
"security": [ "security": [
@@ -6902,6 +7463,24 @@
"schema": { "schema": {
"$ref": "#/definitions/oci-portal_internal_service.OAuthProvidersView" "$ref": "#/definitions/oci-portal_internal_service.OAuthProvidersView"
} }
},
"400": {
"description": "请求体非法",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"401": {
"description": "请求处理期间会话已撤销",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "密码登录禁用期间,该变更将移除最后可用登录方式",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -6953,6 +7532,24 @@
"schema": { "schema": {
"$ref": "#/definitions/oci-portal_internal_service.SecuritySettings" "$ref": "#/definitions/oci-portal_internal_service.SecuritySettings"
} }
},
"400": {
"description": "字段越界或非法",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"401": {
"description": "请求处理期间会话已撤销",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
},
"409": {
"description": "密码登录禁用期间,面板地址变更将移除最后可用登录方式",
"schema": {
"$ref": "#/definitions/internal_api.errorResponse"
}
} }
} }
} }
@@ -7959,6 +8556,10 @@
"internal_api.errorResponse": { "internal_api.errorResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"code": {
"description": "Code 是机器可读错误码,多数错误不携带;当前仅全局 IP 限流的 429\n返回 \"RateLimited\",前端据此与登录守卫的 429(无 code)区分",
"type": "string"
},
"error": { "error": {
"type": "string" "type": "string"
} }
@@ -8148,6 +8749,17 @@
} }
} }
}, },
"internal_api.itemsResponse-oci-portal_internal_model_UserPasskey": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/oci-portal_internal_model.UserPasskey"
}
}
}
},
"internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel": { "internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8192,6 +8804,17 @@
} }
} }
}, },
"internal_api.itemsResponse-oci-portal_internal_service_SessionInfo": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/oci-portal_internal_service.SessionInfo"
}
}
}
},
"internal_api.logWebhookStatusResponse": { "internal_api.logWebhookStatusResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8229,6 +8852,9 @@
"internal_api.oauthProvidersResponse": { "internal_api.oauthProvidersResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"passkeyLogin": {
"type": "boolean"
},
"passwordLoginDisabled": { "passwordLoginDisabled": {
"type": "boolean" "type": "boolean"
}, },
@@ -8237,6 +8863,9 @@
"items": { "items": {
"$ref": "#/definitions/oci-portal_internal_service.ProviderInfo" "$ref": "#/definitions/oci-portal_internal_service.ProviderInfo"
} }
},
"walletLogin": {
"type": "boolean"
} }
} }
}, },
@@ -8310,6 +8939,37 @@
} }
} }
}, },
"internal_api.passkeyFinishRequest": {
"type": "object",
"required": [
"credential",
"sessionId"
],
"properties": {
"credential": {
"$ref": "#/definitions/oci-portal_internal_aiwire.AnyJSON"
},
"name": {
"type": "string",
"maxLength": 64
},
"sessionId": {
"type": "string",
"maxLength": 64
}
}
},
"internal_api.passkeyOptionsResponse": {
"type": "object",
"properties": {
"options": {
"$ref": "#/definitions/oci-portal_internal_aiwire.AnyJSON"
},
"sessionId": {
"type": "string"
}
}
},
"internal_api.passwordResponse": { "internal_api.passwordResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -8605,6 +9265,64 @@
} }
} }
}, },
"internal_api.userApiKeysResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/oci-portal_internal_service.UserApiKeyInfo"
}
}
}
},
"internal_api.walletChallengeRequest": {
"type": "object",
"required": [
"address"
],
"properties": {
"address": {
"type": "string",
"maxLength": 64
},
"mode": {
"type": "string",
"enum": [
"login",
"bind"
]
}
}
},
"internal_api.walletChallengeResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"nonce": {
"type": "string"
}
}
},
"internal_api.walletVerifyRequest": {
"type": "object",
"required": [
"nonce",
"signature"
],
"properties": {
"nonce": {
"type": "string",
"maxLength": 64
},
"signature": {
"type": "string",
"maxLength": 200
}
}
},
"internal_api.webConsoleSessionResponse": { "internal_api.webConsoleSessionResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -9975,6 +10693,23 @@
} }
} }
}, },
"oci-portal_internal_model.UserPasskey": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"id": {
"type": "integer"
},
"lastUsedAt": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"oci-portal_internal_oci.AuditEvent": { "oci-portal_internal_oci.AuditEvent": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11323,6 +12058,20 @@
} }
} }
}, },
"oci-portal_internal_service.CreatedApiKey": {
"type": "object",
"properties": {
"configIni": {
"type": "string"
},
"fingerprint": {
"type": "string"
},
"privateKey": {
"type": "string"
}
}
},
"oci-portal_internal_service.ImportFail": { "oci-portal_internal_service.ImportFail": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11832,6 +12581,36 @@
} }
} }
}, },
"oci-portal_internal_service.SessionInfo": {
"type": "object",
"properties": {
"clientIp": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"current": {
"type": "boolean"
},
"expiresAt": {
"type": "string"
},
"id": {
"type": "integer"
},
"lastSeenAt": {
"type": "string"
},
"method": {
"description": "登录方式:password / oidc / github / passkey / wallet;接续换发保留原值",
"type": "string"
},
"userAgent": {
"type": "string"
}
}
},
"oci-portal_internal_service.TaskSettingsView": { "oci-portal_internal_service.TaskSettingsView": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11905,6 +12684,24 @@
"type": "string" "type": "string"
} }
} }
},
"oci-portal_internal_service.UserApiKeyInfo": {
"type": "object",
"properties": {
"configIni": {
"type": "string"
},
"fingerprint": {
"type": "string"
},
"isCurrent": {
"description": "IsCurrent 表示该 key 正被当前配置用于签名请求",
"type": "boolean"
},
"timeCreated": {
"type": "string"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {
+518 -6
View File
@@ -401,6 +401,11 @@ definitions:
type: object type: object
internal_api.errorResponse: internal_api.errorResponse:
properties: properties:
code:
description: |-
Code 是机器可读错误码,多数错误不携带;当前仅全局 IP 限流的 429
返回 "RateLimited",前端据此与登录守卫的 429(无 code)区分
type: string
error: error:
type: string type: string
type: object type: object
@@ -524,6 +529,13 @@ definitions:
$ref: '#/definitions/oci-portal_internal_model.UserIdentity' $ref: '#/definitions/oci-portal_internal_model.UserIdentity'
type: array type: array
type: object type: object
internal_api.itemsResponse-oci-portal_internal_model_UserPasskey:
properties:
items:
items:
$ref: '#/definitions/oci-portal_internal_model.UserPasskey'
type: array
type: object
internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel: internal_api.itemsResponse-oci-portal_internal_service_AggregatedModel:
properties: properties:
items: items:
@@ -552,6 +564,13 @@ definitions:
$ref: '#/definitions/oci-portal_internal_service.ProxyView' $ref: '#/definitions/oci-portal_internal_service.ProxyView'
type: array type: array
type: object type: object
internal_api.itemsResponse-oci-portal_internal_service_SessionInfo:
properties:
items:
items:
$ref: '#/definitions/oci-portal_internal_service.SessionInfo'
type: array
type: object
internal_api.logWebhookStatusResponse: internal_api.logWebhookStatusResponse:
properties: properties:
exists: exists:
@@ -578,12 +597,16 @@ definitions:
type: object type: object
internal_api.oauthProvidersResponse: internal_api.oauthProvidersResponse:
properties: properties:
passkeyLogin:
type: boolean
passwordLoginDisabled: passwordLoginDisabled:
type: boolean type: boolean
providers: providers:
items: items:
$ref: '#/definitions/oci-portal_internal_service.ProviderInfo' $ref: '#/definitions/oci-portal_internal_service.ProviderInfo'
type: array type: array
walletLogin:
type: boolean
type: object type: object
internal_api.pagedResponse-oci-portal_internal_model_AiCallLog: internal_api.pagedResponse-oci-portal_internal_model_AiCallLog:
properties: properties:
@@ -630,6 +653,27 @@ definitions:
nextPage: nextPage:
type: string type: string
type: object type: object
internal_api.passkeyFinishRequest:
properties:
credential:
$ref: '#/definitions/oci-portal_internal_aiwire.AnyJSON'
name:
maxLength: 64
type: string
sessionId:
maxLength: 64
type: string
required:
- credential
- sessionId
type: object
internal_api.passkeyOptionsResponse:
properties:
options:
$ref: '#/definitions/oci-portal_internal_aiwire.AnyJSON'
sessionId:
type: string
type: object
internal_api.passwordResponse: internal_api.passwordResponse:
properties: properties:
password: password:
@@ -820,6 +864,45 @@ definitions:
usedBy: usedBy:
type: integer type: integer
type: object type: object
internal_api.userApiKeysResponse:
properties:
items:
items:
$ref: '#/definitions/oci-portal_internal_service.UserApiKeyInfo'
type: array
type: object
internal_api.walletChallengeRequest:
properties:
address:
maxLength: 64
type: string
mode:
enum:
- login
- bind
type: string
required:
- address
type: object
internal_api.walletChallengeResponse:
properties:
message:
type: string
nonce:
type: string
type: object
internal_api.walletVerifyRequest:
properties:
nonce:
maxLength: 64
type: string
signature:
maxLength: 200
type: string
required:
- nonce
- signature
type: object
internal_api.webConsoleSessionResponse: internal_api.webConsoleSessionResponse:
properties: properties:
sessionId: sessionId:
@@ -1730,6 +1813,17 @@ definitions:
provider: provider:
type: string type: string
type: object type: object
oci-portal_internal_model.UserPasskey:
properties:
createdAt:
type: string
id:
type: integer
lastUsedAt:
type: string
name:
type: string
type: object
oci-portal_internal_oci.AuditEvent: oci-portal_internal_oci.AuditEvent:
properties: properties:
compartmentName: compartmentName:
@@ -2619,6 +2713,15 @@ definitions:
tenancyOcid: tenancyOcid:
type: string type: string
type: object type: object
oci-portal_internal_service.CreatedApiKey:
properties:
configIni:
type: string
fingerprint:
type: string
privateKey:
type: string
type: object
oci-portal_internal_service.ImportFail: oci-portal_internal_service.ImportFail:
properties: properties:
line: line:
@@ -2958,6 +3061,26 @@ definitions:
description: 真实IP请求头:空 = 直连(RemoteAddr);反代后必须选择与反代匹配的头 description: 真实IP请求头:空 = 直连(RemoteAddr);反代后必须选择与反代匹配的头
type: string type: string
type: object type: object
oci-portal_internal_service.SessionInfo:
properties:
clientIp:
type: string
createdAt:
type: string
current:
type: boolean
expiresAt:
type: string
id:
type: integer
lastSeenAt:
type: string
method:
description: 登录方式:password / oidc / github / passkey / wallet;接续换发保留原值
type: string
userAgent:
type: string
type: object
oci-portal_internal_service.TaskSettingsView: oci-portal_internal_service.TaskSettingsView:
properties: properties:
snatchAuthFailLimit: snatchAuthFailLimit:
@@ -3006,12 +3129,24 @@ definitions:
oidcIssuer: oidcIssuer:
type: string type: string
type: object type: object
oci-portal_internal_service.UserApiKeyInfo:
properties:
configIni:
type: string
fingerprint:
type: string
isCurrent:
description: IsCurrent 表示该 key 正被当前配置用于签名请求
type: boolean
timeCreated:
type: string
type: object
info: info:
contact: {} contact: {}
description: '自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization: description: '自托管 OCI 多租户管理面板 API。业务接口用 JWT(Bearer);AI 网关端点(/ai/v1/*)用独立网关密钥(Authorization:
Bearer 或 x-api-key)。' Bearer 或 x-api-key)。'
title: OCI Portal API title: OCI Portal API
version: 0.8.1 version: 0.8.3
paths: paths:
/ai/v1/audio/speech: /ai/v1/audio/speech:
post: post:
@@ -3641,10 +3776,18 @@ paths:
description: 凭据错误 description: 凭据错误
schema: schema:
$ref: '#/definitions/internal_api.errorResponse' $ref: '#/definitions/internal_api.errorResponse'
"403":
description: 密码登录已禁用
schema:
$ref: '#/definitions/internal_api.errorResponse'
"428": "428":
description: 需要两步验证码(totpRequired=true) description: 需要两步验证码(totpRequired=true)
schema: schema:
$ref: '#/definitions/internal_api.totpRequiredResponse' $ref: '#/definitions/internal_api.totpRequiredResponse'
"429":
description: 登录守卫锁定(无 code);全局 IP 限流时 code=RateLimited
schema:
$ref: '#/definitions/internal_api.errorResponse'
summary: 登录 summary: 登录
tags: tags:
- 认证 - 认证
@@ -3675,6 +3818,18 @@ paths:
description: url description: url
schema: schema:
$ref: '#/definitions/internal_api.urlResponse' $ref: '#/definitions/internal_api.urlResponse'
"401":
description: bind 模式未携带有效 Bearer
schema:
$ref: '#/definitions/internal_api.errorResponse'
"404":
description: 未知 provider
schema:
$ref: '#/definitions/internal_api.errorResponse'
"409":
description: provider 未配置 / 面板地址缺失 / 已禁用
schema:
$ref: '#/definitions/internal_api.errorResponse'
summary: 外部登录授权跳转 summary: 外部登录授权跳转
tags: tags:
- 认证 - 认证
@@ -3706,12 +3861,132 @@ paths:
get: get:
responses: responses:
"200": "200":
description: providerspasswordLoginDisabled description: providerspasswordLoginDisabled 与 passkeyLogin
schema: schema:
$ref: '#/definitions/internal_api.oauthProvidersResponse' $ref: '#/definitions/internal_api.oauthProvidersResponse'
summary: 外部登录 provider 列表 summary: 外部登录 provider 列表
tags: tags:
- 认证 - 认证
/api/v1/auth/passkey/login/begin:
post:
responses:
"200":
description: sessionId 与 WebAuthn options
schema:
$ref: '#/definitions/internal_api.passkeyOptionsResponse'
"409":
description: 面板地址未设置
schema:
$ref: '#/definitions/internal_api.errorResponse'
summary: 发起通行密钥登录
tags:
- 认证
/api/v1/auth/passkey/login/finish:
post:
parameters:
- description: sessionId 与断言响应(name 忽略)
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_api.passkeyFinishRequest'
responses:
"200":
description: token 与 expiresAt
schema:
$ref: '#/definitions/internal_api.tokenResponse'
"400":
description: 请求体非法
schema:
$ref: '#/definitions/internal_api.errorResponse'
"401":
description: 校验失败
schema:
$ref: '#/definitions/internal_api.errorResponse'
"429":
description: 连续失败已锁定
schema:
$ref: '#/definitions/internal_api.errorResponse'
summary: 完成通行密钥登录
tags:
- 认证
/api/v1/auth/passkey/register/begin:
post:
responses:
"200":
description: sessionId 与 WebAuthn options
schema:
$ref: '#/definitions/internal_api.passkeyOptionsResponse'
"409":
description: 面板地址未设置或数量达上限
schema:
$ref: '#/definitions/internal_api.errorResponse'
security:
- BearerAuth: []
summary: 发起通行密钥注册
tags:
- 认证
/api/v1/auth/passkey/register/finish:
post:
parameters:
- description: sessionId、名称与凭据响应
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_api.passkeyFinishRequest'
responses:
"200":
description: 已注册,返回换发的新 token
schema:
$ref: '#/definitions/internal_api.tokenResponse'
"204":
description: 已注册但新 token 签发失败,需重新登录
"400":
description: 请求体非法、会话过期或凭据校验失败
schema:
$ref: '#/definitions/internal_api.errorResponse'
security:
- BearerAuth: []
summary: 完成通行密钥注册
tags:
- 认证
/api/v1/auth/passkeys:
get:
responses:
"200":
description: items
schema:
$ref: '#/definitions/internal_api.itemsResponse-oci-portal_internal_model_UserPasskey'
security:
- BearerAuth: []
summary: 通行密钥列表
tags:
- 认证
/api/v1/auth/passkeys/{id}:
delete:
parameters:
- description: 凭据 ID
in: path
name: id
required: true
type: integer
responses:
"200":
description: 已删除,返回换发的新 token
schema:
$ref: '#/definitions/internal_api.tokenResponse'
"204":
description: 已删除但新 token 签发失败,需重新登录
"409":
description: 密码登录已禁用且这是最后一个免密登录方式
schema:
$ref: '#/definitions/internal_api.errorResponse'
security:
- BearerAuth: []
summary: 删除通行密钥
tags:
- 认证
/api/v1/auth/password-login: /api/v1/auth/password-login:
put: put:
parameters: parameters:
@@ -3749,6 +4024,46 @@ paths:
summary: 撤销全部会话 summary: 撤销全部会话
tags: tags:
- 认证 - 认证
/api/v1/auth/sessions:
get:
responses:
"200":
description: items
schema:
$ref: '#/definitions/internal_api.itemsResponse-oci-portal_internal_service_SessionInfo'
security:
- BearerAuth: []
summary: 活跃会话列表
tags:
- 认证
/api/v1/auth/sessions/{id}:
delete:
parameters:
- description: 会话 ID
in: path
name: id
required: true
type: integer
responses:
"204":
description: 已撤销
"400":
description: id 非法
schema:
$ref: '#/definitions/internal_api.errorResponse'
"404":
description: 会话不存在
schema:
$ref: '#/definitions/internal_api.errorResponse'
"409":
description: 不能撤销当前会话
schema:
$ref: '#/definitions/internal_api.errorResponse'
security:
- BearerAuth: []
summary: 撤销单个会话
tags:
- 认证
/api/v1/auth/totp: /api/v1/auth/totp:
get: get:
responses: responses:
@@ -3775,8 +4090,10 @@ paths:
description: 已启用,返回换发的新 token description: 已启用,返回换发的新 token
schema: schema:
$ref: '#/definitions/internal_api.tokenResponse' $ref: '#/definitions/internal_api.tokenResponse'
"204": "401":
description: 已启用但新 token 签发失败,需重新登录 description: 请求处理期间会话已撤销
schema:
$ref: '#/definitions/internal_api.errorResponse'
security: security:
- BearerAuth: [] - BearerAuth: []
summary: 激活两步验证 summary: 激活两步验证
@@ -3796,8 +4113,10 @@ paths:
description: 已停用,返回换发的新 token description: 已停用,返回换发的新 token
schema: schema:
$ref: '#/definitions/internal_api.tokenResponse' $ref: '#/definitions/internal_api.tokenResponse'
"204": "401":
description: 已停用但新 token 签发失败,需重新登录 description: 请求处理期间会话已撤销
schema:
$ref: '#/definitions/internal_api.errorResponse'
security: security:
- BearerAuth: [] - BearerAuth: []
summary: 停用两步验证 summary: 停用两步验证
@@ -3819,6 +4138,72 @@ paths:
summary: 发起两步验证设置 summary: 发起两步验证设置
tags: tags:
- 认证 - 认证
/api/v1/auth/wallet/challenge:
post:
parameters:
- description: 钱包地址与模式(bind 需 Bearer)
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_api.walletChallengeRequest'
responses:
"200":
description: nonce 与待签名消息
schema:
$ref: '#/definitions/internal_api.walletChallengeResponse'
"400":
description: 地址格式不正确
schema:
$ref: '#/definitions/internal_api.errorResponse'
"401":
description: bind 模式未登录
schema:
$ref: '#/definitions/internal_api.errorResponse'
"409":
description: 面板地址未设置
schema:
$ref: '#/definitions/internal_api.errorResponse'
summary: 发起钱包签名挑战
tags:
- 认证
/api/v1/auth/wallet/verify:
post:
parameters:
- description: nonce 与签名
in: body
name: body
required: true
schema:
$ref: '#/definitions/internal_api.walletVerifyRequest'
responses:
"200":
description: token 与 expiresAt(bind 模式为换发的新 token)
schema:
$ref: '#/definitions/internal_api.tokenResponse'
"400":
description: 挑战无效已过期,或 bind 模式签名校验失败
schema:
$ref: '#/definitions/internal_api.errorResponse'
"401":
description: 签名校验失败(login 模式)
schema:
$ref: '#/definitions/internal_api.errorResponse'
"403":
description: 地址未绑定账号
schema:
$ref: '#/definitions/internal_api.errorResponse'
"409":
description: 地址已绑定过
schema:
$ref: '#/definitions/internal_api.errorResponse'
"429":
description: 连续失败已锁定
schema:
$ref: '#/definitions/internal_api.errorResponse'
summary: 校验钱包签名
tags:
- 认证
/api/v1/console-sessions/{sessionId}: /api/v1/console-sessions/{sessionId}:
delete: delete:
parameters: parameters:
@@ -3964,6 +4349,29 @@ paths:
summary: 更新租户配置 summary: 更新租户配置
tags: tags:
- 租户配置 - 租户配置
/api/v1/oci-configs/{id}/activate-api-key:
post:
description: 将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。userId 给定且异于当前签名用户时,一并把签名用户切换为该用户。
parameters:
- description: 配置 ID
in: path
name: id
required: true
type: integer
- description: 请求体:fingerprint 与 privateKey,可选 userId(切换签名用户)
in: body
name: body
required: true
schema:
type: object
responses:
"204":
description: 无内容
security:
- BearerAuth: []
summary: 启用 API Key 为面板签名凭据
tags:
- 租户 IAM
/api/v1/oci-configs/{id}/audit-events: /api/v1/oci-configs/{id}/audit-events:
get: get:
parameters: parameters:
@@ -5083,6 +5491,10 @@ paths:
responses: responses:
"204": "204":
description: 无内容 description: 无内容
"409":
description: 该实例的生命周期操作正在处理中
schema:
$ref: '#/definitions/internal_api.errorResponse'
security: security:
- BearerAuth: [] - BearerAuth: []
summary: 终止实例 summary: 终止实例
@@ -5162,6 +5574,10 @@ paths:
description: OK description: OK
schema: schema:
$ref: '#/definitions/oci-portal_internal_oci.Instance' $ref: '#/definitions/oci-portal_internal_oci.Instance'
"409":
description: 该实例的生命周期操作正在处理中
schema:
$ref: '#/definitions/internal_api.errorResponse'
security: security:
- BearerAuth: [] - BearerAuth: []
summary: 实例电源操作(启停/重启) summary: 实例电源操作(启停/重启)
@@ -6631,6 +7047,78 @@ paths:
summary: 清除用户全部 API Key summary: 清除用户全部 API Key
tags: tags:
- 租户 IAM - 租户 IAM
get:
parameters:
- description: 配置 ID
in: path
name: id
required: true
type: integer
- description: userId
in: path
name: userId
required: true
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/internal_api.userApiKeysResponse'
security:
- BearerAuth: []
summary: 列出用户 API Key
tags:
- 租户 IAM
post:
description: 后端生成 RSA-2048 密钥对并上传公钥;私钥仅本次响应返回,不落库。
parameters:
- description: 配置 ID
in: path
name: id
required: true
type: integer
- description: userId
in: path
name: userId
required: true
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/oci-portal_internal_service.CreatedApiKey'
security:
- BearerAuth: []
summary: 为用户新增 API Key
tags:
- 租户 IAM
/api/v1/oci-configs/{id}/users/{userId}/api-keys/{fingerprint}:
delete:
description: 当前配置正在使用的 key 拒删(409),避免面板失联。
parameters:
- description: 配置 ID
in: path
name: id
required: true
type: integer
- description: userId
in: path
name: userId
required: true
type: string
- description: key 指纹
in: path
name: fingerprint
required: true
type: string
responses:
"204":
description: 无内容
security:
- BearerAuth: []
summary: 删除用户单把 API Key
tags:
- 租户 IAM
/api/v1/oci-configs/{id}/users/{userId}/mfa-devices: /api/v1/oci-configs/{id}/users/{userId}/mfa-devices:
delete: delete:
parameters: parameters:
@@ -7286,6 +7774,18 @@ paths:
description: OK description: OK
schema: schema:
$ref: '#/definitions/oci-portal_internal_service.OAuthProvidersView' $ref: '#/definitions/oci-portal_internal_service.OAuthProvidersView'
"400":
description: 请求体非法
schema:
$ref: '#/definitions/internal_api.errorResponse'
"401":
description: 请求处理期间会话已撤销
schema:
$ref: '#/definitions/internal_api.errorResponse'
"409":
description: 密码登录禁用期间,该变更将移除最后可用登录方式
schema:
$ref: '#/definitions/internal_api.errorResponse'
security: security:
- BearerAuth: [] - BearerAuth: []
summary: 部分更新 OAuth provider 配置 summary: 部分更新 OAuth provider 配置
@@ -7316,6 +7816,18 @@ paths:
description: OK description: OK
schema: schema:
$ref: '#/definitions/oci-portal_internal_service.SecuritySettings' $ref: '#/definitions/oci-portal_internal_service.SecuritySettings'
"400":
description: 字段越界或非法
schema:
$ref: '#/definitions/internal_api.errorResponse'
"401":
description: 请求处理期间会话已撤销
schema:
$ref: '#/definitions/internal_api.errorResponse'
"409":
description: 密码登录禁用期间,面板地址变更将移除最后可用登录方式
schema:
$ref: '#/definitions/internal_api.errorResponse'
security: security:
- BearerAuth: [] - BearerAuth: []
summary: 部分更新安全设置并返回最新值 summary: 部分更新安全设置并返回最新值
+11 -2
View File
@@ -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
+26 -4
View File
@@ -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=
+4 -3
View File
@@ -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)
} }
+5 -5
View File
@@ -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)
} }
+48 -24
View File
@@ -18,6 +18,8 @@ import (
type authxHandler struct { type authxHandler struct {
auth *service.AuthService auth *service.AuthService
oauth *service.OAuthService oauth *service.OAuthService
passkeys *service.PasskeyService
wallets *service.WalletService
logs *service.SystemLogService logs *service.SystemLogService
} }
@@ -66,7 +68,7 @@ func (h *authxHandler) totpSetup(c *gin.Context) {
// @Tags 认证 // @Tags 认证
// @Param body body object true "{code: 6 位验证码}" // @Param body body object true "{code: 6 位验证码}"
// @Success 200 {object} tokenResponse "已启用,返回换发的新 token" // @Success 200 {object} tokenResponse "已启用,返回换发的新 token"
// @Success 204 "已启用但新 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) {
@@ -77,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()})
@@ -86,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 停用两步验证;需当前验证码或登录密码任一确认。
@@ -95,7 +99,7 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
// @Tags 认证 // @Tags 认证
// @Param body body object true "{password 或 code 任一确认}" // @Param body body object true "{password 或 code 任一确认}"
// @Success 200 {object} tokenResponse "已停用,返回换发的新 token" // @Success 200 {object} tokenResponse "已停用,返回换发的新 token"
// @Success 204 "已停用但新 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) {
@@ -107,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()})
@@ -116,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
@@ -138,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
@@ -183,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
@@ -197,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 保存密码登录禁用开关;开启需至少绑定一个外部身份。
@@ -218,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
@@ -227,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 "providerspasswordLoginDisabled" // @Success 200 {object} oauthProvidersResponse "providerspasswordLoginDisabled 与 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 公开。
@@ -256,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")
@@ -272,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()})
@@ -310,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"
@@ -390,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
@@ -398,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 组内) ----
@@ -426,6 +441,9 @@ func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.Settin
// @Tags 设置 // @Tags 设置
// @Param body body service.UpdateOAuthInput true "出现的字段才会被更新" // @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 [patch] // @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) {
@@ -434,7 +452,13 @@ func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.Set
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
} }
+1 -1
View File
@@ -26,7 +26,7 @@ func newIconTestRouter(t *testing.T) (*gin.Engine, string, uint, *service.System
t.Helper() t.Helper()
router, auth, logs, db := newTestRouterDB(t) router, auth, logs, db := newTestRouterDB(t)
id := seedIconConfig(t, db) id := seedIconConfig(t, db)
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)
} }
+33 -2
View File
@@ -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
+78
View File
@@ -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())
}
})
}
}
+30 -8
View File
@@ -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 记入日志),
+55
View File
@@ -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)
}
})
}
}
+6
View File
@@ -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})
+196
View File
@@ -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,
})
}
+3 -1
View File
@@ -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()
+4 -4
View File
@@ -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,15 +23,15 @@ 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,multipart 边界与字段头另占空间,
+6 -4
View File
@@ -86,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")
@@ -104,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
} }
@@ -146,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)
} }
@@ -171,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)
} }
+26 -5
View File
@@ -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)
+4
View File
@@ -160,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)
+2 -2
View File
@@ -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)
+85
View File
@@ -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)
}
+11 -1
View File
@@ -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
} }
@@ -323,6 +324,9 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
// @Tags 设置 // @Tags 设置
// @Param body body service.SecurityPatch 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 [patch] // @Router /api/v1/settings/security [patch]
func (h *settingsHandler) updateSecurity(c *gin.Context) { func (h *settingsHandler) updateSecurity(c *gin.Context) {
@@ -331,11 +335,17 @@ func (h *settingsHandler) updateSecurity(c *gin.Context) {
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
} }
+22
View File
@@ -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"`
+96
View File
@@ -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 ---- 域通知收件人与密码策略 ----
+161
View File
@@ -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,
})
}
+1 -1
View File
@@ -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{},
+39
View File
@@ -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" // 定时测活
+4
View File
@@ -189,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)
+60
View File
@@ -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)
+96 -31
View File
@@ -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
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
} }
return s.IssueToken(ctx, username) 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 摘要作黑名单键,不在内存长期保留原令牌串。
+16 -16
View File
@@ -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)
} }
+234 -24
View File
@@ -20,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 {
@@ -35,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
@@ -53,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{}
@@ -119,9 +162,10 @@ 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")
} }
@@ -129,27 +173,41 @@ func (s *AuthService) SetPasswordLoginDisabled(ctx context.Context, username str
if disabled { if disabled {
value = "1" value = "1"
} }
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
user, err := lockUserForAuthChange(tx, username) user, err := lockUserForAuthChange(tx, username)
if err != nil { if err != nil {
return err return err
} }
if disabled { if err := s.ensureTokenCurrentTx(tx, user, proof); err != nil {
n, err := identityCountTx(tx, user.ID) return err
}
if err := s.ensurePasswordlessForToggle(tx, user.ID, disabled); err != nil {
return err
}
if err := saveSettingTx(tx, settingSecPasswordLoginOff, value); err != nil {
return err
}
// 登录策略属敏感变更:版本递增与开关写入同事务提交,不留半程状态
return bumpTokenVersionTx(tx, username)
})
}
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 { if err != nil {
return err return err
} }
if n == 0 { ok, err := usablePasswordlessTx(tx, userID, 0, 0, origin)
if err != nil {
return err
}
if !ok {
return ErrNeedIdentity return ErrNeedIdentity
} }
} return nil
return saveSettingTx(tx, settingSecPasswordLoginOff, value)
})
if err != nil {
return err
}
// 登录策略属敏感变更:递增令牌版本,已签发会话全部失效
return s.bumpTokenVersion(ctx, username)
} }
// lockUserForAuthChange 事务内锁定用户行(SQLite 单写天然串行,MySQL/PG 靠行锁), // lockUserForAuthChange 事务内锁定用户行(SQLite 单写天然串行,MySQL/PG 靠行锁),
@@ -164,6 +222,64 @@ func lockUserForAuthChange(tx *gorm.DB, username string) (*model.User, error) {
return &user, nil 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) { func identityCountTx(tx *gorm.DB, userID uint) (int64, error) {
var count int64 var count int64
err := tx.Model(&model.UserIdentity{}). err := tx.Model(&model.UserIdentity{}).
@@ -173,3 +289,97 @@ func identityCountTx(tx *gorm.DB, userID uint) (int64, error) {
} }
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
}
+94 -15
View File
@@ -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)
}
}
+89
View File
@@ -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
}
+136
View File
@@ -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")
}
}
+165 -49
View File
@@ -44,6 +44,8 @@ type oauthPending struct {
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 新建。
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 { if err != nil {
return err 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 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 err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", ErrOAuthNotBound return nil, ErrOAuthNotBound
} }
return "", "", fmt.Errorf("find identity: %w", err) if err != nil {
return nil, 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)
} }
token, _, err := o.auth.signToken(user.Username, user.TokenVersion)
return token, user.Username, 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)
}
if count == 0 {
return ErrOAuthNotBound
}
return nil
} }
// Identities 列出账号已绑定的外部身份。 // Identities 列出账号已绑定的外部身份。
@@ -330,13 +444,20 @@ func (o *OAuthService) Identities(ctx context.Context, username string) ([]model
// Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。 // Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。
// 检查与删除在同一事务内并锁定用户行,防与禁用密码登录并发绕过「至少一种登录方式」。 // 检查与删除在同一事务内并锁定用户行,防与禁用密码登录并发绕过「至少一种登录方式」。
func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error { func (o *OAuthService) Unbind(ctx context.Context, username string, id uint, proof TokenProof) error {
err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := o.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
user, err := lockUserForAuthChange(tx, username) user, err := lockUserForAuthChange(tx, username)
if err != nil { if err != nil {
return err return err
} }
if err := ensureNotLastLogin(tx, user.ID); err != nil { 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 return err
} }
res := tx.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{}) res := tx.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
@@ -346,30 +467,25 @@ func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) err
if res.RowsAffected == 0 { if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound return gorm.ErrRecordNotFound
} }
return nil // 解绑属敏感变更:版本递增与删除同事务提交,不留「已删而旧令牌仍有效」半程
return bumpTokenVersionTx(tx, username)
}) })
if err != nil {
return err return err
} }
// 解绑属敏感变更:递增令牌版本,已签发会话全部失效
return o.auth.bumpTokenVersion(ctx, username)
}
// ensureNotLastLogin 事务内校验不变量:仅剩一个身份且密码登录已禁用时拒绝解绑; // ensureNotLastLogin 事务内校验不变量:密码登录已禁用时,解绑该身份后
// 须仍存在可实际登录的免密方式(provider 被禁用的身份不算),否则拒绝;
// 开关读取失败按失败关闭处理(返回错误),不允许失败放行造成自锁。 // 开关读取失败按失败关闭处理(返回错误),不允许失败放行造成自锁。
func ensureNotLastLogin(tx *gorm.DB, userID uint) error { func ensureNotLastLogin(tx *gorm.DB, userID, identityID uint, origin string) error {
n, err := identityCountTx(tx, userID)
if err != nil {
return err
}
if n > 1 {
return nil
}
off, err := settingValueTx(tx, settingSecPasswordLoginOff) off, err := settingValueTx(tx, settingSecPasswordLoginOff)
if err != nil || off != "1" {
return err
}
ok, err := usablePasswordlessTx(tx, userID, identityID, 0, origin)
if err != nil { if err != nil {
return err return err
} }
if off == "1" { if !ok {
return ErrLastIdentity return ErrLastIdentity
} }
return nil return nil
+178 -17
View File
@@ -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 密文落库。
@@ -103,18 +105,83 @@ func flagPtr(p *bool) *string {
return &v return &v
} }
// UpdateOAuth 部分更新 provider 配置:只落库非 nil 字段; // UpdateOAuth 部分更新 provider 配置:只落库非 nil 字段;issuer 规范化去尾斜杠,
// issuer 规范化去尾斜杠,secret 加密落库(空串清除)。 // secret 加密落库(空串清除)。预检与写入在同一事务并持有认证变更共用的用户行锁:
// 密码登录禁用期间,禁止把最后可实际登录的方式禁用或清空(防自锁),
// 且不与「禁用密码 / 删除最后因子」并发交错。
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error { 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 { writes := []struct {
key string key string
val *string val *string
}{ }{
{settingOauthOidcIssuer, issuerPtr(in.OidcIssuer)}, {settingOauthOidcIssuer, issuerPtr(in.OidcIssuer)},
{settingOauthOidcClientID, trimPtr(in.OidcClientID)}, {settingOauthOidcClientID, trimPtr(in.OidcClientID)},
{settingOauthOidcClientSecret, secrets[settingOauthOidcClientSecret]},
{settingOauthOidcDisplayName, trimPtr(in.OidcDisplayName)}, {settingOauthOidcDisplayName, trimPtr(in.OidcDisplayName)},
{settingOauthOidcDisabled, flagPtr(in.OidcDisabled)}, {settingOauthOidcDisabled, flagPtr(in.OidcDisabled)},
{settingOauthGithubClientID, trimPtr(in.GithubClientID)}, {settingOauthGithubClientID, trimPtr(in.GithubClientID)},
{settingOauthGithubClientSecret, secrets[settingOauthGithubClientSecret]},
{settingOauthGithubDisplayName, trimPtr(in.GithubDisplayName)}, {settingOauthGithubDisplayName, trimPtr(in.GithubDisplayName)},
{settingOauthGithubDisabled, flagPtr(in.GithubDisabled)}, {settingOauthGithubDisabled, flagPtr(in.GithubDisabled)},
} }
@@ -122,29 +189,123 @@ func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) e
if w.val == nil { if w.val == nil {
continue continue
} }
if err := s.set(ctx, w.key, *w.val); err != nil { 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 err
}
return s.saveOAuthSecret(ctx, settingOauthGithubClientSecret, in.GithubClientSecret)
}
// saveOAuthSecret 加密保存 secret;nil 沿用,空串清除。
func (s *SettingService) saveOAuthSecret(ctx context.Context, key string, secret *string) error {
if secret == nil {
return nil return nil
} }
if *secret == "" {
return s.set(ctx, key, "") // ensureLoginRemainsTx 事务内校验补丁生效后仍有可实际登录的方式:密码可登直接放行;
// 否则须有「可登录且已绑定身份」的 provider、任一通行密钥或钱包身份
// (单管理员面板,不区分账号统计)。开关读取失败按失败关闭处理。
func ensureLoginRemainsTx(tx *gorm.DB, in UpdateOAuthInput, origin string) error {
off, err := settingValueTx(tx, settingSecPasswordLoginOff)
if err != nil || off != "1" {
return err
} }
enc, err := s.cipher.EncryptString(*secret) if origin == "" {
return ErrProviderLastLogin
}
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 为空。
+448
View File
@@ -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
}
+298
View File
@@ -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)
}
}
+131 -9
View File
@@ -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)。
@@ -147,17 +150,49 @@ func (p SecurityPatch) merge(dst *SecuritySettings) map[string]bool {
return touched return touched
} }
// UpdateSecurity 把补丁合并到现值上整体校验,只落库出现的字段,再重读刷新快照。 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 { func (s *SettingService) UpdateSecurity(ctx context.Context, p SecurityPatch) error {
cur, err := s.Security(ctx) 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 { if err != nil {
return err return err
} }
touched := p.merge(&cur) err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := validateSecurity(&cur); err != nil { return s.applySecurityUpdateTx(tx, up, check)
})
if err != nil {
return err return err
} }
kv := map[string]string{ return s.ReloadSecurity(context.WithoutCancel(ctx))
}
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), settingSecLoginFailLimit: strconv.Itoa(cur.LoginFailLimit),
settingSecLoginLockMin: strconv.Itoa(cur.LoginLockMinutes), settingSecLoginLockMin: strconv.Itoa(cur.LoginLockMinutes),
settingSecIPRateRPS: strconv.Itoa(cur.IPRateRPS), settingSecIPRateRPS: strconv.Itoa(cur.IPRateRPS),
@@ -165,13 +200,100 @@ func (s *SettingService) UpdateSecurity(ctx context.Context, p SecurityPatch) er
settingSecRealIPHeader: cur.RealIPHeader, settingSecRealIPHeader: cur.RealIPHeader,
settingSecAppURL: cur.AppURL, settingSecAppURL: cur.AppURL,
} }
for key := range touched { return securityUpdate{next: cur, touched: touched, values: values}, nil
if err := s.set(ctx, key, kv[key]); err != 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 return err
} }
} }
// 重读而非直接 Store 合并值:并发补丁各写各键,重读保证快照收敛到库内最终值 if err := s.checkAppURLUpdateTx(tx, up); err != nil {
return s.ReloadSecurity(ctx) return err
}
for key := range up.touched {
if err := saveSettingTx(tx, key, up.values[key]); err != nil {
return err
}
}
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 从库加载安全设置到内存快照;进程启动时调用一次。
+433
View File
@@ -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)
}
}
+525
View File
@@ -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)
}
}
+1 -1
View File
@@ -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")
+64 -26
View File
@@ -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,
) (string, time.Time, error) {
confirm := func(user *model.User) error {
if user.TotpSecretEnc != "" && !s.confirmDisable(user, password, code) {
return ErrTotpConfirm
}
return nil
}
return s.changeTotp(ctx, username, "", oldToken, meta, proof, confirm)
}
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 { if err != nil {
return err return err
} }
if user.TotpSecretEnc == "" { if err := s.ensureTokenCurrentTx(tx, user, proof); err != nil {
return nil return err
} }
if !s.confirmDisable(user, password, code) { if confirm != nil {
return ErrTotpConfirm if err := confirm(user); err != nil {
return err
} }
err = s.db.WithContext(ctx).Model(&model.User{}).
Where("username = ?", username).Update("totp_secret_enc", "").Error
if err != nil {
return fmt.Errorf("clear totp secret: %w", err)
} }
// 两步验证形态变更:旧令牌全部失效 return s.persistTotpTx(tx, user, secret, oldToken, meta, &token, &expires)
return s.bumpTokenVersion(ctx, username) })
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)
}
if err := bumpTokenVersionTx(tx, user.Username); err != nil {
return 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)
}
} }
// confirmDisable 校验停用凭证:验证码或密码任一通过即可。 // confirmDisable 校验停用凭证:验证码或密码任一通过即可。
+349 -27
View File
@@ -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 {
@@ -228,6 +521,11 @@ func TestOAuthProvidersListsConfigured(t *testing.T) {
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: strPtr("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,11 +578,19 @@ 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: strPtr("Iv1.test")} in := UpdateOAuthInput{GithubClientID: strPtr("Iv1.test")}
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 {
t.Fatalf("仅 clientID 半配置 Providers = %v, want empty", got)
}
ghSecret := "gh-secret"
in.GithubClientSecret = &ghSecret
if err := o.settings.UpdateOAuth(ctx, in); err != nil {
t.Fatalf("UpdateOAuth: %v", err)
}
got := o.Providers(ctx) got := o.Providers(ctx)
if len(got) != 1 || got[0].Provider != "github" || got[0].DisplayName != "GitHub" { if len(got) != 1 || got[0].Provider != "github" || got[0].DisplayName != "GitHub" {
t.Fatalf("Providers = %+v, want [github/GitHub]", got) t.Fatalf("Providers = %+v, want [github/GitHub]", got)
@@ -297,12 +611,20 @@ 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)
} }
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 != "公司账号" {
+164
View File
@@ -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)
}
+205
View File
@@ -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)
}
})
}
}
+308
View File
@@ -0,0 +1,308 @@
package service
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"time"
"gorm.io/gorm"
"oci-portal/internal/model"
)
// 钱包(SIWE)流程错误;api 层映射为用户可读提示。
var (
// ErrWalletNoAppURL 表示面板地址缺失,EIP-4361 的 domain/URI 无从派生。
ErrWalletNoAppURL = errors.New("面板地址未设置,请先在「设置 → 安全 → 网络与地址」保存面板地址")
// ErrWalletAddress 表示地址格式非法(非 0x+40 hex)。
ErrWalletAddress = errors.New("钱包地址格式不正确")
// ErrWalletChallenge 表示挑战无效或已过期(一次性消费)。
ErrWalletChallenge = errors.New("签名挑战无效或已过期,请重新发起")
// ErrWalletSig 表示签名校验失败或恢复地址不匹配;统一文案防探测。
ErrWalletSig = errors.New("钱包签名校验失败")
// ErrWalletNotBound 表示地址未绑定任何账号,拒绝登录(不开放注册)。
ErrWalletNotBound = errors.New("该钱包地址未绑定面板账号,请先登录后在设置中绑定")
// ErrWalletBound 表示地址已被绑定(重复绑定)。
ErrWalletBound = errors.New("该钱包地址已绑定过")
)
// walletProvider 是钱包身份在 UserIdentity 表中的 provider 取值。
const walletProvider = "wallet"
// walletPendingTTL 是签名挑战有效期,与消息中的 Expiration Time 一致。
const walletPendingTTL = 5 * time.Minute
// walletPending 是一次进行中的签名挑战;nonce 一次性使用。
type walletPending struct {
message string // EIP-4361 消息全文,验签对象
address string // EIP-55 规范地址
mode string // "login" / "bind"
username string // bind 模式的绑定目标账号
token string // bind 模式发起时的 Bearer;verify 复验,防被盗令牌撤销后仍完成绑定
proof TokenProof // 发起时的版本/jti 快照;绑定事务行锁下复核,覆盖撤销全部/注销/定点撤销
expires time.Time
}
// WalletService 承接以太坊钱包(EIP-4361)绑定与登录;身份复用 UserIdentity 通道。
type WalletService struct {
db *gorm.DB
settings *SettingService
auth *AuthService
mu sync.Mutex
pending map[string]walletPending
}
// NewWalletService 组装依赖。
func NewWalletService(db *gorm.DB, settings *SettingService, auth *AuthService) *WalletService {
return &WalletService{db: db, settings: settings, auth: auth, pending: map[string]walletPending{}}
}
// Challenge 生成 EIP-4361 消息全文并登记一次性 nonce;
// 消息由后端持有并作为最终验签对象,前端仅负责原样签名。
func (w *WalletService) Challenge(ctx context.Context, address, mode, username, bindToken string) (nonce, message string, err error) {
app := w.settings.EffectiveAppURL()
if app == "" {
return "", "", ErrWalletNoAppURL
}
addr, err := normalizeEthAddress(address)
if err != nil {
return "", "", err
}
var proof TokenProof
if mode == "bind" {
if proof, err = w.bindTokenProof(ctx, username, bindToken); err != nil {
return "", "", err
}
}
nonce, err = randHex(16)
if err != nil {
return "", "", err
}
message = buildSiweMessage(app, addr, mode, nonce, time.Now())
w.mu.Lock()
w.gcWalletLocked()
w.pending[nonce] = walletPending{message: message, address: addr, mode: mode, username: username, token: bindToken, proof: proof, expires: time.Now().Add(walletPendingTTL)}
w.mu.Unlock()
return nonce, message, nil
}
// bindTokenProof 验证 bind 发起令牌的有效性与归属,返回其版本 / jti 快照;
// 绑定事务行锁下复核该快照,发起后改密、撤销全部、注销或定点撤销均令绑定作废。
func (w *WalletService) bindTokenProof(ctx context.Context, username, token string) (TokenProof, error) {
name, proof, err := w.auth.ParseTokenProof(ctx, token)
if err != nil || name != username {
return TokenProof{}, ErrWalletChallenge
}
return proof, nil
}
// buildSiweMessage 按 EIP-4361 模板拼装消息;Chain ID 固定 1
// (personal_sign 与链无关,字段仅为满足标准的人类可读格式)。
func buildSiweMessage(appURL, address, mode, nonce string, now time.Time) string {
domain := appURL
if u, err := url.Parse(appURL); err == nil && u.Host != "" {
domain = u.Host
}
statement := "登录 OCI Portal 面板"
if mode == "bind" {
statement = "将此钱包绑定到 OCI Portal 账号"
}
return domain + " wants you to sign in with your Ethereum account:\n" +
address + "\n\n" +
statement + "\n\n" +
"URI: " + appURL + "\n" +
"Version: 1\n" +
"Chain ID: 1\n" +
"Nonce: " + nonce + "\n" +
"Issued At: " + now.UTC().Format(time.RFC3339) + "\n" +
"Expiration Time: " + now.Add(walletPendingTTL).UTC().Format(time.RFC3339)
}
// gcWalletLocked 清理过期挑战;调用方须持锁。
func (w *WalletService) gcWalletLocked() {
now := time.Now()
for k, p := range w.pending {
if now.After(p.expires) {
delete(w.pending, k)
}
}
}
// takeChallenge 取出并消费挑战(一次性);不存在或过期视为无效。
func (w *WalletService) takeChallenge(nonce string) (walletPending, error) {
w.mu.Lock()
defer w.mu.Unlock()
p, ok := w.pending[nonce]
delete(w.pending, nonce)
if !ok || time.Now().After(p.expires) {
return walletPending{}, ErrWalletChallenge
}
return p, nil
}
// WalletVerifyResult 是校验成功后的会话信息;Mode 在失败时也尽力携带,
// 供 api 层留痕与决定响应形态。
type WalletVerifyResult struct {
Token string
ExpiresAt time.Time
Username string
Mode string
}
// Verify 校验签名并完成绑定或登录(落地会话);登录失败按「IP+地址」计入登录守卫,
// 锁定期内一律 ErrLoginLocked。
func (w *WalletService) Verify(ctx context.Context, nonce, signature string, meta SessionMeta) (WalletVerifyResult, error) {
p, err := w.takeChallenge(nonce)
if err != nil {
return WalletVerifyResult{}, err
}
res := WalletVerifyResult{Mode: p.mode}
if p.mode == "login" {
meta.Method = walletProvider // 绑定不改登录方式,见 OAuth 侧同款注释
}
key := guardKey(meta.ClientIP, strings.ToLower(p.address))
now := time.Now()
sec := securityOf(w.auth.settings)
if p.mode == "login" && w.auth.guard.locked(key, now, time.Duration(sec.LoginLockMinutes)*time.Minute) {
return res, ErrLoginLocked
}
if err := verifyWalletSig(p, signature); err != nil {
if p.mode == "login" {
if lockErr := w.auth.failLogin(key, now, p.address, meta.ClientIP, sec); errors.Is(lockErr, ErrLoginLocked) {
return res, ErrLoginLocked
}
}
return res, err
}
if p.mode == "bind" {
// 复验发起挑战时的令牌仍有效且归属一致:改密/撤销全部后挑战随之作废
if name, tokenErr := w.auth.ParseToken(ctx, p.token); tokenErr != nil || name != p.username {
return res, ErrWalletChallenge
}
res.Username = p.username
res.Token, res.ExpiresAt, err = w.bind(ctx, p, meta)
return res, err
}
res.Token, res.ExpiresAt, res.Username, err = w.login(ctx, p, key, meta)
return res, err
}
// verifyWalletSig 验签并比对恢复地址(大小写不敏感);失败统一 ErrWalletSig。
func verifyWalletSig(p walletPending, signature string) error {
raw, err := hex.DecodeString(strings.TrimPrefix(signature, "0x"))
if err != nil {
return ErrWalletSig
}
recovered, err := recoverEthAddress(personalSignDigest([]byte(p.message)), raw)
if err != nil {
return ErrWalletSig
}
if !strings.EqualFold(recovered, p.address) {
return ErrWalletSig
}
return nil
}
// bind 在单事务内完成绑定全程:行锁下比对发起时令牌版本(发起后被撤销即作废)、
// 身份写入、版本递增、原会话行接续换发;任一失败整体回滚,不留半程状态。
// 新令牌接续 p.token 的会话行(保留登录方式与创建时间),旧令牌无行时按 meta 新建。
func (w *WalletService) bind(ctx context.Context, p walletPending, meta SessionMeta) (string, time.Time, error) {
var token string
var expires time.Time
err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
user, err := lockUserForAuthChange(tx, p.username)
if err != nil {
return err
}
if err := w.auth.ensureTokenCurrentTx(tx, user, p.proof); err != nil {
return ErrWalletChallenge
}
if err := createIdentityTx(tx, user.ID, walletProvider, p.address, shortEthAddress(p.address), ErrWalletBound); err != nil {
return err
}
if err := bumpTokenVersionTx(tx, p.username); err != nil {
return err
}
user.TokenVersion++
token, expires, err = w.auth.renewSessionTx(tx, user, p.token, meta)
return err
})
return token, expires, err
}
// login 查绑定关系并签发 JWT(落地会话);未绑定一律拒绝(不开放注册)。
func (w *WalletService) login(ctx context.Context, p walletPending, key string, meta SessionMeta) (string, time.Time, string, error) {
row, err := w.findWalletIdentity(ctx, p.address)
if err != nil {
return "", time.Time{}, "", err
}
token, expires, username, err := w.loginWalletIdentity(ctx, row, p.address, meta)
if err == nil {
w.auth.guard.success(key)
}
return token, expires, username, err
}
func (w *WalletService) loginWalletIdentity(
ctx context.Context, row *model.UserIdentity, address string, meta SessionMeta,
) (string, time.Time, string, error) {
var token, username string
var expires time.Time
err := w.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, walletProvider, address); err != nil {
if errors.Is(err, ErrOAuthNotBound) {
return ErrWalletNotBound
}
return err
}
token, expires, err = w.auth.signSessionTokenTx(tx, user, meta)
username = user.Username
return err
})
return token, expires, username, err
}
func (w *WalletService) findWalletIdentity(ctx context.Context, address string) (*model.UserIdentity, error) {
var row model.UserIdentity
err := w.db.WithContext(ctx).
Where("provider = ? AND subject = ?", walletProvider, address).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrWalletNotBound
}
if err != nil {
return nil, fmt.Errorf("find wallet identity: %w", err)
}
return &row, nil
}
// HasAny 报告是否存在可登录的钱包身份;SIWE 依赖面板地址,缺失时不暴露入口。
func (w *WalletService) HasAny(ctx context.Context) bool {
if w.settings == nil || w.settings.EffectiveAppURL() == "" {
return false
}
var count int64
err := w.db.WithContext(ctx).Model(&model.UserIdentity{}).
Where("provider = ?", walletProvider).Count(&count).Error
if err != nil {
return false
}
return count > 0
}
// shortEthAddress 生成列表展示用的缩写地址(0x1234…abcd)。
func shortEthAddress(addr string) string {
if len(addr) < 12 {
return addr
}
return addr[:6] + "…" + addr[len(addr)-4:]
}
+381
View File
@@ -0,0 +1,381 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"oci-portal/internal/crypto"
"oci-portal/internal/model"
)
// newTestWallet 组装内存库上的 WalletService(admin 已建,面板地址经环境回退注入)。
func newTestWallet(t *testing.T, appURL string) *WalletService {
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.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 NewWalletService(db, settings, auth)
}
func TestWalletChallenge(t *testing.T) {
priv := testPrivKey(9)
addr := ethAddrOf(priv)
tests := []struct {
name string
appURL string
address string
wantErr error
}{
{name: "正常下发", appURL: "https://demo.example.com", address: strings.ToLower(addr)},
{name: "无面板地址", appURL: "", address: addr, wantErr: ErrWalletNoAppURL},
{name: "地址非法", appURL: "https://demo.example.com", address: "0x12", wantErr: ErrWalletAddress},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := newTestWallet(t, tt.appURL)
nonce, message, err := w.Challenge(context.Background(), tt.address, "login", "", "")
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Challenge err = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("Challenge: %v", err)
}
// 消息含 EIP-55 规范地址、nonce 与 domain;地址大小写已被规范化
for _, part := range []string{addr, "Nonce: " + nonce, "demo.example.com wants you"} {
if !strings.Contains(message, part) {
t.Errorf("message missing %q:\n%s", part, message)
}
}
})
}
}
// adminToken 以口令登录换取 admin 的有效会话令牌(bind 挑战的复验凭据)。
func adminToken(t *testing.T, w *WalletService) string {
t.Helper()
token, _, err := w.auth.Login(context.Background(), "admin", "pass123", "", SessionMeta{ClientIP: "10.0.0.1"})
if err != nil {
t.Fatalf("admin login: %v", err)
}
return token
}
// bindWallet 走完整 bind 流程(挑战 → 签名 → 校验),返回校验结果。
func bindWallet(t *testing.T, w *WalletService, seed byte) WalletVerifyResult {
t.Helper()
priv := testPrivKey(seed)
nonce, message, err := w.Challenge(context.Background(), ethAddrOf(priv), "bind", "admin", adminToken(t, w))
if err != nil {
t.Fatalf("Challenge(bind): %v", err)
}
res, err := w.Verify(context.Background(), nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.1.0.1"})
if err != nil {
t.Fatalf("Verify(bind): %v", err)
}
return res
}
func TestWalletBindAndLogin(t *testing.T) {
w := newTestWallet(t, "https://demo.example.com")
ctx := context.Background()
priv := testPrivKey(9)
addr := ethAddrOf(priv)
res := bindWallet(t, w, 9)
if res.Mode != "bind" || res.Token == "" || res.Username != "admin" {
t.Fatalf("bind result = %+v, want bind/admin with token", res)
}
var row model.UserIdentity
if err := w.db.Where("provider = ? AND subject = ?", walletProvider, addr).First(&row).Error; err != nil {
t.Fatalf("identity row: %v", err)
}
if row.Display != shortEthAddress(addr) {
t.Errorf("display = %q, want %q", row.Display, shortEthAddress(addr))
}
// 钱包身份计入外部身份数(禁用密码登录的门槛)
if n, err := identityCountTx(w.db, row.UserID); err != nil || n != 1 {
t.Errorf("identityCountTx = (%d, %v), want 1", n, err)
}
if !w.HasAny(ctx) {
t.Error("HasAny = false, want true after bind")
}
// 重复绑定同一地址被拒
nonce, message, err := w.Challenge(ctx, addr, "bind", "admin", adminToken(t, w))
if err != nil {
t.Fatalf("Challenge(rebind): %v", err)
}
if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.1.0.1"}); !errors.Is(err, ErrWalletBound) {
t.Fatalf("rebind err = %v, want ErrWalletBound", err)
}
// 登录:全大写地址输入被规范化,v=0/1 形态签名同样有效
nonce, message, err = w.Challenge(ctx, "0x"+strings.ToUpper(addr[2:]), "login", "", "")
if err != nil {
t.Fatalf("Challenge(login): %v", err)
}
got, err := w.Verify(ctx, nonce, signPersonal(priv, message, false), SessionMeta{ClientIP: "10.1.0.2"})
if err != nil {
t.Fatalf("Verify(login): %v", err)
}
if got.Mode != "login" || got.Username != "admin" || got.Token == "" || got.ExpiresAt.IsZero() {
t.Fatalf("login result = %+v, want login/admin with token+expiry", got)
}
if _, err := w.auth.ParseToken(ctx, got.Token); err != nil {
t.Errorf("issued token invalid: %v", err)
}
}
func TestWalletVerifyRejections(t *testing.T) {
w := newTestWallet(t, "https://demo.example.com")
ctx := context.Background()
bound := testPrivKey(9)
bindWallet(t, w, 9)
t.Run("nonce 一次性", func(t *testing.T) {
nonce, message, _ := w.Challenge(ctx, ethAddrOf(bound), "login", "", "")
sig := signPersonal(bound, message, true)
if _, err := w.Verify(ctx, nonce, sig, SessionMeta{ClientIP: "10.2.0.1"}); err != nil {
t.Fatalf("first verify: %v", err)
}
if _, err := w.Verify(ctx, nonce, sig, SessionMeta{ClientIP: "10.2.0.1"}); !errors.Is(err, ErrWalletChallenge) {
t.Errorf("replayed nonce err = %v, want ErrWalletChallenge", err)
}
})
t.Run("过期挑战", func(t *testing.T) {
nonce, message, _ := w.Challenge(ctx, ethAddrOf(bound), "login", "", "")
w.mu.Lock()
p := w.pending[nonce]
p.expires = time.Now().Add(-time.Second)
w.pending[nonce] = p
w.mu.Unlock()
if _, err := w.Verify(ctx, nonce, signPersonal(bound, message, true), SessionMeta{ClientIP: "10.2.0.2"}); !errors.Is(err, ErrWalletChallenge) {
t.Errorf("expired nonce err = %v, want ErrWalletChallenge", err)
}
})
t.Run("他人签名(地址不匹配)", func(t *testing.T) {
nonce, message, _ := w.Challenge(ctx, ethAddrOf(bound), "login", "", "")
other := testPrivKey(13)
if _, err := w.Verify(ctx, nonce, signPersonal(other, message, true), SessionMeta{ClientIP: "10.2.0.3"}); !errors.Is(err, ErrWalletSig) {
t.Errorf("foreign signature err = %v, want ErrWalletSig", err)
}
})
t.Run("未绑定地址拒登", func(t *testing.T) {
stranger := testPrivKey(21)
nonce, message, _ := w.Challenge(ctx, ethAddrOf(stranger), "login", "", "")
if _, err := w.Verify(ctx, nonce, signPersonal(stranger, message, true), SessionMeta{ClientIP: "10.2.0.4"}); !errors.Is(err, ErrWalletNotBound) {
t.Errorf("unbound address err = %v, want ErrWalletNotBound", err)
}
})
}
func TestWalletBindStaleToken(t *testing.T) {
w := newTestWallet(t, "https://demo.example.com")
ctx := context.Background()
priv := testPrivKey(9)
// 挑战发起后全量撤销(TokenVersion 自增),verify 复验须拒绝绑定
nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", adminToken(t, w))
if err != nil {
t.Fatalf("Challenge(bind): %v", err)
}
if err := w.auth.bumpTokenVersion(ctx, "admin"); err != nil {
t.Fatalf("bump token version: %v", err)
}
if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.4.0.1"}); !errors.Is(err, ErrWalletChallenge) {
t.Fatalf("stale-token bind err = %v, want ErrWalletChallenge", err)
}
if w.HasAny(ctx) {
t.Error("HasAny = true, want false: stale-token bind must not persist identity")
}
}
// TestWalletBindStaleVersion 验证事务内版本比对:令牌复验通过但发起后版本
// 已变(极窄竞态窗口)的绑定同样被拒,身份不落库。
func TestWalletBindStaleVersion(t *testing.T) {
w := newTestWallet(t, "https://demo.example.com")
ctx := context.Background()
priv := testPrivKey(9)
nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", adminToken(t, w))
if err != nil {
t.Fatalf("Challenge(bind): %v", err)
}
w.mu.Lock()
p := w.pending[nonce]
p.proof.Ver--
w.pending[nonce] = p
w.mu.Unlock()
if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.4.0.2"}); !errors.Is(err, ErrWalletChallenge) {
t.Fatalf("stale-version bind err = %v, want ErrWalletChallenge", err)
}
if w.HasAny(ctx) {
t.Error("HasAny = true, want false: stale-version bind must not persist identity")
}
}
// TestWalletBindKeepsSession 验证绑定接续当前会话:原行同行更新,
// 保留登录方式、行 ID 与 jti,且新令牌有效。
func TestWalletBindKeepsSession(t *testing.T) {
w := newTestWallet(t, "https://demo.example.com")
ctx := context.Background()
priv := testPrivKey(9)
token := adminToken(t, w)
before := sessionRows(t, w.auth)
if len(before) != 1 || before[0].Method != "password" {
t.Fatalf("seed session = %+v, want single password row", before)
}
nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", token)
if err != nil {
t.Fatalf("Challenge(bind): %v", err)
}
res, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.1.0.1", UserAgent: "UA"})
if err != nil {
t.Fatalf("Verify(bind): %v", err)
}
after := sessionRows(t, w.auth)
if len(after) != 1 || after[0].ID != before[0].ID {
t.Fatalf("sessions after bind = %+v, want same single row (continuity)", after)
}
if after[0].Method != "password" || after[0].TokenID != before[0].TokenID {
t.Errorf("renewed row = %+v, want method and jti kept", after[0])
}
if _, err := w.auth.ParseToken(ctx, res.Token); err != nil {
t.Errorf("issued token invalid: %v", err)
}
}
// TestWalletBindRevokedSession 验证绑定事务的 jti 复核:挑战发起后该设备
// 会话被定点撤销(版本未变),验签通过的绑定仍须拒绝且身份不落库。
func TestWalletBindRevokedSession(t *testing.T) {
w := newTestWallet(t, "https://demo.example.com")
ctx := context.Background()
priv := testPrivKey(9)
token := adminToken(t, w)
nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", token)
if err != nil {
t.Fatalf("Challenge(bind): %v", err)
}
// 模拟入口复验之后、事务之前的撤销:直接标记该 jti 的会话行 revoked
// (入口 ParseToken 走缓存路径时可能尚未察觉,事务内查库是最终防线)
w.auth.revokeSessionByJTI(ctx, "admin", w.auth.signedJti(token), tokenTTL)
if _, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.5.0.1"}); err == nil {
t.Fatal("bind after session revoke succeeded, want rejection")
}
if w.HasAny(ctx) {
t.Error("HasAny = true, want false: revoked-session bind must not persist identity")
}
}
// TestWalletBindNoRowMethodEmpty 验证旧版无会话行令牌绑定时,
// 新建会话行的登录方式为空(该令牌并非经钱包登录,与活跃会话设计一致)。
func TestWalletBindNoRowMethodEmpty(t *testing.T) {
w := newTestWallet(t, "https://demo.example.com")
ctx := context.Background()
priv := testPrivKey(9)
token := adminToken(t, w)
// 删除会话行,模拟升级前签发的存量令牌(无行)
if err := w.db.Where("1 = 1").Delete(&model.UserSession{}).Error; err != nil {
t.Fatalf("clear sessions: %v", err)
}
nonce, message, err := w.Challenge(ctx, ethAddrOf(priv), "bind", "admin", token)
if err != nil {
t.Fatalf("Challenge(bind): %v", err)
}
res, err := w.Verify(ctx, nonce, signPersonal(priv, message, true), SessionMeta{ClientIP: "10.5.0.2", UserAgent: "UA"})
if err != nil {
t.Fatalf("Verify(bind): %v", err)
}
rows := sessionRows(t, w.auth)
if len(rows) != 1 || rows[0].Method != "" {
t.Fatalf("rows = %+v, want single row with empty method", rows)
}
if _, err := w.auth.ParseToken(ctx, res.Token); err != nil {
t.Errorf("issued token invalid: %v", err)
}
}
func TestWalletLoginRechecksIdentity(t *testing.T) {
w := newTestWallet(t, "https://demo.example.com")
bindWallet(t, w, 9)
var row model.UserIdentity
if err := w.db.Where("provider = ?", walletProvider).First(&row).Error; err != nil {
t.Fatalf("find identity: %v", err)
}
if err := w.db.Delete(&row).Error; err != nil {
t.Fatalf("delete identity: %v", err)
}
_, _, _, err := w.loginWalletIdentity(
context.Background(), &row, row.Subject, SessionMeta{ClientIP: "10.6.0.1"})
if !errors.Is(err, ErrWalletNotBound) {
t.Fatalf("login err = %v, want ErrWalletNotBound", err)
}
}
func TestWalletHasAnyRequiresAppURL(t *testing.T) {
w := newTestWallet(t, "https://demo.example.com")
row := model.UserIdentity{UserID: 1, Provider: walletProvider, Subject: "0xabc"}
if err := w.db.Create(&row).Error; err != nil {
t.Fatalf("seed identity: %v", err)
}
if !w.HasAny(context.Background()) {
t.Fatal("HasAny = false, want true with app url")
}
w.settings.SetEnvPublicURL("")
if w.HasAny(context.Background()) {
t.Fatal("HasAny = true without app url")
}
}
func TestWalletLoginGuardLock(t *testing.T) {
w := newTestWallet(t, "https://demo.example.com")
ctx := context.Background()
priv := testPrivKey(9)
addr := ethAddrOf(priv)
other := testPrivKey(13)
var lastErr error
for i := 0; i < securityDefaults.LoginFailLimit+1; i++ {
nonce, message, err := w.Challenge(ctx, addr, "login", "", "")
if err != nil {
t.Fatalf("Challenge #%d: %v", i, err)
}
_, lastErr = w.Verify(ctx, nonce, signPersonal(other, message, true), SessionMeta{ClientIP: "10.3.0.1"})
}
if !errors.Is(lastErr, ErrLoginLocked) {
t.Fatalf("after %d bad signatures err = %v, want ErrLoginLocked", securityDefaults.LoginFailLimit+1, lastErr)
}
// 锁定针对「IP+地址」:另一 IP 不连坐
nonce, message, _ := w.Challenge(ctx, addr, "login", "", "")
if _, err := w.Verify(ctx, nonce, signPersonal(other, message, true), SessionMeta{ClientIP: "10.3.0.2"}); errors.Is(err, ErrLoginLocked) {
t.Errorf("different IP got locked prematurely: %v", err)
}
}