Add login rename and password reset flow

This commit is contained in:
DonVoo 2026-07-13 01:19:47 +02:00
parent b625c95c29
commit e7611b2d49
4 changed files with 362 additions and 28 deletions

View file

@ -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, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Login - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-17"></head>
<link rel="stylesheet" href="/static/style.css?v=20260712-18"></head>
<body class="ol2013 login-body">
<main class="login-card">
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
@ -178,11 +248,51 @@ func renderLoginPage(w http.ResponseWriter, errMsg string) {
<label class="label">Passwort<input class="input" name="password" type="password" autocomplete="current-password" required></label>
%s
<button class="btn" type="submit">Anmelden</button>
<a class="login-link" href="/password/forgot">Passwort vergessen?</a>
</form>
</main>
</body></html>`, loginErrorHTML(errMsg))
}
func renderForgotPasswordPage(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Passwort vergessen - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-18"></head>
<body class="ol2013 login-body">
<main class="login-card">
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
<form method="post" action="/password/forgot" class="login-form">
<label class="label">Login/E-Mail<input class="input" name="username" autocomplete="username" autofocus required></label>
%s
<button class="btn" type="submit">Reset-Link senden</button>
<a class="login-link" href="/login">Zurueck zum Login</a>
</form>
</main>
</body></html>`, loginNoticeHTML(msg))
}
func renderResetPasswordPage(w http.ResponseWriter, token, errMsg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Passwort setzen - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-18"></head>
<body class="ol2013 login-body">
<main class="login-card">
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
<form method="post" action="/password/reset?token=%s" class="login-form">
<label class="label">Neues Passwort<input class="input" name="password" type="password" autocomplete="new-password" autofocus required></label>
<label class="label">Wiederholen<input class="input" name="confirm_password" type="password" autocomplete="new-password" required></label>
%s
<button class="btn" type="submit">Passwort speichern</button>
<a class="login-link" href="/login">Zurueck zum Login</a>
</form>
</main>
</body></html>`, html.EscapeString(token), loginErrorHTML(errMsg))
}
func loginErrorHTML(errMsg string) string {
if errMsg == "" {
return ""
@ -190,6 +300,13 @@ func loginErrorHTML(errMsg string) string {
return `<div class="notice bad login-error">` + html.EscapeString(errMsg) + `</div>`
}
func loginNoticeHTML(msg string) string {
if msg == "" {
return ""
}
return `<div class="notice ok login-error">` + html.EscapeString(msg) + `</div>`
}
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
}