diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 4491c60..2c7fffa 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -37,6 +37,6 @@ module.exports = { "max-len": "off", "new-cap": "off", "linebreak-style": ["error", process.platform === "win32" ? "windows" : "unix"], - "no-explicit-any": "off", + "@typescript-eslint/no-explicit-any": "off" }, }; diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index 7539dcb..def613a 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -1,9 +1,10 @@ -import {Request, Response} from "express"; -import {admin, db} from "../config/firebase"; +import { Request, Response } from "express"; +import { admin, db } from "../config/firebase"; import validator from "validator"; import Busboy from "busboy"; import { - APPLICATION_STATES, APPLICATION_STATUS, + APPLICATION_STATES, + APPLICATION_STATUS, DatetimeValidation, DropdownValidation, ExtendedRequest, @@ -13,16 +14,16 @@ import { NumberValidation, Question, QUESTION_TYPE, - StringValidation + StringValidation, } from "../types/application_types"; -import {getUidFromSessionCookie} from "../utils/jwt"; +import { getUidFromSessionCookie } from "../utils/jwt"; import * as functions from "firebase-functions"; const bucket = admin.storage().bucket(); // upload file const USER_UPLOAD_PATH = `users/uploads/`; -const STORAGE_BASE_LINK = `https://storage.googleapis.com/${bucket.name}/` +const STORAGE_BASE_LINK = `https://storage.googleapis.com/${bucket.name}/`; const VALID_STATES = Object.values(APPLICATION_STATES); @@ -44,10 +45,13 @@ const VALID_STATES = Object.values(APPLICATION_STATES); * This field will be validated accordingly and ignore any other * additional fields that is included in the request. */ -export const patchApplication = async (req: Request, res: Response): Promise => { +export const patchApplication = async ( + req: Request, + res: Response +): Promise => { let errors = []; try { - const UID = await getUidFromSessionCookie(req) + const UID = await getUidFromSessionCookie(req); if (!UID) { res.status(400).json({ status: 400, @@ -90,16 +94,20 @@ export const patchApplication = async (req: Request, res: Response): Promise, state: APPLICATION_STATES, uid: string) { +async function saveData( + dataToSave: Record, + state: APPLICATION_STATES, + uid: string +) { try { // if currently in PROFILE state, then upsert data to `users` collection. if (state === APPLICATION_STATES.PROFILE) { @@ -115,7 +123,7 @@ async function saveData(dataToSave: Record, state: APPLICATION_S data.createdAt = new Date().toISOString(); } - await userRef.set(data, {merge: true}); + await userRef.set(data, { merge: true }); } // upsert other data in `application` section. @@ -132,7 +140,7 @@ async function saveData(dataToSave: Record, state: APPLICATION_S data.createdAt = new Date().toISOString(); } - await docRef.set(data, {merge: true}); + await docRef.set(data, { merge: true }); } } catch (error) { console.error("Error saving application:", error); @@ -144,8 +152,10 @@ async function saveData(dataToSave: Record, state: APPLICATION_S * Construct data to be saved in a proper format. * This method change file name into a proper firebase storage link format. */ -async function constructDataToSave(req: Request): Promise> { - const UID = await getUidFromSessionCookie(req) +async function constructDataToSave( + req: Request +): Promise> { + const UID = await getUidFromSessionCookie(req); const questions: Question[] = await findQuestionsByState(req.body.state); const dataToSave: Record = {}; @@ -153,7 +163,11 @@ async function constructDataToSave(req: Request): Promise if (question.id === undefined || question.id === null) continue; const fieldValue = req.body[question.id]; if (question.type === QUESTION_TYPE.FILE) { - dataToSave[question.id] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${UID}_${question.id}.${req.body[question.id].split(".").pop()}`; + dataToSave[ + question.id + ] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${UID}_${ + question.id + }.${req.body[question.id].split(".").pop()}`; } else { dataToSave[question.id] = fieldValue; } @@ -172,21 +186,29 @@ function validateApplicationState(req: Request) { } else if (!VALID_STATES.includes(req.body.state)) { errors.push({ field_id: `state`, - message: `Invalid state ${req.body.state}. Must be one of ${VALID_STATES.join(", ")}`, + message: `Invalid state ${ + req.body.state + }. Must be one of ${VALID_STATES.join(", ")}`, }); } return errors; } // eslint-disable-next-line require-jsdoc -async function findQuestionsByState(state: APPLICATION_STATES): Promise { - const snapshot = await db.collection("questions") +async function findQuestionsByState( + state: APPLICATION_STATES +): Promise { + const snapshot = await db + .collection("questions") .where("state", "==", state) .get(); - const questions = snapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - } as Question)); + const questions = snapshot.docs.map( + (doc) => + ({ + id: doc.id, + ...doc.data(), + } as Question) + ); return questions; } @@ -201,7 +223,7 @@ async function validateApplicationResponse(req: Request, uid: string) { errors.push({ field_id: `id`, message: `Required field id`, - }) + }); continue; } @@ -213,26 +235,28 @@ async function validateApplicationResponse(req: Request, uid: string) { let fieldErrors; switch (question.type) { - case QUESTION_TYPE.STRING: - fieldErrors = validateStringValue(fieldValue, question); - break; - case QUESTION_TYPE.TEXTAREA: - fieldErrors = validateStringValue(fieldValue, question); - break; - case QUESTION_TYPE.NUMBER: - fieldErrors = validateNumberValue(fieldValue, question); - break; - case QUESTION_TYPE.DATE: - fieldErrors = validateDatetimeValue(fieldValue, question); - break; - case QUESTION_TYPE.DROPDOWN: - fieldErrors = validateDropdownValue(fieldValue, question); - break; - case QUESTION_TYPE.FILE: - fieldErrors = await validateFileUploaded(fieldValue, question, uid) - break; - default: - fieldErrors = [`Unsupported type for field ${question.id}: ${typeof fieldValue}`]; + case QUESTION_TYPE.STRING: + fieldErrors = validateStringValue(fieldValue, question); + break; + case QUESTION_TYPE.TEXTAREA: + fieldErrors = validateStringValue(fieldValue, question); + break; + case QUESTION_TYPE.NUMBER: + fieldErrors = validateNumberValue(fieldValue, question); + break; + case QUESTION_TYPE.DATE: + fieldErrors = validateDatetimeValue(fieldValue, question); + break; + case QUESTION_TYPE.DROPDOWN: + fieldErrors = validateDropdownValue(fieldValue, question); + break; + case QUESTION_TYPE.FILE: + fieldErrors = await validateFileUploaded(fieldValue, question, uid); + break; + default: + fieldErrors = [ + `Unsupported type for field ${question.id}: ${typeof fieldValue}`, + ]; } errors.push(...fieldErrors); @@ -245,13 +269,20 @@ async function validateApplicationResponse(req: Request, uid: string) { * Checking is done by matching the originalName in the uploaded metadata * if match, we confirm that file is uploaded already. */ -async function validateFileUploaded(fieldValue: string | any, question: Question, uid: string) { +async function validateFileUploaded( + fieldValue: string | any, + question: Question, + uid: string +) { const errors = []; const validation = question.validation as FileValidation; // required - if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { errors.push({ field_id: `${question.id}`, message: `This field is required`, @@ -262,7 +293,7 @@ async function validateFileUploaded(fieldValue: string | any, question: Question try { // check in firebase storage const fileName = `${uid}_${question.id}.${fieldValue.split(".").pop()}`; - const fullFilename = `${USER_UPLOAD_PATH}${fileName}` + const fullFilename = `${USER_UPLOAD_PATH}${fileName}`; const fileUpload = bucket.file(fullFilename); const [exists] = await fileUpload.exists(); @@ -282,7 +313,6 @@ async function validateFileUploaded(fieldValue: string | any, question: Question }); } } catch (error) { - const e = error as Error; errors.push({ field_id: `${question.id}`, message: `Error checking file`, @@ -299,7 +329,10 @@ function validateDropdownValue(fieldValue: string | any, question: Question) { const validation = question.validation as DropdownValidation; // required - if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { errors.push({ field_id: `${question.id}`, message: `This field is required`, @@ -325,7 +358,10 @@ function validateDatetimeValue(fieldValue: string, question: Question) { const validation = question.validation as DatetimeValidation; // required - if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { errors.push({ field_id: `${question.id}`, message: `This field is required`, @@ -350,7 +386,10 @@ function validateNumberValue(fieldValue: number | any, question: Question) { const validation = question.validation as NumberValidation; // required - if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { errors.push({ field_id: `${question.id}`, message: `This field is required`, @@ -368,8 +407,7 @@ function validateNumberValue(fieldValue: number | any, question: Question) { } // check value - if (validation.minValue && - fieldValue < validation.minValue) { + if (validation.minValue && fieldValue < validation.minValue) { errors.push({ field_id: `${question.id}`, message: `Must be more than equals ${validation.minValue}`, @@ -392,7 +430,10 @@ function validateStringValue(fieldValue: string | any, question: Question) { const validation = question.validation as StringValidation; // required - if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { errors.push({ field_id: `${question.id}`, message: `This field is required`, @@ -410,8 +451,7 @@ function validateStringValue(fieldValue: string | any, question: Question) { } // check length - if (validation.minLength && - fieldValue.length < validation.minLength) { + if (validation.minLength && fieldValue.length < validation.minLength) { errors.push({ field_id: `${question.id}`, message: `Must be at least ${validation.minLength} character(s)`, @@ -439,16 +479,19 @@ function validateStringValue(fieldValue: string | any, question: Question) { * - `file`: file to be uploaded * - `questionId`: question id to be linked to the file */ -export const uploadFile = async (req: ExtendedRequest, res: Response): Promise => { +export const uploadFile = async ( + req: ExtendedRequest, + res: Response +): Promise => { if (!req.headers["content-type"]) { res.status(400).json({ status: 400, - error: "Missing content-type header" + error: "Missing content-type header", }); return; } - const UID = await getUidFromSessionCookie(req) + const UID = await getUidFromSessionCookie(req); if (!UID) { res.status(400).json({ status: 400, @@ -466,24 +509,23 @@ export const uploadFile = async (req: ExtendedRequest, res: Response): Promise(await findQuestionById(questionId))!; + const question: Question = await findQuestionById(questionId); if (!question) { - res.status(400) - .json({ - error: "Validation failed", - details: [ - { - field_id: `${questionId}`, - message: `No such question`, - } - ] - }); + res.status(400).json({ + error: "Validation failed", + details: [ + { + field_id: `${questionId}`, + message: `No such question`, + }, + ], + }); return; } @@ -494,7 +536,7 @@ export const uploadFile = async (req: ExtendedRequest, res: Response): Promise { - busboy.once("close", resolve) + busboy + .once("close", resolve) .once("error", reject) - .on("file", (fieldname: string, file: NodeJS.ReadableStream, info: FileInfo) => { - // const {filename, encoding, mimeType} = info; - const {filename, mimeType} = info; + .on( + "file", + (fieldname: string, file: NodeJS.ReadableStream, info: FileInfo) => { + // const {filename, encoding, mimeType} = info; + const { filename, mimeType } = info; + + if ( + !validation.allowedTypes || + !validation.allowedTypes.includes(mimeType) + ) { + file.resume(); // discard the file + return; + } - if (!validation.allowedTypes || !validation.allowedTypes.includes(mimeType)) { - file.resume(); // discard the file - return; + const chunks: Buffer[] = []; + file.on("data", (chunk: Buffer) => { + if (!fileSizeExceeded) { + // only collect chunks if size limit not exceeded + chunks.push(chunk); + } + }); + + // handle file size limit + file.on("limit", () => { + fileSizeExceeded = true; + res.writeHead(413, { + Connection: "close", + "Content-Type": "application/json", + }); + res.end( + JSON.stringify({ + error: "File too large", + details: [ + { + field_id: questionId, + message: `File size exceeds maximum limit of ${ + MAX_FILE_SIZE / (1024 * 1024) + }MB`, + }, + ], + }) + ); + }); + + file.on("end", () => { + if (!fileSizeExceeded) { + const newfileData: FileData = { + buffer: Buffer.concat(chunks as unknown as Uint8Array[]), + originalname: filename, + mimetype: mimeType, + fieldname: fieldname, + }; + fileData = newfileData; + } + }); } - - const chunks: Buffer[] = []; - file.on("data", (chunk: Buffer) => { - if (!fileSizeExceeded) { // only collect chunks if size limit not exceeded - chunks.push(chunk); - } - }); - - // handle file size limit - file.on("limit", () => { - fileSizeExceeded = true; - res.writeHead(413, {"Connection": "close", "Content-Type": "application/json"}); - res.end(JSON.stringify({ - error: "File too large", - details: [{ - field_id: questionId, - message: `File size exceeds maximum limit of ${MAX_FILE_SIZE / (1024 * 1024)}MB` - }] - })); - }); - - file.on("end", () => { - if (!fileSizeExceeded) { - const newfileData: FileData = { - buffer: Buffer.concat(chunks), - originalname: filename, - mimetype: mimeType, - fieldname: fieldname - }; - fileData = newfileData; - } - }); - }); + ); // feed busboy with the request data if (req.rawBody) { @@ -561,18 +620,17 @@ export const uploadFile = async (req: ExtendedRequest, res: Response): Promise => { +export const getApplicationQuestions = async ( + req: Request, + res: Response +): Promise => { try { const state: string | undefined = req.query.state?.toString(); if (!state) { @@ -661,8 +724,8 @@ export const getApplicationQuestions = async (req: Request, res: Response): Prom { field_id: `state`, message: `This field is required`, - } - ] + }, + ], }); return; } @@ -674,27 +737,31 @@ export const getApplicationQuestions = async (req: Request, res: Response): Prom details: [ { field_id: "state", - message: `This field is required. Must be one of ${VALID_STATES.join(", ")}`, + message: `This field is required. Must be one of ${VALID_STATES.join( + ", " + )}`, }, ], }); return; } - const questions = await findQuestionsByState(state as APPLICATION_STATES) + const questions = await findQuestionsByState(state as APPLICATION_STATES); res.status(200).json({ status: 200, - data: questions - }) - + data: questions, + }); } catch (error) { const e = error as Error; - res.status(500).json({error: e.message}); + res.status(500).json({ error: e.message }); } -} +}; -export const getApplicationQuestion = async (req: Request, res: Response): Promise => { +export const getApplicationQuestion = async ( + req: Request, + res: Response +): Promise => { try { const questionId: string | undefined = req.query.questionId?.toString(); if (!questionId) { @@ -705,13 +772,13 @@ export const getApplicationQuestion = async (req: Request, res: Response): Promi { field_id: `questionId`, message: `This field is required`, - } - ] + }, + ], }); return; } - const question = await findQuestionById(questionId) + const question = await findQuestionById(questionId); if (!question) { res.status(404).json({ @@ -721,26 +788,28 @@ export const getApplicationQuestion = async (req: Request, res: Response): Promi { field_id: `${questionId}`, message: `Cannot find such question`, - } - ] + }, + ], }); return; } res.status(200).json({ status: 200, - data: question - }) - + data: question, + }); } catch (error) { const e = error as Error; - res.status(500).json({error: e.message}); + res.status(500).json({ error: e.message }); } -} +}; -export const getApplicationStatus = async (req: Request, res: Response): Promise => { +export const getApplicationStatus = async ( + req: Request, + res: Response +): Promise => { try { - const UID = await getUidFromSessionCookie(req) + const UID = await getUidFromSessionCookie(req); if (!UID) { res.status(400).json({ status: 400, @@ -754,7 +823,7 @@ export const getApplicationStatus = async (req: Request, res: Response): Promise res.status(404).json({ status: 404, error: "Not found", - message: `Cannot find this user` + message: `Cannot find this user`, }); } @@ -765,26 +834,29 @@ export const getApplicationStatus = async (req: Request, res: Response): Promise status: 404, error: "Not found", message: `Cannot find application status for this user`, - }) - return + }); + return; } res.status(200).json({ status: 200, - data: data.status - }) + data: data.status, + }); } catch (error) { const e = error as Error; res.status(500).json({ status: 500, - error: e.message + error: e.message, }); } -} +}; -export const setApplicationStatusToSubmitted = async (req: Request, res: Response): Promise => { +export const setApplicationStatusToSubmitted = async ( + req: Request, + res: Response +): Promise => { try { - const UID = await getUidFromSessionCookie(req) + const UID = await getUidFromSessionCookie(req); if (!UID) { res.status(400).json({ @@ -801,17 +873,17 @@ export const setApplicationStatusToSubmitted = async (req: Request, res: Respons updatedAt: new Date().toISOString(), }; - await userRef.set(data, {merge: true}); + await userRef.set(data, { merge: true }); res.status(201).json({ status: 201, success: true, - }) + }); } catch (err) { functions.logger.error("Error updating application status:", err); res.status(500).json({ status: 500, - error: "Internal Server Error" + error: "Internal Server Error", }); } -} \ No newline at end of file +}; diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 07c2af3..ba2159a 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -1,14 +1,14 @@ -import {Request, Response} from "express"; -import {auth, db} from "../config/firebase"; +import { Request, Response } from "express"; +import { auth, db } from "../config/firebase"; import axios from "axios"; import validator from "validator"; -import {formatUser, User} from "../models/user"; -import {FieldValue} from "firebase-admin/firestore"; -import {convertResponseToSnakeCase} from "../utils/camel_case"; +import { formatUser, User } from "../models/user"; +import { FieldValue } from "firebase-admin/firestore"; +import { convertResponseToSnakeCase } from "../utils/camel_case"; 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 { FirebaseError } from "firebase-admin"; +import { generateCsrfToken } from "../middlewares/csrf_middleware"; +import { APPLICATION_STATUS } from "../types/application_types"; const SESSION_EXPIRY_SECONDS = 14 * 24 * 60 * 60 * 1000; // lasts 2 weeks @@ -20,17 +20,16 @@ const validateEmailAndPassword = ( if (!validator.isEmail(email)) { res.status(400).json({ status: 400, - error: "Invalid email" + error: "Invalid email", }); return false; } - if (!validator.isLength(password, {min: 6})) { - res - .status(400) - .json({ - status: 400, - error: "Password must be at least 6 characters long"}); + if (!validator.isLength(password, { min: 6 })) { + res.status(400).json({ + status: 400, + error: "Password must be at least 6 characters long", + }); return false; } @@ -41,7 +40,7 @@ const validateEmailAndPassword = ( * Logs in user */ export const login = async (req: Request, res: Response): Promise => { - const {email, password} = req.body; + const { email, password } = req.body; if (!validateEmailAndPassword(email, password, res)) return; @@ -63,36 +62,38 @@ export const login = async (req: Request, res: Response): Promise => { const user = await auth.getUserByEmail(email); try { - const cookies = await auth.createSessionCookie(token.idToken, {expiresIn: SESSION_EXPIRY_SECONDS}); + const cookies = await auth.createSessionCookie(token.idToken, { + expiresIn: SESSION_EXPIRY_SECONDS, + }); // set session cookies res.cookie("__session", cookies, { httpOnly: true, maxAge: SESSION_EXPIRY_SECONDS, sameSite: "strict", - secure: process.env.NODE_ENV === "production" + secure: process.env.NODE_ENV === "production", }); // revoke refresh token - await auth.revokeRefreshTokens(user.uid) + await auth.revokeRefreshTokens(user.uid); const csrfToken = generateCsrfToken(); // http only cookie res.cookie("CSRF-TOKEN", csrfToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "strict" + sameSite: "strict", }); // non http only cookie res.cookie("XSRF-TOKEN", csrfToken, { httpOnly: false, secure: process.env.NODE_ENV === "production", - sameSite: "strict" - }) + sameSite: "strict", + }); } catch (e) { functions.logger.error("Error when returning session for login", e); - res.status(500).json({error: "Something went wrong."}); - return + res.status(500).json({ error: "Something went wrong." }); + return; } res.status(200).json( @@ -107,7 +108,7 @@ export const login = async (req: Request, res: Response): Promise => { } catch (error) { const err = error as Error; functions.logger.error("Error when trying to log in:", err.message); - res.status(400).json({status: 400, error: "Invalid email or password"}); + res.status(400).json({ status: 400, error: "Invalid email or password" }); } }; @@ -115,18 +116,17 @@ export const login = async (req: Request, res: Response): Promise => { * Registers new user */ export const register = async (req: Request, res: Response): Promise => { - const {name, email, password} = req.body; + const { name, email, password } = req.body; if (!validateEmailAndPassword(email, password, res)) return; try { - if (!name) { res.status(400).json({ status: 400, - error: "Name is required" - }) - return + error: "Name is required", + }); + return; } const isEmulator = process.env.FIREBASE_AUTH_EMULATOR_HOST !== undefined; @@ -139,8 +139,8 @@ export const register = async (req: Request, res: Response): Promise => { // set custom claims to user await auth.setCustomUserClaims(user.uid, { - role: "User" - }) + role: "User", + }); const customToken = await auth.createCustomToken(user.uid); @@ -149,7 +149,7 @@ export const register = async (req: Request, res: Response): Promise => { : `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${process.env.WEB_API_KEY}`; const token = ( - await axios.post(url, {token: customToken, returnSecureToken: true}) + await axios.post(url, { token: customToken, returnSecureToken: true }) ).data; const userData: User = formatUser({ @@ -167,13 +167,15 @@ export const register = async (req: Request, res: Response): Promise => { }); try { - const cookies = await auth.createSessionCookie(token.idToken, {expiresIn: SESSION_EXPIRY_SECONDS}); + const cookies = await auth.createSessionCookie(token.idToken, { + expiresIn: SESSION_EXPIRY_SECONDS, + }); // set cookies res.cookie("__session", cookies, { httpOnly: true, maxAge: SESSION_EXPIRY_SECONDS, sameSite: "strict", - secure: process.env.NODE_ENV === "production" + secure: process.env.NODE_ENV === "production", }); const csrfToken = generateCsrfToken(); @@ -181,20 +183,21 @@ export const register = async (req: Request, res: Response): Promise => { res.cookie("CSRF-TOKEN", csrfToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "strict" + sameSite: "strict", }); // non http only cookie res.cookie("XSRF-TOKEN", csrfToken, { httpOnly: false, secure: process.env.NODE_ENV === "production", - sameSite: "strict" - }) + sameSite: "strict", + }); } catch (e) { functions.logger.error("Error when returning session for register", e); res.status(500).json({ status: 500, - error: "Something went wrong"}); - return + error: "Something went wrong", + }); + return; } res.status(201).json( @@ -210,12 +213,16 @@ export const register = async (req: Request, res: Response): Promise => { } catch (error) { const err = error as Error; console.error("error:", err.message); - res.status(400).json({status: 400, error: err.message}); + res.status(400).json({ status: 400, error: err.message }); } }; export const logout = async (req: Request, res: Response): Promise => { - const user = req.user!; // from auth middleware + const user = req.user; // from auth middleware + if (!user) { + res.status(401).json({ status: 401, error: "Unauthorized" }); + return; + } try { await auth.revokeRefreshTokens(user.uid); @@ -223,7 +230,7 @@ export const logout = async (req: Request, res: Response): Promise => { res.clearCookie("__session", { httpOnly: true, sameSite: "strict", - secure: process.env.NODE_ENV === "production" + secure: process.env.NODE_ENV === "production", }); res.status(200).json({ @@ -238,29 +245,34 @@ export const logout = async (req: Request, res: Response): Promise => { res.clearCookie("__session", { httpOnly: true, sameSite: "strict", - secure: process.env.NODE_ENV === "production" + secure: process.env.NODE_ENV === "production", }); - res.status(500).json({status, error: "Something went wrong."}); + res.status(500).json({ status, error: "Something went wrong." }); } }; /** * Session login. Required for native Google Sign In Button. */ -export const sessionLogin = async (req: Request, res: Response): Promise => { +export const sessionLogin = async ( + req: Request, + res: Response +): Promise => { const idToken = req.body.id_token; if (!idToken) { - functions.logger.warn("Required id_token in the body") + functions.logger.warn("Required id_token in the body"); res.status(400).json({ status: 400, - error: "Required id_token in the body" + error: "Required id_token in the body", }); return; } try { - const cookies = await auth.createSessionCookie(idToken, {expiresIn: SESSION_EXPIRY_SECONDS}); // lasts a week + const cookies = await auth.createSessionCookie(idToken, { + expiresIn: SESSION_EXPIRY_SECONDS, + }); // lasts a week const decodedIdToken = await auth.verifyIdToken(idToken); @@ -285,8 +297,11 @@ export const sessionLogin = async (req: Request, res: Response): Promise = }); } } else { - functions.logger.error("Could not find existing user with email", decodedIdToken.email); - res.status(400).json({status: 400, error: "Invalid credentials"}); + functions.logger.error( + "Could not find existing user with email", + decodedIdToken.email + ); + res.status(400).json({ status: 400, error: "Invalid credentials" }); return; } @@ -294,7 +309,7 @@ export const sessionLogin = async (req: Request, res: Response): Promise = httpOnly: true, maxAge: SESSION_EXPIRY_SECONDS, sameSite: "strict", - secure: process.env.NODE_ENV === "production" + secure: process.env.NODE_ENV === "production", }); const csrfToken = generateCsrfToken(); @@ -302,55 +317,65 @@ export const sessionLogin = async (req: Request, res: Response): Promise = res.cookie("CSRF-TOKEN", csrfToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "strict" + sameSite: "strict", }); // non http only cookie res.cookie("XSRF-TOKEN", csrfToken, { httpOnly: false, secure: process.env.NODE_ENV === "production", - sameSite: "strict" - }) + sameSite: "strict", + }); res.status(200).json({ status: 200, - "message": "Login successful", - "user": { + message: "Login successful", + user: { email: user.email, displayName: user.displayName, - } - }) + }, + }); } catch (e) { const err = e as FirebaseError; if (err.code === "auth/user-not-found") { functions.logger.error("User not found", e); - res.status(404).json({status: 404, error: "User not found"}); + res.status(404).json({ status: 404, error: "User not found" }); return; } else if (err.code === "auth/invalid-id-token") { functions.logger.error("Invalid credentials"); - res.status(401).json({status: 401, error: "ID token is invalid"}); + res.status(401).json({ status: 401, error: "ID token is invalid" }); return; } else if (err.code === "auth/id-token-expired") { functions.logger.error("The provided Firebase ID token is expired"); - res.status(401).json({status: 401, error: "The provided Firebase ID token is expired"}); + res.status(401).json({ + status: 401, + error: "The provided Firebase ID token is expired", + }); return; } functions.logger.error("Error when trying to session login", e); - res.status(500).json({status: 500, error: e}); + res.status(500).json({ status: 500, error: e }); } -} +}; /** * Verify cookie session. To be fetched by auth state manager. * @param req * @param res */ -export const sessionCheck = async (req: Request, res: Response): Promise => { +export const sessionCheck = async ( + req: Request, + res: Response +): Promise => { try { - const decodedSessionCookie = await auth.verifySessionCookie(req.cookies.__session); + const decodedSessionCookie = await auth.verifySessionCookie( + req.cookies.__session + ); if (!decodedSessionCookie) { - functions.logger.error("Could not find session cookie") - res.status(400).json({status: 400, error: "Could not find session cookie"}); + functions.logger.error("Could not find session cookie"); + res + .status(400) + .json({ status: 400, error: "Could not find session cookie" }); } res.status(200).json({ @@ -359,13 +384,13 @@ export const sessionCheck = async (req: Request, res: Response): Promise = data: { user: { email: decodedSessionCookie.email, - displayName: decodedSessionCookie.name - } - } - }) - return + displayName: decodedSessionCookie.name, + }, + }, + }); + return; } catch (e) { functions.logger.error("Error when trying to check session", e); - res.status(400).json({status: 400, error: e}); + res.status(400).json({ status: 400, error: e }); } -} \ No newline at end of file +}; diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 74eaa40..f193310 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -2,7 +2,6 @@ import * as functions from "firebase-functions"; import {admin, auth} from "../config/firebase"; import {NextFunction, Request, Response} from "express"; import {extractSessionCookieFromCookie} from "../utils/jwt"; -import {FirebaseError} from "firebase-admin"; // Extend Express Request interface to include the user property. declare global { diff --git a/functions/src/middlewares/csrf_middleware.ts b/functions/src/middlewares/csrf_middleware.ts index a501e6d..a00c3ed 100644 --- a/functions/src/middlewares/csrf_middleware.ts +++ b/functions/src/middlewares/csrf_middleware.ts @@ -1,4 +1,4 @@ -import {NextFunction} from "express"; +import { NextFunction, Request, Response, RequestHandler } from "express"; import crypto from "crypto"; import * as functions from "firebase-functions"; @@ -7,29 +7,43 @@ const csrfExemptRoutes = [ "/auth/register", "/auth/session-login", // "/auth/reset-password", -] +]; -export const csrfProtection = (req: Request, res: Response, next: NextFunction) => { +export const csrfProtection: RequestHandler = ( + req: Request, + res: Response, + next: NextFunction +) => { // Skip CSRF protection for GET, HEAD, OPTIONS if (["GET", "HEAD", "OPTIONS"].includes(req.method)) { - return next(); + next(); + return; } - if (csrfExemptRoutes.some(route => req.path?.startsWith(route))) { - return next(); + if (csrfExemptRoutes.some((route) => req.path?.startsWith(route))) { + next(); + return; } const csrfCookie = req.cookies?.["CSRF-TOKEN"] as string | undefined; const csrfHeader = req.header("x-csrf-token"); + functions.logger.log("CSRF Cookie:", csrfCookie); + functions.logger.log("CSRF Header:", csrfHeader); + if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) { - functions.logger.log("CSRF validation rejected as cookie and header does not match.") - return res.status(403).json({ status: 403, error: "CSRF token validation failed" }); + functions.logger.log( + "CSRF validation rejected as cookie and header does not match." + ); + res + .status(403) + .json({ status: 403, error: "CSRF token validation failed" }); + return; } - return next(); + next(); }; export const generateCsrfToken = (): string => { return crypto.randomBytes(16).toString("hex"); -}; \ No newline at end of file +}; diff --git a/functions/src/middlewares/role_middleware.ts b/functions/src/middlewares/role_middleware.ts index a23b583..c4a5b74 100644 --- a/functions/src/middlewares/role_middleware.ts +++ b/functions/src/middlewares/role_middleware.ts @@ -1,10 +1,15 @@ -import {NextFunction} from "express"; -import {auth} from "../config/firebase"; -import {RoleType} from "../models/role"; +import { NextFunction, Request, Response } from "express"; +import { auth } from "../config/firebase"; +import { RoleType } from "../models/role"; -export const restrictToRole = async (req: Request, res: Response, next: NextFunction, allowedRoles: string[]) => { +export const restrictToRole = async ( + req: Request, + res: Response, + next: NextFunction, + allowedRoles: string[] +) => { try { - const sessionCookie = res.cookies.__session + const sessionCookie = req.cookies.__session; // Verify session cookie const decodedClaims = await auth.verifySessionCookie(sessionCookie, true); @@ -14,13 +19,17 @@ export const restrictToRole = async (req: Request, res: Response, next: NextFunc if (!allowedRoles.includes(userRole)) { return res.status(403).json({ status: 403, - error: "Forbidden: Insufficient permissions" }); + error: "Forbidden: Insufficient permissions", + }); } req.user = decodedClaims; return next(); } catch (error) { console.error("Error verifying session cookie:", error); - return res.status(401).json({ status: 401, error: "Unauthorized: Invalid or missing session cookie" }); + return res.status(401).json({ + status: 401, + error: "Unauthorized: Invalid or missing session cookie", + }); } -} \ No newline at end of file +};