405 lines
13 KiB
PHP
405 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\{ConnectedApp, User};
|
|
use DOMDocument;
|
|
use DOMElement;
|
|
use Exception;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Str;
|
|
use RobRichards\XMLSecLibs\{XMLSecurityDSig, XMLSecurityKey};
|
|
use RuntimeException;
|
|
|
|
final class SamlIdpService
|
|
{
|
|
/**
|
|
* Decode and parse an incoming SAMLRequest (GET/POST).
|
|
*
|
|
* @return array{requestId: string, issuer: string, acsUrl: string}
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function parseRequest(string $samlRequest): array
|
|
{
|
|
$decoded = base64_decode($samlRequest, true);
|
|
if (false === $decoded) {
|
|
throw new Exception('Failed to base64 decode SAMLRequest.');
|
|
}
|
|
|
|
// Try deflated (GET Redirect binding)
|
|
$xml = @gzinflate($decoded);
|
|
if (false === $xml) {
|
|
// Fallback to raw decoded XML (POST binding)
|
|
$xml = $decoded;
|
|
}
|
|
|
|
$dom = new DOMDocument();
|
|
$dom->preserveWhiteSpace = false;
|
|
if (! @$dom->loadXML($xml)) {
|
|
throw new Exception('Failed to load SAMLRequest XML.');
|
|
}
|
|
|
|
$issuerNode = $dom
|
|
->getElementsByTagNameNS(
|
|
'urn:oasis:names:tc:SAML:2.0:assertion',
|
|
'Issuer',
|
|
)
|
|
->item(0);
|
|
if (! $issuerNode) {
|
|
$issuerNode = $dom->getElementsByTagName('Issuer')->item(0);
|
|
}
|
|
$issuer = $issuerNode ? mb_trim($issuerNode->nodeValue) : '';
|
|
|
|
$requestId = $dom->documentElement->getAttribute('ID');
|
|
$acsUrl = $dom->documentElement->getAttribute(
|
|
'AssertionConsumerServiceURL',
|
|
);
|
|
|
|
return [
|
|
'requestId' => $requestId,
|
|
'issuer' => $issuer,
|
|
'acsUrl' => $acsUrl,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Verify if the SAML SP Issuer is registered as a connected app and active.
|
|
*/
|
|
public function findConnectedApp(string $issuer): ?ConnectedApp
|
|
{
|
|
return ConnectedApp::query()
|
|
->where('settings->saml->entity_id', $issuer)
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* Generate the XML SAML metadata for this Identity Provider.
|
|
*/
|
|
public function generateMetadata(
|
|
string $idpEntityId,
|
|
string $ssoUrl,
|
|
): string {
|
|
$certPem = $this->getPublicCertificate();
|
|
$certClean = $this->cleanPem($certPem);
|
|
|
|
$xml = <<<XML
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<EntityDescriptor entityID="{$idpEntityId}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
|
|
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<KeyDescriptor use="signing">
|
|
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
|
<ds:X509Data>
|
|
<ds:X509Certificate>{$certClean}</ds:X509Certificate>
|
|
</ds:X509Data>
|
|
</ds:KeyInfo>
|
|
</KeyDescriptor>
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{$ssoUrl}"/>
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{$ssoUrl}"/>
|
|
</IDPSSODescriptor>
|
|
</EntityDescriptor>
|
|
XML;
|
|
|
|
return $xml;
|
|
}
|
|
|
|
/**
|
|
* Generate a signed SAMLResponse XML for a successful authentication.
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function generateResponse(
|
|
User $user,
|
|
ConnectedApp $app,
|
|
string $requestId,
|
|
string $acsUrl,
|
|
): string {
|
|
$idpEntityId = route('saml.metadata');
|
|
/**
|
|
* @var array{
|
|
* saml?: array{
|
|
* entity_id?: string,
|
|
* acs_url?: string
|
|
* }
|
|
* } $settings
|
|
*/
|
|
$settings = (array) $app->settings;
|
|
$spEntityId = $settings['saml']['entity_id'] ?? $acsUrl;
|
|
|
|
$responseId = '_'.Str::uuid()->toString();
|
|
$assertionId = '_'.Str::uuid()->toString();
|
|
|
|
$now = time();
|
|
$issueInstant = gmdate("Y-m-d\TH:i:s\Z", $now);
|
|
$notBefore = gmdate("Y-m-d\TH:i:s\Z", $now - 30); // 30 sec skew tolerance
|
|
$expireInstant = gmdate("Y-m-d\TH:i:s\Z", $now + 300); // 5 min validity
|
|
|
|
$immutableId = $user->immutable_id ?? base64_encode((string) $user->id);
|
|
|
|
$dom = new DOMDocument('1.0', 'UTF-8');
|
|
$dom->preserveWhiteSpace = false;
|
|
|
|
$response = $dom->createElementNS(
|
|
'urn:oasis:names:tc:SAML:2.0:protocol',
|
|
'samlp:Response',
|
|
);
|
|
$response->setAttribute('ID', $responseId);
|
|
$response->setAttribute('InResponseTo', $requestId);
|
|
$response->setAttribute('Version', '2.0');
|
|
$response->setAttribute('IssueInstant', $issueInstant);
|
|
$response->setAttribute('Destination', $acsUrl);
|
|
|
|
$issuer = $dom->createElementNS(
|
|
'urn:oasis:names:tc:SAML:2.0:assertion',
|
|
'saml:Issuer',
|
|
$idpEntityId,
|
|
);
|
|
$response->appendChild($issuer);
|
|
|
|
$status = $dom->createElement('samlp:Status');
|
|
$statusCode = $dom->createElement('samlp:StatusCode');
|
|
$statusCode->setAttribute(
|
|
'Value',
|
|
'urn:oasis:names:tc:SAML:2.0:status:Success',
|
|
);
|
|
$status->appendChild($statusCode);
|
|
$response->appendChild($status);
|
|
|
|
$assertion = $dom->createElementNS(
|
|
'urn:oasis:names:tc:SAML:2.0:assertion',
|
|
'saml:Assertion',
|
|
);
|
|
$assertion->setAttribute('ID', $assertionId);
|
|
$assertion->setAttribute('Version', '2.0');
|
|
$assertion->setAttribute('IssueInstant', $issueInstant);
|
|
|
|
$assertionIssuer = $dom->createElement('saml:Issuer', $idpEntityId);
|
|
$assertion->appendChild($assertionIssuer);
|
|
|
|
// Subject
|
|
$subject = $dom->createElement('saml:Subject');
|
|
$nameId = $dom->createElement('saml:NameID', $immutableId);
|
|
$nameId->setAttribute(
|
|
'Format',
|
|
'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
|
);
|
|
$subject->appendChild($nameId);
|
|
|
|
$subjectConfirmation = $dom->createElement('saml:SubjectConfirmation');
|
|
$subjectConfirmation->setAttribute(
|
|
'Method',
|
|
'urn:oasis:names:tc:SAML:2.0:cm:bearer',
|
|
);
|
|
$subjectConfirmationData = $dom->createElement(
|
|
'saml:SubjectConfirmationData',
|
|
);
|
|
$subjectConfirmationData->setAttribute('InResponseTo', $requestId);
|
|
$subjectConfirmationData->setAttribute('NotOnOrAfter', $expireInstant);
|
|
$subjectConfirmationData->setAttribute('Recipient', $acsUrl);
|
|
$subjectConfirmation->appendChild($subjectConfirmationData);
|
|
$subject->appendChild($subjectConfirmation);
|
|
$assertion->appendChild($subject);
|
|
|
|
// Conditions
|
|
$conditions = $dom->createElement('saml:Conditions');
|
|
$conditions->setAttribute('NotBefore', $notBefore);
|
|
$conditions->setAttribute('NotOnOrAfter', $expireInstant);
|
|
$audienceRestriction = $dom->createElement('saml:AudienceRestriction');
|
|
$audience = $dom->createElement('saml:Audience', $spEntityId);
|
|
$audienceRestriction->appendChild($audience);
|
|
$conditions->appendChild($audienceRestriction);
|
|
$assertion->appendChild($conditions);
|
|
|
|
// AuthnStatement
|
|
$authnStatement = $dom->createElement('saml:AuthnStatement');
|
|
$authnStatement->setAttribute('AuthnInstant', $issueInstant);
|
|
$authnStatement->setAttribute('SessionIndex', $assertionId);
|
|
$authnContext = $dom->createElement('saml:AuthnContext');
|
|
$authnContextClassRef = $dom->createElement(
|
|
'saml:AuthnContextClassRef',
|
|
'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
|
|
);
|
|
$authnContext->appendChild($authnContextClassRef);
|
|
$authnStatement->appendChild($authnContext);
|
|
$assertion->appendChild($authnStatement);
|
|
|
|
// AttributeStatement (SAML claims)
|
|
$attributeStatement = $dom->createElement('saml:AttributeStatement');
|
|
|
|
// IDPEmail (essential claim for Office 365 mapping to UserPrincipalName)
|
|
$this->addAttribute(
|
|
$dom,
|
|
$attributeStatement,
|
|
'IDPEmail',
|
|
$user->email,
|
|
);
|
|
|
|
// mail
|
|
$this->addAttribute($dom, $attributeStatement, 'mail', $user->email);
|
|
|
|
// UPN
|
|
$this->addAttribute(
|
|
$dom,
|
|
$attributeStatement,
|
|
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn',
|
|
$user->email,
|
|
);
|
|
|
|
// displayName
|
|
$this->addAttribute(
|
|
$dom,
|
|
$attributeStatement,
|
|
'displayName',
|
|
$user->name,
|
|
);
|
|
|
|
// emailaddress (Required by Microsoft SAML 2.0 specifications)
|
|
$this->addAttribute(
|
|
$dom,
|
|
$attributeStatement,
|
|
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
|
$user->email,
|
|
);
|
|
|
|
$assertion->appendChild($attributeStatement);
|
|
$response->appendChild($assertion);
|
|
$dom->appendChild($response);
|
|
|
|
// Sign the assertion node
|
|
$this->signAssertion($dom, $assertion);
|
|
|
|
return base64_encode($dom->saveXML());
|
|
}
|
|
|
|
/**
|
|
* Add a simple attribute to SAML AttributeStatement.
|
|
*/
|
|
private function addAttribute(
|
|
DOMDocument $dom,
|
|
DOMElement $statement,
|
|
string $name,
|
|
string $value,
|
|
): void {
|
|
$attribute = $dom->createElement('saml:Attribute');
|
|
$attribute->setAttribute('Name', $name);
|
|
$attribute->setAttribute(
|
|
'NameFormat',
|
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
|
);
|
|
$val = $dom->createElement(
|
|
'saml:AttributeValue',
|
|
htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8'),
|
|
);
|
|
$attribute->appendChild($val);
|
|
$statement->appendChild($attribute);
|
|
}
|
|
|
|
/**
|
|
* Cryptographically sign the Assertion node in DOMDocument.
|
|
*/
|
|
private function signAssertion(
|
|
DOMDocument $dom,
|
|
DOMElement|DOMDocument $assertion,
|
|
): void {
|
|
$privateKeyPem = $this->getPrivateKey();
|
|
$certPem = $this->getPublicCertificate();
|
|
|
|
// XML Security Signature Setup
|
|
$objDSig = new XMLSecurityDSig();
|
|
$objDSig->setCanonicalMethod(XMLSecurityDSig::C14N);
|
|
|
|
// Explicitly designate Assertion ID attribute so xmlseclibs can locate it correctly
|
|
$assertion->setIdAttribute('ID', true);
|
|
|
|
// Add reference to assertion
|
|
$objDSig->addReference(
|
|
$assertion,
|
|
XMLSecurityDSig::SHA256,
|
|
[
|
|
'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
|
|
'http://www.w3.org/2001/10/xml-exc-c14n#',
|
|
],
|
|
[
|
|
'id_name' => 'ID',
|
|
],
|
|
);
|
|
|
|
$objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, [
|
|
'type' => 'private',
|
|
]);
|
|
$objKey->loadKey($privateKeyPem, false);
|
|
|
|
// Sign the XML
|
|
$objDSig->sign($objKey, $assertion);
|
|
$objDSig->add509Cert($certPem);
|
|
|
|
// Enforce SAML Schema order compliance: Signature node must precede Subject.
|
|
$signatureNode = $assertion
|
|
->getElementsByTagNameNS(
|
|
'http://www.w3.org/2000/09/xmldsig#',
|
|
'Signature',
|
|
)
|
|
->item(0);
|
|
$subjectNode = $assertion
|
|
->getElementsByTagNameNS(
|
|
'urn:oasis:names:tc:SAML:2.0:assertion',
|
|
'Subject',
|
|
)
|
|
->item(0);
|
|
|
|
if ($signatureNode && $subjectNode) {
|
|
$assertion->insertBefore($signatureNode, $subjectNode);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load IdP Private Key.
|
|
*/
|
|
private function getPrivateKey(): string
|
|
{
|
|
$path = storage_path('app/saml/idp.key');
|
|
if (! File::exists($path)) {
|
|
throw new RuntimeException(
|
|
"SAML private key not found at [{$path}]. Run 'php artisan saml:generate-keys'.",
|
|
);
|
|
}
|
|
|
|
return File::get($path);
|
|
}
|
|
|
|
/**
|
|
* Load IdP Public Certificate.
|
|
*/
|
|
private function getPublicCertificate(): string
|
|
{
|
|
$path = storage_path('app/saml/idp.crt');
|
|
if (! File::exists($path)) {
|
|
throw new RuntimeException(
|
|
"SAML public certificate not found at [{$path}]. Run 'php artisan saml:generate-keys'.",
|
|
);
|
|
}
|
|
|
|
return File::get($path);
|
|
}
|
|
|
|
/**
|
|
* Helper to clean up PEM certificate for metadata insertion.
|
|
*/
|
|
private function cleanPem(string $pem): string
|
|
{
|
|
return str_replace(
|
|
[
|
|
'-----BEGIN CERTIFICATE-----',
|
|
'-----END CERTIFICATE-----',
|
|
"\r",
|
|
"\n",
|
|
' ',
|
|
],
|
|
'',
|
|
$pem,
|
|
);
|
|
}
|
|
}
|