singleloginsystem/app/Http/Controllers/SamlIdpController.php
= aaa258903c Feat: Add enhanced SAML configuration and attribute mapping support
- 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.
2026-06-08 12:56:28 +00:00

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',
]);
}
}