377 lines
13 KiB
PHP
377 lines
13 KiB
PHP
<?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());
|
|
}
|
|
}
|
|
}
|