diff --git a/.gitignore b/.gitignore index e4d56ce..bbb3b8e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ scrbl/*.html scrbl/*.js scrbl/*.css +rkt-web-player.ini + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 300394a..f733917 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -37,6 +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] Player[private/player.rkt
application state and commands] Library[private/library.rkt
filesystem and metadata] UI[public/index.html + styles.css + app.js
browser UI] @@ -49,6 +50,7 @@ flowchart TB Main --> Player Main --> Library Server --> Player + Server --> Users Server --> UI Player --> Library 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 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.1 Browse and play a directory @@ -287,6 +303,7 @@ file: - named library root paths under `[libraries]` (the legacy semicolon-separated setting remains supported); - 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 sources are combined and de-duplicated. @@ -297,12 +314,13 @@ state, and all other mutable state. ## 7. Security and operational boundaries -The browser API has no user authentication, TLS termination, CSRF protection, -or per-user state. Anyone who can reach the HTTP port can inspect the exposed -library names and control the shared player. The default localhost binding is -therefore an important security boundary. Binding to a LAN address should be an -explicit deployment decision and should use an external trusted network -boundary or authenticated reverse proxy when untrusted clients are possible. +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. 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 286fa0e..509bfd2 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,14 @@ podcasts=D:\Podcasts [playback-agents] 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 @@ -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 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 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. De server luistert standaard alleen op localhost. Geef alleen bewust een -LAN-adres aan `--listen-ip`; de webinterface bevat nog geen -gebruikersauthenticatie. +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. ## Windows playback agent @@ -132,6 +166,6 @@ geen TLS heeft. ## Controleren ```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 ``` diff --git a/info.rkt b/info.rkt index fbaceee..dd78c4b 100644 --- a/info.rkt +++ b/info.rkt @@ -8,6 +8,7 @@ (define deps '("base" + "crypto-lib" "gui-lib" "net-lib" "web-server-lib" diff --git a/main.rkt b/main.rkt index 4eabf5c..a3bb816 100644 --- a/main.rkt +++ b/main.rkt @@ -9,7 +9,8 @@ simple-log "private/library.rkt" "private/player.rkt" - "private/server.rkt") + "private/server.rkt" + "private/users.rkt") (provide run-web-player) @@ -29,6 +30,14 @@ (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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -41,12 +50,25 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (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] #:listen-ip [listen-ip "127.0.0.1"] #:port [port 8080] #:dlna-port [dlna-port 8734] #:launch-browser? [launch-browser? #t]) (->* ((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? #:port exact-positive-integer? #:dlna-port exact-positive-integer? @@ -55,7 +77,12 @@ (let* ((libraries (make-music-libraries music-paths)) (player (make-player libraries #: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 "Starting with ~a music library/libraries on http://~a:~a/" (length libraries) @@ -65,6 +92,7 @@ void (λ () (serve-player player + #:auth-manager auth-manager #:listen-ip listen-ip #:port port #:launch-browser? launch-browser?)) @@ -134,6 +162,25 @@ #:when (not (eq? (cdr entry) #f))) (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 (append configured-libraries music-paths @@ -143,6 +190,10 @@ (run-web-player 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 (ini-get config 'server 'listen-ip "127.0.0.1")) #:port (or port diff --git a/private/server.rkt b/private/server.rkt index e5bd73d..9636fd4 100644 --- a/private/server.rkt +++ b/private/server.rkt @@ -6,12 +6,15 @@ racket/port racket/runtime-path racket-mimetypes + racket/string + net/url web-server/dispatch web-server/http web-server/http/json web-server/servlet-env "library.rkt" - "player.rkt") + "player.rkt" + "users.rkt") (provide serve-player) @@ -22,12 +25,13 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (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 value #:code code - #:headers (list (header #"Cache-Control" #"no-store")))) + #:headers (cons (header #"Cache-Control" #"no-store") headers))) (define (error-response exception) (json-response @@ -46,6 +50,50 @@ (bytes->jsexpr body) (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) (json-response (player-state->jsexpr current-player))) @@ -123,8 +171,11 @@ (hasheq 'error "track artwork is unavailable") #:code 404)))) -(define-values (dispatch _url) +(define-values (api-dispatch _url) (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" "discover") #:method "post" discover-handler] [("api" "agent" "register") #:method "post" agent-register-handler] @@ -137,6 +188,37 @@ #:method "post" 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 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -148,15 +230,19 @@ ; result : The result returned by serve/servlet. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define/contract (serve-player value + #:auth-manager + [auth-manager (make-auth-manager '())] #:listen-ip [listen-ip "127.0.0.1"] #:port [port 8080] #:launch-browser? [launch-browser? #t]) (->* (any/c) - (#:listen-ip string? + (#:auth-manager auth-manager? + #:listen-ip string? #:port exact-positive-integer? #:launch-browser? boolean?) any) (set! current-player value) + (set! current-auth auth-manager) (serve/servlet dispatch #:listen-ip listen-ip diff --git a/private/users.rkt b/private/users.rkt new file mode 100644 index 0000000..df2b23d --- /dev/null +++ b/private/users.rkt @@ -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"))) diff --git a/public/app.js b/public/app.js index 20748d9..4cb5897 100644 --- a/public/app.js +++ b/public/app.js @@ -32,6 +32,13 @@ const elements = { format: document.querySelector("#audio-format"), source: document.querySelector("#audio-source"), 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; @@ -39,6 +46,14 @@ let seekBusy = false; let draggedTrack = null; let commandBusy = false; +class ApiError extends Error { + constructor(message, status, code) { + super(message); + this.status = status; + this.code = code; + } +} + function formatTime(value) { if (!Number.isFinite(value) || value < 0) return "00:00:00"; const whole = Math.floor(value); @@ -62,10 +77,34 @@ async function api(path, body) { }; const response = await fetch(path, options); 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; } +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 = "") { if (pendingMessage) setStatus(pendingMessage); commandBusy = true; @@ -441,10 +480,45 @@ async function refresh() { if (commandBusy) return; try { render(await api("/api/state")); + hideLogin(); } catch (error) { - setStatus(`Geen verbinding: ${error.message}`); + if (error.code === "authentication-required") { + showLogin(); + } else { + 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(); setInterval(refresh, 1000); diff --git a/public/index.html b/public/index.html index b74471a..ca01a51 100644 --- a/public/index.html +++ b/public/index.html @@ -19,6 +19,7 @@ + @@ -129,6 +130,20 @@ + + diff --git a/public/styles.css b/public/styles.css index 1f3440e..d800d40 100644 --- a/public/styles.css +++ b/public/styles.css @@ -198,6 +198,55 @@ input[type="range"] { 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 { display: flex; gap: 5px; diff --git a/rkt-web-player.ini b/rkt-web-player.ini index b458f76..b908326 100644 --- a/rkt-web-player.ini +++ b/rkt-web-player.ini @@ -1,5 +1,6 @@ + [server] -listen-ip=10.7.3.252 +listen-ip=0.0.0.0 port=8764 [player] @@ -7,9 +8,23 @@ dlna-port=8734 [libraries] muziek=/mnt/music -; muziek=D:\Muziek -; nas=\\server\muziek +; muziek=D:\Muziek +; nas=\\server\muziek [playback-agents] -; Copy the 64-character ID shown by the GUI agent here before it may connect. -; 7b4776ef27104e8eb9f7ea2c622ce76ca23de4260b0f94e6880d321017b32a0e4=true +; Copy the 64-character ID shown by the GUI agent here before it may connect. +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 diff --git a/rkt-web-player.ini.example b/rkt-web-player.ini.example index b438f58..24e78ec 100644 --- a/rkt-web-player.ini.example +++ b/rkt-web-player.ini.example @@ -12,3 +12,15 @@ dlna-port=8734 [playback-agents] ; Copy the 64-character ID shown by the GUI agent here before it may connect. ; 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$... diff --git a/scribblings/rkt-web-player.scrbl b/scribblings/rkt-web-player.scrbl index 26d3abc..1806e42 100644 --- a/scribblings/rkt-web-player.scrbl +++ b/scribblings/rkt-web-player.scrbl @@ -3,7 +3,8 @@ @(require (for-label racket/base racket/contract rkt-web-player - rkt-web-player/player-agent)) + rkt-web-player/player-agent + rkt-web-player/users)) @title{RKT Web Player} @author{Hans van Dijkema} @@ -19,6 +20,10 @@ browser by Racket's web server. [music-paths (listof (or/c path-string? (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] [#:listen-ip listen-ip string? "127.0.0.1"] [#:port port exact-positive-integer? 8080] [#: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. } +@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] @defproc[(run-player-agent) any/c] { diff --git a/set-user.rkt b/set-user.rkt new file mode 100644 index 0000000..cefb152 --- /dev/null +++ b/set-user.rkt @@ -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) + ) + ) + + + + diff --git a/users.rkt b/users.rkt new file mode 100644 index 0000000..ef5961e --- /dev/null +++ b/users.rkt @@ -0,0 +1,7 @@ +#lang racket/base + +(require "private/users.rkt") + +(provide make-password-hash + password-hash-valid?) +