feat(auth): 新增密码登录注册与改密功能并加固安全
- 后端:新增密码登录/注册/重置/改密接口,users 表新增 password_hash 字段 - 安全加固:注册改为冲突即失败防止"注册即改密",登录用户不存在统一返回密码错误并计失败次数防枚举,改密需校验旧密码,清理登录失败计数中的死代码 - 前端:登录页重构为"登录/注册"两个 tab,登录内可切换密码/短信方式,默认密码登录 - 个人中心新增修改密码入口(PC 弹窗 + 移动端 popup),PC 个人资料页移除买家/卖家服务面板
This commit is contained in:
@@ -29,6 +29,28 @@ type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type PasswordLoginRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=8,max=20"`
|
||||
}
|
||||
|
||||
type SetPasswordRequest struct {
|
||||
Password string `json:"password" binding:"required,min=8,max=20"`
|
||||
OldPassword string `json:"old_password"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Code string `json:"code" binding:"required,len=6"`
|
||||
Password string `json:"password" binding:"required,min=8,max=20"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Code string `json:"code" binding:"required,len=6"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8,max=20"`
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
@@ -130,6 +152,65 @@ func (h *Handler) Logout(c *gin.Context) {
|
||||
response.OK(c, gin.H{"logged_out": true})
|
||||
}
|
||||
|
||||
func (h *Handler) PasswordLogin(c *gin.Context) {
|
||||
var req PasswordLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "手机号和密码不能为空,密码长度 8-20 位")
|
||||
return
|
||||
}
|
||||
result, err := h.service.LoginWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), req.Password, c.ClientIP())
|
||||
if err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "手机号、验证码和密码不能为空,密码长度 8-20 位")
|
||||
return
|
||||
}
|
||||
result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password)
|
||||
if err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) SetPassword(c *gin.Context) {
|
||||
var req SetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "密码不能为空,长度 8-20 位")
|
||||
return
|
||||
}
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
if err := h.service.SetPassword(c.Request.Context(), userID, req.Password, req.OldPassword); err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPassword(c *gin.Context) {
|
||||
var req ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "手机号、验证码和新密码不能为空")
|
||||
return
|
||||
}
|
||||
if err := h.service.ResetPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.NewPassword); err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"reseted": true})
|
||||
}
|
||||
|
||||
func writeAuthError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
@@ -146,7 +227,28 @@ func writeAuthError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "验证码错误或已过期")
|
||||
case errors.Is(err, ErrUserDisabled):
|
||||
response.Error(c, http.StatusForbidden, "user_disabled", "用户已被冻结")
|
||||
case errors.Is(err, ErrInvalidPassword):
|
||||
response.BadRequest(c, "手机号或密码错误")
|
||||
case errors.Is(err, ErrInvalidOldPassword):
|
||||
response.BadRequest(c, "原密码错误")
|
||||
case errors.Is(err, ErrPasswordNotSet):
|
||||
response.BadRequest(c, "未设置密码,请使用短信验证码登录后设置密码")
|
||||
case errors.Is(err, ErrPasswordTooWeak):
|
||||
response.BadRequest(c, "密码长度应为 8-20 位")
|
||||
case errors.Is(err, ErrLoginLocked):
|
||||
response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请 15 分钟后再试")
|
||||
case errors.Is(err, ErrUserAlreadyExists):
|
||||
response.BadRequest(c, "该手机号已注册,请直接登录或使用短信验证码登录")
|
||||
default:
|
||||
response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
|
||||
}
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
val, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := val.(uint64)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
@@ -64,6 +64,38 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string)
|
||||
return &found, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByPhone(ctx context.Context, phone string) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := r.db.WithContext(ctx).Where("phone = ?", phone).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("password_hash", hash).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) {
|
||||
now := time.Now()
|
||||
user := model.User{
|
||||
Phone: phone,
|
||||
PasswordHash: hash,
|
||||
Nickname: "用户" + phone[len(phone)-4:],
|
||||
RealnameStatus: "unverified",
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).Create(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool {
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -25,6 +26,12 @@ var (
|
||||
ErrCodeInvalid = errors.New("sms code invalid")
|
||||
ErrSMSSendFailed = errors.New("sms send failed")
|
||||
ErrUserDisabled = errors.New("user disabled")
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
ErrInvalidOldPassword = errors.New("invalid old password")
|
||||
ErrPasswordNotSet = errors.New("password not set")
|
||||
ErrPasswordTooWeak = errors.New("password too weak")
|
||||
ErrLoginLocked = errors.New("login locked")
|
||||
ErrUserAlreadyExists = errors.New("user already exists")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,6 +39,11 @@ const (
|
||||
smsLoginHourlyLimit = 5
|
||||
smsLoginHourlyWindow = time.Hour
|
||||
smsCaptchaTTL = 3 * time.Minute
|
||||
passwordMinLength = 8
|
||||
passwordMaxLength = 20
|
||||
loginFailureTTL = 15 * time.Minute
|
||||
loginLockTTL = 15 * time.Minute
|
||||
loginMaxFailureCount = 5
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -202,3 +214,189 @@ func PublicPhone(phone string) string {
|
||||
}
|
||||
return fmt.Sprintf("%s****%s", phone[:3], phone[len(phone)-4:])
|
||||
}
|
||||
|
||||
func (s *Service) LoginWithPassword(ctx context.Context, phone, password, clientIP string) (LoginResult, error) {
|
||||
if s.redis == nil || s.users == nil {
|
||||
return LoginResult{}, ErrDependencyUnavailable
|
||||
}
|
||||
if !isPhone(phone) {
|
||||
return LoginResult{}, ErrInvalidPhone
|
||||
}
|
||||
|
||||
locked, err := s.redis.Exists(ctx, loginLockRedisKey(phone, clientIP)).Result()
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if locked > 0 {
|
||||
return LoginResult{}, ErrLoginLocked
|
||||
}
|
||||
|
||||
user, err := s.users.FindByPhone(ctx, phone)
|
||||
if IsNotFound(err) {
|
||||
// 用户不存在也计失败次数,避免该接口被用于探测手机号是否已注册
|
||||
_ = recordLoginFailure(ctx, s.redis, phone, clientIP)
|
||||
return LoginResult{}, ErrInvalidPassword
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
if user.PasswordHash == "" {
|
||||
return LoginResult{}, ErrPasswordNotSet
|
||||
}
|
||||
if checkPasswordHash(password, user.PasswordHash) != nil {
|
||||
_ = recordLoginFailure(ctx, s.redis, phone, clientIP)
|
||||
return LoginResult{}, ErrInvalidPassword
|
||||
}
|
||||
|
||||
if user.Status != "active" {
|
||||
return LoginResult{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
_ = clearLoginFailure(ctx, s.redis, phone, clientIP)
|
||||
|
||||
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
return LoginResult{User: user, Tokens: tokens}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetPassword(ctx context.Context, userID uint64, password, oldPassword string) error {
|
||||
if !isValidPassword(password) {
|
||||
return ErrPasswordTooWeak
|
||||
}
|
||||
user, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 已设置过密码时必须校验旧密码,避免会话被他人借用后直接改密锁定原号主
|
||||
if user.PasswordHash != "" {
|
||||
if checkPasswordHash(oldPassword, user.PasswordHash) != nil {
|
||||
return ErrInvalidOldPassword
|
||||
}
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.users.SetPassword(ctx, userID, string(hash))
|
||||
}
|
||||
|
||||
func (s *Service) ResetPassword(ctx context.Context, phone, code, newPassword string) error {
|
||||
if s.redis == nil || s.users == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if !isPhone(phone) {
|
||||
return ErrInvalidPhone
|
||||
}
|
||||
|
||||
stored, err := s.redis.Get(ctx, codeKey(phone)).Result()
|
||||
if errors.Is(err, redis.Nil) || stored != code {
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isValidPassword(newPassword) {
|
||||
return ErrPasswordTooWeak
|
||||
}
|
||||
|
||||
user, err := s.users.FindByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.users.SetPassword(ctx, user.ID, string(hash)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.redis.Del(ctx, codeKey(phone)).Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password string) (LoginResult, error) {
|
||||
if s.redis == nil || s.users == nil {
|
||||
return LoginResult{}, ErrDependencyUnavailable
|
||||
}
|
||||
if !isPhone(phone) {
|
||||
return LoginResult{}, ErrInvalidPhone
|
||||
}
|
||||
|
||||
stored, err := s.redis.Get(ctx, codeKey(phone)).Result()
|
||||
if errors.Is(err, redis.Nil) || stored != code {
|
||||
return LoginResult{}, ErrCodeInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if !isValidPassword(password) {
|
||||
return LoginResult{}, ErrPasswordTooWeak
|
||||
}
|
||||
|
||||
// 已注册的号不允许通过注册流程覆盖密码,避免"注册即改密"接管账号
|
||||
existing, err := s.users.FindByPhone(ctx, phone)
|
||||
if err == nil && existing != nil {
|
||||
return LoginResult{}, ErrUserAlreadyExists
|
||||
}
|
||||
if err != nil && !IsNotFound(err) {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
user, err := s.users.RegisterWithPassword(ctx, phone, string(hash))
|
||||
if err != nil {
|
||||
// 并发场景下仍可能因唯一索引冲突,统一按"已注册"处理
|
||||
return LoginResult{}, ErrUserAlreadyExists
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return LoginResult{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_ = s.redis.Del(ctx, codeKey(phone)).Err()
|
||||
return LoginResult{User: user, Tokens: tokens}, nil
|
||||
}
|
||||
|
||||
func isValidPassword(password string) bool {
|
||||
return len(password) >= passwordMinLength && len(password) <= passwordMaxLength
|
||||
}
|
||||
|
||||
func checkPasswordHash(password, hash string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
|
||||
func recordLoginFailure(ctx context.Context, rdb *redis.Client, phone, clientIP string) error {
|
||||
key := loginFailureRedisKey(phone, clientIP)
|
||||
count, err := rdb.Incr(ctx, key).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = rdb.Expire(ctx, key, loginFailureTTL).Err()
|
||||
if count >= int64(loginMaxFailureCount) {
|
||||
_ = rdb.Set(ctx, loginLockRedisKey(phone, clientIP), "1", loginLockTTL).Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearLoginFailure(ctx context.Context, rdb *redis.Client, phone, clientIP string) error {
|
||||
return rdb.Del(ctx, loginFailureRedisKey(phone, clientIP), loginLockRedisKey(phone, clientIP)).Err()
|
||||
}
|
||||
|
||||
func loginFailureRedisKey(phone, clientIP string) string {
|
||||
return fmt.Sprintf("user:login:fail:%s:%s", clientIP, phone)
|
||||
}
|
||||
|
||||
func loginLockRedisKey(phone, clientIP string) string {
|
||||
return fmt.Sprintf("user:login:lock:%s:%s", clientIP, phone)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user