From 3a555c27b20f788de20275fab4ceddadaa17fe36 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 14 Jul 2026 16:06:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8E=89=E7=BA=BF=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/api/main.go | 17 +++++ backend/internal/logging/context.go | 18 ++++++ backend/internal/middleware/auth.go | 48 +++++++++++++- backend/internal/middleware/request_logger.go | 12 ++++ backend/internal/modules/adminauth/handler.go | 4 ++ .../internal/modules/adminauth/repository.go | 36 +++++++++-- backend/internal/modules/adminauth/service.go | 60 +++++++++++++++++- .../internal/modules/adminmgr/repository.go | 31 +++++++--- .../internal/modules/adminrole/repository.go | 32 ++++++++-- backend/internal/modules/auth/jwt.go | 60 ++++++++++++++++-- backend/internal/modules/auth/jwt_test.go | 62 +++++++++++++++++++ .../shared/components/business/AuthImage.vue | 39 ++++++++++-- 12 files changed, 388 insertions(+), 31 deletions(-) create mode 100644 backend/internal/modules/auth/jwt_test.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 97d57d3..c34580b 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "net/http" "os" "os/signal" @@ -53,6 +55,7 @@ func main() { if err := cfg.ValidateProductionSecurity(); err != nil { logger.Fatal("production security config invalid", zap.Error(err)) } + logAuthRuntimeIdentity(logger, cfg) var deps router.Dependencies db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level) @@ -119,6 +122,20 @@ func main() { logger.Info("api server stopped") } +// logAuthRuntimeIdentity 记录可用于排查多实例或部署配置漂移的非敏感身份信息。 +func logAuthRuntimeIdentity(logger *zap.Logger, cfg config.Config) { + instanceID, err := os.Hostname() + if err != nil || instanceID == "" { + instanceID = "unknown" + } + sum := sha256.Sum256([]byte(cfg.JWTSecret)) + logger.Info("auth runtime identity", + zap.String("instance_id", instanceID), + zap.String("app_env", cfg.AppEnv), + zap.String("jwt_secret_fingerprint", hex.EncodeToString(sum[:])[:12]), + ) +} + func newPaymentConfigRepositoryForJobs(cfg config.Config, db *gorm.DB, logger *zap.Logger) *paymentconfig.Repository { encryptionKey := cfg.PaymentConfigEncryptionKey var encryptor paymentconfig.Encryptor diff --git a/backend/internal/logging/context.go b/backend/internal/logging/context.go index e407ae7..7580f16 100644 --- a/backend/internal/logging/context.go +++ b/backend/internal/logging/context.go @@ -3,6 +3,7 @@ package logging import "context" type requestIDContextKey struct{} +type adminIDContextKey struct{} // WithRequestID 把请求 ID 写入标准 context,供非 HTTP 层日志关联请求链路。 func WithRequestID(ctx context.Context, requestID string) context.Context { @@ -23,3 +24,20 @@ func RequestIDFromContext(ctx context.Context) string { } return value } + +// WithAdminID 把已完成鉴权的管理员 ID 写入标准 context,供仓储审计和日志关联。 +func WithAdminID(ctx context.Context, adminID uint64) context.Context { + if ctx == nil || adminID == 0 { + return ctx + } + return context.WithValue(ctx, adminIDContextKey{}, adminID) +} + +// AdminIDFromContext 从标准 context 读取已完成鉴权的管理员 ID。 +func AdminIDFromContext(ctx context.Context) uint64 { + if ctx == nil { + return 0 + } + adminID, _ := ctx.Value(adminIDContextKey{}).(uint64) + return adminID +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 62ae9e4..ee794dc 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -2,9 +2,11 @@ package middleware import ( "context" + "errors" "net/http" "strings" + "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/pkg/response" @@ -17,6 +19,10 @@ const ( ContextAdminID = "admin_id" ContextUsername = "username" ContextPasswordMustChange = "password_must_change" + ContextAuthFailureReason = "auth_failure_reason" + ContextAuthTokenSource = "auth_token_source" + ContextAuthTokenVersion = "auth_token_version" + ContextAuthCurrentVersion = "auth_current_token_version" AdminAccessCookieName = "hfb_admin_access" ) @@ -68,12 +74,15 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc { func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc { return func(c *gin.Context) { tokenText := extractBearerToken(c) + tokenSource := "bearer" if tokenText == "" { if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil { tokenText = strings.TrimSpace(cookieToken) + tokenSource = "cookie" } } if tokenText == "" { + RecordAdminAuthFailure(c, "missing", "none", 0, 0) response.Unauthorized(c, "缺少后台访问令牌") c.Abort() return @@ -81,15 +90,20 @@ func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gi claims, err := jwtManager.ParseSubject(tokenText, "access", "admin") if err != nil { + RecordAdminAuthFailure(c, auth.TokenFailureReason(err), tokenSource, 0, 0) response.Unauthorized(c, "后台访问令牌无效或已过期") c.Abort() return } + c.Set(ContextAdminID, claims.UserID) + c.Request = c.Request.WithContext(logging.WithAdminID(c.Request.Context(), claims.UserID)) username := claims.Phone passwordMustChange := false if validate != nil { tokenContext, err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion) if err != nil { + reason, tokenVersion, currentVersion := AdminValidationFailure(err) + RecordAdminAuthFailure(c, reason, tokenSource, tokenVersion, currentVersion) response.Unauthorized(c, "后台访问令牌无效或已过期") c.Abort() return @@ -98,13 +112,45 @@ func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gi passwordMustChange = tokenContext.PasswordMustChange } - c.Set(ContextAdminID, claims.UserID) c.Set(ContextUsername, username) c.Set(ContextPasswordMustChange, passwordMustChange) c.Next() } } +type authFailureReasonCarrier interface { + AuthFailureReason() string +} + +type authFailureVersionCarrier interface { + AuthFailureVersions() (int64, int64) +} + +// AdminValidationFailure 将认证包内部错误转换为安全、可观测的失败类别。 +func AdminValidationFailure(err error) (string, int64, int64) { + reason := "admin_validation_failed" + var reasonCarrier authFailureReasonCarrier + if errors.As(err, &reasonCarrier) && reasonCarrier.AuthFailureReason() != "" { + reason = reasonCarrier.AuthFailureReason() + } + var versionCarrier authFailureVersionCarrier + if errors.As(err, &versionCarrier) { + tokenVersion, currentVersion := versionCarrier.AuthFailureVersions() + return reason, tokenVersion, currentVersion + } + return reason, 0, 0 +} + +// RecordAdminAuthFailure 把认证失败诊断字段写入请求上下文,供访问日志统一输出。 +func RecordAdminAuthFailure(c *gin.Context, reason, source string, tokenVersion, currentVersion int64) { + c.Set(ContextAuthFailureReason, reason) + c.Set(ContextAuthTokenSource, source) + if tokenVersion != 0 || currentVersion != 0 { + c.Set(ContextAuthTokenVersion, tokenVersion) + c.Set(ContextAuthCurrentVersion, currentVersion) + } +} + func RequireAdminPasswordChanged() gin.HandlerFunc { allowed := map[string]bool{ "/api/admin/me": true, diff --git a/backend/internal/middleware/request_logger.go b/backend/internal/middleware/request_logger.go index 2df2a27..c06583e 100644 --- a/backend/internal/middleware/request_logger.go +++ b/backend/internal/middleware/request_logger.go @@ -48,6 +48,18 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc { if adminID, ok := c.Get(ContextAdminID); ok { fields = append(fields, zap.Any("admin_id", adminID)) } + if reason, ok := c.Get(ContextAuthFailureReason); ok { + fields = append(fields, zap.Any("auth_failure_reason", reason)) + } + if source, ok := c.Get(ContextAuthTokenSource); ok { + fields = append(fields, zap.Any("auth_token_source", source)) + } + if tokenVersion, ok := c.Get(ContextAuthTokenVersion); ok { + fields = append(fields, zap.Any("auth_token_version", tokenVersion)) + } + if currentVersion, ok := c.Get(ContextAuthCurrentVersion); ok { + fields = append(fields, zap.Any("auth_current_token_version", currentVersion)) + } if len(c.Errors) > 0 { fields = append(fields, zap.String("errors", strings.TrimSpace(c.Errors.String()))) } diff --git a/backend/internal/modules/adminauth/handler.go b/backend/internal/modules/adminauth/handler.go index daf2ba3..e92da17 100644 --- a/backend/internal/modules/adminauth/handler.go +++ b/backend/internal/modules/adminauth/handler.go @@ -124,9 +124,11 @@ func (h *Handler) Refresh(c *gin.Context) { } } refreshToken := strings.TrimSpace(req.RefreshToken) + refreshTokenSource := "body" if refreshToken == "" { if cookieValue, err := c.Cookie(adminRefreshCookieName); err == nil { refreshToken = strings.TrimSpace(cookieValue) + refreshTokenSource = "cookie" } } if refreshToken == "" { @@ -135,6 +137,8 @@ func (h *Handler) Refresh(c *gin.Context) { } tokens, err := h.service.Refresh(c.Request.Context(), refreshToken) if err != nil { + reason, tokenVersion, currentVersion := middleware.AdminValidationFailure(err) + middleware.RecordAdminAuthFailure(c, reason, refreshTokenSource, tokenVersion, currentVersion) writeAdminAuthError(c, err) return } diff --git a/backend/internal/modules/adminauth/repository.go b/backend/internal/modules/adminauth/repository.go index b282df3..a350abb 100644 --- a/backend/internal/modules/adminauth/repository.go +++ b/backend/internal/modules/adminauth/repository.go @@ -8,7 +8,9 @@ import ( "strings" "time" + "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/captcha" + "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/auth" @@ -92,21 +94,45 @@ func (r *Repository) Login(ctx context.Context, username string, password string func (r *Repository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.AdminUser, error) { var admin model.AdminUser if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, newTokenValidationError("admin_not_found", ErrAdminNotFound) + } return nil, err } if admin.Status != "active" { - return nil, ErrAdminDisabled + return nil, newTokenValidationError("admin_disabled", ErrAdminDisabled) } if admin.TokenVersion <= 0 || admin.TokenVersion != tokenVersion { - return nil, ErrInvalidRefreshToken + return nil, newTokenVersionMismatchError(tokenVersion, admin.TokenVersion) } return &admin, nil } func (r *Repository) RevokeTokens(ctx context.Context, adminID uint64) error { - return r.db.WithContext(ctx).Model(&model.AdminUser{}). - Where("id = ?", adminID). - UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.AdminUser{}). + Where("id = ?", adminID). + UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil { + return err + } + return appendTokenVersionAudit(tx, ctx, adminID, "logout") + }) +} + +func appendTokenVersionAudit(tx *gorm.DB, ctx context.Context, adminID uint64, reason string) error { + bizID := adminID + return auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: logging.AdminIDFromContext(ctx), + Action: "auth.token_version.bump", + BizType: "admin_user", + BizID: &bizID, + Meta: auditlog.Meta{RequestID: logging.RequestIDFromContext(ctx)}, + Detail: map[string]any{ + "target_admin_id": adminID, + "reason": reason, + }, + }) } func (r *Repository) verifyCaptcha(ctx context.Context, captchaID string, captchaCode string) error { diff --git a/backend/internal/modules/adminauth/service.go b/backend/internal/modules/adminauth/service.go index 28b7773..49aa2d4 100644 --- a/backend/internal/modules/adminauth/service.go +++ b/backend/internal/modules/adminauth/service.go @@ -14,8 +14,49 @@ var ( ErrAdminDisabled = errors.New("admin disabled") ErrInvalidRefreshToken = errors.New("invalid refresh token") ErrLoginLocked = errors.New("login locked") + ErrAdminNotFound = errors.New("admin not found") + ErrTokenVersionMismatch = errors.New("admin token version mismatch") ) +// TokenValidationError 为后台会话失败保留安全的诊断类别,响应仍使用统一文案。 +type TokenValidationError struct { + Reason string + TokenVersion int64 + CurrentVersion int64 + err error +} + +func (e *TokenValidationError) Error() string { + return "admin token validation failed: " + e.Reason +} + +func (e *TokenValidationError) Unwrap() error { + return e.err +} + +// AuthFailureReason 供中间件读取失败类别并写入结构化日志。 +func (e *TokenValidationError) AuthFailureReason() string { + return e.Reason +} + +// AuthFailureVersions 返回令牌声明版本和数据库当前版本;非版本不匹配时均为 0。 +func (e *TokenValidationError) AuthFailureVersions() (int64, int64) { + return e.TokenVersion, e.CurrentVersion +} + +func newTokenValidationError(reason string, err error) error { + return &TokenValidationError{Reason: reason, err: err} +} + +func newTokenVersionMismatchError(tokenVersion, currentVersion int64) error { + return &TokenValidationError{ + Reason: "token_version_mismatch", + TokenVersion: tokenVersion, + CurrentVersion: currentVersion, + err: ErrTokenVersionMismatch, + } +} + type Service struct { repo *Repository jwt *auth.JWTManager @@ -31,7 +72,7 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.Token } claims, err := s.jwt.ParseSubject(refreshToken, "refresh", "admin") if err != nil { - return nil, ErrInvalidRefreshToken + return nil, newTokenValidationError(auth.TokenFailureReason(err), ErrInvalidRefreshToken) } if s.repo == nil { return nil, ErrDependencyUnavailable @@ -41,7 +82,7 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.Token if errors.Is(err, ErrAdminDisabled) { return nil, ErrAdminDisabled } - return nil, ErrInvalidRefreshToken + return nil, invalidRefreshTokenError(err) } pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion) if err != nil { @@ -50,6 +91,21 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.Token return &pair, nil } +func invalidRefreshTokenError(err error) error { + reason := "invalid" + var tokenErr *TokenValidationError + if errors.As(err, &tokenErr) { + reason = tokenErr.Reason + return &TokenValidationError{ + Reason: reason, + TokenVersion: tokenErr.TokenVersion, + CurrentVersion: tokenErr.CurrentVersion, + err: ErrInvalidRefreshToken, + } + } + return newTokenValidationError(reason, ErrInvalidRefreshToken) +} + func (s *Service) Captcha(ctx context.Context) (*CaptchaDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/adminmgr/repository.go b/backend/internal/modules/adminmgr/repository.go index a3ff463..907a599 100644 --- a/backend/internal/modules/adminmgr/repository.go +++ b/backend/internal/modules/adminmgr/repository.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/adminrole" @@ -105,7 +107,7 @@ func (r *Repository) Update(ctx context.Context, id uint64, req UpdateAdminReque return nil, err } if req.Status != "" { - if err := r.bumpTokenVersion(db, id); err != nil { + if err := r.bumpTokenVersion(db, ctx, id, "admin_status_changed"); err != nil { return nil, err } r.invalidatePermCache(ctx, id) @@ -158,7 +160,7 @@ func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs [] return err } } - return r.bumpTokenVersion(tx, adminID) + return r.bumpTokenVersion(tx, ctx, adminID, "roles_changed") }) if err != nil { return err @@ -186,7 +188,7 @@ func (r *Repository) ChangeOwnPassword(ctx context.Context, id uint64, oldPwd, n }).Error; err != nil { return err } - return r.bumpTokenVersion(tx, id) + return r.bumpTokenVersion(tx, ctx, id, "password_changed") }) } @@ -206,7 +208,7 @@ func (r *Repository) ResetPassword(ctx context.Context, id uint64, newPwd string }).Error; err != nil { return err } - return r.bumpTokenVersion(tx, id) + return r.bumpTokenVersion(tx, ctx, id, "password_reset") }) } @@ -259,10 +261,25 @@ func (r *Repository) invalidatePermCache(ctx context.Context, adminID uint64) { r.redis.Del(ctx, permCacheKey(adminID)) } -func (r *Repository) bumpTokenVersion(db *gorm.DB, adminID uint64) error { - return db.Model(&model.AdminUser{}). +func (r *Repository) bumpTokenVersion(db *gorm.DB, ctx context.Context, adminID uint64, reason string) error { + if err := db.Model(&model.AdminUser{}). Where("id = ?", adminID). - UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error + UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil { + return err + } + bizID := adminID + return auditlog.Append(db, auditlog.Entry{ + ActorType: "admin", + ActorID: logging.AdminIDFromContext(ctx), + Action: "auth.token_version.bump", + BizType: "admin_user", + BizID: &bizID, + Meta: auditlog.Meta{RequestID: logging.RequestIDFromContext(ctx)}, + Detail: map[string]any{ + "target_admin_id": adminID, + "reason": reason, + }, + }) } func permCacheKey(adminID uint64) string { diff --git a/backend/internal/modules/adminrole/repository.go b/backend/internal/modules/adminrole/repository.go index bd2db36..16915fc 100644 --- a/backend/internal/modules/adminrole/repository.go +++ b/backend/internal/modules/adminrole/repository.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/model" "github.com/redis/go-redis/v9" @@ -125,7 +127,7 @@ func (r *Repository) Delete(ctx context.Context, id uint64) error { if err := tx.Delete(&model.Role{}, id).Error; err != nil { return err } - return r.bumpAdminTokenVersions(tx, adminIDs) + return r.bumpAdminTokenVersions(tx, ctx, adminIDs, "role_deleted") }) if err != nil { return err @@ -161,7 +163,7 @@ func (r *Repository) AssignPermissions(ctx context.Context, roleID uint64, permI return err } } - return r.bumpAdminTokenVersions(tx, adminIDs) + return r.bumpAdminTokenVersions(tx, ctx, adminIDs, "role_permissions_changed") }) if err != nil { return err @@ -218,13 +220,33 @@ func (r *Repository) adminIDsByRole(ctx context.Context, roleID uint64) ([]uint6 return ids, err } -func (r *Repository) bumpAdminTokenVersions(tx *gorm.DB, adminIDs []uint64) error { +func (r *Repository) bumpAdminTokenVersions(tx *gorm.DB, ctx context.Context, adminIDs []uint64, reason string) error { if len(adminIDs) == 0 { return nil } - return tx.Model(&model.AdminUser{}). + if err := tx.Model(&model.AdminUser{}). Where("id IN ?", adminIDs). - UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error + UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil { + return err + } + for _, adminID := range adminIDs { + bizID := adminID + if err := auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: logging.AdminIDFromContext(ctx), + Action: "auth.token_version.bump", + BizType: "admin_user", + BizID: &bizID, + Meta: auditlog.Meta{RequestID: logging.RequestIDFromContext(ctx)}, + Detail: map[string]any{ + "target_admin_id": adminID, + "reason": reason, + }, + }); err != nil { + return err + } + } + return nil } func (r *Repository) invalidateAdminPermCaches(ctx context.Context, adminIDs []uint64) { diff --git a/backend/internal/modules/auth/jwt.go b/backend/internal/modules/auth/jwt.go index 64f38f9..eaee74c 100644 --- a/backend/internal/modules/auth/jwt.go +++ b/backend/internal/modules/auth/jwt.go @@ -14,6 +14,34 @@ const ( var ErrInvalidToken = errors.New("invalid token") +// TokenValidationError 仅携带可安全记录的失败类别,不包含原始令牌或签名信息。 +type TokenValidationError struct { + Reason string + err error +} + +func (e *TokenValidationError) Error() string { + return "token validation failed: " + e.Reason +} + +func (e *TokenValidationError) Unwrap() error { + return e.err +} + +// AuthFailureReason 让 HTTP 鉴权层可在不依赖具体认证包的情况下读取失败类别。 +func (e *TokenValidationError) AuthFailureReason() string { + return e.Reason +} + +// TokenFailureReason 返回适合写入日志的令牌校验失败类别。 +func TokenFailureReason(err error) string { + var validationErr *TokenValidationError + if errors.As(err, &validationErr) && validationErr.Reason != "" { + return validationErr.Reason + } + return "invalid" +} + type JWTManager struct { secret []byte accessTTL time.Duration @@ -79,22 +107,44 @@ func (m *JWTManager) ParseSubject(tokenText, expectedType string, expectedSubjec claims := &Claims{} token, err := jwt.ParseWithClaims(tokenText, claims, func(token *jwt.Token) (any, error) { if token.Method != m.signingMethod { - return nil, ErrInvalidToken + return nil, newTokenValidationError("signing_method") } return m.secret, nil }) - if err != nil || !token.Valid { - return nil, ErrInvalidToken + if err != nil { + return nil, newTokenValidationError(jwtFailureReason(err)) + } + if !token.Valid { + return nil, newTokenValidationError("invalid") } if claims.TokenType != expectedType { - return nil, ErrInvalidToken + return nil, newTokenValidationError("token_type") } if expectedSubjectType != "" && claims.SubjectType != expectedSubjectType { - return nil, ErrInvalidToken + return nil, newTokenValidationError("subject_type") } return claims, nil } +func newTokenValidationError(reason string) error { + return &TokenValidationError{Reason: reason, err: ErrInvalidToken} +} + +func jwtFailureReason(err error) string { + switch { + case errors.Is(err, jwt.ErrTokenExpired): + return "expired" + case errors.Is(err, jwt.ErrTokenSignatureInvalid): + return "signature_invalid" + case errors.Is(err, jwt.ErrTokenMalformed): + return "malformed" + case errors.Is(err, jwt.ErrTokenNotValidYet): + return "not_valid_yet" + default: + return "invalid" + } +} + func (m *JWTManager) generate(userID uint64, subject string, subjectType string, tokenType string, tokenVersion int64, ttl time.Duration) (string, error) { now := time.Now() claims := Claims{ diff --git a/backend/internal/modules/auth/jwt_test.go b/backend/internal/modules/auth/jwt_test.go new file mode 100644 index 0000000..a6dfaae --- /dev/null +++ b/backend/internal/modules/auth/jwt_test.go @@ -0,0 +1,62 @@ +package auth + +import ( + "testing" + "time" +) + +func TestParseSubjectReportsSafeFailureReason(t *testing.T) { + manager := NewJWTManager("test-jwt-secret-for-failure-reason") + pair, err := manager.GenerateSubjectPairWithVersion(1, "admin", "admin", 1) + if err != nil { + t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err) + } + + tests := []struct { + name string + manager *JWTManager + token string + tokenType string + want string + }{ + { + name: "签名不匹配", + manager: NewJWTManager("another-jwt-secret-for-failure-reason"), + token: pair.AccessToken, + tokenType: "access", + want: "signature_invalid", + }, + { + name: "令牌类型不匹配", + manager: manager, + token: pair.AccessToken, + tokenType: "refresh", + want: "token_type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.manager.ParseSubject(tt.token, tt.tokenType, "admin") + if err == nil { + t.Fatal("ParseSubject() error = nil") + } + if got := TokenFailureReason(err); got != tt.want { + t.Fatalf("TokenFailureReason() = %q, want %q", got, tt.want) + } + }) + } + + t.Run("令牌已过期", func(t *testing.T) { + expiredManager := NewJWTManager("test-jwt-secret-for-expired-token") + expiredManager.accessTTL = -time.Minute + expiredPair, err := expiredManager.GenerateSubjectPairWithVersion(1, "admin", "admin", 1) + if err != nil { + t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err) + } + _, err = expiredManager.ParseSubject(expiredPair.AccessToken, "access", "admin") + if got := TokenFailureReason(err); got != "expired" { + t.Fatalf("TokenFailureReason() = %q, want %q", got, "expired") + } + }) +} diff --git a/frontend/src/shared/components/business/AuthImage.vue b/frontend/src/shared/components/business/AuthImage.vue index 7443051..91941e7 100644 --- a/frontend/src/shared/components/business/AuthImage.vue +++ b/frontend/src/shared/components/business/AuthImage.vue @@ -35,6 +35,10 @@ const previewURLs = ref([]) const failed = ref(false) const createdURLs: string[] = [] +const maxConcurrentAuthImageLoads = 6 +let activeAuthImageLoads = 0 +const authImageLoadQueue: Array<() => void> = [] + const usePreview = computed(() => !!props.previewSrcList?.length) const effectiveAdmin = computed(() => props.admin || isAdminPath(window.location.pathname)) const fallbackText = computed(() => (failed.value ? '图片加载失败' : '图片加载中')) @@ -42,6 +46,26 @@ const imageStyleValue = computed(() => { if (!props.fit) return props.imageStyle return [props.imageStyle, { objectFit: props.fit }] }) +const previewSignature = computed(() => props.previewSrcList?.join('\u0000') || '') + +function withAuthImageLoadSlot(task: () => Promise): Promise { + return new Promise((resolve, reject) => { + const run = () => { + activeAuthImageLoads++ + task() + .then(resolve, reject) + .finally(() => { + activeAuthImageLoads-- + authImageLoadQueue.shift()?.() + }) + } + if (activeAuthImageLoads < maxConcurrentAuthImageLoads) { + run() + return + } + authImageLoadQueue.push(run) + }) +} function extractObjectKey(value: string) { try { @@ -63,8 +87,9 @@ function shouldFetchAsAdmin(value: string) { async function resolveURL(url: string): Promise { if (!url || !shouldFetchWithAuth(url)) return url const key = extractObjectKey(url) - const blob = - shouldFetchAsAdmin(url) && key ? await fetchAdminFileBlob(key) : await fetchFileBlobByURL(url) + const blob = await withAuthImageLoadSlot(() => + shouldFetchAsAdmin(url) && key ? fetchAdminFileBlob(key) : fetchFileBlobByURL(url) + ) const blobURL = URL.createObjectURL(blob) createdURLs.push(blobURL) return blobURL @@ -87,18 +112,20 @@ async function loadImage() { } try { - imageURL.value = await resolveURL(props.source) + const sourceURL = await resolveURL(props.source) + imageURL.value = sourceURL if (props.previewSrcList?.length) { - previewURLs.value = await Promise.all(props.previewSrcList.map(resolveURL)) + previewURLs.value = await Promise.all( + props.previewSrcList.map(url => (url === props.source ? sourceURL : resolveURL(url))) + ) } } catch { failed.value = true } } -watch(() => [props.source, effectiveAdmin.value, props.previewSrcList] as const, loadImage, { +watch(() => [props.source, effectiveAdmin.value, previewSignature.value] as const, loadImage, { immediate: true, - deep: true, }) onBeforeUnmount(cleanup)