Skip to content

Commit

Permalink
Merge pull request #52 from atlp-rwanda/187354250-two-factor-authenti…
Browse files Browse the repository at this point in the history
…cation-for-seller

ft-2FA two factor authentication for seller
  • Loading branch information
niyontwali authored and P-Rwirangira committed May 7, 2024
2 parents 5ed1800 + a515a03 commit 7ccc770
Show file tree
Hide file tree
Showing 16 changed files with 648 additions and 45 deletions.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,14 @@
"glob": "^10.3.12",
"jsonwebtoken": "^9.0.2",
"mailgen": "^2.0.28",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.13",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"multer": "^1.4.5-lts.1",
"pg": "^8.11.5",
"pg-hstore": "^2.3.4",
"randomstring": "^1.3.0",
"sequelize": "^6.37.2",
"swagger-ui-express": "^5.0.0",
"uuid": "^9.0.1",
Expand All @@ -62,6 +63,7 @@
"@types/nodemailer": "^6.4.14",
"@types/passport": "^1.0.16",
"@types/passport-google-oauth20": "^2.0.14",
"@types/randomstring": "^1.3.0",
"@types/sequelize": "^4.28.20",
"@types/supertest": "^6.0.2",
"@types/swagger-jsdoc": "^6.0.4",
Expand Down Expand Up @@ -93,4 +95,4 @@
"eslint --fix"
]
}
}
}
123 changes: 101 additions & 22 deletions src/controllers/authController.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Request, Response, NextFunction } from 'express';
import passport from 'passport';
import jwt from 'jsonwebtoken';
import jwt, { JwtPayload } from 'jsonwebtoken';
import User, { UserAttributes } from '../database/models/user';
import { sendInternalErrorResponse, validateFields } from '../validations';
import logger from '../logs/config';
import { passwordCompare } from '../helpers/encrypt';
import { verifyIfSeller } from '../middlewares/authMiddlewares';
import { createOTPToken, saveOTPDB } from '../middlewares/otpAuthMiddleware';
import { userToken } from '../helpers/token.generator';
import { sendErrorResponse } from '../helpers/helper';
import { passwordCompare, passwordEncrypt } from '../helpers/encrypt';
import { validatePassword } from '../validations';

