mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
testing 127.0.0.1 issue
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
+126
-35
@@ -71,6 +71,24 @@ const setRefreshCookie = (res, refreshToken) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 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',
|
||||
sameSite: '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.
|
||||
@@ -390,19 +408,24 @@ exports.googleCallback = async (req, res) => {
|
||||
const { code, state, error } = req.query;
|
||||
|
||||
if (error) {
|
||||
return res.redirect(`${CALLBACK_PAGE}?error=${encodeURIComponent(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) return res.redirect(`${CALLBACK_PAGE}?error=session_expired`);
|
||||
if (!rawCookie) {
|
||||
setGoogleResultCookie(res, { error: 'session_expired' });
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
}
|
||||
|
||||
const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie);
|
||||
|
||||
if (!state || state !== expectedState) {
|
||||
return res.redirect(`${CALLBACK_PAGE}?error=state_mismatch`);
|
||||
setGoogleResultCookie(res, { error: 'state_mismatch' });
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
}
|
||||
|
||||
// Exchange authorization code → { id_token, access_token, ... }
|
||||
@@ -467,13 +490,16 @@ exports.googleCallback = async (req, res) => {
|
||||
const status = await checkAccountStatus(user);
|
||||
if (!status.ok) {
|
||||
if (status.code === 'deactivated') {
|
||||
return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`);
|
||||
setGoogleResultCookie(res, { error: 'account_deactivated' });
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
}
|
||||
const params = new URLSearchParams({ error: 'account_banned' });
|
||||
if (status.reason) params.set('reason', status.reason);
|
||||
if (status.ban_type) params.set('ban_type', status.ban_type);
|
||||
if (status.ban_expires_at) params.set('expires_at', new Date(status.ban_expires_at).toISOString());
|
||||
return res.redirect(`${CALLBACK_PAGE}?${params.toString()}`);
|
||||
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
|
||||
@@ -492,7 +518,9 @@ exports.googleCallback = async (req, res) => {
|
||||
|
||||
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
|
||||
|
||||
return res.redirect(`${CALLBACK_PAGE}?otpRequired=false`);
|
||||
// No cookie needed here — the refresh cookie set above is itself the
|
||||
// signal. The frontend just calls restoreSession() and it succeeds.
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
}
|
||||
|
||||
const otp = generateOTP();
|
||||
@@ -501,10 +529,29 @@ exports.googleCallback = async (req, res) => {
|
||||
sendEmail({ to: user.email, type: 'LOGIN_OTP', data: { otp } })
|
||||
.catch(err => console.error('[AUTH] googleCallback: Failed to send login OTP email:', err));
|
||||
|
||||
return res.redirect(`${CALLBACK_PAGE}?otpRequired=true&email=${encodeURIComponent(user.email)}`);
|
||||
setGoogleResultCookie(res, { otpRequired: true, email: user.email });
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
} catch (err) {
|
||||
console.error('[AUTH] googleCallback OIDC error:', err);
|
||||
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google?error=auth_failed`);
|
||||
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.', {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -563,11 +610,17 @@ exports.logout = async (req, res) => {
|
||||
|
||||
logActivity(req.user.user_id, 'logout', { entityType: 'session', entityId: session_id ? Number(session_id) : null });
|
||||
|
||||
await trustedDevice.revokeByToken(req.user.user_id, req.cookies[trustedDevice.COOKIE_NAME]);
|
||||
// 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')
|
||||
res.clearCookie(trustedDevice.COOKIE_NAME)
|
||||
|
||||
return R.success(res, 'Logged out successfully.');
|
||||
} catch (err) {
|
||||
@@ -606,47 +659,85 @@ exports.changePassword = async (req, res) => {
|
||||
|
||||
// ─── 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 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 genericMessage = 'If an account exists for this email, a reset code has been sent.';
|
||||
|
||||
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 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 },
|
||||
);
|
||||
}
|
||||
|
||||
const otp = generateOTP();
|
||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
||||
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));
|
||||
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 });
|
||||
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 — Reset with OTP ──────────────────────────────────────────
|
||||
exports.resetPassword = async (req, res) => {
|
||||
// ─── 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, new_password } = req.body;
|
||||
const { email, otp } = req.body;
|
||||
const genericError = 'Invalid or expired code.';
|
||||
|
||||
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);
|
||||
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, 'Invalid OTP.', 400);
|
||||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user