15206314ab
Mint a 160-bit capability URL (/s/<token>) serving a standalone read-only guest launcher: same tiles, opens apps in new tabs, no account, no admin session. Information only, zero control — every privilege stays behind each app's own auth. - /s/ pre-auth pass-through (after the claim gate) + session-CSRF exemption; guest password POST carries its own pre-auth HMAC CSRF. - Constant-time token match; empty stored token = disabled = byte-identical mux 404. - Optional per-share password: separate bcrypt hash + own attempt map; signed cookie = HMAC(token|passwordHash) keyed with web.session_secret, so rotate/change invalidates. - Guest labels ride the v0.164.0 ruling; never expose internal state vocabulary. - Token redacted in logs (/s/<redacted>); never in CHANGELOG/REPORT/CONTEXT. - Admin modal: copy-link, QR (go-qrcode), set/clear password, rotate, disable. - Tests: Groups A-G (14) + 3 red-proofs verified red.
295 lines
12 KiB
Go
295 lines
12 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/skip2/go-qrcode"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// Guest launcher share handlers (v0.165.0). See share.go for the security model. This file holds the
|
|
// HTTP surface: the pre-auth guest pages (/s/<token>) and the admin share-management POSTs
|
|
// (/launcher/share/*, session-authed).
|
|
|
|
// shareMinPassword is the minimum length of the OPTIONAL per-share password.
|
|
const shareMinPassword = 8
|
|
|
|
// setGuestHeaders stamps the guest launcher responses so search engines never index a capability URL,
|
|
// referrers never leak the token to an opened app, and no intermediary caches the page.
|
|
func (s *Server) setGuestHeaders(w http.ResponseWriter) {
|
|
h := w.Header()
|
|
h.Set("X-Robots-Tag", "noindex, nofollow")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
h.Set("Cache-Control", "no-store")
|
|
}
|
|
|
|
// share404 answers exactly like the mux default 404 (http.NotFound) so a wrong or disabled token is
|
|
// byte-for-byte indistinguishable from any unknown route. The token is redacted from the log.
|
|
func (s *Server) share404(w http.ResponseWriter, r *http.Request) {
|
|
s.logger.Printf("[WARN] [web] 404 Not Found: %s /s/<redacted>", r.Method)
|
|
http.NotFound(w, r)
|
|
}
|
|
|
|
// GuestLauncherApp is one tile on the standalone guest launcher. It carries ONLY what a guest may see
|
|
// — never the internal state vocabulary (stopped/exited/degraded/unhealthy). Clickable apps render as
|
|
// links to their public URL; the rest are greyed with a calm, non-technical Hungarian label.
|
|
type GuestLauncherApp struct {
|
|
DisplayName string
|
|
Slug string
|
|
BrandColor string
|
|
Clickable bool
|
|
Href string // set only when Clickable
|
|
Label string // set only when NOT Clickable
|
|
}
|
|
|
|
// guestLauncherApps maps the shared launcherApps() slice into the guest view. Clickable ⇒ the app is
|
|
// operational AND its public route is actually published (a healthy, reachable app). isOperational
|
|
// alone would let an unhealthy/restarting/degraded app through — its URL 404s at Traefik, so a guest
|
|
// tap would dead-end; routeUnpublished screens exactly those. The label rides the v0.164.0 ruling:
|
|
// StateStopped is a deliberate owner action ("A tulajdonos leállította"); any other non-clickable
|
|
// state is a transient the guest need not understand ("Átmenetileg nem elérhető").
|
|
func (s *Server) guestLauncherApps() []GuestLauncherApp {
|
|
return buildGuestApps(s.launcherApps(), s.cfg.Customer.Domain)
|
|
}
|
|
|
|
// buildGuestApps is the pure mapping from the shared launcher slice to the guest view (no manager, no
|
|
// request) — the tested seam for the clickability rule and the guest label vocabulary.
|
|
func buildGuestApps(apps []LauncherApp, domain string) []GuestLauncherApp {
|
|
out := make([]GuestLauncherApp, 0, len(apps))
|
|
for _, a := range apps {
|
|
g := GuestLauncherApp{DisplayName: a.DisplayName, Slug: a.Slug, BrandColor: a.BrandColor}
|
|
switch {
|
|
case isOperationalState(a.State) && !routeUnpublished(a.State):
|
|
g.Clickable = true
|
|
g.Href = "https://" + a.Subdomain + "." + domain + a.OpenPath
|
|
case a.State == stacks.StateStopped:
|
|
g.Label = "A tulajdonos leállította"
|
|
default:
|
|
g.Label = "Átmenetileg nem elérhető"
|
|
}
|
|
out = append(out, g)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// shareGuestHandler serves GET /s/<token>: the standalone read-only launcher, or the password gate
|
|
// when a share password is set and no valid cookie is present. An unknown token → share404.
|
|
func (s *Server) shareGuestHandler(w http.ResponseWriter, r *http.Request) {
|
|
token := strings.TrimPrefix(r.URL.Path, "/s/")
|
|
stored := s.settings.GetLauncherShareToken()
|
|
if !shareTokenMatches(stored, token) {
|
|
s.share404(w, r)
|
|
return
|
|
}
|
|
pwHash := s.settings.GetLauncherSharePasswordHash()
|
|
if pwHash != "" && !s.shareCookieValid(r, stored, pwHash) {
|
|
s.renderSharePasswordPage(w, r, "")
|
|
return
|
|
}
|
|
s.renderShareGuestPage(w, r)
|
|
}
|
|
|
|
// shareGuestPasswordHandler handles POST /s/<token>: the optional share-password gate. On success it
|
|
// sets the signed, ~30-day gate cookie (bound to token|passwordHash). Pre-auth HMAC CSRF + a per-IP
|
|
// 5/1-min limiter (own map) protect it.
|
|
func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
|
token := strings.TrimPrefix(r.URL.Path, "/s/")
|
|
stored := s.settings.GetLauncherShareToken()
|
|
if !shareTokenMatches(stored, token) {
|
|
s.share404(w, r)
|
|
return
|
|
}
|
|
pwHash := s.settings.GetLauncherSharePasswordHash()
|
|
if pwHash == "" {
|
|
// No gate — a stray POST just returns to the page (renders directly).
|
|
s.renderShareGuestPage(w, r)
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
if !s.validShareCSRF(r) {
|
|
s.renderSharePasswordPage(w, r, "Érvénytelen űrlap — töltse újra az oldalt.")
|
|
return
|
|
}
|
|
ip := clientIP(r)
|
|
if s.shareRateLimited(ip) {
|
|
s.logger.Printf("[WARN] [web] share password rate limited for %s", ip)
|
|
s.renderSharePasswordPage(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva")
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil {
|
|
s.shareRegisterFailure(ip)
|
|
s.renderSharePasswordPage(w, r, "Hibás jelszó")
|
|
return
|
|
}
|
|
s.shareClearFailures(ip)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: shareCookieName,
|
|
Value: s.shareCookieValue(stored, pwHash),
|
|
Path: "/s/",
|
|
MaxAge: int(shareCookieMaxAge.Seconds()),
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
|
})
|
|
// Redirect back to the same URL so a reload/back does not re-POST; the cookie now passes the gate.
|
|
http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
|
|
}
|
|
|
|
// renderShareGuestPage renders the standalone guest launcher (own minimal <html>, no admin chrome).
|
|
func (s *Server) renderShareGuestPage(w http.ResponseWriter, r *http.Request) {
|
|
s.setGuestHeaders(w)
|
|
data := map[string]interface{}{
|
|
"Domain": s.cfg.Customer.Domain,
|
|
"Apps": s.guestLauncherApps(),
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.tmpl.ExecuteTemplate(w, "launcher_shared", data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Template error (launcher_shared): %v", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// renderSharePasswordPage renders the standalone one-field password gate and sets the pre-auth CSRF
|
|
// cookie the POST validates.
|
|
func (s *Server) renderSharePasswordPage(w http.ResponseWriter, r *http.Request, errMsg string) {
|
|
s.setGuestHeaders(w)
|
|
csrf := s.setShareCSRFCookie(w, r)
|
|
data := map[string]interface{}{
|
|
"Action": r.URL.Path, // /s/<token> — the guest already holds this token in their URL bar
|
|
"CSRF": csrf,
|
|
"Error": errMsg,
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.tmpl.ExecuteTemplate(w, "launcher_share_password", data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Template error (launcher_share_password): %v", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// ── admin share management (session-authed via RequireAuth + session CSRF via CsrfProtect) ─────────
|
|
|
|
// launcherShareRedirect returns to /launcher with a Hungarian flash (reuses urlQueryEscape).
|
|
func (s *Server) launcherShareRedirect(w http.ResponseWriter, r *http.Request, flash string) {
|
|
http.Redirect(w, r, "/launcher?flash="+urlQueryEscape(flash), http.StatusSeeOther)
|
|
}
|
|
|
|
// launcherShareQRHandler serves the share link as a ~256px PNG QR code (admin-authed; not exempted, so
|
|
// an unauthenticated request → login redirect). The token is never logged.
|
|
func (s *Server) launcherShareQRHandler(w http.ResponseWriter, r *http.Request) {
|
|
token := s.settings.GetLauncherShareToken()
|
|
if token == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
png, err := qrcode.Encode("https://"+r.Host+"/s/"+token, qrcode.Medium, 256)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] share: QR encode failed: %v", err)
|
|
http.Error(w, "QR error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
|
_, _ = w.Write(png)
|
|
}
|
|
|
|
// launcherShareEnableHandler mints the first token (POST /launcher/share/enable).
|
|
func (s *Server) launcherShareEnableHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.settings.GetLauncherShareToken() != "" {
|
|
s.launcherShareRedirect(w, r, "A megosztás már be van kapcsolva.")
|
|
return
|
|
}
|
|
tok, err := newShareToken()
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
|
|
s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.")
|
|
return
|
|
}
|
|
if err := s.settings.SetLauncherShareToken(tok); err != nil {
|
|
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
|
|
s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.")
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] launcher share link enabled")
|
|
s.launcherShareRedirect(w, r, "A megosztás bekapcsolva.")
|
|
}
|
|
|
|
// launcherShareRotateHandler mints a fresh token (POST /launcher/share/rotate). The old link 404s and
|
|
// every outstanding guest cookie is invalidated (both are bound to the token).
|
|
func (s *Server) launcherShareRotateHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.settings.GetLauncherShareToken() == "" {
|
|
s.launcherShareRedirect(w, r, "A megosztás nincs bekapcsolva.")
|
|
return
|
|
}
|
|
tok, err := newShareToken()
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
|
|
s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.")
|
|
return
|
|
}
|
|
if err := s.settings.SetLauncherShareToken(tok); err != nil {
|
|
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
|
|
s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.")
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] launcher share link rotated")
|
|
s.launcherShareRedirect(w, r, "Új megosztási link készült. A korábbi link és minden korábbi belépés érvénytelen.")
|
|
}
|
|
|
|
// launcherShareDisableHandler clears the token AND the share password (POST /launcher/share/disable) —
|
|
// a clean slate so a later re-enable never inherits a stale gate. All /s/ paths then 404.
|
|
func (s *Server) launcherShareDisableHandler(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.settings.SetLauncherShareToken(""); err != nil {
|
|
s.logger.Printf("[ERROR] [web] share: clearing token failed: %v", err)
|
|
s.launcherShareRedirect(w, r, "A megosztás kikapcsolása nem sikerült.")
|
|
return
|
|
}
|
|
if err := s.settings.SetLauncherSharePasswordHash(""); err != nil {
|
|
s.logger.Printf("[WARN] [web] share: clearing share password on disable failed: %v", err)
|
|
}
|
|
s.logger.Printf("[INFO] [web] launcher share link disabled")
|
|
s.launcherShareRedirect(w, r, "A megosztás kikapcsolva.")
|
|
}
|
|
|
|
// launcherSharePasswordHandler sets or clears the OPTIONAL per-share password (POST
|
|
// /launcher/share/password). action=clear removes it; otherwise a min-length password is bcrypt-hashed
|
|
// into its OWN settings field (never the admin hash). Either change invalidates outstanding cookies.
|
|
func (s *Server) launcherSharePasswordHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
if s.settings.GetLauncherShareToken() == "" {
|
|
s.launcherShareRedirect(w, r, "A megosztás nincs bekapcsolva.")
|
|
return
|
|
}
|
|
if r.FormValue("action") == "clear" {
|
|
if err := s.settings.SetLauncherSharePasswordHash(""); err != nil {
|
|
s.logger.Printf("[ERROR] [web] share: clearing share password failed: %v", err)
|
|
s.launcherShareRedirect(w, r, "A jelszó törlése nem sikerült.")
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] launcher share password cleared")
|
|
s.launcherShareRedirect(w, r, "A megosztási jelszó törölve.")
|
|
return
|
|
}
|
|
pw := r.FormValue("password")
|
|
if len(pw) < shareMinPassword {
|
|
s.launcherShareRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.")
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(pw), 10)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] share: hashing share password failed: %v", err)
|
|
s.launcherShareRedirect(w, r, "A jelszó beállítása nem sikerült.")
|
|
return
|
|
}
|
|
if err := s.settings.SetLauncherSharePasswordHash(string(hash)); err != nil {
|
|
s.logger.Printf("[ERROR] [web] share: saving share password failed: %v", err)
|
|
s.launcherShareRedirect(w, r, "A jelszó beállítása nem sikerült.")
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] launcher share password set")
|
|
s.launcherShareRedirect(w, r, "A megosztási jelszó beállítva. A korábbi belépések érvénytelenek.")
|
|
}
|