Initial commit - WASL Digital Wallet

This commit is contained in:
Hamza-Ayed
2026-06-20 21:55:06 +03:00
commit 7306c47368
61 changed files with 4157 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\AuditLog;
use Illuminate\Support\Facades\Auth;
class AuditRequestMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$response = $next($request);
// Audit only write/mutation operations or auth requests
$method = $request->method();
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE']) || $request->routeIs('*.sensitive')) {
$user = Auth::user();
// Mask sensitive fields in request payload
$payload = $request->all();
$sensitiveKeys = ['password', 'password_confirmation', 'pin', 'pin_confirmation', 'pin_hash', 'code', 'token', 'key', 'national_id', 'card_number'];
foreach ($sensitiveKeys as $key) {
if (isset($payload[$key])) {
$payload[$key] = '********';
}
}
AuditLog::record([
'user_id' => $user?->id,
'actor_id' => $user?->id,
'action' => 'api_request_' . strtolower($method),
'subject_type' => 'Request',
'subject_id' => null,
'old_values' => null,
'new_values' => [
'url' => $request->fullUrl(),
'method' => $method,
'status' => $response->getStatusCode(),
'payload' => $payload,
],
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'device_id' => $request->header('X-Device-Id'),
]);
}
return $response;
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;
class IdempotencyMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
// Only run for mutations (POST, PUT, PATCH, DELETE)
if (!in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
return $next($request);
}
$idempotencyKey = $request->header('Idempotency-Key') ?? $request->header('X-Idempotency-Key');
if (!$idempotencyKey) {
return $next($request);
}
// Clean key
$key = 'idempotency:' . hash('sha256', $idempotencyKey);
$lockKey = $key . ':lock';
$ttl = config('wasl.security.idempotency.ttl_seconds', 86400); // 24 hours default
// Acquire lock using Redis SETNX (Swoole safe)
$lockAcquired = Redis::set($lockKey, 'locked', 'EX', 10, 'NX');
if (!$lockAcquired) {
return response()->json([
'error' => 'Conflict',
'message' => 'A request with this Idempotency-Key is already in progress.',
], Response::HTTP_CONFLICT);
}
try {
// Check if we have a cached response
$cached = Redis::get($key);
if ($cached) {
$data = json_decode($cached, true);
// Release lock
Redis::del($lockKey);
return response($data['content'], $data['status'], $data['headers']);
}
// Execute request
$response = $next($request);
// Only cache successful or non-server-error responses (2xx and 4xx, exclude 5xx)
if ($response->getStatusCode() < 500) {
$cacheData = json_encode([
'status' => $response->getStatusCode(),
'headers' => collect($response->headers->all())->map(fn($val) => $val[0])->toArray(),
'content' => $response->getContent(),
]);
Redis::set($key, $cacheData, 'EX', $ttl);
}
return $response;
} finally {
// Always release lock
Redis::del($lockKey);
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Services\JwtService;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class JwtAuthenticate
{
protected JwtService $jwtService;
public function __construct(JwtService $jwtService)
{
$this->jwtService = $jwtService;
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$authorization = $request->header('Authorization');
if (!$authorization || !str_starts_with($authorization, 'Bearer ')) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'Authorization token is missing or malformed.',
], Response::HTTP_UNAUTHORIZED);
}
$token = substr($authorization, 7);
$payload = $this->jwtService->validateToken($token);
if (!$payload) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'Authorization token is invalid or expired.',
], Response::HTTP_UNAUTHORIZED);
}
$user = User::where('uuid', $payload['sub'])->first();
if (!$user) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'User associated with this token does not exist.',
], Response::HTTP_UNAUTHORIZED);
}
if ($user->status === \App\Enums\UserStatus::BANNED || $user->status === \App\Enums\UserStatus::SUSPENDED) {
return response()->json([
'error' => 'Forbidden',
'message' => 'Your account has been ' . $user->status->value . '.',
], Response::HTTP_FORBIDDEN);
}
// Set authenticated user
Auth::setUser($user);
return $next($request);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\Response;
class ThrottleSensitiveActions
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
* @param string $action
* @return mixed
*/
public function handle(Request $request, Closure $next, string $action)
{
$config = config("wasl.throttle.{$action}");
if (!$config) {
return $next($request);
}
$maxAttempts = $config['max'] ?? 5;
$decayMinutes = $config['minutes'] ?? 1;
// Generate key based on action, user_id (if logged in) or phone hash / IP
$userId = $request->user()?->id;
$ip = $request->ip();
$phoneHash = '';
if ($request->has('phone_number')) {
$phoneHash = hash_phone($request->input('phone_number'));
}
$key = 'throttle:' . $action . ':' . ($userId ?: ($phoneHash ?: $ip));
if (RateLimiter::tooManyAttempts($key, $maxAttempts)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'error' => 'Too Many Requests',
'message' => "Too many attempts for {$action}. Please retry in {$seconds} seconds.",
'retry_after' => $seconds,
], Response::HTTP_TOO_MANY_REQUESTS);
}
RateLimiter::hit($key, $decayMinutes * 60);
$response = $next($request);
// Optional: clear the rate limit on successful authentication for login
if ($action === 'login' && $response->getStatusCode() === Response::HTTP_OK) {
RateLimiter::clear($key);
}
return $response;
}
}