Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/entrance.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type APIGroup struct {
WafSslOrderApi
WafSslExpireApi
WafHttpAuthBaseApi
WafHttpAuthSessionApi
WafTaskApi
WafBlockingPageApi
WafGPTApi
Expand Down Expand Up @@ -134,6 +135,7 @@ var (
wafSslExpireService = waf_service.WafSslExpireServiceApp

wafHttpAuthBaseService = waf_service.WafHttpAuthBaseServiceApp
wafHttpAuthSessionService = waf_service.WafHttpAuthSessionServiceApp

wafTaskService = waf_service.WafTaskServiceApp

Expand Down
112 changes: 112 additions & 0 deletions api/waf_httpauthsession_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package api

import (
"SamWaf/model/common/response"
"SamWaf/model/request"

"github.com/gin-gonic/gin"
)

// WafHttpAuthSessionApi 网站密码访问的在线会话管理。
//
// 与「统一访问认证-会话」不是一套东西:那边是全局认证中心的会话,这边是每个站点自己
// 那道门后面的会话,账号体系与开关都各自独立,所以接口、表、缓存 keyspace 全部分开。
type WafHttpAuthSessionApi struct {
}

// GetListApi 获取某站点的在线会话列表
// @Summary 获取网站密码访问的在线会话列表
// @Tags 网站密码访问-会话
// @Accept json
// @Produce json
// @Param data body request.WafHttpAuthSessionSearchReq true "分页查询参数,host_code 必填"
// @Success 200 {object} response.Response{data=response.PageResult} "获取成功"
// @Security ApiKeyAuth
// @Router /wafhost/httpauthsession/list [post]
func (w *WafHttpAuthSessionApi) GetListApi(c *gin.Context) {
var req request.WafHttpAuthSessionSearchReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
list, total, err := wafHttpAuthSessionService.GetListApi(req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
PageIndex: req.PageIndex,
PageSize: req.PageSize,
}, "获取成功", c)
}

// KickApi 踢下线单条会话
// @Summary 踢下线指定会话
// @Description 因存在最长60秒的正向缓存,最迟60秒生效;浏览器弹窗(Basic)方式下表现为强制重新输入一次密码
// @Tags 网站密码访问-会话
// @Produce json
// @Param id query string true "会话ID"
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /wafhost/httpauthsession/kick [get]
func (w *WafHttpAuthSessionApi) KickApi(c *gin.Context) {
var req request.WafHttpAuthSessionKickReq
if err := c.ShouldBind(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
if err := wafHttpAuthSessionService.KickApi(req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("已下线(最迟60秒内生效)", c)
}

// KickByUserApi 按用户批量踢下线
// @Summary 踢下线指定用户在本站点的全部会话
// @Tags 网站密码访问-会话
// @Accept json
// @Produce json
// @Param data body request.WafHttpAuthSessionKickByUserReq true "站点编码与用户名"
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /wafhost/httpauthsession/kickbyuser [post]
func (w *WafHttpAuthSessionApi) KickByUserApi(c *gin.Context) {
var req request.WafHttpAuthSessionKickByUserReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
cnt, err := wafHttpAuthSessionService.KickByUserApi(req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithDetailed(gin.H{"count": cnt}, "已下线(最迟60秒内生效)", c)
}

// KickAllApi 清空本站点的全部会话
// @Summary 踢下线本站点的全部在线会话
// @Description 应急手段:疑似密码泄露时,一次性让本站点所有人重新登录
// @Tags 网站密码访问-会话
// @Accept json
// @Produce json
// @Param data body request.WafHttpAuthSessionKickAllReq true "站点编码"
// @Success 200 {object} response.Response "操作成功"
// @Security ApiKeyAuth
// @Router /wafhost/httpauthsession/kickall [post]
func (w *WafHttpAuthSessionApi) KickAllApi(c *gin.Context) {
var req request.WafHttpAuthSessionKickAllReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
cnt, err := wafHttpAuthSessionService.KickAllApi(req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithDetailed(gin.H{"count": cnt}, "已全部下线(最迟60秒内生效)", c)
}
1 change: 1 addition & 0 deletions cmd/samwaf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions enums/cache_enum.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 规则的语义
Expand Down
1 change: 1 addition & 0 deletions enums/task_enum.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const (
TASK_STATS_DATA_CLEANUP = "task_stats_data_cleanup" //清理统计数据(按保留策略)
TASK_THREAT_IP_SYNC = "task_threat_ip_sync" //威胁情报IP订阅同步
TASK_ACCESS_CLEAN = "task_access_clean" //统一访问认证:清理过期会话/令牌/票据与审计日志
TASK_HTTPAUTH_CLEAN = "task_httpauth_clean" //网站密码访问:标记到期会话并清理历史行
TASK_HOSTGUARD_CLEAN_EXPIRED = "task_hostguard_clean_expired" //主机防爆破:解封到期封禁(每分钟,因最短阶梯只有5分钟)
TASK_TRAFFIC_FLUSH = "task_traffic_flush" //站点流量计量落库(30秒一次,引擎侧字节计量与日志解耦)
)
60 changes: 60 additions & 0 deletions model/hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 是不校验
Expand Down Expand Up @@ -727,3 +728,62 @@ func GetClientIPByMode(ipMode string, netSrcIp string, srcIP string) string {
// 默认使用网卡模式
return netSrcIp
}

// HttpAuthConfig 「网站密码访问」的会话时效配置(hosts.HttpAuthJSON)。
//
// 兼容硬约束:HttpAuthJSON 为空串时(全部存量站点),DecodeHttpAuthConfig 必须还原成
// 「24 小时绝对有效期 + 绑定登录 IP + 不启用空闲超时」,即与加这套配置之前的行为逐条一致。
// 数值字段用 FlexInt 是因为前端表单回传的是字符串,普通 int 会让该字段悄悄回落默认值。
type HttpAuthConfig struct {
SessionTTL FlexInt `json:"session_ttl"` // 绝对有效期(分钟),<=0 视为默认 1440
IdleTimeout FlexInt `json:"idle_timeout"` // 空闲超时(分钟),0=不启用
BindIP FlexInt `json:"bind_ip"` // 1=登录令牌绑定登录时的 IP(默认) 0=不绑
}

// 默认值。DefaultHttpAuthSessionTTL 对齐改造前硬编码的 24 小时。
const (
DefaultHttpAuthSessionTTL = 1440 // 分钟
)

// DecodeHttpAuthConfig 解析站点的 http_auth_json。
//
// 空串、非法 JSON、字段缺省一律回落到「等价现状」而不是零值:
// 时效为 0 会让所有人一登录就掉线,BindIP 为 0 会静默放宽一条既有约束——
// 两者都属于「解析失败反而改变了防护行为」,这里不允许发生。
func DecodeHttpAuthConfig(raw string) HttpAuthConfig {
cfg := HttpAuthConfig{
SessionTTL: DefaultHttpAuthSessionTTL,
IdleTimeout: 0,
BindIP: 1,
}
if strings.TrimSpace(raw) == "" {
return cfg
}
var parsed HttpAuthConfig
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return cfg
}
if parsed.SessionTTL > 0 {
cfg.SessionTTL = parsed.SessionTTL
}
if parsed.IdleTimeout > 0 {
cfg.IdleTimeout = parsed.IdleTimeout
}
// BindIP 是显式三态:JSON 里给了 0 就是「用户主动关掉」,不能当成缺省再拉回 1。
// 但整份 JSON 都没这个键时(老配置升级上来)必须保持 1,所以靠下面这次单独探测区分。
cfg.BindIP = parsed.BindIP
if !jsonHasKey(raw, "bind_ip") {
cfg.BindIP = 1
}
return cfg
}

// jsonHasKey 判断顶层是否显式出现过某个键,用于区分「用户填了 0」与「压根没这个字段」。
func jsonHasKey(raw, key string) bool {
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return false
}
_, ok := m[key]
return ok
}
55 changes: 55 additions & 0 deletions model/http_auth_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package model

import "testing"

// TestDecodeHttpAuthConfigCompat 钉死向后兼容:所有存量站点的 http_auth_json 都是空串,
// 解析结果必须等价于加这套配置之前的硬编码行为——24 小时有效期、绑定登录 IP、不做空闲超时。
// 这条一旦破掉,就是升级当天全量站点的访问行为静默漂移。
func TestDecodeHttpAuthConfigCompat(t *testing.T) {
for _, raw := range []string{"", " ", "{}", "not a json", "[]"} {
cfg := DecodeHttpAuthConfig(raw)
if cfg.SessionTTL != DefaultHttpAuthSessionTTL {
t.Errorf("raw=%q SessionTTL=%d, 期望 %d", raw, cfg.SessionTTL, DefaultHttpAuthSessionTTL)
}
if cfg.IdleTimeout != 0 {
t.Errorf("raw=%q IdleTimeout=%d, 期望 0(不启用)", raw, cfg.IdleTimeout)
}
if cfg.BindIP != 1 {
t.Errorf("raw=%q BindIP=%d, 期望 1(绑定)", raw, cfg.BindIP)
}
}
}

func TestDecodeHttpAuthConfig(t *testing.T) {
tests := []struct {
name string
raw string
ttl FlexInt
idle FlexInt
bindIP FlexInt
}{
{"正常数值", `{"session_ttl":120,"idle_timeout":30,"bind_ip":1}`, 120, 30, 1},
// 前端表单回传的是字符串,普通 int 会让字段悄悄回落默认值,这里必须被 FlexInt 接住
{"字符串数值", `{"session_ttl":"120","idle_timeout":"30","bind_ip":"0"}`, 120, 30, 0},
{"显式关闭绑IP", `{"session_ttl":60,"bind_ip":0}`, 60, 0, 0},
// 老配置里没有 bind_ip 这个键 → 必须保持 1,不能当成「用户填了 0」
{"缺 bind_ip 键", `{"session_ttl":60}`, 60, 0, 1},
// 非法值不接受:0 或负数的有效期会让所有人一登录就掉线
{"有效期为0回落默认", `{"session_ttl":0,"bind_ip":1}`, DefaultHttpAuthSessionTTL, 0, 1},
{"有效期为负回落默认", `{"session_ttl":-5,"bind_ip":1}`, DefaultHttpAuthSessionTTL, 0, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := DecodeHttpAuthConfig(tt.raw)
if cfg.SessionTTL != tt.ttl {
t.Errorf("SessionTTL=%d, 期望 %d", cfg.SessionTTL, tt.ttl)
}
if cfg.IdleTimeout != tt.idle {
t.Errorf("IdleTimeout=%d, 期望 %d", cfg.IdleTimeout, tt.idle)
}
if cfg.BindIP != tt.bindIP {
t.Errorf("BindIP=%d, 期望 %d", cfg.BindIP, tt.bindIP)
}
})
}
}
59 changes: 59 additions & 0 deletions model/http_auth_session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package model

import (
"SamWaf/customtype"
"SamWaf/model/baseorm"
)

// 会话状态
const (
HttpAuthStatusRevoked = 0 // 已失效(被踢/到期)
HttpAuthStatusValid = 1 // 有效
)

// 认证方式,与 hosts.HttpAuthBaseType 取值一致
const (
HttpAuthTypeAuthorization = "authorization" // 浏览器弹窗(HTTP Basic)
HttpAuthTypeCustom = "custom" // 自定义登录页
)

// 会话失效原因(RevokeReason)
const (
HttpAuthRevokeByAdmin = "admin_kick" // 管理端踢下线
HttpAuthRevokeByExpire = "expired" // 到期,由清理任务标记
HttpAuthRevokeByAccount = "account_off" // 账号被删除或改密
HttpAuthRevokeByHost = "host_off" // 站点被删除或关闭了密码访问
)

// HttpAuthSession 是「网站密码访问」的一次登录,管理端据此展示在线列表并踢下线。
//
// TokenCode 存的是摘要而不是明文:
// - custom 模式 = sha256hex(Cookie 明文)
// - basic 模式 = sha256hex(hostCode|用户名|客户端IP)
//
// 前者与 access_session 同理——库被拖走也拿不到可直接使用的 Cookie,同时它正好能当缓存键后缀,
// 管理端在不知道明文的前提下就能精确驱逐某条会话的缓存。
// 后者是因为 HTTP Basic 没有令牌可言:浏览器每个请求原样重发凭证,服务端能识别的最小单位
// 就是「哪个用户从哪个 IP 来」,所以这三元组的摘要就是它的会话身份。
type HttpAuthSession struct {
baseorm.BaseOrm
HostCode string `gorm:"size:64;index" json:"host_code"` //归属站点
Host string `gorm:"size:255" json:"host"` //冗余域名(含端口),列表直接展示
TokenCode string `gorm:"size:64;index" json:"token_code"` //见上方说明,存摘要不存明文
AuthType string `gorm:"size:20" json:"auth_type"` //authorization | custom
UserName string `gorm:"size:255" json:"user_name"`
ClientIP string `gorm:"size:64" json:"client_ip"` //按站点「真实IP来源」解析出的访客 IP,与访问日志的 SRC_IP 同源
Country string `gorm:"size:64" json:"country"` //归属地-国家
City string `gorm:"size:64" json:"city"` //归属地-省市
UserAgent string `gorm:"size:512" json:"user_agent"` //登录时的UA
Status int `json:"status"` //1有效 0已失效
RevokeReason string `gorm:"size:128" json:"revoke_reason"`
LoginTime customtype.JsonTime `json:"login_time"`
LastActiveTime customtype.JsonTime `json:"last_active_time"` //最后活跃时间(节流更新)
ExpireTime customtype.JsonTime `json:"expire_time"` //绝对过期时间
RemainSeconds int64 `gorm:"-" json:"remain_seconds"`
}

func (HttpAuthSession) TableName() string {
return "http_auth_session"
}
2 changes: 2 additions & 0 deletions model/request/waf_host_req.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 是不校验
Expand Down Expand Up @@ -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 是不校验
Expand Down
29 changes: 29 additions & 0 deletions model/request/waf_httpauthsession_req.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Loading
Loading