Files
MSPE/api/auth.php
T

399 lines
13 KiB
PHP
Executable File

<?php
/**
* MSPE Authentication API
*/
require_once 'config.php';
$method = $_SERVER['REQUEST_METHOD'];
$body = getRequestBody();
switch ($method) {
case 'POST':
// Add CSRF protection for auth endpoints
requireSameOriginRequest();
$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($username);
auditLog('login_failure', ['username' => $username]);
jsonResponse(['success' => false, 'message' => 'Invalid credentials'], 401);
}
function recordFailedLogin($username = '') {
global $db;
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$db->insert('login_attempts', [
'ip_address' => $ip,
'username' => sanitize($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);
}
// Validate email format if it looks like an email
if (strpos($identity, '@') !== false && !filter_var($identity, FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => true, 'message' => 'If an account exists, a reset email has been sent.']);
}
$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']);
}
}
}
}