From b3d95db70da6001c77c0579a560db906286e0b4e Mon Sep 17 00:00:00 2001 From: Wasiu Bakare Date: Wed, 24 Jul 2024 17:52:07 +0100 Subject: [PATCH] feat: add forgot and reset password --- src/controllers/AuthController.ts | 30 +++++++++++- src/models/password-reset-token.ts | 30 ++++++++++++ src/models/user.ts | 7 +++ src/routes/auth.ts | 40 ++++++++++------ src/services/auth.services.ts | 76 ++++++++++++++++++++++++++++-- src/utils/generate-reset-token.ts | 18 +++++++ 6 files changed, 181 insertions(+), 20 deletions(-) create mode 100644 src/models/password-reset-token.ts create mode 100644 src/utils/generate-reset-token.ts diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 028bb0cc..5b712885 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -156,4 +156,32 @@ const login = async (req: Request, res: Response, next: NextFunction) => { } }; -export { signUp, verifyOtp, login }; +const forgotPassword = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const { email } = req.body; + const { message } = await authService.forgotPassword(email); + res.status(200).json({ status: "sucess", status_code: 200, message }); + } catch (error) { + next(error); + } +}; + +const resetPassword = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const { token, newPassword } = req.body; + const { message } = await authService.resetPassword(token, newPassword); + res.status(200).json({ message }); + } catch (error) { + next(error); + } +}; + +export { signUp, verifyOtp, login, forgotPassword, resetPassword }; diff --git a/src/models/password-reset-token.ts b/src/models/password-reset-token.ts new file mode 100644 index 00000000..96d44e27 --- /dev/null +++ b/src/models/password-reset-token.ts @@ -0,0 +1,30 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; +import { User } from "./user"; + +@Entity() +export class PasswordResetToken { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column() + token: string; + + @Column() + expiresAt: Date; + + @ManyToOne(() => User, (user) => user.passwordResetTokens) + user: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/models/user.ts b/src/models/user.ts index 96cc8842..26cfe4b7 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -20,6 +20,7 @@ import { getIsInvalidMessage } from "../utils"; import { UserRole } from "../enums/userRoles"; import { Like } from "./like"; import { Payment } from "./payment"; +import { PasswordResetToken } from "./password-reset-token"; @Entity() @Unique(["email"]) @@ -101,4 +102,10 @@ export class User extends ExtendedBaseEntity { @DeleteDateColumn({ nullable: true }) deletedAt: Date; + + @OneToMany( + () => PasswordResetToken, + (passwordResetToken) => passwordResetToken.user, + ) + passwordResetTokens: PasswordResetToken[]; } diff --git a/src/routes/auth.ts b/src/routes/auth.ts index fedc4c86..5faf76ff 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,10 +1,18 @@ -import { signUp, verifyOtp, login, changeUserRole } from "../controllers"; +import { + signUp, + verifyOtp, + login, + changeUserRole, + forgotPassword, + resetPassword, +} from "../controllers"; import { Router } from "express"; import { authMiddleware, checkPermissions } from "../middleware"; import { UserRole } from "../enums/userRoles"; -import { googleAuthCallback, initiateGoogleAuthRequest } from "../controllers/GoogleAuthController"; - - +import { + googleAuthCallback, + initiateGoogleAuthRequest, +} from "../controllers/GoogleAuthController"; const authRoute = Router(); @@ -13,20 +21,21 @@ authRoute.post("/verify-otp", verifyOtp); authRoute.post("/login", login); authRoute.post("/login", login); authRoute.put( - "/api/v1/organizations/:organization_id/users/:user_id/role", - authMiddleware, - checkPermissions([UserRole.SUPER_ADMIN, UserRole.ADMIN]), - changeUserRole - ); + "/api/v1/organizations/:organization_id/users/:user_id/role", + authMiddleware, + checkPermissions([UserRole.SUPER_ADMIN, UserRole.ADMIN]), + changeUserRole, +); // ---------------------------Google Auth Route Begins------------------------- // // For manually testing google auth functionality locally -authRoute.get('/test-google-auth', (req, res) => { - res.send('Authenticate with Google'); +authRoute.get("/test-google-auth", (req, res) => { + res.send( + 'Authenticate with Google', + ); }); - /** * @openapi * /auth/google: @@ -46,8 +55,7 @@ authRoute.get('/test-google-auth', (req, res) => { * '500': * description: Internal Server Error */ -authRoute.get('/google', initiateGoogleAuthRequest); - +authRoute.get("/google", initiateGoogleAuthRequest); /** * @openapi @@ -77,9 +85,11 @@ authRoute.get('/google', initiateGoogleAuthRequest); * '500': * description: Internal Server Error - if something goes wrong during the callback handling */ -authRoute.get('/google/callback', googleAuthCallback); +authRoute.get("/google/callback", googleAuthCallback); // ---------------------------Google Auth Route Ends------------------------- // +authRoute.post("/forgotPassword", forgotPassword); +authRoute.post("/resetPassword", resetPassword); export { authRoute }; diff --git a/src/services/auth.services.ts b/src/services/auth.services.ts index a27bf008..f955d5af 100644 --- a/src/services/auth.services.ts +++ b/src/services/auth.services.ts @@ -7,7 +7,9 @@ import { Sendmail } from "../utils/mail"; import jwt from "jsonwebtoken"; import { compilerOtp } from "../views/welcome"; import config from "../config"; - +import generateResetToken from "../utils/generate-reset-token"; +import { PasswordResetToken } from "../models/password-reset-token"; +import bcrypt from "bcryptjs"; export class AuthService implements IAuthService { public async signUp(payload: IUserSignUp): Promise<{ mailSent: string; @@ -45,7 +47,7 @@ export class AuthService implements IAuthService { config.TOKEN_SECRET, { expiresIn: "1d", - } + }, ); const mailSent = await Sendmail({ @@ -68,7 +70,7 @@ export class AuthService implements IAuthService { public async verifyEmail( token: string, - otp: number + otp: number, ): Promise<{ message: string }> { try { const decoded: any = jwt.verify(token, config.TOKEN_SECRET); @@ -96,7 +98,7 @@ export class AuthService implements IAuthService { } public async login( - payload: IUserLogin + payload: IUserLogin, ): Promise<{ access_token: string; user: Partial }> { const { email, password } = payload; @@ -127,4 +129,70 @@ export class AuthService implements IAuthService { throw new HttpError(error.status || 500, error.message || error); } } + + public async forgotPassword(email: string): Promise<{ message: string }> { + try { + const user = await User.findOne({ where: { email } }); + + if (!user) { + throw new HttpError(404, "User not found"); + } + + const { resetToken, hashedToken, expiresAt } = generateResetToken(); + + const passwordResetToken = new PasswordResetToken(); + passwordResetToken.token = hashedToken; + passwordResetToken.expiresAt = expiresAt; + passwordResetToken.user = user; + + await AppDataSource.manager.save(passwordResetToken); + + // Send email + const emailContent = { + from: `Boilerplate <${config.SMTP_USER}>`, + to: email, + subject: "Password Reset", + text: `You requested for a password reset. Use this token to reset your password: ${resetToken}`, + }; + + await Sendmail(emailContent); + + return { message: "Password reset link sent successfully." }; + } catch (error) { + throw new HttpError(error.status || 500, error.message || error); + } + } + + public async resetPassword( + token: string, + newPassword: string, + ): Promise<{ message: string }> { + try { + const passwordResetTokenRepository = + AppDataSource.getRepository(PasswordResetToken); + const passwordResetToken = await passwordResetTokenRepository.findOne({ + where: { token }, + relations: ["user"], + }); + + if (!passwordResetToken) { + throw new HttpError(404, "Invalid or expired token"); + } + + if (passwordResetToken.expiresAt < new Date()) { + throw new HttpError(400, "Token expired"); + } + + const user = passwordResetToken.user; + const hashedPassword = await bcrypt.hash(newPassword, 10); + user.password = hashedPassword; + + await AppDataSource.manager.save(user); + await passwordResetTokenRepository.remove(passwordResetToken); + + return { message: "Password reset successfully." }; + } catch (error) { + throw new HttpError(error.status || 500, error.message || error); + } + } } diff --git a/src/utils/generate-reset-token.ts b/src/utils/generate-reset-token.ts new file mode 100644 index 00000000..bf3dd8d4 --- /dev/null +++ b/src/utils/generate-reset-token.ts @@ -0,0 +1,18 @@ +import crypto from "crypto"; + +const generateResetToken = (): { + resetToken: string; + hashedToken: string; + expiresAt: Date; +} => { + const resetToken = crypto.randomBytes(32).toString("hex"); + const hashedToken = crypto + .createHash("sha256") + .update(resetToken) + .digest("hex"); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes from now + + return { resetToken, hashedToken, expiresAt }; +}; + +export default generateResetToken;