package business import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "github.com/go-kratos/kratos/v3/errors" "golang.org/x/crypto/bcrypt" "math/big" "regexp" "strings" "time" "unicode/utf16" ) var ( ErrResetAccount = errors.BadRequest("SW_RESET_ACCOUNT", "账号不存在或未注册") ErrResetPhone = errors.BadRequest("SW_RESET_PHONE", "该账号未绑定手机号,请联系管理员重置") ErrResetCode = errors.BadRequest("SW_RESET_CODE", "验证码错误") ErrResetToken = errors.BadRequest("SW_RESET_TOKEN", "重置凭证已失效,请重新找回密码") ErrResetLength = errors.BadRequest("SW_RESET_LENGTH", "密码长度需为 6-15 位") ErrResetMismatch = errors.BadRequest("SW_RESET_MISMATCH", "两次输入的密码不一致") ErrResetSMS = errors.BadRequest("SW_RESET_SMS", "验证码短信发送失败") ErrResetRate = errors.TooManyRequests("SW_RESET_RATE", "验证码发送过于频繁,请稍后重试") ) type ResetUser struct { ID uint Username, Phone string Enabled bool Status int } type ResetCode struct { UserID uint CodeHash string ExpiresAt, SendAfter *time.Time } type ResetToken struct { Hash string UserID uint ExpiresAt, CreatedAt time.Time Failures int } type PasswordResetRepo interface { UserByName(context.Context, string) (*ResetUser, error) UserByID(context.Context, uint) (*ResetUser, error) WithCodeLock(context.Context, uint, func(context.Context) error) error Code(context.Context, uint) (*ResetCode, error) SaveCode(context.Context, *ResetCode) error CreateToken(context.Context, *ResetToken) error WithTokenLock(context.Context, string, func(context.Context) error) error Token(context.Context, string) (*ResetToken, error) SaveToken(context.Context, *ResetToken) error DeleteToken(context.Context, string) error UpdatePassword(context.Context, uint, string) error } type PasswordResetSMS interface { SendCode(context.Context, string, string) error } type PasswordResetUsecase struct { repo PasswordResetRepo sms PasswordResetSMS } func NewPasswordResetUsecase(repo PasswordResetRepo, sms PasswordResetSMS) *PasswordResetUsecase { return &PasswordResetUsecase{repo: repo, sms: sms} } func secretHash(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } var resetPhonePattern = regexp.MustCompile(`^\d{11}$`) var resetCodePattern = regexp.MustCompile(`^\d{6}$`) func MaskPhone(phone string) string { if !resetPhonePattern.MatchString(phone) { return "****" } return phone[:3] + "****" + phone[7:] } func (uc *PasswordResetUsecase) user(ctx context.Context, username string) (*ResetUser, error) { u, err := uc.repo.UserByName(ctx, username) if err != nil { return nil, err } if u == nil || !u.Enabled || u.Status != 0 { return nil, ErrResetAccount } if strings.TrimSpace(u.Phone) == "" { return nil, ErrResetPhone } return u, nil } func (uc *PasswordResetUsecase) Check(ctx context.Context, username string) (string, error) { u, err := uc.user(ctx, username) if err != nil { return "", err } return MaskPhone(u.Phone), nil } func (uc *PasswordResetUsecase) SendCode(ctx context.Context, username string) (string, error) { u, err := uc.user(ctx, username) if err != nil { return "", err } err = uc.repo.WithCodeLock(ctx, u.ID, func(txctx context.Context) error { state, err := uc.repo.Code(txctx, u.ID) if err != nil { return err } now := time.Now() if state.SendAfter != nil && state.SendAfter.After(now) { return ErrResetRate } next := now.Add(time.Minute) state.SendAfter = &next return uc.repo.SaveCode(txctx, state) }) if err != nil { return "", err } n, err := rand.Int(rand.Reader, big.NewInt(1_000_000)) if err != nil { return "", ErrResetSMS } code := fmt.Sprintf("%06d", n.Int64()) if err := uc.sms.SendCode(ctx, u.Phone, code); err != nil { return "", ErrResetSMS } err = uc.repo.WithCodeLock(ctx, u.ID, func(txctx context.Context) error { state, err := uc.repo.Code(txctx, u.ID) if err != nil { return err } expires := time.Now().Add(2 * time.Minute) state.CodeHash, state.ExpiresAt = secretHash(fmt.Sprintf("%d:%s", u.ID, code)), &expires return uc.repo.SaveCode(txctx, state) }) if err != nil { return "", err } return MaskPhone(u.Phone), nil } func (uc *PasswordResetUsecase) VerifyCode(ctx context.Context, username, code string) (string, error) { u, err := uc.user(ctx, username) if err != nil { return "", err } if !resetCodePattern.MatchString(code) { return "", ErrResetCode } var token string err = uc.repo.WithCodeLock(ctx, u.ID, func(txctx context.Context) error { state, err := uc.repo.Code(txctx, u.ID) if err != nil { return err } if state.ExpiresAt == nil || !state.ExpiresAt.After(time.Now()) || state.CodeHash != secretHash(fmt.Sprintf("%d:%s", u.ID, code)) { return ErrResetCode } bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return ErrStorage } token = hex.EncodeToString(bytes) now := time.Now() if err := uc.repo.CreateToken(txctx, &ResetToken{Hash: secretHash(token), UserID: u.ID, CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute)}); err != nil { return err } state.CodeHash, state.ExpiresAt = "", nil return uc.repo.SaveCode(txctx, state) }) if err != nil { return "", err } return token, nil } func validateResetPassword(password, confirm string) error { length := len(utf16.Encode([]rune(password))) if strings.TrimSpace(password) == "" || length < 6 || length > 15 { return ErrResetLength } if password != confirm { return ErrResetMismatch } return nil } func (uc *PasswordResetUsecase) Reset(ctx context.Context, token, password, confirm string) error { key := secretHash(token) var outcome error err := uc.repo.WithTokenLock(ctx, key, func(txctx context.Context) error { state, err := uc.repo.Token(txctx, key) if err != nil { return err } if state == nil || !state.ExpiresAt.After(time.Now()) { outcome = ErrResetToken return uc.repo.DeleteToken(txctx, key) } if invalid := validateResetPassword(password, confirm); invalid != nil { state.Failures++ outcome = invalid if state.Failures >= 5 { return uc.repo.DeleteToken(txctx, key) } return uc.repo.SaveToken(txctx, state) } u, err := uc.repo.UserByID(txctx, state.UserID) if err != nil { return err } if u == nil || !u.Enabled || u.Status != 0 { outcome = ErrResetAccount return uc.repo.DeleteToken(txctx, key) } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return ErrStorage } if err := uc.repo.UpdatePassword(txctx, u.ID, string(hash)); err != nil { return err } return uc.repo.DeleteToken(txctx, key) }) // 校验失败次数和凭证失效也要提交;不能因为返回业务提示而回滚这些状态。 if err != nil { return err } return outcome }