captchaChallenge(), 'register' => registerUser(), 'login' => loginUser(), 'verify_2fa' => verifyTwoFactor(), 'logout' => logoutUser(), 'check' => checkSession(), default => jsonResponse(['error' => 'Unknown action'], 400), }; function captchaChallenge(): never { $operation = trim((string)($_GET['operation'] ?? $_POST['operation'] ?? '')); jsonResponse(createAuthCaptchaChallenge($operation)); } function registerUser(): never { validateAuthCaptcha('register'); $db = getDB(); $username = trim((string)($_POST['username'] ?? '')); $password = (string)($_POST['password'] ?? ''); $maxUser = (int)getConfigVal('chat.max_username_length', 12); $minPass = (int)getConfigVal('security.min_password_length', 4); if ($username === '' || $password === '') jsonResponse(['error' => 'Username and password required'], 400); if (strlen($username) > $maxUser) jsonResponse(['error' => "Username max $maxUser characters"], 400); if (!preg_match('/^[a-zA-Z0-9_-]+$/', $username)) { jsonResponse(['error' => 'Username: letters, numbers, _ and - only'], 400); } if (strlen($password) < $minPass) jsonResponse(['error' => "Password min $minPass characters"], 400); $check = $db->prepare("SELECT 1 FROM users WHERE username=? COLLATE NOCASE"); $check->execute([$username]); if ($check->fetchColumn()) jsonResponse(['error' => 'Username already taken'], 409); $palette = (array)getConfigVal('colors.user_palette', ['#00ff9f']); $color = $palette[array_rand($palette)]; $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => (int)getConfigVal('security.bcrypt_cost', 10)]); $freeId = (int)$db->query("SELECT id FROM plans WHERE slug='free'")->fetchColumn(); $db->prepare("INSERT INTO users (username,password_hash,color,plan_id,created_at) VALUES (?,?,?,?,?)") ->execute([$username, $hash, $color, $freeId, time()]); $userId = (int)$db->lastInsertId(); createUserSession($db, $userId); $user = userById($userId); trackEvent('registration', '/api/auth.php', $userId); jsonResponse(['success' => true, 'user' => userPublicPayload($user)]); } function loginUser(): never { validateAuthCaptcha('login'); $db = getDB(); $username = trim((string)($_POST['username'] ?? '')); $password = (string)($_POST['password'] ?? ''); if ($username === '' || $password === '') jsonResponse(['error' => 'Username and password required'], 400); $stmt = $db->prepare("SELECT * FROM users WHERE username=? COLLATE NOCASE"); $stmt->execute([$username]); $user = $stmt->fetch(); if (!$user || !password_verify($password, $user['password_hash'])) { trackEvent('login_failed', '/api/auth.php'); jsonResponse(['error' => 'Invalid username or password'], 401); } assertUserSessionLoginAllowed($db, (int)$user['id']); if (!empty($user['two_factor_enabled']) && featureValue($user, 'email_2fa', false)) { if (empty($user['email_verified_at']) || empty($user['email'])) { jsonResponse(['error' => 'Two-factor authentication requires a verified email'], 403); } $challengeId = bin2hex(random_bytes(24)); $code = (string)random_int(100000, 999999); $db->prepare("DELETE FROM login_challenges WHERE user_id=?")->execute([$user['id']]); $db->prepare("INSERT INTO login_challenges (id,user_id,code_hash,expires_at,created_at) VALUES (?,?,?,?,?)") ->execute([$challengeId, $user['id'], password_hash($code, PASSWORD_DEFAULT), time() + 600, time()]); try { sendTwoFactorCode($user, $challengeId, $code); } catch (Throwable $e) { $db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challengeId]); jsonResponse(['error' => 'Could not send the login code: ' . $e->getMessage()], 503); } jsonResponse(['success' => true, 'requires_2fa' => true, 'challenge' => $challengeId]); } createUserSession($db, (int)$user['id']); trackEvent('login', '/api/auth.php', (int)$user['id']); jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$user['id']))]); } function verifyTwoFactor(): never { $challenge = trim((string)($_POST['challenge'] ?? '')); $code = trim((string)($_POST['code'] ?? '')); if ($challenge === '' || !preg_match('/^\d{6}$/', $code)) jsonResponse(['error' => 'Enter the six-digit code'], 400); $db = getDB(); $stmt = $db->prepare("SELECT * FROM login_challenges WHERE id=? AND expires_at>?"); $stmt->execute([$challenge, time()]); $row = $stmt->fetch(); if (!$row || (int)$row['attempts'] >= 5 || !password_verify($code, $row['code_hash'])) { if ($row) $db->prepare("UPDATE login_challenges SET attempts=attempts+1 WHERE id=?")->execute([$challenge]); jsonResponse(['error' => 'Invalid or expired login code'], 401); } assertUserSessionLoginAllowed($db, (int)$row['user_id']); $db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challenge]); createUserSession($db, (int)$row['user_id']); trackEvent('login_2fa', '/api/auth.php', (int)$row['user_id']); jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$row['user_id']))]); } function logoutUser(): never { $sid = $_COOKIE['cyberchat_sid'] ?? ''; if ($sid !== '') getDB()->prepare("DELETE FROM sessions WHERE id=?")->execute([$sid]); setcookie('cyberchat_sid', '', [ 'expires' => time() - 3600, 'path' => '/', 'httponly' => true, 'samesite' => 'Strict', 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', ]); jsonResponse(['success' => true]); } function checkSession(): never { $user = authUser(false); jsonResponse($user ? ['authenticated' => true, 'user' => userPublicPayload($user)] : ['authenticated' => false]); }