stage 1 of oauth removal, switch to sanctum pat tokens
This commit is contained in:
@@ -50,12 +50,24 @@ class AuthenticatedSessionController extends Controller
|
||||
return Inertia::location(route('verification.notice'));
|
||||
}
|
||||
|
||||
$intended = $this->resolveIntended($request);
|
||||
if ($intended !== null) {
|
||||
$this->rememberTenantAdminTarget($request, $intended);
|
||||
|
||||
return Inertia::location($intended);
|
||||
}
|
||||
|
||||
$returnTo = $this->resolveReturnTo($request);
|
||||
if ($returnTo !== null) {
|
||||
$this->rememberTenantAdminTarget($request, $returnTo);
|
||||
|
||||
return Inertia::location($returnTo);
|
||||
}
|
||||
|
||||
return Inertia::location($this->defaultAdminPath());
|
||||
$default = $this->defaultAdminPath();
|
||||
$this->rememberTenantAdminTarget($request, $default);
|
||||
|
||||
return Inertia::location($default);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +91,29 @@ class AuthenticatedSessionController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->decodeReturnTo($encoded, $request);
|
||||
return $this->normalizeTenantAdminTarget(
|
||||
$this->decodeReturnTo($encoded, $request),
|
||||
$request
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveIntended(Request $request): ?string
|
||||
{
|
||||
$intended = $request->session()->pull('url.intended');
|
||||
|
||||
if (! is_string($intended)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($intended);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalizeTenantAdminTarget(
|
||||
$this->decodeReturnTo($trimmed, $request),
|
||||
$request
|
||||
);
|
||||
}
|
||||
|
||||
private function decodeReturnTo(string $value, Request $request): ?string
|
||||
@@ -113,12 +147,73 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
private function defaultAdminPath(): string
|
||||
{
|
||||
$base = rtrim(route('tenant.admin.app', absolute: false), '/');
|
||||
if ($base === '') {
|
||||
$base = '/event-admin';
|
||||
$user = Auth::user();
|
||||
|
||||
// Block users with 'user' role - redirect to package selection
|
||||
if ($user && $user->role === 'user') {
|
||||
return '/packages';
|
||||
}
|
||||
|
||||
return $base.'/events';
|
||||
// Super admins go to Filament superadmin panel
|
||||
if ($user && $user->role === 'super_admin') {
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
// Tenant admins go to their PWA dashboard
|
||||
if ($user && $user->role === 'tenant_admin') {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
// Fallback: redirect to packages (for users with no role)
|
||||
return '/packages';
|
||||
}
|
||||
|
||||
private function normalizeTenantAdminTarget(?string $target, Request $request): ?string
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user || $user->role !== 'tenant_admin') {
|
||||
return $target;
|
||||
}
|
||||
|
||||
if ($target === null || $target === '') {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
$parsed = parse_url($target);
|
||||
$path = $target;
|
||||
$hasScheme = false;
|
||||
|
||||
if ($parsed !== false) {
|
||||
$hasScheme = isset($parsed['scheme']);
|
||||
$host = $parsed['host'] ?? null;
|
||||
$scheme = $parsed['scheme'] ?? null;
|
||||
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||
|
||||
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
if (isset($parsed['path'])) {
|
||||
$path = $parsed['path'];
|
||||
if (isset($parsed['query'])) {
|
||||
$path .= '?'.$parsed['query'];
|
||||
}
|
||||
if (isset($parsed['fragment'])) {
|
||||
$path .= '#'.$parsed['fragment'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! str_starts_with($path, '/')) {
|
||||
$path = '/'.$path;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/event-admin') || str_starts_with($path, '/api/v1/oauth/authorize')) {
|
||||
return $hasScheme ? $target : $path;
|
||||
}
|
||||
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
private function decodeBase64Url(string $value): ?string
|
||||
@@ -137,4 +232,66 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user || $user->role !== 'tenant_admin') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($target) || $target === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeTenantAdminTarget($target, $request);
|
||||
|
||||
if (! is_string($normalized) || $normalized === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $this->extractTenantAdminPath($normalized);
|
||||
|
||||
if ($path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request->session()->put('tenant_admin.return_to', $path);
|
||||
}
|
||||
|
||||
private function extractTenantAdminPath(string $target): ?string
|
||||
{
|
||||
$value = trim($target);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($value, '/event-admin')) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$parsed = parse_url($value);
|
||||
|
||||
if ($parsed === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $parsed['path'] ?? '';
|
||||
|
||||
if ($path === '' || ! str_starts_with($path, '/event-admin')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset($parsed['query'])) {
|
||||
$path .= '?'.$parsed['query'];
|
||||
}
|
||||
|
||||
if (isset($parsed['fragment'])) {
|
||||
$path .= '#'.$parsed['fragment'];
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,26 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, DashboardSummaryService $summaryService): Response
|
||||
public function __invoke(Request $request, DashboardSummaryService $summaryService)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user && $user->role === 'tenant_admin') {
|
||||
$returnTarget = $this->consumeTenantAdminTarget($request);
|
||||
|
||||
if ($returnTarget !== null) {
|
||||
return redirect($returnTarget);
|
||||
}
|
||||
}
|
||||
|
||||
// Block users with 'user' role from accessing dashboard
|
||||
if ($user && $user->role === 'user') {
|
||||
return redirect('/packages');
|
||||
}
|
||||
|
||||
$tenant = $user?->tenant;
|
||||
|
||||
$summary = $tenant instanceof Tenant
|
||||
@@ -65,6 +78,21 @@ class DashboardController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function consumeTenantAdminTarget(Request $request): ?string
|
||||
{
|
||||
$target = $request->session()->pull('tenant_admin.return_to');
|
||||
|
||||
if (! is_string($target)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! str_starts_with($target, '/event-admin')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
private function collectUpcomingEvents(Tenant $tenant): Collection
|
||||
{
|
||||
return Event::query()
|
||||
|
||||
@@ -1,884 +0,0 @@
|
||||
<?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 App\Models\User;
|
||||
use App\Support\ApiError;
|
||||
use Firebase\JWT\JWT;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
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 LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
|
||||
|
||||
/**
|
||||
* Authorize endpoint - PKCE flow
|
||||
*/
|
||||
public function authorize(Request $request)
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return $this->authorizeErrorResponse(
|
||||
$request,
|
||||
'login_required',
|
||||
'Please sign in to continue.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
$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',
|
||||
'code_challenge_method' => 'required|in:S256,plain',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $this->authorizeErrorResponse(
|
||||
$request,
|
||||
'invalid_request',
|
||||
'The authorization request is invalid.',
|
||||
Response::HTTP_BAD_REQUEST,
|
||||
['errors' => $validator->errors()->toArray()]
|
||||
);
|
||||
}
|
||||
|
||||
/** @var OAuthClient|null $client */
|
||||
$client = OAuthClient::query()
|
||||
->where('client_id', $request->string('client_id'))
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $client) {
|
||||
return $this->authorizeErrorResponse(
|
||||
$request,
|
||||
'invalid_client',
|
||||
'The specified client could not be found.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
$allowedRedirects = (array) $client->redirect_uris;
|
||||
if (! in_array($request->redirect_uri, $allowedRedirects, true)) {
|
||||
return $this->authorizeErrorResponse(
|
||||
$request,
|
||||
'invalid_redirect',
|
||||
'The redirect URI is not registered for this client.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$requestedScopes = $this->parseScopes($request->string('scope'));
|
||||
$availableScopes = (array) $client->scopes;
|
||||
if (! $this->scopesAreAllowed($requestedScopes, $availableScopes)) {
|
||||
return $this->authorizeErrorResponse(
|
||||
$request,
|
||||
'invalid_scope',
|
||||
'The client requested scopes that are not permitted.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$tenantId = $this->resolveTenantId($client, $user);
|
||||
if (! $tenantId) {
|
||||
return $this->authorizeErrorResponse(
|
||||
$request,
|
||||
'tenant_mismatch',
|
||||
'You do not have access to the requested tenant.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['client_id' => $client->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' => $requestedScopes,
|
||||
'state' => $request->state,
|
||||
'code_challenge' => $request->code_challenge,
|
||||
'code_challenge_method' => $request->code_challenge_method,
|
||||
'expires_at' => $expiresAt,
|
||||
], $expiresAt);
|
||||
|
||||
OAuthCode::create([
|
||||
'id' => $codeId,
|
||||
'client_id' => $client->client_id,
|
||||
'user_id' => (string) $user->getAuthIdentifier(),
|
||||
'code' => Hash::make($code),
|
||||
'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([
|
||||
'code' => $code,
|
||||
'state' => $request->state,
|
||||
]);
|
||||
|
||||
if ($this->shouldReturnJsonAuthorizeResponse($request)) {
|
||||
return response()->json([
|
||||
'code' => $code,
|
||||
'state' => $request->state,
|
||||
'redirect_url' => $redirectUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->away($redirectUrl);
|
||||
}
|
||||
|
||||
private function resolveTenantId(OAuthClient $client, User $user): ?int
|
||||
{
|
||||
if ($client->tenant_id !== null) {
|
||||
if ((int) $client->tenant_id === (int) ($user->tenant_id ?? 0) || $user->role === 'super_admin') {
|
||||
return (int) $client->tenant_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($user->tenant_id !== null) {
|
||||
return (int) $user->tenant_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function rememberIntendedUrl(Request $request): void
|
||||
{
|
||||
session()->put('url.intended', $request->fullUrl());
|
||||
}
|
||||
|
||||
private function authorizeErrorResponse(
|
||||
Request $request,
|
||||
string $code,
|
||||
string $message,
|
||||
int $status,
|
||||
array $meta = []
|
||||
) {
|
||||
$this->rememberIntendedUrl($request);
|
||||
|
||||
if ($this->shouldReturnJsonAuthorizeResponse($request)) {
|
||||
$payload = [
|
||||
'error' => $code,
|
||||
'error_description' => $message,
|
||||
];
|
||||
|
||||
if ($meta !== []) {
|
||||
$payload['meta'] = $meta;
|
||||
}
|
||||
|
||||
return response()->json($payload, $status);
|
||||
}
|
||||
|
||||
$query = [
|
||||
'error' => $code,
|
||||
'error_description' => $message,
|
||||
'return_to' => $this->encodeReturnTo($request->fullUrl()),
|
||||
];
|
||||
|
||||
if ($meta !== []) {
|
||||
$metaJson = json_encode($meta, JSON_UNESCAPED_SLASHES);
|
||||
if ($metaJson !== false) {
|
||||
$query['error_meta'] = $this->encodeReturnTo($metaJson);
|
||||
}
|
||||
}
|
||||
|
||||
$redirectUrl = route('tenant.admin.login').'?'.http_build_query($query);
|
||||
|
||||
return redirect()->to($redirectUrl);
|
||||
}
|
||||
|
||||
private function encodeReturnTo(?string $value): string
|
||||
{
|
||||
$value ??= '';
|
||||
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
'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,
|
||||
'active_reseller_package_id' => $tenant->active_reseller_package_id,
|
||||
'remaining_events' => $tenant->activeResellerPackage?->remaining_events ?? 0,
|
||||
'package_expires_at' => $tenant->activeResellerPackage?->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',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return $this->errorResponse('Invalid request parameters', 400, $validator->errors());
|
||||
}
|
||||
|
||||
$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,
|
||||
]);
|
||||
|
||||
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,
|
||||
'oauth_code_id' => $oauthCode?->id,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Invalid authorization code', 400);
|
||||
}
|
||||
|
||||
/** @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);
|
||||
}
|
||||
|
||||
if ($request->redirect_uri !== Arr::get($cachedCode, 'redirect_uri')) {
|
||||
return $this->errorResponse('Invalid redirect URI', 400);
|
||||
}
|
||||
|
||||
$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,
|
||||
'oauth_code_id' => $oauthCode->id ?? null,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
$scopes = Arr::get($cachedCode, 'scopes', []);
|
||||
if (empty($scopes)) {
|
||||
$scopes = $this->parseScopes($oauthCode->scope);
|
||||
}
|
||||
|
||||
Cache::forget($cacheKey);
|
||||
$oauthCode->delete();
|
||||
|
||||
$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',
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
$storedRefreshToken->recordAudit('refresh_attempt', [
|
||||
'client_id' => $request->client_id,
|
||||
], null, $request);
|
||||
|
||||
if ($storedRefreshToken->client_id && $storedRefreshToken->client_id !== $request->client_id) {
|
||||
$storedRefreshToken->recordAudit('client_mismatch', [
|
||||
'expected_client' => $storedRefreshToken->client_id,
|
||||
'provided_client' => $request->client_id,
|
||||
], null, $request);
|
||||
|
||||
return $this->errorResponse('Refresh token does not match client', 400);
|
||||
}
|
||||
|
||||
if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) {
|
||||
$storedRefreshToken->revoke('expired', null, $request, [
|
||||
'expired_at' => $storedRefreshToken->expires_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Refresh token expired', 400);
|
||||
}
|
||||
|
||||
if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) {
|
||||
$storedRefreshToken->recordAudit('invalid_secret', [], null, $request);
|
||||
$storedRefreshToken->revoke('invalid_secret', null, $request, [
|
||||
'client_id' => $request->client_id,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Invalid refresh token', 400);
|
||||
}
|
||||
|
||||
$storedIp = (string) ($storedRefreshToken->ip_address ?? '');
|
||||
$currentIp = (string) ($request->ip() ?? '');
|
||||
|
||||
if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) {
|
||||
Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [
|
||||
'client_id' => $request->client_id,
|
||||
'refresh_token_id' => $storedRefreshToken->id,
|
||||
'stored_ip' => $storedIp,
|
||||
'current_ip' => $currentIp,
|
||||
]);
|
||||
|
||||
$storedRefreshToken->revoke('ip_mismatch', null, $request, [
|
||||
'stored_ip' => $storedIp,
|
||||
'current_ip' => $currentIp,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Refresh token cannot be used from this IP address', 403);
|
||||
}
|
||||
|
||||
$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,
|
||||
'refresh_token_id' => $storedRefreshToken->id,
|
||||
'tenant_id' => $storedRefreshToken->tenant_id,
|
||||
]);
|
||||
|
||||
$storedRefreshToken->revoke('tenant_missing', null, $request, [
|
||||
'missing_tenant_id' => $storedRefreshToken->tenant_id,
|
||||
]);
|
||||
|
||||
return $this->errorResponse('Tenant not found', 404);
|
||||
}
|
||||
|
||||
$scopes = $this->parseScopes($storedRefreshToken->scope);
|
||||
|
||||
$storedRefreshToken->forceFill([
|
||||
'last_used_at' => now(),
|
||||
])->save();
|
||||
|
||||
$storedRefreshToken->recordAudit('refreshed', [
|
||||
'client_id' => $request->client_id,
|
||||
], null, $request);
|
||||
|
||||
$tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request);
|
||||
|
||||
$newComposite = $tokenResponse['refresh_token'] ?? null;
|
||||
$newRefreshTokenId = null;
|
||||
|
||||
if ($newComposite && str_contains($newComposite, '|')) {
|
||||
[$newRefreshTokenId] = explode('|', $newComposite, 2);
|
||||
}
|
||||
|
||||
$storedRefreshToken->revoke('rotated', null, $request, [
|
||||
'replaced_by' => $newRefreshTokenId,
|
||||
]);
|
||||
|
||||
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' => $expiresIn,
|
||||
'scope' => implode(' ', $scopes),
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldReturnJsonAuthorizeResponse(Request $request): bool
|
||||
{
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$redirectUri = (string) $request->string('redirect_uri');
|
||||
$redirectHost = $redirectUri !== '' ? parse_url($redirectUri, PHP_URL_HOST) : null;
|
||||
$requestHost = $request->getHost();
|
||||
|
||||
if ($redirectHost && ! $this->hostsMatch($requestHost, $redirectHost)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$origin = $request->headers->get('Origin');
|
||||
if ($origin) {
|
||||
$originHost = parse_url($origin, PHP_URL_HOST);
|
||||
if ($originHost && $redirectHost && ! $this->hostsMatch($originHost, $redirectHost)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hostsMatch(?string $first, ?string $second): bool
|
||||
{
|
||||
if (! $first || ! $second) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtolower($first) === strtolower($second);
|
||||
}
|
||||
|
||||
private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string
|
||||
{
|
||||
$refreshTokenId = (string) Str::uuid();
|
||||
$secret = Str::random(64);
|
||||
$composite = $refreshTokenId.'|'.$secret;
|
||||
$expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS);
|
||||
|
||||
/** @var RefreshToken $refreshToken */
|
||||
$refreshToken = RefreshToken::create([
|
||||
'id' => $refreshTokenId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'client_id' => $client->client_id,
|
||||
'token' => Hash::make($secret),
|
||||
'access_token' => $accessTokenJti,
|
||||
'expires_at' => $expiresAt,
|
||||
'last_used_at' => now(),
|
||||
'scope' => implode(' ', $scopes),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
$refreshToken->recordAudit('issued', [
|
||||
'scopes' => $scopes,
|
||||
], null, $request);
|
||||
|
||||
$maxActive = (int) config('oauth.refresh_tokens.max_active_per_tenant', 5);
|
||||
|
||||
if ($maxActive > 0) {
|
||||
$activeTokens = RefreshToken::query()
|
||||
->forTenant((string) $tenant->id)
|
||||
->active()
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
if ($activeTokens->count() > $maxActive) {
|
||||
$activeTokens
|
||||
->slice($maxActive)
|
||||
->each(function (RefreshToken $token) use ($request, $maxActive, $refreshToken): void {
|
||||
$token->revoke('max_active_limit', null, $request, [
|
||||
'threshold' => $maxActive,
|
||||
'new_token' => $refreshToken->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return $composite;
|
||||
}
|
||||
|
||||
private function generateJWT(
|
||||
int $tenantId,
|
||||
string $clientId,
|
||||
array $scopes,
|
||||
string $type,
|
||||
int $expiresIn,
|
||||
string $jti,
|
||||
int $issuedAt,
|
||||
int $expiresAt
|
||||
): string {
|
||||
[$kid, , $privateKey] = $this->getSigningKeyPair();
|
||||
|
||||
$payload = [
|
||||
'iss' => url('/'),
|
||||
'aud' => $clientId,
|
||||
'iat' => $issuedAt,
|
||||
'nbf' => $issuedAt,
|
||||
'exp' => $expiresAt,
|
||||
'sub' => $tenantId,
|
||||
'tenant_id' => $tenantId,
|
||||
'scopes' => $scopes,
|
||||
'type' => $type,
|
||||
'client_id' => $clientId,
|
||||
'jti' => $jti,
|
||||
];
|
||||
|
||||
return JWT::encode($payload, $privateKey, 'RS256', $kid, ['kid' => $kid]);
|
||||
}
|
||||
|
||||
private function getSigningKeyPair(): array
|
||||
{
|
||||
$kid = $this->currentKid();
|
||||
[$publicKey, $privateKey] = $this->ensureKeysForKid($kid);
|
||||
|
||||
return [$kid, $publicKey, $privateKey];
|
||||
}
|
||||
|
||||
private function currentKid(): string
|
||||
{
|
||||
return config('oauth.keys.current_kid', self::LEGACY_TOKEN_HEADER_KID);
|
||||
}
|
||||
|
||||
private function ensureKeysForKid(string $kid): array
|
||||
{
|
||||
$paths = $this->keyPaths($kid);
|
||||
|
||||
if (! File::exists($paths['directory'])) {
|
||||
File::makeDirectory($paths['directory'], 0700, true);
|
||||
}
|
||||
|
||||
$this->maybeMigrateLegacyKeys($paths);
|
||||
|
||||
if (! File::exists($paths['public']) || ! File::exists($paths['private'])) {
|
||||
$this->generateKeyPair($paths['directory']);
|
||||
}
|
||||
|
||||
return [
|
||||
File::get($paths['public']),
|
||||
File::get($paths['private']),
|
||||
];
|
||||
}
|
||||
|
||||
private function keyPaths(string $kid): array
|
||||
{
|
||||
$base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$directory = $base.DIRECTORY_SEPARATOR.$kid;
|
||||
|
||||
return [
|
||||
'directory' => $directory,
|
||||
'public' => $directory.DIRECTORY_SEPARATOR.'public.key',
|
||||
'private' => $directory.DIRECTORY_SEPARATOR.'private.key',
|
||||
];
|
||||
}
|
||||
|
||||
private function maybeMigrateLegacyKeys(array $paths): void
|
||||
{
|
||||
$legacyPublic = storage_path('app/public.key');
|
||||
$legacyPrivate = storage_path('app/private.key');
|
||||
|
||||
if (! File::exists($paths['public']) && File::exists($legacyPublic)) {
|
||||
File::copy($legacyPublic, $paths['public']);
|
||||
}
|
||||
|
||||
if (! File::exists($paths['private']) && File::exists($legacyPrivate)) {
|
||||
File::copy($legacyPrivate, $paths['private']);
|
||||
}
|
||||
}
|
||||
|
||||
private function generateKeyPair(string $directory): void
|
||||
{
|
||||
$config = [
|
||||
'digest_alg' => OPENSSL_ALGO_SHA256,
|
||||
'private_key_bits' => 4096,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$resource = openssl_pkey_new($config);
|
||||
if (! $resource) {
|
||||
throw new \RuntimeException('Failed to generate key pair');
|
||||
}
|
||||
|
||||
openssl_pkey_export($resource, $privateKey);
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
$publicKey = $details['key'] ?? null;
|
||||
|
||||
if (! $publicKey) {
|
||||
throw new \RuntimeException('Failed to extract public key');
|
||||
}
|
||||
|
||||
File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey, true);
|
||||
File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600);
|
||||
|
||||
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true);
|
||||
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
return response()->json($response, $status);
|
||||
}
|
||||
|
||||
private function ipMatches(string $storedIp, string $currentIp): bool
|
||||
{
|
||||
if ($storedIp === '' || $currentIp === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hash_equals($storedIp, $currentIp)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! config('oauth.refresh_tokens.allow_subnet_match', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter_var($storedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($currentIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$storedParts = explode('.', $storedIp);
|
||||
$currentParts = explode('.', $currentIp);
|
||||
|
||||
return $storedParts[0] === $currentParts[0]
|
||||
&& $storedParts[1] === $currentParts[1]
|
||||
&& $storedParts[2] === $currentParts[2];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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 ?? null;
|
||||
if (! $tenant) {
|
||||
return ApiError::response(
|
||||
'tenant_not_found',
|
||||
'Tenant not found',
|
||||
'The authenticated user is not assigned to a tenant.',
|
||||
Response::HTTP_NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
$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('/event-admin')->with('error', 'Stripe connection failed: '.$error);
|
||||
}
|
||||
|
||||
if (! $code || ! $state) {
|
||||
return redirect('/event-admin')->with('error', 'Invalid callback parameters');
|
||||
}
|
||||
|
||||
$sessionState = session('stripe_state');
|
||||
if (! hash_equals($state, (string) $sessionState)) {
|
||||
return redirect('/event-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()->getContents(), true);
|
||||
|
||||
if (! isset($tokenData['stripe_user_id'])) {
|
||||
return redirect('/event-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('/event-admin')->with('success', 'Stripe account connected successfully');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Stripe OAuth error: '.$e->getMessage());
|
||||
|
||||
return redirect('/event-admin')->with('error', 'Connection error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/Http/Controllers/TenantAdminAuthController.php
Normal file
32
app/Http/Controllers/TenantAdminAuthController.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class TenantAdminAuthController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Allow only tenant_admin and super_admin
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) {
|
||||
return view('admin');
|
||||
}
|
||||
|
||||
// Redirect users with 'user' role to packages
|
||||
if ($user && $user->role === 'user') {
|
||||
return redirect('/packages');
|
||||
}
|
||||
|
||||
// Redirect unauthenticated users to the dedicated admin start flow
|
||||
if (! $user) {
|
||||
return redirect('/event-admin/start');
|
||||
}
|
||||
|
||||
// Default: redirect to regular dashboard
|
||||
return redirect('/dashboard');
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class TenantAdminGoogleController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->intended(route('tenant.admin.app'));
|
||||
return redirect()->intended('/event-admin/dashboard');
|
||||
}
|
||||
|
||||
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
|
||||
|
||||
147
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file
147
app/Http/Middleware/RedirectIfAuthenticated.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Auth\Middleware\RedirectIfAuthenticated as BaseMiddleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class RedirectIfAuthenticated extends BaseMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, ...$guards)
|
||||
{
|
||||
$guards = $guards === [] ? [null] : $guards;
|
||||
|
||||
foreach ($guards as $guard) {
|
||||
if (! Auth::guard($guard)->check()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user = Auth::guard($guard)->user();
|
||||
|
||||
if ($this->shouldBypassForTenantAdmin($request, $user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($user && $user->role === 'tenant_admin') {
|
||||
$this->storeTenantAdminTarget($request);
|
||||
}
|
||||
|
||||
return redirect($this->redirectPath($user));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function shouldBypassForTenantAdmin(Request $request, ?User $user): bool
|
||||
{
|
||||
if (! $user || $user->role !== 'tenant_admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$encoded = $request->string('return_to')->trim()->toString();
|
||||
|
||||
if ($encoded === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$decoded = $this->decodeReturnTo($encoded);
|
||||
|
||||
if ($decoded === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = $this->normalizePath($decoded);
|
||||
|
||||
return str_starts_with($path, '/event-admin');
|
||||
}
|
||||
|
||||
private function decodeReturnTo(string $value): ?string
|
||||
{
|
||||
$padded = str_pad($value, strlen($value) + ((4 - (strlen($value) % 4)) % 4), '=');
|
||||
$normalized = strtr($padded, '-_', '+/');
|
||||
$decoded = base64_decode($normalized, true);
|
||||
|
||||
return $decoded === false ? null : $decoded;
|
||||
}
|
||||
|
||||
private function normalizePath(string $target): string
|
||||
{
|
||||
$trimmed = trim($target);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, '/')) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
$parsed = parse_url($trimmed);
|
||||
|
||||
if ($parsed === false || ! isset($parsed['path'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = $parsed['path'];
|
||||
|
||||
if (! str_starts_with($path, '/')) {
|
||||
$path = '/'.$path;
|
||||
}
|
||||
|
||||
if (isset($parsed['query'])) {
|
||||
$path .= '?'.$parsed['query'];
|
||||
}
|
||||
|
||||
if (isset($parsed['fragment'])) {
|
||||
$path .= '#'.$parsed['fragment'];
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function redirectPath(?User $user): string
|
||||
{
|
||||
if ($user && $user->role === 'tenant_admin') {
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
if ($user && $user->role === 'super_admin') {
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
if ($user && $user->role === 'user') {
|
||||
return '/packages';
|
||||
}
|
||||
|
||||
return '/dashboard';
|
||||
}
|
||||
|
||||
private function storeTenantAdminTarget(Request $request): void
|
||||
{
|
||||
$encoded = $request->string('return_to')->trim()->toString();
|
||||
|
||||
if ($encoded === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$decoded = $this->decodeReturnTo($encoded);
|
||||
|
||||
if ($decoded === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $this->normalizePath($decoded);
|
||||
|
||||
if (! str_starts_with($path, '/event-admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request->session()->put('tenant_admin.return_to', $path);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ class TenantIsolation
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
$abilities = $request->user()?->currentAccessToken()?->abilities ?? [];
|
||||
|
||||
if (! $tenantId && in_array('super-admin', $abilities, true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $tenantId) {
|
||||
return $this->missingTenantIdResponse();
|
||||
}
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantToken;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Illuminate\Auth\GenericUser;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TenantTokenGuard
|
||||
{
|
||||
private const LEGACY_KID = 'fotospiel-jwt';
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, ...$scopes)
|
||||
{
|
||||
$token = $this->getTokenFromRequest($request);
|
||||
|
||||
if (! $token) {
|
||||
return $this->errorResponse(
|
||||
'token_missing',
|
||||
'Token Missing',
|
||||
'Authentication token not provided.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = $this->decodeToken($token);
|
||||
} catch (\Exception $e) {
|
||||
return $this->errorResponse(
|
||||
'token_invalid',
|
||||
'Invalid Token',
|
||||
'Authentication token cannot be decoded.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->isTokenBlacklisted($decoded)) {
|
||||
return $this->errorResponse(
|
||||
'token_revoked',
|
||||
'Token Revoked',
|
||||
'The provided token is no longer valid.',
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
['jti' => $decoded['jti'] ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
if (! empty($scopes) && ! $this->hasScopes($decoded, $scopes)) {
|
||||
return $this->errorResponse(
|
||||
'token_scope_violation',
|
||||
'Insufficient Scopes',
|
||||
'The provided token does not include the required scopes.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['required_scopes' => $scopes, 'token_scopes' => $decoded['scopes'] ?? []]
|
||||
);
|
||||
}
|
||||
|
||||
if (($decoded['exp'] ?? 0) < time()) {
|
||||
$this->blacklistToken($decoded);
|
||||
|
||||
return $this->errorResponse(
|
||||
'token_expired',
|
||||
'Token Expired',
|
||||
'Authentication token has expired.',
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
['expired_at' => $decoded['exp'] ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
$tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
|
||||
if (! $tenantId) {
|
||||
return $this->errorResponse(
|
||||
'token_payload_invalid',
|
||||
'Invalid Token Payload',
|
||||
'Authentication token does not include tenant context.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
if (! $tenant) {
|
||||
return $this->errorResponse(
|
||||
'tenant_not_found',
|
||||
'Tenant Not Found',
|
||||
'The tenant belonging to the token could not be located.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['tenant_id' => $tenantId]
|
||||
);
|
||||
}
|
||||
|
||||
$scopesFromToken = $this->normaliseScopes($decoded['scopes'] ?? []);
|
||||
|
||||
$principal = new GenericUser([
|
||||
'id' => $tenant->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant' => $tenant,
|
||||
'scopes' => $scopesFromToken,
|
||||
'client_id' => $decoded['client_id'] ?? null,
|
||||
'jti' => $decoded['jti'] ?? null,
|
||||
'token_type' => $decoded['type'] ?? 'access',
|
||||
]);
|
||||
|
||||
Auth::setUser($principal);
|
||||
$request->setUserResolver(fn () => $principal);
|
||||
|
||||
$request->merge([
|
||||
'tenant_id' => $tenant->id,
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
$request->attributes->set('tenant_id', $tenant->id);
|
||||
$request->attributes->set('tenant', $tenant);
|
||||
$request->attributes->set('decoded_token', $decoded);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token from request (Bearer or header)
|
||||
*/
|
||||
private function getTokenFromRequest(Request $request): ?string
|
||||
{
|
||||
$header = $request->header('Authorization');
|
||||
|
||||
if (is_string($header) && str_starts_with($header, 'Bearer ')) {
|
||||
return substr($header, 7);
|
||||
}
|
||||
|
||||
if ($request->header('X-API-Token')) {
|
||||
return $request->header('X-API-Token');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token
|
||||
*/
|
||||
private function decodeToken(string $token): array
|
||||
{
|
||||
$kid = $this->extractKid($token);
|
||||
$publicKey = $this->loadPublicKeyForKid($kid);
|
||||
|
||||
if (! $publicKey) {
|
||||
throw new \Exception('JWT public key not found');
|
||||
}
|
||||
|
||||
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
|
||||
|
||||
return (array) $decoded;
|
||||
}
|
||||
|
||||
private function extractKid(string $token): ?string
|
||||
{
|
||||
$segments = explode('.', $token);
|
||||
if (count($segments) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decodedHeader = json_decode(base64_decode($segments[0]), true);
|
||||
|
||||
return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null;
|
||||
}
|
||||
|
||||
private function loadPublicKeyForKid(?string $kid): ?string
|
||||
{
|
||||
$resolvedKid = $kid ?? config('oauth.keys.current_kid', self::LEGACY_KID);
|
||||
$base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$path = $base.DIRECTORY_SEPARATOR.$resolvedKid.DIRECTORY_SEPARATOR.'public.key';
|
||||
|
||||
if (File::exists($path)) {
|
||||
return File::get($path);
|
||||
}
|
||||
|
||||
$legacyPath = storage_path('app/public.key');
|
||||
if (File::exists($legacyPath)) {
|
||||
return File::get($legacyPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is blacklisted
|
||||
*/
|
||||
private function isTokenBlacklisted(array $decoded): bool
|
||||
{
|
||||
$jti = $decoded['jti'] ?? null;
|
||||
if (! $jti) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cacheKey = "blacklisted_token:{$jti}";
|
||||
if (Cache::has($cacheKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$tokenRecord = TenantToken::query()->where('jti', $jti)->first();
|
||||
if (! $tokenRecord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($tokenRecord->revoked_at) {
|
||||
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) {
|
||||
$tokenRecord->update(['revoked_at' => now()]);
|
||||
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add token to blacklist
|
||||
*/
|
||||
private function blacklistToken(array $decoded): void
|
||||
{
|
||||
$jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '').($decoded['iat'] ?? ''));
|
||||
$cacheKey = "blacklisted_token:{$jti}";
|
||||
|
||||
Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded));
|
||||
|
||||
$tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null;
|
||||
$tokenType = $decoded['type'] ?? 'access';
|
||||
|
||||
$record = TenantToken::query()->where('jti', $jti)->first();
|
||||
if ($record) {
|
||||
$record->update([
|
||||
'revoked_at' => now(),
|
||||
'expires_at' => $record->expires_at ?? now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tenantId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
TenantToken::create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'tenant_id' => $tenantId,
|
||||
'jti' => $jti,
|
||||
'token_type' => $tokenType,
|
||||
'expires_at' => now(),
|
||||
'revoked_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token has required scopes
|
||||
*/
|
||||
private function hasScopes(array $decoded, array $requiredScopes): bool
|
||||
{
|
||||
$tokenScopes = $this->normaliseScopes($decoded['scopes'] ?? []);
|
||||
|
||||
foreach ($requiredScopes as $scope) {
|
||||
if (! in_array($scope, $tokenScopes, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function normaliseScopes(mixed $scopes): array
|
||||
{
|
||||
if (is_array($scopes)) {
|
||||
return array_values(array_filter($scopes, fn ($scope) => $scope !== null && $scope !== ''));
|
||||
}
|
||||
|
||||
if (is_string($scopes)) {
|
||||
return array_values(array_filter(explode(' ', $scopes)));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function cacheTtlFromDecoded(array $decoded): int
|
||||
{
|
||||
$exp = $decoded['exp'] ?? time();
|
||||
$ttl = max($exp - time(), 60);
|
||||
|
||||
return $ttl;
|
||||
}
|
||||
|
||||
private function errorResponse(string $code, string $title, string $message, int $status, array $meta = []): JsonResponse
|
||||
{
|
||||
return ApiError::response($code, $title, $message, $status, $meta);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user