aurganize-backend/backend/internal/services/user_service.go

658 lines
21 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"strings"
"github.com/creativenoz/aurganize-v62/backend/internal/models"
"github.com/creativenoz/aurganize-v62/backend/internal/repositories"
"github.com/google/uuid"
)
// Predefined errors for user operations.
// These are package-level error variables that can be:
// 1. Compared using errors.Is() for error handling
// 2. Wrapped with context using fmt.Errorf()
// 3. Tested reliably (same instance)
// 4. Documented centrally
//
// Why define errors at package level?
// - Consistency: Same error for same situation across codebase
// - Testability: Can check for specific error types
// - Documentation: Clear list of possible errors
// - i18n ready: Can map errors to localized messages
var (
// ErrUserNotFound indicates the requested user doesn't exist
// Used when: Looking up user by ID/email and not found
// HTTP code: 404 Not Found
ErrUserNotFound = errors.New("user not found")
// ErrInvalidCredentials indicates email or password is wrong
// Used when: Login fails due to bad credentials
// HTTP code: 401 Unauthorized
// Security: Generic message prevents email enumeration
ErrInvalidCredentials = errors.New("invalid credentials")
// ErrEmailAlreadyExists indicates email is already registered
// Used when: Registration with existing email
// HTTP code: 400 Bad Request or 409 Conflict
ErrEmailAlreadyExists = errors.New("email already exists")
// ErrWeakPassword indicates password doesn't meet security requirements
// Used when: Registration or password change with weak password
// HTTP code: 400 Bad Request
ErrWeakPassword = errors.New("password is too weak")
// ErrInvalidEmail indicates email format is invalid
// Used when: Registration with malformed email
// HTTP code: 400 Bad Request
ErrInvalidEmail = errors.New("invalid email format")
)
// UserService handles all user-related business logic.
// This service sits between HTTP handlers and data repositories.
//
// Responsibilities:
// 1. User registration (with validation)
// 2. User authentication (credential verification)
// 3. User lookup (by ID or email)
// 4. User updates (password, last login)
// 5. Input validation (email format, password strength)
//
// Architecture: Service Layer Pattern
// Benefits:
// - Business logic separated from HTTP concerns
// - Reusable across multiple handlers
// - Testable without HTTP infrastructure
// - Can coordinate multiple repositories
// - Can add cross-cutting concerns (logging, metrics)
//
// Why this layer exists:
// - Handlers should be thin (just HTTP translation)
// - Repositories should be simple (just database operations)
// - Business logic needs a home (validation, coordination)
//
// Security considerations:
// - Email normalization (prevent duplicate accounts)
// - Password strength validation (prevent weak passwords)
// - Generic error messages (prevent information leakage)
// - Input sanitization (prevent injection attacks)
type UserService struct {
userRepo *repositories.UserRepository // Database operations for users
}
// NewUserService creates a new UserService with injected dependencies.
// This follows dependency injection pattern:
// - Repository passed in (not created internally)
// - Makes testing easier (can inject mock repository)
// - Keeps service decoupled from repository implementation
//
// Parameters:
// - userRepo: Repository for user database operations
//
// Returns:
// - Fully initialized UserService
func NewUserService(userRepo *repositories.UserRepository) *UserService {
return &UserService{userRepo: userRepo}
}
// Register creates a new user account with validation.
// This is the complete user registration flow.
//
// What happens:
// 1. Validate input (email format, password strength, uniqueness)
// 2. Normalize email (lowercase, trim whitespace)
// 3. Create user in database (repository handles password hashing)
// 4. Return created user object
//
// Validation performed:
// - Email format validation (structure, length)
// - Email uniqueness check (not already registered)
// - Password strength validation (length, complexity, not common)
//
// Why validate in service layer?
// - Business rules belong here
// - Reusable validation (same rules everywhere)
// - Clear error messages for different failure cases
// - Can be tested independently
//
// Email normalization importance:
// - Prevents duplicate accounts: user@example.com vs USER@Example.com
// - Consistent storage format
// - Easier searching and matching
// - Standard practice for email handling
//
// Security considerations:
// - Password never logged or exposed
// - Email uniqueness check prevents enumeration (generic error)
// - Strong password requirements enforced
// - Input sanitization (trim, lowercase)
//
// After registration:
// - User account created but may need email verification
// - Caller should send verification email
// - User might not be able to log in until verified (depends on status)
//
// Parameters:
// - ctx: Context for database operations
// - userInput: Registration data (email, password, names, etc.)
//
// Return values:
// - (*User, nil): Successfully created user
// - (nil, ErrInvalidEmail): Email format invalid
// - (nil, ErrEmailAlreadyExists): Email already registered
// - (nil, ErrWeakPassword): Password too weak
// - (nil, error): Database error or other failure
func (u *UserService) Register(ctx context.Context, userInput *models.CreateUserInput) (*models.User, error) {
// Step 1: Validate registration input
// This checks:
// - Email format is valid
// - Email not already registered
// - Password meets strength requirements
if err := u.ValidateRegistrationInput(ctx, userInput); err != nil {
return nil, err // Return specific validation error
}
// Step 2: Normalize email for consistent storage
// - TrimSpace: Remove leading/trailing whitespace
// - ToLower: Convert to lowercase for case-insensitive matching
// Why: "User@Example.COM " becomes "user@example.com"
userInput.Email = strings.ToLower(strings.TrimSpace(userInput.Email))
// Step 3: Create user in database
// Repository handles:
// - Password hashing (bcrypt)
// - Database insertion
// - Generating user ID and timestamps
user, err := u.userRepo.Create(ctx, userInput)
if err != nil {
// Wrap error with context for better debugging
return nil, fmt.Errorf("failed to create user : %w", err)
}
// Step 4: Return created user
// User object includes generated ID, timestamps, etc.
return user, err
}
// AuthenticateUserByEmail verifies user credentials (email + password).
// This is the core of the login process.
//
// What happens:
// 1. Normalize email (lowercase, trim)
// 2. Look up user by email
// 3. Verify password against stored hash
// 4. Return user if valid
//
// Security considerations:
// - Generic error message (prevents email enumeration)
// - Email normalization (consistent with registration)
// - Password never logged or exposed
// - Constant-time password comparison (via bcrypt)
//
// Why generic error?
// - "Invalid credentials" for both wrong email AND wrong password
// - Prevents attackers from discovering valid emails
// - Standard security practice
// - Trade-off: Slightly worse UX for better security
//
// Email enumeration attack explained:
// - Attacker tries many emails
// - Different errors for "email not found" vs "wrong password"
// - Attacker can build list of valid emails
// - Then focus on password cracking for valid emails
// - Solution: Same error for both cases
//
// Password verification:
// - Uses bcrypt.CompareHashAndPassword
// - Constant-time comparison (prevents timing attacks)
// - Automatically handles salt extraction
// - Returns error if no match
//
// After successful authentication:
// - Caller should check user.Status (active, suspended, etc.)
// - Caller should generate auth tokens
// - Caller should update last login timestamp
//
// Parameters:
// - ctx: Context for database operations
// - email: User's email address
// - password: User's plaintext password
//
// Return values:
// - (*User, nil): Authentication successful
// - (nil, ErrInvalidCredentials): Wrong email or password
// - (nil, error): Database error (wrapped)
func (u *UserService) AuthenticateUserByEmail(ctx context.Context, email string, password string) (*models.User, error) {
// Step 1: Normalize email
// Must match normalization done during registration
email = strings.ToLower(strings.TrimSpace(email))
// Step 2: Look up user by email
user, err := u.userRepo.FindByEmail(ctx, email)
if err != nil {
// Wrap error with context
// This is a database error, not "user not found"
return nil, fmt.Errorf("repository error : %w", err)
}
// Step 3: Check if user exists
if user == nil {
// Email not found in database
// Return generic error (don't reveal email doesn't exist)
return nil, ErrInvalidCredentials
}
// Step 4: Verify password
// Repository method uses bcrypt to compare
// Returns false if password doesn't match
if !u.userRepo.VerifyPassword(user, password) {
// Password incorrect
// Return generic error (don't reveal password was wrong)
return nil, ErrInvalidCredentials
}
// Step 5: Authentication successful
// Return user object for token generation
return user, nil
}
// GetByID retrieves a user by their unique ID.
// This is used for:
// - Loading user after authentication
// - Fetching user profile
// - Validating user existence
//
// When to use this vs GetByEmail:
// - Use GetByID: When you already have user ID (from token, etc.)
// - Use GetByEmail: During login or user lookup
//
// Why this is simple:
// - Just wraps repository call
// - Adds consistent error wrapping
// - Provides clear error when user not found
//
// Parameters:
// - ctx: Context for database operations
// - id: User's unique identifier (UUID)
//
// Return values:
// - (*User, nil): User found
// - (nil, ErrUserNotFound): User doesn't exist (or is deleted)
// - (nil, error): Database error (wrapped)
func (u *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
// Look up user by ID
user, err := u.userRepo.FindByID(ctx, id)
if err != nil {
// Database error
return nil, fmt.Errorf("repository error : %w", err)
}
// Check if user was found
if user == nil {
// User doesn't exist (or is soft-deleted)
return nil, ErrUserNotFound
}
return user, nil
}
// GetByEmail retrieves a user by their email address.
// This is used for:
// - User lookup in admin interfaces
// - Checking if email is registered
// - Loading user before certain operations
//
// When to use this vs GetByID:
// - Use GetByEmail: When you have email (user lookup, admin search)
// - Use GetByID: When you have ID (token validation, internal operations)
//
// Email should be normalized before calling (lowercase, trim).
//
// Parameters:
// - ctx: Context for database operations
// - email: User's email address
//
// Return values:
// - (*User, nil): User found
// - (nil, ErrUserNotFound): User doesn't exist
// - (nil, error): Database error (wrapped)
func (u *UserService) GetByEmail(ctx context.Context, email string) (*models.User, error) {
// Look up user by email
user, err := u.userRepo.FindByEmail(ctx, email)
if err != nil {
// Database error
return nil, fmt.Errorf("repository error : %w", err)
}
// Check if user was found
if user == nil {
// User doesn't exist
return nil, ErrUserNotFound
}
return user, nil
}
// UpdateLastLogin updates user's last login timestamp and IP address.
// This is called after successful login for audit purposes.
//
// Why track this:
// - Security monitoring
// - User awareness ("last login")
// - Account activity tracking
// - Compliance requirements
//
// When to call:
// - After successful login
// - After token refresh (debatable)
// - Before generating tokens
//
// This is a simple pass-through to repository.
// Service layer included for consistency and future business logic.
//
// Parameters:
// - ctx: Context for database operations
// - id: User's ID
// - ipAddress: IP address of request
//
// Returns:
// - error: Database error if update fails
func (u *UserService) UpdateLastLogin(ctx context.Context, id uuid.UUID, ipAddress *string) error {
// Delegate to repository
return u.userRepo.UpdateLastLogin(ctx, id, ipAddress)
}
// UpdatePassword changes a user's password.
// This is used for:
// - User-initiated password change
// - Password reset flow
// - Admin force password change
//
// Important: Before calling this:
// 1. Validate user's identity (current password or reset token)
// 2. Validate new password strength
// 3. Verify user has permission (self or admin)
//
// After calling this:
// 1. Revoke all user's sessions (force re-login)
// 2. Send email notification to user
// 3. Log event for audit trail
// 4. Update password history (if tracking)
//
// This is a simple pass-through to repository.
// Repository handles password hashing.
//
// Parameters:
// - ctx: Context for database operations
// - id: User's ID
// - newPassword: New plaintext password (will be hashed)
//
// Returns:
// - error: Hashing or database error
func (u *UserService) UpdatePassword(ctx context.Context, id uuid.UUID, newPassword string) error {
// Delegate to repository
// Repository handles bcrypt hashing
return u.userRepo.UpdatePassword(ctx, id, newPassword)
}
// ValidateRegistrationInput validates all user registration input.
// This is called before creating a new user account.
//
// Validations performed:
// 1. Email format validation (structure, length)
// 2. Email uniqueness check (not already registered)
// 3. Password strength validation (length, complexity)
//
// Why validate in service layer?
// - Business rules belong here (not in handler or repository)
// - Reusable validation (called from multiple places)
// - Testable independently
// - Clear separation of concerns
//
// Validation order matters:
// 1. Format validation first (cheap, no database query)
// 2. Uniqueness check second (database query, more expensive)
// 3. Password validation last (computational cost)
//
// Why email uniqueness here vs database constraint?
// - Both! Database constraint is backup
// - Service check provides better error message
// - Service check prevents unnecessary password hashing
// - Database constraint prevents race conditions
//
// Parameters:
// - ctx: Context for database operations
// - input: Registration input to validate
//
// Return values:
// - nil: All validation passed
// - ErrInvalidEmail: Email format invalid
// - ErrEmailAlreadyExists: Email already registered
// - ErrWeakPassword: Password doesn't meet requirements
// - error: Database error during uniqueness check
func (u *UserService) ValidateRegistrationInput(ctx context.Context, input *models.CreateUserInput) error {
// Step 1: Validate email format
// Checks structure, length, basic format
// This is cheap (no database query)
if !isValidEmail(input.Email) {
return ErrInvalidEmail
}
// Step 2: Check email uniqueness
// Queries database to see if email already exists
// Lowercase email for case-insensitive check
exists, err := u.userRepo.EmailExists(ctx, strings.ToLower(input.Email))
if err != nil {
// Database error during uniqueness check
// Wrap with context for debugging
return fmt.Errorf("failed to check email uniqueness : %w email id [%s]", err, input.Email)
}
if exists {
// Email already registered
return ErrEmailAlreadyExists
}
// Step 3: Validate password strength
// Checks length, complexity, common passwords
// Requires password, email (to prevent email in password), and first name (to prevent name in password)
if !isStrongPassword(input.Password, input.Email, *input.FirstName) {
return ErrWeakPassword
}
// All validation passed
return nil
}
// isValidEmail checks if an email address has valid format.
// This is a basic validation, not RFC 5322 compliant.
//
// Checks performed:
// 1. Length: 3-254 characters (RFC 5321 limit)
// 2. Contains @: Must have exactly one @
// 3. @ position: Not at start or end
// 4. Local part: 1-64 characters (before @)
// 5. Domain part: Contains at least one dot
//
// What this DOESN'T check:
// - Special characters in local part
// - International domain names
// - Multiple @ symbols in quoted local part
// - Full RFC 5322 compliance
//
// Why simple validation?
// - Good enough for most cases
// - Fast (no regex or complex parsing)
// - Prevents obvious mistakes
// - Final validation is sending verification email
//
// For production, consider:
// - Using email validation library
// - DNS MX record check (is domain valid?)
// - Disposable email detection
// - Email verification required
//
// Examples:
// - Valid: "user@example.com", "john.doe@company.co.uk"
// - Invalid: "user", "@example.com", "user@", "user@@example.com"
//
// Parameters:
// - emailInput: Email string to validate
//
// Returns:
// - true: Email format appears valid
// - false: Email format is invalid
func isValidEmail(emailInput string) bool {
// Trim whitespace for validation
email := strings.TrimSpace(emailInput)
// Check length constraints
// Min: "a@b.c" = 5 chars (but we use 3 to be permissive)
// Max: 254 chars per RFC 5321
if len(email) < 3 || len(email) > 254 {
return false
}
// Find position of last @ symbol
// LastIndex returns -1 if not found
atIndex := strings.LastIndex(email, "@")
// Validate @ position
// Must exist and not be at start (position 0) or end
if atIndex < 1 || atIndex > len(email)-1 {
return false
}
// Split email into local and domain parts
localPart := email[:atIndex] // Before @
domainPart := email[atIndex+1:] // After @
// Validate local part length
// RFC 5321: Maximum 64 characters before @
if len(localPart) < 1 || len(localPart) > 64 {
return false
}
// Validate domain part has at least one dot
// Required for valid domain (e.g., "example.com")
// Note: This doesn't validate TLD or DNS
if !strings.Contains(domainPart, ".") {
return false
}
// Basic validation passed
return true
}
// isStrongPassword validates password meets security requirements.
// This enforces password policy to prevent weak passwords.
//
// Requirements:
// 1. Minimum 8 characters (longer is better)
// 2. At least one lowercase letter (a-z)
// 3. At least one uppercase letter (A-Z)
// 4. At least one number (0-9)
// 5. At least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?/~)
// 6. Must NOT contain user's email
// 7. Must NOT contain user's first name
//
// Why these requirements?
// - Length: Harder to brute force
// - Lowercase: Increases character space
// - Uppercase: Increases character space
// - Number: Increases character space
// - Special: Increases character space (most important)
// - No email: Prevents easy guessing
// - No name: Prevents easy guessing
//
// Character space importance:
// - Lowercase only: 26^8 = 208 billion combinations
// - + Uppercase: 52^8 = 53 trillion combinations
// - + Numbers: 62^8 = 218 trillion combinations
// - + Special chars: 90^8 = 4.3 quadrillion combinations
//
// What this DOESN'T check:
// - Dictionary words (would need dictionary)
// - Common passwords (would need list like "password123")
// - Keyboard patterns (would need pattern matching)
// - Previously breached passwords (would need Have I Been Pwned API)
//
// For production, consider:
// - Password strength library (zxcvbn)
// - Have I Been Pwned API integration
// - Common password blacklist
// - Personal information checking (birthdate, etc.)
//
// Parameters:
// - passwordToCheck: Password to validate
// - email: User's email (to prevent email in password)
// - firstName: User's first name (to prevent name in password)
//
// Returns:
// - true: Password meets all requirements
// - false: Password fails one or more requirements
func isStrongPassword(passwordToCheck string, email string, firstName string) bool {
// Check minimum length
// 8 characters minimum (NIST recommends at least 8)
// Consider increasing to 12 or 16 for better security
if len(passwordToCheck) < 8 {
return false
}
// Initialize flags for each requirement
hasLowerCase := false
hasUpperCase := false
hasSpecialCharacter := false
hasNumber := false
// Check each character in password
// We iterate once through the string checking all requirements
for _, char := range passwordToCheck {
switch {
// Check for uppercase letter (A-Z)
case char >= 'A' && char <= 'Z':
hasUpperCase = true
// Check for lowercase letter (a-z)
case char >= 'a' && char <= 'z':
hasLowerCase = true
// Check for digit (0-9)
case char >= '0' && char <= '9':
hasNumber = true
// Check for special characters
// Ranges cover common special characters on keyboard:
// !, ", #, $, %, &, ', (, ), *, +, ,, -, ., /
// :, ;, <, =, >, ?, @
// [, \, ], ^, _, `
// {, |, }, ~
case (char >= '!' && char <= '/') || // !"#$%&'()*+,-./
(char >= ':' && char <= '@') || // :;<=>?@
(char >= '[' && char <= '`') || // [\]^_`
(char >= '{' && char <= '~'): // {|}~
hasSpecialCharacter = true
}
}
// Check if all character type requirements are met
if !hasLowerCase || !hasUpperCase || !hasSpecialCharacter || !hasNumber {
return false // Missing at least one required character type
}
// Check if password contains user's email
// Prevents passwords like "myemail@example.com123"
// Case-insensitive check
if strings.Contains(passwordToCheck, email) {
return false
}
// Check if password contains user's first name
// Prevents passwords like "JohnSmith123!"
// Case-insensitive check
if strings.Contains(passwordToCheck, firstName) {
return false
}
// All requirements passed
return true
}