Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 0 additions & 26 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -824,29 +824,3 @@ type Query {
"Version and build metadata."
serverInfo: ServerInfo!
}

# ----- students.graphqls -----
"""
One roster entry of a course, enriched with ZPA (Prüfungsamt) student details when
they could be found. `found` is false and the detail fields are null when ZPA is
not configured or has no unambiguous match — the GUI then shows just the email.
"""
type CourseStudent {
"The roster email (the identity glabs keys students on)."
email: String!
"Whether ZPA details were found for this email."
found: Boolean!
firstName: String
lastName: String
"The ZPA gender field (sensitive — shown only when the course owner opts in)."
gender: String
"The ZPA group/study programme."
group: String
"The Matrikelnummer."
mtknr: String
}

extend type Query {
"The course-level roster of one of the caller's courses, enriched with ZPA details, sorted by last name."
courseStudents(course: String!): [CourseStudent!]!
}
137 changes: 7 additions & 130 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,143 +1,20 @@
import { json } from '@sveltejs/kit';
import type { Handle, RequestEvent } from '@sveltejs/kit';
import { graphql } from '$lib/gql';
import { authContext, backendRequest } from '$lib/server/backend';

/** HTML-escape für die Einblendung der E-Mail in die „Kein Zutritt"-Seite. */
function escapeHtml(s: string) {
return s
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

/**
* Eigenständige „Kein Zutritt"-Antwort ohne Navigation/Layout. Wird ausgeliefert,
* sobald ein am Proxy angemeldeter Benutzer im Backend nicht freigeschaltet ist —
* für /api als 403-JSON, sonst als in sich geschlossene HTML-Seite (kein Nav,
* keine SSR-load()s, die ohnehin alle 401/403→500 laufen würden).
*/
function denyResponse(event: RequestEvent, remoteUser: string) {
if (event.url.pathname.startsWith('/api/')) {
return json(
{ error: 'Kein Zutritt — diese Kennung ist nicht freigeschaltet.' },
{ status: 403 }
);
}
const email = escapeHtml(remoteUser);
const html = `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kein Zutritt · glabs</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
background: #f3f4f6; color: #1f2937; padding: 1.5rem;
}
.card {
background: #fff; max-width: 32rem; width: 100%; border-radius: 1rem;
padding: 2.5rem 2rem; box-shadow: 0 10px 30px rgba(0,0,0,.08);
border: 1px solid #e5e7eb; text-align: center;
}
.icon { font-size: 3rem; line-height: 1; margin-bottom: .75rem; }
h1 { margin: 0 0 .75rem; font-size: 1.5rem; }
p { margin: 0 0 1rem; line-height: 1.55; }
.email {
display: inline-block; font-family: ui-monospace, monospace;
background: #f3f4f6; border-radius: .5rem; padding: .2rem .5rem;
}
.muted { color: #6b7280; font-size: .9rem; margin-bottom: 0; }
@media (prefers-color-scheme: dark) {
body { background: #111827; color: #e5e7eb; }
.card { background: #1f2937; border-color: #374151; box-shadow: none; }
.email { background: #374151; }
.muted { color: #9ca3af; }
}
</style>
</head>
<body>
<main class="card">
<div class="icon">🚫</div>
<h1>Kein Zutritt</h1>
<p>Ihre Kennung <span class="email">${email}</span> ist für glabs nicht freigeschaltet.</p>
<p class="muted">Wenden Sie sich an die Administration, um freigeschaltet zu werden.</p>
</main>
</body>
</html>`;
return new Response(html, {
status: 403,
headers: { 'content-type': 'text/html; charset=utf-8' }
});
}

let authCache: { user: string | undefined; authorized: boolean; expires: number } = {
user: undefined,
authorized: false,
expires: 0
};

/**
* Ist die aktuelle (vom Proxy gelieferte) Kennung im Backend freigeschaltet?
* Ergebnis ~30 s pro Kennung gecacht, damit nicht jeder Request ein `me` auslöst.
*
* Unterscheidung wichtig:
* - Erfolgreiche Antwort mit `me.email` → freigeschaltet.
* - Backend antwortet mit Fehler (ClientError, `.response` gesetzt) → bewusst
* abgelehnt (Kennung nicht in der Allowlist) → gesperrt.
* - Netzwerk-/sonstiger Fehler (keine `.response`) → unklar, NICHT sperren
* (ein kurzzeitig nicht erreichbares Backend darf niemanden aussperren).
*/
async function isAuthorized(remoteUser: string) {
const now = Date.now();
if (authCache.user === remoteUser && now < authCache.expires) return authCache.authorized;
try {
const data = await backendRequest(
graphql(`
query AuthCheck {
me {
email
}
}
`)
);
authCache = { user: remoteUser, authorized: !!data?.me?.email, expires: now + 30000 };
} catch (err) {
// ClientError (bewusste Ablehnung) trägt `.response`; ein Netzwerkfehler
// nicht → im Zweifel nicht aussperren.
const hasResponse = !!(err as { response?: unknown })?.response;
authCache = { user: remoteUser, authorized: !hasResponse, expires: now + 30000 };
}
return authCache.authorized;
}
import type { Handle } from '@sveltejs/kit';
import { authContext } from '$lib/server/backend';

export const handle: Handle = async ({ event, resolve }) => {
// Vom Auth-Proxy (oauth2-proxy hinter Caddy) autoritativ injizierte Identität.
// Wird als AsyncLocalStorage-Kontext gesetzt, damit jeder serverseitige
// GraphQL-Call (SSR-load()s, spätere /api-Proxys) sie als X-Remote-User an
// glabs-web weiterreicht — siehe $lib/server/backend. Bei glabs ist die
// Kennung zugleich die E-Mail-Adresse.
//
// Es gibt keinen Zugangs-Riegel mehr: glabs-web hat keine Allowlist, jede vom
// Proxy authentifizierte Kennung (auf hm.edu eingeschränkt) ist zugelassen und
// arbeitet strikt als eigener Nutzer. Der Proxy ist die Zugangsgrenze.
const remoteUser = event.request.headers.get('x-remote-user') || undefined;
const remoteDisplayname = event.request.headers.get('x-remote-displayname') || undefined;
event.locals.remoteUser = remoteUser;
event.locals.remoteDisplayname = remoteDisplayname;

return authContext.run({ remoteUser, remoteDisplayname }, async () => {
// Zugangs-Riegel: Läuft das GUI hinter dem Auth-Proxy (remoteUser gesetzt),
// autorisiert das Backend anhand einer E-Mail-Allowlist. Ist die Kennung
// nicht freigeschaltet, würde jeder Backend-Call 401/403→500 liefern und die
// Nav trotzdem erscheinen. Stattdessen hier zentral abfangen und eine
// eigenständige „Kein Zutritt"-Seite ausliefern. Ohne remoteUser (lokal/Dev)
// greift der Riegel nicht → Verhalten wie bisher (glabs-web läuft dann mit
// auth.enabled: false als Dev-User).
if (remoteUser && !(await isAuthorized(remoteUser))) {
return denyResponse(event, remoteUser);
}
return resolve(event);
});
return authContext.run({ remoteUser, remoteDisplayname }, () => resolve(event));
};
Loading