package usecase import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "math/big" "strings" "time" "apps/backend/internal/module/member/domain" tokenDomain "apps/backend/internal/module/token/domain" ) // AccountService implements domain.AccountUseCase (stand-alone full member surface). // AuthService is an alias so existing callers keep working. type AccountService struct { Repo domain.Repository Issuer tokenDomain.Issuer BcryptCost int // OAuth optional config (empty = dev mock path) GoogleClientID string LineClientID string LineClientSecret string LineRedirectURI string } // AuthService kept as type alias for svc/logic compatibility. type AuthService = AccountService func NewAuthService(repo domain.Repository, issuer tokenDomain.Issuer, bcryptCost int) *AccountService { return NewAccountService(repo, issuer, bcryptCost) } func NewAccountService(repo domain.Repository, issuer tokenDomain.Issuer, bcryptCost int) *AccountService { if bcryptCost <= 0 { bcryptCost = 10 } return &AccountService{Repo: repo, Issuer: issuer, BcryptCost: bcryptCost} } // ---------- create / login ---------- func (s *AccountService) CreateUserAccount(ctx context.Context, req domain.CreateLoginUserRequest) (*domain.Member, *tokenDomain.TokenPair, error) { platform := strings.ToLower(strings.TrimSpace(req.Platform)) if platform == "" { platform = domain.PlatformPassword } loginID := strings.TrimSpace(req.LoginID) if loginID == "" { return nil, nil, domain.ErrBadPassword } if platform == domain.PlatformPassword { loginID = strings.ToLower(loginID) } // already bound? if _, err := s.Repo.FindIdentity(ctx, loginID, platform); err == nil { return nil, nil, domain.ErrIdentityTaken } uid, err := s.Repo.NextUID(ctx) if err != nil { return nil, nil, err } now := domain.NowNano() display := strings.TrimSpace(req.DisplayName) email := strings.ToLower(strings.TrimSpace(req.Email)) phone := strings.TrimSpace(req.Phone) if platform == domain.PlatformPassword { if req.AccountType == domain.AccountTypePhone || looksLikePhone(loginID) { if phone == "" { phone = loginID } req.AccountType = domain.AccountTypePhone } else { if email == "" { email = loginID } req.AccountType = domain.AccountTypeEmail } } if display == "" { if email != "" { display = strings.Split(email, "@")[0] } else { display = loginID } } var passHash string if platform == domain.PlatformPassword { if req.Password == "" { return nil, nil, domain.ErrWeakPassword } passHash, err = HashPassword(req.Password, s.BcryptCost) if err != nil { return nil, nil, err } } status := domain.StatusActive if platform == domain.PlatformPassword { status = domain.StatusUnverified } m := &domain.Member{ UID: uid, TenantID: "default", Email: email, Phone: phone, DisplayName: display, Roles: []string{domain.RoleMember}, Status: status, EmailVerified: false, InviteCode: NewInviteCode(), PasswordHash: passHash, Timezone: "Asia/Taipei", PreferredLanguage: "zh-TW", Currency: "TWD", CreatedAt: now, UpdatedAt: now, } if err := s.Repo.CreateMember(ctx, m); err != nil { return nil, nil, err } accType := req.AccountType if accType == "" { if platform == domain.PlatformPassword { if phone != "" && email == "" { accType = domain.AccountTypePhone } else { accType = domain.AccountTypeEmail } } else { accType = domain.AccountTypePlatform } } ident := &domain.Identity{ UID: uid, LoginID: loginID, Platform: platform, AccountType: accType, PasswordHash: passHash, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.CreateIdentity(ctx, ident); err != nil { return nil, nil, err } pair, err := s.issueSession(m) if err != nil { return m, nil, err } return m, pair, nil } func (s *AccountService) LoginPassword(ctx context.Context, loginID, password, platform string) (*domain.Member, *tokenDomain.TokenPair, error) { if platform == "" { platform = domain.PlatformPassword } loginID = strings.TrimSpace(loginID) if platform == domain.PlatformPassword { loginID = strings.ToLower(loginID) } // Prefer identity; fallback member email for seed admin without identity row ident, err := s.Repo.FindIdentity(ctx, loginID, platform) var m *domain.Member switch { case err == nil: hash := ident.PasswordHash if hash == "" || !CheckPasswordHash(password, hash) { // also try member primary password m, err = s.Repo.FindByUID(ctx, ident.UID) if err != nil { return nil, nil, err } if m.PasswordHash == "" || !CheckPasswordHash(password, m.PasswordHash) { return nil, nil, domain.ErrBadPassword } } else { m, err = s.Repo.FindByUID(ctx, ident.UID) if err != nil { return nil, nil, err } } case errors.Is(err, domain.ErrIdentityNotFound): // legacy: email login without identity m, err = s.Repo.FindByEmail(ctx, loginID) if errors.Is(err, domain.ErrNotFound) { return nil, nil, domain.ErrBadPassword } if err != nil { return nil, nil, err // DB/auth errors must not look like bad password } if m.PasswordHash == "" || !CheckPasswordHash(password, m.PasswordHash) { return nil, nil, domain.ErrBadPassword } // auto-heal identity for seed users _ = s.Repo.CreateIdentity(ctx, &domain.Identity{ UID: m.UID, LoginID: m.Email, Platform: domain.PlatformPassword, AccountType: domain.AccountTypeEmail, PasswordHash: m.PasswordHash, CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(), }) default: // infrastructure failure (e.g. Mongo auth) return nil, nil, err } if m.IsSuspended() { return nil, nil, domain.ErrSuspended } pair, err := s.issueSession(m) if err != nil { return nil, nil, err } return m, pair, nil } // Login — Harbor Desk email login shorthand func (s *AccountService) Login(ctx context.Context, email, password string) (*domain.Member, *tokenDomain.TokenPair, error) { return s.LoginPassword(ctx, email, password, domain.PlatformPassword) } func (s *AccountService) LoginOAuth(ctx context.Context, platform, subject, email, displayName string) (*domain.Member, *tokenDomain.TokenPair, error) { platform = strings.ToLower(strings.TrimSpace(platform)) subject = strings.TrimSpace(subject) if platform == "" || subject == "" { return nil, nil, domain.ErrOAuthFailed } email = strings.ToLower(strings.TrimSpace(email)) if ident, err := s.Repo.FindIdentity(ctx, subject, platform); err == nil { m, err := s.Repo.FindByUID(ctx, ident.UID) if err != nil { return nil, nil, err } if m.IsSuspended() { return nil, nil, domain.ErrSuspended } pair, err := s.issueSession(m) return m, pair, err } // new member via oauth req := domain.CreateLoginUserRequest{ LoginID: subject, Platform: platform, AccountType: domain.AccountTypePlatform, DisplayName: displayName, Email: email, } m, pair, err := s.CreateUserAccount(ctx, req) if err != nil { return nil, nil, err } // oauth accounts treated as verified email when provider gives email if email != "" { now := domain.NowNano() m.EmailVerified = true m.EmailVerifiedAt = &now m.Status = domain.StatusActive m.UpdatedAt = now _ = s.Repo.UpdateMember(ctx, m) } return m, pair, nil } // ---------- query ---------- func (s *AccountService) GetUIDByAccount(ctx context.Context, account, platform string) (int64, error) { ident, err := s.Repo.FindIdentity(ctx, account, platform) if err != nil { return 0, err } return ident.UID, nil } func (s *AccountService) GetUserAccountInfo(ctx context.Context, account, platform string) (*domain.Identity, error) { return s.Repo.FindIdentity(ctx, account, platform) } func (s *AccountService) GetUserInfo(ctx context.Context, uid int64) (*domain.Member, error) { return s.Repo.FindByUID(ctx, uid) } func (s *AccountService) ListMember(ctx context.Context, page, pageSize int, query, status string) ([]*domain.Member, int64, error) { return s.Repo.ListPage(ctx, page, pageSize, query, status) } func (s *AccountService) ListIdentities(ctx context.Context, uid int64) ([]*domain.Identity, error) { return s.Repo.ListIdentitiesByUID(ctx, uid) } func (s *AccountService) FindLoginIDsByUID(ctx context.Context, uid int64) ([]*domain.Identity, error) { return s.Repo.ListIdentitiesByUID(ctx, uid) } // ---------- update ---------- func (s *AccountService) UpdateUserToken(ctx context.Context, loginID, platform, newPassword string) error { hash, err := HashPassword(newPassword, s.BcryptCost) if err != nil { return err } if err := s.Repo.UpdateIdentityPassword(ctx, loginID, platform, hash); err != nil { return err } // sync primary password on member if platform password identity ident, err := s.Repo.FindIdentity(ctx, loginID, platform) if err != nil { return err } m, err := s.Repo.FindByUID(ctx, ident.UID) if err != nil { return err } m.PasswordHash = hash m.UpdatedAt = domain.NowNano() if err := s.Repo.UpdateMember(ctx, m); err != nil { return err } return s.Repo.RevokeAllRefreshByUID(ctx, ident.UID) } func (s *AccountService) UpdateUserInfo(ctx context.Context, uid int64, patch *domain.UpdateUserInfoPatch) (*domain.Member, error) { m, err := s.Repo.FindByUID(ctx, uid) if err != nil { return nil, err } if patch == nil { return m, nil } if patch.DisplayName != nil { m.DisplayName = *patch.DisplayName } if patch.Nickname != nil { m.Nickname = *patch.Nickname } if patch.FullName != nil { m.FullName = *patch.FullName } if patch.AvatarURL != nil { m.AvatarURL = *patch.AvatarURL } if patch.Bio != nil { m.Bio = *patch.Bio } if patch.Timezone != nil { m.Timezone = *patch.Timezone } if patch.NotifyEmail != nil { m.NotifyEmail = *patch.NotifyEmail } if patch.GenderCode != nil { m.GenderCode = *patch.GenderCode } if patch.Birthdate != nil { m.Birthdate = *patch.Birthdate } if patch.National != nil { m.National = *patch.National } if patch.Address != nil { m.Address = *patch.Address } if patch.PostCode != nil { m.PostCode = *patch.PostCode } if patch.PreferredLanguage != nil { m.PreferredLanguage = *patch.PreferredLanguage } if patch.Currency != nil { m.Currency = *patch.Currency } if patch.Email != nil { newEmail := strings.ToLower(strings.TrimSpace(*patch.Email)) if newEmail != "" && newEmail != m.Email { m.Email = newEmail m.EmailVerified = false m.EmailVerifiedAt = nil } } if patch.Phone != nil { m.Phone = strings.TrimSpace(*patch.Phone) m.PhoneVerified = false m.PhoneVerifiedAt = nil } passwordChanged := patch.NewPassword != nil && *patch.NewPassword != "" if passwordChanged { if patch.CurrentPassword == nil || !CheckPasswordHash(*patch.CurrentPassword, m.PasswordHash) { return nil, domain.ErrBadPassword } hash, err := HashPassword(*patch.NewPassword, s.BcryptCost) if err != nil { return nil, err } m.PasswordHash = hash // update platform identity password if exists if m.Email != "" { _ = s.Repo.UpdateIdentityPassword(ctx, m.Email, domain.PlatformPassword, hash) } } m.UpdatedAt = domain.NowNano() if err := s.Repo.UpdateMember(ctx, m); err != nil { return nil, err } if passwordChanged { if err := s.Repo.RevokeAllRefreshByUID(ctx, uid); err != nil { return nil, err } } return m, nil } func (s *AccountService) UpdateStatus(ctx context.Context, uid int64, status string) (*domain.Member, error) { m, err := s.Repo.FindByUID(ctx, uid) if err != nil { return nil, err } m.Status = status m.UpdatedAt = domain.NowNano() if err := s.Repo.UpdateMember(ctx, m); err != nil { return nil, err } return m, nil } // ---------- session ---------- func (s *AccountService) Refresh(ctx context.Context, refreshToken string) (*tokenDomain.TokenPair, error) { claims, err := s.Issuer.ParseRefresh(refreshToken) if err != nil { return nil, err } uid, ok := s.Repo.ConsumeRefresh(claims.JTI) if !ok || uid != claims.UID { return nil, tokenDomain.ErrInvalidToken } m, err := s.Repo.FindByUID(ctx, uid) if err != nil { return nil, err } if m.IsSuspended() { return nil, domain.ErrSuspended } return s.issueSession(m) } func (s *AccountService) Logout(refreshToken string) { if refreshToken == "" { return } if claims, err := s.Issuer.ParseRefresh(refreshToken); err == nil { s.Repo.RevokeRefresh(claims.JTI) } } func (s *AccountService) LogoutAll(ctx context.Context, uid int64) error { return s.Repo.RevokeAllRefreshByUID(ctx, uid) } // ---------- binding ---------- func (s *AccountService) BindAccount(ctx context.Context, uid int64, loginID, platform, accountType, password string) (*domain.Identity, error) { if _, err := s.Repo.FindByUID(ctx, uid); err != nil { return nil, err } platform = strings.ToLower(strings.TrimSpace(platform)) if platform == "" { platform = domain.PlatformPassword } loginID = strings.TrimSpace(loginID) if platform == domain.PlatformPassword { loginID = strings.ToLower(loginID) } if _, err := s.Repo.FindIdentity(ctx, loginID, platform); err == nil { return nil, domain.ErrIdentityTaken } var hash string if platform == domain.PlatformPassword && password != "" { var err error hash, err = HashPassword(password, s.BcryptCost) if err != nil { return nil, err } } if accountType == "" { if platform == domain.PlatformPassword { accountType = domain.AccountTypeEmail } else { accountType = domain.AccountTypePlatform } } now := domain.NowNano() ident := &domain.Identity{ UID: uid, LoginID: loginID, Platform: platform, AccountType: accountType, PasswordHash: hash, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.CreateIdentity(ctx, ident); err != nil { return nil, err } return ident, nil } func (s *AccountService) UnbindAccount(ctx context.Context, uid int64, loginID, platform string) error { ident, err := s.Repo.FindIdentity(ctx, loginID, platform) if err != nil { return err } if ident.UID != uid { return domain.ErrIdentityNotFound } // refuse unbind last identity list, err := s.Repo.ListIdentitiesByUID(ctx, uid) if err != nil { return err } if len(list) <= 1 { return domain.ErrAlreadyBound // reuse: cannot unbind last } return s.Repo.DeleteIdentity(ctx, loginID, platform) } func (s *AccountService) BindVerifyEmail(ctx context.Context, uid int64, email string) error { email = strings.ToLower(strings.TrimSpace(email)) m, err := s.Repo.FindByUID(ctx, uid) if err != nil { return err } now := domain.NowNano() m.Email = email m.EmailVerified = true m.EmailVerifiedAt = &now if m.Status == domain.StatusUnverified { m.Status = domain.StatusActive } m.UpdatedAt = now if err := s.Repo.UpdateMember(ctx, m); err != nil { return err } // ensure identity if _, err := s.Repo.FindIdentity(ctx, email, domain.PlatformPassword); err != nil { _ = s.Repo.CreateIdentity(ctx, &domain.Identity{ UID: uid, LoginID: email, Platform: domain.PlatformPassword, AccountType: domain.AccountTypeEmail, PasswordHash: m.PasswordHash, CreatedAt: now, UpdatedAt: now, }) } return nil } func (s *AccountService) BindVerifyPhone(ctx context.Context, uid int64, phone string) error { phone = strings.TrimSpace(phone) m, err := s.Repo.FindByUID(ctx, uid) if err != nil { return err } now := domain.NowNano() m.Phone = phone m.PhoneVerified = true m.PhoneVerifiedAt = &now m.UpdatedAt = now return s.Repo.UpdateMember(ctx, m) } // ---------- codes ---------- func (s *AccountService) GenerateCode(ctx context.Context, loginID, codeType string) (string, error) { code := randomDigits(6) if !s.Repo.SetCode(codeType, strings.ToLower(loginID), code, 10*time.Minute) { return "", domain.ErrCodeRateLimited } return code, nil } func (s *AccountService) VerifyCode(ctx context.Context, loginID, codeType, code string, consume bool) error { key := strings.ToLower(loginID) ok := false if consume { ok = s.Repo.CheckCode(codeType, key, code) } else { ok = s.Repo.PeekCode(codeType, key, code) } if !ok { return domain.ErrInvalidCode } return nil } func (s *AccountService) VerifyPlatformAuthResult(ctx context.Context, loginID, platform, password string) (bool, error) { ident, err := s.Repo.FindIdentity(ctx, loginID, platform) if err != nil { return false, err } if ident.PasswordHash == "" { return false, nil } return CheckPasswordHash(password, ident.PasswordHash), nil } // ---------- OAuth providers ---------- func (s *AccountService) VerifyGoogleIDToken(ctx context.Context, idToken string) (subject, email, name string, err error) { if strings.TrimSpace(s.GoogleClientID) == "" { return "", "", "", domain.ErrOAuthFailed } info, e := googleIDTokenInfo(ctx, idToken, s.GoogleClientID) if e != nil { return "", "", "", domain.ErrOAuthFailed } return info.Subject, info.Email, info.Name, nil } func (s *AccountService) LineCodeToProfile(ctx context.Context, code, redirectURI string) (subject, displayName, email string, err error) { if s.LineClientID == "" || s.LineClientSecret == "" { return "", "", "", domain.ErrOAuthFailed } if redirectURI == "" { redirectURI = s.LineRedirectURI } tok, e := lineExchangeCode(ctx, code, redirectURI, s.LineClientID, s.LineClientSecret) if e != nil { return "", "", "", domain.ErrOAuthFailed } prof, e := lineProfile(ctx, tok) if e != nil { return "", "", "", domain.ErrOAuthFailed } return prof.UserID, prof.DisplayName, "", nil } // ---------- Harbor Desk convenience ---------- func (s *AccountService) RegisterEmail(ctx context.Context, email, password, displayName string) (*domain.Member, *tokenDomain.TokenPair, error) { return s.CreateUserAccount(ctx, domain.CreateLoginUserRequest{ LoginID: email, Platform: domain.PlatformPassword, AccountType: domain.AccountTypeEmail, Password: password, DisplayName: displayName, Email: email, }) } // Register — alias for logic layer func (s *AccountService) Register(ctx context.Context, email, password, displayName string) (*domain.Member, *tokenDomain.TokenPair, error) { return s.RegisterEmail(ctx, email, password, displayName) } func (s *AccountService) Me(ctx context.Context, uid int64) (*domain.Member, error) { m, err := s.Repo.FindByUID(ctx, uid) if err != nil { return nil, err } if m.IsSuspended() { return nil, domain.ErrSuspended } return m, nil } func (s *AccountService) RequestPasswordReset(ctx context.Context, email string) (string, error) { email = strings.ToLower(strings.TrimSpace(email)) if email == "" || !strings.Contains(email, "@") { return "", domain.ErrEmailNotRegistered } code := randomDigits(6) // try identity or member — unknown email must error (product: 無此信箱不寄) if _, err := s.Repo.FindByEmail(ctx, email); err == nil { if !s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute) { return "", domain.ErrCodeRateLimited } return code, nil } if _, err := s.Repo.FindIdentity(ctx, email, domain.PlatformPassword); err == nil { if !s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute) { return "", domain.ErrCodeRateLimited } return code, nil } return "", domain.ErrEmailNotRegistered } func (s *AccountService) ResetPassword(ctx context.Context, email, code, newPassword string) error { email = strings.ToLower(strings.TrimSpace(email)) code = strings.TrimSpace(code) // 1) 先驗密碼政策(失敗不消費驗證碼) if err := ValidatePasswordPolicy(newPassword); err != nil { return err } // 2) Peek:輸錯/未過期都不刪(與信箱驗證同一套) match := s.Repo.PeekCode(domain.CodeTypeForgetPassword, email, code) || s.Repo.PeekCode("reset", email, code) if !match { return domain.ErrInvalidCode } // 3) 業務先寫入;成功才消費碼 if err := s.UpdateUserToken(ctx, email, domain.PlatformPassword, newPassword); err != nil { if !errors.Is(err, domain.ErrIdentityNotFound) { return err } // seed / legacy member without identity row hash, herr := HashPassword(newPassword, s.BcryptCost) if herr != nil { return herr } m, err := s.Repo.FindByEmail(ctx, email) if err != nil { return domain.ErrNotFound } m.PasswordHash = hash m.UpdatedAt = domain.NowNano() if err := s.Repo.UpdateMember(ctx, m); err != nil { return err } _ = s.Repo.CreateIdentity(ctx, &domain.Identity{ UID: m.UID, LoginID: email, Platform: domain.PlatformPassword, AccountType: domain.AccountTypeEmail, PasswordHash: hash, CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(), }) if err := s.Repo.RevokeAllRefreshByUID(ctx, m.UID); err != nil { return err } } // 4) 成功才刪碼(兩種 kind 都清) _ = s.Repo.CheckCode(domain.CodeTypeForgetPassword, email, code) _ = s.Repo.CheckCode("reset", email, code) return nil } func (s *AccountService) SendEmailCode(ctx context.Context, uid int64) (string, error) { m, err := s.Repo.FindByUID(ctx, uid) if err != nil { return "", err } code := randomDigits(6) key := m.Email if key == "" { key = fmt.Sprintf("%d", uid) } if !s.Repo.SetCode(domain.CodeTypeEmail, strings.ToLower(key), code, 30*time.Minute) { return "", domain.ErrCodeRateLimited } // also store by uid for VerifyEmailCode if key != fmt.Sprintf("%d", uid) { _ = s.Repo.SetCode(domain.CodeTypeEmail, fmt.Sprintf("%d", uid), code, 30*time.Minute) } return code, nil } func (s *AccountService) VerifyEmailCode(ctx context.Context, uid int64, code string) (*domain.Member, error) { m, err := s.Repo.FindByUID(ctx, uid) if err != nil { return nil, err } uidKey := fmt.Sprintf("%d", uid) emailKey := "" if m.Email != "" { emailKey = strings.ToLower(m.Email) } code = strings.TrimSpace(code) // 與重設密碼同一套:Peek(輸錯不刪)→ 業務成功 → CheckCode 消費 match := s.Repo.PeekCode(domain.CodeTypeEmail, uidKey, code) if !match && emailKey != "" { match = s.Repo.PeekCode(domain.CodeTypeEmail, emailKey, code) } if !match { return nil, domain.ErrInvalidCode } now := domain.NowNano() m.EmailVerified = true m.EmailVerifiedAt = &now if m.Status == domain.StatusUnverified { m.Status = domain.StatusActive } m.UpdatedAt = now if err := s.Repo.UpdateMember(ctx, m); err != nil { // 業務失敗:碼仍有效,可重試 return nil, err } // 成功才消費兩把 key _ = s.Repo.CheckCode(domain.CodeTypeEmail, uidKey, code) if emailKey != "" { _ = s.Repo.CheckCode(domain.CodeTypeEmail, emailKey, code) } return m, nil } // VerifyEmail — alias func (s *AccountService) VerifyEmail(ctx context.Context, uid int64, code string) (*domain.Member, error) { return s.VerifyEmailCode(ctx, uid, code) } // ---------- helpers ---------- func (s *AccountService) issueSession(m *domain.Member) (*tokenDomain.TokenPair, error) { pair, jti, exp, err := s.Issuer.Issue(m.UID, m.Roles) if err != nil { return nil, err } s.Repo.SaveRefresh(jti, m.UID, exp) return pair, nil } func NewInviteCode() string { var b [4]byte _, _ = rand.Read(b[:]) return strings.ToUpper(hex.EncodeToString(b[:])) } func randomDigits(n int) string { var b strings.Builder for i := 0; i < n; i++ { v, _ := rand.Int(rand.Reader, big.NewInt(10)) b.WriteByte(byte('0' + v.Int64())) } return b.String() } func looksLikePhone(s string) bool { if len(s) < 8 { return false } for _, c := range s { if c >= '0' && c <= '9' || c == '+' { continue } return false } return true }