diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml deleted file mode 100644 index 1fdcb05..0000000 --- a/.github/workflows/CI.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: CI for taskMaster Project - -on: - push: - branches: ['develop'] - pull_request: - branches: ['develop'] - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [20.x] - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run build - - run: npm run test:ci - - run: npm run lint - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4.0.1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: jkarenzi/task-master-be - directory: coverage/ diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..d990997 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,50 @@ +name: CI for taskMaster Project + +on: + push: + branches: ['develop'] + pull_request: + branches: ['develop'] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run build + + - name: Set up Docker Compose + run: sudo apt-get install docker-compose + + - name: Build and run tests + run: docker-compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from test + + - run: npm run lint + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: jkarenzi/task-master-be + directory: coverage/ + - name: Spin down docker containers + if: always() + run: docker-compose -f docker-compose.test.yml down + env: + JWT_SECRET: ${{ secrets.JWT_SECRET }} + EMAIL: ${{ secrets.EMAIL }} + EMAIL_PASS: ${{ secrets.EMAIL_PASS }} + MONGO_URL: ${{ secrets.MONGO_URL }} + DB_NAME: ${{ secrets.DB_NAME }} + MONGO_INITDB_ROOT_USERNAME: ${{ secrets.MONGO_INITDB_ROOT_USERNAME }} + MONGO_INITDB_ROOT_PASSWORD: ${{ secrets.MONGO_INITDB_ROOT_PASSWORD }} diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..766f3ee --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,10 @@ +FROM node:latest + +WORKDIR /usr/src/app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +CMD ["npm", "run", "test:ci"] \ No newline at end of file diff --git a/__tests__/authController.test.ts b/__tests__/authController.test.ts index becb2ef..beccd6b 100644 --- a/__tests__/authController.test.ts +++ b/__tests__/authController.test.ts @@ -1,134 +1,227 @@ export {}; const request = require('supertest'); const app = require('../src/app'); -const bcrypt = require('bcrypt'); -const { - signUpSchema, - loginSchema, -} = require('../src/middleware/validators/authSchema'); -const jwt = require('jsonwebtoken') +require('dotenv').config() const User = require('../src/models/User'); +const sendEmail = require('../src/utils/sendEmail') +const jwt = require('jsonwebtoken') +import mongoose, {Document} from 'mongoose' +const {connectDB, disconnectDB, getToken} = require('./testSetup') -jest.mock('../src/models/User'); -jest.mock('../src/middleware/validators/authSchema'); -jest.mock('bcrypt'); -jest.mock('jsonwebtoken') -describe('Auth Controller Tests', () => { - const signUpFormData = { - fullName: 'test tester', - email: 'test@gmail.com', - password: 'test123456', - }; - - const loginFormData = { - email: 'test@gmail.com', - password: 'test123456', - }; - - const returnedUser = { - _id:'some id', - fullName:'mock user', - email:'mock@gmail.com', - password:'password1234', - createdAt: 'some date', - updatedAt: 'some date' - } +beforeAll(connectDB) +afterAll(disconnectDB) - it('should return a 201 if signup is successful', async () => { - signUpSchema.validate.mockReturnValueOnce({ error: null }); +jest.mock('../src/utils/sendEmail') - User.findOne.mockImplementationOnce(() => Promise.resolve(null)); +interface IUser extends Document { + _id:mongoose.Types.ObjectId, + fullName:string, + email:string, + password:string, + imageUrl:{ + public_id:string, + url?:string + }, + isVerified:boolean, + twoFactorAuth:{ + isEnabled:true, + code: number + }, + role:string +} - bcrypt.hash.mockResolvedValueOnce('hashed password'); - User.prototype.save.mockResolvedValueOnce(returnedUser); +describe('Auth Controller Tests', () => { + let user:IUser; + const signUpFormData = { + fullName: 'test tester', + email: 'test@gmail.com', + password: 'test123456', + }; + + const loginFormData = { + email: 'test@gmail.com', + password: 'test123456', + }; + + const fakeId = '65f47d055e66592dd6c5b7c1' + sendEmail.mockImplementationOnce(() => Promise.resolve({response:'ok'})) + + it('should return a 201 if signup is successful', async () => { const response = await request(app).post('/api/auth/signup').send(signUpFormData); expect(response.status).toBe(201); - expect(User.prototype.save).toHaveBeenCalled(); }); it('should return a 400 if validation fails on signup', async () => { - signUpSchema.validate.mockReturnValueOnce({ error: { details: [{ message: 'Validation failed' }] } }); + const formData = { + fullName: 'test tester', + email: 'testgmail.com', + password: 'test123456', + } - const response = await request(app).post('/api/auth/signup').send(signUpFormData); + const response = await request(app).post('/api/auth/signup').send(formData); expect(response.status).toBe(400); expect(response.body.message).toBeDefined(); }) it('should return a 409 if email already exists', async () => { - signUpSchema.validate.mockReturnValueOnce({ error: null }); - - User.findOne.mockImplementationOnce(() => Promise.resolve({ - _id:'some id', - fullName:'mock user', - email:'mock@gmail.com', - password:'password1234', - createdAt: 'some date', - updatedAt: 'some date' - })); - const response = await request(app).post('/api/auth/signup').send(signUpFormData); expect(response.status).toBe(409); expect(response.body.message).toBe('Email already in use'); }) - it('should return a 500 if an error occurs during signup', async () => { - signUpSchema.validate.mockReturnValueOnce({ error: null }); - User.findOne.mockImplementationOnce(() => {throw new Error('DB error')}) - - const response = await request(app).post('/api/auth/signup').send(signUpFormData); - expect(response.status).toBe(500); - expect(response.body.message).toBe('Internal Server Error'); + it('should return a 200 if login is successful with 2fa disabled', async () => { + const response = await request(app).post('/api/auth/login').send(loginFormData); + expect(response.status).toBe(200); + expect(response.body.message).toBe('Login successful'); }) - it('should return a 200 if login is successful', async () => { - loginSchema.validate.mockReturnValueOnce({ error: null }); - User.findOne.mockImplementationOnce(() => Promise.resolve(returnedUser)) - - bcrypt.compare.mockResolvedValueOnce(true) - - jwt.sign.mockResolvedValueOnce('fake token') - + it('should return a 200 if upon login with 2fa enabled', async () => { + user = await User.findOne({email:loginFormData.email}) + await User.findByIdAndUpdate(user._id,{'twoFactorAuth.isEnabled':true}) const response = await request(app).post('/api/auth/login').send(loginFormData); expect(response.status).toBe(200); - expect(response.body.message).toBe('Login successful'); + expect(response.body.message).toBe('A Two Factor Auth Code has been sent to your email'); }) it('should return a 400 if validation fails on login', async () => { - loginSchema.validate.mockReturnValueOnce({ error: { details: [{ message: 'Validation failed' }] } }); + const formData = { + email:'testgmail.com', + password:'karenzi123456' + } - const response = await request(app).post('/api/auth/login').send(loginFormData); + const response = await request(app).post('/api/auth/login').send(formData); expect(response.status).toBe(400); expect(response.body.message).toBeDefined() }) it('should return a 404 if account if not found during login', async () => { - loginSchema.validate.mockReturnValueOnce({ error: null }); - User.findOne.mockImplementationOnce(() => Promise.resolve(null)) - const response = await request(app).post('/api/auth/login').send(loginFormData); + const formData = { + email:'user@gmail.com', + password:'user123456' + } + + const response = await request(app).post('/api/auth/login').send(formData); expect(response.status).toBe(404); expect(response.body.message).toBe('Account not found'); }) it('should return a 401 if password provided is incorrect, in login', async () => { - loginSchema.validate.mockReturnValueOnce({ error: null }); - User.findOne.mockImplementationOnce(() => Promise.resolve(returnedUser)) - - bcrypt.compare.mockResolvedValueOnce(false) + const formData = { + email:'test@gmail.com', + password:'user123456' + } - const response = await request(app).post('/api/auth/login').send(loginFormData); + const response = await request(app).post('/api/auth/login').send(formData); expect(response.status).toBe(401); expect(response.body.message).toBe('Incorrect password'); }) - it('should return a 500 if an error occurs during login', async () => { - loginSchema.validate.mockReturnValueOnce({ error: null }); - User.findOne.mockImplementationOnce(() => {throw new Error('DB error')}) + // email verification tests + it('should verify email successfully', async () => { + const token = await jwt.sign({_id:user._id}, process.env.JWT_SECRET, {expiresIn:'1h'}) + const response = await request(app).get(`/api/auth/verify_email/${token}`) + expect(response.status).toBe(200); + expect(response.body.message).toBe('Email verification successful'); + }) - const response = await request(app).post('/api/auth/login').send(loginFormData); - expect(response.status).toBe(500); - expect(response.body.message).toBe('Internal Server Error'); + it('should request new email link successfully', async () => { + const token = await getToken() + const response = await request(app).post('/api/auth/request_new_link').set('Authorization', `Bearer ${token}`) + expect(response.status).toBe(200); + expect(response.body.message).toBe('Email sent successfully'); + }) + + it('should return a 400 if user is already verified, upon requesting new link', async () => { + const token = await getToken() + const {_id} = await User.findOne({email:'testing@gmail.com'}) + await User.findByIdAndUpdate(_id,{isVerified:true}) + const response = await request(app).post('/api/auth/request_new_link').set('Authorization', `Bearer ${token}`) + expect(response.status).toBe(400); + expect(response.body.message).toBe('User email is already verified'); + }) + + it('should return a 409 when token is invalid on verify email', async () => { + const token = 'fake token' + const response = await request(app).get(`/api/auth/verify_email/${token}`) + expect(response.status).toBe(409); + expect(response.body.message).toBe('Invalid or expired token'); + }) + + // 2fa tests + it('should verify 2fa code successfully', async () => { + await request(app).post('/api/auth/login').send(loginFormData); + const {twoFactorAuth} = await User.findById(user._id) + + const response = await request(app).post(`/api/auth/verify_code/${user._id.toHexString()}`).send({ + twoFactorCode:twoFactorAuth.code + }) + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Login successful'); + + }) + + it('should request new 2fa code successfully', async () => { + const response = await request(app).post(`/api/auth/request_new_code/${user._id.toHexString()}`) + expect(response.status).toBe(200); + expect(response.body.message).toBe('A Two Factor Auth Code has been sent to your email'); + }) + + it('should return a 409 for failed validation upon verifying 2fa code', async () => { + const response = await request(app).post(`/api/auth/verify_code/${user._id.toHexString()}`).send({ + twoFactorCode:null + }) + + expect(response.status).toBe(409); + expect(response.body.message).toBe('Two Factor Code is required'); + }) + + it('should return a 404 when user is not found upon verifying 2fa code', async () => { + const response = await request(app).post(`/api/auth/verify_code/${fakeId}`).send({ + twoFactorCode:123456 + }) + + expect(response.status).toBe(404); + expect(response.body.message).toBe('User not found'); + }) + + it('should return a 401 when 2fa code provided is incorrect', async () => { + await request(app).post('/api/auth/login').send(loginFormData); + const response = await request(app).post(`/api/auth/verify_code/${user._id.toHexString()}`).send({ + twoFactorCode:1111111 + }) + + expect(response.status).toBe(401); + expect(response.body.message).toBe('Invalid code'); + }) + + it('should return a 404 when user is not found upon requesting new 2fa code', async () => { + const response = await request(app).post(`/api/auth/request_new_code/${fakeId}`) + expect(response.status).toBe(404); + expect(response.body.message).toBe('User not found'); + }) + + it('should return a 401 when user requests new 2fa code but their 2fa is disabled', async () => { + await User.findByIdAndUpdate(user._id,{'twoFactorAuth.isEnabled':false}) + const response = await request(app).post(`/api/auth/request_new_code/${user._id.toHexString()}`) + expect(response.status).toBe(401); + expect(response.body.message).toBe('Please enable two factor authentication before requesting code'); + }) + + it('should update 2fa enabled status', async () => { + const token = await getToken() + const response = await request(app).patch('/api/auth/toggle_2fa').send({status:false}).set('Authorization', `Bearer ${token}`) + expect(response.status).toBe(200); + expect(response.body.message).toBe('Update successful'); + }) + + it('should return a 400 for failed validation upon enabling/disabling 2fa', async () => { + const token = await getToken() + const response = await request(app).patch('/api/auth/toggle_2fa').send({status:'nice'}).set('Authorization', `Bearer ${token}`) + expect(response.status).toBe(400); + expect(response.body.message).toBeDefined() }) }); diff --git a/__tests__/testSetup.ts b/__tests__/testSetup.ts new file mode 100644 index 0000000..638897d --- /dev/null +++ b/__tests__/testSetup.ts @@ -0,0 +1,37 @@ +export {} +const request = require('supertest') +const app = require('../src/app'); +const mongoose = require('mongoose') +require('dotenv').config() + +const url = process.env.MONGO_URL +const dbName = process.env.DB_NAME + + +const connectDB = async () => { + await mongoose.connect(url, {dbName}) + console.log('Mongo db connected') +} + +const disconnectDB = async () => { + await mongoose.connection.close() +} + +const getToken = async() => { + const signupFormData = { + fullName: 'Testing Tester', + email:'testing@gmail.com', + password:'test123456' + } + + const loginFormData = { + email:'testing@gmail.com', + password:'test123456' + } + + await request(app).post('/api/auth/signup').send(signupFormData) + const loginResponse = await request(app).post('/api/auth/login').send(loginFormData) + return loginResponse.body.token +} + +module.exports = {getToken, connectDB, disconnectDB} \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..e576e19 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,24 @@ +version: '3.8' +services: + test: + build: + context: . + dockerfile: Dockerfile.test + environment: + JWT_SECRET: ${JWT_SECRET} + EMAIL: ${EMAIL} + EMAIL_PASS: ${EMAIL_PASS} + MONGO_URL: ${MONGO_URL} + DB_NAME: ${DB_NAME} + depends_on: + - mongo + volumes: + - .:/usr/src/app + - /usr/src/app/node_modules + mongo: + image: mongo:latest + ports: + - "32768:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD} diff --git a/docker-compose.yml b/docker-compose.yml index 4694005..21da904 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,13 @@ version: '3' services: app: - build: . + build: + context: . + dockerfile: Dockerfile ports: - '8000:3000' volumes: - .:/usr/src/app - /usr/src/app/node_modules env_file: - - .env + - .env \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 3745fc2..d5933e2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,4 +2,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + "testTimeout": 50000, + testPathIgnorePatterns: [ + "/__tests__/testSetup.ts" + ] }; diff --git a/package-lock.json b/package-lock.json index 2c1a0b3..acd23eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "handlebars": "^4.7.8", "joi": "^17.12.2", "jsonwebtoken": "^9.0.2", "mongoose": "^8.2.1", @@ -3927,6 +3928,26 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5533,6 +5554,11 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", @@ -6982,6 +7008,18 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.18.0.tgz", + "integrity": "sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -7143,6 +7181,11 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 8d09872..7e291ba 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "ts-node-dev src/server.ts", "lint": "eslint . --ext .ts --fix", "format": "prettier --write .", - "email":"ts-node src/utils/sendEmail.ts" + "email": "ts-node src/utils/sendEmail.ts" }, "repository": { "type": "git", @@ -30,6 +30,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "handlebars": "^4.7.8", "joi": "^17.12.2", "jsonwebtoken": "^9.0.2", "mongoose": "^8.2.1", diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index b02adb9..83c99e1 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -3,7 +3,9 @@ const { errorHandler } = require('../middleware/errorHandler'); const { signUpSchema, loginSchema, + updateTwoFactorAuthSchema } = require('../middleware/validators/authSchema'); +const sendEmail = require('../utils/sendEmail') const User = require('../models/User'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); @@ -40,7 +42,13 @@ const signUp = errorHandler(async (req: Request, res: Response) => { email: formData.email, }); - await newUser.save(); + const savedUser = await newUser.save(); + + const token = await jwt.sign({ _id: savedUser._id }, jwtSecret, { expiresIn: '1h' }); + const verifyLink = `${process.env.APP_URL}/api/auth/verify_email/${token}` + + await sendEmail('verify', formData.email, {name:formData.fullName, link:verifyLink}) + return res .status(201) .json({ status: 'success', message: 'Signup successful!' }); @@ -77,6 +85,13 @@ const login = errorHandler(async (req: Request, res: Response) => { .json({ status: 'error', message: 'Incorrect password' }); } + if(user.twoFactorAuth.isEnabled){ + const twoFactorCode = Math.floor(100000 + Math.random() * 900000); + await User.findByIdAndUpdate(user._id, {'twoFactorAuth.code':twoFactorCode}) + await sendEmail('2fa',user.email,{name:user.fullName, code:twoFactorCode.toString()}) + return res.status(200).json({status:'success',message:'A Two Factor Auth Code has been sent to your email'}) + } + const token = await jwt.sign({ user }, jwtSecret, { expiresIn: '1h' }); return res @@ -84,7 +99,104 @@ const login = errorHandler(async (req: Request, res: Response) => { .json({ status: 'success', message: 'Login successful', token }); }); +const verifyEmail = async (req: Request, res: Response) => { + try{ + const token = req.params.token + const userId = await jwt.verify(token, jwtSecret) + console.log(userId) + + await User.findByIdAndUpdate(userId,{isVerified:true},{new:true}) + return res.status(200).json({status:'success',message:'Email verification successful'}) + }catch(err){ + return res.status(409).json({status:'error',message:'Invalid or expired token'}) + } +} + +const requestVerifyLink = errorHandler(async (req:Request, res:Response) => { + //@ts-expect-error yet to come up with the right type + const userId = req.user._id + const user = await User.findById(userId) + if(user.isVerified){ + return res.status(400).json({status:'error',message:'User email is already verified'}) + } + + const token = await jwt.sign({ _id: user._id }, jwtSecret, { expiresIn: '1h' }); + const verifyLink = `${process.env.APP_URL}/api/auth/verify_email/${token}` + + await sendEmail('verify', user.email, {name:user.fullName, link:verifyLink}) + + return res.status(200).json({status:'success',message:'Email sent successfully'}) +}) + +const verifyTwoFactorCode = errorHandler(async (req:Request,res:Response) => { + const { twoFactorCode } = req.body + const userId = req.params.userId + + if(!twoFactorCode){ + return res.status(409).json({status:'error',message:'Two Factor Code is required'}) + } + + const user = await User.findOne({_id:userId}) + if(!user){ + return res.status(404).json({status:'error',message:'User not found'}) + } + + if(user.twoFactorAuth.code !== parseInt(twoFactorCode)){ + return res.status(401).json({status:'error',message:'Invalid code'}) + } + + await User.findByIdAndUpdate(user._id, {'twoFactorAuth.code':null}) + const token = await jwt.sign({ user }, jwtSecret, { expiresIn: '1h' }); + + return res + .status(200) + .json({ status: 'success', message: 'Login successful', token }); +}) + +const requestTwoFactorCode = errorHandler(async (req:Request, res:Response) => { + const userId = req.params.userId + const user = await User.findOne({_id:userId}) + if(!user){ + return res.status(404).json({status:'error',message:'User not found'}) + } + + if(!user.twoFactorAuth.isEnabled){ + return res.status(401).json({status:'error',message:'Please enable two factor authentication before requesting code'}) + } + + const twoFactorCode = Math.floor(100000 + Math.random() * 900000); + await User.findByIdAndUpdate(user._id, {'twoFactorAuth.code':twoFactorCode}) + await sendEmail('2fa',user.email,{name:user.fullName, code:twoFactorCode.toString()}) + return res.status(200).json({status:'success',message:'A Two Factor Auth Code has been sent to your email'}) +}) + +const toggleTwoFactorAuth = errorHandler(async (req:Request, res:Response) => { + //@ts-expect-error yet to come up with the right type + const userId = req.user._id + const formData = req.body + + const validationResult = updateTwoFactorAuthSchema.validate(formData); + + if (validationResult.error) { + return res + .status(400) + .json({ + status: 'error', + message: validationResult.error.details[0].message, + }); + } + + await User.findByIdAndUpdate(userId, {'twoFactorAuth.isEnabled':formData.status},{new:true}) + + return res.status(200).json({status:'success',message:'Update successful'}) +}) + module.exports = { signUp, login, + verifyEmail, + requestVerifyLink, + verifyTwoFactorCode, + requestTwoFactorCode, + toggleTwoFactorAuth }; diff --git a/src/docs/authDocs.ts b/src/docs/authDocs.ts index 4cabad7..6de3225 100644 --- a/src/docs/authDocs.ts +++ b/src/docs/authDocs.ts @@ -30,7 +30,7 @@ /** * @swagger - * api/auth/login: + * /api/auth/login: * post: * summary: User login * tags: [Authentication] @@ -57,3 +57,127 @@ * '500': * description: Internal Server Error */ + +/** + * @swagger + * /api/auth/verify_email/{token}: + * get: + * summary: Verify Email + * tags: [Authentication] + * parameters: + * - in: path + * name: token + * schema: + * type: string + * required: true + * description: token + * responses: + * '200': + * description: Email successfully verified + * '409': + * description: Invalid or expired token + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/auth/request_new_link: + * post: + * summary: Request new verification link + * tags: [Authentication] + * security: + * - bearerAuth: [] + * responses: + * '200': + * description: Email sent successfully + * '400': + * description: User is already verified + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/auth/verify_code/{userId}: + * post: + * summary: Verify 2FA Code + * tags: [Authentication] + * parameters: + * - in: path + * name: userId + * schema: + * type: string + * required: true + * description: userId + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * twoFactorCode: + * type: number + * responses: + * '200': + * description: Login successful + * '409': + * description: Failed validation + * '404': + * description: User not found + * '401': + * description: Code provided does not match code sent + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/auth/request_new_code/{userId}: + * post: + * summary: Request new 2FA Code + * tags: [Authentication] + * parameters: + * - in: path + * name: userId + * schema: + * type: string + * required: true + * description: userId + * responses: + * '200': + * description: Email sent successfully + * '401': + * description: 2FA is not enabled + * '404': + * description: User not found + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/auth/toggle_2fa: + * patch: + * summary: Enable/Disable 2FA + * tags: [Authentication] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * responses: + * '200': + * description: 2FA status updated successfully + * '400': + * description: Failed validation + * '500': + * description: Internal Server Error + */ \ No newline at end of file diff --git a/src/middleware/validators/authSchema.ts b/src/middleware/validators/authSchema.ts index 065c47e..fc3b64f 100644 --- a/src/middleware/validators/authSchema.ts +++ b/src/middleware/validators/authSchema.ts @@ -26,7 +26,12 @@ const loginSchema = Joi.object({ .required(), }); +const updateTwoFactorAuthSchema = Joi.object({ + status: Joi.boolean().required() +}) + module.exports = { signUpSchema, loginSchema, + updateTwoFactorAuthSchema }; diff --git a/src/models/User.ts b/src/models/User.ts index 030db8b..023087b 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -30,7 +30,7 @@ const UserSchema = new Schema( type: Boolean, default: false, }, - twoFactorCode: { + twoFactorAuth: { type: Object, default: { isEnabled: false, diff --git a/src/routes/authRoutes.ts b/src/routes/authRoutes.ts index 9dc217d..7e26483 100644 --- a/src/routes/authRoutes.ts +++ b/src/routes/authRoutes.ts @@ -1,9 +1,23 @@ -const { signUp, login } = require('../controllers/authController'); +const { + signUp, + login, + verifyEmail, + requestVerifyLink, + verifyTwoFactorCode, + requestTwoFactorCode, + toggleTwoFactorAuth +} = require('../controllers/authController'); +const authenticateToken = require('../middleware/authenticateToken') import { Router } from 'express'; const authRouter = Router(); authRouter.post('/signup', signUp); authRouter.post('/login', login); +authRouter.get('/verify_email/:token', verifyEmail) +authRouter.post('/verify_code/:userId', verifyTwoFactorCode) +authRouter.post('/request_new_link', authenticateToken, requestVerifyLink) +authRouter.post('/request_new_code/:userId', requestTwoFactorCode) +authRouter.patch('/toggle_2fa', authenticateToken, toggleTwoFactorAuth) module.exports = authRouter; diff --git a/src/utils/emailTemplates/2fa.html b/src/utils/emailTemplates/2fa.html new file mode 100644 index 0000000..cf6d2d3 --- /dev/null +++ b/src/utils/emailTemplates/2fa.html @@ -0,0 +1,11 @@ + + +
+ + +