Add login rename and password reset flow
This commit is contained in:
parent
b625c95c29
commit
e7611b2d49
4 changed files with 362 additions and 28 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue