Login-/Session-/Passkey-Logik vollständig entfernen

Die App läuft künftig nur noch lokal, daher ist Anmeldung nicht mehr
nötig: Proxy-Middleware, Login-Seite, Session-Handling, Passkey/WebAuthn
(inkl. DB-Tabelle ausgaben_passkeys), Logout-Button und der Passwort-Hash-
Generator (scripts/generate-password.js) entfallen. Der Einstellungen-Tab
enthielt ausschließlich Passkey-Verwaltung und wurde daher ebenfalls
entfernt. Nicht mehr benötigte Abhängigkeiten (@simplewebauthn/*,
bcryptjs, jose) sowie die zugehörigen Env-Variablen (AUTH_USERS,
AUTH_SECRET, AUSGABEN_RP_*) wurden aus package.json, Docker-Compose-
Dateien und Dokumentation gestrichen. AUTH_README.md komplett entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 13:58:46 +02:00
parent d3718da1f8
commit bacd11f722
25 changed files with 22 additions and 1816 deletions
-39
View File
@@ -1,39 +0,0 @@
import bcrypt from 'bcryptjs';
export interface User {
username: string;
password: string;
}
export function getUsers(): User[] {
const usersString = process.env.AUTH_USERS || '';
if (!usersString) {
console.warn('AUTH_USERS not configured in .env');
return [];
}
return usersString
.split(',')
.map((userPair) => {
const [username, password] = userPair.trim().split(':');
return { username: username?.trim(), password: password?.trim() };
})
.filter((user) => user.username && user.password);
}
export async function verifyCredentials(username: string, password: string): Promise<boolean> {
const users = getUsers();
const user = users.find(u => u.username === username);
if (!user) {
return false;
}
return bcrypt.compare(password, user.password);
}
/** Prüft, ob der Benutzername (noch) in AUTH_USERS hinterlegt ist. */
export function isKnownUser(username: string): boolean {
return getUsers().some(u => u.username === username);
}
export function isAuthEnabled(): boolean {
return !!process.env.AUTH_USERS;
}
-146
View File
@@ -1,146 +0,0 @@
import { getDbPool } from './db';
import type { RowDataPacket, ResultSetHeader } from 'mysql2/promise';
// Eigene Tabelle je Anwendung: Auf dem Server teilen sich mehrere Apps die
// Datenbank RXF. werte-next verwendet `passkeys` — würde diese App denselben
// Namen nutzen, sähe sie fremde Passkeys, könnte sie löschen und über
// excludeCredentials die eigene Registrierung blockieren.
const TABLE = 'ausgaben_passkeys';
/** Ein gespeicherter Passkey (WebAuthn-Credential), einem Benutzer zugeordnet. */
export interface PasskeyRecord {
credentialId: string; // base64url
username: string;
publicKey: Uint8Array<ArrayBuffer>;
counter: number;
transports: string[];
label: string;
createdAt: Date;
lastUsedAt: Date | null;
}
interface PasskeyRow extends RowDataPacket {
credential_id: string;
username: string;
public_key: Buffer;
counter: number;
transports: string;
label: string;
created_at: Date;
last_used_at: Date | null;
}
let schemaReady: Promise<void> | null = null;
/**
* Legt die Passkey-Tabelle beim ersten Zugriff an (einmal pro Prozess).
* Das Schema steht zusätzlich in create_table.sql.
*/
function ensureSchema(): Promise<void> {
if (!schemaReady) {
schemaReady = getDbPool()
.query(
`CREATE TABLE IF NOT EXISTS ${TABLE} (
credential_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
username VARCHAR(64) NOT NULL,
public_key VARBINARY(1024) NOT NULL,
counter INT UNSIGNED NOT NULL DEFAULT 0,
transports VARCHAR(255) NOT NULL DEFAULT '[]',
label VARCHAR(80) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL,
INDEX idx_ausgaben_passkeys_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
)
.then(() => undefined)
.catch((error) => {
schemaReady = null; // nächster Aufruf darf es erneut versuchen
throw error;
});
}
return schemaReady;
}
function fromRow(row: PasskeyRow): PasskeyRecord {
let transports: string[] = [];
try {
const parsed = JSON.parse(row.transports);
if (Array.isArray(parsed)) transports = parsed;
} catch {
/* fehlerhafte transports ignorieren */
}
return {
credentialId: row.credential_id,
username: row.username,
// Uint8Array.from kopiert in einen frischen ArrayBuffer (passt zum von
// @simplewebauthn erwarteten Uint8Array<ArrayBuffer>).
publicKey: Uint8Array.from(row.public_key),
counter: row.counter,
transports,
label: row.label,
createdAt: row.created_at,
lastUsedAt: row.last_used_at,
};
}
/** Alle Passkeys eines Benutzers, neueste zuerst. */
export async function listPasskeys(username: string): Promise<PasskeyRecord[]> {
await ensureSchema();
const [rows] = await getDbPool().execute<PasskeyRow[]>(
`SELECT * FROM ${TABLE} WHERE username = ? ORDER BY created_at DESC`,
[username]
);
return rows.map(fromRow);
}
/** Passkey anhand der Credential-ID — benutzerübergreifend, für die Anmeldung. */
export async function getPasskey(credentialId: string): Promise<PasskeyRecord | undefined> {
await ensureSchema();
const [rows] = await getDbPool().execute<PasskeyRow[]>(
`SELECT * FROM ${TABLE} WHERE credential_id = ?`,
[credentialId]
);
return rows.length > 0 ? fromRow(rows[0]) : undefined;
}
export async function insertPasskey(record: {
credentialId: string;
username: string;
publicKey: Uint8Array;
counter: number;
transports: string[];
label: string;
}): Promise<void> {
await ensureSchema();
await getDbPool().execute(
`INSERT INTO ${TABLE} (credential_id, username, public_key, counter, transports, label)
VALUES (?, ?, ?, ?, ?, ?)`,
[
record.credentialId,
record.username,
Buffer.from(record.publicKey),
record.counter,
JSON.stringify(record.transports ?? []),
record.label,
]
);
}
/** Aktualisiert Counter und last_used_at nach erfolgreicher Anmeldung (Replay-Schutz). */
export async function updatePasskeyUsage(credentialId: string, counter: number): Promise<void> {
await ensureSchema();
await getDbPool().execute(
`UPDATE ${TABLE} SET counter = ?, last_used_at = NOW() WHERE credential_id = ?`,
[counter, credentialId]
);
}
/** Löscht einen Passkey — nur wenn er dem angegebenen Benutzer gehört. */
export async function deletePasskey(credentialId: string, username: string): Promise<boolean> {
await ensureSchema();
const [result] = await getDbPool().execute<ResultSetHeader>(
`DELETE FROM ${TABLE} WHERE credential_id = ? AND username = ?`,
[credentialId, username]
);
return result.affectedRows > 0;
}
-102
View File
@@ -1,102 +0,0 @@
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'auth_session';
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
const key = new TextEncoder().encode(secretKey);
export interface SessionData {
username: string;
isAuthenticated: boolean;
expiresAt: number;
}
/**
* Encrypt session data to JWT
*/
async function encrypt(payload: SessionData): Promise<string> {
return await new SignJWT(payload as any)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt))
.sign(key);
}
/**
* Decrypt JWT to session data
*/
async function decrypt(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtVerify(token, key, {
algorithms: ['HS256'],
});
return {
username: payload.username as string,
isAuthenticated: payload.isAuthenticated as boolean,
expiresAt: payload.expiresAt as number,
};
} catch (error) {
return null;
}
}
/**
* Create a new session
*/
export async function createSession(username: string): Promise<void> {
const expiresAt = Date.now() + SESSION_DURATION;
const session: SessionData = {
username,
isAuthenticated: true,
expiresAt,
};
const encryptedSession = await encrypt(session);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, encryptedSession, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expiresAt,
sameSite: 'lax',
path: '/',
});
}
/**
* Get current session
*/
export async function getSession(): Promise<SessionData | null> {
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE_NAME);
if (!cookie?.value) {
return null;
}
const session = await decrypt(cookie.value);
if (!session || session.expiresAt < Date.now()) {
return null;
}
return session;
}
/**
* Delete session (logout)
*/
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}
/**
* Verify if user is authenticated
*/
export async function isAuthenticated(): Promise<boolean> {
const session = await getSession();
return session?.isAuthenticated ?? false;
}
-176
View File
@@ -1,176 +0,0 @@
import { cookies } from 'next/headers';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from '@simplewebauthn/server';
import { isKnownUser } from './auth';
import { listPasskeys, getPasskey, insertPasskey, updatePasskeyUsage } from './passkeys';
const REG_CHALLENGE_COOKIE = 'ausgaben_pk_reg';
const AUTH_CHALLENGE_COOKIE = 'ausgaben_pk_auth';
const CHALLENGE_TTL_S = 300; // 5 Minuten
/**
* Relying-Party-Konfiguration. In Produktion über Env-Vars gesetzt
* (RP_ID = Host, RP_ORIGIN = volle URL). Default-Werte für lokale Entwicklung.
*/
export function getRpConfig() {
const rpID = process.env.RP_ID || 'localhost';
const rpName = process.env.RP_NAME || 'Ausgaben-Log';
const origin = process.env.RP_ORIGIN || 'http://localhost:3005';
return { rpID, rpName, origin };
}
// --- Challenge-Cookie (kurzlebig, httpOnly) ------------------------------
async function setChallengeCookie(name: string, challenge: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(name, challenge, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: CHALLENGE_TTL_S,
});
}
async function takeChallengeCookie(name: string): Promise<string | null> {
const cookieStore = await cookies();
const value = cookieStore.get(name)?.value ?? null;
cookieStore.delete(name);
return value;
}
// --- Registrierung (Enrollment, Session erforderlich) --------------------
export async function buildRegistrationOptions(username: string) {
const { rpID, rpName } = getRpConfig();
const existing = await listPasskeys(username);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: username,
userDisplayName: username,
userID: new TextEncoder().encode(username),
attestationType: 'none',
excludeCredentials: existing.map((pk) => ({
id: pk.credentialId,
transports: pk.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
await setChallengeCookie(REG_CHALLENGE_COOKIE, options.challenge);
return options;
}
/** Verifiziert die Registrierungs-Antwort und speichert den Passkey für den Benutzer. */
export async function finishRegistration(
username: string,
response: RegistrationResponseJSON,
label: string
): Promise<{ ok: true } | { ok: false; error: string }> {
const expectedChallenge = await takeChallengeCookie(REG_CHALLENGE_COOKIE);
if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' };
const { rpID, origin } = getRpConfig();
let verification;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
} catch {
return { ok: false, error: 'Passkey konnte nicht verifiziert werden.' };
}
if (!verification.verified || !verification.registrationInfo) {
return { ok: false, error: 'Passkey-Registrierung fehlgeschlagen.' };
}
const { credential } = verification.registrationInfo;
if (await getPasskey(credential.id)) {
return { ok: false, error: 'Dieser Passkey ist bereits registriert.' };
}
await insertPasskey({
credentialId: credential.id,
username,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports ?? [],
label: label.trim().slice(0, 80) || 'Passkey',
});
return { ok: true };
}
// --- Anmeldung (öffentlich, keine Session) -------------------------------
export async function buildAuthenticationOptions() {
const { rpID } = getRpConfig();
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
// allowCredentials leer lassen → erlaubt Discoverable Credentials / Browser-Auswahl.
});
await setChallengeCookie(AUTH_CHALLENGE_COOKIE, options.challenge);
return options;
}
/**
* Verifiziert die Anmelde-Antwort gegen einen gespeicherten Passkey und liefert
* den zugehörigen Benutzer zurück.
*/
export async function finishAuthentication(
response: AuthenticationResponseJSON
): Promise<{ ok: true; username: string } | { ok: false; error: string }> {
const expectedChallenge = await takeChallengeCookie(AUTH_CHALLENGE_COOKIE);
if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' };
const passkey = await getPasskey(response.id);
if (!passkey) return { ok: false, error: 'Unbekannter Passkey.' };
// Benutzer könnte inzwischen aus AUTH_USERS entfernt worden sein.
if (!isKnownUser(passkey.username)) {
return { ok: false, error: 'Benutzer existiert nicht mehr.' };
}
const { rpID, origin } = getRpConfig();
let verification;
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
credential: {
id: passkey.credentialId,
publicKey: passkey.publicKey,
counter: passkey.counter,
transports: passkey.transports as AuthenticatorTransportFuture[],
},
});
} catch {
return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' };
}
if (!verification.verified) return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' };
await updatePasskeyUsage(passkey.credentialId, verification.authenticationInfo.newCounter);
return { ok: true, username: passkey.username };
}