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.