Skip to content

Commit

Permalink
Merge pull request #58 from CS3219-AY2425S1/shishir/reset-user-pw
Browse files Browse the repository at this point in the history
Implement Password Reset/Update Functionality
  • Loading branch information
shishirbychapur authored Sep 23, 2024
2 parents c5e772c + 10aa4c0 commit 1870a7e
Show file tree
Hide file tree
Showing 29 changed files with 620 additions and 47 deletions.
23 changes: 23 additions & 0 deletions backend/user-service/__tests__/routes/user.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,27 @@ describe('User Routes', () => {
expect(response.status).toBe(400)
})
})

describe('PUT /users/:id/password', () => {
it('should return 200 for successful update', async () => {
const user1 = await request(app).post('/users').send(CREATE_USER_DTO1)
const response = await request(app).put(`/users/${user1.body.id}/password`).send({
password: 'Test12345!',
})
expect(response.status).toBe(200)
expect(response.body.password).not.toEqual(user1.body.password)
})
it('should return 400 for requests with invalid ids', async () => {
const response = await request(app).put('/users/111/password').send()
expect(response.status).toBe(400)
})
it('should return 400 for requests with invalid passwords', async () => {
const user1 = await request(app).post('/users').send(CREATE_USER_DTO1)
const response = await request(app).put(`/users/${user1.body.id}/password`).send({
password: 'Test1234',
})
expect(response.status).toBe(400)
expect(response.body).toHaveLength(1)
})
})
})
5 changes: 3 additions & 2 deletions backend/user-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.6.3",
"nodemailer": "^6.9.15",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"winston": "^3.14.2"
Expand All @@ -36,6 +37,7 @@
"@types/jest": "^29.5.13",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.5.5",
"@types/nodemailer": "^6.4.16",
"@types/passport": "^1.0.16",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.2",
Expand All @@ -44,7 +46,6 @@
"nodemon": "^3.1.4",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
"ts-node": "^10.9.2"
}
}
65 changes: 65 additions & 0 deletions backend/user-service/public/passwordResetEmail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Password Reset Request - PeerPrep</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
padding: 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
color: #333333;
font-size: 24px;
margin-bottom: 20px;
}
p {
color: #666666;
font-size: 16px;
}
.otp {
display: inline-block;
padding: 10px 20px;
background-color: #f7f7f7;
color: #333333;
font-size: 24px;
font-weight: bold;
border: 1px solid #dddddd;
border-radius: 5px;
letter-spacing: 5px;
}
.footer {
margin-top: 20px;
font-size: 12px;
color: #999999;
}
</style>
</head>
<body>
<div class="container">
<h1>Password Reset Request - PeerPrep</h1>
<p>Hello,</p>
<p>
We received a request to reset the password for your account. Please use the following One-Time Password
(OTP) to complete the process:
</p>
<div class="otp">{otp}</div>
<p>If you didn't request a password reset, you can safely ignore this email.</p>
<div class="footer">
<p>If you have any questions, feel free to contact us at peerprep.group31@gmail.com</p>
</div>
</div>
</body>
</html>
106 changes: 101 additions & 5 deletions backend/user-service/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { compare, hash } from 'bcrypt'
import * as path from 'path'

import { Request, Response } from 'express'
import { sign, SignOptions } from 'jsonwebtoken'
import { IVerifyOptions } from 'passport-local'
import config from '../common/config.util'
import { findOneUserByEmail, findOneUserByUsername } from '../models/user.repository'
import { SignOptions, sign } from 'jsonwebtoken'
import { compare, hash } from 'bcrypt'
import { findOneUserByEmail, findOneUserByUsername, updateUser } from '../models/user.repository'

import { EmailVerificationDto } from '../types/EmailVerificationDto'
import { IAccessTokenPayload } from '../types/IAccessTokenPayload'
import { IVerifyOptions } from 'passport-local'
import { Role } from '../types/Role'
import { TypedRequest } from '../types/TypedRequest'
import { UserDto } from '../types/UserDto'
import { ValidationError } from 'class-validator'
import config from '../common/config.util'
import { promises as fs } from 'fs'
import nodemailer from 'nodemailer'

export async function handleAuthentication(
usernameOrEmail: string,
Expand Down Expand Up @@ -67,3 +75,91 @@ export async function hashPassword(password: string): Promise<string> {
const saltRounds = 10
return hash(password, saltRounds)
}

export async function getHTMLTemplate(htmlFilePath: string): Promise<string> {
const filePath = path.join(__dirname, htmlFilePath)
return await fs.readFile(filePath, 'utf8')
}

export async function sendMail(to: string, subject: string, text: string, html: string): Promise<void> {
const transporter = nodemailer.createTransport({
service: 'gmail',
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.NODEMAILER_EMAIL,
pass: process.env.NODEMAILER_PASSWORD,
},
})
await transporter.sendMail({
from: process.env.NODEMAILER_EMAIL,
to,
subject,
text,
html,
})
}

export function generateOTP(): string {
return Math.floor(100000 + Math.random() * 900000).toString()
}

export async function handleReset(request: TypedRequest<EmailVerificationDto>, response: Response): Promise<void> {
const createDto = EmailVerificationDto.fromRequest(request)
createDto.verificationToken = '0'
const errors = await createDto.validate()
if (errors.length) {
const errorMessages = errors.map((error: ValidationError) => `INVALID_${error.property.toUpperCase()}`)
response.status(400).json(errorMessages).send()
return
}

const user = await findOneUserByEmail(createDto.email)

if (!user) {
response.status(404).json('USER_NOT_FOUND').send()
return
}
if (user.verificationToken !== '0') {
response.status(400).json('TOKEN_ALREADY_SENT').send()
return
}

const otp = generateOTP()
createDto.verificationToken = otp

await updateUser(user.id, createDto)

const htmlFile = await getHTMLTemplate('../../public/passwordResetEmail.html')
await sendMail(user.email, 'Password Reset Request', 'PeerPrep', htmlFile.replace('{otp}', otp))

response.status(200).send()
}

