Release 0.2.0
This commit is contained in:
1 parent
7a49e66c37
commit
08e8875cb5
38 files changed
+2645
-93
No files matched your search
@@ -0,0 +1,255 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, readdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
let directory: string;
|
||||
let databasePath: string;
|
||||
|
||||
function testCookies(): Cookies {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
get: (name: string) => values.get(name),
|
||||
getAll: () => Array.from(values, ([name, value]) => ({ name, value })),
|
||||
set: (name: string, value: string) => {
|
||||
values.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
values.delete(name);
|
||||
},
|
||||
serialize: () => ''
|
||||
} as unknown as Cookies;
|
||||
}
|
||||
|
||||
function tokenFromLatestMail(): string {
|
||||
const outbox = path.join(directory, 'outbox');
|
||||
const files = readdirSync(outbox).sort();
|
||||
const message = JSON.parse(readFileSync(path.join(outbox, files.at(-1)!), 'utf8')) as {
|
||||
text: string;
|
||||
};
|
||||
const match = message.text.match(/[?&]token=([^\s]+)/);
|
||||
if (!match) throw new Error('Testmail enthält keinen Token.');
|
||||
return decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-auth-test-'));
|
||||
temporaryDirectories.push(directory);
|
||||
databasePath = path.join(directory, 'test.sqlite');
|
||||
process.env.SENDEWERK_DB_PATH = databasePath;
|
||||
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
|
||||
process.env.SENDEWERK_MAIL_OUTBOX_DIR = path.join(directory, 'outbox');
|
||||
process.env.SENDEWERK_MAIL_TRANSPORT = 'local';
|
||||
process.env.SENDEWERK_APP_ORIGIN = 'http://localhost:5173';
|
||||
process.env.SENDEWERK_SETUP_TOKEN = 'a'.repeat(32);
|
||||
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { closeDatabase } = await import('./database.js');
|
||||
closeDatabase();
|
||||
for (const name of [
|
||||
'SENDEWERK_DB_PATH',
|
||||
'SENDEWERK_UPLOAD_DIR',
|
||||
'SENDEWERK_MAIL_OUTBOX_DIR',
|
||||
'SENDEWERK_MAIL_TRANSPORT',
|
||||
'SENDEWERK_MAIL_FROM',
|
||||
'SENDEWERK_SMTP_HOST',
|
||||
'SENDEWERK_APP_ORIGIN',
|
||||
'SENDEWERK_SETUP_TOKEN'
|
||||
]) {
|
||||
delete process.env[name];
|
||||
}
|
||||
for (const temporaryDirectory of temporaryDirectories.splice(0)) {
|
||||
rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('authentication', () => {
|
||||
it('creates the first Barkeeper once and stores no plaintext secret', async () => {
|
||||
const { createFirstAdmin } = await import('./auth.js');
|
||||
const password = 'Ein-sehr-langes-Testpasswort';
|
||||
const user = await createFirstAdmin({
|
||||
setupToken: 'a'.repeat(32),
|
||||
email: 'Bar@example.org',
|
||||
displayName: 'Erster Barkeeper',
|
||||
password
|
||||
});
|
||||
const database = new Database(databasePath, { readonly: true });
|
||||
const row = database.prepare('SELECT * FROM users').get() as {
|
||||
role: string;
|
||||
email_normalized: string;
|
||||
password_hash: string;
|
||||
};
|
||||
database.close();
|
||||
|
||||
expect(user.role).toBe('barkeeper');
|
||||
expect(row.email_normalized).toBe('bar@example.org');
|
||||
expect(row.password_hash).not.toContain(password);
|
||||
await expect(
|
||||
createFirstAdmin({
|
||||
setupToken: 'a'.repeat(32),
|
||||
email: 'second@example.org',
|
||||
displayName: 'Zweiter Admin',
|
||||
password
|
||||
})
|
||||
).rejects.toThrow('SETUP_CLOSED');
|
||||
});
|
||||
|
||||
it('accepts an invitation only once and rejects an expired invitation', async () => {
|
||||
const { acceptInvitation, createFirstAdmin, inviteUser } = await import('./auth.js');
|
||||
const admin = await createFirstAdmin({
|
||||
setupToken: 'a'.repeat(32),
|
||||
email: 'admin@example.org',
|
||||
displayName: 'Admin',
|
||||
password: 'Admin-Passwort-1234'
|
||||
});
|
||||
await inviteUser(admin, 'crew@example.org', 'thekencrew');
|
||||
const firstToken = tokenFromLatestMail();
|
||||
const crew = await acceptInvitation({
|
||||
token: firstToken,
|
||||
displayName: 'Crew',
|
||||
password: 'Crew-Passwort-1234'
|
||||
});
|
||||
expect(crew.role).toBe('thekencrew');
|
||||
await expect(
|
||||
acceptInvitation({
|
||||
token: firstToken,
|
||||
displayName: 'Crew',
|
||||
password: 'Crew-Passwort-1234'
|
||||
})
|
||||
).rejects.toThrow('TOKEN_INVALID');
|
||||
|
||||
await inviteUser(admin, 'late@example.org', 'thekencrew');
|
||||
const expiredToken = tokenFromLatestMail();
|
||||
const database = new Database(databasePath);
|
||||
database.prepare("UPDATE invitations SET expires_at_utc = '2000-01-01T00:00:00.000Z'").run();
|
||||
database.close();
|
||||
await expect(
|
||||
acceptInvitation({
|
||||
token: expiredToken,
|
||||
displayName: 'Zu spät',
|
||||
password: 'Crew-Passwort-1234'
|
||||
})
|
||||
).rejects.toThrow('TOKEN_INVALID');
|
||||
});
|
||||
|
||||
it('revokes active sessions immediately when a user is locked', async () => {
|
||||
const { createFirstAdmin, createSession, getSessionUser, setUserLocked, SESSION_COOKIE } =
|
||||
await import('./auth.js');
|
||||
const admin = await createFirstAdmin({
|
||||
setupToken: 'a'.repeat(32),
|
||||
email: 'admin@example.org',
|
||||
displayName: 'Admin',
|
||||
password: 'Admin-Passwort-1234'
|
||||
});
|
||||
const database = new Database(databasePath);
|
||||
const crewId = crypto.randomUUID();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, email_normalized, display_name, password_hash, role,
|
||||
password_changed_at_utc, created_at_utc, updated_at_utc)
|
||||
SELECT ?, 'crew@example.org', 'crew@example.org', 'Crew', password_hash, 'thekencrew',
|
||||
password_changed_at_utc, created_at_utc, updated_at_utc FROM users WHERE id = ?`
|
||||
)
|
||||
.run(crewId, admin.id);
|
||||
database.close();
|
||||
const cookies = testCookies();
|
||||
createSession(crewId, cookies);
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
expect(getSessionUser(token)?.id).toBe(crewId);
|
||||
|
||||
setUserLocked(admin, crewId, true);
|
||||
expect(getSessionUser(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses password reset links once and invalidates prior sessions', async () => {
|
||||
const {
|
||||
authenticate,
|
||||
createFirstAdmin,
|
||||
createSession,
|
||||
getSessionUser,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
SESSION_COOKIE
|
||||
} = await import('./auth.js');
|
||||
const admin = await createFirstAdmin({
|
||||
setupToken: 'a'.repeat(32),
|
||||
email: 'admin@example.org',
|
||||
displayName: 'Admin',
|
||||
password: 'Altes-Passwort-1234'
|
||||
});
|
||||
const cookies = testCookies();
|
||||
createSession(admin.id, cookies);
|
||||
const sessionToken = cookies.get(SESSION_COOKIE);
|
||||
await requestPasswordReset(admin.email);
|
||||
const expiredToken = tokenFromLatestMail();
|
||||
const database = new Database(databasePath);
|
||||
database
|
||||
.prepare("UPDATE password_reset_tokens SET expires_at_utc = '2000-01-01T00:00:00.000Z'")
|
||||
.run();
|
||||
database.close();
|
||||
await expect(resetPassword(expiredToken, 'Neues-Passwort-1234')).rejects.toThrow(
|
||||
'TOKEN_INVALID'
|
||||
);
|
||||
await requestPasswordReset(admin.email);
|
||||
const resetToken = tokenFromLatestMail();
|
||||
await resetPassword(resetToken, 'Neues-Passwort-1234');
|
||||
|
||||
expect(getSessionUser(sessionToken)).toBeNull();
|
||||
expect(await authenticate(admin.email, 'Altes-Passwort-1234')).toBeNull();
|
||||
expect(await authenticate(admin.email, 'Neues-Passwort-1234')).not.toBeNull();
|
||||
await expect(resetPassword(resetToken, 'Noch-ein-Passwort-1234')).rejects.toThrow(
|
||||
'TOKEN_INVALID'
|
||||
);
|
||||
});
|
||||
|
||||
it('rate limits repeated failed login attempts without locking the account', async () => {
|
||||
const { authenticate, createFirstAdmin, loginIsRateLimited, reserveLoginAttempt } =
|
||||
await import('./auth.js');
|
||||
await createFirstAdmin({
|
||||
setupToken: 'a'.repeat(32),
|
||||
email: 'admin@example.org',
|
||||
displayName: 'Admin',
|
||||
password: 'Admin-Passwort-1234'
|
||||
});
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
expect(reserveLoginAttempt('admin@example.org', '127.0.0.1')).toBe(true);
|
||||
expect(await authenticate('admin@example.org', 'falsch')).toBeNull();
|
||||
}
|
||||
expect(loginIsRateLimited('admin@example.org', '127.0.0.1')).toBe(true);
|
||||
const database = new Database(databasePath, { readonly: true });
|
||||
const user = database.prepare('SELECT locked_at_utc FROM users').get() as {
|
||||
locked_at_utc: string | null;
|
||||
};
|
||||
database.close();
|
||||
expect(user.locked_at_utc).toBeNull();
|
||||
});
|
||||
|
||||
it('denies administrative operations to Thekencrew', async () => {
|
||||
const { requireBarkeeper } = await import('./auth.js');
|
||||
expect(() =>
|
||||
requireBarkeeper({
|
||||
id: 'crew',
|
||||
email: 'crew@example.org',
|
||||
displayName: 'Crew',
|
||||
role: 'thekencrew',
|
||||
lockedAtUtc: null
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('never falls back to local delivery when SMTP was selected', async () => {
|
||||
process.env.SENDEWERK_MAIL_TRANSPORT = 'smtp';
|
||||
process.env.SENDEWERK_MAIL_FROM = '';
|
||||
process.env.SENDEWERK_SMTP_HOST = '';
|
||||
const { sendMail } = await import('./mail.js');
|
||||
await expect(
|
||||
sendMail({ to: 'test@example.org', subject: 'Test', text: 'Keine echte Nachricht' })
|
||||
).rejects.toThrow('SMTP-Konfiguration');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -7,6 +7,38 @@ function configuredPath(value: string | undefined, fallback: string): string {
|
||||
export function getServerPaths() {
|
||||
return {
|
||||
database: configuredPath(process.env.SENDEWERK_DB_PATH, '.data/sendewerk.sqlite'),
|
||||
uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads')
|
||||
uploads: configuredPath(process.env.SENDEWERK_UPLOAD_DIR, '.data/uploads'),
|
||||
mailOutbox: configuredPath(process.env.SENDEWERK_MAIL_OUTBOX_DIR, '.data/mail-outbox')
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthConfig() {
|
||||
const configuredOrigin = process.env.SENDEWERK_APP_ORIGIN || process.env.CLOUDRON_APP_ORIGIN;
|
||||
if (process.env.NODE_ENV === 'production' && !configuredOrigin) {
|
||||
throw new Error('SENDEWERK_APP_ORIGIN muss im Produktionsbetrieb gesetzt sein.');
|
||||
}
|
||||
const origin = new URL(configuredOrigin || 'http://localhost:5173');
|
||||
return {
|
||||
origin,
|
||||
setupToken: process.env.SENDEWERK_SETUP_TOKEN || '',
|
||||
secureCookies: origin.protocol === 'https:'
|
||||
};
|
||||
}
|
||||
|
||||
export function getMailConfig() {
|
||||
const transport = process.env.SENDEWERK_MAIL_TRANSPORT || 'local';
|
||||
if (transport !== 'local' && transport !== 'smtp') {
|
||||
throw new Error('SENDEWERK_MAIL_TRANSPORT muss local oder smtp sein.');
|
||||
}
|
||||
return {
|
||||
transport,
|
||||
from:
|
||||
process.env.SENDEWERK_MAIL_FROM ||
|
||||
(transport === 'local' ? 'Sendewerk <sendewerk@localhost>' : ''),
|
||||
host: process.env.SENDEWERK_SMTP_HOST || '',
|
||||
port: Number(process.env.SENDEWERK_SMTP_PORT || '587'),
|
||||
secure: process.env.SENDEWERK_SMTP_SECURE === 'true',
|
||||
user: process.env.SENDEWERK_SMTP_USER || '',
|
||||
password: process.env.SENDEWERK_SMTP_PASSWORD || ''
|
||||
};
|
||||
}
|
||||
@@ -52,7 +52,7 @@ describe('database setup', () => {
|
||||
.get() as { migrations: number; pieces: number; shows: number; usages: number };
|
||||
database.close();
|
||||
|
||||
expect(counts).toEqual({ migrations: 1, pieces: 4, shows: 1, usages: 3 });
|
||||
expect(counts).toEqual({ migrations: 2, pieces: 4, shows: 1, usages: 3 });
|
||||
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getMailConfig, getServerPaths } from './config.js';
|
||||
|
||||
export type MailMessage = {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export async function sendMail(message: MailMessage): Promise<'local' | 'smtp'> {
|
||||
const config = getMailConfig();
|
||||
if (config.transport === 'local') {
|
||||
const outbox = getServerPaths().mailOutbox;
|
||||
await mkdir(outbox, { recursive: true, mode: 0o700 });
|
||||
const filename = `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}.json`;
|
||||
await writeFile(
|
||||
path.join(outbox, filename),
|
||||
JSON.stringify({ from: config.from, ...message }, null, 2),
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
);
|
||||
return 'local';
|
||||
}
|
||||
|
||||
if (
|
||||
!config.host ||
|
||||
!config.from ||
|
||||
!Number.isInteger(config.port) ||
|
||||
config.port < 1 ||
|
||||
config.port > 65_535 ||
|
||||
Boolean(config.user) !== Boolean(config.password)
|
||||
) {
|
||||
throw new Error('Die SMTP-Konfiguration ist unvollständig.');
|
||||
}
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
requireTLS: !config.secure,
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
socketTimeout: 20_000,
|
||||
auth: config.user ? { user: config.user, pass: config.password } : undefined
|
||||
});
|
||||
await transporter.sendMail({ from: config.from, ...message });
|
||||
return 'smtp';
|
||||
}
|
||||
Reference in new issue
Block a user