159 lines
4.5 KiB
Go
159 lines
4.5 KiB
Go
package auth
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"errors"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
"yingce/backend/internal/kernel"
|
||
|
||
"yingce/backend/internal/model"
|
||
"yingce/backend/internal/repository"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
const passwordResetEmailPurpose = "password_reset"
|
||
const passwordResetCodeTTL = 10 * time.Minute
|
||
|
||
type PasswordResetRequest struct {
|
||
Email string `json:"email"`
|
||
EmailCode string `json:"emailCode"`
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
func (s *Service) SendPasswordResetEmailCode(rawEmail string) error {
|
||
email := NormalizeEmail(rawEmail)
|
||
if err := ValidateEmail(email); err != nil {
|
||
return err
|
||
}
|
||
_, setting, err := s.readEmailSetting()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !setting.Enabled || setting.Host == "" || setting.Port < 1 || setting.FromEmail == "" {
|
||
return kernel.Forbidden("管理员尚未启用密码找回,请联系管理员")
|
||
}
|
||
|
||
s.emailCodeMu.Lock()
|
||
defer s.emailCodeMu.Unlock()
|
||
|
||
user, err := s.repo.UserByEmail(email)
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if user.Status != model.UserStatusActive || strings.TrimSpace(user.PasswordHash) == "" {
|
||
return nil
|
||
}
|
||
if latest, latestErr := s.repo.LatestEmailVerificationCode(email, passwordResetEmailPurpose); latestErr == nil && time.Since(latest.CreatedAt) < time.Minute {
|
||
return nil
|
||
} else if latestErr != nil && !errors.Is(latestErr, gorm.ErrRecordNotFound) {
|
||
return latestErr
|
||
}
|
||
|
||
code, err := randomNumericCode(6)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
codeHash, err := s.emailVerificationCodeHash(passwordResetEmailPurpose, email, code)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
now := time.Now()
|
||
record := model.EmailVerificationCode{
|
||
ID: kernel.NewID(),
|
||
Email: email,
|
||
CodeHash: codeHash,
|
||
Purpose: passwordResetEmailPurpose,
|
||
ExpiresAt: now.Add(passwordResetCodeTTL),
|
||
CreatedAt: now,
|
||
}
|
||
if err := s.repo.Create(&record); err != nil {
|
||
return err
|
||
}
|
||
setting = resolveEmailSender(setting, s.host.BrandName())
|
||
if err := s.deliverEmail(setting, email, setting.FromName+"密码重置验证码", passwordResetEmailBody(setting.FromName, code)); err != nil {
|
||
if cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID); cleanupErr != nil {
|
||
log.Printf("password reset email cleanup failed: recipient=%s error=%v", maskedEmail(email), cleanupErr)
|
||
}
|
||
return nil
|
||
}
|
||
if cleanupErr := s.repo.DeleteExpiredEmailVerificationCodes(now.Add(-24 * time.Hour)); cleanupErr != nil {
|
||
log.Printf("expired password reset code cleanup failed: error=%v", cleanupErr)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) ResetPassword(req PasswordResetRequest) error {
|
||
email := NormalizeEmail(req.Email)
|
||
if err := ValidateEmail(email); err != nil {
|
||
return err
|
||
}
|
||
if err := ValidatePassword(req.Password); err != nil {
|
||
return err
|
||
}
|
||
code := strings.TrimSpace(req.EmailCode)
|
||
if len(code) != 6 {
|
||
return invalidPasswordResetCode()
|
||
}
|
||
|
||
user, err := s.repo.UserByEmail(email)
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return invalidPasswordResetCode()
|
||
}
|
||
return err
|
||
}
|
||
if user.Status != model.UserStatusActive || strings.TrimSpace(user.PasswordHash) == "" {
|
||
return invalidPasswordResetCode()
|
||
}
|
||
record, err := s.repo.LatestEmailVerificationCode(email, passwordResetEmailPurpose)
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return invalidPasswordResetCode()
|
||
}
|
||
return err
|
||
}
|
||
if time.Now().After(record.ExpiresAt) {
|
||
return invalidPasswordResetCode()
|
||
}
|
||
expectedHash, err := s.emailVerificationCodeHash(passwordResetEmailPurpose, email, code)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !hmac.Equal([]byte(expectedHash), []byte(record.CodeHash)) {
|
||
return invalidPasswordResetCode()
|
||
}
|
||
passwordHash, err := HashPassword(req.Password)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.repo.ResetUserPasswordWithEmailVerification(user.ID, email, passwordResetEmailPurpose, record.ID, passwordHash, time.Now()); err != nil {
|
||
if errors.Is(err, repository.ErrEmailVerificationCodeInvalid) {
|
||
return invalidPasswordResetCode()
|
||
}
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func invalidPasswordResetCode() *AuthError {
|
||
return kernel.BadAuthRequest("验证码无效或已过期")
|
||
}
|
||
|
||
func passwordResetEmailBody(brandName string, code string) string {
|
||
return "你正在重置" + brandName + "账号密码。\n\n验证码:" + code + "\n\n验证码 10 分钟内有效。若非本人操作,请忽略本邮件,并确保邮箱账号安全。"
|
||
}
|
||
|
||
func maskedEmail(email string) string {
|
||
parts := strings.SplitN(email, "@", 2)
|
||
if len(parts) != 2 || parts[0] == "" {
|
||
return "***"
|
||
}
|
||
return string([]rune(parts[0])[0]) + "***@" + parts[1]
|
||
}
|