Release 0.2.0

This commit is contained in:
hnzio committed 2026-10-21 17:25:28 +02:00
1 parent 7a49e66c37
commit 08e8875cb5
38 files changed
+2645 -93

No files matched your search

+610
View File
@@ -0,0 +1,610 @@
import { hash, verify } from '@node-rs/argon2';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { error } from '@sveltejs/kit';
import type { Cookies } from '@sveltejs/kit';
import { getAuthConfig } from './config.js';
import { getDatabase } from './database.js';
import { sendMail } from './mail.js';
export const SESSION_COOKIE = 'sendewerk_session';
export const INVITATION_COOKIE = 'sendewerk_invitation';
export const RESET_COOKIE = 'sendewerk_password_reset';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
const LINK_DURATION_MS = 24 * 60 * 60 * 1000;
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const EMAIL_ATTEMPT_LIMIT = 5;
const ADDRESS_ATTEMPT_LIMIT = 20;
const RESET_WINDOW_MS = 60 * 60 * 1000;
const RESET_EMAIL_LIMIT = 3;
const RESET_ADDRESS_LIMIT = 10;
const PASSWORD_OPTIONS = {
algorithm: 2,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
outputLen: 32
} as const;
export type Role = 'barkeeper' | 'thekencrew';
export type AuthUser = {
id: string;
email: string;
displayName: string;
role: Role;
lockedAtUtc: string | null;
};
type UserRow = {
id: string;
email: string;
display_name: string;
password_hash: string;
role: Role;
locked_at_utc: string | null;
};
type InvitationRow = {
id: string;
email: string;
role: Role;
expires_at_utc: string;
accepted_at_utc: string | null;
revoked_at_utc: string | null;
};
function toUser(row: UserRow): AuthUser {
return {
id: row.id,
email: row.email,
displayName: row.display_name,
role: row.role,
lockedAtUtc: row.locked_at_utc
};
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function validEmail(email: string): boolean {
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function validDisplayName(name: string): boolean {
return name.trim().length >= 2 && name.trim().length <= 80;
}
export function validPassword(password: string): boolean {
return password.length >= 12 && Buffer.byteLength(password, 'utf8') <= 256;
}
export function isRole(value: string): value is Role {
return value === 'barkeeper' || value === 'thekencrew';
}
export async function hashPassword(password: string): Promise<string> {
return hash(password, PASSWORD_OPTIONS);
}
export async function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
return verify(passwordHash, password, PASSWORD_OPTIONS);
}
const dummyPasswordHash = hashPassword(randomBytes(32).toString('hex'));
function createToken(): string {
return randomBytes(32).toString('base64url');
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function nowUtc(): string {
return new Date().toISOString();
}
function futureUtc(durationMs: number): string {
return new Date(Date.now() + durationMs).toISOString();
}
export function hasUsers(): boolean {
const row = getDatabase().prepare('SELECT count(*) AS count FROM users').get() as {
count: number;
};
return row.count > 0;
}
export function setupIsConfigured(): boolean {
return getAuthConfig().setupToken.length >= 32;
}
function setupTokenMatches(candidate: string): boolean {
const configured = getAuthConfig().setupToken;
if (configured.length < 32 || candidate.length !== configured.length) return false;
return timingSafeEqual(Buffer.from(candidate), Buffer.from(configured));
}
export async function createFirstAdmin(input: {
setupToken: string;
email: string;
displayName: string;
password: string;
}): Promise<AuthUser> {
if (!setupTokenMatches(input.setupToken)) throw new Error('SETUP_TOKEN_INVALID');
const email = input.email.trim();
const emailNormalized = normalizeEmail(email);
const displayName = input.displayName.trim();
if (!validEmail(email) || !validDisplayName(displayName) || !validPassword(input.password)) {
throw new Error('VALIDATION');
}
const passwordHash = await hashPassword(input.password);
const database = getDatabase();
const create = database.transaction(() => {
const count = database.prepare('SELECT count(*) AS count FROM users').get() as {
count: number;
};
if (count.count !== 0) throw new Error('SETUP_CLOSED');
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO users (
id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, 'barkeeper', ?, ?, ?)`
)
.run(id, email, emailNormalized, displayName, passwordHash, timestamp, timestamp, timestamp);
return { id, email, displayName, role: 'barkeeper', lockedAtUtc: null } satisfies AuthUser;
});
return create();
}
function sessionCookieOptions(expires?: Date) {
return {
path: '/',
httpOnly: true,
sameSite: 'lax' as const,
secure: getAuthConfig().secureCookies,
expires
};
}
function linkCookieOptions() {
return {
path: '/',
httpOnly: true,
sameSite: 'strict' as const,
secure: getAuthConfig().secureCookies,
maxAge: 10 * 60
};
}
export function storeLinkToken(cookies: Cookies, name: string, token: string): void {
cookies.set(name, token, linkCookieOptions());
}
export function readLinkToken(cookies: Cookies, name: string): string {
return cookies.get(name) || '';
}
export function deleteLinkToken(cookies: Cookies, name: string): void {
cookies.delete(name, { ...linkCookieOptions(), maxAge: 0 });
}
export function createSession(userId: string, cookies: Cookies): void {
const token = createToken();
const expires = new Date(Date.now() + SESSION_DURATION_MS);
getDatabase()
.prepare(
'INSERT INTO sessions (token_hash, user_id, created_at_utc, expires_at_utc) VALUES (?, ?, ?, ?)'
)
.run(hashToken(token), userId, nowUtc(), expires.toISOString());
cookies.set(SESSION_COOKIE, token, sessionCookieOptions(expires));
}
export function deleteSession(cookies: Cookies): void {
const token = cookies.get(SESSION_COOKIE);
if (token)
getDatabase().prepare('DELETE FROM sessions WHERE token_hash = ?').run(hashToken(token));
cookies.delete(SESSION_COOKIE, sessionCookieOptions());
}
export function getSessionUser(token: string | undefined): AuthUser | null {
if (!token) return null;
const database = getDatabase();
const row = database
.prepare(
`SELECT u.id, u.email, u.display_name, u.password_hash, u.role, u.locked_at_utc
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at_utc > ? AND u.locked_at_utc IS NULL`
)
.get(hashToken(token), nowUtc()) as UserRow | undefined;
if (!row) return null;
return toUser(row);
}
function rateKey(value: string): string {
return hashToken(value || 'unknown');
}
export function loginIsRateLimited(email: string, address: string): boolean {
const database = getDatabase();
const since = new Date(Date.now() - LOGIN_WINDOW_MS).toISOString();
database.prepare('DELETE FROM login_attempts WHERE attempted_at_utc < ?').run(since);
const emailCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE email_key = ? AND attempted_at_utc >= ?'
)
.get(rateKey(normalizeEmail(email)), since) as { count: number };
const addressCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE address_key = ? AND attempted_at_utc >= ?'
)
.get(rateKey(address), since) as { count: number };
return emailCount.count >= EMAIL_ATTEMPT_LIMIT || addressCount.count >= ADDRESS_ATTEMPT_LIMIT;
}
export function reserveLoginAttempt(email: string, address: string): boolean {
const database = getDatabase();
const reserve = database.transaction(() => {
if (loginIsRateLimited(email, address)) return false;
database
.prepare(
'INSERT INTO login_attempts (id, email_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)'
)
.run(randomUUID(), rateKey(normalizeEmail(email)), rateKey(address), nowUtc());
return true;
});
return reserve();
}
export async function authenticate(email: string, password: string): Promise<AuthUser | null> {
const database = getDatabase();
const normalized = normalizeEmail(email);
const row = database
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users WHERE email_normalized = ?`
)
.get(normalized) as UserRow | undefined;
const matches = row
? await verifyPassword(row.password_hash, password)
: await verifyPassword(await dummyPasswordHash, password);
if (!row || !matches || row.locked_at_utc) {
return null;
}
database.prepare('DELETE FROM login_attempts WHERE email_key = ?').run(rateKey(normalized));
return toUser(row);
}
export function requireUser(user: AuthUser | null): AuthUser {
if (!user) error(401, 'Anmeldung erforderlich');
return user;
}
export function requireBarkeeper(user: AuthUser | null): AuthUser {
const authenticated = requireUser(user);
if (authenticated.role !== 'barkeeper') error(403, 'Diese Aktion erfordert Barkeeper-Rechte.');
return authenticated;
}
function requireCurrentBarkeeper(actor: AuthUser): AuthUser {
const row = getDatabase()
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users WHERE id = ? AND role = 'barkeeper' AND locked_at_utc IS NULL`
)
.get(actor.id) as UserRow | undefined;
if (!row) error(403, 'Diese Aktion erfordert aktive Barkeeper-Rechte.');
return toUser(row);
}
export function listUsers(): AuthUser[] {
const rows = getDatabase()
.prepare(
`SELECT id, email, display_name, password_hash, role, locked_at_utc
FROM users ORDER BY display_name COLLATE NOCASE`
)
.all() as UserRow[];
return rows.map(toUser);
}
export function listOpenInvitations() {
return getDatabase()
.prepare(
`SELECT id, email, role, expires_at_utc
FROM invitations
WHERE accepted_at_utc IS NULL AND revoked_at_utc IS NULL AND expires_at_utc > ?
ORDER BY created_at_utc DESC`
)
.all(nowUtc()) as Array<{ id: string; email: string; role: Role; expires_at_utc: string }>;
}
export async function inviteUser(
actor: AuthUser,
emailValue: string,
role: Role
): Promise<'local' | 'smtp'> {
actor = requireCurrentBarkeeper(actor);
const email = emailValue.trim();
const normalized = normalizeEmail(email);
if (!validEmail(email) || !isRole(role)) throw new Error('VALIDATION');
const database = getDatabase();
if (database.prepare('SELECT 1 FROM users WHERE email_normalized = ?').get(normalized)) {
throw new Error('USER_EXISTS');
}
const token = createToken();
const id = randomUUID();
const timestamp = nowUtc();
const expiresAt = futureUtc(LINK_DURATION_MS);
const store = database.transaction(() => {
database
.prepare(
`UPDATE invitations SET revoked_at_utc = ?
WHERE email_normalized = ? AND accepted_at_utc IS NULL AND revoked_at_utc IS NULL`
)
.run(timestamp, normalized);
database
.prepare(
`INSERT INTO invitations (
id, email, email_normalized, role, token_hash, created_by_user_id,
created_at_utc, expires_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(id, email, normalized, role, hashToken(token), actor.id, timestamp, expiresAt);
});
store();
const link = new URL(
`/einladung?token=${encodeURIComponent(token)}`,
getAuthConfig().origin
).toString();
try {
return await sendMail({
to: email,
subject: 'Einladung zu Sendewerk',
text: `Du wurdest zu Sendewerk eingeladen. Der Link ist 24 Stunden gültig und einmalig nutzbar:\n\n${link}`
});
} catch (mailError) {
database.prepare('UPDATE invitations SET revoked_at_utc = ? WHERE id = ?').run(nowUtc(), id);
throw mailError;
}
}
export function getInvitation(token: string): InvitationRow | null {
if (!token || token.length > 100) return null;
return (
(getDatabase()
.prepare(
`SELECT id, email, role, expires_at_utc, accepted_at_utc, revoked_at_utc
FROM invitations WHERE token_hash = ?`
)
.get(hashToken(token)) as InvitationRow | undefined) ?? null
);
}
export async function acceptInvitation(input: {
token: string;
displayName: string;
password: string;
}): Promise<AuthUser> {
const invitation = getInvitation(input.token);
const displayName = input.displayName.trim();
if (
!invitation ||
invitation.accepted_at_utc ||
invitation.revoked_at_utc ||
invitation.expires_at_utc <= nowUtc()
) {
throw new Error('TOKEN_INVALID');
}
if (!validDisplayName(displayName) || !validPassword(input.password))
throw new Error('VALIDATION');
const passwordHash = await hashPassword(input.password);
const database = getDatabase();
const accept = database.transaction(() => {
const current = database
.prepare(
`SELECT id, email, role, expires_at_utc, accepted_at_utc, revoked_at_utc
FROM invitations WHERE id = ?`
)
.get(invitation.id) as InvitationRow;
if (current.accepted_at_utc || current.revoked_at_utc || current.expires_at_utc <= nowUtc()) {
throw new Error('TOKEN_INVALID');
}
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO users (
id, email, email_normalized, display_name, password_hash, role,
password_changed_at_utc, created_at_utc, updated_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
current.email,
normalizeEmail(current.email),
displayName,
passwordHash,
current.role,
timestamp,
timestamp,
timestamp
);
database
.prepare('UPDATE invitations SET accepted_at_utc = ? WHERE id = ?')
.run(timestamp, current.id);
return {
id,
email: current.email,
displayName,
role: current.role,
lockedAtUtc: null
} satisfies AuthUser;
});
return accept();
}
export function reservePasswordResetRequest(email: string, address: string): boolean {
const database = getDatabase();
const since = new Date(Date.now() - RESET_WINDOW_MS).toISOString();
const emailKey = rateKey(`reset-email:${normalizeEmail(email)}`);
const addressKey = rateKey(`reset-address:${address}`);
const reserve = database.transaction(() => {
database.prepare('DELETE FROM login_attempts WHERE attempted_at_utc < ?').run(since);
const emailCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE email_key = ? AND attempted_at_utc >= ?'
)
.get(emailKey, since) as { count: number };
const addressCount = database
.prepare(
'SELECT count(*) AS count FROM login_attempts WHERE address_key = ? AND attempted_at_utc >= ?'
)
.get(addressKey, since) as { count: number };
if (emailCount.count >= RESET_EMAIL_LIMIT || addressCount.count >= RESET_ADDRESS_LIMIT)
return false;
database
.prepare(
'INSERT INTO login_attempts (id, email_key, address_key, attempted_at_utc) VALUES (?, ?, ?, ?)'
)
.run(randomUUID(), emailKey, addressKey, nowUtc());
return true;
});
return reserve();
}
export async function requestPasswordReset(emailValue: string): Promise<void> {
const normalized = normalizeEmail(emailValue);
const database = getDatabase();
const row = database
.prepare('SELECT id, email FROM users WHERE email_normalized = ?')
.get(normalized) as { id: string; email: string } | undefined;
if (!row) return;
const token = createToken();
const id = randomUUID();
const timestamp = nowUtc();
database
.prepare(
`INSERT INTO password_reset_tokens
(id, user_id, token_hash, created_at_utc, expires_at_utc)
VALUES (?, ?, ?, ?, ?)`
)
.run(id, row.id, hashToken(token), timestamp, futureUtc(LINK_DURATION_MS));
const link = new URL(
`/passwort-zuruecksetzen?token=${encodeURIComponent(token)}`,
getAuthConfig().origin
).toString();
try {
await sendMail({
to: row.email,
subject: 'Sendewerk-Passwort zurücksetzen',
text: `Mit diesem 24 Stunden gültigen, einmaligen Link kannst du dein Passwort zurücksetzen:\n\n${link}`
});
} catch (mailError) {
database.prepare('DELETE FROM password_reset_tokens WHERE id = ?').run(id);
throw mailError;
}
}
export function passwordResetIsValid(token: string): boolean {
if (!token || token.length > 100) return false;
return Boolean(
getDatabase()
.prepare(
`SELECT 1 FROM password_reset_tokens
WHERE token_hash = ? AND consumed_at_utc IS NULL AND expires_at_utc > ?`
)
.get(hashToken(token), nowUtc())
);
}
export async function resetPassword(token: string, password: string): Promise<void> {
if (!passwordResetIsValid(token)) throw new Error('TOKEN_INVALID');
if (!validPassword(password)) throw new Error('VALIDATION');
const passwordHash = await hashPassword(password);
const database = getDatabase();
const reset = database.transaction(() => {
const row = database
.prepare(
`SELECT id, user_id FROM password_reset_tokens
WHERE token_hash = ? AND consumed_at_utc IS NULL AND expires_at_utc > ?`
)
.get(hashToken(token), nowUtc()) as { id: string; user_id: string } | undefined;
if (!row) throw new Error('TOKEN_INVALID');
const timestamp = nowUtc();
database
.prepare(
`UPDATE users SET password_hash = ?, password_changed_at_utc = ?, updated_at_utc = ?
WHERE id = ?`
)
.run(passwordHash, timestamp, timestamp, row.user_id);
database
.prepare(
'UPDATE password_reset_tokens SET consumed_at_utc = ? WHERE user_id = ? AND consumed_at_utc IS NULL'
)
.run(timestamp, row.user_id);
database.prepare('DELETE FROM sessions WHERE user_id = ?').run(row.user_id);
});
reset();
}
function activeBarkeeperCount(): number {
const row = getDatabase()
.prepare(
"SELECT count(*) AS count FROM users WHERE role = 'barkeeper' AND locked_at_utc IS NULL"
)
.get() as { count: number };
return row.count;
}
export function setUserLocked(actor: AuthUser, userId: string, locked: boolean): void {
actor = requireCurrentBarkeeper(actor);
const database = getDatabase();
const row = database.prepare('SELECT role, locked_at_utc FROM users WHERE id = ?').get(userId) as
{ role: Role; locked_at_utc: string | null } | undefined;
if (!row) throw new Error('NOT_FOUND');
if (locked && actor.id === userId) throw new Error('SELF_LOCK');
if (locked && row.role === 'barkeeper' && !row.locked_at_utc && activeBarkeeperCount() <= 1) {
throw new Error('LAST_BARKEEPER');
}
const update = database.transaction(() => {
database
.prepare('UPDATE users SET locked_at_utc = ?, updated_at_utc = ? WHERE id = ?')
.run(locked ? nowUtc() : null, nowUtc(), userId);
if (locked) database.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
});
update();
}
export function setUserRole(actor: AuthUser, userId: string, role: Role): void {
requireCurrentBarkeeper(actor);
if (!isRole(role)) throw new Error('VALIDATION');
const database = getDatabase();
const row = database.prepare('SELECT role, locked_at_utc FROM users WHERE id = ?').get(userId) as
{ role: Role; locked_at_utc: string | null } | undefined;
if (!row) throw new Error('NOT_FOUND');
if (
row.role === 'barkeeper' &&
role === 'thekencrew' &&
!row.locked_at_utc &&
activeBarkeeperCount() <= 1
) {
throw new Error('LAST_BARKEEPER');
}
database
.prepare('UPDATE users SET role = ?, updated_at_utc = ? WHERE id = ?')
.run(role, nowUtc(), userId);
}
export function revokeInvitation(actor: AuthUser, invitationId: string): void {
requireCurrentBarkeeper(actor);
getDatabase()
.prepare(
`UPDATE invitations SET revoked_at_utc = ?
WHERE id = ? AND accepted_at_utc IS NULL AND revoked_at_utc IS NULL`
)
.run(nowUtc(), invitationId);
}