fix(user): harden account email and password handling
- normalize emails (trim + lowercase) and enforce uniqueness across registration, OAuth auto-registration, and email binding - serialize concurrent writers on the same normalized email within a transaction to avoid duplicate accounts - resolve password reset to a single matching account and reject ambiguous or absent matches - require an existing password before self-service password change and reject login for accounts without a usable password
This commit is contained in:
+21
-23
@@ -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
|
||||
}
|
||||
|
||||
+20
-1
@@ -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) {
|
||||
|
||||
+44
-9
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "无权更新同权限等级或更高权限等级的用户信息"
|
||||
|
||||
@@ -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: "無權更新同權限等級或更高權限等級的使用者資訊"
|
||||
|
||||
@@ -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
|
||||
|
||||
+188
-69
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user