Implement multi-tenancy support with OAuth2 authentication for tenant admins, Stripe integration for event purchases and credits ledger, new Filament resources for event purchases, updated API routes and middleware for tenant isolation and token guarding, added factories/seeders/migrations for new models (Tenant, EventPurchase, OAuth entities, etc.), enhanced tests, and documentation updates. Removed outdated DemoAchievementsSeeder.

This commit is contained in:
2025-09-17 19:56:54 +02:00
parent 5fbb9cb240
commit 42d6e98dff
84 changed files with 6125 additions and 155 deletions

View File

@@ -0,0 +1,376 @@
<?php
namespace App\Http\Controllers;
use App\Models\OAuthClient;
use App\Models\OAuthCode;
use App\Models\RefreshToken;
use App\Models\Tenant;
use App\Models\TenantToken;
use Illuminate\Http\Request;
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
{
/**
* Authorize endpoint - PKCE flow
*/
public function authorize(Request $request)
{
$validator = Validator::make($request->all(), [
'client_id' => 'required|string',
'redirect_uri' => 'required|url',
'scope' => 'required|string',
'state' => 'nullable|string',
'code_challenge' => 'required|string',
'code_challenge_method' => 'required|in:S256,plain',
]);
if ($validator->fails()) {
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
}
$client = OAuthClient::where('client_id', $request->client_id)
->where('is_active', true)
->first();
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)) {
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)) {
return $this->errorResponse('Invalid scopes requested', 400);
}
// Generate authorization code (PKCE validated later)
$code = Str::random(40);
$codeId = Str::uuid();
// Store code in Redis with 5min TTL
Cache::put("oauth_code:{$codeId}", [
'code' => $code,
'client_id' => $request->client_id,
'redirect_uri' => $request->redirect_uri,
'scopes' => $validScopes,
'state' => $request->state ?? null,
'code_challenge' => $request->code_challenge,
'code_challenge_method' => $request->code_challenge_method,
'expires_at' => now()->addMinutes(5),
], now()->addMinutes(5));
// Save to DB for persistence
OAuthCode::create([
'id' => $codeId,
'code' => Hash::make($code),
'client_id' => $request->client_id,
'scopes' => json_encode($validScopes),
'state' => $request->state ?? null,
'expires_at' => now()->addMinutes(5),
]);
$redirectUrl = $request->redirect_uri . '?' . http_build_query([
'code' => $code,
'state' => $request->state ?? '',
]);
return redirect($redirectUrl);
}
/**
* Token endpoint - Code exchange for access/refresh tokens
*/
public function token(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
]);
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) {
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) {
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']) {
return $this->errorResponse('Invalid redirect URI', 400);
}
// Code used - delete/invalidate
Cache::forget("oauth_code:{$codeId}");
OAuthCode::where('id', $codeId)->delete();
// 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'));
}
$accessToken = $this->generateJWT($tenant->id, $cachedCode['scopes'], 'access', 3600); // 1h
$refreshTokenId = Str::uuid();
$refreshToken = Str::random(60);
// 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(),
]);
return response()->json([
'token_type' => 'Bearer',
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => 3600,
'scope' => implode(' ', $cachedCode['scopes']),
]);
}
/**
* Get tenant info
*/
public function me(Request $request)
{
$user = $request->user();
if (!$user) {
return $this->errorResponse('Unauthenticated', 401);
}
$tenant = $user->tenant;
if (!$tenant) {
return $this->errorResponse('Tenant not found', 404);
}
return response()->json([
'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,
]);
}
/**
* Generate JWT token
*/
private function generateJWT($tenantId, $scopes, $type, $expiresIn)
{
$payload = [
'iss' => url('/'),
'aud' => 'fotospiel-api',
'iat' => time(),
'nbf' => time(),
'exp' => time() + $expiresIn,
'sub' => $tenantId,
'scopes' => $scopes,
'type' => $type,
];
$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']);
}
/**
* Generate RSA key pair for JWT
*/
private function generateKeyPair()
{
$config = [
"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');
}
// Get private key
openssl_pkey_export($res, $privateKey);
file_put_contents(storage_path('app/private.key'), $privateKey);
// Get public key
$pubKey = openssl_pkey_get_details($res);
file_put_contents(storage_path('app/public.key'), $pubKey["key"]);
}
/**
* Error response helper
*/
private function errorResponse($message, $status = 400, $errors = null)
{
$response = ['error' => $message];
if ($errors) {
$response['errors'] = $errors;
}
return response()->json($response, $status);
}
/**
* Helper function for base64url encoding
*/
private function base64url_encode($data)
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Stripe Connect OAuth - Start connection
*/
public function stripeConnect(Request $request)
{
$tenant = $request->user()->tenant;
if (!$tenant) {
return response()->json(['error' => 'Tenant not found'], 404);
}
$state = Str::random(40);
session(['stripe_state' => $state, 'tenant_id' => $tenant->id]);
Cache::put("stripe_connect_state:{$tenant->id}", $state, now()->addMinutes(10));
$clientId = config('services.stripe.connect_client_id');
$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);
return redirect($authUrl);
}
/**
* Stripe Connect Callback
*/
public function stripeCallback(Request $request)
{
$code = $request->get('code');
$state = $request->get('state');
$error = $request->get('error');
if ($error) {
return redirect('/admin')->with('error', 'Stripe connection failed: ' . $error);
}
if (!$code || !$state) {
return redirect('/admin')->with('error', 'Invalid callback parameters');
}
// Validate state
$sessionState = session('stripe_state');
if (!hash_equals($state, $sessionState)) {
return redirect('/admin')->with('error', 'Invalid state parameter');
}
$client = new Client();
$clientId = config('services.stripe.connect_client_id');
$secret = config('services.stripe.connect_secret');
$redirectUri = url('/api/v1/oauth/stripe-callback');
try {
$response = $client->post('https://connect.stripe.com/oauth/token', [
'form_params' => [
'grant_type' => 'authorization_code',
'client_id' => $clientId,
'client_secret' => $secret,
'code' => $code,
'redirect_uri' => $redirectUri,
],
]);
$tokenData = json_decode($response->getBody(), true);
if (!isset($tokenData['stripe_user_id'])) {
return redirect('/admin')->with('error', 'Failed to connect Stripe account');
}
$tenant = Tenant::find(session('tenant_id'));
if ($tenant) {
$tenant->update(['stripe_account_id' => $tokenData['stripe_user_id']]);
}
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());
}
}
}