const authenticateViaGoogle = (req: Request, res: Response, next: NextFunction) => {
export const authenticateViaGoogle = (req: Request, res: Response, next: NextFunction) => {
passport.authenticate('google', (err: unknown, user: UserAttributes | null) => {
if (err) {
sendInternalErrorResponse(res, err);
Expand All @@ -29,7 +35,7 @@ const authenticateViaGoogle = (req: Request, res: Response, next: NextFunction)
};

// login function
const login = async (req: Request, res: Response): Promise<void> => {
export const login = async (req: Request, res: Response): Promise<void> => {
try {
const { email, password } = req.body;

Expand All @@ -51,47 +57,120 @@ const login = async (req: Request, res: Response): Promise<void> => {
});

if (!user) {
logger.error('Invalid credentials');
res.status(404).json({ ok: false, message: 'Invalid credentials' });
sendErrorResponse(res, 'invalidCredentials');
return;
}

// Check if user is inactive
if (user.status === 'inactive') {
logger.error('Your account has been blocked. Please contact support.');
res.status(403).json({ ok: false, message: 'Your account has been blocked. Please contact support.' });
sendErrorResponse(res, 'inactiveUser');
return;
}

// Check if user is verified
if (!user.verified) {
logger.error('Your account is not verified. Please verify your account.');
res.status(403).json({ ok: false, message: 'Your account is not verified. Please verify your account.' });
sendErrorResponse(res, 'unverifiedUser');
return;
}

// Verify password
const passwordValid = await passwordCompare(password, user.password);
if (!passwordValid) {
logger.error('Invalid credentials');
res.status(404).json({ ok: false, message: 'Invalid credentials' });
sendErrorResponse(res, 'invalidCredentials');
return;
}

// Authenticate user with jwt
const token = jwt.sign({ id: user.id }, process.env.SECRET_KEY as string, {
expiresIn: process.env.JWT_EXPIRATION as string,
});

res.status(200).json({
ok: true,
token: token,
});
await verifyIfSeller(user, req, res);
} catch (err: any) {
const message = (err as Error).message;
logger.error(message);
sendInternalErrorResponse(res, err);
}
};
// Function to verify OTP
export const verifyOTP = async (req: Request, res: Response) => {
try {
const data = req.user as JwtPayload;
const token = await userToken(data.id);

res.status(200).json({ ok: true, token });
} catch (error) {
logger.error('VerifyOTP Internal Server Error', error);
sendInternalErrorResponse(res, error);
}
};
// Function to create OTP Token, Save it Postgres,
export const sendOTP = async (req: Request, res: Response, email: string) => {
const userInfo = await User.findOne({ where: { email } });
if (userInfo) {
const { id, email, firstName } = userInfo.dataValues;

const token = await createOTPToken(id, email, firstName);

const otpSaved = await saveOTPDB(id, token);

if (otpSaved) {
/**
* The token used for comparing the received OTP via email with the
* generated token, which contains the user's ID.
*/
const accessToken = jwt.sign({ id, FAEnabled: true }, process.env.SECRET_KEY as string, {
expiresIn: process.env.JWT_EXPIRATION as string,
});
res.status(200).json({ ok: true, token: accessToken });
}
}
};
const updatePassword = async (req: Request, res: Response): Promise<void> => {
try {
const { oldPassword, newPassword } = req.body;

// Access decoded user information from the request object
const user = req.user as {
id: string;
password: string;
};

// Check if old password matches with the given one
const match = await passwordCompare(oldPassword, user.password);
if (!match) {
res.status(400).json({
ok: false,
message: 'The old password is incorrect!',
});
return;
}

// Validate password
if (!validatePassword(newPassword)) {
res.status(400).json({
ok: false,
error: 'Ensuring it contains at least 1 letter, 1 number, and 1 special character, minumun 8 characters',
});
return;
}
//
// Generate salt and hash new password
const hashedNewPassword = await passwordEncrypt(newPassword);

// Update user's password
await User.update(
{ password: hashedNewPassword },
{
where: {
id: user.id,
},
}
);

res.status(200).json({
ok: true,
message: 'Successfully updated user password!',
});
} catch (error) {
logger.error('Error updating user:', error);
sendInternalErrorResponse(res, error);
}
};

export { login, authenticateViaGoogle };
export { updatePassword };
22 changes: 22 additions & 0 deletions src/controllers/userController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,25 @@ export const resendVerifyLink = async (req: Request, res: Response) => {
sendInternalErrorResponse(res, error);
}
};
// Function to enable two factor authentication(2FA)
export const enable2FA = async (req: Request, res: Response) => {
try {
const userId = (req.user as User).id;

if (!userId) {
return res.status(404).json({ ok: false, error: 'UserId Not Found' });
}
const user = await User.findByPk(userId);

if (!user) {
return res.status(400).json({ ok: false, error: 'User not found' });
}
user.enable2FA = !user.enable2FA;
await user.save();

res.status(201).json({ ok: true, message: `2FA status toggled to ${user.enable2FA}` });
} catch (error) {
logger.error('Enable 2FA', error);
sendInternalErrorResponse(res, error);
}
};
16 changes: 16 additions & 0 deletions src/database/migrations/20240503195951-addUserCol2FA.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
return queryInterface.addColumn('Users', 'enable2FA', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
},

async down(queryInterface, Sequelize) {
return queryInterface.removeColumn('Users', 'enable2FA');
},
};
43 changes: 43 additions & 0 deletions src/database/migrations/20240503201014-OTP.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('OTP', {
id: {
type: Sequelize.UUID,
unique: true,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true,
},
token: {
type: Sequelize.STRING(1235),
allowNull: false,
unique: true,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'Users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
},
});
},

async down(queryInterface, Sequelize) {
await queryInterface.tableExists('OTP');
},
};
51 changes: 51 additions & 0 deletions src/database/models/otp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Optional, UUIDV4 } from 'sequelize';
import { DataTypes, Model } from 'sequelize';
import sequelize from '.';
import User from './user';

export interface OtpAttributes {
id: string;
token: string;
userId?: string;
}

export interface OtpCreationAttributes extends Optional<OtpAttributes, 'id'> {}

class Otp extends Model<OtpAttributes, OtpCreationAttributes> implements OtpAttributes {
public id!: string;
public token!: string;
public userId!: string | undefined;
public readonly createdAt: Date | undefined;
public readonly updatedAt: Date | undefined;
}
Otp.init(
{
id: {
type: DataTypes.UUID,
defaultValue: UUIDV4,
allowNull: false,
unique: true,
primaryKey: true,
},
token: {
type: DataTypes.STRING(1235),
allowNull: false,
},
userId: {
type: DataTypes.UUID,
defaultValue: UUIDV4,
references: {
model: User,
key: 'id',
},
},
},
{
sequelize,
timestamps: true,
tableName: 'OTP',
}
);
Otp.belongsTo(User, { foreignKey: 'userId' });

export default Otp;
7 changes: 7 additions & 0 deletions src/database/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface UserAttributes {
createdAt?: Date;
updatedAt?: Date;
RoleId?: string;
enable2FA?: boolean;
}

export interface UserCreationAttributes extends Optional<UserAttributes, 'id'> {}
Expand All @@ -42,6 +43,7 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
public verified!: boolean;
public status!: UserStatus;
public RoleId!: string | undefined;
public enable2FA?: boolean | undefined;
public readonly createdAt!: Date | undefined;
public readonly updatedAt!: Date | undefined;
}
Expand Down Expand Up @@ -120,6 +122,11 @@ User.init(
key: 'id',
},
},
enable2FA: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
},
{ sequelize: sequelize, timestamps: true }
);
Expand Down
Loading

0 comments on commit 7ccc770

Please sign in to comment.