Files
user-system/internal/service/password_reset.go

273 lines
7.5 KiB
Go
Raw Normal View History

package service
import (
"context"
cryptorand "crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log"
"net/smtp"
"time"
"github.com/user-management-system/internal/auth"
"github.com/user-management-system/internal/cache"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/security"
)
// PasswordResetConfig controls reset-token issuance and SMTP delivery.
type PasswordResetConfig struct {
TokenTTL time.Duration
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPass string
FromEmail string
SiteURL string
PasswordMinLen int
PasswordRequireSpecial bool
PasswordRequireNumber bool
}
func DefaultPasswordResetConfig() *PasswordResetConfig {
return &PasswordResetConfig{
TokenTTL: 15 * time.Minute,
SMTPHost: "",
SMTPPort: 587,
SMTPUser: "",
SMTPPass: "",
FromEmail: "noreply@example.com",
SiteURL: "http://localhost:8080",
PasswordMinLen: 8,
PasswordRequireSpecial: false,
PasswordRequireNumber: false,
}
}
type PasswordResetService struct {
userRepo userRepositoryInterface
cache *cache.CacheManager
config *PasswordResetConfig
}
func NewPasswordResetService(
userRepo userRepositoryInterface,
cache *cache.CacheManager,
config *PasswordResetConfig,
) *PasswordResetService {
if config == nil {
config = DefaultPasswordResetConfig()
}
return &PasswordResetService{
userRepo: userRepo,
cache: cache,
config: config,
}
}
func (s *PasswordResetService) ForgotPassword(ctx context.Context, email string) error {
user, err := s.userRepo.GetByEmail(ctx, email)
if err != nil {
return nil
}
tokenBytes := make([]byte, 32)
if _, err := cryptorand.Read(tokenBytes); err != nil {
return fmt.Errorf("生成重置Token失败: %w", err)
}
resetToken := hex.EncodeToString(tokenBytes)
cacheKey := "pwd_reset:" + resetToken
ttl := s.config.TokenTTL
if err := s.cache.Set(ctx, cacheKey, user.ID, ttl, ttl); err != nil {
return fmt.Errorf("缓存重置Token失败: %w", err)
}
go s.sendResetEmail(domain.DerefStr(user.Email), user.Username, resetToken)
return nil
}
func (s *PasswordResetService) ResetPassword(ctx context.Context, token, newPassword string) error {
if token == "" || newPassword == "" {
return errors.New("参数不完整")
}
cacheKey := "pwd_reset:" + token
val, ok := s.cache.Get(ctx, cacheKey)
if !ok {
return errors.New("重置链接已失效或不存在,请重新申请")
}
userID, ok := int64Value(val)
if !ok {
return errors.New("重置Token数据异常")
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return errors.New("用户不存在")
}
if err := s.doResetPassword(ctx, user, newPassword); err != nil {
return err
}
if err := s.cache.Delete(ctx, cacheKey); err != nil {
return fmt.Errorf("清理重置Token失败: %w", err)
}
return nil
}
func (s *PasswordResetService) ValidateResetToken(ctx context.Context, token string) (bool, error) {
if token == "" {
return false, errors.New("token不能为空")
}
_, ok := s.cache.Get(ctx, "pwd_reset:"+token)
return ok, nil
}
func (s *PasswordResetService) sendResetEmail(email, username, token string) {
if s.config.SMTPHost == "" {
return
}
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.SiteURL, token)
subject := "密码重置请求"
body := fmt.Sprintf(`您好 %s
您收到此邮件是因为有人请求重置账户密码
请点击以下链接重置密码链接将在 %s 后失效
%s
如果不是您本人操作请忽略此邮件您的密码不会被修改
用户管理系统团队`, username, s.config.TokenTTL.String(), resetURL)
var authInfo smtp.Auth
if s.config.SMTPUser != "" || s.config.SMTPPass != "" {
authInfo = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, s.config.SMTPHost)
}
msg := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
s.config.FromEmail,
email,
subject,
body,
)
addr := fmt.Sprintf("%s:%d", s.config.SMTPHost, s.config.SMTPPort)
if err := smtp.SendMail(addr, authInfo, s.config.FromEmail, []string{email}, []byte(msg)); err != nil {
log.Printf("password-reset-email: send failed to=%s err=%v", email, err)
}
}
// ForgotPasswordByPhoneRequest 短信密码重置请求
type ForgotPasswordByPhoneRequest struct {
Phone string `json:"phone" binding:"required"`
}
// ForgotPasswordByPhone 通过手机验证码重置密码 - 发送验证码
func (s *PasswordResetService) ForgotPasswordByPhone(ctx context.Context, phone string) (string, error) {
user, err := s.userRepo.GetByPhone(ctx, phone)
if err != nil {
return "", nil // 用户不存在不提示,防止用户枚举
}
// 生成6位数字验证码
code, err := generateSMSCode()
if err != nil {
return "", fmt.Errorf("生成验证码失败: %w", err)
}
// 存储验证码关联用户ID
cacheKey := fmt.Sprintf("pwd_reset_sms:%s", phone)
ttl := s.config.TokenTTL
if err := s.cache.Set(ctx, cacheKey, user.ID, ttl, ttl); err != nil {
return "", fmt.Errorf("缓存验证码失败: %w", err)
}
// 存储验证码到另一个key用于后续校验
codeKey := fmt.Sprintf("pwd_reset_sms_code:%s", phone)
if err := s.cache.Set(ctx, codeKey, code, ttl, ttl); err != nil {
return "", fmt.Errorf("缓存验证码失败: %w", err)
}
return code, nil
}
// ResetPasswordByPhoneRequest 通过手机验证码重置密码请求
type ResetPasswordByPhoneRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
// ResetPasswordByPhone 通过手机验证码重置密码 - 验证并重置
func (s *PasswordResetService) ResetPasswordByPhone(ctx context.Context, req *ResetPasswordByPhoneRequest) error {
if req.Phone == "" || req.Code == "" || req.NewPassword == "" {
return errors.New("参数不完整")
}
codeKey := fmt.Sprintf("pwd_reset_sms_code:%s", req.Phone)
storedCode, ok := s.cache.Get(ctx, codeKey)
if !ok {
return errors.New("验证码已失效,请重新获取")
}
code, ok := storedCode.(string)
if !ok || code != req.Code {
return errors.New("验证码不正确")
}
// 获取用户ID
cacheKey := fmt.Sprintf("pwd_reset_sms:%s", req.Phone)
val, ok := s.cache.Get(ctx, cacheKey)
if !ok {
return errors.New("验证码已失效,请重新获取")
}
userID, ok := int64Value(val)
if !ok {
return errors.New("验证码数据异常")
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return errors.New("用户不存在")
}
if err := s.doResetPassword(ctx, user, req.NewPassword); err != nil {
return err
}
// 清理验证码
s.cache.Delete(ctx, codeKey)
s.cache.Delete(ctx, cacheKey)
return nil
}
func (s *PasswordResetService) doResetPassword(ctx context.Context, user *domain.User, newPassword string) error {
policy := security.PasswordPolicy{
MinLength: s.config.PasswordMinLen,
RequireSpecial: s.config.PasswordRequireSpecial,
RequireNumber: s.config.PasswordRequireNumber,
}.Normalize()
if err := policy.Validate(newPassword); err != nil {
return err
}
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("密码加密失败: %w", err)
}
user.Password = hashedPassword
if err := s.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("更新密码失败: %w", err)
}
return nil
}