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 @@
# SESSION_SECRET=random_string
# 登录密码请求体 RSA-OAEP 加密;默认关闭,且不能替代 HTTPS
# 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;兼容本地开发代理。
# true:启用 Secure Refresh Cookie 和严格 OriginGuard,必须同时列出全部可信 HTTPS Origin。
# 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 (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
"golang.org/x/crypto/bcrypt"
)
......@@ -27,6 +28,9 @@ func Password2Hash(password string) (string, error) {
}
func ValidatePasswordAndHash(password string, hash string) bool {
if strings.HasPrefix(hash, "$argon2id$") {
return validateArgon2AccountPassword(password, hash)
}
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
package common
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
......@@ -12,6 +14,7 @@ import (
"fmt"
"strings"
"sync"
"unicode/utf8"
)
const passwordEncryptionKeyBits = 2048
......@@ -92,9 +95,10 @@ func PasswordEncryptionPublicKey() (keyID string, publicKeyPEM string) {
return passwordEncryptionState.keyID, passwordEncryptionState.publicKey
}
// DecryptPassword decrypts a base64 RSA-OAEP/SHA-256 password submitted by a
// browser. All malformed inputs share one error so callers do not expose
// cryptographic details to unauthenticated clients.
// DecryptPassword accepts legacy RSA-OAEP/SHA-256 ciphertext and v2 envelopes.
// V2 wraps a fresh AES-256 key with RSA-OAEP and encrypts the password with GCM,
// 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) {
passwordEncryptionState.RLock()
privateKey := passwordEncryptionState.privateKey
......@@ -103,6 +107,44 @@ func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) {
if privateKey == nil || keyID == "" || keyID != activeKeyID {
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)
if err != nil || len(ciphertext) != privateKey.Size() {
return "", ErrPasswordEncryptionInvalid
......
......@@ -33,6 +33,11 @@ var auditContentTemplates = map[string]string{
"user.2fa_disable_self": "Disabled two-factor authentication",
"user.2fa_backup_codes": "Regenerated two-factor backup codes",
"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",
"user.passkey_delete": "Deleted a passkey",
"user.reset_passkey": "Reset the user passkey",
"option.update": "Updated system setting ${key}",
......@@ -118,5 +123,18 @@ func recordManageAuditFor(c *gin.Context, targetUserId int, action string, param
// recordUserSecurityAudit 记录普通用户自己的安全敏感操作(如 passkey 绑定/解绑)。
// 这类日志没有管理员操作者,不写 admin_info;同时不依赖 AdminAuth/RootAuth 的兜底。
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 (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
......@@ -50,18 +51,21 @@ func (*authFlowTestOAuthProvider) ProviderUserIDColumn() string
func setupAuthFlowControllerTest(t *testing.T) *authFlowTestOAuthProvider {
t.Helper()
previousDB := model.DB
previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedis := common.RedisEnabled
common.RedisEnabled = false
previousType := common.MainDatabaseType()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.AuthFlow{}))
model.DB = db
require.NoError(t, db.AutoMigrate(&model.AuthFlow{}, &model.User{}, &model.UserSession{}, &model.AuditLog{}))
model.DB, model.LOG_DB = db, db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
provider := &authFlowTestOAuthProvider{}
oauth.Register("auth-flow-test", provider)
t.Cleanup(func() {
oauth.Unregister("auth-flow-test")
model.DB = previousDB
model.DB, model.LOG_DB = previousDB, previousLogDB
common.RedisEnabled = previousRedis
common.SetMainDatabaseType(previousType)
})
return provider
......@@ -97,34 +101,23 @@ func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) {
}
func TestGenerateOAuthCodeBindsFlowToAuthenticatedSession(t *testing.T) {
setupAuthFlowControllerTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"bind"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("id", 42)
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 {
_, identity := setupSecurityEnrollmentTest(t)
oauth.Register("auth-flow-test", &authFlowTestOAuthProvider{})
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)
response := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode)
var result struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind,
UserId: 42, SessionId: "session-42",
})
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
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})
require.NoError(t, err)
assert.Equal(t, 42, flow.UserId)
assert.Equal(t, "session-42", flow.SessionId)
assert.Equal(t, identity.UserID, flow.UserId)
assert.Equal(t, identity.SessionID, flow.SessionId)
}
func TestOAuthLoginConsumesFlowOnlyAfterProviderIdentity(t *testing.T) {
......@@ -198,27 +191,25 @@ func TestOAuthLoginConsumesFlowAfterProviderIdentityAndOnProviderError(t *testin
}
func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind,
UserId: 42, SessionId: "session-42", Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("id", 42)
c.Set("session_id", "session-42")
c.Set("auth_version", int64(1))
c.Set("session_version", int64(1))
c.Next()
_, identity := setupSecurityEnrollmentTest(t)
provider := &authFlowTestOAuthProvider{}
oauth.Register("auth-flow-test", provider)
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)
started := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode)
var result struct {
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(started.Body.Bytes(), &result))
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)
_, 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.Zero(t, provider.exchangeCalls)
assert.Zero(t, provider.userInfoCalls)
......
......@@ -10,8 +10,10 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
......@@ -521,27 +523,47 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) {
// UnbindCustomOAuth unbinds a custom OAuth provider from the current user
func UnbindCustomOAuth(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
common.ApiErrorMsg(c, "未登录")
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
providerIdStr := c.Param("provider_id")
providerId, err := strconv.Atoi(providerIdStr)
if err != nil {
if err != nil || providerId <= 0 {
common.ApiErrorMsg(c, "无效的提供商 ID")
return
}
if err := model.DeleteUserOAuthBinding(userId, providerId); err != nil {
common.ApiError(c, err)
succeeded, notificationFailed := false, false
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
}
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "Login account unlinked") != nil
c.JSON(http.StatusOK, gin.H{
"success": true,
"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 (
"errors"
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
......@@ -14,6 +13,7 @@ import (
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
......@@ -216,47 +216,11 @@ func GetHomePageContent(c *gin.Context) {
}
func SendEmailVerification(c *gin.Context) {
email := model.NormalizeEmail(c.Query("email"))
if err := common.Validate.Var(email, "required,email"); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
parts := strings.Split(email, "@")
if len(parts) != 2 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的邮箱地址",
})
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": "管理员已启用邮箱地址别名限制,您的邮箱地址由于包含特殊符号而被拒绝。",
})
email, err := service.ValidateAccountEmail(c.Query("email"))
if err != nil {
writeSecurityOperationError(c, err)
return
}
}
if model.IsEmailAlreadyTaken(email) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
......@@ -268,7 +232,7 @@ func SendEmailVerification(c *gin.Context) {
content := fmt.Sprintf("<p>您好,你正在进行%s邮箱验证。</p>"+
"<p>您的验证码为: <strong>%s</strong></p>"+
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
err = common.SendEmail(subject, email, content)
if err != nil {
common.ApiError(c, err)
return
......
......@@ -493,46 +493,6 @@ func TestListModelsTokenLimitUsesResolvedCustomAutoGroups(t *testing.T) {
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) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}, &model.UserSession{}))
......
......@@ -34,6 +34,7 @@ type oauthFlowPayload struct {
Verification *service.OAuthVerificationFlow `json:"verification,omitempty"`
Telegram *oauth.TelegramOAuthFlow `json:"telegram,omitempty"`
SessionIdentity *service.AuthIdentity `json:"session_identity,omitempty"`
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
}
// providerParams returns map with Provider key for i18n templates
......@@ -62,6 +63,7 @@ func GenerateOAuthCode(c *gin.Context) {
userID := 0
sessionID := ""
flowPayload := oauthFlowPayload{AffiliateCode: request.Aff}
bindingStarted := false
if request.Provider == "telegram" {
telegramFlow, err := oauth.NewTelegramOAuthFlow()
if err != nil {
......@@ -78,6 +80,21 @@ func GenerateOAuthCode(c *gin.Context) {
}
userID = identity.UserID
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 _, _, err := service.ValidateLoginSession(identity); err != nil {
writeSecurityOperationError(c, err)
......@@ -113,6 +130,7 @@ func GenerateOAuthCode(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
bindingStarted = request.Intent == model.AuthFlowIntentBind
data := gin.H{"flow_token": state, "expires_at": expiresAt.Unix()}
if flowPayload.Telegram != nil {
data["authorization_url"] = flowPayload.Telegram.AuthorizationURL(state)
......@@ -155,6 +173,12 @@ func HandleOAuth(c *gin.Context) {
Provider: providerName,
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.
if pendingFlow.Intent == model.AuthFlowIntentBind || pendingFlow.Intent == model.AuthFlowIntentVerify {
identity, ok := middleware.GetSessionAuthIdentity(c)
......@@ -167,6 +191,22 @@ func HandleOAuth(c *gin.Context) {
}
consumeMatch.UserId = identity.UserID
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 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
......@@ -240,15 +280,8 @@ func HandleOAuth(c *gin.Context) {
handleOAuthError(c, err)
return
}
if providerName == "telegram" && pendingFlow.Intent == model.AuthFlowIntentBind {
_, err := model.ConsumeAuthFlowWithAction(state, consumeMatch, func(tx *gorm.DB, _ *model.AuthFlow) error {
return model.BindTelegramForSessionWithTx(tx, *telegramPayload.SessionIdentity, oauthUser.ProviderUserID)
})
if err != nil {
writeSecurityOperationError(c, err)
return
}
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{"action": "bind"})
if pendingFlow.Intent == model.AuthFlowIntentBind {
bindSucceeded, notificationFailed = handleOAuthBind(c, providerName, provider, oauthUser, pendingFlow, state, consumeMatch)
return
}
flow, err := model.ConsumeAuthFlow(state, consumeMatch)
......@@ -260,8 +293,6 @@ func HandleOAuth(c *gin.Context) {
switch flow.Intent {
case model.AuthFlowIntentLogin:
handleOAuthLogin(c, provider, oauthUser, flow)
case model.AuthFlowIntentBind:
handleOAuthBind(c, provider, oauthUser, flow)
case model.AuthFlowIntentVerify:
handleOAuthVerification(c, providerName, oauthUser, flow)
}
......@@ -320,44 +351,57 @@ func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.
}
// handleOAuthBind handles binding OAuth account to existing user
func handleOAuthBind(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) {
// Check if this OAuth account is already bound (check both new ID and legacy ID)
func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow, state string, match model.AuthFlowMatch) (bool, bool) {
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) {
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 != "" {
if provider.IsUserIDTaken(legacyID) {
if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && legacyID != "" && provider.IsUserIDTaken(legacyID) {
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
return
return false, false
}
_, err = model.ConsumeAuthFlowWithAction(state, match, func(tx *gorm.DB, _ *model.AuthFlow) error {
if providerName == "telegram" {
return model.BindTelegramForSessionWithTx(tx, identity, oauthUser.ProviderUserID)
}
userId := flow.UserId
var err error
// 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 custom, ok := provider.(*oauth.GenericOAuthProvider); ok {
return model.UpdateUserOAuthBindingForSessionWithTx(tx, identity, custom.GetProviderId(), oauthUser.ProviderUserID)
}
return model.UpdateUserBindColumnForSessionWithTx(tx, identity, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID)
})
if err != nil {
writeSecurityOperationError(c, err)
return
return false, false
}
} else {
// Built-in provider: 只更新绑定列。完整快照的 user.Update 会把读取时刻的
// role/status/group 一并写回,覆盖并发发生的封禁、降权或分组变更。
err = model.UpdateUserBindColumn(userId, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID)
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
writeSecurityOperationError(c, err)
return
return true, true
}
}
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{
"action": "bind",
})
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
......
......@@ -512,7 +512,12 @@ func PasskeyVerifyBegin(c *gin.Context) {
}
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 {
writeSecurityOperationError(c, err)
return
......
......@@ -34,6 +34,28 @@ func writeSecurityOperationError(c *gin.Context, err error) {
var code, message string
var protocolError *protocol.Error
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):
code, message = "TELEGRAM_OAUTH_NOT_CONFIGURED", oauth.ErrTelegramOAuthNotConfigured.Error()
case errors.Is(err, oauth.ErrTelegramOAuthConflict):
......@@ -43,7 +65,10 @@ func writeSecurityOperationError(c *gin.Context, err error) {
case errors.Is(err, oauth.ErrTelegramAccountNotBound):
code, message = "TELEGRAM_ACCOUNT_NOT_BOUND", oauth.ErrTelegramAccountNotBound.Error()
case errors.Is(err, model.ErrExternalIdentityAlreadyClaimed):
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):
status = http.StatusBadRequest
code, message = "SECURITY_CONTEXT_INVALID", service.ErrVerificationContextInvalid.Error()
......@@ -82,9 +107,11 @@ func writeSecurityOperationError(c *gin.Context, err error) {
writeAuthSessionError(c, service.ErrAuthTokenInvalid)
return
default:
c.Set("security_error_code", "AUTH_INTERNAL_ERROR")
writeAuthSessionError(c, err)
return
}
c.Set("security_error_code", code)
c.JSON(status, gin.H{"success": false, "code": code, "message": message})
}
......
......@@ -193,14 +193,26 @@ func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error)
}
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)
require.NoError(t, err)
require.Len(t, requirements.Methods, 1)
assert.Equal(t, test.method, requirements.Methods[0].Method)
assert.Equal(t, test.available, requirements.Methods[0].Available)
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)
}
}
......@@ -1420,6 +1432,11 @@ func TestSecurityEnrollmentTelegramAndWeChatFirstFactor(t *testing.T) {
}
var body securityEnrollmentResponse
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())
var proof service.SecurityProof
require.NoError(t, common.Unmarshal(body.Data, &proof))
......@@ -1439,8 +1456,8 @@ func TestSecurityEnrollmentTelegramAndWeChatFirstFactor(t *testing.T) {
}
}
func TestSecurityEnrollmentWeChatExceptionRemainsNarrow(t *testing.T) {
for _, scenario := range []string{"password", "passkey", "locked 2fa", "telegram", "github", "disabled custom binding", "no binding", "binding storage failure", "revoked session"} {
func TestSecurityEnrollmentNeverTrustsSessionForFirstFactor(t *testing.T) {
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) {
user, identity := setupSecurityEnrollmentTest(t)
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) {
} else {
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"})
require.NoError(t, err)
require.Error(t, err)
assert.Nil(t, proof)
return
}
_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "passkey.register"})
assert.Error(t, err)
......
......@@ -85,16 +85,16 @@ func PostSetup(c *gin.Context) {
return
}
if len(req.Password) < 8 {
if err := common.ValidateNewAccountPassword(req.Password); err != nil {
c.JSON(200, gin.H{
"success": false,
"message": "密码长度至少为8个字符",
"message": err.Error(),
})
return
}
// Create root user
hashedPassword, err := common.Password2Hash(req.Password)
hashedPassword, err := common.HashAccountPassword(req.Password)
if err != nil {
c.JSON(200, gin.H{
"success": false,
......
......@@ -156,7 +156,11 @@ func (fixture *telegramOAuthFixture) authorization(t *testing.T, intent string,
t.Helper()
request, err := common.Marshal(oauthStateRequest{Provider: "telegram", Intent: intent, Scope: scope})
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
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
require.True(t, body.Success, response.Body.String())
......@@ -428,7 +432,7 @@ func TestTelegramOAuthConfigurationAndLegacyEndpoints(t *testing.T) {
func TestTelegramOAuthConcurrentBindingHasSingleOwner(t *testing.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)
bundle, err := service.CreateLoginSession(other.Id, "password", "127.0.0.1", "test")
require.NoError(t, err)
......
......@@ -34,11 +34,6 @@ type LoginRequest struct {
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) {
if !common.PasswordLoginEncryptionEnabled {
common.ApiSuccess(c, gin.H{"enabled": false})
......@@ -190,7 +185,7 @@ func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin
common.ApiErrorI18n(c, i18n.MsgAuthUserBanned)
return
}
currentUser, err := model.GetUserById(user.Id, false)
currentUser, err := model.GetSelfUserById(user.Id)
if err != nil {
common.ApiError(c, err)
return
......@@ -483,7 +478,7 @@ func GetAffCode(c *gin.Context) {
func GetSelf(c *gin.Context) {
id := c.GetInt("id")
userRole := c.GetInt("role")
user, err := model.GetUserById(id, false)
user, err := model.GetSelfUserById(id)
if err != nil {
common.ApiError(c, err)
return
......@@ -515,6 +510,7 @@ func buildSelfUserData(user *model.User) map[string]interface{} {
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"has_password": user.HasPassword,
"role": user.Role,
"status": user.Status,
"email": user.Email,
......@@ -676,10 +672,7 @@ func UpdateUser(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
if updatedUser.Password == "" {
updatedUser.Password = "$I_LOVE_U" // make Validator happy :)
}
if err := common.Validate.Struct(&updatedUser); err != nil {
if err := common.Validate.StructExcept(&updatedUser, "Password"); err != nil {
common.ApiErrorI18n(c, i18n.MsgUserInputInvalid, map[string]any{"Error": err.Error()})
return
}
......@@ -698,9 +691,6 @@ func UpdateUser(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
return
}
if updatedUser.Password == "$I_LOVE_U" {
updatedUser.Password = "" // rollback to what it should be
}
updatePassword := updatedUser.Password != ""
authzTouched := false
if err := model.DB.Transaction(func(tx *gorm.DB) error {
......@@ -789,8 +779,19 @@ func UpdateSelf(c *gin.Context) {
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)
if sidebarModules, sidebarExists := requestData["sidebar_modules"]; sidebarExists {
if sidebarModules, sidebarExists := requestData["sidebar_modules"]; sidebarExists && !passwordRequested {
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
......@@ -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")
user, err := model.GetUserById(userId, false)
if err != nil {
......@@ -853,10 +854,7 @@ func UpdateSelf(c *gin.Context) {
return
}
if user.Password == "" {
user.Password = "$I_LOVE_U" // make Validator happy :)
}
if err := common.Validate.Struct(&user); err != nil {
if err := common.Validate.StructExcept(&user, "Password"); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidInput)
return
}
......@@ -867,42 +865,39 @@ func UpdateSelf(c *gin.Context) {
Password: user.Password,
DisplayName: user.DisplayName,
}
if user.Password == "$I_LOVE_U" {
user.Password = "" // rollback to what it should be
cleanUser.Password = ""
}
updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id)
if err != nil {
if errors.Is(err, errUserPasswordUnset) {
common.ApiErrorI18n(c, i18n.MsgUserPasswordUnset)
if user.Password != "" {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
if errors.Is(err, errOriginalPasswordFail) {
common.ApiErrorI18n(c, i18n.MsgUserOriginalPasswordError)
current, err := model.GetUserById(identity.UserID, true)
if err != nil {
writeSecurityOperationError(c, err)
return
}
common.ApiError(c, err)
return
firstPassword := current.Password == ""
scope := service.VerificationScopePasswordChange
if firstPassword {
scope = service.VerificationScopePasswordSet
}
if updatePassword {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: scope}) == nil {
return
}
if err := model.DB.Transaction(func(tx *gorm.DB) error {
return cleanUser.UpdateWithTx(tx, true)
}); err != nil {
common.ApiError(c, err)
cleanUser.OriginalPassword = user.OriginalPassword
if err := model.ChangeUserPassword(identity, &cleanUser, firstPassword); err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded = true
notificationFailed = service.NotifyAccountSecurityChange(current.Email, "Password updated") != nil
if err := model.PublishUserAuthCache(cleanUser.Id); err != nil {
common.ApiError(c, err)
writeSecurityOperationError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "password_changed")
if err != nil {
common.ApiError(c, err)
writeSecurityOperationError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
......@@ -913,6 +908,8 @@ func UpdateSelf(c *gin.Context) {
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
"has_password": true,
"notification_warning": notificationFailed,
},
})
return
......@@ -926,29 +923,6 @@ func UpdateSelf(c *gin.Context) {
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) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
......@@ -1272,51 +1246,6 @@ func ManageUser(c *gin.Context) {
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 {
Key string `json:"key"`
}
......
......@@ -6,12 +6,16 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"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"
"gorm.io/gorm"
)
type wechatLoginResponse struct {
......@@ -125,6 +129,15 @@ type wechatBindRequest struct {
}
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 {
c.JSON(http.StatusOK, gin.H{
"message": "管理员未开启通过微信登录以及注册",
......@@ -140,7 +153,15 @@ func WeChatBind(c *gin.Context) {
})
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)
if err != nil {
c.JSON(http.StatusOK, gin.H{
......@@ -156,19 +177,24 @@ func WeChatBind(c *gin.Context) {
})
return
}
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
// 只更新绑定列,避免完整用户快照覆盖并发的封禁、降权或分组变更。
if err := model.DB.Transaction(func(tx *gorm.DB) error {
return model.UpdateUserBindColumnForSessionWithTx(tx, identity, "wechat_id", wechatId)
}); err != nil {
writeSecurityOperationError(c, err)
return
}
// 只更新绑定列,避免完整用户快照覆盖并发的封禁、降权或分组变更。
if err := model.UpdateUserBindColumn(userId, "wechat_id", wechatId); err != nil {
common.ApiError(c, err)
succeeded = true
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
writeSecurityOperationError(c, err)
return
}
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "WeChat account linked") != nil
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{"notification_warning": notificationFailed},
})
return
}
......@@ -76,6 +76,7 @@ func RequireSecurityProof(c *gin.Context, operation service.VerificationOperatio
}
func securityProofError(c *gin.Context, code, message string) {
c.Set("security_error_code", code)
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"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 (
AuthFlowIntentVerify = "verify"
AuthFlowPurposeTwoFASetup = "2fa_setup"
AuthFlowPurposeSecurityProof = "security_proof"
AuthFlowPurposeEmailBinding = "email_binding"
AuthFlowTokenBytes = 32
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 {
type User struct {
Id int `json:"id"`
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!
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
Role int `json:"role" gorm:"type:int;default:1"` // admin, common
......@@ -382,9 +383,16 @@ func EnsureEmailAvailable(email string, excludeUserID int) error {
//
// 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 {
if err := lockNormalizedEmail(tx, email); err != nil {
return err
}
return fn(tx)
}
func lockNormalizedEmail(tx *gorm.DB, email string) error {
email = NormalizeEmail(email)
if email == "" {
return fn(tx)
return nil
}
switch {
case common.UsingMainDatabase(common.DatabaseTypePostgreSQL):
......@@ -397,7 +405,7 @@ func withNormalizedEmailLock(tx *gorm.DB, email string, fn func(tx *gorm.DB) err
return err
}
}
return fn(tx)
return nil
}
func GetMaxUserId() int {
......@@ -524,6 +532,28 @@ func GetUserById(id int, selectAll bool) (*User, error) {
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) {
if affCode == "" {
return 0, errors.New("affCode 为空!")
......@@ -610,7 +640,7 @@ func (user *User) prepareForInsert(tx *gorm.DB) error {
return nil
}
var err error
user.Password, err = common.Password2Hash(user.Password)
user.Password, err = common.HashAccountPassword(user.Password)
return err
}
......@@ -789,7 +819,7 @@ func (user *User) Update(updatePassword bool) error {
func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error {
var err error
if updatePassword {
user.Password, err = common.Password2Hash(user.Password)
user.Password, err = common.HashAccountPassword(user.Password)
if err != nil {
return err
}
......@@ -850,7 +880,7 @@ func (user *User) Edit(updatePassword bool) error {
func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error {
var err error
if updatePassword {
user.Password, err = common.Password2Hash(user.Password)
user.Password, err = common.HashAccountPassword(user.Password)
if err != nil {
return err
}
......@@ -1150,7 +1180,7 @@ func ResetUserPasswordByEmail(email string, password string) error {
if err != nil {
return err
}
hashedPassword, err := common.Password2Hash(password)
hashedPassword, err := common.HashAccountPassword(password)
if err != nil {
return err
}
......
......@@ -46,7 +46,9 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.ResetPassword)
// 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/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.
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.WeChatAuth)
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
if err != nil {
return nil, nil, err
}
currentUser, err := model.GetUserById(session.UserID, false)
currentUser, err := model.GetSelfUserById(session.UserID)
if err != nil {
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 (
VerificationScopeTwoFASetup = "2fa.setup"
VerificationScopeAccessTokenGenerate = "access_token.generate"
VerificationScopeAccessTokenRevoke = "access_token.revoke"
VerificationScopeAccountBind = "account.binding.bind"
VerificationScopeAccountUnbind = "account.binding.unbind"
VerificationScopePasswordSet = "account.password.set"
VerificationScopePasswordChange = "account.password.change"
)
var (
......@@ -48,6 +52,16 @@ type ChannelKeyReadContext struct {
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
// travel through a signed proof or a server-owned interactive verification flow.
type VerificationBinding struct {
......@@ -70,8 +84,38 @@ func BindVerificationOperation(operation VerificationOperation) (VerificationBin
return VerificationBinding{}, ErrVerificationContextInvalid
}
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,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke:
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke,
VerificationScopePasswordSet, VerificationScopePasswordChange:
if len(fields) != 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
......@@ -124,7 +168,6 @@ type verificationAccountState struct {
TwoFALocked bool
HasPasskey bool
PasskeyEnabled bool
WeChatEnrollment bool
}
// securityVerificationPolicy is the only operation-to-method policy. Device
......@@ -146,10 +189,15 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
methods = []string{VerificationMethodPasskey}
}
case VerificationScopePasskeyRegister, VerificationScopeTwoFASetup,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke:
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke,
VerificationScopeAccountBind, VerificationScopeAccountUnbind,
VerificationScopePasswordSet, VerificationScopePasswordChange:
if scope == VerificationScopeTwoFASetup && state.HasTwoFA {
return nil, model.ErrTwoFAAlreadyEnabled
}
if (scope == VerificationScopePasswordSet && state.HasPassword) || (scope == VerificationScopePasswordChange && !state.HasPassword) {
return nil, ErrVerificationForbidden
}
switch {
case state.HasTwoFA:
methods = []string{VerificationMethodTwoFA}
......@@ -157,8 +205,6 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
methods = []string{VerificationMethodPasskey}
case state.HasPassword:
methods = []string{VerificationMethodPassword}
case state.WeChatEnrollment && (scope == VerificationScopePasskeyRegister || scope == VerificationScopeTwoFASetup):
methods = []string{VerificationMethodSession}
default:
methods = []string{VerificationMethodOAuth}
}
......@@ -203,22 +249,19 @@ func GetVerificationRequirements(identity AuthIdentity, scope string) (*Verifica
TwoFALocked: twoFA != nil && twoFA.IsLocked(), HasPasskey: err == nil,
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)
if err != nil {
return nil, err
}
requirements := &VerificationRequirements{Scope: scope, Methods: methods, OAuthProviders: []VerificationOAuthProvider{}, PasswordEncryptionEnabled: common.PasswordLoginEncryptionEnabled}
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 {
continue
}
......@@ -389,9 +432,6 @@ func VerifySecurityInput(identity AuthIdentity, input VerificationInput) (*Secur
return nil, err
}
switch input.Method {
case VerificationMethodSession:
// The policy above permits only first enrollment for a WeChat-only
// account. CompleteSecurityVerification rechecks its live session.
case VerificationMethodPassword:
password := input.Password
if common.PasswordLoginEncryptionEnabled {
......
......@@ -171,7 +171,8 @@ export async function createOAuthAuthorization(
provider: string,
intent: 'login' | 'bind' | 'verify',
operation?: VerificationOperation,
signal?: AbortSignal
signal?: AbortSignal,
proofToken?: string
): Promise<{ state: string; authorizationUrl?: string }> {
const aff = intent === 'login' ? getAffiliateCode() : ''
const res = await api.post(
......@@ -185,6 +186,8 @@ export async function createOAuthAuthorization(
},
{
skipAuthRefresh: intent === 'login',
...(proofToken ? { headers: { 'X-Security-Proof': proofToken } } : {}),
singleUseAuthorization: intent === 'bind',
signal,
skipBusinessError: true,
skipErrorHandler: true,
......@@ -259,14 +262,21 @@ export async function sendEmailVerification(
return res.data
}
// Bind email to OAuth account
// Confirm an authenticated, server-owned email binding flow.
export async function bindEmail(
email: string,
code: string
flowToken: string,
newCode: string,
oldCode = '',
signal?: AbortSignal
): Promise<ApiResponse> {
const res = await api.post('/api/oauth/email/bind', {
email,
code,
})
const res = await api.post(
'/api/oauth/email/bind',
{
flow_token: flowToken,
new_code: newCode,
old_code: oldCode,
},
{ singleUseAuthorization: true, signal }
)
return res.data
}
......@@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
import { accountPasswordSchema } from '@/lib/password-policy'
// ============================================================================
// Form Schemas
// ============================================================================
......@@ -31,11 +33,7 @@ export const registerFormSchema = z
.object({
username: z.string().min(1, 'Please enter your username'),
email: z.string().optional(),
password: z
.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'),
password: accountPasswordSchema,
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
......
......@@ -45,7 +45,7 @@ export async function encryptPassword(
): Promise<EncryptedPassword> {
try {
const key = await getPasswordEncryptionKey()
const ciphertext = await rsaOaepEncrypt(password, key.public_key)
const ciphertext = await rsaOaepEncrypt(password, key.public_key, key.kid)
return {
password_encrypted: ciphertext,
encryption_key_id: key.kid,
......@@ -77,7 +77,8 @@ async function getPasswordEncryptionKey(): Promise<PasswordEncryptionKey> {
async function rsaOaepEncrypt(
password: string,
publicKeyPEM: string
publicKeyPEM: string,
keyId: string
): Promise<string> {
if (typeof globalThis.crypto?.subtle !== 'undefined') {
try {
......@@ -88,10 +89,48 @@ async function rsaOaepEncrypt(
false,
['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(
{ name: 'RSA-OAEP' },
publicKey,
new TextEncoder().encode(password)
plaintext
)
return arrayBufferToBase64(ciphertext)
} catch {
......@@ -104,11 +143,33 @@ async function rsaOaepEncrypt(
// forge keeps the normal HTTPS bundle small while supporting HTTP intranets.
const forge = await import('node-forge')
const publicKey = forge.pki.publicKeyFromPem(publicKeyPEM)
const ciphertext = publicKey.encrypt(
forge.util.encodeUtf8(password),
'RSA-OAEP',
{ md: forge.md.sha256.create() }
)
const plaintext = forge.util.encodeUtf8(password)
if (plaintext.length > publicKey.n.bitLength() / 8 - 66) {
const secret = forge.random.getBytesSync(32)
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)
}
......
......@@ -16,6 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
createDecipheriv,
generateKeyPairSync,
privateDecrypt,
webcrypto,
} from 'node:crypto'
import { waitFor } from '@testing-library/react'
import { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { afterEach, expect, it, vi } from 'vitest'
......@@ -26,6 +33,10 @@ import { useAuthStore, type AuthBundle } from '@/stores/auth-store'
import { createOAuthFlow } from '../../api'
import { OAUTH_POPUP_CALLBACK_MESSAGE } from '../../constants'
import {
clearPasswordEncryptionCache,
encryptPassword,
} from '../../lib/password-encryption'
import { checkVerificationMethods, verify } from '../api'
import type { SecurityProof } from '../types'
......@@ -166,6 +177,7 @@ function mockRefreshResponse(bundle: AuthBundle, onRequest?: () => void) {
}
afterEach(() => {
clearPasswordEncryptionCache()
vi.restoreAllMocks()
vi.unstubAllGlobals()
api.defaults.adapter = originalAdapter
......@@ -173,6 +185,65 @@ afterEach(() => {
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)(
'never replays a %s request after a 401 response',
async (kind) => {
......
......@@ -110,6 +110,8 @@ interface PendingVerification {
request: RequestVerificationOptions
controller: AbortController
resolve: (proof: SecurityProof | null) => void
reject: (error: unknown) => void
initialPassword?: string
submitting: boolean
}
......@@ -121,6 +123,7 @@ export function useSecureVerification() {
const current = pending.current
pending.current = null
current?.controller.abort()
if (current) current.initialPassword = undefined
current?.resolve(null)
dispatch({ type: 'reset' })
}, [])
......@@ -135,6 +138,33 @@ export function useSecureVerification() {
current.controller.signal
)
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 (
requirements.methods.length === 1 &&
requirements.methods[0].method === 'session' &&
......@@ -166,12 +196,17 @@ export function useSecureVerification() {
}, [])
const requestVerification = useCallback(
(request: RequestVerificationOptions): Promise<SecurityProof | null> => {
(
request: RequestVerificationOptions,
initialPassword?: string
): Promise<SecurityProof | null> => {
if (pending.current) return Promise.resolve(null)
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const current: PendingVerification = {
request: structuredClone(request),
resolve,
reject,
initialPassword,
controller: new AbortController(),
submitting: false,
}
......
......@@ -29,11 +29,23 @@ export type SecurityProofScope =
| '2fa.setup'
| 'access_token.generate'
| 'access_token.revoke'
| 'account.binding.bind'
| 'account.binding.unbind'
| 'account.password.set'
| 'account.password.change'
export type VerificationOperation =
| { 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>
}
......
......@@ -271,7 +271,7 @@ export function SignUpForm({
<FormLabel>{t('Password')}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t('Enter password (8-20 characters)')}
placeholder={t('Enter password (8–128 characters)')}
{...field}
/>
</FormControl>
......
......@@ -54,8 +54,9 @@ export interface EmailVerificationPayload {
}
export interface BindEmailPayload {
email: string
code: string
flow_token: string
new_code: string
old_code?: string
}
// ============================================================================
......
......@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
import type { CustomOAuthBinding } from '@/lib/oauth'
import { authRequestOptions, authResult } from '@/lib/secure-verification'
import type { LoginSession } from '@/stores/auth-store'
import { normalizeUserSettings } from './lib/user-settings'
......@@ -29,6 +30,8 @@ import type {
DeleteAccountRequest,
CheckinStatusResponse,
CheckinResponse,
AccountSecurityResult,
EmailBindingFlow,
} from './types'
// ============================================================================
......@@ -55,6 +58,22 @@ export async function updateUserProfile(
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
*/
......@@ -112,27 +131,81 @@ export async function sendEmailVerification(
/**
* Bind email account
*/
export async function bindEmail(
export function startEmailBinding(
email: string,
code: string
): Promise<ApiResponse> {
const res = await api.post('/api/oauth/email/bind', {
email,
code,
})
return res.data
proofToken: string,
signal: AbortSignal
): Promise<EmailBindingFlow> {
return authResult(
api.post(
'/api/oauth/email/bind/start',
{ 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
*/
export async function bindWeChat(code: string): Promise<ApiResponse> {
const res = await api.post(
export function bindWeChat(
code: string,
proofToken: string,
signal: AbortSignal
): Promise<AccountSecurityResult> {
return authResult(
api.post(
'/api/oauth/wechat/bind',
{ code },
{ skipBusinessError: true, skipErrorHandler: true }
{
...authRequestOptions,
headers: { 'X-Security-Proof': proofToken },
singleUseAuthorization: true,
signal,
}
)
)
return res.data
}
export interface TelegramBindFlow {
......@@ -184,11 +257,19 @@ export async function getSelfOAuthBindings(): Promise<
/**
* Unbind a custom OAuth provider for current user
*/
export async function unbindCustomOAuth(
providerId: number
): Promise<ApiResponse> {
const res = await api.delete(`/api/user/oauth/bindings/${providerId}`)
return res.data
export function unbindCustomOAuth(
providerId: number,
proofToken: string,
signal: AbortSignal
): 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> {
* User profile data
*/
export interface UserProfile {
has_password?: boolean
permissions?: UserPermissions
/** User ID */
id: number
......@@ -132,6 +133,19 @@ export interface UpdateUserRequest {
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
*/
......
......@@ -123,15 +123,23 @@ it('refreshes Telegram bindings from the server result after the callback popup
postMessage: vi.fn(),
}
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
vi.spyOn(api, 'post').mockResolvedValue({
vi.spyOn(api, 'post').mockImplementation(async (url) => ({
data: {
success: true,
data: {
data:
url === '/api/verify'
? {
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: {
data: { success: boolean; data: { action: string } }
}) => void
......@@ -140,8 +148,21 @@ it('refreshes Telegram bindings from the server result after the callback popup
}>((done) => {
resolve = done
})
const get = vi.spyOn(api, 'get').mockImplementation((url) =>
url === '/api/status'
const get = vi.spyOn(api, 'get').mockImplementation((url) => {
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({
data: {
success: true,
......@@ -149,7 +170,7 @@ it('refreshes Telegram bindings from the server result after the callback popup
},
})
: response
)
})
const onUpdate = vi.fn()
const user = userEvent.setup()
const client = new QueryClient({
......@@ -166,6 +187,20 @@ it('refreshes Telegram bindings from the server result after the callback popup
const telegram = (await screen.findByText('Telegram')).closest('li')
if (!telegram) throw new Error('Telegram binding entry is missing')
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(() =>
expect(popup.location.replace).toHaveBeenCalledWith(
'https://oauth.telegram.org/auth?server=pkce'
......
......@@ -30,6 +30,8 @@ import { DeleteAccountDialog } from './dialogs/delete-account-dialog'
type AccountActionCardProps = {
action: 'password' | 'delete'
username: string
hasPassword?: boolean
onUpdate?: () => void
}
export function AccountActionCard(props: AccountActionCardProps) {
......@@ -37,8 +39,14 @@ export function AccountActionCard(props: AccountActionCardProps) {
const [open, setOpen] = useState(false)
const actions = {
password: {
title: t('Change Password'),
description: t('Update your password to keep your account secure'),
title: t(
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,
},
delete: {
......@@ -79,6 +87,8 @@ export function AccountActionCard(props: AccountActionCardProps) {
open={open}
onOpenChange={setOpen}
username={props.username}
hasPassword={props.hasPassword}
onSuccess={props.onUpdate}
/>
)}
{props.action === 'delete' && (
......
......@@ -16,17 +16,38 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 { toast } from 'sonner'
import { z } from 'zod'
import { Dialog } from '@/components/dialog'
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 { Spinner } from '@/components/ui/spinner'
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
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 {
open: boolean
qrCodeUrl: string
......@@ -36,56 +57,59 @@ interface WeChatBindDialogProps {
export function WeChatBindDialog(props: WeChatBindDialogProps) {
const { t } = useTranslation()
const [verificationCode, setVerificationCode] = useState('')
const [submitting, setSubmitting] = useState(false)
const security = useAccountSecurity()
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) => {
if (submitting) return
if (!open) setVerificationCode('')
props.onOpenChange(open)
if (!open) {
security.cancel()
form.reset({ code: '' })
}
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const code = verificationCode.trim()
if (!code || submitting) return
setSubmitting(true)
try {
const response = await bindWeChat(code)
if (!response.success) {
toast.error(t('Request failed'))
return
props.onOpenChange(open)
}
const submit = async (values: z.infer<typeof wechatBindingSchema>) => {
const result = await security.run(async (signal) => {
const proof = await security.verify(
{
scope: 'account.binding.bind',
context: { provider: 'wechat', code: values.code },
},
signal
)
return bindWeChat(values.code, proof, signal)
})
if (!result) return
toast.success(t('Binding successful!'))
setVerificationCode('')
props.onOpenChange(false)
handleOpenChange(false)
props.onSuccess()
} catch {
toast.error(t('Request failed'))
} finally {
setSubmitting(false)
}
}
return (
<>
<Dialog
open={props.open}
open={props.open && !security.showVerification}
onOpenChange={handleOpenChange}
title={t('Bind WeChat Account')}
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
contentClassName='max-w-sm'
contentHeight='auto'
bodyClassName='space-y-4'
title={t('Bind WeChat')}
contentClassName='sm:max-w-md'
footer={
<>
<Button
type='button'
variant='outline'
disabled={submitting}
onClick={() => handleOpenChange(false)}
>
{t('Cancel')}
......@@ -93,16 +117,20 @@ export function WeChatBindDialog(props: WeChatBindDialogProps) {
<Button
type='submit'
form='wechat-bind-form'
disabled={submitting || !verificationCode.trim()}
disabled={security.pending}
>
{submitting && <Spinner data-icon='inline-start' />}
{security.pending && <Spinner data-icon='inline-start' />}
{t('Bind')}
</Button>
</>
}
>
<form id='wechat-bind-form' onSubmit={handleSubmit}>
<FieldGroup>
<Form {...form}>
<form
id='wechat-bind-form'
onSubmit={form.handleSubmit(submit)}
className='space-y-4'
>
{props.qrCodeUrl ? (
<div className='flex justify-center'>
<img
......@@ -116,23 +144,31 @@ export function WeChatBindDialog(props: WeChatBindDialogProps) {
{t('QR code is not configured. Please contact support.')}
</p>
)}
<Field data-disabled={submitting}>
<FieldLabel htmlFor='wechat-bind-code'>
{t('Verification code')}
</FieldLabel>
<FormField
control={form.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Verification code')}</FormLabel>
<FormControl>
<Input
id='wechat-bind-code'
value={verificationCode}
onChange={(event) => setVerificationCode(event.target.value)}
{...field}
placeholder={t('Enter the verification code')}
autoComplete='one-time-code'
disabled={submitting}
disabled={security.pending}
autoFocus
/>
</Field>
</FieldGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</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() {
<h3 id='security-authentication' className='text-sm font-semibold'>
{t('Login & Authentication')}
</h3>
<AccountActionCard action='password' username={profile.username} />
<AccountActionCard
action='password'
username={profile.username}
hasPassword={profile.has_password}
onUpdate={refreshProfile}
/>
<TitledCard
title={t('Account Bindings')}
icon={<Link2 className='size-4' />}
......
......@@ -37,6 +37,7 @@ import {
import { Form } from '@/components/ui/form'
import { Skeleton } from '@/components/ui/skeleton'
import { useSystemConfig } from '@/hooks/use-system-config'
import { accountPasswordSchema } from '@/lib/password-policy'
import { cn } from '@/lib/utils'
import { buildSetupPayload, getSetupStatus, submitSetup } from './api'
......@@ -208,8 +209,8 @@ export function SetupWizard() {
if (setupStatus?.root_init) return true
const username = form.getValues('username')?.trim()
const password = form.getValues('password')?.trim()
const confirmPassword = form.getValues('confirmPassword')?.trim()
const password = form.getValues('password')
const confirmPassword = form.getValues('confirmPassword')
if (!username) {
form.setError('username', {
......@@ -220,12 +221,12 @@ export function SetupWizard() {
return false
}
if (!password || password.length < 8) {
if (!accountPasswordSchema.safeParse(password).success) {
form.setError('password', {
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
}
......@@ -328,22 +329,18 @@ export function SetupWizard() {
return (
<li
key={step.titleKey}
className={cn(
'rounded-xl border p-3',
isActive
? 'border-primary ring-primary/20 ring-2'
: isCompleted
? 'border-primary/40 bg-primary/5'
: 'border-muted bg-card'
)}
className={cn('rounded-xl border p-3', {
'border-primary ring-primary/20 ring-2': isActive,
'border-primary/40 bg-primary/5':
!isActive && isCompleted,
'border-muted bg-card': !isActive && !isCompleted,
})}
>
<div className='flex items-start gap-3'>
<span
className={cn(
'flex size-6 items-center justify-center rounded-md border text-xs font-semibold',
isActive
? 'border-primary bg-primary text-primary-foreground'
: isCompleted
isActive || isCompleted
? 'border-primary bg-primary text-primary-foreground'
: 'border-muted-foreground/40 text-muted-foreground'
)}
......@@ -364,14 +361,14 @@ export function SetupWizard() {
})}
</ol>
{isLoading ? (
<LoadingState message={t('Loading setup status…')} />
) : isError ? (
{isLoading && <LoadingState message={t('Loading setup status…')} />}
{!isLoading && isError && (
<ErrorState
title={t('We could not load the setup status.')}
onRetry={() => refetch()}
/>
) : (
)}
{!isLoading && !isError && (
<Form {...form}>
<form
className='space-y-6'
......
......@@ -396,6 +396,11 @@ const AUDIT_TEMPLATES: Record<string, string> = {
'user.2fa_disable_self': 'Disabled two-factor authentication',
'user.2fa_backup_codes': 'Regenerated two-factor backup codes',
'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}}',
// User management
......
......@@ -71,6 +71,7 @@ import {
} from '@/lib/admin-permissions'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
import { accountPasswordSchema } from '@/lib/password-policy'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
......@@ -159,12 +160,11 @@ export function UsersMutateDrawer({
const targetIsAdmin = (selectedRole ?? currentRow?.role ?? 0) >= ROLE.ADMIN
const onSubmit = async (data: UserFormValues) => {
if (!isUpdate) {
const passwordLength = data.password?.length || 0
if (passwordLength < 8 || passwordLength > 20) {
if (!isUpdate || data.password) {
if (!accountPasswordSchema.safeParse(data.password ?? '').success) {
form.setError('password', {
type: 'manual',
message: t('Password must be between 8 and 20 characters'),
message: t('Password must contain between 8 and 128 characters.'),
})
return
}
......@@ -340,7 +340,7 @@ export function UsersMutateDrawer({
placeholder={
isUpdate
? t('Leave empty to keep unchanged')
: t('Enter password (8-20 characters)')
: t('Enter password (8–128 characters)')
}
/>
</FormControl>
......
......@@ -588,4 +588,43 @@ export const STATIC_I18N_KEYS = [
"Verification does not match this action's details. Please verify again.",
'The action details are invalid.',
'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
/*
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 = {
}
export interface AuthUser {
has_password?: boolean
id: number
username: 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