0.2.65 - End Credits wiring / Gateway: 0.1.45 - Credits
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
@@ -17,12 +18,29 @@ import (
|
||||
const (
|
||||
installerCookieName = "memby_installer"
|
||||
installerSessionTTL = 30 * time.Minute
|
||||
adminSessionTTL = 12 * time.Hour
|
||||
installerDeviceID = "memby-web-installer"
|
||||
// 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.
|
||||
@@ -62,23 +80,24 @@ func (s *Server) signInstallerValue(purpose string, payload []byte) []byte {
|
||||
}
|
||||
|
||||
func (s *Server) newInstallerSession() (string, error) {
|
||||
return s.newBrowserSession(installerSessionTTL)
|
||||
return s.newBrowserSession(installerSessionPurpose, installerSessionTTL)
|
||||
}
|
||||
|
||||
func (s *Server) newBrowserSession(ttl time.Duration) (string, error) {
|
||||
func (s *Server) newBrowserSession(purpose string, ttl time.Duration) (string, error) {
|
||||
payload := make([]byte, 8+16)
|
||||
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix()))
|
||||
if _, err := rand.Read(payload[8:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
signature := s.signInstallerValue("session", payload)
|
||||
signature := s.signInstallerValue(purpose, payload)
|
||||
return base64.RawURLEncoding.EncodeToString(payload) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
// installerSessionExpiry reports when the request's session runs out. A cookie that is
|
||||
// missing, malformed, forged or already expired is reported the same way: no session.
|
||||
func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
|
||||
// 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) {
|
||||
if len(s.installerSecret()) == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -95,7 +114,7 @@ func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil || !hmac.Equal(signature, s.signInstallerValue("session", payload)) {
|
||||
if err != nil || !hmac.Equal(signature, s.signInstallerValue(purpose, payload)) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
expires := int64(binary.BigEndian.Uint64(payload[:8]))
|
||||
@@ -106,8 +125,20 @@ func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
|
||||
return time.Unix(expires, 0), true
|
||||
}
|
||||
|
||||
// validInstallerSession gates the public installer, which an administrator's own session
|
||||
// satisfies too.
|
||||
func (s *Server) validInstallerSession(r *http.Request) bool {
|
||||
_, ok := s.installerSessionExpiry(r)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -118,11 +149,11 @@ func (s *Server) validInstallerSession(r *http.Request) bool {
|
||||
// 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, ok := s.installerSessionExpiry(r)
|
||||
expires, ok := s.browserSessionExpiry(r, adminSessionPurpose)
|
||||
if !ok || time.Until(expires) > adminRenewWithin {
|
||||
return
|
||||
}
|
||||
session, err := s.newBrowserSession(adminSessionTTL)
|
||||
session, err := s.newBrowserSession(adminSessionPurpose, adminSessionTTL)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
|
||||
return
|
||||
@@ -183,6 +214,26 @@ func (s *Server) allowedReleaseDownload(r *http.Request, filename string) bool {
|
||||
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)
|
||||
@@ -222,6 +273,10 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusUnauthorized, next)
|
||||
return
|
||||
}
|
||||
// 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{
|
||||
@@ -242,11 +297,27 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ttl := installerSessionTTL
|
||||
purpose, ttl := installerSessionPurpose, installerSessionTTL
|
||||
if strings.HasPrefix(next, "/admin/") {
|
||||
ttl = adminSessionTTL
|
||||
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
|
||||
}
|
||||
session, err := s.newBrowserSession(ttl)
|
||||
session, err := s.newBrowserSession(purpose, ttl)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not start installer session")
|
||||
|
||||
Reference in New Issue
Block a user