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:
376
app/Http/Controllers/OAuthController.php
Normal file
376
app/Http/Controllers/OAuthController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user