From 8bd67200ceb9b0dfa0daf530087b96700887223f Mon Sep 17 00:00:00 2001 From: DungTT Date: Sat, 25 Apr 2026 11:58:09 +0700 Subject: [PATCH] forgot pass --- backend/server.js | 185 +++++++++++++++++++++ public/pages/login.html | 351 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 509 insertions(+), 27 deletions(-) diff --git a/backend/server.js b/backend/server.js index 13686d2..e203585 100644 --- a/backend/server.js +++ b/backend/server.js @@ -44,6 +44,7 @@ const SMTP_USER = process.env.SMTP_USER || ''; const SMTP_PASS = process.env.SMTP_PASS || ''; const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER || 'no-reply@accmanager.local'; const EMAIL_VERIFY_TOKEN_TTL_MINUTES = Number(process.env.EMAIL_VERIFY_TOKEN_TTL_MINUTES || 30); +const PASSWORD_RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES || 30); let mailTransporter; @@ -115,6 +116,10 @@ function getEmailVerificationUrl(token) { return `${APP_BASE_URL}/pages/verify-email.html?token=${encodeURIComponent(token)}`; } +function getPasswordResetUrl(token) { + return `${APP_BASE_URL}/pages/login.html?mode=reset-password&token=${encodeURIComponent(token)}`; +} + function canSendEmails() { return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS); } @@ -180,6 +185,51 @@ async function sendVerificationEmail({ email, username, token }) { } } +async function sendPasswordResetEmail({ email, username, token }) { + const resetUrl = getPasswordResetUrl(token); + + if (!canSendEmails()) { + console.warn(`SMTP is not configured. Password reset URL for ${email}: ${resetUrl}`); + return { + sent: false, + previewUrl: resetUrl, + reason: 'SMTP is not configured' + }; + } + + try { + const transporter = getMailTransporter(); + await transporter.sendMail({ + from: SMTP_FROM, + to: email, + subject: 'AccManager - Reset your password', + text: `Hello ${username || 'there'},\n\nA password reset was requested for your account.\nOpen this link to set a new password:\n${resetUrl}\n\nThis link will expire in ${PASSWORD_RESET_TOKEN_TTL_MINUTES} minutes.\n\nIf you did not request this, please ignore this email.`, + html: ` +
+

Reset your password

+

Hello ${username || 'there'},

+

We received a password reset request for your account.

+

+ Reset Password +

+

Or copy this URL into your browser:

+

${resetUrl}

+

This link will expire in ${PASSWORD_RESET_TOKEN_TTL_MINUTES} minutes.

+

If you did not request this reset, you can ignore this message.

+
+ ` + }); + + return { sent: true }; + } catch (err) { + console.error('Send password reset email error:', err.message); + return { + sent: false, + reason: err.message + }; + } +} + function getUserIdFromRequest(req) { const rawUserId = req.headers['x-user-id'] || req.query.userId; const userId = Number(rawUserId); @@ -236,6 +286,15 @@ async function syncAssetDepartmentsFromInventory() { `); } +async function ensurePasswordResetColumns() { + if (!pool) { + return; + } + + await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetToken') IS NULL ALTER TABLE Users ADD PasswordResetToken NVARCHAR(255) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetTokenExpires') IS NULL ALTER TABLE Users ADD PasswordResetTokenExpires DATETIME NULL;`); +} + async function ensureDepartmentExists(departmentName) { const normalized = normalizeDepartmentName(departmentName); if (!normalized || !pool) { @@ -1624,6 +1683,8 @@ async function createTables() { await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifiedAt') IS NULL ALTER TABLE Users ADD EmailVerifiedAt DATETIME NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifyToken') IS NULL ALTER TABLE Users ADD EmailVerifyToken NVARCHAR(255) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifyTokenExpires') IS NULL ALTER TABLE Users ADD EmailVerifyTokenExpires DATETIME NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetToken') IS NULL ALTER TABLE Users ADD PasswordResetToken NVARCHAR(255) NULL;`); + await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetTokenExpires') IS NULL ALTER TABLE Users ADD PasswordResetTokenExpires DATETIME NULL;`); await pool.request().query(`UPDATE Users SET EmailVerified = 1, EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE()) WHERE LOWER(ISNULL(Role, '')) = 'admin';`); // Backfill Url to empty string to avoid undefined in responses await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`); @@ -2025,6 +2086,130 @@ app.post('/api/auth/resend-verification', async (req, res) => { } }); +app.post('/api/auth/forgot-password', async (req, res) => { + try { + await ensurePasswordResetColumns(); + + const username = String(req.body?.username || '').trim(); + const email = String(req.body?.email || '').trim().toLowerCase(); + + if (!username || !email) { + return res.status(400).json({ success: false, message: 'Username and email are required' }); + } + + const result = await pool.request() + .input('username', sql.NVarChar, username) + .input('email', sql.NVarChar, email) + .query(`SELECT TOP 1 UserId, Username, Email, IsActive + FROM Users + WHERE Username = @username + AND LOWER(ISNULL(Email, '')) = @email`); + + if (result.recordset.length === 0 || !result.recordset[0].IsActive) { + return res.json({ + success: true, + message: 'If the username and email match, a password reset email has been sent.' + }); + } + + const user = result.recordset[0]; + const { token, tokenHash } = generateEmailVerificationToken(); + + await pool.request() + .input('userId', sql.Int, user.UserId) + .input('tokenHash', sql.NVarChar, tokenHash) + .input('tokenTtlMinutes', sql.Int, PASSWORD_RESET_TOKEN_TTL_MINUTES) + .query(`UPDATE Users + SET PasswordResetToken = @tokenHash, + PasswordResetTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()) + WHERE UserId = @userId`); + + const emailResult = await sendPasswordResetEmail({ + email: user.Email, + username: user.Username, + token + }); + + const payload = { + success: true, + message: emailResult.sent + ? 'Password reset email sent. Please check your inbox.' + : 'SMTP is not configured. Use development reset link or configure SMTP.', + emailSent: emailResult.sent + }; + + if (emailResult.previewUrl) { + payload.resetPreviewUrl = emailResult.previewUrl; + } + + if (emailResult.reason && !emailResult.sent) { + payload.emailError = emailResult.reason; + } + + res.json(payload); + } catch (err) { + console.error('Forgot password error:', err.message); + res.status(500).json({ success: false, message: 'Cannot process forgot password request right now' }); + } +}); + +app.post('/api/auth/reset-password', async (req, res) => { + try { + await ensurePasswordResetColumns(); + + const token = String(req.body?.token || '').trim(); + const newPassword = String(req.body?.newPassword || req.body?.password || ''); + + if (!token || !newPassword) { + return res.status(400).json({ success: false, message: 'Reset token and new password are required' }); + } + + if (newPassword.length < 6) { + return res.status(400).json({ success: false, message: 'New password must be at least 6 characters' }); + } + + const tokenHash = hashVerificationToken(token); + const result = await pool.request() + .input('token', sql.NVarChar, tokenHash) + .query(`SELECT TOP 1 UserId, PasswordResetTokenExpires, IsActive + FROM Users + WHERE PasswordResetToken = @token`); + + if (result.recordset.length === 0) { + return res.status(400).json({ success: false, message: 'Reset token is invalid or already used' }); + } + + const user = result.recordset[0]; + if (!user.IsActive) { + return res.status(403).json({ success: false, message: 'Account is inactive. Please contact administrator.' }); + } + + const expiresAt = user.PasswordResetTokenExpires ? new Date(user.PasswordResetTokenExpires) : null; + if (!expiresAt || expiresAt.getTime() < Date.now()) { + return res.status(400).json({ success: false, message: 'Reset token has expired. Please request a new reset email.' }); + } + + const hashedPassword = await hashPassword(newPassword); + const viewPassword = encryptPasswordForView(newPassword); + + await pool.request() + .input('userId', sql.Int, user.UserId) + .input('password', sql.NVarChar, hashedPassword) + .input('viewPassword', sql.NVarChar, viewPassword) + .query(`UPDATE Users + SET Password = @password, + ViewPassword = @viewPassword, + PasswordResetToken = NULL, + PasswordResetTokenExpires = NULL + WHERE UserId = @userId`); + + res.json({ success: true, message: 'Password reset successful. You can sign in now.' }); + } catch (err) { + console.error('Reset password error:', err.message); + res.status(500).json({ success: false, message: 'Password reset failed' }); + } +}); + // Middleware for role-based access control function normalizeRole(value) { return String(value || '').trim().toLowerCase(); diff --git a/public/pages/login.html b/public/pages/login.html index a847897..d5656d0 100644 --- a/public/pages/login.html +++ b/public/pages/login.html @@ -42,7 +42,7 @@

Account Management System

-
+
@@ -86,14 +86,19 @@
-
- - +
+
+ + +
+
@@ -117,6 +122,115 @@
+ + + +