From 095a0d4b3c4a50526f08068e625876a2321ae9ee Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Mon, 6 Jul 2026 15:21:55 +0800 Subject: [PATCH] add: more things Signed-off-by: Kenneth Obsequio --- controllers/admin/users.controller.js | 6 + controllers/auth.controller.js | 408 +++++++++++------- .../client/advertisements.controller.js | 40 ++ controllers/client/profile.controller.js | 3 + data/email_body.data.js | 20 + .../20260705000001-create-trusted-devices.js | 31 ++ middleware/auth.middleware.js | 34 +- models/users/trusted_devices.mdl.js | 58 +++ routes/auth.routes.js | 23 +- routes/client/advertisements.routes.js | 3 + services/accountStatus.service.js | 51 +++ services/s3.service.js | 115 ++++- services/trustedDevice.service.js | 149 +++++++ utils/session_info.util.js | 2 + validators/auth.validator.js | 24 +- 15 files changed, 756 insertions(+), 211 deletions(-) create mode 100644 database/migrations/20260705000001-create-trusted-devices.js create mode 100644 models/users/trusted_devices.mdl.js create mode 100644 services/accountStatus.service.js create mode 100644 services/trustedDevice.service.js diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index 28e9d9e..6ffbf23 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -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()); diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index 9077724..8b249a3 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -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); + } }; \ No newline at end of file diff --git a/controllers/client/advertisements.controller.js b/controllers/client/advertisements.controller.js index da02bac..eba080d 100644 --- a/controllers/client/advertisements.controller.js +++ b/controllers/client/advertisements.controller.js @@ -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 diff --git a/controllers/client/profile.controller.js b/controllers/client/profile.controller.js index 28c6772..f20692e 100644 --- a/controllers/client/profile.controller.js +++ b/controllers/client/profile.controller.js @@ -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 }); diff --git a/data/email_body.data.js b/data/email_body.data.js index 28f8acc..4f8b5c6 100644 --- a/data/email_body.data.js +++ b/data/email_body.data.js @@ -31,6 +31,26 @@ const emailTemplates = { `), }), + LOGIN_OTP: ({ otp, expiryMinutes = 10 }) => ({ + subject: "Sign-In Verification Code - STARR System", + html: wrap(` +

Dear User,

+

Use the One-Time Password (OTP) below to confirm this sign-in. This code is valid for ${expiryMinutes} minutes.

+

${otp}

+

For security reasons, please do not share this code with anyone. If you did not attempt to log in, please contact the administrator.

+ `), + }), + + RESET_PASSWORD_OTP: ({ otp, expiryMinutes = 10 }) => ({ + subject: "Password Reset Code - STARR System", + html: wrap(` +

Dear User,

+

Use the One-Time Password (OTP) below to reset your account password. This code is valid for ${expiryMinutes} minutes.

+

${otp}

+

For security reasons, please do not share this code with anyone. If you did not request a password reset, please ignore this email or contact the administrator.

+ `), + }), + WELCOME: ({ name }) => ({ subject: "Welcome to STARR System", html: wrap(` diff --git a/database/migrations/20260705000001-create-trusted-devices.js b/database/migrations/20260705000001-create-trusted-devices.js new file mode 100644 index 0000000..ee2e554 --- /dev/null +++ b/database/migrations/20260705000001-create-trusted-devices.js @@ -0,0 +1,31 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('trusted_devices', { + id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onUpdate: 'CASCADE', onDelete: 'CASCADE' }, + device_token_hash: { type: Sequelize.TEXT, allowNull: false }, + fingerprint_hash: { type: Sequelize.TEXT, allowNull: false }, + last_session_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'user_sessions', key: 'session_id' }, onUpdate: 'CASCADE', onDelete: 'SET NULL' }, + expires_at: { type: Sequelize.DATE, allowNull: false }, + revoked_at: { type: Sequelize.DATE, allowNull: true }, + createdBy: { type: Sequelize.BIGINT, allowNull: true }, + updatedBy: { type: Sequelize.BIGINT, allowNull: true }, + deletedBy: { type: Sequelize.BIGINT, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + deletedAt: { type: Sequelize.DATE, allowNull: true }, + }); + + await queryInterface.addIndex('trusted_devices', ['user_id']); + await queryInterface.addIndex('trusted_devices', ['user_id', 'fingerprint_hash'], { + unique: true, + name: 'trusted_devices_user_fingerprint_unique', + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('trusted_devices'); + }, +}; diff --git a/middleware/auth.middleware.js b/middleware/auth.middleware.js index e669f84..8f7d5c6 100644 --- a/middleware/auth.middleware.js +++ b/middleware/auth.middleware.js @@ -15,6 +15,7 @@ const { verifyAccessToken } = require('../utils/token.util'); const mdl_Users = require('../models/users/users.mdl'); const R = require('../utils/response.util'); +const { checkAccountStatus } = require('../services/accountStatus.service'); /** * Validates JWT and loads user from DB. @@ -33,19 +34,15 @@ const authenticate = async (req, res, next) => { attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, }); - if (!user) return R.error(res, 'User not found.', 401); - if (!user.is_active) return R.error(res, 'Account is deactivated.', 403); + if (!user) return R.error(res, 'User not found.', 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, - ban_expires_at: user.ban_expires_at ?? null, - }); - } - // Expired temporary ban — auto-lift so the user can log in again - 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, + ban_expires_at: status.ban_expires_at, + }); } req.user = user; @@ -86,18 +83,15 @@ const softAuthenticate = async (req, res, next) => { attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, }); - if (!user || !user.is_active) { + if (!user) { req.user = null; return next(); } - if (user.is_banned) { - const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at); - if (stillBanned) { - req.user = null; - return next(); - } - await user.update({ is_banned: false, ban_expires_at: null }); + const status = await checkAccountStatus(user); + if (!status.ok) { + req.user = null; + return next(); } req.user = user; diff --git a/models/users/trusted_devices.mdl.js b/models/users/trusted_devices.mdl.js new file mode 100644 index 0000000..1af26e6 --- /dev/null +++ b/models/users/trusted_devices.mdl.js @@ -0,0 +1,58 @@ +/*********************************************************************************************************************************************************************** + * File Name: trusted_devices.mdl.js + * Type of Program: Model + * Description: Sequelize model for the `trusted_devices` table. + * One rolling row per (user_id, fingerprint_hash) — lets a login + * from an already-verified device skip the OTP gate until the + * trust window lapses or is explicitly revoked. + * Has a Many-to-One relationship with Users and UserSessions. + * Author: Kenneth Obsequio + * Date Created: Jul. 5, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); +const mdl_Users = require('./users.mdl'); +const mdl_UserSessions = require('./user_sessions.mdl'); + +const mdl_TrustedDevices = sequelize.define('TrustedDevices', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + user_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { model: mdl_Users, key: 'user_id' }, + }, + + // SHA-256 of the opaque token stored in the `device_trust` cookie. + device_token_hash: { type: DataTypes.TEXT, allowNull: false }, + + // SHA-256 of `browser|os|device` parsed from the User-Agent header. + fingerprint_hash: { type: DataTypes.TEXT, allowNull: false }, + + // Most recent user_sessions row minted for this device — lets a single + // session termination revoke just this device's trust. + last_session_id: { + type: DataTypes.BIGINT, + allowNull: true, + references: { model: mdl_UserSessions, key: 'session_id' }, + }, + + expires_at: { type: DataTypes.DATE, allowNull: false }, + revoked_at: { type: DataTypes.DATE, allowNull: true }, + + // ── Audit trails ──────────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, +}, { + tableName: 'trusted_devices', + timestamps: true, + paranoid: true, // enables soft delete — sets deleted_at instead of DELETE +}); + +// Associations +mdl_TrustedDevices.belongsTo(mdl_Users, { foreignKey: 'user_id' }); +mdl_Users.hasMany(mdl_TrustedDevices, { foreignKey: 'user_id' }); + +mdl_TrustedDevices.belongsTo(mdl_UserSessions, { foreignKey: 'last_session_id' }); + +module.exports = mdl_TrustedDevices; diff --git a/routes/auth.routes.js b/routes/auth.routes.js index 9576133..adfd88a 100644 --- a/routes/auth.routes.js +++ b/routes/auth.routes.js @@ -6,18 +6,26 @@ * * Route Map: * GET /api/auth/csrf-token → get CSRF token (for cookie-based clients) - * POST /api/auth/register → system registration - * POST /api/auth/verify-otp → OTP verification + auto-login - * POST /api/auth/resend-otp → resend OTP email - * POST /api/auth/login → system login + * POST /api/auth/register → system registration (sends OTP) + * POST /api/auth/verify-otp → verifies OTP, mints tokens/session — the + * single endpoint every auth path funnels + * through (registration, login, Google) + * POST /api/auth/resend-otp → resend OTP email (only while one is pending) + * POST /api/auth/login → validates credentials, sends a login OTP + * (no tokens issued here — see verify-otp) * POST /api/auth/refresh → refresh access token * POST /api/auth/logout → logout (requires authenticate) * POST /api/auth/change-password → change password (requires authenticate) + * POST /api/auth/forgot-password → same procedure for every acc_type — checks + * reg_type is 'system' (not Google), sends OTP + * POST /api/auth/reset-password → verifies OTP + sets new password in one step * GET /api/auth/google → initiate Google OIDC (generates state/nonce/PKCE) - * GET /api/auth/google/callback → Google OIDC callback (verifies + exchanges code) + * GET /api/auth/google/callback → verifies + exchanges code, sends a login OTP, + * redirects to the frontend with otpRequired=true * * Author: rgrgogu * Date Created: Oct. 6, 2025 + * Date Modified: Jul. 4, 2026 — mandatory OTP on every login + forgot/reset password (Kenneth Obsequio) ***********************************************************************************************************************************************************************/ const express = require('express'); const router = express.Router(); @@ -30,6 +38,7 @@ const { validate } = require('../middleware/validate.middleware'); const { registerValidator, loginValidator, verifyOTPValidator, resendOTPValidator, changePassValidator, + forgotPasswordValidator, resetPasswordValidator, } = require('../validators/auth.validator'); // ── CSRF token (GET — no CSRF needed on GETs) ────────────────────────────────── @@ -39,10 +48,12 @@ router.get('/csrf-token', csrfProtection, getCsrfToken); router.post('/register', ...registerValidator, validate, authCtrl.register); router.post('/verify-otp', otpLimiter, ...verifyOTPValidator, validate, authCtrl.verifyOTP); router.post('/resend-otp', otpLimiter, ...resendOTPValidator, validate, authCtrl.resendOTP); -router.post('/login', ...loginValidator, validate, authCtrl.login); +router.post('/login', authLimiter, ...loginValidator, validate, authCtrl.login); router.post('/refresh', authLimiter, authCtrl.refreshToken); router.post('/logout', authenticate, authLimiter, authCtrl.logout); router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword); +router.post('/forgot-password', otpLimiter, ...forgotPasswordValidator, validate, authCtrl.forgotPassword); +router.post('/reset-password', otpLimiter, ...resetPasswordValidator, validate, authCtrl.resetPassword); // ── Google OIDC ──────────────────────────────────────────────────────────────── router.get('/google', authLimiter, authCtrl.googleRedirect); diff --git a/routes/client/advertisements.routes.js b/routes/client/advertisements.routes.js index 484bba0..4c71405 100644 --- a/routes/client/advertisements.routes.js +++ b/routes/client/advertisements.routes.js @@ -8,6 +8,9 @@ router.get('/active', controller.getActiveAdvertisement); // ─── GET /api/client/advertisements/active-batch?placements=a,b,c ───────────── router.get('/active-batch', controller.getActiveAdvertisements); +// ─── GET /api/client/advertisements/active-list?placement=dashboard.hero ────── +router.get('/active-list', controller.getActiveAdvertisementList); + // ─── POST /api/client/advertisements/:advertisementId/click ─────────────────── router.post('/:advertisementId/click', controller.trackClick); diff --git a/services/accountStatus.service.js b/services/accountStatus.service.js new file mode 100644 index 0000000..f401db6 --- /dev/null +++ b/services/accountStatus.service.js @@ -0,0 +1,51 @@ +/*********************************************************************************************************************************************************************** + * File Name: accountStatus.service.js + * Type of Program: Service + * Description: Shared active/ban-status check + auto-lift for expired temporary + * bans. Used by every entry point that authenticates a user + * (login, Google OAuth callback, refresh token, JWT middleware) + * so the ban logic lives in exactly one place. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 4, 2026 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { checkAccountStatus } = require('../services/accountStatus.service'); + * const status = await checkAccountStatus(user); + * if (!status.ok) { ... map status.code ('deactivated' | 'banned') to a response ... } + ***********************************************************************************************************************************************************************/ +const mdl_UserBans = require('../models/users/user_bans.mdl'); + +/** + * Checks whether a user is allowed to authenticate right now. + * Auto-lifts an expired temporary ban as a side effect. + * + * @param {import('../models/users/users.mdl')} user + * @returns {Promise<{ok: true} | {ok: false, code: 'deactivated'|'banned', reason?: string|null, ban_type?: string|null, ban_expires_at?: Date|null}>} + */ +const checkAccountStatus = async (user) => { + if (!user.is_active) return { ok: false, code: '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'], + }); + return { + ok: false, + code: 'banned', + 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 }); + } + + return { ok: true }; +}; + +module.exports = { checkAccountStatus }; diff --git a/services/s3.service.js b/services/s3.service.js index 6aebe12..2d5b792 100644 --- a/services/s3.service.js +++ b/services/s3.service.js @@ -6,12 +6,14 @@ // deleteFile(key) // // Required .env vars: -// S3_ENDPOINT – http://127.0.0.1:3900 +// S3_ENDPOINT – http://127.0.0.1:3900 (internal — always used for uploads/deletes) // S3_REGION – garage // S3_ACCESS_KEY // S3_SECRET_KEY // S3_BUCKET – your-bucket-name -// S3_PUBLIC_URL – https://cdn.yourdomain.com +// S3_PUBLIC_URL – https://cdn.yourdomain.com (used for browser-facing URLs +// whenever S3_ENDPOINT isn't reachable from this machine — +// see resolvePublicHost() below) const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); @@ -26,6 +28,8 @@ const credentials = { }; // Internal client — uploads, deletes, direct streams from the server itself. +// Always targets S3_ENDPOINT: these calls originate from this machine, so the +// internal address is the correct (and only) one to use. const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION || "garage", @@ -33,19 +37,80 @@ const s3 = new S3Client({ forcePathStyle: true, }); -// Public client — generates pre-signed URLs using the externally reachable -// endpoint (S3_PUBLIC_URL) so URLs work from any machine, not just the one -// running Garage. Falls back to the internal endpoint when S3_PUBLIC_URL is -// unset (single-machine dev). -const s3Public = new S3Client({ - endpoint: process.env.S3_PUBLIC_URL ?? process.env.S3_ENDPOINT, - region: process.env.S3_REGION || "garage", - credentials, - forcePathStyle: true, -}); - const DEFAULT_BUCKET = process.env.S3_BUCKET; -const PUBLIC_URL = (process.env.S3_PUBLIC_URL || "").replace(/\/$/, ""); + +// ─── Public host resolution ─────────────────────────────────────────────────── +// +// URLs handed to browsers (file_url, presigned GET links) need a host reachable +// from wherever the client sits. S3_ENDPOINT (e.g. 127.0.0.1:3900) only works +// from the machine running Garage itself; S3_PUBLIC_URL is the externally +// reachable address (tunnel/CDN/domain). +// +// Rather than always preferring one, probe S3_ENDPOINT and use it when it's +// actually reachable (same-machine dev setup — no extra hop through the +// tunnel), falling back to S3_PUBLIC_URL when it isn't (any other machine). +// +// The probe runs once at startup and then on a background timer — never on +// the request path itself. A machine without Garage would otherwise pay the +// full HeadBucket timeout on whichever upload/asset request happens to land +// right after the cache expires; polling in the background means every +// request just reads the last known-good host instantly. +const PROBE_TIMEOUT_MS = 1500; +const PROBE_CACHE_MS = 15000; + +let hostCache = { host: process.env.S3_PUBLIC_URL || process.env.S3_ENDPOINT || "" }; + +async function probeEndpoint(endpoint) { + const probe = new S3Client({ + endpoint, + region: process.env.S3_REGION || "garage", + credentials, + forcePathStyle: true, + }); + await Promise.race([ + probe.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET })), + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS)), + ]); +} + +async function refreshHostCache() { + const endpoint = process.env.S3_ENDPOINT; + const publicUrl = process.env.S3_PUBLIC_URL || ""; + + if (!endpoint) { hostCache = { host: publicUrl }; return; } + if (!publicUrl) { hostCache = { host: endpoint }; return; } + + try { + await probeEndpoint(endpoint); + hostCache = { host: endpoint }; + } catch { + hostCache = { host: publicUrl }; + } +} + +// Kick off the first probe immediately so the cache is populated before any +// request needs it, then keep it fresh in the background. unref() so this +// timer alone doesn't keep the process (or a test run) alive. +const initialProbe = refreshHostCache(); +const refreshTimer = setInterval(refreshHostCache, PROBE_CACHE_MS); +refreshTimer.unref?.(); + +async function resolvePublicHost() { + await initialProbe; // no-op after the first call — already resolved + return hostCache.host; +} + +// Public client — lazily built against whichever host resolvePublicHost() +// picks, so it follows the reachability check instead of a fixed endpoint. +async function getPublicClient() { + const endpoint = await resolvePublicHost(); + return new S3Client({ + endpoint, + region: process.env.S3_REGION || "garage", + credentials, + forcePathStyle: true, + }); +} // ─── Key prefix map ─────────────────────────────────────────────────────────── // @@ -83,10 +148,11 @@ function buildKey(originalname, ownerType) { } // Builds the public URL for a stored object. -// Garage path-style: {S3_PUBLIC_URL}/{bucket}/{key} +// Garage path-style: {host}/{bucket}/{key} // e.g. https://cdn.yourdomain.com/your-bucket/images/uuid.jpg -function buildPublicUrl(key, bucket = DEFAULT_BUCKET) { - return `${PUBLIC_URL}/${bucket}/${key}`; +async function buildPublicUrl(key, bucket = DEFAULT_BUCKET) { + const host = (await resolvePublicHost()).replace(/\/$/, ""); + return `${host}/${bucket}/${key}`; } // ─── uploadFile ─────────────────────────────────────────────────────────────── @@ -111,7 +177,7 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "image" })); return { - url: buildPublicUrl(key, bucket), + url: await buildPublicUrl(key, bucket), uuid: key, // used as storage_key in DB — mirrors chibi_uuid usage }; } @@ -131,13 +197,15 @@ async function deleteFile(key) { // ─── getSignedDownloadUrl ───────────────────────────────────────────────────── // -// Generates a short-lived pre-signed GET URL using the public endpoint so the -// URL is resolvable from any machine (browser or proxy server), not just the -// one running Garage locally. +// Generates a short-lived pre-signed GET URL against whichever host +// resolvePublicHost() picks, so the URL is resolvable from wherever the +// request is served (browser or proxy server), not just the one running +// Garage locally. // async function getSignedDownloadUrl(key, expiresInSeconds = 3600) { + const client = await getPublicClient(); return getSignedUrl( - s3Public, + client, new GetObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key }), { expiresIn: expiresInSeconds } ); @@ -169,7 +237,8 @@ async function getObjectStream(key) { } async function ping() { - await s3Public.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET })); + const client = await getPublicClient(); + await client.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET })); } module.exports = { uploadFile, deleteFile, getSignedDownloadUrl, getObjectStream, ping }; \ No newline at end of file diff --git a/services/trustedDevice.service.js b/services/trustedDevice.service.js new file mode 100644 index 0000000..45fd113 --- /dev/null +++ b/services/trustedDevice.service.js @@ -0,0 +1,149 @@ +/*********************************************************************************************************************************************************************** + * File Name: trustedDevice.service.js + * Type of Program: Service + * Description: Lets a login from an already-verified device skip the OTP + * gate. A device is trusted the first time its user clears an + * OTP; trust rolls forward 30 days on each trusted login and is + * tied to both an opaque cookie token (device_trust) and a + * User-Agent fingerprint, so a stolen cookie alone isn't enough + * once the fingerprint no longer matches. Trust is revoked on + * logout, password change/reset, admin ban/deactivate, or a + * single session being terminated. + * Author: Kenneth Obsequio + * Date Created: Jul. 5, 2026 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const trustedDevice = require('../services/trustedDevice.service'); + * const fingerprintHash = trustedDevice.getFingerprintHash(req); + * const trusted = await trustedDevice.findValid(user.user_id, req.cookies.device_trust, fingerprintHash); + * if (trusted) { ...skip OTP... } + * await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id); + ***********************************************************************************************************************************************************************/ +const crypto = require('crypto'); +const mdl_TrustedDevices = require('../models/users/trusted_devices.mdl'); +const { parseUA } = require('../utils/session_info.util'); +const { hashToken } = require('../utils/token.util'); + +const TRUST_DAYS = 30; +const COOKIE_NAME = 'device_trust'; + +const getFingerprintHash = (req) => { + const { browser, os, device } = parseUA(req.headers['user-agent']); + return crypto.createHash('sha256').update(`${browser}|${os}|${device}`).digest('hex'); +}; + +const cookieOptions = (maxAge) => ({ + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', + maxAge, +}); + +/** + * Looks up a non-revoked, non-expired trusted device matching both the + * cookie token and the current request's fingerprint. + * + * Fails safe: any lookup error (e.g. table not migrated yet) is treated as + * "not trusted" rather than propagating — a broken trust check should never + * take down the login/OTP path itself, it should just fall back to OTP. + * @returns {Promise} + */ +const findValid = async (userId, rawToken, fingerprintHash) => { + if (!rawToken) return null; + + try { + const row = await mdl_TrustedDevices.findOne({ + where: { + user_id: userId, + device_token_hash: hashToken(rawToken), + fingerprint_hash: fingerprintHash, + revoked_at: null, + }, + }); + return (row && new Date(row.expires_at) > new Date()) ? row : null; + } catch (err) { + console.error('[TRUSTED DEVICE] findValid failed, falling back to OTP:', err.message); + return null; + } +}; + +/** + * Marks the current device as trusted for TRUST_DAYS, rolling the window + * forward on repeat use, and sets the device_trust cookie. + * + * Fails safe: called after tokens/session are already minted, so a failure + * here (e.g. table not migrated yet) must not break an otherwise-successful + * login — it just means this device won't skip OTP next time. + */ +const issueOrRefresh = async (res, userId, fingerprintHash, sessionId) => { + try { + const rawToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + TRUST_DAYS * 24 * 60 * 60 * 1000); + const fields = { + device_token_hash: hashToken(rawToken), + expires_at: expiresAt, + revoked_at: null, + last_session_id: sessionId, + }; + + // Plain find-then-create/update rather than findOrCreate() — Sequelize's + // postgres findOrCreate() relies on a temp PL/pgSQL function for atomicity + // that CockroachDB doesn't support ("cannot create user-defined functions + // under a temporary schema"). + const row = await mdl_TrustedDevices.findOne({ where: { user_id: userId, fingerprint_hash: fingerprintHash } }); + if (row) { + await row.update(fields); + } else { + await mdl_TrustedDevices.create({ user_id: userId, fingerprint_hash: fingerprintHash, ...fields }); + } + + res.cookie(COOKIE_NAME, rawToken, cookieOptions(TRUST_DAYS * 24 * 60 * 60 * 1000)); + } catch (err) { + console.error('[TRUSTED DEVICE] issueOrRefresh failed:', err.message); + } +}; + +const revokeByToken = async (userId, rawToken) => { + if (!rawToken) return; + try { + await mdl_TrustedDevices.update( + { revoked_at: new Date() }, + { where: { user_id: userId, device_token_hash: hashToken(rawToken), revoked_at: null } } + ); + } catch (err) { + console.error('[TRUSTED DEVICE] revokeByToken failed:', err.message); + } +}; + +const revokeAllForUser = async (userId) => { + try { + await mdl_TrustedDevices.update( + { revoked_at: new Date() }, + { where: { user_id: userId, revoked_at: null } } + ); + } catch (err) { + console.error('[TRUSTED DEVICE] revokeAllForUser failed:', err.message); + } +}; + +const revokeBySessionId = async (sessionId) => { + if (!sessionId) return; + try { + await mdl_TrustedDevices.update( + { revoked_at: new Date() }, + { where: { last_session_id: sessionId, revoked_at: null } } + ); + } catch (err) { + console.error('[TRUSTED DEVICE] revokeBySessionId failed:', err.message); + } +}; + +module.exports = { + COOKIE_NAME, + getFingerprintHash, + findValid, + issueOrRefresh, + revokeByToken, + revokeAllForUser, + revokeBySessionId, +}; diff --git a/utils/session_info.util.js b/utils/session_info.util.js index 2a4fb9e..3b64e63 100644 --- a/utils/session_info.util.js +++ b/utils/session_info.util.js @@ -62,3 +62,5 @@ const buildSessionInfo = async (req, extras = {}) => { }; module.exports = buildSessionInfo; +module.exports.parseUA = parseUA; +module.exports.getIP = getIP; diff --git a/validators/auth.validator.js b/validators/auth.validator.js index eae9a8f..2255bf1 100644 --- a/validators/auth.validator.js +++ b/validators/auth.validator.js @@ -6,9 +6,12 @@ * - loginValidator → POST /auth/login * - verifyOTPValidator → POST /auth/verify-otp * - resendOTPValidator → POST /auth/resend-otp - * - changePassValidator → POST /auth/change-password + * - changePassValidator → POST /auth/change-password + * - forgotPasswordValidator → POST /auth/forgot-password + * - resetPasswordValidator → POST /auth/reset-password * Author: rgrgogu * Date Created: Oct. 6, 2025 + * Date Modified: Jul. 4, 2026 — forgot/reset password (Kenneth Obsequio) ***********************************************************************************************************************************************************************/ const { body } = require('express-validator'); @@ -43,4 +46,21 @@ const changePassValidator = [ .matches(/[0-9]/).withMessage('Must contain a number.'), ]; -module.exports = { registerValidator, loginValidator, verifyOTPValidator, resendOTPValidator, changePassValidator }; \ No newline at end of file +const forgotPasswordValidator = [ + body('email').isEmail().withMessage('Valid email is required.'), +]; + +const resetPasswordValidator = [ + body('email').isEmail().withMessage('Valid email is required.'), + body('otp').isLength({ min: 6, max: 6 }).isNumeric().withMessage('OTP must be 6 digits.'), + body('new_password') + .isLength({ min: 8 }).withMessage('New password must be at least 8 characters.') + .matches(/[A-Z]/).withMessage('Must contain an uppercase letter.') + .matches(/[0-9]/).withMessage('Must contain a number.'), +]; + +module.exports = { + registerValidator, loginValidator, + verifyOTPValidator, resendOTPValidator, changePassValidator, + forgotPasswordValidator, resetPasswordValidator, +}; \ No newline at end of file