应对管理员访问凭证失守导致的数据外泄风险,新增三层防护: 审计日志(admin-only 可见,用户不可见) - 新增 append-only audit_logs 表(migration 180)+ 异步批量写入 + 保留期清理 - 审计中间件挂在 admin/user/auth/admin-payment 组认证之后:记录所有变更类 请求 + 白名单敏感读取(账号/代理导出、备份下载、admin/user API key 读取) - 请求头凭证首尾掩码;请求体 JSON 递归脱敏(api_key/password 等擦除,base_url 保留以便追责);非 JSON body 不入库 - 无单条删除;全量清空需现场 TOTP 校验、拒绝 admin API key、未启用 2FA 不允许, 清空后同步写入留痕记录 会话 IP/UA 绑定(默认开启,可在系统设置关闭) - JWT 携带 session id + IP/UA 指纹哈希;IP 或 UA 任一变化即撤销会话家族并要求 重新登录;旧 token 无指纹时放行以平滑升级 敏感操作 step-up 2FA(sudo 窗口 15 分钟) - 账号/代理导出、DB 备份创建/下载、S3 目标修改要求近期 TOTP 二次验证;admin API key 一律拒绝;前端 useStepUp 组合式 + TotpStepUpDialog 弹码后自动重试 - API key 查看按需求暂不加强管控 前端:新增 /admin/audit-logs 操作日志页面(筛选/详情/2FA 清空)、侧边栏入口、 step-up 弹窗接入导出与备份流程、安全设置项(绑定开关 + 日志保留天数)、zh/en i18n
112 lines
3.1 KiB
Go
112 lines
3.1 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// NewJWTAuthMiddleware 创建 JWT 认证中间件
|
|
func NewJWTAuthMiddleware(
|
|
authService *service.AuthService,
|
|
userService *service.UserService,
|
|
settingService *service.SettingService,
|
|
auditService *service.AuditLogService,
|
|
) JWTAuthMiddleware {
|
|
return JWTAuthMiddleware(jwtAuth(authService, userService, userService, settingService, auditService))
|
|
}
|
|
|
|
type jwtUserReader interface {
|
|
GetByID(ctx context.Context, id int64) (*service.User, error)
|
|
}
|
|
|
|
type userActivityToucher interface {
|
|
TouchLastActiveForUser(ctx context.Context, user *service.User)
|
|
}
|
|
|
|
// jwtAuth JWT认证中间件实现
|
|
func jwtAuth(
|
|
authService *service.AuthService,
|
|
userService jwtUserReader,
|
|
activityToucher userActivityToucher,
|
|
settingService *service.SettingService,
|
|
auditService *service.AuditLogService,
|
|
) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// 从Authorization header中提取token
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
AbortWithError(c, 401, "UNAUTHORIZED", "Authorization header is required")
|
|
return
|
|
}
|
|
|
|
// 验证Bearer scheme
|
|
parts := strings.SplitN(authHeader, " ", 2)
|
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
|
AbortWithError(c, 401, "INVALID_AUTH_HEADER", "Authorization header format must be 'Bearer {token}'")
|
|
return
|
|
}
|
|
|
|
tokenString := strings.TrimSpace(parts[1])
|
|
if tokenString == "" {
|
|
AbortWithError(c, 401, "EMPTY_TOKEN", "Token cannot be empty")
|
|
return
|
|
}
|
|
|
|
// 验证token
|
|
claims, err := authService.ValidateToken(tokenString)
|
|
if err != nil {
|
|
if errors.Is(err, service.ErrTokenExpired) {
|
|
AbortWithError(c, 401, "TOKEN_EXPIRED", "Token has expired")
|
|
return
|
|
}
|
|
AbortWithError(c, 401, "INVALID_TOKEN", "Invalid token")
|
|
return
|
|
}
|
|
|
|
// 从数据库获取最新的用户信息
|
|
user, err := userService.GetByID(c.Request.Context(), claims.UserID)
|
|
if err != nil {
|
|
AbortWithError(c, 401, "USER_NOT_FOUND", "User not found")
|
|
return
|
|
}
|
|
|
|
// 检查用户状态
|
|
if !user.IsActive() {
|
|
AbortWithError(c, 401, "USER_INACTIVE", "User account is not active")
|
|
return
|
|
}
|
|
|
|
// Security: Validate TokenVersion to ensure token hasn't been invalidated
|
|
// This check ensures tokens issued before a password change are rejected
|
|
if claims.TokenVersion != user.TokenVersion {
|
|
AbortWithError(c, 401, "TOKEN_REVOKED", "Token has been revoked (password changed)")
|
|
return
|
|
}
|
|
|
|
// 会话绑定校验:IP/UA 任一变化即撤销会话(功能可在系统设置中关闭)
|
|
if !enforceSessionBinding(c, authService, settingService, auditService, claims) {
|
|
return
|
|
}
|
|
|
|
c.Set(string(ContextKeyUser), AuthSubject{
|
|
UserID: user.ID,
|
|
Concurrency: user.Concurrency,
|
|
})
|
|
c.Set(string(ContextKeyUserRole), user.Role)
|
|
c.Set(ContextKeyAuthEmail, user.Email)
|
|
c.Set(ContextKeySessionID, claims.SessionID)
|
|
if activityToucher != nil {
|
|
activityToucher.TouchLastActiveForUser(c.Request.Context(), user)
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// Deprecated: prefer GetAuthSubjectFromContext in auth_subject.go.
|