Files
hfb_sys/backend/internal/middleware/auth.go
T

321 lines
9.7 KiB
Go
Raw 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()
}
}
// OptionalAuth 在请求携带有效用户令牌时注入用户上下文,否则按匿名访问继续。
func OptionalAuth(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 == "" {
c.Next()
return
}
claims, err := jwtManager.ParseSubject(tokenText, "access", "user")
if err != nil {
c.Next()
return
}
if validate != nil {
if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil {
c.Next()
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()
}
}