843 lines
26 KiB
Go
843 lines
26 KiB
Go
package repositories
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
|
|
"github.com/creativenoz/aurganize-v62/backend/internal/models"
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/rs/zerolog/log"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// UserRepository handles all database operations related to users.
|
|
// This is the data access layer for user management.
|
|
//
|
|
// What this repository does:
|
|
// - Creates new users (registration)
|
|
// - Finds users by email or ID (login, profile lookup)
|
|
// - Updates user information (password, last login)
|
|
// - Verifies passwords (authentication)
|
|
// - Checks email uniqueness (prevent duplicates)
|
|
//
|
|
// Architecture pattern: Repository Pattern
|
|
// Benefits:
|
|
// - Separates database logic from business logic
|
|
// - Makes testing easier (can mock repository)
|
|
// - Provides clean interface for data access
|
|
// - Centralizes SQL queries
|
|
// - Makes it easy to change database later
|
|
//
|
|
// Security considerations:
|
|
// - Passwords are NEVER stored in plaintext
|
|
// - Always use bcrypt for password hashing
|
|
// - Soft deletes (deleted_at) preserve data integrity
|
|
// - Email normalization prevents duplicate accounts
|
|
type UserRepository struct {
|
|
db *sqlx.DB // sqlx provides enhanced database operations (named params, struct scanning)
|
|
}
|
|
|
|
// NewUserRepository creates a new instance of UserRepository.
|
|
// This follows dependency injection pattern:
|
|
// - Database connection passed in (not created internally)
|
|
// - Makes testing easier (can inject test database)
|
|
// - Keeps repository decoupled from connection setup
|
|
// - Follows SOLID principles (Dependency Inversion)
|
|
//
|
|
// Parameter:
|
|
// - db: The database connection pool for all operations
|
|
//
|
|
// Returns:
|
|
// - Initialized UserRepository ready for use
|
|
func NewUserRepository(db *sqlx.DB) *UserRepository {
|
|
log.Info().
|
|
Str("repository", "user").
|
|
Str("component", "repository_init").
|
|
Bool("has_db_connection", db != nil).
|
|
Msg("user repository initialized")
|
|
return &UserRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new user in the database.
|
|
// This is called during user registration.
|
|
//
|
|
// What happens:
|
|
// 1. Hash password using bcrypt (NEVER store plaintext!)
|
|
// 2. Insert user record with hashed password
|
|
// 3. Database generates ID, timestamps, and computed fields (full_name)
|
|
// 4. Return complete user object
|
|
//
|
|
// Why bcrypt for passwords?
|
|
// - Specifically designed for password hashing
|
|
// - Slow by design (resists brute-force attacks)
|
|
// - Includes salt automatically (prevents rainbow table attacks)
|
|
// - Adaptive (can increase cost factor as computers get faster)
|
|
// - Industry standard for password storage
|
|
//
|
|
// bcrypt workflow:
|
|
// 1. Generates random salt
|
|
// 2. Combines password + salt
|
|
// 3. Hashes multiple times (cost factor determines iterations)
|
|
// 4. Result: "$2a$10$salt+hash" format (self-contained, includes cost and salt)
|
|
//
|
|
// Why DefaultCost?
|
|
// - Balance between security and performance
|
|
// - DefaultCost = 10 (2^10 = 1024 iterations)
|
|
// - Takes ~100ms to hash (acceptable for login, but slows brute-force)
|
|
// - Can increase for higher security (cost 12 = 4x slower, cost 14 = 16x slower)
|
|
//
|
|
// Database features used:
|
|
// - RETURNING clause: Get back created record without separate SELECT
|
|
// - Auto-generated fields: id (UUID), timestamps, full_name (computed)
|
|
//
|
|
// Error handling:
|
|
// - Returns error if password hashing fails (very rare)
|
|
// - Returns error if insert fails (constraint violations, etc.)
|
|
//
|
|
// Flow:
|
|
// 1. Hash password with bcrypt
|
|
// 2. Execute INSERT with RETURNING
|
|
// 3. Scan returned row into user struct
|
|
// 4. Return populated user object
|
|
func (r *UserRepository) Create(ctx context.Context, input *models.CreateUserInput) (*models.User, error) {
|
|
log.Info().
|
|
Str("repository", "user").
|
|
Str("action", "create_user_started").
|
|
Str("tenant_id", input.TenantID.String()).
|
|
Str("email", input.Email).
|
|
Str("role", input.Role).
|
|
Str("status", input.Status).
|
|
Bool("has_first_name", input.FirstName != nil).
|
|
Bool("has_last_name", input.LastName != nil).
|
|
Msg("creating new user")
|
|
// Step 1: Hash the password using bcrypt
|
|
// bcrypt.GenerateFromPassword:
|
|
// - Takes password as []byte
|
|
// - Takes cost factor (DefaultCost = 10)
|
|
// - Returns hash as []byte (e.g., "$2a$10$...")
|
|
// - Returns error if hashing fails (very rare, maybe out of memory)
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "hashing_password").
|
|
Int("bcrypt_cost", bcrypt.DefaultCost).
|
|
Msg("hashing user password with bcrypt")
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("repository", "user").
|
|
Str("action", "password_hashing_failed").
|
|
Str("email", input.Email).
|
|
Int("bcrypt_cost", bcrypt.DefaultCost).
|
|
Err(err).
|
|
Msg("CRITICAL: failed to hash password with bcrypt")
|
|
// Wrap error with context for better debugging
|
|
return nil, fmt.Errorf("failed to hash password : %w", err)
|
|
}
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "password_hashed").
|
|
Msg("password hashed successfully")
|
|
|
|
// Prepare user struct to receive database response
|
|
user := &models.User{}
|
|
|
|
// SQL INSERT query with RETURNING clause
|
|
// PostgreSQL returns the inserted row, avoiding a separate SELECT
|
|
// This is atomic and more efficient
|
|
query := `
|
|
INSERT INTO users (
|
|
tenant_id, -- Multi-tenancy: which organization user belongs to
|
|
email, -- User's email (unique identifier for login)
|
|
password_hash, -- Bcrypt hash of password (NEVER plaintext!)
|
|
first_name, -- User's first name
|
|
last_name, -- User's last name
|
|
role, -- User's role (admin, user, manager, etc.)
|
|
status -- Account status (active, pending, suspended, deleted)
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id,tenant_id, email, password_hash, first_name, last_name,
|
|
full_name, avatar_url, phone, role, status, email_verified,
|
|
email_verified_at, is_onboarded, last_login_at, last_login_ip,
|
|
created_at, updated_at, deleted_at
|
|
`
|
|
|
|
// Execute query and scan result into user struct
|
|
// GetContext:
|
|
// - Supports context (cancellation, timeout)
|
|
// - Expects exactly one row
|
|
// - Maps columns to struct fields by db tags
|
|
err = r.db.GetContext(
|
|
ctx,
|
|
user, // Destination struct
|
|
query,
|
|
// Parameters matching $1-$7 in query
|
|
input.TenantID,
|
|
input.Email,
|
|
string(hashedPassword), // Convert []byte to string for database
|
|
input.FirstName,
|
|
input.LastName,
|
|
input.Role,
|
|
input.Status,
|
|
)
|
|
|
|
if err != nil {
|
|
log.Error().
|
|
Str("repository", "user").
|
|
Str("action", "create_user_failed").
|
|
Str("tenant_id", input.TenantID.String()).
|
|
Str("email", input.Email).
|
|
Str("role", input.Role).
|
|
Err(err).
|
|
Msg("failed to create user in database")
|
|
return nil, err
|
|
}
|
|
log.Info().
|
|
Str("repository", "user").
|
|
Str("action", "create_user_success").
|
|
Str("user_id", user.ID.String()).
|
|
Str("tenant_id", user.TenantID.String()).
|
|
Str("email", user.Email).
|
|
Str("role", user.Role).
|
|
Str("status", user.Status).
|
|
Str("full_name", user.FullName).
|
|
Bool("email_verified", user.EmailVerified).
|
|
Msg("user created successfully")
|
|
|
|
return user, err
|
|
}
|
|
|
|
// CreateTx creates a new user within an existing transaction.
|
|
// This is used during registration to ensure tenant and user are created atomically.
|
|
//
|
|
// Why this exists:
|
|
// - Registration creates tenant + user in one transaction
|
|
// - User INSERT has FK constraint to tenants.id
|
|
// - FK check must run within the same transaction to see uncommitted tenant
|
|
// - Using r.db.GetContext() would use a different session (FK validation fails)
|
|
// - Using tx.GetContext() keeps everything in the same transaction
|
|
//
|
|
// Parameters:
|
|
// - ctx: Context for cancellation/timeout
|
|
// - tx: Transaction object (implements Execer interface)
|
|
// - input: User creation data
|
|
//
|
|
// Returns:
|
|
// - (*User, nil): User created successfully within transaction
|
|
// - (nil, error): Failed (password hashing or database error)
|
|
func (r *UserRepository) CreateTx(ctx context.Context, tx Execer, input *models.CreateUserInput) (*models.User, error) {
|
|
log.Info().
|
|
Str("repository", "user").
|
|
Str("action", "create_user_in_transaction").
|
|
Str("tenant_id", input.TenantID.String()).
|
|
Str("email", input.Email).
|
|
Str("role", input.Role).
|
|
Bool("in_transaction", true).
|
|
Msg("creating new user within transaction")
|
|
|
|
// Hash password (same as Create method)
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "hashing_password").
|
|
Int("bcrypt_cost", bcrypt.DefaultCost).
|
|
Msg("hashing user password with bcrypt")
|
|
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("repository", "user").
|
|
Str("action", "password_hashing_failed").
|
|
Str("email", input.Email).
|
|
Err(err).
|
|
Msg("CRITICAL: failed to hash password with bcrypt in transaction")
|
|
return nil, fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "password_hashed").
|
|
Msg("password hashed successfully")
|
|
|
|
user := &models.User{}
|
|
|
|
query := `
|
|
INSERT INTO users (
|
|
tenant_id,
|
|
email,
|
|
password_hash,
|
|
first_name,
|
|
last_name,
|
|
role,
|
|
status
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, tenant_id, email, password_hash, first_name, last_name,
|
|
full_name, avatar_url, phone, role, status, email_verified,
|
|
email_verified_at, is_onboarded, last_login_at, last_login_ip,
|
|
created_at, updated_at, deleted_at
|
|
`
|
|
|
|
// ✅ CRITICAL: Use tx.GetContext() to stay within transaction
|
|
err = tx.GetContext(
|
|
ctx,
|
|
user,
|
|
query,
|
|
input.TenantID,
|
|
input.Email,
|
|
string(hashedPassword),
|
|
input.FirstName,
|
|
input.LastName,
|
|
input.Role,
|
|
input.Status,
|
|
)
|
|
|
|
if err != nil {
|
|
log.Error().
|
|
Str("repository", "user").
|
|
Str("action", "create_user_in_transaction_failed").
|
|
Str("tenant_id", input.TenantID.String()).
|
|
Str("email", input.Email).
|
|
Bool("in_transaction", true).
|
|
Err(err).
|
|
Msg("failed to create user within transaction")
|
|
return nil, err
|
|
}
|
|
|
|
log.Info().
|
|
Str("repository", "user").
|
|
Str("action", "create_user_in_transaction_success").
|
|
Str("user_id", user.ID.String()).
|
|
Str("tenant_id", user.TenantID.String()).
|
|
Str("email", user.Email).
|
|
Str("role", user.Role).
|
|
Bool("in_transaction", true).
|
|
Msg("user created successfully within transaction")
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// FindByEmail finds a user by their email address.
|
|
// This is used during login and email existence checks.
|
|
//
|
|
// Why search by email?
|
|
// - Email is the unique identifier for login
|
|
// - Users remember emails better than IDs
|
|
// - Standard practice for web applications
|
|
//
|
|
// Security considerations:
|
|
// - Email comparison is case-sensitive in database
|
|
// - Service layer should normalize email (lowercase, trim)
|
|
// - Prevents duplicate accounts with different casing
|
|
//
|
|
// Soft delete handling:
|
|
// - Only returns users where deleted_at IS NULL
|
|
// - Deleted users are hidden but data preserved
|
|
// - Allows for account recovery
|
|
// - Maintains referential integrity
|
|
//
|
|
// Return values:
|
|
// - (*User, nil): User found
|
|
// - (nil, nil): User not found (not an error, just doesn't exist)
|
|
// - (nil, error): Database error occurred
|
|
//
|
|
// Why return nil instead of error when not found?
|
|
// - "Not found" is a valid state, not an error
|
|
// - Allows caller to distinguish between "doesn't exist" and "database error"
|
|
// - Follows repository pattern best practices
|
|
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) {
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "find_user_by_email_started").
|
|
Str("email", email).
|
|
Msg("looking up user by email")
|
|
|
|
user := &models.User{}
|
|
|
|
// SQL SELECT query
|
|
// Note: Should add LIMIT 1 for optimization (early exit)
|
|
query := `
|
|
SELECT id,tenant_id, email, password_hash, first_name, last_name,
|
|
full_name, avatar_url, phone, role, status, email_verified,
|
|
email_verified_at, is_onboarded, last_login_at, last_login_ip,
|
|
created_at, updated_at, deleted_at
|
|
FROM users
|
|
WHERE email = $1 -- Exact email match
|
|
AND deleted_at IS NULL -- Only non-deleted users
|
|
`
|
|
|
|
// Execute query
|
|
err := r.db.GetContext(
|
|
ctx,
|
|
user,
|
|
query,
|
|
email, // $1 - Email to search for
|
|
)
|
|
|
|
// Handle "not found" case specially
|
|
// sql.ErrNoRows means query executed but returned no rows
|
|
// This is expected when user doesn't exist
|
|
if err == sql.ErrNoRows {
|
|
log.Warn().
|
|
Str("repository", "user").
|
|
Str("action", "user_not_found_by_email").
|
|
Str("email", email).
|
|
Msg("user not found by email")
|
|
|
|
return nil, nil // User not found (not an error)
|
|
}
|
|
if err != nil {
|
|
log.Error().
|
|
Str("repository", "user").
|
|
Str("action", "find_user_by_email_error").
|
|
Str("email", email).
|
|
Err(err).
|
|
Msg("database error while looking up user by email")
|
|
return nil, err // Real database error
|
|
}
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "user_found_by_email").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Str("tenant_id", user.TenantID.String()).
|
|
Str("role", user.Role).
|
|
Str("status", user.Status).
|
|
Bool("email_verified", user.EmailVerified).
|
|
Msg("user found by email")
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// FindByID finds a user by their unique ID.
|
|
// This is used for:
|
|
// - Loading user after authentication
|
|
// - Fetching user profile
|
|
// - Validating user existence
|
|
// - Retrieving user for operations
|
|
//
|
|
// Why search by ID vs email?
|
|
// - ID lookup is faster (primary key index)
|
|
// - ID never changes (email might change)
|
|
// - Used internally after user is identified
|
|
//
|
|
// When to use ID vs email:
|
|
// - Use email: Login, registration checks
|
|
// - Use ID: After authentication, internal operations
|
|
//
|
|
// Soft delete handling:
|
|
// - Only returns non-deleted users (deleted_at IS NULL)
|
|
// - Prevents access to deleted accounts
|
|
// - Maintains data for audit trail
|
|
//
|
|
// Return values:
|
|
// - (*User, nil): User found
|
|
// - (nil, nil): User not found or deleted
|
|
// - (nil, error): Database error occurred
|
|
func (r *UserRepository) FindByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "find_user_by_id_started").
|
|
Str("user_id", id.String()).
|
|
Msg("looking up user by id")
|
|
|
|
user := &models.User{}
|
|
|
|
// SQL SELECT query by ID
|
|
query := `
|
|
SELECT id,tenant_id, email, password_hash, first_name, last_name,
|
|
full_name, avatar_url, phone, role, status, email_verified,
|
|
email_verified_at, is_onboarded, last_login_at, last_login_ip,
|
|
created_at, updated_at, deleted_at
|
|
FROM users
|
|
WHERE id = $1 -- Exact ID match (UUID)
|
|
AND deleted_at IS NULL -- Only non-deleted users
|
|
`
|
|
|
|
// Execute query
|
|
err := r.db.GetContext(
|
|
ctx,
|
|
user,
|
|
query,
|
|
id, // $1 - User ID (UUID)
|
|
)
|
|
|
|
// Handle "not found" case
|
|
if err == sql.ErrNoRows {
|
|
log.Warn().
|
|
Str("repository", "user").
|
|
Str("action", "user_not_found_by_id").
|
|
Str("user_id", id.String()).
|
|
Msg("user not found by id - may be deleted")
|
|
|
|
return nil, nil // User not found (not an error)
|
|
}
|
|
if err != nil {
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "user_found_by_id").
|
|
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 found by id")
|
|
return nil, err // Database error
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
// EmailExists checks if an email is already registered in the system.
|
|
// This is used during registration to prevent duplicate accounts.
|
|
//
|
|
// Why check email existence?
|
|
// 1. Prevent duplicate accounts (UX issue)
|
|
// 2. Provide clear error messages ("Email already registered")
|
|
// 3. Enforce uniqueness at application level (in addition to database constraint)
|
|
// 4. Allow custom error handling (e.g., suggest login instead)
|
|
//
|
|
// Implementation using EXISTS:
|
|
// - EXISTS is efficient (stops at first match)
|
|
// - Returns boolean directly
|
|
// - Doesn't load full user data (faster than COUNT or SELECT)
|
|
// - Uses index on email column
|
|
//
|
|
// Soft delete consideration:
|
|
// - Only checks non-deleted users (deleted_at IS NULL)
|
|
// - Allows email reuse after deletion (debatable design choice)
|
|
// - Alternative: Never allow email reuse (more strict)
|
|
//
|
|
// Return values:
|
|
// - (true, nil): Email exists (already registered)
|
|
// - (false, nil): Email available (can register)
|
|
// - (false, error): Database error occurred
|
|
func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) {
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "checking_email_exists").
|
|
Str("email", email).
|
|
Msg("checking if email already exists")
|
|
|
|
var email_already_exists bool
|
|
|
|
// SQL EXISTS query
|
|
// EXISTS(...) returns true/false based on whether subquery returns rows
|
|
// More efficient than COUNT(*) or SELECT * for existence checks
|
|
query := `
|
|
SELECT EXISTS (
|
|
SELECT FROM users -- SELECT doesn't need columns for EXISTS
|
|
WHERE email = $1 -- Check for email match
|
|
AND deleted_at IS NULL -- Only check non-deleted users
|
|
)
|
|
`
|
|
|
|
// Execute query and scan boolean result
|
|
err := r.db.GetContext(
|
|
ctx,
|
|
&email_already_exists, // Boolean result
|
|
query,
|
|
email, // $1 - Email to check
|
|
)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("repository", "user").
|
|
Str("action", "email_exists_check_error").
|
|
Str("email", email).
|
|
Err(err).
|
|
Msg("database error while checking email existence")
|
|
|
|
return true, err
|
|
}
|
|
if email_already_exists {
|
|
log.Info().
|
|
Str("repository", "user").
|
|
Str("action", "email_already_exists").
|
|
Str("email", email).
|
|
Bool("exists", true).
|
|
Msg("email already registered - registration will be blocked")
|
|
} else {
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "email_available").
|
|
Str("email", email).
|
|
Bool("exists", false).
|
|
Msg("email is available for registration")
|
|
}
|
|
|
|
return email_already_exists, err
|
|
}
|
|
|
|
// UpdateLastLogin updates the user's last login timestamp and IP address.
|
|
// This is called after successful login for audit and security purposes.
|
|
//
|
|
// Why track last login?
|
|
// 1. Security: Detect unusual login patterns (new location, time)
|
|
// 2. User awareness: Show "Last login: 2 hours ago from New York"
|
|
// 3. Audit trail: Compliance requirements (who accessed when)
|
|
// 4. Account activity: Identify inactive accounts
|
|
// 5. Support: Help users verify their own activity
|
|
//
|
|
// What gets updated:
|
|
// - last_login_at: Current timestamp (when login occurred)
|
|
// - last_login_ip: IP address of login (for location/security analysis)
|
|
// - updated_at: Record last modification time
|
|
//
|
|
// IP address considerations:
|
|
// - Can be IPv4 or IPv6
|
|
// - Might be proxy/load balancer IP (need X-Forwarded-For)
|
|
// - Privacy concern: May need to anonymize after time period (GDPR)
|
|
// - Useful for: Geographic analysis, fraud detection
|
|
//
|
|
// Performance note:
|
|
// - This is a quick UPDATE (indexed by id)
|
|
// - Usually fast enough to include in login flow
|
|
// - Alternative: Update asynchronously if performance critical
|
|
//
|
|
// Error handling:
|
|
// - Returns error if update fails
|
|
// - Caller might ignore (login succeeds even if this fails)
|
|
// - Non-critical operation (login more important than tracking)
|
|
func (r *UserRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID, ip *string) error {
|
|
var ipStr string
|
|
if ip != nil {
|
|
ipStr = *ip
|
|
}
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "update_last_login_started").
|
|
Str("user_id", id.String()).
|
|
Str("ip", ipStr).
|
|
Msg("updating user last login timestamp and ip")
|
|
|
|
// SQL UPDATE query
|
|
// Uses NOW() for consistent database timestamp
|
|
query := `
|
|
UPDATE users
|
|
SET last_login_at = NOW(), -- Current database time
|
|
last_login_ip = $2, -- IP address from request
|
|
updated_at = NOW() -- Track this modification
|
|
WHERE id = $1 -- Only update this user
|
|
`
|
|
|
|
// Execute update
|
|
// ExecContext for queries that don't return rows (UPDATE, DELETE)
|
|
results, err := r.db.ExecContext(
|
|
ctx,
|
|
query,
|
|
id, // $1 - User ID
|
|
ipStr, // $2 - IP address
|
|
)
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("repository", "user").
|
|
Str("action", "update_last_login_failed").
|
|
Str("user_id", id.String()).
|
|
Err(err).
|
|
Msg("failed to update last login timestamp")
|
|
return err
|
|
}
|
|
rowsAffected, _ := results.RowsAffected()
|
|
if rowsAffected == 0 {
|
|
log.Warn().
|
|
Str("repository", "user").
|
|
Str("action", "update_last_login_no_rows").
|
|
Str("user_id", id.String()).
|
|
Msg("update succeeded but no user was modified - user may not exist")
|
|
} else {
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "update_last_login_success").
|
|
Str("user_id", id.String()).
|
|
Msg("last login updated successfully")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdatePassword updates a user's password.
|
|
// This is used for:
|
|
// - Password change (user-initiated)
|
|
// - Password reset (forgot password flow)
|
|
// - Force password change (admin action)
|
|
//
|
|
// Security process:
|
|
// 1. Hash new password with bcrypt
|
|
// 2. Update password_hash in database
|
|
// 3. Update updated_at timestamp
|
|
//
|
|
// What happens after password change:
|
|
// - Caller should revoke all sessions (force re-login on all devices)
|
|
// - User receives email notification (security alert)
|
|
// - Audit log entry created
|
|
//
|
|
// Why hash before updating:
|
|
// - NEVER store plaintext passwords in database
|
|
// - bcrypt provides strong one-way hashing
|
|
// - Even database admins can't see actual passwords
|
|
// - Protects users even if database is breached
|
|
//
|
|
// Important considerations:
|
|
// 1. Validate new password strength before calling this
|
|
// 2. Verify user's identity (current password or reset token)
|
|
// 3. Rate limit password changes (prevent abuse)
|
|
// 4. Send notification to user's email
|
|
// 5. Consider revoking all sessions
|
|
//
|
|
// Error handling:
|
|
// - Returns error if hashing fails (rare, memory issues)
|
|
// - Returns error if update fails (user not found, database error)
|
|
func (r *UserRepository) UpdatePassword(ctx context.Context, id uuid.UUID, password string) error {
|
|
log.Info().
|
|
Str("repository", "user").
|
|
Str("action", "update_password_started").
|
|
Str("user_id", id.String()).
|
|
Msg("updating user password")
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "hashing_new_password").
|
|
Int("bcrypt_cost", bcrypt.DefaultCost).
|
|
Msg("hashing new password with bcrypt")
|
|
// Step 1: Hash the new password
|
|
// Always use bcrypt for password hashing (never plaintext!)
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("repository", "user").
|
|
Str("action", "password_hash_failed_on_update").
|
|
Str("user_id", id.String()).
|
|
Err(err).
|
|
Msg("failed to hash new password during update")
|
|
|
|
// Wrap error with context
|
|
return fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// SQL UPDATE query
|
|
query := `
|
|
UPDATE users
|
|
SET password_hash = $2, -- Update to new hashed password
|
|
updated_at = NOW() -- Track modification time
|
|
WHERE id = $1 -- Only update this user
|
|
`
|
|
|
|
// Execute update
|
|
results, err := r.db.ExecContext(
|
|
ctx,
|
|
query,
|
|
id, // $1 - User ID
|
|
string(hashedPassword), // $2 - New password hash
|
|
)
|
|
if err != nil {
|
|
log.Error().
|
|
Str("repository", "user").
|
|
Str("action", "update_password_failed").
|
|
Str("user_id", id.String()).
|
|
Err(err).
|
|
Msg("failed to update password in database")
|
|
|
|
return err
|
|
}
|
|
|
|
rowsAffected, _ := results.RowsAffected()
|
|
if rowsAffected == 0 {
|
|
log.Warn().
|
|
Str("repository", "user").
|
|
Str("action", "update_password_no_rows").
|
|
Str("user_id", id.String()).
|
|
Msg("password update succeeded but no user was modified")
|
|
} else {
|
|
log.Info().
|
|
Str("repository", "user").
|
|
Str("action", "update_password_success").
|
|
Str("user_id", id.String()).
|
|
Msg("password updated successfully - all sessions should be revoked")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifyPassword checks if a provided password matches the user's stored password hash.
|
|
// This is the core of password-based authentication.
|
|
//
|
|
// How it works:
|
|
// 1. Extract password_hash from user (from database)
|
|
// 2. Use bcrypt.CompareHashAndPassword to verify
|
|
// 3. Return true if match, false if not
|
|
//
|
|
// bcrypt verification process:
|
|
// 1. Hash format: "$2a$10$salthashedpassword"
|
|
// 2. bcrypt extracts salt from stored hash
|
|
// 3. Hashes provided password with same salt
|
|
// 4. Compares result with stored hash
|
|
// 5. Returns nil error if match, error if mismatch
|
|
//
|
|
// Why bcrypt is good for this:
|
|
// - Timing-safe comparison (prevents timing attacks)
|
|
// - Salt is stored in hash (no separate storage needed)
|
|
// - Slow by design (prevents brute force)
|
|
// - Industry standard
|
|
//
|
|
// Security considerations:
|
|
// - Never log or display passwords
|
|
// - Don't reveal if email or password was wrong (prevents enumeration)
|
|
// - Rate limit login attempts (prevent brute force)
|
|
// - Consider account lockout after failed attempts
|
|
//
|
|
// Nil check importance:
|
|
// - If password_hash is nil, dereferencing causes panic
|
|
// - This might happen if:
|
|
// - User row is corrupted
|
|
// - Migration error
|
|
// - Direct database manipulation
|
|
//
|
|
// - Better to return false than crash
|
|
//
|
|
// Return values:
|
|
// - true: Password matches (authentication successful)
|
|
// - false: Password doesn't match OR hash is nil (authentication failed)
|
|
func (r *UserRepository) VerifyPassword(user *models.User, providedPassword string) bool {
|
|
log.Debug().
|
|
Str("repository", "user").
|
|
Str("action", "verify_password_started").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Bool("has_password_hash", user.PasswordHash != nil).
|
|
Msg("verifying user password")
|
|
// Safety check: Prevent panic if password_hash is nil
|
|
// This shouldn't happen in normal operation, but better safe than crashed
|
|
if user.PasswordHash == nil {
|
|
log.Error().
|
|
Str("repository", "user").
|
|
Str("action", "missing_password_hash").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Msg("CRITICAL: user has no password hash - data corruption or migration issue")
|
|
return false // No hash = can't verify = authentication fails
|
|
}
|
|
|
|
// Use bcrypt to compare provided password with stored hash
|
|
// CompareHashAndPassword:
|
|
// - Takes stored hash as []byte
|
|
// - Takes provided password as []byte
|
|
// - Returns nil if match, error if mismatch
|
|
// - Handles salt extraction and timing-safe comparison
|
|
err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(providedPassword))
|
|
|
|
if err != nil {
|
|
log.Warn().
|
|
Str("repository", "user").
|
|
Str("action", "password_verification_failed").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Err(err).
|
|
Msg("password verification failed - incorrect password")
|
|
return false
|
|
}
|
|
log.Info().
|
|
Str("repository", "user").
|
|
Str("action", "password_verification_success").
|
|
Str("user_id", user.ID.String()).
|
|
Str("email", user.Email).
|
|
Str("tenant_id", user.TenantID.String()).
|
|
Str("role", user.Role).
|
|
Msg("password verified successfully")
|
|
return true
|
|
}
|