216 lines
6.2 KiB
Go
216 lines
6.2 KiB
Go
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("短信未发送却写入了可用验证码")
|
|
}
|
|
}
|