Skip to content

Commit faca52a

Browse files
committed
studio: username/password login gate — all routes protected
serve.py grows an Auth helper: SHADOWLM_PASSWORD (+ SHADOWLM_USER, default admin) enables a login that mints HMAC-signed, 12h bearer tokens (key=sha256(password), no server-side session store, survives restarts, unforgeable without the pw). New public routes GET /v1/auth (UI asks if login is needed) and POST /v1/login (credentials -> token); every other /v1 route requires a valid bearer — the signed token or the legacy SHADOWLM_API_KEY. Auth stays OFF when nothing is set. Frontend: App splits into a gate -> Login screen -> Studio shell. The token is stored as the existing slm_api_key bearer, so all api() calls carry it untouched; a 401 anywhere clears it and bounces back to login. Sign-out button added. 7 Auth unit tests (login, token roundtrip, forgery/tamper/expiry rejection, legacy api-key, disabled-by-default).
1 parent c4f7586 commit faca52a

9 files changed

Lines changed: 296 additions & 35 deletions

File tree

frontend/src/App.tsx

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
// The studio shell — cream sidebar, lucide icons, hash router.
1+
// The studio shell — cream sidebar, lucide icons, hash router, login gate.
22
import { useEffect, useState } from "react";
3+
import type { FormEvent } from "react";
34
import {
4-
Box, Cpu, Database, ExternalLink, History, LayoutDashboard, MessagesSquare,
5+
Box, Cpu, Database, ExternalLink, History, LayoutDashboard, LogOut,
6+
MessagesSquare,
57
} from "lucide-react";
6-
import { apiKey, getHealth, getMethods, getSettings, setHfToken } from "./api";
7-
import type { MethodInfo } from "./api";
8+
import {
9+
apiKey, getAuthInfo, getHealth, getMethods, getSettings, login, logout,
10+
setHfToken,
11+
} from "./api";
12+
import type { AuthInfo, MethodInfo } from "./api";
813
import Dashboard from "./pages/Dashboard";
914
import Datasets from "./pages/Datasets";
1015
import Models from "./pages/Models";
@@ -31,12 +36,115 @@ const NAV = [
3136
{ hash: "runs", label: "Runs", icon: History },
3237
] as const;
3338

