This commit is contained in:
@ -56,6 +56,9 @@ $routes = [
|
|||||||
'session' => __DIR__ . '/../handlers/session.php',
|
'session' => __DIR__ . '/../handlers/session.php',
|
||||||
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
||||||
'auth/change-password' => __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',
|
'backup' => __DIR__ . '/../handlers/backup.php',
|
||||||
'dev' => __DIR__ . '/../handlers/dev.php',
|
'dev' => __DIR__ . '/../handlers/dev.php',
|
||||||
'dev/import' => __DIR__ . '/../handlers/dev.php',
|
'dev/import' => __DIR__ . '/../handlers/dev.php',
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/../lib/settings.php';
|
require_once __DIR__ . '/../lib/settings.php';
|
||||||
require_once __DIR__ . '/../lib/login_rate_limit.php';
|
require_once __DIR__ . '/../lib/login_rate_limit.php';
|
||||||
|
require_once __DIR__ . '/../lib/keycloak_auth.php';
|
||||||
|
|
||||||
switch ($route) {
|
switch ($route) {
|
||||||
|
|
||||||
@ -186,6 +187,100 @@ case 'auth/change-password':
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
json_error('NOT_FOUND', 'Unknown auth route', 404);
|
json_error('NOT_FOUND', 'Unknown auth route', 404);
|
||||||
}
|
}
|
||||||
|
|||||||
221
lib/keycloak_auth.php
Normal file
221
lib/keycloak_auth.php
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal OpenID Connect support for Keycloak-backed website login.
|
||||||
|
*
|
||||||
|
* Required .env values:
|
||||||
|
* - QDB_KEYCLOAK_ISSUER, e.g. https://.../realms/...
|
||||||
|
* - QDB_KEYCLOAK_CLIENT_ID
|
||||||
|
* - QDB_KEYCLOAK_CLIENT_SECRET
|
||||||
|
*
|
||||||
|
* Optional:
|
||||||
|
* - QDB_KEYCLOAK_REDIRECT_URI (defaults to current /api/auth/keycloak-callback)
|
||||||
|
* - QDB_KEYCLOAK_USERNAME_CLAIM (defaults to preferred_username)
|
||||||
|
* - QDB_KEYCLOAK_DISPLAY_NAME (defaults to University Konstanz)
|
||||||
|
*/
|
||||||
|
|
||||||
|
function qdb_keycloak_config(): array
|
||||||
|
{
|
||||||
|
$issuer = rtrim((string)(qdb_env_get('QDB_KEYCLOAK_ISSUER') ?? ''), '/');
|
||||||
|
$clientId = (string)(qdb_env_get('QDB_KEYCLOAK_CLIENT_ID') ?? '');
|
||||||
|
$clientSecret = (string)(qdb_env_get('QDB_KEYCLOAK_CLIENT_SECRET') ?? '');
|
||||||
|
return [
|
||||||
|
'issuer' => $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(
|
||||||
|
"<!doctype html><meta charset=\"utf-8\"><script>const d=$payload;localStorage.setItem('qdb_token',d.token);localStorage.setItem('qdb_user',d.user);localStorage.setItem('qdb_role',d.role);location.replace(d.target);</script>",
|
||||||
|
['Content-Type' => 'text/html; charset=UTF-8']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -51,4 +51,18 @@ final class AuthTest extends QdbTestCase
|
|||||||
$res = $this->api()->request('POST', 'auth/login', ['username' => '']);
|
$res = $this->api()->request('POST', 'auth/login', ['username' => '']);
|
||||||
$this->assertApiError($res, 'MISSING_FIELDS', 400);
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1091,7 +1091,7 @@ body.login-active .main-content {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 28px 32px;
|
padding: 48px 32px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
@ -1101,8 +1101,7 @@ body.login-active .main-content {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 60% at 30% 40%, rgba(37, 99, 235, 0.12), transparent 65%),
|
linear-gradient(135deg, rgba(219, 234, 254, 0.78) 0%, rgba(248, 250, 252, 0.96) 48%, rgba(238, 242, 255, 0.72) 100%),
|
||||||
radial-gradient(ellipse 55% 45% at 75% 65%, rgba(79, 70, 229, 0.08), transparent 55%),
|
|
||||||
var(--bg);
|
var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1110,56 +1109,28 @@ body.login-active .main-content {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
linear-gradient(rgba(100, 116, 139, 0.08) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
linear-gradient(90deg, rgba(100, 116, 139, 0.08) 1px, transparent 1px);
|
||||||
background-size: 56px 56px;
|
background-size: 48px 48px;
|
||||||
mask-image: radial-gradient(ellipse 90% 80% at 50% 45%, #000 25%, transparent 80%);
|
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), transparent 92%);
|
||||||
}
|
|
||||||
|
|
||||||
.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); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-screen__content {
|
.login-screen__content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 980px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand {
|
.login-brand {
|
||||||
margin-bottom: 28px;
|
margin-bottom: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand__title {
|
.login-brand__title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
font-weight: 750;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: 0;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1171,7 +1142,12 @@ body.login-active .main-content {
|
|||||||
.login-card {
|
.login-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
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 {
|
.login-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
@ -1179,23 +1155,101 @@ body.login-active .main-content {
|
|||||||
inset: 0 0 auto 0;
|
inset: 0 0 auto 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--accent-gradient);
|
background: var(--accent-gradient);
|
||||||
opacity: 0.85;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card__heading {
|
.login-card__heading {
|
||||||
font-size: 1.15rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 750;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card__lead {
|
.login-card__lead {
|
||||||
font-size: 0.875rem;
|
font-size: 0.925rem;
|
||||||
color: var(--text-secondary);
|
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 {
|
.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 {
|
.login-alert {
|
||||||
@ -1212,9 +1266,8 @@ body.login-active .main-content {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-card__footnote {
|
.login-card__footnote {
|
||||||
margin-top: 20px;
|
margin-top: 22px;
|
||||||
padding-top: 16px;
|
text-align: center;
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@ -1227,10 +1280,23 @@ body.login-active .main-content {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding-top: 48px;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.login-orb,
|
|
||||||
.spinner {
|
.spinner {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,15 @@ function setFormBusy(formId, busy, busyLabel) {
|
|||||||
form.querySelectorAll('input').forEach((el) => { el.disabled = busy; });
|
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() {
|
export async function loginPage() {
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
try {
|
try {
|
||||||
@ -40,8 +49,6 @@ export async function loginPage() {
|
|||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="login-screen" role="main">
|
<div class="login-screen" role="main">
|
||||||
<div class="login-screen__backdrop" aria-hidden="true">
|
<div class="login-screen__backdrop" aria-hidden="true">
|
||||||
<div class="login-orb login-orb--a"></div>
|
|
||||||
<div class="login-orb login-orb--b"></div>
|
|
||||||
<div class="login-grid"></div>
|
<div class="login-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -51,8 +58,9 @@ export async function loginPage() {
|
|||||||
<p class="login-brand__subtitle">Questionnaire Management</p>
|
<p class="login-brand__subtitle">Questionnaire Management</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="login-card card">
|
<div id="loginOptions" class="login-options">
|
||||||
<h2 id="login-panel-title" class="login-card__heading">Sign in</h2>
|
<section class="login-card login-card--local card" aria-labelledby="login-panel-title">
|
||||||
|
<h2 id="login-panel-title" class="login-card__heading">Local account</h2>
|
||||||
<p id="loginPanelSubtitle" class="login-card__lead">Admin or supervisor account.</p>
|
<p id="loginPanelSubtitle" class="login-card__lead">Admin or supervisor account.</p>
|
||||||
|
|
||||||
<form id="loginForm" class="login-form" autocomplete="on">
|
<form id="loginForm" class="login-form" autocomplete="on">
|
||||||
@ -84,16 +92,66 @@ export async function loginPage() {
|
|||||||
<p id="changePwError" class="login-alert login-alert--error" role="alert" hidden></p>
|
<p id="changePwError" class="login-alert login-alert--error" role="alert" hidden></p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="keycloakLoginSection" class="login-card login-card--sso card" aria-labelledby="keycloak-panel-title">
|
||||||
|
<h2 id="keycloak-panel-title" class="login-card__heading">University login</h2>
|
||||||
|
<p id="keycloakPanelSubtitle" class="login-card__lead">Use your University Konstanz credentials.</p>
|
||||||
|
<div class="login-sso">
|
||||||
|
<div class="login-sso__logo" aria-hidden="true">
|
||||||
|
<img
|
||||||
|
src="media/uni-logo.png"
|
||||||
|
alt=""
|
||||||
|
onerror="this.hidden=true; this.parentElement.classList.add('login-sso__logo--fallback');"
|
||||||
|
>
|
||||||
|
<span>Universität Konstanz</span>
|
||||||
|
</div>
|
||||||
|
<button id="keycloakLoginBtn" type="button" class="btn btn-block login-sso__button" disabled>
|
||||||
|
Log in with University Konstanz
|
||||||
|
</button>
|
||||||
|
<p id="keycloakLoginError" class="login-alert login-alert--error" role="alert" hidden></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="login-card__footnote">Counselors use the mobile app.</p>
|
<p class="login-card__footnote">Counselors use the mobile app.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let pendingUsername = '';
|
let pendingUsername = '';
|
||||||
let pendingTempToken = '';
|
let pendingTempToken = '';
|
||||||
|
|
||||||
|
const keycloakSection = document.getElementById('keycloakLoginSection');
|
||||||
|
const keycloakBtn = document.getElementById('keycloakLoginBtn');
|
||||||
|
const keycloakErr = document.getElementById('keycloakLoginError');
|
||||||
|
let keycloakLoginUrl = '';
|
||||||
|
|
||||||
|
apiGet('auth/keycloak-config')
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.success || !data.enabled) {
|
||||||
|
keycloakBtn.textContent = 'University login unavailable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keycloakLoginUrl = data.loginUrl ? apiUrl(data.loginUrl) : apiUrl('auth/keycloak-login');
|
||||||
|
keycloakBtn.textContent = `Log in with ${data.displayName || 'University Konstanz'}`;
|
||||||
|
keycloakBtn.disabled = false;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
keycloakBtn.textContent = 'University login unavailable';
|
||||||
|
});
|
||||||
|
|
||||||
|
keycloakBtn.addEventListener('click', () => {
|
||||||
|
keycloakErr.hidden = true;
|
||||||
|
if (!keycloakLoginUrl) {
|
||||||
|
keycloakErr.textContent = 'University login is not available.';
|
||||||
|
keycloakErr.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setButtonBusy(keycloakBtn, true, 'Redirecting...');
|
||||||
|
window.location.assign(keycloakLoginUrl);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const errEl = document.getElementById('loginError');
|
const errEl = document.getElementById('loginError');
|
||||||
@ -136,6 +194,7 @@ export async function loginPage() {
|
|||||||
pendingTempToken = d.token;
|
pendingTempToken = d.token;
|
||||||
document.getElementById('loginForm').hidden = true;
|
document.getElementById('loginForm').hidden = true;
|
||||||
document.getElementById('changePwSection').hidden = false;
|
document.getElementById('changePwSection').hidden = false;
|
||||||
|
keycloakSection.hidden = true;
|
||||||
document.getElementById('login-panel-title').textContent = 'New password';
|
document.getElementById('login-panel-title').textContent = 'New password';
|
||||||
document.getElementById('loginPanelSubtitle').textContent = pendingUsername;
|
document.getElementById('loginPanelSubtitle').textContent = pendingUsername;
|
||||||
document.getElementById('newPw').focus();
|
document.getElementById('newPw').focus();
|
||||||
|
|||||||
BIN
website/media/UniKonstanz_Logo_Optimum_RGB.eps
Normal file
BIN
website/media/UniKonstanz_Logo_Optimum_RGB.eps
Normal file
Binary file not shown.
BIN
website/media/uni-logo.png
Normal file
BIN
website/media/uni-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Reference in New Issue
Block a user