diff --git a/README.md b/README.md index 3a72055..c59f17d 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080" QBIT_USERNAME="..." QBIT_PASSWORD="..." SQLITE_PATH="data/magent.db" -JWT_SECRET="change-me" +JWT_SECRET="replace-with-a-long-random-secret" JWT_EXP_MINUTES="720" -ADMIN_USERNAME="admin" -ADMIN_PASSWORD="adminadmin" +ADMIN_USERNAME="set-a-real-admin-username" +ADMIN_PASSWORD="set-a-long-unique-admin-password" ``` ## Screenshots @@ -112,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080" $env:QBIT_USERNAME="..." $env:QBIT_PASSWORD="..." $env:SQLITE_PATH="data/magent.db" -$env:JWT_SECRET="change-me" +$env:JWT_SECRET="replace-with-a-long-random-secret" $env:JWT_EXP_MINUTES="720" -$env:ADMIN_USERNAME="admin" -$env:ADMIN_PASSWORD="adminadmin" +$env:ADMIN_USERNAME="set-a-real-admin-username" +$env:ADMIN_PASSWORD="set-a-long-unique-admin-password" ``` ### Frontend (Next.js) diff --git a/backend/app/security.py b/backend/app/security.py index ce983d8..1a2adbd 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -44,6 +44,8 @@ def _create_token( return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM) def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str: + if not settings.jwt_secret: + raise ValueError("JWT_SECRET is not configured") minutes = expires_minutes or settings.jwt_exp_minutes expires = datetime.now(timezone.utc) + timedelta(minutes=minutes) return _create_token(subject, role, expires_at=expires, token_type="access") @@ -55,6 +57,8 @@ def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> def decode_token(token: str) -> Dict[str, Any]: + if not settings.jwt_secret: + raise ValueError("JWT_SECRET is not configured") return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM]) diff --git a/frontend/app/feedback/page.tsx b/frontend/app/feedback/page.tsx index ddb3936..ed76558 100644 --- a/frontend/app/feedback/page.tsx +++ b/frontend/app/feedback/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' -import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' +import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth' type Profile = { username?: string @@ -24,15 +24,17 @@ export default function FeedbackPage() { const load = async () => { try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/me`) + const response = await authFetchOrThrow(`${baseUrl}/auth/me`) if (!response.ok) { - clearToken() - router.push('/login') - return + throw new Error('Could not load profile.') } const data = await response.json() setProfile({ username: data?.username }) } catch (error) { + if (error instanceof UnauthorizedError) { + router.push('/login') + return + } console.error(error) } } @@ -49,7 +51,7 @@ export default function FeedbackPage() { setSubmitting(true) try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/feedback`, { + const response = await authFetchOrThrow(`${baseUrl}/feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -58,17 +60,16 @@ export default function FeedbackPage() { }), }) if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } const text = await response.text() throw new Error(text || `Request failed: ${response.status}`) } setMessage('') setStatus('Thanks! Your message has been sent.') } catch (error) { + if (error instanceof UnauthorizedError) { + router.push('/login') + return + } console.error(error) setStatus('That did not send. Please try again.') } finally { diff --git a/frontend/app/lib/auth.ts b/frontend/app/lib/auth.ts index c89bfbb..a0c23ff 100644 --- a/frontend/app/lib/auth.ts +++ b/frontend/app/lib/auth.ts @@ -64,3 +64,37 @@ export const getEventStreamToken = async () => { } return token } + +export class UnauthorizedError extends Error { + constructor() { + super('Unauthorized') + this.name = 'UnauthorizedError' + } +} + +export class ForbiddenError extends Error { + constructor() { + super('Forbidden') + this.name = 'ForbiddenError' + } +} + +export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await authFetch(input, init) + if (response.status === 401) { + clearToken() + throw new UnauthorizedError() + } + if (response.status === 403) { + throw new ForbiddenError() + } + return response +} + +export const readResponseText = async (response: Response) => { + try { + return (await response.text()).trim() + } catch { + return '' + } +}