Files
kra-new/internal/biz/business/password_reset.go
T

240 lines
6.9 KiB
Go

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
}