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:
-39
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user