feat: implement tenant OAuth flow and guest achievements
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user