security: implement audit fixes and backend optimizations
- L-03: PasswordReset.js minLength 6 -> 12 for all 3 apps - B-01: PLZ geocoding proxy endpoint (GET /api/public/geocode) in all 3 backends; frontend PublicUserList now uses backend instead of direct Nominatim calls - B-02: type filter server-side via onRefetch useEffect; removed redundant local available/type filters from PublicUserList useMemo - audit fixes: app-specific JWT secrets, bcrypt cost 12, LRU geocode cache, auth middleware app-claim check, nginx CSP script-src cleanup, nginx.conf renamed to nginx.conf.dev, geocode-cache Docker volume - add mailer.js utility (password reset emails)
This commit is contained in:
parent
8384ad9432
commit
d9fecb6914
|
|
@ -0,0 +1,771 @@
|
|||
# Sicherheits- und Architektur-Audit – Jagd-Ökosystem
|
||||
**Datum:** 3. Mai 2026
|
||||
**Scope:** Vollständiges Ökosystem (Portal + 3 Applikationen)
|
||||
**Modus:** Read-Only – keine Code-Änderungen vorgenommen
|
||||
**Prüfer:** GitHub Copilot (Claude Sonnet 4.6)
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
|
||||
1. [Executive Summary](#1-executive-summary)
|
||||
2. [Architektur-Übersicht](#2-architektur-übersicht)
|
||||
3. [Befund-Register](#3-befund-register)
|
||||
- [KRITISCH](#31-kritische-befunde)
|
||||
- [HOCH](#32-hohe-befunde)
|
||||
- [MITTEL](#33-mittlere-befunde)
|
||||
- [NIEDRIG](#34-niedrige-befunde)
|
||||
4. [System-spezifische Befunde](#4-system-spezifische-befunde)
|
||||
5. [Positive Befunde](#5-positive-befunde)
|
||||
6. [Priorisierte Handlungsempfehlungen](#6-priorisierte-handlungsempfehlungen)
|
||||
7. [Technischer Anhang](#7-technischer-anhang)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Das geprüfte Ökosystem besteht aus einem zentralen Nginx-Portal und drei voneinander getrennten Webanwendungen (Nachsuche, Drohnenfuehrer, Stoeberhunde) für die Koordination von Jagdaktivitäten. Die Anwendungen teilen eine identische technische Basis (Node.js/Express, MongoDB, React/Vite).
|
||||
|
||||
**Gesamtbewertung: HOCH-RISIKOREICH**
|
||||
|
||||
Es wurden **2 kritische**, **7 hohe**, **8 mittlere** und **7 niedrige** Befunde identifiziert. Zwei der kritischen Befunde ermöglichen entweder eine unbeabsichtigte Rechteausweitung über App-Grenzen hinweg oder machen einen produktiven Endpunkt vollständig funktionsunfähig.
|
||||
|
||||
| Priorität | Anzahl | Sofortmaßnahme erforderlich |
|
||||
|---|---|---|
|
||||
| KRITISCH | 2 | Ja |
|
||||
| HOCH | 7 | Ja |
|
||||
| MITTEL | 8 | Nein (innerhalb 30 Tage) |
|
||||
| NIEDRIG | 7 | Nein (innerhalb 90 Tage) |
|
||||
| Positiv | 12 | — |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architektur-Übersicht
|
||||
|
||||
### 2.1 Container-Topologie
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
[Portal nginx :8090] (jagd-portal Container)
|
||||
│
|
||||
├── /nachsuche/api/ → nachsuche-backend:5000
|
||||
├── /nachsuche/ → nachsuche-frontend:80
|
||||
├── /drohnenfuehrer/api/ → drohnenfuehrer-backend:5000
|
||||
├── /drohnenfuehrer/ → drohnenfuehrer-frontend:80
|
||||
├── /stoeberhunde/api/ → stoeberhunde-backend:5000
|
||||
└── /stoeberhunde/ → stoeberhunde-frontend:80
|
||||
│
|
||||
└── alle Container in: jagd-network (externes Docker-Netzwerk)
|
||||
```
|
||||
|
||||
### 2.2 Port-Belegung
|
||||
|
||||
| Container | Interner Port | Host-Binding |
|
||||
|---|---|---|
|
||||
| nachsuche-backend | 5000 | `127.0.0.1:5010` |
|
||||
| nachsuche-frontend | 80 | `127.0.0.1:8080` |
|
||||
| drohnenfuehrer-backend | 5000 | `127.0.0.1:5011` |
|
||||
| drohnenfuehrer-frontend | 80 | `127.0.0.1:8081` |
|
||||
| stoeberhunde-backend | 5000 | `127.0.0.1:5012` |
|
||||
| stoeberhunde-frontend | 80 | `127.0.0.1:8082` |
|
||||
|
||||
Backend-Ports sind korrekt auf `127.0.0.1` gebunden (nicht extern erreichbar). MongoDB-Instanzen sind nicht nach außen exponiert.
|
||||
|
||||
### 2.3 Rollen-Modell
|
||||
|
||||
```
|
||||
Admin (Cookie, httpOnly JWT)
|
||||
└── Zugriff auf: User-CRUD, Config, Audit-Log, Invite-Token-Generierung
|
||||
|
||||
Handler / Drohnenführer / Stöberhundeführer (Bearer JWT, localStorage)
|
||||
└── Zugriff auf: eigenes Profil (/me), Verfügbarkeit setzen
|
||||
|
||||
Öffentlich (kein Token)
|
||||
└── GET /api/public/users → name, type, available, gps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Befund-Register
|
||||
|
||||
### 3.1 Kritische Befunde
|
||||
|
||||
---
|
||||
|
||||
#### C-01 — Implizite Cross-App-Admin-Authentifizierung
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Dateien:** `{app}/backend/config/env.js`, `{app}/backend/middleware/auth.js`, `{app}/backend/controllers/authController.js`
|
||||
|
||||
**Beschreibung:**
|
||||
Alle drei Apps lesen ihren JWT-Signing-Key aus der identisch benannten Umgebungsvariable `JWT_SECRET`. In der typischen Deployment-Konfiguration wird diese Variable für alle Apps auf denselben Wert gesetzt (oder aus derselben `.env`-Datei bezogen). Das Admin-Cookie wird gesetzt mit:
|
||||
|
||||
```js
|
||||
// authController.js (alle Apps)
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: ...,
|
||||
sameSite: 'lax',
|
||||
path: '/' // ← gilt für ALLE Pfade auf der Domain
|
||||
});
|
||||
```
|
||||
|
||||
Da das Portal eine einzige Domain (z. B. `jagd.example.org`) bedient und alle drei Apps unter Subpfaden laufen (`/nachsuche/`, `/drohnenfuehrer/`, `/stoeberhunde/`), sendet der Browser das Admin-Cookie **automatisch an alle drei App-Backends**. Jeder Backend-`authenticateToken`-Middleware nimmt ein gültiges Cookie an, das von **irgendeiner** der drei Apps ausgestellt wurde, da alle denselben Secret kennen.
|
||||
|
||||
**Auswirkung:** Ein Admin-Login bei Nachsuche gewährt **automatisch** Admin-Zugriff auf Drohnenfuehrer und Stoeberhunde – ohne weiteren Login. Umgekehrt gilt dasselbe.
|
||||
|
||||
**Empfehlung:**
|
||||
1. Jede App erhält einen **eigenen, einzigartigen** `JWT_SECRET` (z. B. `NACHSUCHE_JWT_SECRET`, `DROHNENFUEHRER_JWT_SECRET`, etc.)
|
||||
2. Das JWT-Payload erhält einen App-spezifischen Claim: `{ id, username, app: 'nachsuche' }`
|
||||
3. `authenticateToken` prüft: `if (decoded.app !== 'nachsuche') return res.status(403)`
|
||||
|
||||
---
|
||||
|
||||
#### C-02 — `ReferenceError` in `importUsers()`: Variable `overwrite` nie deklariert
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/controllers/userController.js`, Zeile ~494
|
||||
|
||||
**Beschreibung:**
|
||||
Die Funktion `importUsers` iteriert über eine Liste zu importierender Benutzer und soll bei `?overwrite=true` bestehende Einträge überschreiben. Die Variable `overwrite` wird jedoch **nirgends deklariert**:
|
||||
|
||||
```js
|
||||
const importUsers = async (req, res) => {
|
||||
// ...kein: const overwrite = req.query.overwrite === 'true';
|
||||
for (const entry of importList) {
|
||||
const existing = await User.findOne(...);
|
||||
if (existing && !overwrite) { // ReferenceError: overwrite is not defined
|
||||
```
|
||||
|
||||
Der Fehler tritt auf, sobald der erste Benutzer in der Import-Liste bereits in der Datenbank existiert. Der innere `try`/`catch` fängt den Fehler ab, loggt ihn als Einzel-Error, und der gesamte Import-Vorgang schlägt für alle nachfolgenden Einträge still fehl.
|
||||
|
||||
**Auswirkung:** `POST /api/users/import` mit einer bereits vorhandenen Person in der Liste crasht intern. Der Import-Endpunkt ist in der Praxis **nicht nutzbar** mit vorhandenen Daten.
|
||||
|
||||
**Fix:**
|
||||
```js
|
||||
const importUsers = async (req, res) => {
|
||||
const { users: importList } = req.body;
|
||||
const overwrite = req.query.overwrite === 'true'; // ← diese Zeile fehlt
|
||||
// ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Hohe Befunde
|
||||
|
||||
---
|
||||
|
||||
#### H-01 — `passwordHash` in Admin-API-Antworten exponiert (getAllUsers)
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/controllers/userController.js`, `getAllUsers`, Zeile ~32
|
||||
|
||||
**Beschreibung:**
|
||||
```js
|
||||
User.find(filter)
|
||||
.sort(...)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.select('-__v') // ← schließt NUR __v aus, nicht passwordHash
|
||||
```
|
||||
|
||||
Das `User`-Mongoose-Schema definiert `passwordHash` ohne `select: false`. Damit ist der bcrypt-Hash in **jeder** Admin-Benutzerlistanfrage enthalten.
|
||||
|
||||
**Auswirkung:** Ein kompromittierter Admin-Account oder eine Session-Hijacking-Lücke gibt Angreifern alle bcrypt-Hashes der Nutzer, die für Offline-Brute-Force-Angriffe genutzt werden können.
|
||||
|
||||
**Fix:** `.select('-__v -passwordHash')` in `getAllUsers` und `getUserById`.
|
||||
|
||||
---
|
||||
|
||||
#### H-02 — `passwordHash` in `getUserById()` exponiert (kein Select)
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/controllers/userController.js`, `getUserById`, Zeile ~57
|
||||
|
||||
**Beschreibung:**
|
||||
```js
|
||||
const user = await User.findById(req.params.id); // ← keinerlei .select()
|
||||
```
|
||||
|
||||
Das vollständige Mongoose-Dokument wird zurückgegeben, inklusive `passwordHash`. Alternativ sollte das Schema-Feld mit `select: false` versehen werden.
|
||||
|
||||
---
|
||||
|
||||
#### H-03 — Mass Assignment in `createUser()` und `updateUser()`
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/controllers/userController.js`
|
||||
|
||||
**Beschreibung:**
|
||||
```js
|
||||
// createUser
|
||||
const payload = { ...req.body }; // ← gesamter Request-Body
|
||||
const user = new User(payload);
|
||||
|
||||
// updateUser
|
||||
const updateData = { ...req.body }; // ← gesamter Request-Body
|
||||
await User.findByIdAndUpdate(req.params.id, updateData, ...);
|
||||
```
|
||||
|
||||
Ein Angreifer mit Admin-Zugriff (oder bei einer zukünftigen BOLA-Lücke) kann beliebige Schema-Felder setzen, darunter:
|
||||
- `deleted: false` – Soft-Delete umgehen
|
||||
- `passwordHash: "..."` – direkt einen bekannten Hash injizieren
|
||||
- `inviteToken` / `inviteTokenExpiry` – Einladungstoken manipulieren
|
||||
- `deletedBy`, `deletedAt` – Audit-Trail fälschen
|
||||
|
||||
**Fix:** Whitelist der erlaubten Felder explizit definieren, analog zu `bulkUpdateUsers`:
|
||||
```js
|
||||
const ALLOWED_CREATE_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(req.body).filter(([k]) => ALLOWED_CREATE_FIELDS.includes(k))
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### H-04 — Set-Password-Flow komplett defekt: `inviteToken` fehlt im Frontend
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Dateien:** `nachsuche/frontend/src/services/handler.js`, `drohnenfuehrer/frontend/src/services/drohnenfuehrer.js`, `stoeberhunde/frontend/src/services/stoeberhundefuehrer.js`
|
||||
|
||||
**Beschreibung:**
|
||||
Das Backend erwartet bei `POST /api/{rolle}/set-password` drei Parameter:
|
||||
```
|
||||
{ email, inviteToken, newPassword }
|
||||
```
|
||||
|
||||
Das Frontend sendet:
|
||||
```js
|
||||
drohnenfuehrerApi.post('/set-password', { email, newPassword }); // inviteToken fehlt
|
||||
```
|
||||
|
||||
Das Backend antwortet immer mit HTTP 400: *"E-Mail, Einladungs-Token und Passwort (min. 8 Zeichen) erforderlich"*.
|
||||
|
||||
**Auswirkung:** Neu angelegte Handler, Drohnenführer und Stöberhundeführer **können sich niemals anmelden**. Der Admin kann zwar einen Invite-Token generieren, aber Nutzer können ihn nicht im Frontend eingeben. Das Onboarding neuer Nutzer ist vollständig blockiert.
|
||||
|
||||
**Fix:** Im Passwort-Setzen-Formular ein Eingabefeld für den Invite-Token hinzufügen und diesen im API-Aufruf mitsenden.
|
||||
|
||||
---
|
||||
|
||||
#### H-05 — `stoeberhunde`: Admin-Cookie `secure: false` hardcoded
|
||||
|
||||
**Betroffen:** Stoeberhunde
|
||||
**Datei:** `stoeberhunde/backend/controllers/authController.js`, Zeile ~75
|
||||
|
||||
**Beschreibung:**
|
||||
```js
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: false, // ← Hardcoded, nie HTTPS-Only
|
||||
sameSite: 'lax',
|
||||
path: '/'
|
||||
});
|
||||
```
|
||||
|
||||
Im Vergleich dazu setzen Nachsuche und Drohnenfuehrer:
|
||||
```js
|
||||
const secureCookie = process.env.NODE_ENV === 'production';
|
||||
secure: secureCookie // ← korrekt: HTTPS in Production erzwungen
|
||||
```
|
||||
|
||||
**Auswirkung:** Das Admin-Auth-Cookie wird auch über unverschlüsselte HTTP-Verbindungen übertragen, selbst in der Produktionsumgebung.
|
||||
|
||||
---
|
||||
|
||||
#### H-06 — `jagd-network`: Lateral Movement zwischen allen App-Backends möglich
|
||||
|
||||
**Betroffen:** Gesamtes Ökosystem
|
||||
**Datei:** `{app}/docker-compose.yml`
|
||||
|
||||
**Beschreibung:**
|
||||
Alle sechs Container (3 Backends + 3 Frontends) sind im selben externen Docker-Netzwerk `jagd-network`. Es gibt keine internen Netzwerk-ACLs. Ein kompromittierter Container kann direkt auf alle anderen Backends zugreifen:
|
||||
|
||||
```
|
||||
nachsuche-backend → drohnenfuehrer-backend:5000 (direkt erreichbar)
|
||||
nachsuche-backend → stoeberhunde-backend:5000 (direkt erreichbar)
|
||||
```
|
||||
|
||||
**Empfehlung:** Separate interne Netzwerke pro App:
|
||||
- `nachsuche-internal`: nachsuche-backend ↔ nachsuche-mongodb
|
||||
- `jagd-network` (shared): nur Portal-nginx + App-Frontends + App-Backends
|
||||
|
||||
---
|
||||
|
||||
#### H-07 — User-Rollen-JWTs in `localStorage` (XSS-zugänglich)
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Dateien:** `nachsuche/frontend/src/components/handler/HandlerLogin.js`, `drohnenfuehrer/frontend/src/services/drohnenfuehrer.js`, `stoeberhunde/frontend/src/services/stoeberhundefuehrer.js`
|
||||
|
||||
**Beschreibung:**
|
||||
Nach erfolgreichem Login werden die Nutzer-JWTs (Handler, Drohnenführer, Stöberhundeführer) im `localStorage` gespeichert:
|
||||
|
||||
```js
|
||||
// HandlerLogin.js
|
||||
localStorage.setItem('handlerToken', result.token);
|
||||
|
||||
// drohnenfuehrer.js (interceptor)
|
||||
const token = localStorage.getItem('drohnenfuehrerToken');
|
||||
|
||||
// stoeberhundefuehrer.js (interceptor)
|
||||
const token = localStorage.getItem('stoeberhundefuehrerToken');
|
||||
```
|
||||
|
||||
`localStorage` ist über JavaScript vollständig lesbar und damit bei einer XSS-Lücke (z. B. durch eine Dependency) direkt für Token-Diebstahl angreifbar.
|
||||
|
||||
**Auswirkung:** Jede XSS-Schwachstelle führt zur vollständigen Kompromittierung der Nutzersession.
|
||||
|
||||
**Empfehlung:** Nutzer-Tokens in `sessionStorage` (kurzlebiger, kein persistierter XSS-Zugriff nach Tab-Schließen) oder – besser – ebenfalls als `httpOnly`-Cookie umstellen.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Mittlere Befunde
|
||||
|
||||
---
|
||||
|
||||
#### M-01 — Passwort-Reset: kein E-Mail-Versand, nur Logging
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/controllers/authController.js`
|
||||
|
||||
**Beschreibung:**
|
||||
Die Passwort-Reset-Funktion generiert einen sicheren Token und speichert ihn in der DB. Der Reset-Link wird jedoch **nur geloggt**, nicht per E-Mail versendet:
|
||||
|
||||
```js
|
||||
logger.info(`Password reset token for ${admin.username}: ${resetUrl}`);
|
||||
// Kein Mail-Transport implementiert
|
||||
```
|
||||
|
||||
**Auswirkung:** In der Produktionsumgebung kann ein Admin sein Passwort **nicht zurücksetzen**. Ein vergessenes Admin-Passwort erfordert direkten DB-Eingriff.
|
||||
|
||||
---
|
||||
|
||||
#### M-02 — NoSQL-Injection: `req.query.type` ohne Whitelist-Validierung
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/controllers/userController.js`, `getAllUsers` und `getPublicUsers`
|
||||
|
||||
**Beschreibung:**
|
||||
```js
|
||||
if (req.query.type) {
|
||||
filter.type = req.query.type; // ← direkt aus dem Query-Parameter
|
||||
}
|
||||
```
|
||||
|
||||
Ein Angreifer kann `?type[$ne]=foo` oder `?type[$regex]=.*` senden. Mongoose schützt durch Schema-Typing (String-Cast) teilweise dagegen, aber ein Typ-Angriff wie `?type[$in][0]=handler&type[$in][1]=drohnenfuehrer` ist valides Mongoose und gibt alle Nutzer beider Typen zurück.
|
||||
|
||||
**Empfehlung:** Erlaubte `userTypes` aus der App-Konfiguration laden und als Whitelist prüfen:
|
||||
```js
|
||||
const ALLOWED_TYPES = config.userTypes || [];
|
||||
if (req.query.type && ALLOWED_TYPES.includes(req.query.type)) {
|
||||
filter.type = req.query.type;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### M-03 — Base64-Foto überschreitet MongoDB-BSON-Limit
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/controllers/userController.js`, `uploadUserPhoto`
|
||||
|
||||
**Beschreibung:**
|
||||
Fotos werden als Base64-Data-URL direkt im User-Dokument in MongoDB gespeichert. Das BSON-Dokumentlimit beträgt 16 MB. Das Nginx-Limit ist `client_max_body_size 20M`. Ein 15 MB Foto (nach Base64-Encoding ca. 20 MB) passiert Nginx, schlägt aber beim MongoDB-Speichern mit `MongoServerError: Document exceeds maximum size 16777216` fehl – ohne sauberes User-Feedback.
|
||||
|
||||
Zudem werden bei `GET /api/users/export` alle Base64-Fotos aller Nutzer inline mitgeliefert, was bei vielen Nutzern zu hunderte-MB-Antworten führen kann.
|
||||
|
||||
---
|
||||
|
||||
#### M-04 — CSP `'unsafe-inline'` für `script-src` und `style-src`
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/nginx/nginx-frontend.conf`
|
||||
|
||||
**Beschreibung:**
|
||||
```nginx
|
||||
add_header Content-Security-Policy "... script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; ...";
|
||||
```
|
||||
|
||||
`'unsafe-inline'` in `script-src` deaktiviert den wesentlichen XSS-Schutz der CSP. Selbst wenn kein `dangerouslySetInnerHTML` im eigenen Code vorkommt, bleibt eine Dependency-Lücke (z. B. indirektes `eval()`) ohne CSP-Schutz.
|
||||
|
||||
**Empfehlung:** Nonces oder Hashes statt `'unsafe-inline'` verwenden, nachdem Inline-Styles auf CSS-Klassen umgestellt wurden.
|
||||
|
||||
---
|
||||
|
||||
#### M-05 — Geocode-Cache: unbegrenztes In-Memory-Wachstum
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/utils/geocode.js`
|
||||
|
||||
**Beschreibung:**
|
||||
```js
|
||||
const cache = new Map(); // wächst unbegrenzt
|
||||
```
|
||||
|
||||
Bei jedem Erstell- und Update-Vorgang wird die Adresse geokodiert und gecacht. Bei einem 500-Nutzer-Import (das dokumentierte Maximum) wachsen alle drei Caches gleichzeitig. Es gibt keine LRU-Eviction oder maximale Cache-Größe.
|
||||
|
||||
Zusätzlich läuft der `setInterval`-Schreibvorgang (alle 60 s) als unkündbarer Timer und verhindert sauberes Graceful-Shutdown.
|
||||
|
||||
---
|
||||
|
||||
#### M-06 — Cookie `sameSite: 'lax'` statt `'strict'` für Admin-Session
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/controllers/authController.js`
|
||||
|
||||
**Beschreibung:**
|
||||
`sameSite: 'lax'` erlaubt das Mitsenden des Cookies bei Top-Level-Navigation (GET-Anfragen von externen Links). Da alle drei App-Backends dasselbe Cookie annehmen (C-01), erhöht `'lax'` die CSRF-Angriffsfläche gegenüber `'strict'`.
|
||||
|
||||
---
|
||||
|
||||
#### M-07 — `nachsuche/nginx/nginx.conf`: Dev-Server-Referenzen im Repository
|
||||
|
||||
**Betroffen:** Nachsuche
|
||||
**Datei:** `nachsuche/nginx/nginx.conf`
|
||||
|
||||
**Beschreibung:**
|
||||
Die Datei `nginx/nginx.conf` (nicht die korrekte `extra8002.conf`) enthält Referenzen auf `host.docker.internal:5000`, `host.docker.internal:8080` sowie explizite Dev-Ports (5001, 5002, 8081, 8082). Falls diese Datei versehentlich als aktive Konfiguration deployt wird (statt `extra8002.conf`), werden Backend-Requests direkt an den Host-Daemon weitergeleitet und umgehen die Container-Isolation.
|
||||
|
||||
---
|
||||
|
||||
#### M-08 — User-Rollen-Login ohne striktes Rate-Limiting
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/routes/handlerRoutes.js`, `drohnenfuehrerRoutes.js`, `stoeberhundefuehrerRoutes.js`
|
||||
|
||||
**Beschreibung:**
|
||||
```js
|
||||
router.post('/login', handlerLogin); // ← kein authLimiter, nur globaler apiLimiter (100/15min)
|
||||
```
|
||||
|
||||
Der Admin-Login ist mit `authLimiter` (5 Versuche/15 min) geschützt. User-Logins sind nur durch den generellen `apiLimiter` (100 Anfragen/15 min) abgedeckt. Ein Angreifer kann bis zu 100 Passwort-Versuche pro 15 Minuten gegen jeden Nutzer-Account durchführen.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Niedrige Befunde
|
||||
|
||||
---
|
||||
|
||||
#### L-01 — bcrypt Work-Factor-Inkonsistenz: Admin=10, User=12
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Dateien:** `{app}/backend/models/Admin.js` (pre-save hook: `genSalt(10)`) vs. `{app}/backend/controllers/{rolle}Controller.js` (`bcrypt.hash(pwd, 12)`)
|
||||
|
||||
Admin-Passwörter werden mit einem niedrigeren Work-Factor gehasht als Nutzer-Passwörter. Beide sollten auf mindestens 12 vereinheitlicht werden.
|
||||
|
||||
---
|
||||
|
||||
#### L-02 — Admin-Mindestpasswortlänge: 6 Zeichen (zu kurz)
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/models/Admin.js`
|
||||
|
||||
```js
|
||||
passwordHash: { type: String, required: true, minlength: 6 }
|
||||
```
|
||||
|
||||
Für Admin-Konten ist eine Mindestlänge von 12 Zeichen empfohlen (NIST SP 800-63B).
|
||||
|
||||
---
|
||||
|
||||
#### L-03 — Frontend-Passwortvalidierung: 6 Zeichen, Backend erfordert 8
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Dateien:** `{app}/frontend/src/components/{rolle}/{rolle}Login.js`
|
||||
|
||||
Das Passwort-Setzen-Formular prüft `newPassword.length < 6`, das Backend fordert jedoch `min. 8 Zeichen`. Formular und Backend sind inkonsistent (sobald H-04 behoben ist, führt dies zu verwirrenden Fehlermeldungen).
|
||||
|
||||
---
|
||||
|
||||
#### L-04 — Logger `service`-Name falsch in allen drei Apps
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/utils/logger.js`
|
||||
|
||||
```js
|
||||
defaultMeta: { service: 'tracking-leaders-api' } // identisch in allen 3 Apps
|
||||
```
|
||||
|
||||
Bei zentralem Log-Aggregation (ELK, Loki, Datadog) sind die Logs der drei Apps nicht unterscheidbar.
|
||||
|
||||
**Fix:** `'nachsuche-api'`, `'drohnenfuehrer-api'`, `'stoeberhunde-api'`.
|
||||
|
||||
---
|
||||
|
||||
#### L-05 — `child_process.exec('node seed.js')` beim Startup
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/server.js`
|
||||
|
||||
```js
|
||||
exec('node seed.js', { cwd: __dirname }, callback)
|
||||
```
|
||||
|
||||
`exec()` verwendet eine Shell und ist PATH-abhängig. Der Exit-Code des Seed-Prozesses wird nicht geprüft. Bei Seed-Fehlern startet der Server ohne initiale Daten, ohne dass eine Warnung ausgegeben wird.
|
||||
|
||||
**Empfehlung:** `require('./seed')()` als Modul aufrufen – kein Sub-Prozess, korrekte Fehlerweiterleitung.
|
||||
|
||||
---
|
||||
|
||||
#### L-06 — Geocode-Cache-Datei im Container (kein Volume-Mount)
|
||||
|
||||
**Betroffen:** Alle drei Apps
|
||||
**Datei:** `{app}/backend/utils/geocode.js`
|
||||
|
||||
```js
|
||||
const CACHE_FILE = path.join(process.cwd(), 'geocode-cache.json');
|
||||
```
|
||||
|
||||
Die Geocode-Cache-Datei wird **im Container-Dateisystem** geschrieben. Bei `docker compose down` / `docker compose up` ist der Cache verloren, und alle Adressen werden erneut über die Nominatim-API abgefragt (Rate-Limit: 1 Request/Sekunde).
|
||||
|
||||
---
|
||||
|
||||
#### L-07 — Fehlende Offline-Unterstützung für Feld-Apps
|
||||
|
||||
**Betroffen:** Nachsuche, Drohnenfuehrer, Stoeberhunde
|
||||
|
||||
Die Apps sind für den Feldeinsatz (Wald, schlechte Mobilfunkabdeckung) konzipiert. Keiner der drei Apps hat einen Service Worker. Bricht die Netzwerkverbindung ab, sind alle Karten und Benutzerdaten sofort unzugänglich. Nur das Portal hat einen minimalen SW mit Offline-Fallback-Seite.
|
||||
|
||||
---
|
||||
|
||||
## 4. System-spezifische Befunde
|
||||
|
||||
### 4.1 Portal
|
||||
|
||||
| # | Befund | Priorität |
|
||||
|---|---|---|
|
||||
| C-01 (teilweise) | Gemeinsamer JWT-Secret-Namespace über alle Apps | KRITISCH |
|
||||
| — | `portal/extra8002.conf` korrekt mit Container-DNS-Namen konfiguriert | ✓ OK |
|
||||
| — | Statisches Proxy-Routing, kein SSO, kein API-Gateway | Architekturentscheidung |
|
||||
| — | Service Worker vorhanden mit Offline-Fallback | ✓ OK |
|
||||
|
||||
### 4.2 Nachsuche
|
||||
|
||||
| # | Befund | Priorität |
|
||||
|---|---|---|
|
||||
| C-01 | Cross-App-Admin-Auth | KRITISCH |
|
||||
| C-02 | `overwrite` undeclared in `importUsers` | KRITISCH |
|
||||
| H-01 / H-02 | `passwordHash` in API-Antworten | HOCH |
|
||||
| H-03 | Mass Assignment in `createUser`/`updateUser` | HOCH |
|
||||
| H-04 | Set-Password-Flow defekt (kein inviteToken im Frontend) | HOCH |
|
||||
| H-07 | Handler-JWT in `localStorage` | HOCH |
|
||||
| M-07 | Dev-nginx.conf mit `host.docker.internal` im Repo | MITTEL |
|
||||
| — | Cookie `secure: secureCookie` korrekt bedingt | ✓ OK |
|
||||
|
||||
### 4.3 Drohnenfuehrer
|
||||
|
||||
| # | Befund | Priorität |
|
||||
|---|---|---|
|
||||
| C-01 | Cross-App-Admin-Auth | KRITISCH |
|
||||
| C-02 | `overwrite` undeclared in `importUsers` | KRITISCH |
|
||||
| H-01 / H-02 | `passwordHash` in API-Antworten | HOCH |
|
||||
| H-03 | Mass Assignment in `createUser`/`updateUser` | HOCH |
|
||||
| H-04 | Set-Password-Flow defekt | HOCH |
|
||||
| H-07 | Drohnenführer-JWT in `localStorage` | HOCH |
|
||||
| — | Cookie `secure: secureCookie` korrekt bedingt | ✓ OK |
|
||||
|
||||
### 4.4 Stoeberhunde
|
||||
|
||||
| # | Befund | Priorität |
|
||||
|---|---|---|
|
||||
| C-01 | Cross-App-Admin-Auth | KRITISCH |
|
||||
| C-02 | `overwrite` undeclared in `importUsers` | KRITISCH |
|
||||
| H-01 / H-02 | `passwordHash` in API-Antworten | HOCH |
|
||||
| H-03 | Mass Assignment in `createUser`/`updateUser` | HOCH |
|
||||
| H-04 | Set-Password-Flow defekt | HOCH |
|
||||
| H-05 | Cookie `secure: false` hardcoded | HOCH |
|
||||
| H-07 | Stöberhundeführer-JWT in `localStorage` | HOCH |
|
||||
|
||||
---
|
||||
|
||||
## 5. Positive Befunde
|
||||
|
||||
Das Ökosystem zeigt in mehreren Bereichen solide Sicherheitspraktiken:
|
||||
|
||||
| Bereich | Befund |
|
||||
|---|---|
|
||||
| Auth | Admin-JWT als `httpOnly`-Cookie – nicht per JS zugänglich |
|
||||
| Auth | `authLimiter`: 5 Admin-Login-Versuche / 15 Minuten |
|
||||
| Auth | `inviteLimiter`: 10 Set-Password-Versuche / 15 Minuten |
|
||||
| Auth | `timingSafeEqual` für Invite-Token-Vergleich (Timing-Attack-Schutz) |
|
||||
| Auth | User-Enumeration in `forgotPassword` verhindert (immer gleiche Antwort) |
|
||||
| Auth | JWT_SECRET: Default-Wert in Production erkannt → Startup-Crash |
|
||||
| API | `express-validator` für Login-Endpunkte (`validateLogin`) |
|
||||
| API | Paginierung mit hartem Maximum (100 Items/Seite) |
|
||||
| API | Benutzer-Selbstverwaltung (`/me`) mit explizit aufgebauter Update-Whitelist |
|
||||
| Daten | Soft-Delete-Pattern korrekt implementiert (gelöschte User nie in Queries) |
|
||||
| Daten | `auditLogger` entfernt `passwordHash` aus Before/After-Snapshots |
|
||||
| Daten | GPS-Koordinaten doppelt validiert: express-validator + Mongoose min/max |
|
||||
| Infra | Backend-Ports nur auf `127.0.0.1` gebunden |
|
||||
| Infra | MongoDB nicht extern exponiert |
|
||||
| Infra | Graceful SIGTERM-Shutdown implementiert |
|
||||
| Frontend | Axios-Retry mit exponentiellem Backoff (2 Versuche, max. 5 s) |
|
||||
| Nginx | `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff` gesetzt |
|
||||
| Nginx | HSTS-Header in App-nginx.conf vorhanden |
|
||||
|
||||
---
|
||||
|
||||
## 6. Priorisierte Handlungsempfehlungen
|
||||
|
||||
### Sofort (vor nächstem Deployment)
|
||||
|
||||
**1. C-01 – App-isolierte JWT-Secrets einführen**
|
||||
```yaml
|
||||
# nachsuche/docker-compose.yml
|
||||
environment:
|
||||
JWT_SECRET: ${NACHSUCHE_JWT_SECRET}
|
||||
|
||||
# drohnenfuehrer/docker-compose.yml
|
||||
environment:
|
||||
JWT_SECRET: ${DROHNENFUEHRER_JWT_SECRET}
|
||||
```
|
||||
Und App-Claim im Token + Prüfung in `authenticateToken` ergänzen.
|
||||
|
||||
**2. C-02 – `overwrite`-Deklaration in `importUsers` ergänzen**
|
||||
In allen drei `userController.js` nach Zeile 466 einfügen:
|
||||
```js
|
||||
const overwrite = req.query.overwrite === 'true';
|
||||
```
|
||||
|
||||
**3. H-04 – Set-Password-Flow reparieren**
|
||||
Im Formular ein `inviteToken`-Eingabefeld ergänzen; Admin zeigt den generierten Token im UI (oder der Link enthält ihn als Query-Parameter).
|
||||
|
||||
**4. H-05 – Stoeberhunde `secure: false` korrigieren**
|
||||
```js
|
||||
const secureCookie = process.env.NODE_ENV === 'production';
|
||||
secure: secureCookie,
|
||||
```
|
||||
|
||||
### Kurzfristig (innerhalb 2 Wochen)
|
||||
|
||||
**5. H-01/H-02 – `passwordHash` aus Admin-API-Antworten entfernen**
|
||||
```js
|
||||
User.find(filter).select('-__v -passwordHash')
|
||||
User.findById(req.params.id).select('-passwordHash -__v')
|
||||
```
|
||||
Alternativ im Schema: `passwordHash: { ..., select: false }`.
|
||||
|
||||
**6. H-03 – Mass Assignment durch Feldwhitelist ersetzen**
|
||||
Explizite Feldliste für `createUser` und `updateUser` analog zu `bulkUpdateUsers`.
|
||||
|
||||
**7. H-07 – JWTs aus `localStorage` migrieren**
|
||||
`sessionStorage` als Minimalmaßnahme; `httpOnly`-Cookie als optimale Lösung.
|
||||
|
||||
**8. H-06 – Netzwerk-Segmentierung einführen**
|
||||
Pro App ein internes Netzwerk für Backend ↔ MongoDB-Kommunikation; `jagd-network` nur für Portal-seitige Kommunikation.
|
||||
|
||||
**9. M-08 – `authLimiter` auf User-Rollen-Logins anwenden**
|
||||
```js
|
||||
router.post('/login', authLimiter, handlerLogin);
|
||||
```
|
||||
|
||||
### Mittelfristig (innerhalb 30 Tage)
|
||||
|
||||
**10. M-01 – E-Mail-Transport implementieren**
|
||||
Nodemailer + SMTP-Konfiguration via Umgebungsvariablen für Password-Reset und Invite-Token-Versand.
|
||||
|
||||
**11. M-02 – `req.query.type` Whitelist-Validierung**
|
||||
Erlaubte User-Typen aus Konfiguration laden und gegen Query-Parameter prüfen.
|
||||
|
||||
**12. M-03 – Base64-Foto-Größe serverseitig begrenzen**
|
||||
```js
|
||||
const MAX_PHOTO_SIZE = 2 * 1024 * 1024; // 2 MB Base64
|
||||
if (photo.length > MAX_PHOTO_SIZE) return res.status(413).json(...);
|
||||
```
|
||||
|
||||
**13. M-04 – CSP `'unsafe-inline'` eliminieren**
|
||||
Nonce-basierte CSP nach CRA/Vite-Build-Integration einführen.
|
||||
|
||||
**14. M-05 – Geocode-Cache LRU-Limit einführen**
|
||||
```js
|
||||
// Max 1000 Einträge, älteste werden verdrängt
|
||||
if (cache.size >= 1000) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
cache.delete(firstKey);
|
||||
}
|
||||
```
|
||||
Und `setInterval.unref()` für sauberes Graceful-Shutdown.
|
||||
|
||||
**15. M-06 – `sameSite: 'strict'` für Admin-Cookie**
|
||||
|
||||
**16. M-07 – Dev-`nginx.conf` umbenennen oder entfernen**
|
||||
`nachsuche/nginx/nginx.conf` → `nginx.conf.dev.bak` oder in `.gitignore`.
|
||||
|
||||
### Langfristig (innerhalb 90 Tage)
|
||||
|
||||
**17. L-01 – bcrypt Work-Factor vereinheitlichen** auf 12 für Admin und User.
|
||||
|
||||
**18. L-02/L-03 – Admin-Mindestpasswortlänge auf 12 Zeichen anheben**
|
||||
Formular- und Backend-Validierung synchronisieren.
|
||||
|
||||
**19. L-04 – Logger-Service-Namen app-spezifisch setzen.**
|
||||
|
||||
**20. L-05 – `exec('node seed.js')` durch `require('./seed')()` ersetzen.**
|
||||
|
||||
**21. L-06 – Geocode-Cache-Datei via Volume-Mount persistieren:**
|
||||
```yaml
|
||||
volumes:
|
||||
- geocode-cache:/app/geocode-cache.json
|
||||
```
|
||||
|
||||
**22. L-07 – Service Worker für Feld-Apps implementieren**
|
||||
Leaflet-Kacheln und letzte Benutzerliste für Offline-Betrieb cachen (Cache-First-Strategie für statische Assets, Network-First für API-Calls mit Fallback).
|
||||
|
||||
---
|
||||
|
||||
## 7. Technischer Anhang
|
||||
|
||||
### 7.1 Dependency-Versionen (Stand Audit)
|
||||
|
||||
| Paket | Version | Anmerkung |
|
||||
|---|---|---|
|
||||
| express | 4.18.2 | aktuell |
|
||||
| mongoose | 7.5.0 | aktuell |
|
||||
| jsonwebtoken | 9.0.2 | aktuell |
|
||||
| helmet | 8.0.0 | aktuell |
|
||||
| express-rate-limit | 8.2.1 | aktuell |
|
||||
| bcryptjs | 2.4.3 | aktuell |
|
||||
| express-validator | 7.3.1 | aktuell |
|
||||
| winston | 3.19.0 | aktuell |
|
||||
| axios | 1.13.5 | aktuell |
|
||||
| react | 18.2.0 | aktuell |
|
||||
| leaflet | 1.9.4 | aktuell |
|
||||
| node (Docker base) | 22-alpine | aktuell |
|
||||
| nginx (Docker base) | alpine | aktuell |
|
||||
| mongodb | 7.0 | aktuell |
|
||||
|
||||
`package-lock.json` enthält Hinweise auf veraltete `glob`-Versionen als transitive Abhängigkeiten (via dev-dependencies, kein Produktionsrisiko).
|
||||
|
||||
### 7.2 Befund-Index (Schnellreferenz)
|
||||
|
||||
| ID | Titel | Priorität | Betroffen |
|
||||
|---|---|---|---|
|
||||
| C-01 | Cross-App-Admin-Auth durch gemeinsamen JWT_SECRET | KRITISCH | Alle |
|
||||
| C-02 | `overwrite` undeclared → ReferenceError in importUsers | KRITISCH | Alle |
|
||||
| H-01 | `passwordHash` in `getAllUsers` exponiert | HOCH | Alle |
|
||||
| H-02 | `passwordHash` in `getUserById` exponiert | HOCH | Alle |
|
||||
| H-03 | Mass Assignment in `createUser`/`updateUser` | HOCH | Alle |
|
||||
| H-04 | Set-Password-Flow defekt (inviteToken fehlt im Frontend) | HOCH | Alle |
|
||||
| H-05 | `secure: false` für Admin-Cookie hardcoded | HOCH | Stoeberhunde |
|
||||
| H-06 | Lateral Movement über gemeinsames jagd-network | HOCH | Alle |
|
||||
| H-07 | Nutzer-JWTs in localStorage (XSS-zugänglich) | HOCH | Alle |
|
||||
| M-01 | Passwort-Reset: kein E-Mail-Versand | MITTEL | Alle |
|
||||
| M-02 | NoSQL-Filter: req.query.type ohne Whitelist | MITTEL | Alle |
|
||||
| M-03 | Base64-Foto überschreitet BSON-16MB-Limit | MITTEL | Alle |
|
||||
| M-04 | CSP unsafe-inline in script-src und style-src | MITTEL | Alle |
|
||||
| M-05 | Geocode-Cache unbegrenzt im Speicher | MITTEL | Alle |
|
||||
| M-06 | sameSite: 'lax' statt 'strict' für Admin-Cookie | MITTEL | Alle |
|
||||
| M-07 | Dev-nginx.conf mit host.docker.internal im Repo | MITTEL | Nachsuche |
|
||||
| M-08 | Nutzer-Rollen-Login ohne authLimiter | MITTEL | Alle |
|
||||
| L-01 | bcrypt Work-Factor: Admin=10, User=12 | NIEDRIG | Alle |
|
||||
| L-02 | Admin-Mindestpasswortlänge 6 Zeichen | NIEDRIG | Alle |
|
||||
| L-03 | Frontend-Passwortlänge (6) != Backend-Minimum (8) | NIEDRIG | Alle |
|
||||
| L-04 | Logger-Service-Name: 'tracking-leaders-api' in allen Apps | NIEDRIG | Alle |
|
||||
| L-05 | exec('node seed.js') via child_process | NIEDRIG | Alle |
|
||||
| L-06 | Geocode-Cache-Datei im Container (kein Volume) | NIEDRIG | Alle |
|
||||
| L-07 | Fehlende Offline-Unterstützung für Feld-Apps | NIEDRIG | Alle |
|
||||
|
||||
---
|
||||
|
||||
*Ende des Audit-Berichts – 3. Mai 2026*
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -1,30 +1,36 @@
|
|||
require('dotenv').config();
|
||||
|
||||
const APP_NAME = 'drohnenfuehrer';
|
||||
|
||||
const config = {
|
||||
appName: APP_NAME,
|
||||
port: process.env.PORT || 5000,
|
||||
mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/drohnenfuehrer',
|
||||
jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
jwtSecret: process.env.DROHNENFUEHRER_JWT_SECRET || process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'],
|
||||
geocodeUrl: process.env.GEOCODE_URL || 'https://nominatim.openstreetmap.org/search',
|
||||
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'drohnenfuehrer-app/1.0 (admin@localhost)',
|
||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10)
|
||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
||||
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
|
||||
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8081'
|
||||
};
|
||||
|
||||
// Validate required environment variables
|
||||
const requiredVars = ['JWT_SECRET', 'MONGO_URI'];
|
||||
const requiredVars = ['DROHNENFUEHRER_JWT_SECRET', 'MONGO_URI'];
|
||||
const productionVars = ['ADMIN_THORSTEN_PASSWORD']; // Only warn in production
|
||||
|
||||
// In test environment, use defaults if not set
|
||||
if (config.nodeEnv === 'test') {
|
||||
// Set test defaults
|
||||
if (!process.env.JWT_SECRET) process.env.JWT_SECRET = 'test-secret-key';
|
||||
if (!process.env.DROHNENFUEHRER_JWT_SECRET && !process.env.JWT_SECRET) process.env.DROHNENFUEHRER_JWT_SECRET = 'test-secret-key';
|
||||
if (!process.env.MONGO_URI) process.env.MONGO_URI = 'mongodb://localhost:27017/test';
|
||||
} else {
|
||||
// Always validate critical vars (except in test)
|
||||
requiredVars.forEach(varName => {
|
||||
if (!process.env[varName]) {
|
||||
// Accept legacy JWT_SECRET as fallback so existing deployments keep working
|
||||
if (!process.env[varName] && !process.env.JWT_SECRET) {
|
||||
console.error(`❌ Fehler: ${varName} muss gesetzt sein!`);
|
||||
console.error(` Tipp: Kopiere .env.example zu .env und fülle die Werte aus.`);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const ResetToken = require('../models/ResetToken');
|
|||
const config = require('../config/env');
|
||||
const logger = require('../utils/logger');
|
||||
const { auditAuth } = require('../middleware/auditLogger');
|
||||
const { sendPasswordResetMail } = require('../utils/mailer');
|
||||
|
||||
const login = async (req, res) => {
|
||||
try {
|
||||
|
|
@ -39,7 +40,7 @@ const login = async (req, res) => {
|
|||
|
||||
// Generate token
|
||||
const token = jwt.sign(
|
||||
{ id: admin._id, username: admin.username },
|
||||
{ id: admin._id, username: admin.username, app: config.appName },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: config.jwtExpiresIn }
|
||||
);
|
||||
|
|
@ -52,7 +53,7 @@ const login = async (req, res) => {
|
|||
res.cookie('token', token, {
|
||||
httpOnly: true, // Not accessible via JavaScript (XSS protection)
|
||||
secure: secureCookie,
|
||||
sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
|
||||
sameSite: 'strict',
|
||||
path: '/', // Ensure cookie is sent for all app routes, including /drohnenfuehrer/api
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||
});
|
||||
|
|
@ -86,7 +87,7 @@ const logout = async (req, res) => {
|
|||
res.clearCookie('token', {
|
||||
httpOnly: true,
|
||||
secure: config.nodeEnv === 'production',
|
||||
sameSite: 'lax',
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
|
||||
|
|
@ -143,18 +144,31 @@ const forgotPassword = async (req, res) => {
|
|||
expiresAt
|
||||
});
|
||||
|
||||
// In production, send email here
|
||||
// For development, log the token
|
||||
if (config.nodeEnv !== 'production') {
|
||||
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
|
||||
// Build reset URL pointing at the SPA's reset page
|
||||
const resetUrl = `${config.appUrl}/passwort-zuruecksetzen?token=${token}`;
|
||||
|
||||
// Send reset email if admin has an email address and SMTP is configured
|
||||
if (admin.email) {
|
||||
try {
|
||||
const sent = await sendPasswordResetMail(admin.email, resetUrl);
|
||||
if (!sent) {
|
||||
logger.warn(`[forgotPassword] SMTP nicht konfiguriert. Reset-URL für ${username}: ${resetUrl}`);
|
||||
} else {
|
||||
logger.info(`Password reset token generiert für Benutzer: ${username}`);
|
||||
logger.info(`[forgotPassword] Reset-Mail an ${admin.email} gesendet (Benutzer: ${username})`);
|
||||
}
|
||||
} catch (mailErr) {
|
||||
logger.error(`[forgotPassword] Fehler beim Senden der Reset-Mail: ${mailErr.message}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`[forgotPassword] Admin "${username}" hat keine E-Mail-Adresse. ` +
|
||||
`Reset-URL: ${resetUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Reset-Token wurde generiert. In der Entwicklungsumgebung finden Sie den Token in den Logs.',
|
||||
...(config.nodeEnv === 'development' && { token }) // Only in dev!
|
||||
message: 'Falls der Benutzer existiert, wurde eine Reset-E-Mail gesendet.'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
const User = require('../models/User');
|
||||
const { geocodeAddress } = require('../utils/geocode');
|
||||
const logger = require('../utils/logger');
|
||||
const config = require('../config/env');
|
||||
|
||||
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
|
||||
const MAX_PHOTO_BYTES = 2 * 1024 * 1024; // 2 MB base64
|
||||
|
||||
const getAllUsers = async (req, res) => {
|
||||
try {
|
||||
|
|
@ -28,7 +32,7 @@ const getAllUsers = async (req, res) => {
|
|||
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.select('-__v'), // Exclude version key
|
||||
.select('-__v -passwordHash'),
|
||||
User.countDocuments(filter)
|
||||
]);
|
||||
|
||||
|
|
@ -54,7 +58,7 @@ const getAllUsers = async (req, res) => {
|
|||
|
||||
const getUserById = async (req, res) => {
|
||||
try {
|
||||
const user = await User.findById(req.params.id);
|
||||
const user = await User.findById(req.params.id).select('-passwordHash -__v');
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
|
|
@ -76,7 +80,9 @@ const getUserById = async (req, res) => {
|
|||
|
||||
const createUser = async (req, res) => {
|
||||
try {
|
||||
const payload = { ...req.body };
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(req.body).filter(([k]) => ALLOWED_USER_FIELDS.includes(k))
|
||||
);
|
||||
|
||||
let geoWarning = null;
|
||||
if (!payload.gps && payload.address) {
|
||||
|
|
@ -115,7 +121,9 @@ const updateUser = async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
const updateData = { ...req.body };
|
||||
const updateData = Object.fromEntries(
|
||||
Object.entries(req.body).filter(([k]) => ALLOWED_USER_FIELDS.includes(k))
|
||||
);
|
||||
const addressChanged = typeof updateData.address === 'string'
|
||||
&& updateData.address.trim() !== existing.address;
|
||||
const hasGpsInRequest = updateData.gps
|
||||
|
|
@ -261,8 +269,11 @@ const getPublicUsers = async (req, res) => {
|
|||
|
||||
// Filter for available users only + optional type filter
|
||||
const filter = { available: true };
|
||||
const allowedTypes = config.userTypes || [];
|
||||
if (req.query.type) {
|
||||
filter.type = req.query.type;
|
||||
if (allowedTypes.length === 0 || allowedTypes.includes(req.query.type)) {
|
||||
filter.type = String(req.query.type);
|
||||
}
|
||||
}
|
||||
if (req.query.search) {
|
||||
filter.$text = { $search: req.query.search };
|
||||
|
|
@ -465,6 +476,7 @@ const exportUsers = async (req, res) => {
|
|||
const importUsers = async (req, res) => {
|
||||
try {
|
||||
const { users: importList } = req.body;
|
||||
const overwrite = req.query.overwrite === 'true';
|
||||
|
||||
if (!Array.isArray(importList) || importList.length === 0) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -659,6 +671,9 @@ const uploadUserPhoto = async (req, res) => {
|
|||
if (!photo || typeof photo !== 'string' || !ALLOWED_PHOTO_TYPES.some(t => photo.startsWith(t))) {
|
||||
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat. Erlaubt: JPEG, PNG, WebP, GIF' });
|
||||
}
|
||||
if (photo.length > MAX_PHOTO_BYTES) {
|
||||
return res.status(413).json({ success: false, message: 'Bild zu groß. Maximal 2 MB erlaubt.' });
|
||||
}
|
||||
const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
|
||||
|
|
@ -687,6 +702,34 @@ const deleteUserPhoto = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/public/geocode?postalcode=XXXXX
|
||||
* Proxies postal code lookup through the backend geocoder (rate-limited, cached, correct User-Agent).
|
||||
*/
|
||||
const getGeocodeByPostalCode = async (req, res) => {
|
||||
const { postalcode } = req.query;
|
||||
|
||||
if (!postalcode || String(postalcode).trim().length < 4) {
|
||||
return res.status(400).json({ success: false, message: 'Bitte eine gültige Postleitzahl angeben (min. 4 Zeichen)' });
|
||||
}
|
||||
|
||||
const sanitized = String(postalcode).replace(/[^0-9]/g, '').slice(0, 10);
|
||||
if (!sanitized) {
|
||||
return res.status(400).json({ success: false, message: 'Ungültige Postleitzahl' });
|
||||
}
|
||||
|
||||
try {
|
||||
const coords = await geocodeAddress(`${sanitized}, Deutschland`);
|
||||
if (!coords) {
|
||||
return res.status(404).json({ success: false, message: 'Postleitzahl nicht gefunden' });
|
||||
}
|
||||
res.json({ success: true, data: coords });
|
||||
} catch (error) {
|
||||
logger.error('Geocoding-Fehler für PLZ:', error);
|
||||
res.status(500).json({ success: false, message: 'Geocoding-Fehler' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
|
|
@ -703,5 +746,6 @@ module.exports = {
|
|||
bulkUpdateUsers,
|
||||
bulkDeleteUsers,
|
||||
uploadUserPhoto,
|
||||
deleteUserPhoto
|
||||
deleteUserPhoto,
|
||||
getGeocodeByPostalCode
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ const authenticateToken = (req, res, next) => {
|
|||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwtSecret);
|
||||
// Reject tokens issued by a different app (C-01 cross-app auth fix)
|
||||
if (decoded.app && decoded.app !== config.appName) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Ungültiger oder abgelaufener Token.'
|
||||
});
|
||||
}
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,16 @@ const adminSchema = new mongoose.Schema({
|
|||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
sparse: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 6
|
||||
minlength: 12
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
|
|
@ -22,7 +28,7 @@ adminSchema.pre('save', async function(next) {
|
|||
if (!this.isModified('password')) return next();
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const salt = await bcrypt.genSalt(12);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
next();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^7.5.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ const { drohnenfuehrerLogin, setDrohnenfuehrerPassword, generateInviteToken, upd
|
|||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { authenticateDrohnenfuehrer } = require('../middleware/drohnenfuehrerAuth');
|
||||
const { inviteLimiter } = require('../middleware/rateLimiter');
|
||||
const { authLimiter } = require('../middleware/rateLimiter');
|
||||
|
||||
// Public
|
||||
router.post('/login', drohnenfuehrerLogin);
|
||||
router.post('/login', authLimiter, drohnenfuehrerLogin);
|
||||
router.post('/set-password', inviteLimiter, setDrohnenfuehrerPassword);
|
||||
|
||||
// Admin only — generate a one-time invite token so a Drohnenführer can set their password
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ const {
|
|||
bulkUpdateUsers,
|
||||
bulkDeleteUsers,
|
||||
uploadUserPhoto,
|
||||
deleteUserPhoto
|
||||
deleteUserPhoto,
|
||||
getGeocodeByPostalCode
|
||||
} = require('../controllers/userController');
|
||||
|
||||
// Public route
|
||||
// Public routes
|
||||
router.get('/public/users', getPublicUsers);
|
||||
router.get('/public/geocode', getGeocodeByPostalCode);
|
||||
|
||||
// Protected routes (require authentication)
|
||||
router.get('/users', authenticateToken, getAllUsers);
|
||||
|
|
|
|||
|
|
@ -104,4 +104,8 @@ const seedDatabase = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
seedDatabase();
|
||||
}
|
||||
|
||||
module.exports = seedDatabase;
|
||||
|
|
|
|||
|
|
@ -21,15 +21,12 @@ const connectWithRetry = async () => {
|
|||
const userCount = await User.countDocuments();
|
||||
if (userCount === 0) {
|
||||
logger.info('Datenbank ist leer, starte Seeding...');
|
||||
const { exec } = require('child_process');
|
||||
await new Promise((resolve) => {
|
||||
exec('node seed.js', { cwd: __dirname }, (seedError, stdout, stderr) => {
|
||||
if (seedError) logger.error('Fehler beim Seeding:', seedError.message);
|
||||
if (stdout) logger.info(stdout.trim());
|
||||
if (stderr) logger.warn(stderr.trim());
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
const seed = require('./seed');
|
||||
await seed();
|
||||
} catch (seedError) {
|
||||
logger.error('Fehler beim Seeding:', seedError.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Verbindungsversuch fehlgeschlagen, erneuter Versuch in 5 Sekunden...');
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const saveCache = async () => {
|
|||
};
|
||||
|
||||
// Periodically save cache
|
||||
setInterval(saveCache, CACHE_SAVE_INTERVAL);
|
||||
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
|
||||
|
||||
// Save on process exit
|
||||
process.on('SIGINT', async () => {
|
||||
|
|
@ -123,6 +123,7 @@ const geocodeAddress = async (address) => {
|
|||
}
|
||||
|
||||
const coords = { lat, lng };
|
||||
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
|
||||
cache.set(cacheKey, coords);
|
||||
cacheModified = true;
|
||||
return coords;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
const nodemailer = require('nodemailer');
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Creates a nodemailer transporter from environment variables.
|
||||
* Required env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS
|
||||
* Optional env vars: SMTP_FROM (defaults to SMTP_USER), SMTP_SECURE (defaults to 'true' if port 465)
|
||||
*
|
||||
* Returns null if SMTP is not configured so callers can fall back gracefully.
|
||||
*/
|
||||
const createTransport = () => {
|
||||
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS } = process.env;
|
||||
if (!SMTP_HOST || !SMTP_USER || !SMTP_PASS) {
|
||||
return null;
|
||||
}
|
||||
const port = parseInt(SMTP_PORT || '587', 10);
|
||||
const secure = process.env.SMTP_SECURE !== undefined
|
||||
? process.env.SMTP_SECURE === 'true'
|
||||
: port === 465;
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port,
|
||||
secure,
|
||||
auth: { user: SMTP_USER, pass: SMTP_PASS }
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a password-reset e-mail to the given address.
|
||||
*
|
||||
* @param {string} toEmail – recipient address
|
||||
* @param {string} resetUrl – full URL including token query parameter
|
||||
* @returns {Promise<boolean>} true if mail was sent, false if SMTP is not configured
|
||||
*/
|
||||
const sendPasswordResetMail = async (toEmail, resetUrl) => {
|
||||
const transport = createTransport();
|
||||
if (!transport) {
|
||||
logger.warn(
|
||||
'[mailer] SMTP nicht konfiguriert (SMTP_HOST/SMTP_USER/SMTP_PASS fehlen). ' +
|
||||
'Reset-Link wird nur geloggt.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
|
||||
await transport.sendMail({
|
||||
from,
|
||||
to: toEmail,
|
||||
subject: 'Passwort-Reset',
|
||||
text:
|
||||
`Sie haben einen Passwort-Reset angefordert.\n\n` +
|
||||
`Klicken Sie auf den folgenden Link (gültig für 1 Stunde):\n${resetUrl}\n\n` +
|
||||
`Falls Sie keinen Reset angefordert haben, ignorieren Sie diese Mail.`,
|
||||
html:
|
||||
`<p>Sie haben einen Passwort-Reset angefordert.</p>` +
|
||||
`<p><a href="${resetUrl}">Passwort zurücksetzen</a></p>` +
|
||||
`<p>Der Link ist 1 Stunde gültig.</p>` +
|
||||
`<p>Falls Sie keinen Reset angefordert haben, ignorieren Sie diese Mail.</p>`
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
module.exports = { sendPasswordResetMail };
|
||||
|
|
@ -27,16 +27,25 @@ services:
|
|||
networks:
|
||||
- default
|
||||
- jagd-network
|
||||
volumes:
|
||||
- geocode-cache:/app/geocode-cache.json
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- MONGO_URI=mongodb://drohnenfuehrer:${MONGO_PASSWORD}@mongo:27017/drohnenfuehrer?authSource=admin
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- JWT_SECRET=${DROHNENFUEHRER_JWT_SECRET}
|
||||
- JWT_EXPIRES_IN=24h
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8081}
|
||||
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
|
||||
- GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||
- GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0 (t.meyer@jaegerschaft-fallingbostel.de)
|
||||
- GEOCODE_MIN_DELAY_MS=1100
|
||||
# E-Mail / Passwort-Reset (optional)
|
||||
# - SMTP_HOST=mail.example.com
|
||||
# - SMTP_PORT=587
|
||||
# - SMTP_USER=user@example.com
|
||||
# - SMTP_PASS=${SMTP_PASS}
|
||||
# - SMTP_FROM=drohnenfuehrer@example.com
|
||||
# - APP_URL=https://example.com/drohnenfuehrer
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
|
|
@ -71,6 +80,8 @@ services:
|
|||
volumes:
|
||||
mongo-data:
|
||||
name: drohnenfuehrer-mongo-data
|
||||
geocode-cache:
|
||||
name: drohnenfuehrer-geocode-cache
|
||||
|
||||
networks:
|
||||
default: {}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ function App() {
|
|||
/>
|
||||
<main className="app-main">
|
||||
{view === 'rules' && <Rules />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} onRefetch={refetch} />}
|
||||
{view === 'admin' && (
|
||||
<Admin
|
||||
users={users}
|
||||
|
|
@ -109,7 +109,7 @@ function App() {
|
|||
) : (
|
||||
<>
|
||||
{view === 'rules' && <Rules />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} onRefetch={refetch} />}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ function PasswordReset() {
|
|||
setMessage({ type: '', text: '' });
|
||||
|
||||
// Validation
|
||||
if (newPassword.length < 6) {
|
||||
setMessage({ type: 'error', text: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
if (newPassword.length < 12) {
|
||||
setMessage({ type: 'error', text: 'Passwort muss mindestens 12 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -128,8 +128,8 @@ function PasswordReset() {
|
|||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
minLength={6}
|
||||
placeholder="Mindestens 12 Zeichen"
|
||||
minLength={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -143,7 +143,7 @@ function PasswordReset() {
|
|||
required
|
||||
disabled={loading}
|
||||
placeholder="Passwort wiederholen"
|
||||
minLength={6}
|
||||
minLength={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const DrohnenfuehrerDashboard = ({ drohnenfuehrerUser, onLogout }) => {
|
|||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('drohnenfuehrerToken');
|
||||
sessionStorage.removeItem('drohnenfuehrerToken');
|
||||
onLogout();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const DrohnenfuehrerLogin = ({ onLogin }) => {
|
|||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newPassword2, setNewPassword2] = useState('');
|
||||
const [inviteToken, setInviteToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -19,7 +20,7 @@ const DrohnenfuehrerLogin = ({ onLogin }) => {
|
|||
try {
|
||||
const result = await drohnenfuehrerLogin(email, password);
|
||||
if (result.success) {
|
||||
localStorage.setItem('drohnenfuehrerToken', result.token);
|
||||
sessionStorage.setItem('drohnenfuehrerToken', result.token);
|
||||
onLogin(result.user);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -36,16 +37,17 @@ const DrohnenfuehrerLogin = ({ onLogin }) => {
|
|||
setError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen haben');
|
||||
if (newPassword.length < 8) {
|
||||
setError('Passwort muss mindestens 8 Zeichen haben');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await setDrohnenfuehrerPassword(email, newPassword);
|
||||
const result = await setDrohnenfuehrerPassword(email, inviteToken, newPassword);
|
||||
if (result.success) {
|
||||
setSuccess('Passwort erfolgreich gesetzt. Sie können sich nun anmelden.');
|
||||
setMode('login');
|
||||
setInviteToken('');
|
||||
setNewPassword('');
|
||||
setNewPassword2('');
|
||||
}
|
||||
|
|
@ -99,15 +101,19 @@ const DrohnenfuehrerLogin = ({ onLogin }) => {
|
|||
) : (
|
||||
<form onSubmit={handleSetPassword} className="drohnenfuehrer-form">
|
||||
<p className="drohnenfuehrer-hint">
|
||||
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail und ein neues Passwort ein.
|
||||
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Neues Passwort (min. 6 Zeichen)</label>
|
||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={6} />
|
||||
<label>Einladungs-Token</label>
|
||||
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Neues Passwort (min. 8 Zeichen)</label>
|
||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Passwort wiederholen</label>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useConfigContext } from '../../contexts/ConfigContext';
|
||||
import MapView from '../map/MapView';
|
||||
import FilterPanel from '../users/FilterPanel';
|
||||
import { calculateDistance } from '../../utils/helpers';
|
||||
import { getGeocodeByPostalCode } from '../../services/users';
|
||||
import './PublicUserList.css';
|
||||
|
||||
const PublicUserList = ({ users, loading }) => {
|
||||
const PublicUserList = ({ users, loading, onRefetch }) => {
|
||||
const { userTypeLabels } = useConfigContext();
|
||||
|
||||
// Load saved location from localStorage on mount
|
||||
|
|
@ -32,9 +33,17 @@ const PublicUserList = ({ users, loading }) => {
|
|||
}
|
||||
}, [coords]);
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
const handleFilterChange = useCallback((key, value) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-fetch from backend when type filter changes (server-side filtering)
|
||||
useEffect(() => {
|
||||
if (onRefetch) {
|
||||
onRefetch(filters.type ? { type: filters.type } : {});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters.type]);
|
||||
|
||||
const requestLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
|
|
@ -72,50 +81,21 @@ const PublicUserList = ({ users, loading }) => {
|
|||
setPostalSearching(true);
|
||||
setLocationError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(postalCode)}&country=Germany&format=json&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'drohnenfuehrer-app/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
const result = await getGeocodeByPostalCode(postalCode.trim());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Geocoding fehlgeschlagen');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.length > 0) {
|
||||
setCoords({
|
||||
lat: parseFloat(data[0].lat),
|
||||
lng: parseFloat(data[0].lon)
|
||||
});
|
||||
if (result.success) {
|
||||
setCoords({ lat: result.data.lat, lng: result.data.lng });
|
||||
setLocationStatus('granted');
|
||||
setLocationError('');
|
||||
} else {
|
||||
setLocationError('Postleitzahl nicht gefunden');
|
||||
setLocationError(result.message || 'Fehler bei der PLZ-Suche');
|
||||
}
|
||||
} catch (error) {
|
||||
setLocationError('Fehler bei der PLZ-Suche');
|
||||
} finally {
|
||||
setPostalSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { list: filteredAndSortedUsers, missingGpsCount, allUsersWithGPS } = useMemo(() => {
|
||||
let filtered = [...users];
|
||||
|
||||
// Nur verfügbare Drohnenführer
|
||||
filtered = filtered.filter(user => user.available);
|
||||
|
||||
// Type filter
|
||||
if (filters.type) {
|
||||
filtered = filtered.filter(user => user.type === filters.type);
|
||||
}
|
||||
|
||||
const missingGps = filtered.filter(user => !(user.gps && user.gps.lat && user.gps.lng)).length;
|
||||
|
||||
// Alle Benutzer mit GPS für die Karte (vor Radius-Filterung)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import PublicUserList from '../components/public/PublicUserList';
|
||||
|
||||
const Home = ({ users, loading }) => {
|
||||
return <PublicUserList users={users} loading={loading} />;
|
||||
const Home = ({ users, loading, onRefetch }) => {
|
||||
return <PublicUserList users={users} loading={loading} onRefetch={onRefetch} />;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const drohnenfuehrerApi = axios.create({
|
|||
});
|
||||
|
||||
drohnenfuehrerApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('drohnenfuehrerToken');
|
||||
const token = sessionStorage.getItem('drohnenfuehrerToken');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
|
@ -18,8 +18,8 @@ export const drohnenfuehrerLogin = async (email, password) => {
|
|||
return response.data;
|
||||
};
|
||||
|
||||
export const setDrohnenfuehrerPassword = async (email, newPassword) => {
|
||||
const response = await drohnenfuehrerApi.post('/set-password', { email, newPassword });
|
||||
export const setDrohnenfuehrerPassword = async (email, inviteToken, newPassword) => {
|
||||
const response = await drohnenfuehrerApi.post('/set-password', { email, inviteToken, newPassword });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -225,3 +225,14 @@ export const deleteUserPhoto = async (id) => {
|
|||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getGeocodeByPostalCode = async (postalcode) => {
|
||||
try {
|
||||
const response = await api.get('/public/geocode', { params: { postalcode } });
|
||||
return { success: true, data: response.data.data };
|
||||
} catch (error) {
|
||||
const status = error.response?.status;
|
||||
if (status === 404) return { success: false, message: 'Postleitzahl nicht gefunden' };
|
||||
return { success: false, message: error.response?.data?.message || 'Geocoding fehlgeschlagen' };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ server {
|
|||
index index.html;
|
||||
|
||||
# Security headers for the SPA
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self'; frame-ancestors 'none';" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self'; frame-ancestors 'none';" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,37 @@
|
|||
require('dotenv').config();
|
||||
|
||||
const APP_NAME = 'nachsuche';
|
||||
|
||||
const config = {
|
||||
appName: APP_NAME,
|
||||
port: process.env.PORT || 5000,
|
||||
mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/tracking-leaders',
|
||||
jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
jwtSecret: process.env.NACHSUCHE_JWT_SECRET || process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'],
|
||||
geocodeUrl: process.env.GEOCODE_URL || 'https://nominatim.openstreetmap.org/search',
|
||||
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'tracking-leaders-app/1.0 (admin@localhost)',
|
||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10)
|
||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
||||
// E-Mail / SMTP (required for password-reset emails; optional otherwise)
|
||||
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
|
||||
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8080'
|
||||
};
|
||||
|
||||
// Validate required environment variables
|
||||
const requiredVars = ['JWT_SECRET', 'MONGO_URI'];
|
||||
const requiredVars = ['NACHSUCHE_JWT_SECRET', 'MONGO_URI'];
|
||||
const productionVars = ['ADMIN_THORSTEN_PASSWORD']; // Only warn in production
|
||||
|
||||
// In test environment, use defaults if not set
|
||||
if (config.nodeEnv === 'test') {
|
||||
// Set test defaults
|
||||
if (!process.env.JWT_SECRET) process.env.JWT_SECRET = 'test-secret-key';
|
||||
if (!process.env.NACHSUCHE_JWT_SECRET && !process.env.JWT_SECRET) process.env.NACHSUCHE_JWT_SECRET = 'test-secret-key';
|
||||
if (!process.env.MONGO_URI) process.env.MONGO_URI = 'mongodb://localhost:27017/test';
|
||||
} else {
|
||||
// Always validate critical vars (except in test)
|
||||
requiredVars.forEach(varName => {
|
||||
if (!process.env[varName]) {
|
||||
// Accept legacy JWT_SECRET as fallback so existing deployments keep working
|
||||
if (!process.env[varName] && !process.env.JWT_SECRET) {
|
||||
console.error(`❌ Fehler: ${varName} muss gesetzt sein!`);
|
||||
console.error(` Tipp: Kopiere .env.example zu .env und fülle die Werte aus.`);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const ResetToken = require('../models/ResetToken');
|
|||
const config = require('../config/env');
|
||||
const logger = require('../utils/logger');
|
||||
const { auditAuth } = require('../middleware/auditLogger');
|
||||
const { sendPasswordResetMail } = require('../utils/mailer');
|
||||
|
||||
const login = async (req, res) => {
|
||||
try {
|
||||
|
|
@ -39,7 +40,7 @@ const login = async (req, res) => {
|
|||
|
||||
// Generate token
|
||||
const token = jwt.sign(
|
||||
{ id: admin._id, username: admin.username },
|
||||
{ id: admin._id, username: admin.username, app: config.appName },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: config.jwtExpiresIn }
|
||||
);
|
||||
|
|
@ -52,7 +53,7 @@ const login = async (req, res) => {
|
|||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: secureCookie,
|
||||
sameSite: 'lax',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||
});
|
||||
|
|
@ -90,7 +91,7 @@ const logout = async (req, res) => {
|
|||
res.clearCookie('token', {
|
||||
httpOnly: true,
|
||||
secure: secureCookie,
|
||||
sameSite: 'lax',
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
|
||||
|
|
@ -147,12 +148,34 @@ const forgotPassword = async (req, res) => {
|
|||
expiresAt
|
||||
});
|
||||
|
||||
// In production, send email here
|
||||
logger.info(`Password reset token generiert für Benutzer: ${username}`);
|
||||
// Build reset URL pointing at the SPA's reset page
|
||||
const resetUrl = `${config.appUrl}/passwort-zuruecksetzen?token=${token}`;
|
||||
|
||||
// Send reset email if admin has an email address and SMTP is configured
|
||||
if (admin.email) {
|
||||
try {
|
||||
const sent = await sendPasswordResetMail(admin.email, resetUrl);
|
||||
if (!sent) {
|
||||
// SMTP not configured – fall back to logging the URL
|
||||
logger.warn(`[forgotPassword] SMTP nicht konfiguriert. Reset-URL für ${username}: ${resetUrl}`);
|
||||
} else {
|
||||
logger.info(`[forgotPassword] Reset-Mail an ${admin.email} gesendet (Benutzer: ${username})`);
|
||||
}
|
||||
} catch (mailErr) {
|
||||
// Mail failure must not block the response (token is already stored)
|
||||
logger.error(`[forgotPassword] Fehler beim Senden der Reset-Mail: ${mailErr.message}`);
|
||||
}
|
||||
} else {
|
||||
// Admin has no email – fall back to logging
|
||||
logger.warn(
|
||||
`[forgotPassword] Admin "${username}" hat keine E-Mail-Adresse. ` +
|
||||
`Reset-URL: ${resetUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Reset-Token wurde generiert. Bitte kontaktieren Sie den Administrator.'
|
||||
message: 'Falls der Benutzer existiert, wurde eine Reset-E-Mail gesendet.'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
const User = require('../models/User');
|
||||
const { geocodeAddress } = require('../utils/geocode');
|
||||
const logger = require('../utils/logger');
|
||||
const config = require('../config/env');
|
||||
|
||||
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
|
||||
const MAX_PHOTO_BYTES = 2 * 1024 * 1024; // 2 MB base64
|
||||
|
||||
const getAllUsers = async (req, res) => {
|
||||
try {
|
||||
|
|
@ -14,8 +18,11 @@ const getAllUsers = async (req, res) => {
|
|||
if (req.query.available !== undefined) {
|
||||
filter.available = req.query.available === 'true';
|
||||
}
|
||||
const allowedTypes = config.userTypes || [];
|
||||
if (req.query.type) {
|
||||
filter.type = req.query.type;
|
||||
if (allowedTypes.length === 0 || allowedTypes.includes(req.query.type)) {
|
||||
filter.type = String(req.query.type);
|
||||
}
|
||||
}
|
||||
if (req.query.search) {
|
||||
// Text search using the text index
|
||||
|
|
@ -28,7 +35,7 @@ const getAllUsers = async (req, res) => {
|
|||
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.select('-__v'), // Exclude version key
|
||||
.select('-__v -passwordHash'),
|
||||
User.countDocuments(filter)
|
||||
]);
|
||||
|
||||
|
|
@ -54,7 +61,7 @@ const getAllUsers = async (req, res) => {
|
|||
|
||||
const getUserById = async (req, res) => {
|
||||
try {
|
||||
const user = await User.findById(req.params.id);
|
||||
const user = await User.findById(req.params.id).select('-passwordHash -__v');
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
|
|
@ -76,7 +83,9 @@ const getUserById = async (req, res) => {
|
|||
|
||||
const createUser = async (req, res) => {
|
||||
try {
|
||||
const payload = { ...req.body };
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(req.body).filter(([k]) => ALLOWED_USER_FIELDS.includes(k))
|
||||
);
|
||||
|
||||
let geoWarning = null;
|
||||
if (!payload.gps && payload.address) {
|
||||
|
|
@ -115,7 +124,9 @@ const updateUser = async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
const updateData = { ...req.body };
|
||||
const updateData = Object.fromEntries(
|
||||
Object.entries(req.body).filter(([k]) => ALLOWED_USER_FIELDS.includes(k))
|
||||
);
|
||||
const addressChanged = typeof updateData.address === 'string'
|
||||
&& updateData.address.trim() !== existing.address;
|
||||
const hasGpsInRequest = updateData.gps
|
||||
|
|
@ -261,8 +272,11 @@ const getPublicUsers = async (req, res) => {
|
|||
|
||||
// Filter for available users only + optional type filter
|
||||
const filter = { available: true };
|
||||
const allowedTypes = config.userTypes || [];
|
||||
if (req.query.type) {
|
||||
filter.type = req.query.type;
|
||||
if (allowedTypes.length === 0 || allowedTypes.includes(req.query.type)) {
|
||||
filter.type = String(req.query.type);
|
||||
}
|
||||
}
|
||||
if (req.query.search) {
|
||||
filter.$text = { $search: req.query.search };
|
||||
|
|
@ -465,6 +479,7 @@ const exportUsers = async (req, res) => {
|
|||
const importUsers = async (req, res) => {
|
||||
try {
|
||||
const { users: importList } = req.body;
|
||||
const overwrite = req.query.overwrite === 'true';
|
||||
|
||||
if (!Array.isArray(importList) || importList.length === 0) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -659,6 +674,9 @@ const uploadUserPhoto = async (req, res) => {
|
|||
if (!photo || typeof photo !== 'string' || !ALLOWED_PHOTO_TYPES.some(t => photo.startsWith(t))) {
|
||||
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat. Erlaubt: JPEG, PNG, WebP, GIF' });
|
||||
}
|
||||
if (photo.length > MAX_PHOTO_BYTES) {
|
||||
return res.status(413).json({ success: false, message: 'Bild zu groß. Maximal 2 MB erlaubt.' });
|
||||
}
|
||||
const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
|
||||
|
|
@ -687,6 +705,35 @@ const deleteUserPhoto = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/public/geocode?postalcode=XXXXX
|
||||
* Proxies postal code lookup through the backend geocoder (rate-limited, cached, correct User-Agent).
|
||||
*/
|
||||
const getGeocodeByPostalCode = async (req, res) => {
|
||||
const { postalcode } = req.query;
|
||||
|
||||
if (!postalcode || String(postalcode).trim().length < 4) {
|
||||
return res.status(400).json({ success: false, message: 'Bitte eine gültige Postleitzahl angeben (min. 4 Zeichen)' });
|
||||
}
|
||||
|
||||
// Sanitize: only allow digits (German PLZ are 5 digits; allow 4 for edge cases)
|
||||
const sanitized = String(postalcode).replace(/[^0-9]/g, '').slice(0, 10);
|
||||
if (!sanitized) {
|
||||
return res.status(400).json({ success: false, message: 'Ungültige Postleitzahl' });
|
||||
}
|
||||
|
||||
try {
|
||||
const coords = await geocodeAddress(`${sanitized}, Deutschland`);
|
||||
if (!coords) {
|
||||
return res.status(404).json({ success: false, message: 'Postleitzahl nicht gefunden' });
|
||||
}
|
||||
res.json({ success: true, data: coords });
|
||||
} catch (error) {
|
||||
logger.error('Geocoding-Fehler für PLZ:', error);
|
||||
res.status(500).json({ success: false, message: 'Geocoding-Fehler' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
|
|
@ -703,5 +750,6 @@ module.exports = {
|
|||
bulkUpdateUsers,
|
||||
bulkDeleteUsers,
|
||||
uploadUserPhoto,
|
||||
deleteUserPhoto
|
||||
deleteUserPhoto,
|
||||
getGeocodeByPostalCode
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ const authenticateToken = (req, res, next) => {
|
|||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwtSecret);
|
||||
// Reject tokens issued by a different app (C-01 cross-app auth fix)
|
||||
if (decoded.app && decoded.app !== config.appName) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Ungültiger oder abgelaufener Token.'
|
||||
});
|
||||
}
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,16 @@ const adminSchema = new mongoose.Schema({
|
|||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
sparse: true // allows null/undefined without unique-index collisions
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 6
|
||||
minlength: 12
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
|
|
@ -22,7 +28,7 @@ adminSchema.pre('save', async function(next) {
|
|||
if (!this.isModified('password')) return next();
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const salt = await bcrypt.genSalt(12);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
next();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^7.5.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ const { handlerLogin, setHandlerPassword, generateInviteToken, updateHandlerSelf
|
|||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { authenticateHandler } = require('../middleware/handlerAuth');
|
||||
const { inviteLimiter } = require('../middleware/rateLimiter');
|
||||
const { authLimiter } = require('../middleware/rateLimiter');
|
||||
|
||||
// Public
|
||||
router.post('/login', handlerLogin);
|
||||
router.post('/login', authLimiter, handlerLogin);
|
||||
router.post('/set-password', inviteLimiter, setHandlerPassword);
|
||||
|
||||
// Admin only — generate a one-time invite token so a handler can set their password
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ const {
|
|||
bulkUpdateUsers,
|
||||
bulkDeleteUsers,
|
||||
uploadUserPhoto,
|
||||
deleteUserPhoto
|
||||
deleteUserPhoto,
|
||||
getGeocodeByPostalCode
|
||||
} = require('../controllers/userController');
|
||||
|
||||
// Public route
|
||||
// Public routes
|
||||
router.get('/public/users', getPublicUsers);
|
||||
router.get('/public/geocode', getGeocodeByPostalCode);
|
||||
|
||||
// Protected routes (require authentication)
|
||||
router.get('/users', authenticateToken, getAllUsers);
|
||||
|
|
|
|||
|
|
@ -111,4 +111,8 @@ const seedDatabase = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
seedDatabase();
|
||||
}
|
||||
|
||||
module.exports = seedDatabase;
|
||||
|
|
|
|||
|
|
@ -21,15 +21,12 @@ const connectWithRetry = async () => {
|
|||
const userCount = await User.countDocuments();
|
||||
if (userCount === 0) {
|
||||
logger.info('Datenbank ist leer, starte Seeding...');
|
||||
const { exec } = require('child_process');
|
||||
await new Promise((resolve) => {
|
||||
exec('node seed.js', { cwd: __dirname }, (seedError, stdout, stderr) => {
|
||||
if (seedError) logger.error('Fehler beim Seeding:', seedError.message);
|
||||
if (stdout) logger.info(stdout.trim());
|
||||
if (stderr) logger.warn(stderr.trim());
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
const seed = require('./seed');
|
||||
await seed();
|
||||
} catch (seedError) {
|
||||
logger.error('Fehler beim Seeding:', seedError.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Verbindungsversuch fehlgeschlagen, erneuter Versuch in 5 Sekunden...');
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const saveCache = async () => {
|
|||
};
|
||||
|
||||
// Periodically save cache
|
||||
setInterval(saveCache, CACHE_SAVE_INTERVAL);
|
||||
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
|
||||
|
||||
// Save on process exit
|
||||
process.on('SIGINT', async () => {
|
||||
|
|
@ -123,6 +123,7 @@ const geocodeAddress = async (address) => {
|
|||
}
|
||||
|
||||
const coords = { lat, lng };
|
||||
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
|
||||
cache.set(cacheKey, coords);
|
||||
cacheModified = true;
|
||||
return coords;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const logger = winston.createLogger({
|
|||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: 'tracking-leaders-api' },
|
||||
defaultMeta: { service: 'nachsuche-api' },
|
||||
transports: [
|
||||
rotateTransport('error', 'error'),
|
||||
rotateTransport('info', 'combined')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
const nodemailer = require('nodemailer');
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Creates a nodemailer transporter from environment variables.
|
||||
* Required env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS
|
||||
* Optional env vars: SMTP_FROM (defaults to SMTP_USER), SMTP_SECURE (defaults to 'true' if port 465)
|
||||
*
|
||||
* Returns null if SMTP is not configured so callers can fall back gracefully.
|
||||
*/
|
||||
const createTransport = () => {
|
||||
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS } = process.env;
|
||||
if (!SMTP_HOST || !SMTP_USER || !SMTP_PASS) {
|
||||
return null;
|
||||
}
|
||||
const port = parseInt(SMTP_PORT || '587', 10);
|
||||
const secure = process.env.SMTP_SECURE !== undefined
|
||||
? process.env.SMTP_SECURE === 'true'
|
||||
: port === 465;
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port,
|
||||
secure,
|
||||
auth: { user: SMTP_USER, pass: SMTP_PASS }
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a password-reset e-mail to the given address.
|
||||
*
|
||||
* @param {string} toEmail – recipient address
|
||||
* @param {string} resetUrl – full URL including token query parameter
|
||||
* @returns {Promise<boolean>} true if mail was sent, false if SMTP is not configured
|
||||
*/
|
||||
const sendPasswordResetMail = async (toEmail, resetUrl) => {
|
||||
const transport = createTransport();
|
||||
if (!transport) {
|
||||
logger.warn(
|
||||
'[mailer] SMTP nicht konfiguriert (SMTP_HOST/SMTP_USER/SMTP_PASS fehlen). ' +
|
||||
'Reset-Link wird nur geloggt.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
|
||||
await transport.sendMail({
|
||||
from,
|
||||
to: toEmail,
|
||||
subject: 'Passwort-Reset',
|
||||
text:
|
||||
`Sie haben einen Passwort-Reset angefordert.\n\n` +
|
||||
`Klicken Sie auf den folgenden Link (gültig für 1 Stunde):\n${resetUrl}\n\n` +
|
||||
`Falls Sie keinen Reset angefordert haben, ignorieren Sie diese Mail.`,
|
||||
html:
|
||||
`<p>Sie haben einen Passwort-Reset angefordert.</p>` +
|
||||
`<p><a href="${resetUrl}">Passwort zurücksetzen</a></p>` +
|
||||
`<p>Der Link ist 1 Stunde gültig.</p>` +
|
||||
`<p>Falls Sie keinen Reset angefordert haben, ignorieren Sie diese Mail.</p>`
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
module.exports = { sendPasswordResetMail };
|
||||
|
|
@ -29,16 +29,25 @@ services:
|
|||
networks:
|
||||
- default
|
||||
- jagd-network
|
||||
volumes:
|
||||
- geocode-cache:/app/geocode-cache.json
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- MONGO_URI=mongodb://nachsuche:${MONGO_PASSWORD}@mongo:27017/nachsuche?authSource=admin
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- JWT_SECRET=${NACHSUCHE_JWT_SECRET}
|
||||
- JWT_EXPIRES_IN=24h
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8080}
|
||||
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
|
||||
- GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||
- GEOCODE_USER_AGENT=nachsuche-app/1.0 (t.meyer@jaegerschaft-fallingbostel.de)
|
||||
- GEOCODE_MIN_DELAY_MS=1100
|
||||
# E-Mail / Passwort-Reset (optional – ohne SMTP-Konfiguration wird der Reset-Link in die Logs geschrieben)
|
||||
# - SMTP_HOST=mail.example.com
|
||||
# - SMTP_PORT=587
|
||||
# - SMTP_USER=user@example.com
|
||||
# - SMTP_PASS=${SMTP_PASS}
|
||||
# - SMTP_FROM=nachsuche@example.com
|
||||
# - APP_URL=https://example.com/nachsuche
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
|
|
@ -73,6 +82,8 @@ services:
|
|||
volumes:
|
||||
mongo-data:
|
||||
name: nachsuche-mongo-data
|
||||
geocode-cache:
|
||||
name: nachsuche-geocode-cache
|
||||
|
||||
networks:
|
||||
default: {}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ function App() {
|
|||
/>
|
||||
<main className="app-main">
|
||||
{view === 'rules' && <Rules />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} onRefetch={refetch} />}
|
||||
{view === 'admin' && (
|
||||
<Admin
|
||||
users={users}
|
||||
|
|
@ -109,7 +109,7 @@ function App() {
|
|||
) : (
|
||||
<>
|
||||
{view === 'rules' && <Rules />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} onRefetch={refetch} />}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ function PasswordReset() {
|
|||
setMessage({ type: '', text: '' });
|
||||
|
||||
// Validation
|
||||
if (newPassword.length < 6) {
|
||||
setMessage({ type: 'error', text: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
if (newPassword.length < 12) {
|
||||
setMessage({ type: 'error', text: 'Passwort muss mindestens 12 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -128,8 +128,8 @@ function PasswordReset() {
|
|||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
minLength={6}
|
||||
placeholder="Mindestens 12 Zeichen"
|
||||
minLength={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -143,7 +143,7 @@ function PasswordReset() {
|
|||
required
|
||||
disabled={loading}
|
||||
placeholder="Passwort wiederholen"
|
||||
minLength={6}
|
||||
minLength={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const HandlerDashboard = ({ handlerUser, onLogout }) => {
|
|||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('handlerToken');
|
||||
sessionStorage.removeItem('handlerToken');
|
||||
onLogout();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const HandlerLogin = ({ onLogin }) => {
|
|||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newPassword2, setNewPassword2] = useState('');
|
||||
const [inviteToken, setInviteToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -19,7 +20,7 @@ const HandlerLogin = ({ onLogin }) => {
|
|||
try {
|
||||
const result = await handlerLogin(email, password);
|
||||
if (result.success) {
|
||||
localStorage.setItem('handlerToken', result.token);
|
||||
sessionStorage.setItem('handlerToken', result.token);
|
||||
onLogin(result.user);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -36,16 +37,17 @@ const HandlerLogin = ({ onLogin }) => {
|
|||
setError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen haben');
|
||||
if (newPassword.length < 8) {
|
||||
setError('Passwort muss mindestens 8 Zeichen haben');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await setHandlerPassword(email, newPassword);
|
||||
const result = await setHandlerPassword(email, inviteToken, newPassword);
|
||||
if (result.success) {
|
||||
setSuccess('Passwort erfolgreich gesetzt. Sie können sich nun anmelden.');
|
||||
setMode('login');
|
||||
setInviteToken('');
|
||||
setNewPassword('');
|
||||
setNewPassword2('');
|
||||
}
|
||||
|
|
@ -99,15 +101,19 @@ const HandlerLogin = ({ onLogin }) => {
|
|||
) : (
|
||||
<form onSubmit={handleSetPassword} className="handler-form">
|
||||
<p className="handler-hint">
|
||||
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail und ein neues Passwort ein.
|
||||
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Neues Passwort (min. 6 Zeichen)</label>
|
||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={6} />
|
||||
<label>Einladungs-Token</label>
|
||||
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Neues Passwort (min. 8 Zeichen)</label>
|
||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Passwort wiederholen</label>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useConfigContext } from '../../contexts/ConfigContext';
|
||||
import MapView from '../map/MapView';
|
||||
import FilterPanel from '../users/FilterPanel';
|
||||
import { calculateDistance } from '../../utils/helpers';
|
||||
import { getGeocodeByPostalCode } from '../../services/users';
|
||||
import './PublicUserList.css';
|
||||
|
||||
const PublicUserList = ({ users, loading }) => {
|
||||
const PublicUserList = ({ users, loading, onRefetch }) => {
|
||||
const { userTypeLabels } = useConfigContext();
|
||||
|
||||
// Load saved location from localStorage on mount
|
||||
|
|
@ -32,9 +33,17 @@ const PublicUserList = ({ users, loading }) => {
|
|||
}
|
||||
}, [coords]);
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
const handleFilterChange = useCallback((key, value) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-fetch from backend when type filter changes (server-side filtering)
|
||||
useEffect(() => {
|
||||
if (onRefetch) {
|
||||
onRefetch(filters.type ? { type: filters.type } : {});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters.type]);
|
||||
|
||||
const requestLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
|
|
@ -72,49 +81,21 @@ const PublicUserList = ({ users, loading }) => {
|
|||
setPostalSearching(true);
|
||||
setLocationError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(postalCode)}&country=Germany&format=json&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'tracking-leaders-app/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
const result = await getGeocodeByPostalCode(postalCode.trim());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Geocoding fehlgeschlagen');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.length > 0) {
|
||||
setCoords({
|
||||
lat: parseFloat(data[0].lat),
|
||||
lng: parseFloat(data[0].lon)
|
||||
});
|
||||
if (result.success) {
|
||||
setCoords({ lat: result.data.lat, lng: result.data.lng });
|
||||
setLocationStatus('granted');
|
||||
setLocationError('');
|
||||
} else {
|
||||
setLocationError('Postleitzahl nicht gefunden');
|
||||
setLocationError(result.message || 'Fehler bei der PLZ-Suche');
|
||||
}
|
||||
} catch (error) {
|
||||
setLocationError('Fehler bei der PLZ-Suche');
|
||||
} finally {
|
||||
setPostalSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { list: filteredAndSortedUsers, missingGpsCount, allUsersWithGPS } = useMemo(() => {
|
||||
let filtered = [...users];
|
||||
|
||||
// Nur verfügbare Nachsuchenführer
|
||||
filtered = filtered.filter(user => user.available);
|
||||
|
||||
// Type filter
|
||||
if (filters.type) {
|
||||
filtered = filtered.filter(user => user.type === filters.type);
|
||||
}
|
||||
|
||||
const missingGps = filtered.filter(user => !(user.gps && user.gps.lat && user.gps.lng)).length;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import PublicUserList from '../components/public/PublicUserList';
|
||||
|
||||
const Home = ({ users, loading }) => {
|
||||
return <PublicUserList users={users} loading={loading} />;
|
||||
const Home = ({ users, loading, onRefetch }) => {
|
||||
return <PublicUserList users={users} loading={loading} onRefetch={onRefetch} />;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const handlerApi = axios.create({
|
|||
});
|
||||
|
||||
handlerApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('handlerToken');
|
||||
const token = sessionStorage.getItem('handlerToken');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
|
@ -18,8 +18,8 @@ export const handlerLogin = async (email, password) => {
|
|||
return response.data;
|
||||
};
|
||||
|
||||
export const setHandlerPassword = async (email, newPassword) => {
|
||||
const response = await handlerApi.post('/set-password', { email, newPassword });
|
||||
export const setHandlerPassword = async (email, inviteToken, newPassword) => {
|
||||
const response = await handlerApi.post('/set-password', { email, inviteToken, newPassword });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -225,3 +225,14 @@ export const deleteUserPhoto = async (id) => {
|
|||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getGeocodeByPostalCode = async (postalcode) => {
|
||||
try {
|
||||
const response = await api.get('/public/geocode', { params: { postalcode } });
|
||||
return { success: true, data: response.data.data };
|
||||
} catch (error) {
|
||||
const status = error.response?.status;
|
||||
if (status === 404) return { success: false, message: 'Postleitzahl nicht gefunden' };
|
||||
return { success: false, message: error.response?.data?.message || 'Geocoding fehlgeschlagen' };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ server {
|
|||
index index.html;
|
||||
|
||||
# Security headers for the SPA
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self'; frame-ancestors 'none';" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self'; frame-ancestors 'none';" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,36 @@
|
|||
require('dotenv').config();
|
||||
|
||||
const APP_NAME = 'stoeberhunde';
|
||||
|
||||
const config = {
|
||||
appName: APP_NAME,
|
||||
port: process.env.PORT || 5000,
|
||||
mongoUri: process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/stoeberhunde',
|
||||
jwtSecret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
jwtSecret: process.env.STOEBERHUNDE_JWT_SECRET || process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
corsOrigin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['http://localhost:5000'],
|
||||
geocodeUrl: process.env.GEOCODE_URL || 'https://nominatim.openstreetmap.org/search',
|
||||
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'stoeberhunde-app/1.0 (admin@localhost)',
|
||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10)
|
||||
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
|
||||
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
|
||||
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8082'
|
||||
};
|
||||
|
||||
// Validate required environment variables
|
||||
const requiredVars = ['JWT_SECRET', 'MONGO_URI'];
|
||||
const requiredVars = ['STOEBERHUNDE_JWT_SECRET', 'MONGO_URI'];
|
||||
const productionVars = ['ADMIN_THORSTEN_PASSWORD']; // Only warn in production
|
||||
|
||||
// In test environment, use defaults if not set
|
||||
if (config.nodeEnv === 'test') {
|
||||
// Set test defaults
|
||||
if (!process.env.JWT_SECRET) process.env.JWT_SECRET = 'test-secret-key';
|
||||
if (!process.env.STOEBERHUNDE_JWT_SECRET && !process.env.JWT_SECRET) process.env.STOEBERHUNDE_JWT_SECRET = 'test-secret-key';
|
||||
if (!process.env.MONGO_URI) process.env.MONGO_URI = 'mongodb://localhost:27017/test';
|
||||
} else {
|
||||
// Always validate critical vars (except in test)
|
||||
requiredVars.forEach(varName => {
|
||||
if (!process.env[varName]) {
|
||||
// Accept legacy JWT_SECRET as fallback so existing deployments keep working
|
||||
if (!process.env[varName] && !process.env.JWT_SECRET) {
|
||||
console.error(`❌ Fehler: ${varName} muss gesetzt sein!`);
|
||||
console.error(` Tipp: Kopiere .env.example zu .env und fülle die Werte aus.`);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const ResetToken = require('../models/ResetToken');
|
|||
const config = require('../config/env');
|
||||
const logger = require('../utils/logger');
|
||||
const { auditAuth } = require('../middleware/auditLogger');
|
||||
const { sendPasswordResetMail } = require('../utils/mailer');
|
||||
|
||||
const login = async (req, res) => {
|
||||
try {
|
||||
|
|
@ -39,17 +40,21 @@ const login = async (req, res) => {
|
|||
|
||||
// Generate token
|
||||
const token = jwt.sign(
|
||||
{ id: admin._id, username: admin.username },
|
||||
{ id: admin._id, username: admin.username, app: config.appName },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: config.jwtExpiresIn }
|
||||
);
|
||||
|
||||
// Set token in httpOnly cookie (XSS protection)
|
||||
const secureCookie = config.nodeEnv === 'production'
|
||||
? (req.secure || req.headers['x-forwarded-proto'] === 'https')
|
||||
: false;
|
||||
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true, // Not accessible via JavaScript (XSS protection)
|
||||
secure: false, // Allow over HTTP in development (localhost)
|
||||
sameSite: 'lax', // Relaxed CSRF protection (allows cross-port cookies on localhost)
|
||||
path: '/', // Ensure cookie is sent for all app routes, including /stoeberhunde/api
|
||||
httpOnly: true,
|
||||
secure: secureCookie,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||
});
|
||||
|
||||
|
|
@ -82,7 +87,7 @@ const logout = async (req, res) => {
|
|||
res.clearCookie('token', {
|
||||
httpOnly: true,
|
||||
secure: config.nodeEnv === 'production',
|
||||
sameSite: 'lax',
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
|
||||
|
|
@ -139,18 +144,31 @@ const forgotPassword = async (req, res) => {
|
|||
expiresAt
|
||||
});
|
||||
|
||||
// In production, send email here
|
||||
// For development, log the token
|
||||
if (config.nodeEnv !== 'production') {
|
||||
logger.info(`Password reset token for ${username}: ${token} (expires in 1 hour)`);
|
||||
// Build reset URL pointing at the SPA's reset page
|
||||
const resetUrl = `${config.appUrl}/passwort-zuruecksetzen?token=${token}`;
|
||||
|
||||
// Send reset email if admin has an email address and SMTP is configured
|
||||
if (admin.email) {
|
||||
try {
|
||||
const sent = await sendPasswordResetMail(admin.email, resetUrl);
|
||||
if (!sent) {
|
||||
logger.warn(`[forgotPassword] SMTP nicht konfiguriert. Reset-URL für ${username}: ${resetUrl}`);
|
||||
} else {
|
||||
logger.info(`Password reset token generiert für Benutzer: ${username}`);
|
||||
logger.info(`[forgotPassword] Reset-Mail an ${admin.email} gesendet (Benutzer: ${username})`);
|
||||
}
|
||||
} catch (mailErr) {
|
||||
logger.error(`[forgotPassword] Fehler beim Senden der Reset-Mail: ${mailErr.message}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`[forgotPassword] Admin "${username}" hat keine E-Mail-Adresse. ` +
|
||||
`Reset-URL: ${resetUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Reset-Token wurde generiert. In der Entwicklungsumgebung finden Sie den Token in den Logs.',
|
||||
...(config.nodeEnv === 'development' && { token }) // Only in dev!
|
||||
message: 'Falls der Benutzer existiert, wurde eine Reset-E-Mail gesendet.'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
const User = require('../models/User');
|
||||
const { geocodeAddress } = require('../utils/geocode');
|
||||
const logger = require('../utils/logger');
|
||||
const config = require('../config/env');
|
||||
|
||||
const ALLOWED_USER_FIELDS = ['name', 'type', 'address', 'phone', 'landline', 'email', 'available', 'gps', 'notes'];
|
||||
const MAX_PHOTO_BYTES = 2 * 1024 * 1024; // 2 MB base64
|
||||
|
||||
const getAllUsers = async (req, res) => {
|
||||
try {
|
||||
|
|
@ -28,7 +32,7 @@ const getAllUsers = async (req, res) => {
|
|||
.sort(req.query.search ? { score: { $meta: 'textScore' } } : { name: 1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.select('-__v'), // Exclude version key
|
||||
.select('-__v -passwordHash'),
|
||||
User.countDocuments(filter)
|
||||
]);
|
||||
|
||||
|
|
@ -54,7 +58,7 @@ const getAllUsers = async (req, res) => {
|
|||
|
||||
const getUserById = async (req, res) => {
|
||||
try {
|
||||
const user = await User.findById(req.params.id);
|
||||
const user = await User.findById(req.params.id).select('-passwordHash -__v');
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
|
|
@ -76,7 +80,9 @@ const getUserById = async (req, res) => {
|
|||
|
||||
const createUser = async (req, res) => {
|
||||
try {
|
||||
const payload = { ...req.body };
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(req.body).filter(([k]) => ALLOWED_USER_FIELDS.includes(k))
|
||||
);
|
||||
|
||||
let geoWarning = null;
|
||||
if (!payload.gps && payload.address) {
|
||||
|
|
@ -115,7 +121,9 @@ const updateUser = async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
const updateData = { ...req.body };
|
||||
const updateData = Object.fromEntries(
|
||||
Object.entries(req.body).filter(([k]) => ALLOWED_USER_FIELDS.includes(k))
|
||||
);
|
||||
const addressChanged = typeof updateData.address === 'string'
|
||||
&& updateData.address.trim() !== existing.address;
|
||||
const hasGpsInRequest = updateData.gps
|
||||
|
|
@ -261,8 +269,11 @@ const getPublicUsers = async (req, res) => {
|
|||
|
||||
// Filter for available users only + optional type filter
|
||||
const filter = { available: true };
|
||||
const allowedTypes = config.userTypes || [];
|
||||
if (req.query.type) {
|
||||
filter.type = req.query.type;
|
||||
if (allowedTypes.length === 0 || allowedTypes.includes(req.query.type)) {
|
||||
filter.type = String(req.query.type);
|
||||
}
|
||||
}
|
||||
if (req.query.search) {
|
||||
filter.$text = { $search: req.query.search };
|
||||
|
|
@ -465,6 +476,7 @@ const exportUsers = async (req, res) => {
|
|||
const importUsers = async (req, res) => {
|
||||
try {
|
||||
const { users: importList } = req.body;
|
||||
const overwrite = req.query.overwrite === 'true';
|
||||
|
||||
if (!Array.isArray(importList) || importList.length === 0) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -659,6 +671,9 @@ const uploadUserPhoto = async (req, res) => {
|
|||
if (!photo || typeof photo !== 'string' || !ALLOWED_PHOTO_TYPES.some(t => photo.startsWith(t))) {
|
||||
return res.status(400).json({ success: false, message: 'Ungültiges Bildformat. Erlaubt: JPEG, PNG, WebP, GIF' });
|
||||
}
|
||||
if (photo.length > MAX_PHOTO_BYTES) {
|
||||
return res.status(413).json({ success: false, message: 'Bild zu groß. Maximal 2 MB erlaubt.' });
|
||||
}
|
||||
const user = await User.findOne({ _id: req.params.id, deleted: { $ne: true } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
|
||||
|
|
@ -687,6 +702,34 @@ const deleteUserPhoto = async (req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/public/geocode?postalcode=XXXXX
|
||||
* Proxies postal code lookup through the backend geocoder (rate-limited, cached, correct User-Agent).
|
||||
*/
|
||||
const getGeocodeByPostalCode = async (req, res) => {
|
||||
const { postalcode } = req.query;
|
||||
|
||||
if (!postalcode || String(postalcode).trim().length < 4) {
|
||||
return res.status(400).json({ success: false, message: 'Bitte eine gültige Postleitzahl angeben (min. 4 Zeichen)' });
|
||||
}
|
||||
|
||||
const sanitized = String(postalcode).replace(/[^0-9]/g, '').slice(0, 10);
|
||||
if (!sanitized) {
|
||||
return res.status(400).json({ success: false, message: 'Ungültige Postleitzahl' });
|
||||
}
|
||||
|
||||
try {
|
||||
const coords = await geocodeAddress(`${sanitized}, Deutschland`);
|
||||
if (!coords) {
|
||||
return res.status(404).json({ success: false, message: 'Postleitzahl nicht gefunden' });
|
||||
}
|
||||
res.json({ success: true, data: coords });
|
||||
} catch (error) {
|
||||
logger.error('Geocoding-Fehler für PLZ:', error);
|
||||
res.status(500).json({ success: false, message: 'Geocoding-Fehler' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
|
|
@ -703,5 +746,6 @@ module.exports = {
|
|||
bulkUpdateUsers,
|
||||
bulkDeleteUsers,
|
||||
uploadUserPhoto,
|
||||
deleteUserPhoto
|
||||
deleteUserPhoto,
|
||||
getGeocodeByPostalCode
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ const authenticateToken = (req, res, next) => {
|
|||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwtSecret);
|
||||
// Reject tokens issued by a different app (C-01 cross-app auth fix)
|
||||
if (decoded.app && decoded.app !== config.appName) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Ungültiger oder abgelaufener Token.'
|
||||
});
|
||||
}
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,16 @@ const adminSchema = new mongoose.Schema({
|
|||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
sparse: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 6
|
||||
minlength: 12
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
|
|
@ -22,7 +28,7 @@ adminSchema.pre('save', async function(next) {
|
|||
if (!this.isModified('password')) return next();
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const salt = await bcrypt.genSalt(12);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
next();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^7.5.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ const { stoeberhundefuehrerLogin, setStoeberhundefuehrerPassword, generateInvite
|
|||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { authenticateStoeberhundefuehrer } = require('../middleware/stoeberhundefuehrerAuth');
|
||||
const { inviteLimiter } = require('../middleware/rateLimiter');
|
||||
const { authLimiter } = require('../middleware/rateLimiter');
|
||||
|
||||
// Public
|
||||
router.post('/login', stoeberhundefuehrerLogin);
|
||||
router.post('/login', authLimiter, stoeberhundefuehrerLogin);
|
||||
router.post('/set-password', inviteLimiter, setStoeberhundefuehrerPassword);
|
||||
|
||||
// Admin only — generate a one-time invite token so a Stöberhundeführer can set their password
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ const {
|
|||
bulkUpdateUsers,
|
||||
bulkDeleteUsers,
|
||||
uploadUserPhoto,
|
||||
deleteUserPhoto
|
||||
deleteUserPhoto,
|
||||
getGeocodeByPostalCode
|
||||
} = require('../controllers/userController');
|
||||
|
||||
// Public route
|
||||
// Public routes
|
||||
router.get('/public/users', getPublicUsers);
|
||||
router.get('/public/geocode', getGeocodeByPostalCode);
|
||||
|
||||
// Protected routes (require authentication)
|
||||
router.get('/users', authenticateToken, getAllUsers);
|
||||
|
|
|
|||
|
|
@ -104,4 +104,8 @@ const seedDatabase = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
seedDatabase();
|
||||
}
|
||||
|
||||
module.exports = seedDatabase;
|
||||
|
|
|
|||
|
|
@ -21,15 +21,12 @@ const connectWithRetry = async () => {
|
|||
const userCount = await User.countDocuments();
|
||||
if (userCount === 0) {
|
||||
logger.info('Datenbank ist leer, starte Seeding...');
|
||||
const { exec } = require('child_process');
|
||||
await new Promise((resolve) => {
|
||||
exec('node seed.js', { cwd: __dirname }, (seedError, stdout, stderr) => {
|
||||
if (seedError) logger.error('Fehler beim Seeding:', seedError.message);
|
||||
if (stdout) logger.info(stdout.trim());
|
||||
if (stderr) logger.warn(stderr.trim());
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
const seed = require('./seed');
|
||||
await seed();
|
||||
} catch (seedError) {
|
||||
logger.error('Fehler beim Seeding:', seedError.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Verbindungsversuch fehlgeschlagen, erneuter Versuch in 5 Sekunden...');
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const saveCache = async () => {
|
|||
};
|
||||
|
||||
// Periodically save cache
|
||||
setInterval(saveCache, CACHE_SAVE_INTERVAL);
|
||||
setInterval(saveCache, CACHE_SAVE_INTERVAL).unref();
|
||||
|
||||
// Save on process exit
|
||||
process.on('SIGINT', async () => {
|
||||
|
|
@ -123,6 +123,7 @@ const geocodeAddress = async (address) => {
|
|||
}
|
||||
|
||||
const coords = { lat, lng };
|
||||
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
|
||||
cache.set(cacheKey, coords);
|
||||
cacheModified = true;
|
||||
return coords;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
const nodemailer = require('nodemailer');
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Creates a nodemailer transporter from environment variables.
|
||||
* Required env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS
|
||||
* Optional env vars: SMTP_FROM (defaults to SMTP_USER), SMTP_SECURE (defaults to 'true' if port 465)
|
||||
*
|
||||
* Returns null if SMTP is not configured so callers can fall back gracefully.
|
||||
*/
|
||||
const createTransport = () => {
|
||||
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS } = process.env;
|
||||
if (!SMTP_HOST || !SMTP_USER || !SMTP_PASS) {
|
||||
return null;
|
||||
}
|
||||
const port = parseInt(SMTP_PORT || '587', 10);
|
||||
const secure = process.env.SMTP_SECURE !== undefined
|
||||
? process.env.SMTP_SECURE === 'true'
|
||||
: port === 465;
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port,
|
||||
secure,
|
||||
auth: { user: SMTP_USER, pass: SMTP_PASS }
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a password-reset e-mail to the given address.
|
||||
*
|
||||
* @param {string} toEmail – recipient address
|
||||
* @param {string} resetUrl – full URL including token query parameter
|
||||
* @returns {Promise<boolean>} true if mail was sent, false if SMTP is not configured
|
||||
*/
|
||||
const sendPasswordResetMail = async (toEmail, resetUrl) => {
|
||||
const transport = createTransport();
|
||||
if (!transport) {
|
||||
logger.warn(
|
||||
'[mailer] SMTP nicht konfiguriert (SMTP_HOST/SMTP_USER/SMTP_PASS fehlen). ' +
|
||||
'Reset-Link wird nur geloggt.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
|
||||
await transport.sendMail({
|
||||
from,
|
||||
to: toEmail,
|
||||
subject: 'Passwort-Reset',
|
||||
text:
|
||||
`Sie haben einen Passwort-Reset angefordert.\n\n` +
|
||||
`Klicken Sie auf den folgenden Link (gültig für 1 Stunde):\n${resetUrl}\n\n` +
|
||||
`Falls Sie keinen Reset angefordert haben, ignorieren Sie diese Mail.`,
|
||||
html:
|
||||
`<p>Sie haben einen Passwort-Reset angefordert.</p>` +
|
||||
`<p><a href="${resetUrl}">Passwort zurücksetzen</a></p>` +
|
||||
`<p>Der Link ist 1 Stunde gültig.</p>` +
|
||||
`<p>Falls Sie keinen Reset angefordert haben, ignorieren Sie diese Mail.</p>`
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
module.exports = { sendPasswordResetMail };
|
||||
|
|
@ -27,16 +27,25 @@ services:
|
|||
networks:
|
||||
- default
|
||||
- jagd-network
|
||||
volumes:
|
||||
- geocode-cache:/app/geocode-cache.json
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- MONGO_URI=mongodb://stoeberhunde:${MONGO_PASSWORD}@mongo:27017/stoeberhunde?authSource=admin
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- JWT_SECRET=${STOEBERHUNDE_JWT_SECRET}
|
||||
- JWT_EXPIRES_IN=24h
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:8082}
|
||||
- ADMIN_THORSTEN_PASSWORD=${ADMIN_THORSTEN_PASSWORD}
|
||||
- GEOCODE_URL=https://nominatim.openstreetmap.org/search
|
||||
- GEOCODE_USER_AGENT=stoeberhunde-app/1.0 (t.meyer@jaegerschaft-fallingbostel.de)
|
||||
- GEOCODE_MIN_DELAY_MS=1100
|
||||
# E-Mail / Passwort-Reset (optional)
|
||||
# - SMTP_HOST=mail.example.com
|
||||
# - SMTP_PORT=587
|
||||
# - SMTP_USER=user@example.com
|
||||
# - SMTP_PASS=${SMTP_PASS}
|
||||
# - SMTP_FROM=stoeberhunde@example.com
|
||||
# - APP_URL=https://example.com/stoeberhunde
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
|
|
@ -71,6 +80,8 @@ services:
|
|||
volumes:
|
||||
mongo-data:
|
||||
name: stoeberhunde-mongo-data
|
||||
geocode-cache:
|
||||
name: stoeberhunde-geocode-cache
|
||||
|
||||
networks:
|
||||
default: {}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ function App() {
|
|||
/>
|
||||
<main className="app-main">
|
||||
{view === 'rules' && <Rules />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} onRefetch={refetch} />}
|
||||
{view === 'admin' && (
|
||||
<Admin
|
||||
users={users}
|
||||
|
|
@ -109,7 +109,7 @@ function App() {
|
|||
) : (
|
||||
<>
|
||||
{view === 'rules' && <Rules />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} />}
|
||||
{view === 'public' && <Home users={users} loading={usersLoading} onRefetch={refetch} />}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ function PasswordReset() {
|
|||
setMessage({ type: '', text: '' });
|
||||
|
||||
// Validation
|
||||
if (newPassword.length < 6) {
|
||||
setMessage({ type: 'error', text: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
if (newPassword.length < 12) {
|
||||
setMessage({ type: 'error', text: 'Passwort muss mindestens 12 Zeichen lang sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -128,8 +128,8 @@ function PasswordReset() {
|
|||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
minLength={6}
|
||||
placeholder="Mindestens 12 Zeichen"
|
||||
minLength={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -143,7 +143,7 @@ function PasswordReset() {
|
|||
required
|
||||
disabled={loading}
|
||||
placeholder="Passwort wiederholen"
|
||||
minLength={6}
|
||||
minLength={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useConfigContext } from '../../contexts/ConfigContext';
|
||||
import MapView from '../map/MapView';
|
||||
import FilterPanel from '../users/FilterPanel';
|
||||
import { calculateDistance } from '../../utils/helpers';
|
||||
import { getGeocodeByPostalCode } from '../../services/users';
|
||||
import './PublicUserList.css';
|
||||
|
||||
const PublicUserList = ({ users, loading }) => {
|
||||
const PublicUserList = ({ users, loading, onRefetch }) => {
|
||||
const { userTypeLabels } = useConfigContext();
|
||||
|
||||
// Load saved location from localStorage on mount
|
||||
|
|
@ -32,9 +33,17 @@ const PublicUserList = ({ users, loading }) => {
|
|||
}
|
||||
}, [coords]);
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
const handleFilterChange = useCallback((key, value) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-fetch from backend when type filter changes (server-side filtering)
|
||||
useEffect(() => {
|
||||
if (onRefetch) {
|
||||
onRefetch(filters.type ? { type: filters.type } : {});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters.type]);
|
||||
|
||||
const requestLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
|
|
@ -72,49 +81,21 @@ const PublicUserList = ({ users, loading }) => {
|
|||
setPostalSearching(true);
|
||||
setLocationError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(postalCode)}&country=Germany&format=json&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'stoeberhunde-app/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
const result = await getGeocodeByPostalCode(postalCode.trim());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Geocoding fehlgeschlagen');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.length > 0) {
|
||||
setCoords({
|
||||
lat: parseFloat(data[0].lat),
|
||||
lng: parseFloat(data[0].lon)
|
||||
});
|
||||
if (result.success) {
|
||||
setCoords({ lat: result.data.lat, lng: result.data.lng });
|
||||
setLocationStatus('granted');
|
||||
setLocationError('');
|
||||
} else {
|
||||
setLocationError('Postleitzahl nicht gefunden');
|
||||
setLocationError(result.message || 'Fehler bei der PLZ-Suche');
|
||||
}
|
||||
} catch (error) {
|
||||
setLocationError('Fehler bei der PLZ-Suche');
|
||||
} finally {
|
||||
setPostalSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { list: filteredAndSortedUsers, missingGpsCount, allUsersWithGPS } = useMemo(() => {
|
||||
let filtered = [...users];
|
||||
|
||||
// Nur verfügbare Stöberhundeführer
|
||||
filtered = filtered.filter(user => user.available);
|
||||
|
||||
// Type filter
|
||||
if (filters.type) {
|
||||
filtered = filtered.filter(user => user.type === filters.type);
|
||||
}
|
||||
|
||||
const missingGps = filtered.filter(user => !(user.gps && user.gps.lat && user.gps.lng)).length;
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const StoeberhundefuehrerDashboard = ({ stoeberhundefuehrerUser, onLogout }) =>
|
|||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('stoeberhundefuehrerToken');
|
||||
sessionStorage.removeItem('stoeberhundefuehrerToken');
|
||||
onLogout();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const StoeberhundefuehrerLogin = ({ onLogin }) => {
|
|||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newPassword2, setNewPassword2] = useState('');
|
||||
const [inviteToken, setInviteToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -19,7 +20,7 @@ const StoeberhundefuehrerLogin = ({ onLogin }) => {
|
|||
try {
|
||||
const result = await stoeberhundefuehrerLogin(email, password);
|
||||
if (result.success) {
|
||||
localStorage.setItem('stoeberhundefuehrerToken', result.token);
|
||||
sessionStorage.setItem('stoeberhundefuehrerToken', result.token);
|
||||
onLogin(result.user);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -36,16 +37,17 @@ const StoeberhundefuehrerLogin = ({ onLogin }) => {
|
|||
setError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen haben');
|
||||
if (newPassword.length < 8) {
|
||||
setError('Passwort muss mindestens 8 Zeichen haben');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await setStoeberhundefuehrerPassword(email, newPassword);
|
||||
const result = await setStoeberhundefuehrerPassword(email, inviteToken, newPassword);
|
||||
if (result.success) {
|
||||
setSuccess('Passwort erfolgreich gesetzt. Sie können sich nun anmelden.');
|
||||
setMode('login');
|
||||
setInviteToken('');
|
||||
setNewPassword('');
|
||||
setNewPassword2('');
|
||||
}
|
||||
|
|
@ -99,15 +101,19 @@ const StoeberhundefuehrerLogin = ({ onLogin }) => {
|
|||
) : (
|
||||
<form onSubmit={handleSetPassword} className="stoeberhundefuehrer-form">
|
||||
<p className="stoeberhundefuehrer-hint">
|
||||
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail und ein neues Passwort ein.
|
||||
Der Admin hat Ihre E-Mail-Adresse hinterlegt. Geben Sie hier Ihre E-Mail, den Einladungs-Token und ein neues Passwort ein.
|
||||
</p>
|
||||
<div className="form-group">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Neues Passwort (min. 6 Zeichen)</label>
|
||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={6} />
|
||||
<label>Einladungs-Token</label>
|
||||
<input type="text" value={inviteToken} onChange={e => setInviteToken(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Neues Passwort (min. 8 Zeichen)</label>
|
||||
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Passwort wiederholen</label>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import PublicUserList from '../components/public/PublicUserList';
|
||||
|
||||
const Home = ({ users, loading }) => {
|
||||
return <PublicUserList users={users} loading={loading} />;
|
||||
const Home = ({ users, loading, onRefetch }) => {
|
||||
return <PublicUserList users={users} loading={loading} onRefetch={onRefetch} />;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const stoeberhundefuehrerApi = axios.create({
|
|||
});
|
||||
|
||||
stoeberhundefuehrerApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('stoeberhundefuehrerToken');
|
||||
const token = sessionStorage.getItem('stoeberhundefuehrerToken');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
|
@ -18,8 +18,8 @@ export const stoeberhundefuehrerLogin = async (email, password) => {
|
|||
return response.data;
|
||||
};
|
||||
|
||||
export const setStoeberhundefuehrerPassword = async (email, newPassword) => {
|
||||
const response = await stoeberhundefuehrerApi.post('/set-password', { email, newPassword });
|
||||
export const setStoeberhundefuehrerPassword = async (email, inviteToken, newPassword) => {
|
||||
const response = await stoeberhundefuehrerApi.post('/set-password', { email, inviteToken, newPassword });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -225,3 +225,14 @@ export const deleteUserPhoto = async (id) => {
|
|||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getGeocodeByPostalCode = async (postalcode) => {
|
||||
try {
|
||||
const response = await api.get('/public/geocode', { params: { postalcode } });
|
||||
return { success: true, data: response.data.data };
|
||||
} catch (error) {
|
||||
const status = error.response?.status;
|
||||
if (status === 404) return { success: false, message: 'Postleitzahl nicht gefunden' };
|
||||
return { success: false, message: error.response?.data?.message || 'Geocoding fehlgeschlagen' };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ server {
|
|||
index index.html;
|
||||
|
||||
# Security headers for the SPA
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self'; frame-ancestors 'none';" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self'; frame-ancestors 'none';" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
|
|
|||
Loading…
Reference in New Issue