931 lines
29 KiB
Go
931 lines
29 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"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// 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 {
|
|
log.Info().
|
|
Str("service", "user").
|
|
Str("component", "service_init").
|
|
Bool("has_user_repo", userRepo != nil).
|
|
Msg("user service initialized")
|
|
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) {
|
|
log.Info().
|
|
Str("service", "user").
|
|
Str("action", "register_user_started").
|
|
Str("tenant_id", userInput.TenantID.String()).
|
|
Str("email", userInput.Email).
|
|
Str("role", userInput.Role).
|
|
Bool("has_first_name", userInput.FirstName != nil).
|
|
Bool("has_last_name", userInput.LastName != nil).
|
|
Msg("user registration started")
|
|
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "validating_registration_input").
|
|
Str("email", userInput.Email).
|
|
Msg("validating user registration input")
|
|
|
|
// 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 {
|
|
var validationError string
|
|
switch {
|
|
case errors.Is(err, ErrInvalidEmail):
|
|
validationError = "invalid_email_format"
|
|
case errors.Is(err, ErrEmailAlreadyExists):
|
|
validationError = "email_already_taken"
|
|
case errors.Is(err, ErrWeakPassword):
|
|
validationError = "weak_password"
|
|
default:
|
|
validationError = "unknown_validation_error"
|
|
}
|
|
|
|
log.Warn().
|
|
Str("service", "user").
|
|
Str("action", "register_validation_failed").
|
|
Str("validation_error", validationError).
|
|
Str("email", userInput.Email).
|
|
Str("tenant_id", userInput.TenantID.String()).
|
|
Err(err).
|
|
Msg("user registration validation failed")
|
|
|
|
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"
|
|
originalEmail := userInput.Email
|
|
userInput.Email = strings.ToLower(strings.TrimSpace(userInput.Email))
|
|
|
|
if originalEmail != userInput.Email {
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "email_normalized").
|
|
Str("original_email", originalEmail).
|
|
Str("normalized_email", userInput.Email).
|
|
Msg("email normalized for consistent storage")
|
|
}
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "creating_user_in_repository").
|
|
Str("email", userInput.Email).
|
|
Str("tenant_id", userInput.TenantID.String()).
|
|
Msg("creating user in database")
|
|
// 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 {
|
|
log.Error().
|
|
Str("service", "user").
|
|
Str("action", "register_user_failed").
|
|
Str("email", userInput.Email).
|
|
Str("tenant_id", userInput.TenantID.String()).
|
|
Err(err).
|
|
Msg("failed to create user in database")
|
|
// Wrap error with context for better debugging
|
|
return nil, fmt.Errorf("failed to create user : %w", err)
|
|
}
|
|
log.Info().
|
|
Str("service", "user").
|
|
Str("action", "register_user_success").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Str("tenant_id", user.TenantID.String()).
|
|
Str("role", user.Role).
|
|
Str("full_name", user.FullName).
|
|
Msg("user registered successfully")
|
|
// Step 4: Return created user
|
|
// User object includes generated ID, timestamps, etc.
|
|
return user, nil
|
|
}
|
|
|
|
// 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) {
|
|
log.Info().
|
|
Str("service", "user").
|
|
Str("action", "authenticate_user_started").
|
|
Str("email", email).
|
|
Msg("user authentication attempt")
|
|
// Step 1: Normalize email
|
|
// Must match normalization done during registration
|
|
originalEmail := email
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
if email != originalEmail {
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "auth_email_normalized").
|
|
Str("original_email", originalEmail).
|
|
Str("normalized_email", email).
|
|
Msg("email normalized for authentication")
|
|
}
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "looking_up_user_for_auth").
|
|
Str("email", email).
|
|
Msg("looking up user by email for authentication")
|
|
// Step 2: Look up user by email
|
|
user, err := u.userRepo.FindByEmail(ctx, email)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("service", "user").
|
|
Str("action", "authenticate_db_error").
|
|
Str("email", email).
|
|
Err(err).
|
|
Msg("database error during authentication")
|
|
// 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 {
|
|
log.Warn().
|
|
Str("service", "user").
|
|
Str("action", "authenticate_failed_user_not_found").
|
|
Str("email", email).
|
|
Msg("authentication failed - user not found (returning generic error)")
|
|
// Email not found in database
|
|
// Return generic error (don't reveal email doesn't exist)
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "verifying_password").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Msg("user found, verifying password")
|
|
// Step 4: Verify password
|
|
// Repository method uses bcrypt to compare
|
|
// Returns false if password doesn't match
|
|
if !u.userRepo.VerifyPassword(user, password) {
|
|
log.Warn().
|
|
Str("service", "user").
|
|
Str("action", "authenticate_failed_wrong_password").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Str("tenant_id", user.TenantID.String()).
|
|
Msg("authentication failed - incorrect password (returning generic error)")
|
|
// Password incorrect
|
|
// Return generic error (don't reveal password was wrong)
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
log.Info().
|
|
Str("service", "user").
|
|
Str("action", "authenticate_user_success").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Str("tenant_id", user.TenantID.String()).
|
|
Str("role", user.Role).
|
|
Str("status", user.Status).
|
|
Msg("user authenticated successfully")
|
|
// 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) {
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "get_user_by_id").
|
|
Str("user_id", id.String()).
|
|
Msg("retrieving user by id")
|
|
|
|
// Look up user by ID
|
|
user, err := u.userRepo.FindByID(ctx, id)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("service", "user").
|
|
Str("action", "get_user_by_id_error").
|
|
Str("user_id", id.String()).
|
|
Err(err).
|
|
Msg("database error while retrieving user")
|
|
// Database error
|
|
return nil, fmt.Errorf("repository error : %w", err)
|
|
}
|
|
|
|
// Check if user was found
|
|
if user == nil {
|
|
log.Warn().
|
|
Str("service", "user").
|
|
Str("action", "user_not_found_by_id").
|
|
Str("user_id", id.String()).
|
|
Msg("user not found by id")
|
|
// User doesn't exist (or is soft-deleted)
|
|
return nil, ErrUserNotFound
|
|
}
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "get_user_by_id_success").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Msg("user retrieved successfully")
|
|
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) {
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "get_user_by_email").
|
|
Str("email", email).
|
|
Msg("retrieving user by email")
|
|
// Look up user by email
|
|
user, err := u.userRepo.FindByEmail(ctx, email)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("service", "user").
|
|
Str("action", "get_user_by_email_error").
|
|
Str("email", email).
|
|
Err(err).
|
|
Msg("database error while retrieving user")
|
|
// Database error
|
|
return nil, fmt.Errorf("repository error : %w", err)
|
|
}
|
|
|
|
// Check if user was found
|
|
if user == nil {
|
|
log.Warn().
|
|
Str("service", "user").
|
|
Str("action", "user_not_found_by_email").
|
|
Str("email", email).
|
|
Msg("user not found by email")
|
|
// User doesn't exist
|
|
return nil, ErrUserNotFound
|
|
}
|
|
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "get_user_by_email_success").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Msg("user retrieved successfully")
|
|
|
|
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 {
|
|
|
|
var ipStr string
|
|
if ipAddress != nil {
|
|
ipStr = *ipAddress
|
|
}
|
|
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "update_last_login").
|
|
Str("user_id", id.String()).
|
|
Str("ip", ipStr).
|
|
Msg("updating user last login timestamp")
|
|
|
|
err := u.userRepo.UpdateLastLogin(ctx, id, ipAddress)
|
|
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("service", "user").
|
|
Str("action", "update_last_login_failed").
|
|
Str("user_id", id.String()).
|
|
Err(err).
|
|
Msg("failed to update last login timestamp")
|
|
return err
|
|
}
|
|
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "update_last_login_success").
|
|
Str("user_id", id.String()).
|
|
Msg("last login updated successfully")
|
|
// Delegate to repository
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
|
|
log.Info().
|
|
Str("service", "user").
|
|
Str("action", "update_password_started").
|
|
Str("user_id", id.String()).
|
|
Msg("user password update started")
|
|
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "validating_new_password").
|
|
Str("user_id", id.String()).
|
|
Msg("validating new password strength")
|
|
|
|
err := u.userRepo.UpdatePassword(ctx, id, newPassword)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("service", "user").
|
|
Str("action", "update_password_failed").
|
|
Str("user_id", id.String()).
|
|
Err(err).
|
|
Msg("failed to update user password")
|
|
return err
|
|
}
|
|
log.Info().
|
|
Str("service", "user").
|
|
Str("action", "update_password_success").
|
|
Str("user_id", id.String()).
|
|
Msg("user password updated successfully - caller should revoke all sessions")
|
|
// Delegate to repository
|
|
// Repository handles bcrypt hashing
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "validate_registration_started").
|
|
Str("email", input.Email).
|
|
Msg("validating registration input")
|
|
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "validating_email_format").
|
|
Str("email", input.Email).
|
|
Msg("validating email format")
|
|
// Step 1: Validate email format
|
|
// Checks structure, length, basic format
|
|
// This is cheap (no database query)
|
|
if !isValidEmail(input.Email) {
|
|
log.Warn().
|
|
Str("service", "user").
|
|
Str("action", "invalid_email_format").
|
|
Str("email", input.Email).
|
|
Msg("email format validation failed")
|
|
return ErrInvalidEmail
|
|
}
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "checking_email_uniqueness").
|
|
Str("email", strings.ToLower(input.Email)).
|
|
Msg("checking if email already exists")
|
|
// 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 {
|
|
log.Error().
|
|
Str("service", "user").
|
|
Str("action", "email_uniqueness_check_error").
|
|
Str("email", input.Email).
|
|
Err(err).
|
|
Msg("database error while checking email uniqueness")
|
|
// 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 {
|
|
log.Info().
|
|
Str("service", "user").
|
|
Str("action", "email_already_exists").
|
|
Str("email", input.Email).
|
|
Msg("email already registered - validation failed")
|
|
// Email already registered
|
|
return ErrEmailAlreadyExists
|
|
}
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "validating_password_strength").
|
|
Int("password_length", len(input.Password)).
|
|
Msg("validating password strength")
|
|
|
|
// 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) {
|
|
|
|
log.Warn().
|
|
Str("service", "user").
|
|
Str("action", "weak_password_rejected").
|
|
Str("email", input.Email).
|
|
Int("password_length", len(input.Password)).
|
|
Msg("password failed strength requirements")
|
|
return ErrWeakPassword
|
|
}
|
|
log.Debug().
|
|
Str("service", "user").
|
|
Str("action", "validate_registration_success").
|
|
Str("email", input.Email).
|
|
Msg("registration input validated successfully")
|
|
|
|
// 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
|
|
}
|