310 lines
8.8 KiB
PHP
310 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantToken;
|
|
use App\Support\ApiError;
|
|
use Closure;
|
|
use Firebase\JWT\JWT;
|
|
use Firebase\JWT\Key;
|
|
use Illuminate\Auth\GenericUser;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class TenantTokenGuard
|
|
{
|
|
private const LEGACY_KID = 'fotospiel-jwt';
|
|
|
|
/**
|
|
* Handle an incoming request.
|
|
*/
|
|
public function handle(Request $request, Closure $next, ...$scopes)
|
|
{
|
|
$token = $this->getTokenFromRequest($request);
|
|
|
|
if (! $token) {
|
|
return $this->errorResponse(
|
|
'token_missing',
|
|
'Token Missing',
|
|
'Authentication token not provided.',
|
|
Response::HTTP_UNAUTHORIZED
|
|
);
|
|
}
|
|
|
|
try {
|
|
$decoded = $this->decodeToken($token);
|
|
} catch (\Exception $e) {
|
|
return $this->errorResponse(
|
|
'token_invalid',
|
|
'Invalid Token',
|
|
'Authentication token cannot be decoded.',
|
|
Response::HTTP_UNAUTHORIZED
|
|
);
|
|
}
|
|
|
|
if ($this->isTokenBlacklisted($decoded)) {
|
|
return $this->errorResponse(
|
|
'token_revoked',
|
|
'Token Revoked',
|
|
'The provided token is no longer valid.',
|
|
Response::HTTP_UNAUTHORIZED,
|
|
['jti' => $decoded['jti'] ?? null]
|
|
);
|
|
}
|
|
|
|
if (! empty($scopes) && ! $this->hasScopes($decoded, $scopes)) {
|
|
return $this->errorResponse(
|
|
'token_scope_violation',
|
|
'Insufficient Scopes',
|
|
'The provided token does not include the required scopes.',
|
|
Response::HTTP_FORBIDDEN,
|
|
['required_scopes' => $scopes, 'token_scopes' => $decoded['scopes'] ?? []]
|
|
);
|
|
}
|
|
|
|
if (($decoded['exp'] ?? 0) < time()) {
|
|
$this->blacklistToken($decoded);
|
|
|
|
return $this->errorResponse(
|
|
'token_expired',
|
|
'Token Expired',
|
|
'Authentication token has expired.',
|
|
Response::HTTP_UNAUTHORIZED,
|
|
['expired_at' => $decoded['exp'] ?? null]
|
|
);
|
|
}
|
|
|
|
$tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
|
|
if (! $tenantId) {
|
|
return $this->errorResponse(
|
|
'token_payload_invalid',
|
|
'Invalid Token Payload',
|
|
'Authentication token does not include tenant context.',
|
|
Response::HTTP_UNAUTHORIZED
|
|
);
|
|
}
|
|
|
|
$tenant = Tenant::query()->find($tenantId);
|
|
if (! $tenant) {
|
|
return $this->errorResponse(
|
|
'tenant_not_found',
|
|
'Tenant Not Found',
|
|
'The tenant belonging to the token could not be located.',
|
|
Response::HTTP_NOT_FOUND,
|
|
['tenant_id' => $tenantId]
|
|
);
|
|
}
|
|
|
|
$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
|
|
{
|
|
$kid = $this->extractKid($token);
|
|
$publicKey = $this->loadPublicKeyForKid($kid);
|
|
|
|
if (! $publicKey) {
|
|
throw new \Exception('JWT public key not found');
|
|
}
|
|
|
|
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
|
|
|
|
return (array) $decoded;
|
|
}
|
|
|
|
private function extractKid(string $token): ?string
|
|
{
|
|
$segments = explode('.', $token);
|
|
if (count($segments) < 2) {
|
|
return null;
|
|
}
|
|
|
|
$decodedHeader = json_decode(base64_decode($segments[0]), true);
|
|
|
|
return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null;
|
|
}
|
|
|
|
private function loadPublicKeyForKid(?string $kid): ?string
|
|
{
|
|
$resolvedKid = $kid ?? config('oauth.keys.current_kid', self::LEGACY_KID);
|
|
$base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
|
$path = $base.DIRECTORY_SEPARATOR.$resolvedKid.DIRECTORY_SEPARATOR.'public.key';
|
|
|
|
if (File::exists($path)) {
|
|
return File::get($path);
|
|
}
|
|
|
|
$legacyPath = storage_path('app/public.key');
|
|
if (File::exists($legacyPath)) {
|
|
return File::get($legacyPath);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
private function errorResponse(string $code, string $title, string $message, int $status, array $meta = []): JsonResponse
|
|
{
|
|
return ApiError::response($code, $title, $message, $status, $meta);
|
|
}
|
|
}
|