testing 127.0.0.1 issue

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-07 13:39:46 +08:00
parent 095a0d4b3c
commit 062f3b7cfa
11 changed files with 262 additions and 123 deletions
+126 -35
View File
@@ -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 });
@@ -189,8 +189,7 @@ exports.getMyInProgressCourses = async (req, res) => {
if (!pending.length) return R.success(res, 'No courses in progress.', []);
const result = await Promise.all(pending.map(async (row) => {
const courseId = row.course_id;
const readingDone = row.status === 'completed';
const courseId = row.course_id;
const [lessons_total, lessons_completed] = await Promise.all([
Lesson.count({
@@ -207,8 +206,13 @@ exports.getMyInProgressCourses = async (req, res) => {
}),
]);
let pending_quizzes = [];
let pending_assessment = null;
// "Reading done" is derived independently from lesson counts — row.status now also
// requires the course assessment to be passed, so it can't be used as the reading gate.
const readingDone = lessons_total > 0 && lessons_completed === lessons_total;
let pending_quizzes = [];
let pending_assessment = null;
let assessment_configured = true;
if (readingDone) {
const unitQuizzes = await UnitQuiz.findAll({
@@ -244,6 +248,7 @@ exports.getMyInProgressCourses = async (req, res) => {
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
where: { course_id: courseId },
});
assessment_configured = !!assessment;
if (assessment) {
const [hasPassed, attemptCount] = await Promise.all([
QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true } }),
@@ -262,12 +267,13 @@ exports.getMyInProgressCourses = async (req, res) => {
}
return {
course_id: courseId,
title: row.course.title,
reading_status: row.status,
course_id: courseId,
title: row.course.title,
reading_status: readingDone ? 'completed' : 'in_progress',
assessment_configured,
lessons_total,
lessons_completed,
last_accessed_at: row.last_accessed_at,
last_accessed_at: row.last_accessed_at,
pending_quizzes,
pending_assessment,
};
+7
View File
@@ -299,6 +299,13 @@ exports.getCourse = async (req, res) => {
required: false,
}],
},
{
model: mdl_Category,
as: "categories",
through: { attributes: [] },
required: false,
attributes: ["id", "name", "slug"],
},
],
order: [
[{ model: Unit, as: "units" }, "order_index", "ASC"],
+10 -10
View File
@@ -132,7 +132,7 @@ const isMember = async (userId, groupId) => {
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
//
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|done|overdue
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|completed|overdue
//
// has_completed is now computed per-task as: ALL of the task's requirements
// individually have a completion signal — matching RequirementsStatusPanel's
@@ -147,17 +147,17 @@ const isMember = async (userId, groupId) => {
// requirement_id (+ reference_id)
//
// Task bucket:
// done → every requirement passes its check above
// completed → every requirement passes its check above
// (a task with zero requirements is vacuously "ongoing", per
// earlier spec — zero requirements should not normally happen)
// overdue → not done AND task.deadline < now
// overdue → not completed AND task.deadline < now
// ongoing → otherwise
// ─────────────────────────────────────────────────────────────────────────────
exports.getGroupTaskList = async (req, res) => {
try {
const { groupId, taskListId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue'
const { status } = req.query; // optional: 'ongoing' | 'completed' | 'overdue'
const userId = req.user.user_id;
const member = await isMember(userId, groupId);
@@ -259,7 +259,7 @@ exports.getGroupTaskList = async (req, res) => {
let bucket;
if (has_completed) {
bucket = 'done';
bucket = 'completed';
} else if (task.deadline && new Date(task.deadline).getTime() < now) {
bucket = 'overdue';
} else {
@@ -287,7 +287,7 @@ exports.getGroupTaskList = async (req, res) => {
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskLists in task.controller.js (client) — plural
//
// GET /client/groups/:groupId/task-lists?status=ongoing|done|overdue
// GET /client/groups/:groupId/task-lists?status=ongoing|completed|overdue
//
// Updated to match getGroupTaskList (singular): has_completed per task now
// means ALL of that task's requirements individually have a completion signal
@@ -295,8 +295,8 @@ exports.getGroupTaskList = async (req, res) => {
//
// TaskList bucket (based on per-task has_completed, computed below):
// TaskList has zero tasks → Ongoing (nothing to do yet)
// ALL tasks have has_completed → Done
// NOT all done AND any incomplete
// ALL tasks have has_completed → Completed
// NOT all completed AND any incomplete
// task has deadline < now → Overdue
// Otherwise → Ongoing
// ─────────────────────────────────────────────────────────────────────────────
@@ -304,7 +304,7 @@ exports.getGroupTaskList = async (req, res) => {
exports.getGroupTaskLists = async (req, res) => {
try {
const { groupId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue'
const { status } = req.query; // optional: 'ongoing' | 'completed' | 'overdue'
const userId = req.user.user_id;
const member = await isMember(userId, groupId);
@@ -423,7 +423,7 @@ exports.getGroupTaskLists = async (req, res) => {
} else {
const allDone = tasks.every((t) => t.has_completed);
if (allDone) {
bucket = 'done';
bucket = 'completed';
} else {
const anyOverdue = tasks.some((t) =>
!t.has_completed && t.deadline && new Date(t.deadline).getTime() < now
+30 -4
View File
@@ -27,6 +27,9 @@ const sequelize = require('../../config/db.config');
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { Course } = require('../../models/courses/courses.mdl');
const CourseAssessment = require('../../models/courses/course_assessment.mdl');
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
@@ -67,8 +70,10 @@ const deriveUnitCompletion = async (userId, unitRequirementId, t) => {
// ─── Helper: derive course completion ────────────────────────────────────────
// Course is complete when ALL read_unit progress rows under this course requirement
// for this user are marked completed.
const deriveCourseCompletion = async (userId, courseRequirementId, t) => {
// for this user are marked completed AND, if the course has a built assessment,
// the user has passed it. A course with no assessment yet can never be "complete" —
// finishing the reading alone isn't course completion.
const deriveCourseCompletion = async (userId, courseRequirementId, courseUuid, t) => {
const rows = await TaskProgress.findAll({
where: {
requirement_id: courseRequirementId,
@@ -78,7 +83,28 @@ const deriveCourseCompletion = async (userId, courseRequirementId, t) => {
transaction: t,
});
if (!rows.length) return false;
return rows.every((r) => r.completed);
const allUnitsRead = rows.every((r) => r.completed);
if (!allUnitsRead) return false;
const course = await Course.findOne({
where: { uuid: courseUuid },
attributes: ['course_id'],
transaction: t,
});
if (!course) return false;
const assessment = await CourseAssessment.findOne({
where: { course_id: course.course_id },
attributes: ['assessment_id'],
transaction: t,
});
if (!assessment) return false;
const passedAttempt = await QuizAttempt.findOne({
where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true },
transaction: t,
});
return !!passedAttempt;
};
// =============================================================================
@@ -352,7 +378,7 @@ exports.updateProgress = async (req, res) => {
if (course_requirement_id) {
const courseReq = await getRequirement(course_requirement_id, taskId);
if (courseReq && courseReq.type === 'read_course') {
const courseDone = await deriveCourseCompletion(userId, course_requirement_id, t);
const courseDone = await deriveCourseCompletion(userId, course_requirement_id, courseReq.reference_id, t);
await TaskProgress.upsert(
{