2 Commits
Author SHA1 Message Date
wangdefa 7d5ad50621 发布 v0.8.4
CI / test (push) Successful in 1m8s
Release / release (push) Successful in 1m8s
2026-08-11 11:53:52 +08:00
wangdefa e63af49831 滑动续期、网络错误归类、API Key 激活乐观锁与生效提示 2026-08-11 11:45:03 +08:00
25 changed files with 702 additions and 24 deletions
+67 -3
View File
@@ -17,7 +17,71 @@
`bumpTokenVersionTx`(或 `RevokeSessions`)使旧 JWT 失效,并按第 6 节携带 `bumpTokenVersionTx`(或 `RevokeSessions`)使旧 JWT 失效,并按第 6 节携带
`TokenProof`、接续操作者会话。因子写入与版本递增不得拆成两个独立提交。 `TokenProof`、接续操作者会话。因子写入与版本递增不得拆成两个独立提交。
## 4. 「至少一种登录方式」不变量 滑动续期(`MaybeRenew`)是唯一的非敏感换发:剩余有效期 < `renewThreshold`
且请求方法为 GET/HEAD 时,经 `X-Renewed-Token` / `X-Renewed-Expires-At`
响应头换发**同 jti、同版本**新令牌并延长会话行。写请求不得预生成续期头:
handler 可能撤销会话或递增版本,导致响应到达时头中令牌已经失效。续期不 bump
版本、不新建会话行;新增登录方式不得绕过此约定自行签发长时令牌。
## 4. 场景:滑动续期响应头
### 1. Scope / Trigger
已鉴权 GET/HEAD 请求携带的 JWT 剩余有效期低于 `renewThreshold` 时触发。
### 2. Signatures
```go
func (s *AuthService) MaybeRenew(
ctx context.Context, token string,
) (renewed string, expires time.Time, ok bool)
```
成功时响应携带 `X-Renewed-Token` 与 RFC3339 格式的
`X-Renewed-Expires-At`;客户端只在 HTTP 2xx 时应用。
### 3. Contracts
- 新 JWT 沿用原 `jti + Ver`,基础 TTL 仍为 24h。
- 写响应不携带滑动续期头;敏感写接口以响应体中的新会话为准。
- `user_sessions.expires_at` 更新成功后才能返回 `ok=true`;数据库失败不得发头。
- 无会话行的兼容期旧 JWT 可续期,但不得新建会话行。
### 4. Validation & Error Matrix
| 条件 | 结果 |
| --- | --- |
| GET/HEAD 且剩余 <12h、持久化成功 | 返回双响应头 |
| GET/HEAD 且剩余 ≥12h | 无续期头 |
| POST/PUT/PATCH/DELETE | 无续期头 |
| JWT 解析或会话延期失败 | `ok=false`,无续期头 |
| 非 2xx 响应意外携带续期头 | 客户端忽略,不写 Store |
### 5. Good/Base/Bad Cases
- Good:列表 GET 续期后,会话行过期时间与新 JWT 对齐。
- Base:普通写请求由原 JWT 完成,不做滑动续期。
- Bad:在 `c.Next()` 前为写请求签头,handler 随后 bump 版本或 Logout。
### 6. Tests Required
- 路由测试断言临期 GET 返回头、临期敏感 POST 不返回头。
- 服务测试先把会话行缩短,再断言续期确实延长该行。
- 注入数据库更新失败,断言 Token/expiry 为空且 `ok=false`。
### 7. Wrong vs Correct
```go
// Wrong:所有鉴权请求都预生成续期头
maybeRenewToken(c, auth, token)
// Correct:只读请求才允许滑动续期
if allowsSessionRenewal(c.Request.Method) {
maybeRenewToken(c, auth, token)
}
```
## 5. 「至少一种登录方式」不变量
密码登录禁用要求至少一种在当前 `EffectiveAppURL` 下**可实际登录**的免密方式 密码登录禁用要求至少一种在当前 `EffectiveAppURL` 下**可实际登录**的免密方式
(`usablePasswordlessTx`);地址为空时 Passkey、钱包和 OAuth 均不可用。禁用期间移除 (`usablePasswordlessTx`);地址为空时 Passkey、钱包和 OAuth 均不可用。禁用期间移除
@@ -28,11 +92,11 @@
- **复用 `UserIdentity` 表**(如钱包,provider+subject 唯一)→ 走 `identityCountTx` 自动参与,零改动; - **复用 `UserIdentity` 表**(如钱包,provider+subject 唯一)→ 走 `identityCountTx` 自动参与,零改动;
- **独立表**(如 Passkey 的 `UserPasskey`)→ 参照 `passkeyCountTx` 增加计数入口,并在开启门槛(`hasAnyPasswordless`)与两个 `ensureNot*` 检查里对称接入;api 层把 `ErrLastIdentity` 映射 409。 - **独立表**(如 Passkey 的 `UserPasskey`)→ 参照 `passkeyCountTx` 增加计数入口,并在开启门槛(`hasAnyPasswordless`)与两个 `ensureNot*` 检查里对称接入;api 层把 `ErrLastIdentity` 映射 409。
## 5. 挑战/状态的一次性消费 ## 6. 挑战/状态的一次性消费
挑战-响应类流程(OAuth state、WebAuthn challenge、SIWE nonce)一律进程内 `map + mutex + TTL + gc`,取用即删(参考 `oauthPending` / `passkeyPending` / `walletPending`);不落库、不引 Redis。TTL 5-10 分钟。 挑战-响应类流程(OAuth state、WebAuthn challenge、SIWE nonce)一律进程内 `map + mutex + TTL + gc`,取用即删(参考 `oauthPending` / `passkeyPending` / `walletPending`);不落库、不引 Redis。TTL 5-10 分钟。
## 6. 场景:已鉴权敏感事务的提交屏障 ## 7. 场景:已鉴权敏感事务的提交屏障
### 1. Scope / Trigger ### 1. Scope / Trigger
+10
View File
@@ -42,6 +42,16 @@ func sanitizeURLError(err error) error {
**约定**:识别特定语义一律走 `internal/oci/errors.go` 的判定函数(`IsModelUnavailable` / `IsEntityNotFound` / `IsOnDemandUnsupported`),用 `errors.As` 取 `common.ServiceError` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。 **约定**:识别特定语义一律走 `internal/oci/errors.go` 的判定函数(`IsModelUnavailable` / `IsEntityNotFound` / `IsOnDemandUnsupported`),用 `errors.As` 取 `common.ServiceError` 后按 状态码+消息片段 匹配;调用方(探测/网关路由)据此决定「定论、换候选、换渠道、是否计熔断」,不得在业务层散落字符串匹配。
连接层失败(拿不到 OCI 响应)与服务错误分开归类:`UpstreamNetworkError`
识别代理拨号 / SOCKS 握手 / 超时等网络类错误,api 层 `respondError` 据此回
502(`code: UpstreamNetwork` + 中文 hint),不落 500「服务器内部错误」;
url.Error 的 URL 一律压缩为主机名(防 userinfo / 长查询串外流,呼应上文
敏感信息约定)。脱敏必须覆盖嵌套 `url.Error` 及普通拨号错误文本中的
`userinfo@host`;`[NET]` 日志记录与 502 响应相同的脱敏详情,不得再打印原始
错误链。代理配置的独立 Host 字段拒绝 `@`,用户名/密码只走专用字段。
新增网络类特征时扩这个判定函数,不要在 handler 里散判。测试同时断言响应体
与捕获日志均不含用户名、密码,并保留一条存量非法 Host 的普通错误文本用例。
## 外部响应体必须限长,超限报错而非静默截断 ## 外部响应体必须限长,超限报错而非静默截断
读上游/外部响应体一律 `io.LimitReader(max+1)` 再判长度:恰好读到 max+1 说明超限,返回带上限值的错误;不得截断后当成功继续(截断的 JSON/流式响应会以 200 返回坏数据,2026-07-22 审查 #13,genai_responses.go `readCompatBody`)。 读上游/外部响应体一律 `io.LimitReader(max+1)` 再判长度:恰好读到 max+1 说明超限,返回带上限值的错误;不得截断后当成功继续(截断的 JSON/流式响应会以 200 返回坏数据,2026-07-22 审查 #13,genai_responses.go `readCompatBody`)。
+14
View File
@@ -2,6 +2,20 @@
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
## [0.8.4]
### Added
- 登录令牌滑动续期:剩余有效期不足 12 小时的 GET/HEAD 鉴权请求经响应头换发同会话新令牌并延长会话行;登出、撤销会话与凭据变更对新旧令牌同时生效
- 租户配置记录并透出 API Key 激活时间(keyActivatedAt),配合前端传播窗口提示;NotAuthenticated 错误附加「密钥无效或尚未生效」中文提示
### Changed
- 代理与网络类连接失败不再落 500「服务器内部错误」,改以 502 归类透出(code=UpstreamNetwork)并附中文提示;错误文案与服务端日志统一脱敏,不含代理凭据
- API Key 激活落库增加乐观锁:测活期间租户被删除或凭据被并发修改时报错拒绝,不再覆盖较新数据
- 代理主机字段拒绝携带用户凭据(@),用户名密码仅走专用字段
- 升级 OCI Go SDK 至 v65.123.0
## [0.8.3] ## [0.8.3]
### Added ### Added
+1 -1
View File
@@ -1 +1 @@
v0.8.3 v0.8.4
+2 -2
View File
@@ -31,8 +31,8 @@ import (
) )
// @title OCI Portal API // @title OCI Portal API
// @version 0.8.3 // @version 0.8.4
// @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)。令牌滑动续期:剩余有效期不足 12h 时,GET/HEAD 鉴权响应附带 X-Renewed-Token / X-Renewed-Expires-At 头,客户端仅在 2xx 响应以其替换本地会话。
// @BasePath / // @BasePath /
// @securityDefinitions.apikey BearerAuth // @securityDefinitions.apikey BearerAuth
// @in header // @in header
+6 -2
View File
@@ -10533,6 +10533,10 @@ const docTemplate = `{
"id": { "id": {
"type": "integer" "type": "integer"
}, },
"keyActivatedAt": {
"description": "KeyActivatedAt 是最近一次启用新签名 key(面板轮换激活或手工替换私钥)\n的时刻;OCI 公钥全球传播为分钟级,前端据此在窗口期内提示,nil 表示未记录",
"type": "string"
},
"lastError": { "lastError": {
"type": "string" "type": "string"
}, },
@@ -12723,12 +12727,12 @@ 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.3", Version: "0.8.4",
Host: "", Host: "",
BasePath: "/", BasePath: "/",
Schemes: []string{}, Schemes: []string{},
Title: "OCI Portal API", Title: "OCI Portal API",
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)。令牌滑动续期:剩余有效期不足 12h 时,GET/HEAD 鉴权响应附带 X-Renewed-Token / X-Renewed-Expires-At 头,客户端仅在 2xx 响应以其替换本地会话。",
InfoInstanceName: "swagger", InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate, SwaggerTemplate: docTemplate,
LeftDelim: "{{", LeftDelim: "{{",
+7 -3
View File
@@ -1,10 +1,10 @@
{ {
"swagger": "2.0", "swagger": "2.0",
"info": { "info": {
"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)。令牌滑动续期:剩余有效期不足 12h 时,GET/HEAD 鉴权响应附带 X-Renewed-Token / X-Renewed-Expires-At 头,客户端仅在 2xx 响应以其替换本地会话。",
"title": "OCI Portal API", "title": "OCI Portal API",
"contact": {}, "contact": {},
"version": "0.8.3" "version": "0.8.4"
}, },
"basePath": "/", "basePath": "/",
"paths": { "paths": {
@@ -10526,6 +10526,10 @@
"id": { "id": {
"type": "integer" "type": "integer"
}, },
"keyActivatedAt": {
"description": "KeyActivatedAt 是最近一次启用新签名 key(面板轮换激活或手工替换私钥)\n的时刻;OCI 公钥全球传播为分钟级,前端据此在窗口期内提示,nil 表示未记录",
"type": "string"
},
"lastError": { "lastError": {
"type": "string" "type": "string"
}, },
@@ -12712,4 +12716,4 @@
"in": "header" "in": "header"
} }
} }
} }
+8 -2
View File
@@ -1700,6 +1700,11 @@ definitions:
type: string type: string
id: id:
type: integer type: integer
keyActivatedAt:
description: |-
KeyActivatedAt 是最近一次启用新签名 key(面板轮换激活或手工替换私钥)
的时刻;OCI 公钥全球传播为分钟级,前端据此在窗口期内提示,nil 表示未记录
type: string
lastError: lastError:
type: string type: string
lastVerifiedAt: lastVerifiedAt:
@@ -3144,9 +3149,10 @@ definitions:
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)。令牌滑动续期:剩余有效期不足 12h 时,GET/HEAD 鉴权响应附带 X-Renewed-Token / X-Renewed-Expires-At
头,客户端仅在 2xx 响应以其替换本地会话。'
title: OCI Portal API title: OCI Portal API
version: 0.8.3 version: 0.8.4
paths: paths:
/ai/v1/audio/speech: /ai/v1/audio/speech:
post: post:
+1 -1
View File
@@ -10,7 +10,7 @@ require (
github.com/go-webauthn/webauthn v0.17.4 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.122.0 github.com/oracle/oci-go-sdk/v65 v65.123.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
+2 -2
View File
@@ -135,8 +135,8 @@ 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.122.0 h1:yB587yZUGe/syyyp1CHlW74EXo38Rfye5vD3Ox4Cdq4= github.com/oracle/oci-go-sdk/v65 v65.123.0 h1:OjoeFzFMIGqNLJzto+lmI8gLzLc0LhmlOAIO5qKzBDs=
github.com/oracle/oci-go-sdk/v65 v65.122.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ= github.com/oracle/oci-go-sdk/v65 v65.123.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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+20
View File
@@ -39,10 +39,30 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
c.Set(usernameKey, username) c.Set(usernameKey, username)
c.Set(tokenVerKey, proof.Ver) c.Set(tokenVerKey, proof.Ver)
c.Set(tokenJtiKey, proof.Jti) c.Set(tokenJtiKey, proof.Jti)
if allowsSessionRenewal(c.Request.Method) {
maybeRenewToken(c, auth, token)
}
c.Next() c.Next()
} }
} }
// allowsSessionRenewal 仅允许不改变认证状态的只读请求续期。写请求可能在
// handler 内撤销会话或递增令牌版本,预生成的续期头到响应时会已经失效。
func allowsSessionRenewal(method string) bool {
return method == http.MethodGet || method == http.MethodHead
}
// maybeRenewToken 滑动续期:临近过期的令牌换发同会话新令牌,经响应头透出,
// 前端读到后无感替换本地会话;未到阈值时不加头。
func maybeRenewToken(c *gin.Context, auth *service.AuthService, token string) {
newToken, expires, ok := auth.MaybeRenew(c.Request.Context(), token)
if !ok {
return
}
c.Header("X-Renewed-Token", newToken)
c.Header("X-Renewed-Expires-At", expires.Format(time.RFC3339))
}
// tokenProofOf 取出鉴权时的令牌快照,交给敏感 service 事务复核。 // tokenProofOf 取出鉴权时的令牌快照,交给敏感 service 事务复核。
func tokenProofOf(c *gin.Context) service.TokenProof { func tokenProofOf(c *gin.Context) service.TokenProof {
v, _ := c.Get(tokenVerKey) v, _ := c.Get(tokenVerKey)
+17
View File
@@ -283,11 +283,28 @@ func respondError(c *gin.Context, err error) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return return
} }
// 代理/网络类连接失败以 502 透出明确文案,不落无信息量的 500
if msg, ok := oci.UpstreamNetworkError(err); ok {
respondNetworkError(c, msg)
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})
} }
// respondNetworkError 以 502 透出上游连接失败;服务端留日志,requestId 供关联。
func respondNetworkError(c *gin.Context, msg string) {
id := newRequestID()
log.Printf("[NET %s] %s %s: %s", id, c.Request.Method, requestPath(c), msg)
c.JSON(http.StatusBadGateway, gin.H{
"error": msg,
"hint": "上游连接失败(代理或网络),请检查该租户关联代理的可用性",
"code": "UpstreamNetwork",
"requestId": id,
})
}
// newRequestID 生成错误关联 ID(8 字节随机 hex),响应与服务端日志据此对应。 // newRequestID 生成错误关联 ID(8 字节随机 hex),响应与服务端日志据此对应。
func newRequestID() string { func newRequestID() string {
b := make([]byte, 8) b := make([]byte, 8)
+33
View File
@@ -1,8 +1,14 @@
package api package api
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"log"
"net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -52,3 +58,30 @@ func TestRespondErrorOCIStatus(t *testing.T) {
}) })
} }
} }
func TestRespondNetworkErrorHidesProxyCredentials(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/oci-configs/1/instances", nil)
proxyErr := &url.Error{
Op: "Dial", URL: "http://proxy-user:proxy-pass@proxy.example.com:8080",
Err: errors.New("connect: connection refused"),
}
err := &url.Error{Op: "Get", URL: "https://iaas.example.com/instances",
Err: fmt.Errorf("proxyconnect tcp: %w", proxyErr)}
var logs bytes.Buffer
oldWriter := log.Writer()
log.SetOutput(&logs)
t.Cleanup(func() { log.SetOutput(oldWriter) })
respondError(c, err)
if rec.Code != http.StatusBadGateway || !strings.Contains(logs.String(), "[NET ") {
t.Fatalf("response/log = %d %q / %q", rec.Code, rec.Body.String(), logs.String())
}
for _, output := range []string{rec.Body.String(), logs.String()} {
if strings.Contains(output, "proxy-user") || strings.Contains(output, "proxy-pass") {
t.Fatalf("proxy credentials leaked: %q", output)
}
}
}
+54 -2
View File
@@ -8,9 +8,11 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -122,6 +124,30 @@ func doRequest(t *testing.T, r *gin.Engine, method, path, token, body string) *h
return w return w
} }
type apiTestClaims struct {
jwt.RegisteredClaims
Ver uint `json:"ver"`
}
func shortAPIToken(t *testing.T, token string) string {
t.Helper()
claims := &apiTestClaims{}
_, err := jwt.ParseWithClaims(token, claims, func(*jwt.Token) (any, error) {
return []byte("test-secret"), nil
})
if err != nil {
t.Fatalf("parse login token: %v", err)
}
now := time.Now()
claims.IssuedAt = jwt.NewNumericDate(now)
claims.ExpiresAt = jwt.NewNumericDate(now.Add(time.Hour))
short, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("test-secret"))
if err != nil {
t.Fatalf("sign short token: %v", err)
}
return short
}
func TestLoginEndpoint(t *testing.T) { func TestLoginEndpoint(t *testing.T) {
r, _, _ := newTestRouter(t) r, _, _ := newTestRouter(t)
tests := []struct { tests := []struct {
@@ -171,6 +197,26 @@ func TestSecuredRoutesRequireToken(t *testing.T) {
} }
} }
func TestReadRequestRenewsSession(t *testing.T) {
r, auth, _ := newTestRouter(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "",
service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
w := doRequest(t, r, http.MethodGet, "/api/v1/auth/credentials", shortAPIToken(t, token), "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body %s", w.Code, w.Body.String())
}
renewed := w.Header().Get("X-Renewed-Token")
if renewed == "" {
t.Fatal("X-Renewed-Token 为空, want 只读请求续期")
}
if _, err := auth.ParseToken(context.Background(), renewed); err != nil {
t.Errorf("renewed token invalid: %v", err)
}
}
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", "", service.SessionMeta{ClientIP: "127.0.0.1"}) token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
@@ -245,11 +291,17 @@ func TestRevokeSessionsEndpoint(t *testing.T) {
if err := json.Unmarshal(login.Body.Bytes(), &sess); err != nil || sess.Token == "" { if err := json.Unmarshal(login.Body.Bytes(), &sess); err != nil || sess.Token == "" {
t.Fatalf("login: %s", login.Body.String()) t.Fatalf("login: %s", login.Body.String())
} }
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/revoke-sessions", sess.Token, "") short := shortAPIToken(t, sess.Token)
w := doRequest(t, r, http.MethodPost, "/api/v1/auth/revoke-sessions", short, "")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "token") { if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "token") {
t.Fatalf("revoke = %d %s", w.Code, w.Body.String()) t.Fatalf("revoke = %d %s", w.Code, w.Body.String())
} }
w = doRequest(t, r, http.MethodGet, "/api/v1/auth/credentials", sess.Token, "") for _, name := range []string{"X-Renewed-Token", "X-Renewed-Expires-At"} {
if got := w.Header().Get(name); got != "" {
t.Errorf("写请求 %s = %q, want empty", name, got)
}
}
w = doRequest(t, r, http.MethodGet, "/api/v1/auth/credentials", short, "")
if w.Code != http.StatusUnauthorized { if w.Code != http.StatusUnauthorized {
t.Errorf("撤销后旧 token 访问 = %d, want 401", w.Code) t.Errorf("撤销后旧 token 访问 = %d, want 401", w.Code)
} }
+4
View File
@@ -209,6 +209,10 @@ type OciConfig struct {
PrivateKeyEnc string `gorm:"type:text" json:"-"` PrivateKeyEnc string `gorm:"type:text" json:"-"`
PassphraseEnc string `gorm:"type:text" json:"-"` PassphraseEnc string `gorm:"type:text" json:"-"`
// KeyActivatedAt 是最近一次启用新签名 key(面板轮换激活或手工替换私钥)
// 的时刻;OCI 公钥全球传播为分钟级,前端据此在窗口期内提示,nil 表示未记录
KeyActivatedAt *time.Time `json:"keyActivatedAt"`
TenancyName string `json:"tenancyName"` TenancyName string `json:"tenancyName"`
HomeRegionKey string `json:"homeRegionKey"` HomeRegionKey string `json:"homeRegionKey"`
+69
View File
@@ -1,7 +1,11 @@
package oci package oci
import ( import (
"context"
"errors" "errors"
"fmt"
"net"
"net/url"
"regexp" "regexp"
"strings" "strings"
@@ -16,6 +20,7 @@ var ociErrorHints = map[string]string{
"QuotaExceeded": "compartment 配额不足", "QuotaExceeded": "compartment 配额不足",
"OutOfHostCapacity": "该可用域容量不足,可稍后重试或改用抢机任务", "OutOfHostCapacity": "该可用域容量不足,可稍后重试或改用抢机任务",
"TooManyRequests": "请求过于频繁,请稍后再试", "TooManyRequests": "请求过于频繁,请稍后再试",
"NotAuthenticated": "OCI 拒绝了请求签名:密钥无效或尚未生效。若刚替换过 API Key,全球生效需数分钟,请稍后重试",
"InvalidParameter": "请求参数无效", "InvalidParameter": "请求参数无效",
"InternalError": "OCI 服务内部错误,请稍后重试", "InternalError": "OCI 服务内部错误,请稍后重试",
"Conflict": "资源正在变更中,请稍后重试", "Conflict": "资源正在变更中,请稍后重试",
@@ -24,6 +29,9 @@ var ociErrorHints = map[string]string{
// ocidRe 匹配消息中的完整 OCID(unique 段 20 位以上才压缩,避免误伤短标识)。 // ocidRe 匹配消息中的完整 OCID(unique 段 20 位以上才压缩,避免误伤短标识)。
var ocidRe = regexp.MustCompile(`ocid1\.([a-z0-9]+)\.[a-z0-9]*\.[a-z0-9-]*\.{1,2}[a-z0-9]{20,}`) var ocidRe = regexp.MustCompile(`ocid1\.([a-z0-9]+)\.[a-z0-9]*\.[a-z0-9-]*\.{1,2}[a-z0-9]{20,}`)
// proxyUserinfoRe 只匹配网络错误文本里位于 token / URL 起点的 userinfo@。
var proxyUserinfoRe = regexp.MustCompile(`(^|[\s/("'=])[^@\s/]+@`)
// shortenOcids 把消息里的长 OCID 压缩为「ocid1.<类型>…<尾6位>」, // shortenOcids 把消息里的长 OCID 压缩为「ocid1.<类型>…<尾6位>」,
// 保留资源类型与可比对的尾部,避免整条错误被 OCID 撑爆。 // 保留资源类型与可比对的尾部,避免整条错误被 OCID 撑爆。
func shortenOcids(s string) string { func shortenOcids(s string) string {
@@ -68,6 +76,67 @@ func ErrorHint(err error) string {
return ociErrorHints[svcErr.GetCode()] return ociErrorHints[svcErr.GetCode()]
} }
// UpstreamNetworkError 判定错误链是否为上游连接层失败(代理拨号 / SOCKS 握手 /
// 超时 / DNS 等),命中返回给用户看的精简描述。已拿到 OCI 响应的 ServiceError
// 不在此列;判定保守:宁可漏归 500,不把业务错误误标为网络问题。
func UpstreamNetworkError(err error) (string, bool) {
var svcErr common.ServiceError
if errors.As(err, &svcErr) {
return "", false
}
var uerr *url.Error
if errors.As(err, &uerr) {
return classifyNetDetail(redactURLError(uerr)), true
}
if errors.Is(err, context.DeadlineExceeded) {
return "上游连接失败: 请求超时", true
}
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
return "上游连接失败: 连接超时", true
}
return "", false
}
// redactURLError 把 url.Error 的完整 URL 压缩为主机,并递归处理内层 url.Error。
func redactURLError(uerr *url.Error) string {
host := "<redacted-url>"
if u, err := url.Parse(uerr.URL); err == nil && u.Host != "" {
host = u.Host
}
return fmt.Sprintf("%s %s: %s", uerr.Op, host, redactURLCause(uerr.Err))
}
// redactURLCause 保留普通包装前缀,但用脱敏文本替换其中嵌套的 url.Error。
func redactURLCause(err error) string {
if err == nil {
return "unknown network error"
}
var nested *url.Error
if !errors.As(err, &nested) {
return redactProxyUserinfo(err.Error())
}
raw, nestedRaw := err.Error(), nested.Error()
if strings.Contains(raw, nestedRaw) {
raw = strings.Replace(raw, nestedRaw, redactURLError(nested), 1)
return redactProxyUserinfo(raw)
}
return redactURLError(nested)
}
// redactProxyUserinfo 清除存量非法代理 host 可能写入普通错误文本的 userinfo。
func redactProxyUserinfo(detail string) string {
return proxyUserinfoRe.ReplaceAllString(detail, "$1")
}
// classifyNetDetail 按细节特征加中文分类前缀:代理链路失败与一般上游失败分开表述。
func classifyNetDetail(detail string) string {
if strings.Contains(detail, "proxyconnect") || strings.Contains(detail, "socks connect") {
return "代理连接失败: " + detail
}
return "上游连接失败: " + detail
}
// ServiceStatus 返回错误链中 OCI ServiceError 的 HTTP 状态码;非服务端错误 ok=false。 // ServiceStatus 返回错误链中 OCI ServiceError 的 HTTP 状态码;非服务端错误 ok=false。
func ServiceStatus(err error) (int, bool) { func ServiceStatus(err error) (int, bool) {
var svcErr common.ServiceError var svcErr common.ServiceError
+110
View File
@@ -1,8 +1,10 @@
package oci package oci
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"net/url"
"testing" "testing"
) )
@@ -95,6 +97,11 @@ func TestErrorHint(t *testing.T) {
err: fmt.Errorf("launch: %w", fakeServiceError{500, "InternalError", "Out of host capacity."}), err: fmt.Errorf("launch: %w", fakeServiceError{500, "InternalError", "Out of host capacity."}),
want: ociErrorHints["OutOfHostCapacity"], want: ociErrorHints["OutOfHostCapacity"],
}, },
{
name: "NotAuthenticated 提示密钥无效或传播中",
err: fmt.Errorf("get instance: %w", fakeServiceError{401, "NotAuthenticated", "The required information ..."}),
want: ociErrorHints["NotAuthenticated"],
},
{ {
name: "未知错误码返回空串", name: "未知错误码返回空串",
err: fmt.Errorf("x: %w", fakeServiceError{400, "SomethingNew", "boom"}), err: fmt.Errorf("x: %w", fakeServiceError{400, "SomethingNew", "boom"}),
@@ -158,3 +165,106 @@ func TestIsModelUnavailable(t *testing.T) {
}) })
} }
} }
// fakeTimeoutErr 模拟实现 net.Error 的超时错误(如响应体读取超时)。
type fakeTimeoutErr struct{}
func (fakeTimeoutErr) Error() string { return "read tcp 10.0.0.1:443: i/o timeout" }
func (fakeTimeoutErr) Timeout() bool { return true }
func (fakeTimeoutErr) Temporary() bool { return false }
type upstreamNetworkErrorCase struct {
name string
err error
want string
wantOK bool
}
var upstreamNetworkErrorCases = []upstreamNetworkErrorCase{
{
name: "OCI 服务错误不归网络",
err: fmt.Errorf("get instance: %w", fakeServiceError{status: 401, code: "NotAuthenticated", message: "x"}),
wantOK: false,
},
{
name: "普通业务错误不归网络",
err: errors.New("parse payload: bad json"),
wantOK: false,
},
{
name: "代理 CONNECT 失败带中文分类且 URL 缩为主机",
err: fmt.Errorf("list instances: %w", &url.Error{
Op: "Get",
URL: "https://iaas.uk-london-1.oraclecloud.com/20160918/instances?limit=100",
Err: errors.New("proxyconnect tcp: dial tcp 1.2.3.4:8080: connect: connection refused"),
}),
want: "代理连接失败: Get iaas.uk-london-1.oraclecloud.com: proxyconnect tcp: dial tcp 1.2.3.4:8080: connect: connection refused",
wantOK: true,
},
{
name: "SOCKS 握手失败归代理类",
err: &url.Error{
Op: "Post",
URL: "https://identity.us-sanjose-1.oci.oraclecloud.com/20160918/users",
Err: errors.New("socks connect tcp 5.6.7.8:1080->identity: dial refused"),
},
want: "代理连接失败: Post identity.us-sanjose-1.oci.oraclecloud.com: socks connect tcp 5.6.7.8:1080->identity: dial refused",
wantOK: true,
},
{
name: "URL 内嵌 userinfo 不回显",
err: &url.Error{
Op: "Get",
URL: "https://user:pass@example.com/path",
Err: errors.New("EOF"),
},
want: "上游连接失败: Get example.com: EOF",
wantOK: true,
},
{
name: "嵌套 URL 的代理 userinfo 不回显",
err: &url.Error{
Op: "Get",
URL: "https://identity.us-ashburn-1.oraclecloud.com/20160918/tenancies/x",
Err: fmt.Errorf("proxyconnect tcp: %w", &url.Error{
Op: "Dial", URL: "http://proxy-user:proxy-pass@proxy.example.com:8080",
Err: errors.New("connect: connection refused"),
}),
},
want: "代理连接失败: Get identity.us-ashburn-1.oraclecloud.com: proxyconnect tcp: Dial proxy.example.com:8080: connect: connection refused",
wantOK: true,
},
{
name: "普通内层错误中的存量代理 userinfo 不回显",
err: &url.Error{
Op: "Get",
URL: "https://identity.us-phoenix-1.oraclecloud.com/20160918/tenancies/x",
Err: errors.New("proxyconnect tcp: dial tcp alice:secret@proxy.example.com:8080: connect: connection refused"),
},
want: "代理连接失败: Get identity.us-phoenix-1.oraclecloud.com: proxyconnect tcp: dial tcp proxy.example.com:8080: connect: connection refused",
wantOK: true,
},
{
name: "context 超时归网络",
err: fmt.Errorf("summarize costs: %w", context.DeadlineExceeded),
want: "上游连接失败: 请求超时",
wantOK: true,
},
{
name: "net.Error 超时归网络",
err: fmt.Errorf("read body: %w", fakeTimeoutErr{}),
want: "上游连接失败: 连接超时",
wantOK: true,
},
}
func TestUpstreamNetworkError(t *testing.T) {
for _, tt := range upstreamNetworkErrorCases {
t.Run(tt.name, func(t *testing.T) {
got, ok := UpstreamNetworkError(tt.err)
if ok != tt.wantOK || got != tt.want {
t.Errorf("UpstreamNetworkError() = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.wantOK)
}
})
}
}
+6 -2
View File
@@ -25,8 +25,12 @@ var ErrInvalidCredentials = errors.New("invalid username or password")
// ErrLoginLocked 表示该 IP+用户名组合因连续失败被锁定;不提示剩余次数与时长细节。 // ErrLoginLocked 表示该 IP+用户名组合因连续失败被锁定;不提示剩余次数与时长细节。
var ErrLoginLocked = errors.New("too many failed attempts, try again later") var ErrLoginLocked = errors.New("too many failed attempts, try again later")
// tokenTTL 是登录令牌有效期。 // tokenTTL 是登录令牌有效期;renewThreshold 是滑动续期阈值——剩余有效期
const tokenTTL = 24 * time.Hour // 低于该值的令牌在鉴权响应中自动换发同会话新令牌(见 MaybeRenew)。
const (
tokenTTL = 24 * time.Hour
renewThreshold = tokenTTL / 2
)
// authClaims 在标准声明外携带令牌版本;版本落后于账号当前值即失效。 // authClaims 在标准声明外携带令牌版本;版本落后于账号当前值即失效。
// 存量令牌无 ver 字段解析为 0,与存量账号的零值版本兼容(升级不强制登出)。 // 存量令牌无 ver 字段解析为 0,与存量账号的零值版本兼容(升级不强制登出)。
+3
View File
@@ -190,6 +190,9 @@ func (s *OciConfigService) applyCredentialUpdate(cfg *model.OciConfig, in Update
return fmt.Errorf("encrypt private key: %w", err) return fmt.Errorf("encrypt private key: %w", err)
} }
cfg.PrivateKeyEnc = enc cfg.PrivateKeyEnc = enc
// 手工替换私钥同样进入 OCI 公钥传播窗口,记录激活时刻供前端提示
now := time.Now()
cfg.KeyActivatedAt = &now
} }
if in.Passphrase == nil { if in.Passphrase == nil {
return nil return nil
+3
View File
@@ -84,6 +84,9 @@ func validateProxyInput(in ProxyInput) error {
if strings.TrimSpace(in.Host) == "" { if strings.TrimSpace(in.Host) == "" {
return fmt.Errorf("主机不能为空: %w", ErrProxyInvalid) return fmt.Errorf("主机不能为空: %w", ErrProxyInvalid)
} }
if strings.Contains(in.Host, "@") {
return fmt.Errorf("主机不可包含用户凭据: %w", ErrProxyInvalid)
}
return nil return nil
} }
+1
View File
@@ -101,6 +101,7 @@ func TestProxyValidateAndDeleteInUse(t *testing.T) {
{Name: "x", Type: "ss", Host: "h", Port: 1080}, {Name: "x", Type: "ss", Host: "h", Port: 1080},
{Name: "x", Type: "http", Host: "h", Port: 0}, {Name: "x", Type: "http", Host: "h", Port: 0},
{Name: "x", Type: "http", Host: " ", Port: 8080}, {Name: "x", Type: "http", Host: " ", Port: 8080},
{Name: "x", Type: "http", Host: "user:pass@proxy.example.com", Port: 8080},
} { } {
if _, err := svc.Create(ctx, in); err == nil { if _, err := svc.Create(ctx, in); err == nil {
t.Fatalf("Create(%+v) accepted invalid input", in) t.Fatalf("Create(%+v) accepted invalid input", in)
+39
View File
@@ -431,3 +431,42 @@ func (s *AuthService) cleanupSessionsOnce(ctx context.Context) {
log.Printf("session cleanup: %v", err) log.Printf("session cleanup: %v", err)
} }
} }
// MaybeRenew 滑动续期:对剩余有效期不足 renewThreshold 的令牌换发同会话
// (同 jti / 同版本)新令牌,并把会话行有效期延长到新过期点,不新建会话行。
// 仅供鉴权通过后的请求调用(令牌有效性已由 ParseTokenProof 保证);
// 无需换发或换发失败返回 ok=false,调用方跳过即可。
func (s *AuthService) MaybeRenew(ctx context.Context, tokenString string) (string, time.Time, bool) {
claims := &authClaims{}
if _, err := jwt.ParseWithClaims(tokenString, claims, func(*jwt.Token) (any, error) {
return s.jwtSecret, nil
}); err != nil || claims.ExpiresAt == nil {
return "", time.Time{}, false
}
if time.Until(claims.ExpiresAt.Time) >= renewThreshold {
return "", time.Time{}, false
}
token, expires, _, err := s.signTokenWithJTI(claims.Subject, claims.Ver, claims.ID)
if err != nil {
return "", time.Time{}, false
}
if err := s.extendSessionExpiry(ctx, claims.ID, expires); err != nil {
log.Printf("[WARN] %v", err)
return "", time.Time{}, false
}
return token, expires, true
}
// extendSessionExpiry 把会话行有效期延长到新过期点,保证「活跃会话」展示与
// 清理任务看到真实过期时间;无行(存量令牌)静默跳过。
func (s *AuthService) extendSessionExpiry(ctx context.Context, jti string, expires time.Time) error {
if jti == "" {
return nil
}
err := s.db.WithContext(ctx).Model(&model.UserSession{}).
Where("token_id = ?", jti).UpdateColumn("expires_at", expires).Error
if err != nil {
return fmt.Errorf("extend session expiry: %w", err)
}
return nil
}
+122
View File
@@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm" "gorm.io/gorm"
"oci-portal/internal/model" "oci-portal/internal/model"
@@ -523,3 +524,124 @@ func TestSessionCleanup(t *testing.T) {
t.Errorf("after cleanup rows = %+v, want only alive", rows) t.Errorf("after cleanup rows = %+v, want only alive", rows)
} }
} }
// signShortToken 用服务同款密钥手工签指定 TTL 的令牌,构造临近过期态。
func signShortToken(t *testing.T, auth *AuthService, username string, ver uint, jti string, ttl time.Duration) string {
t.Helper()
claims := &authClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: username,
ID: jti,
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
},
Ver: ver,
}
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(auth.jwtSecret)
if err != nil {
t.Fatalf("sign short token: %v", err)
}
return tok
}
func renewalFixture(t *testing.T) (*AuthService, string, TokenProof) {
t.Helper()
auth := newTestAuth(t)
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
ctx := context.Background()
loginTok, _, err := auth.Login(ctx, "admin", "pass123", "",
SessionMeta{ClientIP: "127.0.0.1", UserAgent: "t"})
if err != nil {
t.Fatalf("Login: %v", err)
}
_, proof, err := auth.ParseTokenProof(ctx, loginTok)
if err != nil {
t.Fatalf("ParseTokenProof: %v", err)
}
return auth, loginTok, proof
}
func setSessionExpiry(t *testing.T, auth *AuthService, jti string, expires time.Time) model.UserSession {
t.Helper()
if err := auth.db.Model(&model.UserSession{}).Where("token_id = ?", jti).
UpdateColumn("expires_at", expires).Error; err != nil {
t.Fatalf("set session expiry: %v", err)
}
var row model.UserSession
if err := auth.db.Where("token_id = ?", jti).First(&row).Error; err != nil {
t.Fatalf("find session row: %v", err)
}
return row
}
func TestMaybeRenewEligibility(t *testing.T) {
auth, loginTok, _ := renewalFixture(t)
tests := []struct {
name string
token string
}{
{name: "剩余时间高于阈值", token: loginTok},
{name: "非法令牌", token: "not.a.token"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, _, ok := auth.MaybeRenew(context.Background(), tt.token); ok {
t.Error("MaybeRenew ok = true, want false")
}
})
}
}
func TestMaybeRenewExtendsSession(t *testing.T) {
auth, _, proof := renewalFixture(t)
before := setSessionExpiry(t, auth, proof.Jti, time.Now().Add(time.Hour))
short := signShortToken(t, auth, "admin", proof.Ver, proof.Jti, time.Hour)
renewed, expires, ok := auth.MaybeRenew(context.Background(), short)
if !ok {
t.Fatal("剩余 1h 的令牌应换发")
}
_, newProof, err := auth.ParseTokenProof(context.Background(), renewed)
if err != nil {
t.Fatalf("ParseTokenProof(renewed): %v", err)
}
if newProof.Jti != proof.Jti || newProof.Ver != proof.Ver {
t.Errorf("proof = %+v, want %+v", newProof, proof)
}
var after model.UserSession
if err := auth.db.Where("token_id = ?", proof.Jti).First(&after).Error; err != nil {
t.Fatalf("reload session row: %v", err)
}
if delta := after.ExpiresAt.Sub(before.ExpiresAt); delta < 22*time.Hour {
t.Errorf("session expiry delta = %v, want >= 22h", delta)
}
if gap := after.ExpiresAt.Sub(expires); gap < -time.Second || gap > time.Second {
t.Errorf("session expiry = %v, token expiry = %v", after.ExpiresAt, expires)
}
}
func TestMaybeRenewedTokenFollowsSessionRevocation(t *testing.T) {
auth, loginTok, proof := renewalFixture(t)
short := signShortToken(t, auth, "admin", proof.Ver, proof.Jti, time.Hour)
renewed, _, ok := auth.MaybeRenew(context.Background(), short)
if !ok {
t.Fatal("剩余 1h 的令牌应换发")
}
auth.Logout(context.Background(), loginTok)
if _, err := auth.ParseToken(context.Background(), renewed); err == nil {
t.Error("会话撤销后换发令牌仍有效, want 失效")
}
}
func TestMaybeRenewUpdateFailureDoesNotIssueToken(t *testing.T) {
auth, _, proof := renewalFixture(t)
short := signShortToken(t, auth, "admin", proof.Ver, proof.Jti, time.Hour)
if err := auth.db.Migrator().DropTable(&model.UserSession{}); err != nil {
t.Fatalf("drop sessions table: %v", err)
}
token, expires, ok := auth.MaybeRenew(context.Background(), short)
if ok || token != "" || !expires.IsZero() {
t.Errorf("MaybeRenew = (%q, %v, %v), want empty result", token, expires, ok)
}
}
+13 -4
View File
@@ -10,6 +10,8 @@ import (
"fmt" "fmt"
"time" "time"
"gorm.io/gorm"
"oci-portal/internal/model" "oci-portal/internal/model"
"oci-portal/internal/oci" "oci-portal/internal/oci"
) )
@@ -106,7 +108,7 @@ func (s *OciConfigService) ActivateApiKey(ctx context.Context, id uint, userID,
if err := s.waitApiKeyUsable(ctx, newCred); err != nil { if err := s.waitApiKeyUsable(ctx, newCred); err != nil {
return err return err
} }
return s.persistSigningKey(cfg, newCred) return s.persistSigningKey(ctx, cfg, newCred)
} }
// waitApiKeyUsable 用新凭据测活,等待上传的公钥在 OCI 侧生效。 // waitApiKeyUsable 用新凭据测活,等待上传的公钥在 OCI 侧生效。
@@ -126,7 +128,7 @@ func (s *OciConfigService) waitApiKeyUsable(ctx context.Context, cred oci.Creden
} }
// persistSigningKey 加密新私钥,更新配置签名用户与指纹并清空口令密文(面板生成的 key 无口令)。 // persistSigningKey 加密新私钥,更新配置签名用户与指纹并清空口令密文(面板生成的 key 无口令)。
func (s *OciConfigService) persistSigningKey(cfg *model.OciConfig, newCred oci.Credentials) error { func (s *OciConfigService) persistSigningKey(ctx context.Context, cfg *model.OciConfig, newCred oci.Credentials) error {
enc, err := s.cipher.EncryptString(newCred.PrivateKey) enc, err := s.cipher.EncryptString(newCred.PrivateKey)
if err != nil { if err != nil {
return fmt.Errorf("encrypt private key: %w", err) return fmt.Errorf("encrypt private key: %w", err)
@@ -134,9 +136,16 @@ func (s *OciConfigService) persistSigningKey(cfg *model.OciConfig, newCred oci.C
updates := map[string]any{ updates := map[string]any{
"user_oc_id": newCred.UserOCID, "fingerprint": newCred.Fingerprint, "user_oc_id": newCred.UserOCID, "fingerprint": newCred.Fingerprint,
"private_key_enc": enc, "passphrase_enc": "", "private_key_enc": enc, "passphrase_enc": "",
// 记录激活时刻:OCI 公钥全球传播为分钟级,前端据此做窗口期提示
"key_activated_at": time.Now(),
} }
if err := s.db.Model(cfg).Updates(updates).Error; err != nil { res := s.db.WithContext(ctx).Model(&model.OciConfig{}).
return fmt.Errorf("persist rotated key: %w", err) Where("id = ? AND updated_at = ?", cfg.ID, cfg.UpdatedAt).Updates(updates)
if res.Error != nil {
return fmt.Errorf("persist rotated key: %w", res.Error)
}
if res.RowsAffected != 1 {
return fmt.Errorf("persist rotated key: %w", gorm.ErrRecordNotFound)
} }
return nil return nil
} }
+90
View File
@@ -9,6 +9,8 @@ import (
"testing" "testing"
"time" "time"
"gorm.io/gorm"
"oci-portal/internal/model" "oci-portal/internal/model"
"oci-portal/internal/oci" "oci-portal/internal/oci"
) )
@@ -194,6 +196,9 @@ func TestActivateApiKey(t *testing.T) {
if err != nil || plain != newKey { if err != nil || plain != newKey {
t.Fatalf("persisted key mismatch (err=%v)", err) t.Fatalf("persisted key mismatch (err=%v)", err)
} }
if got.KeyActivatedAt == nil || time.Since(*got.KeyActivatedAt) > time.Minute {
t.Fatalf("keyActivatedAt = %v, want 刚写入的时间", got.KeyActivatedAt)
}
if len(fc.validated) == 0 || fc.validated[0] != "11:22" { if len(fc.validated) == 0 || fc.validated[0] != "11:22" {
t.Fatalf("validated = %v", fc.validated) t.Fatalf("validated = %v", fc.validated)
} }
@@ -203,3 +208,88 @@ func TestActivateApiKey(t *testing.T) {
}) })
} }
} }
func TestPersistSigningKeyRejectsStaleSnapshot(t *testing.T) {
tests := []struct {
name string
deleted bool
}{
{name: "租户已删除", deleted: true},
{name: "凭据已被并发更新"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testStaleSigningKeyPersistence(t, tt.deleted)
})
}
}
func TestPersistSigningKeyAdvancesSnapshotVersion(t *testing.T) {
s := newTestService(t, &apiKeyClient{})
cfg := seedApiKeyConfig(t, s)
stale := *cfg
first := oci.Credentials{UserOCID: cfg.UserOCID, Fingerprint: "11:22", PrivateKey: "first-key"}
if err := s.persistSigningKey(context.Background(), cfg, first); err != nil {
t.Fatalf("first persist: %v", err)
}
second := oci.Credentials{UserOCID: cfg.UserOCID, Fingerprint: "33:44", PrivateKey: "second-key"}
if err := s.persistSigningKey(context.Background(), &stale, second); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("stale persist err = %v, want ErrRecordNotFound", err)
}
var got model.OciConfig
if err := s.db.First(&got, cfg.ID).Error; err != nil || got.Fingerprint != "11:22" {
t.Fatalf("persisted config = %+v, err %v", got, err)
}
}
func testStaleSigningKeyPersistence(t *testing.T, deleted bool) {
t.Helper()
s := newTestService(t, &apiKeyClient{})
cfg := seedApiKeyConfig(t, s)
invalidateSigningSnapshot(t, s, cfg, deleted)
cred := oci.Credentials{UserOCID: cfg.UserOCID, Fingerprint: "11:22", PrivateKey: "new-key"}
err := s.persistSigningKey(context.Background(), cfg, cred)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("err = %v, want ErrRecordNotFound", err)
}
assertSigningSnapshotPreserved(t, s, cfg.ID, deleted)
}
func invalidateSigningSnapshot(t *testing.T, s *OciConfigService, cfg *model.OciConfig, deleted bool) {
t.Helper()
if deleted {
if err := s.db.Delete(&model.OciConfig{}, cfg.ID).Error; err != nil {
t.Fatalf("delete config: %v", err)
}
return
}
enc, err := s.cipher.EncryptString("concurrent-key")
if err != nil {
t.Fatalf("encrypt concurrent key: %v", err)
}
res := s.db.Model(&model.OciConfig{}).Where("id = ?", cfg.ID).UpdateColumns(map[string]any{
"fingerprint": "cc:dd", "private_key_enc": enc, "updated_at": cfg.UpdatedAt.Add(time.Second),
})
if res.Error != nil || res.RowsAffected != 1 {
t.Fatalf("mutate config = rows %d, err %v", res.RowsAffected, res.Error)
}
}
func assertSigningSnapshotPreserved(t *testing.T, s *OciConfigService, id uint, deleted bool) {
t.Helper()
var got model.OciConfig
err := s.db.First(&got, id).Error
if deleted {
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("deleted config reload err = %v", err)
}
return
}
if err != nil || got.Fingerprint != "cc:dd" {
t.Fatalf("concurrent config = %+v, err %v", got, err)
}
plain, err := s.cipher.DecryptString(got.PrivateKeyEnc)
if err != nil || plain != "concurrent-key" {
t.Fatalf("concurrent key = %q, err %v", plain, err)
}
}