From dea33e3f005bfd5f0d58ba000d0c5b599cf774fc Mon Sep 17 00:00:00 2001 From: elpatron Date: Sat, 30 May 2026 13:47:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(security):=20Session-Cookies=20statt=20X-U?= =?UTF-8?q?ser-Id=20und=20API-H=C3=A4rtung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt die spoofbare X-User-Id-Auth durch signierte HttpOnly-Sessions nach WebAuthn, erzwingt WRITE-only Sync, speichert den Master-Key nur im RAM und ergänzt CORS, Rate-Limits, Helmet sowie Passkey-Reauth für sensible Aktionen. Co-authored-by: Cursor --- .env.example | 7 +- .../designs/HYBRID-ELECTRONIC-SIGNATURES.md | 4 +- README.md | 31 +++- client/src/App.tsx | 37 +++-- .../src/components/InvitationAcceptance.tsx | 14 +- client/src/components/LogbookDashboard.tsx | 2 +- client/src/components/SettingsForm.tsx | 47 ++---- client/src/services/api.ts | 38 +++++ client/src/services/auth.ts | 138 +++++++--------- client/src/services/entrySigning.ts | 34 +--- client/src/services/feedback.ts | 14 +- client/src/services/logbook.ts | 22 +-- client/src/services/logbookAccess.ts | 11 +- client/src/services/pushNotifications.ts | 52 ++---- client/src/services/sync.ts | 24 ++- client/src/services/weather.ts | 21 +-- docker-compose.yml | 1 + docs/push-notifications-plan.md | 27 +-- scripts/start-dev-docker.sh | 1 + scripts/start-dev.sh | 32 ++++ server/package-lock.json | 72 ++++++++ server/package.json | 4 + server/src/index.ts | 41 ++++- server/src/middleware/auth.ts | 33 ++++ server/src/routes/auth.ts | 156 +++++++++++++----- server/src/routes/collaboration.ts | 11 +- server/src/routes/feedback.ts | 10 +- server/src/routes/logbooks.ts | 11 +- server/src/routes/push.ts | 10 +- server/src/routes/sign.ts | 10 +- server/src/routes/sync.ts | 26 ++- server/src/routes/weather.ts | 12 +- server/src/session.ts | 101 ++++++++++++ 33 files changed, 657 insertions(+), 397 deletions(-) create mode 100644 client/src/services/api.ts create mode 100644 server/src/middleware/auth.ts create mode 100644 server/src/session.ts diff --git a/.env.example b/.env.example index d81209c..2d3f8fd 100755 --- a/.env.example +++ b/.env.example @@ -4,7 +4,12 @@ OpenWeatherMapAPIKey= # For local dev: localhost and http://localhost # For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu RP_ID=localhost -ORIGIN=http://localhost +# Must match the frontend URL (Vite dev: http://localhost:5173) +ORIGIN=http://localhost:5173 + +# API session signing (min. 32 chars; required in production) +# Generate: openssl rand -base64 48 +SESSION_SECRET= # Web Push (VAPID) — generate with: npx web-push generate-vapid-keys # Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY diff --git a/.planning/designs/HYBRID-ELECTRONIC-SIGNATURES.md b/.planning/designs/HYBRID-ELECTRONIC-SIGNATURES.md index 4f26fc9..5d058f3 100644 --- a/.planning/designs/HYBRID-ELECTRONIC-SIGNATURES.md +++ b/.planning/designs/HYBRID-ELECTRONIC-SIGNATURES.md @@ -168,7 +168,7 @@ Beim Laden eines Eintrags: `computedHash !== sig.entryHash` → UI-Warnung. Neuer Router: `server/src/routes/sign.ts` → Mount unter `/api/sign` -Auth wie bestehend: Header `X-User-Id` (siehe `sync.ts`). +Auth wie bestehend: HttpOnly-Session-Cookie `daagbok_session` nach WebAuthn (`server/src/middleware/auth.ts`, Client `apiFetch` mit `credentials: 'include'`). ### 4.1 `POST /api/sign/options` @@ -472,7 +472,7 @@ test('isSignatureValidForEntry') | WebAuthn Login | `client/src/services/auth.ts`, `server/src/routes/auth.ts` | | Collaborators | `server/src/routes/collaboration.ts`, `SettingsForm.tsx` | | E2E-Einträge | `EntryPayload` in `server/prisma/schema.prisma` | -| Auth-Header | `X-User-Id` in `server/src/routes/sync.ts` | +| API-Auth | Session-Cookie via `requireUser` in `server/src/middleware/auth.ts` | --- diff --git a/README.md b/README.md index cd820d3..b53d357 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API). | Lokaler Speicher | Dexie.js (IndexedDB), Hintergrund-Sync | | Backend | Node.js, Express, Prisma | | Datenbank | PostgreSQL 16 | -| Auth | WebAuthn (Passkeys) via `@simplewebauthn` | +| Auth | WebAuthn (Passkeys) + signiertes HttpOnly-Session-Cookie (`daagbok_session`) | | Krypto | Web Crypto API (AES-GCM), BIP39 Recovery | | Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) | @@ -59,6 +59,18 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API). Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nicht an den Account gebunden. Ein Account kann gleichzeitig Owner eines eigenen und Collaborator in fremden Logbüchern sein. +### Authentifizierung & Session + +| Schicht | Verhalten | +|---------|-----------| +| **Login** | WebAuthn (`/api/auth/login-verify`) — danach HttpOnly-Cookie, 7 Tage gültig | +| **API-Aufrufe** | Cookie `credentials: 'include'` (Client: `apiFetch`) — kein `X-User-Id` | +| **Master-Key** | Nur im RAM; nach Reload Entsperren per Passkey oder lokalem PIN | +| **Step-up** | Konto löschen, PRF-Enrollment: frische Passkey-Bestätigung (`/api/auth/reauth-*`) | +| **Sync WRITE** | Server lehnt Schreib-Sync für Collaborator mit `READ` ab | + +Öffentliche Routen (ohne Session): Registrierung/Login-Optionen, Einladungsdetails, Read-only-Share (`share-pull`), Health-Check, VAPID-Public-Key. + ## Backup & Wiederherstellung Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen: @@ -134,21 +146,30 @@ cd client && npm ci && cd .. cp .env.example .env ``` -Für lokale Passkeys: `RP_ID=localhost`, `ORIGIN=http://localhost:5173` (bzw. die tatsächliche Frontend-URL). +Kopiere `.env.example` nach `.env` und passe mindestens an: -Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen — oder den Key in der **Projekt-`.env`** (`OpenWeatherMapAPIKey=...`); das Backend lädt beide Dateien. +| Variable | Dev (Vite) | Produktion | +|----------|------------|------------| +| `RP_ID` | `localhost` | `kapteins-daagbok.eu` | +| `ORIGIN` | `http://localhost:5173` | `https://kapteins-daagbok.eu` | +| `SESSION_SECRET` | empfohlen (≥ 32 Zeichen) | **Pflicht** | + +`ORIGIN` muss **exakt** der Frontend-URL entsprechen (CORS + Session-Cookie). Das Backend lädt `.env` aus dem Projektroot und optional `server/.env`. ``` DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public" OpenWeatherMapAPIKey= # Fallback für Wetter-Abruf, wenn Nutzer keinen eigenen Key hat RP_ID=localhost ORIGIN=http://localhost:5173 +SESSION_SECRET= # openssl rand -base64 48 (in Prod Pflicht) # Optional — Web Push (npx web-push generate-vapid-keys) VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu ``` +`./scripts/start-dev.sh` prüft `ORIGIN` und `SESSION_SECRET` beim Start und gibt Hinweise aus. + ### 3. Datenbank & Schema Das Dev-Skript startet PostgreSQL in Docker (`postgres-daagbox`). Schema anwenden: @@ -179,7 +200,7 @@ Gesamten Stack lokal bauen und starten: Frontend: http://localhost · API: http://localhost/api/health -Umgebungsvariablen in `.env` setzen — mindestens `RP_ID` und `ORIGIN` für Passkeys. Für Push die VAPID-Variablen an den **Backend**-Container durchreichen (z. B. in `docker-compose.yml` unter `backend.environment` ergänzen). +Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`). ## Deployment @@ -191,7 +212,7 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen): Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar. -Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN` und bei Push `VAPID_*` enthalten. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container. +Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container. ## Dokumentation diff --git a/client/src/App.tsx b/client/src/App.tsx index ca98663..18b444c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,7 +13,7 @@ import SettingsForm from './components/SettingsForm.tsx' import InvitationAcceptance from './components/InvitationAcceptance.tsx' import AppTourOverlay from './components/AppTourOverlay.tsx' import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx' -import { getActiveMasterKey, logoutUser } from './services/auth.js' +import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js' import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js' import { applyAppearanceToDocument, @@ -186,17 +186,23 @@ function App() { ) } - const savedUser = localStorage.getItem('active_username') - const key = getActiveMasterKey() - if (savedUser && key) { - setIsAuthenticated(true) - const savedLogbookId = localStorage.getItem('active_logbook_id') - const savedLogbookTitle = localStorage.getItem('active_logbook_title') - if (savedLogbookId && savedLogbookTitle) { - setActiveLogbookId(savedLogbookId) - setActiveLogbookTitle(savedLogbookTitle) + void (async () => { + const session = await checkServerSession() + if (session.authenticated && session.userId) { + localStorage.setItem('active_userid', session.userId) } - } + const savedUser = localStorage.getItem('active_username') + const key = getActiveMasterKey() + if (session.authenticated && savedUser && key) { + setIsAuthenticated(true) + const savedLogbookId = localStorage.getItem('active_logbook_id') + const savedLogbookTitle = localStorage.getItem('active_logbook_title') + if (savedLogbookId && savedLogbookTitle) { + setActiveLogbookId(savedLogbookId) + setActiveLogbookTitle(savedLogbookTitle) + } + } + })() }, []) useEffect(() => { @@ -307,7 +313,7 @@ function App() { } const handleLogout = () => { - logoutUser() + void logoutUser() setIsAuthenticated(false) setActiveLogbookId(null) setActiveLogbookTitle(null) @@ -382,6 +388,8 @@ function App() { const pwaInstallBanner = + const logbookReadOnly = activeAccessRole === 'READ' + if (!activeLogbookId) { return (
@@ -514,6 +522,7 @@ function App() { {activeTab === 'logs' && ( + )} {activeTab === 'crew' && ( - + )} {activeTab === 'stats' && activeLogbookId && activeLogbookTitle && ( diff --git a/client/src/components/InvitationAcceptance.tsx b/client/src/components/InvitationAcceptance.tsx index 399059a..39f130c 100644 --- a/client/src/components/InvitationAcceptance.tsx +++ b/client/src/components/InvitationAcceptance.tsx @@ -14,6 +14,7 @@ import { parseCollaborationRole } from '../services/logbook.js' import { syncLogbook } from '../services/sync.js' import { db } from '../services/db.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' +import { apiJson } from '../services/api.js' interface InvitationAcceptanceProps { onAccepted: (logbookId: string, title: string) => void @@ -164,12 +165,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio ) const encrypted = await encryptBuffer(logbookKey, aesMasterKey) - const res = await fetch('/api/collaboration/accept', { + const acceptResult = await apiJson<{ role: string; logbookId: string }>('/api/collaboration/accept', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': activeUserId - }, body: JSON.stringify({ token, encryptedLogbookKey: encrypted.ciphertext, @@ -177,13 +174,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio tag: encrypted.tag }) }) - - if (!res.ok) { - const serverError = await res.json().catch(() => ({})) - throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.')) - } - - const acceptResult = await res.json() const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept') await saveLogbookKey(logbookId, logbookKey) diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index 85690b9..1cf44cb 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -99,7 +99,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD } const handleLogout = () => { - logoutUser() + void logoutUser() onLogout() } diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx index 2b56fed..5717d68 100644 --- a/client/src/components/SettingsForm.tsx +++ b/client/src/components/SettingsForm.tsx @@ -11,6 +11,7 @@ import { notifyAppearanceChanged } from '../services/appearance.js' import ThemedSelect from './ThemedSelect.tsx' import { useAppTour } from '../context/AppTourContext.tsx' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' +import { apiFetch } from '../services/api.js' interface SettingsFormProps { logbookId?: string | null @@ -67,15 +68,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF const loadShareLink = async () => { if (!logbookId) return setLoadingShareLink(true) - const userId = localStorage.getItem('active_userid') - if (!userId) return + if (!localStorage.getItem('active_userid')) return try { - const res = await fetch(`/api/collaboration/share-link?logbookId=${logbookId}`, { - headers: { - 'X-User-Id': userId - } - }) + const res = await apiFetch(`/api/collaboration/share-link?logbookId=${logbookId}`) if (res.ok) { const data = await res.json() @@ -99,17 +95,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF const handleToggleShare = async (e: React.ChangeEvent) => { if (!logbookId) return const checked = e.target.checked - const userId = localStorage.getItem('active_userid') - if (!userId) return + if (!localStorage.getItem('active_userid')) return setLoadingShareLink(true) try { - const res = await fetch('/api/collaboration/share-link', { + const res = await apiFetch('/api/collaboration/share-link', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': userId - }, body: JSON.stringify({ logbookId, enabled: checked }) }) @@ -149,15 +140,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF const loadCollaborators = async () => { setLoadingCollabs(true) setCollabError(null) - const userId = localStorage.getItem('active_userid') - if (!userId) return + if (!localStorage.getItem('active_userid')) return try { - const res = await fetch(`/api/collaboration/collaborators?logbookId=${logbookId}`, { - headers: { - 'X-User-Id': userId - } - }) + const res = await apiFetch(`/api/collaboration/collaborators?logbookId=${logbookId}`) if (res.status === 403) { setIsOwner(false) @@ -184,20 +170,15 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF if (!logbookId) return setGeneratingInvite(true) setInviteLink('') - const userId = localStorage.getItem('active_userid') - if (!userId) return + if (!localStorage.getItem('active_userid')) return try { // 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed) const logbookKey = await ensureLogbookKey(logbookId) // 2. Create invite token on server - const res = await fetch('/api/collaboration/invite', { + const res = await apiFetch('/api/collaboration/invite', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': userId - }, body: JSON.stringify({ logbookId, role: 'WRITE' }) }) @@ -230,16 +211,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF } const handleRevoke = async (collabId: string, collName: string) => { - const userId = localStorage.getItem('active_userid') - if (!userId) return + if (!localStorage.getItem('active_userid')) return if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) { try { - const res = await fetch(`/api/collaboration/collaborators/${collabId}`, { - method: 'DELETE', - headers: { - 'X-User-Id': userId - } + const res = await apiFetch(`/api/collaboration/collaborators/${collabId}`, { + method: 'DELETE' }) if (res.ok) { diff --git a/client/src/services/api.ts b/client/src/services/api.ts new file mode 100644 index 0000000..f3532a3 --- /dev/null +++ b/client/src/services/api.ts @@ -0,0 +1,38 @@ +export class ApiError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +export async function apiFetch( + input: string, + init: RequestInit = {} +): Promise { + const headers = new Headers(init.headers) + if (init.body !== undefined && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + + return fetch(input, { + ...init, + headers, + credentials: 'include' + }) +} + +export async function apiJson(input: string, init: RequestInit = {}): Promise { + const res = await apiFetch(input, init) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + const message = + typeof data === 'object' && data && 'error' in data && typeof data.error === 'string' + ? data.error + : `Request failed (${res.status})` + throw new ApiError(message, res.status) + } + return data as T +} diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index 25f4a71..78bcb61 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -6,27 +6,22 @@ import { deriveKeyFromPin, encryptBuffer, decryptBuffer, - generateRecoveryPhrase, - base64ToBuffer, - bufferToBase64 + generateRecoveryPhrase } from './crypto.js' import { clearLogbookKeysCache } from './logbookKeys.js' import { PlausibleEvents, trackPlausibleEvent } from './analytics.js' import { db } from './db.js' +import { apiFetch, apiJson } from './api.js' const API_BASE = '/api/auth' -// Shared in-memory container for the active user's session master key +// Master key lives in memory only (never localStorage — XSS-resistant). let activeMasterKey: ArrayBuffer | null = null -// Restore key from localStorage on load if present (survives reload/restart) try { - const savedKey = localStorage.getItem('active_master_key') - if (savedKey) { - activeMasterKey = base64ToBuffer(savedKey) - } -} catch (e) { - console.error('Failed to restore active master key:', e) + localStorage.removeItem('active_master_key') +} catch { + /* ignore */ } export function getActiveMasterKey(): ArrayBuffer | null { @@ -35,17 +30,34 @@ export function getActiveMasterKey(): ArrayBuffer | null { export function setActiveMasterKey(key: ArrayBuffer | null) { activeMasterKey = key - if (key) { - try { - localStorage.setItem('active_master_key', bufferToBase64(key)) - } catch (e) { - console.error('Failed to save master key to localStorage:', e) - } - } else { - localStorage.removeItem('active_master_key') +} + +export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> { + try { + return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`) + } catch { + return { authenticated: false } } } +export async function reauthWithPasskey(): Promise { + const options = await apiJson(`${API_BASE}/reauth-options`, { + method: 'POST' + }) + + const credentialResponse = await startAuthentication({ optionsJSON: options }) + + await apiJson(`${API_BASE}/reauth-verify`, { + method: 'POST', + body: JSON.stringify({ + credentialResponse, + challenge: options.challenge + }) + }) + + return true +} + // PIN fallback mechanism functions export async function setLocalPin(pin: string, username: string, masterKey: ArrayBuffer): Promise { const pinKey = await deriveKeyFromPin(pin, username) @@ -152,19 +164,11 @@ export interface RegistrationResult { export async function registerUser(username: string): Promise { // 1. Get registration options - const optionsRes = await fetch(`${API_BASE}/register-options`, { + const options = await apiJson(`${API_BASE}/register-options`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }) - if (!optionsRes.ok) { - const err = await optionsRes.json() - throw new Error(err.error || 'Failed to fetch registration options') - } - - const options = await optionsRes.json() - // Request the PRF extension WITH an evaluation salt. This must match the // salt used during login (PRF_SALT), otherwise the PRF-derived key produced // at login would never match what was stored here and every login would fall @@ -229,9 +233,8 @@ export async function registerUser(username: string): Promise(`${API_BASE}/register-verify`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, credentialResponse, @@ -243,13 +246,6 @@ export async function registerUser(username: string): Promise { } // 1. Get authentication options - const optionsRes = await fetch(`${API_BASE}/login-options`, { + const options = await apiJson(`${API_BASE}/login-options`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }) - if (!optionsRes.ok) { - const err = await optionsRes.json() - throw new Error(err.error || 'Failed to fetch login options') - } - - const options = await optionsRes.json() - // Add PRF extension evaluation input. // When the server returned a concrete allowCredentials list we use // `evalByCredential` (keyed by the base64url credential id), which is the @@ -366,21 +354,23 @@ export async function loginUser(username?: string): Promise { } // 3. Verify assertion on the server - const verifyRes = await fetch(`${API_BASE}/login-verify`, { + const result = await apiJson<{ + verified: boolean + userId: string + username: string + encryptedMasterKeyPrf: string | null + encryptedMasterKeyPrfIv: string | null + encryptedMasterKeyPrfTag: string | null + encryptedMasterKeyRec: string + encryptedMasterKeyRecIv: string + encryptedMasterKeyRecTag: string + }>(`${API_BASE}/login-verify`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ credentialResponse, challenge: options.challenge }) }) - - if (!verifyRes.ok) { - const err = await verifyRes.json() - throw new Error(err.error || 'Failed to verify login response') - } - - const result = await verifyRes.json() if (!result.verified) { return { verified: false, prfSuccess: false } } @@ -407,7 +397,12 @@ export async function loginUser(username?: string): Promise { console.log('PRF extension results first present:', !!prfResults.results?.first) } - if (prfResults?.results?.first && result.encryptedMasterKeyPrf) { + if ( + prfResults?.results?.first && + result.encryptedMasterKeyPrf && + result.encryptedMasterKeyPrfIv && + result.encryptedMasterKeyPrfTag + ) { try { const firstBuffer = typeof prfResults.results.first === 'string' ? base64urlToBuffer(prfResults.results.first) @@ -475,22 +470,14 @@ export async function completeLoginWithRecovery( const prfKey = await deriveKeyFromPrf(firstBuffer) const encryptedPrf = await encryptBuffer(decryptedMaster, prfKey) console.log('Sending PRF credentials to server...') - const enrollRes = await fetch(`${API_BASE}/enroll-prf`, { + await apiJson(`${API_BASE}/enroll-prf`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': encryptedPayloads.userId - }, body: JSON.stringify({ encryptedMasterKeyPrf: encryptedPrf.ciphertext, encryptedMasterKeyPrfIv: encryptedPrf.iv, encryptedMasterKeyPrfTag: encryptedPrf.tag }) }) - console.log('Enrollment response status:', enrollRes.status) - if (!enrollRes.ok) { - console.warn('Server rejected PRF enrollment') - } } catch (err) { console.error('Failed to encrypt/enroll master key with PRF key:', err) } @@ -508,25 +495,26 @@ export async function completeLoginWithRecovery( } } -export function logoutUser() { +export async function logoutUser() { setActiveMasterKey(null) clearLogbookKeysCache() localStorage.removeItem('active_username') localStorage.removeItem('active_userid') + try { + await apiFetch(`${API_BASE}/logout`, { method: 'POST' }) + } catch { + /* ignore network errors on logout */ + } } export async function deleteAccount(): Promise { - const userId = localStorage.getItem('active_userid') const username = localStorage.getItem('active_username') - if (!userId) return false + if (!localStorage.getItem('active_userid')) return false try { - const res = await fetch(`${API_BASE}/delete-account`, { - method: 'DELETE', - headers: { - 'X-User-Id': userId - } - }) + await reauthWithPasskey() + + const res = await apiFetch(`${API_BASE}/delete-account`, { method: 'DELETE' }) if (res.ok) { if (username) { @@ -546,7 +534,7 @@ export async function deleteAccount(): Promise { ]) // Wipe localStorage and session variables - logoutUser() + await logoutUser() trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED) return true } diff --git a/client/src/services/entrySigning.ts b/client/src/services/entrySigning.ts index 915da34..1e3d157 100644 --- a/client/src/services/entrySigning.ts +++ b/client/src/services/entrySigning.ts @@ -1,5 +1,6 @@ import { startAuthentication } from '@simplewebauthn/browser' import type { PasskeySignature } from '../types/signatures.js' +import { apiJson } from './api.js' export async function signLogEntry(params: { logbookId: string @@ -7,32 +8,22 @@ export async function signLogEntry(params: { entryHash: string role: 'skipper' | 'crew' }): Promise { - const userId = localStorage.getItem('active_userid') - if (!userId) throw new Error('User not authenticated') + if (!localStorage.getItem('active_userid')) throw new Error('User not authenticated') - const optionsRes = await fetch('/api/sign/options', { + const options = await apiJson('/api/sign/options', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': userId - }, body: JSON.stringify(params) }) - if (!optionsRes.ok) { - const err = await optionsRes.json().catch(() => ({})) - throw new Error(err.error || 'Failed to start passkey signing') - } - - const options = await optionsRes.json() const credentialResponse = await startAuthentication({ optionsJSON: options }) - const verifyRes = await fetch('/api/sign/verify', { + const result = await apiJson<{ + userId: string + username: string + credentialId: string + signedAt: string + }>('/api/sign/verify', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': userId - }, body: JSON.stringify({ credentialResponse, challenge: options.challenge, @@ -43,13 +34,6 @@ export async function signLogEntry(params: { }) }) - if (!verifyRes.ok) { - const err = await verifyRes.json().catch(() => ({})) - throw new Error(err.error || 'Passkey signature verification failed') - } - - const result = await verifyRes.json() - return { kind: 'passkey', version: 1, diff --git a/client/src/services/feedback.ts b/client/src/services/feedback.ts index 71ef1ef..e833ee6 100644 --- a/client/src/services/feedback.ts +++ b/client/src/services/feedback.ts @@ -1,3 +1,5 @@ +import { apiFetch } from './api.js' + export type FeedbackCategory = 'bug' | 'feature' | 'general' export class FeedbackApiError extends Error { @@ -19,15 +21,6 @@ export function isValidFeedbackEmail(email: string): boolean { return EMAIL_PATTERN.test(email.trim()) } -function buildFeedbackHeaders(): Record { - const headers: Record = { - 'Content-Type': 'application/json' - } - const userId = localStorage.getItem('active_userid') - if (userId) headers['X-User-Id'] = userId - return headers -} - export async function sendFeedback(payload: { category: FeedbackCategory message: string @@ -40,9 +33,8 @@ export async function sendFeedback(payload: { throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL') } - const res = await fetch('/api/feedback', { + const res = await apiFetch('/api/feedback', { method: 'POST', - headers: buildFeedbackHeaders(), body: JSON.stringify({ category: payload.category, message: payload.message, diff --git a/client/src/services/logbook.ts b/client/src/services/logbook.ts index 3db0607..d08025c 100644 --- a/client/src/services/logbook.ts +++ b/client/src/services/logbook.ts @@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js' import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js' import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js' import { PlausibleEvents, trackPlausibleEvent } from './analytics.js' +import { apiFetch } from './api.js' const API_BASE = '/api/logbooks' @@ -66,13 +67,7 @@ export async function fetchLogbooks(): Promise { if (navigator.onLine) { try { - const response = await fetch(API_BASE, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': userId - } - }) + const response = await apiFetch(API_BASE, { method: 'GET' }) if (response.ok) { const serverLogbooks = await response.json() @@ -208,12 +203,8 @@ export async function createLogbook(title: string): Promise { if (navigator.onLine) { try { - const response = await fetch(API_BASE, { + const response = await apiFetch(API_BASE, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': userId - }, body: JSON.stringify({ id: localId, ...payloadData @@ -301,12 +292,7 @@ export async function deleteLogbook(id: string): Promise { if (navigator.onLine) { try { - const response = await fetch(`${API_BASE}/${id}`, { - method: 'DELETE', - headers: { - 'X-User-Id': userId - } - }) + const response = await apiFetch(`${API_BASE}/${id}`, { method: 'DELETE' }) if (!response.ok) { console.warn('Server deletion failed or was rejected') } diff --git a/client/src/services/logbookAccess.ts b/client/src/services/logbookAccess.ts index 1ca8790..f311880 100644 --- a/client/src/services/logbookAccess.ts +++ b/client/src/services/logbookAccess.ts @@ -1,3 +1,5 @@ +import { apiJson } from './api.js' + export interface LogbookAccess { isOwner: boolean role: 'OWNER' | 'READ' | 'WRITE' @@ -5,15 +7,10 @@ export interface LogbookAccess { } export async function getLogbookAccess(logbookId: string): Promise { - const userId = localStorage.getItem('active_userid') - if (!userId || !navigator.onLine) return null + if (!localStorage.getItem('active_userid') || !navigator.onLine) return null try { - const res = await fetch(`/api/logbooks/${logbookId}/access`, { - headers: { 'X-User-Id': userId } - }) - if (!res.ok) return null - return res.json() + return await apiJson(`/api/logbooks/${logbookId}/access`) } catch { return null } diff --git a/client/src/services/pushNotifications.ts b/client/src/services/pushNotifications.ts index 1eca890..12bed45 100644 --- a/client/src/services/pushNotifications.ts +++ b/client/src/services/pushNotifications.ts @@ -1,8 +1,6 @@ -const API_BASE = '/api/push' +import { apiFetch, apiJson } from './api.js' -function getUserId(): string | null { - return localStorage.getItem('active_userid') -} +const API_BASE = '/api/push' function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) @@ -46,38 +44,24 @@ async function fetchVapidPublicKey(): Promise { } export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> { - const userId = getUserId() - if (!userId) return { collaboratorChangesEnabled: false } - - const res = await fetch(`${API_BASE}/prefs`, { - headers: { 'X-User-Id': userId } - }) - if (!res.ok) { - throw new Error('Failed to load push notification preferences') + if (!localStorage.getItem('active_userid')) { + return { collaboratorChangesEnabled: false } } - return res.json() + + return apiJson<{ collaboratorChangesEnabled: boolean }>(`${API_BASE}/prefs`) } export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promise { - const userId = getUserId() - if (!userId) throw new Error('Not authenticated') + if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated') - const res = await fetch(`${API_BASE}/prefs`, { + await apiJson(`${API_BASE}/prefs`, { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': userId - }, body: JSON.stringify({ collaboratorChangesEnabled }) }) - if (!res.ok) { - throw new Error('Failed to save push notification preferences') - } } async function saveSubscriptionToServer(subscription: PushSubscription): Promise { - const userId = getUserId() - if (!userId) throw new Error('Not authenticated') + if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated') const json = subscription.toJSON() if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { @@ -86,12 +70,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise const locale = document.documentElement.lang?.startsWith('en') ? 'en' : 'de' - const res = await fetch(`${API_BASE}/subscription`, { + await apiJson(`${API_BASE}/subscription`, { method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': userId - }, body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys, @@ -99,9 +79,6 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise userAgent: navigator.userAgent }) }) - if (!res.ok) { - throw new Error('Failed to register push subscription on server') - } } export async function subscribeToPush(): Promise { @@ -137,7 +114,6 @@ export async function subscribeToPush(): Promise { export async function unsubscribeFromPush(): Promise { if (!isPushSupported()) return - const userId = getUserId() const registration = await navigator.serviceWorker.ready const subscription = await registration.pushManager.getSubscription() if (!subscription) return @@ -145,13 +121,9 @@ export async function unsubscribeFromPush(): Promise { const endpoint = subscription.endpoint await subscription.unsubscribe() - if (userId && endpoint) { - await fetch(`${API_BASE}/subscription`, { + if (localStorage.getItem('active_userid') && endpoint) { + await apiFetch(`${API_BASE}/subscription`, { method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': userId - }, body: JSON.stringify({ endpoint }) }).catch(() => {}) } diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts index a3c8493..9690dd1 100644 --- a/client/src/services/sync.ts +++ b/client/src/services/sync.ts @@ -1,5 +1,7 @@ import { db, type SyncQueueItem } from './db.js' import { getActiveMasterKey } from './auth.js' +import { apiFetch } from './api.js' +import { getLogbookAccess } from './logbookAccess.js' const API_BASE = '/api/sync' const syncingLogbooks = new Set() @@ -126,19 +128,17 @@ function scheduleResync(logbookId: string) { // Push local sync queue items to the server async function pushChanges(logbookId: string): Promise { - const userId = localStorage.getItem('active_userid') - if (!userId) return false + if (!getActiveMasterKey() || !localStorage.getItem('active_userid')) return false + + const access = await getLogbookAccess(logbookId) + if (access && access.role === 'READ') return true const pending = await coalesceSyncQueue(logbookId) if (pending.length === 0) return true try { - const response = await fetch(`${API_BASE}/push`, { + const response = await apiFetch(`${API_BASE}/push`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-User-Id': userId - }, body: JSON.stringify({ items: pending }) }) @@ -187,15 +187,11 @@ async function flushPushQueue(logbookId: string): Promise { // Pull updates from the server and apply last-write-wins async function pullChanges(logbookId: string): Promise { - const userId = localStorage.getItem('active_userid') - if (!userId) return false + if (!localStorage.getItem('active_userid')) return false try { - const response = await fetch(`${API_BASE}/pull?logbookId=${logbookId}`, { - method: 'GET', - headers: { - 'X-User-Id': userId - } + const response = await apiFetch(`${API_BASE}/pull?logbookId=${logbookId}`, { + method: 'GET' }) if (!response.ok) { diff --git a/client/src/services/weather.ts b/client/src/services/weather.ts index efd1ff9..c63a908 100644 --- a/client/src/services/weather.ts +++ b/client/src/services/weather.ts @@ -1,3 +1,5 @@ +import { apiFetch } from './api.js' + export class WeatherApiError extends Error { code: 'NO_KEY' | 'REQUEST_FAILED' @@ -8,17 +10,6 @@ export class WeatherApiError extends Error { } } -function buildWeatherHeaders(): Record { - const headers: Record = {} - const userId = localStorage.getItem('active_userid') - const userKey = localStorage.getItem('owm_api_key')?.trim() - - if (userId) headers['X-User-Id'] = userId - if (userKey) headers['X-OWM-Api-Key'] = userKey - - return headers -} - export async function fetchOpenWeatherCurrent(params: { lat?: string lon?: string @@ -35,9 +26,11 @@ export async function fetchOpenWeatherCurrent(params: { throw new WeatherApiError('lat/lon or location query required') } - const res = await fetch(`/api/weather/current?${searchParams.toString()}`, { - headers: buildWeatherHeaders() - }) + const userKey = localStorage.getItem('owm_api_key')?.trim() + const headers: Record = {} + if (userKey) headers['X-OWM-Api-Key'] = userKey + + const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers }) if (res.status === 503) { throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY') diff --git a/docker-compose.yml b/docker-compose.yml index 83d69d3..405999e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu} OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-} + SESSION_SECRET: ${SESSION_SECRET:-} NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh} NTFY_TOPIC: ${NTFY_TOPIC:-} NTFY_TOKEN: ${NTFY_TOKEN:-} diff --git a/docs/push-notifications-plan.md b/docs/push-notifications-plan.md index 1f7cb1e..fefebeb 100644 --- a/docs/push-notifications-plan.md +++ b/docs/push-notifications-plan.md @@ -2,7 +2,7 @@ **Ziel:** Der Owner eines Logbuchs soll per Web Push informiert werden, wenn ein eingeladenes Crewmitglied (Collaborator mit WRITE) Änderungen synchronisiert — auch wenn die App geschlossen ist. -**Stand Codebase:** Service Worker nur für PWA-Caching/Updates (`vite-plugin-pwa`). Sync läuft per `setInterval` im Tab (~30 s). Kein `web-push`, keine Push-Subscriptions in der DB. +**Stand Codebase:** Push MVP ist implementiert (`web-push`, Prisma-Modelle, `routes/push.ts`, `pushNotify.ts`, Custom SW `sw.ts`, Settings-UI). API-Auth erfolgt über **HttpOnly-Session-Cookie** (`daagbok_session`) nach WebAuthn-Login — nicht mehr über `X-User-Id`. --- @@ -48,7 +48,7 @@ sequenceDiagram participant SW as Service Worker (Owner) participant Owner as Owner-Gerät - Crew->>API: POST /api/sync/push (X-User-Id: crew) + Crew->>API: POST /api/sync/push (Session-Cookie) API->>DB: Payloads speichern API->>API: collaborator change? → notify owner API->>DB: PushSubscriptions (owner) @@ -129,6 +129,8 @@ npm install web-push --workspace=server `.env` (Beispiel): ```env +ORIGIN=https://kapteins-daagbok.eu +SESSION_SECRET=... # min. 32 Zeichen, Pflicht in Produktion VAPID_PUBLIC_KEY=... VAPID_PRIVATE_KEY=... VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu @@ -147,12 +149,12 @@ npx web-push generate-vapid-keys | Methode | Pfad | Auth | Beschreibung | |---------|------|------|--------------| | `GET` | `/vapid-public-key` | nein | Liefert Public Key für `pushManager.subscribe` | -| `PUT` | `/subscription` | `X-User-Id` | Upsert Subscription (endpoint + keys) | -| `DELETE` | `/subscription` | `X-User-Id` | Body: `{ endpoint }` — Gerät abmelden | -| `GET` | `/prefs` | `X-User-Id` | Liest `collaboratorChangesEnabled` | -| `PUT` | `/prefs` | `X-User-Id` | Body: `{ collaboratorChangesEnabled: boolean }` | +| `PUT` | `/subscription` | Session-Cookie | Upsert Subscription (endpoint + keys) | +| `DELETE` | `/subscription` | Session-Cookie | Body: `{ endpoint }` — Gerät abmelden | +| `GET` | `/prefs` | Session-Cookie | Liest `collaboratorChangesEnabled` | +| `PUT` | `/prefs` | Session-Cookie | Body: `{ collaboratorChangesEnabled: boolean }` | -`requireUser`-Middleware wie in `sync.ts` / `collaboration.ts` wiederverwenden. +`requireUser` in `server/src/middleware/auth.ts` — liest und verifiziert `daagbok_session` (HMAC-signiert). Client sendet `credentials: 'include'` (`client/src/services/api.ts`). ### 5.3 Benachrichtigungs-Service @@ -307,7 +309,7 @@ Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route) | Risiko | Maßnahme | |--------|----------| -| Fremde subscriben mit fremder `userId` | Nur authentifizierte Requests (`X-User-Id` wie heute — langfristig Session/JWT erwägen). | +| Fremde subscriben mit fremder `userId` | Session-Cookie nach WebAuthn; `userId` kommt aus verifiziertem Token, nicht aus Client-Header. | | Push an falschen User | `notifyOwner` nur mit `logbook.userId` aus DB, nie aus Client-Body. | | Endpoint-Injection | `endpoint` muss HTTPS-URL sein; Länge begrenzen. | | Spam durch Crew | Rate-Limit + nur `create`/`update` im MVP. | @@ -377,7 +379,7 @@ Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route) 1. **Nur Owner oder auch andere Collaborators?** — MVP: nur Owner. 2. **Rate-Limit-Dauer:** 2 min vs. 5 min — Empfehlung: **3 min** pro Logbuch. 3. **Mehrere Geräte des Owners:** alle Subscriptions benachrichtigen — ja (Standard). -4. **Auth verbessern:** Push-Routen jetzt mit `X-User-Id` wie Rest der API; Roadmap-Item: echte Session. +4. ~~**Auth verbessern**~~ — erledigt: HttpOnly-Session-Cookie für alle geschützten Routen inkl. Push. --- @@ -410,8 +412,13 @@ client/ src/components/SettingsForm.tsx # Integration src/i18n/locales/de.json, en.json .env.example # VITE_VAPID_PUBLIC_KEY + src/services/api.ts # apiFetch (credentials: include) + +server/ + src/session.ts # Session-Cookie signieren/verifizieren + src/middleware/auth.ts # requireUser, requireReauth docs/ push-notifications-plan.md # dieses Dokument -README.md # Feature-Zeile + Env-Hinweis +README.md # Auth/Session, Env-Hinweise ``` diff --git a/scripts/start-dev-docker.sh b/scripts/start-dev-docker.sh index 16d8555..c2148c8 100755 --- a/scripts/start-dev-docker.sh +++ b/scripts/start-dev-docker.sh @@ -44,6 +44,7 @@ if [ "$IS_READY" = true ]; then echo "SUCCESS: Services are up and healthy!" echo " -> App Frontend (Nginx): http://localhost" echo " -> Backend API Health: http://localhost/api/health" + echo " -> Auth: session cookie (set ORIGIN=http://localhost, SESSION_SECRET in .env)" echo "==================================================" else echo "WARNING: Backend did not transition to healthy in time." diff --git a/scripts/start-dev.sh b/scripts/start-dev.sh index 3da25f1..7f78b50 100755 --- a/scripts/start-dev.sh +++ b/scripts/start-dev.sh @@ -38,6 +38,35 @@ resolve_node_toolchain() { command -v npm >/dev/null 2>&1 } +check_dev_env() { + local env_file="$REPO_ROOT/.env" + if [ ! -f "$env_file" ]; then + echo "Warning: $env_file missing — copy from .env.example (RP_ID, ORIGIN, SESSION_SECRET)." + return + fi + + local origin_line origin_val + origin_line=$(grep -E '^ORIGIN=' "$env_file" | tail -1 || true) + origin_val="${origin_line#ORIGIN=}" + origin_val="${origin_val%\"}" + origin_val="${origin_val#\"}" + local expected_origin="http://localhost:$CLIENT_PORT" + if [ -n "$origin_val" ] && [ "$origin_val" != "$expected_origin" ]; then + echo "Warning: ORIGIN=$origin_val — for Vite dev use ORIGIN=$expected_origin (session cookie + CORS)." + fi + + local secret_line secret_val + secret_line=$(grep -E '^SESSION_SECRET=' "$env_file" | tail -1 || true) + secret_val="${secret_line#SESSION_SECRET=}" + secret_val="${secret_val%\"}" + secret_val="${secret_val#\"}" + if [ -z "$secret_val" ]; then + echo "Note: SESSION_SECRET is empty — backend uses a dev-only fallback (not for production)." + elif [ "${#secret_val}" -lt 32 ]; then + echo "Warning: SESSION_SECRET should be at least 32 characters." + fi +} + require_node_toolchain() { if resolve_node_toolchain; then echo "Using Node $(node -v), npm $(npm -v)" @@ -62,6 +91,7 @@ echo "========================================" echo "Preparing to (re)start services..." require_node_toolchain +check_dev_env # Clean up processes running on ports cleanup_port() { @@ -170,6 +200,8 @@ echo "========================================" echo "Dev services are now running:" echo " -> Backend: http://localhost:$SERVER_PORT" echo " -> Frontend: http://localhost:$CLIENT_PORT" +echo " -> API auth: HttpOnly session cookie (after Passkey login)" +echo " -> Health: http://localhost:$SERVER_PORT/api/health" echo "========================================" echo "Press Ctrl+C to terminate both servers." echo "========================================" diff --git a/server/package-lock.json b/server/package-lock.json index 4adcee2..1dca377 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,13 +10,17 @@ "dependencies": { "@prisma/client": "^5.10.2", "@simplewebauthn/server": "^9.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-rate-limit": "^8.5.2", + "helmet": "^8.2.0", "prisma": "^5.10.2", "web-push": "^3.6.7" }, "devDependencies": { + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.11.24", @@ -657,6 +661,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -932,6 +946,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -1175,6 +1208,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -1307,6 +1358,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/http_ece": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", @@ -1390,6 +1453,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/server/package.json b/server/package.json index 92e7ee7..0bc4ea5 100644 --- a/server/package.json +++ b/server/package.json @@ -12,13 +12,17 @@ "dependencies": { "@prisma/client": "^5.10.2", "@simplewebauthn/server": "^9.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-rate-limit": "^8.5.2", + "helmet": "^8.2.0", "prisma": "^5.10.2", "web-push": "^3.6.7" }, "devDependencies": { + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/node": "^20.11.24", diff --git a/server/src/index.ts b/server/src/index.ts index 80cf966..83840f4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,8 @@ import express from 'express' import cors from 'cors' +import cookieParser from 'cookie-parser' +import helmet from 'helmet' +import rateLimit from 'express-rate-limit' import dotenv from 'dotenv' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -21,8 +24,39 @@ dotenv.config({ path: resolve(__dirname, '../.env') }) const app = express() const PORT = process.env.PORT || 5000 -app.use(cors()) -app.use(express.json({ limit: '50mb' })) +const allowedOrigin = process.env.ORIGIN || 'http://localhost:5173' + +app.use( + helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false + }) +) +app.use( + cors({ + origin: allowedOrigin, + credentials: true + }) +) +app.use(cookieParser()) +app.use(express.json({ limit: '10mb' })) + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false +}) + +const apiLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, + max: 300, + standardHeaders: true, + legacyHeaders: false +}) + +app.use('/api/auth', authLimiter) +app.use('/api', apiLimiter) // Mount routes app.use('/api/auth', authRouter) @@ -44,11 +78,10 @@ app.get('/api/health', async (req, res) => { timestamp: new Date().toISOString(), service: 'Kapteins Daagbok Backend' }) - } catch (err: any) { + } catch { res.status(500).json({ status: 'error', database: 'disconnected', - error: err.message, timestamp: new Date().toISOString(), service: 'Kapteins Daagbok Backend' }) diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts new file mode 100644 index 0000000..f8e6cd8 --- /dev/null +++ b/server/src/middleware/auth.ts @@ -0,0 +1,33 @@ +import type { Request, Response, NextFunction } from 'express' +import { hasValidReauth, readSessionFromRequest } from '../session.js' + +export interface AuthedRequest extends Request { + userId: string + session: NonNullable> +} + +export function requireUser(req: Request, res: Response, next: NextFunction): void { + const session = readSessionFromRequest(req) + if (!session) { + res.status(401).json({ error: 'Unauthorized: valid session required' }) + return + } + ;(req as AuthedRequest).userId = session.userId + ;(req as AuthedRequest).session = session + next() +} + +export function requireReauth(req: Request, res: Response, next: NextFunction): void { + const session = readSessionFromRequest(req) + if (!session) { + res.status(401).json({ error: 'Unauthorized: valid session required' }) + return + } + if (!hasValidReauth(session)) { + res.status(403).json({ error: 'Recent passkey confirmation required' }) + return + } + ;(req as AuthedRequest).userId = session.userId + ;(req as AuthedRequest).session = session + next() +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index feb20f0..b8f8c15 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -6,6 +6,14 @@ import { verifyAuthenticationResponse } from '@simplewebauthn/server' import { prisma } from '../db.js' +import { requireReauth, requireUser } from '../middleware/auth.js' +import { + clearSessionCookie, + extendReauth, + readSessionFromRequest, + setSessionCookie, + setSessionTokenCookie +} from '../session.js' const router = Router() @@ -13,12 +21,9 @@ const rpName = 'Kapteins Daagbok' const rpID = process.env.RP_ID || 'localhost' const origin = process.env.ORIGIN || 'http://localhost:5173' -// In-memory challenge stores const registrationChallenges = new Map() -const authenticationChallenges = new Map() const activeChallenges = new Set() -// 1. Generate Registration Options router.post('/register-options', async (req, res) => { try { const { username } = req.body @@ -34,13 +39,6 @@ router.post('/register-options', async (req, res) => { return res.status(400).json({ error: 'User already exists' }) } - // NOTE: @simplewebauthn/server v9 places `userID` verbatim into the - // emitted `user.id` JSON field. The browser client (v13) however decodes - // `user.id` as a base64url string. Passing a raw username therefore either - // corrupts the user handle or, for usernames containing characters outside - // the base64url alphabet (".", " ", "@", umlauts, ...), makes the browser - // throw "Invalid character" before the passkey prompt even appears. - // Encoding the username as base64url keeps the value spec-compliant. const userID = Buffer.from(username, 'utf8').toString('base64url') const options = await generateRegistrationOptions({ @@ -54,10 +52,9 @@ router.post('/register-options', async (req, res) => { residentKey: 'required', userVerification: 'preferred' }, - supportedAlgorithmIDs: [-7, -257] // ES256 and RS256 + supportedAlgorithmIDs: [-7, -257] }) - // Store challenge registrationChallenges.set(username, options.challenge) return res.json(options) @@ -67,7 +64,6 @@ router.post('/register-options', async (req, res) => { } }) -// 2. Verify Registration Response router.post('/register-verify', async (req, res) => { try { const { @@ -103,7 +99,6 @@ router.post('/register-verify', async (req, res) => { const { credentialID, credentialPublicKey, counter } = verification.registrationInfo - // Save user and credential const user = await prisma.user.create({ data: { username, @@ -125,6 +120,7 @@ router.post('/register-verify', async (req, res) => { }) registrationChallenges.delete(username) + setSessionCookie(res, user.id, true) return res.json({ verified: true, userId: user.id }) } catch (error: any) { @@ -133,12 +129,10 @@ router.post('/register-verify', async (req, res) => { } }) -// 3. Generate Authentication Options router.post('/login-options', async (req, res) => { try { const { username } = req.body - // If username is supplied, we do a targeted login, otherwise usernameless let allowCredentials: any[] = [] if (username) { const user = await prisma.user.findUnique({ @@ -146,7 +140,7 @@ router.post('/login-options', async (req, res) => { include: { credentials: true } }) if (user) { - allowCredentials = user.credentials.map(cred => ({ + allowCredentials = user.credentials.map((cred) => ({ id: Buffer.from(cred.credentialId, 'base64url'), type: 'public-key', transports: cred.transports as any[] @@ -160,7 +154,6 @@ router.post('/login-options', async (req, res) => { userVerification: 'preferred' }) - // Store challenge activeChallenges.add(options.challenge) return res.json(options) @@ -170,7 +163,6 @@ router.post('/login-options', async (req, res) => { } }) -// 4. Verify Authentication Response router.post('/login-verify', async (req, res) => { try { const { credentialResponse, challenge } = req.body @@ -178,13 +170,11 @@ router.post('/login-verify', async (req, res) => { return res.status(400).json({ error: 'credentialResponse and challenge are required' }) } - // Verify challenge if (!activeChallenges.has(challenge)) { return res.status(400).json({ error: 'Challenge not found or expired' }) } activeChallenges.delete(challenge) - // Find the credential in DB const dbCred = await prisma.credential.findUnique({ where: { credentialId: credentialResponse.id }, include: { user: true } @@ -212,12 +202,13 @@ router.post('/login-verify', async (req, res) => { return res.status(400).json({ error: 'Authentication failed' }) } - // Update counter await prisma.credential.update({ where: { id: dbCred.id }, data: { counter: BigInt(verification.authenticationInfo.newCounter) } }) + setSessionCookie(res, user.id, true) + return res.json({ verified: true, userId: user.id, @@ -235,16 +226,112 @@ router.post('/login-verify', async (req, res) => { } }) -// 5. Delete own account -router.delete('/delete-account', async (req: any, res) => { +router.get('/session', (req, res) => { + const session = readSessionFromRequest(req) + if (!session) { + return res.status(401).json({ authenticated: false }) + } + return res.json({ authenticated: true, userId: session.userId }) +}) + +router.post('/logout', (req, res) => { + clearSessionCookie(res) + return res.json({ success: true }) +}) + +router.post('/reauth-options', requireUser, async (req: any, res) => { try { - const userId = req.headers['x-user-id'] - if (!userId) { - return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) + const user = await prisma.user.findUnique({ + where: { id: req.userId }, + include: { credentials: true } + }) + + if (!user || user.credentials.length === 0) { + return res.status(400).json({ error: 'No passkey credentials found' }) } + const allowCredentials = user.credentials.map((cred) => ({ + id: Buffer.from(cred.credentialId, 'base64url'), + type: 'public-key' as const, + transports: cred.transports as any[] + })) + + const options = await generateAuthenticationOptions({ + rpID, + allowCredentials, + userVerification: 'required' + }) + + activeChallenges.add(options.challenge) + + return res.json(options) + } catch (error: any) { + console.error('Error generating reauth options:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +router.post('/reauth-verify', requireUser, async (req: any, res) => { + try { + const { credentialResponse, challenge } = req.body + if (!credentialResponse || !challenge) { + return res.status(400).json({ error: 'credentialResponse and challenge are required' }) + } + + if (!activeChallenges.has(challenge)) { + return res.status(400).json({ error: 'Challenge not found or expired' }) + } + activeChallenges.delete(challenge) + + const dbCred = await prisma.credential.findUnique({ + where: { credentialId: credentialResponse.id }, + include: { user: true } + }) + + if (!dbCred || dbCred.userId !== req.userId) { + return res.status(403).json({ error: 'Credential does not belong to this account' }) + } + + const verification = await verifyAuthenticationResponse({ + response: credentialResponse, + expectedChallenge: challenge, + expectedOrigin: origin, + expectedRPID: rpID, + authenticator: { + credentialID: Buffer.from(dbCred.credentialId, 'base64url'), + credentialPublicKey: dbCred.publicKey, + counter: Number(dbCred.counter) + } + }) + + if (!verification.verified || !verification.authenticationInfo) { + return res.status(400).json({ error: 'Reauthentication failed' }) + } + + await prisma.credential.update({ + where: { id: dbCred.id }, + data: { counter: BigInt(verification.authenticationInfo.newCounter) } + }) + + const currentToken = req.cookies?.daagbok_session + const extended = typeof currentToken === 'string' ? extendReauth(currentToken) : null + if (extended) { + setSessionTokenCookie(res, extended) + } else { + setSessionCookie(res, req.userId, true) + } + + return res.json({ verified: true }) + } catch (error: any) { + console.error('Error verifying reauth:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +router.delete('/delete-account', requireReauth, async (req: any, res) => { + try { const user = await prisma.user.findUnique({ - where: { id: userId } + where: { id: req.userId } }) if (!user) { @@ -252,9 +339,10 @@ router.delete('/delete-account', async (req: any, res) => { } await prisma.user.delete({ - where: { id: userId } + where: { id: req.userId } }) + clearSessionCookie(res) return res.json({ success: true }) } catch (error: any) { console.error('Error deleting account:', error) @@ -262,14 +350,8 @@ router.delete('/delete-account', async (req: any, res) => { } }) -// 6. Enroll PRF encrypted master key -router.post('/enroll-prf', async (req: any, res) => { +router.post('/enroll-prf', requireReauth, async (req: any, res) => { try { - const userId = req.headers['x-user-id'] - if (!userId) { - return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) - } - const { encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag } = req.body if (!encryptedMasterKeyPrf || !encryptedMasterKeyPrfIv || !encryptedMasterKeyPrfTag) { return res.status(400).json({ error: 'Missing required PRF key fields' }) @@ -284,7 +366,7 @@ router.post('/enroll-prf', async (req: any, res) => { } await prisma.user.update({ - where: { id: userId }, + where: { id: req.userId }, data: { encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, diff --git a/server/src/routes/collaboration.ts b/server/src/routes/collaboration.ts index d6dc148..d1add2a 100644 --- a/server/src/routes/collaboration.ts +++ b/server/src/routes/collaboration.ts @@ -1,18 +1,9 @@ import { Router } from 'express' import { prisma } from '../db.js' +import { requireUser } from '../middleware/auth.js' const router = Router() -// Middleware to extract user ID from headers (for authenticated routes) -const requireUser = (req: any, res: any, next: any) => { - const userId = req.headers['x-user-id'] - if (!userId) { - return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) - } - req.userId = userId - next() -} - // 1. Get invitation details (public route, does not require authentication) router.get('/invite-details', async (req: any, res) => { try { diff --git a/server/src/routes/feedback.ts b/server/src/routes/feedback.ts index 1c4f03f..7197df6 100644 --- a/server/src/routes/feedback.ts +++ b/server/src/routes/feedback.ts @@ -1,5 +1,6 @@ import { Router } from 'express' import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js' +import { requireUser } from '../middleware/auth.js' const router = Router() @@ -20,15 +21,6 @@ function parseOptionalEmail(value: unknown): string | undefined { return trimmed } -const requireUser = (req: any, res: any, next: any) => { - const userId = req.headers['x-user-id'] - if (!userId) { - return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) - } - req.userId = userId - next() -} - router.get('/status', requireUser, (_req, res) => { res.json({ enabled: isNtfyConfigured() }) }) diff --git a/server/src/routes/logbooks.ts b/server/src/routes/logbooks.ts index 0af051e..93b3e10 100644 --- a/server/src/routes/logbooks.ts +++ b/server/src/routes/logbooks.ts @@ -1,18 +1,9 @@ import { Router } from 'express' import { prisma } from '../db.js' +import { requireUser } from '../middleware/auth.js' const router = Router() -// Middleware to extract user ID from headers -const requireUser = (req: any, res: any, next: any) => { - const userId = req.headers['x-user-id'] - if (!userId) { - return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) - } - req.userId = userId - next() -} - router.use(requireUser) // 1. Get all logbooks for the authenticated user (owned and shared) diff --git a/server/src/routes/push.ts b/server/src/routes/push.ts index c120ba8..4c9f648 100644 --- a/server/src/routes/push.ts +++ b/server/src/routes/push.ts @@ -1,17 +1,9 @@ import { Router } from 'express' import { prisma } from '../db.js' +import { requireUser } from '../middleware/auth.js' const router = Router() -const requireUser = (req: any, res: any, next: any) => { - const userId = req.headers['x-user-id'] - if (!userId) { - return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) - } - req.userId = userId - next() -} - function isValidHttpsEndpoint(endpoint: unknown): endpoint is string { if (typeof endpoint !== 'string' || endpoint.length > 2048) return false try { diff --git a/server/src/routes/sign.ts b/server/src/routes/sign.ts index ea89d1e..ccb23f9 100644 --- a/server/src/routes/sign.ts +++ b/server/src/routes/sign.ts @@ -5,6 +5,7 @@ import { verifyAuthenticationResponse } from '@simplewebauthn/server' import { prisma } from '../db.js' +import { requireUser } from '../middleware/auth.js' const router = Router() @@ -31,15 +32,6 @@ function pruneExpiredChallenges() { } } -const requireUser = (req: any, res: any, next: any) => { - const userId = req.headers['x-user-id'] - if (!userId) { - return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) - } - req.userId = userId - next() -} - router.use(requireUser) async function getLogbookWithAccess(logbookId: string, userId: string) { diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index a1c42eb..dc7891f 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -1,19 +1,10 @@ import { Router } from 'express' import { prisma } from '../db.js' import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js' +import { requireUser } from '../middleware/auth.js' const router = Router() -// Middleware to extract user ID from headers -const requireUser = (req: any, res: any, next: any) => { - const userId = req.headers['x-user-id'] - if (!userId) { - return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) - } - req.userId = userId - next() -} - router.use(requireUser) // 1. Push local changes to the server @@ -99,7 +90,7 @@ router.post('/push', async (req: any, res) => { } const isOwner = logbook.userId === req.userId - const isCollaborator = await prisma.collaboration.findUnique({ + const collaboration = await prisma.collaboration.findUnique({ where: { logbookId_userId: { logbookId, @@ -108,11 +99,16 @@ router.post('/push', async (req: any, res) => { } }) - if (!isOwner && !isCollaborator) { + if (!isOwner && !collaboration) { results.push({ payloadId, status: 'error', error: 'Forbidden: Access denied' }) continue } + if (!isOwner && (!collaboration || collaboration.role !== 'WRITE')) { + results.push({ payloadId, status: 'error', error: 'Forbidden: WRITE access required' }) + continue + } + if (type === 'logbook' && action === 'delete') { if (!isOwner) { results.push({ payloadId, status: 'error', error: 'Forbidden: Only owner can delete logbook' }) @@ -244,7 +240,7 @@ router.post('/push', async (req: any, res) => { logbook.userId, logbookId, isOwner, - isCollaborator, + collaboration, action, type ) @@ -284,7 +280,7 @@ router.get('/pull', async (req: any, res) => { } const isOwner = logbook.userId === req.userId - const isCollaborator = await prisma.collaboration.findUnique({ + const collaboration = await prisma.collaboration.findUnique({ where: { logbookId_userId: { logbookId, @@ -293,7 +289,7 @@ router.get('/pull', async (req: any, res) => { } }) - if (!isOwner && !isCollaborator) { + if (!isOwner && !collaboration) { return res.status(403).json({ error: 'Forbidden: Access denied' }) } diff --git a/server/src/routes/weather.ts b/server/src/routes/weather.ts index 4343c18..6927e95 100644 --- a/server/src/routes/weather.ts +++ b/server/src/routes/weather.ts @@ -1,16 +1,8 @@ import { Router } from 'express' +import { requireUser } from '../middleware/auth.js' const router = Router() -const requireUser = (req: any, res: any, next: any) => { - const userId = req.headers['x-user-id'] - if (!userId) { - return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) - } - req.userId = userId - next() -} - function resolveOwmApiKey(userProvidedKey: unknown): string | null { if (typeof userProvidedKey === 'string' && userProvidedKey.trim()) { return userProvidedKey.trim() @@ -21,7 +13,7 @@ function resolveOwmApiKey(userProvidedKey: unknown): string | null { return fromEnv || null } -router.get('/current', requireUser, async (req: any, res) => { +router.get('/current', requireUser, async (req, res) => { try { const { lat, lon, q } = req.query const apiKey = resolveOwmApiKey(req.headers['x-owm-api-key']) diff --git a/server/src/session.ts b/server/src/session.ts new file mode 100644 index 0000000..81b5e9f --- /dev/null +++ b/server/src/session.ts @@ -0,0 +1,101 @@ +import crypto from 'crypto' +import type { CookieOptions, Request, Response } from 'express' + +export const SESSION_COOKIE = 'daagbok_session' +const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +export const REAUTH_MAX_AGE_MS = 10 * 60 * 1000 + +export interface SessionPayload { + userId: string + exp: number + reauthExp?: number +} + +function sessionSecret(): string { + const secret = process.env.SESSION_SECRET?.trim() + if (secret && secret.length >= 32) return secret + if (process.env.NODE_ENV === 'production') { + throw new Error('SESSION_SECRET must be set in production (min. 32 characters)') + } + return 'dev-only-insecure-session-secret-change-me!!' +} + +function sign(data: string): string { + return crypto.createHmac('sha256', sessionSecret()).update(data).digest('base64url') +} + +export function createSessionToken(userId: string, withReauth = true): string { + const payload: SessionPayload = { + userId, + exp: Date.now() + SESSION_MAX_AGE_MS, + ...(withReauth ? { reauthExp: Date.now() + REAUTH_MAX_AGE_MS } : {}) + } + const body = Buffer.from(JSON.stringify(payload)).toString('base64url') + const signature = sign(body) + return `${body}.${signature}` +} + +export function extendReauth(token: string): string | null { + const payload = verifySessionToken(token) + if (!payload) return null + payload.reauthExp = Date.now() + REAUTH_MAX_AGE_MS + const body = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `${body}.${sign(body)}` +} + +export function verifySessionToken(token: string | undefined): SessionPayload | null { + if (!token || typeof token !== 'string') return null + const dot = token.lastIndexOf('.') + if (dot <= 0) return null + const body = token.slice(0, dot) + const sig = token.slice(dot + 1) + if (sig !== sign(body)) return null + + try { + const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload + if (!payload.userId || typeof payload.exp !== 'number') return null + if (payload.exp <= Date.now()) return null + return payload + } catch { + return null + } +} + +export function readSessionFromRequest(req: Request): SessionPayload | null { + const raw = req.cookies?.[SESSION_COOKIE] + if (typeof raw !== 'string') return null + return verifySessionToken(raw) +} + +export function sessionCookieOptions(): CookieOptions { + const origin = process.env.ORIGIN || 'http://localhost:5173' + const secure = origin.startsWith('https://') + return { + httpOnly: true, + secure, + sameSite: 'lax', + path: '/', + maxAge: SESSION_MAX_AGE_MS + } +} + +export function setSessionCookie(res: Response, userId: string, withReauth = true): void { + res.cookie(SESSION_COOKIE, createSessionToken(userId, withReauth), sessionCookieOptions()) +} + +export function setSessionTokenCookie(res: Response, token: string): void { + res.cookie(SESSION_COOKIE, token, sessionCookieOptions()) +} + +export function clearSessionCookie(res: Response): void { + res.clearCookie(SESSION_COOKIE, { + httpOnly: true, + secure: sessionCookieOptions().secure, + sameSite: 'lax', + path: '/' + }) +} + +export function hasValidReauth(payload: SessionPayload): boolean { + return typeof payload.reauthExp === 'number' && payload.reauthExp > Date.now() +}