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:
sandrodachandClaude Opus 5.5 committed 2026-10-29 12:20:46 +01:00
1 parent f8989d0c44
commit b5f926bcf7
13 files changed
+142 -26

No files matched your search

+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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
View File
@@ -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>
+8
View File
@@ -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;
+11 -3
View File
@@ -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")
+16 -4
View File
@@ -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")
+2 -2
View File
@@ -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)
+10 -1
View File
@@ -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()
+23
View File
@@ -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
+22
View File
@@ -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)
}
}
}