Update: 2026-07-30 02:27:45

This commit is contained in:
Hamza-Ayed
2026-07-30 02:27:45 +03:00
parent 5f62455113
commit ca4a7c2e70
56 changed files with 3391 additions and 709 deletions
+7
View File
@@ -44,6 +44,13 @@ final class AuthMiddleware
exit;
}
// Defence in depth for mobile requests: prove the caller also holds the
// per-device secret, so a stolen bearer token on its own is not enough.
// Controlled by HMAC_ENFORCE in .env - see HmacMiddleware.
if (($decoded['source'] ?? null) === 'mobile') {
HmacMiddleware::verify($decoded);
}
return $decoded;
}
}
+152 -32
View File
@@ -1,62 +1,182 @@
<?php
/**
* HMAC Request Signature Middleware
*
* Verifies that incoming requests are signed with a shared secret,
* preventing replay attacks and ensuring request integrity.
*
* HMAC Request Signature Middleware (per-device)
*
* Proves a request came from a device that completed login on this account, and
* that nobody altered it in flight. This is defence in depth on top of the JWT:
* a stolen bearer token alone is not enough to sign a request.
*
* Client must send:
* X-Timestamp: Unix timestamp (seconds)
* X-HMAC-Signature: HMAC-SHA256(timestamp + "." + raw_body, HMAC_SECRET_KEY)
* X-Timestamp: milliseconds since epoch
* X-Signature: HMAC-SHA256("METHOD:path:timestamp[:json_body]", device_secret)
*
* The device_secret is issued at login and stored encrypted (reversibly) in
* user_devices - it CANNOT be bcrypt-hashed, because the server has to be able
* to recompute the same HMAC the client computed.
*
* Rollout: enforcement is controlled by HMAC_ENFORCE in .env.
* HMAC_ENFORCE=false (default) -> a present signature is verified and a bad
* one is rejected, but a missing signature is
* allowed through. Lets older app builds keep
* working while the new build rolls out.
* HMAC_ENFORCE=true -> a signature is mandatory.
*/
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Security;
use App\Core\Database;
use App\Core\Encryption;
final class HmacMiddleware
{
/**
* @param int $maxAgeSeconds Max age for replay attack window (default: 5 minutes)
* @param array $decoded The decoded JWT payload from AuthMiddleware::check().
* @param int $maxAgeSeconds Replay window (default: 5 minutes).
*/
public static function verify(int $maxAgeSeconds = 300): void
public static function verify(array $decoded, int $maxAgeSeconds = 300): void
{
$headers = getallheaders();
$signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
$enforce = strtolower((string)env('HMAC_ENFORCE', 'false')) === 'true';
// 1. Ensure both headers are present
if (empty($signature) || empty($timestamp)) {
json_error('Missing HMAC signature or timestamp', 401);
$headers = getallheaders();
$signature = $headers['X-Signature'] ?? $headers['x-signature']
?? $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
// 1. Missing headers: hard fail only when enforcing.
if ($signature === '' || $timestamp === '') {
if ($enforce) {
json_error('Missing request signature', 401);
}
return;
}
// 2. Validate timestamp is numeric
// 2. Validate timestamp format.
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);
// 3. Replay prevention. The client sends milliseconds; older callers may
// send seconds, so normalise by magnitude rather than guessing.
$ts = (int)$timestamp;
$tsSeconds = $ts > 100000000000 ? intdiv($ts, 1000) : $ts;
if (abs(time() - $tsSeconds) > $maxAgeSeconds) {
json_error('Request expired. Check your device clock.', 401);
}
// 4. Build the expected signature
$body = file_get_contents('php://input');
$payload = $timestamp . '.' . $body;
$secret = env('HMAC_SECRET_KEY');
// 4. Look up this device's secret.
$deviceId = $decoded['device_id'] ?? null;
$userId = $decoded['user_id'] ?? null;
if (!$secret || strlen($secret) < 32) {
error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env');
json_error('Server configuration error', 500);
if (!$deviceId || !$userId) {
if ($enforce) {
json_error('Signed requests require a registered device', 401);
}
return;
}
// 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);
$secret = self::deviceSecret((string)$userId, (string)$deviceId);
if ($secret === null) {
if ($enforce) {
json_error('Unknown device. Please sign in again.', 401);
}
return;
}
// 5. Rebuild the signing payload exactly as the client does.
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$body = file_get_contents('php://input');
// index.php accepts both clean URLs (/api/v1/batches/create) and the
// ?route=v1/batches/create form, which produce different REQUEST_URIs for
// the same endpoint. Accept either so the signature does not depend on
// how the deployment happens to rewrite URLs.
$paths = array_unique(array_filter([
self::requestPath(),
self::normalisePath((string)($_GET['route'] ?? '')),
]));
$candidates = [];
foreach ($paths as $path) {
if ($body !== '' && $body !== false) {
$candidates[] = "{$method}:{$path}:{$timestamp}:{$body}";
}
// GET/multipart requests sign without a body.
$candidates[] = "{$method}:{$path}:{$timestamp}";
}
foreach ($candidates as $payload) {
$expected = hash_hmac('sha256', $payload, $secret);
if (hash_equals($expected, strtolower($signature))) {
return; // Verified.
}
}
error_log('HMAC verification failed for ' . ($_SERVER['REQUEST_URI'] ?? ''));
json_error('Invalid request signature', 401);
}
/**
* The path the client signed. Dio signs options.path, i.e. the endpoint
* relative to the API base URL ("batches/finalize"), without a query string.
*/
private static function requestPath(): string
{
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '';
return self::normalisePath($uri);
}
/**
* Strip the leading slash and any deployment prefix so the signed value
* matches what the client hashed.
*/
private static function normalisePath(string $raw): string
{
$path = ltrim(trim($raw), '/');
foreach (['api/v1/', 'api/', 'v1/'] as $prefix) {
if (str_starts_with($path, $prefix)) {
$path = substr($path, strlen($prefix));
break;
}
}
return $path;
}
/**
* Decrypt the stored per-device secret, or null if the device is unknown.
*/
private static function deviceSecret(string $userId, string $deviceId): ?string
{
try {
$db = Database::getInstance();
$stmt = $db->prepare("
SELECT device_secret FROM user_devices
WHERE user_id = ? AND device_fingerprint = ? AND is_trusted = 1
LIMIT 1
");
$stmt->execute([$userId, $deviceId]);
$stored = $stmt->fetchColumn();
if (!$stored) {
return null;
}
// Legacy rows hold a bcrypt hash, which is one-way and therefore
// unusable for HMAC. Treat those devices as un-signable until the
// user logs in again on the new build.
if (str_starts_with((string)$stored, '$2y$') || str_starts_with((string)$stored, '$2a$')) {
return null;
}
$plain = Encryption::decrypt((string)$stored);
return $plain === false ? null : $plain;
} catch (\Throwable $e) {
error_log('[HmacMiddleware] device secret lookup failed: ' . $e->getMessage());
return null;
}
}
}
+48 -6
View File
@@ -21,14 +21,16 @@ final class QuotaMiddleware
*
* @return array The current subscription data (for UI display)
*/
public static function checkInvoiceQuota(string $tenantId): array
public static function checkInvoiceQuota(string $tenantId, int $needed = 1): array
{
$cacheKey = "quota_sub_{$tenantId}";
$sub = Cache::get($cacheKey);
if ($sub === false || $sub === null) {
$db = Database::getInstance();
// $db is needed by the auto-reset branch below regardless of whether the
// subscription came from the cache, so resolve it unconditionally.
$db = Database::getInstance();
if ($sub === false || $sub === null) {
// Fetch subscription with plan info
$stmt = $db->prepare("
SELECT s.*, sp.name_ar as plan_name, sp.ai_features, sp.jofotara_enabled, sp.price_monthly_jod, sp.price_annual_jod
@@ -78,18 +80,30 @@ final class QuotaMiddleware
$sub['current_period_start'] = $newStart;
$sub['current_period_end'] = $newEnd;
// The cached copy still holds the pre-reset counters.
Cache::delete($cacheKey);
error_log("QuotaMiddleware: Auto-reset annual counter for tenant {$tenantId}");
}
// Check invoice quota
// Check invoice quota. $needed lets callers reserve a whole batch at once
// instead of only asking "is there room for one more?".
$used = (int)$sub['invoices_used_this_month'];
$limit = (int)$sub['max_invoices_per_month']; // Keeping the DB column name the same for compatibility
$needed = max(1, $needed);
if ($used >= $limit) {
json_error('لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.', 429, [
if (($used + $needed) > $limit) {
$remaining = max(0, $limit - $used);
$message = $remaining === 0
? 'لقد وصلت للحد الأقصى من الفواتير المسموحة في باقتك الحالية (' . $limit . ' فاتورة). يرجى ترقية باقتك للاستمرار.'
: 'رصيدك المتبقي ' . $remaining . ' فاتورة فقط، وقد طلبت ' . $needed . '. يرجى تقليل عدد الفواتير أو ترقية باقتك.';
json_error($message, 429, [
'quota_type' => 'invoices',
'used' => $used,
'limit' => $limit,
'requested' => $needed,
'remaining' => $remaining,
'plan' => $sub['plan_id'] ?? 'free',
'plan_name' => $sub['plan_name'] ?? 'مجانية',
'period_end' => $sub['current_period_end'],
@@ -99,6 +113,34 @@ final class QuotaMiddleware
return $sub;
}
/**
* Non-fatal variant of checkInvoiceQuota() for background workers.
*
* Workers must never emit an HTTP response, so this returns a boolean
* instead of calling json_error().
*/
public static function hasInvoiceQuota(string $tenantId, int $needed = 1): bool
{
try {
$db = Database::getInstance();
$stmt = $db->prepare("
SELECT invoices_used_this_month, max_invoices_per_month, status
FROM subscriptions WHERE tenant_id = ?
");
$stmt->execute([$tenantId]);
$sub = $stmt->fetch();
if (!$sub) return false;
if (in_array($sub['status'], ['cancelled', 'past_due'], true)) return false;
return ((int)$sub['invoices_used_this_month'] + max(1, $needed))
<= (int)$sub['max_invoices_per_month'];
} catch (\Throwable $e) {
error_log('[QuotaMiddleware] hasInvoiceQuota failed: ' . $e->getMessage());
return false;
}
}
/**
* Increment the monthly invoice counter after a successful upload.
*/