392 lines
13 KiB
PHP
Executable File
392 lines
13 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* MSPE Authentication API
|
|
*/
|
|
|
|
require_once 'config.php';
|
|
|
|
$method = $_SERVER['REQUEST_METHOD'];
|
|
$body = getRequestBody();
|
|
|
|
switch ($method) {
|
|
case 'POST':
|
|
$action = $body['action'] ?? 'login';
|
|
|
|
if ($action === 'login') {
|
|
handleLogin($body);
|
|
} elseif ($action === 'logout') {
|
|
handleLogout();
|
|
} elseif ($action === 'verify') {
|
|
handleVerify();
|
|
} elseif ($action === 'request_password_reset') {
|
|
requestPasswordReset($body);
|
|
} elseif ($action === 'reset_password') {
|
|
resetPassword($body);
|
|
} else {
|
|
jsonResponse(['success' => 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 = '<h2>Password Reset</h2>'
|
|
. '<p>A password reset was requested for your admin account.</p>'
|
|
. '<p><a href="' . htmlspecialchars($resetLink) . '" style="display:inline-block;padding:10px 16px;background:#0ea5e9;color:#fff;text-decoration:none;border-radius:8px;">Reset Password</a></p>'
|
|
. '<p style="font-size:13px;color:#64748b;">This link is valid for 30 minutes.</p>';
|
|
|
|
$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']);
|
|
}
|
|
}
|
|
}
|
|
}
|