Files
yml2213 69774458b0 fix(file): 修复商品详情截图与提现打款凭证等私有图片 403 权限问题
- 放宽公开商品截图 key 提取规则,支持任意合规 object key 转换为免鉴权公开端点
- 补充后台截图上传 listing_screenshot 与 operating-expense 场景识别
- dependencies 授权器支持已审核上架商品截图兜底放行、提现人打款凭证鉴权及变体后缀匹配
- /api/files 路由组支持用户与管理员双重鉴权 (AuthUserOrAdmin)
- 前端收款账号凭证与提现详情凭证统一升级为 AuthImage 组件并支持大图预览
2026-09-16 20:33:09 +08:00

291 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"hfb_sys/backend/internal/logging"
"hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
const (
ContextUserID = "user_id"
ContextPhone = "phone"
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"
ContextAuthFailureDetail = "auth_failure_detail"
AdminAccessCookieName = "hfb_admin_access"
UserAccessCookieName = "hfb_user_access"
)
type AdminTokenContext struct {
Username string
PasswordMustChange bool
}
type AdminTokenValidatorFunc func(ctx context.Context, adminID uint64, tokenVersion int64) (AdminTokenContext, error)
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
func extractBearerToken(c *gin.Context) string {
header := c.GetHeader("Authorization")
tokenText := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if tokenText != "" && tokenText != header {
return tokenText
}
return ""
}
func extractAdminToken(c *gin.Context) (string, string) {
if tokenText := extractBearerToken(c); tokenText != "" {
return tokenText, "bearer"
}
if cookieToken, err := c.Cookie(AdminAccessCookieName); err == nil {
if tokenText := strings.TrimSpace(cookieToken); tokenText != "" {
return tokenText, "cookie"
}
}
return "", "none"
}
func Auth(jwtManager *auth.JWTManager, validate UserTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
tokenText := extractBearerToken(c)
if tokenText == "" {
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
}
}
if tokenText == "" {
response.Unauthorized(c, "缺少访问令牌")
c.Abort()
return
}
claims, err := jwtManager.ParseSubject(tokenText, "access", "user")
if err != nil {
response.Unauthorized(c, "访问令牌无效或已过期")
c.Abort()
return
}
if validate != nil {
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
if errors.Is(err, auth.ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
} else {
response.Unauthorized(c, "登录状态已失效,请重新登录")
}
c.Abort()
return
}
}
c.Set(ContextUserID, claims.UserID)
c.Set(ContextPhone, claims.Phone)
c.Next()
}
}
// AuthUserOrAdmin 允许普通用户或管理员访问共享资源(如私有文件流)。
func AuthUserOrAdmin(jwtManager *auth.JWTManager, userValidate UserTokenValidatorFunc, adminValidate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 优先尝试管理员鉴权(Header Bearer 或 Admin Cookie)
if tokenText, tokenSource := extractAdminToken(c); tokenText != "" {
if claims, err := jwtManager.ParseSubject(tokenText, "access", "admin"); err == nil {
username := claims.Phone
passwordMustChange := false
validAdmin := true
if adminValidate != nil {
tokenContext, err := adminValidate(c.Request.Context(), claims.UserID, claims.TokenVersion)
if err != nil {
RecordAdminAuthFailure(c, "invalid", tokenSource, 0, 0)
validAdmin = false
} else {
username = tokenContext.Username
passwordMustChange = tokenContext.PasswordMustChange
}
}
if validAdmin {
c.Set(ContextAdminID, claims.UserID)
c.Request = c.Request.WithContext(logging.WithAdminID(c.Request.Context(), claims.UserID))
c.Set(ContextUsername, username)
c.Set(ContextPasswordMustChange, passwordMustChange)
c.Next()
return
}
}
}
// 2. 尝试普通用户鉴权(Header Bearer 或 User Cookie)
tokenText := extractBearerToken(c)
if tokenText == "" {
if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil {
tokenText = strings.TrimSpace(cookieToken)
}
}
if tokenText == "" {
response.Unauthorized(c, "缺少访问令牌")
c.Abort()
return
}
claims, err := jwtManager.ParseSubject(tokenText, "access", "user")
if err != nil {
response.Unauthorized(c, "访问令牌无效或已过期")
c.Abort()
return
}
if userValidate != nil {
if err := userValidate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
if errors.Is(err, auth.ErrDependencyUnavailable) {
response.ServiceUnavailable(c, "用户认证服务暂时不可用")
} else {
response.Unauthorized(c, "登录状态已失效,请重新登录")
}
c.Abort()
return
}
}
c.Set(ContextUserID, claims.UserID)
c.Set(ContextPhone, claims.Phone)
c.Next()
}
}
func AdminAuth(jwtManager *auth.JWTManager, validate AdminTokenValidatorFunc) gin.HandlerFunc {
return func(c *gin.Context) {
tokenText, tokenSource := extractAdminToken(c)
if tokenText == "" {
RecordAdminAuthFailure(c, "missing", tokenSource, 0, 0)
response.Unauthorized(c, "缺少后台访问令牌")
c.Abort()
return
}
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, AdminValidationFailureDetail(err))
if AdminValidationUnavailable(err) {
response.ServiceUnavailable(c, "后台认证服务暂时不可用")
c.Abort()
return
}
response.Unauthorized(c, "后台访问令牌无效或已过期")
c.Abort()
return
}
username = tokenContext.Username
passwordMustChange = tokenContext.PasswordMustChange
}
c.Set(ContextUsername, username)
c.Set(ContextPasswordMustChange, passwordMustChange)
c.Next()
}
}
type authFailureReasonCarrier interface {
AuthFailureReason() string
}
type authFailureVersionCarrier interface {
AuthFailureVersions() (int64, int64)
}
type authFailureDetailCarrier interface {
AuthFailureDetail() string
}
type authFailureUnavailableCarrier interface {
AuthFailureUnavailable() bool
}
// 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
}
// AdminValidationFailureDetail 返回仅供服务端日志记录的安全错误摘要。
func AdminValidationFailureDetail(err error) string {
var detailCarrier authFailureDetailCarrier
if errors.As(err, &detailCarrier) {
return detailCarrier.AuthFailureDetail()
}
return ""
}
// AdminValidationUnavailable 判断失败是否由临时依赖故障引起。
func AdminValidationUnavailable(err error) bool {
var unavailableCarrier authFailureUnavailableCarrier
return errors.As(err, &unavailableCarrier) && unavailableCarrier.AuthFailureUnavailable()
}
// RecordAdminAuthFailure 把认证失败诊断字段写入请求上下文,供访问日志统一输出。
func RecordAdminAuthFailure(c *gin.Context, reason, source string, tokenVersion, currentVersion int64, detail ...string) {
c.Set(ContextAuthFailureReason, reason)
c.Set(ContextAuthTokenSource, source)
if tokenVersion != 0 || currentVersion != 0 {
c.Set(ContextAuthTokenVersion, tokenVersion)
c.Set(ContextAuthCurrentVersion, currentVersion)
}
if len(detail) > 0 && detail[0] != "" {
c.Set(ContextAuthFailureDetail, detail[0])
}
}
func RequireAdminPasswordChanged() gin.HandlerFunc {
allowed := map[string]bool{
"/api/admin/me": true,
"/api/admin/auth/logout": true,
"/api/admin/admin-users/me/password": true,
}
return func(c *gin.Context) {
value, ok := c.Get(ContextPasswordMustChange)
if !ok {
c.Next()
return
}
mustChange, ok := value.(bool)
if !ok || !mustChange || allowed[c.FullPath()] {
c.Next()
return
}
response.Error(c, http.StatusForbidden, "password_must_change", "请先修改初始密码")
c.Abort()
}
}