273 lines
7.5 KiB
Go
273 lines
7.5 KiB
Go
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
|
||
}
|