Commit 0973dc2b by CaIon

feat(security): harden account binding and password changes

Require scoped, single-use verification for account bindings and password
operations. Bind OAuth authorization and email confirmations to the initiating
session; preserve the last usable login method and audit operation outcomes.

Apply Unicode-aware password length limits, Argon2id writes with bcrypt
compatibility, and long-password encryption.
Return has_password with the existing profile SELECT without extra queries.
Reuse the existing security dialogs and add all seven locale translations.

Validation:
- Go: go test ./common ./model ./service ./middleware ./controller ./router -count=1
- DB: SQLite 3.50.4, MySQL 8.4.11, PostgreSQL 16.15; separate main/log databases
- MySQL/PostgreSQL: TEST_SECURITY_DIALECT=<dialect> with TEST_<DIALECT>_DSN,
  go test ./controller -run '^(TestSecurityAccount|TestSecurityEnrollment|TestGenerateOAuthCode|TestOAuthBind|TestTelegramOAuth)' -count=1 -v
- Web: relevant Vitest suites, bun run typecheck, targeted oxlint/format,
  bun run i18n:sync, and bun run build

Roll out dual-format readers to every instance with
ACCOUNT_PASSWORD_HASH_ALGORITHM=bcrypt before enabling Argon2id writes
and the new UI. Rollbacks must retain Argon2id and v2 envelope readers.

