feat(security): Session-Cookies statt X-User-Id und API-Härtung
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 <cursoragent@cursor.com>
This commit is contained in:
+6
-1
@@ -4,7 +4,12 @@ OpenWeatherMapAPIKey=<owm_api_key>
|
|||||||
# For local dev: localhost and http://localhost
|
# For local dev: localhost and http://localhost
|
||||||
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
|
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
|
||||||
RP_ID=localhost
|
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
|
# 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
|
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ Beim Laden eines Eintrags: `computedHash !== sig.entryHash` → UI-Warnung.
|
|||||||
|
|
||||||
Neuer Router: `server/src/routes/sign.ts` → Mount unter `/api/sign`
|
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`
|
### 4.1 `POST /api/sign/options`
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ test('isSignatureValidForEntry')
|
|||||||
| WebAuthn Login | `client/src/services/auth.ts`, `server/src/routes/auth.ts` |
|
| WebAuthn Login | `client/src/services/auth.ts`, `server/src/routes/auth.ts` |
|
||||||
| Collaborators | `server/src/routes/collaboration.ts`, `SettingsForm.tsx` |
|
| Collaborators | `server/src/routes/collaboration.ts`, `SettingsForm.tsx` |
|
||||||
| E2E-Einträge | `EntryPayload` in `server/prisma/schema.prisma` |
|
| 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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
|
|||||||
| Lokaler Speicher | Dexie.js (IndexedDB), Hintergrund-Sync |
|
| Lokaler Speicher | Dexie.js (IndexedDB), Hintergrund-Sync |
|
||||||
| Backend | Node.js, Express, Prisma |
|
| Backend | Node.js, Express, Prisma |
|
||||||
| Datenbank | PostgreSQL 16 |
|
| 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 |
|
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
|
||||||
| Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) |
|
| 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.
|
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
|
## Backup & Wiederherstellung
|
||||||
|
|
||||||
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
|
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
|
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"
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
|
||||||
OpenWeatherMapAPIKey= # Fallback für Wetter-Abruf, wenn Nutzer keinen eigenen Key hat
|
OpenWeatherMapAPIKey= # Fallback für Wetter-Abruf, wenn Nutzer keinen eigenen Key hat
|
||||||
RP_ID=localhost
|
RP_ID=localhost
|
||||||
ORIGIN=http://localhost:5173
|
ORIGIN=http://localhost:5173
|
||||||
|
SESSION_SECRET= # openssl rand -base64 48 (in Prod Pflicht)
|
||||||
# Optional — Web Push (npx web-push generate-vapid-keys)
|
# Optional — Web Push (npx web-push generate-vapid-keys)
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
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
|
### 3. Datenbank & Schema
|
||||||
|
|
||||||
Das Dev-Skript startet PostgreSQL in Docker (`postgres-daagbox`). Schema anwenden:
|
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
|
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
|
## 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.
|
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
|
## Dokumentation
|
||||||
|
|
||||||
|
|||||||
+14
-5
@@ -13,7 +13,7 @@ import SettingsForm from './components/SettingsForm.tsx'
|
|||||||
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.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 { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||||
import {
|
import {
|
||||||
applyAppearanceToDocument,
|
applyAppearanceToDocument,
|
||||||
@@ -186,9 +186,14 @@ function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const session = await checkServerSession()
|
||||||
|
if (session.authenticated && session.userId) {
|
||||||
|
localStorage.setItem('active_userid', session.userId)
|
||||||
|
}
|
||||||
const savedUser = localStorage.getItem('active_username')
|
const savedUser = localStorage.getItem('active_username')
|
||||||
const key = getActiveMasterKey()
|
const key = getActiveMasterKey()
|
||||||
if (savedUser && key) {
|
if (session.authenticated && savedUser && key) {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||||
@@ -197,6 +202,7 @@ function App() {
|
|||||||
setActiveLogbookTitle(savedLogbookTitle)
|
setActiveLogbookTitle(savedLogbookTitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -307,7 +313,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logoutUser()
|
void logoutUser()
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
setActiveLogbookId(null)
|
setActiveLogbookId(null)
|
||||||
setActiveLogbookTitle(null)
|
setActiveLogbookTitle(null)
|
||||||
@@ -382,6 +388,8 @@ function App() {
|
|||||||
|
|
||||||
const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
|
const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
|
||||||
|
|
||||||
|
const logbookReadOnly = activeAccessRole === 'READ'
|
||||||
|
|
||||||
if (!activeLogbookId) {
|
if (!activeLogbookId) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'contents' }}>
|
<div style={{ display: 'contents' }}>
|
||||||
@@ -514,6 +522,7 @@ function App() {
|
|||||||
{activeTab === 'logs' && (
|
{activeTab === 'logs' && (
|
||||||
<LogEntriesList
|
<LogEntriesList
|
||||||
logbookId={activeLogbookId}
|
logbookId={activeLogbookId}
|
||||||
|
readOnly={logbookReadOnly}
|
||||||
controlledSelectedEntryId={tourSelectedEntryId}
|
controlledSelectedEntryId={tourSelectedEntryId}
|
||||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||||
highlightEntryId={demoHighlightEntryId}
|
highlightEntryId={demoHighlightEntryId}
|
||||||
@@ -521,11 +530,11 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'vessel' && (
|
{activeTab === 'vessel' && (
|
||||||
<VesselForm logbookId={activeLogbookId} />
|
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm logbookId={activeLogbookId} />
|
<CrewForm logbookId={activeLogbookId} readOnly={logbookReadOnly} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
|
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { parseCollaborationRole } from '../services/logbook.js'
|
|||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { apiJson } from '../services/api.js'
|
||||||
|
|
||||||
interface InvitationAcceptanceProps {
|
interface InvitationAcceptanceProps {
|
||||||
onAccepted: (logbookId: string, title: string) => void
|
onAccepted: (logbookId: string, title: string) => void
|
||||||
@@ -164,12 +165,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
)
|
)
|
||||||
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
|
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',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': activeUserId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token,
|
token,
|
||||||
encryptedLogbookKey: encrypted.ciphertext,
|
encryptedLogbookKey: encrypted.ciphertext,
|
||||||
@@ -177,13 +174,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
tag: encrypted.tag
|
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')
|
const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept')
|
||||||
|
|
||||||
await saveLogbookKey(logbookId, logbookKey)
|
await saveLogbookKey(logbookId, logbookKey)
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logoutUser()
|
void logoutUser()
|
||||||
onLogout()
|
onLogout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { notifyAppearanceChanged } from '../services/appearance.js'
|
|||||||
import ThemedSelect from './ThemedSelect.tsx'
|
import ThemedSelect from './ThemedSelect.tsx'
|
||||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { apiFetch } from '../services/api.js'
|
||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
logbookId?: string | null
|
logbookId?: string | null
|
||||||
@@ -67,15 +68,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
const loadShareLink = async () => {
|
const loadShareLink = async () => {
|
||||||
if (!logbookId) return
|
if (!logbookId) return
|
||||||
setLoadingShareLink(true)
|
setLoadingShareLink(true)
|
||||||
const userId = localStorage.getItem('active_userid')
|
if (!localStorage.getItem('active_userid')) return
|
||||||
if (!userId) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/collaboration/share-link?logbookId=${logbookId}`, {
|
const res = await apiFetch(`/api/collaboration/share-link?logbookId=${logbookId}`)
|
||||||
headers: {
|
|
||||||
'X-User-Id': userId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -99,17 +95,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
const handleToggleShare = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleToggleShare = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!logbookId) return
|
if (!logbookId) return
|
||||||
const checked = e.target.checked
|
const checked = e.target.checked
|
||||||
const userId = localStorage.getItem('active_userid')
|
if (!localStorage.getItem('active_userid')) return
|
||||||
if (!userId) return
|
|
||||||
|
|
||||||
setLoadingShareLink(true)
|
setLoadingShareLink(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/collaboration/share-link', {
|
const res = await apiFetch('/api/collaboration/share-link', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': userId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ logbookId, enabled: checked })
|
body: JSON.stringify({ logbookId, enabled: checked })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -149,15 +140,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
const loadCollaborators = async () => {
|
const loadCollaborators = async () => {
|
||||||
setLoadingCollabs(true)
|
setLoadingCollabs(true)
|
||||||
setCollabError(null)
|
setCollabError(null)
|
||||||
const userId = localStorage.getItem('active_userid')
|
if (!localStorage.getItem('active_userid')) return
|
||||||
if (!userId) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/collaboration/collaborators?logbookId=${logbookId}`, {
|
const res = await apiFetch(`/api/collaboration/collaborators?logbookId=${logbookId}`)
|
||||||
headers: {
|
|
||||||
'X-User-Id': userId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
setIsOwner(false)
|
setIsOwner(false)
|
||||||
@@ -184,20 +170,15 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
if (!logbookId) return
|
if (!logbookId) return
|
||||||
setGeneratingInvite(true)
|
setGeneratingInvite(true)
|
||||||
setInviteLink('')
|
setInviteLink('')
|
||||||
const userId = localStorage.getItem('active_userid')
|
if (!localStorage.getItem('active_userid')) return
|
||||||
if (!userId) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
|
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
|
||||||
const logbookKey = await ensureLogbookKey(logbookId)
|
const logbookKey = await ensureLogbookKey(logbookId)
|
||||||
|
|
||||||
// 2. Create invite token on server
|
// 2. Create invite token on server
|
||||||
const res = await fetch('/api/collaboration/invite', {
|
const res = await apiFetch('/api/collaboration/invite', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': userId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ logbookId, role: 'WRITE' })
|
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 handleRevoke = async (collabId: string, collName: string) => {
|
||||||
const userId = localStorage.getItem('active_userid')
|
if (!localStorage.getItem('active_userid')) return
|
||||||
if (!userId) return
|
|
||||||
|
|
||||||
if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/collaboration/collaborators/${collabId}`, {
|
const res = await apiFetch(`/api/collaboration/collaborators/${collabId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE'
|
||||||
headers: {
|
|
||||||
'X-User-Id': userId
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -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<Response> {
|
||||||
|
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<T>(input: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
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
|
||||||
|
}
|
||||||
+61
-73
@@ -6,27 +6,22 @@ import {
|
|||||||
deriveKeyFromPin,
|
deriveKeyFromPin,
|
||||||
encryptBuffer,
|
encryptBuffer,
|
||||||
decryptBuffer,
|
decryptBuffer,
|
||||||
generateRecoveryPhrase,
|
generateRecoveryPhrase
|
||||||
base64ToBuffer,
|
|
||||||
bufferToBase64
|
|
||||||
} from './crypto.js'
|
} from './crypto.js'
|
||||||
import { clearLogbookKeysCache } from './logbookKeys.js'
|
import { clearLogbookKeysCache } from './logbookKeys.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
import { db } from './db.js'
|
import { db } from './db.js'
|
||||||
|
import { apiFetch, apiJson } from './api.js'
|
||||||
|
|
||||||
const API_BASE = '/api/auth'
|
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
|
let activeMasterKey: ArrayBuffer | null = null
|
||||||
|
|
||||||
// Restore key from localStorage on load if present (survives reload/restart)
|
|
||||||
try {
|
try {
|
||||||
const savedKey = localStorage.getItem('active_master_key')
|
localStorage.removeItem('active_master_key')
|
||||||
if (savedKey) {
|
} catch {
|
||||||
activeMasterKey = base64ToBuffer(savedKey)
|
/* ignore */
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to restore active master key:', e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveMasterKey(): ArrayBuffer | null {
|
export function getActiveMasterKey(): ArrayBuffer | null {
|
||||||
@@ -35,15 +30,32 @@ export function getActiveMasterKey(): ArrayBuffer | null {
|
|||||||
|
|
||||||
export function setActiveMasterKey(key: ArrayBuffer | null) {
|
export function setActiveMasterKey(key: ArrayBuffer | null) {
|
||||||
activeMasterKey = key
|
activeMasterKey = key
|
||||||
if (key) {
|
}
|
||||||
|
|
||||||
|
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('active_master_key', bufferToBase64(key))
|
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`)
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.error('Failed to save master key to localStorage:', e)
|
return { authenticated: false }
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
localStorage.removeItem('active_master_key')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reauthWithPasskey(): Promise<boolean> {
|
||||||
|
const options = await apiJson<any>(`${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
|
// PIN fallback mechanism functions
|
||||||
@@ -152,19 +164,11 @@ export interface RegistrationResult {
|
|||||||
|
|
||||||
export async function registerUser(username: string): Promise<RegistrationResult> {
|
export async function registerUser(username: string): Promise<RegistrationResult> {
|
||||||
// 1. Get registration options
|
// 1. Get registration options
|
||||||
const optionsRes = await fetch(`${API_BASE}/register-options`, {
|
const options = await apiJson<any>(`${API_BASE}/register-options`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username })
|
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
|
// Request the PRF extension WITH an evaluation salt. This must match the
|
||||||
// salt used during login (PRF_SALT), otherwise the PRF-derived key produced
|
// 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
|
// 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<RegistrationResult
|
|||||||
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
|
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
|
||||||
|
|
||||||
// 4. Verify registration on the server
|
// 4. Verify registration on the server
|
||||||
const verifyRes = await fetch(`${API_BASE}/register-verify`, {
|
const result = await apiJson<{ verified: boolean; userId: string }>(`${API_BASE}/register-verify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
credentialResponse,
|
credentialResponse,
|
||||||
@@ -243,13 +246,6 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
|||||||
encryptedMasterKeyRecTag: encryptedRecovery.tag
|
encryptedMasterKeyRecTag: encryptedRecovery.tag
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!verifyRes.ok) {
|
|
||||||
const err = await verifyRes.json()
|
|
||||||
throw new Error(err.error || 'Failed to verify registration response')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await verifyRes.json()
|
|
||||||
if (result.verified) {
|
if (result.verified) {
|
||||||
setActiveMasterKey(masterKey)
|
setActiveMasterKey(masterKey)
|
||||||
localStorage.setItem('active_username', username)
|
localStorage.setItem('active_username', username)
|
||||||
@@ -292,19 +288,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Get authentication options
|
// 1. Get authentication options
|
||||||
const optionsRes = await fetch(`${API_BASE}/login-options`, {
|
const options = await apiJson<any>(`${API_BASE}/login-options`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username })
|
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.
|
// Add PRF extension evaluation input.
|
||||||
// When the server returned a concrete allowCredentials list we use
|
// When the server returned a concrete allowCredentials list we use
|
||||||
// `evalByCredential` (keyed by the base64url credential id), which is the
|
// `evalByCredential` (keyed by the base64url credential id), which is the
|
||||||
@@ -366,21 +354,23 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Verify assertion on the server
|
// 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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
credentialResponse,
|
credentialResponse,
|
||||||
challenge: options.challenge
|
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) {
|
if (!result.verified) {
|
||||||
return { verified: false, prfSuccess: false }
|
return { verified: false, prfSuccess: false }
|
||||||
}
|
}
|
||||||
@@ -407,7 +397,12 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
|||||||
console.log('PRF extension results first present:', !!prfResults.results?.first)
|
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 {
|
try {
|
||||||
const firstBuffer = typeof prfResults.results.first === 'string'
|
const firstBuffer = typeof prfResults.results.first === 'string'
|
||||||
? base64urlToBuffer(prfResults.results.first)
|
? base64urlToBuffer(prfResults.results.first)
|
||||||
@@ -475,22 +470,14 @@ export async function completeLoginWithRecovery(
|
|||||||
const prfKey = await deriveKeyFromPrf(firstBuffer)
|
const prfKey = await deriveKeyFromPrf(firstBuffer)
|
||||||
const encryptedPrf = await encryptBuffer(decryptedMaster, prfKey)
|
const encryptedPrf = await encryptBuffer(decryptedMaster, prfKey)
|
||||||
console.log('Sending PRF credentials to server...')
|
console.log('Sending PRF credentials to server...')
|
||||||
const enrollRes = await fetch(`${API_BASE}/enroll-prf`, {
|
await apiJson(`${API_BASE}/enroll-prf`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': encryptedPayloads.userId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
|
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
|
||||||
encryptedMasterKeyPrfIv: encryptedPrf.iv,
|
encryptedMasterKeyPrfIv: encryptedPrf.iv,
|
||||||
encryptedMasterKeyPrfTag: encryptedPrf.tag
|
encryptedMasterKeyPrfTag: encryptedPrf.tag
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
console.log('Enrollment response status:', enrollRes.status)
|
|
||||||
if (!enrollRes.ok) {
|
|
||||||
console.warn('Server rejected PRF enrollment')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to encrypt/enroll master key with PRF key:', 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)
|
setActiveMasterKey(null)
|
||||||
clearLogbookKeysCache()
|
clearLogbookKeysCache()
|
||||||
localStorage.removeItem('active_username')
|
localStorage.removeItem('active_username')
|
||||||
localStorage.removeItem('active_userid')
|
localStorage.removeItem('active_userid')
|
||||||
|
try {
|
||||||
|
await apiFetch(`${API_BASE}/logout`, { method: 'POST' })
|
||||||
|
} catch {
|
||||||
|
/* ignore network errors on logout */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAccount(): Promise<boolean> {
|
export async function deleteAccount(): Promise<boolean> {
|
||||||
const userId = localStorage.getItem('active_userid')
|
|
||||||
const username = localStorage.getItem('active_username')
|
const username = localStorage.getItem('active_username')
|
||||||
if (!userId) return false
|
if (!localStorage.getItem('active_userid')) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/delete-account`, {
|
await reauthWithPasskey()
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
const res = await apiFetch(`${API_BASE}/delete-account`, { method: 'DELETE' })
|
||||||
'X-User-Id': userId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (username) {
|
if (username) {
|
||||||
@@ -546,7 +534,7 @@ export async function deleteAccount(): Promise<boolean> {
|
|||||||
])
|
])
|
||||||
|
|
||||||
// Wipe localStorage and session variables
|
// Wipe localStorage and session variables
|
||||||
logoutUser()
|
await logoutUser()
|
||||||
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
|
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { startAuthentication } from '@simplewebauthn/browser'
|
import { startAuthentication } from '@simplewebauthn/browser'
|
||||||
import type { PasskeySignature } from '../types/signatures.js'
|
import type { PasskeySignature } from '../types/signatures.js'
|
||||||
|
import { apiJson } from './api.js'
|
||||||
|
|
||||||
export async function signLogEntry(params: {
|
export async function signLogEntry(params: {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -7,32 +8,22 @@ export async function signLogEntry(params: {
|
|||||||
entryHash: string
|
entryHash: string
|
||||||
role: 'skipper' | 'crew'
|
role: 'skipper' | 'crew'
|
||||||
}): Promise<PasskeySignature> {
|
}): Promise<PasskeySignature> {
|
||||||
const userId = localStorage.getItem('active_userid')
|
if (!localStorage.getItem('active_userid')) throw new Error('User not authenticated')
|
||||||
if (!userId) throw new Error('User not authenticated')
|
|
||||||
|
|
||||||
const optionsRes = await fetch('/api/sign/options', {
|
const options = await apiJson<any>('/api/sign/options', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': userId
|
|
||||||
},
|
|
||||||
body: JSON.stringify(params)
|
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 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',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': userId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
credentialResponse,
|
credentialResponse,
|
||||||
challenge: options.challenge,
|
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 {
|
return {
|
||||||
kind: 'passkey',
|
kind: 'passkey',
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { apiFetch } from './api.js'
|
||||||
|
|
||||||
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
||||||
|
|
||||||
export class FeedbackApiError extends Error {
|
export class FeedbackApiError extends Error {
|
||||||
@@ -19,15 +21,6 @@ export function isValidFeedbackEmail(email: string): boolean {
|
|||||||
return EMAIL_PATTERN.test(email.trim())
|
return EMAIL_PATTERN.test(email.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFeedbackHeaders(): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
const userId = localStorage.getItem('active_userid')
|
|
||||||
if (userId) headers['X-User-Id'] = userId
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendFeedback(payload: {
|
export async function sendFeedback(payload: {
|
||||||
category: FeedbackCategory
|
category: FeedbackCategory
|
||||||
message: string
|
message: string
|
||||||
@@ -40,9 +33,8 @@ export async function sendFeedback(payload: {
|
|||||||
throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL')
|
throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL')
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/feedback', {
|
const res = await apiFetch('/api/feedback', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: buildFeedbackHeaders(),
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
category: payload.category,
|
category: payload.category,
|
||||||
message: payload.message,
|
message: payload.message,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
|
|||||||
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
|
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
|
||||||
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
|
import { apiFetch } from './api.js'
|
||||||
|
|
||||||
const API_BASE = '/api/logbooks'
|
const API_BASE = '/api/logbooks'
|
||||||
|
|
||||||
@@ -66,13 +67,7 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
|||||||
|
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API_BASE, {
|
const response = await apiFetch(API_BASE, { method: 'GET' })
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': userId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const serverLogbooks = await response.json()
|
const serverLogbooks = await response.json()
|
||||||
@@ -208,12 +203,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
|||||||
|
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API_BASE, {
|
const response = await apiFetch(API_BASE, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': userId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: localId,
|
id: localId,
|
||||||
...payloadData
|
...payloadData
|
||||||
@@ -301,12 +292,7 @@ export async function deleteLogbook(id: string): Promise<void> {
|
|||||||
|
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/${id}`, {
|
const response = await apiFetch(`${API_BASE}/${id}`, { method: 'DELETE' })
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'X-User-Id': userId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn('Server deletion failed or was rejected')
|
console.warn('Server deletion failed or was rejected')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { apiJson } from './api.js'
|
||||||
|
|
||||||
export interface LogbookAccess {
|
export interface LogbookAccess {
|
||||||
isOwner: boolean
|
isOwner: boolean
|
||||||
role: 'OWNER' | 'READ' | 'WRITE'
|
role: 'OWNER' | 'READ' | 'WRITE'
|
||||||
@@ -5,15 +7,10 @@ export interface LogbookAccess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
|
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
|
||||||
const userId = localStorage.getItem('active_userid')
|
if (!localStorage.getItem('active_userid') || !navigator.onLine) return null
|
||||||
if (!userId || !navigator.onLine) return null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/logbooks/${logbookId}/access`, {
|
return await apiJson<LogbookAccess>(`/api/logbooks/${logbookId}/access`)
|
||||||
headers: { 'X-User-Id': userId }
|
|
||||||
})
|
|
||||||
if (!res.ok) return null
|
|
||||||
return res.json()
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
const API_BASE = '/api/push'
|
import { apiFetch, apiJson } from './api.js'
|
||||||
|
|
||||||
function getUserId(): string | null {
|
const API_BASE = '/api/push'
|
||||||
return localStorage.getItem('active_userid')
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||||
@@ -46,38 +44,24 @@ async function fetchVapidPublicKey(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
||||||
const userId = getUserId()
|
if (!localStorage.getItem('active_userid')) {
|
||||||
if (!userId) return { collaboratorChangesEnabled: false }
|
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')
|
|
||||||
}
|
}
|
||||||
return res.json()
|
|
||||||
|
return apiJson<{ collaboratorChangesEnabled: boolean }>(`${API_BASE}/prefs`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promise<void> {
|
export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promise<void> {
|
||||||
const userId = getUserId()
|
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
||||||
if (!userId) throw new Error('Not authenticated')
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/prefs`, {
|
await apiJson(`${API_BASE}/prefs`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': userId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ collaboratorChangesEnabled })
|
body: JSON.stringify({ collaboratorChangesEnabled })
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Failed to save push notification preferences')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||||
const userId = getUserId()
|
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
||||||
if (!userId) throw new Error('Not authenticated')
|
|
||||||
|
|
||||||
const json = subscription.toJSON()
|
const json = subscription.toJSON()
|
||||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
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 locale = document.documentElement.lang?.startsWith('en') ? 'en' : 'de'
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/subscription`, {
|
await apiJson(`${API_BASE}/subscription`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': userId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
endpoint: json.endpoint,
|
endpoint: json.endpoint,
|
||||||
keys: json.keys,
|
keys: json.keys,
|
||||||
@@ -99,9 +79,6 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
|
|||||||
userAgent: navigator.userAgent
|
userAgent: navigator.userAgent
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Failed to register push subscription on server')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subscribeToPush(): Promise<void> {
|
export async function subscribeToPush(): Promise<void> {
|
||||||
@@ -137,7 +114,6 @@ export async function subscribeToPush(): Promise<void> {
|
|||||||
export async function unsubscribeFromPush(): Promise<void> {
|
export async function unsubscribeFromPush(): Promise<void> {
|
||||||
if (!isPushSupported()) return
|
if (!isPushSupported()) return
|
||||||
|
|
||||||
const userId = getUserId()
|
|
||||||
const registration = await navigator.serviceWorker.ready
|
const registration = await navigator.serviceWorker.ready
|
||||||
const subscription = await registration.pushManager.getSubscription()
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
if (!subscription) return
|
if (!subscription) return
|
||||||
@@ -145,13 +121,9 @@ export async function unsubscribeFromPush(): Promise<void> {
|
|||||||
const endpoint = subscription.endpoint
|
const endpoint = subscription.endpoint
|
||||||
await subscription.unsubscribe()
|
await subscription.unsubscribe()
|
||||||
|
|
||||||
if (userId && endpoint) {
|
if (localStorage.getItem('active_userid') && endpoint) {
|
||||||
await fetch(`${API_BASE}/subscription`, {
|
await apiFetch(`${API_BASE}/subscription`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': userId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ endpoint })
|
body: JSON.stringify({ endpoint })
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-14
@@ -1,5 +1,7 @@
|
|||||||
import { db, type SyncQueueItem } from './db.js'
|
import { db, type SyncQueueItem } from './db.js'
|
||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
import { apiFetch } from './api.js'
|
||||||
|
import { getLogbookAccess } from './logbookAccess.js'
|
||||||
|
|
||||||
const API_BASE = '/api/sync'
|
const API_BASE = '/api/sync'
|
||||||
const syncingLogbooks = new Set<string>()
|
const syncingLogbooks = new Set<string>()
|
||||||
@@ -126,19 +128,17 @@ function scheduleResync(logbookId: string) {
|
|||||||
|
|
||||||
// Push local sync queue items to the server
|
// Push local sync queue items to the server
|
||||||
async function pushChanges(logbookId: string): Promise<boolean> {
|
async function pushChanges(logbookId: string): Promise<boolean> {
|
||||||
const userId = localStorage.getItem('active_userid')
|
if (!getActiveMasterKey() || !localStorage.getItem('active_userid')) return false
|
||||||
if (!userId) return false
|
|
||||||
|
const access = await getLogbookAccess(logbookId)
|
||||||
|
if (access && access.role === 'READ') return true
|
||||||
|
|
||||||
const pending = await coalesceSyncQueue(logbookId)
|
const pending = await coalesceSyncQueue(logbookId)
|
||||||
if (pending.length === 0) return true
|
if (pending.length === 0) return true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/push`, {
|
const response = await apiFetch(`${API_BASE}/push`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-User-Id': userId
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ items: pending })
|
body: JSON.stringify({ items: pending })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -187,15 +187,11 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
|
|||||||
|
|
||||||
// Pull updates from the server and apply last-write-wins
|
// Pull updates from the server and apply last-write-wins
|
||||||
async function pullChanges(logbookId: string): Promise<boolean> {
|
async function pullChanges(logbookId: string): Promise<boolean> {
|
||||||
const userId = localStorage.getItem('active_userid')
|
if (!localStorage.getItem('active_userid')) return false
|
||||||
if (!userId) return false
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
|
const response = await apiFetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
|
||||||
method: 'GET',
|
method: 'GET'
|
||||||
headers: {
|
|
||||||
'X-User-Id': userId
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { apiFetch } from './api.js'
|
||||||
|
|
||||||
export class WeatherApiError extends Error {
|
export class WeatherApiError extends Error {
|
||||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||||
|
|
||||||
@@ -8,17 +10,6 @@ export class WeatherApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWeatherHeaders(): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = {}
|
|
||||||
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: {
|
export async function fetchOpenWeatherCurrent(params: {
|
||||||
lat?: string
|
lat?: string
|
||||||
lon?: string
|
lon?: string
|
||||||
@@ -35,9 +26,11 @@ export async function fetchOpenWeatherCurrent(params: {
|
|||||||
throw new WeatherApiError('lat/lon or location query required')
|
throw new WeatherApiError('lat/lon or location query required')
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/weather/current?${searchParams.toString()}`, {
|
const userKey = localStorage.getItem('owm_api_key')?.trim()
|
||||||
headers: buildWeatherHeaders()
|
const headers: Record<string, string> = {}
|
||||||
})
|
if (userKey) headers['X-OWM-Api-Key'] = userKey
|
||||||
|
|
||||||
|
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
|
||||||
|
|
||||||
if (res.status === 503) {
|
if (res.status === 503) {
|
||||||
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
||||||
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||||
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||||
|
|||||||
@@ -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.
|
**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 SW as Service Worker (Owner)
|
||||||
participant Owner as Owner-Gerät
|
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->>DB: Payloads speichern
|
||||||
API->>API: collaborator change? → notify owner
|
API->>API: collaborator change? → notify owner
|
||||||
API->>DB: PushSubscriptions (owner)
|
API->>DB: PushSubscriptions (owner)
|
||||||
@@ -129,6 +129,8 @@ npm install web-push --workspace=server
|
|||||||
`.env` (Beispiel):
|
`.env` (Beispiel):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
ORIGIN=https://kapteins-daagbok.eu
|
||||||
|
SESSION_SECRET=... # min. 32 Zeichen, Pflicht in Produktion
|
||||||
VAPID_PUBLIC_KEY=...
|
VAPID_PUBLIC_KEY=...
|
||||||
VAPID_PRIVATE_KEY=...
|
VAPID_PRIVATE_KEY=...
|
||||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||||
@@ -147,12 +149,12 @@ npx web-push generate-vapid-keys
|
|||||||
| Methode | Pfad | Auth | Beschreibung |
|
| Methode | Pfad | Auth | Beschreibung |
|
||||||
|---------|------|------|--------------|
|
|---------|------|------|--------------|
|
||||||
| `GET` | `/vapid-public-key` | nein | Liefert Public Key für `pushManager.subscribe` |
|
| `GET` | `/vapid-public-key` | nein | Liefert Public Key für `pushManager.subscribe` |
|
||||||
| `PUT` | `/subscription` | `X-User-Id` | Upsert Subscription (endpoint + keys) |
|
| `PUT` | `/subscription` | Session-Cookie | Upsert Subscription (endpoint + keys) |
|
||||||
| `DELETE` | `/subscription` | `X-User-Id` | Body: `{ endpoint }` — Gerät abmelden |
|
| `DELETE` | `/subscription` | Session-Cookie | Body: `{ endpoint }` — Gerät abmelden |
|
||||||
| `GET` | `/prefs` | `X-User-Id` | Liest `collaboratorChangesEnabled` |
|
| `GET` | `/prefs` | Session-Cookie | Liest `collaboratorChangesEnabled` |
|
||||||
| `PUT` | `/prefs` | `X-User-Id` | Body: `{ collaboratorChangesEnabled: boolean }` |
|
| `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
|
### 5.3 Benachrichtigungs-Service
|
||||||
|
|
||||||
@@ -307,7 +309,7 @@ Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route)
|
|||||||
|
|
||||||
| Risiko | Maßnahme |
|
| 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. |
|
| 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. |
|
| Endpoint-Injection | `endpoint` muss HTTPS-URL sein; Länge begrenzen. |
|
||||||
| Spam durch Crew | Rate-Limit + nur `create`/`update` im MVP. |
|
| 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.
|
1. **Nur Owner oder auch andere Collaborators?** — MVP: nur Owner.
|
||||||
2. **Rate-Limit-Dauer:** 2 min vs. 5 min — Empfehlung: **3 min** pro Logbuch.
|
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).
|
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/components/SettingsForm.tsx # Integration
|
||||||
src/i18n/locales/de.json, en.json
|
src/i18n/locales/de.json, en.json
|
||||||
.env.example # VITE_VAPID_PUBLIC_KEY
|
.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/
|
docs/
|
||||||
push-notifications-plan.md # dieses Dokument
|
push-notifications-plan.md # dieses Dokument
|
||||||
README.md # Feature-Zeile + Env-Hinweis
|
README.md # Auth/Session, Env-Hinweise
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ if [ "$IS_READY" = true ]; then
|
|||||||
echo "SUCCESS: Services are up and healthy!"
|
echo "SUCCESS: Services are up and healthy!"
|
||||||
echo " -> App Frontend (Nginx): http://localhost"
|
echo " -> App Frontend (Nginx): http://localhost"
|
||||||
echo " -> Backend API Health: http://localhost/api/health"
|
echo " -> Backend API Health: http://localhost/api/health"
|
||||||
|
echo " -> Auth: session cookie (set ORIGIN=http://localhost, SESSION_SECRET in .env)"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
else
|
else
|
||||||
echo "WARNING: Backend did not transition to healthy in time."
|
echo "WARNING: Backend did not transition to healthy in time."
|
||||||
|
|||||||
@@ -38,6 +38,35 @@ resolve_node_toolchain() {
|
|||||||
command -v npm >/dev/null 2>&1
|
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() {
|
require_node_toolchain() {
|
||||||
if resolve_node_toolchain; then
|
if resolve_node_toolchain; then
|
||||||
echo "Using Node $(node -v), npm $(npm -v)"
|
echo "Using Node $(node -v), npm $(npm -v)"
|
||||||
@@ -62,6 +91,7 @@ echo "========================================"
|
|||||||
echo "Preparing to (re)start services..."
|
echo "Preparing to (re)start services..."
|
||||||
|
|
||||||
require_node_toolchain
|
require_node_toolchain
|
||||||
|
check_dev_env
|
||||||
|
|
||||||
# Clean up processes running on ports
|
# Clean up processes running on ports
|
||||||
cleanup_port() {
|
cleanup_port() {
|
||||||
@@ -170,6 +200,8 @@ echo "========================================"
|
|||||||
echo "Dev services are now running:"
|
echo "Dev services are now running:"
|
||||||
echo " -> Backend: http://localhost:$SERVER_PORT"
|
echo " -> Backend: http://localhost:$SERVER_PORT"
|
||||||
echo " -> Frontend: http://localhost:$CLIENT_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 "========================================"
|
||||||
echo "Press Ctrl+C to terminate both servers."
|
echo "Press Ctrl+C to terminate both servers."
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|||||||
Generated
+72
@@ -10,13 +10,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.10.2",
|
"@prisma/client": "^5.10.2",
|
||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
|
"helmet": "^8.2.0",
|
||||||
"prisma": "^5.10.2",
|
"prisma": "^5.10.2",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.11.24",
|
"@types/node": "^20.11.24",
|
||||||
@@ -657,6 +661,16 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/cors": {
|
||||||
"version": "2.8.19",
|
"version": "2.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
@@ -932,6 +946,25 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
@@ -1175,6 +1208,24 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
@@ -1307,6 +1358,18 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/http_ece": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
@@ -1390,6 +1453,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
|||||||
@@ -12,13 +12,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.10.2",
|
"@prisma/client": "^5.10.2",
|
||||||
"@simplewebauthn/server": "^9.0.3",
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
|
"helmet": "^8.2.0",
|
||||||
"prisma": "^5.10.2",
|
"prisma": "^5.10.2",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.11.24",
|
"@types/node": "^20.11.24",
|
||||||
|
|||||||
+37
-4
@@ -1,5 +1,8 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
|
import cookieParser from 'cookie-parser'
|
||||||
|
import helmet from 'helmet'
|
||||||
|
import rateLimit from 'express-rate-limit'
|
||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
@@ -21,8 +24,39 @@ dotenv.config({ path: resolve(__dirname, '../.env') })
|
|||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.PORT || 5000
|
const PORT = process.env.PORT || 5000
|
||||||
|
|
||||||
app.use(cors())
|
const allowedOrigin = process.env.ORIGIN || 'http://localhost:5173'
|
||||||
app.use(express.json({ limit: '50mb' }))
|
|
||||||
|
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
|
// Mount routes
|
||||||
app.use('/api/auth', authRouter)
|
app.use('/api/auth', authRouter)
|
||||||
@@ -44,11 +78,10 @@ app.get('/api/health', async (req, res) => {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
service: 'Kapteins Daagbok Backend'
|
service: 'Kapteins Daagbok Backend'
|
||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
database: 'disconnected',
|
database: 'disconnected',
|
||||||
error: err.message,
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
service: 'Kapteins Daagbok Backend'
|
service: 'Kapteins Daagbok Backend'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<ReturnType<typeof readSessionFromRequest>>
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
+119
-37
@@ -6,6 +6,14 @@ import {
|
|||||||
verifyAuthenticationResponse
|
verifyAuthenticationResponse
|
||||||
} from '@simplewebauthn/server'
|
} from '@simplewebauthn/server'
|
||||||
import { prisma } from '../db.js'
|
import { prisma } from '../db.js'
|
||||||
|
import { requireReauth, requireUser } from '../middleware/auth.js'
|
||||||
|
import {
|
||||||
|
clearSessionCookie,
|
||||||
|
extendReauth,
|
||||||
|
readSessionFromRequest,
|
||||||
|
setSessionCookie,
|
||||||
|
setSessionTokenCookie
|
||||||
|
} from '../session.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -13,12 +21,9 @@ const rpName = 'Kapteins Daagbok'
|
|||||||
const rpID = process.env.RP_ID || 'localhost'
|
const rpID = process.env.RP_ID || 'localhost'
|
||||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||||
|
|
||||||
// In-memory challenge stores
|
|
||||||
const registrationChallenges = new Map<string, string>()
|
const registrationChallenges = new Map<string, string>()
|
||||||
const authenticationChallenges = new Map<string, { challenge: string; userId: string }>()
|
|
||||||
const activeChallenges = new Set<string>()
|
const activeChallenges = new Set<string>()
|
||||||
|
|
||||||
// 1. Generate Registration Options
|
|
||||||
router.post('/register-options', async (req, res) => {
|
router.post('/register-options', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username } = req.body
|
const { username } = req.body
|
||||||
@@ -34,13 +39,6 @@ router.post('/register-options', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'User already exists' })
|
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 userID = Buffer.from(username, 'utf8').toString('base64url')
|
||||||
|
|
||||||
const options = await generateRegistrationOptions({
|
const options = await generateRegistrationOptions({
|
||||||
@@ -54,10 +52,9 @@ router.post('/register-options', async (req, res) => {
|
|||||||
residentKey: 'required',
|
residentKey: 'required',
|
||||||
userVerification: 'preferred'
|
userVerification: 'preferred'
|
||||||
},
|
},
|
||||||
supportedAlgorithmIDs: [-7, -257] // ES256 and RS256
|
supportedAlgorithmIDs: [-7, -257]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store challenge
|
|
||||||
registrationChallenges.set(username, options.challenge)
|
registrationChallenges.set(username, options.challenge)
|
||||||
|
|
||||||
return res.json(options)
|
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) => {
|
router.post('/register-verify', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@@ -103,7 +99,6 @@ router.post('/register-verify', async (req, res) => {
|
|||||||
|
|
||||||
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
|
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
|
||||||
|
|
||||||
// Save user and credential
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username,
|
username,
|
||||||
@@ -125,6 +120,7 @@ router.post('/register-verify', async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
registrationChallenges.delete(username)
|
registrationChallenges.delete(username)
|
||||||
|
setSessionCookie(res, user.id, true)
|
||||||
|
|
||||||
return res.json({ verified: true, userId: user.id })
|
return res.json({ verified: true, userId: user.id })
|
||||||
} catch (error: any) {
|
} 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) => {
|
router.post('/login-options', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username } = req.body
|
const { username } = req.body
|
||||||
|
|
||||||
// If username is supplied, we do a targeted login, otherwise usernameless
|
|
||||||
let allowCredentials: any[] = []
|
let allowCredentials: any[] = []
|
||||||
if (username) {
|
if (username) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -146,7 +140,7 @@ router.post('/login-options', async (req, res) => {
|
|||||||
include: { credentials: true }
|
include: { credentials: true }
|
||||||
})
|
})
|
||||||
if (user) {
|
if (user) {
|
||||||
allowCredentials = user.credentials.map(cred => ({
|
allowCredentials = user.credentials.map((cred) => ({
|
||||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||||
type: 'public-key',
|
type: 'public-key',
|
||||||
transports: cred.transports as any[]
|
transports: cred.transports as any[]
|
||||||
@@ -160,7 +154,6 @@ router.post('/login-options', async (req, res) => {
|
|||||||
userVerification: 'preferred'
|
userVerification: 'preferred'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store challenge
|
|
||||||
activeChallenges.add(options.challenge)
|
activeChallenges.add(options.challenge)
|
||||||
|
|
||||||
return res.json(options)
|
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) => {
|
router.post('/login-verify', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { credentialResponse, challenge } = req.body
|
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' })
|
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify challenge
|
|
||||||
if (!activeChallenges.has(challenge)) {
|
if (!activeChallenges.has(challenge)) {
|
||||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||||
}
|
}
|
||||||
activeChallenges.delete(challenge)
|
activeChallenges.delete(challenge)
|
||||||
|
|
||||||
// Find the credential in DB
|
|
||||||
const dbCred = await prisma.credential.findUnique({
|
const dbCred = await prisma.credential.findUnique({
|
||||||
where: { credentialId: credentialResponse.id },
|
where: { credentialId: credentialResponse.id },
|
||||||
include: { user: true }
|
include: { user: true }
|
||||||
@@ -212,12 +202,13 @@ router.post('/login-verify', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Authentication failed' })
|
return res.status(400).json({ error: 'Authentication failed' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update counter
|
|
||||||
await prisma.credential.update({
|
await prisma.credential.update({
|
||||||
where: { id: dbCred.id },
|
where: { id: dbCred.id },
|
||||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setSessionCookie(res, user.id, true)
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
verified: true,
|
verified: true,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -235,16 +226,112 @@ router.post('/login-verify', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 5. Delete own account
|
router.get('/session', (req, res) => {
|
||||||
router.delete('/delete-account', async (req: any, 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 {
|
try {
|
||||||
const userId = req.headers['x-user-id']
|
const user = await prisma.user.findUnique({
|
||||||
if (!userId) {
|
where: { id: req.userId },
|
||||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
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({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId }
|
where: { id: req.userId }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -252,9 +339,10 @@ router.delete('/delete-account', async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.user.delete({
|
await prisma.user.delete({
|
||||||
where: { id: userId }
|
where: { id: req.userId }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
clearSessionCookie(res)
|
||||||
return res.json({ success: true })
|
return res.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error deleting account:', error)
|
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', requireReauth, async (req: any, res) => {
|
||||||
router.post('/enroll-prf', async (req: any, res) => {
|
|
||||||
try {
|
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
|
const { encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag } = req.body
|
||||||
if (!encryptedMasterKeyPrf || !encryptedMasterKeyPrfIv || !encryptedMasterKeyPrfTag) {
|
if (!encryptedMasterKeyPrf || !encryptedMasterKeyPrfIv || !encryptedMasterKeyPrfTag) {
|
||||||
return res.status(400).json({ error: 'Missing required PRF key fields' })
|
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({
|
await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: req.userId },
|
||||||
data: {
|
data: {
|
||||||
encryptedMasterKeyPrf,
|
encryptedMasterKeyPrf,
|
||||||
encryptedMasterKeyPrfIv,
|
encryptedMasterKeyPrfIv,
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { prisma } from '../db.js'
|
import { prisma } from '../db.js'
|
||||||
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
|
||||||
const router = Router()
|
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)
|
// 1. Get invitation details (public route, does not require authentication)
|
||||||
router.get('/invite-details', async (req: any, res) => {
|
router.get('/invite-details', async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
|
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
|
||||||
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -20,15 +21,6 @@ function parseOptionalEmail(value: unknown): string | undefined {
|
|||||||
return trimmed
|
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) => {
|
router.get('/status', requireUser, (_req, res) => {
|
||||||
res.json({ enabled: isNtfyConfigured() })
|
res.json({ enabled: isNtfyConfigured() })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { prisma } from '../db.js'
|
import { prisma } from '../db.js'
|
||||||
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
|
||||||
const router = Router()
|
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)
|
router.use(requireUser)
|
||||||
|
|
||||||
// 1. Get all logbooks for the authenticated user (owned and shared)
|
// 1. Get all logbooks for the authenticated user (owned and shared)
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { prisma } from '../db.js'
|
import { prisma } from '../db.js'
|
||||||
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
|
||||||
const router = Router()
|
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 {
|
function isValidHttpsEndpoint(endpoint: unknown): endpoint is string {
|
||||||
if (typeof endpoint !== 'string' || endpoint.length > 2048) return false
|
if (typeof endpoint !== 'string' || endpoint.length > 2048) return false
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
verifyAuthenticationResponse
|
verifyAuthenticationResponse
|
||||||
} from '@simplewebauthn/server'
|
} from '@simplewebauthn/server'
|
||||||
import { prisma } from '../db.js'
|
import { prisma } from '../db.js'
|
||||||
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
|
||||||
const router = Router()
|
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)
|
router.use(requireUser)
|
||||||
|
|
||||||
async function getLogbookWithAccess(logbookId: string, userId: string) {
|
async function getLogbookWithAccess(logbookId: string, userId: string) {
|
||||||
|
|||||||
+11
-15
@@ -1,19 +1,10 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { prisma } from '../db.js'
|
import { prisma } from '../db.js'
|
||||||
import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js'
|
import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js'
|
||||||
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
|
||||||
const router = Router()
|
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)
|
router.use(requireUser)
|
||||||
|
|
||||||
// 1. Push local changes to the server
|
// 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 isOwner = logbook.userId === req.userId
|
||||||
const isCollaborator = await prisma.collaboration.findUnique({
|
const collaboration = await prisma.collaboration.findUnique({
|
||||||
where: {
|
where: {
|
||||||
logbookId_userId: {
|
logbookId_userId: {
|
||||||
logbookId,
|
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' })
|
results.push({ payloadId, status: 'error', error: 'Forbidden: Access denied' })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isOwner && (!collaboration || collaboration.role !== 'WRITE')) {
|
||||||
|
results.push({ payloadId, status: 'error', error: 'Forbidden: WRITE access required' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'logbook' && action === 'delete') {
|
if (type === 'logbook' && action === 'delete') {
|
||||||
if (!isOwner) {
|
if (!isOwner) {
|
||||||
results.push({ payloadId, status: 'error', error: 'Forbidden: Only owner can delete logbook' })
|
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,
|
logbook.userId,
|
||||||
logbookId,
|
logbookId,
|
||||||
isOwner,
|
isOwner,
|
||||||
isCollaborator,
|
collaboration,
|
||||||
action,
|
action,
|
||||||
type
|
type
|
||||||
)
|
)
|
||||||
@@ -284,7 +280,7 @@ router.get('/pull', async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = logbook.userId === req.userId
|
const isOwner = logbook.userId === req.userId
|
||||||
const isCollaborator = await prisma.collaboration.findUnique({
|
const collaboration = await prisma.collaboration.findUnique({
|
||||||
where: {
|
where: {
|
||||||
logbookId_userId: {
|
logbookId_userId: {
|
||||||
logbookId,
|
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' })
|
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
|
||||||
const router = Router()
|
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 {
|
function resolveOwmApiKey(userProvidedKey: unknown): string | null {
|
||||||
if (typeof userProvidedKey === 'string' && userProvidedKey.trim()) {
|
if (typeof userProvidedKey === 'string' && userProvidedKey.trim()) {
|
||||||
return userProvidedKey.trim()
|
return userProvidedKey.trim()
|
||||||
@@ -21,7 +13,7 @@ function resolveOwmApiKey(userProvidedKey: unknown): string | null {
|
|||||||
return fromEnv || null
|
return fromEnv || null
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/current', requireUser, async (req: any, res) => {
|
router.get('/current', requireUser, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { lat, lon, q } = req.query
|
const { lat, lon, q } = req.query
|
||||||
const apiKey = resolveOwmApiKey(req.headers['x-owm-api-key'])
|
const apiKey = resolveOwmApiKey(req.headers['x-owm-api-key'])
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user