PDO::ERRMODE_EXCEPTION]); $database = 'hcr_auth_test_' . bin2hex(random_bytes(6)); $server->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); $server->exec("USE `{$database}`"); $server->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $server->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); session_start(); class TestAuth extends AuthService { public array $mailbox = []; public bool $mailWorks = true; public function __construct(private PDO $db) { parent::__construct(); } public function connect(): PDO { return $this->db; } public function getSettings() { return new SimpleXMLElement('https://roster.example.test/club'); } protected function deliverResetEmail(array $user, string $url): bool { $this->mailbox[] = ['email' => $user['user_email'], 'url' => $url]; return $this->mailWorks; } } class CaptureMailer extends Email { public array $message = []; public function sendEmail($to = '', $subject = '', $message = '', $attachments = '', $attachment_names = 'unknown') { $this->message = ['to' => $to, 'subject' => $subject, 'body' => $message]; return true; } } $checks = []; function check(bool $condition, string $message): void { global $checks; if (!$condition) throw new RuntimeException('FAILED: ' . $message); $checks[] = $message; } function rejects(callable $operation, string $message): void { try { $operation(); } catch (InvalidArgumentException $e) { check(true, $message); return; } check(false, $message); } function tokenFrom(array $email): string { parse_str(parse_url($email['url'], PHP_URL_QUERY), $query); return $query['token']; } try { require __DIR__ . '/../tables/users.php'; $server->exec($SQL); // Exercise the upgrade path from the supplied pre-change schema too. $server->exec('ALTER TABLE users DROP COLUMN user_auth_version'); foreach (explode(';', file_get_contents(__DIR__ . '/../migrations/001_auth.sql')) as $sql) { if (trim($sql)) $server->exec($sql); } require __DIR__.'/../tables/journal.php';$server->exec($SQL); require __DIR__ . '/../views/view_users.php'; $server->exec($SQL); $auth = new TestAuth($server); $auth->initializeSession(); $input = ['user_first_name' => 'Jane', 'user_last_name' => 'Hunter', 'user_email' => 'jane@example.test', 'user_name' => 'jane', 'user_password' => 'A long password 123!', 'user_password_confirm' => 'A long password 123!', 'user_role' => 'Administrator']; $auth->register($input); $user = $auth->findUser('jane@example.test'); $id = (int) $user['user_serial']; check($user['user_role'] === 'User', 'Signup cannot assign administrator privileges'); check(password_verify($input['user_password'], $user['user_password_hash']), 'Signup stores a verifiable password hash'); check($user['user_password_hash'] !== $input['user_password'], 'Signup does not store plaintext passwords'); rejects(fn() => $auth->register($input), 'Duplicate account details are rejected'); rejects(fn() => $auth->register(array_replace($input, ['user_email' => 'invalid'])), 'Invalid email is rejected on the server'); rejects(fn() => $auth->register(array_replace($input, ['user_password_confirm' => 'different'])), 'Mismatched passwords are rejected on the server'); rejects(fn() => $auth->register(array_replace($input, ['user_password' => 'short', 'user_password_confirm' => 'short'])), 'Short passwords are rejected'); rejects(fn() => $auth->register(array_replace($input, ['user_password' => str_repeat('x', 73), 'user_password_confirm' => str_repeat('x', 73)])), 'Passwords above bcrypt byte limit are rejected'); check(!$auth->login("' OR 1=1 -- ", 'wrong'), 'SQL injection input cannot log in'); check(!$auth->login('jane@example.test', 'wrong'), 'Wrong password cannot log in'); $oldSession = session_id(); check($auth->login('jane@example.test', $input['user_password']), 'Email login succeeds'); check(session_id() !== $oldSession, 'Login rotates session ID'); check($auth->currentUser() !== null, 'Authenticated session is valid'); check($server->query('SELECT user_last_login FROM users')->fetchColumn() !== null, 'Login timestamp uses the correct column'); check($auth->login('jane', $input['user_password']), 'Username login remains supported'); $before = $auth->findUser('jane')['user_password_hash']; $auth->requestReset('jane@example.test'); check(count($auth->mailbox) === 1, 'Reset request generates an email'); check($auth->findUser('jane')['user_password_hash'] === $before, 'Reset request leaves password unchanged'); $token = tokenFrom($auth->mailbox[0]); check(strlen($token) === 64, 'Reset token contains 256 bits of randomness'); check($server->query('SELECT token_hash FROM password_resets')->fetchColumn() === hash('sha256', $token), 'Database stores only a hash of the reset token'); $auth->requestReset('missing@example.test'); check(count($auth->mailbox) === 1, 'Unknown accounts do not produce reset email'); check(!$auth->resetPassword(str_repeat('0', 64), 'Another long password!', 'Another long password!'), 'Unknown reset token is rejected'); $server->exec('UPDATE password_resets SET expires_at = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 SECOND)'); check(!$auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Expired reset token is rejected'); $auth->requestReset('jane@example.test'); $token = tokenFrom($auth->mailbox[1]); check($auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Valid reset token changes password'); check(!$auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Reset token cannot be reused'); check($auth->currentUser() === null, 'Password reset revokes an existing session'); check(!$auth->login('jane', $input['user_password']), 'Old password fails after reset'); check($auth->login('jane', 'Another long password!'), 'New password works after reset'); rejects(fn() => $auth->changePassword($id, 'Another long password!', 'New password 123!', 'mismatch'), 'Password change rejects mismatched confirmation'); check(!$auth->changePassword($id, 'wrong', 'New password 123!', 'New password 123!'), 'Password change requires the current password'); check($auth->changePassword($id, 'Another long password!', 'New password 123!', 'New password 123!'), 'Authenticated password change succeeds'); check($auth->currentUser() === null, 'Password change revokes previous sessions'); check($auth->login('jane', 'New password 123!'), 'Changed password works'); $_SESSION['last_activity'] = time() - 1801; check($auth->currentUser() === null, 'Idle sessions expire on the server'); $server->exec("UPDATE users SET user_status = 'inactive'"); check(!$auth->login('jane', 'New password 123!'), 'Inactive users cannot log in'); $server->exec("UPDATE users SET user_status = 'active'"); $auth->mailWorks = false; $beforeFailedEmail = $auth->findUser('jane')['user_password_hash']; try { $auth->requestReset('jane@example.test'); check(false, 'Mail failure must be detected'); } catch (RuntimeException $e) { check($e->getMessage() === 'Password reset email delivery failed.', 'Email failure is detected'); } check((int) $server->query('SELECT COUNT(*) FROM password_resets')->fetchColumn() === 0, 'Failed email delivery removes its token'); check($auth->findUser('jane')['user_password_hash'] === $beforeFailedEmail, 'Email failure preserves the existing password'); $mailer = new CaptureMailer(); $mailer->sendPasswordReset(['user_name' => 'jane', 'user_first_name' => 'Jane & club', 'user_email' => 'jane@example.test'], 'https://app.myhuntingclubroster.com/main.php?Xaction=resetpassword&token=example'); check($mailer->message['to'] === 'jane@example.test', 'Reset email uses the stored account email'); check(str_contains($mailer->message['body'], 'https://app.myhuntingclubroster.com/main.php'), 'Reset email contains the configured application link'); check(str_contains($mailer->message['body'], '<b>Jane & club</b>'), 'Reset email escapes account names'); check(!str_contains($mailer->message['body'], 'New password 123!'), 'Reset email never includes an account password'); for ($i = 0; $i < 3; $i++) $allowed = $auth->allowAttempt('test', 'address', 2, 60); check(!$allowed, 'Rate limits reject excessive attempts'); $server->exec('UPDATE auth_attempts SET window_started = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 2 HOUR)'); check($auth->allowAttempt('test', 'address', 2, 60), 'Rate limits recover after their window'); $freshAuth = new TestAuth($server); for ($i = 0; $i < 11; $i++) $allowed = $freshAuth->login('jane', 'wrong'); check(!$freshAuth->login('jane', 'New password 123!'), 'Login rate limits survive new service instances'); foreach (['login', 'createAccount', 'resetPassword', 'changePassword', 'content'] as $template) { $html = $auth->applyXSL('', $auth->getXSL($template)); check(is_string($html) && strlen($html) > 100, $template . ' stylesheet renders'); } foreach ($checks as $message) echo "PASS: {$message}\n"; echo count($checks) . " checks passed.\n"; } finally { $server->exec("DROP DATABASE `{$database}`"); }