完善掉线诊断并优化二维码加载
This commit is contained in:
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -53,6 +55,7 @@ func main() {
|
|||||||
if err := cfg.ValidateProductionSecurity(); err != nil {
|
if err := cfg.ValidateProductionSecurity(); err != nil {
|
||||||
logger.Fatal("production security config invalid", zap.Error(err))
|
logger.Fatal("production security config invalid", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
logAuthRuntimeIdentity(logger, cfg)
|
||||||
|
|
||||||
var deps router.Dependencies
|
var deps router.Dependencies
|
||||||
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level)
|
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level)
|
||||||
@@ -119,6 +122,20 @@ func main() {
|
|||||||
logger.Info("api server stopped")
|
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 {
|
func newPaymentConfigRepositoryForJobs(cfg config.Config, db *gorm.DB, logger *zap.Logger) *paymentconfig.Repository {
|
||||||
encryptionKey := cfg.PaymentConfigEncryptionKey
|
encryptionKey := cfg.PaymentConfigEncryptionKey
|
||||||
var encryptor paymentconfig.Encryptor
|
var encryptor paymentconfig.Encryptor
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package logging
|
|||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
type requestIDContextKey struct{}
|
type requestIDContextKey struct{}
|
||||||
|
type adminIDContextKey struct{}
|
||||||
|
|
||||||
// WithRequestID 把请求 ID 写入标准 context,供非 HTTP 层日志关联请求链路。
|
// WithRequestID 把请求 ID 写入标准 context,供非 HTTP 层日志关联请求链路。
|
||||||
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
||||||
@@ -23,3 +24,20 @@ func RequestIDFromContext(ctx context.Context) string {
|
|||||||
}
|
}
|
||||||
return value
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/internal/modules/auth"
|
"hfb_sys/backend/internal/modules/auth"
|
||||||
"hfb_sys/backend/pkg/response"
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
@@ -17,6 +19,10 @@ const (
|
|||||||
ContextAdminID = "admin_id"
|
ContextAdminID = "admin_id"
|
||||||
ContextUsername = "username"
|
ContextUsername = "username"
|
||||||
ContextPasswordMustChange = "password_must_change"
|
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"
|
AdminAccessCookieName = "hfb_admin_access"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,12 +74,15 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
|||||||
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
|
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
tokenText := extractBearerToken(c)
|
tokenText := extractBearerToken(c)
|
||||||
|
tokenSource := "bearer"
|
||||||
if tokenText == "" {
|
if tokenText == "" {
|
||||||
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
|
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
|
||||||
tokenText = strings.TrimSpace(cookieToken)
|
tokenText = strings.TrimSpace(cookieToken)
|
||||||
|
tokenSource = "cookie"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tokenText == "" {
|
if tokenText == "" {
|
||||||
|
RecordAdminAuthFailure(c, "missing", "none", 0, 0)
|
||||||
response.Unauthorized(c, "缺少后台访问令牌")
|
response.Unauthorized(c, "缺少后台访问令牌")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
@@ -81,15 +90,20 @@ func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gi
|
|||||||
|
|
||||||
claims, err := jwtManager.ParseSubject(tokenText, "access", "admin")
|
claims, err := jwtManager.ParseSubject(tokenText, "access", "admin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
RecordAdminAuthFailure(c, auth.TokenFailureReason(err), tokenSource, 0, 0)
|
||||||
response.Unauthorized(c, "后台访问令牌无效或已过期")
|
response.Unauthorized(c, "后台访问令牌无效或已过期")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.Set(ContextAdminID, claims.UserID)
|
||||||
|
c.Request = c.Request.WithContext(logging.WithAdminID(c.Request.Context(), claims.UserID))
|
||||||
username := claims.Phone
|
username := claims.Phone
|
||||||
passwordMustChange := false
|
passwordMustChange := false
|
||||||
if validate != nil {
|
if validate != nil {
|
||||||
tokenContext, err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion)
|
tokenContext, err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
reason, tokenVersion, currentVersion := AdminValidationFailure(err)
|
||||||
|
RecordAdminAuthFailure(c, reason, tokenSource, tokenVersion, currentVersion)
|
||||||
response.Unauthorized(c, "后台访问令牌无效或已过期")
|
response.Unauthorized(c, "后台访问令牌无效或已过期")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
@@ -98,13 +112,45 @@ func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gi
|
|||||||
passwordMustChange = tokenContext.PasswordMustChange
|
passwordMustChange = tokenContext.PasswordMustChange
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set(ContextAdminID, claims.UserID)
|
|
||||||
c.Set(ContextUsername, username)
|
c.Set(ContextUsername, username)
|
||||||
c.Set(ContextPasswordMustChange, passwordMustChange)
|
c.Set(ContextPasswordMustChange, passwordMustChange)
|
||||||
c.Next()
|
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 {
|
func RequireAdminPasswordChanged() gin.HandlerFunc {
|
||||||
allowed := map[string]bool{
|
allowed := map[string]bool{
|
||||||
"/api/admin/me": true,
|
"/api/admin/me": true,
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
if adminID, ok := c.Get(ContextAdminID); ok {
|
if adminID, ok := c.Get(ContextAdminID); ok {
|
||||||
fields = append(fields, zap.Any("admin_id", adminID))
|
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 {
|
if len(c.Errors) > 0 {
|
||||||
fields = append(fields, zap.String("errors", strings.TrimSpace(c.Errors.String())))
|
fields = append(fields, zap.String("errors", strings.TrimSpace(c.Errors.String())))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,9 +124,11 @@ func (h *Handler) Refresh(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshToken := strings.TrimSpace(req.RefreshToken)
|
refreshToken := strings.TrimSpace(req.RefreshToken)
|
||||||
|
refreshTokenSource := "body"
|
||||||
if refreshToken == "" {
|
if refreshToken == "" {
|
||||||
if cookieValue, err := c.Cookie(adminRefreshCookieName); err == nil {
|
if cookieValue, err := c.Cookie(adminRefreshCookieName); err == nil {
|
||||||
refreshToken = strings.TrimSpace(cookieValue)
|
refreshToken = strings.TrimSpace(cookieValue)
|
||||||
|
refreshTokenSource = "cookie"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if refreshToken == "" {
|
if refreshToken == "" {
|
||||||
@@ -135,6 +137,8 @@ func (h *Handler) Refresh(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
tokens, err := h.service.Refresh(c.Request.Context(), refreshToken)
|
tokens, err := h.service.Refresh(c.Request.Context(), refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
reason, tokenVersion, currentVersion := middleware.AdminValidationFailure(err)
|
||||||
|
middleware.RecordAdminAuthFailure(c, reason, refreshTokenSource, tokenVersion, currentVersion)
|
||||||
writeAdminAuthError(c, err)
|
writeAdminAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/auditlog"
|
||||||
"hfb_sys/backend/internal/captcha"
|
"hfb_sys/backend/internal/captcha"
|
||||||
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/auth"
|
"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) {
|
func (r *Repository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.AdminUser, error) {
|
||||||
var admin model.AdminUser
|
var admin model.AdminUser
|
||||||
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if admin.Status != "active" {
|
if admin.Status != "active" {
|
||||||
return nil, ErrAdminDisabled
|
return nil, newTokenValidationError("admin_disabled", ErrAdminDisabled)
|
||||||
}
|
}
|
||||||
if admin.TokenVersion <= 0 || admin.TokenVersion != tokenVersion {
|
if admin.TokenVersion <= 0 || admin.TokenVersion != tokenVersion {
|
||||||
return nil, ErrInvalidRefreshToken
|
return nil, newTokenVersionMismatchError(tokenVersion, admin.TokenVersion)
|
||||||
}
|
}
|
||||||
return &admin, nil
|
return &admin, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) RevokeTokens(ctx context.Context, adminID uint64) error {
|
func (r *Repository) RevokeTokens(ctx context.Context, adminID uint64) error {
|
||||||
return r.db.WithContext(ctx).Model(&model.AdminUser{}).
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
Where("id = ?", adminID).
|
if err := tx.Model(&model.AdminUser{}).
|
||||||
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
|
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 {
|
func (r *Repository) verifyCaptcha(ctx context.Context, captchaID string, captchaCode string) error {
|
||||||
|
|||||||
@@ -14,8 +14,49 @@ var (
|
|||||||
ErrAdminDisabled = errors.New("admin disabled")
|
ErrAdminDisabled = errors.New("admin disabled")
|
||||||
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||||
ErrLoginLocked = errors.New("login locked")
|
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 {
|
type Service struct {
|
||||||
repo *Repository
|
repo *Repository
|
||||||
jwt *auth.JWTManager
|
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")
|
claims, err := s.jwt.ParseSubject(refreshToken, "refresh", "admin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrInvalidRefreshToken
|
return nil, newTokenValidationError(auth.TokenFailureReason(err), ErrInvalidRefreshToken)
|
||||||
}
|
}
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
@@ -41,7 +82,7 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.Token
|
|||||||
if errors.Is(err, ErrAdminDisabled) {
|
if errors.Is(err, ErrAdminDisabled) {
|
||||||
return nil, ErrAdminDisabled
|
return nil, ErrAdminDisabled
|
||||||
}
|
}
|
||||||
return nil, ErrInvalidRefreshToken
|
return nil, invalidRefreshTokenError(err)
|
||||||
}
|
}
|
||||||
pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
|
pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,6 +91,21 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.Token
|
|||||||
return &pair, nil
|
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) {
|
func (s *Service) Captcha(ctx context.Context) (*CaptchaDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/auditlog"
|
||||||
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/adminrole"
|
"hfb_sys/backend/internal/modules/adminrole"
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ func (r *Repository) Update(ctx context.Context, id uint64, req UpdateAdminReque
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if req.Status != "" {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
r.invalidatePermCache(ctx, id)
|
r.invalidatePermCache(ctx, id)
|
||||||
@@ -158,7 +160,7 @@ func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs []
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return r.bumpTokenVersion(tx, adminID)
|
return r.bumpTokenVersion(tx, ctx, adminID, "roles_changed")
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -186,7 +188,7 @@ func (r *Repository) ChangeOwnPassword(ctx context.Context, id uint64, oldPwd, n
|
|||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return err
|
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 {
|
}).Error; err != nil {
|
||||||
return err
|
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))
|
r.redis.Del(ctx, permCacheKey(adminID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) bumpTokenVersion(db *gorm.DB, adminID uint64) error {
|
func (r *Repository) bumpTokenVersion(db *gorm.DB, ctx context.Context, adminID uint64, reason string) error {
|
||||||
return db.Model(&model.AdminUser{}).
|
if err := db.Model(&model.AdminUser{}).
|
||||||
Where("id = ?", adminID).
|
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 {
|
func permCacheKey(adminID uint64) string {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/auditlog"
|
||||||
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"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 {
|
if err := tx.Delete(&model.Role{}, id).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return r.bumpAdminTokenVersions(tx, adminIDs)
|
return r.bumpAdminTokenVersions(tx, ctx, adminIDs, "role_deleted")
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -161,7 +163,7 @@ func (r *Repository) AssignPermissions(ctx context.Context, roleID uint64, permI
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return r.bumpAdminTokenVersions(tx, adminIDs)
|
return r.bumpAdminTokenVersions(tx, ctx, adminIDs, "role_permissions_changed")
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -218,13 +220,33 @@ func (r *Repository) adminIDsByRole(ctx context.Context, roleID uint64) ([]uint6
|
|||||||
return ids, err
|
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 {
|
if len(adminIDs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return tx.Model(&model.AdminUser{}).
|
if err := tx.Model(&model.AdminUser{}).
|
||||||
Where("id IN ?", adminIDs).
|
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) {
|
func (r *Repository) invalidateAdminPermCaches(ctx context.Context, adminIDs []uint64) {
|
||||||
|
|||||||
@@ -14,6 +14,34 @@ const (
|
|||||||
|
|
||||||
var ErrInvalidToken = errors.New("invalid token")
|
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 {
|
type JWTManager struct {
|
||||||
secret []byte
|
secret []byte
|
||||||
accessTTL time.Duration
|
accessTTL time.Duration
|
||||||
@@ -79,22 +107,44 @@ func (m *JWTManager) ParseSubject(tokenText, expectedType string, expectedSubjec
|
|||||||
claims := &Claims{}
|
claims := &Claims{}
|
||||||
token, err := jwt.ParseWithClaims(tokenText, claims, func(token *jwt.Token) (any, error) {
|
token, err := jwt.ParseWithClaims(tokenText, claims, func(token *jwt.Token) (any, error) {
|
||||||
if token.Method != m.signingMethod {
|
if token.Method != m.signingMethod {
|
||||||
return nil, ErrInvalidToken
|
return nil, newTokenValidationError("signing_method")
|
||||||
}
|
}
|
||||||
return m.secret, nil
|
return m.secret, nil
|
||||||
})
|
})
|
||||||
if err != nil || !token.Valid {
|
if err != nil {
|
||||||
return nil, ErrInvalidToken
|
return nil, newTokenValidationError(jwtFailureReason(err))
|
||||||
|
}
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, newTokenValidationError("invalid")
|
||||||
}
|
}
|
||||||
if claims.TokenType != expectedType {
|
if claims.TokenType != expectedType {
|
||||||
return nil, ErrInvalidToken
|
return nil, newTokenValidationError("token_type")
|
||||||
}
|
}
|
||||||
if expectedSubjectType != "" && claims.SubjectType != expectedSubjectType {
|
if expectedSubjectType != "" && claims.SubjectType != expectedSubjectType {
|
||||||
return nil, ErrInvalidToken
|
return nil, newTokenValidationError("subject_type")
|
||||||
}
|
}
|
||||||
return claims, nil
|
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) {
|
func (m *JWTManager) generate(userID uint64, subject string, subjectType string, tokenType string, tokenVersion int64, ttl time.Duration) (string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,6 +35,10 @@ const previewURLs = ref<string[]>([])
|
|||||||
const failed = ref(false)
|
const failed = ref(false)
|
||||||
const createdURLs: string[] = []
|
const createdURLs: string[] = []
|
||||||
|
|
||||||
|
const maxConcurrentAuthImageLoads = 6
|
||||||
|
let activeAuthImageLoads = 0
|
||||||
|
const authImageLoadQueue: Array<() => void> = []
|
||||||
|
|
||||||
const usePreview = computed(() => !!props.previewSrcList?.length)
|
const usePreview = computed(() => !!props.previewSrcList?.length)
|
||||||
const effectiveAdmin = computed(() => props.admin || isAdminPath(window.location.pathname))
|
const effectiveAdmin = computed(() => props.admin || isAdminPath(window.location.pathname))
|
||||||
const fallbackText = computed(() => (failed.value ? '图片加载失败' : '图片加载中'))
|
const fallbackText = computed(() => (failed.value ? '图片加载失败' : '图片加载中'))
|
||||||
@@ -42,6 +46,26 @@ const imageStyleValue = computed(() => {
|
|||||||
if (!props.fit) return props.imageStyle
|
if (!props.fit) return props.imageStyle
|
||||||
return [props.imageStyle, { objectFit: props.fit }]
|
return [props.imageStyle, { objectFit: props.fit }]
|
||||||
})
|
})
|
||||||
|
const previewSignature = computed(() => props.previewSrcList?.join('\u0000') || '')
|
||||||
|
|
||||||
|
function withAuthImageLoadSlot<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
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) {
|
function extractObjectKey(value: string) {
|
||||||
try {
|
try {
|
||||||
@@ -63,8 +87,9 @@ function shouldFetchAsAdmin(value: string) {
|
|||||||
async function resolveURL(url: string): Promise<string> {
|
async function resolveURL(url: string): Promise<string> {
|
||||||
if (!url || !shouldFetchWithAuth(url)) return url
|
if (!url || !shouldFetchWithAuth(url)) return url
|
||||||
const key = extractObjectKey(url)
|
const key = extractObjectKey(url)
|
||||||
const blob =
|
const blob = await withAuthImageLoadSlot(() =>
|
||||||
shouldFetchAsAdmin(url) && key ? await fetchAdminFileBlob(key) : await fetchFileBlobByURL(url)
|
shouldFetchAsAdmin(url) && key ? fetchAdminFileBlob(key) : fetchFileBlobByURL(url)
|
||||||
|
)
|
||||||
const blobURL = URL.createObjectURL(blob)
|
const blobURL = URL.createObjectURL(blob)
|
||||||
createdURLs.push(blobURL)
|
createdURLs.push(blobURL)
|
||||||
return blobURL
|
return blobURL
|
||||||
@@ -87,18 +112,20 @@ async function loadImage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
imageURL.value = await resolveURL(props.source)
|
const sourceURL = await resolveURL(props.source)
|
||||||
|
imageURL.value = sourceURL
|
||||||
if (props.previewSrcList?.length) {
|
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 {
|
} catch {
|
||||||
failed.value = true
|
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,
|
immediate: true,
|
||||||
deep: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(cleanup)
|
onBeforeUnmount(cleanup)
|
||||||
|
|||||||
Reference in New Issue
Block a user