@@ -523,16 +708,16 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, accounts []Account) {
fmt.Fprintf(w, `
@@ -545,7 +730,8 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, accounts []Account) {
@@ -767,6 +953,70 @@ func renderAccountsTable(w http.ResponseWriter, accounts []Account, editName str
fmt.Fprint(w, ``)
}
+func renderUsersTable(w http.ResponseWriter, users []AppUser, editName string) {
+ if len(users) == 0 {
+ fmt.Fprint(w, `
Noch keine Benutzer eingetragen.
`)
+ return
+ }
+ fmt.Fprint(w, `
| Benutzer | Rolle | Status | |
`)
+ for _, u := range users {
+ activeClass := ""
+ if u.Username == editName {
+ activeClass = ` class="selected"`
+ }
+ status := "aktiv"
+ if !u.Active {
+ status = "inaktiv"
+ }
+ deleteButton := fmt.Sprintf(``, html.EscapeString(u.Username))
+ fmt.Fprintf(w, `| %s | %s | %s | %s |
`,
+ activeClass, urlQuery(u.Username), html.EscapeString(u.Username), html.EscapeString(u.Role), status, deleteButton)
+ }
+ fmt.Fprint(w, `
`)
+}
+
+func renderUserForm(w http.ResponseWriter, r *http.Request, u AppUser) {
+ roleField := roleSelect(u.Role, IsAdmin(r))
+ if !IsAdmin(r) {
+ roleField = `
`
+ }
+ fmt.Fprintf(w, `
`,
+ 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) {
if len(archives) == 0 {
fmt.Fprint(w, `
Noch keine Archiv-Mailbox angelegt.
`)
@@ -1014,6 +1264,18 @@ func redirectArchives(w http.ResponseWriter, r *http.Request, msg string) {
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 {
return url.QueryEscape(value)
}
diff --git a/backend/02-database.go b/backend/02-database.go
index b7163db..6f47763 100644
--- a/backend/02-database.go
+++ b/backend/02-database.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"os"
+ "strings"
_ "modernc.org/sqlite"
)
@@ -37,6 +38,14 @@ type Account struct {
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.
//
// Schema (TODO Codex):
@@ -107,6 +116,21 @@ func ConnectDB() error {
errors INTEGER NOT NULL DEFAULT 0,
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 {
if _, err := db.Exec(stmt); err != nil {
@@ -118,6 +142,84 @@ func ConnectDB() error {
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) {
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
@@ -202,6 +304,10 @@ type accountScanner interface {
Scan(dest ...any) error
}
+type appUserScanner interface {
+ Scan(dest ...any) error
+}
+
func scanAccount(s accountScanner) (Account, error) {
var a Account
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 {
if v {
return 1
diff --git a/backend/03-auth.go b/backend/03-auth.go
index 807a111..1b222ff 100644
--- a/backend/03-auth.go
+++ b/backend/03-auth.go
@@ -1,28 +1,257 @@
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.
//
// Sicherheitslage: Dieses Tool haelt IMAP-Zugangsdaten fremder Postfaecher und
// 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.
-// - Wenn spaeter remote erreichbar (hinter Caddy): /vadmin-Mail-2FA-Muster
-// aus dem Web-Deploy-Kit vorschalten.
+// - Wenn remote erreichbar (hinter Caddy): HTTPS und restriktive Caddy-Regeln
+// beibehalten. Rollen prueft die App serverseitig.
func InitAuth() error {
- // TODO Codex: Session-Cookie-Store initialisieren.
- return nil
+ if err := CleanupSessions(); err != nil {
+ return err
+ }
+ count, err := CountAppUsers()
+ if err != nil {
+ return err
+ }
+ if count > 0 {
+ 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.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // TODO Codex: /login + /static durchlassen, sonst Session pruefen,
- // sonst Redirect /login.
- next.ServeHTTP(w, r)
+ if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/login" {
+ 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) {
- // TODO Codex: Formular, Abgleich mit Cfg.AdminUser/AdminPass, Cookie setzen.
+func CurrentUser(r *http.Request) AppUser {
+ 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, `
+
+
Login - Mail-Graveyard
+
+
+
+ Mail-Graveyard
+
+
+`, loginErrorHTML(errMsg))
+}
+
+func loginErrorHTML(errMsg string) string {
+ if errMsg == "" {
+ return ""
+ }
+ return `
` + html.EscapeString(errMsg) + `
`
+}
+
+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
}
diff --git a/frontend-js/dist/style.css b/frontend-js/dist/style.css
index c176fac..d71b061 100644
--- a/frontend-js/dist/style.css
+++ b/frontend-js/dist/style.css
@@ -26,6 +26,36 @@ body.ol2013 {
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 {
background: var(--ol-blue);