diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 8ee0e98..ffc5b0b 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -26,7 +26,7 @@ module.exports = { plugins: ["@typescript-eslint", "import"], rules: { indent: ["error", 2], - quotes: ["error", "double", { allowTemplateLiterals: true }], + quotes: "off", "import/no-unresolved": 0, "no-restricted-globals": ["error", "name", "length"], "prefer-arrow-callback": "error", diff --git a/functions/package-lock.json b/functions/package-lock.json index 1d3e2e7..b0acd38 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -16,6 +16,7 @@ "firebase-functions": "^6.3.2", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", + "nodemailer": "^7.0.3", "validator": "^13.15.0" }, "devDependencies": { @@ -27,6 +28,7 @@ "@types/express": "^5.0.1", "@types/jsonwebtoken": "^9.0.9", "@types/lodash": "^4.17.16", + "@types/nodemailer": "^6.4.17", "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -1844,6 +1846,16 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -7001,6 +7013,15 @@ "license": "MIT", "peer": true }, + "node_modules/nodemailer": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", + "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/functions/package.json b/functions/package.json index fe05944..55b50ae 100644 --- a/functions/package.json +++ b/functions/package.json @@ -26,6 +26,7 @@ "firebase-functions": "^6.3.2", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", + "nodemailer": "^7.0.3", "validator": "^13.15.0" }, "devDependencies": { @@ -37,6 +38,7 @@ "@types/express": "^5.0.1", "@types/jsonwebtoken": "^9.0.9", "@types/lodash": "^4.17.16", + "@types/nodemailer": "^6.4.17", "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index ba2159a..395e1c0 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -9,6 +9,7 @@ import * as functions from "firebase-functions"; import { FirebaseError } from "firebase-admin"; import { generateCsrfToken } from "../middlewares/csrf_middleware"; import { APPLICATION_STATUS } from "../types/application_types"; +import nodemailer from "nodemailer"; const SESSION_EXPIRY_SECONDS = 14 * 24 * 60 * 60 * 1000; // lasts 2 weeks @@ -394,3 +395,217 @@ export const sessionCheck = async ( res.status(400).json({ status: 400, error: e }); } }; + +// 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 + + + + +
+
+ Garuda Hacks Logo +
+

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 + */ +export const requestPasswordReset = async ( + req: Request, + res: Response +): Promise => { + const { email } = req.body; + + if (!email || !validator.isEmail(email)) { + res.status(400).json({ + status: 400, + error: "Valid email is required", + }); + return; + } + + try { + // Check if user exists + 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 + ); + functions.logger.info("Password reset link generated successfully"); + + // Send password reset email + await sendPasswordResetEmail(email, link); + + // Send success response + res.status(200).json({ + status: 200, + message: + "If an account exists with this email, a password reset link has been sent", + }); + } catch (error) { + const err = error as FirebaseError; + functions.logger.error("Error in password reset process:", err); + + // Send generic response for security + res.status(200).json({ + status: 200, + message: + "If an account exists with this email, a password reset link has been sent", + }); + } +}; + +/** + * Reset password 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", +// }); +// } +// }; diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index f193310..e3e309f 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -1,7 +1,7 @@ import * as functions from "firebase-functions"; -import {admin, auth} from "../config/firebase"; -import {NextFunction, Request, Response} from "express"; -import {extractSessionCookieFromCookie} from "../utils/jwt"; +import { admin, auth } from "../config/firebase"; +import { NextFunction, Request, Response } from "express"; +import { extractSessionCookieFromCookie } from "../utils/jwt"; // Extend Express Request interface to include the user property. declare global { @@ -16,8 +16,10 @@ declare global { const authExemptRoutes = [ "/auth/register", "/auth/login", - "/auth/session-login" -] + "/auth/session-login", + "/auth/request-reset", + "/auth/reset-password", +]; /** * Middleware that validates Firebase Session Cookie passed as __session cookie. @@ -27,7 +29,7 @@ export const validateSessionCookie = async ( res: Response, next: NextFunction ) => { - if (authExemptRoutes.some(route => req.path?.startsWith(route))) { + if (authExemptRoutes.some((route) => req.path?.startsWith(route))) { return next(); } @@ -43,20 +45,26 @@ export const validateSessionCookie = async ( ); res.status(401).json({ status: 401, - error: "No session cookie found" + error: "No session cookie found", }); return; } try { - const decodedSessionCookie = await auth.verifySessionCookie(sessionCookie, true); - functions.logger.log("Session cookie correctly decoded", decodedSessionCookie); + const decodedSessionCookie = await auth.verifySessionCookie( + sessionCookie, + true + ); + functions.logger.log( + "Session cookie correctly decoded", + decodedSessionCookie + ); req.user = decodedSessionCookie; return next(); } catch (error) { functions.logger.error("Error while verifying session cookie:", error); res.status(401).json({ status: 401, - error: "Error while verifying session cookie" + error: "Error while verifying session cookie", }); } -}; \ No newline at end of file +}; diff --git a/functions/src/middlewares/csrf_middleware.ts b/functions/src/middlewares/csrf_middleware.ts index a472cfa..dfc8cfb 100644 --- a/functions/src/middlewares/csrf_middleware.ts +++ b/functions/src/middlewares/csrf_middleware.ts @@ -6,7 +6,8 @@ const csrfExemptRoutes = [ "/auth/login", "/auth/register", "/auth/session-login", - // "/auth/reset-password", + "/auth/request-reset", + "/auth/reset-password", ]; export const csrfProtection: RequestHandler = ( diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index b8173a4..7d5f4de 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -1,12 +1,24 @@ -import express, {Request, Response} from "express"; -import {login, logout, register, sessionCheck, sessionLogin,} from "../controllers/auth_controller"; +import express, { Request, Response } from "express"; +import { + login, + logout, + register, + requestPasswordReset, + sessionCheck, + sessionLogin, +} from "../controllers/auth_controller"; 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("/session-login", (req: Request, res: Response) => sessionLogin(req, res)) -router.get("/session-check", (req: Request, res: Response) => sessionCheck(req, res)) +router.post("/request-reset", requestPasswordReset); +router.post("/session-login", (req: Request, res: Response) => + sessionLogin(req, res) +); +router.get("/session-check", (req: Request, res: Response) => + sessionCheck(req, res) +); router.post("/logout", (req: Request, res: Response) => logout(req, res)); export default router; diff --git a/functions/src/server.ts b/functions/src/server.ts index 4dcd77d..1fd0553 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -15,6 +15,7 @@ const corsOptions: CorsOptions = { "http://localhost:5173", "https://garudahacks.com", "https://www.garudahacks.com", + "https://portal-ochre-iota.vercel.app" ], credentials: true, allowedHeaders: ["Content-Type", "Authorization", "X-XSRF-TOKEN"],