Session-Cleanup: - app.go: stündlicher Ticker für CleanExpiredSessions mit Context-Shutdown Docker/Infra: - compose/.env.example: Vorlage für ADMIN_PASSWORD, DEV_MODE, DEFAULT_TENANT - server-stack.yml: Backend-Service referenziert neue Env-Variablen Security-Review (Larry): - EnsureAdminUser: Admin-Check tenant-scoped statt global - scanUser() (toter Code, falsche Spaltenanzahl) entfernt - RequireTenantAccess: leerer tenantSlug nicht mehr als Bypass nutzbar - Login: Dummy-bcrypt bei unbekanntem User gegen Timing-Leak - Logout-Cookie: Secure-Flag konsistent mit Login gesetzt Doku (Doris): - DEVELOPMENT.md: Abschnitt "Lokale Entwicklung mit Login" - TENANT-FEATURE-PLAN.md: Phase 3-5 Checkboxen abgehakt - TODO.md: erledigte Punkte abgehakt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
175 lines
5.4 KiB
Go
175 lines
5.4 KiB
Go
package manage
|
|
|
|
import (
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
sessionCookieName = "morz_session"
|
|
sessionTTL = 8 * time.Hour
|
|
)
|
|
|
|
// loginData is the template data for the login page.
|
|
type loginData struct {
|
|
Error string
|
|
Next string
|
|
}
|
|
|
|
// HandleLoginUI renders the login form (GET /login).
|
|
// If a valid session cookie is already present, the user is redirected to /admin
|
|
// (or the tenant dashboard once tenants are wired up in Phase 3).
|
|
func HandleLoginUI(authStore *store.AuthStore) http.HandlerFunc {
|
|
tmpl := template.Must(template.New("login").Parse(loginTmpl))
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Redirect if already logged in.
|
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
|
if u, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil {
|
|
if u.Role == "admin" {
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
} else if u.TenantSlug != "" {
|
|
http.Redirect(w, r, "/manage/"+u.TenantSlug, http.StatusSeeOther)
|
|
} else {
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
next := r.URL.Query().Get("next")
|
|
data := loginData{Next: sanitizeNext(next)}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = tmpl.Execute(w, data)
|
|
}
|
|
}
|
|
|
|
// HandleLoginPost handles form submission (POST /login).
|
|
// It validates credentials, creates a session, sets the session cookie and
|
|
// redirects the user based on their role or the ?next= parameter.
|
|
func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc {
|
|
tmpl := template.Must(template.New("login").Parse(loginTmpl))
|
|
|
|
renderError := func(w http.ResponseWriter, next, msg string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = tmpl.Execute(w, loginData{Error: msg, Next: next})
|
|
}
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
renderError(w, "", "Ungültige Anfrage.")
|
|
return
|
|
}
|
|
|
|
username := strings.TrimSpace(r.FormValue("username"))
|
|
password := r.FormValue("password")
|
|
next := sanitizeNext(r.FormValue("next"))
|
|
|
|
if username == "" || password == "" {
|
|
renderError(w, next, "Bitte Benutzername und Passwort eingeben.")
|
|
return
|
|
}
|
|
|
|
user, err := authStore.GetUserByUsername(r.Context(), username)
|
|
if err != nil {
|
|
// Mitigate user-enumeration timing leak: run a dummy bcrypt
|
|
// comparison so that unknown-user and wrong-password responses
|
|
// take approximately the same time. The dummy hash is a
|
|
// pre-computed bcrypt hash of "dummy" (cost 12).
|
|
const dummyHash = "$2a$12$44H3KPmJUDdgNss7JB7Qneg9GWEl2OgxWwSqVpXRaQdki8T3U9ED2"
|
|
_ = bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password))
|
|
renderError(w, next, "Benutzername oder Passwort falsch.")
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
renderError(w, next, "Benutzername oder Passwort falsch.")
|
|
return
|
|
}
|
|
|
|
session, err := authStore.CreateSession(r.Context(), user.ID, sessionTTL)
|
|
if err != nil {
|
|
renderError(w, next, "Interner Fehler beim Erstellen der Sitzung.")
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: session.ID,
|
|
Path: "/",
|
|
MaxAge: int(sessionTTL.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: !cfg.DevMode,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
// Redirect: honour ?next= for relative paths, otherwise role-based default.
|
|
if next != "" {
|
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
|
return
|
|
}
|
|
switch user.Role {
|
|
case "admin":
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
default:
|
|
if user.TenantSlug != "" {
|
|
http.Redirect(w, r, "/manage/"+user.TenantSlug, http.StatusSeeOther)
|
|
} else {
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// HandleLogoutPost deletes the session and clears the cookie (POST /logout).
|
|
func HandleLogoutPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
|
_ = authStore.DeleteSession(r.Context(), cookie.Value)
|
|
}
|
|
|
|
// Expire the cookie immediately.
|
|
// Secure must match the flag used when the cookie was set so that
|
|
// browsers on HTTPS connections honour the expiry directive.
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: !cfg.DevMode,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// sanitizeNext ensures the redirect target is a safe relative path.
|
|
// Only paths starting with "/" and not containing "//" or a scheme are allowed.
|
|
func sanitizeNext(next string) string {
|
|
if next == "" {
|
|
return ""
|
|
}
|
|
// Reject absolute URLs (contain scheme or authority).
|
|
if strings.Contains(next, "://") || strings.Contains(next, "//") {
|
|
return ""
|
|
}
|
|
// Must start with a slash.
|
|
if !strings.HasPrefix(next, "/") {
|
|
return ""
|
|
}
|
|
// Validate via url.Parse — rejects anything with a host component.
|
|
u, err := url.ParseRequestURI(next)
|
|
if err != nil || u.Host != "" || u.Scheme != "" {
|
|
return ""
|
|
}
|
|
return next
|
|
}
|