Files
2026-08-27 07:31:57 +12:00

398 lines
16 KiB
Go

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
}
// 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
}