feat: Löschfristen je Datenklasse, Passwort-Zurücksetzen, DPF-Recherche
- Löschfristen (Migration 0020): pro Mandant einstellbar statt fest im Regelwerk, da die DSGVO selbst keine festen Fristen nennt (Art. 5 Abs. 1 lit. e). Jede Firma wird mit risikogestaffelten Vorschlagswerten vorbelegt, loeschfrist_max_tage wird jetzt tatsächlich hart gegen Werkzeuge gefiltert (schließt rules/OPEN.md Punkt 4). - Neues internal/mail-Paket (SMTP-Versand + Test-Doppel) und darauf aufbauend Passwort-Zurücksetzen (Migration 0019) - bisher nur als Absicht in der Rollentabelle genannt, nie gebaut. - dpf_zertifiziert für alle 20 Katalogeinträge gegen das offizielle DPF-Register recherchiert und in der Produktions-DB aktualisiert (12 zertifiziert, 5 recherchiert-nicht-gefunden, 3 nicht anwendbar). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -290,16 +290,25 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
|
||||
}
|
||||
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||
for i, w := range werkzeuge {
|
||||
aufbewahrungTage := 0
|
||||
if w.AufbewahrungTage != nil {
|
||||
aufbewahrungTage = *w.AufbewahrungTage
|
||||
}
|
||||
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
||||
}}
|
||||
}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||
|
||||
var loeschfristMaxTage *int
|
||||
loeschfristen, err := s.store.ListLoeschfristEinstellungenForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range loeschfristen {
|
||||
if l.DatenklasseID == datenklasse.ID {
|
||||
maxTage := l.MaxTage
|
||||
loeschfristMaxTage = &maxTage
|
||||
}
|
||||
}
|
||||
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs, loeschfristMaxTage)
|
||||
in.ZulaessigeWerkzeuge = zulaessig
|
||||
for _, a := range ausgeschlossen {
|
||||
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
||||
|
||||
@@ -248,6 +248,51 @@ func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragCreateFiltertNachLoeschfrist(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
|
||||
sess := fs.sessions[cookie.Value]
|
||||
accountID := fs.users[sess.UserID].AccountID
|
||||
// fullAntragForm loest Datenklasse "personenbezogen" aus (b1=ja).
|
||||
fs.loeschfristen[accountID] = map[string]int{"personenbezogen": 30}
|
||||
|
||||
fs.werkzeuge["kurze-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "kurze-aufbewahrung", Name: "Kurz-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(14),
|
||||
}
|
||||
fs.werkzeuge["lange-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "lange-aufbewahrung", Name: "Lang-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(90),
|
||||
}
|
||||
fs.werkzeuge["unbekannte-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "unbekannte-aufbewahrung", Name: "Unbekannt-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true,
|
||||
}
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "kurze-aufbewahrung" {
|
||||
t.Fatalf("ZulaessigeWerkzeuge = %v, want [kurze-aufbewahrung]", b.ZulaessigeWerkzeuge)
|
||||
}
|
||||
if len(b.AusgeschlosseneWerkzeuge) != 2 {
|
||||
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want 2 ausgeschlossene", b.AusgeschlosseneWerkzeuge)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
)
|
||||
|
||||
type authPageData struct {
|
||||
Title string
|
||||
Error string
|
||||
Title string
|
||||
Error string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
|
||||
@@ -23,7 +24,11 @@ func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
|
||||
data := authPageData{Title: "Anmelden"}
|
||||
if r.URL.Query().Get("reset") == "1" {
|
||||
data.Message = "Passwort erfolgreich geändert. Bitte melde dich mit dem neuen Passwort an."
|
||||
}
|
||||
s.renderAuthPage(w, "login", data)
|
||||
}
|
||||
|
||||
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
|
||||
@@ -70,6 +75,10 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Genehmiger-Rollen konnten nicht angelegt werden"})
|
||||
return
|
||||
}
|
||||
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Löschfristen konnten nicht angelegt werden"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
||||
|
||||
@@ -154,6 +154,13 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Löschfristen konnten nicht angelegt werden: " + err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
98
internal/web/loeschfrist_handlers.go
Normal file
98
internal/web/loeschfrist_handlers.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Löschfristen je Datenklasse (Migration 0020) — schließt rules/OPEN.md,
|
||||
// Punkt 4. Die Tageswerte sind KEINE gesetzliche Vorgabe (die DSGVO
|
||||
// nennt keine festen Fristen, nur den Grundsatz "so lange wie für den
|
||||
// Zweck nötig", Art. 5 Abs. 1 lit. e) — deshalb pro Mandant einstellbar,
|
||||
// vom Datenschutzbeauftragten hinterlegt, mit risikogestaffelten
|
||||
// Vorschlagswerten vorbelegt. Wie alle anderen /verwaltung/*-Seiten
|
||||
// bewusst admin-only (konsistent mit Nutzer-/Abteilungs-/Genehmiger-
|
||||
// Rollen-Verwaltung) statt an den Namen einer Genehmiger-Rolle
|
||||
// gekoppelt — Genehmiger-Rollen sind laut Architektur explizit ohne
|
||||
// eigenes Zugriffsrecht (siehe CLAUDE.md, Freigabe-Workflow).
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// standardLoeschfristen sind risikogestaffelte Vorschlagswerte, mit
|
||||
// denen jede neue Firma vorbelegt wird — je sensibler die Datenklasse,
|
||||
// desto kürzer die vorgeschlagene Frist. Frei editierbar unter
|
||||
// /verwaltung/loeschfristen, keine Rechtsvorgabe.
|
||||
var standardLoeschfristen = []struct {
|
||||
DatenklasseID string
|
||||
MaxTage int
|
||||
}{
|
||||
{"oeffentlich", 365},
|
||||
{"intern", 180},
|
||||
{"auftragsdaten", 90},
|
||||
{"personenbezogen", 90},
|
||||
{"berufsgeheimnis", 30},
|
||||
{"besondere_kategorie", 30},
|
||||
}
|
||||
|
||||
// seedStandardLoeschfristen wird direkt nach dem Anlegen einer neuen
|
||||
// Firma aufgerufen (öffentliche Registrierung und Betreiber-Firmenanlage),
|
||||
// analog zu seedStandardGenehmigerRollen.
|
||||
func (s *Server) seedStandardLoeschfristen(ctx context.Context, accountID string) error {
|
||||
for _, l := range standardLoeschfristen {
|
||||
if _, err := s.store.UpsertLoeschfristEinstellung(ctx, accountID, l.DatenklasseID, l.MaxTage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type loeschfristZeile struct {
|
||||
DatenklasseID string
|
||||
MaxTage int
|
||||
}
|
||||
|
||||
type loeschfristenListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Zeilen []loeschfristZeile
|
||||
Error string
|
||||
Gespeichert bool
|
||||
}
|
||||
|
||||
func (s *Server) handleLoeschfristenListe(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
einstellungen, err := s.store.ListLoeschfristEinstellungenForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Löschfristen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := loeschfristenListeData{Title: "Löschfristen", Nav: navFor(r), Gespeichert: r.URL.Query().Get("gespeichert") == "1"}
|
||||
for _, e := range einstellungen {
|
||||
data.Zeilen = append(data.Zeilen, loeschfristZeile{DatenklasseID: e.DatenklasseID, MaxTage: e.MaxTage})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "loeschfristen-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLoeschfristenSpeichern(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accountID := currentUser(r).AccountID
|
||||
for _, l := range standardLoeschfristen {
|
||||
raw := r.FormValue("max_tage_" + l.DatenklasseID)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
maxTage, err := strconv.Atoi(raw)
|
||||
if err != nil || maxTage < 0 {
|
||||
http.Error(w, "ungültiger Wert für "+l.DatenklasseID, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpsertLoeschfristEinstellung(r.Context(), accountID, l.DatenklasseID, maxTage); err != nil {
|
||||
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/loeschfristen?gespeichert=1", http.StatusSeeOther)
|
||||
}
|
||||
103
internal/web/passwort_reset_handlers.go
Normal file
103
internal/web/passwort_reset_handlers.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Passwort-Zurücksetzen (Ebene 1, öffentlich) — nutzt internal/mail für
|
||||
// den Versand. Wie beim Login verrät keine Fehlermeldung, ob eine
|
||||
// E-Mail-Adresse überhaupt existiert (User-Enumeration-Schutz): die
|
||||
// Anfrage-Seite zeigt immer dieselbe Erfolgsmeldung, unabhängig davon,
|
||||
// ob tatsächlich eine Mail verschickt wurde.
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
const passwortVergessenHinweis = "Falls diese E-Mail-Adresse bei uns registriert ist, wurde ein Link zum Zurücksetzen verschickt."
|
||||
|
||||
func (s *Server) handlePasswortVergessenForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Error: "ungültiges Formular"})
|
||||
return
|
||||
}
|
||||
email := r.FormValue("email")
|
||||
|
||||
user, err := s.store.GetUserByEmail(r.Context(), email)
|
||||
if err == nil && user.Active {
|
||||
token, tokenErr := auth.NewSessionToken()
|
||||
if tokenErr == nil {
|
||||
if _, createErr := s.store.CreatePasswordResetToken(r.Context(), user.ID, token); createErr == nil {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
|
||||
body := "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n" + link + "\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
|
||||
// Versandfehler wird bewusst nicht an den Nutzer durchgereicht
|
||||
// (keine Enumeration über unterschiedliches Verhalten bei
|
||||
// Versandfehlern), aber serverseitig geloggt statt
|
||||
// verschluckt — sonst bleibt ein dauerhaft falsch
|
||||
// konfigurierter SMTP-Server unbemerkt.
|
||||
if err := s.mailer.Send(user.Email, "Deklarix — Passwort zurücksetzen", body); err != nil {
|
||||
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Message: passwortVergessenHinweis})
|
||||
}
|
||||
|
||||
type passwortZuruecksetzenData struct {
|
||||
Title string
|
||||
Token string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswortZuruecksetzenForm(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
if _, err := s.store.GetValidPasswordResetToken(r.Context(), token); err != nil {
|
||||
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token}); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswortZuruecksetzen(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
resetToken, err := s.store.GetValidPasswordResetToken(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, "ungültiges Formular")
|
||||
return
|
||||
}
|
||||
passwordHash, err := auth.HashPassword(r.FormValue("password"))
|
||||
if err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserPassword(r.Context(), resetToken.UserID, passwordHash); err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, "Passwort konnte nicht gesetzt werden")
|
||||
return
|
||||
}
|
||||
if err := s.store.MarkPasswordResetTokenUsed(r.Context(), resetToken.ID); err != nil {
|
||||
s.renderPasswortZuruecksetzenError(w, token, "Passwort wurde gesetzt, Token konnte aber nicht als verwendet markiert werden")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) renderPasswortZuruecksetzenError(w http.ResponseWriter, token, msg string) {
|
||||
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token, Error: msg}); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
94
internal/web/passwort_reset_handlers_test.go
Normal file
94
internal/web/passwort_reset_handlers_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var resetLinkPattern = regexp.MustCompile(`/passwort-zuruecksetzen/([a-f0-9]+)`)
|
||||
|
||||
func TestPasswortVergessenSendetLinkUndSetztNeuesPasswort(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s, fm := newServerWithMailer(t, fs)
|
||||
|
||||
regResp := postForm(t, s, nil, "/register", url.Values{
|
||||
"account_name": {"Reset-Firma"}, "email": {"reset@example.com"},
|
||||
"password": {"altes-passwort"},
|
||||
})
|
||||
if regResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register status = %d, want 303", regResp.Code)
|
||||
}
|
||||
|
||||
vergessenResp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"reset@example.com"}})
|
||||
if vergessenResp.Code != http.StatusOK {
|
||||
t.Fatalf("passwort-vergessen status = %d, want 200", vergessenResp.Code)
|
||||
}
|
||||
if len(fm.Sent) != 1 {
|
||||
t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
|
||||
}
|
||||
match := resetLinkPattern.FindStringSubmatch(fm.Sent[0].Body)
|
||||
if match == nil {
|
||||
t.Fatalf("kein Reset-Link in Mail-Body gefunden: %q", fm.Sent[0].Body)
|
||||
}
|
||||
token := match[1]
|
||||
|
||||
getResp := getWithCookie(t, s, nil, "/passwort-zuruecksetzen/"+token)
|
||||
if getResp.Code != http.StatusOK {
|
||||
t.Fatalf("get reset form status = %d, want 200", getResp.Code)
|
||||
}
|
||||
|
||||
resetResp := postForm(t, s, nil, "/passwort-zuruecksetzen/"+token, url.Values{"password": {"neues-passwort-123"}})
|
||||
if resetResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("reset status = %d, want 303, body: %s", resetResp.Code, resetResp.Body.String())
|
||||
}
|
||||
|
||||
// Derselbe Link darf kein zweites Mal funktionieren.
|
||||
reuseResp := postForm(t, s, nil, "/passwort-zuruecksetzen/"+token, url.Values{"password": {"noch-ein-passwort"}})
|
||||
if reuseResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("reuse status = %d, want 404 (Token bereits verwendet)", reuseResp.Code)
|
||||
}
|
||||
|
||||
// Altes Passwort funktioniert nicht mehr.
|
||||
oldLoginResp := postForm(t, s, nil, "/login", url.Values{"email": {"reset@example.com"}, "password": {"altes-passwort"}})
|
||||
if oldLoginResp.Code != http.StatusOK {
|
||||
t.Fatalf("login mit altem Passwort status = %d, want 200 (abgelehnt)", oldLoginResp.Code)
|
||||
}
|
||||
|
||||
// Neues Passwort funktioniert.
|
||||
newLoginResp := postForm(t, s, nil, "/login", url.Values{"email": {"reset@example.com"}, "password": {"neues-passwort-123"}})
|
||||
if newLoginResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login mit neuem Passwort status = %d, want 303", newLoginResp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswortVergessenFuerUnbekannteEmailVerraetNichts(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s, fm := newServerWithMailer(t, fs)
|
||||
|
||||
resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"gibt-es-nicht@example.com"}})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.Code)
|
||||
}
|
||||
if len(fm.Sent) != 0 {
|
||||
t.Fatalf("got %d gesendete Mails für unbekannte E-Mail, want 0", len(fm.Sent))
|
||||
}
|
||||
if !containsHinweis(resp.Body.String()) {
|
||||
t.Fatalf("erwartete generische Erfolgsmeldung, body: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func containsHinweis(body string) bool {
|
||||
return regexp.MustCompile(`Falls diese E-Mail-Adresse`).MatchString(body)
|
||||
}
|
||||
|
||||
func TestPasswortZuruecksetzenMitUngueltigemTokenGibt404(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/passwort-zuruecksetzen/nicht-existierendes-token")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/mail"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
@@ -39,6 +40,12 @@ type Store interface {
|
||||
GetUser(ctx context.Context, id string) (store.User, error)
|
||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||
SetUserActive(ctx context.Context, id string, active bool) error
|
||||
SetUserPassword(ctx context.Context, id, passwordHash string) error
|
||||
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
|
||||
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error)
|
||||
CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
|
||||
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
|
||||
MarkPasswordResetTokenUsed(ctx context.Context, id string) error
|
||||
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
|
||||
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||
@@ -115,11 +122,15 @@ type Server struct {
|
||||
store Store
|
||||
regelwerk Regelwerk
|
||||
templates *template.Template
|
||||
mailer mail.Mailer
|
||||
}
|
||||
|
||||
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
||||
// wird einmal beim Start geladen, nicht pro Request.
|
||||
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
// wird einmal beim Start geladen, nicht pro Request. mailer ist
|
||||
// austauschbar (siehe mail.FakeMailer für Tests) — ohne konfigurierten
|
||||
// SMTP-Server schlägt nur der tatsächliche Versand fehl (z. B.
|
||||
// Passwort-Zurücksetzen), der restliche Server bleibt funktionsfähig.
|
||||
func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, error) {
|
||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||
@@ -129,6 +140,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
store: st,
|
||||
regelwerk: regelwerk,
|
||||
templates: tmpl,
|
||||
mailer: mailer,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -139,6 +151,12 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLogin)
|
||||
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
|
||||
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern))
|
||||
mux.HandleFunc("GET /passwort-vergessen", s.handlePasswortVergessenForm)
|
||||
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
|
||||
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)
|
||||
mux.HandleFunc("POST /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzen)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
||||
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/mail"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
@@ -43,6 +44,8 @@ type fakeStore struct {
|
||||
nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID
|
||||
freigabeRegeln map[string]store.FreigabeRegel
|
||||
freigabeschritte map[string]store.Freigabeschritt
|
||||
passwordResetTokens map[string]store.PasswordResetToken
|
||||
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
@@ -62,6 +65,8 @@ func newFakeStore() *fakeStore {
|
||||
nutzerGenehmigerRollen: map[string]map[string]bool{},
|
||||
freigabeRegeln: map[string]store.FreigabeRegel{},
|
||||
freigabeschritte: map[string]store.Freigabeschritt{},
|
||||
passwordResetTokens: map[string]store.PasswordResetToken{},
|
||||
loeschfristen: map[string]map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +186,73 @@ func (f *fakeStore) SetUserActive(ctx context.Context, id string, active bool) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.users[id]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
u.PasswordHash = passwordHash
|
||||
f.users[id] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
t := store.PasswordResetToken{
|
||||
ID: f.newID(), UserID: userID, Token: token,
|
||||
ExpiresAt: time.Now().Add(store.PasswordResetTokenDuration), CreatedAt: time.Now(),
|
||||
}
|
||||
f.passwordResetTokens[t.ID] = t
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, t := range f.passwordResetTokens {
|
||||
if t.Token == token && t.UsedAt == nil && t.ExpiresAt.After(time.Now()) {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return store.PasswordResetToken{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
t, ok := f.passwordResetTokens[id]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
t.UsedAt = &now
|
||||
f.passwordResetTokens[id] = t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.loeschfristen[accountID] == nil {
|
||||
f.loeschfristen[accountID] = map[string]int{}
|
||||
}
|
||||
f.loeschfristen[accountID][datenklasseID] = maxTage
|
||||
return store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage, UpdatedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.LoeschfristEinstellung
|
||||
for datenklasseID, maxTage := range f.loeschfristen[accountID] {
|
||||
out = append(out, store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -784,11 +856,18 @@ func loadTestRegelwerk(t *testing.T) web.Regelwerk {
|
||||
|
||||
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
||||
t.Helper()
|
||||
s, err := web.NewServer(fs, loadTestRegelwerk(t))
|
||||
s, _ := newServerWithMailer(t, fs)
|
||||
return s
|
||||
}
|
||||
|
||||
func newServerWithMailer(t *testing.T, fs *fakeStore) (*web.Server, *mail.FakeMailer) {
|
||||
t.Helper()
|
||||
fm := &mail.FakeMailer{}
|
||||
s, err := web.NewServer(fs, loadTestRegelwerk(t), fm)
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return s
|
||||
return s, fm
|
||||
}
|
||||
|
||||
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/loeschfristen">Löschfristen</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Abmelden</button>
|
||||
|
||||
43
internal/web/templates/loeschfristen_liste.html
Normal file
43
internal/web/templates/loeschfristen_liste.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "loeschfristen-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Löschfristen</h1>
|
||||
<p class="hinweis">
|
||||
Maximale Aufbewahrungsdauer je Datenklasse, gegen die ein Werkzeug
|
||||
hart geprüft wird (Anforderung <code>loeschfrist_max_tage</code>).
|
||||
Die DSGVO nennt keine festen Fristen, nur den Grundsatz "so lange
|
||||
wie für den Zweck nötig" — die Werte unten sind ein Vorschlag,
|
||||
keine Rechtsvorgabe, und sollten vom Datenschutzbeauftragten
|
||||
geprüft und bei Bedarf angepasst werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Gespeichert}}<p class="hinweis">Löschfristen gespeichert.</p>{{end}}
|
||||
<div class="form-card">
|
||||
<form method="post" action="/verwaltung/loeschfristen">
|
||||
<fieldset class="form-section">
|
||||
<legend>Maximale Aufbewahrungsdauer (Tage)</legend>
|
||||
<div class="form-grid">
|
||||
{{range .Zeilen}}
|
||||
<div>
|
||||
<label for="max_tage_{{.DatenklasseID}}">{{.DatenklasseID}}</label>
|
||||
<input type="number" min="0" id="max_tage_{{.DatenklasseID}}" name="max_tage_{{.DatenklasseID}}" value="{{.MaxTage}}" required>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="page">
|
||||
<h1>Anmelden</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
@@ -15,6 +16,7 @@
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
|
||||
<p><a href="/passwort-vergessen">Passwort vergessen?</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
18
internal/web/templates/passwort_vergessen.html
Normal file
18
internal/web/templates/passwort_vergessen.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{{define "passwort-vergessen"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Passwort vergessen</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
||||
<form method="post" action="/passwort-vergessen">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
<button type="submit">Link anfordern</button>
|
||||
</form>
|
||||
<p><a href="/login">Zurück zur Anmeldung</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
16
internal/web/templates/passwort_zuruecksetzen.html
Normal file
16
internal/web/templates/passwort_zuruecksetzen.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{define "passwort-zuruecksetzen"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Neues Passwort setzen</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/passwort-zuruecksetzen/{{.Token}}">
|
||||
<label for="password">Neues Passwort</label>
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
<button type="submit">Passwort setzen</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user