- Voice enhancements, user management, payment method

This commit is contained in:
Ty Clifford
2026-06-08 15:44:15 -04:00
parent 9e3ff61eb7
commit 063b8dc3e8
26 changed files with 2832 additions and 927 deletions
+102 -145
View File
@@ -1,168 +1,125 @@
<?php
// api/auth.php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/mailer.php';
sendCorsHeaders();
header('Content-Type: application/json');
header('Cache-Control: no-store');
$action = $_POST['action'] ?? $_GET['action'] ?? '';
match ($action) {
'register' => registerUser(),
'login' => loginUser(),
'verify_2fa' => verifyTwoFactor(),
'logout' => logoutUser(),
'check' => checkSession(),
default => jsonResponse(['error' => 'Unknown action'], 400),
};
switch ($action) {
case 'register':
handleRegister();
break;
case 'login':
handleLogin();
break;
case 'logout':
handleLogout();
break;
case 'check':
handleCheck();
break;
default:
jsonResponse(['error' => 'Unknown action'], 400);
function registerUser(): never {
$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 handleRegister(): never {
function loginUser(): never {
$db = getDB();
$config = getConfig();
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$maxUser = getConfigVal('chat.max_username_length', 12);
$minPass = 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, _ - only'], 400);
}
if (strlen($password) < $minPass) {
jsonResponse(['error' => "Password min {$minPass} characters"], 400);
}
// Check if username exists
$stmt = $db->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE");
$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]);
if ($stmt->fetch()) {
jsonResponse(['error' => 'Username already taken'], 409);
}
$colors = getConfigVal('colors.user_palette', ['#00ff9f']);
$color = $colors[array_rand($colors)];
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => getConfigVal('security.bcrypt_cost', 10)]);
$stmt = $db->prepare("INSERT INTO users (username, password_hash, color, created_at) VALUES (?, ?, ?, ?)");
$stmt->execute([$username, $hash, $color, time()]);
$userId = $db->lastInsertId();
createSession($db, $userId, $username, $color);
jsonResponse(['success' => true, 'username' => $username, 'color' => $color]);
}
function handleLogin(): never {
$db = getDB();
$username = trim($_POST['username'] ?? '');
$password = $_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(PDO::FETCH_ASSOC);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
trackEvent('login_failed', '/api/auth.php');
jsonResponse(['error' => 'Invalid username or password'], 401);
}
// Check for existing session (single login enforcement)
if (getConfigVal('chat.session_lock_ip') || getConfigVal('chat.session_lock_cookie')) {
$sessionTimeout = getConfigVal('security.session_timeout_hours', 24) * 3600;
$cutoff = time() - $sessionTimeout;
$stmt2 = $db->prepare("SELECT id, ip FROM sessions WHERE user_id = ? AND last_active > ?");
$stmt2->execute([$user['id'], $cutoff]);
$existing = $stmt2->fetch(PDO::FETCH_ASSOC);
if ($existing) {
$currentIP = clientIP();
// Allow same IP to re-login (refresh), block different IP
if (getConfigVal('chat.session_lock_ip') && $existing['ip'] !== $currentIP) {
jsonResponse(['error' => 'Already logged in from another location'], 403);
}
// Kill old session and create new
$db->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$user['id']]);
if (getConfigVal('chat.session_lock_ip', true) || getConfigVal('chat.session_lock_cookie', true)) {
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
$existingStmt = $db->prepare("SELECT id,ip FROM sessions WHERE user_id=? AND last_active>?");
$existingStmt->execute([$user['id'], $cutoff]);
$existing = $existingStmt->fetch();
if ($existing && getConfigVal('chat.session_lock_ip', true) && $existing['ip'] !== clientIP()) {
jsonResponse(['error' => 'Already logged in from another location'], 403);
}
}
createSession($db, $user['id'], $user['username'], $user['color']);
jsonResponse(['success' => true, 'username' => $user['username'], 'color' => $user['color']]);
}
function createSession(PDO $db, int $userId, string $username, string $color): void {
$sid = bin2hex(random_bytes(32));
$ip = clientIP();
// Remove any existing sessions for this user
$db->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$userId]);
$stmt = $db->prepare("INSERT INTO sessions (id, user_id, ip, created_at, last_active) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$sid, $userId, $ip, time(), time()]);
// Update last_seen
$db->prepare("UPDATE users SET last_seen = ? WHERE id = ?")->execute([time(), $userId]);
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
setcookie('cyberchat_sid', $sid, [
'expires' => time() + (getConfigVal('security.session_timeout_hours', 24) * 3600),
'path' => '/',
'httponly' => true,
'samesite' => 'Strict',
'secure' => $secure,
]);
}
function handleLogout(): never {
$db = getDB();
$sid = $_COOKIE['cyberchat_sid'] ?? '';
if ($sid) {
$db->prepare("DELETE FROM sessions WHERE id = ?")->execute([$sid]);
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]);
}
setcookie('cyberchat_sid', '', time() - 3600, '/', '', false, true);
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);
}
$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 handleCheck(): never {
$db = getDB();
$sid = $_COOKIE['cyberchat_sid'] ?? '';
if (!$sid) jsonResponse(['authenticated' => false]);
$sessionTimeout = getConfigVal('security.session_timeout_hours', 24) * 3600;
$cutoff = time() - $sessionTimeout;
$stmt = $db->prepare("
SELECT s.*, u.username, u.color
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.last_active > ?
");
$stmt->execute([$sid, $cutoff]);
$session = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$session) jsonResponse(['authenticated' => false]);
if (getConfigVal('chat.session_lock_ip') && $session['ip'] !== clientIP()) {
jsonResponse(['authenticated' => false, 'reason' => 'ip_mismatch']);
}
jsonResponse(['authenticated' => true, 'username' => $session['username'], 'color' => $session['color']]);
function checkSession(): never {
$user = authUser(false);
jsonResponse($user
? ['authenticated' => true, 'user' => userPublicPayload($user)]
: ['authenticated' => false]);
}