Add role based login and user management

This commit is contained in:
DonVoo 2026-07-13 00:28:01 +02:00
parent f67bbdce78
commit 353dafc30d
4 changed files with 681 additions and 34 deletions

View file

@ -16,6 +16,10 @@ import (
func RegisterRoutes(mux *http.ServeMux) { 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("/users", usersHandler)
mux.HandleFunc("/users/save", userSaveHandler)
mux.HandleFunc("/users/delete", userDeleteHandler)
// Umzug (Konten pflegen + starten/beobachten) // Umzug (Konten pflegen + starten/beobachten)
mux.HandleFunc("/accounts", accountsHandler) // TODO Codex (02/07) mux.HandleFunc("/accounts", accountsHandler) // TODO Codex (02/07)
@ -59,16 +63,16 @@ 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-14"> <link rel="stylesheet" href="/static/style.css?v=20260712-15">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-14" defer></script></head> <script src="/static/app.js?v=20260712-15" 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>
<nav class="ribbon-tabs"> <nav class="ribbon-tabs">
%s %s
%s %s
<a class="tab" href="/accounts">Einstellungen</a> <a class="tab" href="/users">Einstellungen</a>
</nav> </nav>
<div class="ribbon-title">Mail-Graveyard</div> <div class="ribbon-title">Mail-Graveyard</div>
</header> </header>
@ -81,7 +85,8 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a> <a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a> <a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a> <a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item" href="/accounts">Einstellungen</a> <a class="backstage-item" href="/users">Benutzerverwaltung</a>
<a class="backstage-item" href="/logout">Logout</a>
</aside> </aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button> <button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<div class="three-pane"> <div class="three-pane">
@ -104,6 +109,9 @@ func renderShell(w http.ResponseWriter, active, readingPane string) {
} }
func accountsHandler(w http.ResponseWriter, r *http.Request) { func accountsHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
accounts, err := ListAccounts() accounts, err := ListAccounts()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -122,6 +130,9 @@ func accountsHandler(w http.ResponseWriter, r *http.Request) {
} }
func accountSaveHandler(w http.ResponseWriter, r *http.Request) { func accountSaveHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
@ -151,6 +162,9 @@ func accountSaveHandler(w http.ResponseWriter, r *http.Request) {
} }
func accountDeleteHandler(w http.ResponseWriter, r *http.Request) { func accountDeleteHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
@ -168,6 +182,9 @@ func accountDeleteHandler(w http.ResponseWriter, r *http.Request) {
} }
func accountTestHandler(w http.ResponseWriter, r *http.Request) { func accountTestHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
@ -185,6 +202,9 @@ func accountTestHandler(w http.ResponseWriter, r *http.Request) {
} }
func archivesHandler(w http.ResponseWriter, r *http.Request) { func archivesHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
accounts, err := ListAccounts() accounts, err := ListAccounts()
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -194,6 +214,9 @@ func archivesHandler(w http.ResponseWriter, r *http.Request) {
} }
func transferHandler(w http.ResponseWriter, r *http.Request) { func transferHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
mode := strings.TrimPrefix(r.URL.Path, "/transfer/") mode := strings.TrimPrefix(r.URL.Path, "/transfer/")
if mode == "" || mode == "/transfer" { if mode == "" || mode == "/transfer" {
http.Redirect(w, r, "/transfer/postfach-archiv", http.StatusSeeOther) http.Redirect(w, r, "/transfer/postfach-archiv", http.StatusSeeOther)
@ -225,6 +248,9 @@ func emailBoxHandler(w http.ResponseWriter, r *http.Request) {
} }
func archiveCreateHandler(w http.ResponseWriter, r *http.Request) { func archiveCreateHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
@ -242,6 +268,9 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request) {
} }
func archiveDeleteHandler(w http.ResponseWriter, r *http.Request) { func archiveDeleteHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
@ -277,6 +306,9 @@ func archiveDeleteHandler(w http.ResponseWriter, r *http.Request) {
} }
func migrateRunHandler(w http.ResponseWriter, r *http.Request) { func migrateRunHandler(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
@ -322,6 +354,105 @@ func migrateStatusHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `%s: %s %d/%d Fehler %d`, html.EscapeString(j.Account), html.EscapeString(label), j.Done, j.Total, j.Errors) fmt.Fprintf(w, `%s: %s %d/%d Fehler %d`, html.EscapeString(j.Account), html.EscapeString(label), j.Done, j.Total, j.Errors)
} }
func usersHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
users, err := ListAppUsers()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
editName := strings.TrimSpace(r.URL.Query().Get("edit"))
var edit AppUser
if editName != "" {
edit, err = GetAppUser(editName)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
renderUsersPage(w, r, users, edit, r.URL.Query().Get("msg"), r.URL.Query().Get("err"))
}
func userSaveHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
role := strings.TrimSpace(r.FormValue("role"))
active := r.FormValue("active") == "1"
if username == "" {
redirectUsers(w, r, "", "Benutzername fehlt.")
return
}
existing, existingErr := GetAppUser(username)
if !IsAdmin(r) && existingErr == nil && existing.Role != roleUser {
redirectUsers(w, r, username, "Nur Admins duerfen Verwalter oder Admins bearbeiten.")
return
}
if !IsAdmin(r) {
role = roleUser
}
if errors.Is(existingErr, sql.ErrNoRows) && password == "" {
redirectUsers(w, r, username, "Passwort fuer neuen Benutzer fehlt.")
return
}
var hash string
if password != "" {
var err error
hash, err = HashPassword(password)
if err != nil {
redirectUsers(w, r, username, err.Error())
return
}
}
if err := SaveAppUser(username, hash, role, active); err != nil {
redirectUsers(w, r, username, err.Error())
return
}
redirectUsers(w, r, username, "Benutzer gespeichert.")
}
func userDeleteHandler(w http.ResponseWriter, r *http.Request) {
if !requireManager(w, r) {
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := strings.TrimSpace(r.FormValue("username"))
if username == "" {
redirectUsers(w, r, "", "Kein Benutzer gewaehlt.")
return
}
current := CurrentUser(r)
if strings.EqualFold(username, current.Username) {
redirectUsers(w, r, username, "Eigenen Benutzer nicht loeschen.")
return
}
target, err := GetAppUser(username)
if err != nil {
redirectUsers(w, r, username, err.Error())
return
}
if !IsAdmin(r) && target.Role != roleUser {
redirectUsers(w, r, username, "Nur Admins duerfen Verwalter oder Admins loeschen.")
return
}
if err := DeleteAppUser(username); err != nil {
redirectUsers(w, r, username, err.Error())
return
}
redirectUsers(w, r, "", "Benutzer geloescht.")
}
func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account, msg, errMsg string) { func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account, msg, errMsg string) {
if edit.Name == "" { if edit.Name == "" {
edit = Account{SrcPort: 993, SrcSecurity: "tls", SrcProto: "imap", DstPort: 993, DstSecurity: "tls", Active: true} edit = Account{SrcPort: 993, SrcSecurity: "tls", SrcProto: "imap", DstPort: 993, DstSecurity: "tls", Active: true}
@ -331,16 +462,16 @@ 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-14"> <link rel="stylesheet" href="/static/style.css?v=20260712-15">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-14" defer></script></head> <script src="/static/app.js?v=20260712-15" 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>
<nav class="ribbon-tabs"> <nav class="ribbon-tabs">
%s %s
%s %s
<a class="tab" href="/accounts">Einstellungen</a> <a class="tab" href="/users">Einstellungen</a>
</nav> </nav>
<div class="ribbon-title">Mail-Graveyard</div> <div class="ribbon-title">Mail-Graveyard</div>
</header> </header>
@ -353,7 +484,8 @@ func renderAccountsPage(w http.ResponseWriter, accounts []Account, edit Account,
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a> <a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a> <a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a> <a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item" href="/accounts">Einstellungen</a> <a class="backstage-item" href="/users">Benutzerverwaltung</a>
<a class="backstage-item" href="/logout">Logout</a>
</aside> </aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button> <button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<main class="accounts-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu("")) <main class="accounts-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
@ -375,16 +507,16 @@ 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-14"> <link rel="stylesheet" href="/static/style.css?v=20260712-15">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-14" defer></script></head> <script src="/static/app.js?v=20260712-15" 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>
<nav class="ribbon-tabs"> <nav class="ribbon-tabs">
%s %s
%s %s
<a class="tab" href="/accounts">Einstellungen</a> <a class="tab" href="/users">Einstellungen</a>
</nav> </nav>
<div class="ribbon-title">Mail-Graveyard</div> <div class="ribbon-title">Mail-Graveyard</div>
</header> </header>
@ -397,7 +529,8 @@ func renderArchivesPage(w http.ResponseWriter, archives []string, accounts []Acc
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a> <a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a> <a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a> <a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item" href="/accounts">Einstellungen</a> <a class="backstage-item" href="/users">Benutzerverwaltung</a>
<a class="backstage-item" href="/logout">Logout</a>
</aside> </aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button> <button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<main class="accounts-page archive-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu("")) <main class="accounts-page archive-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
@ -414,6 +547,56 @@ func renderArchivesPage(w http.ResponseWriter, archives []string, accounts []Acc
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`) fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`)
} }
func renderUsersPage(w http.ResponseWriter, r *http.Request, users []AppUser, edit AppUser, msg, errMsg string) {
if edit.Username == "" {
edit = AppUser{Role: roleUser, Active: true}
}
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>Benutzerverwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-15">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-15" defer></script></head>
<body class="ol2013">
<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>
<nav class="ribbon-tabs">
%s
%s
<a class="tab active" href="/users">Einstellungen</a>
</nav>
<div class="ribbon-title">Mail-Graveyard</div>
</header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item active" href="/users">Benutzerverwaltung</a>
<a class="backstage-item" href="/logout">Logout</a>
</aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<main class="accounts-page users-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""))
if msg != "" {
fmt.Fprintf(w, `<div class="notice ok">%s</div>`, html.EscapeString(msg))
}
if errMsg != "" {
fmt.Fprintf(w, `<div class="notice bad">%s</div>`, html.EscapeString(errMsg))
}
fmt.Fprint(w, `<section class="account-layout"><div class="account-list"><div class="pane-head">Benutzer</div>`)
renderUsersTable(w, users, edit.Username)
fmt.Fprint(w, `</div><div class="account-form-panel"><div class="pane-head">Benutzer eintragen</div>`)
renderUserForm(w, r, edit)
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span>angemeldet als `)
fmt.Fprint(w, html.EscapeString(CurrentUser(r).Username+" / "+CurrentUser(r).Role))
fmt.Fprint(w, `</span></footer></body></html>`)
}
type transferMode struct { type transferMode struct {
Slug string Slug string
Title string Title string
@ -442,16 +625,16 @@ 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-14"> <link rel="stylesheet" href="/static/style.css?v=20260712-15">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-14" defer></script></head> <script src="/static/app.js?v=20260712-15" 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>
<nav class="ribbon-tabs"> <nav class="ribbon-tabs">
%s %s
%s %s
<a class="tab" href="/accounts">Einstellungen</a> <a class="tab" href="/users">Einstellungen</a>
</nav> </nav>
<div class="ribbon-title">Mail-Graveyard</div> <div class="ribbon-title">Mail-Graveyard</div>
</header> </header>
@ -462,7 +645,8 @@ func renderTransferPage(w http.ResponseWriter, mode transferMode, accounts []Acc
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a> <a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item active" href="/transfer/postfach-archiv">Mail-Transfer</a> <a class="backstage-item active" href="/transfer/postfach-archiv">Mail-Transfer</a>
%s %s
<a class="backstage-item" href="/accounts">Einstellungen</a> <a class="backstage-item" href="/users">Benutzerverwaltung</a>
<a class="backstage-item" href="/logout">Logout</a>
</aside> </aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button> <button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<main class="accounts-page transfer-page"> <main class="accounts-page transfer-page">
@ -482,16 +666,16 @@ 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-14"> <link rel="stylesheet" href="/static/style.css?v=20260712-15">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-14" defer></script></head> <script src="/static/app.js?v=20260712-15" 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>
<nav class="ribbon-tabs"> <nav class="ribbon-tabs">
%s %s
%s %s
<a class="tab" href="/accounts">Einstellungen</a> <a class="tab" href="/users">Einstellungen</a>
</nav> </nav>
<div class="ribbon-title">Mail-Graveyard</div> <div class="ribbon-title">Mail-Graveyard</div>
</header> </header>
@ -504,7 +688,8 @@ func renderSourceEmailBoxPage(w http.ResponseWriter, accounts []Account) {
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a> <a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a> <a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a> <a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item" href="/accounts">Einstellungen</a> <a class="backstage-item" href="/users">Benutzerverwaltung</a>
<a class="backstage-item" href="/logout">Logout</a>
</aside> </aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button> <button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<div class="three-pane email-box-view"> <div class="three-pane email-box-view">
@ -523,16 +708,16 @@ 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-14"> <link rel="stylesheet" href="/static/style.css?v=20260712-15">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-14" defer></script></head> <script src="/static/app.js?v=20260712-15" 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>
<nav class="ribbon-tabs"> <nav class="ribbon-tabs">
%s %s
%s %s
<a class="tab" href="/accounts">Einstellungen</a> <a class="tab" href="/users">Einstellungen</a>
</nav> </nav>
<div class="ribbon-title">Mail-Graveyard</div> <div class="ribbon-title">Mail-Graveyard</div>
</header> </header>
@ -545,7 +730,8 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, accounts []Account) {
<a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a> <a class="backstage-item transfer-sub" href="/transfer/postfach-archiv">Postfach &lt;--&gt; Archiv-MBox</a>
<a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a> <a class="backstage-item transfer-sub" href="/transfer/archiv-postfach">Archiv-MBox &lt;--&gt; Postfach</a>
<a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a> <a class="backstage-item transfer-sub" href="/transfer/postfach-postfach">Postfach &lt;--&gt; Postfach</a>
<a class="backstage-item" href="/accounts">Einstellungen</a> <a class="backstage-item" href="/users">Benutzerverwaltung</a>
<a class="backstage-item" href="/logout">Logout</a>
</aside> </aside>
<button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button> <button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>
<div class="three-pane email-box-view email-box-target-view"> <div class="three-pane email-box-view email-box-target-view">
@ -767,6 +953,70 @@ func renderAccountsTable(w http.ResponseWriter, accounts []Account, editName str
fmt.Fprint(w, `</tbody></table>`) fmt.Fprint(w, `</tbody></table>`)
} }
func renderUsersTable(w http.ResponseWriter, users []AppUser, editName string) {
if len(users) == 0 {
fmt.Fprint(w, `<div class="ef-empty">Noch keine Benutzer eingetragen.</div>`)
return
}
fmt.Fprint(w, `<table class="account-table user-table"><thead><tr><th>Benutzer</th><th>Rolle</th><th>Status</th><th class="actions-col"></th></tr></thead><tbody>`)
for _, u := range users {
activeClass := ""
if u.Username == editName {
activeClass = ` class="selected"`
}
status := "aktiv"
if !u.Active {
status = "inaktiv"
}
deleteButton := fmt.Sprintf(`<form method="post" action="/users/delete"><input type="hidden" name="username" value="%s"><button class="btn compact secondary danger" type="submit">Loeschen</button></form>`, html.EscapeString(u.Username))
fmt.Fprintf(w, `<tr%s><td><a href="/users?edit=%s">%s</a></td><td>%s</td><td>%s</td><td class="row-actions">%s</td></tr>`,
activeClass, urlQuery(u.Username), html.EscapeString(u.Username), html.EscapeString(u.Role), status, deleteButton)
}
fmt.Fprint(w, `</tbody></table>`)
}
func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) {
roleField := roleSelect(u.Role, IsAdmin(r))
if !IsAdmin(r) {
roleField = `<input type="hidden" name="role" value="user"><input class="input" value="user" disabled>`
}
fmt.Fprintf(w, `<form class="account-form user-form" method="post" action="/users/save">
<div class="form-section">
<label class="label">Benutzername<input class="input" name="username" value="%s" required></label>
<label class="check-row"><input type="checkbox" name="active" value="1" %s> Aktiv</label>
</div>
<div class="form-columns">
<fieldset><legend>Zugang</legend>
<label class="label">Passwort<input class="input" name="password" type="password" autocomplete="new-password" placeholder="%s"></label>
<label class="label">Rolle%s</label>
</fieldset>
</div>
<div class="form-actions">
<button class="btn" type="submit">Speichern</button>
<a class="btn secondary" href="/users">Neu</a>
</div>
</form>`,
html.EscapeString(u.Username), checked(u.Active), passwordPlaceholder(u.Username), roleField)
}
func roleSelect(current string, admin bool) string {
if current == "" {
current = roleUser
}
values := []string{roleUser}
if admin {
values = []string{roleUser, roleManager, roleAdmin}
}
return selectHTML("role", current, values)
}
func passwordPlaceholder(username string) string {
if username == "" {
return "Pflicht fuer neuen Benutzer"
}
return "leer lassen = behalten"
}
func renderArchiveList(w http.ResponseWriter, archives []string, accounts []Account) { func renderArchiveList(w http.ResponseWriter, archives []string, accounts []Account) {
if len(archives) == 0 { if len(archives) == 0 {
fmt.Fprint(w, `<div class="ef-empty">Noch keine Archiv-Mailbox angelegt.</div>`) fmt.Fprint(w, `<div class="ef-empty">Noch keine Archiv-Mailbox angelegt.</div>`)
@ -1014,6 +1264,18 @@ func redirectArchives(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/archives?"+key+"="+urlQuery(msg), http.StatusSeeOther) http.Redirect(w, r, "/archives?"+key+"="+urlQuery(msg), http.StatusSeeOther)
} }
func redirectUsers(w http.ResponseWriter, r *http.Request, edit, msg string) {
key := "msg"
if strings.Contains(strings.ToLower(msg), "fehl") || strings.Contains(strings.ToLower(msg), "pflicht") || strings.Contains(strings.ToLower(msg), "nicht") || strings.Contains(strings.ToLower(msg), "error") {
key = "err"
}
target := "/users?" + key + "=" + urlQuery(msg)
if edit != "" {
target += "&edit=" + urlQuery(edit)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func urlQuery(value string) string { func urlQuery(value string) string {
return url.QueryEscape(value) return url.QueryEscape(value)
} }

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"os" "os"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@ -37,6 +38,14 @@ type Account struct {
Active bool `json:"active"` Active bool `json:"active"`
} }
type AppUser struct {
ID int64
Username string
Role string
Active bool
PasswordHash string
}
// ConnectDB oeffnet die SQLite-DB (modernc, kein cgo) und legt das Schema an. // ConnectDB oeffnet die SQLite-DB (modernc, kein cgo) und legt das Schema an.
// //
// Schema (TODO Codex): // Schema (TODO Codex):
@ -107,6 +116,21 @@ func ConnectDB() error {
errors INTEGER NOT NULL DEFAULT 0, errors INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'running' state TEXT NOT NULL DEFAULT 'running'
)`, )`,
`CREATE TABLE IF NOT EXISTS app_users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS app_sessions(
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
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 {
@ -118,6 +142,84 @@ func ConnectDB() error {
return nil return nil
} }
func CountAppUsers() (int, error) {
var count int
err := DB.QueryRow(`SELECT count(*) FROM app_users`).Scan(&count)
return count, err
}
func ListAppUsers() ([]AppUser, error) {
rows, err := DB.Query(`SELECT id, username, password_hash, role, active FROM app_users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AppUser
for rows.Next() {
u, err := scanAppUser(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func GetAppUser(username string) (AppUser, error) {
row := DB.QueryRow(`SELECT id, username, password_hash, role, active FROM app_users WHERE username=?`, username)
return scanAppUser(row)
}
func GetAppUserByID(id int64) (AppUser, error) {
row := DB.QueryRow(`SELECT id, username, password_hash, role, active FROM app_users WHERE id=?`, id)
return scanAppUser(row)
}
func SaveAppUser(username, passwordHash, role string, active bool) error {
role = normalizeRole(role)
if passwordHash == "" {
_, err := DB.Exec(`UPDATE app_users SET role=?, active=?, updated_at=CURRENT_TIMESTAMP WHERE username=?`,
role, boolInt(active), username)
return err
}
_, err := DB.Exec(`INSERT INTO app_users(username, password_hash, role, active)
VALUES(?,?,?,?)
ON CONFLICT(username) DO UPDATE SET
password_hash=excluded.password_hash,
role=excluded.role,
active=excluded.active,
updated_at=CURRENT_TIMESTAMP`,
username, passwordHash, role, boolInt(active))
return err
}
func DeleteAppUser(username string) error {
_, err := DB.Exec(`DELETE FROM app_users WHERE username=?`, username)
return err
}
func CreateSession(token string, userID int64, expiresAt string) error {
_, err := DB.Exec(`INSERT INTO app_sessions(token, user_id, expires_at) VALUES(?,?,?)`, token, userID, expiresAt)
return err
}
func DeleteSession(token string) error {
_, err := DB.Exec(`DELETE FROM app_sessions WHERE token=?`, token)
return err
}
func SessionUser(token string) (AppUser, error) {
row := DB.QueryRow(`SELECT u.id, u.username, u.password_hash, u.role, u.active
FROM app_sessions s JOIN app_users u ON u.id=s.user_id
WHERE s.token=? AND s.expires_at > CURRENT_TIMESTAMP AND u.active=1`, token)
return scanAppUser(row)
}
func CleanupSessions() error {
_, err := DB.Exec(`DELETE FROM app_sessions WHERE 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
@ -202,6 +304,10 @@ type accountScanner interface {
Scan(dest ...any) error Scan(dest ...any) error
} }
type appUserScanner interface {
Scan(dest ...any) error
}
func scanAccount(s accountScanner) (Account, error) { func scanAccount(s accountScanner) (Account, error) {
var a Account var a Account
var srcInsecure, dstInsecure, active int var srcInsecure, dstInsecure, active int
@ -242,6 +348,26 @@ func normalizeAccount(a *Account) {
} }
} }
func scanAppUser(s appUserScanner) (AppUser, error) {
var u AppUser
var active int
err := s.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &active)
u.Role = normalizeRole(u.Role)
u.Active = active != 0
return u, err
}
func normalizeRole(role string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case "admin":
return "admin"
case "verwalter":
return "verwalter"
default:
return "user"
}
}
func boolInt(v bool) int { func boolInt(v bool) int {
if v { if v {
return 1 return 1

View file

@ -1,28 +1,257 @@
package backend package backend
import "net/http" import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"html"
"net/http"
"strings"
"time"
)
type contextKey string
const (
sessionCookieName = "mail_graveyard_session"
currentUserKey = contextKey("currentUser")
roleUser = "user"
roleManager = "verwalter"
roleAdmin = "admin"
passwordHashIterations = 120000
defaultSessionValidDays = 7
)
// InitAuth bereitet die Session-/Login-Verwaltung vor. // InitAuth bereitet die Session-/Login-Verwaltung vor.
// //
// Sicherheitslage: Dieses Tool haelt IMAP-Zugangsdaten fremder Postfaecher und // Sicherheitslage: Dieses Tool haelt IMAP-Zugangsdaten fremder Postfaecher und
// kann per SMTP Mails verschicken. Es darf NIE offen im Netz stehen. // kann per SMTP Mails verschicken. Es darf NIE offen im Netz stehen.
// - Default: bind 127.0.0.1 (nur lokal), Login mit admin_user/admin_pass. // - Default: bind 127.0.0.1 (nur lokal), Login mit admin_user/admin_pass.
// - Wenn spaeter remote erreichbar (hinter Caddy): /vadmin-Mail-2FA-Muster // - Wenn remote erreichbar (hinter Caddy): HTTPS und restriktive Caddy-Regeln
// aus dem Web-Deploy-Kit vorschalten. // beibehalten. Rollen prueft die App serverseitig.
func InitAuth() error { func InitAuth() error {
// TODO Codex: Session-Cookie-Store initialisieren. if err := CleanupSessions(); err != nil {
return err
}
count, err := CountAppUsers()
if err != nil {
return err
}
if count > 0 {
return nil return nil
} }
if strings.TrimSpace(Cfg.AdminUser) == "" || Cfg.AdminPass == "" {
return fmt.Errorf("admin_user/admin_pass fehlen fuer initialen Login-Benutzer")
}
hash, err := HashPassword(Cfg.AdminPass)
if err != nil {
return err
}
return SaveAppUser(strings.TrimSpace(Cfg.AdminUser), hash, roleAdmin, true)
}
// 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) {
// TODO Codex: /login + /static durchlassen, sonst Session pruefen, if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/login" {
// sonst Redirect /login.
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(sessionCookieName)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
redirectToLogin(w, r)
return
}
user, err := SessionUser(cookie.Value)
if err != nil {
clearSessionCookie(w)
redirectToLogin(w, r)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), currentUserKey, user)))
}) })
} }
func loginHandler(w http.ResponseWriter, r *http.Request) { func CurrentUser(r *http.Request) AppUser {
// TODO Codex: Formular, Abgleich mit Cfg.AdminUser/AdminPass, Cookie setzen. if u, ok := r.Context().Value(currentUserKey).(AppUser); ok {
return u
}
return AppUser{}
}
func CanManageAccounts(r *http.Request) bool {
return hasRole(CurrentUser(r), roleManager)
}
func CanManageUsers(r *http.Request) bool {
return hasRole(CurrentUser(r), roleManager)
}
func IsAdmin(r *http.Request) bool {
return CurrentUser(r).Role == roleAdmin
}
func requireManager(w http.ResponseWriter, r *http.Request) bool {
if CanManageAccounts(r) {
return true
}
http.Error(w, "Keine Berechtigung.", http.StatusForbidden)
return false
}
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if IsAdmin(r) {
return true
}
http.Error(w, "Nur Admins duerfen diese Aktion ausfuehren.", http.StatusForbidden)
return false
}
func hasRole(user AppUser, needed string) bool {
rank := map[string]int{roleUser: 1, roleManager: 2, roleAdmin: 3}
return rank[user.Role] >= rank[needed]
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
renderLoginPage(w, r.URL.Query().Get("err"))
case http.MethodPost:
if err := r.ParseForm(); err != nil {
renderLoginPage(w, err.Error())
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
user, err := GetAppUser(username)
if err != nil || !user.Active || !CheckPassword(password, user.PasswordHash) {
renderLoginPage(w, "Login fehlgeschlagen.")
return
}
token, err := randomToken(32)
if err != nil {
renderLoginPage(w, err.Error())
return
}
expires := time.Now().Add(defaultSessionValidDays * 24 * time.Hour)
if err := CreateSession(token, user.ID, expires.UTC().Format("2006-01-02 15:04:05")); err != nil {
renderLoginPage(w, err.Error())
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
})
http.Redirect(w, r, "/view", 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)
}
clearSessionCookie(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func renderLoginPage(w http.ResponseWriter, 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>Login - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-15"></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="/login" class="login-form">
<label class="label">Benutzer<input class="input" name="username" autocomplete="username" autofocus required></label>
<label class="label">Passwort<input class="input" name="password" type="password" autocomplete="current-password" required></label>
%s
<button class="btn" type="submit">Anmelden</button>
</form>
</main>
</body></html>`, loginErrorHTML(errMsg))
}
func loginErrorHTML(errMsg string) string {
if errMsg == "" {
return ""
}
return `<div class="notice bad login-error">` + html.EscapeString(errMsg) + `</div>`
}
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func HashPassword(password string) (string, error) {
salt, err := randomToken(18)
if err != nil {
return "", err
}
sum := passwordDigest(password, salt, passwordHashIterations)
return fmt.Sprintf("sha256:%d:%s:%s", passwordHashIterations, salt, base64.RawStdEncoding.EncodeToString(sum)), nil
}
func CheckPassword(password, encoded string) bool {
parts := strings.Split(encoded, ":")
if len(parts) != 4 || parts[0] != "sha256" {
return false
}
var iterations int
if _, err := fmt.Sscanf(parts[1], "%d", &iterations); err != nil || iterations <= 0 {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
got := passwordDigest(password, parts[2], iterations)
return subtle.ConstantTimeCompare(got, want) == 1
}
func passwordDigest(password, salt string, iterations int) []byte {
data := []byte(salt + ":" + password)
sum := sha256.Sum256(data)
out := sum[:]
for i := 1; i < iterations; i++ {
next := sha256.Sum256(out)
out = next[:]
}
return out
}
func randomToken(size int) (string, error) {
b := make([]byte, size)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
} }

View file

@ -26,6 +26,36 @@ body.ol2013 {
flex-direction: column; flex-direction: column;
} }
.login-body {
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f6f8fb 0%, #e8eef5 100%);
}
.login-card {
width: min(380px, calc(100vw - 32px));
background: #fff;
border: 1px solid var(--ol-border);
box-shadow: 0 12px 30px rgba(0, 0, 0, .16);
}
.login-brand {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
background: var(--ol-blue);
color: #fff;
font-size: 16px;
}
.login-form {
display: grid;
gap: 12px;
padding: 18px;
}
.login-error {
padding: 8px 10px;
border: 1px solid #f3b5b8;
}
/* Ribbon / Kopfband */ /* Ribbon / Kopfband */
.ribbon { .ribbon {
background: var(--ol-blue); background: var(--ol-blue);