From 52d921c141fe38c9caafebca76cf7102399b3a1b Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 31 Jul 2025 11:58:48 -0600 Subject: [PATCH] smtp integration and password recovery. --- map/app/Dockerfile | 1 + .../controllers/passwordRecoveryController.js | 60 +++++++++++ map/app/package-lock.json | 32 +++--- map/app/package.json | 1 + map/app/public/login.html | 58 +++++++++++ map/app/routes/auth.js | 4 + map/app/routes/debug.js | 18 ++++ map/app/server.js | 4 + map/app/services/email.js | 99 +++++++++++++++++++ map/app/services/emailTemplates.js | 69 +++++++++++++ .../templates/email/password-recovery.html | 79 +++++++++++++++ map/app/templates/email/password-recovery.txt | 15 +++ 12 files changed, 427 insertions(+), 13 deletions(-) create mode 100644 map/app/controllers/passwordRecoveryController.js create mode 100644 map/app/services/email.js create mode 100644 map/app/services/emailTemplates.js create mode 100644 map/app/templates/email/password-recovery.html create mode 100644 map/app/templates/email/password-recovery.txt diff --git a/map/app/Dockerfile b/map/app/Dockerfile index 95c6c6c..0b7361b 100644 --- a/map/app/Dockerfile +++ b/map/app/Dockerfile @@ -30,6 +30,7 @@ COPY config ./config COPY controllers ./controllers COPY middleware ./middleware COPY utils ./utils +COPY templates ./templates # Create non-root user RUN addgroup -g 1001 -S nodejs && \ diff --git a/map/app/controllers/passwordRecoveryController.js b/map/app/controllers/passwordRecoveryController.js new file mode 100644 index 0000000..de83da1 --- /dev/null +++ b/map/app/controllers/passwordRecoveryController.js @@ -0,0 +1,60 @@ +const nocodbService = require('../services/nocodb'); +const { sendPasswordRecovery } = require('../services/email'); +const logger = require('../utils/logger'); + +class PasswordRecoveryController { + async requestPassword(req, res) { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + success: false, + error: 'Email address is required' + }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ + success: false, + error: 'Invalid email format' + }); + } + + logger.info(`Password recovery requested for: ${email}`); + + // Find user in database + const user = await nocodbService.getUserByEmail(email); + + if (!user) { + // Don't reveal whether the email exists or not for security + logger.warn(`Password recovery attempted for non-existent email: ${email}`); + return res.json({ + success: true, + message: 'If an account exists with this email, you will receive your password shortly.' + }); + } + + // Send password email + await sendPasswordRecovery(user); + + logger.info(`Password recovery email sent to: ${email}`); + + res.json({ + success: true, + message: 'If an account exists with this email, you will receive your password shortly.' + }); + + } catch (error) { + logger.error('Password recovery error:', error); + res.status(500).json({ + success: false, + error: 'Failed to process password recovery request. Please try again later.' + }); + } + } +} + +module.exports = new PasswordRecoveryController(); diff --git a/map/app/package-lock.json b/map/app/package-lock.json index 4e93318..1a18f5f 100644 --- a/map/app/package-lock.json +++ b/map/app/package-lock.json @@ -20,6 +20,7 @@ "helmet": "^7.1.0", "multer": "^1.4.5-lts.1", "node-fetch": "^2.7.0", + "nodemailer": "^7.0.5", "qrcode": "^1.5.3", "winston": "^3.11.0" }, @@ -725,16 +726,15 @@ } }, "node_modules/express-session": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", - "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", - "license": "MIT", + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", "dependencies": { "cookie": "0.7.2", "cookie-signature": "1.0.7", "debug": "2.6.9", "depd": "~2.0.0", - "on-headers": "~1.0.2", + "on-headers": "~1.1.0", "parseurl": "~1.3.3", "safe-buffer": "5.2.1", "uid-safe": "~2.1.5" @@ -834,10 +834,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", - "license": "MIT", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -1339,6 +1338,14 @@ } } }, + "node_modules/nodemailer": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", + "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -1437,10 +1444,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "license": "MIT", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "engines": { "node": ">= 0.8" } diff --git a/map/app/package.json b/map/app/package.json index 6df1457..88835dc 100644 --- a/map/app/package.json +++ b/map/app/package.json @@ -29,6 +29,7 @@ "helmet": "^7.1.0", "multer": "^1.4.5-lts.1", "node-fetch": "^2.7.0", + "nodemailer": "^7.0.5", "qrcode": "^1.5.3", "winston": "^3.11.0" }, diff --git a/map/app/public/login.html b/map/app/public/login.html index 052fea7..d0cac79 100644 --- a/map/app/public/login.html +++ b/map/app/public/login.html @@ -136,6 +136,21 @@ color: var(--secondary-color); font-size: 14px; } + + .password-recovery { + text-align: center; + margin-top: 15px; + } + + .password-recovery a { + color: var(--primary-color); + text-decoration: none; + font-size: 14px; + } + + .password-recovery a:hover { + text-decoration: underline; + } @@ -180,6 +195,10 @@ +
+ Forgot your password? +
+ @@ -262,6 +281,45 @@ } }) .catch(console.error); + + // Add password recovery handler + document.getElementById('forgot-password-link').addEventListener('click', async (e) => { + e.preventDefault(); + + const email = prompt('Please enter your email address:'); + if (!email) return; + + const errorMessage = document.getElementById('error-message'); + const successMessage = document.getElementById('success-message'); + + // Clear previous messages + errorMessage.classList.remove('show'); + successMessage.classList.remove('show'); + + try { + const response = await fetch('/api/auth/recover-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }) + }); + + const data = await response.json(); + + if (data.success) { + successMessage.textContent = data.message; + successMessage.classList.add('show'); + } else { + errorMessage.textContent = data.error || 'Failed to process request'; + errorMessage.classList.add('show'); + } + } catch (error) { + console.error('Password recovery error:', error); + errorMessage.textContent = 'Failed to send password recovery email'; + errorMessage.classList.add('show'); + } + }); diff --git a/map/app/routes/auth.js b/map/app/routes/auth.js index 9e6e603..f041300 100644 --- a/map/app/routes/auth.js +++ b/map/app/routes/auth.js @@ -1,11 +1,15 @@ const express = require('express'); const router = express.Router(); const authController = require('../controllers/authController'); +const passwordRecoveryController = require('../controllers/passwordRecoveryController'); const { authLimiter } = require('../middleware/rateLimiter'); // Login route with rate limiting router.post('/login', authLimiter, authController.login); +// Password recovery route with rate limiting +router.post('/recover-password', authLimiter, passwordRecoveryController.requestPassword); + // Logout route router.post('/logout', authController.logout); diff --git a/map/app/routes/debug.js b/map/app/routes/debug.js index 3276851..01fa43f 100644 --- a/map/app/routes/debug.js +++ b/map/app/routes/debug.js @@ -261,4 +261,22 @@ router.get('/login-structure', async (req, res) => { } }); +// Reload email templates (development only) +router.get('/reload-email-templates', (req, res) => { + try { + const emailTemplates = require('../services/emailTemplates'); + emailTemplates.clearCache(); + res.json({ + success: true, + message: 'Email template cache cleared' + }); + } catch (error) { + logger.error('Error clearing email template cache:', error); + res.status(500).json({ + success: false, + error: 'Failed to clear email template cache' + }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/map/app/server.js b/map/app/server.js index 5aad159..4dbb72b 100644 --- a/map/app/server.js +++ b/map/app/server.js @@ -12,6 +12,7 @@ const logger = require('./utils/logger'); const { getCookieConfig } = require('./utils/helpers'); const { apiLimiter } = require('./middleware/rateLimiter'); const { cacheBusting } = require('./utils/cacheBusting'); +const { initializeEmailService } = require('./services/email'); // Initialize Express app const app = express(); @@ -142,6 +143,9 @@ app.get('/api/docs-search', async (req, res) => { } }); +// Initialize email service +initializeEmailService(); + // Import and setup routes require('./routes')(app); diff --git a/map/app/services/email.js b/map/app/services/email.js new file mode 100644 index 0000000..7fcc455 --- /dev/null +++ b/map/app/services/email.js @@ -0,0 +1,99 @@ +const nodemailer = require('nodemailer'); +const logger = require('../utils/logger'); +const emailTemplates = require('./emailTemplates'); +const config = require('../config'); + +// Create reusable transporter +let transporter; + +const initializeEmailService = () => { + const emailConfig = { + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT), + secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS + } + }; + + // Additional settings for specific providers + if (process.env.SMTP_SERVICE) { + emailConfig.service = process.env.SMTP_SERVICE; // 'gmail' for example + } + + // Handle TLS for port 587 + if (emailConfig.port === 587) { + emailConfig.secure = false; + emailConfig.requireTLS = true; + } + + transporter = nodemailer.createTransport(emailConfig); + + // Verify connection + transporter.verify((error, success) => { + if (error) { + logger.error('SMTP connection failed:', error); + } else { + logger.info('SMTP server ready to send emails'); + } + }); +}; + +const sendEmail = async ({ to, subject, text, html }) => { + if (!transporter) { + throw new Error('Email service not initialized'); + } + + const mailOptions = { + from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM_ADDRESS}>`, + to, + subject, + text, + html + }; + + try { + const info = await transporter.sendMail(mailOptions); + logger.info(`Email sent: ${info.messageId}`); + return info; + } catch (error) { + logger.error('Failed to send email:', error); + throw error; + } +}; + +const sendPasswordRecovery = async (user) => { + try { + const baseUrl = config.isProduction ? + `https://map.${config.domain}` : + `http://localhost:${config.port}`; + + const variables = { + APP_NAME: process.env.APP_NAME || 'CMlite Map', + USER_NAME: user.Name || user.name || user.Email || user.email, + USER_EMAIL: user.Email || user.email, + PASSWORD: user.Password || user.password, + LOGIN_URL: `${baseUrl}/login.html`, + TIMESTAMP: new Date().toLocaleString() + }; + + const { html, text } = await emailTemplates.render('password-recovery', variables); + + return await sendEmail({ + to: user.Email || user.email, + subject: `Password Recovery - ${variables.APP_NAME}`, + text, + html + }); + } catch (error) { + logger.error('Failed to send password recovery email:', error); + throw error; + } +}; + +module.exports = { + initializeEmailService, + sendEmail, + sendPasswordRecovery +}; \ No newline at end of file diff --git a/map/app/services/emailTemplates.js b/map/app/services/emailTemplates.js new file mode 100644 index 0000000..cd140f0 --- /dev/null +++ b/map/app/services/emailTemplates.js @@ -0,0 +1,69 @@ +const fs = require('fs').promises; +const path = require('path'); +const logger = require('../utils/logger'); + +class EmailTemplateService { + constructor() { + this.templatesDir = path.join(__dirname, '../templates/email'); + this.cache = new Map(); + } + + async loadTemplate(templateName, type = 'html') { + const cacheKey = `${templateName}.${type}`; + + // Check cache first + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey); + } + + try { + const templatePath = path.join(this.templatesDir, `${templateName}.${type}`); + const template = await fs.readFile(templatePath, 'utf-8'); + + // Cache the template + this.cache.set(cacheKey, template); + + return template; + } catch (error) { + logger.error(`Failed to load email template ${templateName}.${type}:`, error); + throw new Error(`Email template not found: ${templateName}.${type}`); + } + } + + renderTemplate(template, variables) { + let rendered = template; + + // Replace all {{VARIABLE}} with actual values + Object.entries(variables).forEach(([key, value]) => { + const regex = new RegExp(`{{${key}}}`, 'g'); + rendered = rendered.replace(regex, value || ''); + }); + + return rendered; + } + + async render(templateName, variables) { + try { + // Load both HTML and text versions + const [htmlTemplate, textTemplate] = await Promise.all([ + this.loadTemplate(templateName, 'html'), + this.loadTemplate(templateName, 'txt') + ]); + + return { + html: this.renderTemplate(htmlTemplate, variables), + text: this.renderTemplate(textTemplate, variables) + }; + } catch (error) { + logger.error('Failed to render email template:', error); + throw error; + } + } + + // Clear template cache (useful for development) + clearCache() { + this.cache.clear(); + } +} + +module.exports = new EmailTemplateService(); diff --git a/map/app/templates/email/password-recovery.html b/map/app/templates/email/password-recovery.html new file mode 100644 index 0000000..81cbb68 --- /dev/null +++ b/map/app/templates/email/password-recovery.html @@ -0,0 +1,79 @@ + + + + + Your Password + + + +
+
+ +
+
+

Password Recovery

+

Hello {{USER_NAME}},

+

You requested your password for the account associated with {{USER_EMAIL}}.

+

Your password is:

+
{{PASSWORD}}
+

You can use this password to log in at: {{LOGIN_URL}}

+

⚠️ For security reasons, we recommend changing your password after logging in.

+
+ +
+ + diff --git a/map/app/templates/email/password-recovery.txt b/map/app/templates/email/password-recovery.txt new file mode 100644 index 0000000..f34701e --- /dev/null +++ b/map/app/templates/email/password-recovery.txt @@ -0,0 +1,15 @@ +Password Recovery - {{APP_NAME}} + +Hello {{USER_NAME}}, + +You requested your password for the account associated with {{USER_EMAIL}}. + +Your password is: {{PASSWORD}} + +You can use this password to log in at: {{LOGIN_URL}} + +WARNING: For security reasons, we recommend changing your password after logging in. + +--- +This email was sent from {{APP_NAME}} at {{TIMESTAMP}} +If you didn't request this password, please contact your administrator immediately.