Skip to content

Commit

Permalink
Merge pull request hngprojects#511 from AdeGneus/feat/2fa
Browse files Browse the repository at this point in the history
feat: enable 2fa
  • Loading branch information
PreciousIfeaka authored Aug 8, 2024
2 parents fa71514 + a885978 commit 800941e
Show file tree
Hide file tree
Showing 14 changed files with 197 additions and 717 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"pino": "^9.3.1",
"pino-pretty": "^11.2.1",
"reflect-metadata": "^0.1.14",
"speakeasy": "^2.0.0",
"stripe": "^16.5.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
Expand Down
16 changes: 16 additions & 0 deletions src/controllers/AuthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,21 @@ const googleAuthCall = async (req: Request, res: Response) => {
}
};

const enable2FA = async (req: Request, res: Response, next: NextFunction) => {
const { password } = req.body;
const user = req.user;
const { message, data } = await authService.enable2FA(user.id, password);
if (!message) {
return next(new BadRequest("Error enabling 2FA"));
}

return res.status(200).json({
status_code: 200,
message,
data,
});
};

export {
VerifyUserMagicLink,
changePassword,
Expand All @@ -602,4 +617,5 @@ export {
resetPassword,
signUp,
verifyOtp,
enable2FA,
};
4 changes: 2 additions & 2 deletions src/controllers/UserController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class UserController {
*
*/

static async getProfile(req: Request, res: Response, next: NextFunction) {
async getProfile(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.user;

Expand All @@ -82,7 +82,7 @@ class UserController {
});
}

const user = await UserService.getUserById(id);
const user = await this.userService.getUserById(id);
if (!user) {
return res.status(404).json({
status_code: 404,
Expand Down
9 changes: 9 additions & 0 deletions src/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ export class User extends ExtendedBaseEntity {
@OneToMany(() => Comment, (comment) => comment.author)
comments: Comment[];

@Column({ nullable: true })
secret: string;

@Column({ default: false })
is_2fa_enabled: boolean;

@Column("simple-array", { nullable: true })
backup_codes: string[];

createPasswordResetToken(): string {
const resetToken = crypto.randomBytes(32).toString("hex");

Expand Down
8 changes: 8 additions & 0 deletions src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
changePassword,
changeUserRole,
createMagicToken,
enable2FA,
forgotPassword,
googleAuthCall,
login,
Expand All @@ -16,6 +17,7 @@ import { UserRole } from "../enums/userRoles";
import { authMiddleware, checkPermissions } from "../middleware";
import { requestBodyValidator } from "../middleware/request-validation";
import { emailSchema } from "../utils/request-body-validator";
import { enable2FASchema } from "../schema/auth.schema";

const authRoute = Router();

Expand All @@ -42,5 +44,11 @@ authRoute.post(
createMagicToken,
);
authRoute.get("/auth/magic-link/verify", VerifyUserMagicLink);
authRoute.post(
"/auth/2fa/enable",
requestBodyValidator(enable2FASchema),
authMiddleware,
enable2FA,
);

export { authRoute };
6 changes: 5 additions & 1 deletion src/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ userRouter.delete(
userController.deleteUser.bind(userController),
);

userRouter.get("/users/me", authMiddleware, UserController.getProfile);
userRouter.get(
"/users/me",
authMiddleware,
userController.getProfile.bind(userController),
);

userRouter.put(
"/users/:id",
Expand Down
5 changes: 4 additions & 1 deletion src/schema/auth.schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { object, string, TypeOf } from "zod";
import { object, string, TypeOf, z } from "zod";
import { emailSchema } from "../utils/request-body-validator";

/**
Expand Down Expand Up @@ -39,6 +39,9 @@ export const validateMagicLinkSchema = object({
});

export const createMagicLinkSchema = object({ ...createMagicLinkPayload });
export const enable2FASchema = z.object({
password: z.string(),
});

export type validateMagicLinkInput = TypeOf<typeof validateMagicLinkSchema>;
export type CreateMagicLinkInput = TypeOf<typeof createMagicLinkSchema>;
68 changes: 68 additions & 0 deletions src/services/auth.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Conflict,
HttpError,
ResourceNotFound,
ServerError,
} from "../middleware";
import { Profile, User } from "../models";
import { IAuthService, IUserLogin, IUserSignUp } from "../types";
Expand All @@ -27,8 +28,14 @@ import { addEmailToQueue } from "../utils/queue";
import renderTemplate from "../views/email/renderTemplate";
import { generateMagicLinkEmail } from "../views/magic-link.email";
import { compilerOtp } from "../views/welcome";
import speakeasy from "speakeasy";
import { UserService } from "./user.services";

export class AuthService implements IAuthService {
private userService: UserService;
constructor() {
this.userService = new UserService();
}
public async signUp(payload: IUserSignUp): Promise<{
message: string;
user: Partial<User>;
Expand Down Expand Up @@ -328,4 +335,65 @@ export class AuthService implements IAuthService {
throw error;
}
}

public generate2FARecoveryCode() {
const codes = [];
for (let i = 0; i < 8; i++) {
codes.push(Math.random().toString(36).substring(2, 8).toUpperCase());
}
return codes;
}

public async enable2FA(user_id: string, password: string) {
try {
const user = await this.userService.getUserById(user_id);

const is_password_valid = await this.userService.compareUserPassword(
password,
user.password,
);
if (!is_password_valid) {
throw new BadRequest("Invalid password");
}
if (user.is_2fa_enabled) {
throw new BadRequest("2FA is already enabled");
}

const secret = speakeasy.generateSecret({ length: 32 });
const backup_codes = this.generate2FARecoveryCode();
const payload = {
secret: secret.base32,
is_2fa_enabled: true,
backup_codes: backup_codes,
};

await this.userService.updateUserRecord({
updatePayload: payload,
identifierOption: {
identifier: user_id,
identifierType: "id",
},
});

const qrCodeUrl = speakeasy.otpauthURL({
secret: secret.ascii,
label: `Hng:${user.email}`,
issuer: `Hng Boilerplate`,
});

return {
message: "2FA setup initiated",
data: {
secret: secret.base32,
qr_code_url: qrCodeUrl,
backup_codes,
},
};
} catch (error) {
if (error instanceof BadRequest) {
throw error;
}
throw new ServerError("An error occurred while trying to enable 2FA");
}
}
}
1 change: 0 additions & 1 deletion src/services/billing-plans.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export class BillingService {
try {
return await this.billingRepository.find();
} catch (error) {
console.error("Failed to fetch billing plans:", error);
throw new Error("Could not fetch billing plans");
}
}
Expand Down
65 changes: 57 additions & 8 deletions src/services/user.services.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// src/services/UserService.ts
import { Repository, UpdateResult } from "typeorm";
import { cloudinary } from "../config/multer";
import AppDataSource from "../data-source";
import { HttpError } from "../middleware";
import { Profile } from "../models/profile";
import { BadRequest, HttpError, ResourceNotFound } from "../middleware";
import { User } from "../models/user";
import { UpdateUserRecordOption, UserIdentifierOptionsType } from "../types";
import { comparePassword } from "../utils";
import { Profile } from "../models";
import { cloudinary } from "../config/multer";

interface IUserProfileUpdate {
first_name: string;
Expand All @@ -21,18 +22,21 @@ interface TimezoneData {

export class UserService {
private userRepository: Repository<User>;

constructor() {
this.userRepository = AppDataSource.getRepository(User);
}

static async getUserById(id: string): Promise<User | null> {
const userRepository = AppDataSource.getRepository(User);
const user = userRepository.findOne({
public async getUserById(id: string): Promise<User | null> {
const user = await this.userRepository.findOne({
where: { id },
relations: ["profile"],
withDeleted: true,
});

if (!user) {
throw new ResourceNotFound("User Not Found!");
}

return user;
}

Expand Down Expand Up @@ -138,6 +142,51 @@ export class UserService {
}
}

async getUserByEmail(
email: string,
withDeleted: boolean = true,
): Promise<User | null> {
const user = await this.userRepository.findOne({
where: { email },
relations: ["profile"],
withDeleted: withDeleted,
});

if (!user) throw new ResourceNotFound("User not found!");

return user;
}

async updateUserRecord(userUpdateOptions: UpdateUserRecordOption) {
const { identifierOption, updatePayload } = userUpdateOptions;
const user = await this.getUserRecord(identifierOption);
Object.assign(user, updatePayload);
await this.userRepository.save(user);
}

async getUserRecord(
identifierOption: UserIdentifierOptionsType,
): Promise<User> {
const { identifier, identifierType } = identifierOption;
let user = null;
switch (identifierType) {
case "id":
user = await this.getUserById(identifier);
break;
case "email":
user = await this.getUserByEmail(identifier);
break;
default:
throw new BadRequest("Invalid Identifier");
}
if (!user) throw new ResourceNotFound("User not found!");
return user;
}

async compareUserPassword(password: string, hashedPassword: string) {
return comparePassword(password, hashedPassword);
}

public async updateUserTimezone(
userId: string,
timezoneData: TimezoneData,
Expand Down
Loading

0 comments on commit 800941e

Please sign in to comment.