Feature 1: Local accounts, replacing shared token
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s

- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup
- Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access
- Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal)
- Login rate limiting (exponential backoff 1s–30s cap) per IP
- Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping)
- Every account can manage every other account (no roles in v1)
- Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check
- Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed
- Account tab for personal token management; sign-out button in topbar
- Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated

Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:13:54 +02:00
co-authored by Claude Sonnet 5
parent 17a83e737f
commit 8d20d9be84
28 changed files with 1635 additions and 137 deletions
+34 -24
View File
@@ -1,23 +1,8 @@
import type {
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory,
APIToken, Connection, Health, HostKeyInfo, JobSnapshot, Me, PackageInfo, Plan,
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory, User,
} from './types'
// The token, when the server requires one, arrives as a query parameter the
// first time and is kept for the tab afterwards.
function readToken(): string {
const fromUrl = new URLSearchParams(location.search).get('token')
if (fromUrl) {
sessionStorage.setItem('dm.token', fromUrl)
const clean = location.pathname + location.hash
history.replaceState(null, '', clean)
return fromUrl
}
return sessionStorage.getItem('dm.token') ?? ''
}
const token = readToken()
/** ApiError carries the server's message plus anything it attached to it. */
export class ApiError extends Error {
status: number
@@ -33,12 +18,26 @@ export class ApiError extends Error {
}
}
// Paths whose own job is to report or resolve "not authenticated" — a 401
// from one of these is the expected outcome of a bad login, not a session
// that dropped out from under the app, so it must not trigger the global
// handler below.
const authPaths = new Set(['/api/me', '/api/login', '/api/setup'])
// Set by AuthGate so a 401 on any other endpoint (a session that expired or
// was revoked mid-use) drops the app back to the login screen, without every
// call site having to check for it.
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(fn: (() => void) | null) {
onUnauthorized = fn
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { ...(init?.headers as Record<string, string>) }
if (token) headers['X-Auth-Token'] = token
if (init?.body) headers['Content-Type'] = 'application/json'
const res = await fetch(path, { ...init, headers })
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
if (res.status === 401 && !authPaths.has(path)) onUnauthorized?.()
if (res.status === 204) return undefined as T
const text = await res.text()
@@ -65,6 +64,19 @@ export const api = {
source: () => request<SourceResponse>('/api/source'),
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
me: () => request<Me>('/api/me'),
setup: (username: string, password: string) => post<Me>('/api/setup', { username, password }),
login: (username: string, password: string) => post<Me>('/api/login', { username, password }),
logout: () => request<void>('/api/logout', { method: 'POST' }),
users: () => request<User[]>('/api/users'),
createUser: (username: string, password: string) => post<User>('/api/users', { username, password }),
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
tokens: () => request<APIToken[]>('/api/tokens'),
createToken: (name: string) => post<APIToken & { token: string }>('/api/tokens', { name }),
revokeToken: (id: string) => request<void>(`/api/tokens/${id}`, { method: 'DELETE' }),
sources: () => request<SourcesResponse>('/api/sources'),
saveSource: (s: Partial<Source>) => post<Source>('/api/sources', s),
deleteSource: (id: string) => request<void>(`/api/sources/${id}`, { method: 'DELETE' }),
@@ -92,10 +104,8 @@ export const api = {
packages: () => request<PackageInfo[]>('/api/packages'),
deletePackage: (name: string) => request<void>(`/api/packages/${encodeURIComponent(name)}`, { method: 'DELETE' }),
downloadUrl: (name: string) =>
`/api/packages/${encodeURIComponent(name)}/download` + (token ? `?token=${encodeURIComponent(token)}` : ''),
downloadUrl: (name: string) => `/api/packages/${encodeURIComponent(name)}/download`,
/** Opens the live progress stream for a job. */
jobEvents: (id: string) =>
new EventSource(`/api/jobs/${id}/events` + (token ? `?token=${encodeURIComponent(token)}` : '')),
/** Opens the live progress stream for a job. Cookies ride along automatically: it's a same-origin request. */
jobEvents: (id: string) => new EventSource(`/api/jobs/${id}/events`),
}