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

162 lines
4.4 KiB
PHP

<?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;
}
}