From 8f93e1ab7d4022e19e7100215f957f9fe0110861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BCller?= Date: Sat, 30 Dec 2023 22:50:47 +0100 Subject: [PATCH] Add password reset endpoints and mail service for user activation --- package-lock.json | 40 ++- package.json | 2 + src/common/common.mail.nodemailer.ts | 37 +++ src/models/calendar/users/users.router.ts | 268 ++++++++++++++++++++- src/models/calendar/users/users.service.ts | 106 +++++++- 5 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 src/common/common.mail.nodemailer.ts diff --git a/package-lock.json b/package-lock.json index 0995e61..243e020 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,10 @@ "cors": "^2.8.5", "debug": "^4.3.1", "dotenv": "^8.2.0", - "express": "^4.17.1", + "express": "^4.18.2", "guid-typescript": "^1.0.9", "mariadb": "^3.0.2", + "nodemailer": "^6.9.8", "random-words": "^1.1.1", "swagger-jsdoc": "^6.1.0", "swagger-ui-express": "^4.3.0", @@ -27,8 +28,10 @@ "@types/app-root-path": "^1.2.4", "@types/bcrypt": "^3.0.1", "@types/debug": "^4.1.5", - "@types/express": "^4.17.11", + "@types/express": "^4.17.15", "@types/jest": "^28.1.3", + "@types/node": "^18.11.17", + "@types/nodemailer": "^6.4.14", "@types/random-words": "^1.1.2", "@types/swagger-jsdoc": "^6.0.1", "@types/swagger-ui-express": "^4.1.3", @@ -39,7 +42,7 @@ "source-map-support": "^0.5.19", "ts-jest": "^28.0.5", "tslint": "^6.1.3", - "typescript": "^4.1.5" + "typescript": "^4.9.4" } }, "node_modules/@ampproject/remapping": { @@ -1272,6 +1275,15 @@ "integrity": "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==", "dev": true }, + "node_modules/@types/nodemailer": { + "version": "6.4.14", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz", + "integrity": "sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prettier": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", @@ -4020,6 +4032,14 @@ "integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==", "dev": true }, + "node_modules/nodemailer": { + "version": "6.9.8", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.8.tgz", + "integrity": "sha512-cfrYUk16e67Ks051i4CntM9kshRYei1/o/Gi8K1d+R34OIs21xdFnW7Pt7EucmVKA0LKtqUGNcjMZ7ehjl49mQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -6592,6 +6612,15 @@ "integrity": "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==", "dev": true }, + "@types/nodemailer": { + "version": "6.4.14", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz", + "integrity": "sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/prettier": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", @@ -8691,6 +8720,11 @@ "integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==", "dev": true }, + "nodemailer": { + "version": "6.9.8", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.8.tgz", + "integrity": "sha512-cfrYUk16e67Ks051i4CntM9kshRYei1/o/Gi8K1d+R34OIs21xdFnW7Pt7EucmVKA0LKtqUGNcjMZ7ehjl49mQ==" + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", diff --git a/package.json b/package.json index 2daa028..1706a36 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "express": "^4.18.2", "guid-typescript": "^1.0.9", "mariadb": "^3.0.2", + "nodemailer": "^6.9.8", "random-words": "^1.1.1", "swagger-jsdoc": "^6.1.0", "swagger-ui-express": "^4.3.0", @@ -34,6 +35,7 @@ "@types/express": "^4.17.15", "@types/jest": "^28.1.3", "@types/node": "^18.11.17", + "@types/nodemailer": "^6.4.14", "@types/random-words": "^1.1.2", "@types/swagger-jsdoc": "^6.0.1", "@types/swagger-ui-express": "^4.1.3", diff --git a/src/common/common.mail.nodemailer.ts b/src/common/common.mail.nodemailer.ts new file mode 100644 index 0000000..4257dba --- /dev/null +++ b/src/common/common.mail.nodemailer.ts @@ -0,0 +1,37 @@ +import * as nodemailer from 'nodemailer'; + +export namespace MailService { + const transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + pool: true, + port: 465, + secure: true, + auth: { + user: process.env.EMAIL_USERNAME, + pass: process.env.EMAIL_PASSWORD + } + }); + + const mailConfigurations = { + + // It should be a string of sender email + from: 'noreply@nachklang.art', + + // Comma Separated list of mails + to: 'mail@pmueller.me', + + // Subject of Email + subject: '', + + // This would be the text of email body + text: '' + }; + + export const sendMail = async (recipientAddress: string, subject: string, body: string) => { + mailConfigurations.to = recipientAddress; + mailConfigurations.subject = subject; + mailConfigurations.text = body; + + await transporter.sendMail(mailConfigurations); + }; +} \ No newline at end of file diff --git a/src/models/calendar/users/users.router.ts b/src/models/calendar/users/users.router.ts index 2aac8e5..3a9eaef 100644 --- a/src/models/calendar/users/users.router.ts +++ b/src/models/calendar/users/users.router.ts @@ -28,12 +28,19 @@ usersRouter.post('/register', async (req: Request, res: Response) => { const fullName: string = req.body.fullName; const ip: string = req.socket.remoteAddress ?? ''; - if (!password || !email) { + if (!password || !email || !fullName) { // Missing res.status(400).send(JSON.stringify({message: 'Missing parameters'})); return; } + const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/; + + if(!emailRegex.test(email)) { + res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'})); + return; + } + // Create the user and a session const session: Session = await UserService.createUser(email, password, fullName, ip); @@ -53,6 +60,41 @@ usersRouter.post('/register', async (req: Request, res: Response) => { } }); +// POST /users/activate +usersRouter.post('/activate', async (req: Request, res: Response) => { + try { + const userId: number = parseInt(req.query.id as string ?? '-1', 10); + const token: string = req.query.token as string ?? ''; + + if (!userId || !token) { + // Missing + res.status(400).send(JSON.stringify({message: 'Missing parameters'})); + return; + } + + // Create the user and a session + const success: boolean = await UserService.activateUser(userId, token); + + // Send the session details back to the user + if(success) { + res.status(200).send({ + 'status': 'OK', + 'message': 'User activated' + }); + return; + } + res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'}); + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); + } +}); + // POST users/login usersRouter.post('/login', async (req: Request, res: Response) => { try { @@ -123,3 +165,227 @@ usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => { }); } }); + +/** + * @swagger + * /calendar/users/initiatePasswordReset: + * post: + * summary: Initiates a password reset + * description: Checks if the user exists and if so, initiates a password reset by sending an email to the user. + * tags: + * - calendar + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * type: object + * properties: + * messages: + * type: array + * items: + * type: string + * example: Success + * description: A list of status messages + * 400: + * description: Problem with the request. Please consider the returned detailed error. + * content: + * application/json: + * schema: + * type: object + * properties: + * messages: + * type: array + * items: + * type: string + * example: Missing parameters + * description: A list of error messages + * 401: + * description: Problem with authorizing the user. Please check the provided credentials. + * content: + * application/json: + * schema: + * type: object + * properties: + * messages: + * type: array + * items: + * type: string + * example: Invalid session + * description: A list of error messages + * 500: + * description: A server error occurred. Please try again. If this issue persists, contact the admin. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * description: The response status + * example: PROCESSING_ERROR + * message: + * type: string + * description: The detailed error message + * example: Internal Server Error. Try again later. + * reference: + * type: string + * description: An error reference for getting support concerning this error. + * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * example: patrick@nachklang.art + */ +usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => { + try { + const username = req.body.username; + + if (!username) { + // Error logging in, probably wrong username / password + res.status(400).send(JSON.stringify({messages: ['No username given']})); + return; + } + + const success: boolean = await UserService.initiatePasswordReset(username); + + if (!success) { + // Error logging in, probably wrong username / password + res.status(401).send(JSON.stringify({messages: ['Error']})); + return; + } + + res.status(200).send(JSON.stringify({messages: ['Success']})); + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); + } +}); + +/** + * @swagger + * /calendar/users/finalizePasswordReset: + * post: + * summary: Finalizes the password reset + * description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password. + * tags: + * - calendar + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * type: object + * properties: + * messages: + * type: array + * items: + * type: string + * example: Success + * description: A list of status messages + * 400: + * description: Problem with the request. Please consider the returned detailed error. + * content: + * application/json: + * schema: + * type: object + * properties: + * messages: + * type: array + * items: + * type: string + * example: Missing parameters + * description: A list of error messages + * 401: + * description: Problem with authorizing the user. Please check the provided credentials. + * content: + * application/json: + * schema: + * type: object + * properties: + * messages: + * type: array + * items: + * type: string + * example: Invalid session + * description: A list of error messages + * 500: + * description: A server error occurred. Please try again. If this issue persists, contact the admin. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * description: The response status + * example: PROCESSING_ERROR + * message: + * type: string + * description: The detailed error message + * example: Internal Server Error. Try again later. + * reference: + * type: string + * description: An error reference for getting support concerning this error. + * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * example: patrick@nachklang.art + * token: + * type: string + * example: 3ccd147f-720b-4e29-a8b7-46b63de31555 + * password: + * type: string + * example: ExtremelyBadPassword + */ +usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => { + try { + const username = req.body.username; + const token = req.body.token; + const newPassword = req.body.password; + + if (!username) { + // Error logging in, probably wrong username / password + res.status(400).send(JSON.stringify({messages: ['No username, token or password given']})); + return; + } + + const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword); + + if (!success) { + // Error logging in, probably wrong username / password + res.status(401).send(JSON.stringify({messages: ['Error']})); + return; + } + + res.status(200).send(JSON.stringify({messages: ['Success']})); + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); + } +}); diff --git a/src/models/calendar/users/users.service.ts b/src/models/calendar/users/users.service.ts index 68c98a1..58ae407 100644 --- a/src/models/calendar/users/users.service.ts +++ b/src/models/calendar/users/users.service.ts @@ -4,6 +4,7 @@ import {Guid} from 'guid-typescript'; import {User} from './user.interface'; import {Session} from './session.interface'; import {NachklangCalendarDB} from '../Calendar.db'; +import {MailService} from "../../../common/common.mail.nodemailer"; dotenv.config(); @@ -27,9 +28,11 @@ export const createUser = async (email: string, password: string, fullName: stri const sessionKey = Guid.create().toString(); const sessionKeyHash = bcrypt.hashSync(sessionKey, 10); + const activationToken = Guid.create().toString(); + // Create user entry in SQL - const userQuery = 'INSERT INTO users (email, password_hash, full_name) VALUES (?, ?, ?) RETURNING user_id'; - const userIdRes = await conn.query(userQuery, [email, pwHash, fullName]); + const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id'; + const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationToken]); // Get user id of the created user let userId: number = -1; @@ -37,6 +40,9 @@ export const createUser = async (email: string, password: string, fullName: stri userId = row.user_id; } + // Send email with activation link + await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`); + // Create session const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id'; const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]); @@ -63,6 +69,29 @@ export const createUser = async (email: string, password: string, fullName: stri } }; +export const activateUser = async (userId: number, token: string): Promise => { + let conn = await NachklangCalendarDB.getConnection(); + try { + const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0'; + const userNameRes = await conn.query(checkTokenQuery, [userId]); + let storedToken = ''; + for (const row of userNameRes) { + storedToken = row.activation_token; + } + if (storedToken!== token) { + return false; + } + const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id =?'; + const activateRes = await conn.execute(activateQuery, [userId]); + return activateRes.affectedRows !== 0; + } catch (err) { + throw err; + } finally { + // Return connection + await conn.end(); + } +} + /** * Checks if the given credentials are valid and creates a new session if they are. * Returns the session information in case of a successful login @@ -180,3 +209,76 @@ export const checkSession = async (sessionId: string, sessionKey: string, ip: st await conn.end(); } }; + +export const initiatePasswordReset = async (email: string): Promise => { + let conn = await NachklangCalendarDB.getConnection(); + try { + const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?'; + const userNameRes = await conn.query(checkUsernameQuery, [email]); + if (userNameRes.length === 0) { + return false; + } + let userId: number = -1; + let fullName: string = ''; + for(let row of userNameRes) { + userId = row.user_id; + fullName = row.full_Name; + } + + let resetToken = Guid.create().toString(); + let resetTokenHash = bcrypt.hashSync(resetToken, 10); + + const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?'; + const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]); + + if(updateRes.affectedRows === 0) { + return false; + } + await conn.commit(); + + await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`); + return true; + } catch (err) { + throw err; + } finally { + // Return connection + await conn.end(); + } +} + +export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise => { + let conn = await NachklangCalendarDB.getConnection(); + try { + const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?'; + const userNameRes = await conn.query(checkTokenQuery, [email]); + if (userNameRes.length === 0) { + return false; + } + let userId: string = ''; + let tokenHash: string = ''; + for(let row of userNameRes) { + userId = row.user_id; + tokenHash = row.pw_reset_token_hash; + } + + if(!bcrypt.compareSync(token, tokenHash)) { + return false; + } + + const pwHash = bcrypt.hashSync(newPassword, 10); + const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?'; + const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]); + + if(updateRes.affectedRows > 0) { + await conn.commit(); + return true; + } + + return false; + } catch (err) { + throw err; + } finally { + // Return connection + await conn.end(); + } +}