Generisches OIDC-SSO (weitere Auth-Quelle)

- oidc_callback.php (Auth-Code-Flow, state-Validierung, Code->Token->Userinfo)
- login.php: OIDC-Button (konfigurierbarer Name)
- Settings in admin/integrations.php (Endpoints, Client, Scopes)
- nutzt vorhandene externe-SSO-Verknüpfung (loginWithMicrosoft, sub als ID)
This commit is contained in:
Claude 2026-06-06 05:47:20 +00:00
parent 976b357952
commit ebfa27d5c3
No known key found for this signature in database
3 changed files with 160 additions and 0 deletions

View file

@ -32,6 +32,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
$db->setSetting('twilio_sid', trim($_POST['twilio_sid'] ?? ''));
$db->setSetting('twilio_from', trim($_POST['twilio_from'] ?? ''));
if (!empty($_POST['twilio_token'])) { $db->setSetting('twilio_token', trim($_POST['twilio_token'])); }
$db->setSetting('oidc_enabled', isset($_POST['oidc_enabled']) ? '1' : '0');
$db->setSetting('oidc_name', trim($_POST['oidc_name'] ?? 'SSO'));
$db->setSetting('oidc_client_id', trim($_POST['oidc_client_id'] ?? ''));
$db->setSetting('oidc_auth_url', trim($_POST['oidc_auth_url'] ?? ''));
$db->setSetting('oidc_token_url', trim($_POST['oidc_token_url'] ?? ''));
$db->setSetting('oidc_userinfo_url', trim($_POST['oidc_userinfo_url'] ?? ''));
$db->setSetting('oidc_scopes', trim($_POST['oidc_scopes'] ?? 'openid profile email'));
if (!empty($_POST['oidc_client_secret'])) { $db->setSetting('oidc_client_secret', trim($_POST['oidc_client_secret'])); }
$db->setSetting('user_daily_voucher_limit', max(0, (int)($_POST['user_daily_voucher_limit'] ?? 0)));
$db->setSetting('trusted_proxy', trim($_POST['trusted_proxy'] ?? ''));
$db->setSetting('webhook_enabled', isset($_POST['webhook_enabled']) ? '1' : '0');
@ -57,6 +65,14 @@ $smsEnabled = $db->getSetting('sms_enabled', '0') === '1';
$twilioSid = $db->getSetting('twilio_sid', '');
$twilioFrom = $db->getSetting('twilio_from', '');
$twilioTokenSet = $db->getSetting('twilio_token', '') !== '';
$oidcEnabled = $db->getSetting('oidc_enabled', '0') === '1';
$oidcName = $db->getSetting('oidc_name', 'SSO');
$oidcClientId = $db->getSetting('oidc_client_id', '');
$oidcAuthUrl = $db->getSetting('oidc_auth_url', '');
$oidcTokenUrl = $db->getSetting('oidc_token_url', '');
$oidcUserinfoUrl = $db->getSetting('oidc_userinfo_url', '');
$oidcScopes = $db->getSetting('oidc_scopes', 'openid profile email');
$oidcSecretSet = $db->getSetting('oidc_client_secret', '') !== '';
$dailyLimit = (int)$db->getSetting('user_daily_voucher_limit', 0);
$trustedProxy = $db->getSetting('trusted_proxy', '');
$webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1';
@ -145,6 +161,21 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
</div>
</div>
<div class="card">
<h2>Single Sign-On (OpenID Connect)</h2>
<p class="muted">Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI: <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p>
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> OIDC-Login aktiv</label>
<div class="row3" style="margin-top:10px;">
<div><label>Button-Text</label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div>
<div><label>Client ID</label><input class="input" type="text" name="oidc_client_id" value="<?= htmlspecialchars($oidcClientId) ?>"></div>
<div><label>Client Secret<?= $oidcSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
</div>
<label>Authorization Endpoint</label><input class="input" type="url" name="oidc_auth_url" value="<?= htmlspecialchars($oidcAuthUrl) ?>" placeholder="https://idp/authorize">
<label>Token Endpoint</label><input class="input" type="url" name="oidc_token_url" value="<?= htmlspecialchars($oidcTokenUrl) ?>" placeholder="https://idp/token">
<label>Userinfo Endpoint</label><input class="input" type="url" name="oidc_userinfo_url" value="<?= htmlspecialchars($oidcUserinfoUrl) ?>" placeholder="https://idp/userinfo">
<label>Scopes</label><input class="input" type="text" name="oidc_scopes" value="<?= htmlspecialchars($oidcScopes) ?>">
</div>
<div class="card">
<h2>Datenhaltung & Cleanup (DSGVO)</h2>
<p class="muted">Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per <code>cron_cleanup.php</code> (täglich empfohlen).

View file

