Single Sign-On implementieren: SAML, OAuth, LDAP
“Unsere Mitarbeiter haben 12 verschiedene Logins. Das muss doch einfacher gehen.” - Ja, muss es. Und ja, geht es. Single Sign-On (SSO) ist keine Raketenwissenschaft, aber auch kein Wochenend-Projekt. Nach 50+ SSO-Integrationen zeige ich Ihnen, welches Protokoll wann passt, wie die Implementierung aussieht und wo die Fallstricke liegen.
Die harte Wahrheit vorweg: 80% der SSO-Projekte scheitern nicht an der Technik, sondern an der Koordination mit der IT-Abteilung des Kunden. Der Code ist das kleinste Problem.
Quick-Navigation: Welches Protokoll für Sie?
| Situation | Empfehlung | Warum |
|---|---|---|
| Konzern mit Azure AD | SAML 2.0 | Standard in Enterprise, IT kennt es |
| SaaS-Produkt mit Google/Microsoft Login | OAuth 2.0 / OIDC | Einfach für User, schnell implementiert |
| On-Premise mit Active Directory | LDAP | Direkte AD-Anbindung, kein IdP nötig |
| Hybrid (Cloud + On-Prem) | SAML + LDAP Fallback | Flexibel, zukunftssicher |
Die drei Protokolle im Vergleich
SAML 2.0 - Der Enterprise-Standard
Security Assertion Markup Language - XML-basiert, seit 2005, der De-facto-Standard in Konzernen.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ User │ │ Service │ │ Identity │
│ (Browser) │ │ Provider │ │ Provider │
│ │ │ (Ihre App) │ │ (Azure AD) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
│ 1. Zugriff auf App │ │
│───────────────────────>│ │
│ │ │
│ 2. Redirect zu IdP │ │
│<───────────────────────│ │
│ │ │
│ 3. Login beim IdP │ │
│────────────────────────────────────────────────>│
│ │ │
│ 4. SAML Response │ │
│<────────────────────────────────────────────────│
│ │ │
│ 5. POST Response │ │
│───────────────────────>│ │
│ │ │
│ 6. Session erstellt │ │
│<───────────────────────│ │
Vorteile:
- Jede Enterprise-IT kennt es
- Azure AD, Okta, OneLogin - alle unterstützen es
- Keine Passwörter in Ihrer App
- Attribute Mapping (Rollen, Abteilung, etc.)
Nachteile:
- XML-Hölle (Signaturen, Namespaces)
- Debugging ist schmerzhaft
- Zertifikatsmanagement nötig
OAuth 2.0 vs. OpenID Connect - Der Unterschied
Klassisches Missverständnis: OAuth 2.0 und OIDC sind NICHT dasselbe!
| OAuth 2.0 | OpenID Connect (OIDC) | |
|---|---|---|
| Zweck | Autorisierung (Zugriff auf Ressourcen) | Authentifizierung (Wer ist der User?) |
| Token | Access Token | Access Token + ID Token |
| Antwort auf | ”Darf diese App auf meine Daten?" | "Wer bin ich?” |
| User-Info | Muss extra geholt werden | Im ID Token enthalten |
OIDC = OAuth 2.0 + Identity Layer (ID Token)
Wenn Sie “Login mit Google/Microsoft” bauen, nutzen Sie OIDC (nicht nur OAuth). Das ID Token ist ein JWT mit User-Informationen (sub, email, name).
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ User │ │ Ihre App │ │ Google/ │
│ (Browser) │ │ (Client) │ │ Microsoft │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
│ 1. "Login mit Google" │ │
│───────────────────────>│ │
│ │ │
│ 2. Redirect zu Google │ │
│<───────────────────────│ │
│ │ │
│ 3. Login + Consent │ │
│────────────────────────────────────────────────>│
│ │ │
│ 4. Redirect mit Code │ │
│<────────────────────────────────────────────────│
│ │ │
│ 5. Code an App │ │
│───────────────────────>│ │
│ │ 6. Code gegen Token │
│ │───────────────────────>│
│ │ │
│ │ 7. Access + ID Token │
│ │<───────────────────────│
│ 8. Session erstellt │ │
│<───────────────────────│ │
Vorteile:
- JSON statt XML
- Moderne Libraries
- “Login mit Google/Microsoft” in 2 Stunden
- Gut für SaaS und Consumer-Apps
Nachteile:
- Weniger Attribute als SAML
- Nicht jede Enterprise-IT akzeptiert es
- Token-Management komplexer
LDAP/Active Directory - Direktanbindung
Lightweight Directory Access Protocol - Direkte Abfrage des Unternehmensverzeichnisses.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ User │ │ Ihre App │ │ Active │
│ (Browser) │ │ │ │ Directory │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
│ 1. Login-Formular │ │
│───────────────────────>│ │
│ │ │
│ │ 2. LDAP Bind │
│ │───────────────────────>│
│ │ │
│ │ 3. Bind OK / Fail │
│ │<───────────────────────│
│ │ │
│ │ 4. User-Attribute │
│ │───────────────────────>│
│ │ │
│ │ 5. Gruppen, DN, etc. │
│ │<───────────────────────│
│ 6. Session erstellt │ │
│<───────────────────────│ │
Vorteile:
- Kein IdP nötig
- Echtzeit-Prüfung (User gesperrt = sofort draußen)
- Alle AD-Attribute verfügbar
- Funktioniert auch ohne Internet
Nachteile:
- Passwort geht durch Ihre App (Security-Risiko)
- VPN/Firewall-Regeln nötig
- Kein SSO im echten Sinn (User gibt Passwort ein)
Entscheidungsmatrix
| Kriterium | SAML 2.0 | OAuth/OIDC | LDAP |
|---|---|---|---|
| Enterprise-Akzeptanz | Sehr hoch | Mittel | Hoch |
| Implementierungsaufwand | 40-80h | 16-32h | 24-40h |
| Echtes SSO | Ja | Ja | Nein |
| Passwort in App | Nein | Nein | Ja |
| Offline-fähig | Nein | Nein | Ja (mit Cache) |
| Attribute/Rollen | Sehr gut | Gut | Sehr gut |
| Debugging | Schwer | Einfach | Mittel |
| Zertifikate nötig | Ja | Nein | Optional |
Merksatz: SAML für Enterprise, OAuth/OIDC für SaaS, LDAP für On-Premise.
SAML 2.0 Implementierung (PHP)
Schritt 1: Service Provider Metadata
Ihr IdP (z.B. Azure AD) braucht Informationen über Ihre App:
class SamlServiceProvider {
private string $entityId;
private string $acsUrl;
private string $sloUrl;
private string $certificate;
public function __construct(array $config) {
$this->entityId = $config['entity_id']; // z.B. https://ihre-app.de/saml
$this->acsUrl = $config['acs_url']; // Assertion Consumer Service
$this->sloUrl = $config['slo_url']; // Single Logout
$this->certificate = $config['certificate'];
}
public function generateMetadata(): string {
$xml = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="{$this->entityId}">
<md:SPSSODescriptor AuthnRequestsSigned="true"
WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>{$this->getCertificateContent()}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="{$this->acsUrl}"
index="0"
isDefault="true"/>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="{$this->sloUrl}"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>
XML;
return $xml;
}
private function getCertificateContent(): string {
$cert = file_get_contents($this->certificate);
$cert = str_replace(['-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----', "\n", "\r"], '', $cert);
return $cert;
}
}
Schritt 2: AuthnRequest erstellen
class SamlAuthRequest {
private string $idpSsoUrl;
private string $entityId;
private string $acsUrl;
public function createRequest(): string {
$id = '_' . bin2hex(random_bytes(16));
$issueInstant = gmdate('Y-m-d\TH:i:s\Z');
$request = <<<XML
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{$id}"
Version="2.0"
IssueInstant="{$issueInstant}"
Destination="{$this->idpSsoUrl}"
AssertionConsumerServiceURL="{$this->acsUrl}"
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
<saml:Issuer>{$this->entityId}</saml:Issuer>
<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
AllowCreate="true"/>
</samlp:AuthnRequest>
XML;
// Request in Session speichern für Validierung
$_SESSION['saml_request_id'] = $id;
return $request;
}
public function redirect(): void {
$request = $this->createRequest();
$encodedRequest = base64_encode(gzdeflate($request));
$url = $this->idpSsoUrl . '?' . http_build_query([
'SAMLRequest' => $encodedRequest,
'RelayState' => $_SESSION['return_url'] ?? '/',
]);
header('Location: ' . $url);
exit;
}
}
Schritt 3: SAML Response verarbeiten
class SamlResponseHandler {
private string $idpCertificate;
private string $expectedAudience;
private int $clockSkewLeeway = 300; // 5 Minuten Toleranz
public function handleResponse(string $samlResponse): ?SamlUser {
$xml = base64_decode($samlResponse);
$doc = new DOMDocument();
$doc->loadXML($xml);
// 1. Signatur validieren (NUR signierte Assertion!)
if (!$this->validateSignature($doc)) {
throw new SamlException('Ungültige Signatur');
}
// 2. Zeitstempel prüfen (mit Leeway!)
if (!$this->validateTimestamps($doc)) {
throw new SamlException('Response abgelaufen');
}
// 3. Audience prüfen
if (!$this->validateAudience($doc)) {
throw new SamlException('Falscher Empfänger');
}
// 4. InResponseTo prüfen (gegen Session)
if (!$this->validateInResponseTo($doc)) {
throw new SamlException('Request-ID stimmt nicht');
}
// 5. User-Daten extrahieren
return $this->extractUser($doc);
}
private function validateSignature(DOMDocument $doc): bool {
$xpath = new DOMXPath($doc);
$xpath->registerNamespace('ds', 'http://www.w3.org/2000/09/xmldsig#');
$xpath->registerNamespace('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
// WICHTIG: Nur die signierte Assertion akzeptieren!
// XPath fest verdrahten gegen XML Signature Wrapping
$signatureNode = $xpath->query('//saml:Assertion/ds:Signature')->item(0);
if (!$signatureNode) {
// Fallback: Response-Level Signatur
$signatureNode = $xpath->query('/samlp:Response/ds:Signature')->item(0);
}
if (!$signatureNode) {
return false;
}
// XMLSecurityDSig aus xmlseclibs verwenden
$objXMLSecDSig = new XMLSecurityDSig();
$objXMLSecDSig->sigNode = $signatureNode;
$objXMLSecDSig->canonicalizeSignedInfo();
$key = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'public']);
$key->loadKey($this->idpCertificate, true, true);
return $objXMLSecDSig->verify($key) === 1;
}
private function validateTimestamps(DOMDocument $doc): bool {
$xpath = new DOMXPath($doc);
$xpath->registerNamespace('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
$now = time();
// NotBefore prüfen (mit Leeway)
$notBefore = $xpath->query('//saml:Conditions/@NotBefore')->item(0)?->nodeValue;
if ($notBefore) {
$notBeforeTime = strtotime($notBefore);
if ($now < ($notBeforeTime - $this->clockSkewLeeway)) {
return false;
}
}
// NotOnOrAfter prüfen (mit Leeway)
$notOnOrAfter = $xpath->query('//saml:Conditions/@NotOnOrAfter')->item(0)?->nodeValue;
if ($notOnOrAfter) {
$notOnOrAfterTime = strtotime($notOnOrAfter);
if ($now >= ($notOnOrAfterTime + $this->clockSkewLeeway)) {
return false;
}
}
return true;
}
private function extractUser(DOMDocument $doc): SamlUser {
$xpath = new DOMXPath($doc);
$xpath->registerNamespace('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
// NUR einen Assertion-Knoten parsen!
$assertions = $xpath->query('//saml:Assertion');
if ($assertions->length !== 1) {
throw new SamlException('Genau eine Assertion erwartet');
}
$nameId = $xpath->query('//saml:NameID')->item(0)?->nodeValue;
// Attribute extrahieren
$attributes = [];
$attributeNodes = $xpath->query('//saml:Attribute');
foreach ($attributeNodes as $attr) {
$name = $attr->getAttribute('Name');
$value = $xpath->query('.//saml:AttributeValue', $attr)->item(0)?->nodeValue;
$attributes[$name] = $value;
}
return new SamlUser(
email: $nameId,
firstName: $attributes['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'] ?? '',
lastName: $attributes['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'] ?? '',
groups: $this->parseGroups($attributes),
rawAttributes: $attributes
);
}
private function parseGroups(array $attributes): array {
$groupClaim = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups';
$groups = $attributes[$groupClaim] ?? '';
if (is_string($groups)) {
return [$groups];
}
return $groups;
}
}
SAML Security-Fallen
Die 5 SAML-Angriffe, die Sie kennen müssen
| Angriff | Beschreibung | Schutz |
|---|---|---|
| XML Signature Wrapping | Angreifer schiebt unsignierte Assertion ein | XPath fest verdrahten, nur signierte Assertion parsen |
| Multiple Assertions | Mehrere Assertions, nur eine signiert | Genau EINE Assertion akzeptieren |
| Replay Attack | Response wird erneut gesendet | InResponseTo gegen Session prüfen, Response-ID speichern |
| Audience Confusion | Response für andere App | Audience gegen eigene Entity-ID prüfen |
| Certificate Injection | Falsches IdP-Zertifikat | Nur pinned/vertrauenswürdige IdP-Zertifikate, Metadata-Signatur prüfen |
Checkliste:
- XPath für Signatur fest verdrahten (nicht
//ds:Signature) - Nur einen Assertion-Knoten parsen
- Audience und InResponseTo validieren
- NotBefore/NotOnOrAfter mit Leeway prüfen (300s = 5 Min)
- Nur vertrauenswürdige IdP-Zertifikate akzeptieren
- Metadata-Signatur prüfen (wenn dynamisch geladen)
Merksatz: SAML-Validierung ist wie ein Flugzeug-Checklist - eine vergessene Prüfung kann fatal sein.
OAuth 2.0 / OIDC Implementierung
PKCE, State, Nonce - Die Sicherheits-Trias
Drei Parameter, drei Schutzziele:
| Parameter | Schützt gegen | Wann nötig |
|---|---|---|
| state | CSRF-Angriffe | Immer |
| nonce | Replay des ID Tokens | Bei OIDC (ID Token) |
| PKCE | Code Interception | SPAs, Mobile, auch Web-Apps |
PKCE (Proof Key for Code Exchange):
- Auch für Web-Apps empfohlen (nicht nur SPAs!)
- Schützt den Authorization Code vor Interception
OIDC mit PKCE (Code Flow)
class OidcProvider {
private string $clientId;
private string $clientSecret;
private string $redirectUri;
private string $authorizationEndpoint;
private string $tokenEndpoint;
private int $clockSkewLeeway = 300; // 5 Minuten
public function getAuthUrl(): string {
// State für CSRF-Schutz
$state = bin2hex(random_bytes(16));
$_SESSION['oauth_state'] = $state;
// Nonce für Replay-Schutz des ID Tokens
$nonce = bin2hex(random_bytes(16));
$_SESSION['oauth_nonce'] = $nonce;
// PKCE: Code Verifier und Challenge
$codeVerifier = $this->generateCodeVerifier();
$_SESSION['oauth_code_verifier'] = $codeVerifier;
$codeChallenge = $this->generateCodeChallenge($codeVerifier);
$params = [
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'response_type' => 'code',
'scope' => 'openid email profile',
'state' => $state,
'nonce' => $nonce,
// PKCE Parameter
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
];
return $this->authorizationEndpoint . '?' . http_build_query($params);
}
private function generateCodeVerifier(): string {
// 43-128 Zeichen, URL-safe
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
private function generateCodeChallenge(string $verifier): string {
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
}
public function handleCallback(string $code, string $state): OAuthUser {
// 1. State validieren (CSRF-Schutz)
if (!hash_equals($_SESSION['oauth_state'] ?? '', $state)) {
throw new OAuthException('State mismatch - möglicher CSRF-Angriff');
}
// 2. Code gegen Tokens tauschen (mit PKCE)
$tokens = $this->exchangeCodeForTokens($code);
// 3. ID Token validieren
$idTokenPayload = $this->validateIdToken($tokens['id_token']);
// 4. Nonce validieren (Replay-Schutz)
if (!hash_equals($_SESSION['oauth_nonce'] ?? '', $idTokenPayload['nonce'] ?? '')) {
throw new OAuthException('Nonce mismatch - möglicher Replay-Angriff');
}
// Session aufräumen
unset($_SESSION['oauth_state'], $_SESSION['oauth_nonce'], $_SESSION['oauth_code_verifier']);
return new OAuthUser(
id: $idTokenPayload['sub'],
email: $idTokenPayload['email'],
emailVerified: $idTokenPayload['email_verified'] ?? false,
name: $idTokenPayload['name'] ?? '',
provider: 'oidc'
);
}
private function exchangeCodeForTokens(string $code): array {
$response = $this->httpPost($this->tokenEndpoint, [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $this->redirectUri,
// PKCE: Code Verifier mitsenden
'code_verifier' => $_SESSION['oauth_code_verifier'],
]);
if (isset($response['error'])) {
throw new OAuthException($response['error_description'] ?? $response['error']);
}
return $response;
}
private function validateIdToken(string $idToken): array {
$parts = explode('.', $idToken);
if (count($parts) !== 3) {
throw new OAuthException('Ungültiges JWT-Format');
}
$payload = json_decode(
base64_decode(strtr($parts[1], '-_', '+/')),
true
);
// Zeitstempel mit Leeway prüfen
$now = time();
// iat (issued at) - nicht zu weit in der Zukunft
if (isset($payload['iat']) && $payload['iat'] > ($now + $this->clockSkewLeeway)) {
throw new OAuthException('ID Token issued in the future');
}
// exp (expiration) - nicht abgelaufen
if (isset($payload['exp']) && $payload['exp'] < ($now - $this->clockSkewLeeway)) {
throw new OAuthException('ID Token expired');
}
// Audience prüfen
$aud = $payload['aud'] ?? '';
if (is_array($aud) ? !in_array($this->clientId, $aud) : $aud !== $this->clientId) {
throw new OAuthException('Invalid audience');
}
// In Produktion: Signatur gegen JWKS validieren!
return $payload;
}
private function httpPost(string $url, array $data): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
}
Microsoft Azure AD (Enterprise)
class AzureOAuthProvider {
private string $tenantId;
private string $clientId;
private string $clientSecret;
private string $redirectUri;
public function getAuthUrl(): string {
$state = bin2hex(random_bytes(16));
$nonce = bin2hex(random_bytes(16));
$_SESSION['oauth_state'] = $state;
$_SESSION['oauth_nonce'] = $nonce;
$baseUrl = "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize";
$params = [
'client_id' => $this->clientId,
'response_type' => 'code',
'redirect_uri' => $this->redirectUri,
'response_mode' => 'query',
'scope' => 'openid profile email User.Read',
'state' => $state,
'nonce' => $nonce,
];
return $baseUrl . '?' . http_build_query($params);
}
public function handleCallback(string $code, string $state): OAuthUser {
if (!hash_equals($_SESSION['oauth_state'] ?? '', $state)) {
throw new OAuthException('State mismatch');
}
$tokenUrl = "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token";
$tokens = $this->httpPost($tokenUrl, [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $this->redirectUri,
]);
// Microsoft Graph API für User-Details
$user = $this->fetchMicrosoftUser($tokens['access_token']);
return new OAuthUser(
id: $user['id'],
email: $user['mail'] ?? $user['userPrincipalName'],
emailVerified: true,
name: $user['displayName'],
picture: null,
provider: 'azure',
extra: [
'department' => $user['department'] ?? null,
'jobTitle' => $user['jobTitle'] ?? null,
'officeLocation' => $user['officeLocation'] ?? null,
]
);
}
private function fetchMicrosoftUser(string $accessToken): array {
$ch = curl_init('https://graph.microsoft.com/v1.0/me');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $accessToken,
],
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
}
Merksatz: OAuth/OIDC ist JSON statt XML - das allein spart 50% Debugging-Zeit.
Token-Hygiene (OIDC)
Best Practices für Token-Management
| Token-Typ | Empfohlene Lifetime | Rotation |
|---|---|---|
| Access Token | 5-15 Minuten | Bei jedem Refresh |
| Refresh Token | 24h - 7 Tage | Rotating (neues bei Nutzung) |
| ID Token | Nur zur Authentifizierung | Nicht cachen |
Wichtige Regeln:
-
Access Tokens kurzlebig halten - 5-15 Minuten reichen
-
Rotating Refresh Tokens - bei jeder Nutzung neues Token ausgeben
-
Token Revocation bei:
- Logout
- Passwort-Änderung
- Account-Sperrung
- Verdächtige Aktivität
-
Key Rotation:
- JWKS Keys regelmäßig rotieren
- Alte Keys 24h nach Rotation noch akzeptieren
- Alarm 30 Tage vor Zertifikats-Ablauf
class TokenRevocationService {
private PDO $db;
public function revokeAllUserTokens(int $userId, string $reason): void {
// Alle Refresh Tokens invalidieren
$this->db->prepare("
UPDATE refresh_tokens
SET revoked_at = NOW(), revoke_reason = ?
WHERE user_id = ? AND revoked_at IS NULL
")->execute([$reason, $userId]);
// Alle aktiven Sessions beenden
$this->db->prepare("
DELETE FROM sessions WHERE user_id = ?
")->execute([$userId]);
// Audit-Log
$this->logRevocation($userId, $reason);
}
public function revokeOnPasswordChange(int $userId): void {
$this->revokeAllUserTokens($userId, 'password_change');
}
public function revokeOnSuspiciousActivity(int $userId): void {
$this->revokeAllUserTokens($userId, 'suspicious_activity');
}
}
LDAP/Active Directory Implementierung
Sichere LDAP-Anbindung
LDAP-Härtung Checkliste
- Nur LDAPS (Port 636) oder StartTLS - niemals Klartext!
- ldap_escape() immer verwenden - LDAP-Injection ist real
- Account-Lockout beachten - Backoff nach fehlgeschlagenen Versuchen
- Timeouts setzen - gegen DoS
- Größenlimits - gegen Resource Exhaustion
- Leere Passwörter ablehnen - LDAP erlaubt Anonymous Bind!
class SecureLdapAuthenticator {
private string $host;
private int $port;
private string $baseDn;
private string $adminDn;
private string $adminPassword;
private bool $useTls;
private int $timeout = 10;
private int $sizeLimit = 1;
public function __construct(array $config) {
$this->host = $config['host'];
// LDAPS (636) oder LDAP mit StartTLS (389)
$this->port = $config['port'] ?? 636;
$this->baseDn = $config['base_dn'];
$this->adminDn = $config['admin_dn'];
$this->adminPassword = $config['admin_password'];
$this->useTls = $config['use_tls'] ?? ($this->port === 389);
}
public function authenticate(string $username, string $password): ?LdapUser {
// WICHTIG: Leere Passwörter ablehnen!
// LDAP erlaubt Anonymous Bind mit leerem Passwort
if (empty($password) || strlen($password) < 1) {
return null;
}
$connection = $this->connect();
try {
// 1. User-DN finden (mit Admin-Credentials)
$userDn = $this->findUserDn($connection, $username);
if (!$userDn) {
// Timing-Attack verhindern
usleep(random_int(100000, 300000));
return null;
}
// 2. User-Bind versuchen (Passwort prüfen)
// Suppress Errors wegen Account-Lockout
if (!@ldap_bind($connection, $userDn, $password)) {
$this->handleFailedBind($username);
return null;
}
// 3. User-Attribute laden
return $this->loadUserAttributes($connection, $userDn);
} finally {
ldap_close($connection);
}
}
private function connect() {
// Für LDAPS: ldaps://host:636
$uri = ($this->port === 636 ? 'ldaps://' : 'ldap://') . $this->host . ':' . $this->port;
$connection = ldap_connect($uri);
if (!$connection) {
throw new LdapException('Verbindung fehlgeschlagen');
}
ldap_set_option($connection, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($connection, LDAP_OPT_REFERRALS, 0);
// Timeouts setzen
ldap_set_option($connection, LDAP_OPT_NETWORK_TIMEOUT, $this->timeout);
ldap_set_option($connection, LDAP_OPT_TIMELIMIT, $this->timeout);
// Größenlimit
ldap_set_option($connection, LDAP_OPT_SIZELIMIT, $this->sizeLimit);
// StartTLS für Port 389
if ($this->useTls && $this->port === 389) {
if (!ldap_start_tls($connection)) {
throw new LdapException('TLS-Verbindung fehlgeschlagen - Klartext nicht erlaubt');
}
}
// Admin-Bind für User-Suche
if (!ldap_bind($connection, $this->adminDn, $this->adminPassword)) {
throw new LdapException('Admin-Bind fehlgeschlagen');
}
return $connection;
}
private function findUserDn($connection, string $username): ?string {
// LDAP-Injection verhindern!
$escapedUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
$filter = "(|(sAMAccountName={$escapedUsername})(userPrincipalName={$escapedUsername})(mail={$escapedUsername}))";
$result = @ldap_search($connection, $this->baseDn, $filter, ['dn'], 0, $this->sizeLimit, $this->timeout);
if (!$result) {
return null;
}
$entries = ldap_get_entries($connection, $result);
if ($entries['count'] === 0) {
return null;
}
return $entries[0]['dn'];
}
private function handleFailedBind(string $username): void {
// Rate-Limiting / Backoff implementieren
// Account-Lockout-Threshold des AD beachten (default: 5 Versuche)
error_log("LDAP: Failed bind for user: " . $username);
}
private function loadUserAttributes($connection, string $userDn): LdapUser {
$attributes = [
'sAMAccountName',
'userPrincipalName',
'mail',
'givenName',
'sn',
'displayName',
'memberOf',
'department',
'title',
'telephoneNumber',
];
$result = ldap_read($connection, $userDn, '(objectClass=*)', $attributes);
$entry = ldap_get_entries($connection, $result)[0];
return new LdapUser(
dn: $userDn,
username: $entry['samaccountname'][0] ?? '',
email: $entry['mail'][0] ?? $entry['userprincipalname'][0] ?? '',
firstName: $entry['givenname'][0] ?? '',
lastName: $entry['sn'][0] ?? '',
displayName: $entry['displayname'][0] ?? '',
groups: $this->parseGroups($entry['memberof'] ?? []),
department: $entry['department'][0] ?? null,
title: $entry['title'][0] ?? null,
);
}
private function parseGroups(array $memberOf): array {
$groups = [];
foreach ($memberOf as $key => $dn) {
if ($key === 'count') continue;
if (preg_match('/^CN=([^,]+)/i', $dn, $matches)) {
$groups[] = $matches[1];
}
}
return $groups;
}
}
Merksatz: LDAP ohne TLS ist wie Passwörter per Postkarte - jeder kann mitlesen.
Logout-Realität: SLO ist “Best Effort”
Die unbequeme Wahrheit über Single Logout
SLO funktioniert oft nicht zuverlässig:
| Protokoll | SLO-Methode | Problem |
|---|---|---|
| SAML | Front-Channel (Browser-Redirect) | Adblocker, Browser-Timeouts, User schließt Tab |
| SAML | Back-Channel (Server-to-Server) | Firewall-Regeln, Timeouts |
| OIDC | Front-Channel Logout | Same-Site-Cookie-Probleme, ITP |
| OIDC | Back-Channel Logout | Nicht alle IdPs unterstützen es |
Empfehlung: Defense in Depth
- Idle-Timeout: Session nach 30-60 Min Inaktivität beenden
- Absolute Session-Lifetime: Max. 8-12h, dann neu anmelden
- SLO als “nice-to-have”: Nicht darauf verlassen
class RobustSessionManager {
private const IDLE_TIMEOUT = 1800; // 30 Minuten
private const ABSOLUTE_LIFETIME = 28800; // 8 Stunden
public function validateSession(string $sessionId): ?User {
$session = $this->getSession($sessionId);
if (!$session) {
return null;
}
// Absolute Lifetime prüfen
$createdAt = strtotime($session['created_at']);
if (time() > ($createdAt + self::ABSOLUTE_LIFETIME)) {
$this->invalidateSession($sessionId, 'absolute_timeout');
return null;
}
// Idle Timeout prüfen
$lastActivity = strtotime($session['last_activity']);
if (time() > ($lastActivity + self::IDLE_TIMEOUT)) {
$this->invalidateSession($sessionId, 'idle_timeout');
return null;
}
// Session gültig - Aktivität aktualisieren
$this->touchSession($sessionId);
return User::fromArray($session);
}
private function invalidateSession(string $sessionId, string $reason): void {
$this->db->prepare("
UPDATE sessions
SET invalidated_at = NOW(), invalidate_reason = ?
WHERE id = ?
")->execute([$reason, $sessionId]);
}
}
Cookies und Browser-Realität
Browser-Einschränkungen beachten
| Browser | Problem | Auswirkung |
|---|---|---|
| Safari (ITP) | Third-Party-Cookies geblockt | Kein Cross-Site SSO in iframes |
| Firefox (ETP) | Tracking Protection | Third-Party-Cookies eingeschränkt |
| Chrome | Third-Party-Cookie Deprecation | Ab 2025 komplett weg |
Regeln:
- Cross-Site SSO: SameSite=None; Secure nötig
- Keine SSO-Flows in iframes - funktioniert nicht mehr
- First-Party Cookies bevorzugen - auf eigener Domain
// Korrekte Cookie-Konfiguration für SSO
setcookie('session_id', $sessionId, [
'expires' => time() + 28800,
'path' => '/',
'domain' => '.ihre-domain.de', // First-Party!
'secure' => true, // Nur HTTPS
'httponly' => true, // Kein JS-Zugriff
'samesite' => 'Lax', // Oder 'None' für Cross-Site
]);
// Für Cross-Site SSO (z.B. Subdomain-übergreifend):
setcookie('sso_token', $token, [
'expires' => time() + 28800,
'path' => '/',
'domain' => '.ihre-domain.de',
'secure' => true, // PFLICHT bei SameSite=None
'httponly' => true,
'samesite' => 'None', // Cross-Site erlauben
]);
Multi-IdP und Home Realm Discovery
Wenn Sie mehrere Kunden-IdPs anbinden müssen:
Domain-Routing
class HomeRealmDiscovery {
private array $domainToIdpMapping = [
'kunde-a.de' => 'idp_kunde_a',
'kunde-b.com' => 'idp_kunde_b',
'default' => 'idp_local',
];
public function discoverIdp(string $email): string {
$domain = substr(strrchr($email, '@'), 1);
return $this->domainToIdpMapping[$domain]
?? $this->domainToIdpMapping['default'];
}
public function getLoginUrl(string $email): string {
$idpId = $this->discoverIdp($email);
$idp = $this->loadIdpConfig($idpId);
return $idp->getAuthUrl();
}
}
Auswahl-Screen
class IdpSelector {
private array $availableIdps = [
'azure' => ['name' => 'Microsoft Azure AD', 'icon' => 'microsoft.svg'],
'okta' => ['name' => 'Okta', 'icon' => 'okta.svg'],
'google' => ['name' => 'Google Workspace', 'icon' => 'google.svg'],
];
public function renderSelector(): string {
$html = '<div class="idp-selector">';
$html .= '<h2>Anmelden mit</h2>';
foreach ($this->availableIdps as $id => $idp) {
$html .= sprintf(
'<a href="/sso/login/%s" class="idp-button">
<img src="/icons/%s" alt="%s">
<span>%s</span>
</a>',
$id, $idp['icon'], $idp['name'], $idp['name']
);
}
$html .= '</div>';
return $html;
}
}
Aufwand Multi-IdP:
- Domain-Routing: +8-16h
- Auswahl-Screen: +4-8h
- Branding je IdP: +4-8h
SCIM Provisioning
Wann JIT nicht reicht
Just-in-Time Provisioning ist okay für einfache Fälle. Aber bei Enterprise brauchen Sie SCIM (System for Cross-domain Identity Management):
| Anforderung | JIT | SCIM |
|---|---|---|
| User anlegen bei Login | Ja | Ja |
| User deaktivieren wenn im IdP gelöscht | Nein | Ja |
| Gruppen/Rollen synchronisieren | Begrenzt | Ja |
| Lizenzen/Seats verwalten | Nein | Ja |
| Compliance-Reports | Nein | Ja |
Aufwand SCIM-Implementierung: +16-40h
SCIM ist ein REST-API-Standard, den Azure AD, Okta und andere IdPs unterstützen. Der IdP pushed User-Änderungen an Ihre App.
Ops-Runbook: Monitoring und Alerting
Was Sie überwachen müssen
Metriken:
- Login-Fehlerrate (Threshold: >5% = Alert)
- IdP-Latency (Threshold: >2s = Warning, >5s = Alert)
- Token-Ablauf-Warnungen (30 Tage vorher)
- Zertifikats-Expiry (30 Tage vorher = Alert)
Dashboard-Karten:
- Logins/Minute (Live)
- Fehlercodes (SAML Status, OAuth Error)
- Top-Mandanten (bei Multi-Tenant)
- Aktive Sessions
On-Call-Playbook:
| Symptom | Erste Aktion | Eskalation |
|---|---|---|
| IdP nicht erreichbar | Status-Page prüfen, Fallback aktivieren? | Kunde-IT kontaktieren |
| ”Certificate expired” | Zertifikat erneuern, Metadata aktualisieren | - |
| Massenhaft fehlgeschlagene Logins | Angriff? Rate-Limiting prüfen | Security-Team |
| ”Token expired” bei allen | Uhren synchron? NTP prüfen | Infra-Team |
class SsoMonitoring {
public function logLoginAttempt(string $provider, bool $success, ?string $error = null): void {
$this->metrics->increment('sso.login.attempt', [
'provider' => $provider,
'success' => $success ? 'true' : 'false',
'error' => $error ?? 'none',
]);
if (!$success) {
$this->checkErrorRate($provider);
}
}
private function checkErrorRate(string $provider): void {
$errorRate = $this->metrics->getErrorRate($provider, '5m');
if ($errorRate > 0.05) { // > 5%
$this->alerting->send(
level: 'warning',
message: "SSO error rate for {$provider}: {$errorRate}%"
);
}
}
public function checkCertificateExpiry(): void {
foreach ($this->getIdpCertificates() as $idp => $certPath) {
$cert = openssl_x509_parse(file_get_contents($certPath));
$expiresAt = $cert['validTo_time_t'];
$daysUntilExpiry = ($expiresAt - time()) / 86400;
if ($daysUntilExpiry < 30) {
$this->alerting->send(
level: 'critical',
message: "IdP certificate for {$idp} expires in {$daysUntilExpiry} days!"
);
}
}
}
}
Gruppen-zu-Rollen-Mapping
Konfigurationsbasiertes Mapping
class GroupRoleMapper {
private array $mapping;
private array $defaultRoles;
public function __construct(array $config) {
$this->mapping = $config['group_role_mapping'] ?? [];
$this->defaultRoles = $config['default_roles'] ?? ['user'];
}
public function mapGroupsToRoles(array $groups): array {
$roles = [];
foreach ($groups as $group) {
if (isset($this->mapping[$group])) {
$mapped = $this->mapping[$group];
$roles = array_merge($roles, is_array($mapped) ? $mapped : [$mapped]);
continue;
}
foreach ($this->mapping as $pattern => $mappedRoles) {
if ($this->matchesPattern($group, $pattern)) {
$roles = array_merge($roles, is_array($mappedRoles) ? $mappedRoles : [$mappedRoles]);
}
}
}
if (empty($roles)) {
$roles = $this->defaultRoles;
}
return array_unique($roles);
}
private function matchesPattern(string $group, string $pattern): bool {
if (str_contains($pattern, '*')) {
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/i';
return preg_match($regex, $group) === 1;
}
return false;
}
}
// Beispiel-Konfiguration
$config = [
'group_role_mapping' => [
'APP_Portal_Admin' => ['admin', 'user'],
'APP_Portal_Manager' => ['manager', 'user'],
'APP_Portal_User' => ['user'],
'APP_Portal_*' => ['user'],
'Domain Admins' => ['admin', 'user'],
],
'default_roles' => ['guest'],
];
Merksatz: Manuelle Rollen-Zuweisungen nicht überschreiben - source-Feld verwenden!
Aufwand und Kosten
Realistische Aufwandsschätzung
| Komponente | SAML 2.0 | OAuth/OIDC | LDAP |
|---|---|---|---|
| Basis-Integration | 24-32h | 8-16h | 16-24h |
| Gruppen-Mapping | 8-16h | 4-8h | 8-16h |
| Session-Management | 8-16h | 8-16h | 8-16h |
| Logout (SLO) | 16-24h | 4-8h | 2-4h |
| Testing mit IdP | 16-24h | 8-16h | 8-16h |
| Dokumentation | 4-8h | 4-8h | 4-8h |
| Gesamt | 76-120h | 36-72h | 46-84h |
Zusätzliche Aufwände:
| Feature | Aufwand |
|---|---|
| Multi-IdP Support | +50-100% |
| SCIM Provisioning | +16-40h |
| Home Realm Discovery | +8-16h |
| Branding je IdP | +4-8h |
Kosten bei 100 Euro/h:
- SAML 2.0: 7.600 - 12.000 Euro
- OAuth/OIDC: 3.600 - 7.200 Euro
- LDAP: 4.600 - 8.400 Euro
Der versteckte Aufwand
| Was niemand einplant | Aufwand |
|---|---|
| Warten auf IdP-Konfiguration | 1-4 Wochen |
| Debugging mit IT-Abteilung | 8-24h |
| Zertifikats-Erneuerung | 4-8h/Jahr |
| User-Provisioning-Edge-Cases | 8-16h |
Merksatz: IdP-Konfiguration durch den Kunden dauert länger als die Entwicklung.
Checkliste: SSO-Implementierung
Vor dem Projekt
- Welcher IdP? (Azure AD, Okta, on-prem ADFS?)
- Welches Protokoll bevorzugt?
- Wer konfiguriert den IdP? (Sie oder Kunde-IT?)
- Welche Attribute werden benötigt?
- Gruppen-zu-Rollen-Mapping definiert?
- Just-in-Time oder SCIM Provisioning?
- Multi-IdP nötig?
Technische Anforderungen
- IdP-Metadata/Endpoints dokumentiert
- Test-User im IdP angelegt
- Zertifikate ausgetauscht (SAML)
- Client-ID/Secret erstellt (OAuth)
- Netzwerk-Freigaben (LDAP: Port 636 für LDAPS)
Security
- HTTPS everywhere
- Signatur-Validierung aktiv (XPath fest!)
- CSRF-Schutz (State-Parameter)
- Nonce für ID Token (OIDC)
- PKCE implementiert
- Clock-Skew-Leeway konfiguriert (300s)
- Session-Timeout konfiguriert (Idle + Absolute)
- LDAP nur mit TLS
- Audit-Logging aktiv
Go-Live
- Fallback bei IdP-Ausfall?
- Zertifikats-Ablauf überwacht? (30 Tage Alert)
- Monitoring aktiv? (Error-Rate, Latency)
- Dokumentation für Kunden-IT
- On-Call-Playbook erstellt
Fazit: SSO richtig machen
SSO ist kein Projekt, es ist eine Reise. Die Technik ist lösbar, die Koordination ist die Herausforderung.
Meine Empfehlung nach Situation:
| Situation | Empfehlung |
|---|---|
| Neues SaaS-Produkt | OAuth/OIDC (schnell, modern) |
| Enterprise-Kunde mit Azure | SAML 2.0 (IT kennt es) |
| On-Premise, kein IdP | LDAP (pragmatisch) |
| Beides nötig | SAML + OAuth parallel |
Die wichtigsten Learnings:
- OAuth ist nicht OIDC - OIDC = OAuth + ID Token
- PKCE auch für Web-Apps - nicht nur SPAs
- SLO ist Best Effort - Idle-Timeout als Fallback
- Uhren synchronisieren - 300s Leeway für Zeitstempel
- Zertifikate im Kalender - 30 Tage vorher Alert
- Logging, Logging, Logging - SSO-Probleme ohne Logs? Viel Spaß.
Merksatz: SSO ist 20% Code und 80% Kommunikation.
SSO-Integration geplant?
In 30 Minuten klären wir, welches Protokoll für Ihre Situation passt und was die IT-Abteilung vorbereiten muss.
Über Carola Schulte
Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.
Projekt im Kopf?
Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.
Kostenloses Erstgespräch