feat: implement tenant OAuth flow and guest achievements
This commit is contained in:
@@ -279,7 +279,6 @@ class EventPublicController extends BaseController
|
||||
'photos.likes_count',
|
||||
'photos.emotion_id',
|
||||
'photos.task_id',
|
||||
'photos.created_at',
|
||||
'photos.guest_name',
|
||||
'tasks.title as task_title'
|
||||
])
|
||||
@@ -503,4 +502,220 @@ class EventPublicController extends BaseController
|
||||
|
||||
return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists)
|
||||
}
|
||||
public function achievements(Request $request, string $slug)
|
||||
{
|
||||
$event = DB::table('events')->where('slug', $slug)->where('status', 'published')->first(['id']);
|
||||
if (! $event) {
|
||||
return response()->json(['error' => ['code' => 'not_found', 'message' => 'Event not found or not public']], 404);
|
||||
}
|
||||
|
||||
$eventId = $event->id;
|
||||
$locale = $request->query('locale', 'de');
|
||||
|
||||
$totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count();
|
||||
$uniqueGuests = (int) DB::table('photos')->where('event_id', $eventId)->distinct('guest_name')->count('guest_name');
|
||||
$tasksSolved = (int) DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count();
|
||||
$likesTotal = (int) DB::table('photos')->where('event_id', $eventId)->sum('likes_count');
|
||||
|
||||
$summary = [
|
||||
'total_photos' => $totalPhotos,
|
||||
'unique_guests' => $uniqueGuests,
|
||||
'tasks_solved' => $tasksSolved,
|
||||
'likes_total' => $likesTotal,
|
||||
];
|
||||
|
||||
$guestNameParam = trim((string) $request->query('guest_name', ''));
|
||||
$deviceIdHeader = (string) $request->headers->get('X-Device-Id', '');
|
||||
$deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceIdHeader), 0, 120);
|
||||
$candidate = $guestNameParam !== '' ? $guestNameParam : $deviceId;
|
||||
$guestIdentifier = $candidate !== '' ? substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $candidate), 0, 120) : null;
|
||||
|
||||
$personal = null;
|
||||
if ($guestIdentifier) {
|
||||
$personalPhotos = (int) DB::table('photos')
|
||||
->where('event_id', $eventId)
|
||||
->where('guest_name', $guestIdentifier)
|
||||
->count();
|
||||
|
||||
$personalTasks = (int) DB::table('photos')
|
||||
->where('event_id', $eventId)
|
||||
->where('guest_name', $guestIdentifier)
|
||||
->whereNotNull('task_id')
|
||||
->distinct('task_id')
|
||||
->count('task_id');
|
||||
|
||||
$personalLikes = (int) DB::table('photos')
|
||||
->where('event_id', $eventId)
|
||||
->where('guest_name', $guestIdentifier)
|
||||
->sum('likes_count');
|
||||
|
||||
$badgeBlueprints = [
|
||||
['id' => 'first_photo', 'title' => 'Erstes Foto', 'description' => 'Lade dein erstes Foto hoch.', 'target' => 1, 'metric' => 'photos'],
|
||||
['id' => 'five_photos', 'title' => 'Fotoflair', 'description' => 'Fuenf Fotos hochgeladen.', 'target' => 5, 'metric' => 'photos'],
|
||||
['id' => 'first_task', 'title' => 'Aufgabenstarter', 'description' => 'Erste Aufgabe erfuellt.', 'target' => 1, 'metric' => 'tasks'],
|
||||
['id' => 'task_master', 'title' => 'Aufgabenmeister', 'description' => 'Fuenf Aufgaben erfuellt.', 'target' => 5, 'metric' => 'tasks'],
|
||||
['id' => 'crowd_pleaser', 'title' => 'Publikumsliebling', 'description' => 'Sammle zwanzig Likes.', 'target' => 20, 'metric' => 'likes'],
|
||||
];
|
||||
|
||||
$personalBadges = [];
|
||||
foreach ($badgeBlueprints as $badge) {
|
||||
$value = 0;
|
||||
if ($badge['metric'] === 'photos') {
|
||||
$value = $personalPhotos;
|
||||
} elseif ($badge['metric'] === 'tasks') {
|
||||
$value = $personalTasks;
|
||||
} else {
|
||||
$value = $personalLikes;
|
||||
}
|
||||
|
||||
$personalBadges[] = [
|
||||
'id' => $badge['id'],
|
||||
'title' => $badge['title'],
|
||||
'description' => $badge['description'],
|
||||
'earned' => $value >= $badge['target'],
|
||||
'progress' => $value,
|
||||
'target' => $badge['target'],
|
||||
];
|
||||
}
|
||||
|
||||
$personal = [
|
||||
'guest_name' => $guestIdentifier,
|
||||
'photos' => $personalPhotos,
|
||||
'tasks' => $personalTasks,
|
||||
'likes' => $personalLikes,
|
||||
'badges' => $personalBadges,
|
||||
];
|
||||
}
|
||||
|
||||
$topUploads = DB::table('photos')
|
||||
->select('guest_name', DB::raw('COUNT(*) as total'), DB::raw('SUM(likes_count) as likes'))
|
||||
->where('event_id', $eventId)
|
||||
->groupBy('guest_name')
|
||||
->orderByDesc('total')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'guest' => $row->guest_name,
|
||||
'photos' => (int) $row->total,
|
||||
'likes' => (int) $row->likes,
|
||||
])
|
||||
->values();
|
||||
|
||||
$topLikes = DB::table('photos')
|
||||
->select('guest_name', DB::raw('SUM(likes_count) as likes'), DB::raw('COUNT(*) as total'))
|
||||
->where('event_id', $eventId)
|
||||
->groupBy('guest_name')
|
||||
->orderByDesc('likes')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'guest' => $row->guest_name,
|
||||
'likes' => (int) $row->likes,
|
||||
'photos' => (int) $row->total,
|
||||
])
|
||||
->values();
|
||||
|
||||
$topPhotoRow = DB::table('photos')
|
||||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||
->where('photos.event_id', $eventId)
|
||||
->orderByDesc('photos.likes_count')
|
||||
->orderByDesc('photos.created_at')
|
||||
->select([
|
||||
'photos.id',
|
||||
'photos.guest_name',
|
||||
'photos.likes_count',
|
||||
'photos.file_path',
|
||||
'photos.thumbnail_path',
|
||||
'photos.created_at',
|
||||
'tasks.title as task_title',
|
||||
])
|
||||
->first();
|
||||
|
||||
$topPhoto = $topPhotoRow ? [
|
||||
'photo_id' => (int) $topPhotoRow->id,
|
||||
'guest' => $topPhotoRow->guest_name,
|
||||
'likes' => (int) $topPhotoRow->likes_count,
|
||||
'task' => $topPhotoRow->task_title,
|
||||
'created_at' => $topPhotoRow->created_at,
|
||||
'thumbnail' => $this->toPublicUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path),
|
||||
] : null;
|
||||
|
||||
$trendingEmotionRow = DB::table('photos')
|
||||
->join('emotions', 'photos.emotion_id', '=', 'emotions.id')
|
||||
->where('photos.event_id', $eventId)
|
||||
->select('emotions.id', 'emotions.name', DB::raw('COUNT(*) as total'))
|
||||
->groupBy('emotions.id', 'emotions.name')
|
||||
->orderByDesc('total')
|
||||
->first();
|
||||
|
||||
$trendingEmotion = $trendingEmotionRow ? [
|
||||
'emotion_id' => (int) $trendingEmotionRow->id,
|
||||
'name' => $this->getLocalized($trendingEmotionRow->name, $locale, $trendingEmotionRow->name),
|
||||
'count' => (int) $trendingEmotionRow->total,
|
||||
] : null;
|
||||
|
||||
$timeline = DB::table('photos')
|
||||
->where('event_id', $eventId)
|
||||
->selectRaw('DATE(created_at) as day, COUNT(*) as photos, COUNT(DISTINCT guest_name) as guests')
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->limit(14)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'date' => $row->day,
|
||||
'photos' => (int) $row->photos,
|
||||
'guests' => (int) $row->guests,
|
||||
])
|
||||
->values();
|
||||
|
||||
$feed = DB::table('photos')
|
||||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||
->where('photos.event_id', $eventId)
|
||||
->orderByDesc('photos.created_at')
|
||||
->limit(12)
|
||||
->get([
|
||||
'photos.id',
|
||||
'photos.guest_name',
|
||||
'photos.thumbnail_path',
|
||||
'photos.file_path',
|
||||
'photos.likes_count',
|
||||
'photos.created_at',
|
||||
'tasks.title as task_title',
|
||||
])
|
||||
->map(fn ($row) => [
|
||||
'photo_id' => (int) $row->id,
|
||||
'guest' => $row->guest_name,
|
||||
'task' => $row->task_title,
|
||||
'likes' => (int) $row->likes_count,
|
||||
'created_at' => $row->created_at,
|
||||
'thumbnail' => $this->toPublicUrl($row->thumbnail_path ?: $row->file_path),
|
||||
])
|
||||
->values();
|
||||
|
||||
$payload = [
|
||||
'summary' => $summary,
|
||||
'personal' => $personal,
|
||||
'leaderboards' => [
|
||||
'uploads' => $topUploads,
|
||||
'likes' => $topLikes,
|
||||
],
|
||||
'highlights' => [
|
||||
'top_photo' => $topPhoto,
|
||||
'trending_emotion' => $trendingEmotion,
|
||||
'timeline' => $timeline,
|
||||
],
|
||||
'feed' => $feed,
|
||||
];
|
||||
|
||||
$etag = sha1(json_encode($payload));
|
||||
$ifNoneMatch = $request->headers->get('If-None-Match');
|
||||
if ($ifNoneMatch && $ifNoneMatch === $etag) {
|
||||
return response('', 304);
|
||||
}
|
||||
|
||||
return response()->json($payload)
|
||||
->header('Cache-Control', 'no-store')
|
||||
->header('ETag', $etag);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ class PhotoController extends Controller
|
||||
'width' => null, // To be filled by image processing
|
||||
'height' => null,
|
||||
'status' => 'pending', // Requires moderation
|
||||
'uploader_id' => $request->user()->id ?? null,
|
||||
'uploader_id' => null,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
@@ -170,8 +170,8 @@ class PhotoController extends Controller
|
||||
]);
|
||||
|
||||
// Only tenant admins can moderate
|
||||
if (isset($validated['status']) && $request->user()->role !== 'admin') {
|
||||
return response()->json(['error' => 'Insufficient permissions for moderation'], 403);
|
||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
|
||||
return response()->json(['error' => 'Insufficient scopes'], 403);
|
||||
}
|
||||
|
||||
$photo->update($validated);
|
||||
@@ -243,7 +243,7 @@ class PhotoController extends Controller
|
||||
'status' => 'approved',
|
||||
'moderation_notes' => $request->moderation_notes,
|
||||
'moderated_at' => now(),
|
||||
'moderated_by' => $request->user()->id,
|
||||
'moderated_by' => null,
|
||||
]);
|
||||
|
||||
// Load approved photos for response
|
||||
@@ -288,7 +288,7 @@ class PhotoController extends Controller
|
||||
'status' => 'rejected',
|
||||
'moderation_notes' => $request->moderation_notes,
|
||||
'moderated_at' => now(),
|
||||
'moderated_by' => $request->user()->id,
|
||||
'moderated_by' => null,
|
||||
]);
|
||||
|
||||
// Optionally delete rejected photos from storage
|
||||
@@ -369,6 +369,17 @@ class PhotoController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function tokenHasScope(Request $request, string $scope): bool
|
||||
{
|
||||
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
|
||||
|
||||
if (! is_array($scopes)) {
|
||||
$scopes = array_values(array_filter(explode(' ', (string) $scopes)));
|
||||
}
|
||||
|
||||
return in_array($scope, $scopes, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate presigned S3 URL for direct upload (alternative to local storage)
|
||||
*/
|
||||
@@ -467,4 +478,10 @@ class PhotoController extends Controller
|
||||
'status' => 'pending',
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,18 +8,22 @@ use App\Models\RefreshToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantToken;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OAuthController extends Controller
|
||||
{
|
||||
private const AUTH_CODE_TTL_MINUTES = 5;
|
||||
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
||||
private const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||
private const TOKEN_HEADER_KID = 'fotospiel-jwt';
|
||||
|
||||
/**
|
||||
* Authorize endpoint - PKCE flow
|
||||
*/
|
||||
@@ -28,6 +32,7 @@ class OAuthController extends Controller
|
||||
$validator = Validator::make($request->all(), [
|
||||
'client_id' => 'required|string',
|
||||
'redirect_uri' => 'required|url',
|
||||
'response_type' => 'required|in:code',
|
||||
'scope' => 'required|string',
|
||||
'state' => 'nullable|string',
|
||||
'code_challenge' => 'required|string',
|
||||
@@ -38,244 +43,425 @@ class OAuthController extends Controller
|
||||
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
|
||||
}
|
||||
|
||||
$client = OAuthClient::where('client_id', $request->client_id)
|
||||
/** @var OAuthClient|null $client */
|
||||
$client = OAuthClient::query()
|
||||
->where('client_id', $request->string('client_id'))
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$client) {
|
||||
if (! $client) {
|
||||
return $this->errorResponse('Invalid client', 401);
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
$redirectUris = is_array($client->redirect_uris) ? $client->redirect_uris : json_decode($client->redirect_uris, true);
|
||||
if (!in_array($request->redirect_uri, $redirectUris)) {
|
||||
$allowedRedirects = (array) $client->redirect_uris;
|
||||
if (! in_array($request->redirect_uri, $allowedRedirects, true)) {
|
||||
return $this->errorResponse('Invalid redirect URI', 400);
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
$requestedScopes = explode(' ', $request->scope);
|
||||
$availableScopes = is_array($client->scopes) ? array_keys($client->scopes) : [];
|
||||
$validScopes = array_intersect($requestedScopes, $availableScopes);
|
||||
|
||||
if (count($validScopes) !== count($requestedScopes)) {
|
||||
$requestedScopes = $this->parseScopes($request->string('scope'));
|
||||
$availableScopes = (array) $client->scopes;
|
||||
if (! $this->scopesAreAllowed($requestedScopes, $availableScopes)) {
|
||||
return $this->errorResponse('Invalid scopes requested', 400);
|
||||
}
|
||||
|
||||
// Generate authorization code (PKCE validated later)
|
||||
$code = Str::random(40);
|
||||
$codeId = Str::uuid();
|
||||
$tenantId = $client->tenant_id ?? Tenant::query()->orderBy('id')->value('id');
|
||||
if (! $tenantId) {
|
||||
return $this->errorResponse('Unable to resolve tenant for client', 500);
|
||||
}
|
||||
|
||||
// Store code in Redis with 5min TTL
|
||||
Cache::put("oauth_code:{$codeId}", [
|
||||
'code' => $code,
|
||||
'client_id' => $request->client_id,
|
||||
$code = Str::random(64);
|
||||
$codeId = (string) Str::uuid();
|
||||
$expiresAt = now()->addMinutes(self::AUTH_CODE_TTL_MINUTES);
|
||||
$cacheKey = $this->cacheKeyForCode($code);
|
||||
|
||||
Cache::put($cacheKey, [
|
||||
'id' => $codeId,
|
||||
'client_id' => $client->client_id,
|
||||
'tenant_id' => $tenantId,
|
||||
'redirect_uri' => $request->redirect_uri,
|
||||
'scopes' => $validScopes,
|
||||
'state' => $request->state ?? null,
|
||||
'scopes' => $requestedScopes,
|
||||
'state' => $request->state,
|
||||
'code_challenge' => $request->code_challenge,
|
||||
'code_challenge_method' => $request->code_challenge_method,
|
||||
'expires_at' => now()->addMinutes(5),
|
||||
], now()->addMinutes(5));
|
||||
'expires_at' => $expiresAt,
|
||||
], $expiresAt);
|
||||
|
||||
// Save to DB for persistence
|
||||
OAuthCode::create([
|
||||
'id' => $codeId,
|
||||
'client_id' => $client->client_id,
|
||||
'user_id' => (string) $tenantId,
|
||||
'code' => Hash::make($code),
|
||||
'client_id' => $request->client_id,
|
||||
'scopes' => json_encode($validScopes),
|
||||
'state' => $request->state ?? null,
|
||||
'expires_at' => now()->addMinutes(5),
|
||||
'code_challenge' => $request->code_challenge,
|
||||
'state' => $request->state,
|
||||
'redirect_uri' => $request->redirect_uri,
|
||||
'scope' => implode(' ', $requestedScopes),
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
$redirectUrl = $request->redirect_uri . '?' . http_build_query([
|
||||
$redirectUrl = $request->redirect_uri.'?'.http_build_query([
|
||||
'code' => $code,
|
||||
'state' => $request->state ?? '',
|
||||
'state' => $request->state,
|
||||
]);
|
||||
|
||||
return redirect($redirectUrl);
|
||||
return redirect()->away($redirectUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token endpoint - Code exchange for access/refresh tokens
|
||||
* Token endpoint - Code exchange & refresh
|
||||
*/
|
||||
public function token(Request $request)
|
||||
{
|
||||
$grantType = (string) $request->string('grant_type');
|
||||
|
||||
if ($grantType === 'authorization_code') {
|
||||
return $this->handleAuthorizationCodeGrant($request);
|
||||
}
|
||||
|
||||
if ($grantType === 'refresh_token') {
|
||||
return $this->handleRefreshTokenGrant($request);
|
||||
}
|
||||
|
||||
return $this->errorResponse('Unsupported grant type', 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant info based on decoded token
|
||||
*/
|
||||
public function me(Request $request)
|
||||
{
|
||||
$decoded = $request->attributes->get('decoded_token');
|
||||
$tenantId = Arr::get($decoded, 'tenant_id');
|
||||
|
||||
if (! $tenantId) {
|
||||
return $this->errorResponse('Unauthenticated', 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
if (! $tenant) {
|
||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||
'client_id' => $request->client_id,
|
||||
'code_id' => $cachedCode['id'] ?? null,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'id' => $tenant->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $tenant->name,
|
||||
'slug' => $tenant->slug,
|
||||
'email' => $tenant->contact_email,
|
||||
'event_credits_balance' => $tenant->event_credits_balance,
|
||||
'subscription_tier' => $tenant->subscription_tier,
|
||||
'subscription_expires_at' => $tenant->subscription_expires_at,
|
||||
'features' => $tenant->features,
|
||||
'scopes' => Arr::get($decoded, 'scopes', []),
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleAuthorizationCodeGrant(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'grant_type' => 'required|in:authorization_code',
|
||||
'code' => 'required|string',
|
||||
'client_id' => 'required|string',
|
||||
'redirect_uri' => 'required|url',
|
||||
'code_verifier' => 'required|string', // PKCE
|
||||
'code_verifier' => 'required|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
|
||||
}
|
||||
|
||||
// Find the code in cache/DB
|
||||
$cachedCode = Cache::get($request->code);
|
||||
if (!$cachedCode) {
|
||||
$cacheKey = $this->cacheKeyForCode($request->code);
|
||||
$cachedCode = Cache::get($cacheKey);
|
||||
if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) {
|
||||
Log::warning('[OAuth] Authorization code missing or expired', [
|
||||
'client_id' => $request->client_id,
|
||||
'code_id' => $cachedCode['id'] ?? null,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Invalid or expired authorization code', 400);
|
||||
}
|
||||
|
||||
/** @var OAuthCode|null $oauthCode */
|
||||
$oauthCode = OAuthCode::query()->find($cachedCode['id']);
|
||||
if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) {
|
||||
Log::warning('[OAuth] Authorization code validation failed', [
|
||||
'client_id' => $request->client_id,
|
||||
'code_id' => $cachedCode['id'] ?? null,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Invalid authorization code', 400);
|
||||
}
|
||||
|
||||
$codeId = array_search($request->code, Cache::get($request->code)['code'] ?? []);
|
||||
if (!$codeId) {
|
||||
$oauthCode = OAuthCode::where('code', 'LIKE', '%' . $request->code . '%')->first();
|
||||
if (!$oauthCode || !Hash::check($request->code, $oauthCode->code)) {
|
||||
return $this->errorResponse('Invalid authorization code', 400);
|
||||
}
|
||||
$cachedCode = $oauthCode->toArray();
|
||||
$codeId = $oauthCode->id;
|
||||
}
|
||||
|
||||
// Validate client
|
||||
$client = OAuthClient::where('client_id', $request->client_id)->first();
|
||||
if (!$client || !$client->is_active) {
|
||||
/** @var OAuthClient|null $client */
|
||||
$client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first();
|
||||
if (! $client) {
|
||||
return $this->errorResponse('Invalid client', 401);
|
||||
}
|
||||
|
||||
// PKCE validation
|
||||
if ($cachedCode['code_challenge_method'] === 'S256') {
|
||||
$expectedChallenge = $this->base64url_encode(hash('sha256', $request->code_verifier, true));
|
||||
if (!hash_equals($expectedChallenge, $cachedCode['code_challenge'])) {
|
||||
return $this->errorResponse('Invalid code verifier', 400);
|
||||
}
|
||||
} else {
|
||||
if (!hash_equals($request->code_verifier, $cachedCode['code'])) {
|
||||
return $this->errorResponse('Invalid code verifier', 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate redirect URI
|
||||
if ($request->redirect_uri !== $cachedCode['redirect_uri']) {
|
||||
if ($request->redirect_uri !== Arr::get($cachedCode, 'redirect_uri')) {
|
||||
return $this->errorResponse('Invalid redirect URI', 400);
|
||||
}
|
||||
|
||||
// Code used - delete/invalidate
|
||||
Cache::forget("oauth_code:{$codeId}");
|
||||
OAuthCode::where('id', $codeId)->delete();
|
||||
$codeChallengeMethod = Arr::get($cachedCode, 'code_challenge_method', 'S256');
|
||||
$expectedChallenge = $codeChallengeMethod === 'S256'
|
||||
? $this->base64urlEncode(hash('sha256', $request->code_verifier, true))
|
||||
: $request->code_verifier;
|
||||
|
||||
if (! hash_equals($expectedChallenge, Arr::get($cachedCode, 'code_challenge'))) {
|
||||
return $this->errorResponse('Invalid code verifier', 400);
|
||||
}
|
||||
|
||||
$tenantId = Arr::get($cachedCode, 'tenant_id') ?? $client->tenant_id;
|
||||
$tenant = $tenantId ? Tenant::query()->find($tenantId) : null;
|
||||
if (! $tenant) {
|
||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||
'client_id' => $request->client_id,
|
||||
'code_id' => $cachedCode['id'] ?? null,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
// Create tenant token (assuming client is tied to tenant - adjust as needed)
|
||||
$tenant = Tenant::where('id', $client->tenant_id ?? 1)->first(); // Default tenant or from client
|
||||
if (!$tenant) {
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
// Generate JWT Access Token (RS256)
|
||||
$privateKey = file_get_contents(storage_path('app/private.key')); // Ensure keys exist
|
||||
if (!$privateKey) {
|
||||
$this->generateKeyPair();
|
||||
$privateKey = file_get_contents(storage_path('app/private.key'));
|
||||
$scopes = Arr::get($cachedCode, 'scopes', []);
|
||||
if (empty($scopes)) {
|
||||
$scopes = $this->parseScopes($oauthCode->scope);
|
||||
}
|
||||
|
||||
$accessToken = $this->generateJWT($tenant->id, $cachedCode['scopes'], 'access', 3600); // 1h
|
||||
$refreshTokenId = Str::uuid();
|
||||
$refreshToken = Str::random(60);
|
||||
Cache::forget($cacheKey);
|
||||
$oauthCode->delete();
|
||||
|
||||
// Store refresh token
|
||||
RefreshToken::create([
|
||||
'id' => $refreshTokenId,
|
||||
'token' => Hash::make($refreshToken),
|
||||
'tenant_id' => $tenant->id,
|
||||
'client_id' => $request->client_id,
|
||||
'scopes' => json_encode($cachedCode['scopes']),
|
||||
'expires_at' => now()->addDays(30),
|
||||
'ip_address' => $request->ip(),
|
||||
$tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request);
|
||||
|
||||
return response()->json($tokenResponse);
|
||||
}
|
||||
|
||||
private function handleRefreshTokenGrant(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'grant_type' => 'required|in:refresh_token',
|
||||
'refresh_token' => 'required|string',
|
||||
'client_id' => 'required|string',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
if ($validator->fails()) {
|
||||
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
|
||||
}
|
||||
|
||||
$tokenParts = explode('|', $request->refresh_token, 2);
|
||||
if (count($tokenParts) !== 2) {
|
||||
return $this->errorResponse('Malformed refresh token', 400);
|
||||
}
|
||||
|
||||
[$refreshTokenId, $refreshTokenSecret] = $tokenParts;
|
||||
|
||||
/** @var RefreshToken|null $storedRefreshToken */
|
||||
$storedRefreshToken = RefreshToken::query()
|
||||
->where('id', $refreshTokenId)
|
||||
->whereNull('revoked_at')
|
||||
->first();
|
||||
|
||||
if (! $storedRefreshToken) {
|
||||
return $this->errorResponse('Invalid refresh token', 400);
|
||||
}
|
||||
|
||||
if ($storedRefreshToken->client_id && $storedRefreshToken->client_id !== $request->client_id) {
|
||||
return $this->errorResponse('Refresh token does not match client', 400);
|
||||
}
|
||||
|
||||
if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) {
|
||||
$storedRefreshToken->update(['revoked_at' => now()]);
|
||||
return $this->errorResponse('Refresh token expired', 400);
|
||||
}
|
||||
|
||||
if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) {
|
||||
return $this->errorResponse('Invalid refresh token', 400);
|
||||
}
|
||||
|
||||
$client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first();
|
||||
if (! $client) {
|
||||
return $this->errorResponse('Invalid client', 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->find($storedRefreshToken->tenant_id);
|
||||
if (! $tenant) {
|
||||
Log::error('[OAuth] Tenant not found during token issuance', [
|
||||
'client_id' => $request->client_id,
|
||||
'code_id' => $cachedCode['id'] ?? null,
|
||||
'tenant_id' => $storedRefreshToken->tenant_id,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
$scopes = $this->parseScopes($storedRefreshToken->scope);
|
||||
|
||||
$storedRefreshToken->update(['revoked_at' => now()]);
|
||||
|
||||
$tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request);
|
||||
|
||||
return response()->json($tokenResponse);
|
||||
}
|
||||
|
||||
private function issueTokenPair(Tenant $tenant, OAuthClient $client, array $scopes, Request $request): array
|
||||
{
|
||||
$scopes = array_values(array_unique($scopes));
|
||||
$expiresIn = self::ACCESS_TOKEN_TTL_SECONDS;
|
||||
$issuedAt = now();
|
||||
$jti = (string) Str::uuid();
|
||||
$expiresAt = $issuedAt->copy()->addSeconds($expiresIn);
|
||||
|
||||
$accessToken = $this->generateJWT(
|
||||
$tenant->id,
|
||||
$client->client_id,
|
||||
$scopes,
|
||||
'access',
|
||||
$expiresIn,
|
||||
$jti,
|
||||
$issuedAt->timestamp,
|
||||
$expiresAt->timestamp
|
||||
);
|
||||
|
||||
TenantToken::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'tenant_id' => $tenant->id,
|
||||
'jti' => $jti,
|
||||
'token_type' => 'access',
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
$refreshToken = $this->createRefreshToken($tenant, $client, $scopes, $jti, $request);
|
||||
|
||||
return [
|
||||
'token_type' => 'Bearer',
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_in' => 3600,
|
||||
'scope' => implode(' ', $cachedCode['scopes']),
|
||||
]);
|
||||
'expires_in' => $expiresIn,
|
||||
'scope' => implode(' ', $scopes),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant info
|
||||
*/
|
||||
public function me(Request $request)
|
||||
private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return $this->errorResponse('Unauthenticated', 401);
|
||||
}
|
||||
$refreshTokenId = (string) Str::uuid();
|
||||
$secret = Str::random(64);
|
||||
$composite = $refreshTokenId.'|'.$secret;
|
||||
$expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS);
|
||||
|
||||
$tenant = $user->tenant;
|
||||
if (!$tenant) {
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
RefreshToken::create([
|
||||
'id' => $refreshTokenId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $tenant->name,
|
||||
'slug' => $tenant->slug,
|
||||
'event_credits_balance' => $tenant->event_credits_balance,
|
||||
'subscription_tier' => $tenant->subscription_tier,
|
||||
'subscription_expires_at' => $tenant->subscription_expires_at,
|
||||
'features' => $tenant->features,
|
||||
'scopes' => $request->user()->token()->abilities,
|
||||
'client_id' => $client->client_id,
|
||||
'token' => Hash::make($secret),
|
||||
'access_token' => $accessTokenJti,
|
||||
'expires_at' => $expiresAt,
|
||||
'scope' => implode(' ', $scopes),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
return $composite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT token
|
||||
*/
|
||||
private function generateJWT($tenantId, $scopes, $type, $expiresIn)
|
||||
{
|
||||
private function generateJWT(
|
||||
int $tenantId,
|
||||
string $clientId,
|
||||
array $scopes,
|
||||
string $type,
|
||||
int $expiresIn,
|
||||
string $jti,
|
||||
int $issuedAt,
|
||||
int $expiresAt
|
||||
): string {
|
||||
[$publicKey, $privateKey] = $this->ensureKeysExist();
|
||||
|
||||
$payload = [
|
||||
'iss' => url('/'),
|
||||
'aud' => 'fotospiel-api',
|
||||
'iat' => time(),
|
||||
'nbf' => time(),
|
||||
'exp' => time() + $expiresIn,
|
||||
'aud' => $clientId,
|
||||
'iat' => $issuedAt,
|
||||
'nbf' => $issuedAt,
|
||||
'exp' => $expiresAt,
|
||||
'sub' => $tenantId,
|
||||
'tenant_id' => $tenantId,
|
||||
'scopes' => $scopes,
|
||||
'type' => $type,
|
||||
'client_id' => $clientId,
|
||||
'jti' => $jti,
|
||||
];
|
||||
|
||||
$publicKey = file_get_contents(storage_path('app/public.key'));
|
||||
$privateKey = file_get_contents(storage_path('app/private.key'));
|
||||
|
||||
if (!$publicKey || !$privateKey) {
|
||||
$this->generateKeyPair();
|
||||
$publicKey = file_get_contents(storage_path('app/public.key'));
|
||||
$privateKey = file_get_contents(storage_path('app/private.key'));
|
||||
}
|
||||
|
||||
return JWT::encode($payload, $privateKey, 'RS256', null, null, null, ['kid' => 'fotospiel-jwt']);
|
||||
return JWT::encode($payload, $privateKey, 'RS256', self::TOKEN_HEADER_KID, ['kid' => self::TOKEN_HEADER_KID]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate RSA key pair for JWT
|
||||
*/
|
||||
private function generateKeyPair()
|
||||
private function ensureKeysExist(): array
|
||||
{
|
||||
$publicKeyPath = storage_path('app/public.key');
|
||||
$privateKeyPath = storage_path('app/private.key');
|
||||
|
||||
$publicKey = @file_get_contents($publicKeyPath);
|
||||
$privateKey = @file_get_contents($privateKeyPath);
|
||||
|
||||
if ($publicKey && $privateKey) {
|
||||
return [$publicKey, $privateKey];
|
||||
}
|
||||
|
||||
$this->generateKeyPair();
|
||||
|
||||
return [
|
||||
file_get_contents($publicKeyPath),
|
||||
file_get_contents($privateKeyPath),
|
||||
];
|
||||
}
|
||||
|
||||
private function generateKeyPair(): void
|
||||
{
|
||||
$config = [
|
||||
"digest_alg" => OPENSSL_ALGO_SHA256,
|
||||
"private_key_bits" => 2048,
|
||||
"private_key_type" => OPENSSL_KEYTYPE_RSA,
|
||||
'digest_alg' => OPENSSL_ALGO_SHA256,
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$res = openssl_pkey_new($config);
|
||||
if (!$res) {
|
||||
throw new \Exception('Failed to generate key pair');
|
||||
if (! $res) {
|
||||
throw new \RuntimeException('Failed to generate key pair');
|
||||
}
|
||||
|
||||
// Get private key
|
||||
openssl_pkey_export($res, $privateKey);
|
||||
file_put_contents(storage_path('app/private.key'), $privateKey);
|
||||
|
||||
// Get public key
|
||||
openssl_pkey_export($res, $privKey);
|
||||
$pubKey = openssl_pkey_get_details($res);
|
||||
file_put_contents(storage_path('app/public.key'), $pubKey["key"]);
|
||||
|
||||
file_put_contents(storage_path('app/private.key'), $privKey);
|
||||
file_put_contents(storage_path('app/public.key'), $pubKey['key']);
|
||||
}
|
||||
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
|
||||
{
|
||||
if (empty($requestedScopes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$available = array_flip($availableScopes);
|
||||
foreach ($requestedScopes as $scope) {
|
||||
if (! isset($available[$scope])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response helper
|
||||
*/
|
||||
private function errorResponse($message, $status = 400, $errors = null)
|
||||
private function parseScopes(?string $scopeString): array
|
||||
{
|
||||
if (! $scopeString) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(explode(' ', trim($scopeString))));
|
||||
}
|
||||
|
||||
private function errorResponse(string $message, int $status = 400, $errors = null)
|
||||
{
|
||||
$response = ['error' => $message];
|
||||
if ($errors) {
|
||||
@@ -285,21 +471,23 @@ class OAuthController extends Controller
|
||||
return response()->json($response, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for base64url encoding
|
||||
*/
|
||||
private function base64url_encode($data)
|
||||
private function base64urlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function cacheKeyForCode(string $code): string
|
||||
{
|
||||
return 'oauth:code:'.hash('sha256', $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe Connect OAuth - Start connection
|
||||
*/
|
||||
public function stripeConnect(Request $request)
|
||||
{
|
||||
$tenant = $request->user()->tenant;
|
||||
if (!$tenant) {
|
||||
$tenant = $request->user()->tenant ?? null;
|
||||
if (! $tenant) {
|
||||
return response()->json(['error' => 'Tenant not found'], 404);
|
||||
}
|
||||
|
||||
@@ -311,7 +499,7 @@ class OAuthController extends Controller
|
||||
$redirectUri = url('/api/v1/oauth/stripe-callback');
|
||||
$scopes = 'read_write_payments transfers';
|
||||
|
||||
$authUrl = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$clientId}&scope={$scopes}&state={$state}&redirect_uri=" . urlencode($redirectUri);
|
||||
$authUrl = "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$clientId}&scope={$scopes}&state={$state}&redirect_uri=".urlencode($redirectUri);
|
||||
|
||||
return redirect($authUrl);
|
||||
}
|
||||
@@ -326,16 +514,15 @@ class OAuthController extends Controller
|
||||
$error = $request->get('error');
|
||||
|
||||
if ($error) {
|
||||
return redirect('/admin')->with('error', 'Stripe connection failed: ' . $error);
|
||||
return redirect('/admin')->with('error', 'Stripe connection failed: '.$error);
|
||||
}
|
||||
|
||||
if (!$code || !$state) {
|
||||
if (! $code || ! $state) {
|
||||
return redirect('/admin')->with('error', 'Invalid callback parameters');
|
||||
}
|
||||
|
||||
// Validate state
|
||||
$sessionState = session('stripe_state');
|
||||
if (!hash_equals($state, $sessionState)) {
|
||||
if (! hash_equals($state, (string) $sessionState)) {
|
||||
return redirect('/admin')->with('error', 'Invalid state parameter');
|
||||
}
|
||||
|
||||
@@ -355,9 +542,9 @@ class OAuthController extends Controller
|
||||
],
|
||||
]);
|
||||
|
||||
$tokenData = json_decode($response->getBody(), true);
|
||||
$tokenData = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if (!isset($tokenData['stripe_user_id'])) {
|
||||
if (! isset($tokenData['stripe_user_id'])) {
|
||||
return redirect('/admin')->with('error', 'Failed to connect Stripe account');
|
||||
}
|
||||
|
||||
@@ -369,8 +556,8 @@ class OAuthController extends Controller
|
||||
session()->forget(['stripe_state', 'tenant_id']);
|
||||
return redirect('/admin')->with('success', 'Stripe account connected successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Stripe OAuth error: ' . $e->getMessage());
|
||||
return redirect('/admin')->with('error', 'Connection error: ' . $e->getMessage());
|
||||
Log::error('Stripe OAuth error: '.$e->getMessage());
|
||||
return redirect('/admin')->with('error', 'Connection error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,15 @@ use App\Http\Resources\Tenant\EventPurchaseResource;
|
||||
use App\Models\EventCreditsLedger;
|
||||
use App\Models\EventPurchase;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class CreditController extends Controller
|
||||
{
|
||||
public function balance(Request $request)
|
||||
public function balance(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::findOrFail($user->tenant_id);
|
||||
$tenant = $this->resolveTenant($request);
|
||||
|
||||
return response()->json([
|
||||
'balance' => $tenant->event_credits_balance,
|
||||
@@ -29,14 +26,10 @@ class CreditController extends Controller
|
||||
|
||||
public function ledger(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
$tenant = $this->resolveTenant($request);
|
||||
|
||||
$tenant = Tenant::findOrFail($user->tenant_id);
|
||||
$ledgers = EventCreditsLedger::where('tenant_id', $tenant->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return CreditLedgerResource::collection($ledgers);
|
||||
@@ -44,16 +37,100 @@ class CreditController extends Controller
|
||||
|
||||
public function history(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
$tenant = $this->resolveTenant($request);
|
||||
|
||||
$tenant = Tenant::findOrFail($user->tenant_id);
|
||||
$purchases = EventPurchase::where('tenant_id', $tenant->id)
|
||||
->orderBy('purchased_at', 'desc')
|
||||
->orderByDesc('purchased_at')
|
||||
->paginate(20);
|
||||
|
||||
return EventPurchaseResource::collection($purchases);
|
||||
}
|
||||
}
|
||||
|
||||
public function purchase(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $this->resolveTenant($request);
|
||||
|
||||
$data = $request->validate([
|
||||
'package_id' => ['required', 'string', 'max:255'],
|
||||
'credits_added' => ['required', 'integer', 'min:1'],
|
||||
'platform' => ['nullable', 'string', 'max:32'],
|
||||
'transaction_id' => ['nullable', 'string', 'max:255'],
|
||||
'subscription_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$purchase = EventPurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'events_purchased' => $data['credits_added'],
|
||||
'amount' => 0,
|
||||
'currency' => 'EUR',
|
||||
'provider' => $data['platform'] ?? 'tenant-app',
|
||||
'external_receipt_id' => $data['transaction_id'] ?? null,
|
||||
'status' => 'completed',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
$note = 'Package: '.$data['package_id'];
|
||||
$incremented = $tenant->incrementCredits($data['credits_added'], 'purchase', $note, $purchase->id);
|
||||
|
||||
if (! $incremented) {
|
||||
throw new HttpException(500, 'Unable to record credit purchase');
|
||||
}
|
||||
|
||||
if (array_key_exists('subscription_active', $data)) {
|
||||
$tenant->update([
|
||||
'subscription_tier' => $data['subscription_active'] ? 'pro' : $tenant->subscription_tier,
|
||||
]);
|
||||
}
|
||||
|
||||
$tenant->refresh();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Purchase recorded',
|
||||
'balance' => $tenant->event_credits_balance,
|
||||
'subscription_active' => (bool) ($data['subscription_active'] ?? false),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function sync(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $this->resolveTenant($request);
|
||||
|
||||
$data = $request->validate([
|
||||
'balance' => ['nullable', 'integer', 'min:0'],
|
||||
'subscription_active' => ['sometimes', 'boolean'],
|
||||
'last_sync' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
if (array_key_exists('subscription_active', $data)) {
|
||||
$tenant->update([
|
||||
'subscription_tier' => $data['subscription_active'] ? 'pro' : ($tenant->subscription_tier ?? 'free'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Server remains source of truth for balance; echo current state back to client
|
||||
return response()->json([
|
||||
'balance' => $tenant->event_credits_balance,
|
||||
'subscription_active' => (bool) ($tenant->active_subscription ?? false),
|
||||
'server_time' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new HttpException(401, 'Unauthenticated');
|
||||
}
|
||||
|
||||
return Tenant::findOrFail($tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user