新增活跃会话管理与通行密钥、钱包登录
CI / test (push) Successful in 55s

This commit is contained in:
2026-07-30 12:23:05 +08:00
parent f1914880ac
commit 109c345e5e
49 changed files with 6468 additions and 336 deletions
+131 -9
View File
@@ -3,9 +3,12 @@ package service
import (
"context"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"gorm.io/gorm"
)
// 安全设置键:WAF 参数 / 真实IP请求头 / 面板地址(app_url)。
@@ -147,17 +150,49 @@ func (p SecurityPatch) merge(dst *SecuritySettings) map[string]bool {
return touched
}
// UpdateSecurity 把补丁合并到现值上整体校验,只落库出现的字段,再重读刷新快照。
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 {
cur, err := s.Security(ctx)
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
}
touched := p.merge(&cur)
if err := validateSecurity(&cur); err != nil {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.applySecurityUpdateTx(tx, up, check)
})
if err != nil {
return err
}
kv := map[string]string{
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),
@@ -165,13 +200,100 @@ func (s *SettingService) UpdateSecurity(ctx context.Context, p SecurityPatch) er
settingSecRealIPHeader: cur.RealIPHeader,
settingSecAppURL: cur.AppURL,
}
for key := range touched {
if err := s.set(ctx, key, kv[key]); err != nil {
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
}
}
// 重读而非直接 Store 合并值:并发补丁各写各键,重读保证快照收敛到库内最终值
return s.ReloadSecurity(ctx)
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 从库加载安全设置到内存快照;进程启动时调用一次。