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 }