fix(auth): new passwords need at least 8 characters (FLAW-09)
auth.ValidatePassword checks every way a password is set: setup, registration, profile, admin create and reset, reset by mail. Same rule in the browser before sending (lib/password.ts), with our own German message instead of the browser popup. Existing shorter passwords still log in. Profile and admin reset no longer trim the password, which made passwords with surrounding spaces unusable for login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8989d0c44
commit
b5f926bcf7
13 files changed
+142
-26
No files matched your search
+1
-1
@@ -13,7 +13,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
||||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||||
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
|
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
|
||||||
| POST | `/auth/reset-password` | `{token, password}` (mind. 4 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
||||||
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
||||||
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
|
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
|
||||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
|
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
|
|||||||
## Authentifizierung
|
## Authentifizierung
|
||||||
|
|
||||||
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
||||||
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||||
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
||||||
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403` ([FLAW-07](flaws.md)).
|
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403` ([FLAW-07](flaws.md)).
|
||||||
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
|
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
|
||||||
|
|||||||
+2
-1
@@ -18,7 +18,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
|
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
|
||||||
| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig |
|
| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig |
|
||||||
| FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** |
|
| FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** |
|
||||||
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
|
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen; ~~Mindestpasswort 4 Zeichen~~ | Mittel |
|
||||||
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
|
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
|
||||||
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
|
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
|
||||||
| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder~~; kein Backoff bei Fehlern | **Teilweise behoben** |
|
| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder~~; kein Backoff bei Fehlern | **Teilweise behoben** |
|
||||||
@@ -90,6 +90,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
|
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
|
||||||
|
|
||||||
### FLAW-09: Kein Schutz gegen Brute-Force
|
### FLAW-09: Kein Schutz gegen Brute-Force
|
||||||
|
- **Teilweise behoben:** Neue Passwörter brauchen überall mindestens 8 Zeichen (Setup, Registrierung, Profil, Admin-Anlage und -Reset, Reset per Mail), geprüft zentral in `auth.ValidatePassword` und vorab im Browser (`lib/password.ts`). Bestehende kürzere Passwörter funktionieren beim Anmelden weiter. Offen: Rate-Limit und Sperre.
|
||||||
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
|
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
|
||||||
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
|
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
|
||||||
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
|
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { AuthStatus, SharingMode, User } from '../../lib/types';
|
|||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
import { LegalLinks } from '../../components/LegalLinks';
|
import { LegalLinks } from '../../components/LegalLinks';
|
||||||
|
import { passwordProblem } from '../../lib/password';
|
||||||
|
|
||||||
interface SetupAndAuthViewProps {
|
interface SetupAndAuthViewProps {
|
||||||
authStatus: AuthStatus;
|
authStatus: AuthStatus;
|
||||||
@@ -74,6 +75,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
const handleResetPassword = async (e: React.FormEvent) => {
|
const handleResetPassword = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const resetProblem = passwordProblem(newPassword);
|
||||||
|
if (resetProblem) {
|
||||||
|
setError(resetProblem);
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (newPassword !== newPasswordConfirm) {
|
if (newPassword !== newPasswordConfirm) {
|
||||||
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||||
triggerHaptic(25);
|
triggerHaptic(25);
|
||||||
@@ -99,6 +106,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
const setupProblem = passwordProblem(setupPassword);
|
||||||
|
if (setupProblem) {
|
||||||
|
setError(setupProblem);
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (setupPassword !== setupPasswordConfirm) {
|
if (setupPassword !== setupPasswordConfirm) {
|
||||||
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||||
triggerHaptic(25);
|
triggerHaptic(25);
|
||||||
@@ -143,6 +156,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
const handleRegisterOrInvite = async (e: React.FormEvent) => {
|
const handleRegisterOrInvite = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const registerProblem = passwordProblem(password);
|
||||||
|
if (registerProblem) {
|
||||||
|
setError(registerProblem);
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
try {
|
try {
|
||||||
@@ -235,8 +254,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={setupPassword}
|
value={setupPassword}
|
||||||
onChange={(e) => setSetupPassword(e.target.value)}
|
onChange={(e) => setSetupPassword(e.target.value)}
|
||||||
placeholder="Mindestens 4 Zeichen"
|
placeholder="Mindestens 8 Zeichen"
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
className="w-full h-8 px-2.5 input-field"
|
className="w-full h-8 px-2.5 input-field"
|
||||||
/>
|
/>
|
||||||
@@ -248,7 +266,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
value={setupPasswordConfirm}
|
value={setupPasswordConfirm}
|
||||||
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
|
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
|
||||||
placeholder="Passwort erneut eingeben"
|
placeholder="Passwort erneut eingeben"
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
className={`w-full h-8 px-2.5 input-field ${
|
className={`w-full h-8 px-2.5 input-field ${
|
||||||
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
|
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
|
||||||
@@ -529,7 +546,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
minLength={4}
|
placeholder="Mindestens 8 Zeichen"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@@ -542,7 +559,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={newPasswordConfirm}
|
value={newPasswordConfirm}
|
||||||
onChange={(e) => setNewPasswordConfirm(e.target.value)}
|
onChange={(e) => setNewPasswordConfirm(e.target.value)}
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||||
@@ -716,8 +732,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Mindestens 4 Zeichen"
|
placeholder="Mindestens 8 Zeichen"
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
className="w-full h-8 px-2.5 input-field"
|
className="w-full h-8 px-2.5 input-field"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
UserRole,
|
UserRole,
|
||||||
} from '../../lib/types';
|
} from '../../lib/types';
|
||||||
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
|
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
|
||||||
|
import { passwordProblem } from '../../lib/password';
|
||||||
import { UserAvatar } from '../../components/UserAvatar';
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
@@ -151,6 +152,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
const handleCreateUser = async (e: React.FormEvent) => {
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setStatusMsg(null);
|
setStatusMsg(null);
|
||||||
|
const problem = passwordProblem(newUserPassword);
|
||||||
|
if (problem) {
|
||||||
|
setStatusMsg({ type: 'err', text: problem });
|
||||||
|
return;
|
||||||
|
}
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
try {
|
try {
|
||||||
const created = await api.adminCreateUser({
|
const created = await api.adminCreateUser({
|
||||||
@@ -235,8 +241,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
const handleConfirmResetPassword = async (userId: string) => {
|
const handleConfirmResetPassword = async (userId: string) => {
|
||||||
if (!resetPwdVal.trim()) return;
|
if (!resetPwdVal.trim()) return;
|
||||||
setStatusMsg(null);
|
setStatusMsg(null);
|
||||||
|
const problem = passwordProblem(resetPwdVal);
|
||||||
|
if (problem) {
|
||||||
|
setStatusMsg({ type: 'err', text: problem });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api.adminResetPassword(userId, resetPwdVal.trim());
|
// Not trimmed: login compares the password exactly as typed
|
||||||
|
await api.adminResetPassword(userId, resetPwdVal);
|
||||||
setResetPwdUserId(null);
|
setResetPwdUserId(null);
|
||||||
setResetPwdVal('');
|
setResetPwdVal('');
|
||||||
setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' });
|
setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' });
|
||||||
@@ -555,8 +567,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={newUserPassword}
|
value={newUserPassword}
|
||||||
onChange={(e) => setNewUserPassword(e.target.value)}
|
onChange={(e) => setNewUserPassword(e.target.value)}
|
||||||
placeholder="Mind. 4 Zeichen"
|
placeholder="Mind. 8 Zeichen"
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||||
/>
|
/>
|
||||||
@@ -843,7 +854,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={resetPwdVal}
|
value={resetPwdVal}
|
||||||
onChange={(e) => setResetPwdVal(e.target.value)}
|
onChange={(e) => setResetPwdVal(e.target.value)}
|
||||||
placeholder="Neues Passwort eingeben…"
|
placeholder="Neues Passwort (mind. 8 Zeichen)…"
|
||||||
className="flex-1 h-7 px-2 input-field"
|
className="flex-1 h-7 px-2 input-field"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
|
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
|
||||||
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
||||||
|
import { passwordProblem } from '../../lib/password';
|
||||||
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
||||||
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
||||||
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||||
@@ -283,7 +284,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
avatarUrl: avatarUrl.trim(),
|
avatarUrl: avatarUrl.trim(),
|
||||||
};
|
};
|
||||||
if (newPassword.trim()) {
|
if (newPassword.trim()) {
|
||||||
patch.password = newPassword.trim();
|
const problem = passwordProblem(newPassword);
|
||||||
|
if (problem) {
|
||||||
|
setProfileMsg({ type: 'err', text: problem });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Not trimmed: login compares the password exactly as typed
|
||||||
|
patch.password = newPassword;
|
||||||
}
|
}
|
||||||
const updated = await api.updateSettings(patch);
|
const updated = await api.updateSettings(patch);
|
||||||
onUserUpdated(updated);
|
onUserUpdated(updated);
|
||||||
@@ -696,7 +703,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="Mindestens 4 Zeichen"
|
placeholder="Mindestens 8 Zeichen"
|
||||||
className="w-full h-9 px-3 input-field"
|
className="w-full h-9 px-3 input-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Same rule as the server (auth.ValidatePassword): at least 8 characters, not only spaces.
|
||||||
|
// Only for setting a password; logging in with an older, shorter one keeps working.
|
||||||
|
export const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
export const passwordProblem = (password: string): string | null =>
|
||||||
|
!password.trim() || Array.from(password).length < MIN_PASSWORD_LENGTH
|
||||||
|
? `Das Passwort muss mindestens ${MIN_PASSWORD_LENGTH} Zeichen lang sein`
|
||||||
|
: null;
|
||||||
@@ -111,8 +111,12 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Email == "" || req.Password == "" {
|
if req.Email == "" {
|
||||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
|
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only full server admin can create another server admin
|
// Only full server admin can create another server admin
|
||||||
@@ -200,10 +204,14 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
|
|||||||
var req struct {
|
var req struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Password) == "" {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
|
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
||||||
|
|||||||
@@ -90,8 +90,12 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
if strings.TrimSpace(req.Email) == "" {
|
||||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.DisplayName) == "" {
|
if strings.TrimSpace(req.DisplayName) == "" {
|
||||||
@@ -154,8 +158,12 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
if strings.TrimSpace(req.Email) == "" {
|
||||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.DisplayName) == "" {
|
if strings.TrimSpace(req.DisplayName) == "" {
|
||||||
@@ -415,6 +423,10 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||||
|
if err := auth.ValidatePassword(*req.Password); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
hash, err := auth.HashPassword(*req.Password)
|
hash, err := auth.HashPassword(*req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts")
|
writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts")
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, storage.ErrResetTokenInvalid.Error())
|
writeError(w, http.StatusBadRequest, storage.ErrResetTokenInvalid.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 4 {
|
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Das Passwort braucht mindestens 4 Zeichen")
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
|
|||||||
@@ -1344,7 +1344,16 @@ func TestFirstBootSetupWithSampleData(t *testing.T) {
|
|||||||
defer store.Close()
|
defer store.Close()
|
||||||
srv := NewServer(&config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil)
|
srv := NewServer(&config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil)
|
||||||
|
|
||||||
body := []byte(`{"email":"me@example.com","password":"secret","displayName":"Ich","createSampleData":true}`)
|
// Too short passwords are refused (FLAW-09)
|
||||||
|
short := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", strings.NewReader(`{"email":"me@example.com","password":"secret"}`))
|
||||||
|
short.Header.Set("Content-Type", "application/json")
|
||||||
|
shortRec := httptest.NewRecorder()
|
||||||
|
srv.Router().ServeHTTP(shortRec, short)
|
||||||
|
if shortRec.Code != http.StatusBadRequest || !strings.Contains(shortRec.Body.String(), "mindestens 8 Zeichen") {
|
||||||
|
t.Fatalf("setup with 6 characters: want 400, got %d %s", shortRec.Code, shortRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
body := []byte(`{"email":"me@example.com","password":"secret123","displayName":"Ich","createSampleData":true}`)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -25,6 +27,27 @@ type Claims struct {
|
|||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MinPasswordLength applies wherever a password is set (setup, registration, profile, admin, reset).
|
||||||
|
// Existing shorter passwords keep working for login.
|
||||||
|
const MinPasswordLength = 8
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPasswordTooShort = fmt.Errorf("Das Passwort muss mindestens %d Zeichen lang sein", MinPasswordLength)
|
||||||
|
// bcrypt only uses the first 72 bytes and rejects longer input
|
||||||
|
ErrPasswordTooLong = errors.New("Das Passwort darf höchstens 72 Bytes lang sein")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidatePassword checks a new password: at least MinPasswordLength characters, not only whitespace.
|
||||||
|
func ValidatePassword(password string) error {
|
||||||
|
if strings.TrimSpace(password) == "" || utf8.RuneCountInString(password) < MinPasswordLength {
|
||||||
|
return ErrPasswordTooShort
|
||||||
|
}
|
||||||
|
if len(password) > 72 {
|
||||||
|
return ErrPasswordTooLong
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
return string(bytes), err
|
return string(bytes), err
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidatePassword(t *testing.T) {
|
||||||
|
cases := map[string]error{
|
||||||
|
"geheim12": nil,
|
||||||
|
"äöüßäöüß": nil, // counts characters, not bytes
|
||||||
|
"kurz": ErrPasswordTooShort,
|
||||||
|
" ": ErrPasswordTooShort,
|
||||||
|
strings.Repeat("a", 73): ErrPasswordTooLong,
|
||||||
|
"leer zeichen sind ok": nil,
|
||||||
|
}
|
||||||
|
for pw, want := range cases {
|
||||||
|
if got := ValidatePassword(pw); got != want {
|
||||||
|
t.Errorf("ValidatePassword(%q) = %v, want %v", pw, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user