<?php

class security_wrapper {

    public $user;
    public $homedir;

    private $logindb;

    function __construct() {
        session_start();
        $existed = file_exists("/docker/whp/sql/login.db");
        $this->logindb = new SQLite3('/docker/whp/sql/login.db');
        $this->logindb->busyTimeout(6000);
        if (!$existed) {
            $this->logindb->query('CREATE TABLE nonces(nonce STRING, user STRING, ip STRING, last_time String, persist INTEGER DEFAULT 0)');
        } else {
            // Idempotent: add persist column to existing deployments that pre-date Stay-Logged-In.
            $hasPersist = false;
            $cols = $this->logindb->query('PRAGMA table_info(nonces)');
            if ($cols) {
                while ($col = $cols->fetchArray(SQLITE3_ASSOC)) {
                    if ($col['name'] === 'persist') { $hasPersist = true; break; }
                }
            }
            if (!$hasPersist) {
                @$this->logindb->query('ALTER TABLE nonces ADD COLUMN persist INTEGER DEFAULT 0');
            }
        }

        // Long-lived persistent-auth tokens for "Stay Logged In". Separate from
        // nonces because their lifecycle is different — 7-day hard expiry, not
        // single-use-per-request, indexed by a public selector half so the
        // secret verifier never needs a table scan. Multi-device: no UNIQUE on
        // user, each device gets its own row.
        $this->logindb->query('CREATE TABLE IF NOT EXISTS persist_tokens (
            selector       TEXT PRIMARY KEY,
            verifier_hash  TEXT NOT NULL,
            user           TEXT NOT NULL,
            ip             TEXT NOT NULL,
            created_time   INTEGER NOT NULL,
            expires_time   INTEGER NOT NULL,
            last_used_time INTEGER NOT NULL
        )');
        $this->logindb->query('CREATE INDEX IF NOT EXISTS idx_persist_user    ON persist_tokens(user)');
        $this->logindb->query('CREATE INDEX IF NOT EXISTS idx_persist_expires ON persist_tokens(expires_time)');

        // Lazy GC — login.db is small, so a 1% probability sweep on each
        // request is enough to keep expired rows from accumulating without a
        // dedicated cron (which would need flock+timeout wrapping per repo
        // conventions for short-cadence jobs).
        if (mt_rand(1, 100) === 1) {
            $gc = $this->logindb->prepare('DELETE FROM persist_tokens WHERE expires_time < :n');
            $gc->bindValue(':n', time(), SQLITE3_INTEGER);
            $gc->execute();
        }
    }

    function pam_login($user,$password) {
        $error = '';
        $result = pam_auth( $user, $password, $error );
        if ( $result ) {
            $this->user = $user;
        }
        return $result;
    }

    function get_home_dir($user) {
        // Sanitize user input to prevent command injection
        $user = escapeshellarg($user);
        $homedir = trim(shell_exec("cat /etc/passwd |grep $user |awk -F ':' {'print \$6'}|head -1"));
        
        // If homedir is empty or not found, try to construct it based on username
        if (empty($homedir)) {
            // For root user, use /root
            if ($user === "'root'" || $user === "root") {
                $homedir = '/root';
            } else {
                // For regular users in WHP, use /docker/users/username pattern
                // Remove quotes from escapeshellarg for path construction
                $clean_user = trim($user, "'");
                $homedir = '/docker/users/' . $clean_user;
            }
            error_log("Warning: Could not find home directory for user $user in /etc/passwd, using fallback: $homedir");
        }
        
        $this->homedir = $homedir;
        return $homedir;
    }

    /**
     * Validate a nonce and refresh its window. Caller passes $maxAge in seconds;
     * default 900 (15 min) preserves the historical idle-timeout behavior. When
     * the user logged in with "Stay Logged In", callers pass a larger value
     * (e.g., 7 days) so the nonce stays valid across long idle periods.
     */
    function check_nonce($nonce, $ip, $maxAge = 900) {
        $stmt = $this->logindb->prepare('SELECT user,ip,last_time,persist FROM nonces WHERE nonce=:n');
        $stmt->bindValue(':n', $nonce, SQLITE3_TEXT);
        $result = $stmt->execute();
        if ( $result !== false ) {
            $row = $result->fetchArray(SQLITE3_ASSOC);
            if ( $row && hash_equals((string)$row['ip'], (string)$ip) ) {
                $time_diff = time() - $row['last_time'];
                if ( $time_diff > $maxAge ) {
                    return false;
                } else {
                    $dlte = $this->logindb->prepare('DELETE FROM nonces WHERE nonce=:n');
                    $dlte->bindValue(':n', $nonce, SQLITE3_TEXT);
                    $dlte->execute();
                    return $row;
                }
            } else {
                return false;
            }
        } else {
            return false;
        }
    }

