feat: migrate workbench services
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type profileRepository struct {
|
||||
overview *store.MemberOverview
|
||||
security *store.AccountSecurity
|
||||
loginPasswordHash string
|
||||
tradePasswordHash string
|
||||
}
|
||||
|
||||
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (r *profileRepository) AccountByMobileHash(context.Context, int64, string) (*store.Account, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
|
||||
func (r *profileRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) {
|
||||
return r.overview, nil
|
||||
}
|
||||
func (r *profileRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) {
|
||||
return &store.Avatar{AttachmentID: 12, URL: "https://cdn.example/avatar.png"}, nil
|
||||
}
|
||||
func (r *profileRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
|
||||
return r.security, nil
|
||||
}
|
||||
func (r *profileRepository) UpdateLoginPassword(_ context.Context, _, _ int64, _, newHash string) error {
|
||||
r.loginPasswordHash = newHash
|
||||
return nil
|
||||
}
|
||||
func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _, newHash string) error {
|
||||
r.tradePasswordHash = newHash
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProfileMapsMemberCategory(t *testing.T) {
|
||||
repository := &profileRepository{overview: &store.MemberOverview{
|
||||
MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2,
|
||||
Currency: "CNY", WalletStatus: 1,
|
||||
}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
profile, err := service.Profile(context.Background(), 0, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" {
|
||||
t.Fatalf("unexpected profile states: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordVerifiesAndHashes(t *testing.T) {
|
||||
currentHash, err := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash), MemberStatus: 1}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
if err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{
|
||||
OldPassword: "old-secret", NewPassword: "new-secret",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(repository.loginPasswordHash), []byte("new-secret")) != nil {
|
||||
t.Fatal("new login password was not bcrypt hashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordRejectsWrongCurrentPassword(t *testing.T) {
|
||||
currentHash, _ := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost)
|
||||
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash)}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{
|
||||
OldPassword: "wrong-secret", NewPassword: "new-secret",
|
||||
})
|
||||
if !errors.Is(err, ErrCurrentPassword) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrCurrentPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTradePasswordRequiresSixDigits(t *testing.T) {
|
||||
loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost)
|
||||
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(loginHash)}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
for _, password := range []string{"", "12345", "1234567", "abcdef"} {
|
||||
err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
|
||||
LoginPassword: "login-secret", NewTradePassword: password,
|
||||
})
|
||||
if !errors.Is(err, ErrTradePassword) {
|
||||
t.Errorf("password %q: error = %v, want %v", password, err, ErrTradePassword)
|
||||
}
|
||||
}
|
||||
if err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
|
||||
LoginPassword: "login-secret", NewTradePassword: "654321",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(repository.tradePasswordHash), []byte("654321")) != nil {
|
||||
t.Fatal("trade password was not bcrypt hashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeTradePasswordRequiresOldPassword(t *testing.T) {
|
||||
loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost)
|
||||
tradeHash, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.MinCost)
|
||||
repository := &profileRepository{security: &store.AccountSecurity{
|
||||
PasswordHash: string(loginHash), TradePasswordHash: string(tradeHash),
|
||||
}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
|
||||
LoginPassword: "login-secret", NewTradePassword: "654321",
|
||||
})
|
||||
if !errors.Is(err, ErrOldTradePasswordUnset) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrOldTradePasswordUnset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("account service unavailable")
|
||||
ErrUsernameInvalid = errors.New("username invalid")
|
||||
ErrPhoneInvalid = errors.New("phone invalid")
|
||||
ErrPasswordInvalid = errors.New("password invalid")
|
||||
ErrReferralInvalid = errors.New("referral code invalid")
|
||||
ErrPrivacyNotAccepted = errors.New("privacy policy not accepted")
|
||||
ErrRateLimited = errors.New("login rate limited")
|
||||
ErrCurrentPassword = errors.New("current password invalid")
|
||||
ErrLoginPassword = errors.New("login password invalid")
|
||||
ErrTradePassword = errors.New("trade password invalid")
|
||||
ErrOldTradePassword = errors.New("old trade password invalid")
|
||||
ErrOldTradePasswordUnset = errors.New("old trade password is required")
|
||||
ErrAttachmentInvalid = errors.New("attachment invalid")
|
||||
)
|
||||
|
||||
var (
|
||||
usernamePattern = regexp.MustCompile(`^[a-z0-9]{1,10}$`)
|
||||
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
|
||||
referralPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,32}$`)
|
||||
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
|
||||
AccountByMobileHash(context.Context, int64, string) (*store.Account, error)
|
||||
RecordLogin(context.Context, int64, int64) error
|
||||
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
|
||||
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
|
||||
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error)
|
||||
UpdateLoginPassword(context.Context, int64, int64, string, string) error
|
||||
UpdateTradePassword(context.Context, int64, int64, string, string) error
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
Username string
|
||||
Phone string
|
||||
Password string
|
||||
ReferralCode string
|
||||
PrivacyAccepted bool
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Phone string
|
||||
Password string
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Account *store.Account
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
MemberID int64 `json:"member_id"`
|
||||
MemberNo string `json:"member_no"`
|
||||
Username string `json:"username"`
|
||||
PhoneMask string `json:"phone_mask"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
Level ProfileLevel `json:"level"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
MemberCategory string `json:"member_category"`
|
||||
CreditScore int `json:"credit_score"`
|
||||
Wallet ProfileWallet `json:"wallet"`
|
||||
}
|
||||
|
||||
type ProfileLevel struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"icon_url"`
|
||||
CommissionRate int `json:"commission_rate"`
|
||||
}
|
||||
|
||||
type ProfileWallet struct {
|
||||
AvailableAmount int64 `json:"available_amount"`
|
||||
FrozenAmount int64 `json:"frozen_amount"`
|
||||
TotalIncome int64 `json:"total_income"`
|
||||
TotalExpense int64 `json:"total_expense"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
type AvatarResult struct {
|
||||
AttachmentID int64 `json:"attachment_id"`
|
||||
URL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type ChangePasswordInput struct {
|
||||
OldPassword string
|
||||
NewPassword string
|
||||
}
|
||||
|
||||
type ChangeTradePasswordInput struct {
|
||||
LoginPassword string
|
||||
OldTradePassword string
|
||||
NewTradePassword string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
redis *redis.Client
|
||||
cfg config.AuthConfig
|
||||
aead cipher.AEAD
|
||||
lookupKey []byte
|
||||
ready bool
|
||||
}
|
||||
|
||||
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
|
||||
service := &Service{repository: repository, redis: redisClient, cfg: cfg}
|
||||
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
|
||||
lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey)
|
||||
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
|
||||
return service
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return service
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return service
|
||||
}
|
||||
service.aead = aead
|
||||
service.lookupKey = lookupKey
|
||||
service.ready = repository != nil && redisClient != nil
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
||||
input.Phone = strings.TrimSpace(input.Phone)
|
||||
input.ReferralCode = strings.TrimSpace(input.ReferralCode)
|
||||
if !usernamePattern.MatchString(input.Username) {
|
||||
return nil, ErrUsernameInvalid
|
||||
}
|
||||
if !phonePattern.MatchString(input.Phone) {
|
||||
return nil, ErrPhoneInvalid
|
||||
}
|
||||
if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 {
|
||||
return nil, ErrPasswordInvalid
|
||||
}
|
||||
if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) {
|
||||
return nil, ErrReferralInvalid
|
||||
}
|
||||
if !input.PrivacyAccepted {
|
||||
return nil, ErrPrivacyNotAccepted
|
||||
}
|
||||
|
||||
normalizedPhone := "+86" + input.Phone
|
||||
mobileCipher, err := s.encrypt(normalizedPhone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mobileHash := s.lookupHash(normalizedPhone)
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memberNo, err := newPublicID("M", 21)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inviteCode, err := newPublicID("", 8)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
|
||||
TenantID: s.cfg.DefaultTenantID,
|
||||
MemberNo: memberNo,
|
||||
Username: input.Username,
|
||||
MobileCipher: mobileCipher,
|
||||
MobileHash: mobileHash,
|
||||
MobileMask: maskPhone(input.Phone),
|
||||
PasswordHash: string(passwordHash),
|
||||
ReferralCode: input.ReferralCode,
|
||||
InviteCode: inviteCode,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrReferralInvalid) {
|
||||
return nil, ErrReferralInvalid
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.issueSession(ctx, account, input.DeviceID)
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
phone := strings.TrimSpace(input.Phone)
|
||||
if !phonePattern.MatchString(phone) {
|
||||
return nil, ErrPhoneInvalid
|
||||
}
|
||||
if input.Password == "" || len([]byte(input.Password)) > 72 {
|
||||
return nil, store.ErrInvalidCredentials
|
||||
}
|
||||
mobileHash := s.lookupHash("+86" + phone)
|
||||
rateKey := s.loginRateKey(mobileHash)
|
||||
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
account, err := s.repository.AccountByMobileHash(ctx, s.cfg.DefaultTenantID, mobileHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidCredentials) {
|
||||
s.recordLoginFailure(ctx, rateKey)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(input.Password)) != nil {
|
||||
s.recordLoginFailure(ctx, rateKey)
|
||||
return nil, store.ErrInvalidCredentials
|
||||
}
|
||||
if err := s.repository.RecordLogin(ctx, account.TenantID, account.MemberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.issueSession(ctx, account, input.DeviceID)
|
||||
if err == nil {
|
||||
_ = s.redis.Del(ctx, rateKey).Err()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profile, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
row, err := s.repository.MemberOverview(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Profile{
|
||||
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
|
||||
PhoneMask: row.MobileMask, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
|
||||
Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName,
|
||||
IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate},
|
||||
MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory),
|
||||
CreditScore: row.CreditScore,
|
||||
Wallet: ProfileWallet{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount,
|
||||
TotalIncome: row.TotalIncome, TotalExpense: row.TotalExpense, Currency: row.Currency,
|
||||
Status: walletStatusName(row.WalletStatus), Version: row.WalletVersion},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangeAvatar(ctx context.Context, tenantID, memberID, attachmentID int64) (*AvatarResult, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if attachmentID <= 0 {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
avatar, err := s.repository.UpdateAvatar(ctx, tenantID, memberID, attachmentID)
|
||||
if errors.Is(err, store.ErrAttachmentInvalid) {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AvatarResult{AttachmentID: avatar.AttachmentID, URL: avatar.URL}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(ctx context.Context, tenantID, memberID int64, input ChangePasswordInput) error {
|
||||
if !s.ready {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if input.OldPassword == "" || len([]byte(input.OldPassword)) > 72 {
|
||||
return ErrCurrentPassword
|
||||
}
|
||||
if len([]rune(input.NewPassword)) < 6 || len([]byte(input.NewPassword)) > 72 {
|
||||
return ErrPasswordInvalid
|
||||
}
|
||||
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.OldPassword)) != nil {
|
||||
return ErrCurrentPassword
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.NewPassword)) == nil {
|
||||
return ErrPasswordInvalid
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repository.UpdateLoginPassword(ctx, tenantID, memberID, security.PasswordHash, string(newHash))
|
||||
}
|
||||
|
||||
func (s *Service) ChangeTradePassword(ctx context.Context, tenantID, memberID int64, input ChangeTradePasswordInput) error {
|
||||
if !s.ready {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if input.LoginPassword == "" || len([]byte(input.LoginPassword)) > 72 {
|
||||
return ErrLoginPassword
|
||||
}
|
||||
if !tradePasswordPattern.MatchString(input.NewTradePassword) {
|
||||
return ErrTradePassword
|
||||
}
|
||||
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.LoginPassword)) != nil {
|
||||
return ErrLoginPassword
|
||||
}
|
||||
if security.TradePasswordHash != "" {
|
||||
if input.OldTradePassword == "" {
|
||||
return ErrOldTradePasswordUnset
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.OldTradePassword)) != nil {
|
||||
return ErrOldTradePassword
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.NewTradePassword)) == nil {
|
||||
return ErrTradePassword
|
||||
}
|
||||
} else if input.OldTradePassword != "" {
|
||||
return ErrOldTradePassword
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewTradePassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repository.UpdateTradePassword(ctx, tenantID, memberID, security.TradePasswordHash, string(newHash))
|
||||
}
|
||||
|
||||
func (s *Service) issueSession(ctx context.Context, account *store.Account, deviceID string) (*Result, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
expiresAt := time.Now().Add(time.Duration(s.cfg.SessionTTLSeconds) * time.Second).UTC().Round(time.Millisecond)
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"member_id": account.MemberID,
|
||||
"tenant_id": account.TenantID,
|
||||
"device_id": strings.TrimSpace(deviceID),
|
||||
"state": "active",
|
||||
"issued_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.redis.Set(ctx, sessionKey(s.cfg.SessionKeyPattern, raw), payload, time.Until(expiresAt)).Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Result{Account: account, Token: token, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) encrypt(value string) (string, error) {
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := s.aead.Seal(nil, nonce, []byte(value), nil)
|
||||
return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil
|
||||
}
|
||||
|
||||
func (s *Service) lookupHash(value string) string {
|
||||
mac := hmac.New(sha256.New, s.lookupKey)
|
||||
_, _ = mac.Write([]byte(value))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Service) loginRateKey(mobileHash string) string {
|
||||
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + mobileHash
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||
pipe := s.redis.TxPipeline()
|
||||
pipe.Incr(ctx, key)
|
||||
pipe.Expire(ctx, key, 15*time.Minute)
|
||||
_, _ = pipe.Exec(ctx)
|
||||
}
|
||||
|
||||
func decodeKey(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return base64.RawStdEncoding.DecodeString(value)
|
||||
}
|
||||
|
||||
func sessionKey(pattern string, rawToken []byte) string {
|
||||
digest := sha256.Sum256(rawToken)
|
||||
return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1)
|
||||
}
|
||||
|
||||
func maskPhone(phone string) string {
|
||||
if len(phone) != 11 {
|
||||
return ""
|
||||
}
|
||||
return phone[:3] + "****" + phone[7:]
|
||||
}
|
||||
|
||||
func newPublicID(prefix string, length int) (string, error) {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
raw := make([]byte, length-len(prefix))
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := make([]byte, len(raw))
|
||||
for i, value := range raw {
|
||||
result[i] = alphabet[int(value)%len(alphabet)]
|
||||
}
|
||||
return prefix + string(result), nil
|
||||
}
|
||||
|
||||
func memberStatusName(status int) string {
|
||||
if status == 1 {
|
||||
return "active"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
|
||||
func memberCategoryName(category int) string {
|
||||
if category == 2 {
|
||||
return "fake"
|
||||
}
|
||||
return "real"
|
||||
}
|
||||
|
||||
func walletStatusName(status int) string {
|
||||
if status == 1 {
|
||||
return "active"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct{}
|
||||
|
||||
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) AccountByMobileHash(context.Context, int64, string) (*store.Account, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) UpdateLoginPassword(context.Context, int64, int64, string, string) error {
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) UpdateTradePassword(context.Context, int64, int64, string, string) error {
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
|
||||
func TestRegisterValidation(t *testing.T) {
|
||||
s := &Service{repository: fakeRepository{}, ready: true}
|
||||
tests := []struct {
|
||||
name string
|
||||
input RegisterInput
|
||||
want error
|
||||
}{
|
||||
{name: "username", input: RegisterInput{Username: "中文", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrUsernameInvalid},
|
||||
{name: "phone", input: RegisterInput{Username: "alice", Phone: "12800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrPhoneInvalid},
|
||||
{name: "password", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "123", PrivacyAccepted: true}, want: ErrPasswordInvalid},
|
||||
{name: "referral", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "bad!", PrivacyAccepted: true}, want: ErrReferralInvalid},
|
||||
{name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1"}, want: ErrPrivacyNotAccepted},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := s.Register(context.Background(), test.input)
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("Register() error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileEncryptionRoundTrip(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Service{aead: aead, lookupKey: []byte("01234567890123456789012345678901")}
|
||||
encrypted, err := s.encrypt("+8613800138000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := base64.RawStdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil)
|
||||
if err != nil || string(plain) != "+8613800138000" {
|
||||
t.Fatalf("decrypt = %q, err=%v", plain, err)
|
||||
}
|
||||
if len(s.lookupHash("+8613800138000")) != 64 {
|
||||
t.Fatal("mobile lookup hash must be hex sha256")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRequiresMainlandPhone(t *testing.T) {
|
||||
s := &Service{repository: fakeRepository{}, ready: true}
|
||||
for _, phone := range []string{"", "alice01", "12800138000", "+8613800138000"} {
|
||||
_, err := s.Login(context.Background(), LoginInput{Phone: phone, Password: "secret1"})
|
||||
if !errors.Is(err, ErrPhoneInvalid) {
|
||||
t.Errorf("Login(phone=%q) error = %v, want %v", phone, err, ErrPhoneInvalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user