滑动续期、网络错误归类、API Key 激活乐观锁与生效提示

This commit is contained in:
2026-08-11 11:45:03 +08:00
parent 23b2820101
commit e63af49831
21 changed files with 679 additions and 15 deletions
+20
View File
@@ -39,10 +39,30 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
c.Set(usernameKey, username)
c.Set(tokenVerKey, proof.Ver)
c.Set(tokenJtiKey, proof.Jti)
if allowsSessionRenewal(c.Request.Method) {
maybeRenewToken(c, auth, token)
}
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 事务复核。
func tokenProofOf(c *gin.Context) service.TokenProof {
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()})
return
}
// 代理/网络类连接失败以 502 透出明确文案,不落无信息量的 500
if msg, ok := oci.UpstreamNetworkError(err); ok {
respondNetworkError(c, msg)
return
}
id := newRequestID()
log.Printf("[ERR %s] %s %s: %v", id, c.Request.Method, requestPath(c), err)
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),响应与服务端日志据此对应。
func newRequestID() string {
b := make([]byte, 8)
+33
View File
@@ -1,8 +1,14 @@
package api
import (
"bytes"
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"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"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -122,6 +124,30 @@ func doRequest(t *testing.T, r *gin.Engine, method, path, token, body string) *h
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) {
r, _, _ := newTestRouter(t)
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) {
r, auth, logs := newTestRouter(t)
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 == "" {
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") {
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 {
t.Errorf("撤销后旧 token 访问 = %d, want 401", w.Code)
}