feat: implement tenant OAuth flow and guest achievements

This commit is contained in:
2025-09-25 08:32:37 +02:00
parent ef6203c603
commit b22d91ed32
84 changed files with 5984 additions and 1399 deletions

View File

@@ -2,23 +2,18 @@
namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Tenant;
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);
$tenant = $this->resolveTenant($request);
if ($tenant->event_credits_balance < 1) {
return response()->json(['message' => 'Insufficient event credits'], 422);
@@ -30,10 +25,28 @@ class CreditCheckMiddleware
$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);
}
}
private function resolveTenant(Request $request): Tenant
{
$user = $request->user();
if ($user && isset($user->tenant) && $user->tenant instanceof Tenant) {
return $user->tenant;
}
$tenantId = $request->attributes->get('tenant_id');
if (! $tenantId && $user && isset($user->tenant_id)) {
$tenantId = $user->tenant_id;
}
if (! $tenantId) {
abort(401, 'Unauthenticated');
}
return Tenant::findOrFail($tenantId);
}
}

View File

@@ -27,8 +27,12 @@ class TenantIsolation
}
// Set tenant context for query scoping
DB::statement("SET @tenant_id = ?", [$tenantId]);
$connection = DB::connection();
if (in_array($connection->getDriverName(), ['mysql', 'mariadb'])) {
$connection->statement('SET @tenant_id = ?', [$tenantId]);
}
// Add tenant context to request for easy access in controllers
$request->attributes->set('current_tenant_id', $tenantId);
@@ -58,4 +62,4 @@ class TenantIsolation
// 4. For tenant-specific resources, use token tenant_id
return null;
}
}
}

View File

@@ -2,14 +2,16 @@
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Models\TenantToken;
use Closure;
use Firebase\JWT\Exceptions\TokenExpiredException;
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\Validation\ValidationException;
use Illuminate\Support\Str;
class TenantTokenGuard
{
@@ -20,7 +22,7 @@ class TenantTokenGuard
{
$token = $this->getTokenFromRequest($request);
if (!$token) {
if (! $token) {
return response()->json(['error' => 'Token not provided'], 401);
}
@@ -30,25 +32,46 @@ class TenantTokenGuard
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)) {
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
if (($decoded['exp'] ?? 0) < time()) {
$this->blacklistToken($decoded);
return response()->json(['error' => 'Token expired'], 401);
}
// Set tenant ID on request
$request->merge(['tenant_id' => $decoded['sub']]);
$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]);
$request->attributes->set('tenant_id', $tenant->id);
$request->attributes->set('decoded_token', $decoded);
return $next($request);
@@ -61,7 +84,7 @@ class TenantTokenGuard
{
$header = $request->header('Authorization');
if (str_starts_with($header, 'Bearer ')) {
if (is_string($header) && str_starts_with($header, 'Bearer ')) {
return substr($header, 7);
}
@@ -78,11 +101,12 @@ class TenantTokenGuard
private function decodeToken(string $token): array
{
$publicKey = file_get_contents(storage_path('app/public.key'));
if (!$publicKey) {
if (! $publicKey) {
throw new \Exception('JWT public key not found');
}
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
return (array) $decoded;
}
@@ -91,23 +115,29 @@ class TenantTokenGuard
*/
private function isTokenBlacklisted(array $decoded): bool
{
$jti = isset($decoded['jti']) ? $decoded['jti'] : md5($decoded['sub'] . $decoded['iat']);
$jti = $decoded['jti'] ?? null;
if (! $jti) {
return false;
}
$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();
$tokenRecord = TenantToken::query()->where('jti', $jti)->first();
if (! $tokenRecord) {
return false;
}
if ($blacklisted) {
Cache::put($cacheKey, true, now()->addMinutes(5));
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;
}
@@ -119,25 +149,35 @@ class TenantTokenGuard
*/
private function blacklistToken(array $decoded): void
{
$jti = $decoded['jti'] ?? md5($decoded['sub'] . $decoded['iat']);
$jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '') . ($decoded['iat'] ?? ''));
$cacheKey = "blacklisted_token:{$jti}";
// Cache for immediate effect
Cache::put($cacheKey, true, $decoded['exp'] - time());
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
// 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
]
);
$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(),
]);
}
/**
@@ -145,18 +185,36 @@ class TenantTokenGuard
*/
private function hasScopes(array $decoded, array $requiredScopes): bool
{
$tokenScopes = $decoded['scopes'] ?? [];
if (!is_array($tokenScopes)) {
$tokenScopes = explode(' ', $tokenScopes);
}
$tokenScopes = $this->normaliseScopes($decoded['scopes'] ?? []);
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $tokenScopes)) {
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;
}
}