diff --git a/controller/misc.go b/controller/misc.go index eada4909..fb202987 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -2,12 +2,14 @@ package controller import ( "encoding/json" + "errors" "fmt" "net/http" "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" @@ -232,12 +234,9 @@ func GetHomePageContent(c *gin.Context) { } func SendEmailVerification(c *gin.Context) { - email := c.Query("email") + email := model.NormalizeEmail(c.Query("email")) if err := common.Validate.Var(email, "required,email"); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } parts := strings.Split(email, "@") @@ -278,10 +277,7 @@ func SendEmailVerification(c *gin.Context) { } if model.IsEmailAlreadyTaken(email) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "邮箱地址已被占用", - }) + common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) return } code := common.GenerateVerificationCode(6) @@ -303,15 +299,12 @@ func SendEmailVerification(c *gin.Context) { } func SendPasswordResetEmail(c *gin.Context) { - email := c.Query("email") + email := model.NormalizeEmail(c.Query("email")) if err := common.Validate.Var(email, "required,email"); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } - if model.IsEmailAlreadyTaken(email) { + if _, err := model.GetUniqueUserByEmail(email); err == nil { code := common.GenerateVerificationCode(0) common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose) link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code) @@ -324,6 +317,8 @@ func SendPasswordResetEmail(c *gin.Context) { if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email to %s: %s", email, err.Error())) } + } else if err != nil && !errors.Is(err, model.ErrEmailNotFound) { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("skip password reset email for %s: %s", email, err.Error())) } c.JSON(http.StatusOK, gin.H{ "success": true, @@ -339,23 +334,26 @@ type PasswordResetRequest struct { func ResetPassword(c *gin.Context) { var req PasswordResetRequest err := json.NewDecoder(c.Request.Body).Decode(&req) + if err != nil { + common.ApiError(c, err) + return + } + req.Email = model.NormalizeEmail(req.Email) if req.Email == "" || req.Token == "" { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "无效的参数", - }) + common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "重置链接非法或已过期", - }) + common.ApiErrorI18n(c, i18n.MsgUserPasswordResetLinkInvalid) return } password := common.GenerateVerificationCode(12) err = model.ResetUserPasswordByEmail(req.Email, password) if err != nil { + if errors.Is(err, model.ErrEmailNotFound) || errors.Is(err, model.ErrEmailAmbiguous) { + common.ApiErrorI18n(c, i18n.MsgUserPasswordResetLinkInvalid) + return + } common.ApiError(c, err) return } diff --git a/controller/oauth.go b/controller/oauth.go index 9951f22b..9ada6ddd 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -1,6 +1,7 @@ package controller import ( + "errors" "fmt" "net/http" "strconv" @@ -106,11 +107,17 @@ func HandleOAuth(c *gin.Context) { // 7. Find or create user user, err := findOrCreateOAuthUser(c, provider, oauthUser, session) if err != nil { + if errors.Is(err, model.ErrEmailAlreadyTaken) { + common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) + return + } switch err.(type) { case *OAuthUserDeletedError: common.ApiErrorI18n(c, i18n.MsgOAuthUserDeleted) case *OAuthRegistrationDisabledError: common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled) + case *OAuthEmailAlreadyTakenError: + common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) default: common.ApiError(c, err) } @@ -257,7 +264,13 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o user.DisplayName = provider.GetName() + " User" } if oauthUser.Email != "" { - user.Email = oauthUser.Email + user.Email = model.NormalizeEmail(oauthUser.Email) + if err := model.EnsureEmailAvailable(user.Email, 0); err != nil { + if errors.Is(err, model.ErrEmailAlreadyTaken) { + return nil, &OAuthEmailAlreadyTakenError{} + } + return nil, err + } } user.Role = common.RoleCommonUser user.Status = common.UserStatusEnabled @@ -343,6 +356,12 @@ func (e *OAuthRegistrationDisabledError) Error() string { return "registration is disabled" } +type OAuthEmailAlreadyTakenError struct{} + +func (e *OAuthEmailAlreadyTakenError) Error() string { + return "email is already in use" +} + // handleOAuthError handles OAuth errors and returns translated message func handleOAuthError(c *gin.Context, err error) { switch e := err.(type) { diff --git a/controller/user.go b/controller/user.go index 596f8ff8..ff2801d2 100644 --- a/controller/user.go +++ b/controller/user.go @@ -32,6 +32,11 @@ type LoginRequest struct { Password string `json:"password"` } +var ( + errUserPasswordUnset = errors.New("user password is not set") + errOriginalPasswordFail = errors.New("original password is incorrect") +) + func Login(c *gin.Context) { if !common.PasswordLoginEnabled { common.ApiErrorI18n(c, i18n.MsgUserPasswordLoginDisabled) @@ -191,6 +196,7 @@ func Register(c *gin.Context) { return } user.Username = strings.TrimSpace(user.Username) + user.Email = model.NormalizeEmail(user.Email) if user.Username == "" { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return @@ -208,8 +214,20 @@ func Register(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError) return } + if err := model.EnsureEmailAvailable(user.Email, 0); err != nil { + if errors.Is(err, model.ErrEmailAlreadyTaken) { + common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) + return + } + common.ApiErrorI18n(c, i18n.MsgDatabaseError) + return + } } - exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email) + emailForExistCheck := "" + if common.EmailVerificationEnabled { + emailForExistCheck = user.Email + } + exist, err := model.CheckUserExistOrDeleted(user.Username, emailForExistCheck) if err != nil { common.ApiErrorI18n(c, i18n.MsgDatabaseError) common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err)) @@ -232,6 +250,10 @@ func Register(c *gin.Context) { cleanUser.Email = user.Email } if err := cleanUser.Insert(inviterId); err != nil { + if errors.Is(err, model.ErrEmailAlreadyTaken) { + common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) + return + } common.ApiError(c, err) return } @@ -831,6 +853,14 @@ func UpdateSelf(c *gin.Context) { } updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id) if err != nil { + if errors.Is(err, errUserPasswordUnset) { + common.ApiErrorI18n(c, i18n.MsgUserPasswordUnset) + return + } + if errors.Is(err, errOriginalPasswordFail) { + common.ApiErrorI18n(c, i18n.MsgUserOriginalPasswordError) + return + } common.ApiError(c, err) return } @@ -847,6 +877,9 @@ func UpdateSelf(c *gin.Context) { } 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 { @@ -854,12 +887,12 @@ func checkUpdatePassword(originalPassword string, newPassword string, userId int } // 密码不为空,需要验证原密码 - // 支持第一次账号绑定时原密码为空的情况 - if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) && currentUser.Password != "" { - err = fmt.Errorf("原密码错误") + if currentUser.Password == "" { + err = errUserPasswordUnset return } - if newPassword == "" { + if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) { + err = errOriginalPasswordFail return } updatePassword = true @@ -1181,6 +1214,7 @@ func EmailBind(c *gin.Context) { return } email := req.Email + email = model.NormalizeEmail(email) code := req.Code if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) { common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError) @@ -1196,10 +1230,11 @@ func EmailBind(c *gin.Context) { common.ApiError(c, err) return } - user.Email = email - // no need to check if this email already taken, because we have used verification code to check it - err = user.Update(false) - if err != nil { + 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 } diff --git a/i18n/keys.go b/i18n/keys.go index 6cf5c1bd..8e9a4b56 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -86,6 +86,9 @@ const ( MsgUserRequire2FA = "user.require_2fa" MsgUserEmailVerificationRequired = "user.email_verification_required" MsgUserVerificationCodeError = "user.verification_code_error" + MsgUserEmailAlreadyTaken = "user.email_already_taken" + MsgUserPasswordUnset = "user.password_unset" + MsgUserPasswordResetLinkInvalid = "user.password_reset_link_invalid" MsgUserInputInvalid = "user.input_invalid" MsgUserNoPermissionSameLevel = "user.no_permission_same_level" MsgUserNoPermissionHigherLevel = "user.no_permission_higher_level" diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index 198aa274..3f1fd03c 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -74,6 +74,9 @@ user.session_save_failed: "Failed to save session, please try again" user.require_2fa: "Please enter two-factor authentication code" user.email_verification_required: "Email verification is enabled, please enter email address and verification code" user.verification_code_error: "Verification code is incorrect or has expired" +user.email_already_taken: "Email address is already in use" +user.password_unset: "This account has no password set. Please use password reset or contact an administrator to reset it." +user.password_reset_link_invalid: "Password reset link is invalid or has expired" user.input_invalid: "Invalid input {{.Error}}" user.no_permission_same_level: "No permission to access users of same or higher level" user.no_permission_higher_level: "No permission to update users of same or higher permission level" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index 45a10a58..fe982e59 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -75,6 +75,9 @@ user.session_save_failed: "无法保存会话信息,请重试" user.require_2fa: "请输入两步验证码" user.email_verification_required: "管理员开启了邮箱验证,请输入邮箱地址和验证码" user.verification_code_error: "验证码错误或已过期" +user.email_already_taken: "邮箱地址已被占用" +user.password_unset: "当前账号未设置密码,请使用密码重置或联系管理员重置密码" +user.password_reset_link_invalid: "重置链接非法或已过期" user.input_invalid: "输入不合法 {{.Error}}" user.no_permission_same_level: "无权获取同级或更高等级用户的信息" user.no_permission_higher_level: "无权更新同权限等级或更高权限等级的用户信息" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index ad5a6eae..27759d07 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -75,6 +75,9 @@ user.session_save_failed: "無法保存對話,請重試" user.require_2fa: "請輸入雙重驗證碼" user.email_verification_required: "管理員開啟了信箱驗證,請輸入信箱位址和驗證碼" user.verification_code_error: "驗證碼錯誤或已過期" +user.email_already_taken: "信箱位址已被占用" +user.password_unset: "目前帳號未設定密碼,請使用密碼重置或聯繫管理員重置密碼" +user.password_reset_link_invalid: "重置連結非法或已過期" user.input_invalid: "輸入不合法 {{.Error}}" user.no_permission_same_level: "無權獲取同級或更高等級使用者的資訊" user.no_permission_higher_level: "無權更新同權限等級或更高權限等級的使用者資訊" diff --git a/model/errors.go b/model/errors.go index a942a5bc..7f53a03d 100644 --- a/model/errors.go +++ b/model/errors.go @@ -11,6 +11,9 @@ var ( var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrUserEmptyCredentials = errors.New("empty credentials") + ErrEmailAlreadyTaken = errors.New("email already taken") + ErrEmailNotFound = errors.New("email not found") + ErrEmailAmbiguous = errors.New("email matches multiple users") ) // Token auth errors diff --git a/model/user.go b/model/user.go index 2f438e87..c0239317 100644 --- a/model/user.go +++ b/model/user.go @@ -183,10 +183,11 @@ func CheckUserExistOrDeleted(username string, email string) (bool, error) { // err := DB.Unscoped().First(&user, "username = ? or email = ?", username, email).Error // check email if empty var err error + email = NormalizeEmail(email) if email == "" { err = DB.Unscoped().First(&user, "username = ?", username).Error } else { - err = DB.Unscoped().First(&user, "username = ? or email = ?", username, email).Error + err = DB.Unscoped().First(&user, "username = ? or LOWER(email) = ?", username, email).Error } if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -200,6 +201,85 @@ func CheckUserExistOrDeleted(username string, email string) (bool, error) { return true, nil } +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func emailQuery(tx *gorm.DB, email string) *gorm.DB { + if tx == nil { + tx = DB + } + return tx.Unscoped().Model(&User{}).Where("LOWER(email) = ?", NormalizeEmail(email)) +} + +func CountUsersByEmail(email string) (int64, error) { + email = NormalizeEmail(email) + if email == "" { + return 0, nil + } + var count int64 + err := emailQuery(DB, email).Count(&count).Error + return count, err +} + +func IsEmailAvailable(email string, excludeUserID int) (bool, error) { + email = NormalizeEmail(email) + if email == "" { + return true, nil + } + query := emailQuery(DB, email) + if excludeUserID > 0 { + query = query.Where("id <> ?", excludeUserID) + } + var count int64 + if err := query.Count(&count).Error; err != nil { + return false, err + } + return count == 0, nil +} + +func EnsureEmailAvailable(email string, excludeUserID int) error { + available, err := IsEmailAvailable(email, excludeUserID) + if err != nil { + return err + } + if !available { + return ErrEmailAlreadyTaken + } + return nil +} + +// withNormalizedEmailLock serializes concurrent writers that target the same +// normalized email inside tx, so a "check then write" sequence cannot be raced +// by two transactions. It must be called inside an active transaction; the lock +// is scoped to that transaction and released on commit/rollback. +// +// - PostgreSQL: transaction-level advisory lock keyed by the normalized email. +// - MySQL (default REPEATABLE READ): a locking read that takes a next-key/gap +// lock on the email index, blocking concurrent inserts of the same value. +// - SQLite: no explicit lock; the single-writer model already serializes the +// write, so a racing second write fails instead of duplicating. +// +// 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 { + email = NormalizeEmail(email) + if email == "" { + return fn(tx) + } + switch { + case common.UsingMainDatabase(common.DatabaseTypePostgreSQL): + if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", email).Error; err != nil { + return err + } + case common.UsingMainDatabase(common.DatabaseTypeMySQL): + var ids []int + if err := tx.Raw("SELECT id FROM users WHERE email = ? FOR UPDATE", email).Scan(&ids).Error; err != nil { + return err + } + } + return fn(tx) +} + func GetMaxUserId() int { var user User DB.Unscoped().Last(&user) @@ -399,28 +479,77 @@ func (user *User) TransferAffQuotaToQuota(quota int) error { return tx.Commit().Error } -func (user *User) Insert(inviterId int) error { +func (user *User) prepareForInsert(tx *gorm.DB) error { + user.Email = NormalizeEmail(user.Email) + if err := ensureEmailAvailableWithTx(tx, user.Email, 0); err != nil { + return err + } + if user.Password == "" { + return nil + } var err error - if user.Password != "" { - user.Password, err = common.Password2Hash(user.Password) - if err != nil { - return err - } - } - user.Quota = common.QuotaForNewUser - //user.SetAccessToken(common.GetUUID()) - user.AffCode = common.GetRandomString(4) + user.Password, err = common.Password2Hash(user.Password) + return err +} - // 初始化用户设置,包括默认的边栏配置 - if user.Setting == "" { - defaultSetting := dto.UserSetting{} - // 这里暂时不设置SidebarModules,因为需要在用户创建后根据角色设置 - user.SetSetting(defaultSetting) +// BindEmailToUser atomically checks email availability and assigns it to the +// user, serializing concurrent binds of the same email so two accounts cannot +// end up sharing one address. The email is normalized before check and store. +func BindEmailToUser(user *User, email string) error { + email = NormalizeEmail(email) + if err := DB.Transaction(func(tx *gorm.DB) error { + return withNormalizedEmailLock(tx, email, func(tx *gorm.DB) error { + if err := ensureEmailAvailableWithTx(tx, email, user.Id); err != nil { + return err + } + user.Email = email + return user.UpdateWithTx(tx, false) + }) + }); err != nil { + return err } + return updateUserCache(*user) +} - result := DB.Create(user) - if result.Error != nil { - return result.Error +func ensureEmailAvailableWithTx(tx *gorm.DB, email string, excludeUserID int) error { + email = NormalizeEmail(email) + if email == "" { + return nil + } + query := emailQuery(tx, email) + if excludeUserID > 0 { + query = query.Where("id <> ?", excludeUserID) + } + var count int64 + if err := query.Count(&count).Error; err != nil { + return err + } + if count > 0 { + return ErrEmailAlreadyTaken + } + return nil +} + +func (user *User) Insert(inviterId int) error { + if err := DB.Transaction(func(tx *gorm.DB) error { + return withNormalizedEmailLock(tx, user.Email, func(tx *gorm.DB) error { + if err := user.prepareForInsert(tx); err != nil { + return err + } + user.Quota = common.QuotaForNewUser + user.AffCode = common.GetRandomString(4) + + // 初始化用户设置,包括默认的边栏配置 + if user.Setting == "" { + defaultSetting := dto.UserSetting{} + // 这里暂时不设置SidebarModules,因为需要在用户创建后根据角色设置 + user.SetSetting(defaultSetting) + } + + return tx.Create(user).Error + }) + }); err != nil { + return err } user.finishInsert(inviterId) @@ -467,28 +596,21 @@ func (user *User) FinishInsert(inviterId int) { // This is used for OAuth registration where user creation and binding need to be atomic. // Post-creation tasks (sidebar config, logs, inviter rewards) are handled after the transaction commits. func (user *User) InsertWithTx(tx *gorm.DB, inviterId int) error { - var err error - if user.Password != "" { - user.Password, err = common.Password2Hash(user.Password) - if err != nil { + return withNormalizedEmailLock(tx, user.Email, func(tx *gorm.DB) error { + if err := user.prepareForInsert(tx); err != nil { return err } - } - user.Quota = common.QuotaForNewUser - user.AffCode = common.GetRandomString(4) + user.Quota = common.QuotaForNewUser + user.AffCode = common.GetRandomString(4) - // 初始化用户设置 - if user.Setting == "" { - defaultSetting := dto.UserSetting{} - user.SetSetting(defaultSetting) - } + // 初始化用户设置 + if user.Setting == "" { + defaultSetting := dto.UserSetting{} + user.SetSetting(defaultSetting) + } - result := tx.Create(user) - if result.Error != nil { - return result.Error - } - - return nil + return tx.Create(user).Error + }) } // FinalizeOAuthUserCreation performs post-transaction tasks for OAuth user creation. @@ -658,6 +780,9 @@ func (user *User) ValidateAndFill() (err error) { } return fmt.Errorf("%w: %v", ErrDatabase, err) } + if user.Password == "" { + return ErrInvalidCredentials + } okay := common.ValidatePasswordAndHash(password, user.Password) if !okay || user.Status != common.UserStatusEnabled { return ErrInvalidCredentials @@ -733,7 +858,27 @@ func (user *User) FillUserByTelegramId() error { } func IsEmailAlreadyTaken(email string) bool { - return DB.Unscoped().Where("email = ?", email).Find(&User{}).RowsAffected == 1 + count, err := CountUsersByEmail(email) + return err == nil && count > 0 +} + +func GetUniqueUserByEmail(email string) (*User, error) { + email = NormalizeEmail(email) + if email == "" { + return nil, ErrEmailNotFound + } + var users []User + if err := DB.Where("LOWER(email) = ?", email).Limit(2).Find(&users).Error; err != nil { + return nil, err + } + switch len(users) { + case 0: + return nil, ErrEmailNotFound + case 1: + return &users[0], nil + default: + return nil, ErrEmailAmbiguous + } } func IsWeChatIdAlreadyTaken(wechatId string) bool { @@ -760,11 +905,15 @@ func ResetUserPasswordByEmail(email string, password string) error { if email == "" || password == "" { return errors.New("邮箱地址或密码为空!") } + user, err := GetUniqueUserByEmail(email) + if err != nil { + return err + } hashedPassword, err := common.Password2Hash(password) if err != nil { return err } - err = DB.Model(&User{}).Where("email = ?", email).Update("password", hashedPassword).Error + err = DB.Model(&User{}).Where("id = ?", user.Id).Update("password", hashedPassword).Error return err } @@ -781,36 +930,6 @@ func IsAdmin(userId int) bool { return user.Role >= common.RoleAdminUser } -//// IsUserEnabled checks user status from Redis first, falls back to DB if needed -//func IsUserEnabled(id int, fromDB bool) (status bool, err error) { -// defer func() { -// // Update Redis cache asynchronously on successful DB read -// if shouldUpdateRedis(fromDB, err) { -// gopool.Go(func() { -// if err := updateUserStatusCache(id, status); err != nil { -// common.SysError("failed to update user status cache: " + err.Error()) -// } -// }) -// } -// }() -// if !fromDB && common.RedisEnabled { -// // Try Redis first -// status, err := getUserStatusCache(id) -// if err == nil { -// return status == common.UserStatusEnabled, nil -// } -// // Don't return error - fall through to DB -// } -// fromDB = true -// var user User -// err = DB.Where("id = ?", id).Select("status").Find(&user).Error -// if err != nil { -// return false, err -// } -// -// return user.Status == common.UserStatusEnabled, nil -//} - func ValidateAccessToken(token string) (*User, error) { if token == "" { return nil, nil diff --git a/model/user_update_test.go b/model/user_update_test.go index b04f0c6e..be232693 100644 --- a/model/user_update_test.go +++ b/model/user_update_test.go @@ -1,6 +1,7 @@ package model import ( + "errors" "testing" "github.com/QuantumNous/new-api/common" @@ -90,3 +91,130 @@ func TestUpdateUserSettingOnlyUpdatesSetting(t *testing.T) { assert.Equal(t, 4, got.RequestCount) assert.Equal(t, "zh", got.GetSetting().Language) } + +func TestEnsureEmailAvailableRejectsExistingEmailCaseInsensitive(t *testing.T) { + setupUserUpdateTestState(t) + + require.NoError(t, DB.Create(&User{ + Username: "existing", + Password: "old-password", + Email: "Taken@Example.com", + Status: common.UserStatusEnabled, + }).Error) + + err := EnsureEmailAvailable(" taken@example.COM ", 0) + require.ErrorIs(t, err, ErrEmailAlreadyTaken) + + user, err := GetUniqueUserByEmail("TAKEN@example.com") + require.NoError(t, err) + assert.Equal(t, "existing", user.Username) + + require.NoError(t, EnsureEmailAvailable("taken@example.com", user.Id)) +} + +func TestInsertRejectsDuplicateEmailWithoutUniqueIndex(t *testing.T) { + setupUserUpdateTestState(t) + + require.NoError(t, DB.Create(&User{ + Username: "existing", + Password: "old-password", + Email: "taken@example.com", + Status: common.UserStatusEnabled, + }).Error) + + user := &User{ + Username: "oauth-user", + Email: "TAKEN@example.com", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + } + + err := user.Insert(0) + require.ErrorIs(t, err, ErrEmailAlreadyTaken) + + var count int64 + require.NoError(t, DB.Model(&User{}).Where("username = ?", "oauth-user").Count(&count).Error) + assert.Zero(t, count) +} + +func TestInsertKeepsBlankPasswordForPasswordlessUser(t *testing.T) { + setupUserUpdateTestState(t) + + user := &User{ + Username: "passwordless-user", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + } + + require.NoError(t, user.Insert(0)) + + var stored User + require.NoError(t, DB.Where("username = ?", user.Username).First(&stored).Error) + assert.Empty(t, stored.Password) +} + +func TestValidateAndFillRejectsPasswordlessUser(t *testing.T) { + setupUserUpdateTestState(t) + + require.NoError(t, DB.Create(&User{ + Username: "passwordless-user", + Password: "", + Status: common.UserStatusEnabled, + }).Error) + + loginUser := User{ + Username: "passwordless-user", + Password: "NewPassword123", + } + err := loginUser.ValidateAndFill() + require.ErrorIs(t, err, ErrInvalidCredentials) + + var stored User + require.NoError(t, DB.Where("username = ?", "passwordless-user").First(&stored).Error) + assert.Empty(t, stored.Password) +} + +func TestResetUserPasswordByEmailRequiresSingleActiveMatch(t *testing.T) { + setupUserUpdateTestState(t) + + require.NoError(t, DB.Create(&User{ + Username: "duplicate-1", + Password: "old-1", + Email: "legacy@example.com", + AffCode: "dupe1", + Status: common.UserStatusEnabled, + }).Error) + require.NoError(t, DB.Create(&User{ + Username: "duplicate-2", + Password: "old-2", + Email: "LEGACY@example.com", + AffCode: "dupe2", + Status: common.UserStatusEnabled, + }).Error) + + err := ResetUserPasswordByEmail("legacy@example.com", "NewPassword123") + require.ErrorIs(t, err, ErrEmailAmbiguous) + + var duplicates []User + require.NoError(t, DB.Where("LOWER(email) = ?", "legacy@example.com").Order("username asc").Find(&duplicates).Error) + require.Len(t, duplicates, 2) + assert.Equal(t, "old-1", duplicates[0].Password) + assert.Equal(t, "old-2", duplicates[1].Password) + + require.NoError(t, DB.Create(&User{ + Username: "unique", + Password: "old", + Email: "unique@example.com", + AffCode: "unique", + Status: common.UserStatusEnabled, + }).Error) + + require.NoError(t, ResetUserPasswordByEmail("UNIQUE@example.com", "NewPassword123")) + + var unique User + require.NoError(t, DB.Where("username = ?", "unique").First(&unique).Error) + assert.True(t, common.ValidatePasswordAndHash("NewPassword123", unique.Password)) + + err = ResetUserPasswordByEmail("missing@example.com", "NewPassword123") + require.True(t, errors.Is(err, ErrEmailNotFound)) +}