forgot pass

This commit is contained in:
2026-04-25 11:58:09 +07:00
parent 4fb7f412bf
commit 8bd67200ce
2 changed files with 509 additions and 27 deletions

View File

@@ -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: `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #1f2937;">
<h2 style="margin-bottom: 8px;">Reset your password</h2>
<p>Hello ${username || 'there'},</p>
<p>We received a password reset request for your account.</p>
<p style="margin: 18px 0;">
<a href="${resetUrl}" style="background:#2563eb;color:#ffffff;padding:10px 16px;border-radius:8px;text-decoration:none;font-weight:700;display:inline-block;">Reset Password</a>
</p>
<p>Or copy this URL into your browser:</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p>This link will expire in ${PASSWORD_RESET_TOKEN_TTL_MINUTES} minutes.</p>
<p>If you did not request this reset, you can ignore this message.</p>
</div>
`
});
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();