diff --git a/Dockerfile b/Dockerfile index 02c27d3..982396d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN npm run build FROM node:20-slim WORKDIR /app -RUN mkdir -p /tokens /srv +RUN apt-get update && apt-get install -y --no-install-recommends apache2-utils && rm -rf /var/lib/apt/lists/* && mkdir -p /tokens /srv /auth COPY --from=build /app/apps/visualizer/dist /srv COPY services/frontend/server.mjs /app/services/frontend/server.mjs diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx index b2c689c..e36805f 100644 --- a/apps/visualizer/src/App.tsx +++ b/apps/visualizer/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useLocalStorageState } from './app/hooks/useLocalStorageState'; import AppShell from './layout/AppShell'; import ChartPanel from './features/chart/ChartPanel'; @@ -10,6 +10,7 @@ import MarketHeader from './features/market/MarketHeader'; import Button from './ui/Button'; import TopNav from './layout/TopNav'; import AuthStatus from './layout/AuthStatus'; +import LoginScreen from './layout/LoginScreen'; function envNumber(name: string, fallback: number): number { const v = (import.meta as any).env?.[name]; @@ -35,7 +36,69 @@ function formatQty(v: number | null | undefined, decimals: number): string { return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } +type WhoamiResponse = { + ok?: boolean; + user?: string | null; + mode?: string; +}; + export default function App() { + const [user, setUser] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setAuthLoading(true); + fetch('/whoami', { cache: 'no-store' }) + .then(async (res) => { + const json = (await res.json().catch(() => null)) as WhoamiResponse | null; + const u = typeof json?.user === 'string' ? json.user.trim() : ''; + return u || null; + }) + .then((u) => { + if (cancelled) return; + setUser(u); + }) + .catch(() => { + if (cancelled) return; + setUser(null); + }) + .finally(() => { + if (cancelled) return; + setAuthLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + const logout = async () => { + try { + await fetch('/auth/logout', { method: 'POST' }); + } finally { + setUser(null); + } + }; + + if (authLoading) { + return ( +
+
+ Ładowanie… +
+
+ ); + } + + if (!user) { + return ; + } + + return void logout()} />; +} + +function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BTC-PERP', 'ETH-PERP'], []); const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'PUMP-PERP')); @@ -156,7 +219,7 @@ export default function App() { return ( } />} + header={} />} top={} main={
diff --git a/apps/visualizer/src/layout/AuthStatus.tsx b/apps/visualizer/src/layout/AuthStatus.tsx index 4f82ef8..ba1bb08 100644 --- a/apps/visualizer/src/layout/AuthStatus.tsx +++ b/apps/visualizer/src/layout/AuthStatus.tsx @@ -1,53 +1,18 @@ -import { useEffect, useState } from 'react'; import Button from '../ui/Button'; -type WhoamiResponse = { - ok?: boolean; - user?: string | null; +type Props = { + user: string; + onLogout: () => void; }; -export default function AuthStatus() { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - - fetch('/whoami', { cache: 'no-store' }) - .then(async (res) => { - const json = (await res.json().catch(() => null)) as WhoamiResponse | null; - const u = typeof json?.user === 'string' ? json.user.trim() : ''; - return u || null; - }) - .then((u) => { - if (cancelled) return; - setUser(u); - }) - .catch(() => { - if (cancelled) return; - setUser(null); - }) - .finally(() => { - if (cancelled) return; - setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, []); - - const logout = () => { - window.location.href = '/logout'; - }; - +export default function AuthStatus({ user, onLogout }: Props) { return (
Zalogowany
-
{loading ? '…' : user || '—'}
+
{user}
-
diff --git a/apps/visualizer/src/layout/LoginScreen.tsx b/apps/visualizer/src/layout/LoginScreen.tsx new file mode 100644 index 0000000..5cc6975 --- /dev/null +++ b/apps/visualizer/src/layout/LoginScreen.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import Button from '../ui/Button'; + +type Props = { + onLoggedIn: (user: string) => void; +}; + +type LoginResponse = { + ok?: boolean; + user?: string; + error?: string; +}; + +export default function LoginScreen({ onLoggedIn }: Props) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (submitting) return; + setSubmitting(true); + setError(null); + try { + const res = await fetch('/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const json = (await res.json().catch(() => null)) as LoginResponse | null; + const ok = Boolean(res.ok && json?.ok); + if (!ok) throw new Error(json?.error || 'invalid_credentials'); + const u = typeof json?.user === 'string' ? json.user.trim() : ''; + if (!u) throw new Error('bad_response'); + setPassword(''); + onLoggedIn(u); + } catch { + setError('Nieprawidłowy login lub hasło.'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+ +
Zaloguj się, aby wejść do aplikacji.
+ +
void submit(e)}> + + + + {error ?
{error}
: null} + +
+ +
+
+
+
+ ); +} + diff --git a/apps/visualizer/src/styles.css b/apps/visualizer/src/styles.css index 32e5a06..8f51bcf 100644 --- a/apps/visualizer/src/styles.css +++ b/apps/visualizer/src/styles.css @@ -55,6 +55,72 @@ a:hover { box-sizing: border-box; } +.loginScreen { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + box-sizing: border-box; +} + +.loginCard { + width: min(420px, 100%); + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 18px; + padding: 18px; + box-shadow: 0 20px 80px rgba(0, 0, 0, 0.35); +} + +.loginCard__brand { + display: flex; + align-items: center; + gap: 10px; +} + +.loginCard__mark { + width: 18px; + height: 18px; + border-radius: 6px; + background: linear-gradient(135deg, rgba(168, 85, 247, 0.95), rgba(59, 130, 246, 0.9)); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12); +} + +.loginCard__title { + font-weight: 900; + font-size: 16px; + letter-spacing: 0.2px; +} + +.loginCard__subtitle { + margin-top: 10px; + color: var(--muted); + font-size: 13px; +} + +.loginForm { + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.loginForm__input { + width: 100%; +} + +.loginForm__error { + color: var(--neg); + font-size: 12px; +} + +.loginForm__actions { + margin-top: 6px; + display: flex; + justify-content: flex-end; +} + .shellHeader { background: rgba(0, 0, 0, 0.35); border-bottom: 1px solid rgba(255, 255, 255, 0.10); diff --git a/services/frontend/server.mjs b/services/frontend/server.mjs index 6ae0a59..d336904 100644 --- a/services/frontend/server.mjs +++ b/services/frontend/server.mjs @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import https from 'node:https'; @@ -22,12 +23,25 @@ const BASIC_AUTH_ENABLED = !['off', 'false', '0', 'disabled', 'none'].includes(B const AUTH_USER_HEADER = String(process.env.AUTH_USER_HEADER || 'x-trade-user') .trim() .toLowerCase(); +const AUTH_MODE = String(process.env.AUTH_MODE || 'session') + .trim() + .toLowerCase(); +const HTPASSWD_FILE = String(process.env.HTPASSWD_FILE || '/auth/users').trim(); +const AUTH_SESSION_SECRET_FILE = String(process.env.AUTH_SESSION_SECRET_FILE || '').trim() || null; +const AUTH_SESSION_COOKIE = String(process.env.AUTH_SESSION_COOKIE || 'trade_session') + .trim() + .toLowerCase(); +const AUTH_SESSION_TTL_SECONDS = Number.parseInt(process.env.AUTH_SESSION_TTL_SECONDS || '43200', 10); // 12h function readJson(filePath) { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } +function readText(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + function timingSafeEqualStr(a, b) { const aa = Buffer.from(String(a), 'utf8'); const bb = Buffer.from(String(b), 'utf8'); @@ -35,6 +49,12 @@ function timingSafeEqualStr(a, b) { return crypto.timingSafeEqual(aa, bb); } +function timingSafeEqualBuf(a, b) { + if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) return false; + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + function loadBasicAuth() { const j = readJson(BASIC_AUTH_FILE); const username = (j?.username || '').toString(); @@ -66,6 +86,10 @@ function basicAuthRequired(res) { send(res, 401, { 'content-type': 'text/plain; charset=utf-8' }, 'unauthorized'); } +function unauthorized(res) { + sendJson(res, 401, { ok: false, error: 'unauthorized' }); +} + function isAuthorized(req, creds) { const auth = req.headers.authorization || ''; const m = String(auth).match(/^Basic\s+(.+)$/i); @@ -181,19 +205,162 @@ function readHeader(req, name) { return Array.isArray(v) ? v[0] : v; } +function readCookie(req, name) { + const raw = typeof req.headers.cookie === 'string' ? req.headers.cookie : ''; + if (!raw) return null; + const needle = `${name}=`; + for (const part of raw.split(';')) { + const t = part.trim(); + if (!t.startsWith(needle)) continue; + return t.slice(needle.length) || null; + } + return null; +} + function resolveAuthUser(req) { const user = readHeader(req, AUTH_USER_HEADER) || readHeader(req, 'x-webauth-user'); const value = typeof user === 'string' ? user.trim() : ''; return value || null; } -function hasCookie(req, name, value) { - const raw = typeof req.headers.cookie === 'string' ? req.headers.cookie : ''; - if (!raw) return false; - return raw - .split(';') - .map((p) => p.trim()) - .some((kv) => kv === `${name}=${value}`); +function isHttpsRequest(req) { + const xf = readHeader(req, 'x-forwarded-proto'); + if (typeof xf === 'string' && xf.toLowerCase() === 'https') return true; + return Boolean(req.socket && req.socket.encrypted); +} + +function base64urlEncode(buf) { + return Buffer.from(buf) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function base64urlDecode(str) { + const cleaned = String(str).replace(/-/g, '+').replace(/_/g, '/'); + const pad = cleaned.length % 4 === 0 ? '' : '='.repeat(4 - (cleaned.length % 4)); + return Buffer.from(cleaned + pad, 'base64'); +} + +function loadSessionSecret() { + if (process.env.AUTH_SESSION_SECRET && String(process.env.AUTH_SESSION_SECRET).trim()) { + return Buffer.from(String(process.env.AUTH_SESSION_SECRET).trim(), 'utf8'); + } + if (AUTH_SESSION_SECRET_FILE) { + try { + const txt = readText(AUTH_SESSION_SECRET_FILE).trim(); + if (txt) return Buffer.from(txt, 'utf8'); + } catch { + // ignore + } + } + return crypto.randomBytes(32); +} + +const SESSION_SECRET = loadSessionSecret(); + +function signSessionPayload(payloadB64) { + return crypto.createHmac('sha256', SESSION_SECRET).update(payloadB64).digest(); +} + +function makeSessionCookieValue(username) { + const now = Math.floor(Date.now() / 1000); + const exp = now + (Number.isFinite(AUTH_SESSION_TTL_SECONDS) && AUTH_SESSION_TTL_SECONDS > 0 ? AUTH_SESSION_TTL_SECONDS : 43200); + const payload = JSON.stringify({ u: String(username), exp }); + const payloadB64 = base64urlEncode(Buffer.from(payload, 'utf8')); + const sigB64 = base64urlEncode(signSessionPayload(payloadB64)); + return `${payloadB64}.${sigB64}`; +} + +function getSessionUser(req) { + const raw = readCookie(req, AUTH_SESSION_COOKIE); + if (!raw) return null; + const parts = raw.split('.'); + if (parts.length !== 2) return null; + const [payloadB64, sigB64] = parts; + if (!payloadB64 || !sigB64) return null; + + let payload; + try { + payload = JSON.parse(base64urlDecode(payloadB64).toString('utf8')); + } catch { + return null; + } + const u = typeof payload?.u === 'string' ? payload.u.trim() : ''; + const exp = Number(payload?.exp); + if (!u || !Number.isFinite(exp)) return null; + const now = Math.floor(Date.now() / 1000); + if (now >= exp) return null; + + const expected = signSessionPayload(payloadB64); + let got; + try { + got = base64urlDecode(sigB64); + } catch { + return null; + } + if (!timingSafeEqualBuf(expected, got)) return null; + + return u; +} + +function resolveAuthenticatedUser(req) { + const sessionUser = getSessionUser(req); + if (sessionUser) return sessionUser; + const headerUser = resolveAuthUser(req); + if (headerUser) return headerUser; + if (AUTH_MODE === 'off' || AUTH_MODE === 'none' || AUTH_MODE === 'disabled') return 'anonymous'; + return null; +} + +function clearSessionCookie(res, secure) { + const parts = [`${AUTH_SESSION_COOKIE}=`, 'Path=/', 'Max-Age=0', 'HttpOnly', 'SameSite=Lax']; + if (secure) parts.push('Secure'); + res.setHeader('set-cookie', parts.join('; ')); +} + +function setSessionCookie(res, secure, username) { + const value = makeSessionCookieValue(username); + const parts = [ + `${AUTH_SESSION_COOKIE}=${value}`, + 'Path=/', + `Max-Age=${Number.isFinite(AUTH_SESSION_TTL_SECONDS) ? AUTH_SESSION_TTL_SECONDS : 43200}`, + 'HttpOnly', + 'SameSite=Lax', + ]; + if (secure) parts.push('Secure'); + res.setHeader('set-cookie', parts.join('; ')); +} + +function verifyWithHtpasswd(username, password) { + try { + const r = spawnSync('htpasswd', ['-vb', HTPASSWD_FILE, String(username), String(password)], { + stdio: 'ignore', + timeout: 3000, + }); + return r.status === 0; + } catch { + return false; + } +} + +function readBody(req, limitBytes = 1024 * 16) { + return new Promise((resolve, reject) => { + let total = 0; + const chunks = []; + req.on('data', (chunk) => { + total += chunk.length; + if (total > limitBytes) { + reject(new Error('payload_too_large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); } function proxyApi(req, res, apiReadToken) { @@ -246,7 +413,7 @@ function proxyApi(req, res, apiReadToken) { req.pipe(upstreamReq); } -function handler(req, res) { +async function handler(req, res) { if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) { send( res, @@ -259,25 +426,62 @@ function handler(req, res) { const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); if (req.method === 'GET' && url.pathname === '/whoami') { - sendJson(res, 200, { ok: true, user: resolveAuthUser(req) }); + sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE }); return; } - if (req.method === 'GET' && url.pathname === '/logout') { - // NOTE: With HTTP basic auth handled upstream (Traefik), browser "logout" is best-effort. - // We force a single 401 to show the browser prompt, then on retry we redirect back to '/'. - const marker = 'trade_logout'; - if (!hasCookie(req, marker, '1')) { - res.setHeader('set-cookie', `${marker}=1; Path=/logout; Max-Age=10; SameSite=Lax`); - res.setHeader('www-authenticate', 'Basic realm="trade"'); - send(res, 401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }, 'logged_out'); + if (req.method === 'POST' && url.pathname === '/auth/login') { + if (AUTH_MODE === 'off' || AUTH_MODE === 'none' || AUTH_MODE === 'disabled') { + sendJson(res, 400, { ok: false, error: 'auth_disabled' }); return; } - res.setHeader('set-cookie', `${marker}=; Path=/logout; Max-Age=0; SameSite=Lax`); - res.statusCode = 302; - res.setHeader('location', '/'); - res.end(); + const raw = await readBody(req); + const ct = String(req.headers['content-type'] || '').toLowerCase(); + let username = ''; + let password = ''; + if (ct.includes('application/json')) { + let json; + try { + json = JSON.parse(raw); + } catch { + sendJson(res, 400, { ok: false, error: 'bad_json' }); + return; + } + username = typeof json?.username === 'string' ? json.username.trim() : ''; + password = typeof json?.password === 'string' ? json.password : ''; + } else { + const params = new URLSearchParams(raw); + username = String(params.get('username') || '').trim(); + password = String(params.get('password') || ''); + } + + if (!username || !password || username.length > 64 || password.length > 200) { + sendJson(res, 400, { ok: false, error: 'invalid_input' }); + return; + } + + const ok = verifyWithHtpasswd(username, password); + if (!ok) { + unauthorized(res); + return; + } + + const secure = isHttpsRequest(req); + setSessionCookie(res, secure, username); + sendJson(res, 200, { ok: true, user: username }); + return; + } + + if ((req.method === 'POST' || req.method === 'GET') && (url.pathname === '/auth/logout' || url.pathname === '/logout')) { + clearSessionCookie(res, isHttpsRequest(req)); + if (req.method === 'GET') { + res.statusCode = 302; + res.setHeader('location', '/'); + res.end(); + return; + } + sendJson(res, 200, { ok: true }); return; } @@ -297,6 +501,14 @@ function handler(req, res) { } if (req.url?.startsWith('/api') && (req.url === '/api' || req.url.startsWith('/api/'))) { + if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') { + const user = resolveAuthenticatedUser(req); + if (!user) { + unauthorized(res); + return; + } + } + let token; try { token = loadApiReadToken(); @@ -311,7 +523,15 @@ function handler(req, res) { serveStatic(req, res); } -const server = http.createServer(handler); +const server = http.createServer((req, res) => { + handler(req, res).catch((e) => { + if (res.headersSent) { + res.destroy(); + return; + } + send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); + }); +}); server.listen(PORT, () => { console.log( JSON.stringify( @@ -324,6 +544,8 @@ server.listen(PORT, () => { basicAuthMode: BASIC_AUTH_MODE, apiReadTokenFile: API_READ_TOKEN_FILE, authUserHeader: AUTH_USER_HEADER, + authMode: AUTH_MODE, + htpasswdFile: HTPASSWD_FILE, }, null, 2