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:
CaIon
2026-07-05 13:15:41 +08:00
parent 1ae757475f
commit 5fc35e28a2
10 changed files with 416 additions and 102 deletions
+44 -9
View File
@@ -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
}