Files
SamWaf/middleware/auth_api_check.go
T

287 lines
11 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 (
"SamWaf/common/zlog"
"SamWaf/enums"
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/common/response"
"SamWaf/service/waf_service"
"SamWaf/utils"
"SamWaf/wafhostguard"
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
)
var (
wafTokenInfoService = waf_service.WafTokenInfoServiceApp
wafOtpService = waf_service.WafOtpServiceApp
)
// TokenOnlyAuth 仅允许后台 Token 登录访问,拒绝 OpenAPI Key 方式
// 用于保护「开放平台管理」类接口(Key管理、调用日志、API文档),防止通过 API Key 自行操作
func TokenOnlyAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetHeader("X-API-Key") != "" {
response.AuthFailWithMessage("该接口不允许通过 API Key 访问,请使用后台账号登录", c)
c.Abort()
return
}
// 复用 Token 校验逻辑
Auth()(c)
}
}
// Auth 鉴权中间件
func Auth() gin.HandlerFunc {
innerName := "Auth"
return func(c *gin.Context) {
// 优先检查 OpenAPI Key(X-API-Key)
apiKey := c.GetHeader("X-API-Key")
if apiKey != "" {
_, ok := ValidateOpenApiKey(c, apiKey)
if !ok {
c.Abort()
return
}
c.Next()
return
}
// 获取请求头中 token,实际是一个完整被签名过的 token;a complete, signed token
tokenStr := extractTokenStr(c)
if tokenStr == "" {
zlog.Debug("无token")
response.AuthFailWithMessage("鉴权失败", c)
c.Abort()
return
} else {
//检查是否存在
isTokenExist := global.GCACHE_WAFCACHE.IsKeyExist(enums.CACHE_TOKEN + tokenStr)
if !isTokenExist {
// 这条分支以前一行日志都没有,用户遇到"登录后无限跳登录页"时服务端完全是黑盒(issue #938)。
// 令牌只打前 8 位,避免把可用凭证写进日志。
zlog.Debug(fmt.Sprintf("令牌不在缓存中(已过期/进程重启/与签发进程不是同一个) token:%v... path:%v 来源IP:%v",
utils.TruncateString(tokenStr, 8), c.Request.URL.Path, utils.GetManageClientIP(c)))
response.AuthFailWithMessage("令牌过期", c)
c.Abort()
return
} else {
var tokenInfo model.TokenInfo
if err := global.GCACHE_WAFCACHE.GetAs(enums.CACHE_TOKEN+tokenStr, &tokenInfo); err != nil {
zlog.Error(fmt.Sprintf("令牌缓存内容解析失败 token:%v... err:%v", utils.TruncateString(tokenStr, 8), err))
response.AuthFailWithMessage("令牌解析失败", c)
c.Abort()
return
}
// IP检查逻辑
currentIP := utils.GetManageClientIP(c)
ipMatched := false
// 如果启用严格IP绑定,进行严格IP检查
if global.GCONFIG_ENABLE_STRICT_IP_BINDING == 1 {
if tokenInfo.LoginIp == currentIP {
ipMatched = true
} else {
ipMatched = false
}
} else {
ipMatched = true
}
// 指纹检查逻辑
// 豁免路径:WebSocket 握手、SSE、带查询串令牌的下载。这三类请求由浏览器直接发起,
// Accept-Encoding/Accept-Language 与普通 XHR 并不一致,参与指纹比对必然误判。
fingerprintMatched := true
if global.GCONFIG_ENABLE_DEVICE_FINGERPRINT == 1 && tokenInfo.DeviceFingerprint != "" &&
!isFingerprintExemptPath(c) {
currentFingerprint := utils.GenerateFingerprint(c.Request)
if tokenInfo.DeviceFingerprint == currentFingerprint {
fingerprintMatched = true
} else {
fingerprintMatched = false
}
}
// 如果指纹不匹配,则拒绝请求
// 只拒本次请求,不再直接删令牌:反向代理/CDN 改写请求头、浏览器升级等都会造成一次性不匹配,
// 一次不匹配就作废整个会话会让用户莫名其妙被登出(issue #938/#930)。
// 连续 bindFailThreshold 次不匹配才判定为真的异常并作废会话。
if !fingerprintMatched {
zlog.Error(fmt.Sprintf("设备指纹不匹配,请求拒绝。原IP:%v 当前IP:%v 原指纹:%v 当前指纹:%v",
tokenInfo.LoginIp, currentIP, tokenInfo.DeviceFingerprint, utils.GenerateFingerprint(c.Request)))
if bumpTokenBindFailure(tokenStr) {
global.GCACHE_WAFCACHE.Remove(enums.CACHE_TOKEN + tokenStr)
response.AuthFailWithMessage("设备验证失败,需要重新登录", c)
} else {
response.AuthFailWithMessage("设备验证失败,请重试", c)
}
c.Abort()
return
}
// N11 修复
// 开启严格IP绑定即要求令牌与登录时的真实 IP 一致,IP 变化需重新登录。
// 同样改为"连续多次不匹配才作废":动态IP、双栈 IPv4/IPv6 交替、多出口 NAT 都会偶发不匹配。
if !ipMatched {
zlog.Warn(fmt.Sprintf("严格IP绑定不匹配,请求拒绝。原IP:%v 当前IP:%v", tokenInfo.LoginIp, currentIP))
if bumpTokenBindFailure(tokenStr) {
global.GCACHE_WAFCACHE.Remove(enums.CACHE_TOKEN + tokenStr)
response.AuthFailWithMessage("登录环境已变化(IP),需要重新登录", c)
} else {
response.AuthFailWithMessage("登录环境已变化(IP),请重试", c)
}
c.Abort()
return
}
// 走到这说明绑定校验全过,清掉之前累计的失败次数,避免跨越很长时间的零星失败被累加成作废
clearTokenBindFailure(tokenStr)
// 记录"当前正在使用管理端的IP",供主机防爆破豁免。
// 这是防误封的最后一道保险:只要你还在用管理端,你的出口IP就永远进不了封禁名单,
// 哪怕白名单一个字都没配。放在这里是因为走到这说明令牌、指纹、IP绑定都已校验通过。
wafhostguard.TouchAdminIP(currentIP)
//刷新token时间
if global.GWAF_RELEASE == "false" {
tokenList := global.GCACHE_WAFCACHE.ListAvailableKeysWithPrefix(enums.CACHE_TOKEN)
for _, duration := range tokenList {
remainTime := fmt.Sprintf("%02d时%02d分", int(duration.Hours()), int(duration.Minutes())%60)
zlog.Debug(fmt.Sprintf("%v 当前token有效缓存剩余时间 %v", innerName, remainTime))
}
}
expireTime, err := global.GCACHE_WAFCACHE.GetExpireTime(enums.CACHE_TOKEN + tokenStr)
if err == nil {
remainingTime := time.Until(expireTime) // 计算剩余有效时间
if remainingTime > 0 && remainingTime < 2*time.Minute {
zlog.Debug(fmt.Sprintf("%v 当前token有效缓存剩余时间 %v 小于2分钟进行缓存可用时间延期处理", innerName, expireTime))
global.GCACHE_WAFCACHE.SetWithTTlRenewTime(enums.CACHE_TOKEN+tokenStr, tokenInfo, time.Duration(global.GCONFIG_RECORD_TOKEN_EXPIRE_MINTUTES)*time.Minute)
}
}
//强制改密门:需改密的令牌只放行改密/注销/ws,其余一律拦截(默认口令/被重置账号在改密前拿不到其他权限)
if tokenInfo.NeedChangePassword == 1 {
p := c.Request.URL.Path
if p != "/api/v1/account/changemypwd" && p != "/api/v1/logout" && p != "/api/v1/ws" {
zlog.Debug(fmt.Sprintf("令牌处于强制改密状态,拦截其他接口 账号:%v path:%v", tokenInfo.LoginAccount, p))
response.NeedChangePwdWithMessage("请先修改初始/重置密码后再进行其他操作", c)
c.Abort()
return
}
}
//检测是否强制2Fa绑定
if global.GCONFIG_RECORD_FORCE_BIND_2FA == 1 && c.Request.URL.Path != "/api/v1/ws" && c.Request.URL.Path != "/api/v1/logout" {
otpBean := wafOtpService.GetDetailByUserNameApi(tokenInfo.LoginAccount)
if otpBean.UserName == "" {
//需要强制跳转2fa绑定界面
response.NeedBind2FAWithMessage("系统已开启强制 【双因素认证】 ,请进行绑定", c)
c.Abort()
return
}
}
// 将登录账号写入 context,供下游(变更记录、安全审计)取用
c.Set("loginAccount", tokenInfo.LoginAccount)
c.Set("loginIP", currentIP)
// 写入角色,供 RBAC 鉴权中间件判定(空角色兜底为超级管理员,向后兼容)
c.Set("userRole", enums.NormalizeRole(tokenInfo.Role))
}
}
// 这里执行路由 HandlerFunc
c.Next()
}
}
// bindFailThreshold 令牌绑定校验(设备指纹/严格IP)连续失败多少次后作废会话。
const bindFailThreshold = 3
// bindFailCounterMaxTTL 绑定失败计数的存活上限。
// 计数只是为了识别"短时间内连续失败",不需要跟令牌同寿命:
// 令牌有效期可以被设成不限制(按1年封顶),计数若也活一年,早已失效的令牌会在内存里留一年垃圾。
const bindFailCounterMaxTTL = 30 * time.Minute
// bindFailCounterTTL 取"令牌有效期"与上限中的较小者
func bindFailCounterTTL() time.Duration {
ttl := time.Duration(global.GCONFIG_RECORD_TOKEN_EXPIRE_MINTUTES) * time.Minute
if ttl <= 0 || ttl > bindFailCounterMaxTTL {
return bindFailCounterMaxTTL
}
return ttl
}
// extractTokenStr 按请求路径挑选令牌来源。
//
// 判定一律基于 URL.Path,**不能用 RequestURI**——后者带查询串,给这两条路由加任何
// 查询参数(例如 WebSocket 的 ?keyid=)都会让相等判断失配,于是落到常规请求头分支:
// WebSocket 握手带不了自定义头,令牌取空,连接被判鉴权失败,页面表现为收不到通知。
//
// - /api/v1/ws 令牌在 Sec-WebSocket-Protocol(浏览器 WS API 只能这样带)
// - /api/v1/waflog/attack/download 令牌在查询串(下载走 window.open,同样带不了头)
// - 其余 常规请求头,移动端与 Web 各用各的
func extractTokenStr(c *gin.Context) string {
reqPath := c.Request.URL.Path
if reqPath == "/api/v1/ws" {
return c.GetHeader("Sec-WebSocket-Protocol")
}
if strings.HasPrefix(reqPath, "/api/v1/waflog/attack/download") {
return c.Query("X-Token")
}
if c.GetHeader("X-Login-Type") == "mobile" {
return c.GetHeader("X-Mobile-Token")
}
return c.GetHeader("X-Token")
}
// isFingerprintExemptPath 判断当前请求是否豁免设备指纹比对。
// WebSocket 握手、SSE(text/event-stream)、带查询串令牌的下载都由浏览器按各自的规则发头,
// 与页面里 XHR 的 Accept-Encoding/Accept-Language 天然不同,参与比对只会误杀。
func isFingerprintExemptPath(c *gin.Context) bool {
if strings.EqualFold(c.GetHeader("Upgrade"), "websocket") {
return true
}
if strings.Contains(strings.ToLower(c.GetHeader("Accept")), "text/event-stream") {
return true
}
uri := c.Request.URL.Path
if uri == "/api/v1/ws" || strings.HasPrefix(uri, "/api/v1/waflog/attack/download") {
return true
}
return false
}
// bumpTokenBindFailure 累加该令牌的绑定校验失败次数,返回是否已达阈值(达到则调用方作废会话)。
// 计数带 TTL,跟令牌有效期同寿命,过期自然清零。
func bumpTokenBindFailure(tokenStr string) bool {
key := enums.CACHE_TOKEN_BINDFAIL + tokenStr
count := 0
if global.GCACHE_WAFCACHE.IsKeyExist(key) {
if v, err := global.GCACHE_WAFCACHE.GetInt(key); err == nil {
count = v
}
}
count++
if count >= bindFailThreshold {
global.GCACHE_WAFCACHE.Remove(key)
return true
}
global.GCACHE_WAFCACHE.SetWithTTlRenewTime(key, count, bindFailCounterTTL())
return false
}
// clearTokenBindFailure 绑定校验通过后清零失败计数
func clearTokenBindFailure(tokenStr string) {
key := enums.CACHE_TOKEN_BINDFAIL + tokenStr
if global.GCACHE_WAFCACHE.IsKeyExist(key) {
global.GCACHE_WAFCACHE.Remove(key)
}
}