feat:website password session lifetime and online session management

#987
This commit is contained in:
samwaf
2026-09-06 07:32:04 +08:00
parent c379f41729
commit c3b263b7f3
24 changed files with 1255 additions and 77 deletions
+2
View File
@@ -42,6 +42,7 @@ type APIGroup struct {
WafSslOrderApi
WafSslExpireApi
WafHttpAuthBaseApi
WafHttpAuthSessionApi
WafTaskApi
WafBlockingPageApi
WafGPTApi
@@ -134,6 +135,7 @@ var (
wafSslExpireService = waf_service.WafSslExpireServiceApp
wafHttpAuthBaseService = waf_service.WafHttpAuthBaseServiceApp
wafHttpAuthSessionService = waf_service.WafHttpAuthSessionServiceApp
wafTaskService = waf_service.WafTaskServiceApp
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"SamWaf/model/common/response"
"SamWaf/model/request"
"github.com/gin-gonic/gin"
)
// WafHttpAuthSessionApi 网站密码访问的在线会话管理。
//
// 与「统一访问认证-会话」不是一套东西:那边是全局认证中心的会话,这边是每个站点自己
// 那道门后面的会话,账号体系与开关都各自独立,所以接口、表、缓存 keyspace 全部分开。
type WafHttpAuthSessionApi struct {
}
// GetListApi 获取某站点的在线会话列表
// @Summary 获取网站密码访问的在线会话列表
// @Tags 网站密码访问-会话
// @Accept json
// @Produce json
// @Param data body request.WafHttpAuthSessionSearchReq true "分页查询参数,host_code 必填"
// @Success 200 {object} response.Response{data=response.PageResult} "获取成功"
// @Security ApiKeyAuth
// @Router /wafhost/httpauthsession/list [post]
func (w *WafHttpAuthSessionApi) GetListApi(c *gin.Context) {
var req request.WafHttpAuthSessionSearchReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
list, total, err := wafHttpAuthSessionService.GetListApi(req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
PageIndex: req.PageIndex,
PageSize: req.PageSize,
}, "获取成功", c)
}
// KickApi 踢下线单条会话
// @Summary 踢下线指定会话
// @Description 因存在最长60秒的正向缓存,最迟60秒生效;浏览器弹窗(Basic)方式下表现为强制重新输入一次密码
// @Tags 网站密码访问-会话
// @Produce json
// @Param id query string true "会话ID"
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /wafhost/httpauthsession/kick [get]
func (w *WafHttpAuthSessionApi) KickApi(c *gin.Context) {
var req request.WafHttpAuthSessionKickReq
if err := c.ShouldBind(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
if err := wafHttpAuthSessionService.KickApi(req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("已下线(最迟60秒内生效)", c)
}
// KickByUserApi 按用户批量踢下线
// @Summary 踢下线指定用户在本站点的全部会话
// @Tags 网站密码访问-会话
// @Accept json
// @Produce json
// @Param data body request.WafHttpAuthSessionKickByUserReq true "站点编码与用户名"
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /wafhost/httpauthsession/kickbyuser [post]
func (w *WafHttpAuthSessionApi) KickByUserApi(c *gin.Context) {
var req request.WafHttpAuthSessionKickByUserReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
cnt, err := wafHttpAuthSessionService.KickByUserApi(req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithDetailed(gin.H{"count": cnt}, "已下线(最迟60秒内生效)", c)
}
// KickAllApi 清空本站点的全部会话
// @Summary 踢下线本站点的全部在线会话
// @Description 应急手段:疑似密码泄露时,一次性让本站点所有人重新登录
// @Tags 网站密码访问-会话
// @Accept json
// @Produce json
// @Param data body request.WafHttpAuthSessionKickAllReq true "站点编码"
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /wafhost/httpauthsession/kickall [post]
func (w *WafHttpAuthSessionApi) KickAllApi(c *gin.Context) {
var req request.WafHttpAuthSessionKickAllReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
cnt, err := wafHttpAuthSessionService.KickAllApi(req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithDetailed(gin.H{"count": cnt}, "已全部下线(最迟60秒内生效)", c)
}
+1
View File
@@ -525,6 +525,7 @@ func (m *wafSystenService) run() {
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_STATS_DATA_CLEANUP, waftask.TaskStatsDataCleanup)
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_THREAT_IP_SYNC, waftask.TaskThreatIPSync)
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_ACCESS_CLEAN, waftask.TaskAccessClean)
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_HTTPAUTH_CLEAN, waftask.TaskHttpAuthClean)
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_HOSTGUARD_CLEAN_EXPIRED, waftask.TaskHostGuardCleanExpired)
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_TRAFFIC_FLUSH, waftask.TaskTrafficFlush)
+10
View File
@@ -31,6 +31,16 @@ const (
CACHE_ACCESS_AUDIT = "CACHE_ACCESS_AUDIT_" //审计节流标记,防止 denied 事件把审计表刷爆
CACHE_ACCESS_NOTIFY = "CACHE_ACCESS_NOTIFY_" //通知节流标记,审计表扛得住高频,用户的钉钉/邮箱扛不住
// —— 网站密码访问(站点级 Basic/自定义登录页) ——
// 与上面的 Access 模式是两套独立功能,keyspace 也必须分开:
// 前者是全局统一认证,这里是每个站点自己的一道门,账号体系互不相干。
// 会话真相源同样是数据库,缓存只做热路径;正向缓存 TTL 即「踢下线」的最坏生效延迟。
CACHE_HTTPAUTH_SESSION = "CACHE_HTTPAUTH_SESSION_" //会话正向缓存,键后缀是 hostCode:token_code
CACHE_HTTPAUTH_BAD = "CACHE_HTTPAUTH_BAD_" //无效令牌负向缓存,挡住拿废弃 Cookie 反复打库的请求
CACHE_HTTPAUTH_TOUCH = "CACHE_HTTPAUTH_TOUCH_" //last_active 刷新节流标记,避免每个请求写一次库
CACHE_HTTPAUTH_KICK = "CACHE_HTTPAUTH_KICK_" //Basic 模式踢下线窗口,值是 realm nonce,见 waf_httpauthsession_service.go
CACHE_HTTPAUTH_AUDIT = "CACHE_HTTPAUTH_AUDIT_" //审计节流标记,防止未登录拦截把审计表刷爆
// —— 主机远程登录爆破防护(SSH/RDP) ——
// 失败计数刻意不复用 CACHE_IP_FAILURE_PRE:那个 keyspace 会被自定义规则的
// MF.GetIPFailureCount(minutes) 读取,把 SSH 失败混进去会静默改变用户已有 WAF 规则的语义
+1
View File
@@ -27,6 +27,7 @@ const (
TASK_STATS_DATA_CLEANUP = "task_stats_data_cleanup" //清理统计数据(按保留策略)
TASK_THREAT_IP_SYNC = "task_threat_ip_sync" //威胁情报IP订阅同步
TASK_ACCESS_CLEAN = "task_access_clean" //统一访问认证:清理过期会话/令牌/票据与审计日志
TASK_HTTPAUTH_CLEAN = "task_httpauth_clean" //网站密码访问:标记到期会话并清理历史行
TASK_HOSTGUARD_CLEAN_EXPIRED = "task_hostguard_clean_expired" //主机防爆破:解封到期封禁(每分钟,因最短阶梯只有5分钟)
TASK_TRAFFIC_FLUSH = "task_traffic_flush" //站点流量计量落库(30秒一次,引擎侧字节计量与日志解耦)
)
+60
View File
@@ -39,6 +39,7 @@ type Hosts struct {
IsEnableHttpAuthBase int `json:"is_enable_http_auth_base"` //是否 HTTPAuthBase 1 激活 非1 没有激活
HttpAuthBaseType string `gorm:"size:50" json:"http_auth_base_type"` //认证类型 authorization(默认Basic Auth) custom(自定义页面)
HttpAuthPathPrefix string `gorm:"size:255" json:"http_auth_path_prefix"` //HTTP认证路径前缀,用于隐藏系统特征,默认为随机生成
HttpAuthJSON string `gorm:"type:text" json:"http_auth_json"` //网站密码访问的会话时效配置 json(有效期/空闲超时/绑定登录IP),空=按 DecodeHttpAuthConfig 的默认值
ResponseTimeOut int `json:"response_time_out"` //响应超时时间 默认60秒,为0则无限等待
HealthyJSON string `gorm:"type:text" json:"healthy_json"` //后端健康度检测 json
InsecureSkipVerify int `json:"insecure_skip_verify"` //是否开启后端https证书有效性验证 默认 0 是校验 1 是不校验
@@ -727,3 +728,62 @@ func GetClientIPByMode(ipMode string, netSrcIp string, srcIP string) string {
// 默认使用网卡模式
return netSrcIp
}
// HttpAuthConfig 「网站密码访问」的会话时效配置(hosts.HttpAuthJSON)。
//
// 兼容硬约束:HttpAuthJSON 为空串时(全部存量站点),DecodeHttpAuthConfig 必须还原成
// 「24 小时绝对有效期 + 绑定登录 IP + 不启用空闲超时」,即与加这套配置之前的行为逐条一致。
// 数值字段用 FlexInt 是因为前端表单回传的是字符串,普通 int 会让该字段悄悄回落默认值。
type HttpAuthConfig struct {
SessionTTL FlexInt `json:"session_ttl"` // 绝对有效期(分钟),<=0 视为默认 1440
IdleTimeout FlexInt `json:"idle_timeout"` // 空闲超时(分钟),0=不启用
BindIP FlexInt `json:"bind_ip"` // 1=登录令牌绑定登录时的 IP(默认) 0=不绑
}
// 默认值。DefaultHttpAuthSessionTTL 对齐改造前硬编码的 24 小时。
const (
DefaultHttpAuthSessionTTL = 1440 // 分钟
)
// DecodeHttpAuthConfig 解析站点的 http_auth_json。
//
// 空串、非法 JSON、字段缺省一律回落到「等价现状」而不是零值:
// 时效为 0 会让所有人一登录就掉线,BindIP 为 0 会静默放宽一条既有约束——
// 两者都属于「解析失败反而改变了防护行为」,这里不允许发生。
func DecodeHttpAuthConfig(raw string) HttpAuthConfig {
cfg := HttpAuthConfig{
SessionTTL: DefaultHttpAuthSessionTTL,
IdleTimeout: 0,
BindIP: 1,
}
if strings.TrimSpace(raw) == "" {
return cfg
}
var parsed HttpAuthConfig
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return cfg
}
if parsed.SessionTTL > 0 {
cfg.SessionTTL = parsed.SessionTTL
}
if parsed.IdleTimeout > 0 {
cfg.IdleTimeout = parsed.IdleTimeout
}
// BindIP 是显式三态:JSON 里给了 0 就是「用户主动关掉」,不能当成缺省再拉回 1。
// 但整份 JSON 都没这个键时(老配置升级上来)必须保持 1,所以靠下面这次单独探测区分。
cfg.BindIP = parsed.BindIP
if !jsonHasKey(raw, "bind_ip") {
cfg.BindIP = 1
}
return cfg
}
// jsonHasKey 判断顶层是否显式出现过某个键,用于区分「用户填了 0」与「压根没这个字段」。
func jsonHasKey(raw, key string) bool {
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return false
}
_, ok := m[key]
return ok
}
+55
View File
@@ -0,0 +1,55 @@
package model
import "testing"
// TestDecodeHttpAuthConfigCompat 钉死向后兼容:所有存量站点的 http_auth_json 都是空串,
// 解析结果必须等价于加这套配置之前的硬编码行为——24 小时有效期、绑定登录 IP、不做空闲超时。
// 这条一旦破掉,就是升级当天全量站点的访问行为静默漂移。
func TestDecodeHttpAuthConfigCompat(t *testing.T) {
for _, raw := range []string{"", " ", "{}", "not a json", "[]"} {
cfg := DecodeHttpAuthConfig(raw)
if cfg.SessionTTL != DefaultHttpAuthSessionTTL {
t.Errorf("raw=%q SessionTTL=%d, 期望 %d", raw, cfg.SessionTTL, DefaultHttpAuthSessionTTL)
}
if cfg.IdleTimeout != 0 {
t.Errorf("raw=%q IdleTimeout=%d, 期望 0(不启用)", raw, cfg.IdleTimeout)
}
if cfg.BindIP != 1 {
t.Errorf("raw=%q BindIP=%d, 期望 1(绑定)", raw, cfg.BindIP)
}
}
}
func TestDecodeHttpAuthConfig(t *testing.T) {
tests := []struct {
name string
raw string
ttl FlexInt
idle FlexInt
bindIP FlexInt
}{
{"正常数值", `{"session_ttl":120,"idle_timeout":30,"bind_ip":1}`, 120, 30, 1},
// 前端表单回传的是字符串,普通 int 会让字段悄悄回落默认值,这里必须被 FlexInt 接住
{"字符串数值", `{"session_ttl":"120","idle_timeout":"30","bind_ip":"0"}`, 120, 30, 0},
{"显式关闭绑IP", `{"session_ttl":60,"bind_ip":0}`, 60, 0, 0},
// 老配置里没有 bind_ip 这个键 → 必须保持 1,不能当成「用户填了 0」
{"缺 bind_ip 键", `{"session_ttl":60}`, 60, 0, 1},
// 非法值不接受:0 或负数的有效期会让所有人一登录就掉线
{"有效期为0回落默认", `{"session_ttl":0,"bind_ip":1}`, DefaultHttpAuthSessionTTL, 0, 1},
{"有效期为负回落默认", `{"session_ttl":-5,"bind_ip":1}`, DefaultHttpAuthSessionTTL, 0, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := DecodeHttpAuthConfig(tt.raw)
if cfg.SessionTTL != tt.ttl {
t.Errorf("SessionTTL=%d, 期望 %d", cfg.SessionTTL, tt.ttl)
}
if cfg.IdleTimeout != tt.idle {
t.Errorf("IdleTimeout=%d, 期望 %d", cfg.IdleTimeout, tt.idle)
}
if cfg.BindIP != tt.bindIP {
t.Errorf("BindIP=%d, 期望 %d", cfg.BindIP, tt.bindIP)
}
})
}
}
+59
View File
@@ -0,0 +1,59 @@
package model
import (
"SamWaf/customtype"
"SamWaf/model/baseorm"
)
// 会话状态
const (
HttpAuthStatusRevoked = 0 // 已失效(被踢/到期)
HttpAuthStatusValid = 1 // 有效
)
// 认证方式,与 hosts.HttpAuthBaseType 取值一致
const (
HttpAuthTypeAuthorization = "authorization" // 浏览器弹窗(HTTP Basic)
HttpAuthTypeCustom = "custom" // 自定义登录页
)
// 会话失效原因(RevokeReason)
const (
HttpAuthRevokeByAdmin = "admin_kick" // 管理端踢下线
HttpAuthRevokeByExpire = "expired" // 到期,由清理任务标记
HttpAuthRevokeByAccount = "account_off" // 账号被删除或改密
HttpAuthRevokeByHost = "host_off" // 站点被删除或关闭了密码访问
)
// HttpAuthSession 是「网站密码访问」的一次登录,管理端据此展示在线列表并踢下线。
//
// TokenCode 存的是摘要而不是明文:
// - custom 模式 = sha256hex(Cookie 明文)
// - basic 模式 = sha256hex(hostCode|用户名|客户端IP)
//
// 前者与 access_session 同理——库被拖走也拿不到可直接使用的 Cookie,同时它正好能当缓存键后缀,
// 管理端在不知道明文的前提下就能精确驱逐某条会话的缓存。
// 后者是因为 HTTP Basic 没有令牌可言:浏览器每个请求原样重发凭证,服务端能识别的最小单位
// 就是「哪个用户从哪个 IP 来」,所以这三元组的摘要就是它的会话身份。
type HttpAuthSession struct {
baseorm.BaseOrm
HostCode string `gorm:"size:64;index" json:"host_code"` //归属站点
Host string `gorm:"size:255" json:"host"` //冗余域名(含端口),列表直接展示
TokenCode string `gorm:"size:64;index" json:"token_code"` //见上方说明,存摘要不存明文
AuthType string `gorm:"size:20" json:"auth_type"` //authorization | custom
UserName string `gorm:"size:255" json:"user_name"`
ClientIP string `gorm:"size:64" json:"client_ip"` //按站点「真实IP来源」解析出的访客 IP,与访问日志的 SRC_IP 同源
Country string `gorm:"size:64" json:"country"` //归属地-国家
City string `gorm:"size:64" json:"city"` //归属地-省市
UserAgent string `gorm:"size:512" json:"user_agent"` //登录时的UA
Status int `json:"status"` //1有效 0已失效
RevokeReason string `gorm:"size:128" json:"revoke_reason"`
LoginTime customtype.JsonTime `json:"login_time"`
LastActiveTime customtype.JsonTime `json:"last_active_time"` //最后活跃时间(节流更新)
ExpireTime customtype.JsonTime `json:"expire_time"` //绝对过期时间
RemainSeconds int64 `gorm:"-" json:"remain_seconds"`
}
func (HttpAuthSession) TableName() string {
return "http_auth_session"
}
+2
View File
@@ -30,6 +30,7 @@ type WafHostAddReq struct {
IsEnableHttpAuthBase int `json:"is_enable_http_auth_base"` //是否 HTTPAuthBase 1 激活 非1 没有激活
HttpAuthBaseType string `json:"http_auth_base_type"` //认证类型 authorization(默认Basic Auth) custom(自定义页面)
HttpAuthPathPrefix string `json:"http_auth_path_prefix"` //HTTP认证路径前缀,用于隐藏系统特征,默认为随机生成
HttpAuthJSON string `json:"http_auth_json"` //网站密码访问的会话时效配置 json
ResponseTimeOut int `json:"response_time_out"` //响应超时时间
HealthyJSON string `json:"healthy_json"` //后端健康度检测 json
InsecureSkipVerify int `json:"insecure_skip_verify"` //是否开启后端https证书有效性验证 默认 0 是校验 1 是不校验
@@ -96,6 +97,7 @@ type WafHostEditReq struct {
IsEnableHttpAuthBase int `json:"is_enable_http_auth_base"` //是否 HTTPAuthBase 1 激活 非1 没有激活
HttpAuthBaseType string `json:"http_auth_base_type"` //认证类型 authorization(默认Basic Auth) custom(自定义页面)
HttpAuthPathPrefix string `json:"http_auth_path_prefix"` //HTTP认证路径前缀,用于隐藏系统特征,默认为随机生成
HttpAuthJSON string `json:"http_auth_json"` //网站密码访问的会话时效配置 json
ResponseTimeOut int `json:"response_time_out"` //响应超时时间
HealthyJSON string `json:"healthy_json"` //后端健康度检测 json
InsecureSkipVerify int `json:"insecure_skip_verify"` //是否开启后端https证书有效性验证 默认 0 是校验 1 是不校验
+29
View File
@@ -0,0 +1,29 @@
package request
import "SamWaf/model/common/request"
// ─────────────── 网站密码访问:在线会话 ───────────────
type WafHttpAuthSessionSearchReq struct {
HostCode string `json:"host_code" binding:"required"` //必填,会话按站点隔离
UserName string `json:"user_name"`
ClientIP string `json:"client_ip"`
Status *int `json:"status"` //用指针:不传=全部,传0=只看已失效;普通 int 的零值会让「全部」永远查不出有效会话
request.PageInfo
}
// WafHttpAuthSessionKickReq 走 GET,参数在 query 里。
// form tag 不能省:GET 用的是 gin 的 form 绑定,它只认 form tag,
// 只写 json tag 的话取不到值,加上 binding:"required" 就直接报「解析失败」。
type WafHttpAuthSessionKickReq struct {
Id string `json:"id" form:"id" binding:"required"` //会话主键,服务端据此反查 token_code
}
type WafHttpAuthSessionKickByUserReq struct {
HostCode string `json:"host_code" binding:"required"`
UserName string `json:"user_name" binding:"required"`
}
type WafHttpAuthSessionKickAllReq struct {
HostCode string `json:"host_code" binding:"required"`
}
+27 -2
View File
@@ -7,8 +7,9 @@ import (
// 审计分类:access_audit_log 已升级为「统一安全审计流水」security_audit_log,
// 用 Category 区分不同来源的安全事件,前端可分类筛选,将来所有安全日志都汇到这张表。
const (
AuditCategoryAccess = "access" //访问认证类(登录/踢人/票据/未认证拦截等)
AuditCategoryConfig = "config" //敏感配置变更类(SSL 证书导出落盘等)
AuditCategoryAccess = "access" //访问认证类(登录/踢人/票据/未认证拦截等)
AuditCategoryConfig = "config" //敏感配置变更类(SSL 证书导出落盘等)
AuditCategoryHttpAuth = "httpauth" //网站密码访问类(站点级 Basic/自定义登录页)
)
// 审计事件类型
@@ -32,6 +33,15 @@ const (
AuditEventConfigSSLExportWrite = "config_ssl_export_write" //SSL 证书/私钥导出落盘(result 1成功 0失败/被拒)
AuditEventConfigBatchTaskRun = "config_batch_task_run" //批量任务执行:读宿主机文件/拉远端地址并批量写防护策略(result 1成功 0失败/被拒)
AuditEventConfigDiagPackage = "config_diag_package" //运行诊断包生成下载(result 1成功 0失败)
// httpauth 类:网站密码访问。刻意与上面 access 类的同名事件分开命名空间,
// 两者是各自独立开关、各自一套账号的功能,混进同一分类会让按分类筛选失去意义。
HttpAuthEventLoginOK = "httpauth_login_ok" //网站密码登录成功
HttpAuthEventLoginFail = "httpauth_login_fail" //网站密码错误
HttpAuthEventLocked = "httpauth_locked" //登录失败超限,IP 被锁定
HttpAuthEventKick = "httpauth_kick" //管理端踢下线
HttpAuthEventExpired = "httpauth_expired" //会话到期,由清理任务按次汇总
HttpAuthEventDenied = "httpauth_denied" //未登录被拦(高频,走 WriteThrottled)
)
// auditEventCategory 事件 → 分类映射。未登记的事件默认归 access(历史事件全是 access 类)。
@@ -39,6 +49,12 @@ var auditEventCategory = map[string]string{
AuditEventConfigSSLExportWrite: AuditCategoryConfig,
AuditEventConfigBatchTaskRun: AuditCategoryConfig,
AuditEventConfigDiagPackage: AuditCategoryConfig,
HttpAuthEventLoginOK: AuditCategoryHttpAuth,
HttpAuthEventLoginFail: AuditCategoryHttpAuth,
HttpAuthEventLocked: AuditCategoryHttpAuth,
HttpAuthEventKick: AuditCategoryHttpAuth,
HttpAuthEventExpired: AuditCategoryHttpAuth,
HttpAuthEventDenied: AuditCategoryHttpAuth,
}
// AuditEventCategory 取事件所属分类,未知事件回退 access。
@@ -74,6 +90,12 @@ var AccessEventNames = map[string]string{
AuditEventConfigSSLExportWrite: "SSL证书导出落盘",
AuditEventConfigBatchTaskRun: "批量任务执行",
AuditEventConfigDiagPackage: "运行诊断包下载",
HttpAuthEventLoginOK: "网站密码登录成功",
HttpAuthEventLoginFail: "网站密码错误",
HttpAuthEventLocked: "网站密码失败超限,已锁定",
HttpAuthEventKick: "网站密码会话被踢下线",
HttpAuthEventExpired: "网站密码会话到期",
HttpAuthEventDenied: "未登录访问被拦截",
}
// AccessEventName 取事件中文名,未知事件回退成原始事件码而不是空串。
@@ -98,6 +120,9 @@ var AccessNotifyEvents = map[string]bool{
AccessEventLocked: true,
AccessEventTicketReplay: true,
AccessEventBadReturnTo: true,
// 网站密码访问只挑锁定这一件事发通知,理由与上面 access 侧逐条一致:
// 登录成功属正常流程、单次密码错误太常见(连续错会走到 locked)、未登录拦截是高频事件。
HttpAuthEventLocked: true,
}
// SecurityAuditLog 是统一访问认证的结构化安全事件流水。
+1
View File
@@ -39,6 +39,7 @@ type ApiGroup struct {
SslOrderRouter
WafSslExpireRouter
WafHttpAuthBaseRouter
WafHttpAuthSessionRouter
WafTaskRouter
WafBlockingPageRouter
WafGPTRouter
+19
View File
@@ -0,0 +1,19 @@
package router
import (
"SamWaf/api"
"github.com/gin-gonic/gin"
)
type WafHttpAuthSessionRouter struct {
}
func (receiver *WafHttpAuthSessionRouter) InitWafHttpAuthSessionRouter(group *gin.RouterGroup) {
api := api.APIGroupAPP.WafHttpAuthSessionApi
router := group.Group("")
router.POST("/api/v1/wafhost/httpauthsession/list", api.GetListApi)
router.GET("/api/v1/wafhost/httpauthsession/kick", api.KickApi)
router.POST("/api/v1/wafhost/httpauthsession/kickbyuser", api.KickByUserApi)
router.POST("/api/v1/wafhost/httpauthsession/kickall", api.KickAllApi)
}
+13 -2
View File
@@ -98,6 +98,7 @@ func (receiver *WafHostService) AddApi(wafHostAddReq request.WafHostAddReq) (str
IsEnableHttpAuthBase: wafHostAddReq.IsEnableHttpAuthBase,
HttpAuthBaseType: wafHostAddReq.HttpAuthBaseType,
HttpAuthPathPrefix: httpAuthPathPrefix,
HttpAuthJSON: wafHostAddReq.HttpAuthJSON,
ResponseTimeOut: wafHostAddReq.ResponseTimeOut,
HealthyJSON: wafHostAddReq.HealthyJSON,
InsecureSkipVerify: wafHostAddReq.InsecureSkipVerify,
@@ -188,6 +189,7 @@ func (receiver *WafHostService) ModifyApi(wafHostEditReq request.WafHostEditReq)
"IsEnableHttpAuthBase": wafHostEditReq.IsEnableHttpAuthBase,
"HttpAuthBaseType": wafHostEditReq.HttpAuthBaseType,
"HttpAuthPathPrefix": wafHostEditReq.HttpAuthPathPrefix,
"HttpAuthJSON": wafHostEditReq.HttpAuthJSON,
"ResponseTimeOut": wafHostEditReq.ResponseTimeOut,
"HealthyJSON": wafHostEditReq.HealthyJSON,
"InsecureSkipVerify": wafHostEditReq.InsecureSkipVerify,
@@ -222,8 +224,15 @@ func (receiver *WafHostService) ModifyApi(wafHostEditReq request.WafHostEditReq)
hostMap["PortListensJSON"] = *wafHostEditReq.PortListensJSON
}
err := global.GWAF_LOCAL_DB.Debug().Model(model.Hosts{}).Where("CODE=?", wafHostEditReq.CODE).Updates(hostMap).Error
return err
if err != nil {
return err
}
// 关掉「网站密码访问」时把在线会话一并作废:留着的话,等哪天再打开开关,
// 那批旧 Cookie 会直接复活,用户看到的是「刚开的门里已经站着人」。
if oldHost.IsEnableHttpAuthBase == 1 && wafHostEditReq.IsEnableHttpAuthBase != 1 {
WafHttpAuthSessionServiceApp.RevokeByHostCode(wafHostEditReq.CODE, model.HttpAuthRevokeByHost)
}
return nil
}
func (receiver *WafHostService) GetDetailApi(req request.WafHostDetailReq) model.Hosts {
var webHost model.Hosts
@@ -377,6 +386,8 @@ func (receiver *WafHostService) DelHostApi(req request.WafHostDelReq) (model.Hos
err = global.GWAF_LOCAL_DB.Where("Host_Code = ?", req.CODE).Delete(model.URLAllowList{}).Error
//删除用户名和密码访问
err = global.GWAF_LOCAL_DB.Where("Host_Code = ?", req.CODE).Delete(model.HttpAuthBase{}).Error
//站点没了,它的在线会话也一起作废:站点若被同名重建,旧 Cookie 不该还能用
WafHttpAuthSessionServiceApp.RevokeByHostCode(req.CODE, model.HttpAuthRevokeByHost)
// 统一访问认证的残留清理。这三件事必须一起做,否则站点删了配置还在到处生效:
// ① 该站点上已签发的子令牌作废(站点若被同名重建,旧 Cookie 不该还能用)
@@ -121,9 +121,22 @@ func (receiver *WafHttpAuthBaseService) ModifyApi(req request.WafHttpAuthBaseEdi
"Password": req.Password,
"UPDATE_TIME": customtype.JsonTime(time.Now()),
}
err := global.GWAF_LOCAL_DB.Model(model.HttpAuthBase{}).Where("id = ?", req.Id).Updates(beanMap).Error
// 改之前先把老身份记下来:改密/改名之后,凭旧密码建立的会话必须一并作废,
// 否则「改了密码」只挡住新登录,已经登进去的人照样在里面。
var old model.HttpAuthBase
global.GWAF_LOCAL_DB.Where("id = ?", req.Id).Limit(1).Find(&old)
return err
err := global.GWAF_LOCAL_DB.Model(model.HttpAuthBase{}).Where("id = ?", req.Id).Updates(beanMap).Error
if err != nil {
return err
}
if old.UserName != "" {
WafHttpAuthSessionServiceApp.RevokeByUser(old.HostCode, old.UserName, model.HttpAuthRevokeByAccount)
}
if req.UserName != old.UserName || req.HostCode != old.HostCode {
WafHttpAuthSessionServiceApp.RevokeByUser(req.HostCode, req.UserName, model.HttpAuthRevokeByAccount)
}
return nil
}
func (receiver *WafHttpAuthBaseService) GetDetailApi(req request.WafHttpAuthBaseDetailReq) model.HttpAuthBase {
var bean model.HttpAuthBase
@@ -176,5 +189,10 @@ func (receiver *WafHttpAuthBaseService) DelApi(req request.WafHttpAuthBaseDelReq
return err
}
err = global.GWAF_LOCAL_DB.Where("id = ?", req.Id).Delete(model.HttpAuthBase{}).Error
return err
if err != nil {
return err
}
// 账号没了,他的在线会话也不该继续有效
WafHttpAuthSessionServiceApp.RevokeByUser(bean.HostCode, bean.UserName, model.HttpAuthRevokeByAccount)
return nil
}
@@ -0,0 +1,527 @@
package waf_service
import (
"SamWaf/common/uuid"
"SamWaf/customtype"
"SamWaf/enums"
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/baseorm"
"SamWaf/model/request"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
)
type WafHttpAuthSessionService struct{}
var WafHttpAuthSessionServiceApp = new(WafHttpAuthSessionService)
const (
// httpAuthCachePosTTL 会话校验通过的正向缓存时长,同时也是「管理端踢下线」的最坏生效延迟。
// 库才是真相源,缓存只为省掉每请求一次查询,别为了性能把它调大。
httpAuthCachePosTTL = 60 * time.Second
// httpAuthTouchInterval last_active 的写库节流窗口。
// 不节流的话,每个静态资源请求都会写一次库;对空闲超时判定的精度影响可以忽略。
httpAuthTouchInterval = 60 * time.Second
// httpAuthKickCooldown Basic 模式被踢后的强制重认证冷却期。
//
// HTTP Basic 没有登出机制:浏览器把凭证缓存起来每个请求原样重发,服务端返回 401
// 只会让它拿同一份凭证自动重试,用户看不到弹窗。唯一可靠的手段是更换
// WWW-Authenticate 的 realm——浏览器按 realm 缓存凭证,realm 变了才会重新提示。
//
// 所以 Basic 模式的「踢下线」在协议层面只能做到「强制重新输入一次密码」:
// 冷却期内一律用新 realm 挑战,冷却期过后凭同一账号密码可以重新登录。
// 要让某个用户彻底进不来,只能删除或改掉他的账号(那时 checkCredentials 直接不过)。
// Custom 模式没有这个限制,踢下线是干净的一次性失效。
httpAuthKickCooldown = 60 * time.Second
)
// HashHttpAuthToken 把 Cookie 明文摘成入库与缓存用的键。
// 明文永远不落库:库被拖走也拿不到能直接使用的 Cookie;同时这个摘要正好当缓存键后缀,
// 管理端在不知道明文的前提下就能精确驱逐某条会话的缓存。
func HashHttpAuthToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
// BasicSessionCode 是 Basic 模式的会话身份。
//
// Basic 没有令牌可言——浏览器每个请求原样重发凭证,服务端能识别的最小单位就是
// 「哪个用户从哪个 IP 来」,所以用这三元组的摘要当会话标识。
func BasicSessionCode(hostCode, userName, clientIP string) string {
sum := sha256.Sum256([]byte(hostCode + "|" + userName + "|" + clientIP))
return hex.EncodeToString(sum[:])
}
// genHttpAuthToken 生成 32 字节高熵随机串。用 crypto/rand 而不是 uuid:
// uuid 的可预测位太多,不适合当会话凭据。
func genHttpAuthToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func httpAuthSessionCacheKey(hostCode, tokenCode string) string {
return enums.CACHE_HTTPAUTH_SESSION + hostCode + ":" + tokenCode
}
// ─────────────────────────── 签发 ───────────────────────────
// CreateCustomSession 建立一条 custom 模式会话,返回要写进 Cookie 的明文。
func (receiver *WafHttpAuthSessionService) CreateCustomSession(host model.Hosts, userName, clientIP,
userAgent string, cfg model.HttpAuthConfig) (string, model.HttpAuthSession, error) {
plain, err := genHttpAuthToken()
if err != nil {
return "", model.HttpAuthSession{}, err
}
bean := receiver.newSession(host, model.HttpAuthTypeCustom, HashHttpAuthToken(plain),
userName, clientIP, userAgent, cfg)
if err := global.GWAF_LOCAL_DB.Create(&bean).Error; err != nil {
return "", model.HttpAuthSession{}, err
}
receiver.cacheSession(bean)
return plain, bean, nil
}
// newSession 组装一条会话行。归属地在建会话时定格:会话页要能一眼看出「这人从哪儿登进来的」,
// 事后再查已经晚了——IP 库更新或用户换网络后,同一个 IP 的解析结果可能已经变了。
func (receiver *WafHttpAuthSessionService) newSession(host model.Hosts, authType, tokenCode,
userName, clientIP, userAgent string, cfg model.HttpAuthConfig) model.HttpAuthSession {
now := time.Now()
country, city := accessLookupLocation(clientIP)
return model.HttpAuthSession{
BaseOrm: baseorm.BaseOrm{
Id: uuid.GenUUID(),
USER_CODE: global.GWAF_USER_CODE,
Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: customtype.JsonTime(now),
UPDATE_TIME: customtype.JsonTime(now),
},
HostCode: host.Code,
Host: host.Host,
TokenCode: tokenCode,
AuthType: authType,
UserName: userName,
ClientIP: clientIP,
Country: country,
City: city,
UserAgent: truncate(userAgent, 500),
Status: model.HttpAuthStatusValid,
LoginTime: customtype.JsonTime(now),
LastActiveTime: customtype.JsonTime(now),
ExpireTime: customtype.JsonTime(now.Add(time.Duration(cfg.SessionTTL) * time.Minute)),
}
}
// ─────────────────────────── 校验 ───────────────────────────
// ValidateCustom 校验 custom 模式的 Cookie 明文。第二个返回值为 false 表示要下发登录页。
func (receiver *WafHttpAuthSessionService) ValidateCustom(hostCode, plain, clientIP string,
cfg model.HttpAuthConfig) (*model.HttpAuthSession, bool) {
if strings.TrimSpace(plain) == "" {
return nil, false
}
tokenCode := HashHttpAuthToken(plain)
// 负向缓存:挡住拿废弃 Cookie 反复打库的请求
if global.GCACHE_WAFCACHE.IsKeyExist(enums.CACHE_HTTPAUTH_BAD + tokenCode) {
return nil, false
}
sess := receiver.loadSession(hostCode, tokenCode)
if sess == nil {
receiver.markBadToken(tokenCode)
return nil, false
}
now := time.Now()
ok, dead := receiver.alive(sess, clientIP, cfg, now)
if !ok {
// 只有会话真的死了才写负向缓存。换 IP 被拒是"这一次请求"的判断而不是会话状态:
// 记进负向缓存的话,移动网络切一下基站,回到原网络后还要再被挡 60 秒。
if dead {
// 顺手把库里的状态改对。到期由清理任务兜底,但空闲超时只有校验这一刻算得出来,
// 不落库的话管理端会一直把一个早就掉线的人显示成「在线」。
if sess.Status == model.HttpAuthStatusValid {
receiver.revokeRow(sess, model.HttpAuthRevokeByExpire)
} else {
receiver.markBadToken(tokenCode)
global.GCACHE_WAFCACHE.Remove(httpAuthSessionCacheKey(hostCode, tokenCode))
}
}
return nil, false
}
receiver.touch(sess, now)
return sess, true
}
// BasicAuthResult 是 Basic 模式一次认证的判定结果。
type BasicAuthResult struct {
Session *model.HttpAuthSession
// Challenge 为 true 表示要用 RealmNonce 换一个 realm 发 401,强制浏览器重新弹窗。
Challenge bool
// RealmNonce 只在 Challenge 时有值。
RealmNonce string
// Expired 区分「到期」与「被踢」,供审计记录用。
Expired bool
// Created 表示本次是新建/复活了会话行,即一次真正的登录。
// Basic 模式每个请求都带凭证,只有这个标志能区分「刚登录」与「登录后的第 200 个请求」,
// 否则审计表会被同一个人的一次访问刷出成百上千条「登录成功」。
Created bool
}
// TouchBasicSession 是 Basic 模式每次凭证校验通过后的会话记账。
//
// 与 custom 的区别在于这里没有令牌:会话行按 (站点+用户+IP) 唯一,不存在就建、
// 存在就刷新活跃时间;被踢或到期时返回 Challenge,由调用方换 realm 发 401。
func (receiver *WafHttpAuthSessionService) TouchBasicSession(host model.Hosts, userName, clientIP,
userAgent string, cfg model.HttpAuthConfig) BasicAuthResult {
tokenCode := BasicSessionCode(host.Code, userName, clientIP)
// 冷却期内一律挑战,不看会话行状态:这段时间就是留给用户重新输入密码的。
//
// 以「键还在不在」决定挑不挑战,而不是以「值读没读出来」:换 Redis 后端时取回的
// 可能不是 string,类型断言失败就当没被踢,踢下线会静默失效。读不出来就现生成一个
// 新 nonce——realm 变了才是挑战生效的关键,nonce 具体是什么并不重要。
kickKey := enums.CACHE_HTTPAUTH_KICK + tokenCode
if global.GCACHE_WAFCACHE.IsKeyExist(kickKey) {
nonce, _ := global.GCACHE_WAFCACHE.Get(kickKey).(string)
if nonce == "" {
nonce = receiver.startKickCooldown(tokenCode)
}
return BasicAuthResult{Challenge: true, RealmNonce: nonce}
}
now := time.Now()
sess := receiver.loadSession(host.Code, tokenCode)
if sess == nil {
bean := receiver.newSession(host, model.HttpAuthTypeAuthorization, tokenCode,
userName, clientIP, userAgent, cfg)
if err := global.GWAF_LOCAL_DB.Create(&bean).Error; err != nil {
// 记账失败不能连累访问:凭证本身已经校验通过了
return BasicAuthResult{Session: &bean}
}
receiver.cacheSession(bean)
return BasicAuthResult{Session: &bean, Created: true}
}
// 到期:换 realm 挑战一次,同时开冷却,避免同一次页面加载的几十个请求反复走这段
if time.Now().After(time.Time(sess.ExpireTime)) && sess.Status == model.HttpAuthStatusValid {
receiver.revokeRow(sess, model.HttpAuthRevokeByExpire)
nonce := receiver.startKickCooldown(tokenCode)
return BasicAuthResult{Challenge: true, RealmNonce: nonce, Expired: true}
}
// 状态为已失效且冷却期已过 —— 说明这是踢下线之后的重新登录,凭证刚刚校验通过,放行并复活该行
if sess.Status != model.HttpAuthStatusValid {
receiver.reactivate(sess, cfg, now)
return BasicAuthResult{Session: sess, Created: true}
}
receiver.touch(sess, now)
return BasicAuthResult{Session: sess}
}
// loadSession 先查缓存再回落数据库,未命中不缓存空值(负向缓存另有 CACHE_HTTPAUTH_BAD)。
// 回落查库这一步是重启后会话仍然有效的关键。
func (receiver *WafHttpAuthSessionService) loadSession(hostCode, tokenCode string) *model.HttpAuthSession {
key := httpAuthSessionCacheKey(hostCode, tokenCode)
var cached model.HttpAuthSession
if err := global.GCACHE_WAFCACHE.GetAs(key, &cached); err == nil && cached.TokenCode == tokenCode {
return &cached
}
var bean model.HttpAuthSession
err := global.GWAF_LOCAL_DB.Where("host_code = ? and token_code = ?", hostCode, tokenCode).
First(&bean).Error
if err != nil || bean.Id == "" {
return nil
}
receiver.cacheSession(bean)
return &bean
}
func (receiver *WafHttpAuthSessionService) cacheSession(bean model.HttpAuthSession) {
global.GCACHE_WAFCACHE.SetWithTTl(httpAuthSessionCacheKey(bean.HostCode, bean.TokenCode),
bean, httpAuthCachePosTTL)
}
func (receiver *WafHttpAuthSessionService) markBadToken(tokenCode string) {
global.GCACHE_WAFCACHE.SetWithTTl(enums.CACHE_HTTPAUTH_BAD+tokenCode, "1", httpAuthCachePosTTL)
}
// alive 判定一条会话在此刻是否还能放行。
//
// 第二个返回值 dead 区分「会话已经死了」与「只是这次不让过」:
// 前者(撤销/到期/空闲超时)是不可逆的终态,可以写负向缓存少打几次库;
// 后者(换 IP 被拒)换个请求就可能重新成立,写进负向缓存等于连累合法请求。
func (receiver *WafHttpAuthSessionService) alive(sess *model.HttpAuthSession, clientIP string,
cfg model.HttpAuthConfig, now time.Time) (ok bool, dead bool) {
if sess.Status != model.HttpAuthStatusValid {
return false, true
}
if now.After(time.Time(sess.ExpireTime)) {
return false, true
}
if cfg.IdleTimeout > 0 {
last := time.Time(sess.LastActiveTime)
if !last.IsZero() && now.Sub(last) > time.Duration(cfg.IdleTimeout)*time.Minute {
return false, true
}
}
if cfg.BindIP == 1 && sess.ClientIP != "" && sess.ClientIP != clientIP {
return false, false
}
return true, false
}
// touch 节流刷新最后活跃时间。库行变了缓存副本就作废,否则空闲超时会按旧的活跃时间算。
func (receiver *WafHttpAuthSessionService) touch(sess *model.HttpAuthSession, now time.Time) {
last := time.Time(sess.LastActiveTime)
if !last.IsZero() && now.Sub(last) < httpAuthTouchInterval {
return
}
global.GWAF_LOCAL_DB.Model(&model.HttpAuthSession{}).Where("id = ?", sess.Id).
Updates(map[string]interface{}{
"last_active_time": customtype.JsonTime(now), "update_time": customtype.JsonTime(now),
})
sess.LastActiveTime = customtype.JsonTime(now)
global.GCACHE_WAFCACHE.Remove(httpAuthSessionCacheKey(sess.HostCode, sess.TokenCode))
}
// reactivate 把一条已失效的 Basic 会话行按「重新登录」复位,避免同一 (站点+用户+IP) 反复建行。
func (receiver *WafHttpAuthSessionService) reactivate(sess *model.HttpAuthSession,
cfg model.HttpAuthConfig, now time.Time) {
expire := customtype.JsonTime(now.Add(time.Duration(cfg.SessionTTL) * time.Minute))
global.GWAF_LOCAL_DB.Model(&model.HttpAuthSession{}).Where("id = ?", sess.Id).
Updates(map[string]interface{}{
"status": model.HttpAuthStatusValid, "revoke_reason": "",
"login_time": customtype.JsonTime(now), "last_active_time": customtype.JsonTime(now),
"expire_time": expire, "update_time": customtype.JsonTime(now),
})
sess.Status = model.HttpAuthStatusValid
sess.RevokeReason = ""
sess.LoginTime = customtype.JsonTime(now)
sess.LastActiveTime = customtype.JsonTime(now)
sess.ExpireTime = expire
receiver.cacheSession(*sess)
}
// startKickCooldown 开一段强制重认证冷却,返回要塞进 realm 的 nonce。
func (receiver *WafHttpAuthSessionService) startKickCooldown(tokenCode string) string {
nonce := uuid.GenUUID()
if len(nonce) > 8 {
nonce = nonce[:8]
}
global.GCACHE_WAFCACHE.SetWithTTl(enums.CACHE_HTTPAUTH_KICK+tokenCode, nonce, httpAuthKickCooldown)
return nonce
}
// ─────────────────────────── 撤销 ───────────────────────────
// revokeRow 只落库 + 驱逐缓存,不管冷却期。
//
// 先落库、再驱逐缓存,顺序不能反:反过来会有一个「缓存已清、库里还有效」的窗口,
// 期间任何一次请求都会把旧状态重新写回缓存,踢下线就失效了。
func (receiver *WafHttpAuthSessionService) revokeRow(sess *model.HttpAuthSession, reason string) {
now := customtype.JsonTime(time.Now())
global.GWAF_LOCAL_DB.Model(&model.HttpAuthSession{}).Where("id = ?", sess.Id).
Updates(map[string]interface{}{
"status": model.HttpAuthStatusRevoked, "revoke_reason": reason, "update_time": now,
})
sess.Status = model.HttpAuthStatusRevoked
sess.RevokeReason = reason
receiver.kickCache(sess.HostCode, sess.TokenCode, sess.AuthType)
}
// kickCache 精确驱逐缓存条目。做得到「不知道 Cookie 明文也能驱逐」,是因为入库存的
// token_code 本身就是明文的 sha256,而缓存键正是用它作后缀的。
//
// Basic 会话额外开一段冷却:它没有令牌可作废,只能靠换 realm 强制浏览器重新弹窗。
func (receiver *WafHttpAuthSessionService) kickCache(hostCode, tokenCode, authType string) {
global.GCACHE_WAFCACHE.Remove(httpAuthSessionCacheKey(hostCode, tokenCode))
if authType == model.HttpAuthTypeAuthorization {
receiver.startKickCooldown(tokenCode)
} else {
receiver.markBadToken(tokenCode)
}
}
// revokeWhere 按条件批量撤销,返回受影响条数。命中的行要逐条驱逐缓存,
// 只更库不清缓存的话,最长还有一个正向缓存 TTL 的时间里旧状态仍然放行。
func (receiver *WafHttpAuthSessionService) revokeWhere(reason string, query string, args ...interface{}) int {
var list []model.HttpAuthSession
global.GWAF_LOCAL_DB.Where(query, args...).Where("status = ?", model.HttpAuthStatusValid).Find(&list)
if len(list) == 0 {
return 0
}
now := customtype.JsonTime(time.Now())
ids := make([]string, 0, len(list))
for _, s := range list {
ids = append(ids, s.Id)
}
global.GWAF_LOCAL_DB.Model(&model.HttpAuthSession{}).Where("id in ?", ids).
Updates(map[string]interface{}{
"status": model.HttpAuthStatusRevoked, "revoke_reason": reason, "update_time": now,
})
for _, s := range list {
receiver.kickCache(s.HostCode, s.TokenCode, s.AuthType)
}
return len(list)
}
// RevokeByUser 账号被删除或改密时调用:该账号在本站点的全部会话立即失效。
func (receiver *WafHttpAuthSessionService) RevokeByUser(hostCode, userName, reason string) int {
if hostCode == "" || userName == "" {
return 0
}
return receiver.revokeWhere(reason, "host_code = ? and user_name = ?", hostCode, userName)
}
// RevokeByHostCode 站点被删除或关闭密码访问时调用。
func (receiver *WafHttpAuthSessionService) RevokeByHostCode(hostCode, reason string) int {
if hostCode == "" {
return 0
}
return receiver.revokeWhere(reason, "host_code = ?", hostCode)
}
// ─────────────────────────── 管理端 ───────────────────────────
func (receiver *WafHttpAuthSessionService) GetListApi(req request.WafHttpAuthSessionSearchReq) ([]model.HttpAuthSession, int64, error) {
var list []model.HttpAuthSession
var total int64 = 0
if strings.TrimSpace(req.HostCode) == "" {
return nil, 0, errors.New("站点编码不能为空")
}
db := global.GWAF_LOCAL_DB.Model(&model.HttpAuthSession{}).
Where("user_code = ? and tenant_id = ? and host_code = ?",
global.GWAF_USER_CODE, global.GWAF_TENANT_ID, strings.TrimSpace(req.HostCode))
if v := strings.TrimSpace(req.UserName); v != "" {
db = db.Where("user_name like ?", "%"+v+"%")
}
if v := strings.TrimSpace(req.ClientIP); v != "" {
db = db.Where("client_ip like ?", "%"+v+"%")
}
// 状态用指针:不传 = 全部,传 0 = 只看已失效。普通 int 的零值会让「全部」永远查不出有效会话。
if req.Status != nil {
db = db.Where("status = ?", *req.Status)
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Limit(req.PageSize).Offset(req.PageSize * (req.PageIndex - 1)).
Order("last_active_time desc").Find(&list).Error; err != nil {
return nil, 0, err
}
now := time.Now()
for i := range list {
if list[i].Status == model.HttpAuthStatusValid {
if remain := int64(time.Time(list[i].ExpireTime).Sub(now).Seconds()); remain > 0 {
list[i].RemainSeconds = remain
}
}
}
return list, total, nil
}
// KickApi 踢掉一条会话。按主键把行取出来再拿它的 token_code 去撤销,
// 不接受调用方直接传 token_code——那等于让管理端接口成为「凭摘要操作任意会话」的入口。
func (receiver *WafHttpAuthSessionService) KickApi(req request.WafHttpAuthSessionKickReq) error {
if strings.TrimSpace(req.Id) == "" {
return errors.New("会话标识不能为空")
}
var bean model.HttpAuthSession
err := global.GWAF_LOCAL_DB.Where("id = ? and user_code = ? and tenant_id = ?",
strings.TrimSpace(req.Id), global.GWAF_USER_CODE, global.GWAF_TENANT_ID).First(&bean).Error
if err != nil || bean.Id == "" {
return errors.New("会话不存在")
}
if bean.Status != model.HttpAuthStatusValid {
return nil
}
receiver.revokeRow(&bean, model.HttpAuthRevokeByAdmin)
WafSecurityAuditServiceApp.Write(AuditEntry{
Event: model.HttpAuthEventKick,
AccountName: bean.UserName,
SessionCode: bean.TokenCode,
Host: bean.Host,
HostCode: bean.HostCode,
ClientIP: bean.ClientIP,
Country: bean.Country,
City: bean.City,
Result: model.AccessAuditOK,
Message: "管理端踢下线,用户 " + bean.UserName,
})
return nil
}
// KickByUserApi 踢掉某个用户在本站点的全部会话。
func (receiver *WafHttpAuthSessionService) KickByUserApi(req request.WafHttpAuthSessionKickByUserReq) (int, error) {
if strings.TrimSpace(req.HostCode) == "" || strings.TrimSpace(req.UserName) == "" {
return 0, errors.New("站点编码与用户名不能为空")
}
n := receiver.RevokeByUser(strings.TrimSpace(req.HostCode), strings.TrimSpace(req.UserName),
model.HttpAuthRevokeByAdmin)
if n > 0 {
WafSecurityAuditServiceApp.Write(AuditEntry{
Event: model.HttpAuthEventKick,
AccountName: strings.TrimSpace(req.UserName),
HostCode: strings.TrimSpace(req.HostCode),
Result: model.AccessAuditOK,
Message: "管理端按用户踢下线,共 " + strconv.Itoa(n) + " 条会话",
})
}
return n, nil
}
// KickAllApi 踢掉本站点全部会话。
func (receiver *WafHttpAuthSessionService) KickAllApi(req request.WafHttpAuthSessionKickAllReq) (int, error) {
if strings.TrimSpace(req.HostCode) == "" {
return 0, errors.New("站点编码不能为空")
}
n := receiver.RevokeByHostCode(strings.TrimSpace(req.HostCode), model.HttpAuthRevokeByAdmin)
if n > 0 {
WafSecurityAuditServiceApp.Write(AuditEntry{
Event: model.HttpAuthEventKick,
HostCode: strings.TrimSpace(req.HostCode),
Result: model.AccessAuditOK,
Message: "管理端清空本站点会话,共 " + strconv.Itoa(n) + " 条",
})
}
return n, nil
}
// ─────────────────────────── 清理 ───────────────────────────
// CleanExpired 由定时任务调用:先把到期的有效会话标记失效,再删掉超过保留期的历史行。
// 返回 (本次标记到期数, 删除历史行数)。
func (receiver *WafHttpAuthSessionService) CleanExpired(keepDays int) (int64, int64) {
if keepDays <= 0 {
keepDays = 30
}
now := time.Now()
r1 := global.GWAF_LOCAL_DB.Model(&model.HttpAuthSession{}).
Where("status = ? and expire_time < ?", model.HttpAuthStatusValid, now).
Updates(map[string]interface{}{
"status": model.HttpAuthStatusRevoked, "revoke_reason": model.HttpAuthRevokeByExpire,
"update_time": customtype.JsonTime(now),
})
// 到期行的缓存不逐条驱逐:正向缓存 TTL 只有 60 秒,而且 alive() 本来就会独立判过期,
// 缓存里留着的副本不会让一条已过期的会话继续放行。
r2 := global.GWAF_LOCAL_DB.Where("status = ? and update_time < ?",
model.HttpAuthStatusRevoked, now.AddDate(0, 0, -keepDays)).Delete(&model.HttpAuthSession{})
return r1.RowsAffected, r2.RowsAffected
}
+3
View File
@@ -48,6 +48,9 @@ var sensitiveTableSubstrings = []string{
"notifychannel",
"http_auth", // http_auth_base_configs(访问密码)
"httpauth",
// 网站密码访问的在线会话:登录IP/归属地/UA 属访客隐私。上面的 "http_auth" 子串已经覆盖它,
// 这里仍显式列出,免得日后有人动了那个子串就把这张表悄悄放开了。
"http_auth_session",
"token", // 任何令牌表
"secret", // 任何以 secret 命名的表
"config", // system_configs / *config* 键值配置表(可能明文存密钥)
+32
View File
@@ -2187,6 +2187,38 @@ func RunCoreDBMigrations(db *gorm.DB) error {
return nil
},
},
{
ID: "202609040001_add_hosts_http_auth_json",
Migrate: func(tx *gorm.DB) error {
zlog.Info("迁移 202609040001: 网站密码访问新增会话时效配置列")
if !tx.Migrator().HasColumn(&model.Hosts{}, "http_auth_json") {
if err := tx.Migrator().AddColumn(&model.Hosts{}, "HttpAuthJSON"); err != nil {
return fmt.Errorf("新增网站密码访问时效配置列失败: %w", err)
}
}
// 存量站点留空即可:DecodeHttpAuthConfig 对空值的解析结果就是改造前的行为
// (24 小时有效期 + 绑定登录 IP),不需要也不应该在这里回填具体数值。
return nil
},
Rollback: func(tx *gorm.DB) error {
zlog.Info("回滚 202609040001: 删除网站密码访问会话时效配置列")
if tx.Migrator().HasColumn(&model.Hosts{}, "http_auth_json") {
return tx.Migrator().DropColumn(&model.Hosts{}, "HttpAuthJSON")
}
return nil
},
},
{
ID: "202609040002_add_http_auth_session",
Migrate: func(tx *gorm.DB) error {
zlog.Info("迁移 202609040002: 新增网站密码访问会话表")
return tx.AutoMigrate(&model.HttpAuthSession{})
},
Rollback: func(tx *gorm.DB) error {
zlog.Info("回滚 202609040002: 删除网站密码访问会话表")
return tx.Migrator().DropTable(&model.HttpAuthSession{})
},
},
})
// 执行迁移
+40
View File
@@ -544,6 +544,46 @@ func RunTaskInitMigrations(db *gorm.DB) error {
return tx.Where("task_method = ?", enums.TASK_ACCESS_CLEAN).Delete(&model.Task{}).Error
},
},
// 迁移: 网站密码访问的会话清理任务
// 10 分钟一次:只做「把到期会话的状态标对 + 删超保留期的历史行」,
// 校验链路本来就独立判过期时间,这个任务跑不跑都不影响拦不拦。
{
ID: "202609040001_add_httpauth_clean_task",
Migrate: func(tx *gorm.DB) error {
zlog.Info("迁移 202609040001: 创建网站密码访问会话清理任务")
var count int64
tx.Model(&model.Task{}).Where("task_method = ?", enums.TASK_HTTPAUTH_CLEAN).Count(&count)
if count > 0 {
zlog.Info("网站密码访问会话清理任务已存在,跳过", "task_method", enums.TASK_HTTPAUTH_CLEAN)
return nil
}
task := model.Task{
BaseOrm: baseorm.BaseOrm{
Id: uuid.GenUUID(),
USER_CODE: global.GWAF_USER_CODE,
Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: customtype.JsonTime(time.Now()),
UPDATE_TIME: customtype.JsonTime(time.Now()),
},
TaskName: "每10分钟清理网站密码访问的到期会话与历史记录",
TaskUnit: enums.TASK_MIN,
TaskValue: 10,
TaskAt: "",
TaskMethod: enums.TASK_HTTPAUTH_CLEAN,
}
if err := tx.Create(&task).Error; err != nil {
return fmt.Errorf("创建网站密码访问会话清理任务失败: %w", err)
}
zlog.Info("网站密码访问会话清理任务创建成功")
return nil
},
Rollback: func(tx *gorm.DB) error {
zlog.Info("回滚 202609040001: 删除网站密码访问会话清理任务")
return tx.Where("task_method = ?", enums.TASK_HTTPAUTH_CLEAN).Delete(&model.Task{}).Error
},
},
// 迁移: 站点流量计量落库任务
// 30 秒一次:内存里累计的真实进出字节按天/小时增量落库。周期越短掉进程时丢得越少,
// 但每轮只有 2N 条 UPDATE(N=有流量的站点数),30 秒对 SQLite 毫无压力。
+1 -1
View File
@@ -846,7 +846,7 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
//基本验证是否开关是否开启
if hostTarget.Host.IsEnableHttpAuthBase == 1 {
bHttpAuthBaseResult, sHttpAuthBaseResult := waf.DoHttpAuthBase(hostTarget, w, r)
bHttpAuthBaseResult, sHttpAuthBaseResult := waf.DoHttpAuthBase(hostTarget, w, r, model.GetClientIPByMode(hostTarget.Host.IPMode, weblogbean.NetSrcIp, weblogbean.SRC_IP))
if bHttpAuthBaseResult == true {
// 记录日志
weblogbean.RES_BODY = sHttpAuthBaseResult
+151 -69
View File
@@ -539,50 +539,87 @@ func (waf *WafEngine) CheckResponseSensitive() bool {
}
// DoHttpAuthBase Http auth base 检测
func (waf *WafEngine) DoHttpAuthBase(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request) (bool, string) {
isStop := false
// 获取认证类型,默认为 authorization(Basic Auth)
authType := hostSafe.Host.HttpAuthBaseType
if authType == "" {
authType = "authorization"
//
// clientIP 由调用方按站点「真实IP来源」解析后传入,与访问日志的 SRC_IP 同源。
// 认证链路自己再取一次连接 IP 的话,站点挂在 CDN/前置 Nginx 后面时会话列表里
// 全站都是同一个边缘节点地址,「绑定登录 IP」这条约束也会一并失效。
func (waf *WafEngine) DoHttpAuthBase(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request, clientIP string) (bool, string) {
cfg := model.DecodeHttpAuthConfig(hostSafe.Host.HttpAuthJSON)
if hostSafe.Host.HttpAuthBaseType == model.HttpAuthTypeCustom {
return waf.doCustomAuth(hostSafe, w, r, clientIP, cfg)
}
// 根据认证类型选择不同的认证方式
if authType == "authorization" {
// 使用Basic Auth方式
return waf.doBasicAuth(hostSafe, w, r)
} else if authType == "custom" {
// 使用自定义页面方式
return waf.doCustomAuth(hostSafe, w, r)
}
return isStop, ""
// 空值(存量站点)与未知取值都按 Basic 走:开关已经开了,认不出类型就放行等于这道门形同虚设
return waf.doBasicAuth(hostSafe, w, r, clientIP, cfg)
}
// doBasicAuth 使用Basic Auth方式认证(原有逻辑)
func (waf *WafEngine) doBasicAuth(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request) (bool, string) {
isStop := false
// writeBasicChallenge 发 401 并要求浏览器输入密码。
//
// nonce 非空时 realm 会跟着变——浏览器按 realm 缓存凭证,realm 不变就只会拿旧凭证
// 自动重试,用户永远看不到弹窗。这是 Basic 模式下让「踢下线/到期」生效的唯一手段。
func (waf *WafEngine) writeBasicChallenge(w http.ResponseWriter, nonce, tip string) {
realm := "Restricted"
if nonce != "" {
realm = "Restricted-" + nonce
}
w.Header().Set("WWW-Authenticate", "Basic realm=\""+realm+"\"")
http.Error(w, tip, http.StatusUnauthorized)
}
// cutAuditName 截断用户名。用户名来自请求头或登录表单,是攻击者可控的任意长度字符串,
// 直接入库会撞 account_name 的列宽。
//
// 按字节切完还要过一次 ToValidUTF8:128 字节的边界可能落在一个多字节字符中间,
// 留下的半个字符会被 MySQL 的 utf8mb4 列拒收,那条审计记录就没了——
// 等于给了攻击者一个「用超长多字节用户名让自己的爆破不被记录」的口子。
func cutAuditName(name string) string {
if len(name) <= 128 {
return name
}
return strings.ToValidUTF8(name[:128], "")
}
// httpAuthAudit 组装一条网站密码访问的审计流水。
func (waf *WafEngine) httpAuthAudit(hostSafe *wafenginmodel.HostSafe, r *http.Request,
clientIP, userName string) waf_service.AuditEntry {
return waf_service.AuditEntry{
AccountName: cutAuditName(userName),
Host: hostSafe.Host.Host,
HostCode: hostSafe.Host.Code,
URL: r.URL.Path, // 只记路径:查询串里可能带业务参数,审计表没有必要留
ClientIP: clientIP,
UserAgent: r.UserAgent(),
}
}
// auditHttpAuthDenied 记一条「未登录被拦」。
// 这是本功能唯一的高频事件——一次目录扫描就是几千个未登录请求,必须走节流。
func (waf *WafEngine) auditHttpAuthDenied(hostSafe *wafenginmodel.HostSafe, r *http.Request, clientIP, msg string) {
entry := waf.httpAuthAudit(hostSafe, r, clientIP, "")
entry.Event = model.HttpAuthEventDenied
entry.Result = model.AccessAuditFail
entry.Message = msg
waf_service.WafSecurityAuditServiceApp.WriteThrottled(entry)
}
// doBasicAuth 浏览器弹窗方式(HTTP Basic)
func (waf *WafEngine) doBasicAuth(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request,
clientIP string, cfg model.HttpAuthConfig) (bool, string) {
// 获取 Authorization 头部
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
tip := "当前网站需要授权方可访问"
// 如果没有 Authorization 头部,返回 401
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
http.Error(w, tip, http.StatusUnauthorized)
isStop = true
return isStop, tip
waf.writeBasicChallenge(w, "", tip)
waf.auditHttpAuthDenied(hostSafe, r, clientIP, tip)
return true, tip
}
// 验证 Authorization 头部格式
// "Basic base64(username:password)"
// 验证 Authorization 头部格式 "Basic base64(username:password)"
authParts := strings.SplitN(authHeader, " ", 2)
if len(authParts) != 2 || authParts[0] != "Basic" {
tip := "密码格式不正确 Invalid authorization header format"
http.Error(w, tip, http.StatusBadRequest)
isStop = true
return isStop, tip
return true, tip
}
// 解码 base64 编码的用户名和密码
@@ -590,8 +627,7 @@ func (waf *WafEngine) doBasicAuth(hostSafe *wafenginmodel.HostSafe, w http.Respo
if err != nil {
tip := "Invalid base64 encoding"
http.Error(w, tip, http.StatusBadRequest)
isStop = true
return isStop, tip
return true, tip
}
// 解码后的结果是 "username:password"
@@ -599,66 +635,90 @@ func (waf *WafEngine) doBasicAuth(hostSafe *wafenginmodel.HostSafe, w http.Respo
if len(credentials) != 2 {
tip := "密码格式不正确 Invalid authorization format"
http.Error(w, tip, http.StatusBadRequest)
isStop = true
return isStop, tip
return true, tip
}
// 校验用户名和密码
username, password := credentials[0], credentials[1]
if !waf.checkCredentials(hostSafe, username, password) {
tip := "密码错误"
// 如果验证失败,返回 401
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
http.Error(w, tip, http.StatusUnauthorized)
isStop = true
return isStop, tip
waf.writeBasicChallenge(w, "", tip)
// Basic 模式没有登录表单,爆破就是一串带头的普通请求,逐条记会把审计表刷爆,走节流
entry := waf.httpAuthAudit(hostSafe, r, clientIP, username)
entry.Event = model.HttpAuthEventLoginFail
entry.Result = model.AccessAuditFail
entry.Message = "网站密码错误"
waf_service.WafSecurityAuditServiceApp.WriteThrottled(entry)
return true, tip
}
return isStop, ""
// 凭证过关,接着做会话记账:到期或被踢时要换 realm 把浏览器重新逼回弹窗
res := waf_service.WafHttpAuthSessionServiceApp.TouchBasicSession(hostSafe.Host, username,
clientIP, r.UserAgent(), cfg)
if res.Challenge {
tip := "登录状态已失效,请重新输入密码"
if res.Expired {
tip = "登录已到期,请重新输入密码"
entry := waf.httpAuthAudit(hostSafe, r, clientIP, username)
entry.Event = model.HttpAuthEventExpired
entry.Result = model.AccessAuditOK
entry.Message = "网站密码会话到期,要求重新认证"
waf_service.WafSecurityAuditServiceApp.WriteThrottled(entry)
}
waf.writeBasicChallenge(w, res.RealmNonce, tip)
return true, tip
}
// 只有真正建了会话行才算一次登录:Basic 每个请求都带凭证,逐个请求记就是成百上千条
if res.Created {
entry := waf.httpAuthAudit(hostSafe, r, clientIP, username)
entry.Event = model.HttpAuthEventLoginOK
entry.Result = model.AccessAuditOK
entry.Message = "网站密码登录成功(浏览器弹窗方式)"
if res.Session != nil {
entry.SessionCode = res.Session.TokenCode
}
waf_service.WafSecurityAuditServiceApp.Write(entry)
}
return false, ""
}
// doCustomAuth 使用自定义页面方式认证
func (waf *WafEngine) doCustomAuth(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request) (bool, string) {
// 获取HTTP认证路径前缀
authPathPrefix := hostSafe.Host.HttpAuthPathPrefix
if authPathPrefix == "" {
authPathPrefix = "/samwaf_httpauth"
}
// doCustomAuth 自定义登录页方式
func (waf *WafEngine) doCustomAuth(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request,
clientIP string, cfg model.HttpAuthConfig) (bool, string) {
// 处理登录页面的静态资源请求
authPathPrefix := utils.GetHttpAuthPathOrDefault(hostSafe.Host.HttpAuthPathPrefix)
// 处理登录页面自身的请求
if strings.HasPrefix(r.URL.Path, authPathPrefix+"/") {
waf.handleHttpAuthRequest(hostSafe, w, r, authPathPrefix)
waf.handleHttpAuthRequest(hostSafe, w, r, authPathPrefix, clientIP, cfg)
return true, "处理HTTP Auth请求"
}
// 检查是否已经通过认证
clientIP := utils.GetSourceClientIP(r.RemoteAddr)
// 尝试从Cookie中获取认证令牌
cookie, err := r.Cookie("samwaf_httpauth_token")
if err == nil && cookie.Value != "" {
// 验证令牌是否有效
cacheKey := "httpauth_pass:" + cookie.Value + ":" + clientIP
val := global.GCACHE_WAFCACHE.Get(cacheKey)
if val != nil && val == "ok" {
// 认证有效,允许访问
if _, ok := waf_service.WafHttpAuthSessionServiceApp.ValidateCustom(hostSafe.Host.Code,
cookie.Value, clientIP, cfg); ok {
return false, ""
}
}
// 未通过认证,显示登录页面
tip := "需要登录认证"
waf.auditHttpAuthDenied(hostSafe, r, clientIP, tip)
waf.serveLoginPage(w, r, authPathPrefix)
return true, tip
}
// handleHttpAuthRequest 处理HTTP Auth相关请求
func (waf *WafEngine) handleHttpAuthRequest(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request, pathPrefix string) {
func (waf *WafEngine) handleHttpAuthRequest(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request,
pathPrefix, clientIP string, cfg model.HttpAuthConfig) {
path := strings.TrimPrefix(r.URL.Path, pathPrefix+"/")
// 处理验证接口
if path == "validate" && r.Method == "POST" {
waf.handleHttpAuthValidate(hostSafe, w, r)
waf.handleHttpAuthValidate(hostSafe, w, r, clientIP, cfg)
return
}
@@ -667,8 +727,8 @@ func (waf *WafEngine) handleHttpAuthRequest(hostSafe *wafenginmodel.HostSafe, w
}
// handleHttpAuthValidate 处理登录验证
func (waf *WafEngine) handleHttpAuthValidate(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request) {
clientIP := utils.GetSourceClientIP(r.RemoteAddr)
func (waf *WafEngine) handleHttpAuthValidate(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request,
clientIP string, cfg model.HttpAuthConfig) {
// 安全策略:检查IP是否被锁定
lockKey := "httpauth_lock:" + clientIP
@@ -712,16 +772,22 @@ func (waf *WafEngine) handleHttpAuthValidate(hostSafe *wafenginmodel.HostSafe, w
zap.String("username", req.Username),
zap.Int("fail_count", failCount))
entry := waf.httpAuthAudit(hostSafe, r, clientIP, req.Username)
entry.Result = model.AccessAuditFail
// 失败次数超过10次,锁定IP 3分钟
if failCount >= 10 {
global.GCACHE_WAFCACHE.SetWithTTl(lockKey, "locked", 3*time.Minute)
// 清除失败计数
global.GCACHE_WAFCACHE.Remove(failCountKey)
zlog.Error("HTTP Auth登录失败次数过多,锁定IP",
zap.String("ip", clientIP),
zap.Int("fail_count", failCount))
entry.Event = model.HttpAuthEventLocked
entry.Message = "网站密码连续输错10次,该IP已锁定3分钟"
waf_service.WafSecurityAuditServiceApp.Write(entry)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"success": false, "message": "登录失败次数过多,已锁定3分钟"}`))
@@ -731,6 +797,10 @@ func (waf *WafEngine) handleHttpAuthValidate(hostSafe *wafenginmodel.HostSafe, w
// 记录失败次数,5分钟内有效
global.GCACHE_WAFCACHE.SetWithTTl(failCountKey, failCount, 5*time.Minute)
entry.Event = model.HttpAuthEventLoginFail
entry.Message = fmt.Sprintf("网站密码错误,剩余尝试次数:%d", 10-failCount)
waf_service.WafSecurityAuditServiceApp.Write(entry)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(fmt.Sprintf(`{"success": false, "message": "用户名或密码错误,剩余尝试次数:%d"}`, 10-failCount)))
@@ -741,25 +811,37 @@ func (waf *WafEngine) handleHttpAuthValidate(hostSafe *wafenginmodel.HostSafe, w
failCountKey := "httpauth_fail:" + clientIP
global.GCACHE_WAFCACHE.Remove(failCountKey)
// 生成令牌
authToken := uuid.GenUUID()
// 建会话:明文令牌只出现在 Cookie 里,库与缓存存的都是它的 sha256
authToken, sess, err := waf_service.WafHttpAuthSessionServiceApp.CreateCustomSession(hostSafe.Host,
req.Username, clientIP, r.UserAgent(), cfg)
if err != nil {
zlog.Error("HTTP Auth建立会话失败", zap.Error(err))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"success": false, "message": "服务器错误"}`))
return
}
zlog.Info("HTTP Auth登录成功",
zap.String("ip", clientIP),
zap.String("username", req.Username))
// 将令牌存入缓存,默认24小时有效
cacheKey := "httpauth_pass:" + authToken + ":" + clientIP
global.GCACHE_WAFCACHE.SetWithTTl(cacheKey, "ok", 24*time.Hour)
entry := waf.httpAuthAudit(hostSafe, r, clientIP, req.Username)
entry.Event = model.HttpAuthEventLoginOK
entry.Result = model.AccessAuditOK
entry.SessionCode = sess.TokenCode
entry.Message = "网站密码登录成功(自定义页面方式)"
waf_service.WafSecurityAuditServiceApp.Write(entry)
// 设置Cookie
// 设置Cookie,有效期跟随站点配置的会话时长
cookie := &http.Cookie{
Name: "samwaf_httpauth_token",
Value: authToken,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
MaxAge: 24 * 3600, // 24小时
SameSite: http.SameSiteLaxMode,
MaxAge: int(cfg.SessionTTL) * 60,
}
http.SetCookie(w, cookie)
+1
View File
@@ -148,6 +148,7 @@ func (web *WafWebManager) initRouter(r *gin.Engine) {
router.ApiGroupApp.InitSslOrderRouter(RouterGroup)
router.ApiGroupApp.InitWafSslExpireRouter(RouterGroup)
router.ApiGroupApp.InitWafHttpAuthBaseRouter(RouterGroup)
router.ApiGroupApp.InitWafHttpAuthSessionRouter(RouterGroup)
router.ApiGroupApp.InitWafTaskRouter(RouterGroup)
router.ApiGroupApp.InitWafBlockingPageRouter(RouterGroup)
router.ApiGroupApp.InitGPTRouter(RouterGroup)
+44
View File
@@ -0,0 +1,44 @@
package waftask
import (
"SamWaf/common/zlog"
"SamWaf/model"
"SamWaf/service/waf_service"
"strconv"
)
var wafHttpAuthSessionService = waf_service.WafHttpAuthSessionServiceApp
// httpAuthSessionKeepDays 已失效会话行的保留天数。
// 留着是为了让管理员事后还能看到「谁在什么时候被踢的/什么时候到期的」,
// 超过这个天数就没有排查价值了,只剩占空间。
const httpAuthSessionKeepDays = 30
// TaskHttpAuthClean 网站密码访问的会话清理。
//
// 两件事:
// 1. 把已过期但还标着"有效"的会话置为已失效 —— 校验时本来就会独立判过期时间,
// 这一步只是让管理端会话列表的状态与事实一致,不影响安全性
// 2. 删掉超过保留期的历史失效行
//
// 全部幂等,跑多少次都安全;跑不起来也只是数据堆积,不影响认证功能。
func TaskHttpAuthClean() {
innerLogName := "TaskHttpAuthClean"
expired, deleted := wafHttpAuthSessionService.CleanExpired(httpAuthSessionKeepDays)
if expired+deleted == 0 {
zlog.Debug(innerLogName, "无需清理")
return
}
zlog.Info(innerLogName, "网站密码访问会话清理完成", "标记到期", expired, "删除历史行", deleted)
// 到期按轮汇总一条审计,不逐条写:一批同时到期的会话逐条记,就是一堆内容雷同的流水,
// 把真正要看的登录/踢下线记录淹掉。
if expired > 0 {
waf_service.WafSecurityAuditServiceApp.Write(waf_service.AuditEntry{
Event: model.HttpAuthEventExpired,
Result: model.AccessAuditOK,
Message: "网站密码访问会话到期清理,本轮 " + strconv.FormatInt(expired, 10) + " 条",
})
}
}
+44
View File
@@ -482,3 +482,47 @@ notes:
en:
title: Notification channel Webhooks can now reach intranet alerting platforms when declared in config.yml
detail: Notification channel targets (DingTalk, Feishu, WeCom, custom Webhook) still default to public addresses only. Self-hosted alerting platforms on the intranet (such as ntfy, Gotify or an internal ticketing system) were previously rejected outright; they can now be allowed by declaring the host name, IP or CIDR (comma separated) in security.outbound_allowed_hosts in conf/config.yml, then saving and restarting. That file can only be maintained by someone with access to the server configuration - the console UI and API offer no way to change it. Without this setting, behavior is exactly as before.
- id: v1_3_25_httpauth_session
version: v1.3.25
kind: notice
level: normal
page: /waf-host/list
doc: https://doc.samwaf.com/guide/HttpAuthBase.html
apply:
type: navigate
zh:
title: 网站密码访问新增有效期与在线会话管理,认证链路改用真实访客 IP
detail: >-
网站编辑 - 网站密码访问里新增三项配置:登录有效期(默认 1440 分钟,与旧版本一致)、
空闲超时(默认 0 不启用,仅「自定义页面」方式支持)、绑定登录 IP(默认开启,与旧版本一致)。
同一页新增「在线会话」列表,可以看到谁在什么时候从哪个 IP 登录的、还剩多久到期,
并支持踢下线(单条 / 按用户 / 本站全部)。登录成败、锁定、踢下线现在会记入
系统设置 - 安全审计,分类为「网站密码访问」。
同时,认证链路的客户端 IP 由「连接 IP」改为按站点「真实IP来源」解析,与访问日志同源。
effect_on: >-
两处变化需要留意。其一,站点挂在 CDN 或前置 Nginx 后面、且「绑定登录 IP」开着时:
升级前绑的是边缘节点地址(对所有访客都一样,等于没绑),升级后绑的是访客真实 IP,
访客切换基站或重新拨号就会被要求重新登录;频繁掉线可以在同一页把「绑定登录 IP」关掉。
其二,升级后已经登录的访客需要重新登录一次。
effect_off: 没有开启「网站密码访问」的站点完全不受影响。
revert: 把「登录有效期」设回 1440、「空闲超时」设回 0、「绑定登录 IP」保持开启,即为旧版本行为。
en:
title: Website password access gains session lifetime and online session management; auth now uses the real visitor IP
detail: >-
Website editor - Website password access has three new settings: session lifetime (default 1440 minutes,
same as before), idle timeout (default 0 = off, supported only by the Custom page method) and bind to
login IP (on by default, same as before). The same page now lists online sessions - who signed in, from
which IP, at what time and how long is left - and lets you kick them (one session, one user, or all
sessions of the site). Sign-in success and failure, lockout and kick are now recorded under
System settings - Security audit in the "Website password" category.
The client IP used by this authentication path also changed from the connection IP to the site's
configured Real client IP source, the same one used by access logs.
effect_on: >-
Two things to watch. First, for sites behind a CDN or a front Nginx with "bind to login IP" enabled:
before the upgrade this bound to the edge node address (identical for every visitor, so effectively no
binding), and now it binds to the visitor's real IP, so switching mobile towers or reconnecting will
require signing in again - turn "bind to login IP" off on the same page if that happens too often.
Second, visitors who are already signed in must sign in once more after the upgrade.
effect_off: Sites that do not have Website password access enabled are not affected at all.
revert: Set session lifetime back to 1440, idle timeout back to 0 and keep bind to login IP enabled - that is the previous behaviour.