From dc02a2ae522c942b0f5351bcdce0ef9b3e3027fe Mon Sep 17 00:00:00 2001 From: Kai Ritthaler Date: Wed, 25 Jun 2025 07:56:30 +0200 Subject: [PATCH] tokens refresh when jwt is expired and added basic axios config --- .../backend/src/controllers/userController.ts | 28 +- code/backend/src/routes/userRoutes.ts | 2 +- code/backend/src/server.ts | 6 +- code/frontend/package.json | 4 + code/frontend/src/App.tsx | 37 +- code/frontend/src/api/Auth.tsx | 94 ++++ code/frontend/src/api/axios.ts | 67 +++ code/frontend/src/api/refreshToken.ts | 28 ++ code/frontend/src/components/Footer.tsx | 26 +- code/frontend/src/index.tsx | 12 +- code/frontend/src/logo.svg | 1 - .../frontend/src/pages/LoginAndSignUpPage.tsx | 24 +- code/frontend/src/setupTests.ts | 5 - code/frontend/yarn.lock | 421 ++++++++---------- yarn.lock | 4 - 15 files changed, 470 insertions(+), 289 deletions(-) create mode 100644 code/frontend/src/api/Auth.tsx create mode 100644 code/frontend/src/api/axios.ts create mode 100644 code/frontend/src/api/refreshToken.ts delete mode 100644 code/frontend/src/logo.svg delete mode 100644 code/frontend/src/setupTests.ts delete mode 100644 yarn.lock diff --git a/code/backend/src/controllers/userController.ts b/code/backend/src/controllers/userController.ts index 0f22d40..25fb4c0 100644 --- a/code/backend/src/controllers/userController.ts +++ b/code/backend/src/controllers/userController.ts @@ -173,7 +173,7 @@ export const loginUser = async (req: Request, res: Response) => { // Endpoint to get user data export const getUser = async (req: Request, res: Response) => { - const username: string = req.query.username as string; + const username: string = req.params.username as string; if (!username) { res.status(StatusCodes.BAD_REQUEST).json({ error: "no username", @@ -197,9 +197,8 @@ export const getUser = async (req: Request, res: Response) => { message: "User found", data: { username: user.username, - email: user.email, userId: user.id, - userInfo: user.bio, + bio: user.bio, }, }); }; @@ -255,9 +254,15 @@ export const refreshToken = async (req: Request, res: Response) => { }); return; } - await prisma.refreshToken.delete({ + const existingToken = await prisma.refreshToken.findUnique({ where: { id: payload.jti }, }); + + if (existingToken) { + await prisma.refreshToken.deleteMany({ + where: { id: payload.jti }, + }); + } const refreshToken = await generateRefreshToken(storedToken.user.id); res.set("Refresh-Token", refreshToken.token); const token: string = generateAccessToken( @@ -268,7 +273,8 @@ export const refreshToken = async (req: Request, res: Response) => { ); // generate a JWT token with the username and userId as payload res.set("Authorization", `Bearer ${token}`); // set the token in the response header res.status(StatusCodes.OK).send(); - } catch { + } catch (error) { + console.log(error); res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: "Server error", details: [{ message: "Server Error" }], @@ -280,13 +286,15 @@ export const refreshToken = async (req: Request, res: Response) => { }; export const logout = async (req: Request, res: Response) => { - const jti: string = req.query.jti as string; + const jti: string = req.user!.jti as string; try { await prisma.refreshToken.delete({ where: { id: jti } }); - res.removeHeader("Authorization"); - res.removeHeader("Refresh-Token"); res.status(StatusCodes.NO_CONTENT).send(); - } catch { - res.status(StatusCodes.INTERNAL_SERVER_ERROR); + } catch (err) { + console.log(err); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "Server error", + details: [{ message: "Server Error" }], + }); } }; diff --git a/code/backend/src/routes/userRoutes.ts b/code/backend/src/routes/userRoutes.ts index ed33dcd..b8ced4c 100644 --- a/code/backend/src/routes/userRoutes.ts +++ b/code/backend/src/routes/userRoutes.ts @@ -96,7 +96,7 @@ userRouter.post("/login", validateData(userLoginSchema), loginUser); * security: * - bearerAuth: [] * parameters: - * - in: query + * - in: path * name: username * required: true * schema: diff --git a/code/backend/src/server.ts b/code/backend/src/server.ts index 771421c..f73c516 100644 --- a/code/backend/src/server.ts +++ b/code/backend/src/server.ts @@ -14,12 +14,10 @@ app.use( cors({ origin: "http://localhost:3000", credentials: true, + exposedHeaders: ["Authorization", "Refresh-Token"], }) ); -app.use((req, res, next) => { - res.header("Access-Control-Expose-Headers", "Authorization"); - next(); -}); + // minIO config export const minioClient = new Client({ endPoint: "localhost", // Replace with your MinIO server URL diff --git a/code/frontend/package.json b/code/frontend/package.json index 4614b1e..70af0f3 100644 --- a/code/frontend/package.json +++ b/code/frontend/package.json @@ -16,6 +16,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "axios": "^1.10.0", + "jwt-decode": "^4.0.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.2", @@ -47,5 +48,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@types/jwt-decode": "^3.1.0" } } diff --git a/code/frontend/src/App.tsx b/code/frontend/src/App.tsx index 7d5388a..5b91846 100644 --- a/code/frontend/src/App.tsx +++ b/code/frontend/src/App.tsx @@ -7,26 +7,29 @@ import Footer from "./components/Footer"; import Header from "./components/header"; import Profile from "./pages/Profile"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import { Auth } from "./api/Auth"; function App() { return ( - -
-
- - } - > - } - > - }> - -
-
-
+ + +
+
+ + } + > + } + > + }> + +
+
+
+
); } diff --git a/code/frontend/src/api/Auth.tsx b/code/frontend/src/api/Auth.tsx new file mode 100644 index 0000000..e5da5b7 --- /dev/null +++ b/code/frontend/src/api/Auth.tsx @@ -0,0 +1,94 @@ +import { + createContext, + useContext, + useState, + ReactNode, + FC, + useEffect, +} from "react"; +import api from "./axios"; +import { jwtDecode } from "jwt-decode"; +import { redirectToLogin } from "./axios"; +import { refreshToken } from "./refreshToken"; + +type User = { + id: string; + username: string; + role: string; +}; + +type AuthContextType = { + user: User | null; + logout: () => Promise; + setUserState: (tryRefresh?: boolean) => Promise; +}; +type JwtPayload = { + username: string; + role: string; + sub: string; + jti: string; + iat: number; + exp: number; + iss: string; +}; + +const AuthContext = createContext(null); + +type AuthProviderProps = { + children: ReactNode; +}; + +export const Auth: FC = ({ children }) => { + const [user, setUser] = useState(null); + useEffect(() => { + setUserState(); + }, []); + + const setUserState = async (tryRefresh = true) => { + try { + const token = localStorage.getItem("token"); + if (!token) return; + const now = Date.now() / 1000; + const decoded = jwtDecode(token); + + if (decoded.exp > now) { + setUser({ + id: decoded.sub, + username: decoded.username, + role: decoded.role, + }); + } else if (tryRefresh) { + await refreshToken(); + await setUserState(false); + } + } catch { + console.log("Error while reading and refreshing user info"); // this error should only appear if the app is run in strictMode + return; + } + }; + + const logout = async () => { + try { + await api.delete("/user/logout"); + } catch { + console.log("Logout error"); + } + localStorage.clear(); + setUser(null); + redirectToLogin(false); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = (): AuthContextType => { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +}; diff --git a/code/frontend/src/api/axios.ts b/code/frontend/src/api/axios.ts new file mode 100644 index 0000000..ab0e789 --- /dev/null +++ b/code/frontend/src/api/axios.ts @@ -0,0 +1,67 @@ +import axios from "axios"; +import { refreshToken } from "./refreshToken"; + +const excludedUrls: string[] = ["/user/login", "/user/regiser"]; + +const api = axios.create({ + baseURL: "http://localhost:3001/api", + withCredentials: true, +}); + +// get token from local storage +const getAccessToken = () => localStorage.getItem("token"); +const getRefreshToken = () => localStorage.getItem("refreshToken"); + +//redirects the page to the login and back +export const redirectToLogin = (returnToPage = true) => { + if (returnToPage) { + const returnTo = window.location.pathname + window.location.search; + window.location.href = `/login?returnTo=${encodeURIComponent(returnTo)}`; + } else { + window.location.href = "/login"; + } +}; + +// Request interceptor add token +api.interceptors.request.use((config) => { + const token = getAccessToken(); + if (token && config.headers) { + config.headers["Authorization"] = `Bearer ${token}`; + } + return config; +}); + +// retry with new token +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + const isExcluded = excludedUrls.some((url) => + originalRequest.url?.includes(url) + ); + + if ( + error.response?.status === 401 && + !originalRequest._retry && + !isExcluded + ) { + await refreshToken(); + originalRequest._retry = true; + return api(originalRequest); + } + + if ( + error.response?.status === 401 && + originalRequest._retry && + !isExcluded + ) { + localStorage.removeItem("token"); + localStorage.removeItem("refreshToken"); + redirectToLogin(); + } + + return Promise.reject(error); + } +); + +export default api; diff --git a/code/frontend/src/api/refreshToken.ts b/code/frontend/src/api/refreshToken.ts new file mode 100644 index 0000000..6f3a30c --- /dev/null +++ b/code/frontend/src/api/refreshToken.ts @@ -0,0 +1,28 @@ +import axios from "axios"; +const getRefreshToken = () => localStorage.getItem("refreshToken"); + +export const refreshToken = async () => { + const token = getRefreshToken(); + + if (!token) { + throw new Error("No refresh token available"); + } + const response = await axios.get( + "http://localhost:3001/api/user/refreshToken", + { + headers: { + "Refresh-Token": getRefreshToken(), + }, + withCredentials: true, + } + ); + const authHeader = response.headers["authorization"]; + if (authHeader && authHeader.startsWith("Bearer ")) { + const token = authHeader.substring(7); + localStorage.setItem("token", token); + } + const refreshToken = response.headers["refresh-token"]; + if (refreshToken) { + localStorage.setItem("refreshToken", refreshToken); + } +}; diff --git a/code/frontend/src/components/Footer.tsx b/code/frontend/src/components/Footer.tsx index 814aad2..072bb4c 100644 --- a/code/frontend/src/components/Footer.tsx +++ b/code/frontend/src/components/Footer.tsx @@ -1,6 +1,10 @@ import "./footer.css"; import { Link } from "react-router-dom"; +import { useAuth } from "../api/Auth"; function Footer() { + const { user } = useAuth(); + const { logout } = useAuth(); + return (