diff --git a/api/pyproject.toml b/api/pyproject.toml index 59d2f6c..b583cfb 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "redis[hiredis]>=5.0", "python-multipart>=0.0.9", "Pillow>=10.0", + "slowapi>=0.1.9", ] [project.optional-dependencies] diff --git a/api/src/rehearsalhub/dependencies.py b/api/src/rehearsalhub/dependencies.py index 892b07c..635a7dc 100644 --- a/api/src/rehearsalhub/dependencies.py +++ b/api/src/rehearsalhub/dependencies.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from sqlalchemy.ext.asyncio import AsyncSession @@ -13,18 +13,25 @@ from rehearsalhub.db.models import Member from rehearsalhub.services.auth import decode_token from rehearsalhub.repositories.member import MemberRepository -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") +# auto_error=False so we can fall back to cookie auth without a 401 from the scheme itself +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False) async def get_current_member( - token: str = Depends(oauth2_scheme), + request: Request, + bearer_token: str | None = Depends(oauth2_scheme), session: AsyncSession = Depends(get_session), ) -> Member: + # Prefer Authorization: Bearer header; fall back to httpOnly cookie + token = bearer_token or request.cookies.get("rh_token") + credentials_exc = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) + if not token: + raise credentials_exc try: payload = decode_token(token) member_id_str: str | None = payload.get("sub") diff --git a/api/src/rehearsalhub/main.py b/api/src/rehearsalhub/main.py index c9f630e..6c4def4 100644 --- a/api/src/rehearsalhub/main.py +++ b/api/src/rehearsalhub/main.py @@ -6,6 +6,9 @@ import os from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address from rehearsalhub.config import get_settings from rehearsalhub.routers import ( @@ -20,6 +23,8 @@ from rehearsalhub.routers import ( ws_router, ) +limiter = Limiter(key_func=get_remote_address) + @asynccontextmanager async def lifespan(app: FastAPI): @@ -43,6 +48,9 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + app.add_middleware( CORSMiddleware, allow_origins=[f"https://{settings.domain}", "http://localhost:3000"], diff --git a/api/src/rehearsalhub/routers/auth.py b/api/src/rehearsalhub/routers/auth.py index 90b504c..6f30a16 100644 --- a/api/src/rehearsalhub/routers/auth.py +++ b/api/src/rehearsalhub/routers/auth.py @@ -2,10 +2,13 @@ import logging import os import uuid -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status from PIL import Image, UnidentifiedImageError +from slowapi import Limiter +from slowapi.util import get_remote_address from sqlalchemy.ext.asyncio import AsyncSession +from rehearsalhub.config import get_settings from rehearsalhub.db.engine import get_session from rehearsalhub.db.models import Member from rehearsalhub.dependencies import get_current_member @@ -17,13 +20,15 @@ from rehearsalhub.services.auth import AuthService log = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["auth"]) +limiter = Limiter(key_func=get_remote_address) _ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"} _MAX_AVATAR_SIZE = 5 * 1024 * 1024 # 5 MB @router.post("/register", response_model=MemberRead, status_code=status.HTTP_201_CREATED) -async def register(req: RegisterRequest, session: AsyncSession = Depends(get_session)): +@limiter.limit("5/minute") +async def register(request: Request, req: RegisterRequest, session: AsyncSession = Depends(get_session)): svc = AuthService(session) try: member = await svc.register(req) @@ -33,16 +38,38 @@ async def register(req: RegisterRequest, session: AsyncSession = Depends(get_ses @router.post("/login", response_model=TokenResponse) -async def login(req: LoginRequest, session: AsyncSession = Depends(get_session)): +@limiter.limit("10/minute") +async def login( + request: Request, + req: LoginRequest, + response: Response, + session: AsyncSession = Depends(get_session), +): svc = AuthService(session) token = await svc.login(req.email, req.password) if token is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials" ) + settings = get_settings() + response.set_cookie( + key="rh_token", + value=token.access_token, + httponly=True, + secure=not settings.debug, + samesite="lax", + max_age=settings.access_token_expire_minutes * 60, + path="/", + ) return token +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(response: Response): + response.delete_cookie(key="rh_token", path="/") + return None + + @router.get("/me", response_model=MemberRead) async def get_me(current_member: Member = Depends(get_current_member)): return MemberRead.from_model(current_member) diff --git a/api/src/rehearsalhub/routers/songs.py b/api/src/rehearsalhub/routers/songs.py index 219ff00..cb0a9ee 100644 --- a/api/src/rehearsalhub/routers/songs.py +++ b/api/src/rehearsalhub/routers/songs.py @@ -180,9 +180,9 @@ async def scan_nextcloud_stream( yield json.dumps(event) + "\n" if event.get("type") in ("song", "session"): await db.commit() - except Exception as exc: + except Exception: log.exception("SSE scan error for band %s", band_id) - yield json.dumps({"type": "error", "message": str(exc)}) + "\n" + yield json.dumps({"type": "error", "message": "Scan failed due to an internal error."}) + "\n" finally: await db.commit() diff --git a/api/src/rehearsalhub/routers/ws.py b/api/src/rehearsalhub/routers/ws.py index 8759b37..b328d60 100644 --- a/api/src/rehearsalhub/routers/ws.py +++ b/api/src/rehearsalhub/routers/ws.py @@ -16,13 +16,19 @@ router = APIRouter(tags=["websocket"]) async def version_ws( version_id: uuid.UUID, websocket: WebSocket, - token: str = Query(...), + token: str | None = Query(None), ): - """WebSocket endpoint. Requires a valid JWT passed as ?token=.""" - # Validate token before accepting the connection + """ + WebSocket endpoint. Authentication via: + - ?token= query parameter, or + - rh_token httpOnly cookie (sent automatically by the browser) + """ + raw_token = token or websocket.cookies.get("rh_token") async for session in get_session(): try: - payload = decode_token(token) + if not raw_token: + raise ValueError("no token") + payload = decode_token(raw_token) member_id = uuid.UUID(payload["sub"]) member = await MemberRepository(session).get_by_id(member_id) if member is None: diff --git a/web/nginx.conf b/web/nginx.conf index c05e301..a563fb5 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -3,6 +3,13 @@ server { root /usr/share/nginx/html; index index.html; + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "0" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + # Allow avatar uploads up to 10MB (API enforces a 5MB limit) client_max_body_size 10m; diff --git a/web/src/App.tsx b/web/src/App.tsx index 0443bfd..5a17e4d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom"; import "./index.css"; import { ThemeProvider, useTheme } from "./theme"; +import { isLoggedIn } from "./api/client"; import { LoginPage } from "./pages/LoginPage"; import { HomePage } from "./pages/HomePage"; import { BandPage } from "./pages/BandPage"; @@ -15,8 +16,7 @@ const queryClient = new QueryClient({ }); function PrivateRoute({ children }: { children: React.ReactNode }) { - const token = localStorage.getItem("rh_token"); - return token ? <>{children} : ; + return isLoggedIn() ? <>{children} : ; } function ThemeToggle() { diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 7c51fa3..e1381e7 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -1,4 +1,4 @@ -import { api, setToken } from "./client"; +import { api, markLoggedIn, markLoggedOut } from "./client"; export interface LoginRequest { email: string; @@ -20,10 +20,19 @@ export interface MemberRead { export async function login(req: LoginRequest): Promise { const resp = await api.post("/auth/login", req); - setToken(resp.access_token); + markLoggedIn(); return resp; } +export async function logout(): Promise { + try { + await api.post("/auth/logout", {}); + } finally { + markLoggedOut(); + window.location.href = "/login"; + } +} + export async function register(req: { email: string; password: string; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6914219..d8e02ad 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,15 +1,21 @@ const BASE = "/api/v1"; -function getToken(): string | null { - return localStorage.getItem("rh_token"); +// A non-sensitive flag in localStorage that tells the SPA whether the user has +// an active session. The actual JWT lives in an httpOnly cookie and is never +// readable by JavaScript. Clearing this flag is sufficient for client-side +// route guards; the server still validates the cookie on every request. +const SESSION_KEY = "rh_session"; + +export function markLoggedIn(): void { + localStorage.setItem(SESSION_KEY, "1"); } -export function setToken(token: string): void { - localStorage.setItem("rh_token", token); +export function markLoggedOut(): void { + localStorage.removeItem(SESSION_KEY); } -export function clearToken(): void { - localStorage.removeItem("rh_token"); +export function isLoggedIn(): boolean { + return localStorage.getItem(SESSION_KEY) === "1"; } async function request( @@ -17,20 +23,20 @@ async function request( options: RequestInit = {}, isFormData = false ): Promise { - const token = getToken(); const headers: Record = { ...(options.headers as Record), }; if (!isFormData) { headers["Content-Type"] = "application/json"; } - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - const resp = await fetch(`${BASE}${path}`, { ...options, headers }); + const resp = await fetch(`${BASE}${path}`, { + ...options, + headers, + credentials: "include", // send httpOnly cookie on every request + }); if (!resp.ok) { if (resp.status === 401) { - clearToken(); + markLoggedOut(); window.location.href = "/login"; throw new Error("Session expired"); } diff --git a/web/src/hooks/useWaveform.ts b/web/src/hooks/useWaveform.ts index aff3f23..be2f204 100644 --- a/web/src/hooks/useWaveform.ts +++ b/web/src/hooks/useWaveform.ts @@ -40,9 +40,8 @@ export function useWaveform( normalize: true, }); - const token = localStorage.getItem("rh_token"); - const audioUrl = token ? `${options.url}?token=${encodeURIComponent(token)}` : options.url; - ws.load(audioUrl); + // The rh_token httpOnly cookie is sent automatically by the browser. + ws.load(options.url); ws.on("ready", () => { setIsReady(true); diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx index f9c7447..cc2a20b 100644 --- a/web/src/pages/BandPage.tsx +++ b/web/src/pages/BandPage.tsx @@ -123,11 +123,11 @@ export function BandPage() { setScanMsg(null); setScanProgress("Starting scan…"); - const token = localStorage.getItem("rh_token"); - const url = `/api/v1/bands/${bandId}/nc-scan/stream${token ? `?token=${encodeURIComponent(token)}` : ""}`; + const url = `/api/v1/bands/${bandId}/nc-scan/stream`; try { - const resp = await fetch(url); + // credentials: "include" sends the rh_token httpOnly cookie automatically + const resp = await fetch(url, { credentials: "include" }); if (!resp.ok || !resp.body) { const text = await resp.text().catch(() => resp.statusText); throw new Error(text || `HTTP ${resp.status}`); diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 73652ef..ab6a9ee 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { listBands, createBand } from "../api/bands"; -import { clearToken } from "../api/client"; +import { logout } from "../api/auth"; export function HomePage() { const navigate = useNavigate(); @@ -29,8 +29,7 @@ export function HomePage() { }); function handleSignOut() { - clearToken(); - navigate("/login"); + logout(); } const inputStyle: React.CSSProperties = { diff --git a/web/src/pages/InvitePage.tsx b/web/src/pages/InvitePage.tsx index 134fcb2..186e328 100644 --- a/web/src/pages/InvitePage.tsx +++ b/web/src/pages/InvitePage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { api } from "../api/client"; +import { api, isLoggedIn } from "../api/client"; interface InviteInfo { id: string; @@ -19,7 +19,7 @@ export function InvitePage() { const [accepting, setAccepting] = useState(false); const [done, setDone] = useState(false); - const isLoggedIn = !!localStorage.getItem("rh_token"); + const loggedIn = isLoggedIn(); useEffect(() => { if (!token) return; @@ -74,7 +74,7 @@ export function InvitePage() { {invite.used_at && " · Already used"}

- {isLoggedIn ? ( + {loggedIn ? (