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 { // Constant-time failure — same message for unknown user and wrong 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) 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. http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, 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 }