Questionnaire Management
-Sign in
-Admin or supervisor account.
+ + +Counselors use the mobile app.
diff --git a/api/index.php b/api/index.php index ffedb89..9ebee27 100644 --- a/api/index.php +++ b/api/index.php @@ -56,6 +56,9 @@ $routes = [ 'session' => __DIR__ . '/../handlers/session.php', 'auth/login' => __DIR__ . '/../handlers/auth.php', 'auth/change-password' => __DIR__ . '/../handlers/auth.php', + 'auth/keycloak-config' => __DIR__ . '/../handlers/auth.php', + 'auth/keycloak-login' => __DIR__ . '/../handlers/auth.php', + 'auth/keycloak-callback' => __DIR__ . '/../handlers/auth.php', 'backup' => __DIR__ . '/../handlers/backup.php', 'dev' => __DIR__ . '/../handlers/dev.php', 'dev/import' => __DIR__ . '/../handlers/dev.php', diff --git a/handlers/auth.php b/handlers/auth.php index 3778018..69001b1 100644 --- a/handlers/auth.php +++ b/handlers/auth.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/../lib/settings.php'; require_once __DIR__ . '/../lib/login_rate_limit.php'; +require_once __DIR__ . '/../lib/keycloak_auth.php'; switch ($route) { @@ -186,6 +187,100 @@ case 'auth/change-password': } break; +case 'auth/keycloak-config': + if ($method !== 'GET') { + json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); + } + $kcConfig = qdb_keycloak_config(); + json_success([ + 'enabled' => qdb_keycloak_is_configured($kcConfig), + 'displayName' => $kcConfig['display_name'], + 'loginUrl' => qdb_keycloak_is_configured($kcConfig) ? 'auth/keycloak-login' : '', + ]); + break; + +case 'auth/keycloak-login': + if ($method !== 'GET') { + json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); + } + $kcConfig = qdb_keycloak_config(); + if (!qdb_keycloak_is_configured($kcConfig)) { + json_error('KEYCLOAK_NOT_CONFIGURED', 'University login is not configured', 503); + } + try { + $discovery = qdb_keycloak_discovery($kcConfig); + $nonce = bin2hex(random_bytes(16)); + $params = [ + 'client_id' => $kcConfig['client_id'], + 'redirect_uri' => $kcConfig['redirect_uri'], + 'response_type' => 'code', + 'scope' => 'openid profile email', + 'state' => qdb_keycloak_create_state($nonce), + 'nonce' => $nonce, + ]; + qdb_keycloak_redirect($discovery['authorization_endpoint'] . '?' . http_build_query($params)); + } catch (Throwable $e) { + qdb_handler_fail($e, 'keycloak-login', null, null, null); + } + break; + +case 'auth/keycloak-callback': + if ($method !== 'GET') { + json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); + } + if (isset($_GET['error'])) { + json_error('KEYCLOAK_DENIED', 'University login was cancelled or denied', 401); + } + $code = trim((string)($_GET['code'] ?? '')); + $state = trim((string)($_GET['state'] ?? '')); + if ($code === '' || $state === '') { + json_error('MISSING_FIELDS', 'Keycloak callback requires code and state', 400); + } + $kcConfig = qdb_keycloak_config(); + if (!qdb_keycloak_is_configured($kcConfig)) { + json_error('KEYCLOAK_NOT_CONFIGURED', 'University login is not configured', 503); + } + try { + qdb_keycloak_verify_state($state); + $discovery = qdb_keycloak_discovery($kcConfig); + $tokenResponse = qdb_keycloak_exchange_code($kcConfig, $discovery, $code); + $accessToken = (string)($tokenResponse['access_token'] ?? ''); + if ($accessToken === '') { + json_error('KEYCLOAK_TOKEN_FAILED', 'University login did not return an access token', 401); + } + $claims = qdb_keycloak_userinfo($discovery, $accessToken); + $username = qdb_keycloak_claim_username($kcConfig, $claims); + + [$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail(); + $stmt = $pdo->prepare( + "SELECT userID, role, entityID, mustChangePassword + FROM users WHERE username = :u" + ); + $stmt->execute([':u' => $username]); + $user = $stmt->fetch(PDO::FETCH_ASSOC); + qdb_discard($tmpDb, $lockFp); + + if (!$user) { + json_error('KEYCLOAK_USER_NOT_ALLOWED', 'University account is not registered for this application', 403); + } + qdb_reject_coach_web_login((string)($user['role'] ?? '')); + if ((int)($user['mustChangePassword'] ?? 0) === 1) { + json_error('PASSWORD_CHANGE_REQUIRED', 'Please sign in locally once to change your temporary password before using University login', 403); + } + + $securitySettings = qdb_settings_get(); + $token = bin2hex(random_bytes(32)); + token_add($token, qdb_session_ttl_seconds($securitySettings, false), [ + 'role' => $user['role'], + 'entityID' => $user['entityID'], + 'userID' => $user['userID'], + ]); + qdb_keycloak_finish_html($token, $username, (string)$user['role']); + } catch (Throwable $e) { + qdb_handler_fail($e, 'keycloak-callback', null, $tmpDb ?? null, $lockFp ?? null); + } + break; + default: json_error('NOT_FOUND', 'Unknown auth route', 404); } diff --git a/lib/keycloak_auth.php b/lib/keycloak_auth.php new file mode 100644 index 0000000..276aeb8 --- /dev/null +++ b/lib/keycloak_auth.php @@ -0,0 +1,221 @@ + $issuer, + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'redirect_uri' => (string)(qdb_env_get('QDB_KEYCLOAK_REDIRECT_URI') ?? qdb_keycloak_default_redirect_uri()), + 'username_claim' => (string)(qdb_env_get('QDB_KEYCLOAK_USERNAME_CLAIM') ?? 'preferred_username'), + 'display_name' => (string)(qdb_env_get('QDB_KEYCLOAK_DISPLAY_NAME') ?? 'University Konstanz'), + ]; +} + +function qdb_keycloak_is_configured(?array $config = null): bool +{ + $config ??= qdb_keycloak_config(); + return $config['issuer'] !== '' && $config['client_id'] !== '' && $config['client_secret'] !== ''; +} + +function qdb_keycloak_default_redirect_uri(): string +{ + $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); + $scheme = $https ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php'; + $base = rtrim(str_replace('\\', '/', dirname($script)), '/'); + return $scheme . '://' . $host . $base . '/auth/keycloak-callback'; +} + +function qdb_keycloak_frontend_url(): string +{ + $configured = (string)(qdb_env_get('QDB_KEYCLOAK_FRONTEND_URL') ?? ''); + if ($configured !== '') { + return $configured; + } + $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); + $scheme = $https ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php'; + $apiBase = rtrim(str_replace('\\', '/', dirname($script)), '/'); + $root = preg_replace('#/api$#', '', $apiBase) ?: ''; + return $scheme . '://' . $host . $root . '/website/index.html'; +} + +function qdb_keycloak_state_signing_key(): string +{ + $secret = qdb_env_get('QDB_KEYCLOAK_STATE_SECRET') + ?? qdb_env_get('QDB_MASTER_KEY') + ?? qdb_env_get('QDB_KEYCLOAK_CLIENT_SECRET') + ?? ''; + if ($secret === '') { + throw new RuntimeException('No signing secret available for Keycloak state'); + } + return $secret; +} + +function qdb_keycloak_base64url(string $bytes): string +{ + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); +} + +function qdb_keycloak_unbase64url(string $value): string +{ + $padded = strtr($value, '-_', '+/'); + $padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4); + $decoded = base64_decode($padded, true); + if ($decoded === false) { + throw new InvalidArgumentException('Invalid base64url value'); + } + return $decoded; +} + +function qdb_keycloak_create_state(string $nonce): string +{ + $payload = json_encode([ + 'nonce' => $nonce, + 'exp' => time() + 600, + ], JSON_THROW_ON_ERROR); + $body = qdb_keycloak_base64url($payload); + $sig = qdb_keycloak_base64url(hash_hmac('sha256', $body, qdb_keycloak_state_signing_key(), true)); + return $body . '.' . $sig; +} + +function qdb_keycloak_verify_state(string $state): array +{ + $parts = explode('.', $state, 2); + if (count($parts) !== 2) { + json_error('INVALID_STATE', 'Invalid Keycloak state', 400); + } + [$body, $sig] = $parts; + $expected = qdb_keycloak_base64url(hash_hmac('sha256', $body, qdb_keycloak_state_signing_key(), true)); + if (!hash_equals($expected, $sig)) { + json_error('INVALID_STATE', 'Invalid Keycloak state', 400); + } + $payload = json_decode(qdb_keycloak_unbase64url($body), true); + if (!is_array($payload) || (int)($payload['exp'] ?? 0) < time()) { + json_error('INVALID_STATE', 'Expired Keycloak state', 400); + } + return $payload; +} + +function qdb_keycloak_discovery(array $config): array +{ + $url = $config['issuer'] . '/.well-known/openid-configuration'; + $json = qdb_keycloak_http_json($url); + foreach (['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint'] as $key) { + if (empty($json[$key])) { + throw new RuntimeException("Keycloak discovery missing $key"); + } + } + return $json; +} + +function qdb_keycloak_http_json(string $url, array $options = []): array +{ + $context = stream_context_create(['http' => [ + 'method' => $options['method'] ?? 'GET', + 'header' => $options['headers'] ?? '', + 'content' => $options['content'] ?? '', + 'timeout' => 10, + 'ignore_errors' => true, + ]]); + $raw = file_get_contents($url, false, $context); + if ($raw === false) { + throw new RuntimeException('Keycloak request failed'); + } + $status = 200; + foreach (($http_response_header ?? []) as $header) { + if (preg_match('#^HTTP/\S+\s+(\d+)#', $header, $m)) { + $status = (int)$m[1]; + break; + } + } + $json = json_decode($raw, true); + if ($status < 200 || $status >= 300 || !is_array($json)) { + throw new RuntimeException('Keycloak returned an invalid response'); + } + return $json; +} + +function qdb_keycloak_exchange_code(array $config, array $discovery, string $code): array +{ + $body = http_build_query([ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $config['redirect_uri'], + 'client_id' => $config['client_id'], + 'client_secret' => $config['client_secret'], + ]); + return qdb_keycloak_http_json($discovery['token_endpoint'], [ + 'method' => 'POST', + 'headers' => "Content-Type: application/x-www-form-urlencoded\r\n", + 'content' => $body, + ]); +} + +function qdb_keycloak_userinfo(array $discovery, string $accessToken): array +{ + return qdb_keycloak_http_json($discovery['userinfo_endpoint'], [ + 'headers' => "Authorization: Bearer $accessToken\r\n", + ]); +} + +function qdb_keycloak_claim_username(array $config, array $claims): string +{ + $claim = $config['username_claim'] ?: 'preferred_username'; + $username = trim((string)($claims[$claim] ?? '')); + if ($username === '' && $claim !== 'email') { + $username = trim((string)($claims['email'] ?? '')); + } + if ($username === '') { + json_error('KEYCLOAK_NO_USERNAME', 'University login did not provide a usable username', 403); + } + return $username; +} + +function qdb_keycloak_redirect(string $url, int $status = 302): never +{ + if (defined('QDB_TESTING')) { + throw new \Tests\Support\RawHttpResponse('', ['Location' => $url], $status); + } + http_response_code($status); + header('Location: ' . $url, true, $status); + exit; +} + +function qdb_keycloak_finish_html(string $token, string $user, string $role): never +{ + $target = qdb_keycloak_frontend_url() . '#/'; + $payload = json_encode([ + 'token' => $token, + 'user' => $user, + 'role' => $role, + 'target' => $target, + ], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_THROW_ON_ERROR); + qdb_http_download( + "", + ['Content-Type' => 'text/html; charset=UTF-8'] + ); +} + diff --git a/tests/Integration/AuthTest.php b/tests/Integration/AuthTest.php index 3e1c315..0eba9d8 100644 --- a/tests/Integration/AuthTest.php +++ b/tests/Integration/AuthTest.php @@ -51,4 +51,18 @@ final class AuthTest extends QdbTestCase $res = $this->api()->request('POST', 'auth/login', ['username' => '']); $this->assertApiError($res, 'MISSING_FIELDS', 400); } + + public function testKeycloakConfigDisabledByDefault(): void + { + $res = $this->api()->request('GET', 'auth/keycloak-config'); + $this->assertApiOk($res); + $this->assertFalse($res['data']['enabled']); + $this->assertSame('', $res['data']['loginUrl']); + } + + public function testKeycloakLoginRequiresConfiguration(): void + { + $res = $this->api()->request('GET', 'auth/keycloak-login'); + $this->assertApiError($res, 'KEYCLOAK_NOT_CONFIGURED', 503); + } } diff --git a/website/css/style.css b/website/css/style.css index 3a1c419..df89ae3 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -1091,7 +1091,7 @@ body.login-active .main-content { align-items: center; justify-content: center; min-height: 100vh; - padding: 28px 32px; + padding: 48px 32px; overflow: hidden; isolation: isolate; } @@ -1101,8 +1101,7 @@ body.login-active .main-content { inset: 0; z-index: 0; background: - radial-gradient(ellipse 80% 60% at 30% 40%, rgba(37, 99, 235, 0.12), transparent 65%), - radial-gradient(ellipse 55% 45% at 75% 65%, rgba(79, 70, 229, 0.08), transparent 55%), + linear-gradient(135deg, rgba(219, 234, 254, 0.78) 0%, rgba(248, 250, 252, 0.96) 48%, rgba(238, 242, 255, 0.72) 100%), var(--bg); } @@ -1110,56 +1109,28 @@ body.login-active .main-content { position: absolute; inset: 0; background-image: - linear-gradient(var(--grid-line) 1px, transparent 1px), - linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); - background-size: 56px 56px; - mask-image: radial-gradient(ellipse 90% 80% at 50% 45%, #000 25%, transparent 80%); -} - -.login-orb { - position: absolute; - border-radius: 50%; - filter: blur(72px); - opacity: 0.35; - animation: login-orb-drift 22s ease-in-out infinite; -} -.login-orb--a { - width: 420px; - height: 420px; - background: rgba(37, 99, 235, 0.15); - top: 10%; - left: 15%; -} -.login-orb--b { - width: 300px; - height: 300px; - background: rgba(79, 70, 229, 0.1); - bottom: 10%; - right: 20%; - animation-delay: -9s; -} - -@keyframes login-orb-drift { - 0%, 100% { transform: translate(0, 0); } - 50% { transform: translate(18px, -12px); } + linear-gradient(rgba(100, 116, 139, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(100, 116, 139, 0.08) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), transparent 92%); } .login-screen__content { position: relative; z-index: 1; width: 100%; - max-width: 400px; + max-width: 980px; } .login-brand { - margin-bottom: 28px; + margin-bottom: 26px; } .login-brand__title { - font-size: 1.5rem; - font-weight: 700; + font-size: 1.75rem; + font-weight: 750; line-height: 1.25; - letter-spacing: -0.02em; + letter-spacing: 0; margin-bottom: 6px; } @@ -1171,7 +1142,12 @@ body.login-active .main-content { .login-card { position: relative; overflow: hidden; - box-shadow: var(--shadow-lg), var(--accent-glow), var(--shadow-inset); + min-height: 360px; + padding: 30px 32px 32px; + border-color: rgba(148, 163, 184, 0.34); + border-radius: 16px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 18px 50px rgba(15, 23, 42, 0.10), 0 2px 8px rgba(15, 23, 42, 0.06); } .login-card::before { content: ''; @@ -1179,23 +1155,101 @@ body.login-active .main-content { inset: 0 0 auto 0; height: 2px; background: var(--accent-gradient); - opacity: 0.85; + opacity: 0.75; } .login-card__heading { - font-size: 1.15rem; - font-weight: 700; - margin-bottom: 4px; + font-size: 1.25rem; + font-weight: 750; + margin-bottom: 6px; } .login-card__lead { - font-size: 0.875rem; + font-size: 0.925rem; color: var(--text-secondary); - margin-bottom: 20px; + margin-bottom: 26px; +} + +.login-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + align-items: stretch; +} + +.login-options .login-card { + min-width: 0; +} + +.login-options .login-card[hidden] { + display: none; +} + +.login-card--sso { + display: flex; + flex-direction: column; +} + +.login-card--sso .login-sso { + margin-top: 28px; +} + +.login-sso__logo { + display: flex; + align-items: center; + justify-content: center; + min-height: 96px; + margin-bottom: 16px; + padding: 0 8px; +} + +.login-sso__logo img { + display: block; + max-width: 100%; + max-height: 90px; + object-fit: contain; +} + +.login-sso__logo span { + display: none; + color: var(--text); + font-size: 1rem; + font-weight: 700; + text-align: center; +} + +.login-sso__logo--fallback span { + display: block; } .login-form .form-group { - margin-bottom: 16px; + margin-bottom: 18px; +} + +.login-sso { + margin-top: 0; +} + +.login-sso__button { + min-height: 50px; + background: #ffffff; + border-color: rgba(37, 99, 235, 0.34); + color: var(--primary-text); + font-weight: 600; +} + +.login-sso__button:disabled { + background: #f8fafc; + border-color: rgba(148, 163, 184, 0.36); + color: #64748b; + cursor: default; + box-shadow: none; +} + +.login-form .btn-primary { + min-height: 50px; + margin-top: 4px; + font-size: 0.95rem; } .login-alert { @@ -1212,9 +1266,8 @@ body.login-active .main-content { } .login-card__footnote { - margin-top: 20px; - padding-top: 16px; - border-top: 1px solid var(--border); + margin-top: 22px; + text-align: center; font-size: 0.8rem; color: var(--text-secondary); } @@ -1227,10 +1280,23 @@ body.login-active .main-content { align-items: flex-start; padding-top: 48px; } + + .login-options { + grid-template-columns: 1fr; + } + + .login-card { + min-height: 0; + padding: 26px 22px; + } + + .login-brand__title { + font-size: 1.45rem; + } + } @media (prefers-reduced-motion: reduce) { - .login-orb, .spinner { animation: none; } diff --git a/website/js/pages/login.js b/website/js/pages/login.js index 361d0da..7d305b0 100644 --- a/website/js/pages/login.js +++ b/website/js/pages/login.js @@ -17,6 +17,15 @@ function setFormBusy(formId, busy, busyLabel) { form.querySelectorAll('input').forEach((el) => { el.disabled = busy; }); } +function setButtonBusy(button, busy, busyLabel) { + if (!button) return; + if (!button.dataset.defaultLabel) { + button.dataset.defaultLabel = button.textContent.trim(); + } + button.disabled = busy; + button.textContent = busy ? busyLabel : button.dataset.defaultLabel; +} + export async function loginPage() { if (isLoggedIn()) { try { @@ -40,8 +49,6 @@ export async function loginPage() { app.innerHTML = `
Questionnaire Management
-Admin or supervisor account.
+ + +Counselors use the mobile app.