diff --git a/.env b/.env deleted file mode 100644 index ea25aa7..0000000 --- a/.env +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index ef27cf8..933690a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,21 @@ +# Secrets — NEVER commit these .env +.env.* config/secrets.php + +# Storage — runtime data, not code storage/invoices/ storage/logs/ storage/exports/ +storage/cache/ + +# Dependencies vendor/ +node_modules/ + +# Dev tools scratch.js describe.php .DS_Store .idea/ .vscode/ -node_modules/ diff --git a/app/bootstrap/init.php b/app/bootstrap/init.php index 0562658..6221fe3 100644 --- a/app/bootstrap/init.php +++ b/app/bootstrap/init.php @@ -10,11 +10,11 @@ define('ROOT_PATH', dirname(__DIR__, 2)); define('APP_PATH', ROOT_PATH . '/app'); 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 . '/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') { error_reporting(E_ALL); ini_set('display_errors', '1'); @@ -23,36 +23,58 @@ if (env('APP_DEBUG', 'false') === 'true') { 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-Frame-Options: DENY"); header("X-XSS-Protection: 1; mode=block"); 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) { - $prefix = 'App\\'; + $prefix = 'App\\'; $base_dir = APP_PATH . '/'; - + $len = strlen($prefix); if (strncmp($prefix, $class, $len) !== 0) return; - + $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'; - $dir = strtolower(implode('/', $parts)); - + $dir = strtolower(implode('/', $parts)); + $file = $base_dir . ($dir ? $dir . '/' : '') . $filename; - + if (file_exists($file)) { require $file; } }); -// 6. Response Utility +// 7. Response Utility require_once APP_PATH . '/bootstrap/response.php'; -// 7. Global Auth Helper +// 8. Global Auth Helper require_once APP_PATH . '/bootstrap/auth.php'; diff --git a/app/bootstrap/response.php b/app/bootstrap/response.php index 80cdde4..4482e84 100644 --- a/app/bootstrap/response.php +++ b/app/bootstrap/response.php @@ -1,51 +1,65 @@ $success, - 'data' => $data, + 'data' => $data, // Return real data to client 'message' => $message, 'timestamp' => date('c') ], JSON_UNESCAPED_UNICODE); - + exit; } diff --git a/app/helpers/helpers.php b/app/helpers/helpers.php index c798127..6d3f8ec 100644 --- a/app/helpers/helpers.php +++ b/app/helpers/helpers.php @@ -13,19 +13,25 @@ if (!function_exists('input')) { function input(string $key = null, $default = null) { static $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) ?? []); } - + if ($key === null) return $inputData; return $inputData[$key] ?? $default; } } if (!function_exists('dd')) { + // M3 Fix: Guard dd() so it never leaks data in production 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) { - echo "
";
+ echo "";
var_dump($v);
echo "";
}
diff --git a/app/middleware/AuthMiddleware.php b/app/middleware/AuthMiddleware.php
index 17d6249..f1af32f 100644
--- a/app/middleware/AuthMiddleware.php
+++ b/app/middleware/AuthMiddleware.php
@@ -23,6 +23,11 @@ final class AuthMiddleware
$token = substr($authHeader, 7);
$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);
if (!$decoded) {
diff --git a/app/middleware/HmacMiddleware.php b/app/middleware/HmacMiddleware.php
new file mode 100644
index 0000000..f265c92
--- /dev/null
+++ b/app/middleware/HmacMiddleware.php
@@ -0,0 +1,62 @@
+ $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);
+ }
+ }
+}
diff --git a/app/middleware/RateLimitMiddleware.php b/app/middleware/RateLimitMiddleware.php
index 32480d8..aee49bf 100644
--- a/app/middleware/RateLimitMiddleware.php
+++ b/app/middleware/RateLimitMiddleware.php
@@ -1,6 +1,6 @@
$timestamp > ($now - $timeWindow));
+
+ // M2 Fix: Use exclusive file lock to prevent race condition
+ $fp = fopen($cacheFile, 'c+');
+ if ($fp === false) {
+ // If we can't open the file, fail open (don't block all users)
+ return;
+ }
+
+ try {
+ flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired
+
+ $now = time();
+ $content = stream_get_contents($fp);
+ $requests = [];
+
+ 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)));
}
}
diff --git a/app/modules_app/auth/login.php b/app/modules_app/auth/login.php
index ed1aba5..f873362 100644
--- a/app/modules_app/auth/login.php
+++ b/app/modules_app/auth/login.php
@@ -39,7 +39,11 @@ if (!$user || !password_verify($password, $user['password_hash'])) {
}
// 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 = [
'user_id' => $user['id'],
'role' => $user['role'],
@@ -48,10 +52,11 @@ $payload = [
$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));
+$refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
-$stmt->execute([$refreshToken, $user['id']]);
+$stmt->execute([$refreshTokenHash, $user['id']]);
json_success([
'access_token' => $token,
diff --git a/app/modules_app/auth/refresh.php b/app/modules_app/auth/refresh.php
index a0ed6aa..73844b8 100644
--- a/app/modules_app/auth/refresh.php
+++ b/app/modules_app/auth/refresh.php
@@ -14,15 +14,20 @@ if (!$refreshToken) {
}
$db = Database::getInstance();
+$refreshTokenHash = hash('sha256', $refreshToken);
$stmt = $db->prepare("SELECT * FROM users WHERE refresh_token_hash = ? LIMIT 1");
-$stmt->execute([$refreshToken]);
+$stmt->execute([$refreshTokenHash]);
$user = $stmt->fetch();
if (!$user) {
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 = [
'user_id' => $user['id'],
'role' => $user['role'],
@@ -31,9 +36,10 @@ $payload = [
$newToken = JWT::encode($payload, $secret);
$newRefreshToken = bin2hex(random_bytes(32));
+$newRefreshTokenHash = hash('sha256', $newRefreshToken);
$stmt = $db->prepare("UPDATE users SET refresh_token_hash = ? WHERE id = ?");
-$stmt->execute([$newRefreshToken, $user['id']]);
+$stmt->execute([$newRefreshTokenHash, $user['id']]);
json_success([
'access_token' => $newToken,
diff --git a/public/index.php b/public/index.php
index 47ee75e..ff028fe 100644
--- a/public/index.php
+++ b/public/index.php
@@ -5,37 +5,42 @@
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'] ?? '/'));
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$route = $_GET['route'] ?? str_replace('/api/', '', $uri);
$route = trim($route, '/');
-// Log for debugging
-error_log("Router: Resolved route for URI '{$uri}' is '{$route}'");
+error_log("Router: Resolved route '{$route}'");
-// Mapping routes to modules
+// Route map: route => [allowed_method, module_file]
$routes = [
- 'v1/auth/login' => 'auth/login.php',
- 'v1/auth/refresh' => 'auth/refresh.php',
- 'v1/auth/logout' => 'auth/logout.php',
- 'v1/users' => 'users/index.php',
+ 'v1/auth/login' => ['POST', 'auth/login.php'],
+ 'v1/auth/refresh' => ['POST', 'auth/refresh.php'],
+ 'v1/auth/logout' => ['POST', 'auth/logout.php'],
+ 'v1/users' => ['GET', 'users/index.php'],
];
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)) {
require_once $file;
} else {
json_error("Endpoint file missing: {$route}", 500);
}
} else {
- // If no route matches, maybe it's a SPA request or 404
if (str_starts_with($route, 'v1/')) {
json_error("Not Found: {$route}", 404);
} else {
- // Not an API request — serve the SPA shell
include __DIR__ . '/shell.php';
exit;
}