in a browser. * 3. Fills in the "create first admin" form. * On successful submit the first user is created as a `superuser`, the token * file is truncated (consumed), an active session is minted, and the browser * lands on the dashboard. * * Router gate (in index.php): if the users table is empty, EVERY path except * /setup + /assets/* redirects to /setup. * * Attackers scanning for well-known default passwords find nothing — the * token they need is on-disk in a root-restricted file. They'd have to * already have shell access to grab it, at which point 2FA is beside the * point. */ declare(strict_types=1); namespace CustomSSO\Controllers; use CustomSSO\Lib\Audit; use CustomSSO\Lib\Db; use CustomSSO\Lib\Router; use CustomSSO\Lib\Session; use CustomSSO\Models\TrustedDevice; final class Setup { private const TOKEN_FILE = '/etc/customsso-manager/first-run.token'; public function show(array $args): void { // If setup is already done (any user exists), refuse. if (self::alreadySetUp()) { Router::redirect('/login?err=already_set_up'); } $token = trim((string)($_GET['token'] ?? '')); $stored = self::readTokenFile(); $err = $_GET['err'] ?? ''; $matched = ($token !== '' && $stored !== '' && hash_equals($stored, $token)); // If a token was supplied but didn't match, surface an error to the // paste form instead of silently re-rendering. Empty-token first visit // stays clean (no error). if (!$matched && $token !== '' && $err === '') { $err = 'token_invalid'; } require __DIR__ . '/../views/setup.php'; } public function submit(array $args): void { if (self::alreadySetUp()) { Router::redirect('/login?err=already_set_up'); } $token = trim((string)($_POST['token'] ?? '')); $stored = self::readTokenFile(); if ($token === '' || $stored === '' || !hash_equals($stored, $token)) { // Best-effort audit — even without a user_id, a mismatched token // is worth recording for post-mortem. Audit::log([ 'user_id' => null, 'source_ip' => (string)($_SERVER['REMOTE_ADDR'] ?? ''), 'action' => 'setup.token_mismatch', 'success' => false, ]); Router::redirect('/setup?err=token_invalid'); } $u = trim((string)($_POST['username'] ?? '')); $e = trim((string)($_POST['email'] ?? '')); $p = (string)($_POST['password'] ?? ''); $c = (string)($_POST['confirm'] ?? ''); if (!preg_match('/^[a-z0-9_.-]{3,40}$/i', $u)) { Router::redirect('/setup?token=' . urlencode($token) . '&err=bad_username'); } if ($e === '' || !filter_var($e, FILTER_VALIDATE_EMAIL)) { Router::redirect('/setup?token=' . urlencode($token) . '&err=bad_email'); } if (strlen($p) < 7) { Router::redirect('/setup?token=' . urlencode($token) . '&err=password_too_short'); } if ($p !== $c) { Router::redirect('/setup?token=' . urlencode($token) . '&err=password_mismatch'); } try { Db::exec( "INSERT INTO users (username, email, pwhash, role, enabled) VALUES (?, ?, ?, 'superuser', 1)", [$u, strtolower($e), password_hash($p, PASSWORD_DEFAULT)] ); } catch (\Throwable $ex) { Router::redirect('/setup?token=' . urlencode($token) . '&err=user_exists'); } $userId = (int)Db::col("SELECT id FROM users WHERE username = ?", [$u]); // Consume the token: truncate the file so the same token can't be // replayed. Apache owns the group (mode 660) so it can rewrite the // contents in place. `unlink` may fail since the directory is // root-owned, but truncation is enough to invalidate the token. @file_put_contents(self::TOKEN_FILE, ''); @unlink(self::TOKEN_FILE); // best-effort — cleans up if permissions allow // Auto-log in + record trusted device (same pattern the reset-link // flow uses). Session::login($userId); TrustedDevice::record( $userId, (string)($_SERVER['REMOTE_ADDR'] ?? ''), (string)($_SERVER['HTTP_USER_AGENT'] ?? '') ); Audit::log([ 'user_id' => $userId, 'source_ip' => (string)($_SERVER['REMOTE_ADDR'] ?? ''), 'action' => 'setup.completed', 'target' => $u, 'success' => true, 'detail' => ['role' => 'superuser'], ]); Router::redirect('/'); } // ---- helpers ----------------------------------------------------------- /** True iff any user already exists — setup is a one-time event. */ public static function alreadySetUp(): bool { return (int)Db::col("SELECT COUNT(*) FROM users") > 0; } /** True iff a non-empty token file exists — the boot-token is live. */ public static function tokenFilePresent(): bool { if (!is_readable(self::TOKEN_FILE)) return false; $t = self::readTokenFile(); return $t !== '' && preg_match('/^[a-f0-9]{64}$/', $t) === 1; } private static function readTokenFile(): string { if (!is_readable(self::TOKEN_FILE)) return ''; return trim((string)@file_get_contents(self::TOKEN_FILE)); } }