From 2a6153002c9ae9562c4ba9716ca6c5dca7349ccd Mon Sep 17 00:00:00 2001 From: araemer Date: Wed, 19 Nov 2025 20:55:13 +0100 Subject: [PATCH] Implement change password request. Adapt authorizationMiddleware to allow for adminOrOwner access to resources --- bruno/recipe-backend/updateUser.bru | 18 ++-- src/endpoints/UserPoint.ts | 100 +++++++++++++++------- src/handlers/UserHandler.ts | 57 ++++++++---- src/middleware/authorizationMiddleware.ts | 64 +++++++++++++- 4 files changed, 180 insertions(+), 59 deletions(-) diff --git a/bruno/recipe-backend/updateUser.bru b/bruno/recipe-backend/updateUser.bru index 681734c..9a08421 100644 --- a/bruno/recipe-backend/updateUser.bru +++ b/bruno/recipe-backend/updateUser.bru @@ -5,24 +5,24 @@ meta { } post { - url: https://localhost:4000/user/update + url: http://localhost:4000/user/update body: json auth: bearer } auth:bearer { - token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc2MzA2NTI0MCwiZXhwIjoxNzYzMTUxNjQwfQ.e7v1JnlNHm7zwSzumlZIy2Dxfojqsxk55aYC9UA7BkE + token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImE0NDdlNDM0LTQyMWYtNDJiYS04MGRlLTM0ZDE1YzJmNWE2YyIsImlhdCI6MTc2MzQ5MDcxMywiZXhwIjoxNzYzNTc3MTEzfQ.h8ta-4tVhR7EskZDBLtcFTQ7QllV-PfC09Y0DLjYJa4 } body:json { { - "id": "a447e434-421f-42ba-80de-34d15c2f5a6c", - "userName": "admin", - "email": "anika@raemer.net", - "firstName": "Anika", - "lastName": "Rämer", - "role": "admin" - } + "id": "9c913747-ba57-4b12-87d0-3339f4a8117c", + "userName": "test3", + "email": "test@raemer.net", + "firstName": "Thea", + "lastName": "Test", + "role": "user" + } } settings { diff --git a/src/endpoints/UserPoint.ts b/src/endpoints/UserPoint.ts index 72e9f67..793f793 100644 --- a/src/endpoints/UserPoint.ts +++ b/src/endpoints/UserPoint.ts @@ -4,39 +4,47 @@ import { CreateUserRequest } from "../dtos/CreateUserRequest.js"; import { UserRepository } from "../repositories/UserRepository.js"; import { UserDtoEntityMapper } from "../mappers/UserDtoEntityMapper.js"; import { asyncHandler } from "../utils/asyncHandler.js"; -import {CreateUserResponse} from "../dtos/CreateUserResponse.js"; -import {UserDto} from "../dtos/UserDto.js"; -import {requireAdmin} from "../middleware/authorizationMiddleware.js"; -import {UserListResponse} from "../dtos/UserListResponse.js"; -import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; +import { CreateUserResponse } from "../dtos/CreateUserResponse.js"; +import { UserDto } from "../dtos/UserDto.js"; +import { + requireAdmin, + requireAdminOrOwner, + requireAdminOrSelf +} from "../middleware/authorizationMiddleware.js"; +import { UserListResponse } from "../dtos/UserListResponse.js"; +import { HttpStatusCode } from "../apiHelpers/HttpStatusCodes.js"; +import { InternalServerError, NotFoundError } from "../errors/httpErrors.js"; +import { ChangeUserPasswordRequest } from "../dtos/ChangeUserPasswordRequest.js"; export const userBasicRoute = "/user"; + /** * Handles all user related routes */ const router = Router(); + router.use((req, res, next) => { console.log(`Incoming request: ${req.method} ${req.path}`); next(); }); // Inject repo + mapper here -const handler - = new UserHandler(new UserRepository(), new UserDtoEntityMapper()); +const handler = new UserHandler(new UserRepository(), new UserDtoEntityMapper()); /** - * Create a new user + * Create a new user (admin only) * Consumes CreateUserRequest * Responds with UserDto */ router.post( - "/create", - asyncHandler(async (req, res) => { - const request: CreateUserRequest = req.body; - const user : UserDto = await handler.createUser(request); - const response : CreateUserResponse = { userData: user }; - res.status(201).json(response); - }) + "/create", + requireAdmin, + asyncHandler(async (req, res) => { + const request: CreateUserRequest = req.body; + const user: UserDto = await handler.createUser(request); + const response: CreateUserResponse = { userData: user }; + res.status(HttpStatusCode.CREATED).json(response); + }) ); /** @@ -44,25 +52,36 @@ router.post( * Consumes UserDto * Responds with UserDto * Does not allow for password change. Use change-password instead + * + * Users can update their own data, admins can update any user */ router.post( "/update", + requireAdminOrSelf, // Checks req.body.id or req.body.userId asyncHandler(async (req, res) => { - const dto : UserDto = req.body; + const dto: UserDto = req.body; const response = await handler.updateUserData(dto); - res.status(HttpStatusCode.CREATED).json(response); + res.status(HttpStatusCode.OK).json(response); }) ); /** * Update password of existing user * Consumes ChangeUserPasswordRequest - * Responds with code 201 indicating success + * Responds with code 200 indicating success + * + * Users can change their own password, admins can change any password */ router.post( "/change-password", + requireAdminOrOwner(req => req.body.userId), asyncHandler(async (req, res) => { - throw Error("not implemented!"); + const requestData: ChangeUserPasswordRequest = req.body; + const success = await handler.changePassword(requestData); + if (!success) { + throw new InternalServerError("Failed to change password"); + } + res.status(HttpStatusCode.OK).json({ message: "Password changed successfully" }); }) ); @@ -72,11 +91,10 @@ router.post( */ router.get( "/all", - //requireAdmin, + requireAdmin, asyncHandler(async (req, res) => { - const id = req.currentUser?.id; const users = await handler.getAllUsers(); - const response : UserListResponse = {valueList: users} + const response: UserListResponse = { valueList: users }; res.status(HttpStatusCode.OK).json(response); }) ); @@ -85,15 +103,33 @@ router.get( * Get user data for current user * Responds with UserDto */ -router.get("/me", - asyncHandler(async (req, res) => { - - const id = req.currentUser?.id; - if(id){ - const responseDto = await handler.getUserById(id); - res.status(HttpStatusCode.OK).json(responseDto); - } - }) +router.get( + "/me", + asyncHandler(async (req, res) => { + const id = req.currentUser?.id; + if (id) { + const responseDto = await handler.getUserById(id); + res.status(HttpStatusCode.OK).json(responseDto); + } else { + throw new NotFoundError("There is no user id for current session!"); + } + }) ); -export default router; +/** + * Get specific user by ID + * Responds with UserDto + * + * Users can get their own data, admins can get any user's data + */ +router.get( + "/:userId", + requireAdminOrOwner(req => req.params.userId), + asyncHandler(async (req, res) => { + const userId = req.params.userId; + const responseDto = await handler.getUserById(userId); + res.status(HttpStatusCode.OK).json(responseDto); + }) +); + +export default router; \ No newline at end of file diff --git a/src/handlers/UserHandler.ts b/src/handlers/UserHandler.ts index 7974b21..a064c03 100644 --- a/src/handlers/UserHandler.ts +++ b/src/handlers/UserHandler.ts @@ -1,10 +1,12 @@ -import { ValidationError, ConflictError, NotFoundError } from "../errors/httpErrors.js"; -import { CreateUserRequest } from "../dtos/CreateUserRequest.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"; +import {ConflictError, NotFoundError, ValidationError} from "../errors/httpErrors.js"; +import {CreateUserRequest} from "../dtos/CreateUserRequest.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"; +import {ChangeUserPasswordRequest} from "../dtos/ChangeUserPasswordRequest.js"; +import {UserEntity} from "../entities/UserEntity.js"; /** * Controls all user specific actions @@ -17,17 +19,17 @@ export class UserHandler { /** * Create a new user - * @param dto CreateUserRequest containing data for the user to add + * @param requestData CreateUserRequest containing data for the user to add * @returns UserDto Data of the user as stored in the database */ - async createUser(dto: CreateUserRequest): Promise { + async createUser(requestData: CreateUserRequest): Promise { // check mandatory fields - if(!dto.userData){ + if(!requestData.userData){ throw new ValidationError("User data is required") } - this.validateUserData(dto.userData); - const userName = dto.userData.userName; - const password = dto.password; + this.validateUserData(requestData.userData); + const userName = requestData.userData.userName; + const password = requestData.password; if(!password || (password && password.length == 0)){ throw new ValidationError("Password is required"); } @@ -39,7 +41,7 @@ export class UserHandler { throw new ConflictError("User with this user name already exists"); } - const userEntity = this.mapper.toEntity(dto.userData); + const userEntity = this.mapper.toEntity(requestData.userData); userEntity.password = await encrypt.encryptpass(password); const savedUser = await this.userRepository.create(userEntity); @@ -73,7 +75,7 @@ export class UserHandler { // First: Load current version of user from database const entity = await this.userRepository.findById(userId); if (!entity) { - throw new ValidationError("No user with ID " + userId + " found in database!") + throw new ValidationError("No user with ID " + userId + " found in database!"); } // merge changes into entity this.mapper.mergeDtoIntoEntity(dto, entity); @@ -82,6 +84,31 @@ export class UserHandler { return this.mapper.toDto(savedEntity); } + /** + * Update password for a specific user + * @param requestData ChangeUserPasswordRequest containing both password and userId + * @return true on success + */ + async changePassword(requestData : ChangeUserPasswordRequest) : Promise { + const userId = requestData.userId; + if(!userId) { + throw new ValidationError("User id is required to change password"); + } + const password = requestData.password; + if(!password || (password && password.length == 0)){ + throw new ValidationError("Password is required"); + } + // load user from database + let entity : UserEntity | null = await this.userRepository.findById(userId); + if(!entity) { + throw new ValidationError("No user with ID " + userId + " found in database!"); + } + entity.password = password; + const savedEntity = await this.userRepository.update(entity); + + return true; + } + /** * Load data of a specific user diff --git a/src/middleware/authorizationMiddleware.ts b/src/middleware/authorizationMiddleware.ts index 1247f9d..828f217 100644 --- a/src/middleware/authorizationMiddleware.ts +++ b/src/middleware/authorizationMiddleware.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from "express"; import { ForbiddenError } from "../errors/httpErrors.js"; -import {HttpStatusCode} from "../apiHelpers/HttpStatusCodes.js"; +import { HttpStatusCode } from "../apiHelpers/HttpStatusCodes.js"; /** * Middleware to check if the current user has one of the required roles @@ -22,7 +22,6 @@ export const requireRole = (allowedRoles: string[]) => { // Get user's role from the auth payload const userRole = req.currentUser.role; - console.log("userRole", userRole); // Check if user has one of the allowed roles if (!userRole || !allowedRoles.includes(userRole)) { @@ -42,4 +41,63 @@ export const requireRole = (allowedRoles: string[]) => { * @example * router.get("/admin-panel", requireAdmin, asyncHandler(async (req, res) => { ... })); */ -export const requireAdmin = requireRole(["admin"]); \ No newline at end of file +export const requireAdmin = requireRole(["admin"]); + +/** + * Middleware to check if user is either an admin or the owner of the resource + * Must be used after the authentication middleware + * + * @param getUserIdFromRequest Function to extract the target user ID from the request + * @returns Express middleware function + * + * @example + * // For route params: /user/:userId + * router.put("/:userId", requireAdminOrOwner(req => req.params.userId), asyncHandler(...)); + * + * // For request body: { userId: "123" } + * router.post("/update", requireAdminOrOwner(req => req.body.userId), asyncHandler(...)); + * + * // For query params: /user?userId=123 + * router.get("/profile", requireAdminOrOwner(req => req.query.userId), asyncHandler(...)); + */ +export const requireAdminOrOwner = ( + getUserIdFromRequest: (req: Request) => string | undefined +) => { + return (req: Request, res: Response, next: NextFunction) => { + // Check if user is authenticated + if (!req.currentUser) { + return res.status(HttpStatusCode.UNAUTHORIZED).json({ message: "Unauthorized" }); + } + + const currentUserId = req.currentUser.id; + const currentUserRole = req.currentUser.role; + const targetUserId = getUserIdFromRequest(req); + + // Allow if user is admin + if (currentUserRole === "admin") { + return next(); + } + + // Allow if user is accessing their own resource + if (targetUserId && currentUserId === targetUserId) { + return next(); + } + + // Deny access + throw new ForbiddenError( + "Access denied. You can only modify your own data." + ); + }; +}; + +/** + * Middleware for routes where the user ID is in the request body + * Common for update operations + * + * @example + * router.post("/update", requireAdminOrSelf, asyncHandler(async (req, res) => { + * const dto: UserDto = req.body; + * // dto.id is checked against current user + * })); + */ +export const requireAdminOrSelf = requireAdminOrOwner(req => req.body.id || req.body.userId); \ No newline at end of file