@ -94,6 +94,27 @@ try {
$m365LoginUrl = "https://login.microsoftonline.com/$m365TenantId/oauth2/v2.0/authorize?" . http_build_query($params);
}
// Generisches OIDC (optional)
$oidcEnabled = $db->getSetting('oidc_enabled', '0') === '1'
&& $db->getSetting('oidc_client_id', '') !== ''
&& $db->getSetting('oidc_auth_url', '') !== '';
$oidcName = $db->getSetting('oidc_name', 'SSO');
$oidcLoginUrl = '';
if ($oidcEnabled) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$oidcState = bin2hex(random_bytes(16));
$_SESSION['oidc_state'] = $oidcState;
$oidcLoginUrl = rtrim($db->getSetting('oidc_auth_url', ''), '?') . '?' . http_build_query([
'client_id' => $db->getSetting('oidc_client_id', ''),
'response_type' => 'code',
'redirect_uri' => $protocol . '://' . $_SERVER['HTTP_HOST'] . $scriptPath . '/oidc_callback.php',
'scope' => $db->getSetting('oidc_scopes', 'openid profile email'),
'state' => $oidcState,
]);
}
$showLocalLogin = isset($_GET['local']) && $_GET['local'] === '1';
} catch (Exception $e) {
@ -223,6 +244,13 @@ try {
<?php endif; ?>
<?php endif; ?>
<?php if (!$show2fa && $oidcEnabled): ?>
<div class="divider"><span><?= __('or') ?></span></div>
<a href="<?= htmlspecialchars($oidcLoginUrl) ?>" class="btn" style="display:block;text-align:center;text-decoration:none;background:#444;">
🔑 <?= htmlspecialchars($oidcName) ?>
</a>
<?php endif; ?>
<?php if ($publicAccess): ?>
<a href="index.php" class="back-link"><?= __('login_back') ?></a>
<?php endif; ?>

101
oidc_callback.php Normal file
View file

@ -0,0 +1,101 @@
<?php
/**
* Generischer OpenID-Connect-Callback (Authorization-Code-Flow).
* Konfiguration unter Administration Integration & Wartung.
* Nutzt denselben Account-Verknüpfungs-Mechanismus wie M365 (externe SSO-ID).
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
$db = Database::getInstance();
$auth = new Auth();
$clientId = $db->getSetting('oidc_client_id', '');
$clientSecret = $db->getSetting('oidc_client_secret', '');
$tokenUrl = $db->getSetting('oidc_token_url', '');
$userinfoUrl = $db->getSetting('oidc_userinfo_url', '');
if ($db->getSetting('oidc_enabled', '0') !== '1' || $clientId === '' || $tokenUrl === '' || $userinfoUrl === '') {
die('OIDC ist nicht konfiguriert. <a href="login.php">Zurück zum Login</a>');
}
if (isset($_GET['error'])) {
die('OIDC-Fehler: ' . htmlspecialchars($_GET['error']) . '<br><a href="login.php">Zurück zum Login</a>');
}
if (!isset($_GET['code'])) {
die('Kein Authorization Code erhalten.<br><a href="login.php">Zurück zum Login</a>');
}
// State validieren (CSRF)
$sessionState = $_SESSION['oidc_state'] ?? '';
$returnedState = $_GET['state'] ?? '';
unset($_SESSION['oidc_state']);
if ($sessionState === '' || !hash_equals($sessionState, $returnedState)) {
die('Ungültiger Sicherheits-Token (state).<br><a href="login.php">Zurück zum Login</a>');
}
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$redirectUri = $protocol . '://' . $_SERVER['HTTP_HOST'] . $scriptPath . '/oidc_callback.php';
// Code -> Token
$ch = curl_init($tokenUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'authorization_code',
'code' => $_GET['code'],
'redirect_uri' => $redirectUri,
'client_id' => $clientId,
'client_secret' => $clientSecret,
]),
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$resp = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$token = json_decode((string)$resp, true);
if ($httpCode !== 200 || empty($token['access_token'])) {
die('Token-Abruf fehlgeschlagen (HTTP ' . $httpCode . ').<br><a href="login.php">Zurück zum Login</a>');
}
// Userinfo abrufen
$ch = curl_init($userinfoUrl);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token['access_token'], 'Accept: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$uResp = curl_exec($ch);
$uCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$info = json_decode((string)$uResp, true);
if ($uCode !== 200 || !is_array($info)) {
die('Benutzerinfo-Abruf fehlgeschlagen (HTTP ' . $uCode . ').<br><a href="login.php">Zurück zum Login</a>');
}
$sub = $info['sub'] ?? ($info['id'] ?? '');
$email = $info['email'] ?? ($info['preferred_username'] ?? '');
$name = $info['name'] ?? trim(($info['given_name'] ?? '') . ' ' . ($info['family_name'] ?? ''));
if ($sub === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
die('OIDC lieferte keine gültige Identität (sub/email).<br><a href="login.php">Zurück zum Login</a>');
}
try {
// Wiederverwendung der externen-SSO-Verknüpfung (microsoft_id = externe ID)
$auth->loginWithMicrosoft(['id' => 'oidc:' . $sub, 'email' => $email, 'name' => $name ?: $email]);
header('Location: index.php');
exit;
} catch (Exception $e) {
die('Login-Fehler: ' . htmlspecialchars($e->getMessage()) . '<br><a href="login.php">Zurück zum Login</a>');
}