aurganize-backend/backend/internal/repositories/session_repository.go

806 lines
28 KiB
Go

package repositories
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"time"
"github.com/creativenoz/aurganize-v62/backend/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log"
)
// SessionRepository handles all database operations related to user sessions.
// A session represents an authenticated user's connection/login instance.
//
// What is a session?
// - Created when a user logs in
// - Stores refresh token information
// - Tracks device/location information
// - Can be revoked to log out a specific device
// - Has an expiration date
//
// Why track sessions?
// 1. Security: See all active login locations/devices
// 2. Control: Revoke specific sessions (e.g., "logout from my phone")
// 3. Audit: Track when/where users log in
// 4. Token validation: Verify refresh tokens haven't been revoked
//
// Architecture pattern: Repository Pattern
// - Abstracts database operations
// - Provides clean interface for data access
// - Makes testing easier (can mock repository)
// - Keeps SQL queries separate from business logic
type SessionRepository struct {
db *sqlx.DB // sqlx provides enhanced database operations (named queries, struct scanning)
}
// NewSessionRepository creates a new instance of SessionRepository.
// This constructor follows dependency injection pattern:
// - Database connection passed in rather than created internally
// - Makes testing easier (can pass test database)
// - Keeps repository flexible (works with any sqlx.DB connection)
//
// Parameter:
// - db: The database connection pool to use for all operations
//
// Returns:
// - Initialized SessionRepository ready to perform database operations
func NewSessionRepository(db *sqlx.DB) *SessionRepository {
log.Info().
Str("repository", "session").
Str("component", "repository_init").
Bool("has_db_connection", db != nil).
Msg("session repository initialized")
return &SessionRepository{db: db}
}
// Create creates a new session record in the database.
// This is called when a user logs in to track the authentication session.
//
// What happens here:
// 1. Hashes the refresh token (security - never store raw tokens)
// 2. Inserts session record with user info, device info, expiration
// 3. Returns the created session with generated ID and timestamps
//
// Why hash the token?
// - If database is compromised, attackers can't use the tokens directly
// - Hashing is one-way (can verify but can't recover original)
// - Similar to password hashing but using SHA-256 instead of bcrypt
//
// Token hashing strategy explained:
// We use SHA-256 instead of bcrypt because:
// - bcrypt is for passwords (slow, salted, designed for brute-force resistance)
// - bcrypt generates different hash each time for same input (random salt)
// - SHA-256 is for tokens (fast, deterministic, allows exact lookup)
// - SHA-256 always produces same hash for same input (what we need for token lookup)
//
// If we used bcrypt:
// - Each login would generate different hash for same token
// - We couldn't look up sessions by token (bcrypt needs to compare, not lookup)
// - Token validation would require scanning all sessions (very slow)
//
// Flow:
// 1. Hash the plaintext refresh token using SHA-256
// 2. Insert session record with hashed token
// 3. Database generates ID, timestamps
// 4. Return complete session object
//
// Error handling:
// - Returns error if database insert fails
// - Caller should handle errors (usually return 500 to client)
func (r *SessionRepository) Create(ctx context.Context, input *models.CreateSessionInput) (*models.Session, error) {
// OLD CODE (commented out) - Why bcrypt doesn't work for tokens:
// hash, err := bcrypt.GenerateFromPassword([]byte(input.RefreshToken), bcrypt.DefaultCost)
// if err != nil {
// return nil, err
// }
// EXPLANATION OF WHY BCRYPT DOESN'T WORK:
// bcrypt is designed for passwords (slow, with salt, for brute-force protection)
// For tokens, you should use SHA-256 (fast, deterministic hash)
//
// Why this matters:
// * bcrypt generates a different hash each time for the same input (because of random salt)
// - Example: Hash("mytoken") could give "$2a$10$abcd..." first time and "$2a$10$xyz..." second time
// * When you try to verify the token later, bcrypt.CompareHashAndPassword won't work with the plain token
// - You'd need to store which hash belongs to which token (defeats the purpose)
// * SHA-256 always produces the same hash for the same input (what you need for token lookup)
// - Example: Hash("mytoken") always gives "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
//
// NEW CODE: Hash token using SHA-256 for deterministic lookup
log.Info().
Str("repository", "session").
Str("action", "create_session_started").
Str("user_id", input.UserID.String()).
Str("device_type", input.DeviceType).
Str("ip_address", *input.IPAddress).
Bool("has_device_name", input.DeviceName != nil).
Msg("creating new session record")
hash := hashToken(input.RefreshToken)
// Prepare session struct to receive database response
session := &models.Session{}
// SQL query to insert new session
// Uses RETURNING clause to get back the created record in one database round-trip
// This is PostgreSQL-specific syntax (MySQL would need separate SELECT after INSERT)
query := `
INSERT INTO sessions (
user_id, -- Which user this session belongs to
refresh_token_hash,-- Hashed refresh token (never store plaintext tokens!)
user_agent, -- Browser/app information (e.g., "Mozilla/5.0...")
ip_address, -- IP address user logged in from
device_name, -- Optional device name (e.g., "John's iPhone")
device_type, -- Device category: "mobile", "desktop", "web"
expires_at -- When this session expires (usually 7 days from now)
) VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING id, user_id, refresh_token_hash, user_agent,
ip_address, device_name, device_type, expires_at, is_revoked,
revoked_at, revoked_reason, created_at, last_used_at
`
// Execute query and scan result directly into session struct
// GetContext:
// - Executes query with context (supports cancellation/timeout)
// - Expects exactly one row returned
// - Maps columns to struct fields by matching db tags
// - Returns error if query fails or row count != 1
err := r.db.GetContext(
ctx,
session, // Destination struct
query, // SQL query
// Parameters matching $1, $2, $3, etc. in query
input.UserID,
string(hash), // Store hash, not raw token
input.UserAgent,
input.IPAddress,
input.DeviceName,
input.DeviceType,
input.ExpiresAt,
)
if err != nil {
log.Error().
Str("repository", "session").
Str("action", "create_session_failed").
Str("user_id", input.UserID.String()).
Str("device_type", input.DeviceType).
Err(err).
Msg("failed to create session record in database")
return nil, err
}
log.Info().
Str("repository", "session").
Str("action", "create_session_success").
Str("session_id", session.ID.String()).
Str("user_id", session.UserID.String()).
Str("device_type", session.DeviceType).
Str("ip_address", *session.IPAddress).
Time("expires_at", session.ExpiresAt).
Msg("session created successfully")
return session, err
}
// FindBySessionIDAndToken looks up a valid session by session ID and refresh token.
// This is used to validate refresh tokens during token refresh requests.
//
// Why we need both session ID and token:
// - Session ID comes from JWT claims (identifies which session)
// - Token is the actual refresh token (proves possession)
// - Both must match for validation to succeed
//
// Security checks performed:
// 1. Token hash must match stored hash
// 2. Session ID must match
// 3. Session must not be revoked (is_revoked = FALSE)
// 4. Session must not be expired (expires_at > NOW())
//
// Why hash the token for lookup?
// - We never store plaintext tokens in database
// - Hash the provided token using same algorithm (SHA-256)
// - Look up by the hash
// - If database is breached, attackers get hashes, not usable tokens
//
// Flow:
// 1. Hash the provided token
// 2. Query database for matching session ID and token hash
// 3. Only return if session is valid (not revoked, not expired)
// 4. Return nil if not found (not an error, just not found)
//
// Return values:
// - (*Session, nil): Session found and valid
// - (nil, nil): Session not found (not revoked/expired, or doesn't exist)
// - (nil, error): Database error occurred
func (r *SessionRepository) FindBySessionIDAndToken(ctx context.Context, sessionId uuid.UUID, token string) (*models.Session, error) {
log.Debug().
Str("repository", "session").
Str("action", "find_session_by_token_started").
Str("session_id", sessionId.String()).
Msg("looking up session by session id and refresh token")
session := &models.Session{}
// SQL query with multiple conditions for security
query := `
SELECT id, user_id, refresh_token_hash, user_agent,
ip_address, device_name, device_type, expires_at, is_revoked,
revoked_at, revoked_reason, created_at, last_used_at
FROM sessions
WHERE refresh_token_hash = $1 -- Token must match
AND id = $2 -- Session ID must match
AND is_revoked = FALSE -- Session must not be revoked
AND expires_at > NOW() -- Session must not be expired
`
// Hash the provided token using same algorithm used during creation
// This allows us to look up the session by the hash
tokenHash := hashToken(token)
// Execute query
err := r.db.GetContext(
ctx,
session,
query,
tokenHash, // $1 - Hashed token for lookup
sessionId, // $2 - Session ID for matching
)
// Handle "not found" case specially
// sql.ErrNoRows means query executed successfully but returned no rows
// This is not an error condition - it just means session doesn't exist or is invalid
if err == sql.ErrNoRows {
log.Warn().
Str("repository", "session").
Str("action", "session_not_found").
Str("session_id", sessionId.String()).
Msg("session not found or invalid - may be expired, revoked, or token mismatch")
return nil, nil // Return nil session and nil error
}
if err != nil {
log.Error().
Str("repository", "session").
Str("action", "find_session_error").
Str("session_id", sessionId.String()).
Err(err).
Msg("database error while looking up session")
return nil, err
}
log.Debug().
Str("repository", "session").
Str("action", "session_found_valid").
Str("session_id", session.ID.String()).
Str("user_id", session.UserID.String()).
Str("device_type", session.DeviceType).
Time("last_used_at", session.LastUsedAt).
Msg("session found and validated successfully")
return session, err
}
// FindById retrieves a session by its ID if it's valid (not revoked, not expired).
// This is useful for:
// - Checking session status
// - Updating session information
// - Listing user's sessions
//
// Validation checks:
// - Session must exist
// - Session must not be revoked (is_revoked = FALSE)
// - Session must not be expired (expires_at > NOW())
//
// Unlike FindBySessionIDAndToken, this doesn't verify the token itself,
// just checks if the session exists and is valid.
//
// Return values:
// - (*Session, nil): Session found and valid
// - (nil, nil): Session not found or is invalid
// - (nil, error): Database error occurred
func (r *SessionRepository) FindById(ctx context.Context, id uuid.UUID) (*models.Session, error) {
log.Debug().
Str("repository", "session").
Str("action", "find_session_by_id_started").
Str("session_id", id.String()).
Msg("looking up session by id")
session := &models.Session{}
// Query for session by ID with validation checks
query := `
SELECT id, user_id, refresh_token_hash, user_agent,
ip_address, device_name, device_type, expires_at, is_revoked,
revoked_at, revoked_reason, created_at, last_used_at
FROM sessions
WHERE id = $1 -- Match session ID
AND is_revoked = FALSE -- Must not be revoked
AND expires_at > NOW() -- Must not be expired
`
err := r.db.GetContext(
ctx,
session,
query,
id, // $1 - Session ID
)
// Handle "not found" case
if err == sql.ErrNoRows {
log.Debug().
Str("repository", "session").
Str("action", "session_not_found_by_id").
Str("session_id", id.String()).
Msg("session not found by id")
return nil, nil // Not found is not an error
}
if err != nil {
log.Error().
Str("repository", "session").
Str("action", "find_session_by_id_error").
Str("session_id", id.String()).
Err(err).
Msg("database error while looking up session by id")
}
log.Debug().
Str("repository", "session").
Str("action", "session_found_by_id").
Str("session_id", session.ID.String()).
Str("user_id", session.UserID.String()).
Msg("session found by id")
return session, nil
}
// UpdateLastUsed updates the last_used_at timestamp for a session.
// This is called whenever a session's refresh token is used to get a new access token.
//
// Why track last usage?
// 1. Security: Identify sessions that haven't been used recently
// 2. Cleanup: Can remove stale sessions
// 3. User awareness: Show users which sessions are actively being used
// 4. Anomaly detection: Unusual usage patterns might indicate compromise
//
// Called by:
// - Token refresh endpoint (every time user gets new access token)
// - Typically happens every 15 minutes (when access token expires)
//
// Updates:
// - last_used_at: Set to current database time (NOW())
//
// Error handling:
// - Returns error if update fails (database error)
// - Caller usually ignores this error (not critical for token refresh)
func (r *SessionRepository) UpdateLastUsed(ctx context.Context, id uuid.UUID) error {
log.Debug().
Str("repository", "session").
Str("action", "update_last_used_started").
Str("session_id", id.String()).
Msg("updating session last_used_at timestamp")
// SQL update query
// Uses NOW() for database-consistent timestamp (not Go's time.Now())
query := `
UPDATE sessions
SET last_used_at = NOW() -- Update to current database time
WHERE id=$1 -- Only update this session
`
// ExecContext executes query that doesn't return rows (UPDATE, DELETE, etc.)
// Returns:
// - sql.Result: Contains rows affected, last insert ID, etc.
// - error: Database error if query fails
result, err := r.db.ExecContext(
ctx,
query,
id, // $1 - Session ID
)
if err != nil {
log.Warn().
Str("repository", "session").
Str("action", "update_last_used_failed").
Str("session_id", id.String()).
Err(err).
Msg("failed to update session last_used_at timestamp")
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
log.Warn().
Str("repository", "session").
Str("action", "update_last_used_no_rows").
Str("session_id", id.String()).
Msg("update succeeded but no session was modified - session may not exist")
} else {
log.Debug().
Str("repository", "session").
Str("action", "update_last_used_success").
Str("session_id", id.String()).
Msg("session last_used_at updated successfully")
}
return nil
}
// Revoke marks a session as revoked, preventing its refresh token from being used.
// This is called during:
// - User logout (revoke current session)
// - Security actions (revoke compromised session)
// - Administrative actions (force logout)
//
// What happens:
// 1. Finds session by token hash
// 2. Sets is_revoked = TRUE (marks as invalid)
// 3. Sets revoked_at = NOW() (records when revoked)
// 4. Sets revoked_reason (why it was revoked)
//
// Why track revocation reason?
// - Audit trail: Know why sessions ended
// - Analytics: Understand logout patterns
// - Security: Identify security-related revocations
// - User awareness: Can show user why session ended
//
// Common revocation reasons:
// - "user_logout": User clicked logout button
// - "password_change": Password was changed (invalidate all sessions)
// - "security_breach": Suspected compromise
// - "admin_action": Administrator revoked session
// - "device_lost": User reported device lost/stolen
//
// Important: Once revoked, the session cannot be un-revoked.
// User must log in again to create a new session.
//
// Error handling:
// - Returns error if update fails
// - No error if session doesn't exist (idempotent operation)
func (r *SessionRepository) Revoke(ctx context.Context, token string, reason string) error {
log.Info().
Str("repository", "session").
Str("action", "revoke_session_started").
Str("revoke_reason", reason).
Msg("revoking session by token")
// Hash the token to find corresponding session
// We store hashed tokens, so we must hash to look up
tokenHash := hashToken(token)
// SQL update query to mark session as revoked
query := `
UPDATE sessions
SET is_revoked = TRUE, -- Mark as revoked
revoked_at = NOW(), -- Record revocation time
revoked_reason = $2 -- Record why it was revoked
WHERE refresh_token_hash=$1 -- Find session by token hash
`
// Execute update
// Note: UPDATE returns success even if no rows matched
// This makes the operation idempotent (safe to call multiple times)
results, err := r.db.ExecContext(
ctx,
query,
tokenHash, // $1 - Token hash to find session
reason, // $2 - Why session is being revoked
)
if err != nil {
log.Error().
Str("repository", "session").
Str("action", "revoke_session_failed").
Str("revoke_reason", reason).
Err(err).
Msg("failed to revoke session")
return err
}
rowsAffected, _ := results.RowsAffected()
if rowsAffected == 0 {
log.Warn().
Str("repository", "session").
Str("action", "revoke_no_session_found").
Str("revoke_reason", reason).
Msg("revocation succeeded but no session was modified - token may not exist or already revoked")
} else {
log.Info().
Str("repository", "session").
Str("action", "revoke_session_success").
Str("revoke_reason", reason).
Int64("rows_affected", rowsAffected).
Msg("session revoked successfully")
}
return nil
}
// RevokeByUserId revokes all sessions for a specific user.
// This is a security feature called "logout everywhere" or "logout all devices".
//
// When to use this:
// 1. Password change: Invalidate all existing sessions (force re-login)
// 2. Security breach: User reports account compromise
// 3. Administrative action: Admin needs to force user logout
// 4. Account deletion: Revoke all sessions before deleting user
//
// What it does:
// - Finds all non-revoked sessions for the user
// - Marks them all as revoked
// - Records when and why they were revoked
//
// After calling this:
// - All refresh tokens for this user become invalid
// - User must log in again on all devices
// - Current access tokens remain valid until they expire (typically 15 minutes)
//
// Note: This doesn't immediately invalidate access tokens because:
// - Access tokens are stateless (not checked against database)
// - They expire quickly anyway (15 minutes)
// - Checking database for every API request would be slow
// - For immediate invalidation, would need a token blacklist (expensive)
//
// Error handling:
// - Returns error if update fails
// - No error if user has no sessions (idempotent)
func (r *SessionRepository) RevokeByUserId(ctx context.Context, userID uuid.UUID, reason string) error {
log.Info().
Str("repository", "session").
Str("action", "revoke_all_user_sessions_started").
Str("user_id", userID.String()).
Str("revoke_reason", reason).
Msg("revoking all sessions for user")
// SQL query to revoke all user's sessions
query := `
UPDATE sessions
SET is_revoked = TRUE, -- Mark as revoked
revoked_at = NOW(), -- Record revocation time
revoked_reason = $2 -- Record reason
WHERE user_id = $1 -- All sessions for this user
AND is_revoked = FALSE -- Only revoke non-revoked sessions (optimization)
`
// Execute update
// Could affect 0 to many rows depending on how many sessions user has
result, err := r.db.ExecContext(
ctx,
query,
userID, // $1 - User whose sessions to revoke
reason, // $2 - Reason for revocation
)
if err != nil {
log.Error().
Str("repository", "session").
Str("action", "revoke_all_user_sessions_failed").
Str("user_id", userID.String()).
Str("revoke_reason", reason).
Err(err).
Msg("CRITICAL: failed to revoke all user sessions")
return err
}
rowsAffected, _ := result.RowsAffected()
log.Info().
Str("repository", "session").
Str("action", "revoke_all_user_sessions_success").
Str("user_id", userID.String()).
Int64("sessions_revoked", rowsAffected).
Str("revoke_reason", reason).
Msg("all user sessions revoked successfully")
return nil
}
// DeleteExpired removes expired and old revoked sessions from database.
// This is a cleanup/maintenance operation typically run as a scheduled job.
//
// What gets deleted:
// 1. Sessions past their expiration date (expires_at < NOW())
// 2. Revoked sessions older than 30 days (is_revoked AND revoked_at < 30 days ago)
//
// Why delete expired sessions?
// - Database cleanup: Prevents unlimited growth
// - Performance: Smaller tables = faster queries
// - Privacy: No need to keep old session data forever
// - Compliance: Data retention policies may require deletion
//
// Why keep revoked sessions for 30 days?
// - Audit trail: Need recent history for security investigations
// - User support: Can check recent logouts for support issues
// - Analytics: Understand logout patterns
// - After 30 days: Unlikely to need the data, safe to delete
//
// When to run this:
// - Scheduled job: Daily or weekly (off-peak hours)
// - Not during request handling: Too slow, not time-critical
// - Could use database job scheduler or cron job
//
// Performance considerations:
// - Could be slow if millions of sessions
// - Consider adding indexes on expires_at and revoked_at
// - Could batch delete (delete 1000 at a time) for very large tables
// - Consider partitioning sessions table by date for easier cleanup
//
// Return value:
// - Number of rows deleted (for logging/monitoring)
// - Error if delete fails
func (r *SessionRepository) DeleteExpired(ctx context.Context) (int64, error) {
log.Info().
Str("repository", "session").
Str("action", "delete_expired_sessions_started").
Msg("starting cleanup of expired and old revoked sessions")
// SQL delete query with two conditions (connected by OR)
// Deletes sessions that meet EITHER condition
query := `
DELETE FROM sessions
WHERE expires_at < NOW() -- Condition 1: Session expired
OR (
is_revoked = TRUE -- Condition 2: Session revoked AND
AND revoked_at < NOW() - INTERVAL '30 days' -- More than 30 days ago
)
`
// Execute delete operation
// DELETE returns number of affected rows
result, err := r.db.ExecContext(ctx, query)
if err != nil {
log.Error().
Str("repository", "session").
Str("action", "delete_expired_sessions_failed").
Err(err).
Msg("failed to delete expired sessions")
return 0, err // Return 0 and error if delete fails
}
rowsAffected, _ := result.RowsAffected()
log.Info().
Str("repository", "session").
Str("action", "delete_expired_sessions_success").
Int64("sessions_deleted", rowsAffected).
Msg("expired sessions cleanup completed")
// Extract number of rows deleted
// This is useful for logging: "Deleted 1,234 expired sessions"
return rowsAffected, nil
}
// ListByUserID retrieves all sessions for a specific user.
// This is used to show users their active sessions (like Gmail's "devices & activity").
//
// What it returns:
// - ALL sessions for user (both active and revoked)
// - Includes expired sessions (caller can filter if needed)
// - Sorted by database order (consider adding ORDER BY created_at DESC)
//
// Use cases:
// 1. Security page: Show user where they're logged in
// 2. Session management: Let user revoke specific sessions
// 3. Audit: Show login history with locations/devices
// 4. Support: Help user understand their login activity
//
// What each session shows:
// - When created (created_at)
// - Last used (last_used_at)
// - Device type (mobile/desktop/web)
// - Location (IP address)
// - Browser (user agent)
// - Status (is_revoked, expires_at)
//
// Privacy note:
// - Never expose refresh_token_hash to client
// - IP addresses may be considered personal data (GDPR)
// - Consider anonymizing/hashing old IP addresses
//
// Performance consideration:
// - Most users have few sessions (1-5)
// - Not a concern unless user has 100+ sessions
// - Consider adding pagination for enterprise users
//
// Return values:
// - Slice of sessions (empty slice if user has no sessions)
// - Error if query fails
func (r *SessionRepository) ListByUserID(ctx context.Context, userId uuid.UUID) ([]*models.Session, error) {
log.Debug().
Str("repository", "session").
Str("action", "list_user_sessions_started").
Str("user_id", userId.String()).
Msg("listing all sessions for user")
// Slice to hold returned sessions
var sessions []*models.Session
// SQL query to get all user sessions
// No filtering by is_revoked or expires_at - returns everything
// Consider adding: ORDER BY created_at DESC for newest first
query := `
SELECT id, user_id, refresh_token_hash, user_agent,
ip_address, device_name, device_type, expires_at, is_revoked,
revoked_at, revoked_reason, created_at, last_used_at
FROM sessions
WHERE user_id=$1 -- All sessions for this user
`
// SelectContext is like GetContext but for multiple rows
// - Executes query and scans all rows into slice
// - Maps columns to struct fields by db tags
// - Returns empty slice if no rows (not an error)
err := r.db.SelectContext(
ctx,
&sessions, // Destination slice (must be pointer to slice)
query,
userId, // $1 - User ID
)
if err != nil {
log.Error().
Str("repository", "session").
Str("action", "list_user_sessions_failed").
Str("user_id", userId.String()).
Err(err).
Msg("failed to list user sessions")
return nil, err
}
activeCount := 0
for _, sesion := range sessions {
if !sesion.IsRevoked && sesion.ExpiresAt.After(time.Now()) {
activeCount++
}
}
log.Info().
Str("repository", "session").
Str("action", "list_user_sessions_success").
Str("user_id", userId.String()).
Int("total_sessions", len(sessions)).
Int("active_sessions", activeCount).
Msg("user sessions listed successfully")
return sessions, nil
}
// hashToken creates a SHA-256 hash of a token and returns it as a base64-encoded string.
// This is used for secure token storage in the database.
//
// Why hash tokens?
// 1. Security: If database is breached, attackers get hashes not usable tokens
// 2. Defense in depth: Multiple layers of security
// 3. Compliance: Some regulations require token hashing
// 4. Best practice: Never store sensitive tokens in plaintext
//
// Why SHA-256 instead of bcrypt?
// - SHA-256 is deterministic: Same input always gives same output
// Example: hashToken("mytoken") always gives same hash
// Allows database lookup by hash
// - bcrypt is random: Same input gives different output each time (due to salt)
// Example: bcrypt("mytoken") gives different hash every time
// Can't look up by hash, must compare with every stored hash
// - SHA-256 is fast: Good for tokens that are looked up frequently
// - bcrypt is slow: Good for passwords to resist brute force
//
// Why base64 encode?
// - SHA-256 produces binary data (32 bytes)
// - Binary data is hard to store in text fields
// - base64 converts binary to text (safe for VARCHAR/TEXT columns)
// - URLEncoding variant avoids special characters (+, /, =)
//
// Process:
// 1. Convert token string to bytes
// 2. Hash using SHA-256 (produces 32-byte hash)
// 3. Encode to base64 (produces ~44-character string)
// 4. Store in database as string
//
// Security note:
// - SHA-256 is one-way: Can't reverse hash to get original token
// - Can only verify by hashing again and comparing
// - This means if database is compromised, tokens can't be extracted
//
// Return:
// - Base64-encoded SHA-256 hash as string
func hashToken(token string) string {
// Create SHA-256 hash of token bytes
// sha256.Sum256 returns [32]byte array
hash := sha256.Sum256([]byte(token))
// Encode hash bytes to base64 string
// URLEncoding uses URL-safe characters (no +, /, =)
// hash[:] converts [32]byte array to []byte slice
return base64.URLEncoding.EncodeToString(hash[:])
}