false, 'message' => 'Invalid action'], 400); } break; default: jsonResponse(['success' => false, 'message' => 'Method not allowed'], 405); } function handleLogin($body) { global $db; $username = $body['username'] ?? ''; $password = $body['password'] ?? ''; $rememberMe = !empty($body['remember']); if (empty($username) || empty($password)) { jsonResponse(['success' => false, 'message' => 'Username and password required'], 400); } // ── Rate limiting: max 10 attempts per IP per 15 minutes (DB-backed) ── $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $maxAttempts = 10; $windowSeconds = 900; // 15 minutes if (countRecentFailedLogins($ip, $windowSeconds) >= $maxAttempts) { jsonResponse(['success' => false, 'message' => 'Too many login attempts. Try again later.'], 429); } // ── Per-username rate limiting: max 5 attempts per username per 15 minutes ── $maxUsernameAttempts = 5; if (countRecentFailedLoginsByUsername($username, $windowSeconds) >= $maxUsernameAttempts) { jsonResponse(['success' => false, 'message' => 'This account is temporarily locked. Try again later.'], 429); } // Check override admin auth record first (if created via reset flow) $adminAuthRows = $db->getAll('admin_auth'); $adminAuth = !empty($adminAuthRows) ? $adminAuthRows[0] : null; if ($adminAuth && isset($adminAuth['username']) && isset($adminAuth['password_hash'])) { if ($username === $adminAuth['username'] && password_verify($password, $adminAuth['password_hash'])) { loginSuccessResponse('admin', $adminAuth['username'], 'admin', $rememberMe); } } else { // Fallback to config credentials (ADMIN_PASS must be a password hash) if ($username === ADMIN_USER && verifyConfigAdminPassword($password)) { loginSuccessResponse('admin', $username, 'admin', $rememberMe); } } // Check against database users $users = $db->query('users', ['username' => $username]); if (!empty($users)) { $user = array_values($users)[0]; if (password_verify($password, $user['password'])) { loginSuccessResponse($user['id'], $user['username'], $user['role'] ?? 'admin', $rememberMe); } } recordFailedLogin(); auditLog('login_failure', ['username' => $username]); jsonResponse(['success' => false, 'message' => 'Invalid credentials'], 401); } function recordFailedLogin() { global $db; $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $body = getRequestBody(); $db->insert('login_attempts', [ 'ip_address' => $ip, 'username' => sanitize($body['username'] ?? ''), 'attempted_at' => date('Y-m-d H:i:s') ]); cleanupOldFailedLogins(); } function countRecentFailedLogins($ip, $windowSeconds) { global $db; $cutoffDate = date('Y-m-d H:i:s', time() - (int)$windowSeconds); $now = date('Y-m-d H:i:s'); return $db->countByDateRange('login_attempts', 'attempted_at', $cutoffDate, $now, [ 'ip_address' => $ip ]); } function countRecentFailedLoginsByUsername($username, $windowSeconds) { global $db; if (trim($username) === '') return 0; $cutoffDate = date('Y-m-d H:i:s', time() - (int)$windowSeconds); $now = date('Y-m-d H:i:s'); return $db->countByDateRange('login_attempts', 'attempted_at', $cutoffDate, $now, [ 'username' => $username ]); } function cleanupOldFailedLogins() { global $db; $attempts = $db->getAll('login_attempts'); $cutoff = time() - (24 * 60 * 60); foreach ($attempts as $attempt) { $attemptedAt = strtotime((string)($attempt['attempted_at'] ?? '1970-01-01 00:00:00')); if ($attemptedAt < $cutoff && !empty($attempt['id'])) { $db->delete('login_attempts', $attempt['id']); } } } function handleLogout() { clearAdminAuthCookie(); jsonResponse(['success' => true, 'message' => 'Logged out successfully']); } function handleVerify() { $user = checkAuth(); if ($user) { jsonResponse([ 'success' => true, 'user' => [ 'id' => $user['user_id'], 'username' => $user['username'], 'role' => $user['role'] ] ]); } jsonResponse(['success' => false, 'message' => 'Invalid token'], 401); } function loginSuccessResponse($id, $username, $role, $rememberMe = false) { $ttlSeconds = $rememberMe ? (30 * 24 * 60 * 60) : (12 * 60 * 60); $token = JWT::encode([ 'user_id' => $id, 'username' => $username, 'role' => $role ], $ttlSeconds); setAdminAuthCookie($token, $ttlSeconds); auditLog('login_success', ['username' => $username, 'role' => $role, 'remember_me' => $rememberMe], $id); jsonResponse([ 'success' => true, 'message' => 'Login successful', 'user' => [ 'id' => $id, 'username' => $username, 'role' => $role ] ]); } function verifyConfigAdminPassword($submittedPassword) { if (!is_string(ADMIN_PASS) || ADMIN_PASS === '') { return false; } $info = password_get_info(ADMIN_PASS); if (!empty($info['algo'])) { return password_verify($submittedPassword, ADMIN_PASS); } error_log('MSPE security warning: ADMIN_PASS is not a password hash. Configure a password_hash() value in .env.'); return false; } function setAdminAuthCookie($token, $ttlSeconds) { $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['SERVER_PORT'] ?? '') == 443) || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); setcookie('mspe_admin_token', $token, [ 'expires' => time() + max(300, (int)$ttlSeconds), 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Strict' ]); } function clearAdminAuthCookie() { $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['SERVER_PORT'] ?? '') == 443) || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); setcookie('mspe_admin_token', '', [ 'expires' => time() - 3600, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Strict' ]); } function requestPasswordReset($body) { global $db; cleanupExpiredPasswordResets(); $identity = trim((string)($body['identity'] ?? $body['email'] ?? '')); if ($identity === '') { jsonResponse(['success' => false, 'message' => 'Email or username is required'], 400); } $matched = findUserForPasswordReset($identity); // Always return generic success to avoid username/email enumeration if (!$matched) { jsonResponse(['success' => true, 'message' => 'If an account exists, a reset email has been sent.']); } $token = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d H:i:s', time() + (30 * 60)); $db->insert('password_resets', [ 'token' => $token, 'user_type' => $matched['user_type'], 'user_id' => $matched['user_id'], 'email' => $matched['email'], 'used' => false, 'expires_at' => $expiresAt ]); $resetUrl = trim((string)getSetting('password_reset_url', SITE_URL . '/admin/reset-password.html')); if ($resetUrl === '') { $resetUrl = SITE_URL . '/admin/reset-password.html'; } $separator = strpos($resetUrl, '?') === false ? '?' : '&'; $resetLink = $resetUrl . $separator . 'token=' . urlencode($token); $subject = 'Reset your admin password'; $plain = "A password reset was requested for your admin account.\n\n"; $plain .= "Reset link (valid for 30 minutes):\n{$resetLink}\n\n"; $plain .= "If you did not request this, you can ignore this email."; $html = '
A password reset was requested for your admin account.
' . '' . 'This link is valid for 30 minutes.
'; $send = sendEmail($matched['email'], $subject, $html, $plain); if (!$send['success']) { error_log('MSPE password reset email failed: ' . ($send['message'] ?? 'unknown')); } jsonResponse(['success' => true, 'message' => 'If an account exists, a reset email has been sent.']); } function resetPassword($body) { global $db; cleanupExpiredPasswordResets(); $token = trim((string)($body['token'] ?? '')); $newPassword = (string)($body['new_password'] ?? ''); if ($token === '' || $newPassword === '') { jsonResponse(['success' => false, 'message' => 'Token and new password are required'], 400); } if (strlen($newPassword) < 12) { jsonResponse(['success' => false, 'message' => 'Password must be at least 12 characters'], 400); } $resets = $db->query('password_resets', ['token' => $token]); if (empty($resets)) { jsonResponse(['success' => false, 'message' => 'Invalid or expired reset token'], 400); } $reset = array_values($resets)[0]; if (!empty($reset['used'])) { jsonResponse(['success' => false, 'message' => 'This reset token has already been used'], 400); } if (strtotime($reset['expires_at'] ?? '1970-01-01 00:00:00') < time()) { jsonResponse(['success' => false, 'message' => 'Reset token has expired'], 400); } $hash = password_hash($newPassword, PASSWORD_DEFAULT); if (($reset['user_type'] ?? '') === 'default_admin') { $existingRows = $db->getAll('admin_auth'); if (!empty($existingRows)) { $first = $existingRows[0]; $db->update('admin_auth', $first['id'], [ 'username' => ADMIN_USER, 'email' => $reset['email'] ?? getSetting('admin_email', ADMIN_EMAIL), 'password_hash' => $hash ]); } else { $db->insert('admin_auth', [ 'username' => ADMIN_USER, 'email' => $reset['email'] ?? getSetting('admin_email', ADMIN_EMAIL), 'password_hash' => $hash ]); } } else { $userId = $reset['user_id'] ?? ''; $user = $db->get('users', $userId); if (!$user) { jsonResponse(['success' => false, 'message' => 'User no longer exists'], 400); } $db->update('users', $userId, ['password' => $hash]); } $db->update('password_resets', $reset['id'], ['used' => true]); jsonResponse(['success' => true, 'message' => 'Password updated successfully']); } function findUserForPasswordReset($identity) { global $db; // Check DB users by email or username $users = $db->getAll('users'); foreach ($users as $user) { if (($user['email'] ?? '') === $identity || ($user['username'] ?? '') === $identity) { return [ 'user_type' => 'db_user', 'user_id' => $user['id'], 'email' => $user['email'] ]; } } // Fallback default admin account if ($identity === ADMIN_USER || $identity === ADMIN_EMAIL || $identity === getSetting('admin_email', ADMIN_EMAIL)) { return [ 'user_type' => 'default_admin', 'user_id' => 'admin', 'email' => getSetting('admin_email', ADMIN_EMAIL) ]; } return null; } function cleanupExpiredPasswordResets() { global $db; $rows = $db->getAll('password_resets'); $now = time(); $retentionCutoff = $now - (30 * 24 * 60 * 60); foreach ($rows as $row) { $expiresAt = strtotime((string)($row['expires_at'] ?? '1970-01-01 00:00:00')); $createdAt = strtotime((string)($row['created_at'] ?? '1970-01-01 00:00:00')); $isUsed = !empty($row['used']); if (($expiresAt > 0 && $expiresAt < $now) || ($isUsed && $createdAt < $retentionCutoff)) { if (!empty($row['id'])) { $db->delete('password_resets', $row['id']); } } } }