315 lines
12 KiB
Go
315 lines
12 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.
|
|
func safeFilesPath(p string) string {
|
|
if p == "" || p == "/" {
|
|
return "/"
|
|
}
|
|
if !strings.HasPrefix(p, "/files/") || strings.HasPrefix(p, "//") || strings.ContainsAny(p, "\\\r\n") {
|
|
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
|
|
}
|
|
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")
|
|
http.Redirect(w, r, 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)
|
|
}
|
|
}
|