Feature 1: Local accounts, replacing shared token
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
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:
+34
-24
@@ -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`),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user