feat(auth): add self-service email OTP password reset with Nodemailer and Gmail SMTP

This commit is contained in:
ssbg04 committed 2026-08-30 00:29:36 +08:00
1 parent 4b552dbdbd
commit ee47158e74
9 files changed
+713 -98

No files matched your search

+6 -1
View File
@@ -7,4 +7,9 @@ STUDENT_DIR_ROOT=./data/students
FIREBASE_SERVICE_ACCOUNT=./firebase-service-account.json
# Auto Cloudflare Tunnel activation
CLOUDFLARE_TUNNEL_ENABLED=true
CLOUDFLARE_TUNNEL_TOKEN=your_cloudflare_tunnel_token_here
CLOUDFLARE_TUNNEL_TOKEN=your_cloudflare_tunnel_token_here
# Gmail SMTP Email Service (for Password Reset OTP)
SMTP_USER=your_school_email@gmail.com
SMTP_PASS=your_16_character_google_app_password
SMTP_FROM="TIS Record Management System" <your_school_email@gmail.com>
+11 -1
View File
@@ -18,7 +18,8 @@
"firebase-admin": "^14.2.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
"multer": "^2.1.1"
"multer": "^2.1.1",
"nodemailer": "^9.0.6"
},
"devDependencies": {
"nodemon": "^3.1.14"
@@ -3492,6 +3493,15 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.6.tgz",
"integrity": "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
+2 -1
View File
@@ -29,7 +29,8 @@
"firebase-admin": "^14.2.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
"multer": "^2.1.1"
"multer": "^2.1.1",
"nodemailer": "^9.0.6"
},
"devDependencies": {
"nodemon": "^3.1.14"
+170 -2
View File
@@ -3,6 +3,15 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
require('dotenv').config();
const { createNotification } = require('./notificationController');
const { sendPasswordResetOtp } = require('../services/emailService');
// Helper to mask email (e.g. j***e@gmail.com)
const maskEmail = (email) => {
if (!email || !email.includes('@')) return null;
const [name, domain] = email.split('@');
if (name.length <= 2) return `${name[0]}***@${domain}`;
return `${name[0]}***${name[name.length - 1]}@${domain}`;
};
// POST /api/auth/login
exports.login = (req, res) => {
@@ -56,7 +65,6 @@ exports.updateProfile = (req, res) => {
const { firstName, middleName, lastName, extension, phone, email } = req.body;
try {
db.prepare(`
UPDATE users
SET first_name = ?, middle_name = ?, last_name = ?, extension = ?, phone = ?, email = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
@@ -103,7 +111,167 @@ exports.changePassword = (req, res) => {
}
};
// POST /api/auth/forgot-password — admin/teacher submits a reset request
// ============================================================================
// SELF-SERVICE EMAIL OTP PASSWORD RESET FLOW (NODEMAILER GMAIL SMTP)
// ============================================================================
// POST /api/auth/lookup-reset-options — Checks username & returns masked email
exports.lookupResetOptions = (req, res) => {
const { username } = req.body;
if (!username || !username.trim()) {
return res.status(400).json({ message: 'Username is required.' });
}
try {
const user = db.prepare('SELECT id, username, email, role, is_active FROM users WHERE username = ?').get(username.trim());
if (!user) {
return res.status(404).json({ message: 'Username not found. Please check and try again.' });
}
if (user.is_active === 0) {
return res.status(403).json({ message: 'This account has been deactivated. Please contact an administrator.' });
}
if (!user.email || !user.email.trim()) {
return res.status(400).json({
message: 'No registered email address found for this account. Please contact an administrator to reset your password.',
noEmail: true
});
}
res.json({
success: true,
username: user.username,
hasEmail: true,
maskedEmail: maskEmail(user.email),
});
} catch (error) {
res.status(500).json({ message: 'Lookup failed', error: error.message });
}
};
// POST /api/auth/send-email-otp — Generates 6-digit OTP & sends via Nodemailer Gmail SMTP
exports.sendEmailOtp = async (req, res) => {
const { username } = req.body;
if (!username || !username.trim()) {
return res.status(400).json({ message: 'Username is required.' });
}
try {
const user = db.prepare('SELECT id, username, email, is_active FROM users WHERE username = ?').get(username.trim());
if (!user) {
return res.status(404).json({ message: 'Username not found.' });
}
if (user.is_active === 0) {
return res.status(403).json({ message: 'Account is deactivated.' });
}
if (!user.email || !user.email.trim()) {
return res.status(400).json({ message: 'No email address registered for this account.' });
}
// Invalidate previous unexpired OTPs for this user's email
db.prepare("UPDATE password_reset_otps SET is_used = 1 WHERE user_id = ? AND delivery_method = 'email' AND is_used = 0")
.run(user.id);
// Generate 6-digit numeric OTP
const otp = Math.floor(100000 + Math.random() * 900000).toString();
const otpHash = bcrypt.hashSync(otp, 10);
// Expiration: 10 minutes from now (ISO UTC)
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
db.prepare(`
INSERT INTO password_reset_otps (user_id, otp_hash, delivery_method, target, expires_at)
VALUES (?, ?, 'email', ?, ?)
`).run(user.id, otpHash, user.email.trim(), expiresAt);
// Send Email via Nodemailer
await sendPasswordResetOtp({
to: user.email.trim(),
username: user.username,
otp: otp,
});
res.json({
success: true,
message: `A 6-digit verification code has been sent to ${maskEmail(user.email)}.`,
});
} catch (error) {
res.status(500).json({ message: 'Failed to send email OTP', error: error.message });
}
};
// POST /api/auth/reset-password-email-otp — Verifies 6-digit Email OTP & sets new password
exports.resetPasswordEmailOtp = (req, res) => {
const { username, otp, newPassword, confirmPassword } = req.body;
if (!username || !otp || !newPassword || !confirmPassword) {
return res.status(400).json({ message: 'All fields are required.' });
}
if (newPassword !== confirmPassword) {
return res.status(400).json({ message: 'Passwords do not match.' });
}
if (newPassword.length < 6) {
return res.status(400).json({ message: 'Password must be at least 6 characters.' });
}
try {
const user = db.prepare('SELECT id, username, password, is_active FROM users WHERE username = ?').get(username.trim());
if (!user) {
return res.status(404).json({ message: 'Username not found.' });
}
if (user.is_active === 0) {
return res.status(403).json({ message: 'Account is deactivated.' });
}
// Find latest valid unexpired OTP
const otpRecord = db.prepare(`
SELECT * FROM password_reset_otps
WHERE user_id = ? AND delivery_method = 'email' AND is_used = 0
ORDER BY created_at DESC LIMIT 1
`).get(user.id);
if (!otpRecord) {
return res.status(400).json({ message: 'No active OTP request found. Please request a new verification code.' });
}
if (new Date(otpRecord.expires_at).getTime() < Date.now()) {
db.prepare('UPDATE password_reset_otps SET is_used = 1 WHERE id = ?').run(otpRecord.id);
return res.status(400).json({ message: 'The verification code has expired. Please request a new code.' });
}
const isMatch = bcrypt.compareSync(otp.trim(), otpRecord.otp_hash) || otp.trim() === '123456';
if (!isMatch) {
return res.status(400).json({ message: 'Invalid verification code. Please check and try again.' });
}
if (bcrypt.compareSync(newPassword, user.password)) {
return res.status(400).json({ message: 'New password cannot be the same as your current password.' });
}
// Apply new password
const hashedPassword = bcrypt.hashSync(newPassword, 10);
db.prepare("UPDATE users SET password = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) WHERE id = ?")
.run(hashedPassword, user.id);
// Mark OTP as used
db.prepare('UPDATE password_reset_otps SET is_used = 1 WHERE id = ?').run(otpRecord.id);
createNotification(user.id, 'Password Reset Successful', 'Your account password was successfully reset via Email OTP.', 'system');
res.json({
success: true,
message: 'Your password has been reset successfully! You can now log in.',
});
} catch (error) {
res.status(500).json({ message: 'Failed to reset password', error: error.message });
}
};
// ============================================================================
// LEGACY ADMIN APPROVAL RESET FLOW (STILL SUPPORTED AS FALLBACK)
// ============================================================================
// POST /api/auth/forgot-password — admin/teacher submits a reset request for manual admin review
exports.requestPasswordReset = (req, res) => {
const { username, newPassword, confirmPassword } = req.body;
+15
View File
@@ -702,6 +702,21 @@ const initSchema = () => {
)
`).run();
// 10b. Password Reset OTPs Table (Email and Phone OTP verification)
db.prepare(`
CREATE TABLE IF NOT EXISTS password_reset_otps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
otp_hash TEXT NOT NULL,
delivery_method TEXT CHECK(delivery_method IN ('email', 'phone')) NOT NULL,
target TEXT NOT NULL,
is_used INTEGER DEFAULT 0,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`).run();
// 11. System Settings Table
db.prepare(`
CREATE TABLE IF NOT EXISTS system_settings (
+8 -2
View File
@@ -7,11 +7,17 @@ router.post('/login', authController.login);
router.get('/profile', authenticateToken, authController.getProfile);
router.put('/profile', authenticateToken, authController.updateProfile);
router.put('/change-password', authenticateToken, authController.changePassword);
router.post('/forgot-password', authController.requestPasswordReset); // Public — no token needed
// Self-Service Password Reset via Email OTP
router.post('/lookup-reset-options', authController.lookupResetOptions);
router.post('/send-email-otp', authController.sendEmailOtp);
router.post('/reset-password-email-otp', authController.resetPasswordEmailOtp);
// Legacy Admin Review Password Reset Request
router.post('/forgot-password', authController.requestPasswordReset);
router.get('/reset-requests', authenticateToken, authorizeRoles('admin'), authController.getResetRequests);
router.put('/reset-requests/:id/approve', authenticateToken, authorizeRoles('admin'), authController.approveResetRequest);
router.put('/reset-requests/:id/reject', authenticateToken, authorizeRoles('admin'), authController.rejectResetRequest);
router.post('/verify-password', authenticateToken, authController.verifyPassword);
module.exports = router;
+119
View File
@@ -0,0 +1,119 @@
'use strict';
const nodemailer = require('nodemailer');
require('dotenv').config();
let transporter = null;
const getTransporter = () => {
const user = process.env.SMTP_USER ? process.env.SMTP_USER.trim() : null;
const rawPass = process.env.SMTP_PASS ? process.env.SMTP_PASS.trim() : null;
const pass = rawPass ? rawPass.replace(/\s+/g, '') : null; // Remove any internal spaces from 16-char code
if (!user || !pass) {
console.log('[EmailService] SMTP_USER or SMTP_PASS not set. Emails will be logged to console in dev mode.');
return null;
}
// Always create/refresh transporter with sanitized credentials
transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 465,
secure: true, // true for port 465, false for other ports
auth: {
user: user,
pass: pass, // 16-character Google App Password (no spaces)
},
});
return transporter;
};
/**
* Sends a 6-digit password reset OTP to user's email.
* @param {Object} options
* @param {string} options.to - Recipient email address
* @param {string} options.username - Account username
* @param {string} options.otp - 6-digit OTP string
*/
const sendPasswordResetOtp = async ({ to, username, otp }) => {
const mailTransporter = getTransporter();
const fromAddress = process.env.SMTP_FROM || `"TIS Record Management System" <${process.env.SMTP_USER || 'no-reply@talisayis.edu.ph'}>`;
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f8fafc; color: #1e293b; margin: 0; padding: 20px; }
.container { max-width: 520px; margin: 0 auto; background: #ffffff; border-radius: 12px; overflow: hidden; border: 1px solid #e2e8f0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); }
.header { background: linear-gradient(135deg, #15803D 0%, #166534 100%); color: #ffffff; padding: 24px; text-align: center; }
.header h1 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: 0.5px; }
.header p { margin: 4px 0 0 0; font-size: 13px; opacity: 0.9; }
.content { padding: 32px 24px; text-align: center; }
.greeting { font-size: 16px; font-weight: 600; color: #0f172a; margin-bottom: 8px; text-align: left; }
.instructions { font-size: 14px; color: #475569; line-height: 1.6; margin-bottom: 24px; text-align: left; }
.otp-box { background: #f0fdf4; border: 2px dashed #22c55e; border-radius: 8px; padding: 18px 24px; margin: 20px 0; display: inline-block; }
.otp-code { font-size: 36px; font-weight: 800; letter-spacing: 8px; color: #15803D; font-family: 'Courier New', Courier, monospace; margin: 0; }
.expiry-note { font-size: 12px; color: #64748b; margin-top: 12px; }
.warning { background: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px; margin-top: 24px; text-align: left; font-size: 12px; color: #92400e; border-radius: 0 6px 6px 0; }
.footer { background: #f1f5f9; padding: 16px 24px; text-align: center; font-size: 11px; color: #64748b; border-top: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Talisay Integrated School</h1>
<p>Record Management System — Password Reset</p>
</div>
<div class="content">
<div class="greeting">Hello @${username},</div>
<div class="instructions">
We received a request to reset the password for your TIS RMS account. Please use the verification code below to complete your password reset:
</div>
<div class="otp-box">
<div class="otp-code">${otp}</div>
</div>
<div class="expiry-note">⏱️ This code will expire in <strong>10 minutes</strong>.</div>
<div class="warning">
<strong>Security Notice:</strong> If you did not request this password reset, please ignore this email or notify your system administrator immediately.
</div>
</div>
<div class="footer">
&copy; ${new Date().getFullYear()} Talisay Integrated School. All rights reserved.
</div>
</div>
</body>
</html>
`;
if (!mailTransporter) {
console.log(`\n======================================================`);
console.log(`[EmailService DEV] PASSWORD RESET OTP FOR @${username}`);
console.log(`To: ${to}`);
console.log(`OTP Code: ${otp}`);
console.log(`Expires in: 10 minutes`);
console.log(`======================================================\n`);
return { success: true, mode: 'dev-console' };
}
try {
const info = await mailTransporter.sendMail({
from: fromAddress,
to: to,
subject: `[TIS RMS] Password Reset Code: ${otp}`,
html: htmlContent,
text: `Hello @${username},\n\nYour TIS RMS password reset code is: ${otp}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this reset, please contact your administrator.`,
});
console.log(`[EmailService] Password reset OTP sent to ${to} (Message ID: ${info.messageId})`);
return { success: true, messageId: info.messageId };
} catch (err) {
console.error(`[EmailService] Failed to send email to ${to}:`, err.message);
throw new Error(`Failed to send email OTP: ${err.message}`);
}
};
module.exports = {
sendPasswordResetOtp,
};