mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -21,6 +21,7 @@ const mdl_Payments = require('../../models/tiers/payments.mdl');
|
|||||||
const mdl_QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
|
const mdl_QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
|
||||||
|
|
||||||
const { sendEmail } = require('../../services/email.service');
|
const { sendEmail } = require('../../services/email.service');
|
||||||
|
const trustedDevice = require('../../services/trustedDevice.service');
|
||||||
const { fmtDate } = require('../../utils/datetime.util');
|
const { fmtDate } = require('../../utils/datetime.util');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const R = require('../../utils/response.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 } },
|
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||||
{ where: { user_id: req.params.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) });
|
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 } },
|
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||||
{ where: { user_id: activeIds } }
|
{ where: { user_id: activeIds } }
|
||||||
);
|
);
|
||||||
|
await trustedDevice.revokeAllForUser(activeIds);
|
||||||
|
|
||||||
return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, {
|
return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, {
|
||||||
deactivated_ids: activeIds,
|
deactivated_ids: activeIds,
|
||||||
@@ -440,6 +443,7 @@ exports.terminateSession = async (req, res) => {
|
|||||||
is_active: false,
|
is_active: false,
|
||||||
logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id },
|
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 });
|
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 } },
|
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||||
{ where: { user_id: id }, transaction: t }
|
{ where: { user_id: id }, transaction: t }
|
||||||
);
|
);
|
||||||
|
await trustedDevice.revokeAllForUser(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
logActivity(req.user.user_id, 'ban_user', {
|
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 } },
|
{ is_active: false, logout_info: { date: now.toISOString(), forced_by: req.user.user_id } },
|
||||||
{ where: { user_id: targetIds }, transaction: t }
|
{ where: { user_id: targetIds }, transaction: t }
|
||||||
);
|
);
|
||||||
|
await trustedDevice.revokeAllForUser(targetIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
const dateStr = fmtDate(new Date());
|
const dateStr = fmtDate(new Date());
|
||||||
|
|||||||
+204
-116
@@ -1,17 +1,25 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
/***********************************************************************************************************************************************************************
|
||||||
* File Name: auth.controller.js
|
* File Name: auth.controller.js
|
||||||
* Type of Program: Controller
|
* Type of Program: Controller
|
||||||
* Description: Handles all authentication flows:
|
* Description: Handles all authentication flows. Every credential path — system
|
||||||
* 1. System Registration → OTP email → OTP Verify → Auto-Login
|
* registration, system login, and Google OAuth — funnels through
|
||||||
* 2. System Login (verified users)
|
* the same OTP gate before tokens are issued:
|
||||||
* 3. Google OAuth callback
|
* 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
|
* 4. Token Refresh
|
||||||
* 5. Logout (invalidates session)
|
* 5. Logout (invalidates session)
|
||||||
* 6. OTP Resend
|
* 6. OTP Resend (only valid while an OTP is pending)
|
||||||
* 7. Change Password
|
* 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
|
* Author: rgrgogu
|
||||||
* Date Created: Oct. 6, 2025
|
* Date Created: Oct. 6, 2025
|
||||||
|
* Date Modified: Jul. 4, 2026 — mandatory OTP on every login (Kenneth Obsequio)
|
||||||
***********************************************************************************************************************************************************************
|
***********************************************************************************************************************************************************************
|
||||||
* HOW TO USE:
|
* HOW TO USE:
|
||||||
* Mount via routes/auth.routes.js
|
* Mount via routes/auth.routes.js
|
||||||
@@ -29,7 +37,7 @@ const crypto = require('crypto');
|
|||||||
const sequelize = require('../config/db.config')
|
const sequelize = require('../config/db.config')
|
||||||
const mdl_Users = require('../models/users/users.mdl');
|
const mdl_Users = require('../models/users/users.mdl');
|
||||||
const mdl_UserSessions = require('../models/users/user_sessions.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 { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl')
|
||||||
const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util');
|
const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util');
|
||||||
const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.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 { sendEmail } = require('../services/email.service');
|
||||||
const buildSessionInfo = require('../utils/session_info.util');
|
const buildSessionInfo = require('../utils/session_info.util');
|
||||||
const logActivity = require('../utils/logActivity.util');
|
const logActivity = require('../utils/logActivity.util');
|
||||||
|
const trustedDevice = require('../services/trustedDevice.service');
|
||||||
const R = require('../utils/response.util');
|
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 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;
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Register ──────────────────────────────────────────────────────────────────
|
||||||
exports.register = async (req, res) => {
|
exports.register = async (req, res) => {
|
||||||
const transaction = await sequelize.transaction();
|
const transaction = await sequelize.transaction();
|
||||||
@@ -91,7 +128,10 @@ exports.register = async (req, res) => {
|
|||||||
reg_type: 'system',
|
reg_type: 'system',
|
||||||
acc_type: 'user',
|
acc_type: 'user',
|
||||||
personal_info: personal_info ?? null,
|
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,
|
createdBy: null,
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
@@ -148,7 +188,6 @@ exports.verifyOTP = async (req, res) => {
|
|||||||
|
|
||||||
const user = await mdl_Users.findOne({ where: { email } });
|
const user = await mdl_Users.findOne({ where: { email } });
|
||||||
if (!user) return R.error(res, 'User not found.', 404);
|
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 storedOTP = Buffer.from(user.otp_code ?? '');
|
||||||
const givenOTP = Buffer.from(otp ?? '');
|
const givenOTP = Buffer.from(otp ?? '');
|
||||||
@@ -156,22 +195,18 @@ exports.verifyOTP = async (req, res) => {
|
|||||||
return R.error(res, 'Invalid OTP.', 400);
|
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);
|
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 });
|
// wasVerified (captured inside mintSession, before its update) tells us
|
||||||
|
// whether this is the account's very first OTP pass (system registration
|
||||||
// Auto-login after verification
|
// or first-ever Google login) or a routine login OTP — only the former
|
||||||
const { accessToken, refreshToken } = generateTokens(user);
|
// fires the welcome/achievements bundle.
|
||||||
const session = await mdl_UserSessions.create({
|
const { accessToken, refreshToken, session, wasVerified } = await mintSession(req, user, { transaction });
|
||||||
user_id: user.user_id,
|
|
||||||
login_info: await buildSessionInfo(req),
|
|
||||||
refresh_token_hash: hashToken(refreshToken),
|
|
||||||
is_active: true,
|
|
||||||
}, { transaction });
|
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
|
||||||
// Fire-and-forget: activity log
|
// Fire-and-forget: activity log
|
||||||
logActivity(user.user_id, 'register');
|
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)
|
// Fire-and-forget: achievements, welcome email, notification (do not block the response)
|
||||||
onUserRegistered(user.user_id)
|
onUserRegistered(user.user_id)
|
||||||
.catch(err => console.error('[AUTH] Failed to grant achievements:', err));
|
.catch(err => console.error('[AUTH] Failed to grant achievements:', err));
|
||||||
@@ -208,15 +243,16 @@ exports.verifyOTP = async (req, res) => {
|
|||||||
}
|
}
|
||||||
return UserNotification.bulkCreate(notifications, { validate: false });
|
return UserNotification.bulkCreate(notifications, { validate: false });
|
||||||
}).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
|
}).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
|
||||||
|
}
|
||||||
|
|
||||||
res.cookie('refreshToken', refreshToken, {
|
// Clearing an OTP is what marks a device trusted going forward — this is
|
||||||
httpOnly: true, // ← JS cannot read this
|
// the only place trust is first granted (the login/googleCallback fast
|
||||||
secure: process.env.NODE_ENV === 'production',
|
// path only ever rolls an existing trust window forward).
|
||||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
|
await trustedDevice.issueOrRefresh(res, user.user_id, trustedDevice.getFingerprintHash(req), session.session_id);
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
accessToken,
|
||||||
session_id: session.session_id,
|
session_id: session.session_id,
|
||||||
user: safeUser(user),
|
user: safeUser(user),
|
||||||
@@ -236,7 +272,11 @@ exports.resendOTP = async (req, res) => {
|
|||||||
const { email } = req.body;
|
const { email } = req.body;
|
||||||
const user = await mdl_Users.findOne({ where: { email } });
|
const user = await mdl_Users.findOne({ where: { email } });
|
||||||
if (!user) return R.error(res, 'User not found.', 404);
|
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();
|
const otp = generateOTP();
|
||||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction });
|
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
|
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
|
||||||
// the already-persisted OTP refresh.
|
// 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));
|
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||||||
|
|
||||||
return R.success(res, 'A new OTP has been sent to your email.');
|
return R.success(res, 'A new OTP has been sent to your email.');
|
||||||
@@ -264,53 +304,53 @@ exports.login = async (req, res) => {
|
|||||||
const user = await mdl_Users.findOne({ where: { email } });
|
const user = await mdl_Users.findOne({ where: { email } });
|
||||||
if (!user) return R.error(res, 'Invalid credentials.', 401);
|
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.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 status = await checkAccountStatus(user);
|
||||||
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
|
if (!status.ok) {
|
||||||
if (stillBanned) {
|
if (status.code === 'deactivated') return R.error(res, 'Account is deactivated.', 403);
|
||||||
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, {
|
return R.error(res, 'Your account has been suspended.', 403, {
|
||||||
banned: true,
|
banned: true,
|
||||||
reason: activeBan?.reason ?? null,
|
reason: status.reason,
|
||||||
ban_type: activeBan?.ban_type ?? null,
|
ban_type: status.ban_type,
|
||||||
ban_expires_at: activeBan?.expires_at ?? null,
|
ban_expires_at: status.ban_expires_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Expired temporary ban — auto-lift
|
|
||||||
await user.update({ is_banned: false, ban_expires_at: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = await bcrypt.compare(password, user.password);
|
const match = await bcrypt.compare(password, user.password);
|
||||||
if (!match) return R.error(res, 'Invalid credentials.', 401);
|
if (!match) return R.error(res, 'Invalid credentials.', 401);
|
||||||
|
|
||||||
const { accessToken, refreshToken } = generateTokens(user);
|
// Password confirmed. If this device already cleared an OTP recently and
|
||||||
const session = await mdl_UserSessions.create({
|
// its trust window hasn't lapsed or been revoked, skip the OTP gate
|
||||||
user_id: user.user_id,
|
// entirely — otherwise fall through to the usual fresh-OTP flow. Tokens
|
||||||
login_info: await buildSessionInfo(req),
|
// are only ever minted via mintSession (called here or from verifyOTP).
|
||||||
refresh_token_hash: hashToken(refreshToken),
|
const fingerprintHash = trustedDevice.getFingerprintHash(req);
|
||||||
is_active: true,
|
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) });
|
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'Login successful.', {
|
return R.success(res, 'Login successful.', {
|
||||||
|
otpRequired: false,
|
||||||
accessToken,
|
accessToken,
|
||||||
session_id: session.session_id,
|
session_id: session.session_id,
|
||||||
user: safeUser(user),
|
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) {
|
} catch (err) {
|
||||||
console.error('[AUTH] login error:', err);
|
console.error('[AUTH] login error:', err);
|
||||||
return R.error(res, 'Login failed.', 500);
|
return R.error(res, 'Login failed.', 500);
|
||||||
@@ -386,7 +426,7 @@ exports.googleCallback = async (req, res) => {
|
|||||||
reg_type: 'google',
|
reg_type: 'google',
|
||||||
acc_type: 'user',
|
acc_type: 'user',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_verified: true,
|
is_verified: false,
|
||||||
needs_intro: true,
|
needs_intro: true,
|
||||||
personal_info: {
|
personal_info: {
|
||||||
name: {
|
name: {
|
||||||
@@ -409,20 +449,9 @@ exports.googleCallback = async (req, res) => {
|
|||||||
|
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
|
||||||
// Fire-and-forget: achievements + welcome notifications for new Google user
|
// Welcome email/achievements/welcome-notification are deferred to
|
||||||
onUserRegistered(user.user_id)
|
// verifyOTP's first-time branch now (this account isn't verified yet —
|
||||||
.catch(err => console.error('[AUTH] googleCallback: Failed to grant achievements:', err));
|
// it still has to complete the same OTP gate as a system registration).
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
renderNotification({ type: 'nogrp_user_registered', data: {
|
renderNotification({ type: 'nogrp_user_registered', data: {
|
||||||
userEmail: payload.email,
|
userEmail: payload.email,
|
||||||
regType: 'google',
|
regType: 'google',
|
||||||
@@ -435,47 +464,44 @@ exports.googleCallback = async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.is_active) {
|
const status = await checkAccountStatus(user);
|
||||||
|
if (!status.ok) {
|
||||||
|
if (status.code === 'deactivated') {
|
||||||
return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`);
|
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' });
|
const params = new URLSearchParams({ error: 'account_banned' });
|
||||||
if (activeBan?.reason) params.set('reason', activeBan.reason);
|
if (status.reason) params.set('reason', status.reason);
|
||||||
if (activeBan?.ban_type) params.set('ban_type', activeBan.ban_type);
|
if (status.ban_type) params.set('ban_type', status.ban_type);
|
||||||
if (activeBan?.expires_at) params.set('expires_at', new Date(activeBan.expires_at).toISOString());
|
if (status.ban_expires_at) params.set('expires_at', new Date(status.ban_expires_at).toISOString());
|
||||||
return res.redirect(`${CALLBACK_PAGE}?${params.toString()}`);
|
return res.redirect(`${CALLBACK_PAGE}?${params.toString()}`);
|
||||||
}
|
}
|
||||||
await user.update({ is_banned: false, ban_expires_at: null });
|
|
||||||
|
// 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) });
|
||||||
|
|
||||||
|
return res.redirect(`${CALLBACK_PAGE}?otpRequired=false`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken } = generateTokens(user);
|
const otp = generateOTP();
|
||||||
const googleSession = await mdl_UserSessions.create({
|
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
||||||
user_id: user.user_id,
|
|
||||||
login_info: await buildSessionInfo(req),
|
|
||||||
refresh_token_hash: hashToken(refreshToken),
|
|
||||||
is_active: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(googleSession.session_id), details: { reg_type: 'google' } });
|
sendEmail({ to: user.email, type: 'LOGIN_OTP', data: { otp } })
|
||||||
|
.catch(err => console.error('[AUTH] googleCallback: Failed to send login OTP email:', err));
|
||||||
|
|
||||||
res.cookie('refreshToken', refreshToken, {
|
return res.redirect(`${CALLBACK_PAGE}?otpRequired=true&email=${encodeURIComponent(user.email)}`);
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
|
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to the frontend; App.jsx's restoreSession() will pick up the
|
|
||||||
// refresh cookie and complete the login automatically.
|
|
||||||
return res.redirect(CALLBACK_PAGE);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] googleCallback OIDC error:', err);
|
console.error('[AUTH] googleCallback OIDC error:', err);
|
||||||
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google?error=auth_failed`);
|
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);
|
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);
|
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 status = await checkAccountStatus(user);
|
||||||
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
|
if (!status.ok) {
|
||||||
if (stillBanned) return R.error(res, 'Your account has been suspended.', 403, { banned: true });
|
if (status.code === 'deactivated') return R.error(res, 'User not found or deactivated.', 401);
|
||||||
await user.update({ is_banned: false, ban_expires_at: null });
|
return R.error(res, 'Your account has been suspended.', 403, { banned: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if refresh token is expired
|
// Check if refresh token is expired
|
||||||
@@ -515,12 +541,7 @@ exports.refreshToken = async (req, res) => {
|
|||||||
const tokens = generateTokens(user);
|
const tokens = generateTokens(user);
|
||||||
await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) });
|
await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) });
|
||||||
|
|
||||||
res.cookie('refreshToken', tokens.refreshToken, {
|
setRefreshCookie(res, tokens.refreshToken);
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
|
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: safeUser(user) });
|
return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: safeUser(user) });
|
||||||
} catch (err) {
|
} 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 });
|
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('refreshToken')
|
||||||
res.clearCookie('_csrf')
|
res.clearCookie('_csrf')
|
||||||
|
res.clearCookie(trustedDevice.COOKIE_NAME)
|
||||||
|
|
||||||
return R.success(res, 'Logged out successfully.');
|
return R.success(res, 'Logged out successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -569,6 +593,7 @@ exports.changePassword = async (req, res) => {
|
|||||||
|
|
||||||
// Invalidate all sessions to force re-login
|
// Invalidate all sessions to force re-login
|
||||||
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
|
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');
|
logActivity(user.user_id, 'password_change');
|
||||||
|
|
||||||
@@ -578,3 +603,66 @@ exports.changePassword = async (req, res) => {
|
|||||||
return R.error(res, 'Password change failed.', 500);
|
return R.error(res, 'Password change failed.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Forgot Password — Request OTP ─────────────────────────────────────────────
|
||||||
|
// Same procedure for every acc_type (admin/staff/user) — only reg_type matters.
|
||||||
|
exports.forgotPassword = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email } = req.body;
|
||||||
|
const user = await mdl_Users.findOne({ where: { email } });
|
||||||
|
if (!user) return R.error(res, 'User not found.', 404);
|
||||||
|
if (user.reg_type === 'google')
|
||||||
|
return R.error(res, 'This account uses Google sign-in. Please log in with Google.', 400);
|
||||||
|
|
||||||
|
const status = await checkAccountStatus(user);
|
||||||
|
if (!status.ok) {
|
||||||
|
if (status.code === 'deactivated') return R.error(res, 'Account is deactivated.', 403);
|
||||||
|
return R.error(res, 'Your account has been suspended.', 403, { banned: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const otp = generateOTP();
|
||||||
|
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
||||||
|
|
||||||
|
sendEmail({ to: email, type: 'RESET_PASSWORD_OTP', data: { otp } })
|
||||||
|
.catch(err => console.error('[AUTH] Failed to send reset-password OTP email:', err));
|
||||||
|
|
||||||
|
return R.success(res, 'An OTP has been sent to your email.', { email: user.email });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AUTH] forgotPassword error:', err);
|
||||||
|
return R.error(res, 'Could not process request.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Forgot Password — Reset with OTP ──────────────────────────────────────────
|
||||||
|
exports.resetPassword = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, otp, new_password } = req.body;
|
||||||
|
const user = await mdl_Users.findOne({ where: { email } });
|
||||||
|
if (!user) return R.error(res, 'User not found.', 404);
|
||||||
|
if (user.reg_type === 'google')
|
||||||
|
return R.error(res, 'This account uses Google sign-in. Please log in with Google.', 400);
|
||||||
|
|
||||||
|
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||||||
|
const givenOTP = Buffer.from(otp ?? '');
|
||||||
|
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||||||
|
return R.error(res, 'Invalid OTP.', 400);
|
||||||
|
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(new_password, 12);
|
||||||
|
await user.update({ password: hashed, otp_code: null, otp_expires_at: null });
|
||||||
|
|
||||||
|
// Invalidate all sessions — same login procedure (credentials → OTP) applies next time
|
||||||
|
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
|
||||||
|
await trustedDevice.revokeAllForUser(user.user_id);
|
||||||
|
|
||||||
|
logActivity(user.user_id, 'password_reset');
|
||||||
|
|
||||||
|
sendEmail({ to: email, type: 'PASSWORD_CHANGED', data: {} })
|
||||||
|
.catch(err => console.error('[AUTH] Failed to send password-changed email:', err));
|
||||||
|
|
||||||
|
return R.success(res, 'Password reset successful. Please log in with your new password.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AUTH] resetPassword error:', err);
|
||||||
|
return R.error(res, 'Password reset failed.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -104,6 +104,46 @@ exports.getActiveAdvertisement = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── GET ACTIVE (list) ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Resolves every live advertisement for a single placement, ordered by
|
||||||
|
// priority — used by carousel-style slots (e.g. dashboard.hero) that rotate
|
||||||
|
// through several ads instead of showing only the single highest-priority one.
|
||||||
|
//
|
||||||
|
// GET /api/client/advertisements/active-list?placement=dashboard.hero&limit=8
|
||||||
|
//
|
||||||
|
exports.getActiveAdvertisementList = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { placement } = req.query;
|
||||||
|
|
||||||
|
if (!placement) return R.error(res, "placement is required.", 400);
|
||||||
|
if (!PLACEMENT_MAP[placement]) return R.error(res, "Invalid placement.", 400);
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 8, 1), 20);
|
||||||
|
|
||||||
|
const advertisements = await Advertisement.findAll({
|
||||||
|
where: liveWhere({ placement }),
|
||||||
|
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||||
|
include: [AD_IMAGE_INCLUDE],
|
||||||
|
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = [];
|
||||||
|
for (const ad of advertisements) {
|
||||||
|
const json = ad.toJSON();
|
||||||
|
json.status = deriveStatus(json);
|
||||||
|
if (json.image) await attachImageStreamToken(json.image, req);
|
||||||
|
data.push(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
return R.success(res, "Active advertisements retrieved.", { data });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE LIST]", err);
|
||||||
|
return R.error(res, "Could not retrieve advertisements.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ─── GET ACTIVE (batch) ─────────────────────────────────────────────────────
|
// ─── GET ACTIVE (batch) ─────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Resolves the highest-priority live advertisement for each of several
|
// Resolves the highest-priority live advertisement for each of several
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
const mdl_Users = require('../../models/users/users.mdl');
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||||
const mdl_Achievements = require('../../models/users/achievements.mdl');
|
const mdl_Achievements = require('../../models/users/achievements.mdl');
|
||||||
|
const trustedDevice = require('../../services/trustedDevice.service');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { uploadFile, deleteFile } = require('../../services/s3.service');
|
const { uploadFile, deleteFile } = require('../../services/s3.service');
|
||||||
@@ -94,6 +95,7 @@ exports.revokeSession = async (req, res) => {
|
|||||||
is_active: false,
|
is_active: false,
|
||||||
logout_info: { date: new Date().toISOString(), ip_address: req.ip },
|
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 });
|
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' } },
|
{ 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 } },
|
{ 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
|
// 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 });
|
await user.update({ email: `deleted_${req.user.user_id}@deleted.invalid`, deletedBy: req.user.user_id });
|
||||||
|
|||||||
@@ -31,6 +31,26 @@ const emailTemplates = {
|
|||||||
`),
|
`),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
LOGIN_OTP: ({ otp, expiryMinutes = 10 }) => ({
|
||||||
|
subject: "Sign-In Verification Code - STARR System",
|
||||||
|
html: wrap(`
|
||||||
|
<p>Dear User,</p>
|
||||||
|
<p>Use the One-Time Password (OTP) below to confirm this sign-in. This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
|
||||||
|
<p style="font-size: 28px; font-weight: bold; letter-spacing: 6px;">${otp}</p>
|
||||||
|
<p>For security reasons, please do not share this code with anyone. If you did not attempt to log in, please contact the administrator.</p>
|
||||||
|
`),
|
||||||
|
}),
|
||||||
|
|
||||||
|
RESET_PASSWORD_OTP: ({ otp, expiryMinutes = 10 }) => ({
|
||||||
|
subject: "Password Reset Code - STARR System",
|
||||||
|
html: wrap(`
|
||||||
|
<p>Dear User,</p>
|
||||||
|
<p>Use the One-Time Password (OTP) below to reset your account password. This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
|
||||||
|
<p style="font-size: 28px; font-weight: bold; letter-spacing: 6px;">${otp}</p>
|
||||||
|
<p>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.</p>
|
||||||
|
`),
|
||||||
|
}),
|
||||||
|
|
||||||
WELCOME: ({ name }) => ({
|
WELCOME: ({ name }) => ({
|
||||||
subject: "Welcome to STARR System",
|
subject: "Welcome to STARR System",
|
||||||
html: wrap(`
|
html: wrap(`
|
||||||
|
|||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
const { verifyAccessToken } = require('../utils/token.util');
|
const { verifyAccessToken } = require('../utils/token.util');
|
||||||
const mdl_Users = require('../models/users/users.mdl');
|
const mdl_Users = require('../models/users/users.mdl');
|
||||||
const R = require('../utils/response.util');
|
const R = require('../utils/response.util');
|
||||||
|
const { checkAccountStatus } = require('../services/accountStatus.service');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates JWT and loads user from DB.
|
* Validates JWT and loads user from DB.
|
||||||
@@ -34,19 +35,15 @@ const authenticate = async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return R.error(res, 'User not found.', 401);
|
if (!user) return R.error(res, 'User not found.', 401);
|
||||||
if (!user.is_active) return R.error(res, 'Account is deactivated.', 403);
|
|
||||||
|
|
||||||
if (user.is_banned) {
|
const status = await checkAccountStatus(user);
|
||||||
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
|
if (!status.ok) {
|
||||||
if (stillBanned) {
|
if (status.code === 'deactivated') return R.error(res, 'Account is deactivated.', 403);
|
||||||
return R.error(res, 'Your account has been suspended.', 403, {
|
return R.error(res, 'Your account has been suspended.', 403, {
|
||||||
banned: true,
|
banned: true,
|
||||||
ban_expires_at: user.ban_expires_at ?? null,
|
ban_expires_at: status.ban_expires_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Expired temporary ban — auto-lift so the user can log in again
|
|
||||||
await user.update({ is_banned: false, ban_expires_at: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
req.user = user;
|
req.user = user;
|
||||||
next();
|
next();
|
||||||
@@ -86,19 +83,16 @@ const softAuthenticate = async (req, res, next) => {
|
|||||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.is_active) {
|
if (!user) {
|
||||||
req.user = null;
|
req.user = null;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.is_banned) {
|
const status = await checkAccountStatus(user);
|
||||||
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
|
if (!status.ok) {
|
||||||
if (stillBanned) {
|
|
||||||
req.user = null;
|
req.user = null;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
await user.update({ is_banned: false, ban_expires_at: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
req.user = user;
|
req.user = user;
|
||||||
next();
|
next();
|
||||||
|
|||||||
@@ -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;
|
||||||
+17
-6
@@ -6,18 +6,26 @@
|
|||||||
*
|
*
|
||||||
* Route Map:
|
* Route Map:
|
||||||
* GET /api/auth/csrf-token → get CSRF token (for cookie-based clients)
|
* GET /api/auth/csrf-token → get CSRF token (for cookie-based clients)
|
||||||
* POST /api/auth/register → system registration
|
* POST /api/auth/register → system registration (sends OTP)
|
||||||
* POST /api/auth/verify-otp → OTP verification + auto-login
|
* POST /api/auth/verify-otp → verifies OTP, mints tokens/session — the
|
||||||
* POST /api/auth/resend-otp → resend OTP email
|
* single endpoint every auth path funnels
|
||||||
* POST /api/auth/login → system login
|
* 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/refresh → refresh access token
|
||||||
* POST /api/auth/logout → logout (requires authenticate)
|
* POST /api/auth/logout → logout (requires authenticate)
|
||||||
* POST /api/auth/change-password → change password (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 → 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
|
* Author: rgrgogu
|
||||||
* Date Created: Oct. 6, 2025
|
* 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 express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -30,6 +38,7 @@ const { validate } = require('../middleware/validate.middleware');
|
|||||||
const {
|
const {
|
||||||
registerValidator, loginValidator,
|
registerValidator, loginValidator,
|
||||||
verifyOTPValidator, resendOTPValidator, changePassValidator,
|
verifyOTPValidator, resendOTPValidator, changePassValidator,
|
||||||
|
forgotPasswordValidator, resetPasswordValidator,
|
||||||
} = require('../validators/auth.validator');
|
} = require('../validators/auth.validator');
|
||||||
|
|
||||||
// ── CSRF token (GET — no CSRF needed on GETs) ──────────────────────────────────
|
// ── 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('/register', ...registerValidator, validate, authCtrl.register);
|
||||||
router.post('/verify-otp', otpLimiter, ...verifyOTPValidator, validate, authCtrl.verifyOTP);
|
router.post('/verify-otp', otpLimiter, ...verifyOTPValidator, validate, authCtrl.verifyOTP);
|
||||||
router.post('/resend-otp', otpLimiter, ...resendOTPValidator, validate, authCtrl.resendOTP);
|
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('/refresh', authLimiter, authCtrl.refreshToken);
|
||||||
router.post('/logout', authenticate, authLimiter, authCtrl.logout);
|
router.post('/logout', authenticate, authLimiter, authCtrl.logout);
|
||||||
router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword);
|
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 ────────────────────────────────────────────────────────────────
|
// ── Google OIDC ────────────────────────────────────────────────────────────────
|
||||||
router.get('/google', authLimiter, authCtrl.googleRedirect);
|
router.get('/google', authLimiter, authCtrl.googleRedirect);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ router.get('/active', controller.getActiveAdvertisement);
|
|||||||
// ─── GET /api/client/advertisements/active-batch?placements=a,b,c ─────────────
|
// ─── GET /api/client/advertisements/active-batch?placements=a,b,c ─────────────
|
||||||
router.get('/active-batch', controller.getActiveAdvertisements);
|
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 ───────────────────
|
// ─── POST /api/client/advertisements/:advertisementId/click ───────────────────
|
||||||
router.post('/:advertisementId/click', controller.trackClick);
|
router.post('/:advertisementId/click', controller.trackClick);
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
+88
-19
@@ -6,12 +6,14 @@
|
|||||||
// deleteFile(key)
|
// deleteFile(key)
|
||||||
//
|
//
|
||||||
// Required .env vars:
|
// 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_REGION – garage
|
||||||
// S3_ACCESS_KEY
|
// S3_ACCESS_KEY
|
||||||
// S3_SECRET_KEY
|
// S3_SECRET_KEY
|
||||||
// S3_BUCKET – your-bucket-name
|
// 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 { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3");
|
||||||
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
||||||
@@ -26,6 +28,8 @@ const credentials = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Internal client — uploads, deletes, direct streams from the server itself.
|
// 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({
|
const s3 = new S3Client({
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
region: process.env.S3_REGION || "garage",
|
region: process.env.S3_REGION || "garage",
|
||||||
@@ -33,19 +37,80 @@ const s3 = new S3Client({
|
|||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public client — generates pre-signed URLs using the externally reachable
|
const DEFAULT_BUCKET = process.env.S3_BUCKET;
|
||||||
// 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
|
// ─── Public host resolution ───────────────────────────────────────────────────
|
||||||
// unset (single-machine dev).
|
//
|
||||||
const s3Public = new S3Client({
|
// URLs handed to browsers (file_url, presigned GET links) need a host reachable
|
||||||
endpoint: process.env.S3_PUBLIC_URL ?? process.env.S3_ENDPOINT,
|
// 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",
|
region: process.env.S3_REGION || "garage",
|
||||||
credentials,
|
credentials,
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
});
|
});
|
||||||
|
await Promise.race([
|
||||||
|
probe.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET })),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_BUCKET = process.env.S3_BUCKET;
|
async function refreshHostCache() {
|
||||||
const PUBLIC_URL = (process.env.S3_PUBLIC_URL || "").replace(/\/$/, "");
|
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 ───────────────────────────────────────────────────────────
|
// ─── Key prefix map ───────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -83,10 +148,11 @@ function buildKey(originalname, ownerType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Builds the public URL for a stored object.
|
// 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
|
// e.g. https://cdn.yourdomain.com/your-bucket/images/uuid.jpg
|
||||||
function buildPublicUrl(key, bucket = DEFAULT_BUCKET) {
|
async function buildPublicUrl(key, bucket = DEFAULT_BUCKET) {
|
||||||
return `${PUBLIC_URL}/${bucket}/${key}`;
|
const host = (await resolvePublicHost()).replace(/\/$/, "");
|
||||||
|
return `${host}/${bucket}/${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── uploadFile ───────────────────────────────────────────────────────────────
|
// ─── uploadFile ───────────────────────────────────────────────────────────────
|
||||||
@@ -111,7 +177,7 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "image"
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: buildPublicUrl(key, bucket),
|
url: await buildPublicUrl(key, bucket),
|
||||||
uuid: key, // used as storage_key in DB — mirrors chibi_uuid usage
|
uuid: key, // used as storage_key in DB — mirrors chibi_uuid usage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -131,13 +197,15 @@ async function deleteFile(key) {
|
|||||||
|
|
||||||
// ─── getSignedDownloadUrl ─────────────────────────────────────────────────────
|
// ─── getSignedDownloadUrl ─────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Generates a short-lived pre-signed GET URL using the public endpoint so the
|
// Generates a short-lived pre-signed GET URL against whichever host
|
||||||
// URL is resolvable from any machine (browser or proxy server), not just the
|
// resolvePublicHost() picks, so the URL is resolvable from wherever the
|
||||||
// one running Garage locally.
|
// request is served (browser or proxy server), not just the one running
|
||||||
|
// Garage locally.
|
||||||
//
|
//
|
||||||
async function getSignedDownloadUrl(key, expiresInSeconds = 3600) {
|
async function getSignedDownloadUrl(key, expiresInSeconds = 3600) {
|
||||||
|
const client = await getPublicClient();
|
||||||
return getSignedUrl(
|
return getSignedUrl(
|
||||||
s3Public,
|
client,
|
||||||
new GetObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key }),
|
new GetObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key }),
|
||||||
{ expiresIn: expiresInSeconds }
|
{ expiresIn: expiresInSeconds }
|
||||||
);
|
);
|
||||||
@@ -169,7 +237,8 @@ async function getObjectStream(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ping() {
|
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 };
|
module.exports = { uploadFile, deleteFile, getSignedDownloadUrl, getObjectStream, ping };
|
||||||
@@ -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<import('../models/users/trusted_devices.mdl')|null>}
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -62,3 +62,5 @@ const buildSessionInfo = async (req, extras = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
module.exports = buildSessionInfo;
|
module.exports = buildSessionInfo;
|
||||||
|
module.exports.parseUA = parseUA;
|
||||||
|
module.exports.getIP = getIP;
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
* - verifyOTPValidator → POST /auth/verify-otp
|
* - verifyOTPValidator → POST /auth/verify-otp
|
||||||
* - resendOTPValidator → POST /auth/resend-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
|
* Author: rgrgogu
|
||||||
* Date Created: Oct. 6, 2025
|
* Date Created: Oct. 6, 2025
|
||||||
|
* Date Modified: Jul. 4, 2026 — forgot/reset password (Kenneth Obsequio)
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const { body } = require('express-validator');
|
const { body } = require('express-validator');
|
||||||
|
|
||||||
@@ -43,4 +46,21 @@ const changePassValidator = [
|
|||||||
.matches(/[0-9]/).withMessage('Must contain a number.'),
|
.matches(/[0-9]/).withMessage('Must contain a number.'),
|
||||||
];
|
];
|
||||||
|
|
||||||
module.exports = { registerValidator, loginValidator, verifyOTPValidator, resendOTPValidator, changePassValidator };
|
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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user