406 lines
13 KiB
Go
406 lines
13 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.118im.com/im_team/olons_server/internal/config"
|
|
"git.118im.com/im_team/olons_server/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)
|
|
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)
|
|
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"`
|
|
Phone string `json:"phone"`
|
|
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
|
|
ready bool
|
|
}
|
|
|
|
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
|
|
return &Service{
|
|
repository: repository,
|
|
redis: redisClient,
|
|
cfg: cfg,
|
|
ready: repository != nil && redisClient != nil,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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,
|
|
Mobile: 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
|
|
}
|
|
rateKey := s.loginRateKey(phone)
|
|
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
|
|
return nil, ErrRateLimited
|
|
}
|
|
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone)
|
|
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,
|
|
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),
|
|
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) loginRateKey(phone string) string {
|
|
digest := sha256.Sum256([]byte(phone))
|
|
return "olons_server:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
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 sessionKey(pattern string, rawToken []byte) string {
|
|
digest := sha256.Sum256(rawToken)
|
|
return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1)
|
|
}
|
|
|
|
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"
|
|
}
|