    /**
     * Create a fresh nonce row. $persist=true marks the row as a Stay-Logged-In
     * session so the UI / Active Sessions page can label it.
     */
    function create_nonce($user, $ip, $salt, $persist = false) {
        $last_time = time();
        $nonce = hash("sha1", "$salt.$user.$ip.$last_time");
        $stmt = $this->logindb->prepare("INSERT INTO nonces (nonce, user, ip, last_time, persist) VALUES (:nonce, :user, :ip, :last_time, :persist)");
        $stmt->bindValue(':nonce', $nonce, SQLITE3_TEXT);
        $stmt->bindValue(':user', $user, SQLITE3_TEXT);
        $stmt->bindValue(':ip', $ip, SQLITE3_TEXT);
        $stmt->bindValue(':last_time', $last_time, SQLITE3_INTEGER);
        $stmt->bindValue(':persist', $persist ? 1 : 0, SQLITE3_INTEGER);
        $stmt->execute();
        return $nonce;
    }

    /**
     * Remove a single nonce row. Used by logout and the Active Sessions
     * "terminate" action.
     */
    function delete_nonce($nonce) {
        $stmt = $this->logindb->prepare('DELETE FROM nonces WHERE nonce=:n');
        $stmt->bindValue(':n', $nonce, SQLITE3_TEXT);
        $stmt->execute();
    }

