0 && abs(time() - $timeoutNoticeTimestamp) <= 120; // ============================================================ // BRUTE FORCE PROTECTION (IP-based, file-backed) // ============================================================ define('LOGIN_ATTEMPTS_FILE', __DIR__ . '/../content/login_attempts.json'); define('BRUTE_FORCE_DELAY_STEP', 15); // seconds per failed attempt after threshold define('BRUTE_FORCE_THRESHOLD', 3); // free attempts before delay kicks in define('BRUTE_FORCE_MAX_ATTEMPTS', 20); // hard lockout after this many define('BRUTE_FORCE_LOCKOUT_TIME', 86400); // 24 hours hard lockout function getIpHash() { $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; return hash('sha256', $ip . 'raiju'); } function loadAttempts() { if (!file_exists(LOGIN_ATTEMPTS_FILE)) { return []; } $data = json_decode(file_get_contents(LOGIN_ATTEMPTS_FILE), true); return is_array($data) ? $data : []; } function saveAttempts($data) { // Cleanup entries older than 24 hours $now = time(); foreach ($data as $hash => $entry) { if ($now - ($entry['last'] ?? 0) > BRUTE_FORCE_LOCKOUT_TIME) { unset($data[$hash]); } } $dir = dirname(LOGIN_ATTEMPTS_FILE); if (!is_dir($dir)) { mkdir($dir, 0755, true); } file_put_contents(LOGIN_ATTEMPTS_FILE, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX); } /** * Check if login is allowed for this IP. * Returns ['allowed' => bool, 'wait' => seconds, 'locked' => bool] */ function checkBruteForce() { $ipHash = getIpHash(); $data = loadAttempts(); $entry = $data[$ipHash] ?? null; if ($entry === null) { return ['allowed' => true, 'wait' => 0, 'locked' => false]; } $now = time(); $count = $entry['count'] ?? 0; $lastAttempt = $entry['last'] ?? 0; // Hard lockout after MAX_ATTEMPTS if ($count >= BRUTE_FORCE_MAX_ATTEMPTS) { $remaining = BRUTE_FORCE_LOCKOUT_TIME - ($now - $lastAttempt); if ($remaining > 0) { return ['allowed' => false, 'wait' => $remaining, 'locked' => true]; } // Lockout expired, clean up unset($data[$ipHash]); saveAttempts($data); return ['allowed' => true, 'wait' => 0, 'locked' => false]; } // Progressive delay after threshold if ($count >= BRUTE_FORCE_THRESHOLD) { $delay = ($count - BRUTE_FORCE_THRESHOLD + 1) * BRUTE_FORCE_DELAY_STEP; $remaining = $delay - ($now - $lastAttempt); if ($remaining > 0) { return ['allowed' => false, 'wait' => $remaining, 'locked' => false]; } } return ['allowed' => true, 'wait' => 0, 'locked' => false]; } function recordFailedAttempt() { $ipHash = getIpHash(); $data = loadAttempts(); $now = time(); if (!isset($data[$ipHash])) { $data[$ipHash] = ['count' => 0, 'first' => $now, 'last' => $now]; } $data[$ipHash]['count']++; $data[$ipHash]['last'] = $now; saveAttempts($data); } function resetAttempts() { $ipHash = getIpHash(); $data = loadAttempts(); unset($data[$ipHash]); saveAttempts($data); } function raijuIsLoopbackName(string $host): bool { return raijuSessionIsLoopback($host); } function raijuDevLoginAvailable(): bool { return raijuSessionDevLoginAllowed(); } /** * Separate rate-limiting for password reset requests. * Uses a "reset_" prefix to avoid sharing counters with login attempts. * More lenient: 5 attempts per hour. */ function checkResetRateLimit() { $key = 'reset_' . getIpHash(); $data = loadAttempts(); $entry = $data[$key] ?? null; if ($entry === null) { return ['allowed' => true]; } $now = time(); $count = $entry['count'] ?? 0; $firstAttempt = $entry['first'] ?? $now; // Reset counter after 1 hour if ($now - $firstAttempt > 3600) { unset($data[$key]); saveAttempts($data); return ['allowed' => true]; } // Allow 5 attempts per hour if ($count >= 5) { return ['allowed' => false]; } return ['allowed' => true]; } function recordResetAttempt() { $key = 'reset_' . getIpHash(); $data = loadAttempts(); $now = time(); if (!isset($data[$key])) { $data[$key] = ['count' => 0, 'first' => $now, 'last' => $now]; } $data[$key]['count']++; $data[$key]['last'] = $now; saveAttempts($data); } // ============================================================ // PASSWORD STRENGTH CHECK // ============================================================ function isPasswordWeak($password) { if (strlen($password) < 8) return true; if (!preg_match('/[A-Z]/', $password)) return true; if (!preg_match('/[a-z]/', $password)) return true; if (!preg_match('/[0-9]/', $password)) return true; if (!preg_match('/[^A-Za-z0-9]/', $password)) return true; return false; } // ============================================================ // REDIRECT VALIDATION // ============================================================ function validateRedirectUrl($url) { return raijuSessionRedirectUrl(is_string($url) ? $url : ''); } // ============================================================ // LOGOUT // ============================================================ if (isset($_GET['logout'])) { $redirect = validateRedirectUrl($_GET['redirect'] ?? ($_SERVER['HTTP_REFERER'] ?? '/')); session_destroy(); header('Location: ' . $redirect); exit; } // Save redirect URL (where to go after login). // Behaviour depends on the `general.frontendLoginRedirect` site setting: // 'auto' — return to ?redirect=… page after login (default) // 'dashboard' — always land in the dashboard; the dashboard banner offers // a link back to the source page via $_SESSION['login_source_url']. // // We honour ?redirect=… on every GET so that an admin who arrives via the // footer dblclick gets the freshest source URL even if a stale value lingers // in the session from an earlier visit. if (!empty($_GET['redirect'])) { $sourceUrl = validateRedirectUrl($_GET['redirect']); $_SESSION['login_source_url'] = $sourceUrl; $settings = (defined('SETTINGS_PATH') && file_exists(SETTINGS_PATH)) ? json_decode(file_get_contents(SETTINGS_PATH), true) : []; $loginMode = $settings['general']['frontendLoginRedirect'] ?? 'auto'; if ($loginMode === 'auto') { $_SESSION['redirect_after_login'] = $sourceUrl; } else { $_SESSION['redirect_after_login'] = 'dashboard.php'; } } elseif (!isset($_SESSION['redirect_after_login'])) { $_SESSION['redirect_after_login'] = 'dashboard.php'; } // Already logged in? Redirect to saved page if (raijuSessionValidate()) { $redirect = $_SESSION['redirect_after_login'] ?? 'dashboard.php'; unset($_SESSION['redirect_after_login']); header('Location: ' . $redirect); exit; } // ============================================================ // LOGIN ATTEMPT // ============================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = $_POST['username'] ?? ''; $password = $_POST['password'] ?? ''; // Dev bypass: opt-in, loopback-only, existing admin users only. // It must be checked before rate limiting so a local test copy cannot lock // Codex/Claude out of the standard admin/dev workflow after failed probes. // Special case: username "admin" with password "dev" always works on localhost, // even if no "admin" user exists — uses a synthetic admin session in that case. $devLoginAvailable = raijuDevLoginAvailable(); $devPassword = 'dev'; $usedDevLogin = false; $devLoginCandidate = null; if ($devLoginAvailable && $password === $devPassword) { $candidate = findUserByUsername($username); if ($candidate && ($candidate['role'] ?? '') === 'admin') { $devLoginCandidate = $candidate; $usedDevLogin = true; } elseif ($username === 'admin') { // Synthetic fallback: no "admin" user exists, create a virtual one for dev only. $devLoginCandidate = ['id' => 'dev_admin', 'username' => 'admin', 'role' => 'admin', 'email' => '']; $usedDevLogin = true; } } $bruteCheck = $devLoginCandidate ? ['allowed' => true] : checkBruteForce(); if (!$bruteCheck['allowed']) { $lockoutWait = $bruteCheck['wait']; if ($bruteCheck['locked']) { $hours = ceil($lockoutWait / 3600); $error = t('login.error_locked', ['time' => $hours . ' ' . t('login.hour')]); } else { $error = t('login.error_too_many'); } } else { $user = $devLoginCandidate ?: verifyUserPassword($username, $password); if ($user) { resetAttempts(); session_regenerate_id(true); raijuSessionForgetLogin(); $_SESSION['admin_logged_in'] = true; $_SESSION['admin_login_time'] = time(); $_SESSION['admin_user_id'] = $user['id']; $_SESSION['admin_username'] = $user['username']; $_SESSION['admin_role'] = $user['role']; $_SESSION['admin_password_fingerprint'] = hash('sha256', (string)($user['passwordHash'] ?? '')); $_SESSION['raiju_dev_login'] = $usedDevLogin; $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); updateUserLastLogin($user['id']); // Check password strength and set warning if (!$usedDevLogin && isPasswordWeak($password)) { $_SESSION['password_warning'] = true; } // Prompt to add email if missing if (empty($user['email'])) { $_SESSION['email_missing'] = true; } $redirect = $_SESSION['redirect_after_login'] ?? 'dashboard.php'; unset($_SESSION['redirect_after_login']); header('Location: ' . $redirect); exit; } else { recordFailedAttempt(); // Re-check to show countdown if threshold now exceeded $bruteCheck = checkBruteForce(); if (!$bruteCheck['allowed']) { $lockoutWait = $bruteCheck['wait']; $error = t('login.error_wait'); } else { $error = t('login.error_invalid'); } } } } // ============================================================ // PASSWORD RESET FLOW // ============================================================ $resetAction = $_GET['action'] ?? ''; $resetSuccess = ''; $resetError = ''; // Handle reset request (email form submission) if ($resetAction === 'reset-request' && $_SERVER['REQUEST_METHOD'] === 'POST') { $resetRateCheck = checkResetRateLimit(); if (!$resetRateCheck['allowed']) { $resetError = t('login.reset_rate_limit'); } else { $resetEmail = trim($_POST['reset_email'] ?? ''); // Always show same message to prevent user enumeration $resetSuccess = t('login.reset_sent'); if (!empty($resetEmail) && filter_var($resetEmail, FILTER_VALIDATE_EMAIL)) { $user = findUserByEmail($resetEmail); if ($user) { $rawToken = generateResetToken($user['id']); if ($rawToken) { // Send reset email $settingsPath = defined('SETTINGS_PATH') ? SETTINGS_PATH : __DIR__ . '/../content/settings.json'; $emailConfig = null; if (file_exists($settingsPath)) { $s = json_decode(file_get_contents($settingsPath), true); if (!empty($s['email']) && ($s['email']['method'] ?? 'inactive') !== 'inactive') { $emailConfig = $s['email']; } } if ($emailConfig) { require_once __DIR__ . '/../api/SmtpMailer.php'; $resetUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . dirname($_SERVER['SCRIPT_NAME']) . '/index.php?action=reset&token=' . urlencode($rawToken); $subject = t('login.reset_email_subject'); $body = t('login.reset_email_body', ['url' => $resetUrl, 'username' => $user['username']]); $fromEmail = $emailConfig['fromEmail'] ?? $emailConfig['recipientEmail'] ?? ''; $fromName = $emailConfig['fromName'] ?? ''; if ($emailConfig['method'] === 'smtp') { $mailer = new SmtpMailer( $emailConfig['smtpHost'] ?? '', intval($emailConfig['smtpPort'] ?? 587), $emailConfig['smtpUsername'] ?? '', $emailConfig['smtpPassword'] ?? '', $emailConfig['smtpEncryption'] ?? 'tls' ); $mailer->send($resetEmail, $subject, $body, $fromEmail, $fromName); } elseif ($emailConfig['method'] === 'sendmail') { $headers = []; $headers[] = 'From: ' . ($fromName ? "=?UTF-8?B?" . base64_encode($fromName) . "?= <$fromEmail>" : $fromEmail); $headers[] = 'Content-Type: text/plain; charset=UTF-8'; @mail($resetEmail, '=?UTF-8?B?' . base64_encode($subject) . '?=', $body, implode("\r\n", $headers)); } } } } } // Record attempt to rate-limit (separate counter from login) recordResetAttempt(); } } // Handle password reset (new password form submission) if ($resetAction === 'reset' && $_SERVER['REQUEST_METHOD'] === 'POST') { $token = $_POST['token'] ?? ''; $newPassword = $_POST['new_password'] ?? ''; $confirmPassword = $_POST['confirm_password'] ?? ''; $user = validateResetToken($token); if (!$user) { $resetError = t('login.reset_invalid_token'); } elseif (empty($newPassword) || $newPassword !== $confirmPassword) { $resetError = t('login.reset_password_mismatch'); } elseif (isPasswordWeak($newPassword)) { $resetError = t('login.reset_password_weak'); } else { $newHash = password_hash($newPassword, PASSWORD_DEFAULT); if (completePasswordReset($user['id'], $token, $newHash)) { $resetSuccess = t('login.reset_success'); $resetAction = ''; // Show login form with success message } else { $resetError = t('login.reset_invalid_token'); } } } // Check if email is active (for showing forgot password link) $emailActive = false; $settingsForEmail = null; if (defined('SETTINGS_PATH') && file_exists(SETTINGS_PATH)) { $settingsForEmail = json_decode(file_get_contents(SETTINGS_PATH), true); if (!empty($settingsForEmail['email']['method']) && $settingsForEmail['email']['method'] !== 'inactive') { $emailActive = true; } } $devLoginAvailable = raijuDevLoginAvailable(); function raijuLoginAssetPath(string $path): string { $path = trim($path); if ($path === '' || preg_match('#^https?://#i', $path)) { return ''; } return '/' . ltrim($path, '/'); } function raijuLoginResolveBrandAsset(array $settings, string $asset): string { if ($asset === 'favicon') { return raijuLoginAssetPath((string)($settings['favicon'] ?? '/assets/images/favicon.svg')); } if ($asset === 'logo') { $branding = $settings['branding'] ?? []; $logo = (string)($branding['adminLogo'] ?? ''); if ($logo === '') { $logo = (string)($branding['logo'] ?? ''); } return raijuLoginAssetPath($logo !== '' ? $logo : (string)($settings['favicon'] ?? '/assets/images/favicon.svg')); } return ''; } function raijuLoginCssUrl(string $path): string { return str_replace(["\\", "\"", "\n", "\r"], ["\\\\", "\\\"", "", ""], $path); } function raijuLoginImageLayout(string $layout): string { if ($layout === 'split') { return 'left'; } return in_array($layout, ['none', 'background', 'left', 'right'], true) ? $layout : 'none'; } function raijuLoginRgbaFromHex(string $hex, int $opacity): string { if (!preg_match('/^#[0-9a-fA-F]{6}$/', $hex)) { return ''; } $opacity = max(0, min(100, $opacity)); $red = hexdec(substr($hex, 1, 2)); $green = hexdec(substr($hex, 3, 2)); $blue = hexdec(substr($hex, 5, 2)); $alpha = rtrim(rtrim(sprintf('%.2F', $opacity / 100), '0'), '.'); return "rgba($red, $green, $blue, $alpha)"; } // Load settings for branding/theme $_defaultFavicon = defined('RAIJU_DEFAULT_FAVICON') ? RAIJU_DEFAULT_FAVICON : '/assets/images/favicon.svg'; $siteSettings = [ 'favicon' => $_defaultFavicon, 'branding' => ['logo' => '', 'logoDark' => '', 'adminLogo' => '', 'name' => '', 'showBranding' => true, 'logoDisplay' => 'both'], 'theme' => ['adminTheme' => 'light', 'preset' => 'soft', 'density' => 'auto', 'font' => 'system', 'primaryColor' => '#3858e9', 'accentColor' => '', 'sidebarBg' => '', 'darkSidebarBg' => ''], 'login' => ['brandAsset' => 'favicon', 'image' => '', 'imageLayout' => 'none', 'overlayColor' => '', 'overlayOpacity' => 86, 'boxStyle' => 'card', 'boxColor' => '', 'boxTextColor' => ''] ]; if (defined('SETTINGS_PATH') && file_exists(SETTINGS_PATH)) { $loadedSettings = json_decode(file_get_contents(SETTINGS_PATH), true); if (is_array($loadedSettings)) { // Only merge sub-arrays; skip old-format scalar values (e.g. "theme": "dark") foreach ($siteSettings as $key => $defaults) { if (isset($loadedSettings[$key]) && is_array($loadedSettings[$key])) { $siteSettings[$key] = array_replace($defaults, $loadedSettings[$key]); } } if (!empty($loadedSettings['favicon'])) $siteSettings['favicon'] = $loadedSettings['favicon']; } } $adminTheme = $siteSettings['theme']['adminTheme'] ?? 'light'; $_themePreset = in_array($siteSettings['theme']['preset'] ?? '', ['soft', 'classic', 'paper', 'studio', 'console', 'pop'], true) ? $siteSettings['theme']['preset'] : 'soft'; $_themeFontRaw = $siteSettings['theme']['font'] ?? ''; if ($_themeFontRaw === 'geist') $_themeFontRaw = 'plex'; $_themeFont = in_array($_themeFontRaw, ['system', 'inter', 'plex', 'atkinson', 'public', 'geist-mono'], true) ? $_themeFontRaw : 'system'; $_loginBase = preg_match('/^#[0-9a-fA-F]{6}$/', (string)$siteSettings['theme']['primaryColor']) ? strtolower((string)$siteSettings['theme']['primaryColor']) : '#3858e9'; $_loginAccentRaw = strtolower((string)($siteSettings['theme']['accentColor'] ?? '')); $_loginAccent = (preg_match('/^#[0-9a-fA-F]{6}$/', $_loginAccentRaw) && $_loginAccentRaw !== $_loginBase) ? $_loginAccentRaw : $_loginBase; $showBranding = $siteSettings['branding']['showBranding'] ?? true; // Backend uses the configured login asset, with admin logo preferred for "logo". $loginSettings = $siteSettings['login'] ?? []; $loginBrandAsset = (string)($loginSettings['brandAsset'] ?? 'favicon'); $brandLogo = raijuLoginResolveBrandAsset($siteSettings, $loginBrandAsset); $brandName = $siteSettings['branding']['name'] ?? (defined('SITE_NAME') ? SITE_NAME : 'CMS'); $loginImage = raijuLoginAssetPath((string)($loginSettings['image'] ?? '')); $loginImageLayout = $loginImage !== '' ? raijuLoginImageLayout((string)($loginSettings['imageLayout'] ?? 'none')) : 'none'; $loginBodyClass = 'login-page'; if ($loginImageLayout === 'background') { $loginBodyClass .= ' login-page--background'; } elseif ($loginImageLayout === 'left') { $loginBodyClass .= ' login-page--split login-page--image-left'; } elseif ($loginImageLayout === 'right') { $loginBodyClass .= ' login-page--split login-page--image-right'; } $loginCssImage = raijuLoginCssUrl($loginImage); $loginOverlayColor = trim((string)($loginSettings['overlayColor'] ?? '')); $loginOverlayOpacity = (int)($loginSettings['overlayOpacity'] ?? 86); $loginOverlayRgba = raijuLoginRgbaFromHex($loginOverlayColor, $loginOverlayOpacity); $loginOverlayStyle = $loginOverlayColor !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $loginOverlayColor) ? '; --login-overlay-color: ' . htmlspecialchars($loginOverlayRgba, ENT_QUOTES, 'UTF-8') : ''; $loginBoxStyle = (string)($loginSettings['boxStyle'] ?? 'card'); if (!in_array($loginBoxStyle, ['card', 'plain'], true)) { $loginBoxStyle = 'card'; } $loginBoxColor = trim((string)($loginSettings['boxColor'] ?? '')); $loginBoxTextColor = trim((string)($loginSettings['boxTextColor'] ?? '')); $loginBoxVariables = []; if ($loginBoxStyle === 'card' && $loginBoxColor !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $loginBoxColor)) { $loginBoxVariables[] = '--login-box-bg: ' . $loginBoxColor; } if ($loginBoxTextColor !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $loginBoxTextColor)) { $loginBoxVariables[] = '--login-box-text: ' . $loginBoxTextColor; } $loginBoxStyleAttr = $loginBoxVariables ? ' style="' . htmlspecialchars(implode('; ', $loginBoxVariables), ENT_QUOTES, 'UTF-8') . '"' : ''; ?> Admin Login - <?php echo defined('SITE_NAME') ? SITE_NAME : 'CMS'; ?> >
>

0): ?>
s remaining

Raiju. Lightning fast.

0): ?>