240 lines
6.9 KiB
Go
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
|
|
}
|