From 9e6c3859c07993c6b68d861e253912ec29b77ddc Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 26 Apr 2026 15:26:46 +0200 Subject: [PATCH] add proper auth --- Dockerfile | 1 + .../webdav_token_controller.cpython-310.pyc | Bin 0 -> 2840 bytes docker/supervisord.conf | 3 + docker/webdav_token_controller.py | 71 +++++++ docker/wsgidav.yaml | 24 +-- packages/meta-fuse-core/src/api/APIServer.ts | 43 +++++ .../meta-fuse-core/src/webdav/TokenStore.ts | 138 ++++++++++++++ packages/meta-fuse-ui/src/App.tsx | 13 ++ .../src/components/WebdavTokens.tsx | 177 ++++++++++++++++++ 9 files changed, 454 insertions(+), 16 deletions(-) create mode 100644 docker/__pycache__/webdav_token_controller.cpython-310.pyc create mode 100644 docker/webdav_token_controller.py create mode 100644 packages/meta-fuse-core/src/webdav/TokenStore.ts create mode 100644 packages/meta-fuse-ui/src/components/WebdavTokens.tsx diff --git a/Dockerfile b/Dockerfile index 36bdcb8..dcea7a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -121,6 +121,7 @@ RUN chmod +x /usr/local/bin/meta-core COPY docker/nginx.conf /etc/nginx/nginx.conf COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY docker/wsgidav.yaml /app/docker/ +COPY docker/webdav_token_controller.py /app/docker/ COPY docker/welcome.html /app/welcome/ COPY docker/start-fuse-driver.sh /app/docker/ RUN chmod 644 /app/welcome/welcome.html && \ diff --git a/docker/__pycache__/webdav_token_controller.cpython-310.pyc b/docker/__pycache__/webdav_token_controller.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7293fdd5e8e8fd96be3bcb88cd7402d96fffd6f0 GIT binary patch literal 2840 zcmZ`*OK%)S5bmDW&W_iQ7{`zhpalW4$PW<#f(Ri@Y(oIqR%{3?LX+`y&+f#tGn?*S zJ8RZbq(np<5Ep)cedH$~@k^Q;oH%ge#v!4)XV=*nXI4`^Q`22t_0?C^Ta1qf2(BNl z{GI+@Md(lcGI}}i@*&(}0|Z44L@~88+`wkE8kQODh7F^gIc>M$Vm-&nymqBgfoHej zQ?F6|7|{y#?<4B7%00UgP-_#_tKDBQLctBuOqZ9wBym^BqD_{Ic9iB1%=K2~*(u|Qko=VAbvfX=Z^mReDw4qH|aJC>tJxDkcEmGu!?K0jY+mT4)`KT*f zgtOaSCM5ae?$0DINF2qjG;hukQIyWWgxw;JWD1KD4)Mqq3uq)-+eO4Fkh1B+7e1 zUgYzv-H|;4pq1vp!zRlaPh%2iDa)lGmsZo9GCoH(GInQ<&}cUim+N3Pl7zB$lvA=@ z$X1Y~ECYCV80!$&dIut22>t?2ptU+y_EO%1VdkSqu#?$SZadkd;r_+N;}kB&MV_S1 z#XYtS8_o|>cuN#{tSydShMk}g--BQXc!xCSfNvIasLfpJP&e@&pafHoR_@mto@N-W z>MyL4cr>76_Z{G1?O0m{?KqtPZ2)tsiA5*r6uga@IcH$r^vK+RK0{~VYwZDYP-7fe zdsdx=M1bdpGLLy0&1f1SvO=|T^3JfRO zreRJC2x`a_L(WqgNp@_L?4`f}9hYcDm*0K|=tW7~V(|`1fon}DRwxI}V~9zlLPD^d z#XwM~OTA2u?LmbDU}4nhD6h=E@;H-Sp0CCBNW%5A?m2oCFN2Wi9y-K*+_#P_jHGqT zK6EIS?vd3;p+^zq!&*~QCyTybQLgBMKgtioG*4w1DytBxGJr?fO(xYdqsx|8R+ql` zd`pdmCKF*NlC8R}9Ko`Ld*BMMfcOQgY8b{DlwcVCg8qGP5n@{GK#HR#6C)g_1!SEM zI35~~hY3*JE zqFl4`sc!OT!5pM->j{_6f$~(BW(TY_g#g_vEt~Azv153D?sUgaUl?n+hL?UG!Z-A5 zwe$;@P-)mk06GWs>J(gxuq9a6d-B%wNPL64+1b$+ye8b5HQbLjTnBYfc9fOFh| z-#r_n+Ne4lwT!;49r%48-n}E-uk2X-8~9I9*+jS34L|+0RfYM!+m*@uyKsc~ zERPEcr;GB+V?jB(Gi}u;R3KyoaO-BIYGvVWTwhte_LXvq4$JuzV4)m+iYO;5BC5`; z!g0S=$kn2o(-qDOt}5%B<-?@@Kd=l`k$;-tEbcIgiQbqx}QEEp!)@3*e+-{)6caqyxonm!Iw%1 zg&v0?*VU~Yn#R)fCS?oB`Ey3OT@)EV50CtLEiP;2_@EEQEX!c^N sq%qe=27g71NiFo&BR)Mw-haQu0f-EaX^) None: + super().__init__(wsgidav_app, config) + self._store_path = os.environ.get("TOKEN_STORE_PATH", TOKEN_STORE_DEFAULT) + + # --- BaseDomainController contract -------------------------------------- + + def get_domain_realm(self, path_info: str, environ: dict) -> str: + # Single realm — all paths require the same token. + return "metafuse-webdav" + + def require_authentication(self, realm: str, environ: dict) -> bool: + return True + + def supports_http_digest_auth(self) -> bool: + # Tokens are not stored in plaintext, so we cannot answer digest + # challenges — clients must use basic auth (over TLS). + return False + + def basic_auth_user( + self, realm: str, user_name: str, password: str, environ: dict + ) -> bool: + if not password or not password.startswith(TOKEN_PREFIX): + return False + + try: + with open(self._store_path, "r", encoding="utf-8") as fh: + store = json.load(fh) + except FileNotFoundError: + return False + except (OSError, json.JSONDecodeError): + # Treat unreadable store as "no valid tokens" — fail closed. + return False + + candidate = hashlib.sha256(password.encode("utf-8")).hexdigest() + for token in store.get("tokens", []): + if token.get("hash") == candidate: + return True + return False diff --git a/docker/wsgidav.yaml b/docker/wsgidav.yaml index 8867c44..f365860 100644 --- a/docker/wsgidav.yaml +++ b/docker/wsgidav.yaml @@ -3,17 +3,14 @@ host: 0.0.0.0 port: 8080 -# Simple user authentication -simple_dc: - user_mapping: - "/": - metamesh: - password: metamesh - roles: ["editor"] - "/webdav": - metamesh: - password: metamesh - roles: ["editor"] +# Authentication is delegated to a custom DomainController that validates the +# basic-auth password against meta-fuse's per-device token store. Any +# non-empty username is accepted; the password IS the token (`mfwd_*`). +http_authenticator: + domain_controller: webdav_token_controller.WebdavTokenController + accept_basic: true + accept_digest: false + default_to_digest: false # Mount the virtual filesystem provider_mapping: @@ -31,11 +28,6 @@ server: cheroot server_args: numthreads: 10 -# Authentication settings -accept_basic: true -accept_digest: false -default_to_digest: false - # HTTP configuration add_header_MS_Author_Via: false diff --git a/packages/meta-fuse-core/src/api/APIServer.ts b/packages/meta-fuse-core/src/api/APIServer.ts index 4d59503..05f986a 100644 --- a/packages/meta-fuse-core/src/api/APIServer.ts +++ b/packages/meta-fuse-core/src/api/APIServer.ts @@ -13,6 +13,7 @@ import type { RenamingConfig, RenamingRule, PreviewResponse } from '../vfs/types import { TEMPLATE_VARIABLES } from '../vfs/types/RenamingRuleTypes.js'; import { TemplateEngine } from '../vfs/template/TemplateEngine.js'; import { ConditionEvaluator } from '../vfs/template/ConditionEvaluator.js'; +import { TokenStore } from '../webdav/TokenStore.js'; const logger = new Logger({ name: 'APIServer' }); @@ -30,6 +31,7 @@ export class APIServer { private vfs: VirtualFileSystem; private kvManager: KVManager | null = null; private config: Required; + private tokenStore: TokenStore; constructor(vfs: VirtualFileSystem, config: APIServerConfig = {}, kvManager?: KVManager) { this.vfs = vfs; @@ -38,6 +40,10 @@ export class APIServer { port: config.port ?? parseInt(process.env.API_PORT ?? '3000', 10), host: config.host ?? process.env.API_HOST ?? '0.0.0.0', }; + // CONFIG_DIR is the same dir ConfigStorage uses for renaming rules. + this.tokenStore = new TokenStore({ + configDir: process.env.CONFIG_DIR ?? '/meta-fuse/config', + }); this.app = Fastify({ logger: false, @@ -88,6 +94,43 @@ export class APIServer { // Service discovery — proxies to meta-core's /api/services, resolving the // current leader's URL via LeaderClient (no hardcoded hostname). this.app.get('/api/services', this.handleServices.bind(this)); + + // WebDAV access tokens (per-device basic-auth passwords for /webdav). + this.app.get('/api/webdav-tokens', this.handleListTokens.bind(this)); + this.app.post('/api/webdav-tokens', this.handleCreateToken.bind(this)); + this.app.delete('/api/webdav-tokens/:id', this.handleRevokeToken.bind(this)); + } + + private async handleListTokens(_req: FastifyRequest, reply: FastifyReply): Promise { + reply.send({ tokens: this.tokenStore.list() }); + } + + private async handleCreateToken( + req: FastifyRequest<{ Body: { label?: string } }>, + reply: FastifyReply + ): Promise { + const label = (req.body?.label ?? '').toString(); + if (label.length > 200) { + reply.status(400).send({ error: 'label too long (max 200 chars)' }); + return; + } + const { plaintext, view } = this.tokenStore.create(label); + // The plaintext is included exactly once in this response — the dashboard + // surfaces it to the user and then discards it. Subsequent GETs do not + // include it. + reply.status(201).send({ ...view, token: plaintext }); + } + + private async handleRevokeToken( + req: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply + ): Promise { + const ok = this.tokenStore.revoke(req.params.id); + if (!ok) { + reply.status(404).send({ error: 'token not found' }); + return; + } + reply.status(204).send(); } /** diff --git a/packages/meta-fuse-core/src/webdav/TokenStore.ts b/packages/meta-fuse-core/src/webdav/TokenStore.ts new file mode 100644 index 0000000..ad207be --- /dev/null +++ b/packages/meta-fuse-core/src/webdav/TokenStore.ts @@ -0,0 +1,138 @@ +/** + * TokenStore — Per-device WebDAV access tokens. + * + * Tokens are minted from the OIDC-protected dashboard and used as the + * basic-auth password by WebDAV clients (Plex, Finder, etc.). The plaintext + * is shown to the user once at creation time; only sha256 hashes are + * persisted. The matching WsgiDAV DomainController + * (`docker/webdav_token_controller.py`) reads the same JSON file fresh on + * every auth request, so revocation is immediate. + * + * Tokens identify a *device*, not an OIDC user — multi-user support requires + * hash-lock to forward an X-Forwarded-User header (not currently the case), + * at which point a `userId` field can be added to the record. + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Logger } from 'tslog'; + +const logger = new Logger({ name: 'TokenStore' }); + +/** Length of the random portion (in hex chars) of a generated token. */ +const TOKEN_RANDOM_HEX_LEN = 32; // 16 bytes => 128 bits of entropy +/** Public prefix marker so support can grep logs for stray tokens. */ +const TOKEN_PREFIX = 'mfwd_'; +/** Number of hex chars (after the prefix) we surface in the dashboard list. */ +const VISIBLE_PREFIX_LEN = 4; + +export interface WebdavTokenRecord { + id: string; + label: string; + hash: string; // sha256(plaintext) + prefix: string; // first VISIBLE_PREFIX_LEN chars of the random portion + createdAt: string; // ISO 8601 +} + +/** Public view shown in the dashboard list — no plaintext, no hash. */ +export interface WebdavTokenView { + id: string; + label: string; + prefix: string; + createdAt: string; +} + +interface TokenFile { + tokens: WebdavTokenRecord[]; +} + +export interface TokenStoreOptions { + configDir: string; +} + +export class TokenStore { + private readonly file: string; + private readonly configDir: string; + + constructor(opts: TokenStoreOptions) { + this.configDir = opts.configDir; + this.file = path.join(this.configDir, 'webdav-tokens.json'); + this.ensureConfigDir(); + } + + private ensureConfigDir(): void { + if (!fs.existsSync(this.configDir)) { + fs.mkdirSync(this.configDir, { recursive: true }); + } + } + + private read(): TokenFile { + if (!fs.existsSync(this.file)) return { tokens: [] }; + try { + const raw = fs.readFileSync(this.file, 'utf-8'); + const parsed = JSON.parse(raw) as TokenFile; + if (!Array.isArray(parsed.tokens)) return { tokens: [] }; + return parsed; + } catch (err) { + logger.warn(`Token store ${this.file} unreadable, treating as empty:`, err); + return { tokens: [] }; + } + } + + /** + * Atomic write — write to a sibling tmp file and rename. WsgiDAV's + * controller reads this file on every auth request, so a partial write + * would briefly fail authentication. + */ + private write(file: TokenFile): void { + const tmp = `${this.file}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(file, null, 2), 'utf-8'); + fs.renameSync(tmp, this.file); + } + + /** Public list — no plaintext, no hash. */ + list(): WebdavTokenView[] { + return this.read().tokens.map(({ id, label, prefix, createdAt }) => ({ + id, label, prefix, createdAt, + })); + } + + /** + * Mint a new token. Returns the plaintext exactly once — the caller is + * responsible for surfacing it to the user and forgetting it. + */ + create(label: string): { plaintext: string; view: WebdavTokenView } { + const random = crypto.randomBytes(TOKEN_RANDOM_HEX_LEN / 2).toString('hex'); + const plaintext = `${TOKEN_PREFIX}${random}`; + const id = crypto.randomBytes(8).toString('hex'); + const record: WebdavTokenRecord = { + id, + label: label.trim() || 'Unnamed token', + hash: crypto.createHash('sha256').update(plaintext).digest('hex'), + prefix: random.slice(0, VISIBLE_PREFIX_LEN), + createdAt: new Date().toISOString(), + }; + + const file = this.read(); + file.tokens.push(record); + this.write(file); + + logger.info(`Minted WebDAV token id=${id} label=${JSON.stringify(record.label)}`); + return { + plaintext, + view: { id, label: record.label, prefix: record.prefix, createdAt: record.createdAt }, + }; + } + + /** Delete by id. Returns true if a record was removed. */ + revoke(id: string): boolean { + const file = this.read(); + const idx = file.tokens.findIndex(t => t.id === id); + if (idx < 0) return false; + file.tokens.splice(idx, 1); + this.write(file); + logger.info(`Revoked WebDAV token id=${id}`); + return true; + } +} diff --git a/packages/meta-fuse-ui/src/App.tsx b/packages/meta-fuse-ui/src/App.tsx index a899819..2e9fdae 100644 --- a/packages/meta-fuse-ui/src/App.tsx +++ b/packages/meta-fuse-ui/src/App.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { RulesEditor } from './components/RulesEditor'; import ServiceNav from './components/ServiceNav'; +import { WebdavTokens } from './components/WebdavTokens'; interface Stats { fileCount: number; @@ -39,6 +40,7 @@ function App() { const [refreshing, setRefreshing] = useState(false); const [showRules, setShowRules] = useState(false); const [showSetup, setShowSetup] = useState(false); + const [showTokens, setShowTokens] = useState(false); const fetchData = useCallback(async () => { try { @@ -223,6 +225,17 @@ function App() { )} + +
+
setShowTokens(!showTokens)} + > +

