package backend 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 remote erreichbar (hinter Caddy): HTTPS und restriktive Caddy-Regeln // beibehalten. Rollen prueft die App serverseitig. func InitAuth() error { 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), "Admin", 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) { 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 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
%s
`, 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 }