From 34c2fd959d2581b0fa17183547e7695b06a63300 Mon Sep 17 00:00:00 2001 From: ChaohuiLi0321 Date: Fri, 26 Sep 2025 17:20:00 +1000 Subject: [PATCH] feat: resolve the problem of the SendGrid 'Maximum credits exceeded' - Initialize SendGrid API key with support for multiple environment variable names. - Add debug logging for MFA token exposure during local development. - Enable MFA by default during user registration. - Make contact number and address fields optional in signup validation for API compatibility. --- controller/loginController.js | 97 +++++++++++++++++++++++++++------- controller/signupController.js | 1 + services/authService.js | 13 +++-- validators/signupValidator.js | 6 ++- 4 files changed, 90 insertions(+), 27 deletions(-) diff --git a/controller/loginController.js b/controller/loginController.js index c225d04..64af688 100644 --- a/controller/loginController.js +++ b/controller/loginController.js @@ -8,8 +8,13 @@ const crypto = require("crypto"); const supabase = require("../dbConnection"); const { validationResult } = require("express-validator"); -// โœ… Set SendGrid API key once globally -sgMail.setApiKey(process.env.SENDGRID_KEY); +// โœ… Initialize SendGrid API key once globally (support multiple env var names) +const _sendgridKey = process.env.SENDGRID_API_KEY || process.env.SENDGRID_KEY; +if (_sendgridKey) { + sgMail.setApiKey(_sendgridKey); +} else { + console.warn("SendGrid API key not set (SENDGRID_API_KEY or SENDGRID_KEY). Email sending will be disabled."); +} const login = async (req, res) => { const errors = validationResult(req); @@ -92,10 +97,40 @@ const login = async (req, res) => { if (user.mfa_enabled) { const token = crypto.randomInt(100000, 999999); await addMfaToken(user.user_id, token); - await sendOtpEmail(user.email, token); - return res.status(202).json({ - message: "An MFA Token has been sent to your email address" - }); + // If developer explicitly requests the OTP be sent to client (for local testing), skip calling SendGrid + const exposeOtpToClient = (process.env.NODE_ENV !== 'production') && (process.env.SEND_OTP_TO_CLIENT === 'true'); + console.log('DEBUG: NODE_ENV=', process.env.NODE_ENV, 'SEND_OTP_TO_CLIENT=', process.env.SEND_OTP_TO_CLIENT, 'exposeOtpToClient=', exposeOtpToClient); + + if (exposeOtpToClient) { + // Skip sending via SendGrid to avoid 'Maximum credits exceeded' during local dev + // Display the MFA token prominently in terminal for developer testing + console.log(''); + console.log('๐Ÿ” =============================================='); + console.log('๐Ÿ“ง [DEV] MFA Token Generated for Testing:'); + console.log(`๐Ÿ“ฑ Email: ${user.email}`); + console.log(`๐Ÿ”ข MFA Code: ${token}`); + console.log('๐Ÿ” =============================================='); + console.log(''); + + // Also expose the token in a response header so frontends can read it automatically + res.setHeader('X-DEV-MFA-TOKEN', token); + // Allow browsers to access this custom header + res.setHeader('Access-Control-Expose-Headers', 'X-DEV-MFA-TOKEN'); + const responseBody = { message: "An MFA Token has been requested for your account", token }; + return res.status(202).json(responseBody); + } + + // production/default: attempt to send via SendGrid as before + const sendResult = await sendOtpEmail(user.email, token); + + if (!sendResult?.ok) { + console.warn('sendOtpEmail failed', sendResult); + const responseBody = { message: "An MFA Token has been requested for your account" }; + if (process.env.NODE_ENV !== 'production') responseBody.sendgrid = sendResult; + return res.status(202).json(responseBody); + } + + return res.status(202).json({ message: "An MFA Token has been sent to your email address" }); } await logLoginEvent({ @@ -106,13 +141,19 @@ const login = async (req, res) => { }); // โœ… RBAC-aware JWT generation + const jwtSecret = process.env.JWT_SECRET || process.env.JWT_TOKEN || process.env.JWT; + if (!jwtSecret) { + console.error('JWT secret is not configured. Set JWT_SECRET (or JWT_TOKEN) in your environment.'); + return res.status(500).json({ error: 'Server configuration error: missing JWT secret' }); + } + const token = jwt.sign( { userId: user.user_id, role: user.user_roles?.role_name || "unknown" }, - process.env.JWT_TOKEN, - { expiresIn: "1h" } + jwtSecret, + { expiresIn: "10m" } // ไฟฎๆ”นไธบ10ๅˆ†้’Ÿ ); return res.status(200).json({ user, token }); @@ -154,13 +195,19 @@ const loginMfa = async (req, res) => { } // โœ… RBAC-aware JWT + const jwtSecret = process.env.JWT_SECRET || process.env.JWT_TOKEN || process.env.JWT; + if (!jwtSecret) { + console.error('JWT secret is not configured. Set JWT_SECRET (or JWT_TOKEN) in your environment.'); + return res.status(500).json({ error: 'Server configuration error: missing JWT secret' }); + } + const token = jwt.sign( { userId: user.user_id, role: user.user_roles?.role_name || "unknown" }, - process.env.JWT_TOKEN, - { expiresIn: "1h" } + jwtSecret, + { expiresIn: "10m" } // ไฟฎๆ”นไธบ10ๅˆ†้’Ÿ ); return res.status(200).json({ user, token }); @@ -174,33 +221,43 @@ const loginMfa = async (req, res) => { // โœ… Send OTP email via SendGrid async function sendOtpEmail(email, token) { try { + const from = process.env.FROM_EMAIL || process.env.SENDGRID_FROM || 'noreply@nutrihelp.com'; + if (!_sendgridKey) { + console.warn(`Not sending OTP email to ${email} because SendGrid is not configured. OTP token: ${token}`); + return { ok: false, reason: 'sendgrid_not_configured', token }; + } + await sgMail.send({ to: email, - from: process.env.SENDGRID_FROM, + from, subject: "NutriHelp Login Token", text: `Your token to log in is ${token}`, html: `Your token to log in is ${token}` }); console.log("OTP email sent successfully to", email); + return { ok: true }; } catch (err) { - console.error("Error sending OTP email:", err.response?.body || err.message); + const errBody = err.response?.body || err.message; + console.error("Error sending OTP email:", errBody); + // If SendGrid returns an error like 'Maximum credits exceeded', surface that to caller + return { ok: false, reason: 'sendgrid_error', detail: errBody }; } } // โœ… Send failed login alert via SendGrid async function sendFailedLoginAlert(email, ip) { try { + const from = process.env.FROM_EMAIL || process.env.SENDGRID_FROM || 'noreply@nutrihelp.com'; + if (!_sendgridKey) { + console.warn(`Not sending failed-login alert to ${email} because SendGrid is not configured. IP: ${ip}`); + return; + } + await sgMail.send({ - from: process.env.SENDGRID_FROM, + from, to: email, subject: "Failed Login Attempt on NutriHelp", - text: `Hi, - -Someone tried to log in to NutriHelp using your email address from IP: ${ip}. - -If this wasn't you, please ignore this message. But if you're concerned, consider resetting your password or contacting support. - -โ€“ NutriHelp Security Team` + text: `Hi,\n\nSomeone tried to log in to NutriHelp using your email address from IP: ${ip}.\n\nIf this wasn't you, please ignore this message. But if you're concerned, consider resetting your password or contacting support.\n\nโ€“ NutriHelp Security Team` }); console.log(`Failed login alert sent to ${email}`); } catch (err) { diff --git a/controller/signupController.js b/controller/signupController.js index eb836a7..0ae199f 100644 --- a/controller/signupController.js +++ b/controller/signupController.js @@ -6,6 +6,7 @@ const { registerValidation } = require('../validators/signupValidator.js'); // const supabase = require('../dbConnection'); const logLoginEvent = require("../Monitor_&_Logging/loginLogger"); const { supabase } = require("../database/supabase"); +const { createClient } = require('@supabase/supabase-js'); // Add missing import const safeLog = async (payload) => { try { await logLoginEvent(payload); } catch (e) { console.warn("log error:", e.message); } diff --git a/services/authService.js b/services/authService.js index db0f437..078656e 100644 --- a/services/authService.js +++ b/services/authService.js @@ -11,7 +11,7 @@ const supabase = createClient( class AuthService { constructor() { - this.accessTokenExpiry = '15m'; // 15 minutes + this.accessTokenExpiry = '10m'; // 10 minutes - ไฟฎๆ”นไธบ10ๅˆ†้’Ÿ this.refreshTokenExpiry = 7 * 24 * 60 * 60 * 1000; // 7 days } @@ -47,9 +47,12 @@ class AuthService { last_name, role_id: 7, account_status: 'active', - email_verified: false, - mfa_enabled: false, - registration_date: new Date().toISOString() + //email_verified: false, + //mfa_enabled: false, + //registration_date: new Date().toISOString() + mfa_enabled: true, // โœ… Enable MFA by default + // Remove fields that don't exist in database schema + // first_name, last_name, role_id, account_status, email_verified, registration_date }) .select('user_id, email, name') .single(); @@ -181,7 +184,7 @@ class AuthService { return { accessToken, refreshToken, - expiresIn: 15 * 60, // 15 minutes in seconds + expiresIn: 10 * 60, // 10 minutes in seconds - ไฟฎๆ”นไธบ10ๅˆ†้’Ÿ tokenType: 'Bearer' }; diff --git a/validators/signupValidator.js b/validators/signupValidator.js index d11b735..a98475d 100644 --- a/validators/signupValidator.js +++ b/validators/signupValidator.js @@ -18,11 +18,13 @@ const registerValidation = [ .matches(/[^A-Za-z0-9]/).withMessage('Password needs a special character'), body('contact_number') - .notEmpty().withMessage('Contact number is required') + //.notEmpty().withMessage('Contact number is required') + .optional() // Make optional for /api/auth/register compatibility .isMobilePhone().withMessage('Please enter a valid contact number'), body('address') - .notEmpty().withMessage('Address is required') + //.notEmpty().withMessage('Address is required') + .optional() // Make optional for /api/auth/register compatibility .isLength({ min: 10 }).withMessage('Address should be at least 10 characters long'), ];