From aef411a0eac625c33274dfe3e88b6f75115c44d8 Mon Sep 17 00:00:00 2001 From: Saad Jutt Date: Tue, 14 Jun 2022 22:08:56 +0500 Subject: [PATCH] feat: new APIs added for GET|PATCH|DELETE of user by username --- api/public/swagger.yaml | 88 +++++++ api/src/controllers/user.ts | 96 ++++++-- api/src/middlewares/verifyAdminIfNeeded.ts | 17 +- api/src/routes/api/spec/user.spec.ts | 259 +++++++++++++++++++++ api/src/routes/api/user.ts | 76 ++++++ api/src/utils/validation.ts | 5 + 6 files changed, 525 insertions(+), 16 deletions(-) diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index 1f76c10..7e761fe 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -985,6 +985,94 @@ paths: application/json: schema: $ref: '#/components/schemas/UserPayload' + '/SASjsApi/user/by/username/{username}': + get: + operationId: GetUserByUsername + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetailsResponse' + description: 'Only Admin or user itself will get user autoExec code.' + summary: 'Get user properties - such as group memberships, userName, displayName.' + tags: + - User + security: + - + bearerAuth: [] + parameters: + - + description: 'The User''s username' + in: path + name: username + required: true + schema: + type: string + example: johnSnow01 + patch: + operationId: UpdateUserByUsername + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetailsResponse' + examples: + 'Example 1': + value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true} + summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.' + tags: + - User + security: + - + bearerAuth: [] + parameters: + - + description: 'The User''s username' + in: path + name: username + required: true + schema: + type: string + example: johnSnow01 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserPayload' + delete: + operationId: DeleteUserByUsername + responses: + '204': + description: 'No content' + summary: 'Delete a user. Can be performed either by admins, or the user in question.' + tags: + - User + security: + - + bearerAuth: [] + parameters: + - + description: 'The User''s username' + in: path + name: username + required: true + schema: + type: string + example: johnSnow01 + requestBody: + required: true + content: + application/json: + schema: + properties: + password: + type: string + type: object '/SASjsApi/user/{userId}': get: operationId: GetUser diff --git a/api/src/controllers/user.ts b/api/src/controllers/user.ts index cfbce9d..99a8f9b 100644 --- a/api/src/controllers/user.ts +++ b/api/src/controllers/user.ts @@ -77,6 +77,26 @@ export class UserController { return createUser(body) } + /** + * Only Admin or user itself will get user autoExec code. + * @summary Get user properties - such as group memberships, userName, displayName. + * @param username The User's username + * @example username "johnSnow01" + */ + @Get('by/username/{username}') + public async getUserByUsername( + @Request() req: express.Request, + @Path() username: string + ): Promise { + const { MODE } = process.env + + if (MODE === ModeType.Desktop) return getDesktopAutoExec() + + const { user } = req + const getAutoExec = user!.isAdmin || user!.username == username + return getUser({ username }, getAutoExec) + } + /** * Only Admin or user itself will get user autoExec code. * @summary Get user properties - such as group memberships, userName, displayName. @@ -94,7 +114,32 @@ export class UserController { const { user } = req const getAutoExec = user!.isAdmin || user!.userId == userId - return getUser(userId, getAutoExec) + return getUser({ id: userId }, getAutoExec) + } + + /** + * @summary Update user properties - such as displayName. Can be performed either by admins, or the user in question. + * @param username The User's username + * @example username "johnSnow01" + */ + @Example({ + id: 1234, + displayName: 'John Snow', + username: 'johnSnow01', + isAdmin: false, + isActive: true + }) + @Patch('by/username/{username}') + public async updateUserByUsername( + @Path() username: string, + @Body() body: UserPayload + ): Promise { + const { MODE } = process.env + + if (MODE === ModeType.Desktop) + return updateDesktopAutoExec(body.autoExec ?? '') + + return updateUser({ username }, body) } /** @@ -119,7 +164,21 @@ export class UserController { if (MODE === ModeType.Desktop) return updateDesktopAutoExec(body.autoExec ?? '') - return updateUser(userId, body) + return updateUser({ id: userId }, body) + } + + /** + * @summary Delete a user. Can be performed either by admins, or the user in question. + * @param username The User's username + * @example username "johnSnow01" + */ + @Delete('by/username/{username}') + public async deleteUserByUsername( + @Path() username: string, + @Body() body: { password?: string }, + @Query() @Hidden() isAdmin: boolean = false + ) { + return deleteUser({ username }, isAdmin, body) } /** @@ -133,7 +192,7 @@ export class UserController { @Body() body: { password?: string }, @Query() @Hidden() isAdmin: boolean = false ) { - return deleteUser(userId, isAdmin, body) + return deleteUser({ id: userId }, isAdmin, body) } } @@ -174,11 +233,16 @@ const createUser = async (data: UserPayload): Promise => { } } +interface GetUserBy { + id?: number + username?: string +} + const getUser = async ( - id: number, + findBy: GetUserBy, getAutoExec: boolean ): Promise => { - const user = await User.findOne({ id }) + const user = await User.findOne(findBy) if (!user) throw new Error('User is not found.') @@ -201,7 +265,7 @@ const getDesktopAutoExec = async () => { } const updateUser = async ( - id: number, + findBy: GetUserBy, data: Partial ): Promise => { const { displayName, username, password, isAdmin, isActive, autoExec } = data @@ -211,8 +275,13 @@ const updateUser = async ( if (username) { // Checking if user is already in the database const usernameExist = await User.findOne({ username }) - if (usernameExist && usernameExist.id != id) - throw new Error('Username already exists.') + if (usernameExist) { + if ( + (findBy.id && usernameExist.id != findBy.id) || + (findBy.username && usernameExist.username != findBy.username) + ) + throw new Error('Username already exists.') + } params.username = username } @@ -221,9 +290,10 @@ const updateUser = async ( params.password = User.hashPassword(password) } - const updatedUser = await User.findOneAndUpdate({ id }, params, { new: true }) + const updatedUser = await User.findOneAndUpdate(findBy, params, { new: true }) - if (!updatedUser) throw new Error(`Unable to find user with id: ${id}`) + if (!updatedUser) + throw new Error(`Unable to find user with ${findBy.id || findBy.username}`) return { id: updatedUser.id, @@ -245,11 +315,11 @@ const updateDesktopAutoExec = async (autoExec: string) => { } const deleteUser = async ( - id: number, + findBy: GetUserBy, isAdmin: boolean, { password }: { password?: string } ) => { - const user = await User.findOne({ id }) + const user = await User.findOne(findBy) if (!user) throw new Error('User is not found.') if (!isAdmin) { @@ -257,5 +327,5 @@ const deleteUser = async ( if (!validPass) throw new Error('Invalid password.') } - await User.deleteOne({ id }) + await User.deleteOne(findBy) } diff --git a/api/src/middlewares/verifyAdminIfNeeded.ts b/api/src/middlewares/verifyAdminIfNeeded.ts index d126f3c..c9246f6 100644 --- a/api/src/middlewares/verifyAdminIfNeeded.ts +++ b/api/src/middlewares/verifyAdminIfNeeded.ts @@ -1,11 +1,22 @@ import { RequestHandler } from 'express' +// This middleware checks if a non-admin user trying to +// access information of other user export const verifyAdminIfNeeded: RequestHandler = (req, res, next) => { const { user } = req - const userId = parseInt(req.params.userId) - if (!user?.isAdmin && user?.userId !== userId) { - return res.status(401).send('Admin account required') + if (!user?.isAdmin) { + let adminAccountRequired: boolean = true + + if (req.params.userId) { + adminAccountRequired = user?.userId !== parseInt(req.params.userId) + } else if (req.params.username) { + adminAccountRequired = user?.username !== req.params.username + } + + if (adminAccountRequired) + return res.status(401).send('Admin account required') } + next() } diff --git a/api/src/routes/api/spec/user.spec.ts b/api/src/routes/api/spec/user.spec.ts index 4e5e2bf..36913a3 100644 --- a/api/src/routes/api/spec/user.spec.ts +++ b/api/src/routes/api/spec/user.spec.ts @@ -270,6 +270,102 @@ describe('user', () => { expect(res.text).toEqual('Error: Username already exists.') expect(res.body).toEqual({}) }) + + describe('by username', () => { + it('should respond with updated user when admin user requests', async () => { + const dbUser = await controller.createUser(user) + const newDisplayName = 'My new display Name' + + const res = await request(app) + .patch(`/SASjsApi/user/by/username/${user.username}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send({ ...user, displayName: newDisplayName }) + .expect(200) + + expect(res.body.username).toEqual(user.username) + expect(res.body.displayName).toEqual(newDisplayName) + expect(res.body.isAdmin).toEqual(user.isAdmin) + expect(res.body.isActive).toEqual(user.isActive) + }) + + it('should respond with updated user when user himself requests', async () => { + const dbUser = await controller.createUser(user) + const accessToken = await generateAndSaveToken(dbUser.id) + const newDisplayName = 'My new display Name' + + const res = await request(app) + .patch(`/SASjsApi/user/by/username/${user.username}`) + .auth(accessToken, { type: 'bearer' }) + .send({ + displayName: newDisplayName, + username: user.username, + password: user.password + }) + .expect(200) + + expect(res.body.username).toEqual(user.username) + expect(res.body.displayName).toEqual(newDisplayName) + expect(res.body.isAdmin).toEqual(user.isAdmin) + expect(res.body.isActive).toEqual(user.isActive) + }) + + it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => { + const dbUser = await controller.createUser(user) + const accessToken = await generateAndSaveToken(dbUser.id) + const newDisplayName = 'My new display Name' + + await request(app) + .patch(`/SASjsApi/user/by/username/${user.username}`) + .auth(accessToken, { type: 'bearer' }) + .send({ ...user, displayName: newDisplayName }) + .expect(400) + }) + + it('should respond with Unauthorized if access token is not present', async () => { + const res = await request(app) + .patch('/SASjsApi/user/by/username/1234') + .send(user) + .expect(401) + + expect(res.text).toEqual('Unauthorized') + expect(res.body).toEqual({}) + }) + + it('should respond with Unauthorized when access token is not of an admin account or himself', async () => { + const dbUser1 = await controller.createUser(user) + const dbUser2 = await controller.createUser({ + ...user, + username: 'randomUser' + }) + const accessToken = await generateAndSaveToken(dbUser2.id) + + const res = await request(app) + .patch(`/SASjsApi/user/${dbUser1.id}`) + .auth(accessToken, { type: 'bearer' }) + .send(user) + .expect(401) + + expect(res.text).toEqual('Admin account required') + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden if username is already present', async () => { + const dbUser1 = await controller.createUser(user) + const dbUser2 = await controller.createUser({ + ...user, + username: 'randomuser' + }) + + const res = await request(app) + .patch(`/SASjsApi/user/by/username/${dbUser1.username}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send({ username: dbUser2.username }) + .expect(403) + + expect(res.text).toEqual('Error: Username already exists.') + expect(res.body).toEqual({}) + }) + }) }) describe('delete', () => { @@ -363,6 +459,89 @@ describe('user', () => { expect(res.text).toEqual('Error: Invalid password.') expect(res.body).toEqual({}) }) + + describe('by username', () => { + it('should respond with OK when admin user requests', async () => { + const dbUser = await controller.createUser(user) + + const res = await request(app) + .delete(`/SASjsApi/user/by/username/${dbUser.username}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.body).toEqual({}) + }) + + it('should respond with OK when user himself requests', async () => { + const dbUser = await controller.createUser(user) + const accessToken = await generateAndSaveToken(dbUser.id) + + const res = await request(app) + .delete(`/SASjsApi/user/by/username/${dbUser.username}`) + .auth(accessToken, { type: 'bearer' }) + .send({ password: user.password }) + .expect(200) + + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request when user himself requests and password is missing', async () => { + const dbUser = await controller.createUser(user) + const accessToken = await generateAndSaveToken(dbUser.id) + + const res = await request(app) + .delete(`/SASjsApi/user/by/username/${dbUser.username}`) + .auth(accessToken, { type: 'bearer' }) + .send() + .expect(400) + + expect(res.text).toEqual(`"password" is required`) + expect(res.body).toEqual({}) + }) + + it('should respond with Unauthorized when access token is not present', async () => { + const res = await request(app) + .delete('/SASjsApi/user/by/username/RandomUsername') + .send(user) + .expect(401) + + expect(res.text).toEqual('Unauthorized') + expect(res.body).toEqual({}) + }) + + it('should respond with Unauthorized when access token is not of an admin account or himself', async () => { + const dbUser1 = await controller.createUser(user) + const dbUser2 = await controller.createUser({ + ...user, + username: 'randomUser' + }) + const accessToken = await generateAndSaveToken(dbUser2.id) + + const res = await request(app) + .delete(`/SASjsApi/user/by/username/${dbUser1.username}`) + .auth(accessToken, { type: 'bearer' }) + .send(user) + .expect(401) + + expect(res.text).toEqual('Admin account required') + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden when user himself requests and password is incorrect', async () => { + const dbUser = await controller.createUser(user) + const accessToken = await generateAndSaveToken(dbUser.id) + + const res = await request(app) + .delete(`/SASjsApi/user/by/username/${dbUser.username}`) + .auth(accessToken, { type: 'bearer' }) + .send({ password: 'incorrectpassword' }) + .expect(403) + + expect(res.text).toEqual('Error: Invalid password.') + expect(res.body).toEqual({}) + }) + }) }) describe('get', () => { @@ -455,6 +634,86 @@ describe('user', () => { expect(res.text).toEqual('Error: User is not found.') expect(res.body).toEqual({}) }) + + describe('by username', () => { + it('should respond with user autoExec when same user requests', async () => { + const dbUser = await controller.createUser(user) + const userId = dbUser.id + const accessToken = await generateAndSaveToken(userId) + + const res = await request(app) + .get(`/SASjsApi/user/by/username/${dbUser.username}`) + .auth(accessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.body.username).toEqual(user.username) + expect(res.body.displayName).toEqual(user.displayName) + expect(res.body.isAdmin).toEqual(user.isAdmin) + expect(res.body.isActive).toEqual(user.isActive) + expect(res.body.autoExec).toEqual(user.autoExec) + }) + + it('should respond with user autoExec when admin user requests', async () => { + const dbUser = await controller.createUser(user) + + const res = await request(app) + .get(`/SASjsApi/user/by/username/${dbUser.username}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.body.username).toEqual(user.username) + expect(res.body.displayName).toEqual(user.displayName) + expect(res.body.isAdmin).toEqual(user.isAdmin) + expect(res.body.isActive).toEqual(user.isActive) + expect(res.body.autoExec).toEqual(user.autoExec) + }) + + it('should respond with user when access token is not of an admin account', async () => { + const accessToken = await generateSaveTokenAndCreateUser({ + ...user, + username: 'randomUser' + }) + + const dbUser = await controller.createUser(user) + + const res = await request(app) + .get(`/SASjsApi/user/by/username/${dbUser.username}`) + .auth(accessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.body.username).toEqual(user.username) + expect(res.body.displayName).toEqual(user.displayName) + expect(res.body.isAdmin).toEqual(user.isAdmin) + expect(res.body.isActive).toEqual(user.isActive) + expect(res.body.autoExec).toBeUndefined() + }) + + it('should respond with Unauthorized if access token is not present', async () => { + const res = await request(app) + .get('/SASjsApi/user/by/username/randomUsername') + .send() + .expect(401) + + expect(res.text).toEqual('Unauthorized') + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden if username is incorrect', async () => { + await controller.createUser(user) + + const res = await request(app) + .get('/SASjsApi/user/by/username/randomUsername') + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(403) + + expect(res.text).toEqual('Error: User is not found.') + expect(res.body).toEqual({}) + }) + }) }) describe('getAll', () => { diff --git a/api/src/routes/api/user.ts b/api/src/routes/api/user.ts index fcd6d5e..20ce88c 100644 --- a/api/src/routes/api/user.ts +++ b/api/src/routes/api/user.ts @@ -7,6 +7,7 @@ import { } from '../../middlewares' import { deleteUserValidation, + getUserValidation, registerUserValidation, updateUserValidation } from '../../utils' @@ -36,6 +37,25 @@ userRouter.get('/', authenticateAccessToken, async (req, res) => { } }) +userRouter.get( + '/by/username/:username', + authenticateAccessToken, + async (req, res) => { + const { error, value: params } = getUserValidation(req.params) + if (error) return res.status(400).send(error.details[0].message) + + const { username } = params + + const controller = new UserController() + try { + const response = await controller.getUserByUsername(req, username) + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } + } +) + userRouter.get('/:userId', authenticateAccessToken, async (req, res) => { const { userId } = req.params @@ -48,6 +68,34 @@ userRouter.get('/:userId', authenticateAccessToken, async (req, res) => { } }) +userRouter.patch( + '/by/username/:username', + authenticateAccessToken, + verifyAdminIfNeeded, + async (req, res) => { + const { user } = req + const { error: errorUsername, value: params } = getUserValidation( + req.params + ) + if (errorUsername) + return res.status(400).send(errorUsername.details[0].message) + + const { username } = params + + // only an admin can update `isActive` and `isAdmin` fields + const { error, value: body } = updateUserValidation(req.body, user!.isAdmin) + if (error) return res.status(400).send(error.details[0].message) + + const controller = new UserController() + try { + const response = await controller.updateUserByUsername(username, body) + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } + } +) + userRouter.patch( '/:userId', authenticateAccessToken, @@ -70,6 +118,34 @@ userRouter.patch( } ) +userRouter.delete( + '/by/username/:username', + authenticateAccessToken, + verifyAdminIfNeeded, + async (req, res) => { + const { user } = req + const { error: errorUsername, value: params } = getUserValidation( + req.params + ) + if (errorUsername) + return res.status(400).send(errorUsername.details[0].message) + + const { username } = params + + // only an admin can delete user without providing password + const { error, value: data } = deleteUserValidation(req.body, user!.isAdmin) + if (error) return res.status(400).send(error.details[0].message) + + const controller = new UserController() + try { + await controller.deleteUserByUsername(username, data, user!.isAdmin) + res.status(200).send('Account Deleted!') + } catch (err: any) { + res.status(403).send(err.toString()) + } + } +) + userRouter.delete( '/:userId', authenticateAccessToken, diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index 33a403f..3499715 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -5,6 +5,11 @@ const passwordSchema = Joi.string().min(6).max(1024) export const blockFileRegex = /\.(exe|sh|htaccess)$/i +export const getUserValidation = (data: any): Joi.ValidationResult => + Joi.object({ + username: usernameSchema.required() + }).validate(data) + export const loginWebValidation = (data: any): Joi.ValidationResult => Joi.object({ username: usernameSchema.required(),