feat(auth): add self-service email OTP password reset with Nodemailer and Gmail SMTP
This commit is contained in:
1 parent
4b552dbdbd
commit
ee47158e74
9 files changed
+713
-98
No files matched your search
@@ -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>
|
||||
Generated
+11
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
© ${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,
|
||||
};
|
||||
Reference in new issue
Block a user