    /**
     * List nonce rows that still represent live sessions. A row counts as live
     * when its last_time is within the lifetime appropriate for its persist
     * flag — 7 days for Stay-Logged-In rows, 900 seconds for standard rows.
     * Expired rows are excluded so the UI doesn't offer terminate buttons for
     * sessions whose next request would already force re-login.
     *
     * Pass $user=null for the root view (every account); otherwise scopes to
     * that user. Pass $include_expired=true to skip the lifetime filter (for
     * an admin "show all" toggle if we ever need it).
     */
    function list_nonces($user = null, $include_expired = false) {
        $now = time();
        $persistThreshold  = $now - (7 * 86400);
        $standardThreshold = $now - 900;

        $select = 'SELECT nonce, user, ip, last_time, persist FROM nonces';
        $where  = [];
        $bind   = [];

        if ($user !== null) {
            $where[] = 'user = :u';
            $bind[':u'] = [$user, SQLITE3_TEXT];
        }
        if (!$include_expired) {
            $where[] = '((persist = 1 AND last_time >= :pt) OR (persist = 0 AND last_time >= :st))';
            $bind[':pt'] = [$persistThreshold,  SQLITE3_INTEGER];
            $bind[':st'] = [$standardThreshold, SQLITE3_INTEGER];
        }

        $sql = $select;
        if (!empty($where)) {
            $sql .= ' WHERE ' . implode(' AND ', $where);
        }
        $sql .= ' ORDER BY last_time DESC';

        $stmt = $this->logindb->prepare($sql);
        foreach ($bind as $key => $pair) {
            $stmt->bindValue($key, $pair[0], $pair[1]);
        }
        $result = $stmt->execute();

        $rows = [];
        if ($result) {
            while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
                $rows[] = $r;
            }
        }
        return $rows;
    }

    /**
     * Bulk-terminate every active session for a given user. Root uses this when
     * a user reports a compromised account.
     */
    function delete_nonces_by_user($user) {
        $stmt = $this->logindb->prepare('DELETE FROM nonces WHERE user=:u');
        $stmt->bindValue(':u', $user, SQLITE3_TEXT);
        $stmt->execute();
    }

    /**
     * Look up a single nonce row (used by the terminate API to authorize the
     * caller before deleting).
     */
    function get_nonce($nonce) {
        $stmt = $this->logindb->prepare('SELECT nonce, user, ip, last_time, persist FROM nonces WHERE nonce=:n');
        $stmt->bindValue(':n', $nonce, SQLITE3_TEXT);
        $result = $stmt->execute();
        if ($result) {
            $row = $result->fetchArray(SQLITE3_ASSOC);
            return $row ?: null;
        }
        return null;
    }

    /**
     * Mint a persistent-auth token. The cookie value is `selector:verifier` —
     * selector is a public lookup key, verifier is the secret stored only as a
     * SHA-256 hash. Constant-time comparison against the hash defends against
     * timing attacks; storing the hash means a DB read leak does not yield a
     * live cookie.
     */
    function create_persist_token($user, $ip) {
        $selector = bin2hex(random_bytes(8));   // 16 hex chars
        $verifier = bin2hex(random_bytes(32));  // 64 hex chars
        $vhash    = hash('sha256', $verifier);
        $now      = time();
        $expires  = $now + (7 * 86400);
        $stmt = $this->logindb->prepare('INSERT INTO persist_tokens
            (selector, verifier_hash, user, ip, created_time, expires_time, last_used_time)
            VALUES (:s, :vh, :u, :ip, :ct, :et, :lt)');
        $stmt->bindValue(':s',  $selector, SQLITE3_TEXT);
        $stmt->bindValue(':vh', $vhash,    SQLITE3_TEXT);
        $stmt->bindValue(':u',  $user,     SQLITE3_TEXT);
        $stmt->bindValue(':ip', $ip,       SQLITE3_TEXT);
        $stmt->bindValue(':ct', $now,      SQLITE3_INTEGER);
        $stmt->bindValue(':et', $expires,  SQLITE3_INTEGER);
        $stmt->bindValue(':lt', $now,      SQLITE3_INTEGER);
        $stmt->execute();
        return "$selector:$verifier";
    }

    /**
     * Validate and consume a persistent-auth token. On a selector hit the row
     * is deleted regardless of whether verification succeeded — this keeps
     * brute-force / replay surface narrow and matches the single-use semantics
     * the recovery path expects (caller mints a fresh token on success).
     * Returns the user/ip on success, false on any failure.
     */
    function consume_persist_token($cookie_value, $ip) {
        if (!preg_match('/^([a-f0-9]{16}):([a-f0-9]{64})$/', $cookie_value, $m)) {
            return false;
        }
        $selector = $m[1];
        $verifier = $m[2];
        $stmt = $this->logindb->prepare(
            'SELECT verifier_hash, user, ip, expires_time FROM persist_tokens WHERE selector=:s');
        $stmt->bindValue(':s', $selector, SQLITE3_TEXT);
        $result = $stmt->execute();
        $row = $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
        if (!$row) {
            return false;
        }
        $ok = hash_equals((string)$row['verifier_hash'], hash('sha256', $verifier))
           && hash_equals((string)$row['ip'], (string)$ip)
           && (int)$row['expires_time'] > time();
        // Always delete on lookup hit — even on failure (defense in depth).
        $this->delete_persist_token_by_selector($selector);
        if (!$ok) {
            return false;
        }
        return ['user' => $row['user'], 'ip' => $row['ip']];
    }

    /**
     * Remove a single persistent-auth row by its selector. Used by the
     * consume path, the logout path, and the Active Sessions terminate API.
     */
    function delete_persist_token_by_selector($selector) {
        $stmt = $this->logindb->prepare('DELETE FROM persist_tokens WHERE selector=:s');
        $stmt->bindValue(':s', $selector, SQLITE3_TEXT);
        $stmt->execute();
    }

    /**
     * Bulk-terminate every persistent-auth token for a given user. Pairs with
     * delete_nonces_by_user() for the root-driven "kick this account off
     * everywhere" flow.
     */
    function delete_persist_tokens_by_user($user) {
        $stmt = $this->logindb->prepare('DELETE FROM persist_tokens WHERE user=:u');
        $stmt->bindValue(':u', $user, SQLITE3_TEXT);
        $stmt->execute();
    }

    /**
     * List persistent-auth tokens for the Active Sessions UI. Never exposes
     * verifier_hash. Scoped to $user or returns everything when $user is null.
     * Excludes already-expired rows so the UI doesn't offer terminate buttons
     * for rows that would fail recovery on their next request anyway.
     */
    function list_persist_tokens($user = null) {
        $now = time();
        $sql = 'SELECT selector, user, ip, created_time, expires_time, last_used_time
                FROM persist_tokens
                WHERE expires_time >= :now';
        if ($user !== null) {
            $sql .= ' AND user = :u';
        }
        $sql .= ' ORDER BY last_used_time DESC';
        $stmt = $this->logindb->prepare($sql);
        $stmt->bindValue(':now', $now, SQLITE3_INTEGER);
        if ($user !== null) {
            $stmt->bindValue(':u', $user, SQLITE3_TEXT);
        }
        $result = $stmt->execute();
        $rows = [];
        if ($result) {
            while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
                $rows[] = $r;
            }
        }
        return $rows;
    }

    /**
     * Look up a single persistent-auth row by selector (used by the terminate
     * API to authorize the caller before deleting). Verifier_hash is included
     * so the API can decide whether the row matches a presented cookie if it
     * ever needs to; current callers ignore that field.
     */
    function get_persist_token_by_selector($selector) {
        $stmt = $this->logindb->prepare(
            'SELECT selector, user, ip, created_time, expires_time, last_used_time
             FROM persist_tokens WHERE selector=:s');
        $stmt->bindValue(':s', $selector, SQLITE3_TEXT);
        $result = $stmt->execute();
        if ($result) {
            $row = $result->fetchArray(SQLITE3_ASSOC);
            return $row ?: null;
        }
        return null;
    }

    function __destruct() {
        $this->logindb->close();
    }

}

