Implement multi-tenancy support with OAuth2 authentication for tenant admins, Stripe integration for event purchases and credits ledger, new Filament resources for event purchases, updated API routes and middleware for tenant isolation and token guarding, added factories/seeders/migrations for new models (Tenant, EventPurchase, OAuth entities, etc.), enhanced tests, and documentation updates. Removed outdated DemoAchievementsSeeder.
This commit is contained in:
39
app/Http/Middleware/CreditCheckMiddleware.php
Normal file
39
app/Http/Middleware/CreditCheckMiddleware.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Event;
|
||||
|
||||
class CreditCheckMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->isMethod('post') && $request->routeIs('api.v1.tenant.events.store')) {
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::findOrFail($user->tenant_id);
|
||||
|
||||
if ($tenant->event_credits_balance < 1) {
|
||||
return response()->json(['message' => 'Insufficient event credits'], 422);
|
||||
}
|
||||
|
||||
$request->merge(['tenant' => $tenant]);
|
||||
} elseif (($request->isMethod('put') || $request->isMethod('patch')) && $request->routeIs('api.v1.tenant.events.update')) {
|
||||
$eventSlug = $request->route('event');
|
||||
$event = Event::where('slug', $eventSlug)->firstOrFail();
|
||||
$tenant = $event->tenant;
|
||||
|
||||
// For update, no credit check needed (already consumed on create)
|
||||
$request->merge(['tenant' => $tenant]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
61
app/Http/Middleware/TenantIsolation.php
Normal file
61
app/Http/Middleware/TenantIsolation.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TenantIsolation
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if (!$tenantId) {
|
||||
return response()->json(['error' => 'Tenant ID not found in token'], 401);
|
||||
}
|
||||
|
||||
// Get the tenant from request (query param, route param, or header)
|
||||
$requestTenantId = $this->getTenantIdFromRequest($request);
|
||||
|
||||
if ($requestTenantId && $requestTenantId != $tenantId) {
|
||||
return response()->json(['error' => 'Tenant isolation violation'], 403);
|
||||
}
|
||||
|
||||
// Set tenant context for query scoping
|
||||
DB::statement("SET @tenant_id = ?", [$tenantId]);
|
||||
|
||||
// Add tenant context to request for easy access in controllers
|
||||
$request->attributes->set('current_tenant_id', $tenantId);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tenant ID from request
|
||||
*/
|
||||
private function getTenantIdFromRequest(Request $request): ?int
|
||||
{
|
||||
// 1. Route parameter (e.g., /api/v1/tenant/123/events)
|
||||
if ($request->route('tenant')) {
|
||||
return (int) $request->route('tenant');
|
||||
}
|
||||
|
||||
// 2. Query parameter (e.g., ?tenant_id=123)
|
||||
if ($request->query('tenant_id')) {
|
||||
return (int) $request->query('tenant_id');
|
||||
}
|
||||
|
||||
// 3. Header (X-Tenant-ID)
|
||||
if ($request->header('X-Tenant-ID')) {
|
||||
return (int) $request->header('X-Tenant-ID');
|
||||
}
|
||||
|
||||
// 4. For tenant-specific resources, use token tenant_id
|
||||
return null;
|
||||
}
|
||||
}
|
||||
162
app/Http/Middleware/TenantTokenGuard.php
Normal file
162
app/Http/Middleware/TenantTokenGuard.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\TenantToken;
|
||||
use Closure;
|
||||
use Firebase\JWT\Exceptions\TokenExpiredException;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantTokenGuard
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, ...$scopes)
|
||||
{
|
||||
$token = $this->getTokenFromRequest($request);
|
||||
|
||||
if (!$token) {
|
||||
return response()->json(['error' => 'Token not provided'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = $this->decodeToken($token);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => 'Invalid token'], 401);
|
||||
}
|
||||
|
||||
// Check token blacklist
|
||||
if ($this->isTokenBlacklisted($decoded)) {
|
||||
return response()->json(['error' => 'Token has been revoked'], 401);
|
||||
}
|
||||
|
||||
// Validate scopes if specified
|
||||
if (!empty($scopes) && !$this->hasScopes($decoded, $scopes)) {
|
||||
return response()->json(['error' => 'Insufficient scopes'], 403);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if ($decoded['exp'] < time()) {
|
||||
// Add to blacklist on expiry
|
||||
$this->blacklistToken($decoded);
|
||||
return response()->json(['error' => 'Token expired'], 401);
|
||||
}
|
||||
|
||||
// Set tenant ID on request
|
||||
$request->merge(['tenant_id' => $decoded['sub']]);
|
||||
$request->attributes->set('decoded_token', $decoded);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token from request (Bearer or header)
|
||||
*/
|
||||
private function getTokenFromRequest(Request $request): ?string
|
||||
{
|
||||
$header = $request->header('Authorization');
|
||||
|
||||
if (str_starts_with($header, 'Bearer ')) {
|
||||
return substr($header, 7);
|
||||
}
|
||||
|
||||
if ($request->header('X-API-Token')) {
|
||||
return $request->header('X-API-Token');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token
|
||||
*/
|
||||
private function decodeToken(string $token): array
|
||||
{
|
||||
$publicKey = file_get_contents(storage_path('app/public.key'));
|
||||
if (!$publicKey) {
|
||||
throw new \Exception('JWT public key not found');
|
||||
}
|
||||
|
||||
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
|
||||
return (array) $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is blacklisted
|
||||
*/
|
||||
private function isTokenBlacklisted(array $decoded): bool
|
||||
{
|
||||
$jti = isset($decoded['jti']) ? $decoded['jti'] : md5($decoded['sub'] . $decoded['iat']);
|
||||
$cacheKey = "blacklisted_token:{$jti}";
|
||||
|
||||
// Check cache first (faster)
|
||||
if (Cache::has($cacheKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check DB blacklist
|
||||
$dbJti = $decoded['jti'] ?? null;
|
||||
$blacklisted = TenantToken::where('jti', $dbJti)
|
||||
->orWhere('token_hash', md5($decoded['sub'] . $decoded['iat']))
|
||||
->where('expires_at', '>', now())
|
||||
->exists();
|
||||
|
||||
if ($blacklisted) {
|
||||
Cache::put($cacheKey, true, now()->addMinutes(5));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add token to blacklist
|
||||
*/
|
||||
private function blacklistToken(array $decoded): void
|
||||
{
|
||||
$jti = $decoded['jti'] ?? md5($decoded['sub'] . $decoded['iat']);
|
||||
$cacheKey = "blacklisted_token:{$jti}";
|
||||
|
||||
// Cache for immediate effect
|
||||
Cache::put($cacheKey, true, $decoded['exp'] - time());
|
||||
|
||||
// Store in DB for persistence
|
||||
TenantToken::updateOrCreate(
|
||||
[
|
||||
'jti' => $jti,
|
||||
'tenant_id' => $decoded['sub'],
|
||||
],
|
||||
[
|
||||
'token_hash' => md5(json_encode($decoded)),
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'expires_at' => now()->addHours(24), // Keep for 24h after expiry
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token has required scopes
|
||||
*/
|
||||
private function hasScopes(array $decoded, array $requiredScopes): bool
|
||||
{
|
||||
$tokenScopes = $decoded['scopes'] ?? [];
|
||||
|
||||
if (!is_array($tokenScopes)) {
|
||||
$tokenScopes = explode(' ', $tokenScopes);
|
||||
}
|
||||
|
||||
foreach ($requiredScopes as $scope) {
|
||||
if (!in_array($scope, $tokenScopes)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user