package repositories import ( "context" "crypto/sha256" "database/sql" "encoding/base64" "github.com/creativenoz/aurganize-v62/backend/internal/models" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) // 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 { 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 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, ) 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) { 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 { return nil, nil // Return nil session and nil error } 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) { 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 { return nil, nil // Not found is not an error } return session, err } // 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 { // 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 _, err := r.db.ExecContext( ctx, query, id, // $1 - Session ID ) return err } // 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 { // 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) _, err := r.db.ExecContext( ctx, query, tokenHash, // $1 - Token hash to find session reason, // $2 - Why session is being revoked ) return err } // 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 { // 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 _, err := r.db.ExecContext( ctx, query, userID, // $1 - User whose sessions to revoke reason, // $2 - Reason for revocation ) return err } // 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) { // 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 { return 0, err // Return 0 and error if delete fails } // Extract number of rows deleted // This is useful for logging: "Deleted 1,234 expired sessions" return result.RowsAffected() } // 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) { // 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 ) return sessions, err } // 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[:]) }