user admin

This commit is contained in:
2026-08-27 15:57:48 +02:00
parent 84891adb89
commit ffd077bb4d
15 changed files with 765 additions and 24 deletions
+2
View File
@@ -21,3 +21,5 @@ scrbl/*.html
scrbl/*.js scrbl/*.js
scrbl/*.css scrbl/*.css
rkt-web-player.ini
+24 -6
View File
@@ -37,6 +37,7 @@ there is no browser-side persistence or independent playback state.
flowchart TB flowchart TB
Main[main.rkt<br/>configuration and lifecycle] Main[main.rkt<br/>configuration and lifecycle]
Server[private/server.rkt<br/>HTTP adapter] Server[private/server.rkt<br/>HTTP adapter]
Users[private/users.rkt<br/>users, networks and sessions]
Player[private/player.rkt<br/>application state and commands] Player[private/player.rkt<br/>application state and commands]
Library[private/library.rkt<br/>filesystem and metadata] Library[private/library.rkt<br/>filesystem and metadata]
UI[public/index.html + styles.css + app.js<br/>browser UI] UI[public/index.html + styles.css + app.js<br/>browser UI]
@@ -49,6 +50,7 @@ flowchart TB
Main --> Player Main --> Player
Main --> Library Main --> Library
Server --> Player Server --> Player
Server --> Users
Server --> UI Server --> UI
Player --> Library Player --> Library
Player --> Audio Player --> Audio
@@ -214,6 +216,20 @@ DOM signatures prevent rebuilding unchanged library, tab, and playlist
collections on every poll. Playback status and other small values are updated collections on every poll. Playback status and other small values are updated
on every render. 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.
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.
## 4. Key runtime flows ## 4. Key runtime flows
### 4.1 Browse and play a directory ### 4.1 Browse and play a directory
@@ -287,6 +303,7 @@ file:
- named library root paths under `[libraries]` (the legacy semicolon-separated - named library root paths under `[libraries]` (the legacy semicolon-separated
setting remains supported); setting remains supported);
- allowed 256-bit playback-agent IDs under `[playback-agents]`. - allowed 256-bit playback-agent IDs under `[playback-agents]`.
- Argon2id user hashes, local networks, trusted proxies, and session timeout.
Command-line network settings override INI values. Library paths from both Command-line network settings override INI values. Library paths from both
sources are combined and de-duplicated. sources are combined and de-duplicated.
@@ -297,12 +314,13 @@ state, and all other mutable state.
## 7. Security and operational boundaries ## 7. Security and operational boundaries
The browser API has no user authentication, TLS termination, CSRF protection, The browser API has optional user authentication but no TLS termination or
or per-user state. Anyone who can reach the HTTP port can inspect the exposed per-user player state. Authentication must be enabled before exposing it to an
library names and control the shared player. The default localhost binding is untrusted network, and a reverse proxy must provide HTTPS because session
therefore an important security boundary. Binding to a LAN address should be an cookies are always marked `Secure`. Local-network bypass and forwarded client
explicit deployment decision and should use an external trusted network addresses are security-sensitive configuration: only known reverse-proxy peers
boundary or authenticated reverse proxy when untrusted clients are possible. may be trusted, and the application port should remain firewalled from the
internet.
Playback-agent registration and polling are authorized against a default-deny Playback-agent registration and polling are authorized against a default-deny
INI allowlist. Media URLs additionally contain an opaque per-track token. The INI allowlist. Media URLs additionally contain an opaque per-track token. The
+37 -3
View File
@@ -39,6 +39,14 @@ podcasts=D:\Podcasts
[playback-agents] [playback-agents]
7b4776ef27104e8eb9f7ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true 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
[users]
hans=$argon2id$v=19$m=19456,t=2,p=1$...
``` ```
Start dat bestand met `racket main.rkt --config rkt-web-player.ini`. Iedere key Start dat bestand met `racket main.rkt --config rkt-web-player.ini`. Iedere key
@@ -46,6 +54,24 @@ onder `[libraries]` is de zichtbare bibliotheeknaam; de waarde is de lokale of
UNC-rootmap. Het oudere `[library] paths=D:\Muziek;D:\Podcasts` blijft eveneens UNC-rootmap. Het oudere `[library] paths=D:\Muziek;D:\Podcasts` blijft eveneens
ondersteund. ondersteund.
Zodra `[users]` minstens één gebruiker bevat, toont de webinterface voor
niet-lokale clients een eigen loginvenster. Wachtwoorden staan uitsluitend als
Argon2id-hash in de INI. Maak zo'n hash vanuit Racket:
```racket
#lang racket/base
(require rkt-web-player/users)
(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
gebruikers is authenticatie uitgeschakeld en blijft het oude gedrag behouden.
## Werking ## Werking
De interface volgt de informatiearchitectuur van `rktplayer`, maar is De interface volgt de informatiearchitectuur van `rktplayer`, maar is
@@ -68,8 +94,16 @@ drag-and-drop verplaatst. De tabs zijn in deze versie alleen in het geheugen
aanwezig en worden niet na een herstart hersteld. aanwezig en worden niet na een herstart hersteld.
De server luistert standaard alleen op localhost. Geef alleen bewust een De server luistert standaard alleen op localhost. Geef alleen bewust een
LAN-adres aan `--listen-ip`; de webinterface bevat nog geen LAN-adres aan `--listen-ip`. Configureer gebruikersauthenticatie voordat de
gebruikersauthenticatie. 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.
## Windows playback agent ## Windows playback agent
@@ -132,6 +166,6 @@ geen TLS heeft.
## Controleren ## Controleren
```console ```console
raco test private/library.rkt private/player.rkt raco test private/users.rkt private/library.rkt private/player.rkt
raco setup --check-pkg-deps rkt-web-player raco setup --check-pkg-deps rkt-web-player
``` ```
+1
View File
@@ -8,6 +8,7 @@
(define deps (define deps
'("base" '("base"
"crypto-lib"
"gui-lib" "gui-lib"
"net-lib" "net-lib"
"web-server-lib" "web-server-lib"
+53 -2
View File
@@ -9,7 +9,8 @@
simple-log simple-log
"private/library.rkt" "private/library.rkt"
"private/player.rkt" "private/player.rkt"
"private/server.rkt") "private/server.rkt"
"private/users.rkt")
(provide run-web-player) (provide run-web-player)
@@ -29,6 +30,14 @@
(caddr line))) (caddr line)))
'()))) '())))
(define (configuration-list value defaults)
(cond
((list? value) (map (lambda (item) (format "~a" item)) value))
((and (string? value)
(not (string=? (string-trim value) "")))
(map string-trim (string-split value ";")))
(else defaults)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Provided functions ;; Provided functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -41,12 +50,25 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define/contract (run-web-player music-paths (define/contract (run-web-player music-paths
#:allowed-agent-ids [allowed-agent-ids '()] #: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]
#:listen-ip [listen-ip "127.0.0.1"] #:listen-ip [listen-ip "127.0.0.1"]
#:port [port 8080] #:port [port 8080]
#:dlna-port [dlna-port 8734] #:dlna-port [dlna-port 8734]
#:launch-browser? [launch-browser? #t]) #:launch-browser? [launch-browser? #t])
(->* ((listof library-spec/c)) (->* ((listof library-spec/c))
(#:allowed-agent-ids (listof string?) (#: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? #:listen-ip string?
#:port exact-positive-integer? #:port exact-positive-integer?
#:dlna-port exact-positive-integer? #:dlna-port exact-positive-integer?
@@ -55,7 +77,12 @@
(let* ((libraries (make-music-libraries music-paths)) (let* ((libraries (make-music-libraries music-paths))
(player (make-player libraries (player (make-player libraries
#:allowed-agent-ids allowed-agent-ids #:allowed-agent-ids allowed-agent-ids
#:dlna-port dlna-port))) #: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 (info-rkt-web-player
"Starting with ~a music library/libraries on http://~a:~a/" "Starting with ~a music library/libraries on http://~a:~a/"
(length libraries) (length libraries)
@@ -65,6 +92,7 @@
void void
(λ () (λ ()
(serve-player player (serve-player player
#:auth-manager auth-manager
#:listen-ip listen-ip #:listen-ip listen-ip
#:port port #:port port
#:launch-browser? launch-browser?)) #:launch-browser? launch-browser?))
@@ -134,6 +162,25 @@
#:when (not (eq? (cdr entry) #f))) #:when (not (eq? (cdr entry) #f)))
(car entry))) (car entry)))
(define configured-users
(for/list ((entry (in-list (ini-section-key-values config 'users)))
#: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))
(define all-libraries (define all-libraries
(append configured-libraries (append configured-libraries
music-paths music-paths
@@ -143,6 +190,10 @@
(run-web-player (run-web-player
all-libraries all-libraries
#:allowed-agent-ids allowed-agent-ids #: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 #:listen-ip (or listen-ip
(ini-get config 'server 'listen-ip "127.0.0.1")) (ini-get config 'server 'listen-ip "127.0.0.1"))
#:port (or port #:port (or port
+91 -5
View File
@@ -6,12 +6,15 @@
racket/port racket/port
racket/runtime-path racket/runtime-path
racket-mimetypes racket-mimetypes
racket/string
net/url
web-server/dispatch web-server/dispatch
web-server/http web-server/http
web-server/http/json web-server/http/json
web-server/servlet-env web-server/servlet-env
"library.rkt" "library.rkt"
"player.rkt") "player.rkt"
"users.rkt")
(provide serve-player) (provide serve-player)
@@ -22,12 +25,13 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define current-player #f) (define current-player #f)
(define current-auth #f)
(define (json-response value #:code [code 200]) (define (json-response value #:code [code 200] #:headers [headers '()])
(response/jsexpr (response/jsexpr
value value
#:code code #:code code
#:headers (list (header #"Cache-Control" #"no-store")))) #:headers (cons (header #"Cache-Control" #"no-store") headers)))
(define (error-response exception) (define (error-response exception)
(json-response (json-response
@@ -46,6 +50,50 @@
(bytes->jsexpr body) (bytes->jsexpr body)
(hasheq)))) (hasheq))))
(define (auth-status-handler request)
(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)))))
(define (auth-login-handler request)
(with-handlers ((exn:fail? error-response))
(let* ((data (request-jsexpr request))
(username (hash-ref data 'username #f))
(password (hash-ref data 'password #f)))
(unless (and (string? username) (string? password))
(raise-arguments-error
'login
"username and password must be strings"))
(let ((result (auth-login! current-auth request username password)))
(cond
((eq? result 'rate-limited)
(json-response
(hasheq 'error "Te veel mislukte aanmeldpogingen; probeer het over enkele minuten opnieuw"
'code "login-rate-limited")
#:code 429))
((not result)
(json-response
(hasheq 'error "Ongeldige gebruikersnaam of wachtwoord"
'code "invalid-credentials")
#:code 401))
(else
(json-response
(hasheq 'authenticated #t
'username (string-downcase (string-trim username)))
#:headers
(list (header #"Set-Cookie"
(auth-session-cookie current-auth result))))))))))
(define (auth-logout-handler request)
(auth-logout! current-auth request)
(json-response
(hasheq 'authenticated #f)
#:headers
(list (header #"Set-Cookie" (auth-expired-cookie)))))
(define (state-handler _request) (define (state-handler _request)
(json-response (player-state->jsexpr current-player))) (json-response (player-state->jsexpr current-player)))
@@ -123,8 +171,11 @@
(hasheq 'error "track artwork is unavailable") (hasheq 'error "track artwork is unavailable")
#:code 404)))) #:code 404))))
(define-values (dispatch _url) (define-values (api-dispatch _url)
(dispatch-rules (dispatch-rules
[("api" "auth" "status") #:method "get" auth-status-handler]
[("api" "auth" "login") #:method "post" auth-login-handler]
[("api" "auth" "logout") #:method "post" auth-logout-handler]
[("api" "state") #:method "get" state-handler] [("api" "state") #:method "get" state-handler]
[("api" "discover") #:method "post" discover-handler] [("api" "discover") #:method "post" discover-handler]
[("api" "agent" "register") #:method "post" agent-register-handler] [("api" "agent" "register") #:method "post" agent-register-handler]
@@ -137,6 +188,37 @@
#:method "post" #:method "post"
command-handler])) command-handler]))
(define (request-path request)
(url->string (request-uri request)))
(define (json-request? request)
(let ((content-type
(headers-assq* #"Content-Type" (request-headers/raw request))))
(and content-type
(regexp-match? #px#"(?i:^application/json(?:;|$))"
(header-value content-type)))))
(define (public-api-request? request)
(regexp-match? #px"^/api/(?:auth|agent)(?:/|$)"
(request-path request)))
(define (dispatch request)
(cond
((and (bytes=? (request-method request) #"POST")
(not (json-request? request)))
(json-response
(hasheq 'error "Content-Type application/json is vereist"
'code "json-required")
#:code 415))
((or (public-api-request? request)
(auth-request-user current-auth request))
(api-dispatch request))
(else
(json-response
(hasheq 'error "Aanmelden is vereist"
'code "authentication-required")
#:code 401))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Provided functions ;; Provided functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -148,15 +230,19 @@
; result : The result returned by serve/servlet. ; result : The result returned by serve/servlet.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define/contract (serve-player value (define/contract (serve-player value
#:auth-manager
[auth-manager (make-auth-manager '())]
#:listen-ip [listen-ip "127.0.0.1"] #:listen-ip [listen-ip "127.0.0.1"]
#:port [port 8080] #:port [port 8080]
#:launch-browser? [launch-browser? #t]) #:launch-browser? [launch-browser? #t])
(->* (any/c) (->* (any/c)
(#:listen-ip string? (#:auth-manager auth-manager?
#:listen-ip string?
#:port exact-positive-integer? #:port exact-positive-integer?
#:launch-browser? boolean?) #:launch-browser? boolean?)
any) any)
(set! current-player value) (set! current-player value)
(set! current-auth auth-manager)
(serve/servlet (serve/servlet
dispatch dispatch
#:listen-ip listen-ip #:listen-ip listen-ip
+334
View File
@@ -0,0 +1,334 @@
#lang racket/base
(require crypto
crypto/argon2
net/private/ip
racket/list
racket/random
racket/string
web-server/http
web-server/http/cookie-parse)
(provide make-password-hash
password-hash-valid?
make-auth-manager
auth-manager?
auth-enabled?
auth-request-local?
auth-request-user
auth-login!
auth-logout!
auth-session-cookie
auth-expired-cookie)
(struct ip-network (address prefix) #:transparent)
(struct session (username [last-seen #:mutable]) #:transparent)
(struct failures ([attempts #:mutable] [started #:mutable]) #:transparent)
(struct auth-manager
(users local-networks trusted-proxies session-seconds sessions failed lock)
#:transparent)
(define password-kdf
(or (get-kdf 'argon2id argon2-factory)
(error 'rkt-web-player/users "Argon2id is unavailable")))
(define password-parameters
'((m 19456) (t 2) (p 1)))
;; Used to make an unknown username take the same expensive verification path.
(define dummy-password-hash
"$argon2id$v=19$m=19456,t=2,p=1$WrJi0t7NsbD3adX8kxrT/g$yCIf2Ork8G8PRdIcA0bEIYIdMwzunrTou/BKcM4cO/0")
(define session-cookie-name "rkt-web-player-session")
(define failure-window-seconds 300)
(define maximum-failures 5)
(define (make-password-hash password)
(unless (and (string? password)
(>= (string-length password) 12))
(raise-argument-error
'make-password-hash
"string containing at least 12 characters"
password))
(pwhash password-kdf
(string->bytes/utf-8 password)
password-parameters))
(define (password-hash-valid? password encoded)
(and (string? password)
(string? encoded)
(with-handlers ((exn:fail? (lambda (_) #f)))
(pwhash-verify password-kdf
(string->bytes/utf-8 password)
encoded))))
(define (normal-ip-bytes value)
(define raw
(ip-address->bytes (make-ip-address value)))
;; Normalize IPv4-mapped IPv6 addresses to four bytes.
(if (and (= (bytes-length raw) 16)
(for/and ((index (in-range 10)))
(zero? (bytes-ref raw index)))
(= (bytes-ref raw 10) #xff)
(= (bytes-ref raw 11) #xff))
(subbytes raw 12)
raw))
(define (parse-network value)
(define parts (string-split (string-trim value) "/"))
(unless (member (length parts) '(1 2))
(raise-argument-error 'make-auth-manager "IP address or CIDR network" value))
(define address
(with-handlers ((exn:fail?
(lambda (_)
(raise-argument-error
'make-auth-manager
"IP address or CIDR network"
value))))
(normal-ip-bytes (car parts))))
(define maximum (* 8 (bytes-length address)))
(define prefix
(if (= (length parts) 2)
(string->number (cadr parts))
maximum))
(unless (and (exact-nonnegative-integer? prefix)
(<= prefix maximum))
(raise-argument-error 'make-auth-manager "IP address or CIDR network" value))
(ip-network address prefix))
(define (network-contains? network address-string)
(with-handlers ((exn:fail? (lambda (_) #f)))
(define candidate (normal-ip-bytes address-string))
(define expected (ip-network-address network))
(and (= (bytes-length candidate) (bytes-length expected))
(let-values (((whole remainder)
(quotient/remainder (ip-network-prefix network) 8)))
(and (for/and ((index (in-range whole)))
(= (bytes-ref candidate index)
(bytes-ref expected index)))
(or (zero? remainder)
(let ((mask
(bitwise-and #xff
(arithmetic-shift #xff (- remainder 8)))))
(= (bitwise-and (bytes-ref candidate whole) mask)
(bitwise-and (bytes-ref expected whole) mask)))))))))
(define (header-string request name)
(let ((value (headers-assq* name (request-headers/raw request))))
(and value
(bytes->string/utf-8 (header-value value)))))
(define (trusted-proxy? manager address)
(ormap (lambda (network) (network-contains? network address))
(auth-manager-trusted-proxies manager)))
(define (request-address manager request)
(define peer (request-client-ip request))
(define forwarded
(and (trusted-proxy? manager peer)
(header-string request #"X-Forwarded-For")))
(if forwarded
;; A trusted reverse proxy appends the address it observed. Earlier
;; values can have been supplied by the untrusted client.
(string-trim (last (string-split forwarded ",")))
peer))
(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)
(client-cookie-value cookie))))
(define (prune-sessions! manager now)
(for ((token (in-list (hash-keys (auth-manager-sessions manager)))))
(let ((value (hash-ref (auth-manager-sessions manager) token)))
(when (> (- now (session-last-seen value))
(auth-manager-session-seconds manager))
(hash-remove! (auth-manager-sessions manager) token)))))
(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)))
(and token
(call-with-semaphore
(auth-manager-lock manager)
(lambda ()
(prune-sessions! manager now)
(let ((value (hash-ref (auth-manager-sessions manager)
token
#f)))
(and value
(begin
(set-session-last-seen! value now)
(session-username value)))))))))))
(define (failure-blocked? manager address now)
(define value (hash-ref (auth-manager-failed manager) address #f))
(and value
(if (> (- now (failures-started value)) failure-window-seconds)
(begin
(hash-remove! (auth-manager-failed manager) address)
#f)
(>= (failures-attempts value) maximum-failures))))
(define (record-failure! manager address now)
(define value (hash-ref (auth-manager-failed manager) address #f))
(if (and value
(<= (- now (failures-started value)) failure-window-seconds))
(set-failures-attempts! value (+ 1 (failures-attempts value)))
(hash-set! (auth-manager-failed manager)
address
(failures 1 now))))
;; Returns a new token, #f for invalid credentials, or 'rate-limited.
(define (auth-login! manager request username password)
(define address (request-address manager request))
(define now (current-seconds))
(define normalized (string-downcase (string-trim username)))
(call-with-semaphore
(auth-manager-lock manager)
(lambda ()
(if (failure-blocked? manager address now)
'rate-limited
(let* ((stored (hash-ref (auth-manager-users manager)
normalized
#f))
(valid?
(password-hash-valid?
password
(or stored dummy-password-hash))))
(if (and stored valid?)
(let ((token
(bytes->hex-string (crypto-random-bytes 32))))
(hash-remove! (auth-manager-failed manager) address)
(prune-sessions! manager now)
(hash-set! (auth-manager-sessions manager)
token
(session normalized now))
token)
(begin
(record-failure! manager address now)
#f)))))))
(define (auth-logout! manager request)
(let ((token (request-session-token request)))
(when token
(call-with-semaphore
(auth-manager-lock manager)
(lambda ()
(hash-remove! (auth-manager-sessions manager) token))))))
(define (auth-session-cookie manager token)
(string->bytes/utf-8
(format
"~a=~a; Path=/; Max-Age=~a; Secure; HttpOnly; SameSite=Strict"
session-cookie-name
token
(auth-manager-session-seconds manager))))
(define (auth-expired-cookie)
(string->bytes/utf-8
(format
"~a=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Strict"
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])
(unless (exact-positive-integer? session-seconds)
(raise-argument-error 'make-auth-manager "exact-positive-integer?"
session-seconds))
(define users (make-hash))
(for ((entry (in-list user-pairs)))
(unless (and (pair? entry)
(string? (car entry))
(string? (cdr entry)))
(raise-argument-error
'make-auth-manager
"(listof (cons/c string? string?))"
user-pairs))
(when (string=? (string-trim (car entry)) "")
(raise-arguments-error
'make-auth-manager
"username must not be empty"
"username" (car entry)))
(unless (regexp-match? #px"^[$]argon2id[$]" (cdr entry))
(raise-arguments-error
'make-auth-manager
"user password is not an Argon2id hash"
"username" (car entry)))
(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)
(make-hash)
(make-semaphore 1)))
(module+ test
(require net/url
rackunit
racket/promise
web-server/http/request-structs)
(define test-hash (make-password-hash "correct horse battery staple"))
(check-true (password-hash-valid? "correct horse battery staple" test-hash))
(check-false (password-hash-valid? "incorrect password" test-hash))
(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"))
(define token
(auth-login! manager remote "hans" "correct horse battery staple"))
(check-true (string? token))
(define authenticated
(test-request
"198.51.100.2"
(list
(header #"Cookie"
(string->bytes/utf-8
(format "~a=~a" session-cookie-name token))))))
(check-equal? (auth-request-user manager authenticated) "hans")
(auth-logout! manager authenticated)
(check-false (auth-request-user manager authenticated))
(check-false (auth-login! manager remote "hans" "wrong password")))
+75 -1
View File
@@ -32,6 +32,13 @@ const elements = {
format: document.querySelector("#audio-format"), format: document.querySelector("#audio-format"),
source: document.querySelector("#audio-source"), source: document.querySelector("#audio-source"),
status: document.querySelector("#status"), status: document.querySelector("#status"),
logout: document.querySelector("#logout"),
loginOverlay: document.querySelector("#login-overlay"),
loginForm: document.querySelector("#login-form"),
loginUsername: document.querySelector("#login-username"),
loginPassword: document.querySelector("#login-password"),
loginError: document.querySelector("#login-error"),
loginSubmit: document.querySelector("#login-submit"),
}; };
let state = null; let state = null;
@@ -39,6 +46,14 @@ let seekBusy = false;
let draggedTrack = null; let draggedTrack = null;
let commandBusy = false; let commandBusy = false;
class ApiError extends Error {
constructor(message, status, code) {
super(message);
this.status = status;
this.code = code;
}
}
function formatTime(value) { function formatTime(value) {
if (!Number.isFinite(value) || value < 0) return "00:00:00"; if (!Number.isFinite(value) || value < 0) return "00:00:00";
const whole = Math.floor(value); const whole = Math.floor(value);
@@ -62,10 +77,34 @@ async function api(path, body) {
}; };
const response = await fetch(path, options); const response = await fetch(path, options);
const data = await response.json(); const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); if (!response.ok) {
throw new ApiError(data.error || `HTTP ${response.status}`, response.status, data.code);
}
return data; return data;
} }
function showLogin(message = null) {
if (message !== null) elements.loginError.textContent = message;
elements.loginOverlay.hidden = false;
window.setTimeout(() => elements.loginUsername.focus(), 0);
}
function hideLogin() {
elements.loginOverlay.hidden = true;
elements.loginError.textContent = "";
elements.loginPassword.value = "";
}
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();
} catch (error) {
setStatus(`Authenticatiestatus onbekend: ${error.message}`);
}
}
async function command(name, data = {}, pendingMessage = "") { async function command(name, data = {}, pendingMessage = "") {
if (pendingMessage) setStatus(pendingMessage); if (pendingMessage) setStatus(pendingMessage);
commandBusy = true; commandBusy = true;
@@ -441,10 +480,45 @@ async function refresh() {
if (commandBusy) return; if (commandBusy) return;
try { try {
render(await api("/api/state")); render(await api("/api/state"));
hideLogin();
} catch (error) { } catch (error) {
if (error.code === "authentication-required") {
showLogin();
} else {
setStatus(`Geen verbinding: ${error.message}`); setStatus(`Geen verbinding: ${error.message}`);
} }
}
} }
elements.loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
elements.loginSubmit.disabled = true;
elements.loginError.textContent = "";
try {
await api("/api/auth/login", {
username: elements.loginUsername.value,
password: elements.loginPassword.value,
});
hideLogin();
await refreshAuth();
await refresh();
} catch (error) {
showLogin(error.message);
elements.loginPassword.select();
} finally {
elements.loginSubmit.disabled = false;
}
});
elements.logout.addEventListener("click", async () => {
try {
await api("/api/auth/logout", {});
} finally {
elements.logout.hidden = true;
showLogin("Je bent uitgelogd.");
}
});
refreshAuth();
refresh(); refresh();
setInterval(refresh, 1000); setInterval(refresh, 1000);
+15
View File
@@ -19,6 +19,7 @@
<label for="renderer">UITVOER</label> <label for="renderer">UITVOER</label>
<select id="renderer"></select> <select id="renderer"></select>
<button id="discover" class="square-button" type="button" title="Netwerkspelers zoeken" aria-label="Netwerkspelers zoeken"></button> <button id="discover" class="square-button" type="button" title="Netwerkspelers zoeken" aria-label="Netwerkspelers zoeken"></button>
<button id="logout" class="text-button" type="button" hidden>UITLOGGEN</button>
</div> </div>
</header> </header>
@@ -129,6 +130,20 @@
</footer> </footer>
</main> </main>
<section id="login-overlay" class="login-overlay" aria-labelledby="login-title" hidden>
<form id="login-form" class="login-panel">
<span class="brand-mark">RKT</span>
<p class="panel-kicker">WEB PLAYER</p>
<h1 id="login-title">Aanmelden</h1>
<label for="login-username">Gebruikersnaam</label>
<input id="login-username" name="username" autocomplete="username" required>
<label for="login-password">Wachtwoord</label>
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
<p id="login-error" class="login-error" role="alert"></p>
<button id="login-submit" class="text-button" type="submit">AANMELDEN</button>
</form>
</section>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>
</body> </body>
</html> </html>
+49
View File
@@ -198,6 +198,55 @@ input[type="range"] {
accent-color: var(--accent); accent-color: var(--accent);
} }
.login-overlay {
position: fixed;
z-index: 1000;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgb(10 10 10 / 82%);
backdrop-filter: blur(8px);
}
.login-overlay[hidden] {
display: none;
}
.login-panel {
display: grid;
width: min(390px, 100%);
gap: 9px;
padding: 28px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 24px 80px rgb(0 0 0 / 55%);
}
.login-panel h1 {
margin: 8px 0 14px;
}
.login-panel input {
min-width: 0;
margin-bottom: 8px;
padding: 10px 11px;
border: 1px solid var(--line);
color: var(--text);
background: #111;
}
.login-panel .text-button {
justify-self: start;
margin-top: 5px;
}
.login-error {
min-height: 1.3em;
margin: 0;
color: #ff796f;
}
.time-display { .time-display {
display: flex; display: flex;
gap: 5px; gap: 5px;
+17 -2
View File
@@ -1,5 +1,6 @@
[server] [server]
listen-ip=10.7.3.252 listen-ip=0.0.0.0
port=8764 port=8764
[player] [player]
@@ -12,4 +13,18 @@ muziek=/mnt/music
[playback-agents] [playback-agents]
; Copy the 64-character ID shown by the GUI agent here before it may connect. ; Copy the 64-character ID shown by the GUI agent here before it may connect.
; 7b4776ef27104e8eb9f7ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true 7b4776ef27104e8eb97ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true
[authentication]
; Local clients do not need to log in. Separate networks with semicolons.
local-networks=10.7.3.0/24;127.0.0.0/8;::1/128
; Only these direct peers may supply X-Forwarded-For.
; trusted-proxies=127.0.0.0/8;::1/128
trusted-proxies=10.7.3.252
session-seconds=43200
[users]
; Generate a hash with (make-password-hash "a long password") from
; rkt-web-player/users. Authentication is disabled while this section is empty.
; hans=$argon2id$v=19$m=19456,t=2,p=1$...
hans=$argon2id$v=19$m=19456,t=2,p=1$d9hXrfagyIpC7YD4QC3OTg$ES/UoXh0zfTBa5eLlLCBDQxOU/7zBZxOQFIVPfaHVK8
+12
View File
@@ -12,3 +12,15 @@ dlna-port=8734
[playback-agents] [playback-agents]
; Copy the 64-character ID shown by the GUI agent here before it may connect. ; Copy the 64-character ID shown by the GUI agent here before it may connect.
; 7b4776ef27104e8eb9f7ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true ; 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
[users]
; Generate a hash with (make-password-hash "a long password") from
; rkt-web-player/users. Authentication is disabled while this section is empty.
; hans=$argon2id$v=19$m=19456,t=2,p=1$...
+20 -1
View File
@@ -3,7 +3,8 @@
@(require (for-label racket/base @(require (for-label racket/base
racket/contract racket/contract
rkt-web-player rkt-web-player
rkt-web-player/player-agent)) rkt-web-player/player-agent
rkt-web-player/users))
@title{RKT Web Player} @title{RKT Web Player}
@author{Hans van Dijkema} @author{Hans van Dijkema}
@@ -19,6 +20,10 @@ browser by Racket's web server.
[music-paths (listof (or/c path-string? [music-paths (listof (or/c path-string?
(list/c string? path-string?)))] (list/c string? path-string?)))]
[#:allowed-agent-ids allowed-agent-ids (listof string?) null] [#: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]
[#:listen-ip listen-ip string? "127.0.0.1"] [#:listen-ip listen-ip string? "127.0.0.1"]
[#:port port exact-positive-integer? 8080] [#:port port exact-positive-integer? 8080]
[#:dlna-port dlna-port exact-positive-integer? 8734] [#:dlna-port dlna-port exact-positive-integer? 8734]
@@ -40,6 +45,20 @@ The default listen address only exposes the interface to the local computer.
Use a LAN address deliberately if other devices should control the player. Use a LAN address deliberately if other devices should control the player.
} }
@defmodule[rkt-web-player/users]
@defproc[(make-password-hash [password string?]) string?] {
Creates a salted Argon2id password hash suitable for a value in the INI
@tt{[users]} section. Passwords must contain at least twelve characters.
}
@defproc[(password-hash-valid? [password string?]
[encoded string?]) boolean?] {
Checks a password against an encoded Argon2id hash.
}
@defmodule[rkt-web-player/player-agent] @defmodule[rkt-web-player/player-agent]
@defproc[(run-player-agent) any/c] { @defproc[(run-player-agent) any/c] {
+24
View File
@@ -0,0 +1,24 @@
#lang racket/base
(require "users.rkt"
simple-ini/class)
(provide set-user)
(define (set-user)
(displayln "Using rkt-web-player.ini as configuration file")
(newline)
(display "Give username: >")
(define user (read-line))
(display "Give password: >")
(define pwd (read-line))
(let ((hash (make-password-hash pwd)))
(define ini (new ini% [file "rkt-web-player.ini"]))
(send ini set! 'users (string->symbol user) hash)
)
)
+7
View File
@@ -0,0 +1,7 @@
#lang racket/base
(require "private/users.rkt")
(provide make-password-hash
password-hash-valid?)