feat: 迁移商务登录品牌和三步找回密码
This commit is contained in:
+1
-1
@@ -11,4 +11,4 @@ import (
|
||||
)
|
||||
|
||||
// ProviderSet is biz providers.
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase)
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase)
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type resetRepoFake struct {
|
||||
PasswordResetRepo
|
||||
mu sync.Mutex
|
||||
user *ResetUser
|
||||
code *ResetCode
|
||||
tokens map[string]*ResetToken
|
||||
password string
|
||||
}
|
||||
|
||||
func (r *resetRepoFake) UserByName(context.Context, string) (*ResetUser, error) {
|
||||
if r.user == nil {
|
||||
return nil, nil
|
||||
}
|
||||
u := *r.user
|
||||
return &u, nil
|
||||
}
|
||||
func (r *resetRepoFake) UserByID(context.Context, uint) (*ResetUser, error) {
|
||||
return r.UserByName(context.Background(), "")
|
||||
}
|
||||
func (r *resetRepoFake) transaction(ctx context.Context, fn func(context.Context) error) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
beforeCode := r.code
|
||||
beforeTokens := map[string]*ResetToken{}
|
||||
for key, value := range r.tokens {
|
||||
copy := *value
|
||||
beforeTokens[key] = ©
|
||||
}
|
||||
beforePassword := r.password
|
||||
err := fn(ctx)
|
||||
if err != nil {
|
||||
r.code, r.tokens, r.password = beforeCode, beforeTokens, beforePassword
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (r *resetRepoFake) WithCodeLock(ctx context.Context, id uint, fn func(context.Context) error) error {
|
||||
return r.transaction(ctx, func(ctx context.Context) error {
|
||||
if r.code == nil {
|
||||
r.code = &ResetCode{UserID: id}
|
||||
}
|
||||
return fn(ctx)
|
||||
})
|
||||
}
|
||||
func (r *resetRepoFake) WithTokenLock(ctx context.Context, _ string, fn func(context.Context) error) error {
|
||||
return r.transaction(ctx, fn)
|
||||
}
|
||||
func (r *resetRepoFake) Code(context.Context, uint) (*ResetCode, error) {
|
||||
copy := *r.code
|
||||
return ©, nil
|
||||
}
|
||||
func (r *resetRepoFake) SaveCode(_ context.Context, c *ResetCode) error {
|
||||
copy := *c
|
||||
r.code = ©
|
||||
return nil
|
||||
}
|
||||
func (r *resetRepoFake) CreateToken(_ context.Context, t *ResetToken) error {
|
||||
copy := *t
|
||||
r.tokens[t.Hash] = ©
|
||||
return nil
|
||||
}
|
||||
func (r *resetRepoFake) Token(_ context.Context, key string) (*ResetToken, error) {
|
||||
if t := r.tokens[key]; t != nil {
|
||||
copy := *t
|
||||
return ©, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (r *resetRepoFake) SaveToken(_ context.Context, t *ResetToken) error {
|
||||
copy := *t
|
||||
r.tokens[t.Hash] = ©
|
||||
return nil
|
||||
}
|
||||
func (r *resetRepoFake) DeleteToken(_ context.Context, key string) error {
|
||||
delete(r.tokens, key)
|
||||
return nil
|
||||
}
|
||||
func (r *resetRepoFake) UpdatePassword(_ context.Context, _ uint, hash string) error {
|
||||
r.password = hash
|
||||
return nil
|
||||
}
|
||||
|
||||
type resetSMSFake struct {
|
||||
code string
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *resetSMSFake) SendCode(_ context.Context, _ string, code string) error {
|
||||
s.code = code
|
||||
s.calls++
|
||||
return s.err
|
||||
}
|
||||
func resetFixture() (*PasswordResetUsecase, *resetRepoFake, *resetSMSFake) {
|
||||
r := &resetRepoFake{user: &ResetUser{ID: 7, Username: "business", Phone: "13800138000", Enabled: true}, tokens: map[string]*ResetToken{}}
|
||||
s := &resetSMSFake{}
|
||||
return NewPasswordResetUsecase(r, s), r, s
|
||||
}
|
||||
|
||||
func TestPasswordResetConsumesOnlyCorrectCodeAndOneToken(t *testing.T) {
|
||||
uc, repo, sms := resetFixture()
|
||||
ctx := context.Background()
|
||||
phone, err := uc.SendCode(ctx, "business")
|
||||
if err != nil || phone != "138****8000" {
|
||||
t.Fatalf("发码失败: %s %v", phone, err)
|
||||
}
|
||||
if _, err := uc.SendCode(ctx, "business"); err != ErrResetRate || sms.calls != 1 {
|
||||
t.Fatal("未执行 60 秒发码限制")
|
||||
}
|
||||
wrong := "000000"
|
||||
if sms.code == wrong {
|
||||
wrong = "111111"
|
||||
}
|
||||
if _, err := uc.VerifyCode(ctx, "business", wrong); err != ErrResetCode {
|
||||
t.Fatal("错误验证码被接受")
|
||||
}
|
||||
token, err := uc.VerifyCode(ctx, "business", sms.code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.code.CodeHash != "" || len(token) != 64 {
|
||||
t.Fatal("匹配验证码未消费或未签发凭证")
|
||||
}
|
||||
if _, err := uc.VerifyCode(ctx, "business", sms.code); err != ErrResetCode {
|
||||
t.Fatal("验证码可重放")
|
||||
}
|
||||
if err := uc.Reset(ctx, token, "newpass1", "newpass1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(repo.password), []byte("newpass1")) != nil {
|
||||
t.Fatal("未保存新密码 hash")
|
||||
}
|
||||
if err := uc.Reset(ctx, token, "newpass2", "newpass2"); err != ErrResetToken {
|
||||
t.Fatal("重置凭证可重放")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetFailuresCommitAndRevokeAfterFive(t *testing.T) {
|
||||
uc, repo, sms := resetFixture()
|
||||
ctx := context.Background()
|
||||
uc.SendCode(ctx, "business")
|
||||
token, err := uc.VerifyCode(ctx, "business", sms.code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := uc.Reset(ctx, token, "short", "short"); err != ErrResetLength {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(repo.tokens) != 0 {
|
||||
t.Fatal("业务校验失败后计数被回滚,凭证未吊销")
|
||||
}
|
||||
if err := uc.Reset(ctx, token, "newpass1", "newpass1"); err != ErrResetToken {
|
||||
t.Fatal("失败达到五次后仍允许重置")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetRechecksStatusAndExpiration(t *testing.T) {
|
||||
uc, repo, sms := resetFixture()
|
||||
ctx := context.Background()
|
||||
uc.SendCode(ctx, "business")
|
||||
token, err := uc.VerifyCode(ctx, "business", sms.code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo.user.Status = 3
|
||||
if err := uc.Reset(ctx, token, "newpass1", "newpass1"); err != ErrResetAccount || repo.password != "" || len(repo.tokens) != 0 {
|
||||
t.Fatal("发码后停用或待审核的账号仍可重置")
|
||||
}
|
||||
repo.tokens[secretHash("expired")] = &ResetToken{Hash: secretHash("expired"), UserID: 7, ExpiresAt: time.Now().Add(-time.Second)}
|
||||
if err := uc.Reset(ctx, "expired", "newpass1", "newpass1"); err != ErrResetToken {
|
||||
t.Fatal("过期凭证仍可用")
|
||||
}
|
||||
if MaskPhone("not-a-phone") != "****" {
|
||||
t.Fatal("非标准手机号泄露明文")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetConcurrentVerificationIssuesOneToken(t *testing.T) {
|
||||
uc, repo, sms := resetFixture()
|
||||
ctx := context.Background()
|
||||
if _, err := uc.SendCode(ctx, "business"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func() { _, err := uc.VerifyCode(ctx, "business", sms.code); results <- err }()
|
||||
}
|
||||
a, b := <-results, <-results
|
||||
if !((a == nil && b == ErrResetCode) || (b == nil && a == ErrResetCode)) || len(repo.tokens) != 1 {
|
||||
t.Fatalf("并发消费不原子: %v %v", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedSMSDoesNotIssueUsableCode(t *testing.T) {
|
||||
uc, repo, sms := resetFixture()
|
||||
sms.err = errors.New("provider failed")
|
||||
if _, err := uc.SendCode(context.Background(), "business"); err != ErrResetSMS {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.code.CodeHash != "" || repo.code.ExpiresAt != nil {
|
||||
t.Fatal("短信未发送却写入了可用验证码")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user