39+
// ---- the login gate ---------------------------------------------------------
3440
export default function App() {
41+
const [auth, setAuth] = useState<AuthInfo | null>(null);
42+
const [token, setToken] = useState(apiKey.get());
43+
44+
useEffect(() => {
45+
getAuthInfo()
46+
.then(setAuth)
47+
.catch(() => setAuth({ auth_required: false, mode: "none" }));
48+
}, []);
49+
50+
useEffect(() => {
51+
const onUnauth = () => setToken("");
52+
window.addEventListener("slm-unauthorized", onUnauth);
53+
return () => window.removeEventListener("slm-unauthorized", onUnauth);
54+
}, []);
55+
56+
if (auth === null) {
57+
return (
58+
<div className="min-h-screen grid place-items-center text-sm text-muted-foreground">
59+
connecting…
60+
</div>
61+
);
62+
}
63+
if (auth.auth_required && !token) {
64+
return <Login mode={auth.mode} onAuthed={() => setToken(apiKey.get())} />;
65+
}
66+
return (
67+
<Studio
68+
authEnabled={auth.auth_required}
69+
onSignOut={() => { logout(); setToken(""); }}
70+
/>
71+
);
72+
}
73+
74+
function Login({ mode, onAuthed }: { mode: string; onAuthed: () => void }) {
75+
const [username, setUsername] = useState("admin");
76+
const [password, setPassword] = useState("");
77+
const [key, setKey] = useState("");
78+
const [err, setErr] = useState("");
79+
const [busy, setBusy] = useState(false);
80+
const apikeyMode = mode === "apikey";
81+
82+
async function submit(e: FormEvent) {
83+
e.preventDefault();
84+
setBusy(true);
85+
setErr("");
86+
try {
87+
if (apikeyMode) {
88+
apiKey.set(key.trim());
89+
await getHealth(); // 401 throws → invalid key
90+
} else {
91+
await login(username.trim(), password);
92+
}
93+
onAuthed();
94+
} catch (e2) {
95+
apiKey.clear();
96+
setErr(apikeyMode ? "Invalid API key" : (e2 as Error).message);
97+
} finally {
98+
setBusy(false);
99+
}
100+
}
101+
102+
return (
103+
<div className="min-h-screen grid place-items-center bg-background px-4">
104+
<form onSubmit={submit}
105+
className="w-full max-w-xs rounded-xl border border-sidebar-border bg-sidebar p-6 space-y-4">
106+
<div className="flex items-center gap-2.5">
107+
<img src="/lyzr-mark.png" alt="" className="size-8 rounded-lg" />
108+
<div className="leading-tight">
109+
<div className="text-sm font-semibold tracking-tight">ShadowLM</div>
110+
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
111+
Studio
112+
</div>
113+
</div>
114+
</div>
115+
<div className="text-xs text-muted-foreground">
116+
{apikeyMode ? "Enter the API key to continue." : "Sign in to continue."}
117+
</div>
118+
{apikeyMode ? (
119+
<input type="password" autoFocus value={key} placeholder="API key"
120+
className="w-full text-sm"
121+
onChange={(e) => setKey(e.target.value)} />
122+
) : (
123+
<>
124+
<input type="text" autoFocus value={username} placeholder="Username"
125+
autoComplete="username" className="w-full text-sm"
126+
onChange={(e) => setUsername(e.target.value)} />
127+
<input type="password" value={password} placeholder="Password"
128+
autoComplete="current-password" className="w-full text-sm"
129+
onChange={(e) => setPassword(e.target.value)} />
130+
</>
131+
)}
132+
{err && <div className="text-xs text-red-500">{err}</div>}
133+
<button type="submit" disabled={busy}
134+
className="w-full rounded-md bg-primary text-primary-foreground text-sm py-2 disabled:opacity-50">
135+
{busy ? "Signing in…" : "Sign in"}
136+
</button>
137+
</form>
138+
</div>
139+
);
140+
}
141+
142+
// ---- the authenticated studio shell ----------------------------------------
143+
function Studio({ authEnabled, onSignOut }: { authEnabled: boolean; onSignOut: () => void }) {
35144
const hash = useHash();
36145
const [section, arg] = hash.split("/");
37146
const [health, setHealth] = useState("connecting…");
38147
const [methods, setMethods] = useState<MethodInfo[]>([]);
39-
const [key, setKey] = useState(apiKey.get());
40148
const [hf, setHf] = useState("");
41149
const [hfSet, setHfSet] = useState(false);
42150

@@ -92,10 +200,6 @@ export default function App() {
92200
</nav>
93201
<div className="px-3 py-3 border-t border-sidebar-border space-y-2">
94202
<div className="px-3 text-[10px] font-mono text-muted-foreground">{health}</div>
95-
<input type="password" placeholder="API key" value={key}
96-
title="Sent as Bearer auth; stored in this browser only"
97-
className="w-full text-xs"
98-
onChange={(e) => { setKey(e.target.value); apiKey.set(e.target.value); }} />
99203
<div className="flex gap-1.5">
100204
<input type="password" value={hf}
101205
placeholder={hfSet ? "HF token ✓ set — replace" : "HF token (gated models)"}
@@ -108,6 +212,13 @@ export default function App() {
108212
save
109213
</button>
110214
</div>
215+
{authEnabled && (
216+
<button onClick={onSignOut}
217+
className="w-full flex items-center gap-3 px-3 py-2 rounded-md text-xs text-muted-foreground hover:bg-sidebar-accent/40 transition-colors">
218+
<LogOut className="size-3.5" />
219+
<span>Sign out</span>
220+
</button>
221+
)}
111222
<a href="https://github.com/open-gitagent/shadowLM" target="_blank" rel="noreferrer"
112223
className="w-full flex items-center gap-3 px-3 py-2 rounded-md text-xs text-muted-foreground hover:bg-sidebar-accent/40 transition-colors no-underline">
113224
<ExternalLink className="size-3.5" />

frontend/src/api.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,46 @@ export interface StepMetric {
7474
export const apiKey = {
7575
get: () => localStorage.getItem("slm_api_key") || "",
7676
set: (v: string) => localStorage.setItem("slm_api_key", v),
77+
clear: () => localStorage.removeItem("slm_api_key"),
7778
};
7879

7980
export async function api<T>(path: string, opts: RequestInit = {}): Promise<T> {
8081
const headers: Record<string, string> = { "Content-Type": "application/json" };
8182
const key = apiKey.get();
8283
if (key) headers["Authorization"] = `Bearer ${key}`;
8384
const r = await fetch(path, { ...opts, headers });
85+
if (r.status === 401) {
86+
// token missing/expired — drop it and bounce back to the login gate
87+
apiKey.clear();
88+
window.dispatchEvent(new Event("slm-unauthorized"));
89+
}
8490
if (!r.ok) {
8591
const detail = await r.json().catch(() => ({} as { error?: string }));
8692
throw new Error(detail.error || r.statusText);
8793
}
8894
return r.json() as Promise<T>;
8995
}
9096

97+
// ---- auth: login/password gate ---------------------------------------------
98+
export interface AuthInfo { auth_required: boolean; mode: "password" | "apikey" | "none"; }
99+
export const getAuthInfo = () =>
100+
fetch("/v1/auth").then((r) => r.json() as Promise<AuthInfo>);
101+
102+
export async function login(username: string, password: string): Promise<void> {
103+
const r = await fetch("/v1/login", {
104+
method: "POST", headers: { "Content-Type": "application/json" },
105+
body: JSON.stringify({ username, password }),
106+
});
107+
if (!r.ok) {
108+
const d = await r.json().catch(() => ({} as { error?: string }));
109+
throw new Error(d.error || "login failed");
110+
}
111+
const { token } = (await r.json()) as { token: string };
112+
apiKey.set(token);
113+
}
114+
115+
export const logout = () => apiKey.clear();
116+
91117
export const getHealth = () =>
92118
api<{ ok: boolean; backend: string; version: string }>("/v1/health");
93119
export const getDatasets = () =>

shadowlm/_static/assets/index-CFBE-pFl.js

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shadowlm/_static/assets/index-D1wlxHM6.js

Lines changed: 0 additions & 13 deletions
This file was deleted.

shadowlm/_static/assets/index-DkmXDLRR.css

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shadowlm/_static/assets/index-JnxmbGdq.css

Lines changed: 0 additions & 2 deletions
This file was deleted.

shadowlm/_static/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
<link rel="icon" type="image/png" href="/logo.png" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>ShadowLM · slm♥</title>
8-
<script type="module" crossorigin src="./assets/index-D1wlxHM6.js"></script>
9-
<link rel="stylesheet" crossorigin href="./assets/index-JnxmbGdq.css">
8+
<script type="module" crossorigin src="./assets/index-CFBE-pFl.js"></script>
9+
<link rel="stylesheet" crossorigin href="./assets/index-DkmXDLRR.css">
1010
</head>
1111
<body>
1212
<div id="root"></div>

shadowlm/serve.py

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from __future__ import annotations
1717

1818
import argparse
19+
import hashlib
20+
import hmac
1921
import io
2022
import json
2123
import os
@@ -502,7 +504,64 @@ def artifact(self, job: _Job) -> bytes:
502504
return buf.getvalue()
503505

504506

505-
def make_handler(server: Server, api_key: str | None):
507+
class Auth:
508+
"""Studio auth — username/password login that mints short-lived bearer tokens,
509+
plus the legacy static `SHADOWLM_API_KEY`. Both gate every `/v1` route.
510+
511+
Tokens are HMAC-signed (`<exp>.<sig>`, key = sha256(password)) so they need no
512+
server-side session store and survive restarts; an attacker can't forge one
513+
without the password. Auth is OFF unless a password or api key is configured.
514+
"""
515+
516+
def __init__(self, *, user: str | None, password: str | None,
517+
api_key: str | None, ttl: int = 12 * 3600) -> None:
518+
self.user = user or "admin"
519+
self.password = password or None
520+
self.api_key = api_key or None
521+
self.ttl = ttl
522+
523+
@property
524+
def enabled(self) -> bool:
525+
return bool(self.password or self.api_key)
526+
527+
@property
528+
def mode(self) -> str:
529+
return "password" if self.password else "apikey" if self.api_key else "none"
530+
531+
def _secret(self) -> bytes:
532+
return hashlib.sha256((self.password or "").encode()).digest()
533+
534+
def check_login(self, user: str, password: str) -> bool:
535+
if not self.password:
536+
return False
537+
return (hmac.compare_digest(user or "", self.user)
538+
& hmac.compare_digest(password or "", self.password))
539+
540+
def issue_token(self) -> tuple[str, int]:
541+
exp = int(time.time()) + self.ttl
542+
sig = hmac.new(self._secret(), str(exp).encode(), hashlib.sha256).hexdigest()
543+
return f"{exp}.{sig}", exp
544+
545+
def _valid_token(self, token: str) -> bool:
546+
if not self.password or "." not in token:
547+
return False
548+
exp_s, _, sig = token.partition(".")
549+
try:
550+
exp = int(exp_s)
551+
except ValueError:
552+
return False
553+
if exp < int(time.time()):
554+
return False
555+
good = hmac.new(self._secret(), str(exp).encode(), hashlib.sha256).hexdigest()
556+
return hmac.compare_digest(sig, good)
557+
558+
def valid_bearer(self, token: str) -> bool:
559+
if self.api_key and hmac.compare_digest(token, self.api_key):
560+
return True
561+
return self._valid_token(token)
562+
563+
564+
def make_handler(server: Server, auth: "Auth"):
506565
class Handler(BaseHTTPRequestHandler):
507566
def log_message(self, fmt, *args): # quiet; job logs print directly
508567
pass
@@ -522,12 +581,12 @@ def _error(self, code: int, msg: str) -> None:
522581
self._send(code, {"error": msg})
523582

524583
def _authed(self) -> bool:
525-
if not api_key:
584+
if not auth.enabled:
526585
return True
527586
got = self.headers.get("Authorization", "")
528-
if got == f"Bearer {api_key}":
587+
if got.startswith("Bearer ") and auth.valid_bearer(got[7:]):
529588
return True
530-
self._error(401, "missing or invalid API key")
589+
self._error(401, "authentication required")
531590
return False
532591

533592
def _body(self) -> dict:
@@ -570,6 +629,9 @@ def do_GET(self): # noqa: N802
570629
except (FileNotFoundError, ModuleNotFoundError):
571630
self._error(404, "no such image bundled")
572631
return
632+
if parts == ["v1", "auth"]: # public: lets the UI decide to show login
633+
self._send(200, {"auth_required": auth.enabled, "mode": auth.mode})
634+
return
573635
if not self._authed():
574636
return
575637
if parts == ["v1", "health"]:
@@ -650,9 +712,17 @@ def do_GET(self): # noqa: N802
650712
self._error(404, f"no route: GET {self.path}")
651713

652714
def do_POST(self): # noqa: N802
715+
parts = self.path.split("?")[0].strip("/").split("/")
716+
if parts == ["v1", "login"]: # public: exchange credentials for a token
717+
b = self._body()
718+
if auth.check_login(b.get("username", ""), b.get("password", "")):
719+
token, exp = auth.issue_token()
720+
self._send(200, {"token": token, "user": auth.user, "expires": exp})
721+
else:
722+
self._error(401, "invalid username or password")
723+
return
653724
if not self._authed():
654725
return
655-
parts = self.path.split("?")[0].strip("/").split("/")
656726
try:
657727
if parts == ["v1", "finetunes"]:
658728
body = self._body()
@@ -759,19 +829,23 @@ def main(argv: list[str] | None = None) -> int:
759829
help="also launch the Vite UI dev server (hot reload)")
760830
args = parser.parse_args(argv)
761831

762-
api_key = os.environ.get("SHADOWLM_API_KEY")
832+
auth = Auth(user=os.environ.get("SHADOWLM_USER", "admin"),
833+
password=os.environ.get("SHADOWLM_PASSWORD"),
834+
api_key=os.environ.get("SHADOWLM_API_KEY"))
763835
work_root = Path(args.work_dir)
764836
work_root.mkdir(parents=True, exist_ok=True)
765837
server = Server(backend=args.backend, accelerator=args.accelerator,
766838
device=args.device, work_root=work_root)
767839
httpd = ThreadingHTTPServer((args.host, args.port),
768-
make_handler(server, api_key))
840+
make_handler(server, auth))
769841

770842
static = Path(__file__).parent / "_static" / "index.html"
771843
ui = ("React studio" if static.exists() else "built-in dashboard (no-build)")
772-
auth = "Bearer auth ON" if api_key else "no auth (set SHADOWLM_API_KEY)"
844+
auth_desc = (f"login required (user '{auth.user}')" if auth.password
845+
else "Bearer api-key auth" if auth.api_key
846+
else "no auth (set SHADOWLM_PASSWORD to require login)")
773847
base = f"http://{args.host}:{args.port}"
774-
print(f"slm♥ ShadowLM server · {base} · backend={args.backend} · {auth}",
848+
print(f"slm♥ ShadowLM server · {base} · backend={args.backend} · {auth_desc}",
775849
flush=True)
776850
print(f" UI: {ui} + API on the same port — open {base}", flush=True)
777851

0 commit comments

Comments
 (0)