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
|
|
@ -17,6 +17,8 @@ func RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/", homeHandler)
|
mux.HandleFunc("/", homeHandler)
|
||||||
mux.HandleFunc("/login", loginHandler)
|
mux.HandleFunc("/login", loginHandler)
|
||||||
mux.HandleFunc("/logout", logoutHandler)
|
mux.HandleFunc("/logout", logoutHandler)
|
||||||
|
mux.HandleFunc("/password/forgot", forgotPasswordHandler)
|
||||||
|
mux.HandleFunc("/password/reset", resetPasswordHandler)
|
||||||
mux.HandleFunc("/users", usersHandler)
|
mux.HandleFunc("/users", usersHandler)
|
||||||
mux.HandleFunc("/users/save", userSaveHandler)
|
mux.HandleFunc("/users/save", userSaveHandler)
|
||||||
mux.HandleFunc("/users/toggle-active", userToggleActiveHandler)
|
mux.HandleFunc("/users/toggle-active", userToggleActiveHandler)
|
||||||
|
|
@ -64,9 +66,9 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta 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">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Mail-Graveyard</title>
|
<title>Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-17" defer></script></head>
|
<script src="/static/app.js?v=20260712-18" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
|
|
@ -384,6 +386,7 @@ func userSaveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
oldUsername := strings.TrimSpace(r.FormValue("old_username"))
|
||||||
username := strings.TrimSpace(r.FormValue("username"))
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
|
@ -393,9 +396,13 @@ func userSaveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
redirectUsers(w, r, "", "Benutzername fehlt.")
|
redirectUsers(w, r, "", "Benutzername fehlt.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
existing, existingErr := GetAppUser(username)
|
lookupUsername := username
|
||||||
|
if oldUsername != "" {
|
||||||
|
lookupUsername = oldUsername
|
||||||
|
}
|
||||||
|
existing, existingErr := GetAppUser(lookupUsername)
|
||||||
if !IsAdmin(r) && existingErr == nil && existing.Role != roleUser {
|
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
|
return
|
||||||
}
|
}
|
||||||
if !IsAdmin(r) {
|
if !IsAdmin(r) {
|
||||||
|
|
@ -414,8 +421,8 @@ func userSaveHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := SaveAppUser(username, displayName, hash, role, active); err != nil {
|
if err := UpdateAppUser(oldUsername, username, displayName, hash, role, active); err != nil {
|
||||||
redirectUsers(w, r, username, err.Error())
|
redirectUsers(w, r, oldUsername, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
redirectUsers(w, r, username, "Benutzer gespeichert.")
|
redirectUsers(w, r, username, "Benutzer gespeichert.")
|
||||||
|
|
@ -504,9 +511,9 @@ func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account,
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta 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">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Konten-Verwaltung - Mail-Graveyard</title>
|
<title>Konten-Verwaltung - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-17" defer></script></head>
|
<script src="/static/app.js?v=20260712-18" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
|
|
@ -549,9 +556,9 @@ func renderArchivesPage(w http.ResponseWriter, archives []string, accounts []Acc
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta 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">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Archiv-mbox Verwaltung - Mail-Graveyard</title>
|
<title>Archiv-mbox Verwaltung - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-17" defer></script></head>
|
<script src="/static/app.js?v=20260712-18" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
|
|
@ -597,9 +604,9 @@ func renderUsersPage(w http.ResponseWriter, r *http.Request, users []AppUser, ed
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta 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">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Benutzerverwaltung - Mail-Graveyard</title>
|
<title>Benutzerverwaltung - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-17" defer></script></head>
|
<script src="/static/app.js?v=20260712-18" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
|
|
@ -667,9 +674,9 @@ func renderTransferPage(w http.ResponseWriter, mode transferMode, accounts []Acc
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta 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">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>%s - Mail-Graveyard</title>
|
<title>%s - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-17" defer></script></head>
|
<script src="/static/app.js?v=20260712-18" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
|
|
@ -708,9 +715,9 @@ func renderSourceEmailBoxPage(w http.ResponseWriter, accounts []Account) {
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta 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">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Quell-Postfach - Mail-Graveyard</title>
|
<title>Quell-Postfach - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-17" defer></script></head>
|
<script src="/static/app.js?v=20260712-18" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
|
|
@ -750,9 +757,9 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, accounts []Account) {
|
||||||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta 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">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Ziel-Postfach - Mail-Graveyard</title>
|
<title>Ziel-Postfach - Mail-Graveyard</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260712-17">
|
<link rel="stylesheet" href="/static/style.css?v=20260712-18">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/app.js?v=20260712-17" defer></script></head>
|
<script src="/static/app.js?v=20260712-18" defer></script></head>
|
||||||
<body class="ol2013">
|
<body class="ol2013">
|
||||||
<header class="ribbon">
|
<header class="ribbon">
|
||||||
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
|
||||||
|
|
@ -1034,6 +1041,7 @@ func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) {
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, `<form class="account-form user-form" method="post" action="/users/save">
|
fmt.Fprintf(w, `<form class="account-form user-form" method="post" action="/users/save">
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
|
<input type="hidden" name="old_username" value="%s">
|
||||||
<label class="label">Login/E-Mail<input class="input" name="username" value="%s" required></label>
|
<label class="label">Login/E-Mail<input class="input" name="username" value="%s" required></label>
|
||||||
<label class="check-row"><input type="checkbox" name="active" value="1" %s> Aktiv</label>
|
<label class="check-row"><input type="checkbox" name="active" value="1" %s> Aktiv</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1050,7 +1058,7 @@ func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) {
|
||||||
<a class="btn secondary" href="/users">Neu</a>
|
<a class="btn secondary" href="/users">Neu</a>
|
||||||
</div>
|
</div>
|
||||||
</form>`,
|
</form>`,
|
||||||
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 {
|
func userDisplayName(u AppUser) string {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -133,6 +134,13 @@ func ConnectDB() error {
|
||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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 {
|
for _, stmt := range schema {
|
||||||
if _, err := db.Exec(stmt); err != nil {
|
if _, err := db.Exec(stmt); err != nil {
|
||||||
|
|
@ -232,6 +240,44 @@ func SaveAppUser(username, displayName, passwordHash, role string, active bool)
|
||||||
return err
|
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 {
|
func DeleteAppUser(username string) error {
|
||||||
_, err := DB.Exec(`DELETE FROM app_users WHERE username=?`, username)
|
_, err := DB.Exec(`DELETE FROM app_users WHERE username=?`, username)
|
||||||
return err
|
return err
|
||||||
|
|
@ -259,6 +305,46 @@ func CleanupSessions() error {
|
||||||
return err
|
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) {
|
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,
|
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
|
dst_host,dst_port,dst_security,dst_insecure,dst_user,dst_pass,mbox_dir,active
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -36,6 +37,9 @@ func InitAuth() error {
|
||||||
if err := CleanupSessions(); err != nil {
|
if err := CleanupSessions(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := CleanupPasswordResets(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
count, err := CountAppUsers()
|
count, err := CountAppUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -56,7 +60,7 @@ func InitAuth() error {
|
||||||
// AuthMiddleware schuetzt alle Routen ausser /login und /static.
|
// AuthMiddleware schuetzt alle Routen ausser /login und /static.
|
||||||
func AuthMiddleware(next http.Handler) http.Handler {
|
func AuthMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
next.ServeHTTP(w, r)
|
||||||
return
|
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) {
|
func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
_ = DeleteSession(cookie.Value)
|
_ = 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">
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Login - Mail-Graveyard</title>
|
<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">
|
<body class="ol2013 login-body">
|
||||||
<main class="login-card">
|
<main class="login-card">
|
||||||
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
|
<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>
|
<label class="label">Passwort<input class="input" name="password" type="password" autocomplete="current-password" required></label>
|
||||||
%s
|
%s
|
||||||
<button class="btn" type="submit">Anmelden</button>
|
<button class="btn" type="submit">Anmelden</button>
|
||||||
|
<a class="login-link" href="/password/forgot">Passwort vergessen?</a>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
</body></html>`, loginErrorHTML(errMsg))
|
</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 {
|
func loginErrorHTML(errMsg string) string {
|
||||||
if errMsg == "" {
|
if errMsg == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -190,6 +300,13 @@ func loginErrorHTML(errMsg string) string {
|
||||||
return `<div class="notice bad login-error">` + html.EscapeString(errMsg) + `</div>`
|
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) {
|
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get("HX-Request") == "true" {
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
w.Header().Set("HX-Redirect", "/login")
|
w.Header().Set("HX-Redirect", "/login")
|
||||||
|
|
@ -255,3 +372,39 @@ func randomToken(size int) (string, error) {
|
||||||
}
|
}
|
||||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,100 @@
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
// SMTP wird AUSSCHLIESSLICH fuers manuelle Weiterleiten einzelner Mails aus dem
|
import (
|
||||||
// Viewer benutzt (Cfg.ForwardSMTP). Der Postfach-Umzug laeuft NIE ueber SMTP,
|
"crypto/tls"
|
||||||
// sondern ueber IMAP APPEND (05-imap-target.go) -- sonst gingen Datum, Flags
|
"fmt"
|
||||||
// und Ordnerstruktur verloren.
|
"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.
|
// ForwardMessage leitet den mbox-Rohtext an einen Empfaenger weiter.
|
||||||
func ForwardMessage(rawMessage []byte, to []string, extraNote string) error {
|
func ForwardMessage(rawMessage []byte, to []string, extraNote string) error {
|
||||||
// TODO Codex: net/smtp (stdlib) mit STARTTLS + AUTH gegen Cfg.ForwardSMTP.
|
body := extraNote + "\n\n" + string(rawMessage)
|
||||||
// Original als Anhang oder inline weiterleiten, From = Cfg.ForwardSMTP.From.
|
return SendPlainMail(to, "Weitergeleitete Nachricht aus Mail-Graveyard", body)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue