users need always login

This commit is contained in:
2026-08-28 13:19:30 +02:00
parent cd16e3ae42
commit 1316e49456
9 changed files with 129 additions and 96 deletions
+18 -15
View File
@@ -37,7 +37,7 @@ there is no browser-side persistence or independent playback state.
flowchart TB
Main[main.rkt<br/>configuration and lifecycle]
Server[private/server.rkt<br/>HTTP adapter]
Users[private/users.rkt<br/>users, networks and sessions]
Users[private/users.rkt<br/>users, trusted proxies and sessions]
Player[private/player.rkt<br/>application state and commands]
Playlists[private/playlists.rkt<br/>durable playlist tabs]
DLNAAdapter[private/dlna-playback.rkt<br/>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
+17 -15
View File
@@ -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
+2 -16
View File
@@ -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
+10 -10
View File
@@ -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
+19 -2
View File
@@ -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"
+54 -31
View File
@@ -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")))
+2 -2
View File
@@ -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}`);
}
+2 -3
View File
@@ -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
+5 -2
View File
@@ -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.