package api import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "net/http" "net/url" "strings" "time" "unicode/utf8" "github.com/ponzischeme89/memby/server/internal/emby" ) const ( installerCookieName = "memby_installer" installerSessionTTL = 30 * time.Minute // adminSessionTTL is deliberately long. The console is reached from the household's // own machines, the sign-in behind it is an Emby password check, and an operator who // opens it once a month was being asked for that password every single visit — which // is the shape of a gate people work around rather than one that protects anything. // Ninety days of idle time matches MEMBY_SESSION_IDLE_EXPIRY, so a browser and a // television are forgotten on the same schedule. adminSessionTTL = 90 * 24 * time.Hour installerDeviceID = "memby-web-installer" // adminRenewWithin is how close to expiry a session must be before an operator's own // request re-issues it. Half the TTL avoids rewriting the cookie on every request. adminRenewWithin = adminSessionTTL / 2 // A browser session says what it may be used for, and it says so by being signed with // its own purpose rather than by carrying a claim the holder could edit. The two gates // are not one gate: /install is public by design — any member of the household signs // in there to install Memby on a new television — while the console administers the // server. One cookie serves both, so without this separation an ordinary viewer's // installer sign-in satisfied the console's gate, and opening /admin/ then handed them // the admin token cookie. An admin session is accepted at /install as well, since // somebody who may administer the server may certainly download the app. installerSessionPurpose = "session" adminSessionPurpose = "admin session" ) // gatewayDeviceName is what Emby records for a device row the gateway creates for itself. // It follows the gateway's client name so one operator-set word covers both halves of how // the server identifies itself, and it is deliberately never the product name — Emby's // device list is read by whoever runs the server, and an entry called "Memby …" there // reads as one of the household's televisions. func (s *Server) gatewayDeviceName() string { if name := strings.TrimSpace(s.cfg.GatewayClientName); name != "" { return name } return emby.DefaultGatewayClientName } // installerDeviceName separates the temporary record an admin or installer sign-in // creates from the gateway's own, so a password check is recognisable while it exists. func (s *Server) installerDeviceName() string { return s.gatewayDeviceName() + " Installer" } func (s *Server) installerSecret() []byte { if s.cfg.ReleasePublishToken == "" { return nil } // Domain separation means a cookie/signature is not the release publisher token and // cannot be presented to the upload endpoint. sum := sha256.Sum256([]byte("memby installer access v1\x00" + s.cfg.ReleasePublishToken)) return sum[:] } func (s *Server) signInstallerValue(purpose string, payload []byte) []byte { mac := hmac.New(sha256.New, s.installerSecret()) _, _ = mac.Write([]byte(purpose)) _, _ = mac.Write([]byte{0}) _, _ = mac.Write(payload) return mac.Sum(nil) } func (s *Server) newInstallerSession() (string, error) { return s.newBrowserSession(installerSessionPurpose, installerSessionTTL) } func (s *Server) newBrowserSession(purpose string, ttl time.Duration) (string, error) { return s.newBrowserSessionFor(purpose, ttl, "") } // newBrowserSessionFor includes the verified account name in an admin session. It remains // inside the signed, HttpOnly cookie: the console learns who is at the keyboard from the // status response, without adding a second identity cookie that JavaScript could alter. // Sessions minted by older gateways had only the 24-byte prefix and remain valid. func (s *Server) newBrowserSessionFor(purpose string, ttl time.Duration, username string) (string, error) { username = strings.TrimSpace(username) if len(username) > 512 || !utf8.ValidString(username) { username = "" } payload := make([]byte, 8+16+len(username)) binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix())) if _, err := rand.Read(payload[8:24]); err != nil { return "", err } copy(payload[24:], username) signature := s.signInstallerValue(purpose, payload) return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(signature), nil } // browserSessionExpiry reports when the request's session of this purpose runs out. A // cookie that is missing, malformed, forged, signed for a different purpose or already // expired is reported the same way: no session. func (s *Server) browserSessionExpiry(r *http.Request, purpose string) (time.Time, bool) { expires, _, ok := s.browserSession(r, purpose) return expires, ok } // browserSession verifies the session once and returns the optional identity carried by // newer cookies. The empty identity is legitimate for a session issued before it was // added, so validity is reported separately. func (s *Server) browserSession(r *http.Request, purpose string) (time.Time, string, bool) { if len(s.installerSecret()) == 0 { return time.Time{}, "", false } cookie, err := r.Cookie(installerCookieName) if err != nil { return time.Time{}, "", false } parts := strings.Split(cookie.Value, ".") if len(parts) != 2 { return time.Time{}, "", false } payload, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil || len(payload) < 24 || len(payload) > 536 || !utf8.Valid(payload[24:]) { return time.Time{}, "", false } signature, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil || !hmac.Equal(signature, s.signInstallerValue(purpose, payload)) { return time.Time{}, "", false } expires := int64(binary.BigEndian.Uint64(payload[:8])) now := time.Now().Unix() if expires <= now || expires > now+int64(adminSessionTTL/time.Second)+60 { return time.Time{}, "", false } return time.Unix(expires, 0), strings.TrimSpace(string(payload[24:])), true } // validInstallerSession gates the public installer, which an administrator's own session // satisfies too. func (s *Server) validInstallerSession(r *http.Request) bool { if s.validAdminSession(r) { return true } _, ok := s.browserSessionExpiry(r, installerSessionPurpose) return ok } // validAdminSession gates the console. Only a sign-in Emby confirmed as an administrator // mints one of these, so a household member's installer cookie cannot reach it. func (s *Server) validAdminSession(r *http.Request) bool { _, ok := s.browserSessionExpiry(r, adminSessionPurpose) return ok } // adminSessionNamed reports whether the request's admin session carries the verified Emby // account name. Every session minted since the identity was added to the cookie has one; // a session predating it is valid but anonymous, which is what makes the console's user // switcher fall back to "Administrator" instead of showing who is signed in. The console // document handler treats an anonymous session as needing a fresh sign-in so the name is // picked up — a one-time prompt, since renewal preserves whatever the cookie already held. func (s *Server) adminSessionNamed(r *http.Request) bool { _, username, ok := s.browserSession(r, adminSessionPurpose) return ok && username != "" } // renewAdminSession slides a valid session's expiry forward. The TTL was absolute and // nothing extended it, so an operator working the admin console was signed out from under // themselves and the page's poll became a permanent "invalid admin // token" banner with no sign-in to return to. Callers must only reach here for a request // an operator actually made — see operatorPresent — or an abandoned tab's own polling // would keep the session alive indefinitely, which is what the TTL exists to stop. func (s *Server) renewAdminSession(w http.ResponseWriter, r *http.Request) { expires, username, ok := s.browserSession(r, adminSessionPurpose) if !ok || time.Until(expires) > adminRenewWithin { return } session, err := s.newBrowserSessionFor(adminSessionPurpose, adminSessionTTL, username) if err != nil { s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err) return } s.setBrowserSessionCookie(w, session, adminSessionTTL) } func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) { s.setBrowserSessionCookie(w, value, installerSessionTTL) } func (s *Server) setBrowserSessionCookie(w http.ResponseWriter, value string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: installerCookieName, Value: value, Path: "/", MaxAge: int(ttl / time.Second), HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) } func (s *Server) clearInstallerCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: installerCookieName, Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, }) } func (s *Server) releaseAccessToken(filename string) string { if len(s.installerSecret()) == 0 || !releaseFilenamePattern.MatchString(filename) { return "" } return base64.RawURLEncoding.EncodeToString( s.signInstallerValue("release", []byte(filename)), ) } func (s *Server) signedReleasePath(filename string) string { token := s.releaseAccessToken(filename) if token == "" { return "" } return "/updates/" + filename + "?access=" + url.QueryEscape(token) } func (s *Server) allowedReleaseDownload(r *http.Request, filename string) bool { if s.validInstallerSession(r) { return true } expected := s.releaseAccessToken(filename) presented := strings.TrimSpace(r.URL.Query().Get("access")) return expected != "" && hmac.Equal([]byte(presented), []byte(expected)) } // embyAdministrator asks Emby whether the account that just signed in administers the // server — the access level in its own user policy, which is the only authority on the // question and the one an operator already manages. Emby answers it in the authentication // response, so this normally costs nothing; a response carrying no policy at all is asked // again directly rather than read as a refusal, because reading silence as "no" would lock // an operator out of their own console with no way back in. func (s *Server) embyAdministrator(ctx context.Context, auth *emby.AuthResult) (bool, error) { if auth.User.Policy.IsAdministrator != nil { return *auth.User.Policy.IsAdministrator, nil } user, err := s.emby.UserByID(ctx, emby.Credentials{ UserID: auth.User.ID, Token: auth.AccessToken, DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), Gateway: true, }, auth.User.ID) if err != nil { return false, err } return user.Policy.IsAdministrator, nil } func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) { if len(s.installerSecret()) == 0 { http.NotFound(w, r) return } // Password authentication necessarily registers a device with Emby. Do not start // it unless the service credential needed to remove that temporary record exists. if s.cfg.SyncAPIKey == "" { s.loggerFor(r.Context()).Error("installer login unavailable: MEMBY_SYNC_API_KEY is not configured") s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.", http.StatusServiceUnavailable, "/install") return } r.Body = http.MaxBytesReader(w, r.Body, 8<<10) if err := r.ParseForm(); err != nil { s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusBadRequest, "/install") return } next := cleanInstallerDestination(r.FormValue("next")) username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") if username == "" || password == "" { s.renderAccessLogin(w, r, "Enter your username and password.", http.StatusBadRequest, next) return } // Gateway, not a television: this sign-in is the admin console or the web installer // checking a password, so Emby records it under the gateway's own client name. log := s.loggerFor(r.Context()).With( "emby_client", s.cfg.GatewayClientName, "device", s.installerDeviceName(), "device_id", installerDeviceID, ) log.Info("Emby installer authentication starting") auth, err := s.emby.Authenticate( r.Context(), username, password, emby.Credentials{ DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), Gateway: true, }, ) if err != nil { log.Warn("installer Emby authentication failed", "username", username) s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusUnauthorized, next) return } log.Info("Emby installer authentication succeeded", "username", username, "user_id", auth.User.ID) // Ask before the token is retired below: the fallback lookup needs it. Whether the // answer is wanted depends on where the sign-in was headed, but it is asked either way // so that the cleanup underneath runs on one path rather than two. administrator, adminErr := s.embyAdministrator(r.Context(), auth) // Authentication creates an Emby access token. The installer needs only proof that // it succeeded, so retire the upstream session immediately and never persist it. if err := s.emby.Logout(r.Context(), emby.Credentials{ UserID: auth.User.ID, Token: auth.AccessToken, DeviceID: installerDeviceID, DeviceName: s.installerDeviceName(), Gateway: true, }); err != nil { s.loggerFor(r.Context()).Warn("installer Emby session cleanup failed", "error", err) } if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{ UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey, DeviceID: "memby-gateway", DeviceName: s.gatewayDeviceName(), Gateway: true, }, installerDeviceID); err != nil { s.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err) s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.", http.StatusBadGateway, next) return } purpose, ttl := installerSessionPurpose, installerSessionTTL if strings.HasPrefix(next, "/admin/") { if adminErr != nil { s.loggerFor(r.Context()).Error("admin sign-in could not read Emby access level", "user", username, "error", adminErr) s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.", http.StatusBadGateway, next) return } if !administrator { // Deliberately the same wording an unknown password gets. Somebody who is not // an administrator has no business learning that the console exists and that // their password was right; the operator can see the refusal in the log. s.loggerFor(r.Context()).Warn("admin sign-in refused: not an Emby administrator", "user", username) s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusForbidden, next) return } purpose, ttl = adminSessionPurpose, adminSessionTTL } verifiedUsername := strings.TrimSpace(auth.User.Name) if verifiedUsername == "" { verifiedUsername = username } session, err := s.newBrowserSessionFor(purpose, ttl, verifiedUsername) if err != nil { s.loggerFor(r.Context()).Error("installer session generation failed", "error", err) writeError(w, http.StatusInternalServerError, "could not start installer session") return } s.setBrowserSessionCookie(w, session, ttl) http.Redirect(w, r, next, http.StatusSeeOther) } func (s *Server) handleInstallLogout(w http.ResponseWriter, r *http.Request) { s.clearInstallerCookie(w) http.Redirect(w, r, "/install", http.StatusSeeOther) } // cleanInstallerDestination decides where a sign-in may return to. // // It admits any /admin path other than the API, because the console is a single-page // application now and its routes are its own: there is no list in Go to check them // against, and there should not be — a page added to the console would otherwise have to // be declared here as well, and the failure when somebody forgot would be a sign-in that // silently landed on the wrong screen. That was already the case for the account and // settings-history pages, whose URLs carry an id: neither could be named here, so signing // in from either dropped the operator back on the user list. // // What it must still refuse is anything that is not a path on this origin — an absolute // URL, a scheme-relative //host, or a backslash some browsers normalise into one — since // this value ends up in a redirect and an open redirect from an admin sign-in is a real // one. Everything that is not clearly an admin path falls back to the installer. func cleanInstallerDestination(value string) string { value = strings.TrimSpace(value) if !strings.HasPrefix(value, "/admin") { return "/install" } if strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\\r\n") { return "/install" } if strings.HasPrefix(value, "/admin/api/") { return "/admin/" } if value == "/admin" { return "/admin/" } return value }