From 4f255948c013c00c3c4e75717d0c3e3477e8e252 Mon Sep 17 00:00:00 2001 From: Hibatullah Fawwaz Hana Date: Fri, 23 May 2025 08:40:39 +0700 Subject: [PATCH 1/2] feat: email verification --- functions/src/controllers/auth_controller.ts | 362 +++++++++++-------- functions/src/middlewares/auth_middleware.ts | 2 +- functions/src/middlewares/csrf_middleware.ts | 2 +- functions/src/routes/auth.ts | 4 + 4 files changed, 211 insertions(+), 159 deletions(-) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 8ba6210..815ebe1 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -37,6 +37,152 @@ const validateEmailAndPassword = ( return true; }; +// Configure Nodemailer to use Mailtrap's SMTP +const transporter = nodemailer.createTransport({ + host: "live.smtp.mailtrap.io", + port: 587, + auth: { + user: process.env.MAILTRAP_USER, + pass: process.env.MAILTRAP_PASS, + }, +}); + +interface MailOptions { + from: string | { name: string; address: string }; + to: string; + subject: string; + html: string; + text: string; +} + +const createPasswordResetMailOptions = ( + email: string, + link: string +): MailOptions => ({ + from: { + name: "Garuda Hacks", + address: "no-reply@garudahacks.com", + }, + to: email, + subject: "Reset your Garuda Hacks password", + html: ` + + + + + + Reset Your Password + + + + +
+

Reset Your Password

+

You requested a password reset. Click the button below to choose a new password:

+ Reset Password +

+ If you didn't request this, you can safely ignore this email. Your password will remain unchanged. +

+

+ This link will expire in 1 hour for security reasons. +

+
+
+

© ${new Date().getFullYear()} Garuda Hacks. All rights reserved.

+

+ Visit our website | + Contact Support +

+
+ + + `, + text: `Reset Your Password + +You requested a password reset. Click the link below to choose a new password: + +${link} + +If you didn't request this, you can safely ignore this email. Your password will remain unchanged. + +This link will expire in 1 hour for security reasons. + +© ${new Date().getFullYear()} Garuda Hacks. All rights reserved.`, +}); + +const sendPasswordResetEmail = async ( + email: string, + link: string +): Promise => { + const mailOptions = createPasswordResetMailOptions(email, link); + await transporter.sendMail(mailOptions); + functions.logger.info("Password reset email sent successfully to:", email); +}; + +const createVerificationMailOptions = ( + email: string, + link: string +): MailOptions => ({ + from: { + name: "Garuda Hacks", + address: "no-reply@garudahacks.com", + }, + to: email, + subject: "Verify your Garuda Hacks account", + html: ` + + + + + + Verify Your Account + + + + +
+

Welcome to Garuda Hacks!

+

Thank you for registering. Please verify your email address by clicking the button below:

+ Verify Email +

+ If you didn't create an account with us, you can safely ignore this email. +

+

+ This verification link will expire in 24 hours. +

+
+
+

© ${new Date().getFullYear()} Garuda Hacks. All rights reserved.

+

+ Visit our website | + Contact Support +

