From c3b263b7f3f142796a4c375d9f0090f3fdec7e17 Mon Sep 17 00:00:00 2001 From: samwaf Date: Sun, 6 Sep 2026 07:32:04 +0800 Subject: [PATCH] feat:website password session lifetime and online session management #987 --- api/entrance.go | 2 + api/waf_httpauthsession_api.go | 112 ++++ cmd/samwaf/main.go | 1 + enums/cache_enum.go | 10 + enums/task_enum.go | 1 + model/hosts.go | 60 ++ model/http_auth_config_test.go | 55 ++ model/http_auth_session.go | 59 ++ model/request/waf_host_req.go | 2 + model/request/waf_httpauthsession_req.go | 29 + model/security_audit_log.go | 29 +- router/entrance.go | 1 + router/waf_httpauthsession_router.go | 19 + service/waf_service/waf_host.go | 15 +- .../waf_service/waf_httpauthbase_service.go | 24 +- .../waf_httpauthsession_service.go | 527 ++++++++++++++++++ service/waf_service/waf_sql_query.go | 3 + wafdb/migrations_core.go | 32 ++ wafdb/migrations_task.go | 40 ++ wafenginecore/wafengine.go | 2 +- wafenginecore/wafworker.go | 216 ++++--- wafmangeweb/localserver.go | 1 + waftask/task_httpauth_clean.go | 44 ++ wafupgradenotice/upgrade_notes.yaml | 44 ++ 24 files changed, 1253 insertions(+), 75 deletions(-) create mode 100644 api/waf_httpauthsession_api.go create mode 100644 model/http_auth_config_test.go create mode 100644 model/http_auth_session.go create mode 100644 model/request/waf_httpauthsession_req.go create mode 100644 router/waf_httpauthsession_router.go create mode 100644 service/waf_service/waf_httpauthsession_service.go create mode 100644 waftask/task_httpauth_clean.go diff --git a/api/entrance.go b/api/entrance.go index 1049dffe..165b8f63 100644 --- a/api/entrance.go +++ b/api/entrance.go @@ -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 diff --git a/api/waf_httpauthsession_api.go b/api/waf_httpauthsession_api.go new file mode 100644 index 00000000..40a5973c --- /dev/null +++ b/api/waf_httpauthsession_api.go @@ -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) +} diff --git a/cmd/samwaf/main.go b/cmd/samwaf/main.go index fc1efcb9..1cd58e37 100644 --- a/cmd/samwaf/main.go +++ b/cmd/samwaf/main.go @@ -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) diff --git a/enums/cache_enum.go b/enums/cache_enum.go index ee51e9de..6b2f9257 100644 --- a/enums/cache_enum.go +++ b/enums/cache_enum.go @@ -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 规则的语义 diff --git a/enums/task_enum.go b/enums/task_enum.go index af029a5b..5a6f3ece 100644 --- a/enums/task_enum.go +++ b/enums/task_enum.go @@ -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秒一次,引擎侧字节计量与日志解耦) ) diff --git a/model/hosts.go b/model/hosts.go index e611b34e..26e39dab 100644 --- a/model/hosts.go +++ b/model/hosts.go @@ -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 +} diff --git a/model/http_auth_config_test.go b/model/http_auth_config_test.go new file mode 100644 index 00000000..49958468 --- /dev/null +++ b/model/http_auth_config_test.go @@ -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) + } + }) + } +} diff --git a/model/http_auth_session.go b/model/http_auth_session.go new file mode 100644 index 00000000..17b1396a --- /dev/null +++ b/model/http_auth_session.go @@ -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" +} diff --git a/model/request/waf_host_req.go b/model/request/waf_host_req.go index ba4f114f..cf2dfa4e 100644 --- a/model/request/waf_host_req.go +++ b/model/request/waf_host_req.go @@ -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 是不校验 diff --git a/model/request/waf_httpauthsession_req.go b/model/request/waf_httpauthsession_req.go new file mode 100644 index 00000000..29ba6a2d --- /dev/null +++ b/model/request/waf_httpauthsession_req.go @@ -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"` +} diff --git a/model/security_audit_log.go b/model/security_audit_log.go index aa5be3cf..ce7ebe20 100644 --- a/model/security_audit_log.go +++ b/model/security_audit_log.go @@ -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 是统一访问认证的结构化安全事件流水。 diff --git a/router/entrance.go b/router/entrance.go index 4c27bfbe..2de8b67f 100644 --- a/router/entrance.go +++ b/router/entrance.go @@ -39,6 +39,7 @@ type ApiGroup struct { SslOrderRouter WafSslExpireRouter WafHttpAuthBaseRouter + WafHttpAuthSessionRouter WafTaskRouter WafBlockingPageRouter WafGPTRouter diff --git a/router/waf_httpauthsession_router.go b/router/waf_httpauthsession_router.go new file mode 100644 index 00000000..0c4fcd18 --- /dev/null +++ b/router/waf_httpauthsession_router.go @@ -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) +} diff --git a/service/waf_service/waf_host.go b/service/waf_service/waf_host.go index d1cd51bc..7261b3a0 100644 --- a/service/waf_service/waf_host.go +++ b/service/waf_service/waf_host.go @@ -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 不该还能用) diff --git a/service/waf_service/waf_httpauthbase_service.go b/service/waf_service/waf_httpauthbase_service.go index f24779aa..17c38bc8 100644 --- a/service/waf_service/waf_httpauthbase_service.go +++ b/service/waf_service/waf_httpauthbase_service.go @@ -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 } diff --git a/service/waf_service/waf_httpauthsession_service.go b/service/waf_service/waf_httpauthsession_service.go new file mode 100644 index 00000000..03afba2a --- /dev/null +++ b/service/waf_service/waf_httpauthsession_service.go @@ -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 +} diff --git a/service/waf_service/waf_sql_query.go b/service/waf_service/waf_sql_query.go index a3a2388d..918dd0a1 100644 --- a/service/waf_service/waf_sql_query.go +++ b/service/waf_service/waf_sql_query.go @@ -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* 键值配置表(可能明文存密钥) diff --git a/wafdb/migrations_core.go b/wafdb/migrations_core.go index c71133d2..269ff689 100644 --- a/wafdb/migrations_core.go +++ b/wafdb/migrations_core.go @@ -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{}) + }, + }, }) // 执行迁移 diff --git a/wafdb/migrations_task.go b/wafdb/migrations_task.go index a125e627..d4d44aa2 100644 --- a/wafdb/migrations_task.go +++ b/wafdb/migrations_task.go @@ -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 毫无压力。 diff --git a/wafenginecore/wafengine.go b/wafenginecore/wafengine.go index 9f5a56d4..b65d3cff 100644 --- a/wafenginecore/wafengine.go +++ b/wafenginecore/wafengine.go @@ -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 diff --git a/wafenginecore/wafworker.go b/wafenginecore/wafworker.go index c61e657b..a633fe37 100644 --- a/wafenginecore/wafworker.go +++ b/wafenginecore/wafworker.go @@ -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 +// +// 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) + } + // 空值(存量站点)与未知取值都按 Basic 走:开关已经开了,认不出类型就放行等于这道门形同虚设 + return waf.doBasicAuth(hostSafe, w, r, clientIP, cfg) +} - // 获取认证类型,默认为 authorization(Basic Auth) - authType := hostSafe.Host.HttpAuthBaseType - if authType == "" { - authType = "authorization" - } +// 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], "") +} - // 根据认证类型选择不同的认证方式 - if authType == "authorization" { - // 使用Basic Auth方式 - return waf.doBasicAuth(hostSafe, w, r) - } else if authType == "custom" { - // 使用自定义页面方式 - return waf.doCustomAuth(hostSafe, w, r) +// 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(), } +} - return isStop, "" +// 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 使用Basic Auth方式认证(原有逻辑) -func (waf *WafEngine) doBasicAuth(hostSafe *wafenginmodel.HostSafe, w http.ResponseWriter, r *http.Request) (bool, string) { - isStop := false +// 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 + } + + // 凭证过关,接着做会话记账:到期或被踢时要换 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 isStop, "" + 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) diff --git a/wafmangeweb/localserver.go b/wafmangeweb/localserver.go index 6b359202..b11be667 100644 --- a/wafmangeweb/localserver.go +++ b/wafmangeweb/localserver.go @@ -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) diff --git a/waftask/task_httpauth_clean.go b/waftask/task_httpauth_clean.go new file mode 100644 index 00000000..6f1ee9ec --- /dev/null +++ b/waftask/task_httpauth_clean.go @@ -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) + " 条", + }) + } +} diff --git a/wafupgradenotice/upgrade_notes.yaml b/wafupgradenotice/upgrade_notes.yaml index e96fec05..162309e9 100644 --- a/wafupgradenotice/upgrade_notes.yaml +++ b/wafupgradenotice/upgrade_notes.yaml @@ -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.