From a9bda1989198385f08ca43299941d0da4fc385ba Mon Sep 17 00:00:00 2001 From: Kai Ritthaler Date: Thu, 19 Jun 2025 22:22:28 +0200 Subject: [PATCH] add refresh token endpoints --- code/backend/prisma/schema.prisma | 9 +- code/backend/scripts/install.json | 1 + code/backend/scripts/install.ts | 4 +- .../backend/src/controllers/userController.ts | 160 ++++++++++++++++-- .../src/middleware/authenticateToken.ts | 10 +- code/backend/src/routes/postRoutes.ts | 14 +- code/backend/src/routes/userRoutes.ts | 58 ++++++- code/backend/src/types/tokens.ts | 14 ++ 8 files changed, 235 insertions(+), 35 deletions(-) create mode 100644 code/backend/src/types/tokens.ts diff --git a/code/backend/prisma/schema.prisma b/code/backend/prisma/schema.prisma index fe34a68..39d8f4f 100644 --- a/code/backend/prisma/schema.prisma +++ b/code/backend/prisma/schema.prisma @@ -25,8 +25,9 @@ model User { posts Post[] comments Comment[] likes Like[] - media Media[] @relation("UploadedMedia") + refreshToken RefreshToken[] + media Media[] @relation("UploadedMedia") following Follow[] @relation("Following") followers Follow[] @relation("Followers") } @@ -124,4 +125,10 @@ model Follow { followedUser User @relation("Followers", fields: [followedUserId], references: [id]) @@id([followingUserId, followedUserId]) +} +model RefreshToken { + id String @id @default(uuid()) + expiresAt DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String } \ No newline at end of file diff --git a/code/backend/scripts/install.json b/code/backend/scripts/install.json index ea48dc0..ec010cf 100644 --- a/code/backend/scripts/install.json +++ b/code/backend/scripts/install.json @@ -9,6 +9,7 @@ "requiredKeys": [ { "name": "DATABASE_URL", "generated": true }, { "name": "TOKEN_SECRET", "generated": true }, + { "name": "REFRESH_TOKEN_SECRET", "generated": true }, { "name": "DB_USER", "generated": false, diff --git a/code/backend/scripts/install.ts b/code/backend/scripts/install.ts index 4dd10e7..271d445 100644 --- a/code/backend/scripts/install.ts +++ b/code/backend/scripts/install.ts @@ -54,9 +54,9 @@ if (fs.existsSync(".env")) { ); } while (!input || input.length < setting.minLength); process.env[setting.name] = input; - } else if (setting.name === "TOKEN_SECRET") { + } else if (setting.name === "TOKEN_SECRET" || setting.name === "REFRESH_TOKEN_SECRET") { // generating a random JWT secret - const jwtSecret: string = crypto.randomBytes(32).toString("hex"); // 64 Zeichen + const jwtSecret: string = crypto.randomBytes(32).toString("hex"); // 64 character process.env[setting.name] = jwtSecret; } } diff --git a/code/backend/src/controllers/userController.ts b/code/backend/src/controllers/userController.ts index 327d121..0f22d40 100644 --- a/code/backend/src/controllers/userController.ts +++ b/code/backend/src/controllers/userController.ts @@ -1,24 +1,63 @@ import express, { Request, Response } from "express"; -import { PrismaClient, User } from "../../prisma/app/generated/prisma/client"; +import { + PrismaClient, + User, + RefreshToken, +} from "../../prisma/app/generated/prisma/client"; import { UserLoginDto, UserRegistrationDto } from "../schemas/userSchemas"; -import jwt from "jsonwebtoken"; +import jwt, { TokenExpiredError } from "jsonwebtoken"; import dotenv from "dotenv"; import bcrypt from "bcryptjs"; import { StatusCodes } from "http-status-codes"; - +import { RefreshTokenPayload } from "../types/tokens"; const app = express(); app.use(express.json()); const prisma = new PrismaClient(); // load environment variables from .env file dotenv.config(); const JWT_SECRET: string = process.env.TOKEN_SECRET!; // this secret is used to sign the JWT token +const REFRESH_TOKEN_SECRET: string = process.env.REFRESH_TOKEN_SECRET!; // Generate a JWT token with the username as payload and a secret from the environment variables which expires in 1800 seconds (30 minutes) -function generateAccessToken(username: string, userId: string, role: string) { - return jwt.sign({ username: username, role: role, sub: userId }, JWT_SECRET, { - expiresIn: "1800s", - issuer: "VogelApi", - }); +function generateAccessToken( + username: string, + userId: string, + role: string, + refreshTokenId: string +) { + return jwt.sign( + { username: username, role: role, sub: userId, jti: refreshTokenId }, + JWT_SECRET, + { + expiresIn: "1800s", + issuer: "VogelApi", + } + ); +} + +async function generateRefreshToken( + userId: string +): Promise<{ token: string; id: string }> { + const expiresAt = new Date(Date.now() + 100 * 60 * 60 * 1000); // 100 h + let refreshToken: RefreshToken; + { + refreshToken = await prisma.refreshToken.create({ + data: { + expiresAt: expiresAt, + user: { + connect: { + id: userId, + }, + }, + }, + }); + } + return { + token: jwt.sign({ jti: refreshToken.id }, REFRESH_TOKEN_SECRET, { + expiresIn: "100h", + }), + id: refreshToken.id, + }; } // Endpoint to register a new user @@ -78,7 +117,14 @@ export const registerUser = async (req: Request, res: Response) => { }); return; } - const token: string = generateAccessToken(user.username, user.id, user.role); // generate a JWT token with the username and userId as payload + const refreshToken = await generateRefreshToken(user.id); + res.set("Refresh-Token", refreshToken.token); + const token: string = generateAccessToken( + user.username, + user.id, + user.role, + refreshToken.id + ); // 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.CREATED).json({ message: "user created", @@ -113,7 +159,14 @@ export const loginUser = async (req: Request, res: Response) => { }); return; } - const token: string = generateAccessToken(user.username, user.id, user.role); // generate a JWT token with the username and userId as payload + const refreshToken = await generateRefreshToken(user.id); + res.set("Refresh-Token", refreshToken.token); + const token: string = generateAccessToken( + user.username, + user.id, + user.role, + refreshToken.id + ); // 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).json({ message: "User logged in successfully" }); }; @@ -150,3 +203,90 @@ export const getUser = async (req: Request, res: Response) => { }, }); }; +export const refreshToken = async (req: Request, res: Response) => { + const refreshToken: string | undefined = req.headers[ + "refresh-token" + ] as string; + if (!refreshToken) { + res.status(StatusCodes.UNAUTHORIZED).json({ + error: "Unauthorized", + details: [{ message: "No token provided" }], + }); + return; + } + + await jwt.verify( + refreshToken, + REFRESH_TOKEN_SECRET, + async (err: any, decoded: any) => { + if (err) { + if (err instanceof jwt.TokenExpiredError) { + res.status(StatusCodes.UNAUTHORIZED).json({ + error: "Refreshtoken expired", + details: [{ message: "Refreshtoken has expired" }], + }); + return; + } + res.status(StatusCodes.FORBIDDEN).json({ + error: "Invalid refreshtoken", + details: [{ message: "Refreshtoken is invalid" }], + }); + return; + } + + const payload = decoded as RefreshTokenPayload; + try { + const now = new Date(); + const storedToken = await prisma.refreshToken.findUnique({ + where: { + id: payload.jti, + expiresAt: { + gt: now, // expiresAt > now + }, + }, + include: { user: true }, + }); + if (!storedToken) { + res.status(StatusCodes.UNAUTHORIZED).json({ + error: "InvalidRefreshToken", + details: [ + { message: "Refresh token is invalid or no longer exists" }, + ], + }); + return; + } + await prisma.refreshToken.delete({ + where: { id: payload.jti }, + }); + const refreshToken = await generateRefreshToken(storedToken.user.id); + res.set("Refresh-Token", refreshToken.token); + const token: string = generateAccessToken( + storedToken.user.username, + storedToken.user.id, + storedToken.user.role, + refreshToken.id + ); // 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 { + res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: "Server error", + details: [{ message: "Server Error" }], + }); + return; + } + } + ); +}; + +export const logout = async (req: Request, res: Response) => { + const jti: string = req.query.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); + } +}; diff --git a/code/backend/src/middleware/authenticateToken.ts b/code/backend/src/middleware/authenticateToken.ts index 75064fd..37a4be5 100644 --- a/code/backend/src/middleware/authenticateToken.ts +++ b/code/backend/src/middleware/authenticateToken.ts @@ -2,20 +2,14 @@ import express, { NextFunction, Request, Response } from "express"; import jwt, { TokenExpiredError } from "jsonwebtoken"; import dotenv from "dotenv"; import { StatusCodes } from "http-status-codes"; +import { JwtPayload } from "../types/tokens"; dotenv.config(); // Import the JWT secret from environment variables const JWT_SECRET: string = process.env.TOKEN_SECRET!; if (!JWT_SECRET) console.error("No JWT secret provided"); -// Define the structure of the JWT payload -interface JwtPayload { - id: string; - username: string; - role: string; - iat: number; - exp: number; -} + // Extend the Express Request interface to include the user property declare global { diff --git a/code/backend/src/routes/postRoutes.ts b/code/backend/src/routes/postRoutes.ts index 344ff89..3609b70 100644 --- a/code/backend/src/routes/postRoutes.ts +++ b/code/backend/src/routes/postRoutes.ts @@ -8,7 +8,6 @@ import { upload } from "../middleware/fileUpload"; import { validateData } from "../middleware/validationMiddleware"; import { uploadPostSchema } from "../schemas/postSchemas"; -import { get } from "http"; const router = express.Router(); /** @@ -80,27 +79,20 @@ router.post("/upload", upload, validateData(uploadPostSchema), uploadPost); * 401: * description: not authenticated */ -router.get("/getPost/:userId", getPost); +router.get("/getPost/:postId", getPost); /** * @swagger - * /api/posts/getUserPosts/{userId}: + * /api/posts/getUserPosts/: * get: * summary: Get Post * tags: [posts] * security: * - bearerAuth: [] - * parameters: - * - in: query - * name: postId - * required: true - * schema: - * type: string - * description: The user id * responses: * 200: * description: Ok * 401: * description: not authenticated */ -router.get("/getuserposts/:userId", getUserPosts); +router.get("/getuserposts/", getUserPosts); export default router; diff --git a/code/backend/src/routes/userRoutes.ts b/code/backend/src/routes/userRoutes.ts index 3e0cd20..ed33dcd 100644 --- a/code/backend/src/routes/userRoutes.ts +++ b/code/backend/src/routes/userRoutes.ts @@ -6,7 +6,7 @@ import { userLoginSchema, } from "../schemas/userSchemas"; import { authenticateToken } from "../middleware/authenticateToken"; - +import { logout, refreshToken } from "../controllers/userController"; const userRouter = express.Router(); import { @@ -50,7 +50,7 @@ import { * /api/user/register: * post: * summary: Register a new user - * tags: [User] + * tags: [Auth] * requestBody: * required: true * content: @@ -73,7 +73,7 @@ userRouter.post( * /api/user/login: * post: * summary: Log in a user - * tags: [User] + * tags: [Auth] * requestBody: * required: true * content: @@ -109,5 +109,57 @@ userRouter.post("/login", validateData(userLoginSchema), loginUser); * description: Ungültige Anmeldedaten */ userRouter.get("/getUser/:username", authenticateToken(), getUser); +/** + * @swagger + * /api/user/refreshToken: + * get: + * summary: Refresh JWT tokens + * description: | + * Verifiziert einen bereitgestellten Refresh-Token (im Header) und gibt neue Tokens im Header zurück. + * tags: + * - Auth + * parameters: + * - in: header + * name: Refresh-Token + * required: true + * schema: + * type: string + * description: Der gültige JWT-Refresh-Token + * responses: + * 200: + * description: Tokens erfolgreich erneuert + * headers: + * Authorization: + * description: Neuer Access-Token im Bearer-Format + * schema: + * type: string + * Refresh-Token: + * description: Neuer Refresh-Token + * schema: + * type: string + * 401: + * description: Ungültiger oder abgelaufener Refresh-Token + * 403: + * description: Fehlerhafte Signatur oder ungültiger Token + * 500: + * description: Serverfehler + */ +userRouter.get("/refreshToken", refreshToken); + +/** + * @swagger + * /api/user/logout/: + * delete: + * summary: logout + * tags: [Auth] + * security: + * - bearerAuth: [] + * responses: + * 204: + * description: logged out + * 401: + * description: not authenticated + */ +userRouter.delete("/logout", authenticateToken(), logout); export default userRouter; diff --git a/code/backend/src/types/tokens.ts b/code/backend/src/types/tokens.ts new file mode 100644 index 0000000..ea79140 --- /dev/null +++ b/code/backend/src/types/tokens.ts @@ -0,0 +1,14 @@ +export interface JwtPayload { + id: string; + username: string; + role: string; + iat: number; + exp: number; + jti: string; +} + +export interface RefreshTokenPayload { + jti: string; + iat: number; + exp: number; +}