e6956aa001
- React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility
330 lines
9.0 KiB
Go
330 lines
9.0 KiB
Go
package middleware
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/engigu/taskpool/internal/constant"
|
||
"github.com/engigu/taskpool/internal/database"
|
||
"github.com/engigu/taskpool/internal/models"
|
||
"github.com/engigu/taskpool/internal/models/vo"
|
||
"github.com/engigu/taskpool/internal/services"
|
||
"github.com/engigu/taskpool/internal/utils"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// AuthRequired 认证中间件
|
||
func AuthRequired() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
// 基础的 CSRF 防护:校验 Origin/Referer (针对非 GET 请求)
|
||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodOptions && c.Request.Method != http.MethodHead {
|
||
origin := c.GetHeader("Origin")
|
||
if origin == "" {
|
||
origin = c.GetHeader("Referer")
|
||
}
|
||
// 如果有 Origin 且不匹配则拒绝(实际部署时应配置允许的 Origin)
|
||
if origin != "" && !utils.CheckWSOrigin(c.Request) {
|
||
utils.Forbidden(c, "CSRF 校验失败: 非法的请求来源")
|
||
c.Abort()
|
||
return
|
||
}
|
||
}
|
||
|
||
// 检查是否携带互联 Token(支持跨面板远程全接口调用)
|
||
authHeader := c.GetHeader("Authorization")
|
||
if authHeader != "" {
|
||
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||
if tokenStr != "" {
|
||
settingsSvc := services.NewSettingsService()
|
||
interconnectToken := settingsSvc.Get(constant.SectionSite, constant.KeyInterconnectToken)
|
||
parentToken := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentToken)
|
||
|
||
isMatched := false
|
||
h1 := sha256.Sum256([]byte(tokenStr))
|
||
|
||
if interconnectToken != "" {
|
||
h2 := sha256.Sum256([]byte(interconnectToken))
|
||
if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 {
|
||
isMatched = true
|
||
}
|
||
}
|
||
|
||
if !isMatched && parentToken != "" {
|
||
h2 := sha256.Sum256([]byte(parentToken))
|
||
if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 {
|
||
isMatched = true
|
||
}
|
||
}
|
||
|
||
if isMatched {
|
||
// 模拟 Admin 角色
|
||
var adminUser models.User
|
||
res := database.DB.Where("role = ?", constant.AdminRole).Limit(1).Find(&adminUser)
|
||
if res.Error == nil && res.RowsAffected > 0 {
|
||
c.Set("userID", adminUser.ID)
|
||
c.Set("username", adminUser.Username)
|
||
c.Set("role", adminUser.Role)
|
||
c.Next()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
token, err := c.Cookie(constant.CookieName)
|
||
if err != nil || token == "" {
|
||
utils.Unauthorized(c, "请先登录")
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
// 验证 token
|
||
userID, username, tokenVersion, err := utils.ParseToken(token, constant.Secret)
|
||
if err != nil {
|
||
utils.Unauthorized(c, "登录已过期,请重新登录")
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
// 安全增强:校验数据库中该用户的 ID 是否与 Token 一致,并验证 TokenVersion
|
||
var user models.User
|
||
res := database.DB.Where("username = ?", username).Limit(1).Find(&user)
|
||
if res.Error != nil || res.RowsAffected == 0 || user.ID != userID || user.TokenVersion != tokenVersion {
|
||
utils.Unauthorized(c, "会话失效,请重新登录")
|
||
ClearAuthCookie(c)
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
// 将用户信息存入上下文 (必须使用数据库中的最新 ID)
|
||
c.Set("userID", user.ID)
|
||
c.Set("username", user.Username)
|
||
c.Set("role", user.Role)
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// AdminRequired 管理员权限认证中间件
|
||
func AdminRequired() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
role, exists := c.Get("role")
|
||
if !exists || role != constant.AdminRole {
|
||
utils.Forbidden(c, "需要管理员权限")
|
||
c.Abort()
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// OpenapiRequired OpenAPI 认证中间件
|
||
func OpenapiRequired() gin.HandlerFunc {
|
||
settingsSvc := services.NewSettingsService()
|
||
return func(c *gin.Context) {
|
||
if checkOpenapiToken(c, settingsSvc) {
|
||
return
|
||
}
|
||
utils.Unauthorized(c, "无效的 OpenAPI 令牌")
|
||
c.Abort()
|
||
}
|
||
}
|
||
|
||
// checkOpenapiToken 校验 OpenAPI Token
|
||
// 返回 true 表示校验通过并已放行请求
|
||
func checkOpenapiToken(c *gin.Context, settingsSvc *services.SettingsService) bool {
|
||
authHeader := c.GetHeader("Authorization")
|
||
if authHeader == "" {
|
||
return false
|
||
}
|
||
|
||
// 提取 token:支持 "Bearer <token>" 和直接 "<token>" 两种格式
|
||
var openapiToken string
|
||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||
// 标准格式:Bearer <token>
|
||
openapiToken = authHeader[7:]
|
||
} else {
|
||
// 直接使用 token
|
||
openapiToken = authHeader
|
||
}
|
||
|
||
// Token 不能为空
|
||
if openapiToken == "" {
|
||
return false
|
||
}
|
||
|
||
siteConfig := settingsSvc.GetSection(constant.SectionSite)
|
||
tokenJson, ok := siteConfig[constant.KeyOpenapiToken]
|
||
if !ok || tokenJson == "" {
|
||
return false
|
||
}
|
||
|
||
var tokenConfig vo.TokenConfig
|
||
if err := json.Unmarshal([]byte(tokenJson), &tokenConfig); err != nil {
|
||
return false
|
||
}
|
||
|
||
// 校验开启状态
|
||
if !tokenConfig.Enabled {
|
||
return false
|
||
}
|
||
|
||
if tokenConfig.Token == "" {
|
||
return false
|
||
}
|
||
|
||
// 使用恒定时间比较防止时序攻击
|
||
h1 := sha256.Sum256([]byte(openapiToken))
|
||
h2 := sha256.Sum256([]byte(tokenConfig.Token))
|
||
if subtle.ConstantTimeCompare(h1[:], h2[:]) != 1 {
|
||
return false
|
||
}
|
||
|
||
// 检查过期时间
|
||
if tokenConfig.ExpireAt != "" {
|
||
expireDate, err := time.Parse("2006-01-02", tokenConfig.ExpireAt)
|
||
if err == nil {
|
||
expireDate = expireDate.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||
if time.Now().After(expireDate) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
// 模拟 Admin 角色
|
||
var adminUser models.User
|
||
res := database.DB.Where("role = ?", "admin").Limit(1).Find(&adminUser)
|
||
if res.Error != nil || res.RowsAffected == 0 {
|
||
utils.Unauthorized(c, "未找到管理员账户,OpenAPI Token 校验失败")
|
||
c.Abort()
|
||
return true
|
||
}
|
||
|
||
c.Set("userID", adminUser.ID)
|
||
c.Set("username", adminUser.Username)
|
||
c.Set("role", adminUser.Role)
|
||
c.Next()
|
||
return true
|
||
}
|
||
|
||
// SetAuthCookie 设置认证 Cookie,expireDays 为过期天数
|
||
func SetAuthCookie(c *gin.Context, token string, expireDays int) {
|
||
maxAge := 86400 * expireDays
|
||
// 增加 SameSite=Lax 和 Secure 属性(如果环境支持,这里暂时设为 false,但生产建议 true)
|
||
c.SetSameSite(http.SameSiteLaxMode)
|
||
c.SetCookie(constant.CookieName, token, maxAge, "/", "", false, true)
|
||
}
|
||
|
||
// ClearAuthCookie 清除认证 Cookie
|
||
func ClearAuthCookie(c *gin.Context) {
|
||
c.SetCookie(constant.CookieName, "", -1, "/", "", false, true)
|
||
}
|
||
|
||
// SwaggerAuth Swagger 认证中间件 (Basic Auth)
|
||
func SwaggerAuth() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
settingsSvc := services.NewSettingsService()
|
||
siteConfig := settingsSvc.GetSection(constant.SectionSite)
|
||
tokenJson := siteConfig[constant.KeyOpenapiToken]
|
||
|
||
if tokenJson == "" {
|
||
c.Status(http.StatusNotFound)
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
var tokenConfig vo.TokenConfig
|
||
if err := json.Unmarshal([]byte(tokenJson), &tokenConfig); err != nil {
|
||
c.Status(http.StatusNotFound)
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
// 必须开启鉴权开关
|
||
if !tokenConfig.Enabled {
|
||
c.Status(http.StatusNotFound)
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
// 检查过期时间
|
||
if tokenConfig.ExpireAt != "" {
|
||
expire, err := time.ParseInLocation("2006/01/02", tokenConfig.ExpireAt, time.Local)
|
||
if err == nil {
|
||
// 包含当天,所以设置到当天 23:59:59
|
||
expire = expire.Add(24*time.Hour - time.Second)
|
||
if time.Now().After(expire) {
|
||
c.Status(http.StatusNotFound)
|
||
c.Abort()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取请求中携带的凭证
|
||
// 1. URL 参数 token
|
||
// 2. Cookie 中的 openapi_token
|
||
// 3. HTTP Basic Auth
|
||
tokenQuery := c.Query("token")
|
||
tokenCookie, _ := c.Cookie("openapi_token")
|
||
_, password, hasAuth := c.Request.BasicAuth()
|
||
|
||
var providedToken string
|
||
if tokenQuery != "" {
|
||
providedToken = tokenQuery
|
||
} else if tokenCookie != "" {
|
||
providedToken = tokenCookie
|
||
} else if hasAuth {
|
||
providedToken = password
|
||
}
|
||
|
||
// 检查提供的 token 是否匹配
|
||
if providedToken != "" {
|
||
h1 := sha256.Sum256([]byte(providedToken))
|
||
h2 := sha256.Sum256([]byte(tokenConfig.Token))
|
||
if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 {
|
||
// 如果是通过 url 参数进来的,自动将其种入 Cookie,便于后续加载静态资源 (如 json)
|
||
if tokenQuery != "" {
|
||
c.SetCookie("openapi_token", providedToken, 86400, "/openapi", "", false, false)
|
||
}
|
||
c.Next()
|
||
return
|
||
}
|
||
}
|
||
|
||
// 验证失败,不再返回 WWW-Authenticate 头触发浏览器反人类原生弹窗
|
||
// 我们返回 401 的 JSON 或纯文本结构,以便由调用方自行接管鉴权逻辑
|
||
c.JSON(http.StatusUnauthorized, gin.H{
|
||
"code": 401,
|
||
"msg": "OpenAPI 访问未授权或 Token 错误",
|
||
})
|
||
c.Abort()
|
||
}
|
||
}
|
||
|
||
// LocalhostOnly 仅允许本地回环地址访问,并进行简单的内部凭证校验
|
||
func LocalhostOnly() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
ip := c.ClientIP()
|
||
if ip != "127.0.0.1" && ip != "::1" {
|
||
utils.BadRequest(c, "仅允许本地访问")
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
// 简单的内部通信认证
|
||
token := c.GetHeader("X-Internal-Token")
|
||
if token == "" || token != constant.Secret {
|
||
utils.Unauthorized(c, "无效的内部调用凭证")
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
c.Next()
|
||
}
|
||
}
|