新增活跃会话管理与通行密钥、钱包登录
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
+177 -16
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"strings"
"gorm.io/gorm"
)
// OAuth provider 配置键;client secret 以 AES-GCM 密文落库。
@@ -103,18 +105,83 @@ func flagPtr(p *bool) *string {
return &v
}
// UpdateOAuth 部分更新 provider 配置:只落库非 nil 字段;
// issuer 规范化去尾斜杠,secret 加密落库(空串清除)。
// UpdateOAuth 部分更新 provider 配置:只落库非 nil 字段;issuer 规范化去尾斜杠,
// secret 加密落库(空串清除)。预检与写入在同一事务并持有认证变更共用的用户行锁:
// 密码登录禁用期间,禁止把最后可实际登录的方式禁用或清空(防自锁),
// 且不与「禁用密码 / 删除最后因子」并发交错。
func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) error {
return s.updateOAuth(ctx, in, nil)
}
// UpdateOAuthAuthenticated 在写事务持锁后复核请求令牌,防撤销后的慢请求落库。
func (s *SettingService) UpdateOAuthAuthenticated(
ctx context.Context, in UpdateOAuthInput, auth *AuthService, username string, proof TokenProof,
) error {
check := &authenticatedMutation{auth: auth, username: username, proof: proof}
return s.updateOAuth(ctx, in, check)
}
func (s *SettingService) updateOAuth(ctx context.Context, in UpdateOAuthInput, check *authenticatedMutation) error {
secrets, err := s.encryptOAuthSecrets(in)
if err != nil {
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if check != nil {
err = check.lockAndCheck(tx)
} else {
err = lockUsersForAuthChange(tx)
}
if err != nil {
return err
}
origin, err := effectiveOriginTx(tx, s)
if err != nil {
return err
}
if err := ensureLoginRemainsTx(tx, in, origin); err != nil {
return err
}
return writeOAuthTx(tx, in, secrets)
})
}
// encryptOAuthSecrets 预先加密补丁中的 secret(nil 沿用,空串清除),事务内直接落库。
func (s *SettingService) encryptOAuthSecrets(in UpdateOAuthInput) (map[string]*string, error) {
out := map[string]*string{}
for key, sec := range map[string]*string{
settingOauthOidcClientSecret: in.OidcClientSecret,
settingOauthGithubClientSecret: in.GithubClientSecret,
} {
if sec == nil {
continue
}
v := ""
if *sec != "" {
enc, err := s.cipher.EncryptString(*sec)
if err != nil {
return nil, fmt.Errorf("encrypt oauth secret: %w", err)
}
v = enc
}
out[key] = &v
}
return out, nil
}
// writeOAuthTx 事务内落库补丁中出现的字段。
func writeOAuthTx(tx *gorm.DB, in UpdateOAuthInput, secrets map[string]*string) error {
writes := []struct {
key string
val *string
}{
{settingOauthOidcIssuer, issuerPtr(in.OidcIssuer)},
{settingOauthOidcClientID, trimPtr(in.OidcClientID)},
{settingOauthOidcClientSecret, secrets[settingOauthOidcClientSecret]},
{settingOauthOidcDisplayName, trimPtr(in.OidcDisplayName)},
{settingOauthOidcDisabled, flagPtr(in.OidcDisabled)},
{settingOauthGithubClientID, trimPtr(in.GithubClientID)},
{settingOauthGithubClientSecret, secrets[settingOauthGithubClientSecret]},
{settingOauthGithubDisplayName, trimPtr(in.GithubDisplayName)},
{settingOauthGithubDisabled, flagPtr(in.GithubDisabled)},
}
@@ -122,29 +189,123 @@ func (s *SettingService) UpdateOAuth(ctx context.Context, in UpdateOAuthInput) e
if w.val == nil {
continue
}
if err := s.set(ctx, w.key, *w.val); err != nil {
if err := saveSettingTx(tx, w.key, *w.val); err != nil {
return err
}
}
if err := s.saveOAuthSecret(ctx, settingOauthOidcClientSecret, in.OidcClientSecret); err != nil {
return err
}
return s.saveOAuthSecret(ctx, settingOauthGithubClientSecret, in.GithubClientSecret)
return nil
}
// saveOAuthSecret 加密保存 secret;nil 沿用,空串清除。
func (s *SettingService) saveOAuthSecret(ctx context.Context, key string, secret *string) error {
if secret == nil {
return nil
// ensureLoginRemainsTx 事务内校验补丁生效后仍有可实际登录的方式:密码可登直接放行;
// 否则须有「可登录且已绑定身份」的 provider、任一通行密钥或钱包身份
// (单管理员面板,不区分账号统计)。开关读取失败按失败关闭处理。
func ensureLoginRemainsTx(tx *gorm.DB, in UpdateOAuthInput, origin string) error {
off, err := settingValueTx(tx, settingSecPasswordLoginOff)
if err != nil || off != "1" {
return err
}
if *secret == "" {
return s.set(ctx, key, "")
if origin == "" {
return ErrProviderLastLogin
}
enc, err := s.cipher.EncryptString(*secret)
view, err := oauthViewTx(tx)
if err != nil {
return fmt.Errorf("encrypt oauth secret: %w", err)
return err
}
return s.set(ctx, key, enc)
if ok, err := anyBoundUsableProviderTx(tx, patchedUsable(view, in)); err != nil || ok {
return err
}
// 通行密钥兜底须当前地址下可用(origin 一致);0 表示不排除任何行
if n, err := passkeyCountExcludingTx(tx, 0, 0, origin); err != nil || n > 0 {
return err
}
n, err := identityProviderCountTx(tx, "wallet")
if err != nil || n > 0 {
return err
}
return ErrProviderLastLogin
}
// oauthViewTx 事务内读 provider 配置视图(secret 只取「已设置」布尔)。
func oauthViewTx(tx *gorm.DB) (OAuthProvidersView, error) {
var view OAuthProvidersView
reads := []struct {
key string
set func(string)
}{
{settingOauthOidcIssuer, func(v string) { view.OidcIssuer = v }},
{settingOauthOidcClientID, func(v string) { view.OidcClientID = v }},
{settingOauthOidcClientSecret, func(v string) { view.OidcSecretSet = v != "" }},
{settingOauthOidcDisabled, func(v string) { view.OidcDisabled = v == "1" }},
{settingOauthGithubClientID, func(v string) { view.GithubClientID = v }},
{settingOauthGithubClientSecret, func(v string) { view.GithubSecretSet = v != "" }},
{settingOauthGithubDisabled, func(v string) { view.GithubDisabled = v == "1" }},
}
for _, r := range reads {
v, err := settingValueTx(tx, r.key)
if err != nil {
return view, err
}
r.set(v)
}
return view, nil
}
// patchedOidcUsable 计算补丁生效后 OIDC 是否可登录(clientID/secret/issuer 齐备且未禁用)。
func patchedOidcUsable(view OAuthProvidersView, in UpdateOAuthInput) bool {
id, sec, iss, off := view.OidcClientID, view.OidcSecretSet, view.OidcIssuer, view.OidcDisabled
if v := trimPtr(in.OidcClientID); v != nil {
id = *v
}
if in.OidcClientSecret != nil {
sec = *in.OidcClientSecret != ""
}
if v := issuerPtr(in.OidcIssuer); v != nil {
iss = *v
}
if in.OidcDisabled != nil {
off = *in.OidcDisabled
}
return id != "" && sec && iss != "" && !off
}
// patchedGithubUsable 计算补丁生效后 GitHub 是否可登录(clientID/secret 齐备且未禁用)。
func patchedGithubUsable(view OAuthProvidersView, in UpdateOAuthInput) bool {
id, sec, off := view.GithubClientID, view.GithubSecretSet, view.GithubDisabled
if v := trimPtr(in.GithubClientID); v != nil {
id = *v
}
if in.GithubClientSecret != nil {
sec = *in.GithubClientSecret != ""
}
if in.GithubDisabled != nil {
off = *in.GithubDisabled
}
return id != "" && sec && !off
}
// patchedUsable 汇总补丁生效后各 provider 的可登录性。
func patchedUsable(view OAuthProvidersView, in UpdateOAuthInput) map[string]bool {
return map[string]bool{
"oidc": patchedOidcUsable(view, in),
"github": patchedGithubUsable(view, in),
}
}
// anyBoundUsableProviderTx 事务内判断是否存在「可登录且已有绑定身份」的 provider。
func anyBoundUsableProviderTx(tx *gorm.DB, usable map[string]bool) (bool, error) {
for p, ok := range usable {
if !ok {
continue
}
n, err := identityProviderCountTx(tx, p)
if err != nil {
return false, err
}
if n > 0 {
return true, nil
}
}
return false, nil
}
// oauthClient 返回 provider 的 clientID/明文 secret/issuer(仅 oidc);未配置时 clientID 为空。