Files
felhom-controller/controller/internal/web/files_sso.go
T

337 lines
13 KiB
Go

package web
import (
"crypto/hmac"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
)
// ── One sign-in for the file manager (R-1100, operator decision D16 = yes, 2026-10-19) ───────────────────────────────
//
// Design: felhom.eu audits/proxyauth-spike-2026-10-18/DESIGN-NOTE.md (measured) and `02` „Logins and folders". The
// setup gate's handshake (setup_gate.go, decision 46) with one change: the pass names a FileBrowser user.
//
// - „Fájlok ↗" goes to GET /__files/open?to=<FileBrowser path> on the DASHBOARD host. With the household's session it
// mints a pass — 60 s, one use, bound to files.<domain> and to that session — and sends the browser to
// https://files.<domain>/_felhom/sso?t=<pass>. Without a session: the dashboard's sign-in, then back here.
// - Every request to files.<domain> first asks GET /__felhom_gate/files (traefik forwardAuth). /_felhom/sso swaps the
// pass for the files cookie (`felhom_files`, host-only on files.<domain>, HttpOnly, Secure, Lax) and goes on to the
// folder. A valid files cookie → 200 WITH the box's secret header naming the household's FileBrowser user. No files
// cookie → 200 WITHOUT it: FileBrowser asks for its own password (`admin` — the fallback, and the operator's way).
// A files cookie that no longer opens (the dashboard session ended, the dashboard password changed) → both cookies
// cleared and the browser sent back to the dashboard, which asks for the sign-in again.
//
// THE RULES. The dashboard cookie is never widened to the parent domain — every app subdomain would receive it (the
// setup gate's measured reason). The files cookie carries a value DERIVED from the session's fingerprint for this one
// host (the R-976 shape), never the session: lifted from FileBrowser's side it opens nothing else, and it dies with
// the session. The header's NAME is the box's secret (infra.LoadOrCreateFileBrowserSSOHeader) and only this answer
// and traefik's route carry it — an app container that reaches filebrowser:80 directly cannot forge it. The answer
// gives the header only for files.<domain> (X-Forwarded-Host, which traefik writes; the entrypoint removes a client's).
// Pinned by internal/web/files_sso_test.go.
const (
filesAuthPath = "/__felhom_gate/files"
filesCallbackURI = "/_felhom/sso"
filesOpenPath = "/__files/open"
filesCookieName = "felhom_files"
// fileBrowserJWTCookie is FileBrowser's own session cookie (backend/http/auth.go sessionCookieName at
// 1.5.6-stable). It is cleared with the files cookie, so a header user's FileBrowser token does not outlive the
// dashboard session it came from.
fileBrowserJWTCookie = "filebrowser_quantum_jwt"
)
type filesSSOState struct {
mu sync.Mutex
header string // cached once read: the name never changes once made
}
// fileBrowserSSO is this box's header sign-in (the same file the stacks manager renders FileBrowser's config and
// traefik's route from). An error = none, logged; the name itself is never logged.
func (s *Server) fileBrowserSSO() infra.FileBrowserSSO {
if s.cfg == nil {
return infra.FileBrowserSSO{}
}
sso, err := infra.FileBrowserSSOFor(s.cfg.Paths.DataDir, s.cfg.Customer.Domain)
if err != nil {
s.logger.Printf("[WARN] [web] files sign-in: none (R-1100): %v — FileBrowser asks for its own password", err)
return infra.FileBrowserSSO{}
}
return sso
}
// filesHeader is fileBrowserSSO's header name, read once (the forwardAuth answer runs on every FileBrowser request).
func (s *Server) filesHeader() string {
s.filesSSO.mu.Lock()
defer s.filesSSO.mu.Unlock()
if s.filesSSO.header == "" {
s.filesSSO.header = s.fileBrowserSSO().Header
}
return s.filesSSO.header
}
func (s *Server) filesHost() string {
if s.cfg == nil || s.cfg.Customer.Domain == "" {
return ""
}
return "files." + strings.ToLower(s.cfg.Customer.Domain)
}
// filesSessionKey is what files.<domain> ever sees of a dashboard session: a value derived from its fingerprint for
// this purpose and host (R-976's shape).
func (s *Server) filesSessionKey(fp string) string {
return s.gateMAC("filessess", s.filesHost(), fp)
}
// filesSessionAlive reports whether a live dashboard session has the key sk.
func (s *Server) filesSessionAlive(sk string) bool {
if sk == "" {
return false
}
now := time.Now()
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
for fp, se := range s.sessions {
if se != nil && now.Before(se.expiresAt) && hmac.Equal([]byte(s.filesSessionKey(fp)), []byte(sk)) {
return true
}
}
return false
}
type filesToken struct {
Host string `json:"h"`
SK string `json:"s"`
Exp int64 `json:"e"`
Nonce string `json:"n"`
RD string `json:"r"`
MAC string `json:"m"`
}
func (s *Server) mintFilesToken(sk, rd string) string {
nb := make([]byte, 12)
_, _ = rand.Read(nb)
t := filesToken{Host: s.filesHost(), SK: sk, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
t.MAC = s.gateMAC("filestoken", t.Host, t.SK, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
b, _ := json.Marshal(t)
return base64.RawURLEncoding.EncodeToString(b)
}
// takeFilesToken checks a pass for host, uses it up, and returns the session key and where the browser was going.
// A setup-gate or family token cannot pass: its MAC is over another kind.
func (s *Server) takeFilesToken(raw, host string) (sk, rd string, err error) {
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return "", "", errors.New("malformed")
}
var t filesToken
if json.Unmarshal(b, &t) != nil {
return "", "", errors.New("malformed")
}
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("filestoken", t.Host, t.SK, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
return "", "", errors.New("bad signature")
}
if t.Host != host {
return "", "", errors.New("for another host")
}
now := s.gateNow()
if now.Unix() > t.Exp {
return "", "", errors.New("expired")
}
s.gateKey()
s.gate.mu.Lock()
defer s.gate.mu.Unlock()
for n, until := range s.gate.used {
if now.After(until) {
delete(s.gate.used, n)
}
}
if _, seen := s.gate.used["fs:"+t.Nonce]; seen {
return "", "", errors.New("already used")
}
s.gate.used["fs:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second)
return t.SK, t.RD, nil
}
// filesCookieKey: "<session key>.<unix expiry>.<mac over host+key+expiry>" → the session key, if genuine for this host,
// unexpired, and its dashboard session still alive.
func (s *Server) filesCookieKey(r *http.Request) (present, ok bool) {
c, err := r.Cookie(filesCookieName)
if err != nil || c.Value == "" {
return false, false
}
parts := strings.Split(c.Value, ".")
if len(parts) != 3 {
return true, false
}
n, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || s.gateNow().Unix() > n {
return true, false
}
if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("filescookie", s.filesHost(), parts[0], parts[1]))) {
return true, false
}
return true, s.filesSessionAlive(parts[0])
}
// safeFilesPath accepts a FileBrowser path to go to after the pass: "/" or under /files/ — never another host, and never
// a dot-segment that a browser would resolve out of /files/ (onto FileBrowser's API, say).
func safeFilesPath(p string) string {
if p == "" || p == "/" {
return "/"
}
if !strings.HasPrefix(p, "/files/") || strings.HasPrefix(p, "//") || strings.ContainsAny(p, "\\\r\n") {
return "/"
}
path, _, _ := strings.Cut(p, "?")
dec, err := url.PathUnescape(path)
if err != nil {
return "/"
}
for _, seg := range strings.Split(dec, "/") {
if seg == ".." || seg == "." {
return "/"
}
}
return p
}
// filesPathOf is the FileBrowser path of an absolute files.<domain> link (fileBrowserLink's output).
func filesPathOf(link, host string) string {
u, err := url.Parse(link)
if err != nil || !strings.EqualFold(u.Host, host) {
return "/"
}
p := u.EscapedPath()
if u.RawQuery != "" {
p += "?" + u.RawQuery
}
return safeFilesPath(p)
}
func (s *Server) clearFilesCookies(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: filesCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
http.SetCookie(w, &http.Cookie{Name: fileBrowserJWTCookie, Value: "", Path: "/", Domain: s.filesHost(), MaxAge: -1, HttpOnly: true, Secure: true})
}
func filesRefuse(w http.ResponseWriter, code int) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"error":"sign in on the dashboard"}`))
}
// ServeFilesOpen is GET /__files/open?to=<FileBrowser path> on the dashboard host — where „Fájlok ↗" goes.
func (s *Server) ServeFilesOpen(w http.ResponseWriter, r *http.Request) {
to := safeFilesPath(r.URL.Query().Get("to"))
host := s.filesHost()
w.Header().Set("Cache-Control", "no-store")
if host == "" || r.Method != http.MethodGet {
http.Redirect(w, r, "/", http.StatusFound)
return
}
// A pass is minted only for the household's own press (the dashboard page: same-origin) or a typed or bookmarked
// address (none) — never for a request another site started. A browser that sends no Sec-Fetch-Site (an old one) is
// let through: the pass opens only the household's own files on this box. Pinned by TestFilesSSO_R1100_CrossSiteNoPass.
if sfs := r.Header.Get("Sec-Fetch-Site"); sfs == "cross-site" {
s.logger.Printf("[WARN] [web] files sign-in: a pass asked for from another site — refused, visitor %s", clientIP(r))
http.Redirect(w, r, "/", http.StatusFound)
return
}
c, err := r.Cookie(sessionCookieName)
if err != nil || !s.isValidSession(c.Value) {
http.Redirect(w, r, "/login?"+url.Values{"next": {filesOpenPath + "?" + url.Values{"to": {to}}.Encode()}}.Encode(), http.StatusFound)
return
}
if s.filesHeader() == "" { // no header sign-in on this box: the plain link, FileBrowser asks for its password
http.Redirect(w, r, "https://"+host+to, http.StatusFound)
return
}
tok := s.mintFilesToken(s.filesSessionKey(sessionFingerprint(c.Value)), to)
http.Redirect(w, r, "https://"+host+filesCallbackURI+"?"+url.Values{"t": {tok}}.Encode(), http.StatusFound)
}
// ServeFilesAuth is traefik's forwardAuth answer for files.<domain>. It trusts only X-Forwarded-Host/-Uri/-Method,
// which traefik writes from the request it forwards.
func (s *Server) ServeFilesAuth(w http.ResponseWriter, r *http.Request) {
host := strings.ToLower(r.Header.Get("X-Forwarded-Host"))
if i := strings.LastIndex(host, ":"); i != -1 {
host = host[:i]
}
if host == "" || host != s.filesHost() {
s.logger.Printf("[WARN] [web] files sign-in: asked about %q, which is not the file manager — refused", host)
filesRefuse(w, http.StatusForbidden)
return
}
header := s.filesHeader()
if header == "" {
w.WriteHeader(http.StatusOK) // no header sign-in on this box; traefik's route has no middleware then anyway
return
}
uri := r.Header.Get("X-Forwarded-Uri")
if uri == "" {
uri = "/"
}
method := r.Header.Get("X-Forwarded-Method")
browser := (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html")
back := "https://felhom." + s.cfg.Customer.Domain + filesOpenPath + "?" + url.Values{"to": {safeFilesPath(uri)}}.Encode()
if u, err := url.Parse(uri); err == nil && u.Path == filesCallbackURI {
sk, rd, err := s.takeFilesToken(u.Query().Get("t"), host)
if err == nil && !s.filesSessionAlive(sk) {
err = errors.New("the session ended")
}
if err != nil {
s.logger.Printf("[WARN] [web] files sign-in: a pass was refused (%v) — visitor %s", err, clientIP(r))
if browser {
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+filesOpenPath+"?to=%2F", http.StatusFound)
return
}
filesRefuse(w, http.StatusForbidden)
return
}
exp := strconv.FormatInt(s.gateNow().Add(sessionMaxAge).Unix(), 10)
http.SetCookie(w, &http.Cookie{
Name: filesCookieName, Value: sk + "." + exp + "." + s.gateMAC("filescookie", host, sk, exp), Path: "/",
MaxAge: int(sessionMaxAge.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
// FileBrowser's own token of an earlier sign-in (admin's, or an old header user's) would win over the header.
http.SetCookie(w, &http.Cookie{Name: fileBrowserJWTCookie, Value: "", Path: "/", Domain: host, MaxAge: -1, HttpOnly: true, Secure: true})
s.logger.Printf("[INFO] [web] files sign-in: a signed-in household browser passed — visitor %s", clientIP(r))
w.Header().Set("Cache-Control", "no-store")
// ABSOLUTE, like the setup gate's: traefik makes a relative Location in a forwardAuth answer absolute against
// the AUTH server's address — measured on 9201, "/files/" became http://felhom-controller:8080/files/.
// Pinned by TestFilesSSO_R1100_HouseholdOpensTheFolderWithoutALogin.
http.Redirect(w, r, "https://"+host+safeFilesPath(rd), http.StatusFound)
return
}
present, ok := s.filesCookieKey(r)
switch {
case ok:
w.Header().Set(header, infra.FileBrowserSSOUser)
w.WriteHeader(http.StatusOK)
case !present:
w.WriteHeader(http.StatusOK) // FileBrowser's own sign-in (traefik has removed any client copy of the header)
default:
s.clearFilesCookies(w)
if browser {
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, back, http.StatusFound)
return
}
filesRefuse(w, http.StatusUnauthorized)
}
}