feat: implement API security measures phase 1

This commit is contained in:
Marko (Hermes Implementer)
2026-05-27 00:24:09 +00:00
parent a0b3ae7e34
commit c2fc7bc15f
21 changed files with 1050 additions and 44 deletions
+80 -6
View File
@@ -1,23 +1,97 @@
import { type ReactNode } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import AppLayout from "./components/AppLayout";
import HomePage from "./pages/HomePage";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
function ProtectedRoute({ children }: { children: ReactNode }): ReactNode {
const { state } = useAuth();
const location = useLocation();
if (state.isInitializing) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
color: "#888",
}}
>
Loading...
</div>
);
}
if (!state.isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
function PublicRoute({ children }: { children: ReactNode }): ReactNode {
const { state } = useAuth();
if (state.isInitializing) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
color: "#888",
}}
>
Loading...
</div>
);
}
if (state.isAuthenticated) {
return <Navigate to="/" replace />;
}
return children;
}
export default function App(): ReactNode {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<RegisterPage />
</PublicRoute>
}
/>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
index
element={
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
}
/>
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
}
+17 -2
View File
@@ -1,12 +1,14 @@
import { type ReactNode } from "react";
import { Outlet } from "react-router-dom";
import { Outlet, useNavigate } from "react-router-dom";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import Container from "@mui/material/Container";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { useAuth } from "../contexts/AuthContext";
const theme = createTheme({
palette: {
@@ -20,15 +22,28 @@ const theme = createTheme({
});
export default function AppLayout(): ReactNode {
const { state, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate("/login");
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
<AppBar position="sticky">
<Toolbar>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700, flexGrow: 1 }}>
Job Tracker
</Typography>
{state.isAuthenticated && (
<Button color="inherit" onClick={handleLogout}>
Sign Out
</Button>
)}
</Toolbar>
</AppBar>
<Container component="main" maxWidth="lg" sx={{ mt: 4, mb: 4, flexGrow: 1 }}>
+53 -9
View File
@@ -3,12 +3,18 @@ import {
useContext,
useReducer,
useCallback,
useEffect,
type ReactNode,
type Dispatch,
} from "react";
import {
loginUser,
registerUser,
getProfile,
logoutUser,
setTokens,
clearTokens,
refreshAccessToken,
type UserProfile,
type LoginPayload,
type RegisterPayload,
@@ -19,6 +25,7 @@ interface AuthState {
user: UserProfile | null;
isAuthenticated: boolean;
isLoading: boolean;
isInitializing: boolean;
error: string | null;
}
@@ -26,6 +33,7 @@ const initialState: AuthState = {
user: null,
isAuthenticated: false,
isLoading: false,
isInitializing: true,
error: null,
};
@@ -35,6 +43,7 @@ type AuthAction =
| { type: "AUTH_SUCCESS"; payload: UserProfile }
| { type: "AUTH_FAILURE"; payload: string }
| { type: "LOGOUT" }
| { type: "INIT_COMPLETE" }
| { type: "CLEAR_ERROR" };
function authReducer(state: AuthState, action: AuthAction): AuthState {
@@ -45,6 +54,7 @@ function authReducer(state: AuthState, action: AuthAction): AuthState {
return {
...state,
isLoading: false,
isInitializing: false,
isAuthenticated: true,
user: action.payload,
error: null,
@@ -56,7 +66,9 @@ function authReducer(state: AuthState, action: AuthAction): AuthState {
error: action.payload,
};
case "LOGOUT":
return { ...initialState };
return { ...initialState, isInitializing: false };
case "INIT_COMPLETE":
return { ...state, isInitializing: false };
case "CLEAR_ERROR":
return { ...state, error: null };
default:
@@ -70,7 +82,7 @@ interface AuthContextValue {
dispatch: Dispatch<AuthAction>;
login: (payload: LoginPayload) => Promise<void>;
register: (payload: RegisterPayload) => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
clearError: () => void;
}
@@ -80,12 +92,38 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(authReducer, initialState);
// On mount: try to refresh the access token and fetch user profile
useEffect(() => {
const initAuth = async () => {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
dispatch({ type: "INIT_COMPLETE" });
return;
}
try {
// Try refreshing the access token first
const tokens = await refreshAccessToken(refreshToken);
setTokens(tokens.access, tokens.refresh);
// Fetch user profile with the fresh token
const profile = await getProfile();
dispatch({ type: "AUTH_SUCCESS", payload: profile });
} catch {
// Token invalid or expired — clear everything
clearTokens();
dispatch({ type: "LOGOUT" });
}
};
initAuth();
}, []);
const login = useCallback(async (payload: LoginPayload) => {
dispatch({ type: "AUTH_START" });
try {
const response = await loginUser(payload);
localStorage.setItem("access_token", response.access);
localStorage.setItem("refresh_token", response.refresh);
setTokens(response.access, response.refresh);
dispatch({ type: "AUTH_SUCCESS", payload: response.user });
} catch (err: unknown) {
const message =
@@ -100,7 +138,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
await registerUser(payload);
// Registration succeeded — the page component handles redirect to /login.
// Set isLoading=false by clearing auth state (no auto-authentication).
dispatch({ type: "LOGOUT" });
} catch (err: unknown) {
const message =
@@ -112,9 +149,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, []);
const logout = useCallback(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
const logout = useCallback(async () => {
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
try {
await logoutUser(refreshToken);
} catch {
// Even if the server request fails, clear local state
}
}
clearTokens();
dispatch({ type: "LOGOUT" });
}, []);
@@ -138,4 +182,4 @@ export function useAuth(): AuthContextValue {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
}
+13 -2
View File
@@ -11,9 +11,20 @@ interface UseDashboardDataResult {
const API_BASE = "/api";
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
const token = localStorage.getItem("access_token");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const body = await response.json().catch(() => ({}));
throw new Error(
(body as { error?: string }).error ||
`HTTP ${response.status}: ${response.statusText}`
);
}
return response.json() as Promise<T>;
}
+126 -1
View File
@@ -16,6 +16,97 @@ apiClient.interceptors.request.use((config) => {
return config;
});
// Response interceptor: auto-refresh on 401, retry once
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (err: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null = null): void {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else if (token) {
prom.resolve(token);
}
});
failedQueue = [];
}
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Only handle 401s that aren't already refresh/login/register attempts
if (
error.response?.status !== 401 ||
originalRequest._retry ||
originalRequest.url?.includes("/api/auth/token/refresh/") ||
originalRequest.url?.includes("/api/auth/login/") ||
originalRequest.url?.includes("/api/auth/register/") ||
originalRequest.url?.includes("/api/auth/logout/")
) {
return Promise.reject(error);
}
if (isRefreshing) {
// Queue this request until the refresh completes
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
isRefreshing = false;
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
// Redirect to login
window.location.href = "/login";
return Promise.reject(error);
}
try {
const response = await axios.post(
`${
import.meta.env.VITE_API_URL || "http://localhost:8000"
}/api/auth/token/refresh/`,
{ refresh: refreshToken }
);
const newAccessToken = response.data.access;
const newRefreshToken = response.data.refresh;
localStorage.setItem("access_token", newAccessToken);
localStorage.setItem("refresh_token", newRefreshToken);
processQueue(null, newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
// ── Types ──────────────────────────────────────────────────────────────
export interface RegisterPayload {
email: string;
password: string;
@@ -31,7 +122,6 @@ export interface LoginPayload {
export interface UserProfile {
id: number;
email: string;
first_name: string;
last_name: string;
}
@@ -42,6 +132,8 @@ export interface LoginResponse {
refresh: string;
}
// ── API Functions ──────────────────────────────────────────────────────
export function registerUser(payload: RegisterPayload): Promise<UserProfile> {
return apiClient
.post<UserProfile>("/api/auth/register/", payload)
@@ -53,3 +145,36 @@ export function loginUser(payload: LoginPayload): Promise<LoginResponse> {
.post<LoginResponse>("/api/auth/login/", payload)
.then((res) => res.data);
}
export function getProfile(): Promise<UserProfile> {
return apiClient
.get<UserProfile>("/api/auth/me/")
.then((res) => res.data);
}
export function logoutUser(refreshToken: string): Promise<void> {
return apiClient
.post("/api/auth/logout/", { refresh: refreshToken })
.then(() => {});
}
export function refreshAccessToken(
refreshToken: string
): Promise<{ access: string; refresh: string }> {
return apiClient
.post<{ access: string; refresh: string }>(
"/api/auth/token/refresh/",
{ refresh: refreshToken }
)
.then((res) => res.data);
}
export function setTokens(access: string, refresh: string): void {
localStorage.setItem("access_token", access);
localStorage.setItem("refresh_token", refresh);
}
export function clearTokens(): void {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
}