From e7611b2d49328bf73de28e29e39ef86198addf78 Mon Sep 17 00:00:00 2001 From: DonVoo Date: Mon, 13 Jul 2026 01:19:47 +0200 Subject: [PATCH] Add login rename and password reset flow --- backend/00-router.go | 46 +++++++----- backend/02-database.go | 86 ++++++++++++++++++++++ backend/03-auth.go | 157 ++++++++++++++++++++++++++++++++++++++++- backend/09-smtp.go | 101 ++++++++++++++++++++++++-- 4 files changed, 362 insertions(+), 28 deletions(-) diff --git a/backend/00-router.go b/backend/00-router.go index 2d3f4db..6a53f86 100644 --- a/backend/00-router.go +++ b/backend/00-router.go @@ -17,6 +17,8 @@ func RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/", homeHandler) mux.HandleFunc("/login", loginHandler) mux.HandleFunc("/logout", logoutHandler) + mux.HandleFunc("/password/forgot", forgotPasswordHandler) + mux.HandleFunc("/password/reset", resetPasswordHandler) mux.HandleFunc("/users", usersHandler) mux.HandleFunc("/users/save", userSaveHandler) mux.HandleFunc("/users/toggle-active", userToggleActiveHandler) @@ -64,9 +66,9 @@ func renderShell(w http.ResponseWriter, active, readingPane string) { fmt.Fprintf(w, ` Mail-Graveyard - + - +
@@ -384,6 +386,7 @@ func userSaveHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + oldUsername := strings.TrimSpace(r.FormValue("old_username")) username := strings.TrimSpace(r.FormValue("username")) displayName := strings.TrimSpace(r.FormValue("display_name")) password := r.FormValue("password") @@ -393,9 +396,13 @@ func userSaveHandler(w http.ResponseWriter, r *http.Request) { redirectUsers(w, r, "", "Benutzername fehlt.") return } - existing, existingErr := GetAppUser(username) + lookupUsername := username + if oldUsername != "" { + lookupUsername = oldUsername + } + existing, existingErr := GetAppUser(lookupUsername) if !IsAdmin(r) && existingErr == nil && existing.Role != roleUser { - redirectUsers(w, r, username, "Nur Admins duerfen Verwalter oder Admins bearbeiten.") + redirectUsers(w, r, oldUsername, "Nur Admins duerfen Verwalter oder Admins bearbeiten.") return } if !IsAdmin(r) { @@ -414,8 +421,8 @@ func userSaveHandler(w http.ResponseWriter, r *http.Request) { return } } - if err := SaveAppUser(username, displayName, hash, role, active); err != nil { - redirectUsers(w, r, username, err.Error()) + if err := UpdateAppUser(oldUsername, username, displayName, hash, role, active); err != nil { + redirectUsers(w, r, oldUsername, err.Error()) return } redirectUsers(w, r, username, "Benutzer gespeichert.") @@ -504,9 +511,9 @@ func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account, fmt.Fprintf(w, ` Konten-Verwaltung - Mail-Graveyard - + - +
@@ -549,9 +556,9 @@ func renderArchivesPage(w http.ResponseWriter, archives []string, accounts []Acc fmt.Fprintf(w, ` Archiv-mbox Verwaltung - Mail-Graveyard - + - +
@@ -597,9 +604,9 @@ func renderUsersPage(w http.ResponseWriter, r *http.Request, users []AppUser, ed fmt.Fprintf(w, ` Benutzerverwaltung - Mail-Graveyard - + - +
@@ -667,9 +674,9 @@ func renderTransferPage(w http.ResponseWriter, mode transferMode, accounts []Acc fmt.Fprintf(w, ` %s - Mail-Graveyard - + - +
@@ -708,9 +715,9 @@ func renderSourceEmailBoxPage(w http.ResponseWriter, accounts []Account) { fmt.Fprintf(w, ` Quell-Postfach - Mail-Graveyard - + - +
@@ -750,9 +757,9 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, accounts []Account) { fmt.Fprintf(w, ` Ziel-Postfach - Mail-Graveyard - + - +
@@ -1034,6 +1041,7 @@ func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) { } fmt.Fprintf(w, ``, - html.EscapeString(u.Username), checked(u.Active), html.EscapeString(u.DisplayName), passwordHint(u.Username), passwordPlaceholder(u.Username), roleField) + html.EscapeString(u.Username), html.EscapeString(u.Username), checked(u.Active), html.EscapeString(u.DisplayName), passwordHint(u.Username), passwordPlaceholder(u.Username), roleField) } func userDisplayName(u AppUser) string { diff --git a/backend/02-database.go b/backend/02-database.go index 787701d..6759efe 100644 --- a/backend/02-database.go +++ b/backend/02-database.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "os" "strings" @@ -133,6 +134,13 @@ func ConnectDB() error { expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )`, + `CREATE TABLE IF NOT EXISTS app_password_resets( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, } for _, stmt := range schema { if _, err := db.Exec(stmt); err != nil { @@ -232,6 +240,44 @@ func SaveAppUser(username, displayName, passwordHash, role string, active bool) return err } +func UpdateAppUser(oldUsername, username, displayName, passwordHash, role string, active bool) error { + oldUsername = strings.TrimSpace(oldUsername) + username = strings.TrimSpace(username) + displayName = strings.TrimSpace(displayName) + role = normalizeRole(role) + if oldUsername == "" || oldUsername == username { + return SaveAppUser(username, displayName, passwordHash, role, active) + } + tx, err := DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var existing int + err = tx.QueryRow(`SELECT 1 FROM app_users WHERE username=?`, username).Scan(&existing) + if err == nil { + return fmt.Errorf("Login/E-Mail existiert bereits.") + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + if passwordHash == "" { + _, err = tx.Exec(`UPDATE app_users SET username=?, display_name=?, role=?, active=?, updated_at=CURRENT_TIMESTAMP WHERE username=?`, + username, displayName, role, boolInt(active), oldUsername) + } else { + _, err = tx.Exec(`UPDATE app_users SET username=?, display_name=?, password_hash=?, role=?, active=?, updated_at=CURRENT_TIMESTAMP WHERE username=?`, + username, displayName, passwordHash, role, boolInt(active), oldUsername) + } + if err != nil { + return err + } + _, err = tx.Exec(`DELETE FROM app_sessions WHERE user_id IN (SELECT id FROM app_users WHERE username=?)`, username) + if err != nil { + return err + } + return tx.Commit() +} + func DeleteAppUser(username string) error { _, err := DB.Exec(`DELETE FROM app_users WHERE username=?`, username) return err @@ -259,6 +305,46 @@ func CleanupSessions() error { return err } +func CreatePasswordReset(tokenHash string, userID int64, expiresAt string) error { + _, err := DB.Exec(`INSERT INTO app_password_resets(token_hash, user_id, expires_at) VALUES(?,?,?)`, tokenHash, userID, expiresAt) + return err +} + +func PasswordResetUser(tokenHash string) (AppUser, error) { + row := DB.QueryRow(`SELECT u.id, u.username, u.display_name, u.password_hash, u.role, u.active + FROM app_password_resets r JOIN app_users u ON u.id=r.user_id + WHERE r.token_hash=? AND r.used_at IS NULL AND r.expires_at > CURRENT_TIMESTAMP AND u.active=1`, tokenHash) + return scanAppUser(row) +} + +func UsePasswordReset(tokenHash, passwordHash string) error { + tx, err := DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var userID int64 + err = tx.QueryRow(`SELECT user_id FROM app_password_resets WHERE token_hash=? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP`, tokenHash).Scan(&userID) + if err != nil { + return err + } + if _, err := tx.Exec(`UPDATE app_users SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`, passwordHash, userID); err != nil { + return err + } + if _, err := tx.Exec(`UPDATE app_password_resets SET used_at=CURRENT_TIMESTAMP WHERE token_hash=?`, tokenHash); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM app_sessions WHERE user_id=?`, userID); err != nil { + return err + } + return tx.Commit() +} + +func CleanupPasswordResets() error { + _, err := DB.Exec(`DELETE FROM app_password_resets WHERE used_at IS NOT NULL OR expires_at <= CURRENT_TIMESTAMP`) + return err +} + func ListAccounts() ([]Account, error) { rows, err := DB.Query(`SELECT id,name,src_host,src_port,src_security,src_insecure,src_user,src_pass,src_proto, dst_host,dst_port,dst_security,dst_insecure,dst_user,dst_pass,mbox_dir,active diff --git a/backend/03-auth.go b/backend/03-auth.go index 102a93e..1df6200 100644 --- a/backend/03-auth.go +++ b/backend/03-auth.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "fmt" "html" + "log" "net/http" "strings" "time" @@ -36,6 +37,9 @@ func InitAuth() error { if err := CleanupSessions(); err != nil { return err } + if err := CleanupPasswordResets(); err != nil { + return err + } count, err := CountAppUsers() if err != nil { return err @@ -56,7 +60,7 @@ func InitAuth() error { // AuthMiddleware schuetzt alle Routen ausser /login und /static. func AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/login" { + if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/login" || strings.HasPrefix(r.URL.Path, "/password/") { next.ServeHTTP(w, r) return } @@ -156,6 +160,72 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { } } +func forgotPasswordHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + renderForgotPasswordPage(w, "") + case http.MethodPost: + if err := r.ParseForm(); err != nil { + renderForgotPasswordPage(w, err.Error()) + return + } + username := strings.TrimSpace(r.FormValue("username")) + if username != "" { + user, err := GetAppUser(username) + if err == nil && user.Active { + if err := sendPasswordReset(r, user); err != nil { + log.Printf("password reset mail for %s failed: %v", username, err) + } + } + } + renderForgotPasswordPage(w, "Wenn der Benutzer existiert, wurde ein Reset-Link per E-Mail verschickt.") + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func resetPasswordHandler(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(r.URL.Query().Get("token")) + if token == "" { + renderResetPasswordPage(w, "", "Reset-Link fehlt.") + return + } + tokenHash := resetTokenHash(token) + user, err := PasswordResetUser(tokenHash) + if err != nil { + renderResetPasswordPage(w, "", "Reset-Link ist ungueltig oder abgelaufen.") + return + } + switch r.Method { + case http.MethodGet: + renderResetPasswordPage(w, token, "") + case http.MethodPost: + if err := r.ParseForm(); err != nil { + renderResetPasswordPage(w, token, err.Error()) + return + } + password := r.FormValue("password") + confirm := r.FormValue("confirm_password") + if password == "" || password != confirm { + renderResetPasswordPage(w, token, "Passwoerter stimmen nicht ueberein.") + return + } + hash, err := HashPassword(password) + if err != nil { + renderResetPasswordPage(w, token, err.Error()) + return + } + if err := UsePasswordReset(tokenHash, hash); err != nil { + renderResetPasswordPage(w, token, err.Error()) + return + } + log.Printf("password reset completed for %s", user.Username) + http.Redirect(w, r, "/login?err="+urlQuery("Passwort wurde geaendert. Bitte neu anmelden."), http.StatusSeeOther) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + func logoutHandler(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(sessionCookieName); err == nil { _ = DeleteSession(cookie.Value) @@ -169,7 +239,7 @@ func renderLoginPage(w http.ResponseWriter, errMsg string) { fmt.Fprintf(w, ` Login - Mail-Graveyard - +
@@ -178,11 +248,51 @@ func renderLoginPage(w http.ResponseWriter, errMsg string) { %s +
`, loginErrorHTML(errMsg)) } +func renderForgotPasswordPage(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` + +Passwort vergessen - Mail-Graveyard + + +
+ + +
+`, loginNoticeHTML(msg)) +} + +func renderResetPasswordPage(w http.ResponseWriter, token, errMsg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` + +Passwort setzen - Mail-Graveyard + + +
+ + +
+`, html.EscapeString(token), loginErrorHTML(errMsg)) +} + func loginErrorHTML(errMsg string) string { if errMsg == "" { return "" @@ -190,6 +300,13 @@ func loginErrorHTML(errMsg string) string { return `` } +func loginNoticeHTML(msg string) string { + if msg == "" { + return "" + } + return `` +} + func redirectToLogin(w http.ResponseWriter, r *http.Request) { if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", "/login") @@ -255,3 +372,39 @@ func randomToken(size int) (string, error) { } return base64.RawURLEncoding.EncodeToString(b), nil } + +func resetTokenHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func sendPasswordReset(r *http.Request, user AppUser) error { + token, err := randomToken(32) + if err != nil { + return err + } + expires := time.Now().Add(30 * time.Minute) + if err := CreatePasswordReset(resetTokenHash(token), user.ID, expires.UTC().Format("2006-01-02 15:04:05")); err != nil { + return err + } + link := baseURL(r) + "/password/reset?token=" + token + subject := "Mail-Graveyard Passwort zuruecksetzen" + body := fmt.Sprintf("Hallo %s,\n\nzum Zuruecksetzen deines Mail-Graveyard-Passworts nutze diesen Link:\n\n%s\n\nDer Link ist 30 Minuten gueltig.\n", userDisplayName(user), link) + return SendPlainMail([]string{user.Username}, subject, body) +} + +func baseURL(r *http.Request) string { + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + if r.TLS != nil { + proto = "https" + } else { + proto = "http" + } + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + return proto + "://" + host +} diff --git a/backend/09-smtp.go b/backend/09-smtp.go index e2edd53..25d6540 100644 --- a/backend/09-smtp.go +++ b/backend/09-smtp.go @@ -1,13 +1,100 @@ package backend -// SMTP wird AUSSCHLIESSLICH fuers manuelle Weiterleiten einzelner Mails aus dem -// Viewer benutzt (Cfg.ForwardSMTP). Der Postfach-Umzug laeuft NIE ueber SMTP, -// sondern ueber IMAP APPEND (05-imap-target.go) -- sonst gingen Datum, Flags -// und Ordnerstruktur verloren. +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "strings" +) + +// SMTP wird AUSSCHLIESSLICH fuers manuelle Weiterleiten einzelner Mails und +// App-Benachrichtigungen wie Passwort-Reset benutzt. Der Postfach-Umzug laeuft +// NIE ueber SMTP, sondern ueber IMAP APPEND (05-imap-target.go). + +func SendPlainMail(to []string, subject, body string) error { + cfg := Cfg.ForwardSMTP + if strings.TrimSpace(cfg.Host) == "" || cfg.Port == 0 || strings.TrimSpace(cfg.From) == "" { + return fmt.Errorf("forward_smtp ist nicht vollstaendig konfiguriert") + } + from := cfg.From + if strings.TrimSpace(cfg.User) != "" { + from = cfg.User + } + message := []byte("From: " + cfg.From + "\r\n" + + "To: " + strings.Join(to, ", ") + "\r\n" + + "Subject: " + subject + "\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "\r\n" + body) + addr := net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port)) + auth := smtp.Auth(nil) + if cfg.User != "" || cfg.Pass != "" { + auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host) + } + if cfg.Port == 465 { + return sendTLS(addr, cfg.Host, auth, from, to, message) + } + return sendStartTLS(addr, cfg.Host, auth, from, to, message) +} + +func sendStartTLS(addr, host string, auth smtp.Auth, from string, to []string, message []byte) error { + c, err := smtp.Dial(addr) + if err != nil { + return err + } + defer c.Close() + if ok, _ := c.Extension("STARTTLS"); ok { + if err := c.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { + return err + } + } + return smtpClientSend(c, auth, from, to, message) +} + +func sendTLS(addr, host string, auth smtp.Auth, from string, to []string, message []byte) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}) + if err != nil { + return err + } + c, err := smtp.NewClient(conn, host) + if err != nil { + _ = conn.Close() + return err + } + defer c.Close() + return smtpClientSend(c, auth, from, to, message) +} + +func smtpClientSend(c *smtp.Client, auth smtp.Auth, from string, to []string, message []byte) error { + if auth != nil { + if err := c.Auth(auth); err != nil { + return err + } + } + if err := c.Mail(from); err != nil { + return err + } + for _, rcpt := range to { + if err := c.Rcpt(rcpt); err != nil { + return err + } + } + w, err := c.Data() + if err != nil { + return err + } + if _, err := w.Write(message); err != nil { + _ = w.Close() + return err + } + if err := w.Close(); err != nil { + return err + } + return c.Quit() +} // ForwardMessage leitet den mbox-Rohtext an einen Empfaenger weiter. func ForwardMessage(rawMessage []byte, to []string, extraNote string) error { - // TODO Codex: net/smtp (stdlib) mit STARTTLS + AUTH gegen Cfg.ForwardSMTP. - // Original als Anhang oder inline weiterleiten, From = Cfg.ForwardSMTP.From. - return nil + body := extraNote + "\n\n" + string(rawMessage) + return SendPlainMail(to, "Weitergeleitete Nachricht aus Mail-Graveyard", body) }