confirm email

This commit is contained in:
2026-04-02 15:23:32 +07:00
parent 6c2e89dc93
commit 5a7bf191d0
11 changed files with 893 additions and 39 deletions

View File

@@ -6,6 +6,7 @@ const sql = require('mssql');
const cors = require('cors');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const nodemailer = require('nodemailer');
const dotenv = require('dotenv');
const app = express();
@@ -32,6 +33,17 @@ const BCRYPT_ROUNDS = Number(process.env.BCRYPT_ROUNDS || 12);
const PASSWORD_VIEW_SECRET = process.env.PASSWORD_VIEW_SECRET || 'change-this-password-view-secret';
const PASSWORD_VIEW_KEY = crypto.createHash('sha256').update(String(PASSWORD_VIEW_SECRET)).digest();
const PASSWORD_VIEW_PREFIX = 'enc:v1';
const PORT = process.env.PORT || 3000;
const APP_BASE_URL = String(process.env.APP_BASE_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
const SMTP_HOST = process.env.SMTP_HOST || '';
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
const SMTP_SECURE = envBool('SMTP_SECURE', false);
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);
let mailTransporter;
function isBcryptHash(value) {
return typeof value === 'string' && /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/.test(value);
@@ -85,6 +97,93 @@ function decryptPasswordForView(payload) {
}
}
function hashVerificationToken(token) {
return crypto.createHash('sha256').update(String(token)).digest('hex');
}
function generateEmailVerificationToken() {
const token = crypto.randomBytes(32).toString('hex');
return {
token,
tokenHash: hashVerificationToken(token)
};
}
function getEmailVerificationUrl(token) {
return `${APP_BASE_URL}/pages/verify-email.html?token=${encodeURIComponent(token)}`;
}
function canSendEmails() {
return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS);
}
function getMailTransporter() {
if (!mailTransporter) {
mailTransporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
auth: {
user: SMTP_USER,
pass: SMTP_PASS
}
});
}
return mailTransporter;
}
async function sendVerificationEmail({ email, username, token }) {
const verifyUrl = getEmailVerificationUrl(token);
if (!canSendEmails()) {
console.warn(`SMTP is not configured. Verification URL for ${email}: ${verifyUrl}`);
return {
sent: false,
previewUrl: verifyUrl,
reason: 'SMTP is not configured'
};
}
try {
const transporter = getMailTransporter();
await transporter.sendMail({
from: SMTP_FROM,
to: email,
subject: 'AccManager - Confirm your email',
text: `Hello ${username || 'there'},\n\nPlease confirm your email by opening this link:\n${verifyUrl}\n\nThis link will expire in ${EMAIL_VERIFY_TOKEN_TTL_MINUTES} minutes.\n\nIf you did not register, please ignore this email.`,
html: `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #1f2937;">
<h2 style="margin-bottom: 8px;">Confirm your email</h2>
<p>Hello ${username || 'there'},</p>
<p>Thank you for registering. Please confirm your email by clicking the button below:</p>
<p style="margin: 18px 0;">
<a href="${verifyUrl}" style="background:#2563eb;color:#ffffff;padding:10px 16px;border-radius:8px;text-decoration:none;font-weight:700;display:inline-block;">Confirm Email</a>
</p>
<p>Or copy this URL into your browser:</p>
<p><a href="${verifyUrl}">${verifyUrl}</a></p>
<p>This link will expire in ${EMAIL_VERIFY_TOKEN_TTL_MINUTES} minutes.</p>
<p>If you did not register this account, you can ignore this message.</p>
</div>
`
});
return { sent: true };
} catch (err) {
console.error('Send verification 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);
return Number.isInteger(userId) && userId > 0 ? userId : null;
}
// Middleware
app.use(cors());
app.use(express.json());
@@ -284,6 +383,11 @@ async function createTables() {
await pool.request().query(`IF COL_LENGTH('dbo.Applications','Url') IS NULL ALTER TABLE Applications ADD Url NVARCHAR(255);`);
await pool.request().query(`IF COL_LENGTH('dbo.Applications','Description') IS NULL ALTER TABLE Applications ADD Description NVARCHAR(500);`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','ViewPassword') IS NULL ALTER TABLE Users ADD ViewPassword NVARCHAR(1024);`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerified') IS NULL ALTER TABLE Users ADD EmailVerified BIT NOT NULL CONSTRAINT DF_Users_EmailVerified DEFAULT(0);`);
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(`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;`);
} catch (err) {
@@ -302,8 +406,13 @@ async function createTables() {
.input('fullname', sql.NVarChar, 'Administrator')
.input('role', sql.NVarChar, 'admin')
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive)
VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 1)`);
INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive, EmailVerified, EmailVerifiedAt)
VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 1, 1, GETDATE())
ELSE
UPDATE Users
SET EmailVerified = 1,
EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE())
WHERE Username = @username`);
console.log('✓ Admin user created: admin / admin');
} catch (err) {
console.error('Admin user error:', err.message);
@@ -345,7 +454,10 @@ app.post('/api/auth/login', async (req, res) => {
const result = await pool.request()
.input('username', sql.NVarChar, username)
.query('SELECT UserId, Username, Email, FullName, Role, RoleId, Status, Password FROM Users WHERE Username = @username AND IsActive = 1');
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, Password, EmailVerified
FROM Users
WHERE (Username = @username OR Email = @username)
AND IsActive = 1`);
if (result.recordset.length > 0) {
const dbUser = result.recordset[0];
@@ -358,6 +470,16 @@ app.post('/api/auth/login', async (req, res) => {
});
}
if (!dbUser.EmailVerified) {
return res.status(403).json({
success: false,
message: 'Please confirm your email before signing in',
requiresEmailVerification: true,
email: dbUser.Email,
username: dbUser.Username
});
}
// Upgrade old plain-text passwords after successful legacy login.
if (!isBcryptHash(dbUser.Password)) {
const upgradedHash = await hashPassword(password);
@@ -398,23 +520,37 @@ app.post('/api/auth/register', async (req, res) => {
try {
const { username, password, email, fullname } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: 'Username and password are required' });
if (!username || !password || !email) {
return res.status(400).json({ success: false, message: 'Username, password and email are required' });
}
// Prevent duplicate usernames
const normalizedEmail = String(email).trim().toLowerCase();
const safeUsername = String(username).trim();
const isEmailFormatValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
if (!isEmailFormatValid) {
return res.status(400).json({ success: false, message: 'Email format is invalid' });
}
// Prevent duplicate usernames/emails
const existing = await pool.request()
.input('username', sql.NVarChar, username)
.query('SELECT UserId FROM Users WHERE Username = @username');
.input('username', sql.NVarChar, safeUsername)
.input('email', sql.NVarChar, normalizedEmail)
.query('SELECT TOP 1 UserId, Username, Email FROM Users WHERE Username = @username OR Email = @email');
if (existing.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Username already exists' });
const existed = existing.recordset[0];
const duplicateByEmail = String(existed.Email || '').toLowerCase() === normalizedEmail;
return res.status(409).json({
success: false,
message: duplicateByEmail ? 'Email already exists' : 'Username already exists'
});
}
const hashedPassword = await hashPassword(password);
const viewPassword = encryptPasswordForView(password);
const { token, tokenHash } = generateEmailVerificationToken();
const safeFullname = fullname && fullname.trim() ? fullname.trim() : username;
const safeFullname = fullname && fullname.trim() ? fullname.trim() : safeUsername;
let guestRoleName = 'guest';
let guestRoleId = null;
@@ -449,31 +585,34 @@ app.post('/api/auth/register', async (req, res) => {
if (hasRoleIdColumn && guestRoleId !== null) {
result = await pool.request()
.input('username', sql.NVarChar, username)
.input('username', sql.NVarChar, safeUsername)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, email || null)
.input('email', sql.NVarChar, normalizedEmail)
.input('fullname', sql.NVarChar, safeFullname)
.input('roleId', sql.Int, guestRoleId)
.input('role', sql.NVarChar, guestRoleName)
.input('viewPassword', sql.NVarChar, viewPassword)
.query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, Status, IsActive)
.input('emailVerifyToken', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
.query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, Status, IsActive, EmailVerified, EmailVerifyToken, EmailVerifyTokenExpires)
OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role, INSERTED.RoleId
VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 'Active', 1)`);
VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 'Active', 1, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()))`);
} else {
result = await pool.request()
.input('username', sql.NVarChar, username)
.input('username', sql.NVarChar, safeUsername)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, email || null)
.input('email', sql.NVarChar, normalizedEmail)
.input('fullname', sql.NVarChar, safeFullname)
.input('role', sql.NVarChar, guestRoleName)
.input('viewPassword', sql.NVarChar, viewPassword)
.query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, Status, IsActive)
.input('emailVerifyToken', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
.query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, Status, IsActive, EmailVerified, EmailVerifyToken, EmailVerifyTokenExpires)
OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role
VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 'Active', 1)`);
VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 'Active', 1, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()))`);
}
const inserted = result.recordset[0];
const user = { ...inserted, role: inserted.Role || guestRoleName || 'guest' };
// Repair previous self-registered users that were wrongly assigned Admin RoleId.
if (hasRoleIdColumn && guestRoleId !== null) {
@@ -486,13 +625,162 @@ app.post('/api/auth/register', async (req, res) => {
AND (RoleId IS NULL OR RoleId <> @guestRoleId)`);
}
res.json({ success: true, message: 'Registration successful', user });
const emailResult = await sendVerificationEmail({
email: normalizedEmail,
username: safeUsername,
token
});
const responsePayload = {
success: true,
message: emailResult.sent
? 'Registration successful. Please check your email to confirm your account.'
: 'Registration successful, but email sending is not configured. Please contact administrator or use verification preview link in development.',
registrationPendingVerification: true,
emailSent: emailResult.sent,
userId: inserted?.UserId
};
if (emailResult.previewUrl) {
responsePayload.verificationPreviewUrl = emailResult.previewUrl;
}
if (emailResult.reason && !emailResult.sent) {
responsePayload.emailError = emailResult.reason;
}
res.json(responsePayload);
} catch (err) {
console.error('Registration error:', err);
res.status(500).json({ success: false, message: 'Registration failed' });
}
});
app.get('/api/auth/verify-email', async (req, res) => {
try {
const token = String(req.query.token || '').trim();
if (!token) {
return res.status(400).json({ success: false, message: 'Verification token is required' });
}
const tokenHash = hashVerificationToken(token);
const result = await pool.request()
.input('token', sql.NVarChar, tokenHash)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive, EmailVerifyTokenExpires
FROM Users
WHERE EmailVerifyToken = @token`);
if (result.recordset.length === 0) {
return res.status(400).json({ success: false, message: 'Verification token is invalid or already used' });
}
const verifiedUser = result.recordset[0];
const expiresAt = verifiedUser.EmailVerifyTokenExpires ? new Date(verifiedUser.EmailVerifyTokenExpires) : null;
if (!expiresAt || expiresAt.getTime() < Date.now()) {
return res.status(400).json({ success: false, message: 'Verification token has expired. Please request a new email.' });
}
await pool.request()
.input('userId', sql.Int, verifiedUser.UserId)
.query(`UPDATE Users
SET EmailVerified = 1,
EmailVerifiedAt = GETDATE(),
EmailVerifyToken = NULL,
EmailVerifyTokenExpires = NULL
WHERE UserId = @userId`);
if (!verifiedUser.IsActive) {
return res.status(403).json({
success: false,
message: 'Email confirmed, but account is inactive. Please contact administrator.'
});
}
// Align verification flow with login payload shape to support auto-login on verify page.
const { EmailVerifyTokenExpires: _expires, ...safeUser } = verifiedUser;
const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' };
await pool.request()
.input('userId', sql.Int, verifiedUser.UserId)
.query('UPDATE Users SET LastLogin = GETDATE() WHERE UserId = @userId');
res.json({
success: true,
message: 'Email confirmed successfully. Logging you in...',
autoLogin: true,
user
});
} catch (err) {
console.error('Verify email error:', err.message);
res.status(500).json({ success: false, message: 'Email verification failed' });
}
});
app.post('/api/auth/resend-verification', async (req, res) => {
try {
const identifier = String(req.body?.identifier || req.body?.email || '').trim();
if (!identifier) {
return res.status(400).json({ success: false, message: 'Username or email is required' });
}
const result = await pool.request()
.input('identifier', sql.NVarChar, identifier)
.query(`SELECT TOP 1 UserId, Username, Email, EmailVerified
FROM Users
WHERE Username = @identifier OR Email = @identifier`);
if (result.recordset.length === 0) {
return res.json({ success: true, message: 'If the account exists, a confirmation email has been sent.' });
}
const user = result.recordset[0];
if (user.EmailVerified) {
return res.json({ success: true, message: 'This email is already confirmed.' });
}
if (!user.Email) {
return res.status(400).json({ success: false, message: 'This account has no email. Please contact administrator.' });
}
const { token, tokenHash } = generateEmailVerificationToken();
await pool.request()
.input('userId', sql.Int, user.UserId)
.input('tokenHash', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
.query(`UPDATE Users
SET EmailVerifyToken = @tokenHash,
EmailVerifyTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE())
WHERE UserId = @userId`);
const emailResult = await sendVerificationEmail({
email: user.Email,
username: user.Username,
token
});
const payload = {
success: true,
message: emailResult.sent
? 'Verification email sent. Please check your inbox.'
: 'SMTP is not configured. Use development preview link or configure SMTP.',
emailSent: emailResult.sent
};
if (emailResult.previewUrl) {
payload.verificationPreviewUrl = emailResult.previewUrl;
}
if (emailResult.reason && !emailResult.sent) {
payload.emailError = emailResult.reason;
}
res.json(payload);
} catch (err) {
console.error('Resend verification error:', err.message);
res.status(500).json({ success: false, message: 'Cannot resend verification email right now' });
}
});
// Middleware for role-based access control
const requireAdmin = (req, res, next) => {
const userRole = req.headers['x-user-role'] || req.query.userRole;
@@ -613,7 +901,7 @@ app.get('/api/users', async (req, res) => {
try {
const result = await pool.request()
.query(`SELECT u.UserId, u.Username, u.Email, u.FullName, u.Role, u.RoleId, r.RoleName,
u.Status, u.CreatedDate, u.LastLogin, u.IsActive
u.Status, u.CreatedDate, u.LastLogin, u.IsActive, u.EmailVerified, u.EmailVerifiedAt
FROM Users u
LEFT JOIN Roles r ON u.RoleId = r.RoleId
ORDER BY u.CreatedDate DESC`);
@@ -623,6 +911,159 @@ app.get('/api/users', async (req, res) => {
}
});
app.get('/api/users/me', async (req, res) => {
try {
const userId = getUserIdFromRequest(req);
if (!userId) {
return res.status(401).json({ success: false, message: 'Missing or invalid user id' });
}
const result = await pool.request()
.input('userId', sql.Int, userId)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive,
CreatedDate, LastLogin, EmailVerified, EmailVerifiedAt
FROM Users
WHERE UserId = @userId`);
if (result.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'User not found' });
}
res.json({ success: true, data: result.recordset[0] });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.put('/api/users/me', async (req, res) => {
try {
const userId = getUserIdFromRequest(req);
if (!userId) {
return res.status(401).json({ success: false, message: 'Missing or invalid user id' });
}
const fullname = String(req.body?.fullname || '').trim();
const incomingEmail = String(req.body?.email || '').trim().toLowerCase();
const currentPassword = String(req.body?.currentPassword || '');
const newPassword = String(req.body?.newPassword || '').trim();
if (!fullname) {
return res.status(400).json({ success: false, message: 'Full name is required' });
}
if (!incomingEmail) {
return res.status(400).json({ success: false, message: 'Email is required' });
}
const isEmailFormatValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(incomingEmail);
if (!isEmailFormatValid) {
return res.status(400).json({ success: false, message: 'Email format is invalid' });
}
const userResult = await pool.request()
.input('userId', sql.Int, userId)
.query(`SELECT UserId, Username, Email, Password, Role, RoleId
FROM Users
WHERE UserId = @userId`);
if (userResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'User not found' });
}
const existingUser = userResult.recordset[0];
const emailChanged = String(existingUser.Email || '').toLowerCase() !== incomingEmail;
const shouldChangePassword = newPassword.length > 0;
if (shouldChangePassword) {
if (!currentPassword) {
return res.status(400).json({ success: false, message: 'Current password is required to set a new password' });
}
const isCurrentPasswordValid = await verifyPassword(currentPassword, existingUser.Password);
if (!isCurrentPasswordValid) {
return res.status(400).json({ success: false, message: 'Current password is incorrect' });
}
}
if (emailChanged) {
const duplicateEmailResult = await pool.request()
.input('email', sql.NVarChar, incomingEmail)
.input('userId', sql.Int, userId)
.query('SELECT TOP 1 UserId FROM Users WHERE Email = @email AND UserId <> @userId');
if (duplicateEmailResult.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Email already exists' });
}
}
const request = pool.request()
.input('userId', sql.Int, userId)
.input('fullname', sql.NVarChar, fullname)
.input('email', sql.NVarChar, incomingEmail);
let token;
if (emailChanged) {
const tokenResult = generateEmailVerificationToken();
token = tokenResult.token;
request.input('emailVerifyToken', sql.NVarChar, tokenResult.tokenHash);
request.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES);
}
if (shouldChangePassword) {
const hashedPassword = await hashPassword(newPassword);
const viewPassword = encryptPasswordForView(newPassword);
request.input('password', sql.NVarChar, hashedPassword);
request.input('viewPassword', sql.NVarChar, viewPassword);
}
await request.query(`UPDATE Users
SET FullName = @fullname,
Email = @email
${shouldChangePassword ? ', Password = @password, ViewPassword = @viewPassword' : ''}
${emailChanged ? ', EmailVerified = 0, EmailVerifiedAt = NULL, EmailVerifyToken = @emailVerifyToken, EmailVerifyTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE())' : ''}
WHERE UserId = @userId`);
let emailResult = { sent: true };
if (emailChanged) {
emailResult = await sendVerificationEmail({
email: incomingEmail,
username: existingUser.Username,
token
});
}
const safeResult = await pool.request()
.input('userId', sql.Int, userId)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive,
CreatedDate, LastLogin, EmailVerified, EmailVerifiedAt
FROM Users
WHERE UserId = @userId`);
const payload = {
success: true,
message: emailChanged
? 'Profile updated. Please confirm your new email address.'
: (shouldChangePassword ? 'Profile and password updated successfully' : 'Profile updated successfully'),
user: safeResult.recordset[0],
verificationRequired: emailChanged,
emailSent: emailChanged ? emailResult.sent : undefined
};
if (emailChanged && emailResult.previewUrl) {
payload.verificationPreviewUrl = emailResult.previewUrl;
}
if (emailChanged && emailResult.reason && !emailResult.sent) {
payload.emailError = emailResult.reason;
}
res.json(payload);
} catch (err) {
console.error('Update profile error:', err.message);
res.status(500).json({ success: false, message: 'Cannot update profile right now' });
}
});
// Get user by ID
app.get('/api/users/:id', requireAdmin, async (req, res) => {
try {
@@ -1012,8 +1453,6 @@ app.use((err, req, res, next) => {
// Server Startup
// ==========================================
const PORT = process.env.PORT || 3000;
async function startServer() {
try {
await initializeDatabase();