feat(web): login-gated BFF auth — sealed session cookie + server-side token injection
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Single-user, LAN-reachable-but-gated. The web server is a backend-for-frontend:
- Login: POST /_auth/login {password} checks PUNKTFUNK_UI_PASSWORD (constant-time) and
sets a SEALED session cookie (h3 useSession / AES-GCM). server/middleware/auth.ts gates
every request — pages 302 → /login, /api → 401 — and FAILS CLOSED (503) when
PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
- The management API stays loopback-only + token (never LAN-exposed). The proxy
(server/routes/api/[...].ts) injects PUNKTFUNK_MGMT_TOKEN server-side and drops the
browser's cookie before forwarding — the token never reaches the browser, which only
holds the session cookie.
Nitro doesn't auto-scan a server/ dir, so the Nitro plugin gets an explicit scanDirs to
pick up middleware + routes. Client: removed the localStorage token (server injects it);
the fetcher bounces to /login on 401; new /login page (bare, no shell); Settings drops the
token field and gains a Sign-out button; en/de strings.
Validated live end to end: unauth /→302, /api→401; wrong pw→401; right pw→200+cookie;
authed /api/v1/status→200 (proxied, mgmt token injected — the host required it); logout→
session cleared→401. tsc + build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-21
@@ -1,24 +1,11 @@
|
||||
// The fetch mutator orval-generated hooks call: `apiFetch<T>(url, RequestInit)`. orval is
|
||||
// configured (includeHttpResponseReturnType: false) so `T` is the response BODY; on an HTTP
|
||||
// error we THROW an `ApiError` so React Query's `isError` works (the query client is
|
||||
// configured not to retry 4xx — see src/router.tsx).
|
||||
// error we THROW an `ApiError` so React Query's `isError` works (the query client skips
|
||||
// retries on 4xx — see src/router.tsx).
|
||||
//
|
||||
// Centralizes the bearer token (from Settings → localStorage). In dev, requests use a
|
||||
// relative `/api/...` path that Vite proxies to the management host (same-origin, no CORS,
|
||||
// the token rides along); a production build served by the host hits the same path.
|
||||
|
||||
const TOKEN_KEY = 'punktfunk.apiToken'
|
||||
|
||||
export function getApiToken(): string {
|
||||
if (typeof localStorage === 'undefined') return ''
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function setApiToken(token: string): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
// Auth: requests are same-origin to `/api/...`; the browser sends only the session cookie
|
||||
// (the server-side proxy injects the management bearer token — the token never lives in the
|
||||
// browser). A 401 means the session is gone → bounce to /login.
|
||||
|
||||
/** A failed API call. `status` is the HTTP code; `data` is the parsed `ApiError` body if any. */
|
||||
export class ApiError extends Error {
|
||||
@@ -33,19 +20,26 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const token = getApiToken()
|
||||
const headers = new Headers(options?.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
const res = await fetch(url, { ...options, headers })
|
||||
const res = await fetch(url, { ...options, headers, credentials: 'same-origin' })
|
||||
|
||||
const text = await res.text()
|
||||
const body = text ? safeJson(text) : undefined
|
||||
if (res.status === 401) redirectToLogin()
|
||||
if (!res.ok) throw new ApiError(res.status, body, res.statusText)
|
||||
return body as T
|
||||
}
|
||||
|
||||
/** On lost session, send the user to the login screen, remembering where they were. */
|
||||
function redirectToLogin(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
if (window.location.pathname === '/login') return
|
||||
const next = encodeURIComponent(window.location.pathname)
|
||||
window.location.href = `/login?next=${next}`
|
||||
}
|
||||
|
||||
function safeJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
useRouterState,
|
||||
} from '@tanstack/react-router'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { AppShell } from '@/components/app-shell'
|
||||
@@ -27,15 +28,21 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
// The login screen renders bare (no sidebar); everything else gets the app shell.
|
||||
const isLogin = useRouterState({ select: (s) => s.location.pathname === '/login' })
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="min-h-screen">
|
||||
<AppShell>
|
||||
{isLogin ? (
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
) : (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
)}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||||
import { Radio } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { m } from '@/paraglide/messages'
|
||||
import { useLocale } from '@/lib/i18n'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
validateSearch: (s: Record<string, unknown>): { next?: string } => ({
|
||||
next: typeof s.next === 'string' ? s.next : undefined,
|
||||
}),
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
function LoginPage() {
|
||||
useLocale()
|
||||
const router = useRouter()
|
||||
const { next } = Route.useSearch()
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(false)
|
||||
try {
|
||||
const res = await fetch('/_auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError(true)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
// Full reload to the target so SSR re-runs WITH the new session cookie.
|
||||
window.location.href = next && next.startsWith('/') ? next : '/'
|
||||
} catch {
|
||||
setError(true)
|
||||
setBusy(false)
|
||||
}
|
||||
void router
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-6">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Radio className="size-5 text-[var(--success)]" />
|
||||
<span className="font-semibold">{m.app_name()}</span>
|
||||
</div>
|
||||
<CardTitle>{m.login_title()}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{m.login_subtitle()}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pw">{m.login_password()}</Label>
|
||||
<Input
|
||||
id="pw"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{m.login_error()}</p>}
|
||||
<Button type="submit" className="w-full" disabled={busy || !password}>
|
||||
{busy ? m.login_signing_in() : m.login_submit()}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+17
-44
@@ -1,63 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Check } from 'lucide-react'
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { getApiToken, setApiToken } from '@/api/fetcher'
|
||||
import { m } from '@/paraglide/messages'
|
||||
import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const Route = createFileRoute('/settings')({ component: SettingsPage })
|
||||
|
||||
function SettingsPage() {
|
||||
const current = useLocale()
|
||||
const qc = useQueryClient()
|
||||
const [token, setToken] = useState(getApiToken())
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const onSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setApiToken(token.trim())
|
||||
// Re-fetch everything with the new credential.
|
||||
qc.invalidateQueries()
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2_000)
|
||||
const onLogout = async () => {
|
||||
await fetch('/_auth/logout', { method: 'POST' })
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">{m.settings_title()}</h1>
|
||||
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>{m.settings_token_label()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSave} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token">{m.settings_token_label()}</Label>
|
||||
<Input
|
||||
id="token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{m.settings_token_help()}</p>
|
||||
</div>
|
||||
<Button type="submit">
|
||||
{saved ? <Check className="size-4" /> : null}
|
||||
{saved ? m.settings_saved() : m.settings_save()}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>{m.settings_language()}</CardTitle>
|
||||
@@ -68,7 +29,7 @@ function SettingsPage() {
|
||||
key={l}
|
||||
variant={l === current ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn('uppercase')}
|
||||
className="uppercase"
|
||||
onClick={() => changeLocale(l)}
|
||||
>
|
||||
{l}
|
||||
@@ -76,6 +37,18 @@ function SettingsPage() {
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>{m.nav_settings()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" onClick={onLogout}>
|
||||
<LogOut className="size-4" />
|
||||
{m.action_logout()}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user