814 lines
23 KiB
Go
814 lines
23 KiB
Go
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
|
||
}
|