WebDAV Access Tokens

+ {showTokens ? '-' : '+'} +
+ {showTokens && } +
diff --git a/packages/meta-fuse-ui/src/components/WebdavTokens.tsx b/packages/meta-fuse-ui/src/components/WebdavTokens.tsx new file mode 100644 index 0000000..01d95f4 --- /dev/null +++ b/packages/meta-fuse-ui/src/components/WebdavTokens.tsx @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useState } from 'react'; + +interface TokenView { + id: string; + label: string; + prefix: string; + createdAt: string; +} + +interface CreatedToken extends TokenView { + token: string; // plaintext, surfaced only once +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +export function WebdavTokens() { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newLabel, setNewLabel] = useState(''); + const [creating, setCreating] = useState(false); + const [justCreated, setJustCreated] = useState(null); + const [copied, setCopied] = useState(false); + + const refresh = useCallback(async () => { + try { + const res = await fetch('/api/webdav-tokens'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setTokens(data.tokens ?? []); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const create = async () => { + setCreating(true); + try { + const res = await fetch('/api/webdav-tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: newLabel }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const created: CreatedToken = await res.json(); + setJustCreated(created); + setNewLabel(''); + setCopied(false); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setCreating(false); + } + }; + + const revoke = async (id: string, label: string) => { + if (!confirm(`Revoke token "${label}"? Devices using it will lose WebDAV access immediately.`)) return; + try { + const res = await fetch(`/api/webdav-tokens/${id}`, { method: 'DELETE' }); + if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const copyToken = async () => { + if (!justCreated) return; + try { + await navigator.clipboard.writeText(justCreated.token); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard API may be unavailable on http; fall back silently. + } + }; + + if (loading) return
Loading tokens…
; + + return ( +
+

+ Generate one token per device and paste it as the basic-auth password + when you add the WebDAV mount in Plex / Finder / Windows Explorer / etc. + Any username works — the password is what authenticates. Revoke a + token to cut off that single device without affecting the others. +

+ + {error &&
{error}
} + + {justCreated && ( +
+

New token — copy it now

+

+ This is the only time the full token will be shown. After you close + this banner only the prefix will appear in the list. +

+
+ {justCreated.token} + +
+ +
+ )} + +
+
+ Generate token +
+
+ setNewLabel(e.target.value)} + placeholder="Label (e.g. 'Plex on living-room TV')" + maxLength={200} + style={{ flex: 1, padding: '0.5rem' }} + onKeyDown={(e) => { if (e.key === 'Enter' && !creating) create(); }} + /> + +
+
+ +
+ {tokens.length === 0 ? ( +
+ + No tokens yet — generate one to enable WebDAV access. + +
+ ) : ( + tokens.map((t) => ( +
+
+ {t.label} +
+ mfwd_{t.prefix}… · created {formatDate(t.createdAt)} +
+
+ +
+ )) + )} +
+
+ ); +}