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/reqcontext" "git.az-it.net/az/morz-infoboard/server/backend/internal/store" "golang.org/x/crypto/bcrypt" ) // handleScreenUserRedirect looks up accessible screens for a screen_user and // redirects to the first one. If none exist, it redirects to an error page. func handleScreenUserRedirect(w http.ResponseWriter, r *http.Request, screenStore *store.ScreenStore, user *store.User) { screens, err := screenStore.GetAccessibleScreens(r.Context(), user.ID) if err != nil || len(screens) == 0 { http.Redirect(w, r, "/login?error=no_screens", http.StatusSeeOther) return } http.Redirect(w, r, "/manage/"+screens[0].Slug, http.StatusSeeOther) } const sessionTTL = 8 * time.Hour // sessionCookieName ist ein Alias auf die zentrale Konstante (V5). const sessionCookieName = reqcontext.SessionCookieName // loginData is the template data for the login page. type loginData struct { Error string Next string CSRFToken string } // HandleLoginUI renders the login form (GET /login). // If a valid session cookie is already present, the user is redirected based on role. func HandleLoginUI(authStore *store.AuthStore, screenStore *store.ScreenStore, cfg config.Config) 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 { switch u.Role { case "admin": http.Redirect(w, r, "/admin", http.StatusSeeOther) case "screen_user": handleScreenUserRedirect(w, r, screenStore, u) default: if u.TenantSlug != "" { http.Redirect(w, r, "/tenant/"+u.TenantSlug+"/dashboard", http.StatusSeeOther) } else { http.Redirect(w, r, "/admin", http.StatusSeeOther) } } return } } // K1: CSRF-Token für das Login-Formular setzen/erneuern. csrfToken := setCSRFCookie(w, r, cfg.DevMode) next := r.URL.Query().Get("next") data := loginData{Next: sanitizeNext(next), CSRFToken: csrfToken} 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, screenStore *store.ScreenStore, 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) case "screen_user": handleScreenUserRedirect(w, r, screenStore, user) default: if user.TenantSlug != "" { http.Redirect(w, r, "/tenant/"+user.TenantSlug+"/dashboard", 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 }