Relevant controls: ASVS 5.0.0 6.2.1-6.2.3, 6.2.5-6.2.9, 6.3.7, 7.4.3, 7.5.1;
this change does not assert application-wide ASVS certification.
parent a8729b5c
...@@ -89,6 +89,13 @@ ...@@ -89,6 +89,13 @@
# SESSION_SECRET=random_string # SESSION_SECRET=random_string
# 登录密码请求体 RSA-OAEP 加密;默认关闭,且不能替代 HTTPS # 登录密码请求体 RSA-OAEP 加密;默认关闭,且不能替代 HTTPS
# PASSWORD_LOGIN_ENCRYPTION_ENABLED=true # PASSWORD_LOGIN_ENCRYPTION_ENABLED=true
# Account password storage. For a rolling upgrade, deploy every node with bcrypt
# first (dual-format verification), then switch all nodes to argon2id before
# enabling long passwords. A rollback must retain Argon2id verification and
# v2 login-password envelope support (when login encryption is enabled).
# Existing bcrypt hashes and MFA backup codes are not rewritten.
# ACCOUNT_PASSWORD_HASH_ALGORITHM=argon2id
# false/未配置:本地 HTTP 模式,关闭 refresh/logout OriginGuard,且不得设置 TRUSTED_URL;兼容本地开发代理。 # false/未配置:本地 HTTP 模式,关闭 refresh/logout OriginGuard,且不得设置 TRUSTED_URL;兼容本地开发代理。
# true:启用 Secure Refresh Cookie 和严格 OriginGuard,必须同时列出全部可信 HTTPS Origin。 # true:启用 Secure Refresh Cookie 和严格 OriginGuard,必须同时列出全部可信 HTTPS Origin。
# SESSION_COOKIE_TRUSTED_URL 多项用英文逗号分隔;不支持通配符、路径或域名后缀匹配。 # SESSION_COOKIE_TRUSTED_URL 多项用英文逗号分隔;不支持通配符、路径或域名后缀匹配。
......
package common
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
"unicode/utf8"
"golang.org/x/crypto/argon2"
)
const (
MinAccountPasswordLength = 8
MaxAccountPasswordLength = 128
accountPasswordMemory = 19 * 1024
accountPasswordTime = 2
accountPasswordSaltBytes = 16
accountPasswordKeyBytes = 32
)
var (
ErrAccountPasswordLength = errors.New("Password must contain between 8 and 128 characters.")
ErrAccountPasswordSame = errors.New("New password must be different from current password")
ErrPasswordLegacyLimit = errors.New("Long passwords are unavailable until the password storage upgrade is complete.")
)
// ValidateNewAccountPassword applies only when a user chooses a new password.
// Authentication must continue to accept historical passwords without applying
// the new policy. Do not normalize passwords, including surrounding whitespace.
func ValidateNewAccountPassword(password string) error {
if !utf8.ValidString(password) || utf8.RuneCountInString(password) < MinAccountPasswordLength || utf8.RuneCountInString(password) > MaxAccountPasswordLength {
return ErrAccountPasswordLength
}
return nil
}
// HashAccountPassword is for account passwords, not MFA backup codes. The
// temporary bcrypt mode permits rolling out dual-format readers to all nodes
// before enabling Argon2id writes. Existing hashes are never rewritten in bulk.
func HashAccountPassword(password string) (string, error) {
if err := ValidateNewAccountPassword(password); err != nil {
return "", err
}
switch os.Getenv("ACCOUNT_PASSWORD_HASH_ALGORITHM") {
case "bcrypt":
if len(password) > 72 {
return "", ErrPasswordLegacyLimit
}
return Password2Hash(password)
case "", "argon2id":
default:
return "", errors.New("Unsupported account password hashing configuration.")
}
salt := make([]byte, accountPasswordSaltBytes)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate account password salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, accountPasswordTime, accountPasswordMemory, 1, accountPasswordKeyBytes)
return fmt.Sprintf("$argon2id$v=19$m=19456,t=2,p=1$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
func validateArgon2AccountPassword(password, encoded string) bool {
// Bound both plaintext and parameters before invoking a memory-hard KDF.
// Only the version/parameters emitted by this application are accepted.
if len(password) > MaxAccountPasswordLength*utf8.UTFMax || len(encoded) > 256 {
return false
}
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" || parts[2] != "v=19" || parts[3] != "m=19456,t=2,p=1" {
return false
}
salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4])
if err != nil || len(salt) != accountPasswordSaltBytes {
return false
}
expected, err := base64.RawStdEncoding.Strict().DecodeString(parts[5])
if err != nil || len(expected) != accountPasswordKeyBytes {
return false
}
actual := argon2.IDKey([]byte(password), salt, accountPasswordTime, accountPasswordMemory, 1, accountPasswordKeyBytes)
return subtle.ConstantTimeCompare(actual, expected) == 1
}
...@@ -4,6 +4,7 @@ import ( ...@@ -4,6 +4,7 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"strings"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
...@@ -27,6 +28,9 @@ func Password2Hash(password string) (string, error) { ...@@ -27,6 +28,9 @@ func Password2Hash(password string) (string, error) {
} }
func ValidatePasswordAndHash(password string, hash string) bool { func ValidatePasswordAndHash(password string, hash string) bool {
if strings.HasPrefix(hash, "$argon2id$") {
return validateArgon2AccountPassword(password, hash)
}
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil return err == nil
} }
package common package common
import ( import (
"crypto/aes"
"crypto/cipher"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/sha256" "crypto/sha256"
...@@ -12,6 +14,7 @@ import ( ...@@ -12,6 +14,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"unicode/utf8"
) )
const passwordEncryptionKeyBits = 2048 const passwordEncryptionKeyBits = 2048
...@@ -92,9 +95,10 @@ func PasswordEncryptionPublicKey() (keyID string, publicKeyPEM string) { ...@@ -92,9 +95,10 @@ func PasswordEncryptionPublicKey() (keyID string, publicKeyPEM string) {
return passwordEncryptionState.keyID, passwordEncryptionState.publicKey return passwordEncryptionState.keyID, passwordEncryptionState.publicKey
} }
// DecryptPassword decrypts a base64 RSA-OAEP/SHA-256 password submitted by a // DecryptPassword accepts legacy RSA-OAEP/SHA-256 ciphertext and v2 envelopes.
// browser. All malformed inputs share one error so callers do not expose // V2 wraps a fresh AES-256 key with RSA-OAEP and encrypts the password with GCM,
// cryptographic details to unauthenticated clients. // allowing long Unicode passwords to work with existing 2048-bit server keys.
// Both formats share one public error for all malformed inputs.
func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) { func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) {
passwordEncryptionState.RLock() passwordEncryptionState.RLock()
privateKey := passwordEncryptionState.privateKey privateKey := passwordEncryptionState.privateKey
...@@ -103,6 +107,44 @@ func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) { ...@@ -103,6 +107,44 @@ func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) {
if privateKey == nil || keyID == "" || keyID != activeKeyID { if privateKey == nil || keyID == "" || keyID != activeKeyID {
return "", ErrPasswordEncryptionInvalid return "", ErrPasswordEncryptionInvalid
} }
if strings.HasPrefix(ciphertextBase64, "v2.") {
if len(ciphertextBase64) > 4096 {
return "", ErrPasswordEncryptionInvalid
}
parts := strings.Split(ciphertextBase64, ".")
if len(parts) != 4 {
return "", ErrPasswordEncryptionInvalid
}
wrappedKey, err := base64.StdEncoding.Strict().DecodeString(parts[1])
if err != nil || len(wrappedKey) != privateKey.Size() {
return "", ErrPasswordEncryptionInvalid
}
nonce, err := base64.StdEncoding.Strict().DecodeString(parts[2])
if err != nil || len(nonce) != 12 {
return "", ErrPasswordEncryptionInvalid
}
ciphertext, err := base64.StdEncoding.Strict().DecodeString(parts[3])
if err != nil || len(ciphertext) <= 16 || len(ciphertext) > MaxAccountPasswordLength*utf8.UTFMax+16 {
return "", ErrPasswordEncryptionInvalid
}
key, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, wrappedKey, []byte("password-v2"))
if err != nil || len(key) != 32 {
return "", ErrPasswordEncryptionInvalid
}
block, err := aes.NewCipher(key)
if err != nil {
return "", ErrPasswordEncryptionInvalid
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", ErrPasswordEncryptionInvalid
}
plaintext, err := gcm.Open(nil, nonce, ciphertext, []byte("password-v2:"+keyID))
if err != nil {
return "", ErrPasswordEncryptionInvalid
}
return string(plaintext), nil
}
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64) ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
if err != nil || len(ciphertext) != privateKey.Size() { if err != nil || len(ciphertext) != privateKey.Size() {
return "", ErrPasswordEncryptionInvalid return "", ErrPasswordEncryptionInvalid
......
...@@ -16,26 +16,31 @@ import ( ...@@ -16,26 +16,31 @@ import (
// action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的 // action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的
// 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。 // 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。
var auditContentTemplates = map[string]string{ var auditContentTemplates = map[string]string{
"user.create": "Created user ${username} (role ${role})", "user.create": "Created user ${username} (role ${role})",
"user.update": "Updated user ${username} (ID: ${id})", "user.update": "Updated user ${username} (ID: ${id})",
"user.delete": "Deleted user ${username} (ID: ${id})", "user.delete": "Deleted user ${username} (ID: ${id})",
"user.manage": "Performed ${action} on user ${username} (ID: ${id})", "user.manage": "Performed ${action} on user ${username} (ID: ${id})",
"user.quota_add": "Increased user quota by ${quota}", "user.quota_add": "Increased user quota by ${quota}",
"user.quota_subtract": "Decreased user quota by ${quota}", "user.quota_subtract": "Decreased user quota by ${quota}",
"user.quota_override": "Overrode user quota from ${from} to ${to}", "user.quota_override": "Overrode user quota from ${from} to ${to}",
"user.binding_clear": "Cleared ${bindingType} binding for user ${username}", "user.binding_clear": "Cleared ${bindingType} binding for user ${username}",
"user.2fa_disable": "Force-disabled two-factor authentication for the user", "user.2fa_disable": "Force-disabled two-factor authentication for the user",
"user.passkey_register": "Registered a passkey", "user.passkey_register": "Registered a passkey",
"access_token.generate": "Generated a system access token", "access_token.generate": "Generated a system access token",
"access_token.revoke": "Revoked the system access token", "access_token.revoke": "Revoked the system access token",
"user.2fa_setup": "Started two-factor authentication setup", "user.2fa_setup": "Started two-factor authentication setup",
"user.2fa_enable": "Enabled two-factor authentication", "user.2fa_enable": "Enabled two-factor authentication",
"user.2fa_disable_self": "Disabled two-factor authentication", "user.2fa_disable_self": "Disabled two-factor authentication",
"user.2fa_backup_codes": "Regenerated two-factor backup codes", "user.2fa_backup_codes": "Regenerated two-factor backup codes",
"user.security_verify": "Completed security verification", "user.security_verify": "Completed security verification",
"user.passkey_delete": "Deleted a passkey", "user.password_change": "Account password change",
"user.reset_passkey": "Reset the user passkey", "user.binding_start": "Account binding request",
"option.update": "Updated system setting ${key}", "user.binding_bind": "Account binding",
"user.binding_unbind": "Account unlinking",
"user.email_binding_resend": "Email confirmation code resend",
"user.passkey_delete": "Deleted a passkey",
"user.reset_passkey": "Reset the user passkey",
"option.update": "Updated system setting ${key}",
"channel.create": "Created channel ${name} (type ${type}, count ${count})", "channel.create": "Created channel ${name} (type ${type}, count ${count})",
"channel.update": "Updated channel ${name} (ID: ${id})", "channel.update": "Updated channel ${name} (ID: ${id})",
...@@ -118,5 +123,18 @@ func recordManageAuditFor(c *gin.Context, targetUserId int, action string, param ...@@ -118,5 +123,18 @@ func recordManageAuditFor(c *gin.Context, targetUserId int, action string, param
// recordUserSecurityAudit 记录普通用户自己的安全敏感操作(如 passkey 绑定/解绑)。 // recordUserSecurityAudit 记录普通用户自己的安全敏感操作(如 passkey 绑定/解绑)。
// 这类日志没有管理员操作者,不写 admin_info;同时不依赖 AdminAuth/RootAuth 的兜底。 // 这类日志没有管理员操作者,不写 admin_info;同时不依赖 AdminAuth/RootAuth 的兜底。
func recordUserSecurityAudit(c *gin.Context, userId int, action string, params map[string]interface{}) { func recordUserSecurityAudit(c *gin.Context, userId int, action string, params map[string]interface{}) {
model.RecordOperationAuditLog(userId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, nil, nil, c) if code := c.GetString("security_error_code"); code != "" {
if params == nil {
params = map[string]interface{}{}
}
params["code"] = code
}
var auditInfo *model.AuditRequestInfo
if success, ok := params["success"].(bool); ok {
auditInfo = &model.AuditRequestInfo{
Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(),
Status: c.Writer.Status(), Success: success,
}
}
model.RecordOperationAuditLog(userId, c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, nil, auditInfo, c)
} }
...@@ -12,6 +12,7 @@ import ( ...@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth" "github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
...@@ -50,18 +51,21 @@ func (*authFlowTestOAuthProvider) ProviderUserIDColumn() string ...@@ -50,18 +51,21 @@ func (*authFlowTestOAuthProvider) ProviderUserIDColumn() string
func setupAuthFlowControllerTest(t *testing.T) *authFlowTestOAuthProvider { func setupAuthFlowControllerTest(t *testing.T) *authFlowTestOAuthProvider {
t.Helper() t.Helper()
previousDB := model.DB previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedis := common.RedisEnabled
common.RedisEnabled = false
previousType := common.MainDatabaseType() previousType := common.MainDatabaseType()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.AuthFlow{})) require.NoError(t, db.AutoMigrate(&model.AuthFlow{}, &model.User{}, &model.UserSession{}, &model.AuditLog{}))
model.DB = db model.DB, model.LOG_DB = db, db
common.SetMainDatabaseType(common.DatabaseTypeSQLite) common.SetMainDatabaseType(common.DatabaseTypeSQLite)
provider := &authFlowTestOAuthProvider{} provider := &authFlowTestOAuthProvider{}
oauth.Register("auth-flow-test", provider) oauth.Register("auth-flow-test", provider)
t.Cleanup(func() { t.Cleanup(func() {
oauth.Unregister("auth-flow-test") oauth.Unregister("auth-flow-test")
model.DB = previousDB model.DB, model.LOG_DB = previousDB, previousLogDB
common.RedisEnabled = previousRedis
common.SetMainDatabaseType(previousType) common.SetMainDatabaseType(previousType)
}) })
return provider return provider
...@@ -97,34 +101,23 @@ func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) { ...@@ -97,34 +101,23 @@ func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) {
} }
func TestGenerateOAuthCodeBindsFlowToAuthenticatedSession(t *testing.T) { func TestGenerateOAuthCodeBindsFlowToAuthenticatedSession(t *testing.T) {
setupAuthFlowControllerTest(t) _, identity := setupSecurityEnrollmentTest(t)
recorder := httptest.NewRecorder() oauth.Register("auth-flow-test", &authFlowTestOAuthProvider{})
c, _ := gin.CreateTestContext(recorder) t.Cleanup(func() { oauth.Unregister("auth-flow-test") })
c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"bind"}`)) proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"auth-flow-test"}`)}, service.VerificationMethodPassword)
c.Request.Header.Set("Content-Type", "application/json") response := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode)
c.Set("id", 42) var result struct {
c.Set("session_id", "session-42")
c.Set("auth_version", int64(3))
c.Set("session_version", int64(2))
GenerateOAuthCode(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"` Success bool `json:"success"`
Data struct { Data struct {
FlowToken string `json:"flow_token"` FlowToken string `json:"flow_token"`
} `json:"data"` } `json:"data"`
} }
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, response.Success) require.True(t, result.Success, response.Body.String())
flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{ flow, err := model.GetAuthFlow(result.Data.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind, UserId: identity.UserID, SessionId: identity.SessionID})
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind,
UserId: 42, SessionId: "session-42",
})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 42, flow.UserId) assert.Equal(t, identity.UserID, flow.UserId)
assert.Equal(t, "session-42", flow.SessionId) assert.Equal(t, identity.SessionID, flow.SessionId)
} }
func TestOAuthLoginConsumesFlowOnlyAfterProviderIdentity(t *testing.T) { func TestOAuthLoginConsumesFlowOnlyAfterProviderIdentity(t *testing.T) {
...@@ -198,27 +191,25 @@ func TestOAuthLoginConsumesFlowAfterProviderIdentityAndOnProviderError(t *testin ...@@ -198,27 +191,25 @@ func TestOAuthLoginConsumesFlowAfterProviderIdentityAndOnProviderError(t *testin
} }
func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) { func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) {
provider := setupAuthFlowControllerTest(t) _, identity := setupSecurityEnrollmentTest(t)
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ provider := &authFlowTestOAuthProvider{}
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind, oauth.Register("auth-flow-test", provider)
UserId: 42, SessionId: "session-42", Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute), t.Cleanup(func() { oauth.Unregister("auth-flow-test") })
}) proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"auth-flow-test"}`)}, service.VerificationMethodPassword)
require.NoError(t, err) started := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode)
router := gin.New() var result struct {
router.Use(func(c *gin.Context) { Data struct {
c.Set("id", 42) FlowToken string `json:"flow_token"`
c.Set("session_id", "session-42") } `json:"data"`
c.Set("auth_version", int64(1)) }
c.Set("session_version", int64(1)) require.NoError(t, common.Unmarshal(started.Body.Bytes(), &result))
c.Next() require.NotEmpty(t, result.Data.FlowToken)
response := securityEnrollmentRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+result.Data.FlowToken+"&error=access_denied&error_description=cancelled", "", "", identity, func(c *gin.Context) {
c.Params = gin.Params{{Key: "provider", Value: "auth-flow-test"}}
HandleOAuth(c)
}) })
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+flowToken+"&error=access_denied&error_description=cancelled", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code) assert.Equal(t, http.StatusOK, response.Code)
_, err = model.GetAuthFlow(flowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth}) _, err := model.GetAuthFlow(result.Data.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed) assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Zero(t, provider.exchangeCalls) assert.Zero(t, provider.exchangeCalls)
assert.Zero(t, provider.userInfoCalls) assert.Zero(t, provider.userInfoCalls)
......
...@@ -10,8 +10,10 @@ import ( ...@@ -10,8 +10,10 @@ import (
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth" "github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
...@@ -521,27 +523,47 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) { ...@@ -521,27 +523,47 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) {
// UnbindCustomOAuth unbinds a custom OAuth provider from the current user // UnbindCustomOAuth unbinds a custom OAuth provider from the current user
func UnbindCustomOAuth(c *gin.Context) { func UnbindCustomOAuth(c *gin.Context) {
userId := c.GetInt("id") identity, ok := middleware.GetSessionAuthIdentity(c)
if userId == 0 { if !ok {
common.ApiErrorMsg(c, "未登录") writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return return
} }
providerIdStr := c.Param("provider_id") providerIdStr := c.Param("provider_id")
providerId, err := strconv.Atoi(providerIdStr) providerId, err := strconv.Atoi(providerIdStr)
if err != nil { if err != nil || providerId <= 0 {
common.ApiErrorMsg(c, "无效的提供商 ID") common.ApiErrorMsg(c, "无效的提供商 ID")
return return
} }
if err := model.DeleteUserOAuthBinding(userId, providerId); err != nil { succeeded, notificationFailed := false, false
common.ApiError(c, err) defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.binding_unbind", map[string]interface{}{"provider_id": providerId, "success": succeeded, "notification_failed": notificationFailed})
}()
context, err := common.Marshal(service.AccountUnbindingContext{ProviderID: providerId})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountUnbind, Context: context}) == nil {
return
}
if err := service.UnbindAccountOAuth(identity, providerId); err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded = true
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
writeSecurityOperationError(c, err)
return return
} }
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "Login account unlinked") != nil
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "解绑成功", "message": "解绑成功",
"data": gin.H{"notification_warning": notificationFailed},
}) })
} }
......
package controller
import (
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
type emailBindRequest struct {
Email string `json:"email"`
FlowToken string `json:"flow_token"`
NewCode string `json:"new_code"`
OldCode string `json:"old_code"`
}
func EmailBindStart(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
succeeded, notificationFailed := false, false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.binding_start", map[string]interface{}{"provider": "email", "success": succeeded, "notification_failed": notificationFailed})
}()
var request emailBindRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
writeSecurityOperationError(c, service.ErrVerificationContextInvalid)
return
}
email, err := service.ValidateAccountEmail(request.Email)
if err != nil {
writeSecurityOperationError(c, err)
return
}
context, err := common.Marshal(service.AccountBindingContext{Provider: "email", Email: email})
if err != nil {
writeSecurityOperationError(c, err)
return
}
authorization := middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context})
if authorization == nil {
return
}
data, err := service.StartEmailBinding(identity, authorization, email)
if err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded, notificationFailed = true, data.NotificationWarning
common.ApiSuccess(c, data)
}
func EmailBindResend(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
succeeded := false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.email_binding_resend", map[string]interface{}{"success": succeeded})
}()
var request emailBindRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.FlowToken == "" {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
data, err := service.ResendAccountEmailBinding(identity, request.FlowToken)
if err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded = true
common.ApiSuccess(c, data)
}
func EmailBind(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
succeeded, notificationFailed := false, false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.binding_bind", map[string]interface{}{"provider": "email", "success": succeeded, "notification_failed": notificationFailed})
}()
var request emailBindRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.FlowToken == "" {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
state, err := service.FinishEmailBinding(identity, request.FlowToken, request.NewCode, request.OldCode)
if err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded = true
notificationFailed = service.NotifyAccountSecurityChange(state.CurrentEmail, "Email address changed") != nil
if err := service.NotifyAccountSecurityChange(state.Email, "Email address confirmed"); err != nil {
notificationFailed = true
}
if err := model.PublishUserAuthCache(identity.UserID); err != nil {
writeSecurityOperationError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"notification_warning": notificationFailed}})
}
...@@ -5,7 +5,6 @@ import ( ...@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
...@@ -14,6 +13,7 @@ import ( ...@@ -14,6 +13,7 @@ import (
"github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth" "github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting" "github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
...@@ -216,47 +216,11 @@ func GetHomePageContent(c *gin.Context) { ...@@ -216,47 +216,11 @@ func GetHomePageContent(c *gin.Context) {
} }
func SendEmailVerification(c *gin.Context) { func SendEmailVerification(c *gin.Context) {
email := model.NormalizeEmail(c.Query("email")) email, err := service.ValidateAccountEmail(c.Query("email"))
if err := common.Validate.Var(email, "required,email"); err != nil { if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) writeSecurityOperationError(c, err)
return
}
parts := strings.Split(email, "@")
if len(parts) != 2 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的邮箱地址",
})
return return
} }
localPart := parts[0]
domainPart := parts[1]
if common.EmailDomainRestrictionEnabled {
allowed := false
for _, domain := range common.EmailDomainWhitelist {
if domainPart == domain {
allowed = true
break
}
}
if !allowed {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "The administrator has enabled the email domain name whitelist, and your email address is not allowed due to special symbols or it's not in the whitelist.",
})
return
}
}
if common.EmailAliasRestrictionEnabled {
containsSpecialSymbols := strings.Contains(localPart, "+") || strings.Contains(localPart, ".")
if containsSpecialSymbols {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员已启用邮箱地址别名限制,您的邮箱地址由于包含特殊符号而被拒绝。",
})
return
}
}
if model.IsEmailAlreadyTaken(email) { if model.IsEmailAlreadyTaken(email) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
...@@ -268,7 +232,7 @@ func SendEmailVerification(c *gin.Context) { ...@@ -268,7 +232,7 @@ func SendEmailVerification(c *gin.Context) {
content := fmt.Sprintf("<p>您好,你正在进行%s邮箱验证。</p>"+ content := fmt.Sprintf("<p>您好,你正在进行%s邮箱验证。</p>"+
"<p>您的验证码为: <strong>%s</strong></p>"+ "<p>您的验证码为: <strong>%s</strong></p>"+
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes) "<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content) err = common.SendEmail(subject, email, content)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
......
...@@ -493,46 +493,6 @@ func TestListModelsTokenLimitUsesResolvedCustomAutoGroups(t *testing.T) { ...@@ -493,46 +493,6 @@ func TestListModelsTokenLimitUsesResolvedCustomAutoGroups(t *testing.T) {
require.Empty(t, anthropicResponse.LastID) require.Empty(t, anthropicResponse.LastID)
} }
func TestCheckUpdatePasswordRequiresCurrentPassword(t *testing.T) {
db := setupModelListControllerTestDB(t)
hashedPassword, err := common.Password2Hash("CurrentPassword123")
require.NoError(t, err)
user := &model.User{
Username: "password-user",
Password: hashedPassword,
Status: common.UserStatusEnabled,
}
require.NoError(t, db.Create(user).Error)
updatePassword, err := checkUpdatePassword("", "", user.Id)
require.NoError(t, err)
assert.False(t, updatePassword)
updatePassword, err = checkUpdatePassword("", "NewPassword123", user.Id)
require.Error(t, err)
assert.False(t, updatePassword)
assert.ErrorIs(t, err, errOriginalPasswordFail)
updatePassword, err = checkUpdatePassword("CurrentPassword123", "NewPassword123", user.Id)
require.NoError(t, err)
assert.True(t, updatePassword)
}
func TestCheckUpdatePasswordRejectsHistoricalEmptyPassword(t *testing.T) {
db := setupModelListControllerTestDB(t)
user := &model.User{
Username: "legacy-passwordless-user",
Password: "",
Status: common.UserStatusEnabled,
}
require.NoError(t, db.Create(user).Error)
updatePassword, err := checkUpdatePassword("", "NewPassword123", user.Id)
require.Error(t, err)
assert.False(t, updatePassword)
assert.ErrorIs(t, err, errUserPasswordUnset)
}
func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) { func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) {
db := setupModelListControllerTestDB(t) db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}, &model.UserSession{})) require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}, &model.UserSession{}))
......
...@@ -34,6 +34,7 @@ type oauthFlowPayload struct { ...@@ -34,6 +34,7 @@ type oauthFlowPayload struct {
Verification *service.OAuthVerificationFlow `json:"verification,omitempty"` Verification *service.OAuthVerificationFlow `json:"verification,omitempty"`
Telegram *oauth.TelegramOAuthFlow `json:"telegram,omitempty"` Telegram *oauth.TelegramOAuthFlow `json:"telegram,omitempty"`
SessionIdentity *service.AuthIdentity `json:"session_identity,omitempty"` SessionIdentity *service.AuthIdentity `json:"session_identity,omitempty"`
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
} }
// providerParams returns map with Provider key for i18n templates // providerParams returns map with Provider key for i18n templates
...@@ -62,6 +63,7 @@ func GenerateOAuthCode(c *gin.Context) { ...@@ -62,6 +63,7 @@ func GenerateOAuthCode(c *gin.Context) {
userID := 0 userID := 0
sessionID := "" sessionID := ""
flowPayload := oauthFlowPayload{AffiliateCode: request.Aff} flowPayload := oauthFlowPayload{AffiliateCode: request.Aff}
bindingStarted := false
if request.Provider == "telegram" { if request.Provider == "telegram" {
telegramFlow, err := oauth.NewTelegramOAuthFlow() telegramFlow, err := oauth.NewTelegramOAuthFlow()
if err != nil { if err != nil {
...@@ -78,6 +80,21 @@ func GenerateOAuthCode(c *gin.Context) { ...@@ -78,6 +80,21 @@ func GenerateOAuthCode(c *gin.Context) {
} }
userID = identity.UserID userID = identity.UserID
sessionID = identity.SessionID sessionID = identity.SessionID
if request.Intent == model.AuthFlowIntentBind {
defer func() {
recordUserSecurityAudit(c, userID, "user.binding_start", map[string]interface{}{"provider": request.Provider, "success": bindingStarted})
}()
context, err := common.Marshal(service.AccountBindingContext{Provider: request.Provider})
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.Authorization = middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context})
if flowPayload.Authorization == nil {
return
}
flowPayload.SessionIdentity = &identity
}
if flowPayload.Telegram != nil { if flowPayload.Telegram != nil {
if _, _, err := service.ValidateLoginSession(identity); err != nil { if _, _, err := service.ValidateLoginSession(identity); err != nil {
writeSecurityOperationError(c, err) writeSecurityOperationError(c, err)
...@@ -113,6 +130,7 @@ func GenerateOAuthCode(c *gin.Context) { ...@@ -113,6 +130,7 @@ func GenerateOAuthCode(c *gin.Context) {
writeSecurityOperationError(c, err) writeSecurityOperationError(c, err)
return return
} }
bindingStarted = request.Intent == model.AuthFlowIntentBind
data := gin.H{"flow_token": state, "expires_at": expiresAt.Unix()} data := gin.H{"flow_token": state, "expires_at": expiresAt.Unix()}
if flowPayload.Telegram != nil { if flowPayload.Telegram != nil {
data["authorization_url"] = flowPayload.Telegram.AuthorizationURL(state) data["authorization_url"] = flowPayload.Telegram.AuthorizationURL(state)
...@@ -155,6 +173,12 @@ func HandleOAuth(c *gin.Context) { ...@@ -155,6 +173,12 @@ func HandleOAuth(c *gin.Context) {
Provider: providerName, Provider: providerName,
Intent: pendingFlow.Intent, Intent: pendingFlow.Intent,
} }
bindSucceeded, notificationFailed := false, false
if pendingFlow.Intent == model.AuthFlowIntentBind {
defer func() {
recordUserSecurityAudit(c, pendingFlow.UserId, "user.binding_bind", map[string]interface{}{"provider": providerName, "success": bindSucceeded, "notification_failed": notificationFailed})
}()
}
// Bind and verification callbacks must use the dashboard session that started them. // Bind and verification callbacks must use the dashboard session that started them.
if pendingFlow.Intent == model.AuthFlowIntentBind || pendingFlow.Intent == model.AuthFlowIntentVerify { if pendingFlow.Intent == model.AuthFlowIntentBind || pendingFlow.Intent == model.AuthFlowIntentVerify {
identity, ok := middleware.GetSessionAuthIdentity(c) identity, ok := middleware.GetSessionAuthIdentity(c)
...@@ -167,6 +191,22 @@ func HandleOAuth(c *gin.Context) { ...@@ -167,6 +191,22 @@ func HandleOAuth(c *gin.Context) {
} }
consumeMatch.UserId = identity.UserID consumeMatch.UserId = identity.UserID
consumeMatch.SessionId = identity.SessionID consumeMatch.SessionId = identity.SessionID
if pendingFlow.Intent == model.AuthFlowIntentBind {
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(pendingFlow.Payload, &payload); err != nil {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
context, err := common.Marshal(service.AccountBindingContext{Provider: providerName})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, payload.Authorization); err != nil {
writeSecurityOperationError(c, err)
return
}
}
} else if pendingFlow.Intent != model.AuthFlowIntentLogin { } else if pendingFlow.Intent != model.AuthFlowIntentLogin {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return return
...@@ -240,15 +280,8 @@ func HandleOAuth(c *gin.Context) { ...@@ -240,15 +280,8 @@ func HandleOAuth(c *gin.Context) {
handleOAuthError(c, err) handleOAuthError(c, err)
return return
} }
if providerName == "telegram" && pendingFlow.Intent == model.AuthFlowIntentBind { if pendingFlow.Intent == model.AuthFlowIntentBind {
_, err := model.ConsumeAuthFlowWithAction(state, consumeMatch, func(tx *gorm.DB, _ *model.AuthFlow) error { bindSucceeded, notificationFailed = handleOAuthBind(c, providerName, provider, oauthUser, pendingFlow, state, consumeMatch)
return model.BindTelegramForSessionWithTx(tx, *telegramPayload.SessionIdentity, oauthUser.ProviderUserID)
})
if err != nil {
writeSecurityOperationError(c, err)
return
}
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{"action": "bind"})
return return
} }
flow, err := model.ConsumeAuthFlow(state, consumeMatch) flow, err := model.ConsumeAuthFlow(state, consumeMatch)
...@@ -260,8 +293,6 @@ func HandleOAuth(c *gin.Context) { ...@@ -260,8 +293,6 @@ func HandleOAuth(c *gin.Context) {
switch flow.Intent { switch flow.Intent {
case model.AuthFlowIntentLogin: case model.AuthFlowIntentLogin:
handleOAuthLogin(c, provider, oauthUser, flow) handleOAuthLogin(c, provider, oauthUser, flow)
case model.AuthFlowIntentBind:
handleOAuthBind(c, provider, oauthUser, flow)
case model.AuthFlowIntentVerify: case model.AuthFlowIntentVerify:
handleOAuthVerification(c, providerName, oauthUser, flow) handleOAuthVerification(c, providerName, oauthUser, flow)
} }
...@@ -320,44 +351,57 @@ func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth. ...@@ -320,44 +351,57 @@ func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.
} }
// handleOAuthBind handles binding OAuth account to existing user // handleOAuthBind handles binding OAuth account to existing user
func handleOAuthBind(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) { func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow, state string, match model.AuthFlowMatch) (bool, bool) {
// Check if this OAuth account is already bound (check both new ID and legacy ID) identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return false, false
}
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return false, false
}
context, err := common.Marshal(service.AccountBindingContext{Provider: providerName})
if err != nil {
writeSecurityOperationError(c, err)
return false, false
}
// Recheck after the external provider round trip, then validate the session
// under the transaction's locks before consuming the flow and writing.
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, payload.Authorization); err != nil {
writeSecurityOperationError(c, err)
return false, false
}
if provider.IsUserIDTaken(oauthUser.ProviderUserID) { if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName())) common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
return return false, false
} }
// Also check legacy ID to prevent duplicate bindings during migration period if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && legacyID != "" && provider.IsUserIDTaken(legacyID) {
if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && legacyID != "" { common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
if provider.IsUserIDTaken(legacyID) { return false, false
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
return
}
} }
_, err = model.ConsumeAuthFlowWithAction(state, match, func(tx *gorm.DB, _ *model.AuthFlow) error {
userId := flow.UserId if providerName == "telegram" {
var err error return model.BindTelegramForSessionWithTx(tx, identity, oauthUser.ProviderUserID)
// Handle binding based on provider type
if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
// Custom provider: use user_oauth_bindings table
err = model.UpdateUserOAuthBinding(userId, genericProvider.GetProviderId(), oauthUser.ProviderUserID)
if err != nil {
writeSecurityOperationError(c, err)
return
} }
} else { if custom, ok := provider.(*oauth.GenericOAuthProvider); ok {
// Built-in provider: 只更新绑定列。完整快照的 user.Update 会把读取时刻的 return model.UpdateUserOAuthBindingForSessionWithTx(tx, identity, custom.GetProviderId(), oauthUser.ProviderUserID)
// role/status/group 一并写回,覆盖并发发生的封禁、降权或分组变更。
err = model.UpdateUserBindColumn(userId, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID)
if err != nil {
writeSecurityOperationError(c, err)
return
} }
} return model.UpdateUserBindColumnForSessionWithTx(tx, identity, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID)
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{
"action": "bind",
}) })
if err != nil {
writeSecurityOperationError(c, err)
return false, false
}
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
writeSecurityOperationError(c, err)
return true, true
}
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{"action": "bind", "notification_warning": notificationFailed})
return true, notificationFailed
} }
// findOrCreateOAuthUser finds existing user or creates new user // findOrCreateOAuthUser finds existing user or creates new user
......
...@@ -512,7 +512,12 @@ func PasskeyVerifyBegin(c *gin.Context) { ...@@ -512,7 +512,12 @@ func PasskeyVerifyBegin(c *gin.Context) {
} }
waUser := passkeysvc.NewWebAuthnUser(user, credential) waUser := passkeysvc.NewWebAuthnUser(user, credential)
assertion, sessionData, err := wa.BeginLogin(waUser) var options []webauthnlib.LoginOption
switch request.Scope {
case service.VerificationScopeAccountBind, service.VerificationScopeAccountUnbind, service.VerificationScopePasswordSet, service.VerificationScopePasswordChange:
options = append(options, webauthnlib.WithUserVerification(protocol.VerificationRequired))
}
assertion, sessionData, err := wa.BeginLogin(waUser, options...)
if err != nil { if err != nil {
writeSecurityOperationError(c, err) writeSecurityOperationError(c, err)
return return
......
...@@ -34,6 +34,28 @@ func writeSecurityOperationError(c *gin.Context, err error) { ...@@ -34,6 +34,28 @@ func writeSecurityOperationError(c *gin.Context, err error) {
var code, message string var code, message string
var protocolError *protocol.Error var protocolError *protocol.Error
switch { switch {
case errors.Is(err, service.ErrAccountEmailInvalid), errors.Is(err, service.ErrAccountEmailRestricted):
code, message = "EMAIL_ADDRESS_REJECTED", err.Error()
case errors.Is(err, model.ErrEmailAlreadyTaken):
code, message = "EMAIL_ALREADY_TAKEN", "This email address is already in use."
case errors.Is(err, service.ErrEmailBindingDelivery):
code, message = "EMAIL_BINDING_DELIVERY_FAILED", err.Error()
case errors.Is(err, model.ErrEmailBindingCodeInvalid):
code, message = "EMAIL_BINDING_CODE_INVALID", err.Error()
case errors.Is(err, model.ErrEmailBindingLocked):
code, message = "EMAIL_BINDING_LOCKED", err.Error()
case errors.Is(err, model.ErrEmailBindingResendWait):
status = http.StatusTooManyRequests
code, message = "EMAIL_BINDING_RESEND_WAIT", err.Error()
case errors.Is(err, common.ErrAccountPasswordLength), errors.Is(err, common.ErrAccountPasswordSame), errors.Is(err, common.ErrPasswordLegacyLimit):
code, message = "PASSWORD_POLICY_REJECTED", err.Error()
case errors.Is(err, model.ErrCurrentPasswordInvalid):
code, message = "CURRENT_PASSWORD_INVALID", err.Error()
case errors.Is(err, model.ErrAccountPasswordState), errors.Is(err, model.ErrAccountBindingChanged):
status = http.StatusConflict
code, message = "ACCOUNT_SECURITY_STATE_CHANGED", err.Error()
case errors.Is(err, model.ErrLastLoginMethod):
code, message = "LAST_LOGIN_METHOD", err.Error()
case errors.Is(err, oauth.ErrTelegramOAuthNotConfigured): case errors.Is(err, oauth.ErrTelegramOAuthNotConfigured):
code, message = "TELEGRAM_OAUTH_NOT_CONFIGURED", oauth.ErrTelegramOAuthNotConfigured.Error() code, message = "TELEGRAM_OAUTH_NOT_CONFIGURED", oauth.ErrTelegramOAuthNotConfigured.Error()
case errors.Is(err, oauth.ErrTelegramOAuthConflict): case errors.Is(err, oauth.ErrTelegramOAuthConflict):
...@@ -43,7 +65,10 @@ func writeSecurityOperationError(c *gin.Context, err error) { ...@@ -43,7 +65,10 @@ func writeSecurityOperationError(c *gin.Context, err error) {
case errors.Is(err, oauth.ErrTelegramAccountNotBound): case errors.Is(err, oauth.ErrTelegramAccountNotBound):
code, message = "TELEGRAM_ACCOUNT_NOT_BOUND", oauth.ErrTelegramAccountNotBound.Error() code, message = "TELEGRAM_ACCOUNT_NOT_BOUND", oauth.ErrTelegramAccountNotBound.Error()
case errors.Is(err, model.ErrExternalIdentityAlreadyClaimed): case errors.Is(err, model.ErrExternalIdentityAlreadyClaimed):
code, message = "TELEGRAM_BIND_ALREADY_BOUND", "This Telegram account is already bound." code, message = "ACCOUNT_ALREADY_BOUND", "This external account is already bound."
if c.Param("provider") == "telegram" {
code, message = "TELEGRAM_BIND_ALREADY_BOUND", "This Telegram account is already bound."
}
case errors.Is(err, service.ErrVerificationContextInvalid): case errors.Is(err, service.ErrVerificationContextInvalid):
status = http.StatusBadRequest status = http.StatusBadRequest
code, message = "SECURITY_CONTEXT_INVALID", service.ErrVerificationContextInvalid.Error() code, message = "SECURITY_CONTEXT_INVALID", service.ErrVerificationContextInvalid.Error()
...@@ -82,9 +107,11 @@ func writeSecurityOperationError(c *gin.Context, err error) { ...@@ -82,9 +107,11 @@ func writeSecurityOperationError(c *gin.Context, err error) {
writeAuthSessionError(c, service.ErrAuthTokenInvalid) writeAuthSessionError(c, service.ErrAuthTokenInvalid)
return return
default: default:
c.Set("security_error_code", "AUTH_INTERNAL_ERROR")
writeAuthSessionError(c, err) writeAuthSessionError(c, err)
return return
} }
c.Set("security_error_code", code)
c.JSON(status, gin.H{"success": false, "code": code, "message": message}) c.JSON(status, gin.H{"success": false, "code": code, "message": message})
} }
......
...@@ -193,14 +193,26 @@ func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) { ...@@ -193,14 +193,26 @@ func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error) require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error)
} }
system_setting.GetPasskeySettings().Enabled = !test.disabledPasskey system_setting.GetPasskeySettings().Enabled = !test.disabledPasskey
for _, scope := range []string{service.VerificationScopeAccessTokenGenerate, service.VerificationScopeAccessTokenRevoke} { passwordScope := service.VerificationScopePasswordChange
if !test.password {
passwordScope = service.VerificationScopePasswordSet
}
for _, scope := range []string{service.VerificationScopeAccessTokenGenerate, service.VerificationScopeAccessTokenRevoke,
service.VerificationScopeAccountBind, service.VerificationScopeAccountUnbind, passwordScope} {
requirements, err := service.GetVerificationRequirements(identity, scope) requirements, err := service.GetVerificationRequirements(identity, scope)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, requirements.Methods, 1) require.Len(t, requirements.Methods, 1)
assert.Equal(t, test.method, requirements.Methods[0].Method) assert.Equal(t, test.method, requirements.Methods[0].Method)
assert.Equal(t, test.available, requirements.Methods[0].Available) assert.Equal(t, test.available, requirements.Methods[0].Available)
if test.wechat { if test.wechat {
_, err := service.VerifySecurityInput(identity, service.VerificationInput{Scope: scope, Method: "session"}) input := service.VerificationInput{Scope: scope, Method: "session"}
switch scope {
case service.VerificationScopeAccountBind:
input.Context = []byte(`{"provider":"email","email":"new@example.com"}`)
case service.VerificationScopeAccountUnbind:
input.Context = []byte(`{"provider_id":1}`)
}
_, err := service.VerifySecurityInput(identity, input)
assert.ErrorIs(t, err, service.ErrProofMethod) assert.ErrorIs(t, err, service.ErrProofMethod)
} }
} }
...@@ -1420,6 +1432,11 @@ func TestSecurityEnrollmentTelegramAndWeChatFirstFactor(t *testing.T) { ...@@ -1420,6 +1432,11 @@ func TestSecurityEnrollmentTelegramAndWeChatFirstFactor(t *testing.T) {
} }
var body securityEnrollmentResponse var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
if provider == "wechat" {
assert.False(t, body.Success)
assert.NotContains(t, response.Body.String(), "proof_token")
return
}
require.True(t, body.Success, response.Body.String()) require.True(t, body.Success, response.Body.String())
var proof service.SecurityProof var proof service.SecurityProof
require.NoError(t, common.Unmarshal(body.Data, &proof)) require.NoError(t, common.Unmarshal(body.Data, &proof))
...@@ -1439,8 +1456,8 @@ func TestSecurityEnrollmentTelegramAndWeChatFirstFactor(t *testing.T) { ...@@ -1439,8 +1456,8 @@ func TestSecurityEnrollmentTelegramAndWeChatFirstFactor(t *testing.T) {
} }
} }
func TestSecurityEnrollmentWeChatExceptionRemainsNarrow(t *testing.T) { func TestSecurityEnrollmentNeverTrustsSessionForFirstFactor(t *testing.T) {
for _, scenario := range []string{"password", "passkey", "locked 2fa", "telegram", "github", "disabled custom binding", "no binding", "binding storage failure", "revoked session"} { for _, scenario := range []string{"wechat only", "password", "passkey", "locked 2fa", "telegram", "github", "disabled custom binding", "no binding", "binding storage failure", "revoked session"} {
t.Run(scenario, func(t *testing.T) { t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t) user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "wechat_id": "wechat-user"}).Error) require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "wechat_id": "wechat-user"}).Error)
...@@ -1555,7 +1572,9 @@ func TestSecurityEnrollmentRejectsChangedFirstFactorPolicy(t *testing.T) { ...@@ -1555,7 +1572,9 @@ func TestSecurityEnrollmentRejectsChangedFirstFactorPolicy(t *testing.T) {
} else { } else {
require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error) require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error)
proof, err = service.VerifySecurityInput(identity, service.VerificationInput{Scope: "2fa.setup", Method: "session"}) proof, err = service.VerifySecurityInput(identity, service.VerificationInput{Scope: "2fa.setup", Method: "session"})
require.NoError(t, err) require.Error(t, err)
assert.Nil(t, proof)
return
} }
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "passkey.register"}) _, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "passkey.register"})
assert.Error(t, err) assert.Error(t, err)
......
...@@ -85,16 +85,16 @@ func PostSetup(c *gin.Context) { ...@@ -85,16 +85,16 @@ func PostSetup(c *gin.Context) {
return return
} }
if len(req.Password) < 8 { if err := common.ValidateNewAccountPassword(req.Password); err != nil {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "密码长度至少为8个字符", "message": err.Error(),
}) })
return return
} }
// Create root user // Create root user
hashedPassword, err := common.Password2Hash(req.Password) hashedPassword, err := common.HashAccountPassword(req.Password)
if err != nil { if err != nil {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
......
...@@ -156,7 +156,11 @@ func (fixture *telegramOAuthFixture) authorization(t *testing.T, intent string, ...@@ -156,7 +156,11 @@ func (fixture *telegramOAuthFixture) authorization(t *testing.T, intent string,
t.Helper() t.Helper()
request, err := common.Marshal(oauthStateRequest{Provider: "telegram", Intent: intent, Scope: scope}) request, err := common.Marshal(oauthStateRequest{Provider: "telegram", Intent: intent, Scope: scope})
require.NoError(t, err) require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/oauth/state", string(request), "", identity, GenerateOAuthCode) proof := ""
if intent == "bind" {
proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"telegram"}`)}, service.VerificationMethodPassword)
}
response := securityEnrollmentRequest("POST", "/api/oauth/state", string(request), proof, identity, GenerateOAuthCode)
var body securityEnrollmentResponse var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body)) require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
require.True(t, body.Success, response.Body.String()) require.True(t, body.Success, response.Body.String())
...@@ -428,7 +432,7 @@ func TestTelegramOAuthConfigurationAndLegacyEndpoints(t *testing.T) { ...@@ -428,7 +432,7 @@ func TestTelegramOAuthConfigurationAndLegacyEndpoints(t *testing.T) {
func TestTelegramOAuthConcurrentBindingHasSingleOwner(t *testing.T) { func TestTelegramOAuthConcurrentBindingHasSingleOwner(t *testing.T) {
fixture := setupTelegramOAuthTest(t) fixture := setupTelegramOAuthTest(t)
other := &model.User{Username: "other-owner", AffCode: "other-owner", Status: common.UserStatusEnabled, AuthVersion: 1} other := &model.User{Username: "other-owner", AffCode: "other-owner", Password: fixture.user.Password, Status: common.UserStatusEnabled, AuthVersion: 1}
require.NoError(t, model.DB.Create(other).Error) require.NoError(t, model.DB.Create(other).Error)
bundle, err := service.CreateLoginSession(other.Id, "password", "127.0.0.1", "test") bundle, err := service.CreateLoginSession(other.Id, "password", "127.0.0.1", "test")
require.NoError(t, err) require.NoError(t, err)
......
...@@ -34,11 +34,6 @@ type LoginRequest struct { ...@@ -34,11 +34,6 @@ type LoginRequest struct {
EncryptionKeyID string `json:"encryption_key_id"` EncryptionKeyID string `json:"encryption_key_id"`
} }
var (
errUserPasswordUnset = errors.New("user password is not set")
errOriginalPasswordFail = errors.New("original password is incorrect")
)
func GetPasswordEncryptionKey(c *gin.Context) { func GetPasswordEncryptionKey(c *gin.Context) {
if !common.PasswordLoginEncryptionEnabled { if !common.PasswordLoginEncryptionEnabled {
common.ApiSuccess(c, gin.H{"enabled": false}) common.ApiSuccess(c, gin.H{"enabled": false})
...@@ -190,7 +185,7 @@ func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin ...@@ -190,7 +185,7 @@ func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin
common.ApiErrorI18n(c, i18n.MsgAuthUserBanned) common.ApiErrorI18n(c, i18n.MsgAuthUserBanned)
return return
} }
currentUser, err := model.GetUserById(user.Id, false) currentUser, err := model.GetSelfUserById(user.Id)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
...@@ -483,7 +478,7 @@ func GetAffCode(c *gin.Context) { ...@@ -483,7 +478,7 @@ func GetAffCode(c *gin.Context) {
func GetSelf(c *gin.Context) { func GetSelf(c *gin.Context) {
id := c.GetInt("id") id := c.GetInt("id")
userRole := c.GetInt("role") userRole := c.GetInt("role")
user, err := model.GetUserById(id, false) user, err := model.GetSelfUserById(id)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
...@@ -515,6 +510,7 @@ func buildSelfUserData(user *model.User) map[string]interface{} { ...@@ -515,6 +510,7 @@ func buildSelfUserData(user *model.User) map[string]interface{} {
"id": user.Id, "id": user.Id,
"username": user.Username, "username": user.Username,
"display_name": user.DisplayName, "display_name": user.DisplayName,
"has_password": user.HasPassword,
"role": user.Role, "role": user.Role,
"status": user.Status, "status": user.Status,
"email": user.Email, "email": user.Email,
...@@ -676,10 +672,7 @@ func UpdateUser(c *gin.Context) { ...@@ -676,10 +672,7 @@ func UpdateUser(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return return
} }
if updatedUser.Password == "" { if err := common.Validate.StructExcept(&updatedUser, "Password"); err != nil {
updatedUser.Password = "$I_LOVE_U" // make Validator happy :)
}
if err := common.Validate.Struct(&updatedUser); err != nil {
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()}) common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
return return
} }
...@@ -698,9 +691,6 @@ func UpdateUser(c *gin.Context) { ...@@ -698,9 +691,6 @@ func UpdateUser(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel) common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return return
} }
if updatedUser.Password == "$I_LOVE_U" {
updatedUser.Password = "" // rollback to what it should be
}
updatePassword := updatedUser.Password != "" updatePassword := updatedUser.Password != ""
authzTouched := false authzTouched := false
if err := model.DB.Transaction(func(tx *gorm.DB) error { if err := model.DB.Transaction(func(tx *gorm.DB) error {
...@@ -789,8 +779,19 @@ func UpdateSelf(c *gin.Context) { ...@@ -789,8 +779,19 @@ func UpdateSelf(c *gin.Context) {
return return
} }
passwordRequested := false
if value, exists := requestData["password"]; exists && value != nil {
password, isString := value.(string)
passwordRequested = !isString || password != ""
}
succeeded, notificationFailed := false, false
if passwordRequested {
defer func() {
recordUserSecurityAudit(c, c.GetInt("id"), "user.password_change", map[string]interface{}{"success": succeeded, "notification_failed": notificationFailed})
}()
}
// 检查是否是用户设置更新请求 (sidebar_modules 或 language) // 检查是否是用户设置更新请求 (sidebar_modules 或 language)
if sidebarModules, sidebarExists := requestData["sidebar_modules"]; sidebarExists { if sidebarModules, sidebarExists := requestData["sidebar_modules"]; sidebarExists && !passwordRequested {
userId := c.GetInt("id") userId := c.GetInt("id")
user, err := model.GetUserById(userId, false) user, err := model.GetUserById(userId, false)
if err != nil { if err != nil {
...@@ -816,7 +817,7 @@ func UpdateSelf(c *gin.Context) { ...@@ -816,7 +817,7 @@ func UpdateSelf(c *gin.Context) {
} }
// 检查是否是语言偏好更新请求 // 检查是否是语言偏好更新请求
if language, langExists := requestData["language"]; langExists { if language, langExists := requestData["language"]; langExists && !passwordRequested {
userId := c.GetInt("id") userId := c.GetInt("id")
user, err := model.GetUserById(userId, false) user, err := model.GetUserById(userId, false)
if err != nil { if err != nil {
...@@ -853,10 +854,7 @@ func UpdateSelf(c *gin.Context) { ...@@ -853,10 +854,7 @@ func UpdateSelf(c *gin.Context) {
return return
} }
if user.Password == "" { if err := common.Validate.StructExcept(&user, "Password"); err != nil {
user.Password = "$I_LOVE_U" // make Validator happy :)
}
if err := common.Validate.Struct(&user); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidInput) common.ApiErrorI18n(c, i18n.MsgInvalidInput)
return return
} }
...@@ -867,52 +865,51 @@ func UpdateSelf(c *gin.Context) { ...@@ -867,52 +865,51 @@ func UpdateSelf(c *gin.Context) {
Password: user.Password, Password: user.Password,
DisplayName: user.DisplayName, DisplayName: user.DisplayName,
} }
if user.Password == "$I_LOVE_U" { if user.Password != "" {
user.Password = "" // rollback to what it should be identity, ok := middleware.GetSessionAuthIdentity(c)
cleanUser.Password = "" if !ok {
} writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id)
if err != nil {
if errors.Is(err, errUserPasswordUnset) {
common.ApiErrorI18n(c, i18n.MsgUserPasswordUnset)
return return
} }
if errors.Is(err, errOriginalPasswordFail) { current, err := model.GetUserById(identity.UserID, true)
common.ApiErrorI18n(c, i18n.MsgUserOriginalPasswordError) if err != nil {
writeSecurityOperationError(c, err)
return return
} }
common.ApiError(c, err) firstPassword := current.Password == ""
return scope := service.VerificationScopePasswordChange
} if firstPassword {
if updatePassword { scope = service.VerificationScopePasswordSet
identity, ok := middleware.GetSessionAuthIdentity(c) }
if !ok { if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: scope}) == nil {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return return
} }
if err := model.DB.Transaction(func(tx *gorm.DB) error { cleanUser.OriginalPassword = user.OriginalPassword
return cleanUser.UpdateWithTx(tx, true) if err := model.ChangeUserPassword(identity, &cleanUser, firstPassword); err != nil {
}); err != nil { writeSecurityOperationError(c, err)
common.ApiError(c, err)
return return
} }
succeeded = true
notificationFailed = service.NotifyAccountSecurityChange(current.Email, "Password updated") != nil
if err := model.PublishUserAuthCache(cleanUser.Id); err != nil { if err := model.PublishUserAuthCache(cleanUser.Id); err != nil {
common.ApiError(c, err) writeSecurityOperationError(c, err)
return return
} }
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "password_changed") bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "password_changed")
if err != nil { if err != nil {
common.ApiError(c, err) writeSecurityOperationError(c, err)
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": gin.H{ "data": gin.H{
"access_token": bundle.AccessToken, "access_token": bundle.AccessToken,
"token_type": bundle.TokenType, "token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt, "access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session, "session": bundle.Session,
"has_password": true,
"notification_warning": notificationFailed,
}, },
}) })
return return
...@@ -926,29 +923,6 @@ func UpdateSelf(c *gin.Context) { ...@@ -926,29 +923,6 @@ func UpdateSelf(c *gin.Context) {
return return
} }
func checkUpdatePassword(originalPassword string, newPassword string, userId int) (updatePassword bool, err error) {
if newPassword == "" {
return
}
var currentUser *model.User
currentUser, err = model.GetUserById(userId, true)
if err != nil {
return
}
// 密码不为空,需要验证原密码
if currentUser.Password == "" {
err = errUserPasswordUnset
return
}
if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) {
err = errOriginalPasswordFail
return
}
updatePassword = true
return
}
func DeleteUser(c *gin.Context) { func DeleteUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
...@@ -1272,51 +1246,6 @@ func ManageUser(c *gin.Context) { ...@@ -1272,51 +1246,6 @@ func ManageUser(c *gin.Context) {
return return
} }
type emailBindRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
func EmailBind(c *gin.Context) {
var req emailBindRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiError(c, errors.New("invalid request body"))
return
}
email := req.Email
email = model.NormalizeEmail(email)
code := req.Code
if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
return
}
user := model.User{
Id: c.GetInt("id"),
}
if user.Id == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "not authenticated"})
return
}
err := user.FillUserById()
if err != nil {
common.ApiError(c, err)
return
}
if err := model.BindEmailToUser(&user, email); err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
return
}
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
type topUpRequest struct { type topUpRequest struct {
Key string `json:"key"` Key string `json:"key"`
} }
......
...@@ -6,12 +6,16 @@ import ( ...@@ -6,12 +6,16 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
type wechatLoginResponse struct { type wechatLoginResponse struct {
...@@ -125,6 +129,15 @@ type wechatBindRequest struct { ...@@ -125,6 +129,15 @@ type wechatBindRequest struct {
} }
func WeChatBind(c *gin.Context) { func WeChatBind(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
succeeded, notificationFailed := false, false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.binding_bind", map[string]interface{}{"provider": "wechat", "success": succeeded, "notification_failed": notificationFailed})
}()
if !common.WeChatAuthEnabled { if !common.WeChatAuthEnabled {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "管理员未开启通过微信登录以及注册", "message": "管理员未开启通过微信登录以及注册",
...@@ -140,7 +153,15 @@ func WeChatBind(c *gin.Context) { ...@@ -140,7 +153,15 @@ func WeChatBind(c *gin.Context) {
}) })
return return
} }
code := req.Code code := strings.TrimSpace(req.Code)
context, err := common.Marshal(service.AccountBindingContext{Provider: "wechat", Code: code})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}) == nil {
return
}
wechatId, err := getWeChatIdByCode(code) wechatId, err := getWeChatIdByCode(code)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
...@@ -156,19 +177,24 @@ func WeChatBind(c *gin.Context) { ...@@ -156,19 +177,24 @@ func WeChatBind(c *gin.Context) {
}) })
return return
} }
userId := c.GetInt("id") // 只更新绑定列,避免完整用户快照覆盖并发的封禁、降权或分组变更。
if userId == 0 { if err := model.DB.Transaction(func(tx *gorm.DB) error {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) return model.UpdateUserBindColumnForSessionWithTx(tx, identity, "wechat_id", wechatId)
}); err != nil {
writeSecurityOperationError(c, err)
return return
} }
// 只更新绑定列,避免完整用户快照覆盖并发的封禁、降权或分组变更。 succeeded = true
if err := model.UpdateUserBindColumn(userId, "wechat_id", wechatId); err != nil { user, err := model.GetUserById(identity.UserID, false)
common.ApiError(c, err) if err != nil {
writeSecurityOperationError(c, err)
return return
} }
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "WeChat account linked") != nil
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": gin.H{"notification_warning": notificationFailed},
}) })
return return
} }
...@@ -76,6 +76,7 @@ func RequireSecurityProof(c *gin.Context, operation service.VerificationOperatio ...@@ -76,6 +76,7 @@ func RequireSecurityProof(c *gin.Context, operation service.VerificationOperatio
} }
func securityProofError(c *gin.Context, code, message string) { func securityProofError(c *gin.Context, code, message string) {
c.Set("security_error_code", code)
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
"success": false, "success": false,
"message": message, "message": message,
......
package model
import (
"errors"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
var (
ErrCurrentPasswordInvalid = errors.New("Current password is incorrect.")
ErrAccountPasswordState = errors.New("Password settings have changed. Reload the page and try again.")
ErrLastLoginMethod = errors.New("Add another login method before unlinking this account.")
ErrAccountBindingChanged = errors.New("Account bindings have changed. Start this operation again.")
)
// ChangeUserPassword rechecks the authorized session, password state and current
// password under the same user lock as the credential update. Only self-profile
// fields are accepted; a stale snapshot cannot restore roles or account status.
func ChangeUserPassword(identity AuthSessionIdentity, update *User, firstPassword bool) error {
hash, err := common.HashAccountPassword(update.Password)
if err != nil {
return err
}
return DB.Transaction(func(tx *gorm.DB) error {
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var current User
if err := tx.First(&current, identity.UserID).Error; err != nil {
return err
}
if firstPassword != (current.Password == "") {
return ErrAccountPasswordState
}
if !firstPassword && !common.ValidatePasswordAndHash(update.OriginalPassword, current.Password) {
return ErrCurrentPasswordInvalid
}
if current.Password != "" && common.ValidatePasswordAndHash(update.Password, current.Password) {
return common.ErrAccountPasswordSame
}
changes := map[string]any{"password": hash}
if update.Username != "" {
changes["username"] = update.Username
}
if update.DisplayName != "" {
changes["display_name"] = update.DisplayName
}
if _, err := IncrementUserAuthVersionWithTx(tx, identity.UserID); err != nil {
return err
}
if err := tx.Model(&User{}).Where("id = ?", identity.UserID).Updates(changes).Error; err != nil {
return err
}
return tx.First(update, identity.UserID).Error
})
}
// AccountLoginMethods is a snapshot of administrator-enabled login mechanisms.
// Enrollment is checked inside the transaction, not trusted from this snapshot.
type AccountLoginMethods struct {
Password bool
Passkey bool
WeChat bool
OAuthColumns []string
CustomProviderIDs []int
}
func UnbindUserOAuthForSession(identity AuthSessionIdentity, providerID int, enabled AccountLoginMethods) error {
return DB.Transaction(func(tx *gorm.DB) error {
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var current User
if err := tx.First(&current, identity.UserID).Error; err != nil {
return err
}
var binding UserOAuthBinding
if err := tx.Where("user_id = ? AND provider_id = ?", identity.UserID, providerID).First(&binding).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAccountBindingChanged
}
return err
}
hasLogin := enabled.Password && current.Password != "" || enabled.WeChat && current.WeChatId != ""
columns := map[string]string{
"github_id": current.GitHubId, "discord_id": current.DiscordId,
"oidc_id": current.OidcId, "linux_do_id": current.LinuxDOId, "telegram_id": current.TelegramId,
}
for _, column := range enabled.OAuthColumns {
hasLogin = hasLogin || columns[column] != ""
}
if !hasLogin && enabled.Passkey {
var count int64
if err := tx.Model(&PasskeyCredential{}).Where("user_id = ?", identity.UserID).Count(&count).Error; err != nil {
return err
}
hasLogin = count > 0
}
if !hasLogin && len(enabled.CustomProviderIDs) > 0 {
var count int64
if err := tx.Model(&UserOAuthBinding{}).Where("user_id = ? AND provider_id <> ? AND provider_id IN ?", identity.UserID, providerID, enabled.CustomProviderIDs).Count(&count).Error; err != nil {
return err
}
hasLogin = count > 0
}
if !hasLogin {
return ErrLastLoginMethod
}
return tx.Delete(&binding).Error
})
}
func UpdateUserBindColumnForSessionWithTx(tx *gorm.DB, identity AuthSessionIdentity, column, value string) error {
if !userBindColumns[column] || value == "" {
return ErrAccountBindingChanged
}
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
// Preserve the existing ownership check within the transaction and update
// only the chosen binding column, never a complete user snapshot.
var count int64
if err := tx.Model(&User{}).Where(column+" = ? AND id <> ?", value, identity.UserID).Count(&count).Error; err != nil {
return err
}
if count != 0 {
return ErrExternalIdentityAlreadyClaimed
}
return tx.Model(&User{}).Where("id = ?", identity.UserID).Update(column, value).Error
}
func UpdateUserOAuthBindingForSessionWithTx(tx *gorm.DB, identity AuthSessionIdentity, providerID int, subject string) error {
if providerID <= 0 || subject == "" || len(subject) > 256 {
return ErrAccountBindingChanged
}
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var binding UserOAuthBinding
err := tx.Where("user_id = ? AND provider_id = ?", identity.UserID, providerID).First(&binding).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return CreateUserOAuthBindingWithTx(tx, &UserOAuthBinding{UserId: identity.UserID, ProviderId: providerID, ProviderUserId: subject})
}
if err != nil {
return err
}
// The existing unique provider/subject index rejects concurrent ownership.
return tx.Model(&binding).Update("provider_user_id", subject).Error
}
...@@ -27,6 +27,7 @@ const ( ...@@ -27,6 +27,7 @@ const (
AuthFlowIntentVerify = "verify" AuthFlowIntentVerify = "verify"
AuthFlowPurposeTwoFASetup = "2fa_setup" AuthFlowPurposeTwoFASetup = "2fa_setup"
AuthFlowPurposeSecurityProof = "security_proof" AuthFlowPurposeSecurityProof = "security_proof"
AuthFlowPurposeEmailBinding = "email_binding"
AuthFlowTokenBytes = 32 AuthFlowTokenBytes = 32
AuthFlowDefaultCleanupRetention = 24 * time.Hour AuthFlowDefaultCleanupRetention = 24 * time.Hour
) )
......
package model
import (
"errors"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
const (
EmailBindingTTL = 10 * time.Minute
EmailBindingResendDelay = time.Minute
EmailBindingMaxAttempts = 5
)
var (
ErrEmailBindingCodeInvalid = errors.New("Email verification code is incorrect.")
ErrEmailBindingLocked = errors.New("Too many incorrect codes. Start email verification again.")
ErrEmailBindingResendWait = errors.New("Please wait before requesting another verification code.")
)
// EmailBindingState is server-owned payload. It deliberately contains only
// salted code hashes and an already-consumed authorization, never usable codes
// or proof tokens. Resending must retain the deadline and failure counter.
type EmailBindingState struct {
Authorization *AuthFlowAuthorization `json:"authorization"`
CurrentEmail string `json:"current_email"`
Email string `json:"email"`
NewCodeHash string `json:"new_code_hash"`
OldCodeHash string `json:"old_code_hash,omitempty"`
FailedAttempts int `json:"failed_attempts"`
ResendAt int64 `json:"resend_at"`
}
func CreateEmailBinding(identity AuthSessionIdentity, state EmailBindingState) (string, *AuthFlow, error) {
var token string
var flow *AuthFlow
err := DB.Transaction(func(tx *gorm.DB) error {
if err := validateEmailBindingAccountWithTx(tx, identity, &state); err != nil {
return err
}
if err := ensureEmailAvailableWithTx(tx, state.Email, identity.UserID); err != nil {
return err
}
payload, err := common.Marshal(state)
if err != nil {
return err
}
token, flow, err = createAuthFlowWithTx(tx, AuthFlowCreate{
Purpose: AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID,
Payload: string(payload), ExpiresAt: time.Now().Add(EmailBindingTTL),
})
return err
})
return token, flow, err
}
func GetEmailBinding(identity AuthSessionIdentity, token string) (*AuthFlow, *EmailBindingState, error) {
flow, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
if err != nil {
return nil, nil, err
}
var state EmailBindingState
if err := common.UnmarshalJsonStr(flow.Payload, &state); err != nil {
return nil, nil, ErrAuthFlowInvalid
}
return flow, &state, nil
}
func ResendEmailBinding(identity AuthSessionIdentity, token, newCodeHash, oldCodeHash string) (*AuthFlow, *EmailBindingState, error) {
var flow *AuthFlow
var state *EmailBindingState
err := DB.Transaction(func(tx *gorm.DB) error {
var err error
flow, state, err = lockEmailBindingWithTx(tx, identity, token)
if err != nil {
return err
}
now := time.Now()
if state.ResendAt > now.Unix() {
return ErrEmailBindingResendWait
}
if newCodeHash == "" || (state.OldCodeHash != "") != (oldCodeHash != "") {
return ErrAuthFlowInvalid
}
state.NewCodeHash, state.OldCodeHash = newCodeHash, oldCodeHash
state.ResendAt = now.Add(EmailBindingResendDelay).Unix()
payload, err := common.Marshal(state)
if err != nil {
return err
}
return tx.Model(flow).Update("payload", string(payload)).Error
})
return flow, state, err
}
// CompleteEmailBinding commits failed attempts even though verification fails.
// Successful confirmation consumes the flow in the same transaction as the
// email update, preserving the existing cross-database email ownership lock.
func CompleteEmailBinding(identity AuthSessionIdentity, token, email, newCode, oldCode string) (*EmailBindingState, error) {
var state *EmailBindingState
var verificationError error
err := DB.Transaction(func(tx *gorm.DB) error {
// Acquire ownership protection before the first consistent read. Under
// MySQL REPEATABLE READ, a snapshot created before this lock could hide
// an address claimed by a concurrent transaction while we were waiting.
if email == "" || email != NormalizeEmail(email) {
return ErrAuthFlowInvalid
}
if err := lockNormalizedEmail(tx, email); err != nil {
return err
}
flow, lockedState, err := lockEmailBindingWithTx(tx, identity, token)
if err != nil {
return err
}
state = lockedState
if state.Email != email {
return ErrAuthFlowInvalid
}
newCode, newCodeError := common.ValidateNumericCode(newCode)
oldCode, oldCodeError := common.ValidateNumericCode(oldCode)
newValid := newCodeError == nil && common.ValidatePasswordAndHash(newCode, state.NewCodeHash)
oldValid := state.OldCodeHash == "" || oldCodeError == nil && common.ValidatePasswordAndHash(oldCode, state.OldCodeHash)
if !newValid || !oldValid {
state.FailedAttempts++
verificationError = ErrEmailBindingCodeInvalid
if state.FailedAttempts >= EmailBindingMaxAttempts {
verificationError = ErrEmailBindingLocked
}
payload, err := common.Marshal(state)
if err != nil {
return err
}
return tx.Model(flow).Update("payload", string(payload)).Error
}
if err := ensureEmailAvailableWithTx(tx, state.Email, identity.UserID); err != nil {
return err
}
if err := tx.Model(&User{}).Where("id = ?", identity.UserID).Update("email", state.Email).Error; err != nil {
return err
}
return tx.Model(flow).Update("consumed_at", time.Now()).Error
})
if err != nil {
return nil, err
}
return state, verificationError
}
func lockEmailBindingWithTx(tx *gorm.DB, identity AuthSessionIdentity, token string) (*AuthFlow, *EmailBindingState, error) {
match := AuthFlowMatch{Purpose: AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID}
// Make the first statement a write so SQLite does not have to upgrade a
// deferred read transaction. A no-op update also locks the row on MySQL and
// PostgreSQL; do not interpret dialect-dependent RowsAffected as success.
if err := applyAuthFlowMatch(tx.Model(&AuthFlow{}), token, match).
Where("consumed_at IS NULL AND expires_at > ?", time.Now()).
UpdateColumn("payload", gorm.Expr("payload")).Error; err != nil {
return nil, nil, err
}
var flow AuthFlow
if err := applyAuthFlowMatch(tx, token, match).First(&flow).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, ErrAuthFlowInvalid
}
return nil, nil, err
}
if flow.ConsumedAt != nil {
return nil, nil, ErrAuthFlowConsumed
}
if !flow.ExpiresAt.After(time.Now()) {
return nil, nil, ErrAuthFlowExpired
}
var state EmailBindingState
if err := common.UnmarshalJsonStr(flow.Payload, &state); err != nil {
return nil, nil, ErrAuthFlowInvalid
}
if err := validateEmailBindingAccountWithTx(tx, identity, &state); err != nil {
return nil, nil, err
}
if state.FailedAttempts >= EmailBindingMaxAttempts {
return nil, nil, ErrEmailBindingLocked
}
return &flow, &state, nil
}
func validateEmailBindingAccountWithTx(tx *gorm.DB, identity AuthSessionIdentity, state *EmailBindingState) error {
if state.Authorization == nil || state.Authorization.ProofID <= 0 || state.Authorization.AuthSessionIdentity != identity ||
state.Authorization.Scope != "account.binding.bind" || state.Authorization.ContextHash == "" ||
state.Email == "" || state.Email != NormalizeEmail(state.Email) || state.NewCodeHash == "" {
return ErrAuthFlowInvalid
}
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var user User
if err := tx.Select("email").First(&user, identity.UserID).Error; err != nil {
return err
}
if NormalizeEmail(user.Email) != state.CurrentEmail || state.CurrentEmail == state.Email {
return ErrAccountBindingChanged
}
return nil
}
...@@ -79,7 +79,8 @@ func resolveUserSortOptions(sortOptions []UserSortOptions) UserSortOptions { ...@@ -79,7 +79,8 @@ func resolveUserSortOptions(sortOptions []UserSortOptions) UserSortOptions {
type User struct { type User struct {
Id int `json:"id"` Id int `json:"id"`
Username string `json:"username" gorm:"unique;index" validate:"max=20"` Username string `json:"username" gorm:"unique;index" validate:"max=20"`
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"` Password string `json:"password" gorm:"not null;" validate:"min=8,max=128"`
HasPassword bool `json:"-" gorm:"-:all"`
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database! OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
Role int `json:"role" gorm:"type:int;default:1"` // admin, common Role int `json:"role" gorm:"type:int;default:1"` // admin, common
...@@ -382,9 +383,16 @@ func EnsureEmailAvailable(email string, excludeUserID int) error { ...@@ -382,9 +383,16 @@ func EnsureEmailAvailable(email string, excludeUserID int) error {
// //
// An empty email is allowed to repeat and needs no serialization. // An empty email is allowed to repeat and needs no serialization.
func withNormalizedEmailLock(tx *gorm.DB, email string, fn func(tx *gorm.DB) error) error { func withNormalizedEmailLock(tx *gorm.DB, email string, fn func(tx *gorm.DB) error) error {
if err := lockNormalizedEmail(tx, email); err != nil {
return err
}
return fn(tx)
}
func lockNormalizedEmail(tx *gorm.DB, email string) error {
email = NormalizeEmail(email) email = NormalizeEmail(email)
if email == "" { if email == "" {
return fn(tx) return nil
} }
switch { switch {
case common.UsingMainDatabase(common.DatabaseTypePostgreSQL): case common.UsingMainDatabase(common.DatabaseTypePostgreSQL):
...@@ -397,7 +405,7 @@ func withNormalizedEmailLock(tx *gorm.DB, email string, fn func(tx *gorm.DB) err ...@@ -397,7 +405,7 @@ func withNormalizedEmailLock(tx *gorm.DB, email string, fn func(tx *gorm.DB) err
return err return err
} }
} }
return fn(tx) return nil
} }
func GetMaxUserId() int { func GetMaxUserId() int {
...@@ -524,6 +532,28 @@ func GetUserById(id int, selectAll bool) (*User, error) { ...@@ -524,6 +532,28 @@ func GetUserById(id int, selectAll bool) (*User, error) {
return &user, err return &user, err
} }
// GetSelfUserById reads dashboard profile data and password existence in one
// query. The password hash and management access token are never selected.
func GetSelfUserById(id int) (*User, error) {
if id == 0 {
return nil, errors.New("id 为空!")
}
var profile struct {
User
HasPassword bool `gorm:"column:has_password"`
}
err := DB.Model(&User{}).Select([]string{
"id", "username", "display_name", "role", "status", "email",
"github_id", "discord_id", "oidc_id", "wechat_id", "telegram_id",
"group", "quota", "used_quota", "request_count", "aff_code", "aff_count",
"aff_quota", "aff_history", "inviter_id", "linux_do_id", "setting",
"stripe_customer", "auth_version",
"CASE WHEN password <> '' THEN 1 ELSE 0 END AS has_password",
}).First(&profile, "id = ?", id).Error
profile.User.HasPassword = profile.HasPassword
return &profile.User, err
}
func GetUserIdByAffCode(affCode string) (int, error) { func GetUserIdByAffCode(affCode string) (int, error) {
if affCode == "" { if affCode == "" {
return 0, errors.New("affCode 为空!") return 0, errors.New("affCode 为空!")
...@@ -610,7 +640,7 @@ func (user *User) prepareForInsert(tx *gorm.DB) error { ...@@ -610,7 +640,7 @@ func (user *User) prepareForInsert(tx *gorm.DB) error {
return nil return nil
} }
var err error var err error
user.Password, err = common.Password2Hash(user.Password) user.Password, err = common.HashAccountPassword(user.Password)
return err return err
} }
...@@ -789,7 +819,7 @@ func (user *User) Update(updatePassword bool) error { ...@@ -789,7 +819,7 @@ func (user *User) Update(updatePassword bool) error {
func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error { func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error {
var err error var err error
if updatePassword { if updatePassword {
user.Password, err = common.Password2Hash(user.Password) user.Password, err = common.HashAccountPassword(user.Password)
if err != nil { if err != nil {
return err return err
} }
...@@ -850,7 +880,7 @@ func (user *User) Edit(updatePassword bool) error { ...@@ -850,7 +880,7 @@ func (user *User) Edit(updatePassword bool) error {
func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error { func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error {
var err error var err error
if updatePassword { if updatePassword {
user.Password, err = common.Password2Hash(user.Password) user.Password, err = common.HashAccountPassword(user.Password)
if err != nil { if err != nil {
return err return err
} }
...@@ -1150,7 +1180,7 @@ func ResetUserPasswordByEmail(email string, password string) error { ...@@ -1150,7 +1180,7 @@ func ResetUserPasswordByEmail(email string, password string) error {
if err != nil { if err != nil {
return err return err
} }
hashedPassword, err := common.Password2Hash(password) hashedPassword, err := common.HashAccountPassword(password)
if err != nil { if err != nil {
return err return err
} }
......
...@@ -46,7 +46,9 @@ func SetApiRouter(router *gin.Engine) { ...@@ -46,7 +46,9 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.ResetPassword) apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.ResetPassword)
// OAuth routes - specific routes must come before :provider wildcard // OAuth routes - specific routes must come before :provider wildcard
apiRouter.POST("/oauth/state", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), anonymousRequestBodyLimit, controller.GenerateOAuthCode) apiRouter.POST("/oauth/state", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), anonymousRequestBodyLimit, controller.GenerateOAuthCode)
apiRouter.POST("/oauth/email/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.EmailBind) apiRouter.POST("/oauth/email/bind/start", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("account-security"), middleware.EmailVerificationRateLimit(), middleware.DisableCache(), controller.EmailBindStart)
apiRouter.POST("/oauth/email/bind/resend", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("account-security"), middleware.EmailVerificationRateLimit(), middleware.DisableCache(), controller.EmailBindResend)
apiRouter.POST("/oauth/email/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("account-security"), middleware.DisableCache(), controller.EmailBind)
// WeChat uses its existing authorization-code service. // WeChat uses its existing authorization-code service.
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.WeChatAuth) apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.WeChatAuth)
apiRouter.POST("/oauth/wechat/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.WeChatBind) apiRouter.POST("/oauth/wechat/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.WeChatBind)
......
package service
import (
"fmt"
"html"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/setting/system_setting"
)
func UnbindAccountOAuth(identity AuthIdentity, providerID int) error {
enabled := model.AccountLoginMethods{
Password: common.PasswordLoginEnabled,
Passkey: system_setting.GetPasskeySettings().Enabled,
WeChat: common.WeChatAuthEnabled,
}
for _, provider := range oauth.GetAllProviders() {
if !provider.IsEnabled() {
continue
}
if custom, ok := provider.(*oauth.GenericOAuthProvider); ok {
enabled.CustomProviderIDs = append(enabled.CustomProviderIDs, custom.GetProviderId())
} else {
enabled.OAuthColumns = append(enabled.OAuthColumns, provider.ProviderUserIDColumn())
}
}
return model.UnbindUserOAuthForSession(identity, providerID, enabled)
}
// NotifyAccountSecurityChange never includes credentials or tokens. The caller
// records delivery failure independently from the already-committed change.
func NotifyAccountSecurityChange(email, event string) error {
if email == "" {
return nil
}
subject := common.SystemName + " — Account security notification"
content := fmt.Sprintf("<p>Your account security settings have changed: %s.</p><p>If you did not make this change, open your account security settings, revoke other login sessions, and contact your administrator.</p>", html.EscapeString(event))
return common.SendEmail(subject, email, content)
}
...@@ -227,7 +227,7 @@ func RefreshLoginSession(rawRefreshToken, expectedSID, ip, userAgent string) (*A ...@@ -227,7 +227,7 @@ func RefreshLoginSession(rawRefreshToken, expectedSID, ip, userAgent string) (*A
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
currentUser, err := model.GetUserById(session.UserID, false) currentUser, err := model.GetSelfUserById(session.UserID)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
......
package service
import (
"crypto/rand"
"errors"
"fmt"
"html"
"math/big"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
)
var (
ErrAccountEmailInvalid = errors.New("Please enter a valid email address")
ErrAccountEmailRestricted = errors.New("This email address is not allowed by the administrator's email policy.")
ErrEmailBindingDelivery = errors.New("Verification email could not be sent. Start email verification again.")
)
type EmailBindingData struct {
FlowToken string `json:"flow_token"`
Email string `json:"email"`
CurrentEmail string `json:"current_email,omitempty"`
OldEmailRequired bool `json:"old_email_required"`
ExpiresAt int64 `json:"expires_at"`
ResendAt int64 `json:"resend_at"`
NotificationWarning bool `json:"notification_warning"`
}
// ValidateAccountEmail is shared with registration mail delivery so binding
// cannot bypass the site's existing domain, alias and address-length rules.
func ValidateAccountEmail(email string) (string, error) {
email = model.NormalizeEmail(email)
if common.Validate.Var(email, "required,email,max=50") != nil {
return "", ErrAccountEmailInvalid
}
parts := strings.Split(email, "@")
if len(parts) != 2 {
return "", ErrAccountEmailInvalid
}
if common.EmailDomainRestrictionEnabled {
allowed := false
for _, domain := range common.EmailDomainWhitelist {
if parts[1] == domain {
allowed = true
break
}
}
if !allowed {
return "", ErrAccountEmailRestricted
}
}
if common.EmailAliasRestrictionEnabled && strings.ContainsAny(parts[0], "+.") {
return "", ErrAccountEmailRestricted
}
return email, nil
}
func StartEmailBinding(identity AuthIdentity, authorization *model.AuthFlowAuthorization, email string) (*EmailBindingData, error) {
email, err := ValidateAccountEmail(email)
if err != nil {
return nil, err
}
context, err := common.Marshal(AccountBindingContext{Provider: "email", Email: email})
if err != nil {
return nil, err
}
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeAccountBind, Context: context}, authorization); err != nil {
return nil, err
}
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
return nil, err
}
state := model.EmailBindingState{Authorization: authorization, CurrentEmail: model.NormalizeEmail(user.Email), Email: email, ResendAt: time.Now().Add(model.EmailBindingResendDelay).Unix()}
requireOld := state.CurrentEmail != "" && authorization.Method != VerificationMethodTwoFA && authorization.Method != VerificationMethodPasskey
codes, err := generateEmailBindingCodes(requireOld)
if err != nil {
return nil, err
}
state.NewCodeHash, state.OldCodeHash = codes.NewHash, codes.OldHash
token, flow, err := model.CreateEmailBinding(identity, state)
if err != nil {
return nil, err
}
if err := sendEmailBindingCodes(state, codes); err != nil {
// A partially delivered pair must not leave a usable change request.
_, _ = model.ConsumeAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
return nil, ErrEmailBindingDelivery
}
data := emailBindingData(token, flow, &state)
if state.CurrentEmail != "" && !requireOld {
data.NotificationWarning = NotifyAccountSecurityChange(state.CurrentEmail, "A change of your email address was requested") != nil
}
return data, nil
}
func ResendAccountEmailBinding(identity AuthIdentity, token string) (*EmailBindingData, error) {
_, state, err := model.GetEmailBinding(identity, token)
if err != nil {
return nil, err
}
context, err := common.Marshal(AccountBindingContext{Provider: "email", Email: state.Email})
if err != nil {
return nil, err
}
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeAccountBind, Context: context}, state.Authorization); err != nil {
return nil, err
}
if state.ResendAt > time.Now().Unix() {
return nil, model.ErrEmailBindingResendWait
}
if state.FailedAttempts >= model.EmailBindingMaxAttempts {
return nil, model.ErrEmailBindingLocked
}
codes, err := generateEmailBindingCodes(state.OldCodeHash != "")
if err != nil {
return nil, err
}
flow, state, err := model.ResendEmailBinding(identity, token, codes.NewHash, codes.OldHash)
if err != nil {
return nil, err
}
if err := sendEmailBindingCodes(*state, codes); err != nil {
_, _ = model.ConsumeAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
return nil, ErrEmailBindingDelivery
}
return emailBindingData(token, flow, state), nil
}
func FinishEmailBinding(identity AuthIdentity, token, newCode, oldCode string) (*model.EmailBindingState, error) {
_, state, err := model.GetEmailBinding(identity, token)
if err != nil {
return nil, err
}
context, err := common.Marshal(AccountBindingContext{Provider: "email", Email: state.Email})
if err != nil {
return nil, err
}
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeAccountBind, Context: context}, state.Authorization); err != nil {
return nil, err
}
if _, err := ValidateAccountEmail(state.Email); err != nil {
return nil, err
}
return model.CompleteEmailBinding(identity, token, state.Email, newCode, oldCode)
}
type emailBindingCodes struct {
New, Old string
NewHash, OldHash string
}
func generateEmailBindingCodes(requireOld bool) (emailBindingCodes, error) {
var codes emailBindingCodes
count := 1
if requireOld {
count = 2
}
for index := 0; index < count; {
number, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return codes, err
}
code := fmt.Sprintf("%06d", number.Int64())
if code == codes.New {
continue
}
hash, err := common.Password2Hash(code)
if err != nil {
return codes, err
}
if index == 0 {
codes.New, codes.NewHash = code, hash
} else {
codes.Old, codes.OldHash = code, hash
}
index++
}
return codes, nil
}
func sendEmailBindingCodes(state model.EmailBindingState, codes emailBindingCodes) error {
subject := common.SystemName + " — Confirm your email address"
content := fmt.Sprintf("<p>Confirm linking this email address to your account.</p><p>Verification code: <strong>%s</strong></p><p>This code expires in 10 minutes. If you did not request this change, do not share this code.</p>", html.EscapeString(codes.New))
if err := common.SendEmail(subject, state.Email, content); err != nil {
return err
}
if codes.Old != "" {
content = fmt.Sprintf("<p>A change to your account email address was requested. Confirm replacing your current address.</p><p>Verification code: <strong>%s</strong></p><p>This code expires in 10 minutes. If you did not request this change, do not share this code and contact your administrator.</p>", html.EscapeString(codes.Old))
return common.SendEmail(subject, state.CurrentEmail, content)
}
return nil
}
func emailBindingData(token string, flow *model.AuthFlow, state *model.EmailBindingState) *EmailBindingData {
return &EmailBindingData{
FlowToken: token, Email: state.Email, CurrentEmail: common.MaskEmail(state.CurrentEmail),
OldEmailRequired: state.OldCodeHash != "", ExpiresAt: flow.ExpiresAt.Unix(), ResendAt: state.ResendAt,
}
}
...@@ -26,6 +26,10 @@ const ( ...@@ -26,6 +26,10 @@ const (
VerificationScopeTwoFASetup = "2fa.setup" VerificationScopeTwoFASetup = "2fa.setup"
VerificationScopeAccessTokenGenerate = "access_token.generate" VerificationScopeAccessTokenGenerate = "access_token.generate"
VerificationScopeAccessTokenRevoke = "access_token.revoke" VerificationScopeAccessTokenRevoke = "access_token.revoke"
VerificationScopeAccountBind = "account.binding.bind"
VerificationScopeAccountUnbind = "account.binding.unbind"
VerificationScopePasswordSet = "account.password.set"
VerificationScopePasswordChange = "account.password.change"
) )
var ( var (
...@@ -48,6 +52,16 @@ type ChannelKeyReadContext struct { ...@@ -48,6 +52,16 @@ type ChannelKeyReadContext struct {
ChannelID int `json:"channel_id"` ChannelID int `json:"channel_id"`
} }
type AccountBindingContext struct {
Provider string `json:"provider"`
Email string `json:"email,omitempty"`
Code string `json:"code,omitempty"`
}
type AccountUnbindingContext struct {
ProviderID int `json:"provider_id"`
}
// VerificationBinding contains no original operation parameters. It can safely // VerificationBinding contains no original operation parameters. It can safely
// travel through a signed proof or a server-owned interactive verification flow. // travel through a signed proof or a server-owned interactive verification flow.
type VerificationBinding struct { type VerificationBinding struct {
...@@ -70,8 +84,38 @@ func BindVerificationOperation(operation VerificationOperation) (VerificationBin ...@@ -70,8 +84,38 @@ func BindVerificationOperation(operation VerificationOperation) (VerificationBin
return VerificationBinding{}, ErrVerificationContextInvalid return VerificationBinding{}, ErrVerificationContextInvalid
} }
normalized = context normalized = context
case VerificationScopeAccountBind:
var context AccountBindingContext
if common.Unmarshal(operation.Context, &context) != nil {
return VerificationBinding{}, ErrVerificationContextInvalid
}
context.Provider = strings.TrimSpace(context.Provider)
switch context.Provider {
case "email":
context.Email = model.NormalizeEmail(context.Email)
if len(fields) != 2 || common.Validate.Var(context.Email, "required,email") != nil || context.Code != "" {
return VerificationBinding{}, ErrVerificationContextInvalid
}
case "wechat":
context.Code = strings.TrimSpace(context.Code)
if len(fields) != 2 || context.Code == "" || len(context.Code) > 128 || context.Email != "" {
return VerificationBinding{}, ErrVerificationContextInvalid
}
default:
if len(fields) != 1 || context.Provider == "" || len(context.Provider) > 64 || oauth.GetProvider(context.Provider) == nil {
return VerificationBinding{}, ErrVerificationContextInvalid
}
}
normalized = context
case VerificationScopeAccountUnbind:
var context AccountUnbindingContext
if len(fields) != 1 || common.Unmarshal(fields["provider_id"], &context.ProviderID) != nil || context.ProviderID <= 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopePasskeyRegister, VerificationScopePasskeyDelete, VerificationScopeTwoFASetup, case VerificationScopePasskeyRegister, VerificationScopePasskeyDelete, VerificationScopeTwoFASetup,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke: VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke,
VerificationScopePasswordSet, VerificationScopePasswordChange:
if len(fields) != 0 { if len(fields) != 0 {
return VerificationBinding{}, ErrVerificationContextInvalid return VerificationBinding{}, ErrVerificationContextInvalid
} }
...@@ -124,7 +168,6 @@ type verificationAccountState struct { ...@@ -124,7 +168,6 @@ type verificationAccountState struct {
TwoFALocked bool TwoFALocked bool
HasPasskey bool HasPasskey bool
PasskeyEnabled bool PasskeyEnabled bool
WeChatEnrollment bool
} }
// securityVerificationPolicy is the only operation-to-method policy. Device // securityVerificationPolicy is the only operation-to-method policy. Device
...@@ -146,10 +189,15 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([ ...@@ -146,10 +189,15 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
methods = []string{VerificationMethodPasskey} methods = []string{VerificationMethodPasskey}
} }
case VerificationScopePasskeyRegister, VerificationScopeTwoFASetup, case VerificationScopePasskeyRegister, VerificationScopeTwoFASetup,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke: VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke,
VerificationScopeAccountBind, VerificationScopeAccountUnbind,
VerificationScopePasswordSet, VerificationScopePasswordChange:
if scope == VerificationScopeTwoFASetup && state.HasTwoFA { if scope == VerificationScopeTwoFASetup && state.HasTwoFA {
return nil, model.ErrTwoFAAlreadyEnabled return nil, model.ErrTwoFAAlreadyEnabled
} }
if (scope == VerificationScopePasswordSet && state.HasPassword) || (scope == VerificationScopePasswordChange && !state.HasPassword) {
return nil, ErrVerificationForbidden
}
switch { switch {
case state.HasTwoFA: case state.HasTwoFA:
methods = []string{VerificationMethodTwoFA} methods = []string{VerificationMethodTwoFA}
...@@ -157,8 +205,6 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([ ...@@ -157,8 +205,6 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
methods = []string{VerificationMethodPasskey} methods = []string{VerificationMethodPasskey}
case state.HasPassword: case state.HasPassword:
methods = []string{VerificationMethodPassword} methods = []string{VerificationMethodPassword}
case state.WeChatEnrollment && (scope == VerificationScopePasskeyRegister || scope == VerificationScopeTwoFASetup):
methods = []string{VerificationMethodSession}
default: default:
methods = []string{VerificationMethodOAuth} methods = []string{VerificationMethodOAuth}
} }
...@@ -203,22 +249,19 @@ func GetVerificationRequirements(identity AuthIdentity, scope string) (*Verifica ...@@ -203,22 +249,19 @@ func GetVerificationRequirements(identity AuthIdentity, scope string) (*Verifica
TwoFALocked: twoFA != nil && twoFA.IsLocked(), HasPasskey: err == nil, TwoFALocked: twoFA != nil && twoFA.IsLocked(), HasPasskey: err == nil,
PasskeyEnabled: system_setting.GetPasskeySettings().Enabled, PasskeyEnabled: system_setting.GetPasskeySettings().Enabled,
} }
if (scope == VerificationScopeTwoFASetup || scope == VerificationScopePasskeyRegister) &&
!state.HasPassword && !state.HasTwoFA && !state.HasPasskey &&
user.WeChatId != "" && user.TelegramId == "" && user.GitHubId == "" &&
user.DiscordId == "" && user.OidcId == "" && user.LinuxDOId == "" {
bindings, err := model.GetUserOAuthBindingsByUserId(user.Id)
if err != nil {
return nil, err
}
state.WeChatEnrollment = len(bindings) == 0
}
methods, err := securityVerificationPolicy(scope, state) methods, err := securityVerificationPolicy(scope, state)
if err != nil { if err != nil {
return nil, err return nil, err
} }
requirements := &VerificationRequirements{Scope: scope, Methods: methods, OAuthProviders: []VerificationOAuthProvider{}, PasswordEncryptionEnabled: common.PasswordLoginEncryptionEnabled} requirements := &VerificationRequirements{Scope: scope, Methods: methods, OAuthProviders: []VerificationOAuthProvider{}, PasswordEncryptionEnabled: common.PasswordLoginEncryptionEnabled}
for i := range methods { for i := range methods {
if methods[i].Method == VerificationMethodPassword && !common.PasswordLoginEnabled {
switch scope {
case VerificationScopeAccountBind, VerificationScopeAccountUnbind, VerificationScopePasswordSet, VerificationScopePasswordChange:
methods[i].Available, methods[i].Reason = false, "Password authentication is disabled."
}
}
if methods[i].Method != VerificationMethodOAuth { if methods[i].Method != VerificationMethodOAuth {
continue continue
} }
...@@ -389,9 +432,6 @@ func VerifySecurityInput(identity AuthIdentity, input VerificationInput) (*Secur ...@@ -389,9 +432,6 @@ func VerifySecurityInput(identity AuthIdentity, input VerificationInput) (*Secur
return nil, err return nil, err
} }
switch input.Method { switch input.Method {
case VerificationMethodSession:
// The policy above permits only first enrollment for a WeChat-only
// account. CompleteSecurityVerification rechecks its live session.
case VerificationMethodPassword: case VerificationMethodPassword:
password := input.Password password := input.Password
if common.PasswordLoginEncryptionEnabled { if common.PasswordLoginEncryptionEnabled {
......
...@@ -171,7 +171,8 @@ export async function createOAuthAuthorization( ...@@ -171,7 +171,8 @@ export async function createOAuthAuthorization(
provider: string, provider: string,
intent: 'login' | 'bind' | 'verify', intent: 'login' | 'bind' | 'verify',
operation?: VerificationOperation, operation?: VerificationOperation,
signal?: AbortSignal signal?: AbortSignal,
proofToken?: string
): Promise<{ state: string; authorizationUrl?: string }> { ): Promise<{ state: string; authorizationUrl?: string }> {
const aff = intent === 'login' ? getAffiliateCode() : '' const aff = intent === 'login' ? getAffiliateCode() : ''
const res = await api.post( const res = await api.post(
...@@ -185,6 +186,8 @@ export async function createOAuthAuthorization( ...@@ -185,6 +186,8 @@ export async function createOAuthAuthorization(
}, },
{ {
skipAuthRefresh: intent === 'login', skipAuthRefresh: intent === 'login',
...(proofToken ? { headers: { 'X-Security-Proof': proofToken } } : {}),
singleUseAuthorization: intent === 'bind',
signal, signal,
skipBusinessError: true, skipBusinessError: true,
skipErrorHandler: true, skipErrorHandler: true,
...@@ -259,14 +262,21 @@ export async function sendEmailVerification( ...@@ -259,14 +262,21 @@ export async function sendEmailVerification(
return res.data return res.data
} }
// Bind email to OAuth account // Confirm an authenticated, server-owned email binding flow.
export async function bindEmail( export async function bindEmail(
email: string, flowToken: string,
code: string newCode: string,
oldCode = '',
signal?: AbortSignal
): Promise<ApiResponse> { ): Promise<ApiResponse> {
const res = await api.post('/api/oauth/email/bind', { const res = await api.post(
email, '/api/oauth/email/bind',
code, {
}) flow_token: flowToken,
new_code: newCode,
old_code: oldCode,
},
{ singleUseAuthorization: true, signal }
)
return res.data return res.data
} }
...@@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { z } from 'zod' import { z } from 'zod'
import { accountPasswordSchema } from '@/lib/password-policy'
// ============================================================================ // ============================================================================
// Form Schemas // Form Schemas
// ============================================================================ // ============================================================================
...@@ -31,11 +33,7 @@ export const registerFormSchema = z ...@@ -31,11 +33,7 @@ export const registerFormSchema = z
.object({ .object({
username: z.string().min(1, 'Please enter your username'), username: z.string().min(1, 'Please enter your username'),
email: z.string().optional(), email: z.string().optional(),
password: z password: accountPasswordSchema,
.string()
.min(1, 'Please enter your password')
.min(8, 'Password must be between 8 and 20 characters')
.max(20, 'Password must be at most 20 characters long'),
confirmPassword: z.string().min(1, 'Please confirm your password'), confirmPassword: z.string().min(1, 'Please confirm your password'),
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
......
...@@ -45,7 +45,7 @@ export async function encryptPassword( ...@@ -45,7 +45,7 @@ export async function encryptPassword(
): Promise<EncryptedPassword> { ): Promise<EncryptedPassword> {
try { try {
const key = await getPasswordEncryptionKey() const key = await getPasswordEncryptionKey()
const ciphertext = await rsaOaepEncrypt(password, key.public_key) const ciphertext = await rsaOaepEncrypt(password, key.public_key, key.kid)
return { return {
password_encrypted: ciphertext, password_encrypted: ciphertext,
encryption_key_id: key.kid, encryption_key_id: key.kid,
...@@ -77,7 +77,8 @@ async function getPasswordEncryptionKey(): Promise<PasswordEncryptionKey> { ...@@ -77,7 +77,8 @@ async function getPasswordEncryptionKey(): Promise<PasswordEncryptionKey> {
async function rsaOaepEncrypt( async function rsaOaepEncrypt(
password: string, password: string,
publicKeyPEM: string publicKeyPEM: string,
keyId: string
): Promise<string> { ): Promise<string> {
if (typeof globalThis.crypto?.subtle !== 'undefined') { if (typeof globalThis.crypto?.subtle !== 'undefined') {
try { try {
...@@ -88,10 +89,48 @@ async function rsaOaepEncrypt( ...@@ -88,10 +89,48 @@ async function rsaOaepEncrypt(
false, false,
['encrypt'] ['encrypt']
) )
const plaintext = new TextEncoder().encode(password)
const algorithm = publicKey.algorithm as RsaHashedKeyAlgorithm
if (plaintext.byteLength > algorithm.modulusLength / 8 - 66) {
const secret = globalThis.crypto.getRandomValues(new Uint8Array(32))
const nonce = globalThis.crypto.getRandomValues(new Uint8Array(12))
const key = await globalThis.crypto.subtle.importKey(
'raw',
secret,
'AES-GCM',
false,
['encrypt']
)
const [wrappedKey, ciphertext] = await Promise.all([
globalThis.crypto.subtle.encrypt(
{
name: 'RSA-OAEP',
label: new TextEncoder().encode('password-v2'),
},
publicKey,
secret
),
globalThis.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: nonce,
additionalData: new TextEncoder().encode(`password-v2:${keyId}`),
},
key,
plaintext
),
])
return [
'v2',
arrayBufferToBase64(wrappedKey),
arrayBufferToBase64(nonce.buffer),
arrayBufferToBase64(ciphertext),
].join('.')
}
const ciphertext = await globalThis.crypto.subtle.encrypt( const ciphertext = await globalThis.crypto.subtle.encrypt(
{ name: 'RSA-OAEP' }, { name: 'RSA-OAEP' },
publicKey, publicKey,
new TextEncoder().encode(password) plaintext
) )
return arrayBufferToBase64(ciphertext) return arrayBufferToBase64(ciphertext)
} catch { } catch {
...@@ -104,11 +143,33 @@ async function rsaOaepEncrypt( ...@@ -104,11 +143,33 @@ async function rsaOaepEncrypt(
// forge keeps the normal HTTPS bundle small while supporting HTTP intranets. // forge keeps the normal HTTPS bundle small while supporting HTTP intranets.
const forge = await import('node-forge') const forge = await import('node-forge')
const publicKey = forge.pki.publicKeyFromPem(publicKeyPEM) const publicKey = forge.pki.publicKeyFromPem(publicKeyPEM)
const ciphertext = publicKey.encrypt( const plaintext = forge.util.encodeUtf8(password)
forge.util.encodeUtf8(password), if (plaintext.length > publicKey.n.bitLength() / 8 - 66) {
'RSA-OAEP', const secret = forge.random.getBytesSync(32)
{ md: forge.md.sha256.create() } const nonce = forge.random.getBytesSync(12)
) const wrappedKey = publicKey.encrypt(secret, 'RSA-OAEP', {
md: forge.md.sha256.create(),
label: 'password-v2',
})
const cipher = forge.cipher.createCipher('AES-GCM', secret)
cipher.start({
iv: nonce,
additionalData: `password-v2:${keyId}`,
tagLength: 128,
})
cipher.update(forge.util.createBuffer(plaintext))
if (!cipher.finish()) throw new Error('Password encryption failed')
const ciphertext = cipher.output.getBytes() + cipher.mode.tag.getBytes()
return [
'v2',
forge.util.encode64(wrappedKey),
forge.util.encode64(nonce),
forge.util.encode64(ciphertext),
].join('.')
}
const ciphertext = publicKey.encrypt(plaintext, 'RSA-OAEP', {
md: forge.md.sha256.create(),
})
return forge.util.encode64(ciphertext) return forge.util.encode64(ciphertext)
} }
......
...@@ -16,6 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import {
createDecipheriv,
generateKeyPairSync,
privateDecrypt,
webcrypto,
} from 'node:crypto'
import { waitFor } from '@testing-library/react' import { waitFor } from '@testing-library/react'
import { AxiosError, type InternalAxiosRequestConfig } from 'axios' import { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { afterEach, expect, it, vi } from 'vitest' import { afterEach, expect, it, vi } from 'vitest'
...@@ -26,6 +33,10 @@ import { useAuthStore, type AuthBundle } from '@/stores/auth-store' ...@@ -26,6 +33,10 @@ import { useAuthStore, type AuthBundle } from '@/stores/auth-store'
import { createOAuthFlow } from '../../api' import { createOAuthFlow } from '../../api'
import { OAUTH_POPUP_CALLBACK_MESSAGE } from '../../constants' import { OAUTH_POPUP_CALLBACK_MESSAGE } from '../../constants'
import {
clearPasswordEncryptionCache,
encryptPassword,
} from '../../lib/password-encryption'
import { checkVerificationMethods, verify } from '../api' import { checkVerificationMethods, verify } from '../api'
import type { SecurityProof } from '../types' import type { SecurityProof } from '../types'
...@@ -166,6 +177,7 @@ function mockRefreshResponse(bundle: AuthBundle, onRequest?: () => void) { ...@@ -166,6 +177,7 @@ function mockRefreshResponse(bundle: AuthBundle, onRequest?: () => void) {
} }
afterEach(() => { afterEach(() => {
clearPasswordEncryptionCache()
vi.restoreAllMocks() vi.restoreAllMocks()
vi.unstubAllGlobals() vi.unstubAllGlobals()
api.defaults.adapter = originalAdapter api.defaults.adapter = originalAdapter
...@@ -173,6 +185,65 @@ afterEach(() => { ...@@ -173,6 +185,65 @@ afterEach(() => {
window.history.replaceState(null, '', originalLocation) window.history.replaceState(null, '', originalLocation)
}) })
it.each([true, false])(
'encrypts short and 128-character Unicode passwords with Web Crypto enabled: %s',
async (useWebCrypto) => {
vi.stubGlobal(
'crypto',
useWebCrypto
? webcrypto
: { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) }
)
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
})
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
kid: 'test-encryption-key',
public_key: publicKey
.export({ type: 'spki', format: 'pem' })
.toString(),
},
},
})
for (const password of ['legacy-password', '🔒'.repeat(128)]) {
const encrypted = await encryptPassword(password)
const parts = encrypted.password_encrypted.split('.')
let decrypted: Buffer
if (parts[0] === 'v2') {
const key = privateDecrypt(
{
key: privateKey,
oaepHash: 'sha256',
oaepLabel: Buffer.from('password-v2'),
},
Buffer.from(parts[1], 'base64')
)
const ciphertext = Buffer.from(parts[3], 'base64')
const decipher = createDecipheriv(
'aes-256-gcm',
key,
Buffer.from(parts[2], 'base64')
)
decipher.setAAD(Buffer.from('password-v2:test-encryption-key'))
decipher.setAuthTag(ciphertext.subarray(-16))
decrypted = Buffer.concat([
decipher.update(ciphertext.subarray(0, -16)),
decipher.final(),
])
} else {
decrypted = privateDecrypt(
{ key: privateKey, oaepHash: 'sha256' },
Buffer.from(encrypted.password_encrypted, 'base64')
)
}
expect(decrypted.toString('utf8')).toBe(password)
}
}
)
it.each(['proof', 'flow'] as const)( it.each(['proof', 'flow'] as const)(
'never replays a %s request after a 401 response', 'never replays a %s request after a 401 response',
async (kind) => { async (kind) => {
......
...@@ -110,6 +110,8 @@ interface PendingVerification { ...@@ -110,6 +110,8 @@ interface PendingVerification {
request: RequestVerificationOptions request: RequestVerificationOptions
controller: AbortController controller: AbortController
resolve: (proof: SecurityProof | null) => void resolve: (proof: SecurityProof | null) => void
reject: (error: unknown) => void
initialPassword?: string
submitting: boolean submitting: boolean
} }
...@@ -121,6 +123,7 @@ export function useSecureVerification() { ...@@ -121,6 +123,7 @@ export function useSecureVerification() {
const current = pending.current const current = pending.current
pending.current = null pending.current = null
current?.controller.abort() current?.controller.abort()
if (current) current.initialPassword = undefined
current?.resolve(null) current?.resolve(null)
dispatch({ type: 'reset' }) dispatch({ type: 'reset' })
}, []) }, [])
...@@ -135,6 +138,33 @@ export function useSecureVerification() { ...@@ -135,6 +138,33 @@ export function useSecureVerification() {
current.controller.signal current.controller.signal
) )
if (pending.current !== current) return if (pending.current !== current) return
const initialPassword = current.initialPassword
current.initialPassword = undefined
if (
initialPassword !== undefined &&
requirements.methods.length === 1 &&
requirements.methods[0].method === 'password' &&
requirements.methods[0].available
) {
try {
const proof = await verify(
{ method: 'password', password: initialPassword },
current.request,
requirements.password_encryption_enabled,
current.controller.signal
)
if (pending.current !== current) return
pending.current = null
dispatch({ type: 'reset' })
current.resolve(proof)
} catch (error) {
if (pending.current !== current) return
pending.current = null
dispatch({ type: 'reset' })
current.reject(error)
}
return
}
if ( if (
requirements.methods.length === 1 && requirements.methods.length === 1 &&
requirements.methods[0].method === 'session' && requirements.methods[0].method === 'session' &&
...@@ -166,12 +196,17 @@ export function useSecureVerification() { ...@@ -166,12 +196,17 @@ export function useSecureVerification() {
}, []) }, [])
const requestVerification = useCallback( const requestVerification = useCallback(
(request: RequestVerificationOptions): Promise<SecurityProof | null> => { (
request: RequestVerificationOptions,
initialPassword?: string
): Promise<SecurityProof | null> => {
if (pending.current) return Promise.resolve(null) if (pending.current) return Promise.resolve(null)
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const current: PendingVerification = { const current: PendingVerification = {
request: structuredClone(request), request: structuredClone(request),
resolve, resolve,
reject,
initialPassword,
controller: new AbortController(), controller: new AbortController(),
submitting: false, submitting: false,
} }
......
...@@ -29,11 +29,23 @@ export type SecurityProofScope = ...@@ -29,11 +29,23 @@ export type SecurityProofScope =
| '2fa.setup' | '2fa.setup'
| 'access_token.generate' | 'access_token.generate'
| 'access_token.revoke' | 'access_token.revoke'
| 'account.binding.bind'
| 'account.binding.unbind'
| 'account.password.set'
| 'account.password.change'
export type VerificationOperation = export type VerificationOperation =
| { scope: 'channel.key.read'; context: { channel_id: number } } | { scope: 'channel.key.read'; context: { channel_id: number } }
| { | {
scope: Exclude<SecurityProofScope, 'channel.key.read'> scope: 'account.binding.bind'
context: { provider: string; email?: string; code?: string }
}
| { scope: 'account.binding.unbind'; context: { provider_id: number } }
| {
scope: Exclude<
SecurityProofScope,
'channel.key.read' | 'account.binding.bind' | 'account.binding.unbind'
>
context?: Record<string, never> context?: Record<string, never>
} }
......
...@@ -271,7 +271,7 @@ export function SignUpForm({ ...@@ -271,7 +271,7 @@ export function SignUpForm({
<FormLabel>{t('Password')}</FormLabel> <FormLabel>{t('Password')}</FormLabel>
<FormControl> <FormControl>
<PasswordInput <PasswordInput
placeholder={t('Enter password (8-20 characters)')} placeholder={t('Enter password (8–128 characters)')}
{...field} {...field}
/> />
</FormControl> </FormControl>
......
...@@ -54,8 +54,9 @@ export interface EmailVerificationPayload { ...@@ -54,8 +54,9 @@ export interface EmailVerificationPayload {
} }
export interface BindEmailPayload { export interface BindEmailPayload {
email: string flow_token: string
code: string new_code: string
old_code?: string
} }
// ============================================================================ // ============================================================================
......
...@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { api } from '@/lib/api' import { api } from '@/lib/api'
import type { CustomOAuthBinding } from '@/lib/oauth' import type { CustomOAuthBinding } from '@/lib/oauth'
import { authRequestOptions, authResult } from '@/lib/secure-verification'
import type { LoginSession } from '@/stores/auth-store' import type { LoginSession } from '@/stores/auth-store'
import { normalizeUserSettings } from './lib/user-settings' import { normalizeUserSettings } from './lib/user-settings'
...@@ -29,6 +30,8 @@ import type { ...@@ -29,6 +30,8 @@ import type {
DeleteAccountRequest, DeleteAccountRequest,
CheckinStatusResponse, CheckinStatusResponse,
CheckinResponse, CheckinResponse,
AccountSecurityResult,
EmailBindingFlow,
} from './types' } from './types'
// ============================================================================ // ============================================================================
...@@ -55,6 +58,22 @@ export async function updateUserProfile( ...@@ -55,6 +58,22 @@ export async function updateUserProfile(
return res.data return res.data
} }
export function changeAccountPassword(
data: UpdateUserRequest,
proofToken: string,
signal: AbortSignal
): Promise<AccountSecurityResult & { has_password: boolean }> {
return authResult(
api.put('/api/user/self', data, {
...authRequestOptions,
headers: { 'X-Security-Proof': proofToken },
acceptAuthRotation: true,
singleUseAuthorization: true,
signal,
})
)
}
/** /**
* Update user settings * Update user settings
*/ */
...@@ -112,27 +131,81 @@ export async function sendEmailVerification( ...@@ -112,27 +131,81 @@ export async function sendEmailVerification(
/** /**
* Bind email account * Bind email account
*/ */
export async function bindEmail( export function startEmailBinding(
email: string, email: string,
code: string proofToken: string,
): Promise<ApiResponse> { signal: AbortSignal
const res = await api.post('/api/oauth/email/bind', { ): Promise<EmailBindingFlow> {
email, return authResult(
code, api.post(
}) '/api/oauth/email/bind/start',
return res.data { email },
{
...authRequestOptions,
headers: { 'X-Security-Proof': proofToken },
singleUseAuthorization: true,
signal,
}
)
)
}
export function resendEmailBinding(
flowToken: string,
signal: AbortSignal
): Promise<EmailBindingFlow> {
return authResult(
api.post(
'/api/oauth/email/bind/resend',
{ flow_token: flowToken },
{
...authRequestOptions,
singleUseAuthorization: true,
signal,
}
)
)
}
export function bindEmail(
flowToken: string,
newCode: string,
oldCode: string,
signal: AbortSignal
): Promise<AccountSecurityResult> {
return authResult(
api.post(
'/api/oauth/email/bind',
{ flow_token: flowToken, new_code: newCode, old_code: oldCode },
{
...authRequestOptions,
singleUseAuthorization: true,
signal,
}
)
)
} }
/** /**
* Bind WeChat account * Bind WeChat account
*/ */
export async function bindWeChat(code: string): Promise<ApiResponse> { export function bindWeChat(
const res = await api.post( code: string,
'/api/oauth/wechat/bind', proofToken: string,
{ code }, signal: AbortSignal
{ skipBusinessError: true, skipErrorHandler: true } ): Promise<AccountSecurityResult> {
return authResult(
api.post(
'/api/oauth/wechat/bind',
{ code },
{
...authRequestOptions,
headers: { 'X-Security-Proof': proofToken },
singleUseAuthorization: true,
signal,
}
)
) )
return res.data
} }
export interface TelegramBindFlow { export interface TelegramBindFlow {
...@@ -184,11 +257,19 @@ export async function getSelfOAuthBindings(): Promise< ...@@ -184,11 +257,19 @@ export async function getSelfOAuthBindings(): Promise<
/** /**
* Unbind a custom OAuth provider for current user * Unbind a custom OAuth provider for current user
*/ */
export async function unbindCustomOAuth( export function unbindCustomOAuth(
providerId: number providerId: number,
): Promise<ApiResponse> { proofToken: string,
const res = await api.delete(`/api/user/oauth/bindings/${providerId}`) signal: AbortSignal
return res.data ): Promise<AccountSecurityResult> {
return authResult(
api.delete(`/api/user/oauth/bindings/${providerId}`, {
...authRequestOptions,
headers: { 'X-Security-Proof': proofToken },
singleUseAuthorization: true,
signal,
})
)
} }
// ============================================================================ // ============================================================================
......
...@@ -35,6 +35,7 @@ export interface ApiResponse<T = unknown> { ...@@ -35,6 +35,7 @@ export interface ApiResponse<T = unknown> {
* User profile data * User profile data
*/ */
export interface UserProfile { export interface UserProfile {
has_password?: boolean
permissions?: UserPermissions permissions?: UserPermissions
/** User ID */ /** User ID */
id: number id: number
...@@ -132,6 +133,19 @@ export interface UpdateUserRequest { ...@@ -132,6 +133,19 @@ export interface UpdateUserRequest {
original_password?: string original_password?: string
} }
export interface AccountSecurityResult {
notification_warning?: boolean
}
export interface EmailBindingFlow extends AccountSecurityResult {
flow_token: string
email: string
current_email?: string
old_email_required: boolean
expires_at: number
resend_at: number
}
/** /**
* User settings update request * User settings update request
*/ */
......
...@@ -123,15 +123,23 @@ it('refreshes Telegram bindings from the server result after the callback popup ...@@ -123,15 +123,23 @@ it('refreshes Telegram bindings from the server result after the callback popup
postMessage: vi.fn(), postMessage: vi.fn(),
} }
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window) vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
vi.spyOn(api, 'post').mockResolvedValue({ vi.spyOn(api, 'post').mockImplementation(async (url) => ({
data: { data: {
success: true, success: true,
data: { data:
flow_token: 'binding-state', url === '/api/verify'
authorization_url: 'https://oauth.telegram.org/auth?server=pkce', ? {
}, proof_token: 'binding-proof',
method: 'password',
scope: 'account.binding.bind',
expires_at: expiresAt(),
}
: {
flow_token: 'binding-state',
authorization_url: 'https://oauth.telegram.org/auth?server=pkce',
},
}, },
}) }))
let resolve!: (response: { let resolve!: (response: {
data: { success: boolean; data: { action: string } } data: { success: boolean; data: { action: string } }
}) => void }) => void
...@@ -140,8 +148,21 @@ it('refreshes Telegram bindings from the server result after the callback popup ...@@ -140,8 +148,21 @@ it('refreshes Telegram bindings from the server result after the callback popup
}>((done) => { }>((done) => {
resolve = done resolve = done
}) })
const get = vi.spyOn(api, 'get').mockImplementation((url) => const get = vi.spyOn(api, 'get').mockImplementation((url) => {
url === '/api/status' if (url === '/api/verify/methods') {
return Promise.resolve({
data: {
success: true,
data: {
scope: 'account.binding.bind',
methods: [{ method: 'password', available: true }],
oauth_providers: [],
password_encryption_enabled: false,
},
},
})
}
return url === '/api/status'
? Promise.resolve({ ? Promise.resolve({
data: { data: {
success: true, success: true,
...@@ -149,7 +170,7 @@ it('refreshes Telegram bindings from the server result after the callback popup ...@@ -149,7 +170,7 @@ it('refreshes Telegram bindings from the server result after the callback popup
}, },
}) })
: response : response
) })
const onUpdate = vi.fn() const onUpdate = vi.fn()
const user = userEvent.setup() const user = userEvent.setup()
const client = new QueryClient({ const client = new QueryClient({
...@@ -166,6 +187,20 @@ it('refreshes Telegram bindings from the server result after the callback popup ...@@ -166,6 +187,20 @@ it('refreshes Telegram bindings from the server result after the callback popup
const telegram = (await screen.findByText('Telegram')).closest('li') const telegram = (await screen.findByText('Telegram')).closest('li')
if (!telegram) throw new Error('Telegram binding entry is missing') if (!telegram) throw new Error('Telegram binding entry is missing')
await user.click(within(telegram).getByRole('button', { name: 'Bind' })) await user.click(within(telegram).getByRole('button', { name: 'Bind' }))
const verification = await screen.findByRole('dialog', {
name: 'Security verification',
})
await user.type(
within(verification).getByLabelText('Password', { selector: 'input' }),
'current-password'
)
await user.click(within(verification).getByRole('button', { name: 'Verify' }))
const continuation = await screen.findByRole('alertdialog', {
name: 'Continue account binding',
})
await user.click(
within(continuation).getByRole('button', { name: 'Continue' })
)
await waitFor(() => await waitFor(() =>
expect(popup.location.replace).toHaveBeenCalledWith( expect(popup.location.replace).toHaveBeenCalledWith(
'https://oauth.telegram.org/auth?server=pkce' 'https://oauth.telegram.org/auth?server=pkce'
......
...@@ -30,6 +30,8 @@ import { DeleteAccountDialog } from './dialogs/delete-account-dialog' ...@@ -30,6 +30,8 @@ import { DeleteAccountDialog } from './dialogs/delete-account-dialog'
type AccountActionCardProps = { type AccountActionCardProps = {
action: 'password' | 'delete' action: 'password' | 'delete'
username: string username: string
hasPassword?: boolean
onUpdate?: () => void
} }
export function AccountActionCard(props: AccountActionCardProps) { export function AccountActionCard(props: AccountActionCardProps) {
...@@ -37,8 +39,14 @@ export function AccountActionCard(props: AccountActionCardProps) { ...@@ -37,8 +39,14 @@ export function AccountActionCard(props: AccountActionCardProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const actions = { const actions = {
password: { password: {
title: t('Change Password'), title: t(
description: t('Update your password to keep your account secure'), props.hasPassword === false ? 'Set Password' : 'Change Password'
),
description: t(
props.hasPassword === false
? 'Add a password after verifying your identity'
: 'Update your password to keep your account secure'
),
icon: Shield, icon: Shield,
}, },
delete: { delete: {
...@@ -79,6 +87,8 @@ export function AccountActionCard(props: AccountActionCardProps) { ...@@ -79,6 +87,8 @@ export function AccountActionCard(props: AccountActionCardProps) {
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
username={props.username} username={props.username}
hasPassword={props.hasPassword}
onSuccess={props.onUpdate}
/> />
)} )}
{props.action === 'delete' && ( {props.action === 'delete' && (
......
...@@ -16,17 +16,38 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,17 +16,38 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState } from 'react' import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { z } from 'zod'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field' import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
import { bindWeChat } from '@/features/profile/api' import { bindWeChat } from '@/features/profile/api'
import { useAccountSecurity } from '../../hooks/use-account-security'
const wechatBindingSchema = z.object({
code: z
.string()
.trim()
.min(1, 'Enter the verification code')
.max(128, 'Invalid verification code'),
})
interface WeChatBindDialogProps { interface WeChatBindDialogProps {
open: boolean open: boolean
qrCodeUrl: string qrCodeUrl: string
...@@ -36,103 +57,118 @@ interface WeChatBindDialogProps { ...@@ -36,103 +57,118 @@ interface WeChatBindDialogProps {
export function WeChatBindDialog(props: WeChatBindDialogProps) { export function WeChatBindDialog(props: WeChatBindDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [verificationCode, setVerificationCode] = useState('') const security = useAccountSecurity()
const [submitting, setSubmitting] = useState(false) const form = useForm<z.infer<typeof wechatBindingSchema>>({
resolver: zodResolver(wechatBindingSchema),
defaultValues: { code: '' },
})
const reset = form.reset
const cancel = security.cancel
useEffect(() => {
reset({ code: '' })
}, [reset, security.sessionKey])
useEffect(() => {
if (!props.open) {
cancel()
reset({ code: '' })
}
}, [props.open, cancel, reset])
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (submitting) return if (!open) {
if (!open) setVerificationCode('') security.cancel()
form.reset({ code: '' })
}
props.onOpenChange(open) props.onOpenChange(open)
} }
const submit = async (values: z.infer<typeof wechatBindingSchema>) => {
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const result = await security.run(async (signal) => {
event.preventDefault() const proof = await security.verify(
const code = verificationCode.trim() {
if (!code || submitting) return scope: 'account.binding.bind',
context: { provider: 'wechat', code: values.code },
setSubmitting(true) },
try { signal
const response = await bindWeChat(code) )
if (!response.success) { return bindWeChat(values.code, proof, signal)
toast.error(t('Request failed')) })
return if (!result) return
} toast.success(t('Binding successful!'))
handleOpenChange(false)
toast.success(t('Binding successful!')) props.onSuccess()
setVerificationCode('')
props.onOpenChange(false)
props.onSuccess()
} catch {
toast.error(t('Request failed'))
} finally {
setSubmitting(false)
}
} }
return ( return (
<Dialog <>
open={props.open} <Dialog
onOpenChange={handleOpenChange} open={props.open && !security.showVerification}
title={t('Bind WeChat Account')} onOpenChange={handleOpenChange}
description={t( title={t('Bind WeChat')}
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.' contentClassName='sm:max-w-md'
)} footer={
contentClassName='max-w-sm' <>
contentHeight='auto' <Button
bodyClassName='space-y-4' type='button'
footer={ variant='outline'
<> onClick={() => handleOpenChange(false)}
<Button >
type='button' {t('Cancel')}
variant='outline' </Button>
disabled={submitting} <Button
onClick={() => handleOpenChange(false)} type='submit'
form='wechat-bind-form'
disabled={security.pending}
>
{security.pending && <Spinner data-icon='inline-start' />}
{t('Bind')}
</Button>
</>
}
>
<Form {...form}>
<form
id='wechat-bind-form'
onSubmit={form.handleSubmit(submit)}
className='space-y-4'
> >
{t('Cancel')} {props.qrCodeUrl ? (
</Button> <div className='flex justify-center'>
<Button <img
type='submit' src={props.qrCodeUrl}
form='wechat-bind-form' alt={t('WeChat login QR code')}
disabled={submitting || !verificationCode.trim()} className='size-48 rounded-lg border object-contain'
> />
{submitting && <Spinner data-icon='inline-start' />} </div>
{t('Bind')} ) : (
</Button> <p className='text-muted-foreground text-sm'>
</> {t('QR code is not configured. Please contact support.')}
} </p>
> )}
<form id='wechat-bind-form' onSubmit={handleSubmit}> <FormField
<FieldGroup> control={form.control}
{props.qrCodeUrl ? ( name='code'
<div className='flex justify-center'> render={({ field }) => (
<img <FormItem>
src={props.qrCodeUrl} <FormLabel>{t('Verification code')}</FormLabel>
alt={t('WeChat login QR code')} <FormControl>
className='size-48 rounded-lg border object-contain' <Input
/> {...field}
</div> placeholder={t('Enter the verification code')}
) : ( autoComplete='one-time-code'
<p className='text-muted-foreground text-sm'> disabled={security.pending}
{t('QR code is not configured. Please contact support.')} autoFocus
</p> />
)} </FormControl>
<FormMessage />
<Field data-disabled={submitting}> </FormItem>
<FieldLabel htmlFor='wechat-bind-code'> )}
{t('Verification code')}
</FieldLabel>
<Input
id='wechat-bind-code'
value={verificationCode}
onChange={(event) => setVerificationCode(event.target.value)}
placeholder={t('Enter the verification code')}
autoComplete='one-time-code'
disabled={submitting}
autoFocus
/> />
</Field> </form>
</FieldGroup> </Form>
</form> </Dialog>
</Dialog> {security.showVerification && (
<SecureVerificationDialog {...security.verificationDialogProps} />
)}
</>
) )
} }
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
useSecureVerification,
type RequestVerificationOptions,
} from '@/features/auth/secure-verification'
import type { AccountSecurityResult } from '@/features/profile/types'
import { AuthOperationError } from '@/lib/secure-verification'
import { useAuthStore } from '@/stores/auth-store'
// Account operations compose the shared verification ceremony with the
// subsequent mutation. Both must be cancelled when the account/session changes.
export function useAccountSecurity() {
const { t } = useTranslation()
const userId = useAuthStore((state) => state.auth.user?.id)
const sessionId = useAuthStore((state) => state.auth.session?.sid)
const sessionKey = `${userId ?? ''}:${sessionId ?? ''}`
const verification = useSecureVerification()
const cancelVerification = verification.cancel
const requestVerification = verification.requestVerification
const current = useRef<AbortController | null>(null)
const [pending, setPending] = useState(false)
const [error, setError] = useState<AuthOperationError | null>(null)
const cancel = useCallback(() => {
current.current?.abort()
current.current = null
cancelVerification()
setPending(false)
setError(null)
}, [cancelVerification])
useEffect(() => cancel, [cancel, sessionKey])
const run = useCallback(
async <T extends AccountSecurityResult>(
action: (signal: AbortSignal) => Promise<T>
): Promise<T | undefined> => {
if (current.current) return undefined
const controller = new AbortController()
current.current = controller
setPending(true)
setError(null)
try {
const result = await action(controller.signal)
controller.signal.throwIfAborted()
if (result.notification_warning) {
toast.warning(
t(
'The change succeeded, but the notification email could not be sent.'
)
)
}
return result
} catch (error) {
if (!controller.signal.aborted) {
const failure = AuthOperationError.from(error)
if (failure.code !== 'AUTH_CANCELLED') {
setError(failure)
toast.error(t(failure.message))
}
}
return undefined
} finally {
if (current.current === controller) {
current.current = null
setPending(false)
}
}
},
[t]
)
const verify = useCallback(
async (
operation: RequestVerificationOptions,
signal: AbortSignal,
initialPassword?: string
) => {
signal.throwIfAborted()
const proof = await requestVerification(operation, initialPassword)
signal.throwIfAborted()
if (!proof) {
throw new AuthOperationError('Verification cancelled', 'AUTH_CANCELLED')
}
return proof.proof_token
},
[requestVerification]
)
const showVerification =
verification.dialogProps.state.phase !== 'idle' &&
verification.dialogProps.state.phase !== 'loading'
return {
run,
verify,
cancel,
pending,
error,
sessionKey,
showVerification,
verificationDialogProps: verification.dialogProps,
}
}
...@@ -82,7 +82,12 @@ export function Security() { ...@@ -82,7 +82,12 @@ export function Security() {
<h3 id='security-authentication' className='text-sm font-semibold'> <h3 id='security-authentication' className='text-sm font-semibold'>
{t('Login & Authentication')} {t('Login & Authentication')}
</h3> </h3>
<AccountActionCard action='password' username={profile.username} /> <AccountActionCard
action='password'
username={profile.username}
hasPassword={profile.has_password}
onUpdate={refreshProfile}
/>
<TitledCard <TitledCard
title={t('Account Bindings')} title={t('Account Bindings')}
icon={<Link2 className='size-4' />} icon={<Link2 className='size-4' />}
......
...@@ -37,6 +37,7 @@ import { ...@@ -37,6 +37,7 @@ import {
import { Form } from '@/components/ui/form' import { Form } from '@/components/ui/form'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useSystemConfig } from '@/hooks/use-system-config' import { useSystemConfig } from '@/hooks/use-system-config'
import { accountPasswordSchema } from '@/lib/password-policy'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { buildSetupPayload, getSetupStatus, submitSetup } from './api' import { buildSetupPayload, getSetupStatus, submitSetup } from './api'
...@@ -208,8 +209,8 @@ export function SetupWizard() { ...@@ -208,8 +209,8 @@ export function SetupWizard() {
if (setupStatus?.root_init) return true if (setupStatus?.root_init) return true
const username = form.getValues('username')?.trim() const username = form.getValues('username')?.trim()
const password = form.getValues('password')?.trim() const password = form.getValues('password')
const confirmPassword = form.getValues('confirmPassword')?.trim() const confirmPassword = form.getValues('confirmPassword')
if (!username) { if (!username) {
form.setError('username', { form.setError('username', {
...@@ -220,12 +221,12 @@ export function SetupWizard() { ...@@ -220,12 +221,12 @@ export function SetupWizard() {
return false return false
} }
if (!password || password.length < 8) { if (!accountPasswordSchema.safeParse(password).success) {
form.setError('password', { form.setError('password', {
type: 'manual', type: 'manual',
message: t('Password must be at least 8 characters'), message: t('Password must contain between 8 and 128 characters.'),
}) })
toast.error(t('Password must be at least 8 characters')) toast.error(t('Password must contain between 8 and 128 characters.'))
return false return false
} }
...@@ -328,24 +329,20 @@ export function SetupWizard() { ...@@ -328,24 +329,20 @@ export function SetupWizard() {
return ( return (
<li <li
key={step.titleKey} key={step.titleKey}
className={cn( className={cn('rounded-xl border p-3', {
'rounded-xl border p-3', 'border-primary ring-primary/20 ring-2': isActive,
isActive 'border-primary/40 bg-primary/5':
? 'border-primary ring-primary/20 ring-2' !isActive && isCompleted,
: isCompleted 'border-muted bg-card': !isActive && !isCompleted,
? 'border-primary/40 bg-primary/5' })}
: 'border-muted bg-card'
)}
> >
<div className='flex items-start gap-3'> <div className='flex items-start gap-3'>
<span <span
className={cn( className={cn(
'flex size-6 items-center justify-center rounded-md border text-xs font-semibold', 'flex size-6 items-center justify-center rounded-md border text-xs font-semibold',
isActive isActive || isCompleted
? 'border-primary bg-primary text-primary-foreground' ? 'border-primary bg-primary text-primary-foreground'
: isCompleted : 'border-muted-foreground/40 text-muted-foreground'
? 'border-primary bg-primary text-primary-foreground'
: 'border-muted-foreground/40 text-muted-foreground'
)} )}
> >
{index + 1} {index + 1}
...@@ -364,14 +361,14 @@ export function SetupWizard() { ...@@ -364,14 +361,14 @@ export function SetupWizard() {
})} })}
</ol> </ol>
{isLoading ? ( {isLoading && <LoadingState message={t('Loading setup status…')} />}
<LoadingState message={t('Loading setup status…')} /> {!isLoading && isError && (
) : isError ? (
<ErrorState <ErrorState
title={t('We could not load the setup status.')} title={t('We could not load the setup status.')}
onRetry={() => refetch()} onRetry={() => refetch()}
/> />
) : ( )}
{!isLoading && !isError && (
<Form {...form}> <Form {...form}>
<form <form
className='space-y-6' className='space-y-6'
......
...@@ -396,6 +396,11 @@ const AUDIT_TEMPLATES: Record<string, string> = { ...@@ -396,6 +396,11 @@ const AUDIT_TEMPLATES: Record<string, string> = {
'user.2fa_disable_self': 'Disabled two-factor authentication', 'user.2fa_disable_self': 'Disabled two-factor authentication',
'user.2fa_backup_codes': 'Regenerated two-factor backup codes', 'user.2fa_backup_codes': 'Regenerated two-factor backup codes',
'user.security_verify': 'Completed security verification', 'user.security_verify': 'Completed security verification',
'user.password_change': 'Account password change',
'user.binding_start': 'Account binding request',
'user.binding_bind': 'Account binding',
'user.binding_unbind': 'Account unlinking',
'user.email_binding_resend': 'Email confirmation code resend',
login: 'Logged in successfully via {{method}}', login: 'Logged in successfully via {{method}}',
// User management // User management
......
...@@ -71,6 +71,7 @@ import { ...@@ -71,6 +71,7 @@ import {
} from '@/lib/admin-permissions' } from '@/lib/admin-permissions'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { formatQuota, parseQuotaFromDollars } from '@/lib/format' import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
import { accountPasswordSchema } from '@/lib/password-policy'
import { ROLE } from '@/lib/roles' import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
...@@ -159,12 +160,11 @@ export function UsersMutateDrawer({ ...@@ -159,12 +160,11 @@ export function UsersMutateDrawer({
const targetIsAdmin = (selectedRole ?? currentRow?.role ?? 0) >= ROLE.ADMIN const targetIsAdmin = (selectedRole ?? currentRow?.role ?? 0) >= ROLE.ADMIN
const onSubmit = async (data: UserFormValues) => { const onSubmit = async (data: UserFormValues) => {
if (!isUpdate) { if (!isUpdate || data.password) {
const passwordLength = data.password?.length || 0 if (!accountPasswordSchema.safeParse(data.password ?? '').success) {
if (passwordLength < 8 || passwordLength > 20) {
form.setError('password', { form.setError('password', {
type: 'manual', type: 'manual',
message: t('Password must be between 8 and 20 characters'), message: t('Password must contain between 8 and 128 characters.'),
}) })
return return
} }
...@@ -340,7 +340,7 @@ export function UsersMutateDrawer({ ...@@ -340,7 +340,7 @@ export function UsersMutateDrawer({
placeholder={ placeholder={
isUpdate isUpdate
? t('Leave empty to keep unchanged') ? t('Leave empty to keep unchanged')
: t('Enter password (8-20 characters)') : t('Enter password (8–128 characters)')
} }
/> />
</FormControl> </FormControl>
......
...@@ -588,4 +588,43 @@ export const STATIC_I18N_KEYS = [ ...@@ -588,4 +588,43 @@ export const STATIC_I18N_KEYS = [
"Verification does not match this action's details. Please verify again.", "Verification does not match this action's details. Please verify again.",
'The action details are invalid.', 'The action details are invalid.',
'You do not have permission to perform this action.', 'You do not have permission to perform this action.',
// Account binding and password-operation messages.
'Account bindings have changed. Start this operation again.',
'Add another login method before unlinking this account.',
'Bind WeChat',
'Continue account binding',
'Current email verification code',
'Current password is incorrect.',
'Email verification code is incorrect.',
'Email verification has ended. Start again to continue.',
'Enter password (8–128 characters)',
'Long passwords are unavailable until the password storage upgrade is complete.',
'New email verification code',
'Password must contain between 8 and 128 characters.',
'Password settings have changed. Reload the page and try again.',
'Please wait before requesting another verification code.',
'Resend codes',
'Resend in {{seconds}}s',
'Start again',
'The change succeeded, but the notification email could not be sent.',
"This email address is not allowed by the administrator's email policy.",
'Too many incorrect codes. Start email verification again.',
'Unsupported account password hashing configuration.',
'Use 8–128 characters.',
'Verification email could not be sent. Start email verification again.',
'Your identity has been verified. Continue to the provider to finish linking your account.',
'Set Password',
'Password set successfully',
'Confirm email',
'Confirm both your current and new email addresses to finish this change.',
'Confirm the verification code sent to your new email address.',
'Enter the 6-digit email verification code.',
'Password authentication is disabled.',
'This external account is already bound.',
'This email address is already in use.',
'Account password change',
'Account binding request',
'Account binding',
'Account unlinking',
'Email confirmation code resend',
] as const ] as const
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
// Match the backend's Unicode character count without modifying the password.
export const accountPasswordSchema = z.string().refine((password) => {
const length = [...password].length
return length >= 8 && length <= 128
}, 'Password must contain between 8 and 128 characters.')
...@@ -27,6 +27,7 @@ export type UserPermissions = { ...@@ -27,6 +27,7 @@ export type UserPermissions = {
} }
export interface AuthUser { export interface AuthUser {
has_password?: boolean
id: number id: number
username: string username: string
display_name?: string display_name?: string
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment