Files
starr-philproperties/controllers/auth.controller.js
T
2026-07-16 02:18:56 +08:00

815 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/***********************************************************************************************************************************************************************
* File Name: auth.controller.js
* Type of Program: Controller
* 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 (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
* POST /api/auth/register
* POST /api/auth/verify-otp
* POST /api/auth/resend-otp
* POST /api/auth/login
* POST /api/auth/refresh
* POST /api/auth/logout
* GET /api/auth/google
* GET /api/auth/google/callback
***********************************************************************************************************************************************************************/
const bcrypt = require('bcryptjs');
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 { checkAccountStatus } = require('../services/accountStatus.service');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl')
const { NOGRP_CODE, getDefaultGroupId, enrollDefaultGroup } = require('../utils/defaultGroup.util');
const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util');
const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.util');
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
const { onUserRegistered } = require('../services/achievements.service');
const AdminNotification = require('../models/notifications/admin_notification.mdl');
const UserNotification = require('../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
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'];
const safeUser = (user, extraExclude = []) => {
const u = user.toJSON ? user.toJSON() : { ...user };
[...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]);
return u;
};
const setRefreshCookie = (res, refreshToken) => {
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
// 'none' (not 'strict') in production — frontend (Vercel) and this API
// (Render) are cross-site, so 'strict'/'lax' silently drop the cookie on
// every fetch/XHR refresh call. 'none' requires secure:true (set above).
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
};
// The Google OAuth callback can't carry its outcome (otpRequired, ban details,
// errors) as a query string on the redirect without flashing it in the address
// bar — the browser lands on that literal URL before any frontend JS runs, so
// client-side scrubbing is always at least a frame too late. Instead, the
// outcome is stashed in a short-lived signed cookie and handed to the frontend
// only when it explicitly asks for it via GET /auth/google/result.
const GOOGLE_RESULT_COOKIE = '_googleAuthResult';
const setGoogleResultCookie = (res, payload) => {
res.cookie(GOOGLE_RESULT_COOKIE, JSON.stringify(payload), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
// Set right after a top-level redirect (survives 'lax'), but read back via
// a cross-site fetch from GET /auth/google/result — 'lax' drops it there
// in production since frontend (Vercel) and this API (Render) are cross-site.
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
maxAge: 2 * 60 * 1000, // 2 minutes — just long enough for the callback redirect to land
signed: true,
});
};
// ─── 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();
try {
const { email, password, personal_info, group_code, confirm_resume } = req.body;
// ── Duplicate check ───────────────────────────────────────────────────────
// A verified account owns the email outright — hard block. An unverified
// one is just an abandoned attempt (e.g. dropped connection before the OTP
// step completed, or the client retried after a failed send) — the same
// person retrying should be able to resume it rather than dead-end here.
// The client must explicitly confirm_resume (after the user accepts a
// confirmation dialog) before we overwrite that abandoned attempt's data.
const existing = await mdl_Users.findOne({ where: { email } });
if (existing) {
if (existing.is_verified) {
await transaction.rollback();
return R.error(res, 'Email is already registered.', 409);
}
if (existing.reg_type !== 'system') {
await transaction.rollback();
return R.error(res, 'This email is linked to a Google account. Please sign in with Google instead.', 409, { google: true });
}
if (!confirm_resume) {
await transaction.rollback();
return R.error(
res,
'An account with this email already has a pending verification. Resend the code and continue?',
409,
{ pendingVerification: true },
);
}
const hashed = await bcrypt.hash(password, 12);
const otp = generateOTP();
await existing.update({
password: hashed,
personal_info: personal_info ?? existing.personal_info,
otp_code: otp,
otp_expires_at: getOTPExpiry(),
}, { transaction });
await transaction.commit();
sendEmail({ to: email, type: 'OTP', data: { otp } })
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
email: existing.email,
}, 201);
}
// ── Validate group_code if provided ───────────────────────────────────────
let group = null;
if (group_code) {
group = await mdl_UserGroups.findOne({
where: { group_code: group_code.toUpperCase().trim(), is_active: true },
});
if (!group) return R.error(res, 'Invalid or inactive group code.', 400);
}
// ── Resolve enroll target (explicit group or NOGRP fallback) ──────────────
const enrollGroupId = group?.group_id ?? await getDefaultGroupId();
// ── Create user ───────────────────────────────────────────────────────────
const hashed = await bcrypt.hash(password, 12);
const otp = generateOTP();
const user = await mdl_Users.create({
email,
password: hashed,
otp_code: otp,
otp_expires_at: getOTPExpiry(),
is_active: true,
is_verified: false,
reg_type: 'system',
acc_type: 'user',
personal_info: personal_info ?? null,
// 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 });
// ── Enroll into group ─────────────────────────────────────────────────────
if (enrollGroupId) {
await mdl_UserGroupMembers.create({
group_id: enrollGroupId,
user_id: user.user_id,
createdBy: null,
}, { transaction });
}
await transaction.commit();
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
// an otherwise-successful registration — resendOTP covers redelivery.
sendEmail({ to: email, type: 'OTP', data: { otp } })
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
// Fire-and-forget: notify admins — explicit group or NOGRP fallback
if (group) {
AdminNotification.create(NOTIFICATION_REGISTRY.user_registration.build({
groupName: group.name,
groupCode: group.group_code,
userEmail: email,
}))
.catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err));
} else if (enrollGroup) {
AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({
userEmail: email,
regType: 'system',
}))
.catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err));
}
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
email: user.email,
}, 201);
} catch (err) {
await transaction.rollback();
console.error('[AUTH] register error:', err);
return R.error(res, 'Registration failed.', 500);
}
};
// ─── Verify OTP ────────────────────────────────────────────────────────────────
exports.verifyOTP = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { email, otp } = req.body;
const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'User not found.', 404);
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);
// 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, wasVerified ? 'login' : 'register', { entityType: 'session', entityId: Number(session.session_id) });
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));
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,
...NOTIFICATION_REGISTRY.welcome.build({
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_CODE) {
notifications.push({
user_id: user.user_id,
...NOTIFICATION_REGISTRY.nogrp_welcome.build(),
createdAt: now,
updatedAt: now,
});
}
return UserNotification.bulkCreate(notifications, { validate: false });
}).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
}
// 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);
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),
});
} catch (err) {
await transaction.rollback();
console.error('[AUTH] verifyOTP error:', err);
return R.error(res, 'OTP verification failed.', 500);
}
};
// ─── Resend OTP ────────────────────────────────────────────────────────────────
exports.resendOTP = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { email } = req.body;
const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'User not found.', 404);
// 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 });
await transaction.commit();
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
// the already-persisted OTP refresh.
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.');
} catch (err) {
await transaction.rollback();
console.error('[AUTH] resendOTP error:', err);
return R.error(res, 'Could not resend OTP.', 500);
}
};
// ─── System Login ──────────────────────────────────────────────────────────────
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
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);
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);
// 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);
if (trusted) {
const { accessToken, refreshToken, session } = await mintSession(req, user, {});
await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
setRefreshCookie(res, refreshToken);
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
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);
return R.error(res, 'Login failed.', 500);
}
};
// ─── Google OIDC — Redirect ────────────────────────────────────────────────────
// Generates state, nonce, and PKCE verifier, stores them in a signed httpOnly
// cookie, then redirects the browser to Google's authorization endpoint.
exports.googleRedirect = (req, res) => {
const state = generateState();
const nonce = generateNonce();
const { codeVerifier, codeChallenge } = generatePKCE();
// SameSite=Lax is required: the cookie must survive the cross-site redirect
// back from Google (top-level GET navigations are allowed under Lax).
res.cookie('_oauth', JSON.stringify({ state, nonce, codeVerifier }), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 10 * 60 * 1000, // 10 minutes — enough time to complete the flow
signed: true,
});
return res.redirect(buildAuthUrl(state, nonce, codeChallenge));
};
// ─── Google OIDC — Callback ────────────────────────────────────────────────────
// Verifies state (CSRF), exchanges the authorization code, verifies the ID token
// (signature + nonce), finds or creates the user, sets the refresh cookie, then
// redirects the browser to the frontend callback page.
exports.googleCallback = async (req, res) => {
const FRONTEND_URL = process.env.FRONTEND_URL;
const CALLBACK_PAGE = `${FRONTEND_URL}/auth/callback/google`;
try {
const { code, state, error } = req.query;
if (error) {
setGoogleResultCookie(res, { error });
return res.redirect(CALLBACK_PAGE);
}
// Read and immediately clear the oauth state cookie.
const rawCookie = req.signedCookies['_oauth'];
res.clearCookie('_oauth');
if (!rawCookie) {
setGoogleResultCookie(res, { error: 'session_expired' });
return res.redirect(CALLBACK_PAGE);
}
const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie);
if (!state || state !== expectedState) {
setGoogleResultCookie(res, { error: 'state_mismatch' });
return res.redirect(CALLBACK_PAGE);
}
// Exchange authorization code → { id_token, access_token, ... }
const tokens = await exchangeCode(code, codeVerifier);
// Verify ID token signature, audience, expiry, and nonce.
const payload = await verifyIdToken(tokens.id_token, nonce);
// Find or auto-create the user.
// Use paranoid:false so soft-deleted rows are visible — if one is blocking the email slot, free it first.
let user = await mdl_Users.findOne({ where: { email: payload.email }, paranoid: false });
if (user?.deletedAt) {
await user.update({ email: `deleted_${user.user_id}@deleted.invalid` });
user = null;
}
// A system (manual) registration signing in with Google for the first time
// gets folded into that same account rather than blocked or duplicated.
// From this point on the account is Google-only — mirrors the existing
// rule that blocks Google accounts from manual login/password reset.
let justLinkedGoogle = false;
if (user && user.reg_type === 'system') {
justLinkedGoogle = true;
await user.update({ reg_type: 'google' });
}
if (!user) {
const t = await sequelize.transaction();
try {
user = await mdl_Users.create({
email: payload.email,
reg_type: 'google',
acc_type: 'user',
is_active: true,
is_verified: false,
needs_intro: true,
personal_info: {
name: {
given_name: payload.given_name ?? '',
last_name: payload.family_name ?? '',
full_name: payload.name ?? '',
},
avatar: { url: payload.picture ?? null },
},
}, { transaction: t });
await enrollDefaultGroup(user.user_id, { transaction: t });
await t.commit();
// 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).
AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({
userEmail: payload.email,
regType: 'google',
}))
.catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err));
} catch (err) {
await t.rollback();
throw err;
}
}
const status = await checkAccountStatus(user);
if (!status.ok) {
if (status.code === 'deactivated') {
setGoogleResultCookie(res, { error: 'account_deactivated' });
return res.redirect(CALLBACK_PAGE);
}
setGoogleResultCookie(res, {
error: 'account_banned',
reason: status.reason ?? null,
ban_type: status.ban_type ?? null,
expires_at: status.ban_expires_at ? new Date(status.ban_expires_at).toISOString() : null,
});
return res.redirect(CALLBACK_PAGE);
}
// 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);
if (trusted) {
const { refreshToken, session } = await mintSession(req, user, {});
await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
setRefreshCookie(res, refreshToken);
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
// Normally no cookie is needed here — the refresh cookie set above is
// itself the signal, and the frontend just calls restoreSession(). The
// one exception is the just-linked flag, which restoreSession() has no
// way to surface on its own.
if (justLinkedGoogle) {
setGoogleResultCookie(res, { justLinkedGoogle: true });
}
return res.redirect(CALLBACK_PAGE);
}
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));
setGoogleResultCookie(res, { otpRequired: true, email: user.email, justLinkedGoogle });
return res.redirect(CALLBACK_PAGE);
} catch (err) {
console.error('[AUTH] googleCallback OIDC error:', err);
setGoogleResultCookie(res, { error: 'auth_failed' });
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google`);
}
};
// ─── Google OIDC — Result handoff ─────────────────────────────────────────────
// Single-use: reads and immediately clears the cookie stashed by googleCallback.
// Returns {} when nothing is pending (trusted-device path — frontend should
// just call restoreSession(), since the real refresh cookie was already set).
exports.googleResult = (req, res) => {
const raw = req.signedCookies[GOOGLE_RESULT_COOKIE];
res.clearCookie(GOOGLE_RESULT_COOKIE);
if (!raw) return R.success(res, 'No pending Google auth result.', {});
try {
return R.success(res, 'Pending Google auth result.', JSON.parse(raw));
} catch (_) {
return R.success(res, 'No pending Google auth result.', {});
}
};
// ─── Refresh Token ─────────────────────────────────────────────────────────────
exports.refreshToken = async (req, res) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return R.error(res, 'Refresh token is required.', 400);
const decoded = verifyRefreshToken(refreshToken);
const tokenHash = hashToken(refreshToken);
const session = await mdl_UserSessions.findOne({
where: { user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true },
});
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) return R.error(res, 'User not found or deactivated.', 401);
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
if (!shouldRotateRefreshToken(decoded)) {
const { accessToken } = generateTokens(user);
return R.success(res, 'Token refreshed.', { accessToken, session_id: session.session_id, user: safeUser(user) });
}
// ─── Rotate refresh token ───────────────────────────────────────────────────
const tokens = generateTokens(user);
await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) });
setRefreshCookie(res, tokens.refreshToken);
return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: safeUser(user) });
} catch (err) {
console.error('[AUTH] refresh token error:', err);
return R.error(res, 'Invalid or expired refresh token.', 401);
}
};
// ─── Logout ────────────────────────────────────────────────────────────────────
exports.logout = async (req, res) => {
try {
const { session_id } = req.body;
if (session_id) {
await mdl_UserSessions.update(
{ is_active: false, logout_info: await buildSessionInfo(req) },
{ where: { session_id, user_id: req.user.user_id } }
);
}
logActivity(req.user.user_id, 'logout', { entityType: 'session', entityId: session_id ? Number(session_id) : null });
// Ordinary logout intentionally does NOT touch trusted_devices or clear
// device_trust: expires_at (rolling 30-day window) is what ends the
// OTP-skip, not the act of logging out. Clearing/revoking here would
// force OTP on the very next login on the same device, which defeats
// the point of trusted_devices. Trust is only force-revoked elsewhere
// for actual security events — password change/reset, admin ban/
// deactivate/force-logout, or a specific session being terminated
// (see trustedDevice.service.js: revokeAllForUser / revokeBySessionId).
res.clearCookie('refreshToken')
res.clearCookie('_csrf')
return R.success(res, 'Logged out successfully.');
} catch (err) {
console.error('[AUTH] logout error:', err);
return R.error(res, 'Logout failed.', 500);
}
};
// ─── Change Password ───────────────────────────────────────────────────────────
exports.changePassword = async (req, res) => {
try {
const { current_password, new_password } = req.body;
const user = await mdl_Users.findByPk(req.user.user_id);
if (user.reg_type === 'google')
return R.error(res, 'Google accounts cannot change passwords here.', 400);
const match = await bcrypt.compare(current_password, user.password);
if (!match) return R.error(res, 'Current password is incorrect.', 400);
const hashed = await bcrypt.hash(new_password, 12);
await user.update({ password: hashed });
// 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');
return R.success(res, 'Password changed. All sessions have been invalidated. Please log in again.');
} catch (err) {
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.
// Response is identical whether the email is unknown or deactivated/suspended —
// only a real, eligible account actually gets an OTP — EXCEPT for Google-linked
// accounts, which get an explicit "use Google sign-in" dialog by design (accepted
// tradeoff: this does reveal that a given email is a Google-linked account).
exports.forgotPassword = async (req, res) => {
try {
const { email } = req.body;
const genericMessage = 'If an account exists for this email, a reset code has been sent.';
const user = await mdl_Users.findOne({ where: { email } });
if (user && user.reg_type === 'google') {
return R.error(
res,
'This account was created using Google. Sign in with Google instead — there’s no password to reset for accounts created this way.',
400,
{ google: true },
);
}
if (user) {
const status = await checkAccountStatus(user);
if (status.ok) {
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, genericMessage, { email });
} catch (err) {
console.error('[AUTH] forgotPassword error:', err);
return R.error(res, 'Could not process request.', 500);
}
};
// ─── Forgot Password — Verify OTP only (step 2 of 3) ───────────────────────────
// Checks the code without consuming it or touching the password, so the reset
// flow can gate the "new password" step behind a verified code. resetPassword
// re-checks the same OTP when the password is actually submitted.
exports.verifyResetOTP = async (req, res) => {
try {
const { email, otp } = req.body;
const genericError = 'Invalid or expired code.';
const user = await mdl_Users.findOne({ where: { email } });
if (!user || user.reg_type === 'google') return R.error(res, genericError, 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, genericError, 400);
if (isOTPExpired(user.otp_expires_at)) return R.error(res, genericError, 400);
return R.success(res, 'Code verified.');
} catch (err) {
console.error('[AUTH] verifyResetOTP error:', err);
return R.error(res, 'Could not process request.', 500);
}
};
// ─── Forgot Password — Reset with OTP ──────────────────────────────────────────
// Same generic error for unknown email / Google-linked / wrong OTP / expired OTP
// so this endpoint can't be used to enumerate accounts either.
exports.resetPassword = async (req, res) => {
try {
const { email, otp, new_password } = req.body;
const genericError = 'Invalid or expired code.';
const user = await mdl_Users.findOne({ where: { email } });
if (!user || user.reg_type === 'google') return R.error(res, genericError, 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, genericError, 400);
if (isOTPExpired(user.otp_expires_at)) return R.error(res, genericError, 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);
}
};