From 660551da73a76a549f8f89fb0e6e9fb965a305c8 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 11 Sep 2026 03:58:47 -0400 Subject: [PATCH] feat(installer): confirm email + password, and a credentials backup file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a user cannot recover from after this screen, because the installer deletes itself. Confirm email + confirm password on the admin step A typo in the ONE email that owns the install is unrecoverable from here: the password reset goes to the address that was typed wrong. Catching it costs a re-type; missing it costs the account. Validated BEFORE the download and the migration run, so nobody waits through the slow work to be told they mistyped their own address. Email comparison is case-insensitive — people retype with different capitalisation constantly and that is not a typo. Password comparison is exact, via hash_equals. Password STRENGTH stays Tiger's rule in createOwner, so there is one authority for it rather than two that can disagree. A credentials backup file The DB password afterwards lives only in local.ini above the docroot, and the agent key is shown exactly once. A user who closes this tab has lost them. The finish screen now offers one file with the admin login, database details, paths and the agent key. Served as a `data:` URI on a plain . That means no JavaScript, and — the reason it is built this way — NOTHING SENSITIVE IS EVER WRITTEN TO THE SERVER. Writing this file into the docroot would publish every secret in the install to anyone who guessed the filename, and would outlive the installer that created it. A file that never exists on disk cannot be fetched and cannot be left behind. Verified the URI round-trips, is attribute-safe, and that the decoded file carries what it claims. 29 new assertions (83 total). Mutation-tested: removing either confirmation check, making the email match case-sensitive, and dropping the db password or the agent key from the file each fail by name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ --- tests/wizard.php | 39 ++++++++++++++++ tiger-install.php | 113 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 147 insertions(+), 5 deletions(-) diff --git a/tests/wizard.php b/tests/wizard.php index 6d86cd6..9222ba3 100644 --- a/tests/wizard.php +++ b/tests/wizard.php @@ -75,4 +75,43 @@ ''); is_same ('an empty state emits nothing', state_block([]), ''); +group('Confirm email / password (typo protection)'); +$ok = ['org'=>'A','email'=>'me@example.com','email2'=>'me@example.com','username'=>'', + 'password'=>'correct-horse','password2'=>'correct-horse','agent'=>'']; +is_same ('a matching pair passes', admin_errors($ok), ''); +is_same ('missing email is caught', admin_errors(['email'=>'']+$ok), 'Enter an admin email address.'); +is_true ('mismatched email is caught', admin_errors(['email2'=>'you@example.com']+$ok) !== ''); +// cPanel users retype with different capitalisation constantly; that is not a typo. +is_same ('email match is case-insensitive', admin_errors(['email2'=>'ME@Example.com']+$ok), ''); +is_same ('missing password is caught', admin_errors(['password'=>'']+$ok), 'Choose a password.'); +is_true ('mismatched password is caught', admin_errors(['password2'=>'correct-horse ']+$ok) !== ''); +is_true ('password match is EXACT', admin_errors(['password2'=>'Correct-Horse']+$ok) !== ''); + +group('The admin form asks for both confirmations'); +$af = admin_form($ok); +is_true ('confirm email field', (bool) preg_match('/name="email2"/', $af)); +is_true ('confirm password field', (bool) preg_match('/name="password2"/', $af)); +is_false('neither rides in the hidden bag', + (bool) preg_match('/type="hidden" name="(email2|password2)"/', $af)); + +group('The credentials backup file'); +$bag = $ok + ['db_host'=>'localhost','db_name'=>'cp_tiger','db_user'=>'cp_tiger','db_pass'=>'dbsecret', + 'app_dir'=>'/home/u/site/tiger-app','docroot'=>'/home/u/public_html']; +$plain = credentials_file($bag, 'https://example.com', ['ok'=>false]); +foreach ([['the admin email','me@example.com'], ['the admin password','correct-horse'], + ['the db name','cp_tiger'], ['the db password','dbsecret'], + ['the app dir','/home/u/site/tiger-app'], ['the config path','local.ini'], + ['a keep-it-safe warning','CONTAINS PASSWORDS']] as $c) { + is_true('carries ' . $c[0], strpos($plain, $c[1]) !== false); +} +is_false('no agent section when it was not enabled', strpos($plain, 'AGENT ACCESS') !== false); + +$withAgent = credentials_file($bag, 'https://example.com', + ['ok'=>true, 'token'=>'tgr_abc', 'modules'=>['cms','media']]); +is_true ('agent section when enabled', strpos($withAgent, 'AGENT ACCESS') !== false); +is_true ('carries the access key', strpos($withAgent, 'tgr_abc') !== false); +is_true ('says it cannot be retrieved later', stripos($withAgent, 'shown once') !== false); +is_true ('says where to revoke', strpos($withAgent, '/mcp/admin') !== false); + + done(); diff --git a/tiger-install.php b/tiger-install.php index 0290cc6..448ef57 100644 --- a/tiger-install.php +++ b/tiger-install.php @@ -29,7 +29,7 @@ @ini_set('display_errors', '1'); @set_time_limit(0); -const INSTALLER_VERSION = '1.1.0'; +const INSTALLER_VERSION = '1.2.0'; const RELEASE_REPO = 'webtigers/tiger'; // the skeleton repo whose releases host the full-app bundle const MIN_PHP = '8.1.0'; const GH_API = 'https://api.github.com'; @@ -410,12 +410,14 @@ function db_form($bag, $errNote = '') { function admin_form($bag, $errNote = '') { return '

