feat(auth): 新增密码登录注册与改密功能并加固安全

- 后端:新增密码登录/注册/重置/改密接口,users 表新增 password_hash 字段
- 安全加固:注册改为冲突即失败防止"注册即改密",登录用户不存在统一返回密码错误并计失败次数防枚举,改密需校验旧密码,清理登录失败计数中的死代码
- 前端:登录页重构为"登录/注册"两个 tab,登录内可切换密码/短信方式,默认密码登录
- 个人中心新增修改密码入口(PC 弹窗 + 移动端 popup),PC 个人资料页移除买家/卖家服务面板
This commit is contained in:
yml2213
2026-07-02 19:02:49 +08:00
parent 3701ae36ab
commit 1ba7c1dea1
13 changed files with 1043 additions and 456 deletions
+198
View File
@@ -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)
}