Files
honeyDueAPI/internal/services/auth_service.go
T
Trey t e881d37de0
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Migrate Auth/Contractor/Document/Notification/Subscription services to ctx
Every public method on these five services now takes ctx context.Context as
the first arg and routes its repo calls through .WithContext(ctx). With
TaskService and ResidenceService already migrated, this means every
in-process service that touches Postgres now produces a flame graph in
Jaeger where the SQL spans nest under the parent HTTP request span.

Endpoints now fully traced (HTTP → service → SQL):
- /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification
- /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile
- /api/contractors/* (CRUD + favorite + by-residence + tasks)
- /api/documents/* (CRUD + activate/deactivate + image upload/delete)
- /api/notifications/* (list, count, mark-read, prefs, devices)
- /api/subscription/* (status, purchase, cancel, triggers, promotions)
- All previously-migrated /api/tasks/* and /api/residences/* paths

Internal helpers also threaded:
- TaskService.sendTaskCompletedNotification → forwards ctx
- TaskService.UpdateUserTimezone → forwards ctx to NotificationService
- ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit
- NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx

~75 method signatures, ~120 handler/test call sites updated. Tests pass
green; the only failure is the pre-existing flaky TaskHandler_QuickComplete
SQLite race that fails ~60% of runs on master.

Step 3 of the observability plan is now genuinely complete: every API
endpoint backed by a Go service emits a per-request flame graph with
HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:26:21 -05:00

966 lines
30 KiB
Go

package services
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
)
// Deprecated: Legacy error constants - kept for reference during transition
// Use apperrors package instead
var (
// ErrInvalidCredentials = errors.New("invalid credentials")
// ErrUsernameTaken = errors.New("username already taken")
// ErrEmailTaken = errors.New("email already taken")
// ErrUserInactive = errors.New("user account is inactive")
// ErrInvalidCode = errors.New("invalid verification code")
// ErrCodeExpired = errors.New("verification code expired")
// ErrAlreadyVerified = errors.New("email already verified")
// ErrRateLimitExceeded = errors.New("too many requests, please try again later")
// ErrInvalidResetToken = errors.New("invalid or expired reset token")
ErrAppleSignInFailed = errors.New("Apple Sign In failed")
ErrGoogleSignInFailed = errors.New("Google Sign In failed")
)
// AuthService handles authentication business logic
type AuthService struct {
userRepo *repositories.UserRepository
notificationRepo *repositories.NotificationRepository
cfg *config.Config
}
// NewAuthService creates a new auth service
func NewAuthService(userRepo *repositories.UserRepository, cfg *config.Config) *AuthService {
return &AuthService{
userRepo: userRepo,
cfg: cfg,
}
}
// SetNotificationRepository sets the notification repository for creating notification preferences
func (s *AuthService) SetNotificationRepository(notificationRepo *repositories.NotificationRepository) {
s.notificationRepo = notificationRepo
}
// Login authenticates a user and returns a token
func (s *AuthService) Login(ctx context.Context, req *requests.LoginRequest) (*responses.LoginResponse, error) {
// Find user by username or email
identifier := req.Username
if identifier == "" {
identifier = req.Email
}
user, err := s.userRepo.WithContext(ctx).FindByUsernameOrEmail(identifier)
if err != nil {
if errors.Is(err, repositories.ErrUserNotFound) {
return nil, apperrors.Unauthorized("error.invalid_credentials")
}
return nil, apperrors.Internal(err)
}
// Check if user is active
if !user.IsActive {
return nil, apperrors.Unauthorized("error.account_inactive")
}
// Verify password
if !user.CheckPassword(req.Password) {
return nil, apperrors.Unauthorized("error.invalid_credentials")
}
// Get or create auth token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Update last login
if err := s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID); err != nil {
// Log error but don't fail the login
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to update last login")
}
return &responses.LoginResponse{
Token: token.Key,
User: responses.NewUserResponse(user),
}, nil
}
// Register creates a new user account.
// F-10: User creation, profile creation, notification preferences, and confirmation code
// are wrapped in a transaction for atomicity.
func (s *AuthService) Register(ctx context.Context, req *requests.RegisterRequest) (*responses.RegisterResponse, string, error) {
// Check if username exists
exists, err := s.userRepo.WithContext(ctx).ExistsByUsername(req.Username)
if err != nil {
return nil, "", apperrors.Internal(err)
}
if exists {
return nil, "", apperrors.Conflict("error.username_taken")
}
// Check if email exists
exists, err = s.userRepo.WithContext(ctx).ExistsByEmail(req.Email)
if err != nil {
return nil, "", apperrors.Internal(err)
}
if exists {
return nil, "", apperrors.Conflict("error.email_taken")
}
// Create user
user := &models.User{
Username: req.Username,
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
IsActive: true,
}
// Hash password
if err := user.SetPassword(req.Password); err != nil {
return nil, "", apperrors.Internal(err)
}
// Generate confirmation code - use fixed code when DEBUG_FIXED_CODES is enabled for easier local testing
var code string
if s.cfg.Server.DebugFixedCodes {
code = "123456"
} else {
code = generateSixDigitCode()
}
expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry)
// Wrap user creation + profile + notification preferences + confirmation code in a transaction
txErr := s.userRepo.WithContext(ctx).Transaction(func(txRepo *repositories.UserRepository) error {
// Save user
if err := txRepo.Create(user); err != nil {
return err
}
// Create user profile
if _, err := txRepo.GetOrCreateProfile(user.ID); err != nil {
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to create user profile during registration")
}
// Create notification preferences with all options enabled
if s.notificationRepo != nil {
if _, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(user.ID); err != nil {
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to create notification preferences during registration")
}
}
// Create confirmation code
if _, err := txRepo.CreateConfirmationCode(user.ID, code, expiresAt); err != nil {
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to create confirmation code during registration")
}
return nil
})
if txErr != nil {
return nil, "", apperrors.Internal(txErr)
}
// Create auth token (outside transaction since token generation is idempotent)
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
if err != nil {
return nil, "", apperrors.Internal(err)
}
return &responses.RegisterResponse{
Token: token.Key,
User: responses.NewUserResponse(user),
Message: "Registration successful. Please check your email to verify your account.",
}, code, nil
}
// RefreshToken handles token refresh logic.
// - If token is expired (> expiryDays old), returns error (must re-login).
// - If token is in the renewal window (> refreshDays old), generates a new token.
// - If token is still fresh (< refreshDays old), returns the existing token (no-op).
func (s *AuthService) RefreshToken(ctx context.Context, tokenKey string, userID uint) (*responses.RefreshTokenResponse, error) {
expiryDays := s.cfg.Security.TokenExpiryDays
if expiryDays <= 0 {
expiryDays = 90
}
refreshDays := s.cfg.Security.TokenRefreshDays
if refreshDays <= 0 {
refreshDays = 60
}
// Look up the token
authToken, err := s.userRepo.WithContext(ctx).FindTokenByKey(tokenKey)
if err != nil {
return nil, apperrors.Unauthorized("error.invalid_token")
}
// Verify ownership
if authToken.UserID != userID {
return nil, apperrors.Unauthorized("error.invalid_token")
}
tokenAge := time.Since(authToken.Created)
expiryDuration := time.Duration(expiryDays) * 24 * time.Hour
refreshDuration := time.Duration(refreshDays) * 24 * time.Hour
// Token is expired — must re-login
if tokenAge > expiryDuration {
return nil, apperrors.Unauthorized("error.token_expired")
}
// Token is still fresh — no-op refresh
if tokenAge < refreshDuration {
return &responses.RefreshTokenResponse{
Token: tokenKey,
Message: "Token is still valid.",
}, nil
}
// Token is in the renewal window — generate a new one
// Delete the old token
if err := s.userRepo.WithContext(ctx).DeleteToken(tokenKey); err != nil {
log.Warn().Err(err).Str("token", tokenKey[:8]+"...").Msg("Failed to delete old token during refresh")
}
// Create a new token
newToken, err := s.userRepo.WithContext(ctx).CreateToken(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.RefreshTokenResponse{
Token: newToken.Key,
Message: "Token refreshed successfully.",
}, nil
}
// Logout invalidates a user's token
func (s *AuthService) Logout(ctx context.Context, token string) error {
return s.userRepo.WithContext(ctx).DeleteToken(token)
}
// GetCurrentUser returns the current authenticated user with profile
func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*responses.CurrentUserResponse, error) {
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(userID)
if err != nil {
return nil, err
}
authProvider, err := s.userRepo.WithContext(ctx).FindAuthProvider(userID)
if err != nil {
// Log but don't fail - default to "email"
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to determine auth provider")
authProvider = "email"
}
response := responses.NewCurrentUserResponse(user, authProvider)
return &response, nil
}
// DeleteAccount deletes a user's account and all associated data.
// For email auth users, password verification is required.
// For social auth users, confirmation string "DELETE" is required.
// Returns a list of file URLs that need to be deleted from disk.
func (s *AuthService) DeleteAccount(ctx context.Context, userID uint, password, confirmation *string) ([]string, error) {
// Fetch user
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
if err != nil {
if errors.Is(err, repositories.ErrUserNotFound) {
return nil, apperrors.NotFound("error.user_not_found")
}
return nil, apperrors.Internal(err)
}
// Determine auth provider
authProvider, err := s.userRepo.WithContext(ctx).FindAuthProvider(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Validate credentials based on auth provider
if authProvider == "email" {
if password == nil || *password == "" {
return nil, apperrors.BadRequest("error.password_required")
}
if !user.CheckPassword(*password) {
return nil, apperrors.Unauthorized("error.invalid_credentials")
}
} else {
// Social auth (apple or google) - require confirmation
if confirmation == nil || *confirmation != "DELETE" {
return nil, apperrors.BadRequest("error.confirmation_required")
}
}
// Start transaction and cascade delete
var fileURLs []string
txErr := s.userRepo.WithContext(ctx).Transaction(func(txRepo *repositories.UserRepository) error {
urls, err := txRepo.DeleteUserCascade(userID)
if err != nil {
return err
}
fileURLs = urls
return nil
})
if txErr != nil {
return nil, apperrors.Internal(txErr)
}
return fileURLs, nil
}
// UpdateProfile updates a user's profile
func (s *AuthService) UpdateProfile(ctx context.Context, userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) {
user, err := s.userRepo.WithContext(ctx).FindByID(userID)
if err != nil {
return nil, err
}
// Check if new email is taken (if email is being changed)
if req.Email != nil && *req.Email != user.Email {
exists, err := s.userRepo.WithContext(ctx).ExistsByEmail(*req.Email)
if err != nil {
return nil, apperrors.Internal(err)
}
if exists {
return nil, apperrors.Conflict("error.email_already_taken")
}
user.Email = *req.Email
}
if req.FirstName != nil {
user.FirstName = *req.FirstName
}
if req.LastName != nil {
user.LastName = *req.LastName
}
if err := s.userRepo.WithContext(ctx).Update(user); err != nil {
return nil, apperrors.Internal(err)
}
// Reload with profile
user, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(userID)
if err != nil {
return nil, err
}
authProvider, err := s.userRepo.WithContext(ctx).FindAuthProvider(userID)
if err != nil {
log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to determine auth provider")
authProvider = "email"
}
response := responses.NewCurrentUserResponse(user, authProvider)
return &response, nil
}
// VerifyEmail verifies a user's email with a confirmation code
func (s *AuthService) VerifyEmail(ctx context.Context, userID uint, code string) error {
// Get user profile
profile, err := s.userRepo.WithContext(ctx).GetOrCreateProfile(userID)
if err != nil {
return apperrors.Internal(err)
}
// Check if already verified
if profile.Verified {
return apperrors.BadRequest("error.email_already_verified")
}
// Check for test code when DEBUG_FIXED_CODES is enabled
if s.cfg.Server.DebugFixedCodes && code == "123456" {
if err := s.userRepo.WithContext(ctx).SetProfileVerified(userID, true); err != nil {
return apperrors.Internal(err)
}
return nil
}
// Find and validate confirmation code
confirmCode, err := s.userRepo.WithContext(ctx).FindConfirmationCode(userID, code)
if err != nil {
if errors.Is(err, repositories.ErrCodeNotFound) {
return apperrors.BadRequest("error.invalid_verification_code")
}
if errors.Is(err, repositories.ErrCodeExpired) {
return apperrors.BadRequest("error.verification_code_expired")
}
return apperrors.Internal(err)
}
// Mark code as used
if err := s.userRepo.WithContext(ctx).MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
return apperrors.Internal(err)
}
// Set profile as verified
if err := s.userRepo.WithContext(ctx).SetProfileVerified(userID, true); err != nil {
return apperrors.Internal(err)
}
return nil
}
// ResendVerificationCode creates and returns a new verification code
func (s *AuthService) ResendVerificationCode(ctx context.Context, userID uint) (string, error) {
// Get user profile
profile, err := s.userRepo.WithContext(ctx).GetOrCreateProfile(userID)
if err != nil {
return "", apperrors.Internal(err)
}
// Check if already verified
if profile.Verified {
return "", apperrors.BadRequest("error.email_already_verified")
}
// Generate new code - use fixed code when DEBUG_FIXED_CODES is enabled for easier local testing
var code string
if s.cfg.Server.DebugFixedCodes {
code = "123456"
} else {
code = generateSixDigitCode()
}
expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry)
if _, err := s.userRepo.WithContext(ctx).CreateConfirmationCode(userID, code, expiresAt); err != nil {
return "", apperrors.Internal(err)
}
return code, nil
}
// ForgotPassword initiates the password reset process
func (s *AuthService) ForgotPassword(ctx context.Context, email string) (string, *models.User, error) {
// Find user by email
user, err := s.userRepo.WithContext(ctx).FindByEmail(email)
if err != nil {
if errors.Is(err, repositories.ErrUserNotFound) {
// Don't reveal that the email doesn't exist
return "", nil, nil
}
return "", nil, err
}
// Check rate limit
count, err := s.userRepo.WithContext(ctx).CountRecentPasswordResetRequests(user.ID)
if err != nil {
return "", nil, apperrors.Internal(err)
}
if count >= int64(s.cfg.Security.MaxPasswordResetRate) {
return "", nil, apperrors.TooManyRequests("error.rate_limit_exceeded")
}
// Generate code and reset token - use fixed code when DEBUG_FIXED_CODES is enabled for easier local testing
var code string
if s.cfg.Server.DebugFixedCodes {
code = "123456"
} else {
code = generateSixDigitCode()
}
resetToken := generateResetToken()
expiresAt := time.Now().UTC().Add(s.cfg.Security.PasswordResetExpiry)
// Hash the code before storing
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
if err != nil {
return "", nil, apperrors.Internal(err)
}
if _, err := s.userRepo.WithContext(ctx).CreatePasswordResetCode(user.ID, string(codeHash), resetToken, expiresAt); err != nil {
return "", nil, apperrors.Internal(err)
}
return code, user, nil
}
// VerifyResetCode verifies a password reset code and returns a reset token
func (s *AuthService) VerifyResetCode(ctx context.Context, email, code string) (string, error) {
// Find the reset code
resetCode, user, err := s.userRepo.WithContext(ctx).FindPasswordResetCodeByEmail(email)
if err != nil {
if errors.Is(err, repositories.ErrUserNotFound) || errors.Is(err, repositories.ErrCodeNotFound) {
return "", apperrors.BadRequest("error.invalid_verification_code")
}
return "", apperrors.Internal(err)
}
// Check for test code when DEBUG_FIXED_CODES is enabled
if s.cfg.Server.DebugFixedCodes && code == "123456" {
return resetCode.ResetToken, nil
}
// Verify the code
if !resetCode.CheckCode(code) {
// Increment attempts
s.userRepo.WithContext(ctx).IncrementResetCodeAttempts(resetCode.ID)
return "", apperrors.BadRequest("error.invalid_verification_code")
}
// Check if code is still valid
if !resetCode.IsValid() {
if resetCode.Used {
return "", apperrors.BadRequest("error.invalid_verification_code")
}
if resetCode.Attempts >= resetCode.MaxAttempts {
return "", apperrors.TooManyRequests("error.rate_limit_exceeded")
}
return "", apperrors.BadRequest("error.verification_code_expired")
}
_ = user // user available if needed
return resetCode.ResetToken, nil
}
// ResetPassword resets the user's password using a reset token
func (s *AuthService) ResetPassword(ctx context.Context, resetToken, newPassword string) error {
// Find the reset code by token
resetCode, err := s.userRepo.WithContext(ctx).FindPasswordResetCodeByToken(resetToken)
if err != nil {
if errors.Is(err, repositories.ErrCodeNotFound) || errors.Is(err, repositories.ErrCodeExpired) {
return apperrors.BadRequest("error.invalid_reset_token")
}
return apperrors.Internal(err)
}
// Get the user
user, err := s.userRepo.WithContext(ctx).FindByID(resetCode.UserID)
if err != nil {
return apperrors.Internal(err)
}
// Update password
if err := user.SetPassword(newPassword); err != nil {
return apperrors.Internal(err)
}
if err := s.userRepo.WithContext(ctx).Update(user); err != nil {
return apperrors.Internal(err)
}
// Mark reset code as used
if err := s.userRepo.WithContext(ctx).MarkPasswordResetCodeUsed(resetCode.ID); err != nil {
// Log error but don't fail
log.Warn().Err(err).Uint("reset_code_id", resetCode.ID).Msg("Failed to mark reset code as used")
}
// Invalidate all existing tokens for this user (security measure)
if err := s.userRepo.WithContext(ctx).DeleteTokenByUserID(user.ID); err != nil {
// Log error but don't fail
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to delete user tokens after password reset")
}
return nil
}
// AppleSignIn handles Sign in with Apple authentication
func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthService, req *requests.AppleSignInRequest) (*responses.AppleSignInResponse, error) {
// 1. Verify the Apple JWT token
claims, err := appleAuth.VerifyIdentityToken(ctx, req.IDToken)
if err != nil {
return nil, apperrors.Unauthorized("error.invalid_credentials").Wrap(err)
}
// Use the subject from claims as the authoritative Apple ID
appleID := claims.Subject
if appleID == "" {
appleID = req.UserID // Fallback to request UserID
}
// 2. Check if this Apple ID is already linked to an account
existingAuth, err := s.userRepo.WithContext(ctx).FindByAppleID(appleID)
if err == nil && existingAuth != nil {
// User already linked with this Apple ID - log them in
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(existingAuth.UserID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !user.IsActive {
return nil, apperrors.Unauthorized("error.account_inactive")
}
// Get or create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Update last login
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID)
return &responses.AppleSignInResponse{
Token: token.Key,
User: responses.NewUserResponse(user),
IsNewUser: false,
}, nil
}
// 3. Check if email matches an existing user (for account linking)
email := getEmailFromRequest(req.Email, claims.Email)
if email != "" {
existingUser, err := s.userRepo.WithContext(ctx).FindByEmail(email)
if err == nil && existingUser != nil {
// S-06: Log auto-linking of social account to existing user
log.Warn().
Str("email", email).
Str("provider", "apple").
Uint("user_id", existingUser.ID).
Msg("Auto-linking social account to existing user by email match")
// Link Apple ID to existing account
appleAuthRecord := &models.AppleSocialAuth{
UserID: existingUser.ID,
AppleID: appleID,
Email: email,
IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(),
}
if err := s.userRepo.WithContext(ctx).CreateAppleSocialAuth(appleAuthRecord); err != nil {
return nil, apperrors.Internal(err)
}
// Mark as verified since Apple verified the email
_ = s.userRepo.WithContext(ctx).SetProfileVerified(existingUser.ID, true)
// Get or create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(existingUser.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Update last login
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(existingUser.ID)
// B-08: Check error from FindByIDWithProfile
existingUser, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(existingUser.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.AppleSignInResponse{
Token: token.Key,
User: responses.NewUserResponse(existingUser),
IsNewUser: false,
}, nil
}
}
// 4. Create new user
username := generateUniqueUsername(email, req.FirstName)
user := &models.User{
Username: username,
Email: getEmailOrDefault(email),
FirstName: getStringOrEmpty(req.FirstName),
LastName: getStringOrEmpty(req.LastName),
IsActive: true,
}
// Set a random password (user won't use it since they log in with Apple)
randomPassword := generateResetToken()
_ = user.SetPassword(randomPassword)
if err := s.userRepo.WithContext(ctx).Create(user); err != nil {
return nil, apperrors.Internal(err)
}
// Create profile (already verified since Apple verified)
profile, _ := s.userRepo.WithContext(ctx).GetOrCreateProfile(user.ID)
if profile != nil {
_ = s.userRepo.WithContext(ctx).SetProfileVerified(user.ID, true)
}
// Create notification preferences with all options enabled
if s.notificationRepo != nil {
if _, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(user.ID); err != nil {
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to create notification preferences for Apple Sign In user")
}
}
// Link Apple ID
appleAuthRecord := &models.AppleSocialAuth{
UserID: user.ID,
AppleID: appleID,
Email: getEmailOrDefault(email),
IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(),
}
if err := s.userRepo.WithContext(ctx).CreateAppleSocialAuth(appleAuthRecord); err != nil {
return nil, apperrors.Internal(err)
}
// Create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
// B-08: Check error from FindByIDWithProfile
user, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(user.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.AppleSignInResponse{
Token: token.Key,
User: responses.NewUserResponse(user),
IsNewUser: true,
}, nil
}
// GoogleSignIn handles Google Sign In authentication
func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthService, req *requests.GoogleSignInRequest) (*responses.GoogleSignInResponse, error) {
// 1. Verify the Google ID token
tokenInfo, err := googleAuth.VerifyIDToken(ctx, req.IDToken)
if err != nil {
return nil, apperrors.Unauthorized("error.invalid_credentials").Wrap(err)
}
googleID := tokenInfo.Sub
if googleID == "" {
return nil, apperrors.Unauthorized("error.invalid_credentials")
}
// 2. Check if this Google ID is already linked to an account
existingAuth, err := s.userRepo.WithContext(ctx).FindByGoogleID(googleID)
if err == nil && existingAuth != nil {
// User already linked with this Google ID - log them in
user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(existingAuth.UserID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !user.IsActive {
return nil, apperrors.Unauthorized("error.account_inactive")
}
// Get or create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Update last login
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(user.ID)
return &responses.GoogleSignInResponse{
Token: token.Key,
User: responses.NewUserResponse(user),
IsNewUser: false,
}, nil
}
// 3. Check if email matches an existing user (for account linking)
email := tokenInfo.Email
if email != "" {
existingUser, err := s.userRepo.WithContext(ctx).FindByEmail(email)
if err == nil && existingUser != nil {
// S-06: Log auto-linking of social account to existing user
log.Warn().
Str("email", email).
Str("provider", "google").
Uint("user_id", existingUser.ID).
Msg("Auto-linking social account to existing user by email match")
// Link Google ID to existing account
googleAuthRecord := &models.GoogleSocialAuth{
UserID: existingUser.ID,
GoogleID: googleID,
Email: email,
Name: tokenInfo.Name,
Picture: tokenInfo.Picture,
}
if err := s.userRepo.WithContext(ctx).CreateGoogleSocialAuth(googleAuthRecord); err != nil {
return nil, apperrors.Internal(err)
}
// Mark as verified since Google verified the email
if tokenInfo.IsEmailVerified() {
_ = s.userRepo.WithContext(ctx).SetProfileVerified(existingUser.ID, true)
}
// Get or create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(existingUser.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Update last login
_ = s.userRepo.WithContext(ctx).UpdateLastLogin(existingUser.ID)
// B-08: Check error from FindByIDWithProfile
existingUser, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(existingUser.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.GoogleSignInResponse{
Token: token.Key,
User: responses.NewUserResponse(existingUser),
IsNewUser: false,
}, nil
}
}
// 4. Create new user
username := generateGoogleUsername(email, tokenInfo.GivenName)
user := &models.User{
Username: username,
Email: email,
FirstName: tokenInfo.GivenName,
LastName: tokenInfo.FamilyName,
IsActive: true,
}
// Set a random password (user won't use it since they log in with Google)
randomPassword := generateResetToken()
_ = user.SetPassword(randomPassword)
if err := s.userRepo.WithContext(ctx).Create(user); err != nil {
return nil, apperrors.Internal(err)
}
// Create profile (already verified if Google verified email)
profile, _ := s.userRepo.WithContext(ctx).GetOrCreateProfile(user.ID)
if profile != nil && tokenInfo.IsEmailVerified() {
_ = s.userRepo.WithContext(ctx).SetProfileVerified(user.ID, true)
}
// Create notification preferences with all options enabled
if s.notificationRepo != nil {
if _, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(user.ID); err != nil {
log.Warn().Err(err).Uint("user_id", user.ID).Msg("Failed to create notification preferences for Google Sign In user")
}
}
// Link Google ID
googleAuthRecord := &models.GoogleSocialAuth{
UserID: user.ID,
GoogleID: googleID,
Email: email,
Name: tokenInfo.Name,
Picture: tokenInfo.Picture,
}
if err := s.userRepo.WithContext(ctx).CreateGoogleSocialAuth(googleAuthRecord); err != nil {
return nil, apperrors.Internal(err)
}
// Create token
token, err := s.userRepo.WithContext(ctx).GetOrCreateToken(user.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
// B-08: Check error from FindByIDWithProfile
user, err = s.userRepo.WithContext(ctx).FindByIDWithProfile(user.ID)
if err != nil {
return nil, apperrors.Internal(err)
}
return &responses.GoogleSignInResponse{
Token: token.Key,
User: responses.NewUserResponse(user),
IsNewUser: true,
}, nil
}
// Helper functions
func generateSixDigitCode() string {
b := make([]byte, 4)
rand.Read(b)
num := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
if num < 0 {
num = -num
}
code := num % 1000000
return fmt.Sprintf("%06d", code)
}
func generateResetToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// Helper functions for Apple Sign In
func getEmailFromRequest(reqEmail *string, claimsEmail string) string {
if reqEmail != nil && *reqEmail != "" {
return *reqEmail
}
return claimsEmail
}
func getEmailOrDefault(email string) string {
if email == "" {
// Generate a placeholder email for users without one
return fmt.Sprintf("apple_%s@privaterelay.appleid.com", generateResetToken()[:16])
}
return email
}
func getStringOrEmpty(s *string) string {
if s == nil {
return ""
}
return *s
}
func isPrivateRelayEmail(email string) bool {
return strings.HasSuffix(strings.ToLower(email), "@privaterelay.appleid.com")
}
func generateUniqueUsername(email string, firstName *string) string {
// Try using first part of email
if email != "" && !isPrivateRelayEmail(email) {
parts := strings.Split(email, "@")
if len(parts) > 0 && parts[0] != "" {
// Add random suffix to ensure uniqueness
return parts[0] + "_" + generateResetToken()[:6]
}
}
// Try using first name
if firstName != nil && *firstName != "" {
return strings.ToLower(*firstName) + "_" + generateResetToken()[:6]
}
// Fallback to random username
return "user_" + generateResetToken()[:10]
}
func generateGoogleUsername(email string, firstName string) string {
// Try using first part of email
if email != "" {
parts := strings.Split(email, "@")
if len(parts) > 0 && parts[0] != "" {
// Add random suffix to ensure uniqueness
return parts[0] + "_" + generateResetToken()[:6]
}
}
// Try using first name
if firstName != "" {
return strings.ToLower(firstName) + "_" + generateResetToken()[:6]
}
// Fallback to random username
return "google_" + generateResetToken()[:10]
}