feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
This commit is contained in:
272
internal/service/password_reset.go
Normal file
272
internal/service/password_reset.go
Normal file
@@ -0,0 +1,272 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user