Security Hardening: Phase 1-3 complete
- C1: Hash refresh tokens before DB storage (sha256) - C2: Remove JWT_SECRET fallback, fail hard if missing - H1: Enforce HTTP methods per route (405 on mismatch) - H2: CORS with origin whitelist from CORS_ORIGIN env var - H3: Redact sensitive fields (tokens, passwords) from logs - M1: Build HmacMiddleware with replay attack prevention - M2: Fix rate limiter race condition with flock LOCK_EX - M3: Guard dd() — suppressed in production - M4: Remove .env from git tracking, strengthen .gitignore - I1: Add HSTS header (max-age=31536000)
This commit is contained in:
46
.env
46
.env
@@ -1,46 +0,0 @@
|
|||||||
APP_NAME="مُصادَق"
|
|
||||||
APP_ENV=development
|
|
||||||
APP_URL=http://localhost:8000
|
|
||||||
APP_TIMEZONE=Asia/Amman
|
|
||||||
|
|
||||||
# MySQL (CloudPanel managed)
|
|
||||||
DB_HOST=127.0.0.1
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_DATABASE=musadaqDb
|
|
||||||
DB_USERNAME=musadaqUser
|
|
||||||
DB_PASSWORD=FWVG3vx2fhrwUULXa6E4
|
|
||||||
DB_CHARSET=utf8mb4
|
|
||||||
|
|
||||||
# Redis (system service)
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET=751139b2b6feb81d5a208a22a624a2f13269eef71044d6147484c85c1111c359
|
|
||||||
HMAC_SECRET_KEY=6eae97d9aa6c6732c1882a4eb62da79e7530d8a0dc93f7d03b6e80b15c6f9c55
|
|
||||||
ENCRYPTION_KEY_B64=0AEcpckd2g6eMA3ofBXRpgrDbV6ExWkB+D1Hl5pE+I0=
|
|
||||||
JWT_ACCESS_EXPIRY=900
|
|
||||||
JWT_REFRESH_EXPIRY=604800
|
|
||||||
|
|
||||||
# AI Providers
|
|
||||||
GEMINI_API_KEY=
|
|
||||||
GEMINI_MODEL=gemini-2.0-flash
|
|
||||||
OPENAI_API_KEY=
|
|
||||||
OPENAI_MODEL=gpt-4o
|
|
||||||
|
|
||||||
# JoFotara
|
|
||||||
JOFOTARA_BASE_URL=https://backend.jofotara.gov.jo/core/invoices
|
|
||||||
JOFOTARA_ENV=sandbox
|
|
||||||
|
|
||||||
# Email
|
|
||||||
MAIL_HOST=smtp.mailtrap.io
|
|
||||||
MAIL_PORT=2525
|
|
||||||
MAIL_USERNAME=
|
|
||||||
MAIL_PASSWORD=
|
|
||||||
MAIL_FROM=noreply@musadaq.app
|
|
||||||
MAIL_FROM_NAME="مُصادَق"
|
|
||||||
|
|
||||||
# Storage
|
|
||||||
STORAGE_PATH=/Users/hamzaaleghwairyeen/development/App/musadeq/storage
|
|
||||||
UPLOAD_MAX_SIZE=20971520
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,12 +1,21 @@
|
|||||||
|
# Secrets — NEVER commit these
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
config/secrets.php
|
config/secrets.php
|
||||||
|
|
||||||
|
# Storage — runtime data, not code
|
||||||
storage/invoices/
|
storage/invoices/
|
||||||
storage/logs/
|
storage/logs/
|
||||||
storage/exports/
|
storage/exports/
|
||||||
|
storage/cache/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
vendor/
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Dev tools
|
||||||
scratch.js
|
scratch.js
|
||||||
describe.php
|
describe.php
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
node_modules/
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ define('ROOT_PATH', dirname(__DIR__, 2));
|
|||||||
define('APP_PATH', ROOT_PATH . '/app');
|
define('APP_PATH', ROOT_PATH . '/app');
|
||||||
define('STORAGE_PATH', ROOT_PATH . '/storage');
|
define('STORAGE_PATH', ROOT_PATH . '/storage');
|
||||||
|
|
||||||
// 2. Load Environment Loader & Helpers FIRST
|
// 2. Load Environment & Helpers FIRST
|
||||||
require_once APP_PATH . '/bootstrap/env.php';
|
require_once APP_PATH . '/bootstrap/env.php';
|
||||||
require_once APP_PATH . '/helpers/helpers.php';
|
require_once APP_PATH . '/helpers/helpers.php';
|
||||||
|
|
||||||
// 3. Error Reporting (Secure for production - Now we can use env())
|
// 3. Error Reporting (Secure for production)
|
||||||
if (env('APP_DEBUG', 'false') === 'true') {
|
if (env('APP_DEBUG', 'false') === 'true') {
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', '1');
|
ini_set('display_errors', '1');
|
||||||
@@ -23,15 +23,38 @@ if (env('APP_DEBUG', 'false') === 'true') {
|
|||||||
ini_set('display_errors', '0');
|
ini_set('display_errors', '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Security Headers
|
// 4. H2 Fix: CORS — Whitelist only known origins
|
||||||
|
$allowedOrigins = array_filter(array_map('trim', explode(',', env('CORS_ORIGIN', 'https://musadaq.intaleqapp.com'))));
|
||||||
|
$requestOrigin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
|
||||||
|
if (in_array($requestOrigin, $allowedOrigins, true)) {
|
||||||
|
header("Access-Control-Allow-Origin: {$requestOrigin}");
|
||||||
|
} else {
|
||||||
|
// Fallback to first allowed origin (for non-browser API clients)
|
||||||
|
header("Access-Control-Allow-Origin: " . ($allowedOrigins[0] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||||
|
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-HMAC-Signature, X-Timestamp");
|
||||||
|
header("Access-Control-Allow-Credentials: true");
|
||||||
|
header("Vary: Origin");
|
||||||
|
|
||||||
|
// Handle CORS preflight
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Security Headers
|
||||||
header("X-Content-Type-Options: nosniff");
|
header("X-Content-Type-Options: nosniff");
|
||||||
header("X-Frame-Options: DENY");
|
header("X-Frame-Options: DENY");
|
||||||
header("X-XSS-Protection: 1; mode=block");
|
header("X-XSS-Protection: 1; mode=block");
|
||||||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||||||
|
header("Strict-Transport-Security: max-age=31536000; includeSubDomains"); // I1 Fix: HSTS
|
||||||
|
|
||||||
// 5. Intelligent Autoloader (Case-Insensitive for directories)
|
// 6. Intelligent Autoloader (Case-Insensitive for directories)
|
||||||
spl_autoload_register(function ($class) {
|
spl_autoload_register(function ($class) {
|
||||||
$prefix = 'App\\';
|
$prefix = 'App\\';
|
||||||
$base_dir = APP_PATH . '/';
|
$base_dir = APP_PATH . '/';
|
||||||
|
|
||||||
$len = strlen($prefix);
|
$len = strlen($prefix);
|
||||||
@@ -39,10 +62,9 @@ spl_autoload_register(function ($class) {
|
|||||||
|
|
||||||
$relative_class = substr($class, $len);
|
$relative_class = substr($class, $len);
|
||||||
|
|
||||||
// Normalize path to lowercase for directories, keep filename case
|
$parts = explode('\\', $relative_class);
|
||||||
$parts = explode('\\', $relative_class);
|
|
||||||
$filename = array_pop($parts) . '.php';
|
$filename = array_pop($parts) . '.php';
|
||||||
$dir = strtolower(implode('/', $parts));
|
$dir = strtolower(implode('/', $parts));
|
||||||
|
|
||||||
$file = $base_dir . ($dir ? $dir . '/' : '') . $filename;
|
$file = $base_dir . ($dir ? $dir . '/' : '') . $filename;
|
||||||
|
|
||||||
@@ -51,8 +73,8 @@ spl_autoload_register(function ($class) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Response Utility
|
// 7. Response Utility
|
||||||
require_once APP_PATH . '/bootstrap/response.php';
|
require_once APP_PATH . '/bootstrap/response.php';
|
||||||
|
|
||||||
// 7. Global Auth Helper
|
// 8. Global Auth Helper
|
||||||
require_once APP_PATH . '/bootstrap/auth.php';
|
require_once APP_PATH . '/bootstrap/auth.php';
|
||||||
|
|||||||
@@ -1,27 +1,37 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Standardized JSON Responses with Multi-Level Logging
|
* Standardized JSON Responses with Secure Logging
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
function json_response(bool $success, $data = null, ?string $message = null, int $code = 200) {
|
function json_response(bool $success, $data = null, ?string $message = null, int $code = 200) {
|
||||||
// 1. Prepare Log Entry
|
|
||||||
|
// H3 Fix: Redact sensitive fields before logging
|
||||||
|
$safeData = $data;
|
||||||
|
if (is_array($safeData)) {
|
||||||
|
$sensitiveKeys = ['access_token', 'refresh_token', 'password', 'password_hash', 'refresh_token_hash', 'token'];
|
||||||
|
array_walk_recursive($safeData, function (&$value, $key) use ($sensitiveKeys) {
|
||||||
|
if (in_array(strtolower((string)$key), $sensitiveKeys, true)) {
|
||||||
|
$value = '[REDACTED]';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log (safe — no secrets)
|
||||||
$logEntry = sprintf(
|
$logEntry = sprintf(
|
||||||
"API %s %s | Code: %d | Success: %s | Message: %s | Data: %s",
|
"API %s %s | %d | %s | %s",
|
||||||
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
$_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
||||||
$_SERVER['REQUEST_URI'] ?? '',
|
$_SERVER['REQUEST_URI'] ?? '',
|
||||||
$code,
|
$code,
|
||||||
$success ? 'YES' : 'NO',
|
$success ? 'OK' : 'FAIL',
|
||||||
$message ?? 'N/A',
|
$message ?? 'N/A'
|
||||||
json_encode($data, JSON_UNESCAPED_UNICODE)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Log to Standard PHP Error Log (Visible in CloudPanel Logs)
|
|
||||||
error_log($logEntry);
|
error_log($logEntry);
|
||||||
|
|
||||||
// 3. Try to log to custom app.log in storage folder
|
// Try custom log file
|
||||||
$logDir = STORAGE_PATH . '/logs';
|
$logDir = STORAGE_PATH . '/logs';
|
||||||
$logFile = $logDir . '/app.log';
|
$logFile = $logDir . '/app.log';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -29,19 +39,23 @@ function json_response(bool $success, $data = null, ?string $message = null, int
|
|||||||
@mkdir($logDir, 0775, true);
|
@mkdir($logDir, 0775, true);
|
||||||
}
|
}
|
||||||
if (is_writable($logDir) || is_writable($logFile)) {
|
if (is_writable($logDir) || is_writable($logFile)) {
|
||||||
@file_put_contents($logFile, "[" . date('Y-m-d H:i:s') . "] " . $logEntry . "\n", FILE_APPEND);
|
@file_put_contents(
|
||||||
|
$logFile,
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] " . $logEntry . "\n",
|
||||||
|
FILE_APPEND
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Fallback if filesystem is locked
|
// Fallback silently
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. HTTP Response
|
// HTTP Response
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
http_response_code($code);
|
http_response_code($code);
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => $success,
|
'success' => $success,
|
||||||
'data' => $data,
|
'data' => $data, // Return real data to client
|
||||||
'message' => $message,
|
'message' => $message,
|
||||||
'timestamp' => date('c')
|
'timestamp' => date('c')
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ if (!function_exists('input')) {
|
|||||||
function input(string $key = null, $default = null) {
|
function input(string $key = null, $default = null) {
|
||||||
static $inputData = null;
|
static $inputData = null;
|
||||||
if ($inputData === null) {
|
if ($inputData === null) {
|
||||||
$json = file_get_contents('php://input');
|
$json = file_get_contents('php://input');
|
||||||
$inputData = array_merge($_GET, $_POST, json_decode($json, true) ?? []);
|
$inputData = array_merge($_GET, $_POST, json_decode($json, true) ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,9 +23,15 @@ if (!function_exists('input')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('dd')) {
|
if (!function_exists('dd')) {
|
||||||
|
// M3 Fix: Guard dd() so it never leaks data in production
|
||||||
function dd(...$vars) {
|
function dd(...$vars) {
|
||||||
|
if (env('APP_DEBUG', 'false') !== 'true') {
|
||||||
|
error_log('dd() called in production — suppressed. Check your code.');
|
||||||
|
json_error('Internal Server Error', 500);
|
||||||
|
}
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
foreach ($vars as $v) {
|
foreach ($vars as $v) {
|
||||||
echo "<pre>";
|
echo "<pre style='background:#1e1e1e;color:#d4d4d4;padding:1rem;border-radius:4px;'>";
|
||||||
var_dump($v);
|
var_dump($v);
|
||||||
echo "</pre>";
|
echo "</pre>";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ final class AuthMiddleware
|
|||||||
$token = substr($authHeader, 7);
|
$token = substr($authHeader, 7);
|
||||||
$secret = env('JWT_SECRET');
|
$secret = env('JWT_SECRET');
|
||||||
|
|
||||||
|
if (!$secret || strlen($secret) < 32) {
|
||||||
|
error_log('FATAL: JWT_SECRET is missing or too short');
|
||||||
|
json_error('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
$decoded = JWT::decode($token, $secret);
|
$decoded = JWT::decode($token, $secret);
|
||||||
|
|
||||||
if (!$decoded) {
|
if (!$decoded) {
|
||||||
|
|||||||
62
app/middleware/HmacMiddleware.php
Normal file
62
app/middleware/HmacMiddleware.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* HMAC Request Signature Middleware
|
||||||
|
*
|
||||||
|
* Verifies that incoming requests are signed with a shared secret,
|
||||||
|
* preventing replay attacks and ensuring request integrity.
|
||||||
|
*
|
||||||
|
* Client must send:
|
||||||
|
* X-Timestamp: Unix timestamp (seconds)
|
||||||
|
* X-HMAC-Signature: HMAC-SHA256(timestamp + "." + raw_body, HMAC_SECRET_KEY)
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Security;
|
||||||
|
|
||||||
|
final class HmacMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int $maxAgeSeconds Max age for replay attack window (default: 5 minutes)
|
||||||
|
*/
|
||||||
|
public static function verify(int $maxAgeSeconds = 300): void
|
||||||
|
{
|
||||||
|
$headers = getallheaders();
|
||||||
|
$signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
|
||||||
|
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
|
||||||
|
|
||||||
|
// 1. Ensure both headers are present
|
||||||
|
if (empty($signature) || empty($timestamp)) {
|
||||||
|
json_error('Missing HMAC signature or timestamp', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate timestamp is numeric
|
||||||
|
if (!ctype_digit((string)$timestamp)) {
|
||||||
|
json_error('Invalid timestamp format', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Replay attack prevention — reject stale requests
|
||||||
|
$age = abs(time() - (int)$timestamp);
|
||||||
|
if ($age > $maxAgeSeconds) {
|
||||||
|
json_error('Request expired. Check your system clock.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the expected signature
|
||||||
|
$body = file_get_contents('php://input');
|
||||||
|
$payload = $timestamp . '.' . $body;
|
||||||
|
$secret = env('HMAC_SECRET_KEY');
|
||||||
|
|
||||||
|
if (!$secret || strlen($secret) < 32) {
|
||||||
|
error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env');
|
||||||
|
json_error('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verify using constant-time comparison (prevents timing attacks)
|
||||||
|
if (!Security::verifySignature($payload, $signature, $secret)) {
|
||||||
|
error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? ''));
|
||||||
|
json_error('Invalid request signature', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Simple Rate Limiting Middleware
|
* Rate Limiting Middleware (File-based, Race-Condition Safe)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -10,44 +10,62 @@ namespace App\Middleware;
|
|||||||
final class RateLimitMiddleware
|
final class RateLimitMiddleware
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Basic file-based rate limiter to keep dependencies zero.
|
* File-based rate limiter with file-lock to prevent race conditions.
|
||||||
* In a production multi-server setup, switch this to Redis/DB.
|
* For multi-server deployments, replace with Redis.
|
||||||
*/
|
*/
|
||||||
public static function check(int $maxRequests = 60, int $timeWindow = 60): void
|
public static function check(int $maxRequests = 60, int $timeWindow = 60): void
|
||||||
{
|
{
|
||||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||||
$cacheDir = STORAGE_PATH . '/cache';
|
$cacheDir = STORAGE_PATH . '/cache';
|
||||||
$cacheFile = $cacheDir . '/rate_limit_' . md5($ip) . '.json';
|
$cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
|
||||||
|
|
||||||
// Ensure cache directory exists
|
|
||||||
if (!is_dir($cacheDir)) {
|
if (!is_dir($cacheDir)) {
|
||||||
mkdir($cacheDir, 0755, true);
|
mkdir($cacheDir, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = time();
|
// M2 Fix: Use exclusive file lock to prevent race condition
|
||||||
$requests = [];
|
$fp = fopen($cacheFile, 'c+');
|
||||||
|
if ($fp === false) {
|
||||||
|
// If we can't open the file, fail open (don't block all users)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Read existing requests if file exists and is writable
|
try {
|
||||||
if (file_exists($cacheFile)) {
|
flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired
|
||||||
$content = file_get_contents($cacheFile);
|
|
||||||
if ($content !== false) {
|
$now = time();
|
||||||
$data = json_decode($content, true);
|
$content = stream_get_contents($fp);
|
||||||
if (is_array($data)) {
|
$requests = [];
|
||||||
// Filter out requests older than the time window
|
|
||||||
$requests = array_filter($data, fn($timestamp) => $timestamp > ($now - $timeWindow));
|
if (!empty($content)) {
|
||||||
|
$decoded = json_decode($content, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
// Keep only requests within the time window
|
||||||
|
$requests = array_values(
|
||||||
|
array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (count($requests) >= $maxRequests) {
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
header('Retry-After: ' . $timeWindow);
|
||||||
|
json_error('Too Many Requests. Please slow down.', 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this request
|
||||||
|
$requests[] = $now;
|
||||||
|
|
||||||
|
// Write updated data back
|
||||||
|
ftruncate($fp, 0);
|
||||||
|
rewind($fp);
|
||||||
|
fwrite($fp, json_encode($requests));
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
flock($fp, LOCK_UN);
|
||||||
|
fclose($fp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check limit
|
|
||||||
if (count($requests) >= $maxRequests) {
|
|
||||||
json_error('Too Many Requests. Please try again later.', 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add current request
|
|
||||||
$requests[] = $now;
|
|
||||||
|
|
||||||
// Save back to file
|
|
||||||
file_put_contents($cacheFile, json_encode(array_values($requests)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ if (!$user || !password_verify($password, $user['password_hash'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Issue Token
|
// 3. Issue Token
|
||||||
$secret = env('JWT_SECRET', 'super-secret-key');
|
$secret = env('JWT_SECRET');
|
||||||
|
if (!$secret || strlen($secret) < 32) {
|
||||||
|
error_log('FATAL: JWT_SECRET is missing or too short in .env');
|
||||||
|
json_error('Server configuration error', 500);
|
||||||
|
}
|
||||||
$payload = [
|
$payload = [
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
'role' => $user['role'],
|
'role' => $user['role'],
|
||||||
@@ -48,10 +52,11 @@ $payload = [
|
|||||||
|
|
||||||
$token = JWT::encode($payload, $secret);
|
$token = JWT::encode($payload, $secret);
|
||||||
|
|
||||||
// 4. Update Refresh Token (Simple stored in DB as requested)
|
// 4. Update Refresh Token (Hashed before storage for security)
|
||||||
$refreshToken = bin2hex(random_bytes(32));
|
$refreshToken = bin2hex(random_bytes(32));
|
||||||
|
$refreshTokenHash = hash('sha256', $refreshToken);
|
||||||
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
|
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
|
||||||
$stmt->execute([$refreshToken, $user['id']]);
|
$stmt->execute([$refreshTokenHash, $user['id']]);
|
||||||
|
|
||||||
json_success([
|
json_success([
|
||||||
'access_token' => $token,
|
'access_token' => $token,
|
||||||
|
|||||||
@@ -14,15 +14,20 @@ if (!$refreshToken) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
|
$refreshTokenHash = hash('sha256', $refreshToken);
|
||||||
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? LIMIT 1");
|
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? LIMIT 1");
|
||||||
$stmt->execute([$refreshToken]);
|
$stmt->execute([$refreshTokenHash]);
|
||||||
$user = $stmt->fetch();
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
json_error('Invalid refresh token', 401);
|
json_error('Invalid refresh token', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
$secret = env('JWT_SECRET', 'super-secret-key');
|
$secret = env('JWT_SECRET');
|
||||||
|
if (!$secret || strlen($secret) < 32) {
|
||||||
|
error_log('FATAL: JWT_SECRET is missing or too short in .env');
|
||||||
|
json_error('Server configuration error', 500);
|
||||||
|
}
|
||||||
$payload = [
|
$payload = [
|
||||||
'user_id' => $user['id'],
|
'user_id' => $user['id'],
|
||||||
'role' => $user['role'],
|
'role' => $user['role'],
|
||||||
@@ -31,9 +36,10 @@ $payload = [
|
|||||||
|
|
||||||
$newToken = JWT::encode($payload, $secret);
|
$newToken = JWT::encode($payload, $secret);
|
||||||
$newRefreshToken = bin2hex(random_bytes(32));
|
$newRefreshToken = bin2hex(random_bytes(32));
|
||||||
|
$newRefreshTokenHash = hash('sha256', $newRefreshToken);
|
||||||
|
|
||||||
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
|
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
|
||||||
$stmt->execute([$newRefreshToken, $user['id']]);
|
$stmt->execute([$newRefreshTokenHash, $user['id']]);
|
||||||
|
|
||||||
json_success([
|
json_success([
|
||||||
'access_token' => $newToken,
|
'access_token' => $newToken,
|
||||||
|
|||||||
@@ -5,37 +5,42 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/../app/bootstrap/init.php';
|
require_once __DIR__ . '/../app/bootstrap/init.php';
|
||||||
|
|
||||||
// Global Request Logging (for debugging on server)
|
// Global Request Logging (non-sensitive)
|
||||||
error_log("Incoming Request: " . ($_SERVER['REQUEST_METHOD'] ?? 'GET') . " " . ($_SERVER['REQUEST_URI'] ?? '/'));
|
error_log("Incoming Request: " . ($_SERVER['REQUEST_METHOD'] ?? 'GET') . " " . ($_SERVER['REQUEST_URI'] ?? '/'));
|
||||||
|
|
||||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
$route = $_GET['route'] ?? str_replace('/api/', '', $uri);
|
$route = $_GET['route'] ?? str_replace('/api/', '', $uri);
|
||||||
$route = trim($route, '/');
|
$route = trim($route, '/');
|
||||||
|
|
||||||
// Log for debugging
|
error_log("Router: Resolved route '{$route}'");
|
||||||
error_log("Router: Resolved route for URI '{$uri}' is '{$route}'");
|
|
||||||
|
|
||||||
// Mapping routes to modules
|
// Route map: route => [allowed_method, module_file]
|
||||||
$routes = [
|
$routes = [
|
||||||
'v1/auth/login' => 'auth/login.php',
|
'v1/auth/login' => ['POST', 'auth/login.php'],
|
||||||
'v1/auth/refresh' => 'auth/refresh.php',
|
'v1/auth/refresh' => ['POST', 'auth/refresh.php'],
|
||||||
'v1/auth/logout' => 'auth/logout.php',
|
'v1/auth/logout' => ['POST', 'auth/logout.php'],
|
||||||
'v1/users' => 'users/index.php',
|
'v1/users' => ['GET', 'users/index.php'],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isset($routes[$route])) {
|
if (isset($routes[$route])) {
|
||||||
$file = APP_PATH . '/modules_app/' . $routes[$route];
|
[$allowedMethod, $moduleFile] = $routes[$route];
|
||||||
|
|
||||||
|
// H1 Fix: Enforce HTTP Method
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== $allowedMethod) {
|
||||||
|
header("Allow: {$allowedMethod}");
|
||||||
|
json_error("Method Not Allowed. Use {$allowedMethod}.", 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = APP_PATH . '/modules_app/' . $moduleFile;
|
||||||
if (file_exists($file)) {
|
if (file_exists($file)) {
|
||||||
require_once $file;
|
require_once $file;
|
||||||
} else {
|
} else {
|
||||||
json_error("Endpoint file missing: {$route}", 500);
|
json_error("Endpoint file missing: {$route}", 500);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If no route matches, maybe it's a SPA request or 404
|
|
||||||
if (str_starts_with($route, 'v1/')) {
|
if (str_starts_with($route, 'v1/')) {
|
||||||
json_error("Not Found: {$route}", 404);
|
json_error("Not Found: {$route}", 404);
|
||||||
} else {
|
} else {
|
||||||
// Not an API request — serve the SPA shell
|
|
||||||
include __DIR__ . '/shell.php';
|
include __DIR__ . '/shell.php';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user