+
+ + + `, + text: `Welcome to Garuda Hacks! + +Thank you for registering. Please verify your email address by clicking the link below: + +${link} + +If you didn't create an account with us, you can safely ignore this email. + +This verification link will expire in 24 hours. + +© ${new Date().getFullYear()} Garuda Hacks. All rights reserved.`, +}); + +const sendVerificationEmail = async ( + email: string, + link: string +): Promise => { + const mailOptions = createVerificationMailOptions(email, link); + await transporter.sendMail(mailOptions); + functions.logger.info("Verification email sent successfully to:", email); +}; + /** * Logs in user */ @@ -143,6 +289,12 @@ export const register = async (req: Request, res: Response): Promise => { role: "User", }); + // Generate email verification link + const verificationLink = await auth.generateEmailVerificationLink(email); + + // Send verification email + await sendVerificationEmail(email, verificationLink); + const customToken = await auth.createCustomToken(user.uid); const url = isEmulator @@ -204,7 +356,8 @@ export const register = async (req: Request, res: Response): Promise => { res.status(201).json( convertResponseToSnakeCase({ status: 201, - message: "Registration successful", + message: + "Registration successful. Please check your email for verification link.", user: { email: user.email, displayName: user.displayName, @@ -278,12 +431,15 @@ export const sessionLogin = async ( const decodedIdToken = await auth.verifyIdToken(idToken); let user; + let userDoc; if (decodedIdToken.email != null) { user = await auth.getUserByEmail(decodedIdToken.email); - // update user record for first time - const docRef = await db.collection("questions").doc(user.uid).get(); - if (!docRef.exists) { + // Get user document from Firestore + userDoc = await db.collection("users").doc(user.uid).get(); + + // Check if user exists in Firestore + if (!userDoc.exists) { const userData: User = formatUser({ email: user.email ?? "", firstName: user.displayName ?? "", @@ -296,6 +452,16 @@ export const sessionLogin = async ( ...userData, createdAt: FieldValue.serverTimestamp(), }); + } else { + // Check verification status + if (!decodedIdToken.email_verified) { + res.status(403).json({ + status: 403, + error: + "Account not verified. Please check your email for verification link.", + }); + return; + } } } else { functions.logger.error( @@ -396,97 +562,6 @@ export const sessionCheck = async ( } }; -// Configure Nodemailer to use Mailtrap's SMTP -const transporter = nodemailer.createTransport({ - host: "live.smtp.mailtrap.io", - port: 587, - auth: { - user: process.env.MAILTRAP_USER, - pass: process.env.MAILTRAP_PASS, - }, -}); - -interface ActionCodeSettings { - url: string; - handleCodeInApp: boolean; -} - -interface MailOptions { - from: string | { name: string; address: string }; - to: string; - subject: string; - html: string; - text: string; -} - -const getActionCodeSettings = (): ActionCodeSettings => ({ - url: process.env.FRONTEND_URL - ? `${process.env.FRONTEND_URL}/reset-password` - : "https://portal.garudahacks.com/reset-password", - handleCodeInApp: true, -}); - -const createMailOptions = (email: string, link: string): MailOptions => ({ - from: { - name: "Garuda Hacks", - address: "no-reply@garudahacks.com", - }, - to: email, - subject: "Reset your Garuda Hacks password", - html: ` - - - - - - Reset Your Password - - - - -
-

Reset Your Password

-

You requested a password reset. Click the button below to choose a new password:

- Reset Password -

- If you didn't request this, you can safely ignore this email. Your password will remain unchanged. -

-

- This link will expire in 1 hour for security reasons. -

-
-
-

© ${new Date().getFullYear()} Garuda Hacks. All rights reserved.

-

- Visit our website | - Contact Support -

-
- - - `, - text: `Reset Your Password - -You requested a password reset. Click the link below to choose a new password: - -${link} - -If you didn't request this, you can safely ignore this email. Your password will remain unchanged. - -This link will expire in 1 hour for security reasons. - -© ${new Date().getFullYear()} Garuda Hacks. All rights reserved.`, -}); - -const sendPasswordResetEmail = async ( - email: string, - link: string -): Promise => { - const mailOptions = createMailOptions(email, link); - await transporter.sendMail(mailOptions); - functions.logger.info("Password reset email sent successfully to:", email); -}; - /** * Request password reset by sending email */ @@ -509,13 +584,9 @@ export const requestPasswordReset = async ( await auth.getUserByEmail(email); // Generate password reset link - const actionCodeSettings = getActionCodeSettings(); functions.logger.info("Generating password reset link for:", email); - const link = await auth.generatePasswordResetLink( - email, - actionCodeSettings - ); + const link = await auth.generatePasswordResetLink(email); functions.logger.info("Password reset link generated successfully"); // Send password reset email @@ -541,61 +612,38 @@ export const requestPasswordReset = async ( }; /** - * Reset password using verification code + * Verify user account using verification code */ -// export const resetPassword = async ( -// req: Request, -// res: Response -// ): Promise => { -// const { oobCode, newPassword } = req.body; - -// if (!oobCode || !newPassword) { -// res.status(400).json({ -// status: 400, -// error: "Reset code and new password are required", -// }); -// return; -// } - -// if (!validator.isLength(newPassword, { min: 6 })) { -// res.status(400).json({ -// status: 400, -// error: "Password must be at least 6 characters long", -// }); -// return; -// } - -// try { -// // Get the user from the reset code -// const user = await auth.getUserByEmail(oobCode); - -// // Update the user's password -// await auth.updateUser(user.uid, { -// password: newPassword, -// }); - -// // Revoke all refresh tokens -// await auth.revokeRefreshTokens(user.uid); - -// res.status(200).json({ -// status: 200, -// message: "Password has been reset successfully", -// }); -// } catch (error) { -// const err = error as FirebaseError; -// functions.logger.error("Error resetting password:", err); - -// if (err.code === "auth/user-not-found") { -// res.status(400).json({ -// status: 400, -// error: "Invalid or expired reset code", -// }); -// return; -// } - -// res.status(500).json({ -// status: 500, -// error: "Failed to reset password", -// }); -// } -// }; +export const verifyAccount = async ( + req: Request, + res: Response +): Promise => { + const { email } = req.body; + + if (!email) { + res.status(400).json({ + status: 400, + error: "Email is required", + }); + return; + } + + try { + const link = await auth.generateEmailVerificationLink(email); + + await sendVerificationEmail(email, link); + + res.status(200).json({ + status: 200, + message: "Account verified successfully", + }); + } catch (error) { + const err = error as FirebaseError; + functions.logger.error("Error in account verification:", err); + + res.status(400).json({ + status: 400, + error: "Invalid or expired verification code", + }); + } +}; diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index e3e309f..3d06b1c 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -17,7 +17,7 @@ const authExemptRoutes = [ "/auth/register", "/auth/login", "/auth/session-login", - "/auth/request-reset", + "/auth/verify-account", "/auth/reset-password", ]; diff --git a/functions/src/middlewares/csrf_middleware.ts b/functions/src/middlewares/csrf_middleware.ts index 063d842..559cf14 100644 --- a/functions/src/middlewares/csrf_middleware.ts +++ b/functions/src/middlewares/csrf_middleware.ts @@ -6,7 +6,7 @@ const csrfExemptRoutes = [ "/auth/login", "/auth/register", "/auth/session-login", - "/auth/request-reset", + "/auth/verify-account", "/auth/reset-password", "/auth/logout", ]; diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index ebab38e..c9cbc75 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -6,6 +6,7 @@ import { requestPasswordReset, sessionCheck, sessionLogin, + verifyAccount, } from "../controllers/auth_controller"; const router = express.Router(); @@ -13,6 +14,9 @@ const router = express.Router(); router.post("/login", (req: Request, res: Response) => login(req, res)); router.post("/register", (req: Request, res: Response) => register(req, res)); router.post("/reset-password", requestPasswordReset); +router.post("/verify-account", (req: Request, res: Response) => + verifyAccount(req, res) +); router.post("/session-login", (req: Request, res: Response) => sessionLogin(req, res) ); From 98638e89a575eecd8d50136d9fc77f2189d398a2 Mon Sep 17 00:00:00 2001 From: Hibatullah Fawwaz Hana Date: Fri, 23 May 2025 15:07:25 +0700 Subject: [PATCH 2/2] feat: send verification email --- functions/src/controllers/auth_controller.ts | 59 ++++++++++---------- functions/src/middlewares/auth_middleware.ts | 1 - functions/src/middlewares/csrf_middleware.ts | 2 +- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 815ebe1..dfb8a77 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -431,15 +431,12 @@ export const sessionLogin = async ( const decodedIdToken = await auth.verifyIdToken(idToken); let user; - let userDoc; if (decodedIdToken.email != null) { user = await auth.getUserByEmail(decodedIdToken.email); - // Get user document from Firestore - userDoc = await db.collection("users").doc(user.uid).get(); - - // Check if user exists in Firestore - if (!userDoc.exists) { + // update user record for first time + const docRef = await db.collection("questions").doc(user.uid).get(); + if (!docRef.exists) { const userData: User = formatUser({ email: user.email ?? "", firstName: user.displayName ?? "", @@ -452,16 +449,6 @@ export const sessionLogin = async ( ...userData, createdAt: FieldValue.serverTimestamp(), }); - } else { - // Check verification status - if (!decodedIdToken.email_verified) { - res.status(403).json({ - status: 403, - error: - "Account not verified. Please check your email for verification link.", - }); - return; - } } } else { functions.logger.error( @@ -543,8 +530,12 @@ export const sessionCheck = async ( res .status(400) .json({ status: 400, error: "Could not find session cookie" }); + return; } + // Get user data to check email verification status + const user = await auth.getUser(decodedSessionCookie.sub); + res.status(200).json({ status: 200, message: "Session is valid", @@ -552,6 +543,7 @@ export const sessionCheck = async ( user: { email: decodedSessionCookie.email, displayName: decodedSessionCookie.name, + emailVerified: user.emailVerified, }, }, }); @@ -612,30 +604,41 @@ export const requestPasswordReset = async ( }; /** - * Verify user account using verification code + * Send verification email to user */ export const verifyAccount = async ( req: Request, res: Response ): Promise => { - const { email } = req.body; + try { + const decodedSessionCookie = await auth.verifySessionCookie( + req.cookies.__session + ); - if (!email) { - res.status(400).json({ - status: 400, - error: "Email is required", - }); - return; - } + if (!decodedSessionCookie) { + functions.logger.error("Could not find session cookie"); + res + .status(400) + .json({ status: 400, error: "Could not find session cookie" }); + return; + } + + const email = decodedSessionCookie.email; + if (!email) { + res.status(400).json({ + status: 400, + error: "Email not found in session", + }); + return; + } - try { const link = await auth.generateEmailVerificationLink(email); await sendVerificationEmail(email, link); res.status(200).json({ status: 200, - message: "Account verified successfully", + message: "Email verification link sent", }); } catch (error) { const err = error as FirebaseError; @@ -643,7 +646,7 @@ export const verifyAccount = async ( res.status(400).json({ status: 400, - error: "Invalid or expired verification code", + error: "Something went wrong", }); } }; diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 3d06b1c..8202f9b 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -17,7 +17,6 @@ const authExemptRoutes = [ "/auth/register", "/auth/login", "/auth/session-login", - "/auth/verify-account", "/auth/reset-password", ]; diff --git a/functions/src/middlewares/csrf_middleware.ts b/functions/src/middlewares/csrf_middleware.ts index 559cf14..7056189 100644 --- a/functions/src/middlewares/csrf_middleware.ts +++ b/functions/src/middlewares/csrf_middleware.ts @@ -6,9 +6,9 @@ const csrfExemptRoutes = [ "/auth/login", "/auth/register", "/auth/session-login", - "/auth/verify-account", "/auth/reset-password", "/auth/logout", + "/auth/verify-account", ]; export const csrfProtection: RequestHandler = (