Create your admin account

' . ($errNote !== '' ? '
' . h($errNote) . '
' : '
Database installed and ready.
') - . '
' . hidden_bag($bag, ['org', 'email', 'username', 'password', 'agent']) + . '' . hidden_bag($bag, ['org', 'email', 'email2', 'username', 'password', 'password2', 'agent']) . '
' . field('Organization name', 'org', 'text', $bag['org'], 'My Company') . field('Admin email', 'email', 'email', $bag['email']) + . field('Confirm email', 'email2', 'email', $bag['email2']) . field('Username (optional)', 'username', 'text', $bag['username']) . field('Password (min 8)', 'password', 'password', $bag['password']) + . field('Confirm password', 'password2', 'password', $bag['password2']) . ', which means NO JavaScript and, more importantly, + * nothing sensitive is ever written to the server. Writing this file into the docroot would publish + * every secret in the install to anyone who guessed the filename; a file that never exists on disk + * cannot be fetched, and cannot be left behind when the installer removes itself. + */ +function credentials_file($bag, $base, $agent) { + $L = function ($k, $v) { return $v === '' || $v === null ? '' : sprintf("%-16s %s\n", $k, $v); }; + + $t = "Tiger — installation credentials\n"; + $t .= "================================\n"; + $t .= "Saved " . gmdate('Y-m-d H:i') . " UTC by tiger-install " . INSTALLER_VERSION . "\n\n"; + $t .= "THIS FILE CONTAINS PASSWORDS. Store it somewhere private — a password manager,\n"; + $t .= "not your Downloads folder. Anyone holding it can take over the site.\n\n"; + + $t .= "SITE\n"; + $t .= $L('Site', $base . '/'); + $t .= $L('Sign in', $base . '/login'); + $t .= $L('Admin', $base . '/admin'); + $t .= "\nADMIN ACCOUNT\n"; + $t .= $L('Organization', $bag['org']); + $t .= $L('Email', $bag['email']); + $t .= $L('Username', $bag['username'] !== '' ? $bag['username'] : '(email is the login)'); + $t .= $L('Password', $bag['password']); + $t .= "\nDATABASE\n"; + $t .= $L('Host', $bag['db_host']); + $t .= $L('Name', $bag['db_name']); + $t .= $L('User', $bag['db_user']); + $t .= $L('Password', $bag['db_pass']); + $t .= "\nPATHS\n"; + $t .= $L('Application', $bag['app_dir']); + $t .= $L('Config', $bag['app_dir'] . '/application/configs/local.ini'); + $t .= $L('Document root', $bag['docroot']); + + if (!empty($agent['ok'])) { + $t .= "\nAGENT ACCESS (MCP)\n"; + $t .= $L('Endpoint', $base . '/mcp'); + $t .= $L('Access key', $agent['token']); + $t .= $L('Scope', implode(', ', $agent['modules']) . ' — this organization only'); + $t .= $L('Manage/revoke', $base . '/mcp/admin'); + $t .= "\nThe access key is shown once and cannot be retrieved later. If it leaks, revoke it\n"; + $t .= "at /mcp/admin and mint a new one — the site itself is unaffected.\n"; + } + + $t .= "\nThe database password also lives in local.ini above your document root. The admin\n"; + $t .= "password is not stored anywhere in readable form — if you lose it, reset it by email.\n"; + return $t; +} + function do_create_owner($bag, &$owner = null) { try { ensure_booted($bag['app_dir']); @@ -672,7 +753,7 @@ function do_enable_agent($bag, $owner) { // The value bag — read every field each request; fill sensible defaults once. $bag = []; -foreach (['app_dir', 'docroot', 'db_host', 'db_name', 'db_user', 'db_pass', 'org', 'email', 'username', 'password', 'agent'] as $f) { +foreach (['app_dir', 'docroot', 'db_host', 'db_name', 'db_user', 'db_pass', 'org', 'email', 'email2', 'username', 'password', 'password2', 'agent'] as $f) { $bag[$f] = post($f, ''); } // `agent` may be SEEDED from the query string (?agent=1) but only on a GET. On a POST the visible @@ -774,11 +855,21 @@ function do_enable_agent($bag, $owner) { 'fields' => ['db_host', 'db_name', 'db_user', 'db_pass']]); break; } page('Admin', steps_nav('admin') . admin_form($bag), ['installer' => INSTALLER_VERSION, 'step' => 'admin', 'status' => 'awaiting-input', 'next_step' => 'finish', - 'fields' => ['org', 'email', 'username', 'password', 'agent'], 'agent_requested' => $agentWanted]); + 'fields' => ['org', 'email', 'email2', 'username', 'password', 'password2', 'agent'], 'agent_requested' => $agentWanted]); break; /* --- Finish — create the admin, self-delete ----------------------------- */ case 'finish': + // Cheap, local checks before the slow work — nobody should wait through a download and a + // migration only to be told they mistyped their own email. + $err = admin_errors($bag); + if ($err !== '') { + page('Admin', steps_nav('admin') . admin_form($bag, $err), + ['installer' => INSTALLER_VERSION, 'step' => 'admin', 'status' => 'error', 'error' => 'admin_fields_invalid', + 'detail' => $err, 'fields' => ['org', 'email', 'email2', 'username', 'password', 'password2', 'agent'], + 'agent_requested' => $agentWanted]); + break; + } $err = do_install_files($bag, $home); if ($err !== '') { page('Download', steps_nav('download') . download_error($bag, $err), ['installer' => INSTALLER_VERSION, 'step' => 'download', 'status' => 'error', 'error' => 'download_failed', 'detail' => $err]); break; } @@ -790,7 +881,7 @@ function do_enable_agent($bag, $owner) { $err = do_create_owner($bag, $owner); if ($err !== '') { page('Admin', steps_nav('admin') . admin_form($bag, $err), ['installer' => INSTALLER_VERSION, 'step' => 'admin', 'status' => 'error', 'error' => 'owner_failed', 'detail' => $err, - 'fields' => ['org', 'email', 'username', 'password', 'agent'], 'agent_requested' => $agentWanted]); break; } + 'fields' => ['org', 'email', 'email2', 'username', 'password', 'password2', 'agent'], 'agent_requested' => $agentWanted]); break; } // TIGER-90 — only now: the owner exists, so the credential has someone to belong to. $agent = $agentWanted ? do_enable_agent($bag, $owner) : ['ok' => false, 'error' => '']; @@ -835,6 +926,18 @@ function do_enable_agent($bag, $owner) { . 'Turn it on and mint a scoped key at ' . h($base) . '/mcp/admin, then reconnect your assistant.
'; } + // One file with everything, generated in-page so no secret is ever written to the server. + $credText = credentials_file($bag, $base, $agentWanted ? $agent : ['ok' => false]); + $credName = 'tiger-credentials-' . preg_replace('/[^a-z0-9.-]/i', '-', $domain) . '-' . gmdate('Ymd') . '.txt'; + $body .= ''; + page('Done', $body, [ 'installer' => INSTALLER_VERSION, 'step' => 'finish',