Mail-Graveyard/backend/03-auth.go
2026-07-13 00:28:01 +02:00

257 lines
7.2 KiB
Go

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), 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, `<!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
}