package web import ( "context" "net/http" "time" "github.com/netcell-it/deklarix/internal/auth" "github.com/netcell-it/deklarix/internal/store" ) type authPageData struct { Title string Error string Message string } func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) { if err := s.templates.ExecuteTemplate(w, name, data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren"}) } func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { 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- // Spezifikation): legt einen neuen Mandanten (Account) und den ersten // Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb // wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows). // Es gibt bewusst KEINE offene Selbstregistrierung für einzelne // Mitarbeiter über dieses Formular — die kommen über den // Einladungslink (siehe einladung_handlers.go) oder künftig CSV- // Import/Mandantenlink (weiterhin nicht gebaut). func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"}) return } accountInput := store.AccountInput{ Name: r.FormValue("account_name"), Strasse: r.FormValue("strasse"), PLZ: r.FormValue("plz"), Ort: r.FormValue("ort"), Land: r.FormValue("land"), UStID: r.FormValue("ust_id"), // optional, siehe Migration 0023 (Kleinunternehmer) Rechnungsemail: r.FormValue("rechnungsemail"), } email := r.FormValue("email") password := r.FormValue("password") if accountInput.Name == "" || email == "" || password == "" || accountInput.Strasse == "" || accountInput.PLZ == "" || accountInput.Ort == "" || accountInput.Land == "" || accountInput.Rechnungsemail == "" { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder außer USt-IdNr. sind Pflicht"}) return } passwordHash, err := auth.HashPassword(password) if err != nil { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: err.Error()}) return } // Die account_id ist zu Beginn noch nicht bekannt (die Firma entsteht // ja gerade erst) — WithTenantScope startet die RLS-Transaktion // zunächst ohne Mandant, SetTenantScope trägt die neue ID nach, // sobald CreateAccount sie liefert. Ohne das würden die anschließenden // Schreibzugriffe auf genehmiger_rolle/loeschfrist_einstellung (beide // RLS-geschützt) von der Policy abgelehnt. var errMsg string err = s.store.WithTenantScope(r.Context(), "", false, func(ctx context.Context) error { acc, err := s.store.CreateAccount(ctx, accountInput) if err != nil { errMsg = "Konto konnte nicht angelegt werden" return err } if err := s.store.SetTenantScope(ctx, acc.ID, false); err != nil { errMsg = "Interner Fehler" return err } user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin") if err != nil { errMsg = "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben" return err } if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil { errMsg = "Genehmiger-Rollen konnten nicht angelegt werden" return err } if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil { errMsg = "Löschfristen konnten nicht angelegt werden" return err } // r.WithContext(ctx): startSession muss in DERSELBEN Transaktion // laufen wie CreateUser darüber — sonst sieht die Session-Zeile // (andere Verbindung über s.Pool, da r.Context() sonst ohne Tx // wäre) den gerade erst angelegten, noch nicht committeten // Nutzer nicht und die Fremdschlüsselprüfung schlägt fehl. if err := s.startSession(w, r.WithContext(ctx), user.ID); err != nil { errMsg = "Sitzung konnte nicht gestartet werden" return err } return nil }) if err != nil { if errMsg == "" { errMsg = "Registrierung fehlgeschlagen" } s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: errMsg}) return } http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "ungültiges Formular"}) return } email := r.FormValue("email") password := r.FormValue("password") ctx := r.Context() user, err := s.store.GetUserByEmail(ctx, email) if err != nil { // Absichtlich dieselbe Meldung wie bei falschem Passwort — sonst // verrät die Fehlermeldung, ob eine E-Mail-Adresse existiert. s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"}) return } if err := auth.VerifyPassword(user.PasswordHash, password); err != nil { s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"}) return } // Erst NACH erfolgreicher Passwortprüfung unterscheiden — sonst würde // diese Meldung einem Angreifer ohne gültiges Passwort verraten, dass // die E-Mail-Adresse zu einem (deaktivierten) Konto gehört. if !user.Active { s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Dieses Konto ist deaktiviert"}) return } if err := s.startSession(w, r, user.ID); err != nil { s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"}) return } // Ein Betreiber-Login hat keinen eigenen Antrag zu stellen — der // Plattform-Bereich ist der sinnvolle Einstieg (Ebene 5, technisch // getrennt vom Mandantenbereich). if user.Role == "betreiber" { http.Redirect(w, r, "/betreiber", http.StatusSeeOther) return } http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(sessionCookieName); err == nil { _ = s.store.DeleteSession(r.Context(), cookie.Value) } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, }) http.Redirect(w, r, "/login", http.StatusSeeOther) } // startSession erzeugt eine neue Sitzung und setzt das Session-Cookie. // Secure ist an genau dann, wenn die Anfrage selbst über TLS kam — ein // fest verdrahtetes Secure=true würde lokale Entwicklung ohne TLS // (http://localhost) brechen, da Browser ein Secure-Cookie über Klartext- // HTTP schlicht nicht speichern. func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) error { token, err := auth.NewSessionToken() if err != nil { return err } expiresAt := time.Now().Add(auth.SessionDuration) if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil { return err } setSessionCookie(w, r, token, expiresAt) return nil } // setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von // startSession (regulärer Login) und handleBetreiberLoginAls // (Support-Login). func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode, Expires: expiresAt, }) }