package services import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "strings" "time" "golang.org/x/crypto/bcrypt" "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/dto/requests" "github.com/treytartt/casera-api/internal/dto/responses" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/repositories" ) 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") ) // 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(req *requests.LoginRequest) (*responses.LoginResponse, error) { // Find user by username or email identifier := req.Username if identifier == "" { identifier = req.Email } user, err := s.userRepo.FindByUsernameOrEmail(identifier) if err != nil { if errors.Is(err, repositories.ErrUserNotFound) { return nil, ErrInvalidCredentials } return nil, fmt.Errorf("failed to find user: %w", err) } // Check if user is active if !user.IsActive { return nil, ErrUserInactive } // Verify password if !user.CheckPassword(req.Password) { return nil, ErrInvalidCredentials } // Get or create auth token token, err := s.userRepo.GetOrCreateToken(user.ID) if err != nil { return nil, fmt.Errorf("failed to create token: %w", err) } // Update last login if err := s.userRepo.UpdateLastLogin(user.ID); err != nil { // Log error but don't fail the login fmt.Printf("Failed to update last login: %v\n", err) } return &responses.LoginResponse{ Token: token.Key, User: responses.NewUserResponse(user), }, nil } // Register creates a new user account func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.RegisterResponse, string, error) { // Check if username exists exists, err := s.userRepo.ExistsByUsername(req.Username) if err != nil { return nil, "", fmt.Errorf("failed to check username: %w", err) } if exists { return nil, "", ErrUsernameTaken } // Check if email exists exists, err = s.userRepo.ExistsByEmail(req.Email) if err != nil { return nil, "", fmt.Errorf("failed to check email: %w", err) } if exists { return nil, "", ErrEmailTaken } // 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, "", fmt.Errorf("failed to hash password: %w", err) } // Save user if err := s.userRepo.Create(user); err != nil { return nil, "", fmt.Errorf("failed to create user: %w", err) } // Create user profile if _, err := s.userRepo.GetOrCreateProfile(user.ID); err != nil { // Log error but don't fail registration fmt.Printf("Failed to create user profile: %v\n", err) } // Create notification preferences with all options enabled if s.notificationRepo != nil { if _, err := s.notificationRepo.GetOrCreatePreferences(user.ID); err != nil { // Log error but don't fail registration fmt.Printf("Failed to create notification preferences: %v\n", err) } } // Create auth token token, err := s.userRepo.GetOrCreateToken(user.ID) if err != nil { return nil, "", fmt.Errorf("failed to create token: %w", err) } // Generate confirmation code - use fixed code in debug mode for easier local testing var code string if s.cfg.Server.Debug { code = "123456" } else { code = generateSixDigitCode() } expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry) if _, err := s.userRepo.CreateConfirmationCode(user.ID, code, expiresAt); err != nil { // Log error but don't fail registration fmt.Printf("Failed to create confirmation code: %v\n", err) } return &responses.RegisterResponse{ Token: token.Key, User: responses.NewUserResponse(user), Message: "Registration successful. Please check your email to verify your account.", }, code, nil } // Logout invalidates a user's token func (s *AuthService) Logout(token string) error { return s.userRepo.DeleteToken(token) } // GetCurrentUser returns the current authenticated user with profile func (s *AuthService) GetCurrentUser(userID uint) (*responses.CurrentUserResponse, error) { user, err := s.userRepo.FindByIDWithProfile(userID) if err != nil { return nil, err } response := responses.NewCurrentUserResponse(user) return &response, nil } // UpdateProfile updates a user's profile func (s *AuthService) UpdateProfile(userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) { user, err := s.userRepo.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.ExistsByEmail(*req.Email) if err != nil { return nil, fmt.Errorf("failed to check email: %w", err) } if exists { return nil, ErrEmailTaken } user.Email = *req.Email } if req.FirstName != nil { user.FirstName = *req.FirstName } if req.LastName != nil { user.LastName = *req.LastName } if err := s.userRepo.Update(user); err != nil { return nil, fmt.Errorf("failed to update user: %w", err) } // Reload with profile user, err = s.userRepo.FindByIDWithProfile(userID) if err != nil { return nil, err } response := responses.NewCurrentUserResponse(user) return &response, nil } // VerifyEmail verifies a user's email with a confirmation code func (s *AuthService) VerifyEmail(userID uint, code string) error { // Get user profile profile, err := s.userRepo.GetOrCreateProfile(userID) if err != nil { return fmt.Errorf("failed to get profile: %w", err) } // Check if already verified if profile.Verified { return ErrAlreadyVerified } // Check for test code in debug mode if s.cfg.Server.Debug && code == "123456" { if err := s.userRepo.SetProfileVerified(userID, true); err != nil { return fmt.Errorf("failed to verify profile: %w", err) } return nil } // Find and validate confirmation code confirmCode, err := s.userRepo.FindConfirmationCode(userID, code) if err != nil { if errors.Is(err, repositories.ErrCodeNotFound) { return ErrInvalidCode } if errors.Is(err, repositories.ErrCodeExpired) { return ErrCodeExpired } return err } // Mark code as used if err := s.userRepo.MarkConfirmationCodeUsed(confirmCode.ID); err != nil { return fmt.Errorf("failed to mark code as used: %w", err) } // Set profile as verified if err := s.userRepo.SetProfileVerified(userID, true); err != nil { return fmt.Errorf("failed to verify profile: %w", err) } return nil } // ResendVerificationCode creates and returns a new verification code func (s *AuthService) ResendVerificationCode(userID uint) (string, error) { // Get user profile profile, err := s.userRepo.GetOrCreateProfile(userID) if err != nil { return "", fmt.Errorf("failed to get profile: %w", err) } // Check if already verified if profile.Verified { return "", ErrAlreadyVerified } // Generate new code - use fixed code in debug mode for easier local testing var code string if s.cfg.Server.Debug { code = "123456" } else { code = generateSixDigitCode() } expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry) if _, err := s.userRepo.CreateConfirmationCode(userID, code, expiresAt); err != nil { return "", fmt.Errorf("failed to create confirmation code: %w", err) } return code, nil } // ForgotPassword initiates the password reset process func (s *AuthService) ForgotPassword(email string) (string, *models.User, error) { // Find user by email user, err := s.userRepo.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.CountRecentPasswordResetRequests(user.ID) if err != nil { return "", nil, fmt.Errorf("failed to check rate limit: %w", err) } if count >= int64(s.cfg.Security.MaxPasswordResetRate) { return "", nil, ErrRateLimitExceeded } // Generate code and reset token - use fixed code in debug mode for easier local testing var code string if s.cfg.Server.Debug { 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, fmt.Errorf("failed to hash code: %w", err) } if _, err := s.userRepo.CreatePasswordResetCode(user.ID, string(codeHash), resetToken, expiresAt); err != nil { return "", nil, fmt.Errorf("failed to create reset code: %w", err) } return code, user, nil } // VerifyResetCode verifies a password reset code and returns a reset token func (s *AuthService) VerifyResetCode(email, code string) (string, error) { // Find the reset code resetCode, user, err := s.userRepo.FindPasswordResetCodeByEmail(email) if err != nil { if errors.Is(err, repositories.ErrUserNotFound) || errors.Is(err, repositories.ErrCodeNotFound) { return "", ErrInvalidCode } return "", err } // Check for test code in debug mode if s.cfg.Server.Debug && code == "123456" { return resetCode.ResetToken, nil } // Verify the code if !resetCode.CheckCode(code) { // Increment attempts s.userRepo.IncrementResetCodeAttempts(resetCode.ID) return "", ErrInvalidCode } // Check if code is still valid if !resetCode.IsValid() { if resetCode.Used { return "", ErrInvalidCode } if resetCode.Attempts >= resetCode.MaxAttempts { return "", ErrRateLimitExceeded } return "", ErrCodeExpired } _ = user // user available if needed return resetCode.ResetToken, nil } // ResetPassword resets the user's password using a reset token func (s *AuthService) ResetPassword(resetToken, newPassword string) error { // Find the reset code by token resetCode, err := s.userRepo.FindPasswordResetCodeByToken(resetToken) if err != nil { if errors.Is(err, repositories.ErrCodeNotFound) || errors.Is(err, repositories.ErrCodeExpired) { return ErrInvalidResetToken } return err } // Get the user user, err := s.userRepo.FindByID(resetCode.UserID) if err != nil { return fmt.Errorf("failed to find user: %w", err) } // Update password if err := user.SetPassword(newPassword); err != nil { return fmt.Errorf("failed to hash password: %w", err) } if err := s.userRepo.Update(user); err != nil { return fmt.Errorf("failed to update user: %w", err) } // Mark reset code as used if err := s.userRepo.MarkPasswordResetCodeUsed(resetCode.ID); err != nil { // Log error but don't fail fmt.Printf("Failed to mark reset code as used: %v\n", err) } // Invalidate all existing tokens for this user (security measure) if err := s.userRepo.DeleteTokenByUserID(user.ID); err != nil { // Log error but don't fail fmt.Printf("Failed to delete user tokens: %v\n", err) } 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, fmt.Errorf("%w: %v", ErrAppleSignInFailed, 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.FindByAppleID(appleID) if err == nil && existingAuth != nil { // User already linked with this Apple ID - log them in user, err := s.userRepo.FindByIDWithProfile(existingAuth.UserID) if err != nil { return nil, fmt.Errorf("failed to find user: %w", err) } if !user.IsActive { return nil, ErrUserInactive } // Get or create token token, err := s.userRepo.GetOrCreateToken(user.ID) if err != nil { return nil, fmt.Errorf("failed to create token: %w", err) } // Update last login _ = s.userRepo.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.FindByEmail(email) if err == nil && existingUser != nil { // 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.CreateAppleSocialAuth(appleAuthRecord); err != nil { return nil, fmt.Errorf("failed to link Apple ID: %w", err) } // Mark as verified since Apple verified the email _ = s.userRepo.SetProfileVerified(existingUser.ID, true) // Get or create token token, err := s.userRepo.GetOrCreateToken(existingUser.ID) if err != nil { return nil, fmt.Errorf("failed to create token: %w", err) } // Update last login _ = s.userRepo.UpdateLastLogin(existingUser.ID) // Reload user with profile existingUser, _ = s.userRepo.FindByIDWithProfile(existingUser.ID) 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.Create(user); err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } // Create profile (already verified since Apple verified) profile, _ := s.userRepo.GetOrCreateProfile(user.ID) if profile != nil { _ = s.userRepo.SetProfileVerified(user.ID, true) } // Create notification preferences with all options enabled if s.notificationRepo != nil { if _, err := s.notificationRepo.GetOrCreatePreferences(user.ID); err != nil { // Log error but don't fail registration fmt.Printf("Failed to create notification preferences: %v\n", err) } } // Link Apple ID appleAuthRecord := &models.AppleSocialAuth{ UserID: user.ID, AppleID: appleID, Email: getEmailOrDefault(email), IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(), } if err := s.userRepo.CreateAppleSocialAuth(appleAuthRecord); err != nil { return nil, fmt.Errorf("failed to create Apple auth: %w", err) } // Create token token, err := s.userRepo.GetOrCreateToken(user.ID) if err != nil { return nil, fmt.Errorf("failed to create token: %w", err) } // Reload user with profile user, _ = s.userRepo.FindByIDWithProfile(user.ID) return &responses.AppleSignInResponse{ 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] }