package auth import ( "context" "errors" "strings" "time" "galo/internal/httpx" "galo/internal/models" ) type TokenPair struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` ExpiresIn int64 `json:"expires_in"` } type Service struct { store Repository tm *TokenManager } func NewService(store Repository, tm *TokenManager) *Service { return &Service{store: store, tm: tm} } // Register creates an account with at least one of email/phone. A missing // identifier is stored as NULL so phone-only accounts are possible. func (s *Service) Register(ctx context.Context, email, phone, password, name string) (*models.User, *TokenPair, error) { name = strings.TrimSpace(name) if name == "" { return nil, nil, httpx.BadRequest("invalid_name", "name is required") } if len(password) < 8 { return nil, nil, httpx.BadRequest("weak_password", "password must be at least 8 characters") } normEmail, normPhone, err := s.normalizeIdentifiers(email, phone) if err != nil { return nil, nil, err } if normEmail == "" && normPhone == "" { return nil, nil, httpx.BadRequest("identifier_required", "email or phone is required") } if normEmail != "" { if _, err := s.store.GetUserByEmail(ctx, normEmail); err == nil { return nil, nil, httpx.Conflict("email_taken", "email is already registered") } else if !errors.Is(err, ErrNotFound) { return nil, nil, err } } if normPhone != "" { if _, err := s.store.GetUserByPhone(ctx, normPhone); err == nil { return nil, nil, httpx.Conflict("phone_taken", "phone is already registered") } else if !errors.Is(err, ErrNotFound) { return nil, nil, err } } hash, err := HashPassword(password) if err != nil { return nil, nil, err } u := &models.User{ ID: NewID(), Email: normEmail, Phone: normPhone, PasswordHash: hash, Name: name, CreatedAt: time.Now(), } if err := s.store.CreateUser(ctx, u); err != nil { return nil, nil, mapIdentifierConflict(err) } pair, err := s.issuePair(ctx, u) if err != nil { return nil, nil, err } return u, pair, nil } // Login authenticates by a single identifier — email or E.164 phone — and never // reveals which part of the credentials was wrong. func (s *Service) Login(ctx context.Context, identifier, password string) (*models.User, *TokenPair, error) { kind, value, err := NormalizeIdentifier(identifier) if err != nil { return nil, nil, invalidCredentials() } var u *models.User if kind == IdentifierEmail { u, err = s.store.GetUserByEmail(ctx, value) } else { u, err = s.store.GetUserByPhone(ctx, value) } if err != nil { if errors.Is(err, ErrNotFound) { return nil, nil, invalidCredentials() } return nil, nil, err } if !CheckPassword(u.PasswordHash, password) { return nil, nil, invalidCredentials() } pair, err := s.issuePair(ctx, u) if err != nil { return nil, nil, err } return u, pair, nil } func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) { hash := HashRefreshToken(refreshToken) t, err := s.store.GetRefreshToken(ctx, hash) if err != nil { if errors.Is(err, ErrNotFound) { return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired") } return nil, err } if t.RevokedAt != nil || time.Now().After(t.ExpiresAt) { return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired") } u, err := s.store.GetUserByID(ctx, t.UserID) if err != nil { return nil, httpx.Unauthorized("invalid_refresh_token", "refresh token is invalid or expired") } if err := s.store.RevokeRefreshToken(ctx, t.ID); err != nil { return nil, err } return s.issuePair(ctx, u) } func (s *Service) Logout(ctx context.Context, refreshToken string) error { hash := HashRefreshToken(refreshToken) t, err := s.store.GetRefreshToken(ctx, hash) if err != nil { if errors.Is(err, ErrNotFound) { return nil } return err } return s.store.RevokeRefreshToken(ctx, t.ID) } // UpdateName changes the display name, rejecting an empty one. func (s *Service) UpdateName(ctx context.Context, userID, name string) (*models.User, error) { name = strings.TrimSpace(name) if name == "" { return nil, httpx.BadRequest("invalid_name", "name is required") } if err := s.store.UpdateName(ctx, userID, name); err != nil { return nil, userErr(err) } return s.store.GetUserByID(ctx, userID) } // ChangePassword verifies the current password and revokes every refresh token // so all sessions must log in again. func (s *Service) ChangePassword(ctx context.Context, userID, current, newPassword string) error { if len(newPassword) < 8 { return httpx.BadRequest("weak_password", "password must be at least 8 characters") } u, err := s.store.GetUserByID(ctx, userID) if err != nil { return userErr(err) } if !CheckPassword(u.PasswordHash, current) { return httpx.Unauthorized("current_password_incorrect", "current password is incorrect") } hash, err := HashPassword(newPassword) if err != nil { return err } if err := s.store.UpdatePasswordHash(ctx, userID, hash); err != nil { return userErr(err) } return s.store.RevokeAllUserTokens(ctx, userID) } // AddIdentifier adds the one missing login identifier. Rejecting an attempt to // re-set an existing one or to provide both at once keeps identifiers immutable. func (s *Service) AddIdentifier(ctx context.Context, userID, email, phone string) (*models.User, error) { u, err := s.store.GetUserByID(ctx, userID) if err != nil { return nil, userErr(err) } normEmail, normPhone, err := s.normalizeIdentifiers(email, phone) if err != nil { return nil, err } if normEmail == "" && normPhone == "" { return nil, httpx.BadRequest("identifier_required", "email or phone is required") } if normEmail != "" && normPhone != "" { return nil, httpx.BadRequest("invalid_request", "provide only one identifier at a time") } if u.Email != "" && u.Phone != "" { return nil, httpx.BadRequest("identifiers_complete", "both identifiers are already set") } if normEmail != "" { if u.Email != "" { return nil, httpx.BadRequest("email_already_set", "email can no longer be changed") } if _, err := s.store.GetUserByEmail(ctx, normEmail); err == nil { return nil, httpx.Conflict("email_taken", "email is already registered") } else if !errors.Is(err, ErrNotFound) { return nil, err } } if normPhone != "" { if u.Phone != "" { return nil, httpx.BadRequest("phone_already_set", "phone can no longer be changed") } if _, err := s.store.GetUserByPhone(ctx, normPhone); err == nil { return nil, httpx.Conflict("phone_taken", "phone is already registered") } else if !errors.Is(err, ErrNotFound) { return nil, err } } if err := s.store.AddIdentifier(ctx, userID, normEmail, normPhone); err != nil { return nil, mapIdentifierConflict(err) } return s.store.GetUserByID(ctx, userID) } // DeleteAccount removes the account and everything that cascades from it after // confirming the password. It is immediate and irreversible. func (s *Service) DeleteAccount(ctx context.Context, userID, password string) error { u, err := s.store.GetUserByID(ctx, userID) if err != nil { return userErr(err) } if !CheckPassword(u.PasswordHash, password) { return httpx.Unauthorized("current_password_incorrect", "password is incorrect") } if err := s.store.DeleteUser(ctx, userID); err != nil { return userErr(err) } return nil } // normalizeIdentifiers trims and canonicalises the provided email/phone. Empty // fields stay empty; supplied ones must be valid. func (s *Service) normalizeIdentifiers(email, phone string) (string, string, error) { var normEmail, normPhone string if e := strings.TrimSpace(email); e != "" { e = strings.ToLower(e) if !validEmail(e) { return "", "", httpx.BadRequest("invalid_email", "valid email is required") } normEmail = e } if p := strings.TrimSpace(phone); p != "" { np, err := NormalizePhone(p) if err != nil { return "", "", err } normPhone = np } return normEmail, normPhone, nil } func invalidCredentials() error { return httpx.Unauthorized("invalid_credentials", "invalid identifier or password") } // userErr converts a missing user into an unauthorized/not-found style error // appropriate for authenticated profile operations. func userErr(err error) error { if errors.Is(err, ErrNotFound) { return httpx.Unauthorized("unauthorized", "user not found") } return err } func mapIdentifierConflict(err error) error { switch { case errors.Is(err, ErrEmailTaken): return httpx.Conflict("email_taken", "email is already registered") case errors.Is(err, ErrPhoneTaken): return httpx.Conflict("phone_taken", "phone is already registered") default: return err } } func (s *Service) issuePair(ctx context.Context, u *models.User) (*TokenPair, error) { access, err := s.tm.GenerateAccess(u.ID, u.Email) if err != nil { return nil, err } raw, hash, err := NewRefreshToken() if err != nil { return nil, err } rt := &models.RefreshToken{ ID: NewID(), UserID: u.ID, TokenHash: hash, ExpiresAt: time.Now().Add(s.tm.RefreshTTL()), } if err := s.store.CreateRefreshToken(ctx, rt); err != nil { return nil, err } return &TokenPair{ AccessToken: access, RefreshToken: raw, TokenType: "Bearer", ExpiresIn: int64(s.tm.AccessTTL().Seconds()), }, nil }