Files
huntingclubroster/classes/EmailVerification.php
T

115 lines
7.1 KiB
PHP
Raw Normal View History

<?php
class EmailVerification extends Base {
public function request(string $identifier): bool {
$auth = new AuthService();
if (!$auth->allowAttempt('verify-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600) || !$auth->allowAttempt('verify-account', $identifier, 3, 3600))
return false;
$user = $auth->findUser($identifier);
if (!$user || $user['user_status'] !== 'active' || $user['user_email_verified_at'])
return false;
$url = rtrim((string) $this->getSettings()->ApplicationUrl, '/');
if (!filter_var($url, FILTER_VALIDATE_URL) || parse_url($url, PHP_URL_SCHEME) !== 'https' || parse_url($url, PHP_URL_QUERY) !== null || parse_url($url, PHP_URL_FRAGMENT) !== null || parse_url($url, PHP_URL_USER) !== null || parse_url($url, PHP_URL_PASS) !== null)
throw new RuntimeException('Configure a trusted HTTPS application URL.');
$token = bin2hex(random_bytes(32));
$db = $this->connect();
$db->beginTransaction();
try {
$stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$user['user_serial']]);
$current = $stmt->fetch();
if (!$current || $current['user_status'] !== 'active' || $current['user_email_verified_at'] || $current['user_email'] !== $user['user_email'] || (int) $current['user_auth_version'] !== (int) $user['user_auth_version']) {
$db->rollBack();
return false;
}
$stmt = $db->prepare('INSERT INTO email_verifications(token_hash,user_serial,email,auth_version,expires_at) VALUES(?,?,?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 24 HOUR))');
$stmt->execute([hash('sha256', $token), $user['user_serial'], $user['user_email'], $user['user_auth_version']]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
if (!$this->deliver($user, $url . '/main.php?Xaction=verifyemail&token=' . $token)) {
$stmt = $db->prepare('DELETE FROM email_verifications WHERE token_hash=?');
$stmt->execute([hash('sha256', $token)]);
throw new RuntimeException('Verification email could not be delivered.');
}
return true;
}
protected function deliver(array $user, string $url): bool {
$h = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
return (new Email())->sendEmail($user['user_email'], self::APPLICATION_NAME . ' - Verify your email', '<p>Verify the email address for account ' . $h($user['user_name']) . '.</p><p><a href="' . $h($url) . '">Verify email</a></p><p>The link expires in 24 hours. You must also enter the account password. If you did not create this account, ignore this message or use password reset to secure it.</p>');
}
public function confirm(string $token, string $password): bool {
if (!preg_match('/\A[a-f0-9]{64}\z/', $token))
return false;
$db = $this->connect();
$db->beginTransaction();
try {
$stmt = $db->prepare('SELECT user_serial FROM email_verifications WHERE token_hash=?');
$stmt->execute([hash('sha256', $token)]);
$id = $stmt->fetchColumn();
if (!$id) {
$db->rollBack();
return false;
}
$stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$id]);
$user = $stmt->fetch();
$stmt = $db->prepare('SELECT * FROM email_verifications WHERE token_hash=? AND expires_at>UTC_TIMESTAMP() FOR UPDATE');
$stmt->execute([hash('sha256', $token)]);
$row = $stmt->fetch();
if (!$row || !$user || $user['user_status'] !== 'active' || $row['email'] !== $user['user_email'] || (int) $row['auth_version'] !== (int) $user['user_auth_version'] || !password_verify($password, $user['user_password_hash'])) {
$db->rollBack();
return false;
}
$stmt = $db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=?');
$stmt->execute([$id]);
$stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
$stmt->execute([$id]);
Audit::required($db, (int) $id, 'verified email ownership');
$db->commit();
return true;
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
}
public function page(): void {
$token = $_POST['token'] ?? $_GET['token'] ?? '';
$message = '';
$complete = false;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$this->requireCsrf();
if (($_POST['step'] ?? '') === 'confirm') {
$allowed = (new AuthService())->allowAttempt('verify-submit-ip', $_SERVER['REMOTE_ADDR'] ?? '', 20, 900);
$complete = $allowed && $this->confirm($token, $_POST['user_password'] ?? '');
$message = $complete ? 'Email verified. You can now be assigned to a club.' : 'Unable to verify. Check the password or request a fresh link. You can also reset your password to prove mailbox ownership.';
} else {
try {
$this->request(trim($_POST['user_email'] ?? ''));
} catch (Throwable $e) {
error_log('Email verification failed: ' . $e->getMessage());
}
$message = 'If an active account needs verification, a link will be sent. You can also reset your password to verify its email.';
}
}
$h = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
echo '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Verify email</title><link rel="stylesheet" href="css/themes/MyHuntingClubRoster/bootstrap.css"></head><body><main class="container py-4"><h1>Verify your email</h1><p>' . $h($message) . '</p>';
if (!$complete) {
echo '<form method="post" action="main.php"><input type="hidden" name="action" value="verifyemail"><input type="hidden" name="csrf_token" value="' . $h($this->csrfToken()) . '">';
if ($token)
echo '<input type="hidden" name="step" value="confirm"><input type="hidden" name="token" value="' . $h($token) . '"><label class="form-label">Account password <input class="form-control" type="password" name="user_password" autocomplete="current-password" required></label><button class="btn btn-primary">Verify email</button>';
else
echo '<label class="form-label">Email <input class="form-control" type="email" name="user_email" required></label><button class="btn btn-primary">Send verification link</button>';
echo '</form>';
}
echo '<p><a href="main.php">Return to application</a> · <a href="main.php?Xaction=verifyemail">Request another link</a> · <a href="main.php?Xaction=resetpassword">Reset password</a></p></main></body></html>';
exit;
}
}