Skip to content

Commit

Permalink
Merge pull request #28 from atlp-rwanda/ft-2fa-authentication-#187419173
Browse files Browse the repository at this point in the history
 #23187419173 - ft:sets up Two Factor Authentication for Sellers
  • Loading branch information
teerenzo authored Apr 26, 2024
2 parents bd87282 + 3361d25 commit bc4be51
Show file tree
Hide file tree
Showing 18 changed files with 308 additions and 83 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ jobs:
env:
DB_CONNECTION: ${{ secrets.DB_CONNECTION }}
TEST_DB: ${{ secrets.TEST_DB }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASS: ${{ secrets.SMTP_PASS }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
run: npm run test

- name: Build application
Expand Down
64 changes: 40 additions & 24 deletions __test__/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import app from "../src/utils/server";
import User from "../src/sequelize/models/users";
import * as userServices from "../src/services/user.service";
import sequelize, { connect } from "../src/config/dbConnection";
import * as twoFAService from "../src/utils/2fa";

const userData: any = {
name: "yvanna",
Expand All @@ -12,6 +13,13 @@ const userData: any = {
password: "test1234",
};

const dummySeller = {
name: "dummy",
username: "username",
email: "[email protected]",
password: "1234567890",
isMerchant: true,
};
const userTestData = {
newPassword: "Test@123",
confirmPassword: "Test@123",
Expand All @@ -26,30 +34,28 @@ describe("Testing user Routes", () => {
beforeAll(async () => {
try {
await connect();
await User.destroy({ truncate: true });
const dummy = await request(app).post("/api/v1/users/register").send(dummySeller);
} catch (error) {
throw error;
sequelize.close();
}
}, 40000);

afterAll(async () => {
await User.destroy({ truncate: true });
await sequelize.close();
});
}, 20000);

let token: any;
describe("Testing user authentication", () => {
test("should return 201 and create a new user when registering successfully", async () => {
const response = await request(app)
.post("/api/v1/users/register")
.send(userData);
const response = await request(app).post("/api/v1/users/register").send(userData);
expect(response.status).toBe(201);
}, 20000);

test("should return 409 when registering with an existing email", async () => {
User.create(userData);
const response = await request(app)
.post("/api/v1/users/register")
.send(userData);
const response = await request(app).post("/api/v1/users/register").send(userData);
expect(response.status).toBe(409);
}, 20000);

Expand All @@ -59,9 +65,7 @@ describe("Testing user Routes", () => {
name: "",
username: "existinguser",
};
const response = await request(app)
.post("/api/v1/users/register")
.send(userData);
const response = await request(app).post("/api/v1/users/register").send(userData);

expect(response.status).toBe(400);
}, 20000);
Expand All @@ -74,22 +78,36 @@ describe("Testing user Routes", () => {
expect(spy).toHaveBeenCalled();
expect(spy2).toHaveBeenCalled();
}, 20000);

test("Should return status 401 to indicate Unauthorized user", async () => {
const loggedInUser = {
email: userData.email,
password: "test",
password: "test123456",
};
const spyonOne = jest.spyOn(User, "findOne").mockResolvedValueOnce({
//@ts-ignore
email: userData.email,
password: loginData.password,
});
const response = await request(app)
.post("/api/v1/users/login")
.send(loggedInUser);
const response = await request(app).post("/api/v1/users/login").send(loggedInUser);
expect(response.body.status).toBe(401);
spyonOne.mockRestore();
});
}, 20000);

test("Should return send magic link if seller try to login", async () => {
const spy = jest.spyOn(twoFAService, "sendOTP");
const user = {
email: dummySeller.email,
password: dummySeller.password,
};

const response = await request(app).post("/api/v1/users/login").send({
email: dummySeller.email,
password: dummySeller.password,
});

expect(response.body.message).toBe("Verification link has been sent to your email. Please verify it to continue");
}, 20000);

test("should log a user in to retrieve a token", async () => {
const response = await request(app).post("/api/v1/users/login").send({
Expand All @@ -114,13 +132,11 @@ describe("Testing user Routes", () => {
});

test("should return 401 when updating password without authorization", async () => {
const response = await request(app)
.put("/api/v1/users/passwordupdate")
.send({
oldPassword: userData.password,
newPassword: userTestData.newPassword,
confirmPassword: userTestData.confirmPassword,
});
const response = await request(app).put("/api/v1/users/passwordupdate").send({
oldPassword: userData.password,
newPassword: userTestData.newPassword,
confirmPassword: userTestData.confirmPassword,
});
expect(response.status).toBe(401);
});

Expand Down Expand Up @@ -154,7 +170,7 @@ describe("Testing user Routes", () => {
.send({
oldPassword: userTestData.wrongPassword,
newPassword: userTestData.newPassword,
confirmPassword:userTestData.wrongPassword,
confirmPassword: userTestData.wrongPassword,
})
.set("Authorization", "Bearer " + token);
expect(response.status).toBe(400);
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@types/jest": "^29.5.12",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.12.7",
"@types/nodemailer": "^6.4.14",
"@types/supertest": "^6.0.2",
"@types/swagger-ui-express": "^4.1.6",
"@typescript-eslint/eslint-plugin": "^7.7.1",
Expand Down Expand Up @@ -79,6 +80,7 @@
"husky": "^9.0.11",
"joi": "^17.13.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.13",
"lint-staged": "^15.2.2",
"path": "^0.12.7",
"pg": "^8.11.5",
Expand Down
32 changes: 32 additions & 0 deletions src/controllers/2faControllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Request, Response } from "express";
import { IUser, STATUS } from "../types";
import { generateToken, verifyMagicLinkToken } from "../utils/jsonwebtoken";
import User from "../sequelize/models/users";

export const otpVerification = async (req: Request, res: Response) => {
const token = req.query.token as string;
try {
const decoded = await verifyMagicLinkToken(token);
//@ts-ignore
const userEmail = decoded.email;
//@ts-ignore
const user: IUser = await User.findOne({ where: { email: userEmail } });

if (!user) {
return res.status(401).json({
message: "Token expired",
});
}

const accessToken = await generateToken(user);

return res.status(200).json({
message: "Succesfuly loged in",
token: accessToken,
});
} catch (error: any) {
return res.status(500).json({
message: error.message,
});
}
};
57 changes: 32 additions & 25 deletions src/controllers/userControllers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Request, Response } from "express";
import * as userService from "../services/user.service";
import { generateToken } from "../utils/jsonwebtoken";
import * as twoFAService from "../utils/2fa";
import { IUser, STATUS } from "../types";
import { comparePasswords } from "../utils/comparePassword";
import { loggedInUser} from "../services/user.service";
import { loggedInUser } from "../services/user.service";
import { createUserService, getUserByEmail, updateUserPassword } from "../services/user.service";
import { hashedPassword } from "../utils/hashPassword";

Expand Down Expand Up @@ -33,7 +35,7 @@ export const fetchAllUsers = async (req: Request, res: Response) => {

export const userLogin = async (req: Request, res: Response) => {
const { email, password } = req.body;
const user = await loggedInUser(email);
const user: IUser = await loggedInUser(email);
const accessToken = await generateToken(user);
if (!user) {
res.status(404).json({
Expand All @@ -48,27 +50,33 @@ export const userLogin = async (req: Request, res: Response) => {
message: " User email or password is incorrect!",
});
} else {
res.status(200).json({
status: 200,
message: "Logged in",
token: accessToken,
});
if (user?.isMerchant) {
await twoFAService.sendOTP(user);
return res.status(200).json({
status: STATUS.PENDING,
message: "Verification link has been sent to your email. Please verify it to continue",
});
} else {
return res.status(200).json({
status: 200,
message: "Logged in",
token: accessToken,
});
}
}
}
};

export const createUserController = async (req: Request, res: Response) => {
try {
const { name, email, username, password } = req.body;
const user = await createUserService(name, email, username, password);

const { name, email, username, password, isMerchant } = req.body;
const user = await createUserService(name, email, username, password, isMerchant);
if (!user) {
return res.status(409).json({
status: 409,
message: "Username or email already exists",
message: "User already exists",
});
}

res.status(201).json({
status: 201,
message: "User successfully created.",
Expand All @@ -87,28 +95,27 @@ export const updatePassword = async (req: Request, res: Response) => {
// @ts-ignore
const user = await getUserByEmail(req.user.email);
if (!user) {
return res.status(404).json({ message: 'User not found' });
return res.status(404).json({ message: "User not found" });
}
const isPasswordValid = await comparePasswords(oldPassword, user.password);
if (!isPasswordValid) {
return res.status(400).json({ message: 'Old password is incorrect' });
return res.status(400).json({ message: "Old password is incorrect" });
}

if (newPassword !== confirmPassword) {
return res.status(400).json({ message: 'New password and confirm password do not match' });
return res.status(400).json({ message: "New password and confirm password do not match" });
}

if(await comparePasswords(newPassword, user.password)){
return res.status(400).json({ message: 'New password is similar to the old one. Please use a new password'})
if (await comparePasswords(newPassword, user.password)) {
return res.status(400).json({ message: "New password is similar to the old one. Please use a new password" });
}

const password = await hashedPassword(newPassword);
await updateUserPassword(user, password)
return res.status(200).json({ message: 'Password updated successfully' });

} catch(err: any){
await updateUserPassword(user, password);
return res.status(200).json({ message: "Password updated successfully" });
} catch (err: any) {
return res.status(500).json({
message: err.message
})
message: err.message,
});
}
}
};
25 changes: 7 additions & 18 deletions src/routes/userRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,17 @@
import { Router } from "express";
import {
fetchAllUsers,
createUserController,
userLogin,
updatePassword}
from "../controllers/userControllers";
import {
emailValidation,
validateSchema,
} from "../middleware/validator";
import { fetchAllUsers, createUserController, userLogin, updatePassword } from "../controllers/userControllers";
import { emailValidation, validateSchema } from "../middleware/validator";
import signUpSchema from "../schemas/signUpSchema";
import { isLoggedIn } from "../middlewares/isLoggedIn";
import { passwordUpdateSchema } from "../schemas/passwordUpdate";
import { otpVerification } from "../controllers/2faControllers";

const userRoutes = Router();

userRoutes.get("/", fetchAllUsers);
userRoutes.post('/login',userLogin);
userRoutes.post("/register",
emailValidation,
validateSchema(signUpSchema),
createUserController
)
userRoutes.put("/passwordupdate", isLoggedIn, validateSchema(passwordUpdateSchema), updatePassword)

userRoutes.post("/login", userLogin);
userRoutes.post("/register", emailValidation, validateSchema(signUpSchema), createUserController);
userRoutes.put("/passwordupdate", isLoggedIn, validateSchema(passwordUpdateSchema), updatePassword);
userRoutes.get("/2fa/verify", otpVerification);

export default userRoutes;
1 change: 1 addition & 0 deletions src/schemas/signUpSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const signUpSchema = Joi.object({
username: Joi.string().min(4).required(),
email: Joi.string().min(6).required().email(),
password: Joi.string().min(6).max(20).required(),
isMerchant: Joi.boolean().default(false).optional(),
}).options({ allowUnknown: false });

export default signUpSchema;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("users", "isMerchant", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false, // Default value for isMerchant field
});

await queryInterface.addColumn("users", "twoFAEnabled", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false, // Default value for twoFAEnabled field
});
},

async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("users", "isMerchant");
await queryInterface.removeColumn("users", "twoFAEnabled");
},
};
Loading

0 comments on commit bc4be51

Please sign in to comment.