商城c端前端
This commit is contained in:
@@ -19,7 +19,7 @@ type profileRepository struct {
|
||||
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) {
|
||||
func (r *profileRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
|
||||
@@ -44,14 +44,14 @@ func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _
|
||||
func TestProfileMapsMemberCategory(t *testing.T) {
|
||||
repository := &profileRepository{overview: &store.MemberOverview{
|
||||
MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2,
|
||||
Currency: "CNY", WalletStatus: 1,
|
||||
Mobile: "13800138000", 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" {
|
||||
if profile.Phone != "13800138000" || profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" {
|
||||
t.Fatalf("unexpected profile states: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -47,7 +44,7 @@ var (
|
||||
|
||||
type Repository interface {
|
||||
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
|
||||
AccountByMobileHash(context.Context, int64, string) (*store.Account, error)
|
||||
AccountByMobile(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)
|
||||
@@ -81,7 +78,7 @@ type Profile struct {
|
||||
MemberID int64 `json:"member_id"`
|
||||
MemberNo string `json:"member_no"`
|
||||
Username string `json:"username"`
|
||||
PhoneMask string `json:"phone_mask"`
|
||||
Phone string `json:"phone"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
Level ProfileLevel `json:"level"`
|
||||
@@ -129,30 +126,16 @@ 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
|
||||
return &Service{
|
||||
repository: repository,
|
||||
redis: redisClient,
|
||||
cfg: cfg,
|
||||
ready: repository != nil && redisClient != nil,
|
||||
}
|
||||
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) {
|
||||
@@ -178,12 +161,6 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
||||
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
|
||||
@@ -200,9 +177,7 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
||||
TenantID: s.cfg.DefaultTenantID,
|
||||
MemberNo: memberNo,
|
||||
Username: input.Username,
|
||||
MobileCipher: mobileCipher,
|
||||
MobileHash: mobileHash,
|
||||
MobileMask: maskPhone(input.Phone),
|
||||
Mobile: input.Phone,
|
||||
PasswordHash: string(passwordHash),
|
||||
ReferralCode: input.ReferralCode,
|
||||
InviteCode: inviteCode,
|
||||
@@ -227,12 +202,11 @@ func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error)
|
||||
if input.Password == "" || len([]byte(input.Password)) > 72 {
|
||||
return nil, store.ErrInvalidCredentials
|
||||
}
|
||||
mobileHash := s.lookupHash("+86" + phone)
|
||||
rateKey := s.loginRateKey(mobileHash)
|
||||
rateKey := s.loginRateKey(phone)
|
||||
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)
|
||||
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidCredentials) {
|
||||
s.recordLoginFailure(ctx, rateKey)
|
||||
@@ -263,7 +237,7 @@ func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profi
|
||||
}
|
||||
return &Profile{
|
||||
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
|
||||
PhoneMask: row.MobileMask, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
|
||||
Phone: row.Mobile, 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),
|
||||
@@ -379,23 +353,9 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
|
||||
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) loginRateKey(phone string) string {
|
||||
digest := sha256.Sum256([]byte(phone))
|
||||
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||
@@ -405,26 +365,11 @@ func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||
_, _ = 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))
|
||||
|
||||
@@ -2,10 +2,8 @@ package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
@@ -16,7 +14,7 @@ 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) {
|
||||
func (fakeRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
|
||||
@@ -61,31 +59,11 @@ func TestRegisterValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 TestLoginRateKeyDoesNotExposePhone(t *testing.T) {
|
||||
s := &Service{}
|
||||
key := s.loginRateKey("13800138000")
|
||||
if strings.Contains(key, "13800138000") {
|
||||
t.Fatal("login rate key must not expose the phone number")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ func accountResultPayload(result *accountsvc.Result) map[string]any {
|
||||
"member_id": result.Account.MemberID,
|
||||
"member_no": result.Account.MemberNo,
|
||||
"username": result.Account.Username,
|
||||
"phone_mask": result.Account.MobileMask,
|
||||
"phone": result.Account.Mobile,
|
||||
"invite_code": result.Account.InviteCode,
|
||||
"token": result.Token,
|
||||
"token_type": "Bearer",
|
||||
|
||||
+13
-16
@@ -21,9 +21,7 @@ type AccountRegistration struct {
|
||||
TenantID int64
|
||||
MemberNo string
|
||||
Username string
|
||||
MobileCipher string
|
||||
MobileHash string
|
||||
MobileMask string
|
||||
Mobile string
|
||||
PasswordHash string
|
||||
ReferralCode string
|
||||
InviteCode string
|
||||
@@ -34,8 +32,7 @@ type Account struct {
|
||||
MemberID int64 `db:"member_id"`
|
||||
MemberNo string `db:"member_no"`
|
||||
Username string `db:"username"`
|
||||
MobileMask string `db:"mobile_mask"`
|
||||
MobileHash string `db:"mobile_hash"`
|
||||
Mobile string `db:"mobile"`
|
||||
PasswordHash string `db:"password_hash"`
|
||||
InviteCode string `db:"invite_code"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
@@ -62,12 +59,12 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
|
||||
}
|
||||
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member
|
||||
(tenant_id,member_no,username,mobile_cipher,mobile_hash,mobile_mask,password_hash,
|
||||
(tenant_id,member_no,username,mobile,password_hash,
|
||||
level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch,
|
||||
credit_score,is_test_account,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,1,2,100,2,0,0)`,
|
||||
input.TenantID, input.MemberNo, input.Username, input.MobileCipher, input.MobileHash,
|
||||
input.MobileMask, input.PasswordHash, levelID, parentID, teamLevel, tree, input.InviteCode)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,2,100,2,0,0)`,
|
||||
input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash,
|
||||
levelID, parentID, teamLevel, tree, input.InviteCode)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrAccountExists
|
||||
@@ -91,15 +88,15 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
|
||||
return nil, err
|
||||
}
|
||||
return &Account{TenantID: input.TenantID, MemberID: memberID, MemberNo: input.MemberNo,
|
||||
Username: input.Username, MobileMask: input.MobileMask, MobileHash: input.MobileHash,
|
||||
Username: input.Username, Mobile: input.Mobile,
|
||||
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountByMobileHash(ctx context.Context, tenantID int64, mobileHash string) (*Account, error) {
|
||||
func (s *Store) AccountByMobile(ctx context.Context, tenantID int64, mobile string) (*Account, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
return accountByMobileHash(ctx, s.db, tenantID, mobileHash)
|
||||
return accountByMobile(ctx, s.db, tenantID, mobile)
|
||||
}
|
||||
|
||||
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
|
||||
@@ -112,11 +109,11 @@ type queryer interface {
|
||||
GetContext(context.Context, any, string, ...any) error
|
||||
}
|
||||
|
||||
func accountByMobileHash(ctx context.Context, db queryer, tenantID int64, mobileHash string) (*Account, error) {
|
||||
func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile string) (*Account, error) {
|
||||
var row Account
|
||||
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile_mask,
|
||||
mobile_hash, password_hash, invite_code, member_status, risk_status AS member_category
|
||||
FROM hg_app_member WHERE tenant_id=? AND mobile_hash=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobileHash)
|
||||
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile,
|
||||
password_hash, invite_code, member_status, risk_status AS member_category
|
||||
FROM hg_app_member WHERE tenant_id=? AND mobile=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobile)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type MemberOverview struct {
|
||||
MemberID int64 `db:"member_id"`
|
||||
MemberNo string `db:"member_no"`
|
||||
Username string `db:"username"`
|
||||
MobileMask string `db:"mobile_mask"`
|
||||
Mobile string `db:"mobile"`
|
||||
InviteCode string `db:"invite_code"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
MemberCategory int `db:"member_category"`
|
||||
@@ -48,7 +48,7 @@ func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row MemberOverview
|
||||
err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile_mask,
|
||||
err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile,
|
||||
m.invite_code,m.member_status,m.risk_status AS member_category,m.credit_score,
|
||||
COALESCE(p.avatar_attachment_id,0) AS avatar_attachment_id,COALESCE(a.file_url,'') AS avatar_url,
|
||||
COALESCE(l.id,0) AS level_id,COALESCE(l.level_code,'') AS level_code,
|
||||
|
||||
Reference in New Issue
Block a user