Clone Nabih Pure PHP Architecture
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Security;
|
||||
|
||||
class AuthMiddleware
|
||||
{
|
||||
/**
|
||||
* Verifies the JWT token and populates request properties.
|
||||
*/
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
$authHeader = $request->getHeader('authorization', '');
|
||||
|
||||
if (!$authHeader || !preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
|
||||
$response->json(['error' => 'Unauthorized', 'message' => 'Token not provided or invalid format'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = $matches[1];
|
||||
$payload = Security::verifyJWT($token);
|
||||
|
||||
if (!$payload) {
|
||||
$response->json(['error' => 'Unauthorized', 'message' => 'Invalid or expired token'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate required custom payload elements
|
||||
if (!isset($payload['user_id']) || !isset($payload['company_id']) || !isset($payload['role'])) {
|
||||
$response->json(['error' => 'Unauthorized', 'message' => 'Malformed token payload structure'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Attach user info to the Request instance dynamically so controllers can use it
|
||||
$request->user_id = $payload['user_id'];
|
||||
$request->company_id = $payload['company_id'];
|
||||
$request->role = $payload['role'];
|
||||
$request->is_super_admin = (int)$payload['company_id'] === 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
|
||||
/**
|
||||
* Rate Limit Middleware
|
||||
* Limits the number of requests per IP address using file-based counters.
|
||||
* Protects sensitive endpoints (login, register) from Brute Force attacks.
|
||||
*/
|
||||
class RateLimitMiddleware
|
||||
{
|
||||
/**
|
||||
* Maximum allowed requests within the time window
|
||||
*/
|
||||
private int $maxAttempts;
|
||||
|
||||
/**
|
||||
* Time window in seconds
|
||||
*/
|
||||
private int $decaySeconds;
|
||||
|
||||
public function __construct(int $maxAttempts = 5, int $decaySeconds = 60)
|
||||
{
|
||||
$this->maxAttempts = $maxAttempts;
|
||||
$this->decaySeconds = $decaySeconds;
|
||||
}
|
||||
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
$ip = $this->getClientIp();
|
||||
$key = 'rate_' . md5($ip . '_' . $request->getPath());
|
||||
|
||||
$storageDir = APP_ROOT . '/storage/rate_limits';
|
||||
if (!is_dir($storageDir)) {
|
||||
mkdir($storageDir, 0750, true);
|
||||
}
|
||||
|
||||
$filePath = $storageDir . '/' . $key . '.json';
|
||||
|
||||
$data = ['count' => 0, 'expires_at' => time() + $this->decaySeconds];
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
$raw = json_decode(file_get_contents($filePath), true);
|
||||
if ($raw && isset($raw['expires_at']) && $raw['expires_at'] > time()) {
|
||||
// Window still active — use existing data
|
||||
$data = $raw;
|
||||
}
|
||||
// If window expired, fall through and reset (overwrite with fresh data below)
|
||||
}
|
||||
|
||||
$data['count']++;
|
||||
|
||||
if ($data['count'] > $this->maxAttempts) {
|
||||
$retryAfter = max(0, $data['expires_at'] - time());
|
||||
$response->setHeader('Retry-After', (string)$retryAfter);
|
||||
$response->json([
|
||||
'error' => 'Too Many Requests',
|
||||
'message' => "You have exceeded the maximum number of {$this->maxAttempts} attempts. Please try again in {$retryAfter} seconds."
|
||||
], 429);
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the updated counter
|
||||
file_put_contents($filePath, json_encode($data), LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real client IP, accounting for proxies
|
||||
*/
|
||||
private function getClientIp(): string
|
||||
{
|
||||
$headers = [
|
||||
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
'REMOTE_ADDR'
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
// X-Forwarded-For can be a comma-separated list; take first
|
||||
$ip = trim(explode(',', $_SERVER[$header])[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '0.0.0.0';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
|
||||
class SecurityMiddleware
|
||||
{
|
||||
/**
|
||||
* Applies OWASP security headers and sanitizes incoming body/query parameters
|
||||
* to protect against XSS and basic Injection.
|
||||
*/
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
// 1. Set OWASP Security Headers
|
||||
$response->setHeader('X-Frame-Options', 'DENY'); // Prevent Clickjacking
|
||||
$response->setHeader('X-XSS-Protection', '1; mode=block'); // Prevent Cross-Site Scripting (XSS)
|
||||
$response->setHeader('X-Content-Type-Options', 'nosniff'); // Prevent MIME-sniffing
|
||||
$response->setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); // HSTS
|
||||
$response->setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; object-src 'none';"); // CSP
|
||||
|
||||
// 2. Input Sanitization to prevent XSS (Recursive)
|
||||
$body = $request->getBody();
|
||||
if (is_array($body)) {
|
||||
$request->setBody($this->sanitizeArray($body));
|
||||
}
|
||||
|
||||
$query = $request->getQueryParams();
|
||||
if (is_array($query)) {
|
||||
$request->setQueryParams($this->sanitizeArray($query));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively trim input arrays
|
||||
*/
|
||||
private function sanitizeArray(array $data): array
|
||||
{
|
||||
$sanitized = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$sanitized[$key] = $this->sanitizeArray($value);
|
||||
} elseif (is_string($value)) {
|
||||
$sanitized[$key] = trim($value);
|
||||
} else {
|
||||
$sanitized[$key] = $value;
|
||||
}
|
||||
}
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Models\CompanySubscription;
|
||||
use App\Models\CompanySubscriptionUsage;
|
||||
|
||||
/**
|
||||
* SubscriptionMiddleware
|
||||
* Validates company subscription validity and request quotas before processing operations.
|
||||
*/
|
||||
class SubscriptionMiddleware
|
||||
{
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
// 1. Get company ID (populated by AuthMiddleware)
|
||||
$companyId = $request->company_id ?? null;
|
||||
|
||||
if (!$companyId) {
|
||||
$response->json(['error' => 'Unauthorized', 'message' => 'Company details not found in request Context'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Allow Company 1 (Intaleq admin/demo) to bypass limits temporarily or have unlimited
|
||||
if ($companyId === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fetch active subscription
|
||||
$activeSub = CompanySubscription::findActiveByCompany($companyId);
|
||||
if (!$activeSub) {
|
||||
$response->json([
|
||||
'error' => 'Payment Required',
|
||||
'message' => 'This account does not have an active subscription or the current subscription has expired. Please subscribe to a plan to continue.'
|
||||
], 402);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Verify total requests limit
|
||||
$hasQuota = CompanySubscriptionUsage::hasRemainingLimit($companyId, 'request');
|
||||
if (!$hasQuota) {
|
||||
$response->json([
|
||||
'error' => 'Quota Exceeded',
|
||||
'message' => 'You have exceeded the monthly request quota for your plan (' . $activeSub['max_requests'] . ' requests). Please upgrade your subscription.'
|
||||
], 403);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user