Files
fotospiel-app/app/Http/Middleware/TenantTokenGuard.php

225 lines
6.1 KiB
PHP

<?php
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Models\TenantToken;
use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Auth\GenericUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
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);
}
if ($this->isTokenBlacklisted($decoded)) {
return response()->json(['error' => 'Token has been revoked'], 401);
}
if (! empty($scopes) && ! $this->hasScopes($decoded, $scopes)) {
return response()->json(['error' => 'Insufficient scopes'], 403);
}
if (($decoded['exp'] ?? 0) < time()) {
$this->blacklistToken($decoded);
return response()->json(['error' => 'Token expired'], 401);
}
$tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
if (! $tenantId) {
return response()->json(['error' => 'Invalid token payload'], 401);
}
$tenant = Tenant::query()->find($tenantId);
if (! $tenant) {
return response()->json(['error' => 'Tenant not found'], 404);
}
$scopesFromToken = $this->normaliseScopes($decoded['scopes'] ?? []);
$principal = new GenericUser([
'id' => $tenant->id,
'tenant_id' => $tenant->id,
'tenant' => $tenant,
'scopes' => $scopesFromToken,
'client_id' => $decoded['client_id'] ?? null,
'jti' => $decoded['jti'] ?? null,
'token_type' => $decoded['type'] ?? 'access',
]);
Auth::setUser($principal);
$request->setUserResolver(fn () => $principal);
$request->merge([
'tenant_id' => $tenant->id,
'tenant' => $tenant,
]);
$request->attributes->set('tenant_id', $tenant->id);
$request->attributes->set('tenant', $tenant);
$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 (is_string($header) && 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 = $decoded['jti'] ?? null;
if (! $jti) {
return false;
}
$cacheKey = "blacklisted_token:{$jti}";
if (Cache::has($cacheKey)) {
return true;
}
$tokenRecord = TenantToken::query()->where('jti', $jti)->first();
if (! $tokenRecord) {
return false;
}
if ($tokenRecord->revoked_at) {
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
return true;
}
if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) {
$tokenRecord->update(['revoked_at' => now()]);
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
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::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
$tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
$tokenType = $decoded['type'] ?? 'access';
$record = TenantToken::query()->where('jti', $jti)->first();
if ($record) {
$record->update([
'revoked_at' => now(),
'expires_at' => $record->expires_at ?? now(),
]);
return;
}
if ($tenantId === null) {
return;
}
TenantToken::create([
'id' => (string) Str::uuid(),
'tenant_id' => $tenantId,
'jti' => $jti,
'token_type' => $tokenType,
'expires_at' => now(),
'revoked_at' => now(),
]);
}
/**
* Check if token has required scopes
*/
private function hasScopes(array $decoded, array $requiredScopes): bool
{
$tokenScopes = $this->normaliseScopes($decoded['scopes'] ?? []);
foreach ($requiredScopes as $scope) {
if (! in_array($scope, $tokenScopes, true)) {
return false;
}
}
return true;
}
private function normaliseScopes(mixed $scopes): array
{
if (is_array($scopes)) {
return array_values(array_filter($scopes, fn ($scope) => $scope !== null && $scope !== ''));
}
if (is_string($scopes)) {
return array_values(array_filter(explode(' ', $scopes)));
}
return [];
}
private function cacheTtlFromDecoded(array $decoded): int
{
$exp = $decoded['exp'] ?? time();
$ttl = max($exp - time(), 60);
return $ttl;
}
}