export async function handleVerify(request: TypedRequest<EmailVerificationDto>, response: Response): Promise<void> {
const createDto = EmailVerificationDto.fromRequest(request)
const errors = await createDto.validate()
if (errors.length) {
const errorMessages = errors.map((error: ValidationError) => `INVALID_${error.property.toUpperCase()}`)
response.status(400).json(errorMessages).send()
return
}

const user = await findOneUserByEmail(createDto.email)

if (!user) {
response.status(404).json('USER_NOT_FOUND').send()
return
}

if (user.verificationToken == '0' || user.verificationToken !== createDto.verificationToken) {
response.status(400).json('INVALID_OTP').send()
return
}

createDto.verificationToken = '0'
await updateUser(user.id, createDto)

response.status(200).send()
}
17 changes: 17 additions & 0 deletions backend/user-service/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CreateUserDto } from '../types/CreateUserDto'
import { Response } from 'express'
import { TypedRequest } from '../types/TypedRequest'
import { UserDto } from '../types/UserDto'
import { UserPasswordDto } from '../types/UserPasswordDto'
import { UserProfileDto } from '../types/UserProfileDto'
import { ValidationError } from 'class-validator'
import { hashPassword } from './auth.controller'
Expand Down Expand Up @@ -83,3 +84,19 @@ export async function handleDeleteUser(request: TypedRequest<void>, response: Re
await deleteUser(id)
response.status(200).send()
}

export async function handleUpdatePassword(request: TypedRequest<UserPasswordDto>, response: Response): Promise<void> {
const createDto = UserPasswordDto.fromRequest(request)
const errors = await createDto.validate()
if (errors.length) {
const errorMessages = errors.map((error: ValidationError) => `INVALID_${error.property.toUpperCase()}`)
response.status(400).json(errorMessages).send()
return
}

const id = request.params.id
createDto.password = await hashPassword(createDto.password)

const user = await updateUser(id, createDto)
response.status(200).json(user).send()
}
10 changes: 6 additions & 4 deletions backend/user-service/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import cors from 'cors'
import express, { Express, NextFunction, Request, Response } from 'express'
import 'express-async-errors'

import express, { Express, NextFunction, Request, Response } from 'express'

import authRouter from './routes/auth.routes'
import cors from 'cors'
import defaultErrorHandler from './middlewares/errorHandler.middleware'
import helmet from 'helmet'
import passport from 'passport'
import defaultErrorHandler from './middlewares/errorHandler.middleware'
import authRouter from './routes/auth.routes'
import userRouter from './routes/user.routes'

const app: Express = express()
Expand Down
8 changes: 4 additions & 4 deletions backend/user-service/src/models/user.model.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Schema } from 'mongoose'
import { IUser } from '../types/IUser'
import { Proficiency } from '../types/Proficiency'
import { Role } from '../types/Role'
import { Schema } from 'mongoose'

export default new Schema<IUser>({
username: {
Expand Down Expand Up @@ -37,9 +37,9 @@ export default new Schema<IUser>({
required: false,
default: null,
},
deletedAt: {
type: Date,
verificationToken: {
type: String,
required: false,
default: null,
default: '0',
},
})
7 changes: 6 additions & 1 deletion backend/user-service/src/models/user.repository.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Model, model } from 'mongoose'

import { CreateUserDto } from '../types/CreateUserDto'
import { EmailVerificationDto } from '../types/EmailVerificationDto'
import { IUser } from '../types/IUser'
import { UserDto } from '../types/UserDto'
import { UserPasswordDto } from '../types/UserPasswordDto'
import { UserProfileDto } from '../types/UserProfileDto'
import userSchema from './user.model'

Expand All @@ -28,7 +30,10 @@ export async function createUser(dto: CreateUserDto): Promise<IUser> {
return userModel.create(dto)
}

export async function updateUser(id: string, dto: UserDto | UserProfileDto): Promise<IUser | null> {
export async function updateUser(
id: string,
dto: UserDto | UserProfileDto | UserPasswordDto | EmailVerificationDto
): Promise<IUser | null> {
return userModel.findByIdAndUpdate(id, dto, { new: true })
}

Expand Down
7 changes: 5 additions & 2 deletions backend/user-service/src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { handleAuthentication, handleLogin, handleReset, handleVerify } from '../controllers/auth.controller'

import { Strategy as LocalStrategy } from 'passport-local'
import { Router } from 'express'
import passport from 'passport'
import { Strategy as LocalStrategy } from 'passport-local'
import { handleAuthentication, handleLogin } from '../controllers/auth.controller'

passport.use(
'local',
Expand All @@ -19,5 +20,7 @@ passport.use(
const router = Router()

router.post('/login', passport.authenticate('local', { session: false }), handleLogin)
router.post('/reset', handleReset)
router.post('/verify', handleVerify)

export default router
4 changes: 3 additions & 1 deletion backend/user-service/src/routes/user.routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
handleCreateUser,
handleGetCurrentProfile,
handleDeleteUser,
handleGetCurrentProfile,
handleUpdatePassword,
handleUpdateProfile,
} from '../controllers/user.controller'

Expand All @@ -13,5 +14,6 @@ router.post('/', handleCreateUser)
router.put('/:id', handleUpdateProfile)
router.get('/:id', handleGetCurrentProfile)
router.delete('/:id', handleDeleteUser)
router.put('/:id/password', handleUpdatePassword)

export default router
Loading

0 comments on commit 1870a7e

Please sign in to comment.