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

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}
}
}

View File

@@ -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);
}
}