353 lines
12 KiB
Go
353 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// 安全设置键:WAF 参数 / 真实IP请求头 / 面板地址(app_url)。
|
|
const (
|
|
settingSecLoginFailLimit = "security_login_fail_limit"
|
|
settingSecLoginLockMin = "security_login_lock_minutes"
|
|
settingSecIPRateRPS = "security_ip_rate_rps"
|
|
settingSecIPRateBurst = "security_ip_rate_burst"
|
|
settingSecRealIPHeader = "security_real_ip_header"
|
|
settingSecAppURL = "security_app_url"
|
|
// 密码登录禁用开关("1" 为禁用);启停校验在 AuthService.SetPasswordLoginDisabled
|
|
settingSecPasswordLoginOff = "security_password_login_off"
|
|
)
|
|
|
|
// SecuritySettings 是安全设置的读写视图;零值无意义,一律经 Security/SecurityCached 构造。
|
|
type SecuritySettings struct {
|
|
// 登录守卫:LockMinutes 分钟窗口内失败 FailLimit 次锁 LockMinutes 分钟
|
|
LoginFailLimit int `json:"loginFailLimit"`
|
|
LoginLockMinutes int `json:"loginLockMinutes"`
|
|
// 全局 IP 限速令牌桶
|
|
IPRateRPS int `json:"ipRateRps"`
|
|
IPRateBurst int `json:"ipRateBurst"`
|
|
// 真实IP请求头:空 = 直连(RemoteAddr);反代后必须选择与反代匹配的头
|
|
RealIPHeader string `json:"realIpHeader"`
|
|
// 面板公网基址;优先于 PUBLIC_URL 环境变量,回传链路与 OAuth 回调用
|
|
AppURL string `json:"appUrl"`
|
|
}
|
|
|
|
// securityDefaults 与探索文档主题三的推荐参数一致。
|
|
var securityDefaults = SecuritySettings{
|
|
LoginFailLimit: 5,
|
|
LoginLockMinutes: 15,
|
|
IPRateRPS: 10,
|
|
IPRateBurst: 30,
|
|
RealIPHeader: "",
|
|
AppURL: "",
|
|
}
|
|
|
|
// realIPHeaderPattern 校验真实IP请求头名:空为直连,非空仅限字母数字与连字符
|
|
// (RFC 7230 token 的常用子集),支持 X-Forwarded-For 等预设外的自定义头。
|
|
var realIPHeaderPattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,64}$`)
|
|
|
|
func realIPHeaderValid(h string) bool {
|
|
return h == "" || realIPHeaderPattern.MatchString(h)
|
|
}
|
|
|
|
// ErrInvalidSecurity 标记安全设置越界或非法,api 层据此返回 400。
|
|
var ErrInvalidSecurity = fmt.Errorf("安全设置非法")
|
|
|
|
// validateSecurity 校验范围;AppURL 规范化(去尾斜杠)。
|
|
func validateSecurity(in *SecuritySettings) error {
|
|
switch {
|
|
case in.LoginFailLimit < 1 || in.LoginFailLimit > 20:
|
|
return fmt.Errorf("登录失败阈值须在 1-20 之间: %w", ErrInvalidSecurity)
|
|
case in.LoginLockMinutes < 1 || in.LoginLockMinutes > 1440:
|
|
return fmt.Errorf("锁定时长须在 1-1440 分钟之间: %w", ErrInvalidSecurity)
|
|
case in.IPRateRPS < 1 || in.IPRateRPS > 100:
|
|
return fmt.Errorf("IP 限速须在 1-100 req/s 之间: %w", ErrInvalidSecurity)
|
|
case in.IPRateBurst < 1 || in.IPRateBurst > 500:
|
|
return fmt.Errorf("突发额度须在 1-500 之间: %w", ErrInvalidSecurity)
|
|
case !realIPHeaderValid(in.RealIPHeader):
|
|
return fmt.Errorf("真实IP请求头仅限字母数字与连字符: %w", ErrInvalidSecurity)
|
|
}
|
|
in.AppURL = strings.TrimRight(strings.TrimSpace(in.AppURL), "/")
|
|
if in.AppURL != "" && !strings.HasPrefix(in.AppURL, "http://") && !strings.HasPrefix(in.AppURL, "https://") {
|
|
return fmt.Errorf("面板地址须以 http(s):// 开头: %w", ErrInvalidSecurity)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Security 从库读安全设置;键缺省或脏数据按字段回退默认值。
|
|
func (s *SettingService) Security(ctx context.Context) (SecuritySettings, error) {
|
|
out := securityDefaults
|
|
ints := []struct {
|
|
key string
|
|
dst *int
|
|
lo int
|
|
hi int
|
|
}{
|
|
{settingSecLoginFailLimit, &out.LoginFailLimit, 1, 20},
|
|
{settingSecLoginLockMin, &out.LoginLockMinutes, 1, 1440},
|
|
{settingSecIPRateRPS, &out.IPRateRPS, 1, 100},
|
|
{settingSecIPRateBurst, &out.IPRateBurst, 1, 500},
|
|
}
|
|
for _, f := range ints {
|
|
val, err := s.get(ctx, f.key)
|
|
if err != nil {
|
|
return SecuritySettings{}, err
|
|
}
|
|
if n, err := strconv.Atoi(val); err == nil && n >= f.lo && n <= f.hi {
|
|
*f.dst = n
|
|
}
|
|
}
|
|
header, err := s.get(ctx, settingSecRealIPHeader)
|
|
if err != nil {
|
|
return SecuritySettings{}, err
|
|
}
|
|
if realIPHeaderValid(header) {
|
|
out.RealIPHeader = header
|
|
}
|
|
if out.AppURL, err = s.get(ctx, settingSecAppURL); err != nil {
|
|
return SecuritySettings{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SecurityPatch 是安全设置的部分更新;nil 字段沿用现值,只落库出现的字段,
|
|
// 并发编辑不同字段因此互不回滚(2026-07-22 审查 #18)。
|
|
type SecurityPatch struct {
|
|
LoginFailLimit *int `json:"loginFailLimit"`
|
|
LoginLockMinutes *int `json:"loginLockMinutes"`
|
|
IPRateRPS *int `json:"ipRateRps"`
|
|
IPRateBurst *int `json:"ipRateBurst"`
|
|
RealIPHeader *string `json:"realIpHeader"`
|
|
AppURL *string `json:"appUrl"`
|
|
}
|
|
|
|
// merge 把非 nil 字段覆盖到 dst,返回被触碰字段的存储键集合。
|
|
func (p SecurityPatch) merge(dst *SecuritySettings) map[string]bool {
|
|
touched := map[string]bool{}
|
|
fields := []struct {
|
|
key string
|
|
set func()
|
|
on bool
|
|
}{
|
|
{settingSecLoginFailLimit, func() { dst.LoginFailLimit = *p.LoginFailLimit }, p.LoginFailLimit != nil},
|
|
{settingSecLoginLockMin, func() { dst.LoginLockMinutes = *p.LoginLockMinutes }, p.LoginLockMinutes != nil},
|
|
{settingSecIPRateRPS, func() { dst.IPRateRPS = *p.IPRateRPS }, p.IPRateRPS != nil},
|
|
{settingSecIPRateBurst, func() { dst.IPRateBurst = *p.IPRateBurst }, p.IPRateBurst != nil},
|
|
{settingSecRealIPHeader, func() { dst.RealIPHeader = *p.RealIPHeader }, p.RealIPHeader != nil},
|
|
{settingSecAppURL, func() { dst.AppURL = *p.AppURL }, p.AppURL != nil},
|
|
}
|
|
for _, f := range fields {
|
|
if f.on {
|
|
f.set()
|
|
touched[f.key] = true
|
|
}
|
|
}
|
|
return touched
|
|
}
|
|
|
|
type securityUpdate struct {
|
|
next SecuritySettings
|
|
touched map[string]bool
|
|
values map[string]string
|
|
}
|
|
|
|
// UpdateSecurity 供启动与内部配置使用;HTTP PATCH 使用带令牌证明的变体。
|
|
func (s *SettingService) UpdateSecurity(ctx context.Context, p SecurityPatch) error {
|
|
return s.updateSecurity(ctx, p, nil)
|
|
}
|
|
|
|
// UpdateSecurityAuthenticated 在写事务持锁后复核请求令牌,防撤销后的慢请求落库。
|
|
func (s *SettingService) UpdateSecurityAuthenticated(
|
|
ctx context.Context, p SecurityPatch, auth *AuthService, username string, proof TokenProof,
|
|
) error {
|
|
check := &authenticatedMutation{auth: auth, username: username, proof: proof}
|
|
return s.updateSecurity(ctx, p, check)
|
|
}
|
|
|
|
func (s *SettingService) updateSecurity(ctx context.Context, p SecurityPatch, check *authenticatedMutation) error {
|
|
up, err := s.prepareSecurityUpdate(ctx, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
return s.applySecurityUpdateTx(tx, up, check)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.ReloadSecurity(context.WithoutCancel(ctx))
|
|
}
|
|
|
|
func (s *SettingService) prepareSecurityUpdate(ctx context.Context, p SecurityPatch) (securityUpdate, error) {
|
|
cur, err := s.Security(ctx)
|
|
if err != nil {
|
|
return securityUpdate{}, err
|
|
}
|
|
touched := p.merge(&cur)
|
|
if err := validateSecurity(&cur); err != nil {
|
|
return securityUpdate{}, err
|
|
}
|
|
values := map[string]string{
|
|
settingSecLoginFailLimit: strconv.Itoa(cur.LoginFailLimit),
|
|
settingSecLoginLockMin: strconv.Itoa(cur.LoginLockMinutes),
|
|
settingSecIPRateRPS: strconv.Itoa(cur.IPRateRPS),
|
|
settingSecIPRateBurst: strconv.Itoa(cur.IPRateBurst),
|
|
settingSecRealIPHeader: cur.RealIPHeader,
|
|
settingSecAppURL: cur.AppURL,
|
|
}
|
|
return securityUpdate{next: cur, touched: touched, values: values}, nil
|
|
}
|
|
|
|
func (s *SettingService) applySecurityUpdateTx(tx *gorm.DB, up securityUpdate, check *authenticatedMutation) error {
|
|
if check != nil {
|
|
if err := check.lockAndCheck(tx); err != nil {
|
|
return err
|
|
}
|
|
} else if up.touched[settingSecAppURL] {
|
|
if err := lockUsersForAuthChange(tx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.checkAppURLUpdateTx(tx, up); err != nil {
|
|
return err
|
|
}
|
|
for key := range up.touched {
|
|
if err := saveSettingTx(tx, key, up.values[key]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SettingService) checkAppURLUpdateTx(tx *gorm.DB, up securityUpdate) error {
|
|
if !up.touched[settingSecAppURL] {
|
|
return nil
|
|
}
|
|
oldApp, err := settingValueTx(tx, settingSecAppURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.ensureAppURLKeepsLogin(tx, oldApp, up.next.AppURL)
|
|
}
|
|
|
|
// ensureAppURLKeepsLogin 防自锁:密码登录禁用期间,面板地址是所有免密方式的
|
|
// 运行时依赖——清空(生效值)会使通行密钥/钱包/OAuth 回调全部不可用,一律拒绝;
|
|
// 域名变更会使通行密钥失效(RP ID 绑定域名),须留有钱包身份或可用外部登录兜底。
|
|
func (s *SettingService) ensureAppURLKeepsLogin(tx *gorm.DB, oldURL, newURL string) error {
|
|
off, err := settingValueTx(tx, settingSecPasswordLoginOff)
|
|
if err != nil || off != "1" {
|
|
return err
|
|
}
|
|
newEff := s.effectiveOf(newURL)
|
|
if newEff == "" {
|
|
return ErrProviderLastLogin
|
|
}
|
|
// origin(scheme://host)整体比较:scheme 变化同样使 WebAuthn origin 失配
|
|
if originOf(newEff) == originOf(s.effectiveOf(oldURL)) {
|
|
return nil
|
|
}
|
|
if n, err := identityProviderCountTx(tx, "wallet"); err != nil || n > 0 {
|
|
return err
|
|
}
|
|
view, err := oauthViewTx(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ok, err := anyBoundUsableProviderTx(tx, patchedUsable(view, UpdateOAuthInput{}))
|
|
if err != nil || ok {
|
|
return err
|
|
}
|
|
return ErrProviderLastLogin
|
|
}
|
|
|
|
// effectiveOf 计算给定 app_url 设置值的生效地址(设置优先,回退 PUBLIC_URL 环境变量)。
|
|
func (s *SettingService) effectiveOf(v string) string {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
return s.envPublicURL
|
|
}
|
|
|
|
func effectiveOriginTx(tx *gorm.DB, settings *SettingService) (string, error) {
|
|
if settings == nil {
|
|
return "", nil
|
|
}
|
|
appURL, err := settingValueTx(tx, settingSecAppURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return originOf(settings.effectiveOf(appURL)), nil
|
|
}
|
|
|
|
// originOf 取 URL 的 origin(scheme://host);空串或解析失败按原串返回。
|
|
func originOf(rawURL string) string {
|
|
if rawURL == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil || u.Host == "" {
|
|
return rawURL
|
|
}
|
|
return u.Scheme + "://" + u.Host
|
|
}
|
|
|
|
// ReloadSecurity 从库加载安全设置到内存快照;进程启动时调用一次。
|
|
func (s *SettingService) ReloadSecurity(ctx context.Context) error {
|
|
sec, err := s.Security(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.security.Store(sec)
|
|
return nil
|
|
}
|
|
|
|
// SecurityCached 返回安全设置内存快照,供每请求热路径零 IO 读取;
|
|
// 快照未初始化(启动未 Reload 或测试环境)时返回默认值。
|
|
func (s *SettingService) SecurityCached() SecuritySettings {
|
|
if v, ok := s.security.Load().(SecuritySettings); ok {
|
|
return v
|
|
}
|
|
return securityDefaults
|
|
}
|
|
|
|
// securityOf 是 nil 容忍的快照读取,供未注入 settings 的调用方(测试)回退默认。
|
|
func securityOf(s *SettingService) SecuritySettings {
|
|
if s == nil {
|
|
return securityDefaults
|
|
}
|
|
return s.SecurityCached()
|
|
}
|
|
|
|
// SetEnvPublicURL 记录 PUBLIC_URL 环境变量,作为 app_url 未配置时的回退。
|
|
func (s *SettingService) SetEnvPublicURL(url string) {
|
|
s.envPublicURL = strings.TrimRight(url, "/")
|
|
}
|
|
|
|
// PasswordLoginDisabled 读密码登录禁用开关;键缺省视为未禁用。
|
|
func (s *SettingService) PasswordLoginDisabled(ctx context.Context) (bool, error) {
|
|
v, err := s.get(ctx, settingSecPasswordLoginOff)
|
|
return v == "1", err
|
|
}
|
|
|
|
// SetPasswordLoginDisabled 保存密码登录禁用开关。
|
|
func (s *SettingService) SetPasswordLoginDisabled(ctx context.Context, disabled bool) error {
|
|
v := ""
|
|
if disabled {
|
|
v = "1"
|
|
}
|
|
return s.set(ctx, settingSecPasswordLoginOff, v)
|
|
}
|
|
|
|
// EffectiveAppURL 返回生效的面板公网基址:设置里的 app_url 优先,回退环境变量。
|
|
func (s *SettingService) EffectiveAppURL() string {
|
|
if u := s.SecurityCached().AppURL; u != "" {
|
|
return u
|
|
}
|
|
return s.envPublicURL
|
|
}
|