diff --git a/src/controller.js b/src/controller.js index eedb777..78566b1 100644 --- a/src/controller.js +++ b/src/controller.js @@ -1,4 +1,7 @@ -import { deletePasskey, getAllUsers, getPasskey, getUserByWebAuthnID, getUserPasskeys, storePasskey, updatePasskeyCounter } from "./db.js"; +import { deletePasskey, getAllUsers, getPasskey, getUserByWebAuthnID, getUserPasskeys, storePasskey, updatePasskeyCounter, + createInviteLink, getInviteLink, removeInviteLink, setInviteLinkUsed, + getAllInviteLinks, + deleteUser} from "./db.js"; import fs from "fs"; import path from "path"; import { @@ -16,24 +19,27 @@ function log(message) { fs.appendFileSync(logPath, `[${timestamp}] ${message}\n`); console.log(`[${timestamp}] ${message}`); } + +function isAdmin(req) { + return req.session.uuid && req.session.uuid === process.env.ADMIN_UUID; +} export async function admin(req, res) { log("Admin-Panel Zugriff versucht von UUID: " + req.session.uuid); - const adminUuid = process.env.ADMIN_UUID; - if (!req.session.uuid || req.session.uuid !== adminUuid) { + if (!isAdmin(req)) { log("Admin-Panel Zugriff verweigert für UUID: " + req.session.uuid); return res.status(403).render('error', {error: 403, message: "Zugriff verweigert"}); } const users = await getAllUsers(); - log("Admin-Panel Zugriff gewährt. Nutzer geladen: " + users.length); - res.render('admin', {users, title: "Admin"}); + const invites = await getAllInviteLinks(); + log("Admin-Panel Zugriff gewährt. Nutzer geladen: " + users.length + ", Einladungen geladen: " + (Array.isArray(invites) ? invites.length : 0)); + res.render('admin', {users, invites, title: "Admin"}); } // Admin: Passwort zurücksetzen (Formular) export function adminPasswordResetView(req, res) { const { username } = req.params; log(`AdminPasswordReset-View aufgerufen von UUID: ${req.session.uuid} für username: ${username}`); - const adminUuid = process.env.ADMIN_UUID; - if (!req.session.uuid || req.session.uuid !== adminUuid) { + if (!isAdmin(req)) { log(`AdminPasswordReset Zugriff verweigert für UUID: ${req.session.uuid}`); return res.status(403).render('error', {error: 403, message: "Zugriff verweigert"}); } @@ -45,13 +51,144 @@ export function adminPasswordResetView(req, res) { res.render('adminPasswordReset', {username: username, title: `Passwort zurücksetzen: ${username}`}); } +// Einladungslink-System +export function inviteCreateView(req, res) { + // Admin-Formular zum Erstellen eines Einladungslinks anzeigen + res.render('inviteCreate', { title: 'Einladung erstellen' }); +} + +// Admin: Nutzer löschen (Bestätigungsseite) +export async function adminDeleteUserView(req, res) { + const { username } = req.params; + if (!isAdmin(req)) { + return res.status(403).render('error', {error: 403, message: "Zugriff verweigert"}); + } + if (!username) { + return res.redirect('/admin?error=missingUsername'); + } + const uuid = await getUUIDByUsername(username); + if (!uuid) { + return res.redirect('/admin?error=userNotFound'); + } + const displayname = await getDisplayname(uuid); + res.render('adminDeleteUser', {user: {uuid, username, displayname}, title: `Nutzer löschen: ${username}`}); +} + +// Admin: Nutzer löschen (POST) +export async function adminDeleteUserPost(req, res) { + const { username } = req.params; + if (!isAdmin(req)) { + return res.status(403).render('error', {error: 403, message: "Zugriff verweigert"}); + } + if (!username) { + return res.redirect('/admin?error=missingUsername'); + } + const uuid = await getUUIDByUsername(username); + if (!uuid) { + return res.redirect('/admin?error=userNotFound'); + } + try { + await deleteUser(uuid); + return res.redirect('/admin'); + } catch (err) { + return res.redirect('/admin?error=deleteFailed'); + } +} + +export function inviteCreatePost(req, res) { + if (!isAdmin(req)) { + return res.status(403).render('error', {error: 403, message: "Zugriff verweigert"}); + } + const { inviteId } = req.body; + if (!inviteId || inviteId.trim() === "") { + return res.redirect('/admin?error=inviteIdMissing'); + } + createInviteLink(inviteId.trim()).then(() => { + return res.redirect('/admin'); + }).catch((err) => { + return res.redirect('/admin?error=inviteCreateFailed'); + }); +} + +export function inviteDeleteView(req, res) { + // Bestätigungsseite zum Löschen eines Einladungslinks anzeigen + const { id } = req.params; + res.render('inviteDelete', { title: 'Einladung löschen', invite: { id } }); +} + +export function inviteDeletePost(req, res) { + if (!isAdmin(req)) { + return res.status(403).render('error', {error: 403, message: "Zugriff verweigert"}); + } + const { id } = req.params; + if (!id || id.trim() === "") { + return res.redirect('/admin?error=inviteIdMissing'); + } + removeInviteLink(id.trim()).then(() => { + return res.redirect('/admin'); + }).catch((err) => { + return res.redirect('/admin?error=inviteDeleteFailed'); + }); +} + +export function inviteRegistrationView(req, res) { + // Registrierung über Einladung anzeigen + res.render('inviteRegistration', { title: 'Registrierung über Einladung'}); +} + +export function inviteRegistrationPost(req, res) { + const { username, password, passwordRepeat, displayname, inviteId } = req.body; + if (!inviteId || inviteId.trim() === "") { + return res.render('inviteRegistration', { error: "Einladungscode fehlt.", title: "Registrierung über Einladung" }); + } + // Lade Invite-Daten + getInviteLink(inviteId).then(async (invite) => { + if (!invite || (invite.usedAt && invite.usedAt !== "")) { + console.log('tried to used invalid or already used invite link',invite); + return res.render('inviteRegistration', { error: "Ungültiger oder bereits genutzter Einladungslink.", invite: { inviteId }, title: "Registrierung über Einladung" }); + } + // Validierung + if (!username || !password || !passwordRepeat || !displayname) { + return res.render('inviteRegistration', { error: "Bitte alle Felder ausfüllen.", invite: { inviteId }, title: "Registrierung über Einladung" }); + } + if (password !== passwordRepeat) { + return res.render('inviteRegistration', { error: "Passwörter stimmen nicht überein.", invite: { inviteId }, title: "Registrierung über Einladung" }); + } + if (!isUsernameValid(username)) { + return res.render('inviteRegistration', { error: "Ungültiger Benutzername.", invite: { inviteId }, title: "Registrierung über Einladung" }); + } + if (!isPasswordValid(password)) { + return res.render('inviteRegistration', { error: "Ungültiges Passwort.", invite: { inviteId }, title: "Registrierung über Einladung" }); + } + if (!isDisplaynameValid(displayname)) { + return res.render('inviteRegistration', { error: "Ungültiger Anzeigename.", invite: { inviteId }, title: "Registrierung über Einladung" }); + } + // Prüfe, ob Username verfügbar + const exists = await isUsernameAvailable(username); + if (exists) { + return res.render('inviteRegistration', { error: "Benutzername bereits vergeben.", invite: { inviteId }, title: "Registrierung über Einladung" }); + } + // Registrierung durchführen + const uuid = crypto.randomUUID(); + try { + await storeUser(uuid, username, password, displayname); + await setInviteLinkUsed(inviteId, uuid); + return res.redirect('/login'); + } catch (err) { + console.log(err); + return res.render('inviteRegistration', { error: "Fehler bei der Registrierung.", invite: { inviteId }, title: "Registrierung über Einladung" }); + } + }).catch(() => { + return res.render('inviteRegistration', { error: "Ungültiger Einladungslink.", invite: { inviteId }, title: "Registrierung über Einladung" }); + }); +} + // Admin: Passwort setzen (Formular-Submit) export function doAdminPasswordReset(req, res) { const { username } = req.params; const { password, passwordRepeat } = req.body; log(`AdminPasswordReset Aktion von UUID: ${req.session.uuid} für username: ${username}`); - const adminUuid = process.env.ADMIN_UUID; - if (!req.session.uuid || req.session.uuid !== adminUuid) { + if (!isAdmin(req)) { log(`AdminPasswordReset Zugriff verweigert für UUID: ${req.session.uuid}`); return res.status(403).render('error', {error: 403, message: "Zugriff verweigert"}); } @@ -306,6 +443,46 @@ function isLoggedIn(req) { return req.session.uuid != null; } +export async function auth_request(req, res) { + // Helper to build the original URL the user tried to access + const originalUrl = (() => { + // Prefer proxy-provided headers if behind NGINX + const scheme = req.headers['x-forwarded-proto'] || 'https'; + const host = req.headers['x-forwarded-host'] || req.headers.host || ''; + const uri = req.headers['x-original-uri'] || req.originalUrl || '/'; + return `${scheme}://${host}${uri}`; + })(); + + if (!isLoggedIn(req)) { + log("Auth-Request: Nutzer nicht eingeloggt."); + + // Optional hint headers for gateways or API clients + // NGINX won't automatically use these, but you can read them in config + // with auth_request_set $login_url $upstream_http_x_login_url; etc. + const loginUrl = `/login?redirect_uri=${encodeURIComponent(originalUrl)}`; + res.setHeader('X-Login-URL', loginUrl); + res.setHeader('X-Return-To', originalUrl); + res.setHeader('Cache-Control', 'no-store'); + + return res.status(401).end(); + } + + log("Auth-Request: Nutzer ist eingeloggt."); + + const uuid = req.session.uuid; + const username = await getUsername(uuid); + const displayname = await getDisplayname(uuid); + + res.setHeader('X-User-UUID', uuid); + res.setHeader('X-User-Username', username); + res.setHeader('X-User-Displayname', displayname); + + res.setHeader('X-Auth-Method', 'session'); + res.setHeader('Cache-Control', 'no-store'); + + // 204 is common for auth_request success; 200 also works + return res.status(204).end(); +} export async function dashboard(req, res) { const {redirect_uri, error, state} = req.query; log(`Dashboard-View aufgerufen. Session UUID: ${req.session.uuid}, redirect_uri: ${redirect_uri}, error: ${error}, state: ${state}`); @@ -318,10 +495,12 @@ export async function dashboard(req, res) { log("Dashboard-View: Nutzer nicht eingeloggt. Weiterleitung zu: " + suffix); return res.redirect(suffix); } - let uname = await getUsername(req.session.uuid); - let dname = await getDisplayname(req.session.uuid); + const uname = await getUsername(req.session.uuid); + const dname = await getDisplayname(req.session.uuid); + const adminUuid = process.env.ADMIN_UUID; + const is_admin = req.session.uuid === adminUuid; log(`Dashboard-View für Nutzer: ${uname}, Displayname: ${dname}`); - res.render('dashboard', {username: uname, displayname: dname, error: error, title: "Dashboard", state: state, redirect_uri: redirect_uri}); + res.render('dashboard', {username: uname, displayname: dname, error: error, title: "Dashboard", state: state, redirect_uri: redirect_uri,is_admin}); } diff --git a/src/db.js b/src/db.js index 6716595..746f15a 100644 --- a/src/db.js +++ b/src/db.js @@ -204,4 +204,69 @@ export async function getPasskeysByWebAuthnUserID(webauthnUserID) { } return getUserPasskeys(uuid); +} + +export async function createInviteLink(id) { + const exists = await rc.exists("guard:invite:" + escape(id)); + if (exists) { + throw new Error("Invite link already exists"); + } + const now = Date.now(); + await rc.hSet("guard:invite:" + escape(id), { + id, + createdAt: now, + usedAt: "", + usedBy: "" + }); +} + +export async function removeInviteLink(id) { + await rc.del("guard:invite:" + escape(id)); +} + +export async function setInviteLinkUsed(id,uuid){ + const now = Date.now(); + await rc.hSet("guard:invite:" + escape(id), "usedAt", now); + await rc.hSet("guard:invite:" + escape(id), "usedBy", uuid); +} + +export async function getInviteLink(id) { + const data = await rc.hGetAll("guard:invite:" + escape(id)); + if (!data || Object.keys(data).length === 0) { + return null; + } + return { + id: data.id, + createdAt: new Date(Number(data.createdAt)).toLocaleString(), + usedAt: data.usedAt ? new Date(Number(data.usedAt)).toLocaleString() : "", + usedBy: data.usedBy || "" + }; +} + +export async function getAllInviteLinks() { + const keys = await rc.keys("guard:invite:*"); + const invites = []; + for (const key of keys) { + const invite = await getInviteLink(key.split(":").pop()); + if (invite) { + invites.push(invite); + } + } + return invites; +} + +// Admin: Nutzer löschen +export async function deleteUser(uuid) { + // Hole alle Passkeys des Nutzers und lösche sie + const passkeyIds = await rc.hKeys("guard:user:" + escape(uuid) + ":passkeys"); + for (const id of passkeyIds) { + await deletePasskey(id); + } + // Lösche Nutzer-Daten + const username = await getUsername(uuid); + await rc.hDel("guard:usernames", username); + await rc.del("guard:user:" + escape(uuid)); + await rc.del("guard:user:" + escape(uuid) + ":passkeys"); + // Optional: weitere zugehörige Daten entfernen + return true; } \ No newline at end of file diff --git a/src/webapp.js b/src/webapp.js index 4eb266b..e605e65 100644 --- a/src/webapp.js +++ b/src/webapp.js @@ -16,6 +16,7 @@ import { displaynamechange, registerUser, token, + auth_request, passkeyAdd, passkeyManage, passkeyRemove, @@ -29,7 +30,9 @@ import { endpointVerifyAuthenticationResponse, endpointVerifyRegistrationResponse } from "./controller.js"; -import { adminPasswordResetView, doAdminPasswordReset } from "./controller.js"; +import { adminPasswordResetView, doAdminPasswordReset, inviteCreateView, inviteCreatePost, inviteDeleteView, inviteDeletePost, inviteRegistrationView, inviteRegistrationPost } from "./controller.js"; + +import { adminDeleteUserView, adminDeleteUserPost } from "./controller.js"; import * as bodyParser from "express"; const __filename = fileURLToPath(import.meta.url); @@ -62,12 +65,30 @@ webapp.use(bodyParser.urlencoded({extended: true})); // WEP PAGES // +webapp.get('/health', (req, res) => { + res.status(200).send('OK'); +}); + +webapp.get('/auth_request', auth_request) + webapp.get('/', dashboard) webapp.get('/admin', admin) webapp.get('/admin/passwordreset/:username', adminPasswordResetView) webapp.post('/admin/passwordreset/:username', doAdminPasswordReset) +// Admin: Nutzer löschen +webapp.get('/admin/deleteuser/:username', adminDeleteUserView) +webapp.post('/admin/deleteuser/:username', adminDeleteUserPost) + +// Einladungslink-System +webapp.get('/admin/invite/create', inviteCreateView) +webapp.post('/admin/invite/create', inviteCreatePost) +webapp.get('/admin/invite/delete/:id', inviteDeleteView) +webapp.post('/admin/invite/delete/:id', inviteDeletePost) +webapp.get('/register/invite', inviteRegistrationView) +webapp.post('/register/invite', inviteRegistrationPost) + webapp.get('/info', getInformation) webapp.get('/register', registerUser) diff --git a/views/admin.pug b/views/admin.pug index 1be142e..a983dc0 100644 --- a/views/admin.pug +++ b/views/admin.pug @@ -1,24 +1,51 @@ extends neumorphLayout block inner-content - h1.fs-1.mt-2.mb-3 Admin: Nutzerübersicht - table.table.table-striped.w-full.text-left.border-collapse + h1.fs-1.mb-3 Admin: Nutzerübersicht + h2.fs-3.mt-3.mb-3 Nutzer + table.table.table-striped.w-full.text-left.border-collapse.mt-2 thead.bg-gray-100 - tr - th.px-4.py-2.fs-5.font-semibold.text-gray-700 UUID - th.px-4.py-2.fs-5.font-semibold.text-gray-700 Displayname - th.px-4.py-2.fs-5.font-semibold.text-gray-700 Benutzername - th.px-4.py-2.fs-5.font-semibold.text-gray-700 Erstellungsdatum - th.px-4.py-2.fs-5.font-semibold.text-gray-700 Letzter Login - th.px-4.py-2.fs-5.font-semibold.text-gray-700 Aktionen - tbody - each user in users tr - td.px-4.py-2.fs-5.border-b.border-gray-200= user.uuid - td.px-4.py-2.fs-5.border-b.border-gray-200= user.displayname - td.px-4.py-2.fs-5.border-b.border-gray-200= user.username - td.px-4.py-2.fs-5.border-b.border-gray-200= user.creation - td.px-4.py-2.fs-5.border-b.border-gray-200= user.lastLogin - td.px-4.py-2.fs-5.border-b.border-gray-200 - a(href=`/admin/passwordreset/${encodeURIComponent(user.username)}`) - button.btn.btn-primary.fs-5.m-1 Passwort zurücksetzen + th.px-4.py-2.fs-5.font-semibold.text-gray-700 UUID + th.px-4.py-2.fs-5.font-semibold.text-gray-700 Displayname + th.px-4.py-2.fs-5.font-semibold.text-gray-700 Benutzername + th.px-4.py-2.fs-5.font-semibold.text-gray-700 Erstellungsdatum + th.px-4.py-2.fs-5.font-semibold.text-gray-700 Letzter Login + th.px-4.py-2.fs-5.font-semibold.text-gray-700 Aktionen + tbody + each user in users + tr + td.px-4.py-2.fs-5.border-b.border-gray-200= user.uuid + td.px-4.py-2.fs-5.border-b.border-gray-200= user.displayname + td.px-4.py-2.fs-5.border-b.border-gray-200= user.username + td.px-4.py-2.fs-5.border-b.border-gray-200= user.creation + td.px-4.py-2.fs-5.border-b.border-gray-200= user.lastLogin + td.px-4.py-2.fs-5.border-b.border-gray-200 + a(href=`/admin/passwordreset/${encodeURIComponent(user.username)}`) + button.btn.btn-primary.fs-5.m-1 Passwort zurücksetzen + a(href=`/admin/deleteuser/${encodeURIComponent(user.username)}`) + button.btn.btn-danger.fs-5.m-1 Löschen + + h2.fs-3.mt-3.mb-3 Einladungslinks + if invites && invites.length > 0 + table.table.table-striped.w-full.text-left.border-collapse.mt-2 + thead.bg-gray-100 + tr + th.px-4.py-2.fs-5.font-semibold.text-gray-700 Einladungscode + th.px-4.py-2.fs-5.font-semibold.text-gray-700 Erstellt am + th.px-4.py-2.fs-5.font-semibold.text-gray-700 Genutzt am + th.px-4.py-2.fs-5.font-semibold.text-gray-700 Benutzer + th.px-4.py-2.fs-5.font-semibold.text-gray-700 Aktionen + tbody + each invite in invites + tr + td.px-4.py-2.fs-5.border-b.border-gray-200= invite.id + td.px-4.py-2.fs-5.border-b.border-gray-200= invite.createdAt + td.px-4.py-2.fs-5.border-b.border-gray-200= invite.usedAt && invite.usedAt !== "" ? invite.usedAt : "-" + td.px-4.py-2.fs-5.border-b.border-gray-200= invite.usedBy && invite.usedBy !== "" ? invite.usedBy : "-" + td.px-4.py-2.fs-5.border-b.border-gray-200 + a.btn.btn-danger.fs-5.m-1(href=`/admin/invite/delete/${invite.id}`) Löschen + else + p.text-gray-600 Keine Einladungslinks vorhanden. + + a.btn.btn-success.mt-2.mb-3(href="/admin/invite/create") Neue Einladung erstellen diff --git a/views/adminDeleteUser.pug b/views/adminDeleteUser.pug new file mode 100644 index 0000000..a566dea --- /dev/null +++ b/views/adminDeleteUser.pug @@ -0,0 +1,24 @@ +extends neumorphLayout + +block inner-content + .card.shadow.p-4.mx-auto(style="max-width: 500px;") + h1.fs-2.mb-4.text-danger.text-center Nutzer löschen + p.fs-5.mb-4.text-center Möchten Sie den Nutzer wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. + .list-group.mb-4 + .list-group-item + strong UUID: + span.ms-2.text-monospace #{user.uuid} + .list-group-item + strong Benutzername: + span.ms-2 #{user.username} + .list-group-item + strong Displayname: + span.ms-2 #{user.displayname} + form(method="post", action=`/admin/deleteuser/${encodeURIComponent(user.username)}`) + .d-flex.justify-content-center.gap-3 + button.btn.btn-danger.fs-5.px-4(type="submit") + i.bi.bi-trash.me-2 + | Löschen + a.btn.btn-secondary.fs-5.px-4(href="/admin") + i.bi.bi-arrow-left.me-2 + | Abbrechen diff --git a/views/dashboard.pug b/views/dashboard.pug index b227429..eca9b4c 100644 --- a/views/dashboard.pug +++ b/views/dashboard.pug @@ -6,6 +6,8 @@ block inner-content a.btn.btn-primary.fs-5.m-1(href="usernamechange") Nutzername ändern a.btn.btn-primary.fs-5.m-1(href="passwordchange") Passwort ändern a.btn.btn-primary.fs-5.m-1(href="passkeymanage") Passkeys verwalten + if is_admin + a.btn.btn-warning.fs-5.m-1(href="admin") Admin-Bereich br .mb-3 a.btn.btn-outline-secondary.fs-5.m-1#logoutButton(href="logout") Abmelden \ No newline at end of file diff --git a/views/error.pug b/views/error.pug index c540809..0d49910 100644 --- a/views/error.pug +++ b/views/error.pug @@ -4,5 +4,7 @@ block inner-content .spinner-border.fs-5 .alert.alert-danger(hidden)#alert #alert-text + span + | span.fs-2.text-danger #{error} p.fs-5 #{message} \ No newline at end of file diff --git a/views/inviteCreate.pug b/views/inviteCreate.pug new file mode 100644 index 0000000..4acd4b4 --- /dev/null +++ b/views/inviteCreate.pug @@ -0,0 +1,9 @@ +extends neumorphLayout + +block inner-content + h1.fs-1.mt-2.mb-3 Einladung erstellen + form(method="post" action="/admin/invite/create") + .mb-3 + label(for="inviteId") Einladungscode + input#inviteId.form-control(type="text" name="inviteId" required placeholder="Einladungscode eingeben") + button.btn.btn-primary(type="submit") Einladung erstellen diff --git a/views/inviteDelete.pug b/views/inviteDelete.pug new file mode 100644 index 0000000..fe654b9 --- /dev/null +++ b/views/inviteDelete.pug @@ -0,0 +1,8 @@ +extends neumorphLayout + +block inner-content + h1.fs-1.mt-2.mb-3 Einladung löschen + p Möchtest du die Einladung wirklich löschen? + form(method="post" action=`/admin/invite/delete/${invite.id}`) + button.btn.btn-danger.mr-2(type="submit") Einladung löschen + a.btn.btn-secondary(href="/admin") Abbrechen diff --git a/views/inviteRegistration.pug b/views/inviteRegistration.pug new file mode 100644 index 0000000..e663e45 --- /dev/null +++ b/views/inviteRegistration.pug @@ -0,0 +1,23 @@ +extends neumorphLayout + +block inner-content + h1.fs-1.mt-2.mb-3 Registrierung über Einladung + if error + .alert.alert-danger= error + form(method="post" action=`/register/invite`) + .mb-3 + label(for="username") Benutzername + input#username.form-control(type="text" name="username" required) + .mb-3 + label(for="displayname") Anzeigename + input#displayname.form-control(type="text" name="displayname" required) + .mb-3 + label(for="password") Passwort + input#password.form-control(type="password" name="password" required) + .mb-3 + label(for="passwordRepeat") Passwort wiederholen + input#passwordRepeat.form-control(type="password" name="passwordRepeat" required) + .mb-3 + label(for="inviteId") Einladungscode + input#inviteId.form-control(type="text" name="inviteId" required) + button.btn.btn-primary(type="submit") Registrieren