Files
olons_server/internal/accounts/service.go
T

438 lines
13 KiB
Go

package accounts
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"regexp"
"strconv"
"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, 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"`
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: s.cfg.DefaultTenantID,
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, 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,
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:" + 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"
}