package accounts import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "math/big" "regexp" "strings" "time" "unicode" "unicode/utf8" "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 ( phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`) referralPattern = regexp.MustCompile(`^[0-9]{6}$`) tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`) ) type Repository interface { RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) AccountByMobile(context.Context, 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"` HasTradePassword bool `json:"has_trade_password"` 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.TrimSpace(input.Username) input.Phone = strings.TrimSpace(input.Phone) input.ReferralCode = strings.TrimSpace(input.ReferralCode) if !validMemberName(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 !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 } for attempt := 0; attempt < 10; attempt++ { inviteCode, err := newInviteCode() if err != nil { return nil, err } account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{ TenantID: 0, MemberNo: memberNo, Username: input.Username, Mobile: input.Phone, PasswordHash: string(passwordHash), ReferralCode: input.ReferralCode, InviteCode: inviteCode, }) if errors.Is(err, store.ErrInviteCodeExists) { continue } if errors.Is(err, store.ErrReferralInvalid) { return nil, ErrReferralInvalid } if err != nil { return nil, err } return s.issueSession(ctx, account, input.DeviceID) } return nil, ErrUnavailable } func newInviteCode() (string, error) { number, err := rand.Int(rand.Reader, big.NewInt(1000000)) if err != nil { return "", err } return fmt.Sprintf("%06d", number.Int64()), nil } func validMemberName(value string) bool { count := utf8.RuneCountInString(value) if count < 1 || count > 10 { return false } for _, r := range value { if !unicode.IsLetter(r) && !unicode.IsMark(r) && r != 'ยท' { return false } } return true } 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, 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, HasTradePassword: row.HasTradePassword != 0, 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:" + 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" }