diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index e5784a1..c2dc4bc 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -5,7 +5,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.api.deps import CurrentUser -from app.core.security import create_access_token, create_refresh_token, verify_password +from app.core.security import create_access_token, create_refresh_token, decode_token, verify_password from app.db.session import get_db from app.models.domain import User from app.schemas.domain import LoginRequest, TokenPair, UserRead @@ -33,6 +33,27 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenPair: ) +@router.post("/refresh", response_model=TokenPair) +def refresh(payload: dict[str, str], db: Session = Depends(get_db)) -> TokenPair: + token = payload.get("refresh_token") + if not token: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token") + try: + claims = decode_token(token) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc + if claims.get("typ") != "refresh": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") + user = db.scalar(select(User).where(User.id == claims.get("sub"), User.is_active.is_(True))) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user") + permissions = sorted({permission for role in user.roles for permission in role.permissions}) + return TokenPair( + access_token=create_access_token(user.id, permissions), + refresh_token=create_refresh_token(user.id), + ) + + @router.get("/me", response_model=UserRead) def me(user: CurrentUser) -> User: return user diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 094807b..1c47999 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -180,11 +180,41 @@ export function token() { return localStorage.getItem("nexafabric.token"); } -export function setToken(value: string) { - localStorage.setItem("nexafabric.token", value); +export function refreshToken() { + return localStorage.getItem("nexafabric.refreshToken"); } -export async function api(path: string, init: RequestInit = {}): Promise { +export function setTokens(accessToken: string, nextRefreshToken: string) { + localStorage.setItem("nexafabric.token", accessToken); + localStorage.setItem("nexafabric.refreshToken", nextRefreshToken); +} + +export function clearTokens() { + localStorage.removeItem("nexafabric.token"); + localStorage.removeItem("nexafabric.refreshToken"); +} + +async function refreshAccessToken() { + const currentRefreshToken = refreshToken(); + if (!currentRefreshToken) { + clearTokens(); + return false; + } + const response = await fetch(`${API_BASE_URL}/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: currentRefreshToken }), + }); + if (!response.ok) { + clearTokens(); + return false; + } + const data = (await response.json()) as { access_token: string; refresh_token: string }; + setTokens(data.access_token, data.refresh_token); + return true; +} + +async function request(path: string, init: RequestInit, retry: boolean): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, headers: { @@ -193,12 +223,23 @@ export async function api(path: string, init: RequestInit = {}): Promise { ...init.headers, }, }); + if (response.status === 401 && retry && (await refreshAccessToken())) { + return request(path, init, false); + } + if (response.status === 401) { + clearTokens(); + window.dispatchEvent(new Event("nexafabric.authExpired")); + } if (!response.ok) { throw new Error(await response.text()); } return response.json() as Promise; } +export async function api(path: string, init: RequestInit = {}): Promise { + return request(path, init, true); +} + export async function publicApi(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, @@ -213,11 +254,31 @@ export async function publicApi(path: string, init: RequestInit = {}): Promis return response.json() as Promise; } +export async function authorizedFetch(path: string, init: RequestInit = {}) { + const makeRequest = () => + fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + ...(token() ? { Authorization: `Bearer ${token()}` } : {}), + ...init.headers, + }, + }); + let response = await makeRequest(); + if (response.status === 401 && (await refreshAccessToken())) { + response = await makeRequest(); + } + if (response.status === 401) { + clearTokens(); + window.dispatchEvent(new Event("nexafabric.authExpired")); + } + return response; +} + export async function login(email: string, password: string) { - const data = await api<{ access_token: string }>("/auth/login", { + const data = await publicApi<{ access_token: string; refresh_token: string }>("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }); - setToken(data.access_token); + setTokens(data.access_token, data.refresh_token); return data; } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 08aa0ba..240c35d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -49,6 +49,11 @@ export function Layout() { useEffect(() => { if (!token()) navigate("/login"); + function handleAuthExpired() { + navigate("/login"); + } + window.addEventListener("nexafabric.authExpired", handleAuthExpired); + return () => window.removeEventListener("nexafabric.authExpired", handleAuthExpired); }, [navigate]); return ( diff --git a/frontend/src/pages/Ipam.tsx b/frontend/src/pages/Ipam.tsx index 45c7cd2..bbab6fe 100644 --- a/frontend/src/pages/Ipam.tsx +++ b/frontend/src/pages/Ipam.tsx @@ -2,7 +2,7 @@ import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Database, Download, Plus } from "lucide-react"; -import { api, IpAddress, Network, Subnet, token } from "../api/client"; +import { api, authorizedFetch, IpAddress, Network, Subnet } from "../api/client"; import { DataTable } from "../components/DataTable"; import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; import { Modal } from "../components/Modal"; @@ -49,9 +49,7 @@ export function Ipam() { } async function exportCsv() { - const response = await fetch("/api/v1/ipam/export.csv", { - headers: token() ? { Authorization: `Bearer ${token()}` } : {}, - }); + const response = await authorizedFetch("/ipam/export.csv"); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a");