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
163 changes: 151 additions & 12 deletions src/controller.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"});
}
Expand All @@ -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"});
}
Expand Down Expand Up @@ -318,10 +455,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});

}

Expand Down
65 changes: 65 additions & 0 deletions src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
16 changes: 15 additions & 1 deletion src/webapp.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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);
Expand Down Expand Up @@ -68,6 +70,18 @@ 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)
Expand Down
65 changes: 46 additions & 19 deletions views/admin.pug
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading