auth improvements
Mserver/src/index.ts
@@ -83,6 +83,26 @@ const app = setup
},
{ body: t.String() },
)
.get(
"/auth/status",
({ cookie: { authToken } }) => {
const authRequired = !!(username && password);
//when no AUTH is configured every route is open, so treat the user as logged in
const loggedIn = !authRequired || (!!authToken.value && authTokens.has(authToken.value));
return { authRequired, loggedIn };
},
{ cookie: t.Cookie({ authToken: t.Optional(t.String()) }) },
)
.post(
"/logout",
({ cookie: { authToken }, set }) => {
if (authToken.value) authTokens.delete(authToken.value); //invalidate the token server-side
//the cookie is HttpOnly, so only the server can clear it - expire it in the past
set.headers["set-cookie"] = `authToken=; Expires=${new Date(0).toUTCString()}; Secure; HttpOnly; SameSite=Strict`;
return "Logged out";
},
{ cookie: t.Cookie({ authToken: t.Optional(t.String()) }) },
)
.guard(
{
cookie: t.Cookie({ authToken: t.Optional(t.String()) }),
Mwebclient/src/App.tsx
@@ -8,7 +8,7 @@ import {
} from "music-server-shared/types";
import { createRFC6381String, decodePath, encodePath } from "music-server-shared/utils";
import type { Component } from "solid-js";
import { batch, createEffect, createResource, createSignal, on, onMount } from "solid-js";
import { batch, createEffect, createResource, createSignal, on, onMount, Show } from "solid-js";
import { createStore, reconcile } from "solid-js/store";
import { BUILD_HASH } from "./build-info";
import DownloadManager from "./components/DownloadManager";
@@ -24,12 +24,13 @@ import {
DownloadManagerSVG,
FullscreenSVG,
LoginSVG,
LogoutSVG,
OpenFolderSVG,
PlaylistSVG,
} from "./components/SVG";
import { getCover, getSrc, listFiles } from "./offline";
import { type AppOptions, FileDisplay, type PlayerState, type PlaylistItem } from "./types";
import { formatFilename, LocalStorageValues, toast } from "./utils";
import { formatFilename, handleUnauthorized, LocalStorageValues, setUnauthorizedHandler, toast } from "./utils";
//TODO: bypass music-metadata if it fails, maybe only use for fallback for files with weird encodings
//TODO: more file display options
@@ -59,6 +60,8 @@ const App: Component = () => {
const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
const [showDownloadManager, setShowDownloadManager] = createSignal(false);
const [showSignIn, setShowSignIn] = createSignal(false);
const [authRequired, setAuthRequired] = createSignal(false);
const [loggedIn, setLoggedIn] = createSignal(false);
const [options, setOptions] = createStore<AppOptions>({
//default settings
enableTranscoding: true,
@@ -109,6 +112,36 @@ const App: Component = () => {
);
const [videoShown, setVideoShown] = createSignal(false);
async function refreshAuthStatus() {
try {
const res = await fetch("/auth/status", { credentials: "same-origin" });
if (!res.ok) return;
const status = (await res.json()) as { authRequired: boolean; loggedIn: boolean };
setAuthRequired(status.authRequired);
setLoggedIn(status.loggedIn);
} catch {
//server unreachable (e.g. offline) - leave the last known state untouched
}
}
async function doLogout() {
const res = await fetch("/logout", { method: "POST", credentials: "same-origin" }).catch(() => {});
if (res?.ok) {
setLoggedIn(false);
toast("Signed out", "success");
} else {
toast("Sign out failed", "error");
}
}
//a 401 from any request means the session is no longer valid
setUnauthorizedHandler(() => setLoggedIn(false));
//re-check auth whenever we (re)enter online mode
createEffect(() => {
if (!isOffline()) refreshAuthStatus();
});
createEffect(() => {
const parts = currentDir().split("/").filter(Boolean);
setBreadcrumbs(["root", ...parts]);
@@ -268,6 +301,7 @@ const App: Component = () => {
mediaSourceAbortController = new AbortController();
const signal = mediaSourceAbortController.signal;
const response = await fetch(srcUrl, { credentials: "same-origin", signal });
if (handleUnauthorized(response)) return;
if (!response.ok || !response.body) throw new Error("Failed to fetch stream");
const reader = response.body.getReader();
@@ -664,14 +698,21 @@ const App: Component = () => {
<div class="spacer" />
<div class="app-buttons">
<div class="signin-offline-group">
<SignIn showSignIn={showSignIn} setShowSignIn={setShowSignIn} />
<button
style={{ display: isOffline() ? "none" : undefined }}
title="Sign in"
onclick={() => setShowSignIn(true)}
>
<LoginSVG />
</button>
<SignIn showSignIn={showSignIn} setShowSignIn={setShowSignIn} onLoggedIn={() => setLoggedIn(true)} />
<Show when={authRequired() && !isOffline()}>
<Show
when={loggedIn()}
fallback={
<button title="Sign in" onclick={() => setShowSignIn(true)}>
<LoginSVG />
</button>
}
>
<button title="Sign out" onclick={doLogout}>
<LogoutSVG />
</button>
</Show>
</Show>
<button
title="Switch between online and offline mode. In offline mode only locally cached files are listed"
Mwebclient/src/components/OptionsModal.tsx
@@ -5,7 +5,7 @@ import "./Dialog.css";
import "toastify-js/src/toastify.css";
import { AudioCodec, MediaContainer, VideoCodec, VideoEncodingSetting } from "music-server-shared/types";
import { createStore } from "solid-js/store";
import { toast } from "../utils";
import { handleUnauthorized, toast } from "../utils";
interface OptionsModalProps {
showOptions: Accessor<boolean>;
@@ -110,6 +110,7 @@ export default function OptionsModal(props: OptionsModalProps) {
async function deleteServerCache() {
const response = await fetch("/reset-cache", { method: "POST", credentials: "same-origin" }).catch(() => {});
if (handleUnauthorized(response)) return;
if (response?.status !== 200) {
toast("Clearing server cache failed", "error");
} else {
Mwebclient/src/components/Playlist.tsx
@@ -15,7 +15,7 @@ import "./Playlist.css";
import type { FlatFileListing } from "../App";
import { download, getCover, savePlaylist, storedFilesLive } from "../offline";
import type { AppOptions, PlayerState, PlaylistItem } from "../types";
import { formatFilename, formatTime, LocalStorageValues, toast } from "../utils";
import { formatFilename, formatTime, handleUnauthorized, LocalStorageValues, toast } from "../utils";
import { DraggableList } from "./DraggableList";
import { LoadingScreen } from "./LoadingScreen";
import { ClearSVG, DownloadSVG, DownSVG, DragSVG, LinkSVG, SaveSVG, SyncSVG, UpSVG } from "./SVG";
@@ -63,6 +63,7 @@ export default function Playlist(props: PlaylistProps) {
method: "POST",
credentials: "same-origin",
});
if (handleUnauthorized(response)) return;
if (!response.ok) throw new Error(`Generating playlist id not successful: ${await response.text()}`);
const id = await response.text();
if (download) {
Mwebclient/src/components/SVG.tsx
@@ -555,3 +555,24 @@ export function LoginSVG() {
</svg>
);
}
export function LogoutSVG() {
const [hovered, setHovered] = createSignal();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<path
transform="scale(-1,1) translate(-24,0)"
fill={hovered() ? highlightColor : defaultColor}
d="M20 2h3v20h-3zM8 4h2v1h1v1h1v1h1v1h1v1h1v1h1v1h1v2h-1v1h-1v1h-1v1h-1v1h-1v1h-1v1h-1v1H8v-1H7v-2h1v-1h1v-1h1v-1H1v-4h9V9H9V8H8V7H7V5h1z"
/>
</svg>
);
}
Mwebclient/src/components/SignIn.tsx
@@ -7,6 +7,7 @@ import { LoadingSpinner } from "./LoadingScreen";
interface SignInProps {
showSignIn: Accessor<boolean>;
setShowSignIn: Setter<boolean>;
onLoggedIn: () => void;
}
export function SignIn(props: SignInProps) {
@@ -48,6 +49,7 @@ export function SignIn(props: SignInProps) {
});
if (response.status === 200) {
toast("Login successful", "success");
props.onLoggedIn();
handleClose();
} else if (response.status === 401) {
toast("Login failed: wrong credentials", "error");
Mwebclient/src/offline.ts
@@ -14,7 +14,7 @@ import { createStore } from "solid-js/store";
import type { FlatFileListing } from "./App";
import { AsyncSemaphore } from "./semaphore";
import type { AppOptions, Playlist } from "./types";
import { joinPath, toast, unproxy } from "./utils";
import { handleUnauthorized, joinPath, toast, unproxy } from "./utils";
interface StoredFile {
path: string;
@@ -225,6 +225,7 @@ async function listOnlineFiles(dir: string, recursive: boolean): Promise<FileLis
const response = await fetch(`/list/${encodePath(dir)}?recursive=${recursive}`, { credentials: "same-origin" }).catch(
() => {},
);
if (handleUnauthorized(response)) return;
if (response?.status === 404) {
toast(`Directory not found: ${dir}`, "error");
return;
@@ -328,6 +329,7 @@ export async function getCover(
method: "GET",
credentials: "same-origin",
}).catch(() => {});
if (handleUnauthorized(response)) return undefined;
if (!response || response.status === 404) {
coverCache.set(dir, { data: null });
if (saveToDb) db.covers.add({ path: dir, data: null }).catch(() => {});
@@ -513,6 +515,7 @@ async function downloadFile(
//let the browser do the actual download
const blobPromise = res.clone().blob();
handleUnauthorized(res);
if (!res.ok || !res.body) throw res.statusText;
//we just iterate over the chunks to get the current progress
const reader = res.body.getReader();
Mwebclient/src/utils.ts
@@ -70,6 +70,27 @@ export function remoteLog(e: unknown) {
fetch("remote-log", { body: JSON.stringify(e?.toString()), method: "POST", credentials: "same-origin" });
}
//debounce so a burst of failing requests (e.g. many covers loading at once) shows a single notification
let lastUnauthorized = 0;
//registered by the app root so a 401 anywhere can update the logged-in state
let onUnauthorized: (() => void) | undefined;
export function setUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler;
}
export function handleUnauthorized(response?: Response | { status?: number } | void): boolean {
if (response?.status !== 401) return false;
//not debounced: keeps the logged-in state correct even while the toast is throttled
onUnauthorized?.();
const now = Date.now();
if (now - lastUnauthorized > 3000) {
lastUnauthorized = now;
toast("Unauthorized, please sign in", "error");
}
return true;
}
export type FileListingRecord = Record<string, MediaFile | { files: FileListingRecord }>;
export function fileListingToObject(listing: FileListing): FileListingRecord {
const result: FileListingRecord = {};