diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 3a9da6d..a2a4b16 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -5,6 +5,7 @@ import "time" type User struct { ID uint64 `gorm:"primaryKey" json:"id"` Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"` + PasswordHash string `gorm:"size:255;not null;default:''" json:"-"` Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"` RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"` diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go index 54d3c23..2de27f1 100644 --- a/backend/internal/modules/auth/handler.go +++ b/backend/internal/modules/auth/handler.go @@ -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 +} diff --git a/backend/internal/modules/auth/repository.go b/backend/internal/modules/auth/repository.go index 0e54994..f0d8721 100644 --- a/backend/internal/modules/auth/repository.go +++ b/backend/internal/modules/auth/repository.go @@ -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) } diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index dbed589..7945c01 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -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) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 473038c..ed426b6 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -371,12 +371,16 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { authRoutes.GET("/captcha", authHandler.Captcha) authRoutes.POST("/sms/send", authHandler.SendSMS) authRoutes.POST("/sms/login", authHandler.Login) + authRoutes.POST("/password/login", authHandler.PasswordLogin) + authRoutes.POST("/password/register", authHandler.Register) + authRoutes.POST("/password/reset", authHandler.ResetPassword) authRoutes.POST("/refresh", authHandler.Refresh) authRoutes.POST("/logout", authHandler.Logout) } api.GET("/me", requireAuth, userHandler.Me) api.PUT("/me", requireAuth, userHandler.UpdateMe) + api.PUT("/password", requireAuth, authHandler.SetPassword) listingRoutes := api.Group("/listings") { diff --git a/backend/migrations/000020_add_user_password.sql b/backend/migrations/000020_add_user_password.sql new file mode 100644 index 0000000..d958ea6 --- /dev/null +++ b/backend/migrations/000020_add_user_password.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE users +ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码哈希(bcrypt)' +AFTER phone; + +-- +goose Down + +ALTER TABLE users DROP COLUMN password_hash; diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index e591f61..241a73f 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -53,6 +53,31 @@ export async function loginWithSms(phone: string, code: string) { return data.data } +export async function loginWithPassword(phone: string, password: string) { + const { data } = await apiClient.post>('/auth/password/login', { + phone, + password, + }) + return data.data +} + +export async function registerWithPassword(phone: string, code: string, password: string) { + const { data } = await apiClient.post>('/auth/password/register', { + phone, + code, + password, + }) + return data.data +} + +export async function setPassword(password: string, oldPassword?: string) { + await apiClient.put('/password', { password, old_password: oldPassword }) +} + +export async function resetPassword(phone: string, code: string, newPassword: string) { + await apiClient.post('/auth/password/reset', { phone, code, new_password: newPassword }) +} + export async function fetchMe() { const { data } = await apiClient.get>('/me') return data.data diff --git a/frontend/src/features/auth/views/LoginView.vue b/frontend/src/features/auth/views/LoginView.vue index 9e91405..0604d85 100644 --- a/frontend/src/features/auth/views/LoginView.vue +++ b/frontend/src/features/auth/views/LoginView.vue @@ -1,8 +1,8 @@