- Implemented `EnumMorphsToOptionsContract` interface to enable enums with options and labels for dropdowns. - Created `SamlNameIdFormatEnum` and `SamlNameIdSourceEnum` for standardized SAML NameID configuration. - Added reusable `SAML Configuration` Blade component for easier integration into connected app forms. - Enabled custom SAML Attribute mapping with dynamic add/remove functionality and validation. - Improved `SamlIdpController` to enforce ACS URL matching and user authorization checks. - Refactored SAML-related tests and added scenarios for role-based SAML access and custom configurations.
243 lines
8.8 KiB
PHP
243 lines
8.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\User;
|
|
use App\Services\{SamlIdpService, UserAppAccessService};
|
|
use Illuminate\Http\{Request, Response};
|
|
use Illuminate\Support\Facades\{Auth, Log};
|
|
use Throwable;
|
|
|
|
class SamlIdpController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly SamlIdpService $samlService,
|
|
private readonly UserAppAccessService $userAppAccessService
|
|
) {}
|
|
|
|
/**
|
|
* SAML 2.0 Single Sign-On (SSO) Endpoint.
|
|
* Handles both GET (Redirect binding) and POST (POST binding) requests.
|
|
*/
|
|
public function sso(Request $request)
|
|
{
|
|
$samlRequest = $request->input('SAMLRequest') ?? session('saml_pending_request');
|
|
$relayState = $request->input('RelayState') ?? session('saml_pending_relay_state');
|
|
|
|
Log::info('SAML SSO Request received.', [
|
|
'ip' => $request->ip(),
|
|
'method' => $request->method(),
|
|
'has_saml_request' => ! empty($samlRequest),
|
|
'has_relay_state' => ! empty($relayState),
|
|
'session_has_pending' => session()->has('saml_pending_request'),
|
|
]);
|
|
|
|
if (empty($samlRequest)) {
|
|
Log::warning('SAML SSO access rejected: Missing SAMLRequest parameter.', [
|
|
'ip' => $request->ip(),
|
|
]);
|
|
abort(400, 'Missing SAMLRequest parameter.');
|
|
}
|
|
|
|
try {
|
|
Log::info('Decoding and parsing SAMLRequest XML payload.');
|
|
$parsed = $this->samlService->parseRequest($samlRequest);
|
|
Log::info('SAMLRequest successfully parsed.', [
|
|
'request_id' => $parsed['requestId'],
|
|
'issuer' => $parsed['issuer'],
|
|
'acs_url' => $parsed['acsUrl'],
|
|
]);
|
|
} catch (Throwable $e) {
|
|
Log::error('SAMLRequest parsing failed.', [
|
|
'ip' => $request->ip(),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
abort(400, 'Invalid SAMLRequest: '.$e->getMessage());
|
|
}
|
|
|
|
// Find corresponding Connected App by issuer (SAML Entity ID)
|
|
$connectedApp = $this->samlService->findConnectedApp($parsed['issuer']);
|
|
|
|
if (! $connectedApp) {
|
|
Log::warning('SAML SSO access rejected: Unregistered Service Provider (EntityID / Issuer).', [
|
|
'issuer' => $parsed['issuer'],
|
|
'request_id' => $parsed['requestId'],
|
|
'ip' => $request->ip(),
|
|
]);
|
|
abort(
|
|
403,
|
|
"The Service Provider [{$parsed['issuer']}] is not authorized in this system.",
|
|
);
|
|
}
|
|
|
|
Log::info('SAML ConnectedApp matched successfully.', [
|
|
'app_id' => $connectedApp->id,
|
|
'app_name' => $connectedApp->name,
|
|
'status' => $connectedApp->status?->name,
|
|
]);
|
|
|
|
if ('disconnected' === $connectedApp->status?->name) {
|
|
Log::warning('SAML SSO access rejected: Connected App is deactivated.', [
|
|
'app_id' => $connectedApp->id,
|
|
'app_name' => $connectedApp->name,
|
|
'request_id' => $parsed['requestId'],
|
|
]);
|
|
abort(
|
|
403,
|
|
"The Service Provider [{$connectedApp->name}] is currently deactivated.",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @var array{
|
|
* saml?: array{
|
|
* entity_id?: string,
|
|
* acs_url?: string
|
|
* }
|
|
* } $settings
|
|
*/
|
|
$settings = (array) $connectedApp->settings;
|
|
$configuredAcsUrl = $settings['saml']['acs_url'] ?? null;
|
|
|
|
if (empty($configuredAcsUrl)) {
|
|
Log::error('SAML SSO failed: Assertion Consumer Service (ACS) URL is undefined in Connected App settings.', [
|
|
'app_id' => $connectedApp->id,
|
|
'app_name' => $connectedApp->name,
|
|
'request_id' => $parsed['requestId'],
|
|
]);
|
|
abort(400, 'Assertion Consumer Service (ACS) URL is not defined.');
|
|
}
|
|
|
|
$requestAcsUrl = $parsed['acsUrl'];
|
|
if (! empty($requestAcsUrl)) {
|
|
if (mb_strtolower(urldecode($requestAcsUrl)) !== mb_strtolower(urldecode($configuredAcsUrl))) {
|
|
Log::warning('SAML SSO failed: Request ACS URL does not match configured ACS URL.', [
|
|
'app_id' => $connectedApp->id,
|
|
'app_name' => $connectedApp->name,
|
|
'request_acs_url' => $requestAcsUrl,
|
|
'configured_acs_url' => $configuredAcsUrl,
|
|
'request_id' => $parsed['requestId'],
|
|
]);
|
|
abort(400, 'The Assertion Consumer Service (ACS) URL does not match the configured callback URL.');
|
|
}
|
|
$acsUrl = $requestAcsUrl;
|
|
} else {
|
|
$acsUrl = $configuredAcsUrl;
|
|
}
|
|
|
|
// Authenticate the User
|
|
if (! Auth::check()) {
|
|
Log::info('SAML SSO guest session intercepted. Persisting request parameters to session and redirecting to login.', [
|
|
'request_id' => $parsed['requestId'],
|
|
'relay_state' => $relayState,
|
|
'ip' => $request->ip(),
|
|
]);
|
|
|
|
session([
|
|
'saml_pending_request' => $samlRequest,
|
|
'saml_pending_relay_state' => $relayState,
|
|
]);
|
|
|
|
return redirect()->route('login');
|
|
}
|
|
|
|
/** @var User $user */
|
|
$user = Auth::user();
|
|
|
|
// Enforce user authorization check
|
|
$activeServices = $this->userAppAccessService->getActiveServices($user);
|
|
if (! $activeServices->contains('id', $connectedApp->id)) {
|
|
Log::warning('SAML SSO access rejected: User is not authorized for this Connected App.', [
|
|
'user_id' => $user->id,
|
|
'app_id' => $connectedApp->id,
|
|
'app_name' => $connectedApp->name,
|
|
]);
|
|
abort(403, 'You are not authorized to access this application.');
|
|
}
|
|
|
|
Log::info('SAML SSO user authenticated and authorized session detected. Generating signed SAML Response assertion.', [
|
|
'user_id' => $user->id,
|
|
'user_email' => $user->email,
|
|
'app_id' => $connectedApp->id,
|
|
'app_name' => $connectedApp->name,
|
|
'acs_url' => $acsUrl,
|
|
'request_id' => $parsed['requestId'],
|
|
]);
|
|
|
|
try {
|
|
$samlResponse = $this->samlService->generateResponse(
|
|
$user,
|
|
$connectedApp,
|
|
$parsed['requestId'],
|
|
$acsUrl,
|
|
);
|
|
Log::info('Signed SAML Response assertion successfully generated.');
|
|
} catch (Throwable $e) {
|
|
Log::error('SAML Response generation failed.', [
|
|
'user_id' => $user->id,
|
|
'app_id' => $connectedApp->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
abort(500, 'Cryptographic signing of SAML Response failed.');
|
|
}
|
|
|
|
// Clear pending SAML request from session
|
|
session()->forget(['saml_pending_request', 'saml_pending_relay_state']);
|
|
Log::info('Pending SAML session parameters cleared.');
|
|
|
|
Log::info('Rendering SAML HTTP-POST auto-submission redirect form.', [
|
|
'user_id' => $user->id,
|
|
'app_name' => $connectedApp->name,
|
|
'acs_url' => $acsUrl,
|
|
'relay_state' => $relayState,
|
|
]);
|
|
|
|
// Return HTTP-POST Auto-Submission Page
|
|
return view('saml.post_response', [
|
|
'appName' => $connectedApp->name,
|
|
'acsUrl' => $acsUrl,
|
|
'samlResponse' => $samlResponse,
|
|
'relayState' => $relayState,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* SAML 2.0 Identity Provider (IdP) Metadata Endpoint.
|
|
* Exposes public keys and bindings to Service Providers like Microsoft Entra ID.
|
|
*/
|
|
public function metadata(): Response
|
|
{
|
|
$idpEntityId = route('saml.metadata');
|
|
$ssoUrl = route('saml.sso');
|
|
|
|
Log::info('SAML IdP Metadata requested.', [
|
|
'ip' => request()->ip(),
|
|
'user_agent' => request()->userAgent(),
|
|
'entity_id' => $idpEntityId,
|
|
'sso_url' => $ssoUrl,
|
|
]);
|
|
|
|
try {
|
|
$metadataXml = $this->samlService->generateMetadata(
|
|
$idpEntityId,
|
|
$ssoUrl,
|
|
);
|
|
Log::info('SAML IdP Metadata XML descriptor generated successfully.');
|
|
} catch (Throwable $e) {
|
|
Log::error('SAML IdP Metadata XML generation failed.', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
abort(
|
|
500,
|
|
'Failed to retrieve Identity Provider cryptographic identity.',
|
|
);
|
|
}
|
|
|
|
return response($metadataXml, 200, [
|
|
'Content-Type' => 'application/xml; charset=utf-8',
|
|
]);
|
|
}
|
|
}
|