ARCHITECTURE.md: - Node version: 18+ → 22 (Alpine) - Tech stack: add helmet, express-rate-limit, cookie-parser, testing tools - Directory structure: add server/app.js, verifyCsrf.js, tokenStore.js, sanitizeError.js, tests/, docs/, .gitea/workflows/, vitest.config.js - §4.1: document app.js factory (createApp) vs index.js entry point; CSP nonce, rate limiters, CSRF middleware, trust proxy - §4.2: add CSRF Required column; document verifyCsrf; fix auth note - §4.3: add tokenStore.js and sanitizeError.js descriptions - §6 Auth flow: add rememberMe, rate limiter, stable DeviceId, server-side token store, CSRF token issuance, correct cookie TTL (session/30d not 24h) - §9 API: add csrfToken to login response, rememberMe field, 400/429 codes; add GET /api/auth/csrf endpoint; fix /me response; fix /logout CSRF note - §11 Config: add DATA_DIR, COOKIE_SECRET, TRUST_PROXY, NODE_ENV; split into Core / Emby / Service Instances / Tuning sections - §12 Deployment: update Dockerfile description to multi-stage node:22-alpine; add COOKIE_SECRET, TRUST_PROXY, named volume to compose example; add security hardening checklist; add CI/CD table diagrams/seq-auth.puml: - Add TokenStore participant - Add rememberMe, CSRF token issuance, stable DeviceId note - Add login rate limiter note - Add GET /csrf refresh flow - Add server-side token revocation on logout diagrams/class-server.puml: - Add app.js createApp() factory class - Add verifyCsrf middleware class - Add TokenStore and SanitizeError utility classes - Update auth.js routes (add GET /csrf) - Fix relationships: entry → appfn → routes diagrams/component.puml: - Add app.js factory component - Add helmet, express-rate-limit components - Add verifyCsrf middleware component - Add tokenStore.js and sanitizeError.js utility components - Fix wiring: entry → createApp() → mounts routes Dockerfile: - Fix stale comments referencing better-sqlite3 and SQLite server/routes/auth.js: - Fix stale comment: SQLite-backed → JSON file-backed
105 lines
3.7 KiB
Plaintext
105 lines
3.7 KiB
Plaintext
@startuml seq-auth
|
|
!theme plain
|
|
title sofarr — Authentication Sequence
|
|
|
|
actor User as user
|
|
participant "Browser\n(app.js)" as browser
|
|
participant "Express\n/api/auth" as auth
|
|
participant "TokenStore\n(tokens.json)" as tokens
|
|
participant "Emby\nServer" as emby
|
|
|
|
== Page Load ==
|
|
user -> browser : Navigate to sofarr
|
|
activate browser
|
|
browser -> auth : GET /api/auth/me
|
|
activate auth
|
|
auth -> auth : Read emby_user cookie\n(signed if COOKIE_SECRET set)
|
|
alt Cookie exists and valid
|
|
auth --> browser : { authenticated: true, user: { name, isAdmin } }
|
|
browser -> auth : GET /api/auth/csrf
|
|
activate auth
|
|
auth -> auth : Generate 32-byte hex csrfToken
|
|
auth --> browser : { csrfToken } + Set csrf_token cookie
|
|
deactivate auth
|
|
browser -> browser : store csrfToken in memory
|
|
browser -> browser : showDashboard()
|
|
browser -> browser : startAutoRefresh()
|
|
browser -> browser : dismissSplash()
|
|
else No cookie / tampered
|
|
auth --> browser : { authenticated: false }
|
|
browser -> browser : dismissSplash()
|
|
browser -> browser : showLogin()
|
|
end
|
|
deactivate auth
|
|
|
|
== Login ==
|
|
user -> browser : Enter username + password\n(+ optional rememberMe checkbox)
|
|
browser -> auth : POST /api/auth/login\n{ username, password, rememberMe }
|
|
activate auth
|
|
note right of auth
|
|
Rate limiter: max 10 failed
|
|
attempts per IP / 15 min
|
|
(successful requests excluded)
|
|
end note
|
|
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }\nX-Emby-Authorization: MediaBrowser\nDeviceId = sha256(username)[0:16]
|
|
activate emby
|
|
alt Valid credentials
|
|
emby --> auth : { User: { Id }, AccessToken }
|
|
auth -> emby : GET /Users/{userId}\nX-MediaBrowser-Token: AccessToken
|
|
emby --> auth : { Name, Policy: { IsAdministrator } }
|
|
deactivate emby
|
|
auth -> tokens : storeToken(userId, AccessToken)
|
|
note right of tokens
|
|
Stored server-side only.
|
|
Never sent to the client.
|
|
31-day TTL, atomic JSON write.
|
|
end note
|
|
auth -> auth : Set emby_user cookie\n{ id, name, isAdmin }\nhttpOnly, sameSite=strict\nsecure (prod), signed (COOKIE_SECRET)\nrememberMe=true → Max-Age 30d\nrememberMe=false → session cookie
|
|
auth -> auth : Generate csrfToken\n(32-byte random hex)
|
|
auth -> auth : Set csrf_token cookie\nhttpOnly=false (JS-readable)\nsameSite=strict, secure (prod)
|
|
auth --> browser : { success: true, user, csrfToken }
|
|
browser -> browser : store csrfToken in memory
|
|
browser -> browser : fadeOutLogin()
|
|
browser -> browser : showDashboard()
|
|
browser -> browser : startAutoRefresh()
|
|
browser -> browser : dismissSplash()
|
|
else Invalid credentials
|
|
emby --> auth : 401 Error
|
|
deactivate emby
|
|
auth --> browser : { success: false, error: "Invalid username or password" }
|
|
browser -> browser : showLoginError()
|
|
end
|
|
deactivate auth
|
|
|
|
== CSRF Token Refresh (after page reload) ==
|
|
note over browser : csrfToken lost from memory\non hard page reload
|
|
browser -> auth : GET /api/auth/csrf
|
|
activate auth
|
|
auth -> auth : Generate new csrfToken
|
|
auth --> browser : { csrfToken } + new csrf_token cookie
|
|
deactivate auth
|
|
browser -> browser : store new csrfToken in memory
|
|
|
|
== Logout ==
|
|
user -> browser : Click Logout
|
|
browser -> browser : stopAutoRefresh()
|
|
browser -> auth : POST /api/auth/logout\n(no CSRF required — auth routes\nexempt; sameSite:strict protects)
|
|
activate auth
|
|
auth -> auth : Parse emby_user cookie → user
|
|
auth -> tokens : getToken(user.id)
|
|
activate tokens
|
|
tokens --> auth : { accessToken }
|
|
deactivate tokens
|
|
auth -> emby : POST /Sessions/Logout\nX-MediaBrowser-Token: accessToken
|
|
activate emby
|
|
emby --> auth : 204 / error (ignored)
|
|
deactivate emby
|
|
auth -> tokens : clearToken(user.id)
|
|
auth -> auth : clearCookie(emby_user)\nclearCookie(csrf_token)
|
|
auth --> browser : { success: true }
|
|
deactivate auth
|
|
browser -> browser : showLogin()
|
|
|
|
deactivate browser
|
|
@enduml
|