滑动续期、网络错误归类、API Key 激活乐观锁与生效提示
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user