From 1316e49456f49009fb642f446aacbf273cf28426 Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Fri, 28 Aug 2026 13:19:30 +0200 Subject: [PATCH] users need always login --- ARCHITECTURE.md | 33 +++++++------ README.md | 32 ++++++------ main.rkt | 18 +------ private/player.rkt | 20 ++++---- private/server.rkt | 21 +++++++- private/users.rkt | 85 ++++++++++++++++++++------------ public/app.js | 4 +- rkt-web-player.ini.example | 5 +- scribblings/rkt-web-player.scrbl | 7 ++- 9 files changed, 129 insertions(+), 96 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fa62be8..6fc8c97 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -37,7 +37,7 @@ there is no browser-side persistence or independent playback state. flowchart TB Main[main.rkt
configuration and lifecycle] Server[private/server.rkt
HTTP adapter] - Users[private/users.rkt
users, networks and sessions] + Users[private/users.rkt
users, trusted proxies and sessions] Player[private/player.rkt
application state and commands] Playlists[private/playlists.rkt
durable playlist tabs] DLNAAdapter[private/dlna-playback.rkt
playlist transition orchestration] @@ -241,16 +241,19 @@ on every render. ### 3.7 User authentication Authentication is enabled by adding Argon2id password hashes under `[users]`. -Requests whose effective client address belongs to a configured local network -bypass login. A forwarded address is accepted only when the direct peer belongs -to `[authentication] trusted-proxies`; the rightmost `X-Forwarded-For` value is -used so an untrusted client cannot prepend a local address. +All browser clients then authenticate, including clients on the local network. +A forwarded address is accepted only when the direct peer belongs to +`[authentication] trusted-proxies`; the rightmost `X-Forwarded-For` value is +used so an untrusted client cannot prepend a forged address. Successful logins create opaque 256-bit session tokens. Only the token is sent to the browser in a `Secure`, `HttpOnly`, `SameSite=Strict` cookie; server-side session state has an idle timeout and is intentionally volatile. Login failures -are rate-limited per effective client address. Agent endpoints are outside user -sessions and retain their separate application-ID authorization. +are rate-limited per effective client address. Authenticated requests move the +server-side idle deadline. Once half the configured lifetime has elapsed, the +cookie is reissued with a fresh lifetime; this avoids a `Set-Cookie` header on +every one-second state poll. Agent endpoints are outside user sessions and +retain their separate application-ID authorization. ## 4. Key runtime flows @@ -334,7 +337,7 @@ file: setting remains supported); - allowed 256-bit playback-agent IDs under `[playback-agents]`; - the optional playlist-keystore override under `[player]`; -- Argon2id user hashes, local networks, trusted proxies, and session timeout. +- Argon2id user hashes, trusted proxies, and session timeout. Command-line network settings override INI values. Library paths from both sources are combined and de-duplicated. @@ -345,13 +348,13 @@ and sessions still reset when the process restarts. ## 7. Security and operational boundaries -The browser API has optional user authentication but no TLS termination or -per-user player state. Authentication must be enabled before exposing it to an -untrusted network, and a reverse proxy must provide HTTPS because session -cookies are always marked `Secure`. Local-network bypass and forwarded client -addresses are security-sensitive configuration: only known reverse-proxy peers -may be trusted, and the application port should remain firewalled from the -internet. +The browser API has optional user authentication but no TLS termination. +Authentication must be enabled before exposing it to an untrusted network, and +a reverse proxy must provide HTTPS because session cookies are always marked +`Secure`. Forwarded client addresses are security-sensitive configuration: +only known reverse-proxy peers may be trusted, and the application port should +remain firewalled from the internet. Playlists are isolated by username, while +renderer selection and transport state remain shared. Playback-agent registration and polling are authorized against a default-deny INI allowlist. Media URLs additionally contain an opaque per-track token. The diff --git a/README.md b/README.md index da18f13..72f8d38 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,8 @@ podcasts=D:\Podcasts 7b4776ef27104e8eb9f7ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true [authentication] -local-networks=127.0.0.0/8;::1/128;10.0.0.0/8;172.16.0.0/12;192.168.0.0/16 trusted-proxies=127.0.0.0/8;::1/128 -session-seconds=43200 +session-seconds=604800 [users] hans=$argon2id$v=19$m=19456,t=2,p=1$... @@ -57,8 +56,8 @@ ondersteund. Playlisttabs worden standaard opgeslagen in de keystore `playlist-keystore=...` onder `[player]` kan desgewenst een ander pad worden gebruikt. -Zodra `[users]` minstens één gebruiker bevat, toont de webinterface voor -niet-lokale clients een eigen loginvenster. Wachtwoorden staan uitsluitend als +Zodra `[users]` minstens één gebruiker bevat, moeten alle browserclients +inloggen, zowel lokaal als via internet. Wachtwoorden staan uitsluitend als Argon2id-hash in de INI. Maak zo'n hash vanuit Racket: ```racket @@ -69,7 +68,6 @@ Argon2id-hash in de INI. Maak zo'n hash vanuit Racket: (displayln (make-password-hash "een lang en uniek wachtwoord")) ``` -`local-networks` bepaalt welke clients zonder login mogen werken. `trusted-proxies` bepaalt uitsluitend van welke directe peers de laatste `X-Forwarded-For`-waarde wordt geaccepteerd. Laat die lijst zo klein mogelijk; bij Apache op dezelfde machine zijn loopbackadressen voldoende. Zonder @@ -108,21 +106,25 @@ tab verdwijnt daarbij ook uit de keystore. Voor iedere gebruiker bevat de key GUID-key staan de naam en tracks van die playlist. Tracks uit verschillende geconfigureerde libraries mogen in dezelfde playlist staan; ontbrekende of buiten de libraries gelegen bestanden worden bij het laden overgeslagen. -`local`, `anonymous` en iedere aangemelde gebruiker hebben daarbij een eigen -playlistverzameling. Het fysieke afspeelpunt en de transportbediening blijven -gedeeld; een playlistcommando van een andere gebruiker neemt die speler over. +Iedere aangemelde gebruiker heeft daarbij een eigen playlistverzameling. Als +authenticatie is uitgeschakeld, wordt de verzameling van `anonymous` gebruikt. +Het fysieke afspeelpunt en de transportbediening blijven gedeeld; een +playlistcommando van een andere gebruiker neemt die speler over. Een oudere +`playlists-for-local`-verzameling blijft in de keystore staan, maar wordt niet +automatisch aan een gebruiker toegewezen. De server luistert standaard alleen op localhost. Geef alleen bewust een LAN-adres aan `--listen-ip`. Configureer gebruikersauthenticatie voordat de webinterface via een publiek bereikbare reverse proxy wordt aangeboden. -Externe gebruikers krijgen na succesvolle aanmelding een willekeurige 256-bit -sessiecookie met `Secure`, `HttpOnly` en `SameSite=Strict`. Sessies verlopen na -de ingestelde inactiviteitsduur en worden niet over een serverherstart heen -bewaard. Na vijf mislukte pogingen vanaf hetzelfde clientadres wordt aanmelden -vijf minuten geblokkeerd. De `/api/agent/*`-routes gebruiken geen -gebruikerssessie: daarvoor blijft de afzonderlijke playback-agent-allowlist -gelden. +Gebruikers krijgen na succesvolle aanmelding een willekeurige 256-bit +sessiecookie met `Secure`, `HttpOnly` en `SameSite=Strict`. De standaard +inactiviteitsduur is zeven dagen. Geldig gebruik verschuift de servertermijn; +halverwege de termijn wordt ook de browsercookie opnieuw voor zeven dagen +uitgegeven. Sessies worden niet over een serverherstart heen bewaard. Na vijf +mislukte pogingen vanaf hetzelfde clientadres wordt aanmelden vijf minuten +geblokkeerd. De `/api/agent/*`-routes gebruiken geen gebruikerssessie: daarvoor +blijft de afzonderlijke playback-agent-allowlist gelden. ## Windows playback agent diff --git a/main.rkt b/main.rkt index 4c125c7..d11e678 100644 --- a/main.rkt +++ b/main.rkt @@ -55,14 +55,9 @@ (define/contract (run-web-player music-paths #:allowed-agent-ids [allowed-agent-ids '()] #:users [users '()] - #:local-networks - [local-networks - '("127.0.0.0/8" "::1/128" - "10.0.0.0/8" "172.16.0.0/12" - "192.168.0.0/16")] #:trusted-proxies [trusted-proxies '("127.0.0.0/8" "::1/128")] - #:session-seconds [session-seconds 43200] + #:session-seconds [session-seconds 604800] #:listen-ip [listen-ip "127.0.0.1"] #:port [port 8080] #:dlna-port [dlna-port 8734] @@ -72,7 +67,6 @@ (->* ((listof library-spec/c)) (#:allowed-agent-ids (listof string?) #:users (listof (cons/c string? string?)) - #:local-networks (listof string?) #:trusted-proxies (listof string?) #:session-seconds exact-positive-integer? #:listen-ip string? @@ -88,7 +82,6 @@ #:dlna-port dlna-port)) (auth-manager (make-auth-manager users - #:local-networks local-networks #:trusted-proxies trusted-proxies #:session-seconds session-seconds))) (info-rkt-web-player @@ -179,19 +172,13 @@ #:when (string? (cdr entry))) entry)) - (define local-networks - (configuration-list - (ini-get config 'authentication 'local-networks "") - '("127.0.0.0/8" "::1/128" - "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"))) - (define trusted-proxies (configuration-list (ini-get config 'authentication 'trusted-proxies "") '("127.0.0.0/8" "::1/128"))) (define session-seconds - (ini-get config 'authentication 'session-seconds 43200)) + (ini-get config 'authentication 'session-seconds 604800)) (define all-libraries (append configured-libraries @@ -203,7 +190,6 @@ all-libraries #:allowed-agent-ids allowed-agent-ids #:users configured-users - #:local-networks local-networks #:trusted-proxies trusted-proxies #:session-seconds session-seconds #:listen-ip (or listen-ip diff --git a/private/player.rkt b/private/player.rkt index 473da8e..f78adf6 100644 --- a/private/player.rkt +++ b/private/player.rkt @@ -1126,7 +1126,7 @@ (string-downcase app-id))) (define store (open-playlist-store playlist-keystore)) (define stored-tabs - (load-user-playlists store "local" libraries)) + (load-user-playlists store "anonymous" libraries)) (let* ((library (and (pair? libraries) (car libraries))) (browser-entries (if library @@ -1142,7 +1142,7 @@ (selected-index 0) (contexts (make-hash)) (initial-context (playlist-context tabs selected-index))) - (hash-set! contexts "local" initial-context) + (hash-set! contexts "anonymous" initial-context) (define value (player libraries (remove-duplicates normalized-agent-ids string=?) @@ -1175,7 +1175,7 @@ (make-hash) store contexts - "local" + "anonymous" dlna-port)) value)) @@ -1185,7 +1185,7 @@ ; post : Cached DLNA playback information has been incorporated. ; result : A JSON-compatible hash. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (player-state->jsexpr value #:username [username "local"]) +(define (player-state->jsexpr value #:username [username "anonymous"]) (define normalized (normal-playlist-username username)) (call-with-semaphore (player-command-lock value) @@ -1264,7 +1264,7 @@ "playlist-clear" "tab-add" "tab-select" "tab-rename" "tab-delete" "play")) -(define (player-command! value command data #:username [username "local"]) +(define (player-command! value command data #:username [username "anonymous"]) (define normalized (normal-playlist-username username)) (call-with-semaphore (player-command-lock value) @@ -1488,7 +1488,7 @@ ; post : Player state remains unchanged. ; result : Artwork bytes and MIME type, or #f when unavailable. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define (player-track-artwork value artwork-id #:username [username "local"]) +(define (player-track-artwork value artwork-id #:username [username "anonymous"]) (define item (call-with-semaphore (player-command-lock value) @@ -1757,10 +1757,10 @@ "tab-rename" (hasheq 'index 1 'name "Hans favorieten") #:username "hans") - (define local-after-hans - (player-state->jsexpr example-player #:username "local")) - (check-equal? (length (hash-ref local-after-hans 'tabs)) 1) - (check-equal? (length (hash-ref local-after-hans 'tracks)) 2) + (define anonymous-after-hans + (player-state->jsexpr example-player #:username "anonymous")) + (check-equal? (length (hash-ref anonymous-after-hans 'tabs)) 1) + (check-equal? (length (hash-ref anonymous-after-hans 'tracks)) 2) (player-close! example-player) (define restored-player diff --git a/private/server.rkt b/private/server.rkt index 4ccb257..e83771f 100644 --- a/private/server.rkt +++ b/private/server.rkt @@ -54,7 +54,6 @@ (let ((user (auth-request-user current-auth request))) (json-response (hasheq 'enabled (auth-enabled? current-auth) - 'local (auth-request-local? current-auth request) 'authenticated (and user #t) 'username (or user 'null))))) @@ -215,6 +214,24 @@ (regexp-match? #px"^/api/(?:auth|agent)(?:/|$)" (request-path request))) +(define (response-add-header value extra-header) + (response (response-code value) + (response-message value) + (response-seconds value) + (response-mime value) + (cons extra-header (response-headers value)) + (response-output value))) + +(define (dispatch-api request) + (define value (api-dispatch request)) + (define renewed-cookie + (and (not (regexp-match? #px"^/api/agent(?:/|$)" + (request-path request))) + (auth-renewal-cookie current-auth request))) + (if renewed-cookie + (response-add-header value (header #"Set-Cookie" renewed-cookie)) + value)) + (define (dispatch request) (cond ((and (bytes=? (request-method request) #"POST") @@ -225,7 +242,7 @@ #:code 415)) ((or (public-api-request? request) (auth-request-user current-auth request)) - (api-dispatch request)) + (dispatch-api request)) (else (json-response (hasheq 'error "Aanmelden is vereist" diff --git a/private/users.rkt b/private/users.rkt index df2b23d..d0f7c86 100644 --- a/private/users.rkt +++ b/private/users.rkt @@ -14,18 +14,20 @@ make-auth-manager auth-manager? auth-enabled? - auth-request-local? auth-request-user auth-login! auth-logout! auth-session-cookie + auth-renewal-cookie auth-expired-cookie) (struct ip-network (address prefix) #:transparent) -(struct session (username [last-seen #:mutable]) #:transparent) +(struct session + (username [last-seen #:mutable] [last-cookie-renewal #:mutable]) + #:transparent) (struct failures ([attempts #:mutable] [started #:mutable]) #:transparent) (struct auth-manager - (users local-networks trusted-proxies session-seconds sessions failed lock) + (users trusted-proxies session-seconds sessions failed lock) #:transparent) (define password-kdf @@ -136,11 +138,6 @@ (define (auth-enabled? manager) (positive? (hash-count (auth-manager-users manager)))) -(define (auth-request-local? manager request) - (ormap (lambda (network) - (network-contains? network (request-address manager request))) - (auth-manager-local-networks manager))) - (define (request-session-token request) (for/or ((cookie (in-list (request-cookies request)))) (and (string=? (client-cookie-name cookie) session-cookie-name) @@ -156,7 +153,6 @@ (define (auth-request-user manager request) (cond ((not (auth-enabled? manager)) "anonymous") - ((auth-request-local? manager request) "local") (else (let ((token (request-session-token request)) (now (current-seconds))) @@ -215,7 +211,7 @@ (prune-sessions! manager now) (hash-set! (auth-manager-sessions manager) token - (session normalized now)) + (session normalized now now)) token) (begin (record-failure! manager address now) @@ -237,6 +233,30 @@ token (auth-manager-session-seconds manager)))) +;; Return a refreshed cookie at most once per half session lifetime. The +;; server-side inactivity timer is updated on every authenticated request, but +;; limiting Set-Cookie avoids rewriting it for every one-second player poll. +(define (auth-renewal-cookie manager request) + (and (auth-enabled? manager) + (let ((token (request-session-token request)) + (now (current-seconds))) + (and token + (call-with-semaphore + (auth-manager-lock manager) + (lambda () + (prune-sessions! manager now) + (define value + (hash-ref (auth-manager-sessions manager) token #f)) + (and value + (>= (- now (session-last-cookie-renewal value)) + (max 1 + (quotient + (auth-manager-session-seconds manager) + 2))) + (begin + (set-session-last-cookie-renewal! value now) + (auth-session-cookie manager token))))))))) + (define (auth-expired-cookie) (string->bytes/utf-8 (format @@ -244,14 +264,9 @@ session-cookie-name))) (define (make-auth-manager user-pairs - #:local-networks - [local-network-values - '("127.0.0.0/8" "::1/128" - "10.0.0.0/8" "172.16.0.0/12" - "192.168.0.0/16")] #:trusted-proxies [trusted-proxy-values '("127.0.0.0/8" "::1/128")] - #:session-seconds [session-seconds 43200]) + #:session-seconds [session-seconds 604800]) (unless (exact-positive-integer? session-seconds) (raise-argument-error 'make-auth-manager "exact-positive-integer?" session-seconds)) @@ -277,7 +292,6 @@ (hash-set! users (string-downcase (string-trim (car entry))) (cdr entry))) (auth-manager users - (map parse-network local-network-values) (map parse-network trusted-proxy-values) session-seconds (make-hash) @@ -297,30 +311,35 @@ (define manager (make-auth-manager (list (cons "Hans" test-hash)) - #:local-networks '("192.168.1.0/24") #:trusted-proxies '("127.0.0.1/32"))) (define (test-request peer [headers '()]) (request #"GET" (string->url "http://example.test/api/state") headers (delay '()) #f "127.0.0.1" 80 peer)) - (check-true (auth-request-local? manager (test-request "192.168.1.42"))) - (check-false (auth-request-local? manager (test-request "192.168.2.42"))) - (check-true - (auth-request-local? - manager - (test-request "127.0.0.1" - (list (header #"X-Forwarded-For" #"198.51.100.2, 192.168.1.8"))))) - (check-false - (auth-request-local? - manager - (test-request "198.51.100.2" - (list (header #"X-Forwarded-For" #"192.168.1.8"))))) - (define remote (test-request "198.51.100.2")) + ;; Local and remote browser requests follow the same login path. + (check-false (auth-request-user manager (test-request "127.0.0.1"))) + (check-equal? + (request-address + manager + (test-request + "127.0.0.1" + (list (header #"X-Forwarded-For" + #"198.51.100.8, 203.0.113.9")))) + "203.0.113.9") + (check-equal? + (request-address + manager + (test-request + "198.51.100.2" + (list (header #"X-Forwarded-For" #"203.0.113.9")))) + "198.51.100.2") (define token (auth-login! manager remote "hans" "correct horse battery staple")) (check-true (string? token)) + (check-true + (regexp-match? #rx#"Max-Age=604800" (auth-session-cookie manager token))) (define authenticated (test-request "198.51.100.2" @@ -329,6 +348,10 @@ (string->bytes/utf-8 (format "~a=~a" session-cookie-name token)))))) (check-equal? (auth-request-user manager authenticated) "hans") + (define stored-session (hash-ref (auth-manager-sessions manager) token)) + (set-session-last-cookie-renewal! stored-session 0) + (check-true (bytes? (auth-renewal-cookie manager authenticated))) + (check-false (auth-renewal-cookie manager authenticated)) (auth-logout! manager authenticated) (check-false (auth-request-user manager authenticated)) (check-false (auth-login! manager remote "hans" "wrong password"))) diff --git a/public/app.js b/public/app.js index 7e869c5..8fbc625 100644 --- a/public/app.js +++ b/public/app.js @@ -101,8 +101,8 @@ function hideLogin() { async function refreshAuth() { try { const auth = await api("/api/auth/status"); - elements.logout.hidden = !auth.enabled || auth.local || !auth.authenticated; - if (auth.enabled && !auth.local && !auth.authenticated) showLogin(); + elements.logout.hidden = !auth.enabled || !auth.authenticated; + if (auth.enabled && !auth.authenticated) showLogin(); } catch (error) { setStatus(`Authenticatiestatus onbekend: ${error.message}`); } diff --git a/rkt-web-player.ini.example b/rkt-web-player.ini.example index 24e78ec..c2cc00f 100644 --- a/rkt-web-player.ini.example +++ b/rkt-web-player.ini.example @@ -14,11 +14,10 @@ dlna-port=8734 ; 7b4776ef27104e8eb9f7ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true [authentication] -; Local clients do not need to log in. Separate networks with semicolons. -local-networks=127.0.0.0/8;::1/128;10.0.0.0/8;172.16.0.0/12;192.168.0.0/16 ; Only these direct peers may supply X-Forwarded-For. trusted-proxies=127.0.0.0/8;::1/128 -session-seconds=43200 +; Seven-day sliding idle timeout. Active cookies are renewed halfway through. +session-seconds=604800 [users] ; Generate a hash with (make-password-hash "a long password") from diff --git a/scribblings/rkt-web-player.scrbl b/scribblings/rkt-web-player.scrbl index 13eeb61..47fae73 100644 --- a/scribblings/rkt-web-player.scrbl +++ b/scribblings/rkt-web-player.scrbl @@ -21,9 +21,8 @@ browser by Racket's web server. (list/c string? path-string?)))] [#:allowed-agent-ids allowed-agent-ids (listof string?) null] [#:users users (listof (cons/c string? string?)) null] - [#:local-networks local-networks (listof string?)] [#:trusted-proxies trusted-proxies (listof string?)] - [#:session-seconds session-seconds exact-positive-integer? 43200] + [#:session-seconds session-seconds exact-positive-integer? 604800] [#:listen-ip listen-ip string? "127.0.0.1"] [#:port port exact-positive-integer? 8080] [#:dlna-port dlna-port exact-positive-integer? 8734] @@ -45,6 +44,10 @@ network renderer. Playlist tabs are atomically persisted in resources are closed when the web server exits. The default is @tt{data/playlists.keystore} below the installed rkt-web-player collection. Each username owns an ordered GUID index and separate playlist values. +When @racket[users] is non-empty, every browser client must authenticate. +Sessions have a sliding idle timeout; an active browser cookie is renewed once +half of @racket[session-seconds] has elapsed. Playback-agent endpoints continue +to use their separate application-ID allowlist. The default listen address only exposes the interface to the local computer. Use a LAN address deliberately if other devices should control the player.