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
+
+
+
+
+
+
+

+
+
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.
+
+
+
+
+
+ `,
+ 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"],