Files
tomtomgames-app/includes/auth.php
T

294 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/mailer.php';
function isLoggedIn(): bool {
return isset($_SESSION['user_id']) && !empty($_SESSION['user_id']);
}
function requireLogin(): void {
if (!isLoggedIn()) {
header('Location: /'); exit;
}
}
function requireAdmin(): void {
requireLogin();
if (empty($_SESSION['is_admin'])) {
header('Location: /'); exit;
}
}
function currentUser(): ?array {
if (!isLoggedIn()) return null;
$stmt = db()->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
return $stmt->fetch() ?: null;
}
function loginUser(string $username, string $password): array {
$stmt = db()->prepare("SELECT * FROM users WHERE username = ? AND status = 'active'");
$stmt->execute([strtolower(trim($username))]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password'])) {
return ['success' => false, 'error' => 'Invalid username or password.'];
}
// Block unverified accounts — admins are always exempt
if (!$user['email_verified'] && !$user['is_admin']) {
return [
'success' => false,
'error' => 'Please verify your email address before logging in. Check your inbox for the verification link.',
'unverified' => true,
'email' => $user['email'],
];
}
// Auto-verify admin accounts so they're never locked out
if ($user['is_admin'] && !$user['email_verified']) {
db()->prepare("UPDATE users SET email_verified=1 WHERE id=?")->execute([$user['id']]);
$user['email_verified'] = 1;
}
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['alias'] = $user['alias'];
$_SESSION['is_admin'] = $user['is_admin'];
db()->prepare("UPDATE users SET last_login=NOW() WHERE id=?")->execute([$user['id']]);
return ['success' => true, 'user' => $user];
}
/**
* Stage a registration: store in pending_registrations, send verification email.
* Does NOT create the user row yet.
*/
function initiateRegistration(string $username, string $password, string $alias, string $email, string $referralCode = ''): array {
$username = strtolower(trim($username));
$alias = trim($alias);
$email = strtolower(trim($email));
// Validate
if (strlen($username) < 3 || strlen($username) > 50)
return ['success' => false, 'error' => 'Username must be 350 characters.'];
if (!preg_match('/^[a-z0-9_]+$/', $username))
return ['success' => false, 'error' => 'Username may only contain letters, numbers, and underscores.'];
if (strlen($password) < 6)
return ['success' => false, 'error' => 'Password must be at least 6 characters.'];
if (empty($alias))
return ['success' => false, 'error' => 'Alias is required.'];
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL))
return ['success' => false, 'error' => 'A valid email address is required to verify your account.'];
// Check username taken (existing users)
$s = db()->prepare("SELECT id FROM users WHERE username=?");
$s->execute([$username]);
if ($s->fetch()) return ['success' => false, 'error' => 'Username already taken.'];
// Check email taken (existing users)
$s = db()->prepare("SELECT id FROM users WHERE email=?");
$s->execute([$email]);
if ($s->fetch()) return ['success' => false, 'error' => 'An account with that email already exists.'];
// Check username in pending
$s = db()->prepare("SELECT id FROM pending_registrations WHERE username=? AND expires_at > NOW()");
$s->execute([$username]);
if ($s->fetch()) return ['success' => false, 'error' => 'Username already reserved. Try again in 24 hours or choose another.'];
// Check email in pending — resend if already pending
$s = db()->prepare("SELECT id, token FROM pending_registrations WHERE email=? AND expires_at > NOW()");
$s->execute([$email]);
$existing = $s->fetch();
if ($existing) {
// Resend verification to same email
sendVerificationEmail($email, $alias, $existing['token']);
return ['success' => true, 'resent' => true, 'email' => $email];
}
// Delete any expired pending rows for this email/username
db()->prepare("DELETE FROM pending_registrations WHERE email=? OR (username=? AND expires_at <= NOW())")->execute([$email, $username]);
// Resolve referral code to user ID
$referrerId = null;
if ($referralCode) {
$refStmt = db()->prepare("SELECT id FROM users WHERE referral_code=? AND status='active'");
$refStmt->execute([strtoupper(trim($referralCode))]);
$refUser = $refStmt->fetch();
if ($refUser) $referrerId = (int)$refUser['id'];
}
$token = bin2hex(random_bytes(32));
$hash = password_hash($password, PASSWORD_BCRYPT);
$expiresAt = date('Y-m-d H:i:s', time() + VERIFY_TTL);
$stmt = db()->prepare("INSERT INTO pending_registrations (username, password, alias, email, token, referred_by, expires_at) VALUES (?,?,?,?,?,?,?)");
$stmt->execute([$username, $hash, $alias, $email, $token, $referrerId, $expiresAt]);
$sent = sendVerificationEmail($email, $alias, $token);
if (!$sent) {
// Email failed but keep registration — user can resend from login screen
error_log('[TomTomGames] Verification email failed for ' . $email);
return ['success' => true, 'email' => $email, 'mail_warning' => true];
}
return ['success' => true, 'email' => $email];
}
/**
* Consume a verification token: create the real user, delete pending row.
*/
function verifyEmailToken(string $token): array {
$token = trim($token);
if (empty($token)) return ['success' => false, 'error' => 'Invalid verification link.'];
$stmt = db()->prepare("SELECT * FROM pending_registrations WHERE token=? AND expires_at > NOW()");
$stmt->execute([$token]);
$pending = $stmt->fetch();
if (!$pending) {
return ['success' => false, 'error' => 'This verification link is invalid or has expired. Please register again.'];
}
// Check username/email not taken since pending was created
$s = db()->prepare("SELECT id FROM users WHERE username=? OR email=?");
$s->execute([$pending['username'], $pending['email']]);
if ($s->fetch()) {
db()->prepare("DELETE FROM pending_registrations WHERE token=?")->execute([$token]);
return ['success' => false, 'error' => 'This username or email was already registered. Please log in.'];
}
db()->beginTransaction();
try {
// Create the user
$ins = db()->prepare("INSERT INTO users (username, password, alias, email, email_verified, status) VALUES (?,?,?,?,1,'active')");
$ins->execute([$pending['username'], $pending['password'], $pending['alias'], $pending['email']]);
$userId = db()->lastInsertId();
// Generate unique referral code
$code = strtoupper(substr(md5($userId . uniqid()), 0, 8));
db()->prepare("UPDATE users SET referral_code=? WHERE id=?")->execute([$code, $userId]);
// Track referral if referred_by is in pending
if (!empty($pending['referred_by'])) {
$referrerId = (int)$pending['referred_by'];
try {
db()->prepare("INSERT IGNORE INTO referrals (referrer_id, referred_id, status) VALUES (?,?,'pending')")
->execute([$referrerId, $userId]);
db()->prepare("UPDATE users SET referred_by=? WHERE id=?")->execute([$referrerId, $userId]);
} catch(Exception $e) {}
}
// Delete pending row
db()->prepare("DELETE FROM pending_registrations WHERE token=?")->execute([$token]);
db()->commit();
} catch (Exception $e) {
db()->rollBack();
return ['success' => false, 'error' => 'Account creation failed. Please try again.'];
}
// Auto-login
$_SESSION['user_id'] = $userId;
$_SESSION['username'] = $pending['username'];
$_SESSION['alias'] = $pending['alias'];
$_SESSION['is_admin'] = 0;
return ['success' => true, 'username' => $pending['username'], 'alias' => $pending['alias']];
}
/**
* Resend verification email for an unverified account.
*/
function resendVerification(string $email): array {
$email = strtolower(trim($email));
$stmt = db()->prepare("SELECT id, token FROM pending_registrations WHERE email=? AND expires_at > NOW() ORDER BY id DESC LIMIT 1");
$stmt->execute([$email]);
$pending = $stmt->fetch();
if (!$pending) {
return ['success' => false, 'error' => 'No pending registration found for that email, or it has expired. Please register again.'];
}
$alias = db()->prepare("SELECT alias FROM pending_registrations WHERE id=?");
$alias->execute([$pending['id']]);
$row = $alias->fetch();
sendVerificationEmail($email, $row['alias'] ?? 'Player', $pending['token']);
return ['success' => true];
}
function logoutUser(): void {
$_SESSION = [];
session_destroy();
}
// ─── Comprehensive Audit Logger ────────────────────────────
function logActivity(
string $action,
?int $userId = null,
?int $adminId = null,
string $entityType= '',
int $entityId = 0,
string $detail = '',
string $ip = '',
string $category = 'general',
string $oldValue = '',
string $newValue = '',
string $severity = 'info'
): void {
try {
// Auto-purge entries older than 90 days (probabilistic — 3% of calls)
if (rand(1, 100) <= 3) {
db()->exec("DELETE FROM activity_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)");
}
$ip = $ip ?: ($_SERVER['REMOTE_ADDR'] ?? '');
$userAgent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 300);
$page = substr(($_SERVER['REQUEST_URI'] ?? ''), 0, 200);
$sessionId = session_id() ?: '';
db()->prepare("
INSERT INTO activity_log
(user_id, admin_id, action, category, entity_type, entity_id, detail,
old_value, new_value, ip, user_agent, page, session_id, severity)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
")->execute([
$userId ?: null,
$adminId ?: null,
substr($action, 0, 120),
$category,
$entityType,
$entityId ?: null,
substr($detail, 0, 2000),
substr($oldValue, 0, 2000),
substr($newValue, 0, 2000),
$ip,
$userAgent,
$page,
$sessionId,
$severity,
]);
} catch (Exception $e) { /* never fail silently */ }
}
// Convenience wrappers
function logPlayerAction(string $action, int $userId, string $detail='', string $category='player', string $severity='info'): void {
logActivity($action, $userId, null, 'user', $userId, $detail, '', $category, '', '', $severity);
}
function logAdminAction(string $action, int $adminId, string $entityType='', int $entityId=0, string $detail='', string $old='', string $new='', string $severity='info'): void {
logActivity($action, null, $adminId, $entityType, $entityId, $detail, '', 'admin', $old, $new, $severity);
}
function logSecurityEvent(string $action, ?int $userId=null, string $detail='', string $severity='warning'): void {
logActivity($action, $userId, null, 'security', 0, $detail, '', 'security', '', '', $severity);
}
// ─── App Version ───────────────────────────────────────────
function getAppVersion(): string {
try {
$v = db()->query("SELECT version FROM app_version ORDER BY id DESC LIMIT 1")->fetchColumn();
return $v ?: '1.0.0';
} catch(Exception $e) { return '1.0.0'; }
}