mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
815 lines
37 KiB
JavaScript
815 lines
37 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
||
* File Name: auth.controller.js
|
||
* Type of Program: Controller
|
||
* Description: Handles all authentication flows. Every credential path — system
|
||
* registration, system login, and Google OAuth — funnels through
|
||
* the same OTP gate before tokens are issued:
|
||
* 1. System Registration → OTP email → verifyOTP (first-time) → tokens
|
||
* 2. System Login → OTP email → verifyOTP (routine) → tokens
|
||
* 3. Google OAuth callback → OTP email → verifyOTP (either) → tokens
|
||
* 4. Token Refresh
|
||
* 5. Logout (invalidates session)
|
||
* 6. OTP Resend (only valid while an OTP is pending)
|
||
* 7. Change Password
|
||
*
|
||
* verifyOTP is the single place tokens/sessions are minted — it
|
||
* branches on the user's is_verified flag *before* the update to
|
||
* decide whether this is a first-time pass (fires welcome email/
|
||
* achievements/notifications) or a routine login OTP (skips them).
|
||
*
|
||
* Author: rgrgogu
|
||
* Date Created: Oct. 6, 2025
|
||
* Date Modified: Jul. 4, 2026 — mandatory OTP on every login (Kenneth Obsequio)
|
||
***********************************************************************************************************************************************************************
|
||
* HOW TO USE:
|
||
* Mount via routes/auth.routes.js
|
||
* POST /api/auth/register
|
||
* POST /api/auth/verify-otp
|
||
* POST /api/auth/resend-otp
|
||
* POST /api/auth/login
|
||
* POST /api/auth/refresh
|
||
* POST /api/auth/logout
|
||
* GET /api/auth/google
|
||
* GET /api/auth/google/callback
|
||
***********************************************************************************************************************************************************************/
|
||
const bcrypt = require('bcryptjs');
|
||
const crypto = require('crypto');
|
||
const sequelize = require('../config/db.config')
|
||
const mdl_Users = require('../models/users/users.mdl');
|
||
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
|
||
const { checkAccountStatus } = require('../services/accountStatus.service');
|
||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl')
|
||
const { NOGRP_CODE, getDefaultGroupId, enrollDefaultGroup } = require('../utils/defaultGroup.util');
|
||
const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util');
|
||
const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.util');
|
||
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
|
||
const { onUserRegistered } = require('../services/achievements.service');
|
||
const AdminNotification = require('../models/notifications/admin_notification.mdl');
|
||
const UserNotification = require('../models/notifications/user_notification.mdl');
|
||
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
|
||
const { sendEmail } = require('../services/email.service');
|
||
const buildSessionInfo = require('../utils/session_info.util');
|
||
const logActivity = require('../utils/logActivity.util');
|
||
const trustedDevice = require('../services/trustedDevice.service');
|
||
const R = require('../utils/response.util');
|
||
|
||
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy'];
|
||
|
||
const safeUser = (user, extraExclude = []) => {
|
||
const u = user.toJSON ? user.toJSON() : { ...user };
|
||
|
||
[...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]);
|
||
|
||
return u;
|
||
};
|
||
|
||
const setRefreshCookie = (res, refreshToken) => {
|
||
res.cookie('refreshToken', refreshToken, {
|
||
httpOnly: true, // ← JS cannot read this
|
||
secure: process.env.NODE_ENV === 'production',
|
||
// 'none' (not 'strict') in production — frontend (Vercel) and this API
|
||
// (Render) are cross-site, so 'strict'/'lax' silently drop the cookie on
|
||
// every fetch/XHR refresh call. 'none' requires secure:true (set above).
|
||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||
});
|
||
};
|
||
|
||
// The Google OAuth callback can't carry its outcome (otpRequired, ban details,
|
||
// errors) as a query string on the redirect without flashing it in the address
|
||
// bar — the browser lands on that literal URL before any frontend JS runs, so
|
||
// client-side scrubbing is always at least a frame too late. Instead, the
|
||
// outcome is stashed in a short-lived signed cookie and handed to the frontend
|
||
// only when it explicitly asks for it via GET /auth/google/result.
|
||
const GOOGLE_RESULT_COOKIE = '_googleAuthResult';
|
||
|
||
const setGoogleResultCookie = (res, payload) => {
|
||
res.cookie(GOOGLE_RESULT_COOKIE, JSON.stringify(payload), {
|
||
httpOnly: true,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
// Set right after a top-level redirect (survives 'lax'), but read back via
|
||
// a cross-site fetch from GET /auth/google/result — 'lax' drops it there
|
||
// in production since frontend (Vercel) and this API (Render) are cross-site.
|
||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||
maxAge: 2 * 60 * 1000, // 2 minutes — just long enough for the callback redirect to land
|
||
signed: true,
|
||
});
|
||
};
|
||
|
||
// ─── Mint Session ──────────────────────────────────────────────────────────────
|
||
// Shared by verifyOTP and the trusted-device fast path in login/googleCallback —
|
||
// the only two places tokens/sessions get minted.
|
||
const mintSession = async (req, user, { transaction } = {}) => {
|
||
const wasVerified = user.is_verified;
|
||
|
||
await user.update({ is_verified: true, otp_code: null, otp_expires_at: null }, { transaction });
|
||
|
||
const { accessToken, refreshToken } = generateTokens(user);
|
||
const session = await mdl_UserSessions.create({
|
||
user_id: user.user_id,
|
||
login_info: await buildSessionInfo(req),
|
||
refresh_token_hash: hashToken(refreshToken),
|
||
is_active: true,
|
||
}, { transaction });
|
||
|
||
return { accessToken, refreshToken, session, wasVerified };
|
||
};
|
||
|
||
// ─── Register ──────────────────────────────────────────────────────────────────
|
||
exports.register = async (req, res) => {
|
||
const transaction = await sequelize.transaction();
|
||
|
||
try {
|
||
const { email, password, personal_info, group_code, confirm_resume } = req.body;
|
||
|
||
// ── Duplicate check ───────────────────────────────────────────────────────
|
||
// A verified account owns the email outright — hard block. An unverified
|
||
// one is just an abandoned attempt (e.g. dropped connection before the OTP
|
||
// step completed, or the client retried after a failed send) — the same
|
||
// person retrying should be able to resume it rather than dead-end here.
|
||
// The client must explicitly confirm_resume (after the user accepts a
|
||
// confirmation dialog) before we overwrite that abandoned attempt's data.
|
||
const existing = await mdl_Users.findOne({ where: { email } });
|
||
if (existing) {
|
||
if (existing.is_verified) {
|
||
await transaction.rollback();
|
||
return R.error(res, 'Email is already registered.', 409);
|
||
}
|
||
if (existing.reg_type !== 'system') {
|
||
await transaction.rollback();
|
||
return R.error(res, 'This email is linked to a Google account. Please sign in with Google instead.', 409, { google: true });
|
||
}
|
||
|
||
if (!confirm_resume) {
|
||
await transaction.rollback();
|
||
return R.error(
|
||
res,
|
||
'An account with this email already has a pending verification. Resend the code and continue?',
|
||
409,
|
||
{ pendingVerification: true },
|
||
);
|
||
}
|
||
|
||
const hashed = await bcrypt.hash(password, 12);
|
||
const otp = generateOTP();
|
||
|
||
await existing.update({
|
||
password: hashed,
|
||
personal_info: personal_info ?? existing.personal_info,
|
||
otp_code: otp,
|
||
otp_expires_at: getOTPExpiry(),
|
||
}, { transaction });
|
||
|
||
await transaction.commit();
|
||
|
||
sendEmail({ to: email, type: 'OTP', data: { otp } })
|
||
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||
|
||
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
|
||
email: existing.email,
|
||
}, 201);
|
||
}
|
||
|
||
// ── Validate group_code if provided ───────────────────────────────────────
|
||
let group = null;
|
||
if (group_code) {
|
||
group = await mdl_UserGroups.findOne({
|
||
where: { group_code: group_code.toUpperCase().trim(), is_active: true },
|
||
});
|
||
if (!group) return R.error(res, 'Invalid or inactive group code.', 400);
|
||
}
|
||
|
||
// ── Resolve enroll target (explicit group or NOGRP fallback) ──────────────
|
||
const enrollGroupId = group?.group_id ?? await getDefaultGroupId();
|
||
|
||
// ── Create user ───────────────────────────────────────────────────────────
|
||
const hashed = await bcrypt.hash(password, 12);
|
||
const otp = generateOTP();
|
||
|
||
const user = await mdl_Users.create({
|
||
email,
|
||
password: hashed,
|
||
otp_code: otp,
|
||
otp_expires_at: getOTPExpiry(),
|
||
is_active: true,
|
||
is_verified: false,
|
||
reg_type: 'system',
|
||
acc_type: 'user',
|
||
personal_info: personal_info ?? null,
|
||
// System registration's Personal Info step already collects the exact
|
||
// fields the /intro flow asks for (name, birthday, occupation, phone) —
|
||
// Google signups only get name+avatar from the ID token and still need it.
|
||
needs_intro: false,
|
||
createdBy: null,
|
||
}, { transaction });
|
||
|
||
// ── Enroll into group ─────────────────────────────────────────────────────
|
||
if (enrollGroupId) {
|
||
await mdl_UserGroupMembers.create({
|
||
group_id: enrollGroupId,
|
||
user_id: user.user_id,
|
||
createdBy: null,
|
||
}, { transaction });
|
||
}
|
||
|
||
await transaction.commit();
|
||
|
||
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
|
||
// an otherwise-successful registration — resendOTP covers redelivery.
|
||
sendEmail({ to: email, type: 'OTP', data: { otp } })
|
||
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||
|
||
// Fire-and-forget: notify admins — explicit group or NOGRP fallback
|
||
if (group) {
|
||
AdminNotification.create(NOTIFICATION_REGISTRY.user_registration.build({
|
||
groupName: group.name,
|
||
groupCode: group.group_code,
|
||
userEmail: email,
|
||
}))
|
||
.catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err));
|
||
} else if (enrollGroup) {
|
||
AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({
|
||
userEmail: email,
|
||
regType: 'system',
|
||
}))
|
||
.catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err));
|
||
}
|
||
|
||
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
|
||
email: user.email,
|
||
}, 201);
|
||
} catch (err) {
|
||
await transaction.rollback();
|
||
console.error('[AUTH] register error:', err);
|
||
return R.error(res, 'Registration failed.', 500);
|
||
}
|
||
};
|
||
|
||
// ─── Verify OTP ────────────────────────────────────────────────────────────────
|
||
exports.verifyOTP = async (req, res) => {
|
||
const transaction = await sequelize.transaction();
|
||
|
||
try {
|
||
const { email, otp } = req.body;
|
||
|
||
const user = await mdl_Users.findOne({ where: { email } });
|
||
if (!user) return R.error(res, 'User not found.', 404);
|
||
|
||
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||
const givenOTP = Buffer.from(otp ?? '');
|
||
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||
return R.error(res, 'Invalid OTP.', 400);
|
||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
|
||
|
||
// wasVerified (captured inside mintSession, before its update) tells us
|
||
// whether this is the account's very first OTP pass (system registration
|
||
// or first-ever Google login) or a routine login OTP — only the former
|
||
// fires the welcome/achievements bundle.
|
||
const { accessToken, refreshToken, session, wasVerified } = await mintSession(req, user, { transaction });
|
||
|
||
await transaction.commit();
|
||
|
||
// Fire-and-forget: activity log
|
||
logActivity(user.user_id, wasVerified ? 'login' : 'register', { entityType: 'session', entityId: Number(session.session_id) });
|
||
|
||
if (!wasVerified) {
|
||
// Fire-and-forget: achievements, welcome email, notification (do not block the response)
|
||
onUserRegistered(user.user_id)
|
||
.catch(err => console.error('[AUTH] Failed to grant achievements:', err));
|
||
|
||
sendEmail({ to: email, type: "WELCOME", data: { name: email } })
|
||
.catch(err => console.error('[AUTH] Failed to send welcome email:', err));
|
||
|
||
mdl_UserGroupMembers.findOne({
|
||
where: { user_id: user.user_id },
|
||
include: [{ model: mdl_UserGroups, attributes: ['name', 'group_code'] }],
|
||
}).then(async membership => {
|
||
const grp = membership?.UserGroup;
|
||
const now = new Date();
|
||
const notifications = [
|
||
{
|
||
user_id: user.user_id,
|
||
...NOTIFICATION_REGISTRY.welcome.build({
|
||
groupName: grp?.name ?? null,
|
||
groupCode: grp?.group_code ?? null,
|
||
accType: user.acc_type,
|
||
groupId: membership?.group_id ?? null,
|
||
}),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
},
|
||
];
|
||
if (grp?.group_code === NOGRP_CODE) {
|
||
notifications.push({
|
||
user_id: user.user_id,
|
||
...NOTIFICATION_REGISTRY.nogrp_welcome.build(),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
});
|
||
}
|
||
return UserNotification.bulkCreate(notifications, { validate: false });
|
||
}).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
|
||
}
|
||
|
||
// Clearing an OTP is what marks a device trusted going forward — this is
|
||
// the only place trust is first granted (the login/googleCallback fast
|
||
// path only ever rolls an existing trust window forward).
|
||
await trustedDevice.issueOrRefresh(res, user.user_id, trustedDevice.getFingerprintHash(req), session.session_id);
|
||
|
||
setRefreshCookie(res, refreshToken);
|
||
|
||
return R.success(res, wasVerified ? 'Login successful.' : 'Email verified successfully. You are now logged in.', {
|
||
accessToken,
|
||
session_id: session.session_id,
|
||
user: safeUser(user),
|
||
});
|
||
} catch (err) {
|
||
await transaction.rollback();
|
||
console.error('[AUTH] verifyOTP error:', err);
|
||
return R.error(res, 'OTP verification failed.', 500);
|
||
}
|
||
};
|
||
|
||
// ─── Resend OTP ────────────────────────────────────────────────────────────────
|
||
exports.resendOTP = async (req, res) => {
|
||
const transaction = await sequelize.transaction();
|
||
|
||
try {
|
||
const { email } = req.body;
|
||
const user = await mdl_Users.findOne({ where: { email } });
|
||
if (!user) return R.error(res, 'User not found.', 404);
|
||
// Only a resend, never a first send — otp_code is only ever populated by
|
||
// register/login/googleCallback, each of which already proved credential
|
||
// ownership. Without this guard, resendOTP would let anyone force a fresh
|
||
// OTP for an arbitrary verified account without ever knowing its password.
|
||
if (!user.otp_code) return R.error(res, 'No pending verification. Please log in again.', 400);
|
||
|
||
const otp = generateOTP();
|
||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction });
|
||
|
||
await transaction.commit();
|
||
|
||
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
|
||
// the already-persisted OTP refresh.
|
||
sendEmail({ to: email, type: user.is_verified ? 'LOGIN_OTP' : 'OTP', data: { otp } })
|
||
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||
|
||
return R.success(res, 'A new OTP has been sent to your email.');
|
||
} catch (err) {
|
||
await transaction.rollback();
|
||
console.error('[AUTH] resendOTP error:', err);
|
||
return R.error(res, 'Could not resend OTP.', 500);
|
||
}
|
||
};
|
||
|
||
// ─── System Login ──────────────────────────────────────────────────────────────
|
||
exports.login = async (req, res) => {
|
||
try {
|
||
const { email, password } = req.body;
|
||
|
||
const user = await mdl_Users.findOne({ where: { email } });
|
||
if (!user) return R.error(res, 'Invalid credentials.', 401);
|
||
if (user.reg_type === 'google') return R.error(res, 'Please log in with Google.', 400);
|
||
|
||
const status = await checkAccountStatus(user);
|
||
if (!status.ok) {
|
||
if (status.code === 'deactivated') return R.error(res, 'Account is deactivated.', 403);
|
||
return R.error(res, 'Your account has been suspended.', 403, {
|
||
banned: true,
|
||
reason: status.reason,
|
||
ban_type: status.ban_type,
|
||
ban_expires_at: status.ban_expires_at,
|
||
});
|
||
}
|
||
|
||
const match = await bcrypt.compare(password, user.password);
|
||
if (!match) return R.error(res, 'Invalid credentials.', 401);
|
||
|
||
// Password confirmed. If this device already cleared an OTP recently and
|
||
// its trust window hasn't lapsed or been revoked, skip the OTP gate
|
||
// entirely — otherwise fall through to the usual fresh-OTP flow. Tokens
|
||
// are only ever minted via mintSession (called here or from verifyOTP).
|
||
const fingerprintHash = trustedDevice.getFingerprintHash(req);
|
||
const trusted = await trustedDevice.findValid(user.user_id, req.cookies[trustedDevice.COOKIE_NAME], fingerprintHash);
|
||
|
||
if (trusted) {
|
||
const { accessToken, refreshToken, session } = await mintSession(req, user, {});
|
||
await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
|
||
setRefreshCookie(res, refreshToken);
|
||
|
||
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
|
||
|
||
return R.success(res, 'Login successful.', {
|
||
otpRequired: false,
|
||
accessToken,
|
||
session_id: session.session_id,
|
||
user: safeUser(user),
|
||
});
|
||
}
|
||
|
||
const otp = generateOTP();
|
||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
||
|
||
sendEmail({ to: email, type: 'LOGIN_OTP', data: { otp } })
|
||
.catch(err => console.error('[AUTH] Failed to send login OTP email:', err));
|
||
|
||
return R.success(res, 'OTP sent to your email. Please verify to complete login.', {
|
||
otpRequired: true,
|
||
email: user.email,
|
||
});
|
||
} catch (err) {
|
||
console.error('[AUTH] login error:', err);
|
||
return R.error(res, 'Login failed.', 500);
|
||
}
|
||
};
|
||
|
||
// ─── Google OIDC — Redirect ────────────────────────────────────────────────────
|
||
// Generates state, nonce, and PKCE verifier, stores them in a signed httpOnly
|
||
// cookie, then redirects the browser to Google's authorization endpoint.
|
||
exports.googleRedirect = (req, res) => {
|
||
const state = generateState();
|
||
const nonce = generateNonce();
|
||
const { codeVerifier, codeChallenge } = generatePKCE();
|
||
|
||
// SameSite=Lax is required: the cookie must survive the cross-site redirect
|
||
// back from Google (top-level GET navigations are allowed under Lax).
|
||
res.cookie('_oauth', JSON.stringify({ state, nonce, codeVerifier }), {
|
||
httpOnly: true,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
sameSite: 'lax',
|
||
maxAge: 10 * 60 * 1000, // 10 minutes — enough time to complete the flow
|
||
signed: true,
|
||
});
|
||
|
||
return res.redirect(buildAuthUrl(state, nonce, codeChallenge));
|
||
};
|
||
|
||
// ─── Google OIDC — Callback ────────────────────────────────────────────────────
|
||
// Verifies state (CSRF), exchanges the authorization code, verifies the ID token
|
||
// (signature + nonce), finds or creates the user, sets the refresh cookie, then
|
||
// redirects the browser to the frontend callback page.
|
||
exports.googleCallback = async (req, res) => {
|
||
const FRONTEND_URL = process.env.FRONTEND_URL;
|
||
const CALLBACK_PAGE = `${FRONTEND_URL}/auth/callback/google`;
|
||
|
||
try {
|
||
const { code, state, error } = req.query;
|
||
|
||
if (error) {
|
||
setGoogleResultCookie(res, { error });
|
||
return res.redirect(CALLBACK_PAGE);
|
||
}
|
||
|
||
// Read and immediately clear the oauth state cookie.
|
||
const rawCookie = req.signedCookies['_oauth'];
|
||
res.clearCookie('_oauth');
|
||
|
||
if (!rawCookie) {
|
||
setGoogleResultCookie(res, { error: 'session_expired' });
|
||
return res.redirect(CALLBACK_PAGE);
|
||
}
|
||
|
||
const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie);
|
||
|
||
if (!state || state !== expectedState) {
|
||
setGoogleResultCookie(res, { error: 'state_mismatch' });
|
||
return res.redirect(CALLBACK_PAGE);
|
||
}
|
||
|
||
// Exchange authorization code → { id_token, access_token, ... }
|
||
const tokens = await exchangeCode(code, codeVerifier);
|
||
|
||
// Verify ID token signature, audience, expiry, and nonce.
|
||
const payload = await verifyIdToken(tokens.id_token, nonce);
|
||
|
||
// Find or auto-create the user.
|
||
// Use paranoid:false so soft-deleted rows are visible — if one is blocking the email slot, free it first.
|
||
let user = await mdl_Users.findOne({ where: { email: payload.email }, paranoid: false });
|
||
if (user?.deletedAt) {
|
||
await user.update({ email: `deleted_${user.user_id}@deleted.invalid` });
|
||
user = null;
|
||
}
|
||
|
||
// A system (manual) registration signing in with Google for the first time
|
||
// gets folded into that same account rather than blocked or duplicated.
|
||
// From this point on the account is Google-only — mirrors the existing
|
||
// rule that blocks Google accounts from manual login/password reset.
|
||
let justLinkedGoogle = false;
|
||
if (user && user.reg_type === 'system') {
|
||
justLinkedGoogle = true;
|
||
await user.update({ reg_type: 'google' });
|
||
}
|
||
|
||
if (!user) {
|
||
const t = await sequelize.transaction();
|
||
try {
|
||
user = await mdl_Users.create({
|
||
email: payload.email,
|
||
reg_type: 'google',
|
||
acc_type: 'user',
|
||
is_active: true,
|
||
is_verified: false,
|
||
needs_intro: true,
|
||
personal_info: {
|
||
name: {
|
||
given_name: payload.given_name ?? '',
|
||
last_name: payload.family_name ?? '',
|
||
full_name: payload.name ?? '',
|
||
},
|
||
avatar: { url: payload.picture ?? null },
|
||
},
|
||
}, { transaction: t });
|
||
|
||
await enrollDefaultGroup(user.user_id, { transaction: t });
|
||
|
||
await t.commit();
|
||
|
||
// Welcome email/achievements/welcome-notification are deferred to
|
||
// verifyOTP's first-time branch now (this account isn't verified yet —
|
||
// it still has to complete the same OTP gate as a system registration).
|
||
AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({
|
||
userEmail: payload.email,
|
||
regType: 'google',
|
||
}))
|
||
.catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err));
|
||
} catch (err) {
|
||
await t.rollback();
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
const status = await checkAccountStatus(user);
|
||
if (!status.ok) {
|
||
if (status.code === 'deactivated') {
|
||
setGoogleResultCookie(res, { error: 'account_deactivated' });
|
||
return res.redirect(CALLBACK_PAGE);
|
||
}
|
||
setGoogleResultCookie(res, {
|
||
error: 'account_banned',
|
||
reason: status.reason ?? null,
|
||
ban_type: status.ban_type ?? null,
|
||
expires_at: status.ban_expires_at ? new Date(status.ban_expires_at).toISOString() : null,
|
||
});
|
||
return res.redirect(CALLBACK_PAGE);
|
||
}
|
||
|
||
// Every Google sign-in (new or returning account) still has to clear the
|
||
// same OTP gate as a manual login, unless this device already cleared one
|
||
// recently and its trust window hasn't lapsed or been revoked — same
|
||
// fast path as the manual login controller. A brand-new Google account
|
||
// can never have a trusted device yet, so this naturally falls through
|
||
// to the OTP branch below for first-time sign-ins.
|
||
const fingerprintHash = trustedDevice.getFingerprintHash(req);
|
||
const trusted = await trustedDevice.findValid(user.user_id, req.cookies[trustedDevice.COOKIE_NAME], fingerprintHash);
|
||
|
||
if (trusted) {
|
||
const { refreshToken, session } = await mintSession(req, user, {});
|
||
await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
|
||
setRefreshCookie(res, refreshToken);
|
||
|
||
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
|
||
|
||
// Normally no cookie is needed here — the refresh cookie set above is
|
||
// itself the signal, and the frontend just calls restoreSession(). The
|
||
// one exception is the just-linked flag, which restoreSession() has no
|
||
// way to surface on its own.
|
||
if (justLinkedGoogle) {
|
||
setGoogleResultCookie(res, { justLinkedGoogle: true });
|
||
}
|
||
return res.redirect(CALLBACK_PAGE);
|
||
}
|
||
|
||
const otp = generateOTP();
|
||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
||
|
||
sendEmail({ to: user.email, type: 'LOGIN_OTP', data: { otp } })
|
||
.catch(err => console.error('[AUTH] googleCallback: Failed to send login OTP email:', err));
|
||
|
||
setGoogleResultCookie(res, { otpRequired: true, email: user.email, justLinkedGoogle });
|
||
return res.redirect(CALLBACK_PAGE);
|
||
} catch (err) {
|
||
console.error('[AUTH] googleCallback OIDC error:', err);
|
||
setGoogleResultCookie(res, { error: 'auth_failed' });
|
||
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google`);
|
||
}
|
||
};
|
||
|
||
// ─── Google OIDC — Result handoff ─────────────────────────────────────────────
|
||
// Single-use: reads and immediately clears the cookie stashed by googleCallback.
|
||
// Returns {} when nothing is pending (trusted-device path — frontend should
|
||
// just call restoreSession(), since the real refresh cookie was already set).
|
||
exports.googleResult = (req, res) => {
|
||
const raw = req.signedCookies[GOOGLE_RESULT_COOKIE];
|
||
res.clearCookie(GOOGLE_RESULT_COOKIE);
|
||
|
||
if (!raw) return R.success(res, 'No pending Google auth result.', {});
|
||
|
||
try {
|
||
return R.success(res, 'Pending Google auth result.', JSON.parse(raw));
|
||
} catch (_) {
|
||
return R.success(res, 'No pending Google auth result.', {});
|
||
}
|
||
};
|
||
|
||
// ─── Refresh Token ─────────────────────────────────────────────────────────────
|
||
exports.refreshToken = async (req, res) => {
|
||
try {
|
||
const refreshToken = req.cookies.refreshToken;
|
||
if (!refreshToken) return R.error(res, 'Refresh token is required.', 400);
|
||
|
||
const decoded = verifyRefreshToken(refreshToken);
|
||
const tokenHash = hashToken(refreshToken);
|
||
|
||
const session = await mdl_UserSessions.findOne({
|
||
where: { user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true },
|
||
});
|
||
if (!session) return R.error(res, 'Session is invalid or expired. Please log in again.', 401);
|
||
|
||
const user = await mdl_Users.findByPk(decoded.user_id);
|
||
if (!user) return R.error(res, 'User not found or deactivated.', 401);
|
||
|
||
const status = await checkAccountStatus(user);
|
||
if (!status.ok) {
|
||
if (status.code === 'deactivated') return R.error(res, 'User not found or deactivated.', 401);
|
||
return R.error(res, 'Your account has been suspended.', 403, { banned: true });
|
||
}
|
||
|
||
// Check if refresh token is expired
|
||
if (!shouldRotateRefreshToken(decoded)) {
|
||
const { accessToken } = generateTokens(user);
|
||
return R.success(res, 'Token refreshed.', { accessToken, session_id: session.session_id, user: safeUser(user) });
|
||
}
|
||
|
||
// ─── Rotate refresh token ───────────────────────────────────────────────────
|
||
const tokens = generateTokens(user);
|
||
await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) });
|
||
|
||
setRefreshCookie(res, tokens.refreshToken);
|
||
|
||
return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: safeUser(user) });
|
||
} catch (err) {
|
||
console.error('[AUTH] refresh token error:', err);
|
||
return R.error(res, 'Invalid or expired refresh token.', 401);
|
||
}
|
||
};
|
||
|
||
// ─── Logout ────────────────────────────────────────────────────────────────────
|
||
exports.logout = async (req, res) => {
|
||
try {
|
||
const { session_id } = req.body;
|
||
if (session_id) {
|
||
await mdl_UserSessions.update(
|
||
{ is_active: false, logout_info: await buildSessionInfo(req) },
|
||
{ where: { session_id, user_id: req.user.user_id } }
|
||
);
|
||
}
|
||
|
||
logActivity(req.user.user_id, 'logout', { entityType: 'session', entityId: session_id ? Number(session_id) : null });
|
||
|
||
// Ordinary logout intentionally does NOT touch trusted_devices or clear
|
||
// device_trust: expires_at (rolling 30-day window) is what ends the
|
||
// OTP-skip, not the act of logging out. Clearing/revoking here would
|
||
// force OTP on the very next login on the same device, which defeats
|
||
// the point of trusted_devices. Trust is only force-revoked elsewhere
|
||
// for actual security events — password change/reset, admin ban/
|
||
// deactivate/force-logout, or a specific session being terminated
|
||
// (see trustedDevice.service.js: revokeAllForUser / revokeBySessionId).
|
||
|
||
res.clearCookie('refreshToken')
|
||
res.clearCookie('_csrf')
|
||
|
||
return R.success(res, 'Logged out successfully.');
|
||
} catch (err) {
|
||
console.error('[AUTH] logout error:', err);
|
||
return R.error(res, 'Logout failed.', 500);
|
||
}
|
||
};
|
||
|
||
// ─── Change Password ───────────────────────────────────────────────────────────
|
||
exports.changePassword = async (req, res) => {
|
||
try {
|
||
const { current_password, new_password } = req.body;
|
||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||
|
||
if (user.reg_type === 'google')
|
||
return R.error(res, 'Google accounts cannot change passwords here.', 400);
|
||
|
||
const match = await bcrypt.compare(current_password, user.password);
|
||
if (!match) return R.error(res, 'Current password is incorrect.', 400);
|
||
|
||
const hashed = await bcrypt.hash(new_password, 12);
|
||
await user.update({ password: hashed });
|
||
|
||
// Invalidate all sessions to force re-login
|
||
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
|
||
await trustedDevice.revokeAllForUser(user.user_id);
|
||
|
||
logActivity(user.user_id, 'password_change');
|
||
|
||
return R.success(res, 'Password changed. All sessions have been invalidated. Please log in again.');
|
||
} catch (err) {
|
||
console.error('[AUTH] changePassword error:', err);
|
||
return R.error(res, 'Password change failed.', 500);
|
||
}
|
||
};
|
||
|
||
// ─── Forgot Password — Request OTP ─────────────────────────────────────────────
|
||
// Same procedure for every acc_type (admin/staff/user) — only reg_type matters.
|
||
// Response is identical whether the email is unknown or deactivated/suspended —
|
||
// only a real, eligible account actually gets an OTP — EXCEPT for Google-linked
|
||
// accounts, which get an explicit "use Google sign-in" dialog by design (accepted
|
||
// tradeoff: this does reveal that a given email is a Google-linked account).
|
||
exports.forgotPassword = async (req, res) => {
|
||
try {
|
||
const { email } = req.body;
|
||
const genericMessage = 'If an account exists for this email, a reset code has been sent.';
|
||
|
||
const user = await mdl_Users.findOne({ where: { email } });
|
||
|
||
if (user && user.reg_type === 'google') {
|
||
return R.error(
|
||
res,
|
||
'This account was created using Google. Sign in with Google instead — there’s no password to reset for accounts created this way.',
|
||
400,
|
||
{ google: true },
|
||
);
|
||
}
|
||
|
||
if (user) {
|
||
const status = await checkAccountStatus(user);
|
||
if (status.ok) {
|
||
const otp = generateOTP();
|
||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
||
|
||
sendEmail({ to: email, type: 'RESET_PASSWORD_OTP', data: { otp } })
|
||
.catch(err => console.error('[AUTH] Failed to send reset-password OTP email:', err));
|
||
}
|
||
}
|
||
|
||
return R.success(res, genericMessage, { email });
|
||
} catch (err) {
|
||
console.error('[AUTH] forgotPassword error:', err);
|
||
return R.error(res, 'Could not process request.', 500);
|
||
}
|
||
};
|
||
|
||
// ─── Forgot Password — Verify OTP only (step 2 of 3) ───────────────────────────
|
||
// Checks the code without consuming it or touching the password, so the reset
|
||
// flow can gate the "new password" step behind a verified code. resetPassword
|
||
// re-checks the same OTP when the password is actually submitted.
|
||
exports.verifyResetOTP = async (req, res) => {
|
||
try {
|
||
const { email, otp } = req.body;
|
||
const genericError = 'Invalid or expired code.';
|
||
|
||
const user = await mdl_Users.findOne({ where: { email } });
|
||
if (!user || user.reg_type === 'google') return R.error(res, genericError, 400);
|
||
|
||
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||
const givenOTP = Buffer.from(otp ?? '');
|
||
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||
return R.error(res, genericError, 400);
|
||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, genericError, 400);
|
||
|
||
return R.success(res, 'Code verified.');
|
||
} catch (err) {
|
||
console.error('[AUTH] verifyResetOTP error:', err);
|
||
return R.error(res, 'Could not process request.', 500);
|
||
}
|
||
};
|
||
|
||
// ─── Forgot Password — Reset with OTP ──────────────────────────────────────────
|
||
// Same generic error for unknown email / Google-linked / wrong OTP / expired OTP
|
||
// so this endpoint can't be used to enumerate accounts either.
|
||
exports.resetPassword = async (req, res) => {
|
||
try {
|
||
const { email, otp, new_password } = req.body;
|
||
const genericError = 'Invalid or expired code.';
|
||
|
||
const user = await mdl_Users.findOne({ where: { email } });
|
||
if (!user || user.reg_type === 'google') return R.error(res, genericError, 400);
|
||
|
||
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||
const givenOTP = Buffer.from(otp ?? '');
|
||
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||
return R.error(res, genericError, 400);
|
||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, genericError, 400);
|
||
|
||
const hashed = await bcrypt.hash(new_password, 12);
|
||
await user.update({ password: hashed, otp_code: null, otp_expires_at: null });
|
||
|
||
// Invalidate all sessions — same login procedure (credentials → OTP) applies next time
|
||
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
|
||
await trustedDevice.revokeAllForUser(user.user_id);
|
||
|
||
logActivity(user.user_id, 'password_reset');
|
||
|
||
sendEmail({ to: email, type: 'PASSWORD_CHANGED', data: {} })
|
||
.catch(err => console.error('[AUTH] Failed to send password-changed email:', err));
|
||
|
||
return R.success(res, 'Password reset successful. Please log in with your new password.');
|
||
} catch (err) {
|
||
console.error('[AUTH] resetPassword error:', err);
|
||
return R.error(res, 'Password reset failed.', 500);
|
||
}
|
||
}; |