add: more things

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-06 15:21:55 +08:00
parent 7ba03ad4db
commit 095a0d4b3c
15 changed files with 756 additions and 211 deletions
+6
View File
@@ -21,6 +21,7 @@ const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
const { sendEmail } = require('../../services/email.service');
const trustedDevice = require('../../services/trustedDevice.service');
const { fmtDate } = require('../../utils/datetime.util');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
@@ -189,6 +190,7 @@ exports.deactivateUser = async (req, res) => {
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
{ where: { user_id: req.params.id } }
);
await trustedDevice.revokeAllForUser(req.params.id);
logActivity(req.user.user_id, 'deactivate_user', { entityType: 'user', entityId: Number(req.params.id) });
@@ -228,6 +230,7 @@ exports.bulkDeactivateUsers = async (req, res) => {
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
{ where: { user_id: activeIds } }
);
await trustedDevice.revokeAllForUser(activeIds);
return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, {
deactivated_ids: activeIds,
@@ -440,6 +443,7 @@ exports.terminateSession = async (req, res) => {
is_active: false,
logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id },
});
await trustedDevice.revokeBySessionId(session.session_id);
logActivity(req.user.user_id, 'terminate_session', { entityType: 'session', entityId: session.session_id });
@@ -552,6 +556,7 @@ exports.banUser = async (req, res) => {
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
{ where: { user_id: id }, transaction: t }
);
await trustedDevice.revokeAllForUser(id);
});
logActivity(req.user.user_id, 'ban_user', {
@@ -679,6 +684,7 @@ exports.bulkBanUsers = async (req, res) => {
{ is_active: false, logout_info: { date: now.toISOString(), forced_by: req.user.user_id } },
{ where: { user_id: targetIds }, transaction: t }
);
await trustedDevice.revokeAllForUser(targetIds);
});
const dateStr = fmtDate(new Date());
+248 -160
View File
@@ -1,17 +1,25 @@
/***********************************************************************************************************************************************************************
* File Name: auth.controller.js
* Type of Program: Controller
* Description: Handles all authentication flows:
* 1. System Registration → OTP email → OTP Verify → Auto-Login
* 2. System Login (verified users)
* 3. Google OAuth callback
* Description: Handles all authentication flows. Every credential path — system
* registration, system login, and Google OAuth — funnels through
* the same OTP gate before tokens are issued:
* 1. System Registration → OTP email → verifyOTP (first-time) → tokens
* 2. System Login → OTP email → verifyOTP (routine) → tokens
* 3. Google OAuth callback → OTP email → verifyOTP (either) → tokens
* 4. Token Refresh
* 5. Logout (invalidates session)
* 6. OTP Resend
* 6. OTP Resend (only valid while an OTP is pending)
* 7. Change Password
*
* verifyOTP is the single place tokens/sessions are minted — it
* branches on the user's is_verified flag *before* the update to
* decide whether this is a first-time pass (fires welcome email/
* achievements/notifications) or a routine login OTP (skips them).
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
* Date Modified: Jul. 4, 2026 — mandatory OTP on every login (Kenneth Obsequio)
***********************************************************************************************************************************************************************
* HOW TO USE:
* Mount via routes/auth.routes.js
@@ -29,7 +37,7 @@ const crypto = require('crypto');
const sequelize = require('../config/db.config')
const mdl_Users = require('../models/users/users.mdl');
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
const mdl_UserBans = require('../models/users/user_bans.mdl');
const { checkAccountStatus } = require('../services/accountStatus.service');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl')
const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util');
const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.util');
@@ -41,6 +49,7 @@ const { renderNotification } = require('../services/notificationTemplate.service
const { sendEmail } = require('../services/email.service');
const buildSessionInfo = require('../utils/session_info.util');
const logActivity = require('../utils/logActivity.util');
const trustedDevice = require('../services/trustedDevice.service');
const R = require('../utils/response.util');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy'];
@@ -53,6 +62,34 @@ const safeUser = (user, extraExclude = []) => {
return u;
};
const setRefreshCookie = (res, refreshToken) => {
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
};
// ─── Mint Session ──────────────────────────────────────────────────────────────
// Shared by verifyOTP and the trusted-device fast path in login/googleCallback —
// the only two places tokens/sessions get minted.
const mintSession = async (req, user, { transaction } = {}) => {
const wasVerified = user.is_verified;
await user.update({ is_verified: true, otp_code: null, otp_expires_at: null }, { transaction });
const { accessToken, refreshToken } = generateTokens(user);
const session = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: await buildSessionInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
}, { transaction });
return { accessToken, refreshToken, session, wasVerified };
};
// ─── Register ──────────────────────────────────────────────────────────────────
exports.register = async (req, res) => {
const transaction = await sequelize.transaction();
@@ -91,7 +128,10 @@ exports.register = async (req, res) => {
reg_type: 'system',
acc_type: 'user',
personal_info: personal_info ?? null,
needs_intro: true,
// System registration's Personal Info step already collects the exact
// fields the /intro flow asks for (name, birthday, occupation, phone) —
// Google signups only get name+avatar from the ID token and still need it.
needs_intro: false,
createdBy: null,
}, { transaction });
@@ -148,7 +188,6 @@ exports.verifyOTP = async (req, res) => {
const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'User not found.', 404);
if (user.is_verified) return R.error(res, 'Account already verified.', 400);
const storedOTP = Buffer.from(user.otp_code ?? '');
const givenOTP = Buffer.from(otp ?? '');
@@ -156,67 +195,64 @@ exports.verifyOTP = async (req, res) => {
return R.error(res, 'Invalid OTP.', 400);
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
await user.update({ is_verified: true, otp_code: null, otp_expires_at: null }, { transaction });
// Auto-login after verification
const { accessToken, refreshToken } = generateTokens(user);
const session = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: await buildSessionInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
}, { transaction });
// wasVerified (captured inside mintSession, before its update) tells us
// whether this is the account's very first OTP pass (system registration
// or first-ever Google login) or a routine login OTP — only the former
// fires the welcome/achievements bundle.
const { accessToken, refreshToken, session, wasVerified } = await mintSession(req, user, { transaction });
await transaction.commit();
// Fire-and-forget: activity log
logActivity(user.user_id, 'register');
logActivity(user.user_id, wasVerified ? 'login' : 'register', { entityType: 'session', entityId: Number(session.session_id) });
// Fire-and-forget: achievements, welcome email, notification (do not block the response)
onUserRegistered(user.user_id)
.catch(err => console.error('[AUTH] Failed to grant achievements:', err));
if (!wasVerified) {
// Fire-and-forget: achievements, welcome email, notification (do not block the response)
onUserRegistered(user.user_id)
.catch(err => console.error('[AUTH] Failed to grant achievements:', err));
sendEmail({ to: email, type: "WELCOME", data: { name: email } })
.catch(err => console.error('[AUTH] Failed to send welcome email:', err));
sendEmail({ to: email, type: "WELCOME", data: { name: email } })
.catch(err => console.error('[AUTH] Failed to send welcome email:', err));
mdl_UserGroupMembers.findOne({
where: { user_id: user.user_id },
include: [{ model: mdl_UserGroups, attributes: ['name', 'group_code'] }],
}).then(async membership => {
const grp = membership?.UserGroup;
const now = new Date();
const notifications = [
{
user_id: user.user_id,
...(await renderNotification({ type: 'welcome', data: {
groupName: grp?.name ?? null,
groupCode: grp?.group_code ?? null,
accType: user.acc_type,
groupId: membership?.group_id ?? null,
} })),
createdAt: now,
updatedAt: now,
},
];
if (grp?.group_code === 'NOGRP') {
notifications.push({
user_id: user.user_id,
...(await renderNotification({ type: 'nogrp_welcome', data: {} })),
createdAt: now,
updatedAt: now,
});
}
return UserNotification.bulkCreate(notifications, { validate: false });
}).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
mdl_UserGroupMembers.findOne({
where: { user_id: user.user_id },
include: [{ model: mdl_UserGroups, attributes: ['name', 'group_code'] }],
}).then(async membership => {
const grp = membership?.UserGroup;
const now = new Date();
const notifications = [
{
user_id: user.user_id,
...(await renderNotification({ type: 'welcome', data: {
groupName: grp?.name ?? null,
groupCode: grp?.group_code ?? null,
accType: user.acc_type,
groupId: membership?.group_id ?? null,
} })),
createdAt: now,
updatedAt: now,
},
];
if (grp?.group_code === 'NOGRP') {
notifications.push({
user_id: user.user_id,
...(await renderNotification({ type: 'nogrp_welcome', data: {} })),
createdAt: now,
updatedAt: now,
});
}
return UserNotification.bulkCreate(notifications, { validate: false });
}).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
}
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// Clearing an OTP is what marks a device trusted going forward — this is
// the only place trust is first granted (the login/googleCallback fast
// path only ever rolls an existing trust window forward).
await trustedDevice.issueOrRefresh(res, user.user_id, trustedDevice.getFingerprintHash(req), session.session_id);
return R.success(res, 'Email verified successfully. You are now logged in.', {
setRefreshCookie(res, refreshToken);
return R.success(res, wasVerified ? 'Login successful.' : 'Email verified successfully. You are now logged in.', {
accessToken,
session_id: session.session_id,
user: safeUser(user),
@@ -236,7 +272,11 @@ exports.resendOTP = async (req, res) => {
const { email } = req.body;
const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'User not found.', 404);
if (user.is_verified) return R.error(res, 'Account is already verified.', 400);
// Only a resend, never a first send — otp_code is only ever populated by
// register/login/googleCallback, each of which already proved credential
// ownership. Without this guard, resendOTP would let anyone force a fresh
// OTP for an arbitrary verified account without ever knowing its password.
if (!user.otp_code) return R.error(res, 'No pending verification. Please log in again.', 400);
const otp = generateOTP();
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction });
@@ -245,7 +285,7 @@ exports.resendOTP = async (req, res) => {
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
// the already-persisted OTP refresh.
sendEmail({ to: email, type: "OTP", data: { otp } })
sendEmail({ to: email, type: user.is_verified ? 'LOGIN_OTP' : 'OTP', data: { otp } })
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
return R.success(res, 'A new OTP has been sent to your email.');
@@ -264,52 +304,52 @@ exports.login = async (req, res) => {
const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'Invalid credentials.', 401);
if (user.reg_type === 'google') return R.error(res, 'Please log in with Google.', 400);
if (!user.is_active) return R.error(res, 'Account is deactivated.', 403);
if (!user.is_verified) return R.error(res, 'Please verify your email first.', 403);
if (user.is_banned) {
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
if (stillBanned) {
const activeBan = await mdl_UserBans.findOne({
where: { user_id: user.user_id, is_lifted: false },
order: [['banned_at', 'DESC']],
attributes: ['reason', 'ban_type', 'expires_at'],
});
return R.error(res, 'Your account has been suspended.', 403, {
banned: true,
reason: activeBan?.reason ?? null,
ban_type: activeBan?.ban_type ?? null,
ban_expires_at: activeBan?.expires_at ?? null,
});
}
// Expired temporary ban — auto-lift
await user.update({ is_banned: false, ban_expires_at: null });
const status = await checkAccountStatus(user);
if (!status.ok) {
if (status.code === 'deactivated') return R.error(res, 'Account is deactivated.', 403);
return R.error(res, 'Your account has been suspended.', 403, {
banned: true,
reason: status.reason,
ban_type: status.ban_type,
ban_expires_at: status.ban_expires_at,
});
}
const match = await bcrypt.compare(password, user.password);
if (!match) return R.error(res, 'Invalid credentials.', 401);
const { accessToken, refreshToken } = generateTokens(user);
const session = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: await buildSessionInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
});
// Password confirmed. If this device already cleared an OTP recently and
// its trust window hasn't lapsed or been revoked, skip the OTP gate
// entirely — otherwise fall through to the usual fresh-OTP flow. Tokens
// are only ever minted via mintSession (called here or from verifyOTP).
const fingerprintHash = trustedDevice.getFingerprintHash(req);
const trusted = await trustedDevice.findValid(user.user_id, req.cookies[trustedDevice.COOKIE_NAME], fingerprintHash);
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
if (trusted) {
const { accessToken, refreshToken, session } = await mintSession(req, user, {});
await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
setRefreshCookie(res, refreshToken);
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
return R.success(res, 'Login successful.', {
accessToken,
session_id: session.session_id,
user: safeUser(user),
return R.success(res, 'Login successful.', {
otpRequired: false,
accessToken,
session_id: session.session_id,
user: safeUser(user),
});
}
const otp = generateOTP();
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
sendEmail({ to: email, type: 'LOGIN_OTP', data: { otp } })
.catch(err => console.error('[AUTH] Failed to send login OTP email:', err));
return R.success(res, 'OTP sent to your email. Please verify to complete login.', {
otpRequired: true,
email: user.email,
});
} catch (err) {
console.error('[AUTH] login error:', err);
@@ -386,7 +426,7 @@ exports.googleCallback = async (req, res) => {
reg_type: 'google',
acc_type: 'user',
is_active: true,
is_verified: true,
is_verified: false,
needs_intro: true,
personal_info: {
name: {
@@ -409,20 +449,9 @@ exports.googleCallback = async (req, res) => {
await t.commit();
// Fire-and-forget: achievements + welcome notifications for new Google user
onUserRegistered(user.user_id)
.catch(err => console.error('[AUTH] googleCallback: Failed to grant achievements:', err));
const _now = new Date();
Promise.all([
renderNotification({ type: 'welcome', data: { groupName: null, groupCode: 'NOGRP', accType: 'user', groupId: noGrp?.group_id ?? null } }),
renderNotification({ type: 'nogrp_welcome', data: {} }),
]).then(([welcomeNotify, nogrpNotify]) => UserNotification.bulkCreate([
{ user_id: user.user_id, ...welcomeNotify, createdAt: _now, updatedAt: _now },
{ user_id: user.user_id, ...nogrpNotify, createdAt: _now, updatedAt: _now },
], { validate: false }))
.catch(err => console.error('[AUTH] googleCallback: Failed to emit welcome notifications:', err));
// Welcome email/achievements/welcome-notification are deferred to
// verifyOTP's first-time branch now (this account isn't verified yet —
// it still has to complete the same OTP gate as a system registration).
renderNotification({ type: 'nogrp_user_registered', data: {
userEmail: payload.email,
regType: 'google',
@@ -435,47 +464,44 @@ exports.googleCallback = async (req, res) => {
}
}
if (!user.is_active) {
return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`);
}
if (user.is_banned) {
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
if (stillBanned) {
const activeBan = await mdl_UserBans.findOne({
where: { user_id: user.user_id, is_lifted: false },
order: [['banned_at', 'DESC']],
attributes: ['reason', 'ban_type', 'expires_at'],
});
const params = new URLSearchParams({ error: 'account_banned' });
if (activeBan?.reason) params.set('reason', activeBan.reason);
if (activeBan?.ban_type) params.set('ban_type', activeBan.ban_type);
if (activeBan?.expires_at) params.set('expires_at', new Date(activeBan.expires_at).toISOString());
return res.redirect(`${CALLBACK_PAGE}?${params.toString()}`);
const status = await checkAccountStatus(user);
if (!status.ok) {
if (status.code === 'deactivated') {
return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`);
}
await user.update({ is_banned: false, ban_expires_at: null });
const params = new URLSearchParams({ error: 'account_banned' });
if (status.reason) params.set('reason', status.reason);
if (status.ban_type) params.set('ban_type', status.ban_type);
if (status.ban_expires_at) params.set('expires_at', new Date(status.ban_expires_at).toISOString());
return res.redirect(`${CALLBACK_PAGE}?${params.toString()}`);
}
const { accessToken, refreshToken } = generateTokens(user);
const googleSession = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: await buildSessionInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
});
// Every Google sign-in (new or returning account) still has to clear the
// same OTP gate as a manual login, unless this device already cleared one
// recently and its trust window hasn't lapsed or been revoked — same
// fast path as the manual login controller. A brand-new Google account
// can never have a trusted device yet, so this naturally falls through
// to the OTP branch below for first-time sign-ins.
const fingerprintHash = trustedDevice.getFingerprintHash(req);
const trusted = await trustedDevice.findValid(user.user_id, req.cookies[trustedDevice.COOKIE_NAME], fingerprintHash);
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(googleSession.session_id), details: { reg_type: 'google' } });
if (trusted) {
const { refreshToken, session } = await mintSession(req, user, {});
await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
setRefreshCookie(res, refreshToken);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
// Redirect to the frontend; App.jsx's restoreSession() will pick up the
// refresh cookie and complete the login automatically.
return res.redirect(CALLBACK_PAGE);
return res.redirect(`${CALLBACK_PAGE}?otpRequired=false`);
}
const otp = generateOTP();
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
sendEmail({ to: user.email, type: 'LOGIN_OTP', data: { otp } })
.catch(err => console.error('[AUTH] googleCallback: Failed to send login OTP email:', err));
return res.redirect(`${CALLBACK_PAGE}?otpRequired=true&email=${encodeURIComponent(user.email)}`);
} catch (err) {
console.error('[AUTH] googleCallback OIDC error:', err);
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google?error=auth_failed`);
@@ -497,12 +523,12 @@ exports.refreshToken = async (req, res) => {
if (!session) return R.error(res, 'Session is invalid or expired. Please log in again.', 401);
const user = await mdl_Users.findByPk(decoded.user_id);
if (!user || !user.is_active) return R.error(res, 'User not found or deactivated.', 401);
if (!user) return R.error(res, 'User not found or deactivated.', 401);
if (user.is_banned) {
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
if (stillBanned) return R.error(res, 'Your account has been suspended.', 403, { banned: true });
await user.update({ is_banned: false, ban_expires_at: null });
const status = await checkAccountStatus(user);
if (!status.ok) {
if (status.code === 'deactivated') return R.error(res, 'User not found or deactivated.', 401);
return R.error(res, 'Your account has been suspended.', 403, { banned: true });
}
// Check if refresh token is expired
@@ -515,12 +541,7 @@ exports.refreshToken = async (req, res) => {
const tokens = generateTokens(user);
await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) });
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
setRefreshCookie(res, tokens.refreshToken);
return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: safeUser(user) });
} catch (err) {
@@ -542,8 +563,11 @@ exports.logout = async (req, res) => {
logActivity(req.user.user_id, 'logout', { entityType: 'session', entityId: session_id ? Number(session_id) : null });
await trustedDevice.revokeByToken(req.user.user_id, req.cookies[trustedDevice.COOKIE_NAME]);
res.clearCookie('refreshToken')
res.clearCookie('_csrf')
res.clearCookie(trustedDevice.COOKIE_NAME)
return R.success(res, 'Logged out successfully.');
} catch (err) {
@@ -569,6 +593,7 @@ exports.changePassword = async (req, res) => {
// Invalidate all sessions to force re-login
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
await trustedDevice.revokeAllForUser(user.user_id);
logActivity(user.user_id, 'password_change');
@@ -577,4 +602,67 @@ exports.changePassword = async (req, res) => {
console.error('[AUTH] changePassword error:', err);
return R.error(res, 'Password change failed.', 500);
}
};
// ─── Forgot Password — Request OTP ─────────────────────────────────────────────
// Same procedure for every acc_type (admin/staff/user) — only reg_type matters.
exports.forgotPassword = async (req, res) => {
try {
const { email } = req.body;
const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'User not found.', 404);
if (user.reg_type === 'google')
return R.error(res, 'This account uses Google sign-in. Please log in with Google.', 400);
const status = await checkAccountStatus(user);
if (!status.ok) {
if (status.code === 'deactivated') return R.error(res, 'Account is deactivated.', 403);
return R.error(res, 'Your account has been suspended.', 403, { banned: true });
}
const otp = generateOTP();
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
sendEmail({ to: email, type: 'RESET_PASSWORD_OTP', data: { otp } })
.catch(err => console.error('[AUTH] Failed to send reset-password OTP email:', err));
return R.success(res, 'An OTP has been sent to your email.', { email: user.email });
} catch (err) {
console.error('[AUTH] forgotPassword error:', err);
return R.error(res, 'Could not process request.', 500);
}
};
// ─── Forgot Password — Reset with OTP ──────────────────────────────────────────
exports.resetPassword = async (req, res) => {
try {
const { email, otp, new_password } = req.body;
const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'User not found.', 404);
if (user.reg_type === 'google')
return R.error(res, 'This account uses Google sign-in. Please log in with Google.', 400);
const storedOTP = Buffer.from(user.otp_code ?? '');
const givenOTP = Buffer.from(otp ?? '');
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
return R.error(res, 'Invalid OTP.', 400);
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
const hashed = await bcrypt.hash(new_password, 12);
await user.update({ password: hashed, otp_code: null, otp_expires_at: null });
// Invalidate all sessions — same login procedure (credentials → OTP) applies next time
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
await trustedDevice.revokeAllForUser(user.user_id);
logActivity(user.user_id, 'password_reset');
sendEmail({ to: email, type: 'PASSWORD_CHANGED', data: {} })
.catch(err => console.error('[AUTH] Failed to send password-changed email:', err));
return R.success(res, 'Password reset successful. Please log in with your new password.');
} catch (err) {
console.error('[AUTH] resetPassword error:', err);
return R.error(res, 'Password reset failed.', 500);
}
};
@@ -104,6 +104,46 @@ exports.getActiveAdvertisement = async (req, res) => {
}
};
// ─── GET ACTIVE (list) ──────────────────────────────────────────────────────
//
// Resolves every live advertisement for a single placement, ordered by
// priority — used by carousel-style slots (e.g. dashboard.hero) that rotate
// through several ads instead of showing only the single highest-priority one.
//
// GET /api/client/advertisements/active-list?placement=dashboard.hero&limit=8
//
exports.getActiveAdvertisementList = async (req, res) => {
try {
const { placement } = req.query;
if (!placement) return R.error(res, "placement is required.", 400);
if (!PLACEMENT_MAP[placement]) return R.error(res, "Invalid placement.", 400);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 8, 1), 20);
const advertisements = await Advertisement.findAll({
where: liveWhere({ placement }),
order: [["order", "ASC"], ["createdAt", "DESC"]],
include: [AD_IMAGE_INCLUDE],
attributes: { exclude: AD_CLIENT_EXCLUDE },
limit,
});
const data = [];
for (const ad of advertisements) {
const json = ad.toJSON();
json.status = deriveStatus(json);
if (json.image) await attachImageStreamToken(json.image, req);
data.push(json);
}
return R.success(res, "Active advertisements retrieved.", { data });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE LIST]", err);
return R.error(res, "Could not retrieve advertisements.", 500);
}
};
// ─── GET ACTIVE (batch) ─────────────────────────────────────────────────────
//
// Resolves the highest-priority live advertisement for each of several
+3
View File
@@ -17,6 +17,7 @@
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl');
const trustedDevice = require('../../services/trustedDevice.service');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { uploadFile, deleteFile } = require('../../services/s3.service');
@@ -94,6 +95,7 @@ exports.revokeSession = async (req, res) => {
is_active: false,
logout_info: { date: new Date().toISOString(), ip_address: req.ip },
});
await trustedDevice.revokeBySessionId(session.session_id);
logActivity(req.user.user_id, 'revoke_session', { entityType: 'session', entityId: session.session_id });
@@ -177,6 +179,7 @@ exports.deleteAccount = async (req, res) => {
{ is_active: false, logout_info: { date: new Date().toISOString(), ip_address: req.ip, reason: 'account_deleted' } },
{ where: { user_id: req.user.user_id, is_active: true } },
);
await trustedDevice.revokeAllForUser(req.user.user_id);
// Anonymize email before soft-delete so the unique slot is freed for re-registration
await user.update({ email: `deleted_${req.user.user_id}@deleted.invalid`, deletedBy: req.user.user_id });