diff --git a/bruno/recipe-backend/createUser.bru b/bruno/recipe-backend/createUser.bru index f0291fa..a23626d 100644 --- a/bruno/recipe-backend/createUser.bru +++ b/bruno/recipe-backend/createUser.bru @@ -7,11 +7,7 @@ meta { post { url: http://localhost:4000/user body: json - auth: inherit -} - -headers { - Authorization: bearcer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjU2NGE5NjY0LTI2ZWYtNGMxMS1hNjIyLWU4MDI2MzczYmRkZCIsImlhdCI6MTc1ODc0MTM5MywiZXhwIjoxNzU4ODI3NzkzfQ.q33R9FfhGUIn92PTIIAmKmUnGxcLlv6om7KwiDD61Rc + auth: bearer } body:json { diff --git a/bruno/recipe-backend/me.bru b/bruno/recipe-backend/me.bru new file mode 100644 index 0000000..1df9c43 --- /dev/null +++ b/bruno/recipe-backend/me.bru @@ -0,0 +1,19 @@ +meta { + name: me + type: http + seq: 3 +} + +get { + url: http://localhost:4000/user/me + body: none + auth: bearer +} + +auth:bearer { + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjU2NGE5NjY0LTI2ZWYtNGMxMS1hNjIyLWU4MDI2MzczYmRkZCIsImlhdCI6MTc1ODk1MTI4NSwiZXhwIjoxNzU5MDM3Njg1fQ.FeuMlAurJFAhBPyvJFx3MX64WHB0_sa5pldkbQylUOw +} + +settings { + encodeUrl: true +} diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 8ea00ba..15664a6 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -5,12 +5,21 @@ import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; import { LoginResponseDto } from "../dtos/LoginResponseDto.js"; import { LoginRequestDto } from "../dtos/LoginRequestDto.js"; +/** + * Controller responsible for authentication, e.g., login or issueing a token with extended + * lifetime + */ export class AuthController { constructor( private userRepository: UserRepository, private mapper: UserDtoEntityMapper ) {} + /** + * Login: Check user and password and generate token + * @param loginRequest LoginRequestDto containing userName and password for login + * @returns LoginResponse containing token and user data for the user who just logged in + */ async login(loginRequest : LoginRequestDto): Promise { const userName :string|undefined = loginRequest.userName; const password :string|undefined = loginRequest.password; @@ -25,6 +34,11 @@ export class AuthController { if(!user){ throw new UnauthorizedError("Invalid username or password"); } + // ensure user has an id - required to generate token + const userId = user.id; + if(user.id == undefined){ + throw new UnauthorizedError("Invalid username or password"); + } // Compare password const passwordMatches = encrypt.comparepassword(password, user.password); if (!passwordMatches) { @@ -33,7 +47,7 @@ export class AuthController { // Create JWT const tokenInfo = encrypt.generateToken({ - id: user.id, + id: userId!, // ! to indicate that we've definitely checked for userId being defined }); const responseDto = new LoginResponseDto(); diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts index 1f074c9..68ad0ef 100644 --- a/src/controllers/UserController.ts +++ b/src/controllers/UserController.ts @@ -1,18 +1,26 @@ -import { ValidationError, ConflictError } from "../errors/httpErrors.js"; +import { ValidationError, ConflictError, NotFoundError } from "../errors/httpErrors.js"; import { CreateUserRequestDto } from "../dtos/CreateUserRequestDto.js"; import { UserDto } from "../dtos/UserDto.js"; import { encrypt } from "../utils/encryptionUtils.js"; import { UserRepository } from "../repositories/UserRepository.js"; import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; +import { UUID } from "crypto"; +/** + * Controls all user specific actions + */ export class UserController { constructor( private userRepository: UserRepository, private mapper: UserDtoEntityMapper ) {} + /** + * Create a new user + * @param dto CreateUserRequestDto containing data for the user to add + * @returns UserDto Data of the user as stored in the database + */ async createUser(dto: CreateUserRequestDto): Promise { - // @todo make authorized! Create initial user! // check mandatory fields if(!dto.userData){ throw new ValidationError("User data is required") @@ -43,4 +51,21 @@ export class UserController { return this.mapper.toDto(savedUser); } + + /** + * Load data of a specific user + * @param userId Id of user to load + * @returns UserDto containing the user's data + */ + async getUserById(userId: UUID|string|undefined): Promise { + if(!userId){ + throw new ValidationError("userId is required"); + } + + const userEntity = await this.userRepository.findById(userId); + if(!userEntity){ + throw new NotFoundError("user with id" + userId + "not found!") + } + return this.mapper.toDto(userEntity); + } } diff --git a/src/dtos/AuthPayload.ts b/src/dtos/AuthPayload.ts new file mode 100644 index 0000000..7b8c14a --- /dev/null +++ b/src/dtos/AuthPayload.ts @@ -0,0 +1,9 @@ +import { JwtPayload } from "jsonwebtoken"; + +/** + * Shape of the payload stored inside JWT tokens. + * We extend JwtPayload so "iat" / "exp" etc. remain available. + */ +export interface AuthPayload extends JwtPayload { + id: string; +} diff --git a/src/endpoints/UserPoint.ts b/src/endpoints/UserPoint.ts index 698ea58..edf9167 100644 --- a/src/endpoints/UserPoint.ts +++ b/src/endpoints/UserPoint.ts @@ -3,13 +3,11 @@ import { UserController } from "../controllers/UserController.js"; import { CreateUserRequestDto } from "../dtos/CreateUserRequestDto.js"; import { UserRepository } from "../repositories/UserRepository.js"; import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; -import { - ValidationError, - ConflictError, - NotFoundError, - InternalServerError, -} from "../errors/httpErrors.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; +/** + * Handles all user related routes + */ const router = Router(); // Inject repo + mapper here @@ -17,22 +15,31 @@ const userRepository = new UserRepository(); const userMapper = new UserDtoEntityMapper(); const userController = new UserController(userRepository, userMapper); -router.post("/", async (req, res) => { - try { +/** + * Create a new user + */ +router.post( + "/", + asyncHandler(async (req, res) => { const requestDto: CreateUserRequestDto = req.body; const responseDto = await userController.createUser(requestDto); res.status(201).json(responseDto); - } catch (err: any) { - if (err instanceof ValidationError || - err instanceof ConflictError || - err instanceof NotFoundError) { - res.status(err.statusCode).json({ error: err.message }); - } else { - console.error("Unexpected error:", err); - const internalError = new InternalServerError("Some unexpected error occurred!"); - res.status(internalError.statusCode).json({ error: internalError.message }); + }) +); + +/** + * Get user data for current user + */ +router.get("/me", + asyncHandler(async (req, res) => { + + const id = req.currentUser?.id; + if(id){ + // it breaks here because id is no longer a uuid + const responseDto = await userController.getUserById(id); + res.status(201).json(responseDto); } - } -}); + }) +); export default router; diff --git a/src/errors/httpErrors.ts b/src/errors/httpErrors.ts index 4a34361..1f0fe4c 100644 --- a/src/errors/httpErrors.ts +++ b/src/errors/httpErrors.ts @@ -1,39 +1,46 @@ -export class ValidationError extends Error { - statusCode = 400; - constructor(message: string) { +/** + * Base class for all HTTP-related errors. + * Extends the built-in Error with a status code and message + * so that they can be handled in a consistent way. + */ +export class HttpError extends Error { + public statusCode: number; + + constructor(message: string, statusCode: number) { super(message); - this.name = "ValidationError"; + this.statusCode = statusCode; + + // Restore prototype chain (important when targeting ES5/ES2015+) + Object.setPrototypeOf(this, new.target.prototype); } } -export class NotFoundError extends Error { - statusCode = 404; +export class ValidationError extends HttpError { constructor(message: string) { - super(message); - this.name = "NotFoundError"; + super(message, 400); // Bad Request } } -export class ConflictError extends Error { - statusCode = 409; - constructor(message: string) { - super(message); - this.name = "ConflictError"; +export class UnauthorizedError extends HttpError { + constructor(message: string = "Unauthorized") { + super(message, 401); } } -export class InternalServerError extends Error { - statusCode = 500; - constructor(message: string) { - super(message); - this.name = "InternalServerError"; +export class NotFoundError extends HttpError { + constructor(message: string = "Resource not found") { + super(message, 404); } } -export class UnauthorizedError extends Error { - statusCode = 409; - constructor(message: string) { - super(message); - this.name = "UnauthorizedError"; +export class ConflictError extends HttpError { + constructor(message: string = "Conflict") { + super(message, 409); } -} \ No newline at end of file +} + +export class InternalServerError extends HttpError { + constructor(message: string = "Internal Server Error") { + super(message, 500); + } +} diff --git a/src/index.ts b/src/index.ts index 081aefe..2838a7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,12 @@ async function startServer() { app.use("/user", userRoutes); // app.use("/recipe", recipeRoutes); + // Error handling for all rest-calls + // must come last! + app.use(errorHandler); + console.log("auth and user routes added") + // catch all other routes app.get(/(.*)/, (req: Request, res: Response, next: NextFunction) => { res.status(400).json({ message: "Bad Request" }); }); diff --git a/src/middleware/authenticationMiddleware.ts b/src/middleware/authenticationMiddleware.ts index a6be35b..e460bb8 100644 --- a/src/middleware/authenticationMiddleware.ts +++ b/src/middleware/authenticationMiddleware.ts @@ -2,14 +2,14 @@ import { NextFunction, Request, Response } from "express"; import jwt from "jsonwebtoken"; import dotenv from "dotenv"; import { authBasicRoute } from "../endpoints/AuthPoint.js"; +import { AuthPayload } from "../dtos/AuthPayload.js"; dotenv.config(); -//@todo this seems to be clumsy... We need some propper session handling as we'll have multiple users accessing the app declare global { namespace Express { interface Request { - currentUser?: string | jwt.JwtPayload; + currentUser?: AuthPayload; } } } @@ -46,10 +46,10 @@ export const authentication = ( } try { - const decoded = jwt.verify(token, JWT_SECRET); + const decoded = jwt.verify(token, JWT_SECRET) as AuthPayload; req.currentUser = decoded; next(); - } catch (err) { + } catch { return res.status(401).json({ message: "Unauthorized" }); } }; diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index b7528ad..1b335d3 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,11 +1,35 @@ -import { NextFunction, Request, Response } from "express"; +// middleware/errorHandler.ts +import { Request, Response, NextFunction } from "express"; +import { HttpError, InternalServerError } from "../errors/httpErrors.js"; -export const errorHandler = ( - error: Error, +/** + * Express global error-handling middleware. + * + * Responsibilities: + * - Catch and handle errors thrown in controllers or routes + * - Map known HttpError subclasses (ValidationError, UnauthorizedError, etc.) + * to the appropriate HTTP status code and JSON response + * - Fallback to InternalServerError for unexpected/unhandled errors + * + * Usage: + * 1. Register after all routes: `app.use(errorHandler);` + * 2. Throw `HttpError` subclasses in your controllers/services + * 3. Any other uncaught error is logged and returned as 500 Internal Server Error + */ +export function errorHandler( + err: any, req: Request, res: Response, next: NextFunction -) => { - console.error(`Error: ${error.message}`); - return res.status(500).json({ message: "Internal server error" }); -}; \ No newline at end of file +) { + if (err instanceof HttpError) { + return res.status(err.statusCode).json({ statusCode: err.statusCode, error: err.message }); + } + + console.error("Unexpected error:", err); + + const internalError = new InternalServerError( + "An unexpected error occurred. Please try again later." + ); + return res.status(internalError.statusCode).json({ error: internalError.message }); +} diff --git a/src/utils/asyncHandler.ts b/src/utils/asyncHandler.ts new file mode 100644 index 0000000..64d2ec2 --- /dev/null +++ b/src/utils/asyncHandler.ts @@ -0,0 +1,12 @@ +import { Request, Response, NextFunction } from "express"; + + +/** + * Handles asyncronous rest calls + * @param fn function to execute asyncronously + */ +export const asyncHandler = + (fn: (req: Request, res: Response, next: NextFunction) => Promise) => + (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; diff --git a/src/utils/encryptionUtils.ts b/src/utils/encryptionUtils.ts index 16cd5fb..b64e629 100644 --- a/src/utils/encryptionUtils.ts +++ b/src/utils/encryptionUtils.ts @@ -1,10 +1,15 @@ import jwt from "jsonwebtoken"; import bcrypt from "bcrypt"; import dotenv from "dotenv"; +import { AuthPayload } from "../dtos/AuthPayload.js"; dotenv.config(); const { JWT_SECRET = "" } = process.env; +/** + * Information on the current auth token containing the + * token itself as well as its expiry date + */ export class tokenInfo{ constructor(token: string, expiryDate: Date){ this.token = token; @@ -14,22 +19,47 @@ export class tokenInfo{ expiryDate: Date; } +/** + * Responsible for handling of encrypted passwords and token handling + */ export class encrypt { + /** + * Encrypts a password string + * @param password Password string + * @returns encrypted password string + */ static async encryptpass(password: string) { return bcrypt.hashSync(password, 12); } - static comparepassword(password: string, hashPassword: string) { - return bcrypt.compareSync(password, hashPassword); -} - static generateToken(payload: object) { + /** + * Compares a plain text password to an encrypted password + * @param password plain text password + * @param hashPassword ecrypted password + * @returns Boolean indicating whether both passwords are identical + */ + static comparepassword(password: string, hashPassword: string) : boolean{ + return bcrypt.compareSync(password, hashPassword); + } + +/** + * Generates an authentication token that will be valid for one day based on the payload provided + * @param payload AuthPayload containing the userId + * @returns tokenInfo containing authentication token and expiryDate + */ + static generateToken(payload: AuthPayload) : tokenInfo { let expiryDate = new Date(); expiryDate.setDate(expiryDate.getDate() + 1); // 1 day const token = jwt.sign(payload, JWT_SECRET, { expiresIn: "1d" }); return new tokenInfo(token, expiryDate); } - static verifyToken(token: string) { + /** + * Verifys the token and decrypts the payload + * @param token authentiction token + * @returns Payload of the token + */ + static verifyToken(token: string) : jwt.JwtPayload | string { return jwt.verify(token, JWT_SECRET); } }