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
@@ -111,8 +111,12 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if req.Email == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
|
||||
if req.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// 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 {
|
||||
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")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
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")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
||||
if strings.TrimSpace(req.Email) == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
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")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
||||
if strings.TrimSpace(req.Email) == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
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 err := auth.ValidatePassword(*req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(*req.Password)
|
||||
if err != nil {
|
||||
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())
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "Das Passwort braucht mindestens 4 Zeichen")
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
|
||||
@@ -1344,7 +1344,16 @@ func TestFirstBootSetupWithSampleData(t *testing.T) {
|
||||
defer store.Close()
|
||||
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.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -25,6 +27,27 @@ type Claims struct {
|
||||
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) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
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