769 lines
33 KiB
PHP
769 lines
33 KiB
PHP
<?php
|
|
/**
|
|
* Admin Login
|
|
*/
|
|
|
|
// Redirect to setup if not yet configured
|
|
if (!file_exists(__DIR__ . '/config.php')) {
|
|
header('Location: setup.php');
|
|
exit;
|
|
}
|
|
|
|
require_once 'config.php';
|
|
require_once __DIR__ . '/../includes/version.php';
|
|
require_once __DIR__ . '/lang/i18n.php';
|
|
require_once __DIR__ . '/users.php';
|
|
require_once __DIR__ . '/../includes/asset-helpers.php';
|
|
ensureUsersFile();
|
|
|
|
require_once __DIR__ . '/../includes/session-helper.php';
|
|
raijuSessionStart();
|
|
|
|
$error = '';
|
|
$lockoutWait = 0; // seconds remaining for countdown
|
|
$timeoutNoticeTimestamp = isset($_GET['timeout']) ? (int)$_GET['timeout'] : 0;
|
|
$showTimeoutNotice = $timeoutNoticeTimestamp > 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') . '"'
|
|
: '';
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en" data-site-theme="<?php echo htmlspecialchars($adminTheme === 'system' ? 'light' : $adminTheme); ?>" data-theme-preset="<?php echo $_themePreset; ?>" data-font="<?php echo $_themeFont; ?>">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<title>Admin Login - <?php echo defined('SITE_NAME') ? SITE_NAME : 'CMS'; ?></title>
|
|
<?php
|
|
$_loginFavicon = $siteSettings['favicon'] ?? $brandLogo;
|
|
$_loginFaviconType = pathinfo($_loginFavicon, PATHINFO_EXTENSION) === 'svg' ? 'image/svg+xml' : 'image/png';
|
|
?>
|
|
<link rel="icon" href="<?php echo htmlspecialchars($_loginFavicon); ?>" type="<?php echo $_loginFaviconType; ?>">
|
|
<link rel="stylesheet" href="fonts.css">
|
|
<link rel="stylesheet" href="style.css">
|
|
<?php if ($adminTheme === 'system'): ?>
|
|
<script>
|
|
(function() {
|
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
document.documentElement.setAttribute('data-site-theme', 'dark');
|
|
}
|
|
})();
|
|
</script>
|
|
<?php endif; ?>
|
|
<style>
|
|
:root {
|
|
--rj-base: <?php echo $_loginBase; ?>;
|
|
--rj-accent-base: <?php echo $_loginAccent; ?>;
|
|
}
|
|
@supports not (color: oklch(from red l c h)) {
|
|
:root {
|
|
--rj-primary: <?php echo $_loginBase; ?>;
|
|
--rj-primary-btn: radial-gradient(ellipse at 50% 0%, color-mix(in srgb, <?php echo $_loginBase; ?> 70%, white) 0%, <?php echo $_loginBase; ?> 70%);
|
|
--rj-primary-btn-hover: radial-gradient(ellipse at 50% 0%, color-mix(in srgb, <?php echo $_loginBase; ?> 50%, white) 0%, <?php echo $_loginBase; ?> 70%);
|
|
--rj-brand: <?php echo $_loginAccent; ?>;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="<?php echo htmlspecialchars($loginBodyClass); ?>"<?php echo $loginImageLayout !== 'none' ? ' style="--login-image: url("' . htmlspecialchars($loginCssImage, ENT_QUOTES, 'UTF-8') . '")' . $loginOverlayStyle . '"' : ''; ?>>
|
|
<div class="login-media" aria-hidden="true"></div>
|
|
<div class="login-container login-container--<?php echo htmlspecialchars($loginBoxStyle, ENT_QUOTES, 'UTF-8'); ?>"<?php echo $loginBoxStyleAttr; ?>>
|
|
<?php if ($showBranding && $brandLogo !== ''): ?>
|
|
<div class="login-logo">
|
|
<?php echo raijuIconOrImg($brandLogo, $brandName, ['width' => 40, 'height' => 40]); ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
<h1><?php echo t('login.title'); ?></h1>
|
|
<p class="site-name"><?php echo defined('SITE_NAME') ? htmlspecialchars(SITE_NAME) : 'CMS'; ?></p>
|
|
|
|
<?php if ($resetSuccess && $resetAction === ''): ?>
|
|
<div class="success-message"><?php echo htmlspecialchars($resetSuccess); ?></div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($resetAction === 'reset-request'): ?>
|
|
<!-- Forgot Password: enter email -->
|
|
<?php if ($resetSuccess): ?>
|
|
<div class="success-message"><?php echo htmlspecialchars($resetSuccess); ?></div>
|
|
<p class="back-link"><a href="index.php">← <?php echo t('login.back_to_login'); ?></a></p>
|
|
<?php else: ?>
|
|
<?php if ($resetError): ?>
|
|
<div class="error-message"><?php echo htmlspecialchars($resetError); ?></div>
|
|
<?php endif; ?>
|
|
<p class="reset-desc"><?php echo t('login.reset_desc'); ?></p>
|
|
<form method="post" action="?action=reset-request">
|
|
<div class="form-group">
|
|
<label for="reset_email"><?php echo t('login.reset_email_label'); ?></label>
|
|
<input type="email" id="reset_email" name="reset_email" required autofocus>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-block"><?php echo t('login.reset_send'); ?></button>
|
|
</form>
|
|
<p class="back-link"><a href="index.php">← <?php echo t('login.back_to_login'); ?></a></p>
|
|
<?php endif; ?>
|
|
|
|
<?php elseif ($resetAction === 'reset'): ?>
|
|
<!-- Reset Password: enter new password -->
|
|
<?php
|
|
$tokenParam = $_GET['token'] ?? $_POST['token'] ?? '';
|
|
$tokenUser = validateResetToken($tokenParam);
|
|
?>
|
|
<?php if (!$tokenUser): ?>
|
|
<div class="error-message"><?php echo t('login.reset_invalid_token'); ?></div>
|
|
<p class="back-link"><a href="index.php">← <?php echo t('login.back_to_login'); ?></a></p>
|
|
<?php else: ?>
|
|
<?php if ($resetError): ?>
|
|
<div class="error-message"><?php echo htmlspecialchars($resetError); ?></div>
|
|
<?php endif; ?>
|
|
<p class="reset-desc"><?php echo t('login.reset_new_password_desc'); ?></p>
|
|
<form method="post" action="?action=reset">
|
|
<input type="hidden" name="token" value="<?php echo htmlspecialchars($tokenParam); ?>">
|
|
<div class="form-group">
|
|
<label for="new_password"><?php echo t('settings.new_password'); ?></label>
|
|
<input type="password" id="new_password" name="new_password" required autofocus>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="confirm_password"><?php echo t('settings.confirm_password'); ?></label>
|
|
<input type="password" id="confirm_password" name="confirm_password" required>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-block"><?php echo t('login.reset_set_password'); ?></button>
|
|
</form>
|
|
<p class="back-link"><a href="index.php">← <?php echo t('login.back_to_login'); ?></a></p>
|
|
<?php endif; ?>
|
|
|
|
<?php else: ?>
|
|
<!-- Normal Login Form -->
|
|
<?php if ($showTimeoutNotice): ?>
|
|
<div class="info-message"><?php echo t('login.session_expired'); ?></div>
|
|
<?php endif; ?>
|
|
<?php if ($error): ?>
|
|
<div class="error-message">
|
|
<?php echo htmlspecialchars($error); ?>
|
|
<?php if ($lockoutWait > 0): ?>
|
|
<div class="lockout-countdown" id="lockoutCountdown">
|
|
<span id="countdownSeconds"><?php echo $lockoutWait; ?></span>s remaining
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
<form method="post" action="" id="loginForm">
|
|
<div class="form-group">
|
|
<label for="username"><?php echo t('login.username'); ?></label>
|
|
<input type="text" id="username" name="username" required autofocus>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="password"><?php echo t('login.password'); ?></label>
|
|
<div class="password-input-wrap">
|
|
<input type="password" id="password" name="password" required>
|
|
<button type="button" class="password-toggle" id="passwordToggle" aria-label="<?php echo t('login.show_password'); ?>" data-label-show="<?php echo t('login.show_password'); ?>" data-label-hide="<?php echo t('login.hide_password'); ?>">
|
|
<svg class="icon-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
<svg class="icon-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
|
</button>
|
|
</div>
|
|
<?php if ($emailActive): ?>
|
|
<p class="forgot-password"><a href="?action=reset-request"><?php echo t('login.forgot_password'); ?></a></p>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary btn-block" id="loginBtn"<?php echo $lockoutWait > 0 ? ' disabled' : ''; ?>><?php echo t('login.button'); ?></button>
|
|
</form>
|
|
|
|
<p class="back-link"><a href="..">← <?php echo t('login.back_to_site'); ?></a></p>
|
|
<?php endif; ?>
|
|
<p class="login-version">raiju <?php echo htmlspecialchars(raijuVersion(), ENT_QUOTES, 'UTF-8'); ?></p>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
var toggle = document.getElementById('passwordToggle');
|
|
if (toggle) {
|
|
toggle.addEventListener('click', function() {
|
|
var input = document.getElementById('password');
|
|
var isVisible = input.type === 'text';
|
|
input.type = isVisible ? 'password' : 'text';
|
|
this.classList.toggle('visible', !isVisible);
|
|
this.setAttribute('aria-label', isVisible ? this.dataset.labelShow : this.dataset.labelHide);
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
<?php if (isset($_GET['timeout'])): ?>
|
|
<script>
|
|
(function() {
|
|
if (!window.history || !window.history.replaceState) return;
|
|
var url = new URL(window.location.href);
|
|
url.searchParams.delete('timeout');
|
|
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
|
})();
|
|
</script>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($lockoutWait > 0): ?>
|
|
<script>
|
|
(function() {
|
|
var remaining = <?php echo (int)$lockoutWait; ?>;
|
|
var countdownEl = document.getElementById('countdownSeconds');
|
|
var loginBtn = document.getElementById('loginBtn');
|
|
|
|
var labelHour = <?php echo json_encode(t('login.hour')); ?>;
|
|
var labelMin = <?php echo json_encode(t('login.minute')); ?>;
|
|
function formatTime(seconds) {
|
|
if (seconds >= 3600) {
|
|
var h = Math.floor(seconds / 3600);
|
|
var m = Math.ceil((seconds % 3600) / 60);
|
|
return h + labelHour + ' ' + m + labelMin;
|
|
}
|
|
if (seconds >= 60) {
|
|
var m = Math.floor(seconds / 60);
|
|
var s = seconds % 60;
|
|
return m + ':' + (s < 10 ? '0' : '') + s;
|
|
}
|
|
return seconds;
|
|
}
|
|
|
|
function tick() {
|
|
remaining--;
|
|
if (remaining <= 0) {
|
|
countdownEl.parentElement.parentElement.style.display = 'none';
|
|
loginBtn.disabled = false;
|
|
loginBtn.textContent = <?php echo json_encode(t('login.button')); ?>;
|
|
return;
|
|
}
|
|
countdownEl.textContent = formatTime(remaining);
|
|
setTimeout(tick, 1000);
|
|
}
|
|
|
|
countdownEl.textContent = formatTime(remaining);
|
|
loginBtn.disabled = true;
|
|
setTimeout(tick, 1000);
|
|
})();
|
|
</script>
|
|
<?php endif; ?>
|
|
</body>
|
|
</html>
|