From 6bd6a6c8c888bf69212368ef68bff828000663a7 Mon Sep 17 00:00:00 2001 From: Vadim Sobinin Date: Sun, 15 Mar 2026 00:23:15 +0300 Subject: [PATCH] feat(proxy): add optional auth for dashboard and API Co-Authored-By: Claude --- docs/architecture.md | 12 ++ packages/proxy/src/auth/middleware.ts | 18 +++ packages/proxy/src/auth/routes.ts | 57 +++++++ packages/proxy/src/auth/sessionStore.ts | 22 +++ packages/proxy/src/config.ts | 9 ++ packages/proxy/src/index.ts | 15 +- packages/proxy/src/server/app.ts | 8 + packages/proxy/web/src/lib/api.ts | 59 ++++++- .../web/src/lib/components/LoginForm.svelte | 148 ++++++++++++++++++ packages/proxy/web/src/lib/stores/auth.ts | 29 ++++ packages/proxy/web/src/lib/stores/machine.ts | 8 +- packages/proxy/web/src/routes/+layout.svelte | 80 ++++++++-- 12 files changed, 445 insertions(+), 20 deletions(-) create mode 100644 packages/proxy/src/auth/middleware.ts create mode 100644 packages/proxy/src/auth/routes.ts create mode 100644 packages/proxy/src/auth/sessionStore.ts create mode 100644 packages/proxy/web/src/lib/components/LoginForm.svelte create mode 100644 packages/proxy/web/src/lib/stores/auth.ts diff --git a/docs/architecture.md b/docs/architecture.md index f28b405..d277eb1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,6 +50,18 @@ Proxy матчит сервисы по `Host` заголовку. Если PC on В Docker volume монтируется: `./data/proxy:/app/data`. +## Аутентификация + +Опциональная auth по логину/паролю через env-переменные `AUTH_USERNAME` и `AUTH_PASSWORD`. Если не заданы — auth отключена. + +- `POST /api/auth/login` — возвращает `{ token }` (UUID v4) +- `POST /api/auth/logout` — удаляет сессию +- `GET /api/auth/check` — проверяет, нужна ли auth и валиден ли токен +- Сессии in-memory (`Map`), теряются при рестарте +- Auth middleware защищает `/api/*` кроме `/api/auth/*` +- WebSocket: токен через query `?token=...` +- Сервис-прокси (по Host) — без auth + ## Деплой Два приложения в Dokploy из одного git repo: diff --git a/packages/proxy/src/auth/middleware.ts b/packages/proxy/src/auth/middleware.ts new file mode 100644 index 0000000..e45e3c8 --- /dev/null +++ b/packages/proxy/src/auth/middleware.ts @@ -0,0 +1,18 @@ +import type { MiddlewareHandler } from 'hono'; +import { isAuthEnabled } from '../config.js'; +import { validateSession } from './sessionStore.js'; + +export const authMiddleware: MiddlewareHandler = async (c, next) => { + if (!isAuthEnabled()) { + return next(); + } + + const header = c.req.header('Authorization'); + const token = header?.startsWith('Bearer ') ? header.slice(7) : null; + + if (!token || !validateSession(token)) { + return c.json({ error: 'Unauthorized' }, 401); + } + + return next(); +}; diff --git a/packages/proxy/src/auth/routes.ts b/packages/proxy/src/auth/routes.ts new file mode 100644 index 0000000..9525ae6 --- /dev/null +++ b/packages/proxy/src/auth/routes.ts @@ -0,0 +1,57 @@ +import { Hono } from 'hono'; +import { timingSafeEqual } from 'node:crypto'; +import { config, isAuthEnabled } from '../config.js'; +import { createSession, validateSession, removeSession } from './sessionStore.js'; + +const authRoutes = new Hono(); + +function safeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + +authRoutes.post('/login', async (c) => { + if (!isAuthEnabled()) { + return c.json({ error: 'Auth is not enabled' }, 400); + } + + const body = await c.req.json<{ username: string; password: string }>(); + const { username, password } = body; + + if (!username || !password) { + return c.json({ error: 'Username and password required' }, 400); + } + + const validUser = safeEqual(username, config.auth.username); + const validPass = safeEqual(password, config.auth.password); + + if (!validUser || !validPass) { + return c.json({ error: 'Invalid credentials' }, 401); + } + + const token = createSession(username); + return c.json({ token }); +}); + +authRoutes.post('/logout', (c) => { + const header = c.req.header('Authorization'); + const token = header?.startsWith('Bearer ') ? header.slice(7) : null; + if (token) { + removeSession(token); + } + return c.json({ ok: true }); +}); + +authRoutes.get('/check', (c) => { + if (!isAuthEnabled()) { + return c.json({ authRequired: false }); + } + + const header = c.req.header('Authorization'); + const token = header?.startsWith('Bearer ') ? header.slice(7) : null; + const valid = token ? validateSession(token) !== null : false; + + return c.json({ authRequired: true, valid }); +}); + +export { authRoutes }; diff --git a/packages/proxy/src/auth/sessionStore.ts b/packages/proxy/src/auth/sessionStore.ts new file mode 100644 index 0000000..bf78551 --- /dev/null +++ b/packages/proxy/src/auth/sessionStore.ts @@ -0,0 +1,22 @@ +import { randomUUID } from 'node:crypto'; + +interface Session { + username: string; + createdAt: number; +} + +const sessions = new Map(); + +export function createSession(username: string): string { + const token = randomUUID(); + sessions.set(token, { username, createdAt: Date.now() }); + return token; +} + +export function validateSession(token: string): Session | null { + return sessions.get(token) ?? null; +} + +export function removeSession(token: string): void { + sessions.delete(token); +} diff --git a/packages/proxy/src/config.ts b/packages/proxy/src/config.ts index 88d55de..ae19868 100644 --- a/packages/proxy/src/config.ts +++ b/packages/proxy/src/config.ts @@ -30,5 +30,14 @@ export const config = { healthCheckIntervalSeconds: intEnv('HEALTH_CHECK_INTERVAL_SECONDS', 10), wakingTimeoutSeconds: intEnv('WAKING_TIMEOUT_SECONDS', 120), + auth: { + username: optionalEnv('AUTH_USERNAME', ''), + password: optionalEnv('AUTH_PASSWORD', ''), + }, + services: parseServices(), } as const; + +export function isAuthEnabled(): boolean { + return config.auth.username !== '' && config.auth.password !== ''; +} diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts index 4b70683..833d805 100644 --- a/packages/proxy/src/index.ts +++ b/packages/proxy/src/index.ts @@ -5,7 +5,8 @@ import { app } from './server/app.js'; import { proxyRequest, proxyWebSocket } from './server/proxy.js'; import { initOrchestrator, getState, onRequest } from './services/orchestrator.js'; import { addClient, removeClient } from './server/ws.js'; -import { config } from './config.js'; +import { config, isAuthEnabled } from './config.js'; +import { validateSession } from './auth/sessionStore.js'; import { initServiceManager, getServiceManager } from './services/serviceManager.js'; import { WebSocketServer } from 'ws'; @@ -52,7 +53,17 @@ server.on('upgrade', (req, socket, head) => { } // Dashboard WebSocket at /api/ws - if (req.url === '/api/ws') { + if (req.url?.startsWith('/api/ws')) { + if (isAuthEnabled()) { + const url = new URL(req.url, `http://${req.headers.host}`); + const token = url.searchParams.get('token'); + if (!token || !validateSession(token)) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + } + wss.handleUpgrade(req, socket, head, (ws) => { addClient(ws); ws.on('close', () => removeClient(ws)); diff --git a/packages/proxy/src/server/app.ts b/packages/proxy/src/server/app.ts index 422ab9e..0ed4bc4 100644 --- a/packages/proxy/src/server/app.ts +++ b/packages/proxy/src/server/app.ts @@ -2,6 +2,8 @@ import { Hono } from 'hono'; import { serveStatic } from '@hono/node-server/serve-static'; import { MachineState } from '@sleepguard/shared'; import { corsMiddleware } from '../api/middleware.js'; +import { authRoutes } from '../auth/routes.js'; +import { authMiddleware } from '../auth/middleware.js'; import { api } from '../api/routes.js'; import { getState, onRequest } from '../services/orchestrator.js'; import { getWakingPageHtml } from './wakingPage.js'; @@ -12,6 +14,12 @@ const app = new Hono(); // CORS for API app.use('/api/*', corsMiddleware); +// Auth routes (before auth middleware) +app.route('/api/auth', authRoutes); + +// Auth middleware (after auth routes) +app.use('/api/*', authMiddleware); + // API routes app.route('/api', api); diff --git a/packages/proxy/web/src/lib/api.ts b/packages/proxy/web/src/lib/api.ts index 30bde2f..72204ba 100644 --- a/packages/proxy/web/src/lib/api.ts +++ b/packages/proxy/web/src/lib/api.ts @@ -1,12 +1,32 @@ import type { ProxyStatus, ServiceHealth, LogEntry } from '@sleepguard/shared'; +import { getToken, clearToken } from './stores/auth.js'; const BASE = '/api'; +export class AuthError extends Error { + constructor() { + super('Unauthorized'); + this.name = 'AuthError'; + } +} + async function request(path: string, options?: RequestInit): Promise { + const token = getToken(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + const res = await fetch(`${BASE}${path}`, { - headers: { 'Content-Type': 'application/json' }, ...options, + headers: { ...headers, ...options?.headers }, }); + + if (res.status === 401) { + clearToken(); + throw new AuthError(); + } + if (!res.ok) { throw new Error(`API error: ${res.status} ${res.statusText}`); } @@ -44,3 +64,40 @@ export function updateService(host: string, updates: { name?: string; target?: s export function deleteService(host: string): Promise<{ ok: boolean }> { return request(`/services/${encodeURIComponent(host)}`, { method: 'DELETE' }); } + +export async function login(username: string, password: string): Promise { + const res = await fetch(`${BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: 'Login failed' })) as { error: string }; + throw new Error(body.error || 'Login failed'); + } + + const data = (await res.json()) as { token: string }; + return data.token; +} + +export async function logout(): Promise { + const token = getToken(); + await fetch(`${BASE}/auth/logout`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }).catch(() => {}); + clearToken(); +} + +export async function checkAuth(): Promise<{ authRequired: boolean; valid: boolean }> { + const token = getToken(); + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const res = await fetch(`${BASE}/auth/check`, { headers }); + const data = (await res.json()) as { authRequired: boolean; valid?: boolean }; + return { authRequired: data.authRequired, valid: data.valid ?? false }; +} diff --git a/packages/proxy/web/src/lib/components/LoginForm.svelte b/packages/proxy/web/src/lib/components/LoginForm.svelte new file mode 100644 index 0000000..040261b --- /dev/null +++ b/packages/proxy/web/src/lib/components/LoginForm.svelte @@ -0,0 +1,148 @@ + + + + + diff --git a/packages/proxy/web/src/lib/stores/auth.ts b/packages/proxy/web/src/lib/stores/auth.ts new file mode 100644 index 0000000..f8a889f --- /dev/null +++ b/packages/proxy/web/src/lib/stores/auth.ts @@ -0,0 +1,29 @@ +import { writable, derived } from 'svelte/store'; + +const STORAGE_KEY = 'sleepguard_token'; + +function loadToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem(STORAGE_KEY); +} + +export const token = writable(loadToken()); +export const authRequired = writable(false); +export const isAuthenticated = derived( + [token, authRequired], + ([$token, $authRequired]) => !$authRequired || $token !== null, +); + +export function setToken(t: string): void { + localStorage.setItem(STORAGE_KEY, t); + token.set(t); +} + +export function clearToken(): void { + localStorage.removeItem(STORAGE_KEY); + token.set(null); +} + +export function getToken(): string | null { + return loadToken(); +} diff --git a/packages/proxy/web/src/lib/stores/machine.ts b/packages/proxy/web/src/lib/stores/machine.ts index 733c748..48d12e5 100644 --- a/packages/proxy/web/src/lib/stores/machine.ts +++ b/packages/proxy/web/src/lib/stores/machine.ts @@ -2,6 +2,7 @@ import { writable, derived } from 'svelte/store'; import type { ProxyStatus, WsMessage } from '@sleepguard/shared'; import { MachineState } from '@sleepguard/shared'; import { fetchStatus } from '../api.js'; +import { getToken } from './auth.js'; export const status = writable(null); export const connected = writable(false); @@ -15,7 +16,12 @@ let pollTimer: ReturnType | null = null; function getWsUrl(): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${proto}//${window.location.host}/api/ws`; + let url = `${proto}//${window.location.host}/api/ws`; + const token = getToken(); + if (token) { + url += `?token=${encodeURIComponent(token)}`; + } + return url; } export function connectWs(): void { diff --git a/packages/proxy/web/src/routes/+layout.svelte b/packages/proxy/web/src/routes/+layout.svelte index 9e9581e..b2b565f 100644 --- a/packages/proxy/web/src/routes/+layout.svelte +++ b/packages/proxy/web/src/routes/+layout.svelte @@ -2,33 +2,69 @@ import '../app.css'; import { onMount, onDestroy } from 'svelte'; import { connectWs, disconnectWs } from '$lib/stores/machine.js'; + import { token, authRequired, isAuthenticated } from '$lib/stores/auth.js'; + import { checkAuth, logout as apiLogout } from '$lib/api.js'; + import LoginForm from '$lib/components/LoginForm.svelte'; import type { Snippet } from 'svelte'; let { children }: { children: Snippet } = $props(); + let ready = $state(false); - onMount(() => { - connectWs(); + onMount(async () => { + try { + const auth = await checkAuth(); + authRequired.set(auth.authRequired); + + if (!auth.authRequired || auth.valid) { + connectWs(); + } + } catch { + // If check fails, assume auth not required + } + ready = true; }); onDestroy(() => { disconnectWs(); }); + + // Reconnect WS when token changes (e.g. after login) + $effect(() => { + if ($token && ready) { + disconnectWs(); + connectWs(); + } + }); + + async function handleLogout() { + disconnectWs(); + await apiLogout(); + } -
-
- -
-
- {@render children()} -
-
+{#if !ready} + +{:else if $authRequired && !$isAuthenticated} + +{:else} +
+
+ +
+
+ {@render children()} +
+
+{/if}