generated from jric11/baseProject
135 lines
9.7 KiB
PHP
135 lines
9.7 KiB
PHP
<?php
|
|
/** Destructive only inside a new, randomly named test database. Never reads project DB credentials. */
|
|
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
|
|
require_once __DIR__ . '/../classes/Base.php';
|
|
if (!getenv('HCR_TEST_DSN')) throw new RuntimeException('Set HCR_TEST_DSN to an isolated MySQL/MariaDB server (without dbname).');
|
|
$server = new PDO(getenv('HCR_TEST_DSN'), getenv('HCR_TEST_USER') ?: 'root', getenv('HCR_TEST_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => 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('<Settings><ApplicationUrl>https://roster.example.test/club</ApplicationUrl></Settings>'); }
|
|
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__ . '/../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' => '<b>Jane & club</b>', '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('<XML/>', $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}`");
|
|
}
|