/*********************************************************************************************************************************************************************** * 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; } 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) }); // No cookie needed here — the refresh cookie set above is itself the // signal. The frontend just calls restoreSession() and it succeeds. 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 }); 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); } };