mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 02:01:44 +08:00
feat:website password session lifetime and online session management
#987
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 规则的语义
|
||||
|
||||
@@ -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秒一次,引擎侧字节计量与日志解耦)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 是不校验
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 是统一访问认证的结构化安全事件流水。
|
||||
|
||||
@@ -39,6 +39,7 @@ type ApiGroup struct {
|
||||
SslOrderRouter
|
||||
WafSslExpireRouter
|
||||
WafHttpAuthBaseRouter
|
||||
WafHttpAuthSessionRouter
|
||||
WafTaskRouter
|
||||
WafBlockingPageRouter
|
||||
WafGPTRouter
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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* 键值配置表(可能明文存密钥)
|
||||
|
||||
@@ -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{})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 执行迁移
|
||||
|
||||
@@ -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 毫无压力。
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) + " 条",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user