410 lines
13 KiB
Go
410 lines
13 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"html"
|
|
"log"
|
|
"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
|
|
}
|
|
if err := CleanupPasswordResets(); 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" || strings.HasPrefix(r.URL.Path, "/password/") {
|
|
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 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) {
|
|
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-20"></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>
|
|
<a class="login-link" href="/password/forgot">Passwort vergessen?</a>
|
|
</form>
|
|
</main>
|
|
</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-20"></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-20"></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 {
|
|
if errMsg == "" {
|
|
return ""
|
|
}
|
|
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) {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|