add me point to load user data of current user

This commit is contained in:
Anika Raemer 2025-09-27 07:47:26 +02:00
parent fac606cf97
commit e5b5d7e67d
12 changed files with 215 additions and 67 deletions

View file

@ -7,11 +7,7 @@ meta {
post { post {
url: http://localhost:4000/user url: http://localhost:4000/user
body: json body: json
auth: inherit auth: bearer
}
headers {
Authorization: bearcer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjU2NGE5NjY0LTI2ZWYtNGMxMS1hNjIyLWU4MDI2MzczYmRkZCIsImlhdCI6MTc1ODc0MTM5MywiZXhwIjoxNzU4ODI3NzkzfQ.q33R9FfhGUIn92PTIIAmKmUnGxcLlv6om7KwiDD61Rc
} }
body:json { body:json {

View file

@ -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
}

View file

@ -5,12 +5,21 @@ import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js";
import { LoginResponseDto } from "../dtos/LoginResponseDto.js"; import { LoginResponseDto } from "../dtos/LoginResponseDto.js";
import { LoginRequestDto } from "../dtos/LoginRequestDto.js"; import { LoginRequestDto } from "../dtos/LoginRequestDto.js";
/**
* Controller responsible for authentication, e.g., login or issueing a token with extended
* lifetime
*/
export class AuthController { export class AuthController {
constructor( constructor(
private userRepository: UserRepository, private userRepository: UserRepository,
private mapper: UserDtoEntityMapper 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<LoginResponseDto> { async login(loginRequest : LoginRequestDto): Promise<LoginResponseDto> {
const userName :string|undefined = loginRequest.userName; const userName :string|undefined = loginRequest.userName;
const password :string|undefined = loginRequest.password; const password :string|undefined = loginRequest.password;
@ -25,6 +34,11 @@ export class AuthController {
if(!user){ if(!user){
throw new UnauthorizedError("Invalid username or password"); 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 // Compare password
const passwordMatches = encrypt.comparepassword(password, user.password); const passwordMatches = encrypt.comparepassword(password, user.password);
if (!passwordMatches) { if (!passwordMatches) {
@ -33,7 +47,7 @@ export class AuthController {
// Create JWT // Create JWT
const tokenInfo = encrypt.generateToken({ const tokenInfo = encrypt.generateToken({
id: user.id, id: userId!, // ! to indicate that we've definitely checked for userId being defined
}); });
const responseDto = new LoginResponseDto(); const responseDto = new LoginResponseDto();

View file

@ -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 { CreateUserRequestDto } from "../dtos/CreateUserRequestDto.js";
import { UserDto } from "../dtos/UserDto.js"; import { UserDto } from "../dtos/UserDto.js";
import { encrypt } from "../utils/encryptionUtils.js"; import { encrypt } from "../utils/encryptionUtils.js";
import { UserRepository } from "../repositories/UserRepository.js"; import { UserRepository } from "../repositories/UserRepository.js";
import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js";
import { UUID } from "crypto";
/**
* Controls all user specific actions
*/
export class UserController { export class UserController {
constructor( constructor(
private userRepository: UserRepository, private userRepository: UserRepository,
private mapper: UserDtoEntityMapper 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<UserDto> { async createUser(dto: CreateUserRequestDto): Promise<UserDto> {
// @todo make authorized! Create initial user!
// check mandatory fields // check mandatory fields
if(!dto.userData){ if(!dto.userData){
throw new ValidationError("User data is required") throw new ValidationError("User data is required")
@ -43,4 +51,21 @@ export class UserController {
return this.mapper.toDto(savedUser); 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<UserDto> {
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);
}
} }

9
src/dtos/AuthPayload.ts Normal file
View file

@ -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;
}

View file

@ -3,13 +3,11 @@ import { UserController } from "../controllers/UserController.js";
import { CreateUserRequestDto } from "../dtos/CreateUserRequestDto.js"; import { CreateUserRequestDto } from "../dtos/CreateUserRequestDto.js";
import { UserRepository } from "../repositories/UserRepository.js"; import { UserRepository } from "../repositories/UserRepository.js";
import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js";
import { import { asyncHandler } from "../utils/asyncHandler.js";
ValidationError,
ConflictError,
NotFoundError,
InternalServerError,
} from "../errors/httpErrors.js";
/**
* Handles all user related routes
*/
const router = Router(); const router = Router();
// Inject repo + mapper here // Inject repo + mapper here
@ -17,22 +15,31 @@ const userRepository = new UserRepository();
const userMapper = new UserDtoEntityMapper(); const userMapper = new UserDtoEntityMapper();
const userController = new UserController(userRepository, userMapper); 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 requestDto: CreateUserRequestDto = req.body;
const responseDto = await userController.createUser(requestDto); const responseDto = await userController.createUser(requestDto);
res.status(201).json(responseDto); 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 }); * Get user data for current user
} else { */
console.error("Unexpected error:", err); router.get("/me",
const internalError = new InternalServerError("Some unexpected error occurred!"); asyncHandler(async (req, res) => {
res.status(internalError.statusCode).json({ error: internalError.message });
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; export default router;

View file

@ -1,39 +1,46 @@
export class ValidationError extends Error { /**
statusCode = 400; * Base class for all HTTP-related errors.
constructor(message: string) { * 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); 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 { export class ValidationError extends HttpError {
statusCode = 404;
constructor(message: string) { constructor(message: string) {
super(message); super(message, 400); // Bad Request
this.name = "NotFoundError";
} }
} }
export class ConflictError extends Error { export class UnauthorizedError extends HttpError {
statusCode = 409; constructor(message: string = "Unauthorized") {
constructor(message: string) { super(message, 401);
super(message);
this.name = "ConflictError";
} }
} }
export class InternalServerError extends Error { export class NotFoundError extends HttpError {
statusCode = 500; constructor(message: string = "Resource not found") {
constructor(message: string) { super(message, 404);
super(message);
this.name = "InternalServerError";
} }
} }
export class UnauthorizedError extends Error { export class ConflictError extends HttpError {
statusCode = 409; constructor(message: string = "Conflict") {
constructor(message: string) { super(message, 409);
super(message); }
this.name = "UnauthorizedError"; }
export class InternalServerError extends HttpError {
constructor(message: string = "Internal Server Error") {
super(message, 500);
} }
} }

View file

@ -32,7 +32,12 @@ async function startServer() {
app.use("/user", userRoutes); app.use("/user", userRoutes);
// app.use("/recipe", recipeRoutes); // app.use("/recipe", recipeRoutes);
// Error handling for all rest-calls
// must come last!
app.use(errorHandler);
console.log("auth and user routes added") console.log("auth and user routes added")
// catch all other routes
app.get(/(.*)/, (req: Request, res: Response, next: NextFunction) => { app.get(/(.*)/, (req: Request, res: Response, next: NextFunction) => {
res.status(400).json({ message: "Bad Request" }); res.status(400).json({ message: "Bad Request" });
}); });

View file

@ -2,14 +2,14 @@ import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { authBasicRoute } from "../endpoints/AuthPoint.js"; import { authBasicRoute } from "../endpoints/AuthPoint.js";
import { AuthPayload } from "../dtos/AuthPayload.js";
dotenv.config(); 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 { declare global {
namespace Express { namespace Express {
interface Request { interface Request {
currentUser?: string | jwt.JwtPayload; currentUser?: AuthPayload;
} }
} }
} }
@ -46,10 +46,10 @@ export const authentication = (
} }
try { try {
const decoded = jwt.verify(token, JWT_SECRET); const decoded = jwt.verify(token, JWT_SECRET) as AuthPayload;
req.currentUser = decoded; req.currentUser = decoded;
next(); next();
} catch (err) { } catch {
return res.status(401).json({ message: "Unauthorized" }); return res.status(401).json({ message: "Unauthorized" });
} }
}; };

View file

@ -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, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction
) => { ) {
console.error(`Error: ${error.message}`); if (err instanceof HttpError) {
return res.status(500).json({ message: "Internal server error" }); 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 });
}

12
src/utils/asyncHandler.ts Normal file
View file

@ -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<any>) =>
(req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};

View file

@ -1,10 +1,15 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { AuthPayload } from "../dtos/AuthPayload.js";
dotenv.config(); dotenv.config();
const { JWT_SECRET = "" } = process.env; const { JWT_SECRET = "" } = process.env;
/**
* Information on the current auth token containing the
* token itself as well as its expiry date
*/
export class tokenInfo{ export class tokenInfo{
constructor(token: string, expiryDate: Date){ constructor(token: string, expiryDate: Date){
this.token = token; this.token = token;
@ -14,22 +19,47 @@ export class tokenInfo{
expiryDate: Date; expiryDate: Date;
} }
/**
* Responsible for handling of encrypted passwords and token handling
*/
export class encrypt { export class encrypt {
/**
* Encrypts a password string
* @param password Password string
* @returns encrypted password string
*/
static async encryptpass(password: string) { static async encryptpass(password: string) {
return bcrypt.hashSync(password, 12); 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(); let expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 1); // 1 day expiryDate.setDate(expiryDate.getDate() + 1); // 1 day
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: "1d" }); const token = jwt.sign(payload, JWT_SECRET, { expiresIn: "1d" });
return new tokenInfo(token, expiryDate); 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); return jwt.verify(token, JWT_SECRET);
} }
} }