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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user