feat: add JWT refresh token support with automatic token renewal and session expiration handling
Add /auth/refresh endpoint to issue new access tokens using refresh tokens with token type validation and user activity checks, implement automatic token refresh on 401 responses with single retry logic in frontend API client, add authorizedFetch helper for non-JSON endpoints with refresh support, store both access and refresh tokens in localStorage with clearTokens cleanup helper, add nexafabric.authExpired event
This commit is contained in:
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import CurrentUser
|
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.db.session import get_db
|
||||||
from app.models.domain import User
|
from app.models.domain import User
|
||||||
from app.schemas.domain import LoginRequest, TokenPair, UserRead
|
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)
|
@router.get("/me", response_model=UserRead)
|
||||||
def me(user: CurrentUser) -> User:
|
def me(user: CurrentUser) -> User:
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -180,11 +180,41 @@ export function token() {
|
|||||||
return localStorage.getItem("nexafabric.token");
|
return localStorage.getItem("nexafabric.token");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(value: string) {
|
export function refreshToken() {
|
||||||
localStorage.setItem("nexafabric.token", value);
|
return localStorage.getItem("nexafabric.refreshToken");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
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<T>(path: string, init: RequestInit, retry: boolean): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -193,12 +223,23 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
...init.headers,
|
...init.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (response.status === 401 && retry && (await refreshAccessToken())) {
|
||||||
|
return request<T>(path, init, false);
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearTokens();
|
||||||
|
window.dispatchEvent(new Event("nexafabric.authExpired"));
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await response.text());
|
throw new Error(await response.text());
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
return request<T>(path, init, true);
|
||||||
|
}
|
||||||
|
|
||||||
export async function publicApi<T>(path: string, init: RequestInit = {}): Promise<T> {
|
export async function publicApi<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
@@ -213,11 +254,31 @@ export async function publicApi<T>(path: string, init: RequestInit = {}): Promis
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
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",
|
method: "POST",
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
setToken(data.access_token);
|
setTokens(data.access_token, data.refresh_token);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export function Layout() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token()) navigate("/login");
|
if (!token()) navigate("/login");
|
||||||
|
function handleAuthExpired() {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
window.addEventListener("nexafabric.authExpired", handleAuthExpired);
|
||||||
|
return () => window.removeEventListener("nexafabric.authExpired", handleAuthExpired);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { FormEvent, useState } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Database, Download, Plus } from "lucide-react";
|
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 { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
@@ -49,9 +49,7 @@ export function Ipam() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exportCsv() {
|
async function exportCsv() {
|
||||||
const response = await fetch("/api/v1/ipam/export.csv", {
|
const response = await authorizedFetch("/ipam/export.csv");
|
||||||
headers: token() ? { Authorization: `Bearer ${token()}` } : {},
|
|
||||||
});
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|||||||
Reference in New Issue
Block a user