feat(activation): wire notifications into enrollment/auth, real password-reset delivery
- integration-routes.js: accepts student.contact_email, calls notifyForEnrollment on a genuinely new enrollment only (never on an idempotent replay — inherits Phase 5B's proven enrollment-level idempotency, no separate notification dedup needed). - admin-routes.js: create-student/convert/reset-password now enqueue the corresponding notification using the SAME token already minted synchronously (never mints twice, which would silently invalidate the token just handed back in the response). Fixed a real bug: request-password-reset would mint a reset token for a never-activated student, which makes no sense and would confuse them — now falls through to the generic anti-enumeration response. - admin-programs-routes.js: manual enrollment triggers the same notification decision as the website path. - student-auth-routes.js: /activate records a metric; /change-password and /reset-password both queue a password_changed security notice; /request-password-reset now actually delivers email (production still never returns the token — unchanged anti-enumeration/isProduction gating, just now with a real send behind it). - rbac.js: requireStudentRelationshipAccess/checkStudentRelationshipAccess — the notification visibility rule (cohort-shared or an enrollment they created), reusable beyond just enrollments. - server.js: wires notification-routes.js and starts the notification worker in main(). - docker-compose.dev.yml: PUBLIC_APP_ORIGIN, EMAIL_FROM_NAME, and a parametrized COOKIE_SECURE (was hard-commented-out; needed so e2e-phase5c.js can actually prove production gating).
This commit is contained in:
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
const programsLib = require('./programs');
|
const programsLib = require('./programs');
|
||||||
const enrollmentLib = require('./enrollment');
|
const enrollmentLib = require('./enrollment');
|
||||||
|
const notificationsLib = require('./notifications');
|
||||||
|
const studentsAuth = require('./students-auth');
|
||||||
const { buildProgramStatus } = require('./program-status');
|
const { buildProgramStatus } = require('./program-status');
|
||||||
|
|
||||||
function enrollmentErrorStatus(code) {
|
function enrollmentErrorStatus(code) {
|
||||||
@@ -104,19 +106,30 @@ module.exports = function registerAdminProgramsRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
admin.post('/api/admin/enrollments', requireAdminOrOwner, async (req, res) => {
|
admin.post('/api/admin/enrollments', requireAdminOrOwner, async (req, res) => {
|
||||||
const { student_id, program_id, status, starts_at, access_ends_at, delivery_mode, external_reference, metadata } = req.body || {};
|
const { student_id, program_id, status, starts_at, access_ends_at, delivery_mode, external_reference, contact_email, metadata } = req.body || {};
|
||||||
if (!student_id || !program_id) return res.status(400).json({ error: 'student_id and program_id are required' });
|
if (!student_id || !program_id) return res.status(400).json({ error: 'student_id and program_id are required' });
|
||||||
|
// Phase 5C §10 — same transitional contact-email convention as the integration endpoint;
|
||||||
|
// folded into the same metadata JSONB an admin could already pass, not a new column.
|
||||||
|
const mergedMetadata = contact_email ? { ...(metadata || {}), contact_email: String(contact_email).trim() } : metadata;
|
||||||
|
let created;
|
||||||
try {
|
try {
|
||||||
const created = await enrollmentLib.createManualEnrollment(getPool(), {
|
created = await enrollmentLib.createManualEnrollment(getPool(), {
|
||||||
studentId: student_id, programId: program_id, status, startsAt: starts_at, accessEndsAt: access_ends_at,
|
studentId: student_id, programId: program_id, status, startsAt: starts_at, accessEndsAt: access_ends_at,
|
||||||
deliveryMode: delivery_mode, externalReference: external_reference, metadata, createdBy: req.instructor.instructor_id,
|
deliveryMode: delivery_mode, externalReference: external_reference, metadata: mergedMetadata, createdBy: req.instructor.instructor_id,
|
||||||
});
|
});
|
||||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_created', resourceType: 'enrollment', resourceId: created.enrollment_id, result: 'success', sourceIp: req.ip, metadata: { student_id, program_id } });
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_created', resourceType: 'enrollment', resourceId: created.enrollment_id, result: 'success', sourceIp: req.ip, metadata: { student_id, program_id } });
|
||||||
res.status(201).json(created);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
|
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
// §11 — an admin-created enrollment notifies exactly like a website-triggered one: activation
|
||||||
|
// for a not-yet-credentialed student, enrollment confirmation for an already-active one.
|
||||||
|
const program = await programsLib.getProgram(getPool(), program_id);
|
||||||
|
const notification = await notificationsLib.notifyForEnrollment(getPool(), studentsAuth, {
|
||||||
|
studentId: student_id, enrollmentId: created.enrollment_id, programName: program?.name,
|
||||||
|
});
|
||||||
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: `notification_queued_${notification?.notification_type || 'unknown'}`, resourceType: 'notification', resourceId: notification?.notification_id, result: 'success', sourceIp: req.ip });
|
||||||
|
res.status(201).json(created);
|
||||||
});
|
});
|
||||||
|
|
||||||
admin.get('/api/admin/enrollments/:id', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => {
|
admin.get('/api/admin/enrollments/:id', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => {
|
||||||
|
|||||||
@@ -153,6 +153,15 @@ module.exports = function registerAdminRoutes(app, deps) {
|
|||||||
if (email || login_identifier) {
|
if (email || login_identifier) {
|
||||||
const issued = await issueCredentialToken(pool, studentId, 'activation');
|
const issued = await issueCredentialToken(pool, studentId, 'activation');
|
||||||
activationToken = issued.token;
|
activationToken = issued.token;
|
||||||
|
// Phase 5C §5/§6 — reuses the SAME token just minted above (never mints a second one,
|
||||||
|
// which would silently invalidate the one this response is about to hand the admin for
|
||||||
|
// manual relay). A student with only login_identifier and no email has no deliverable
|
||||||
|
// address here — enqueue() records that as an already-failed row (never silently
|
||||||
|
// dropped), and the admin's manual-relay token in the response still works either way.
|
||||||
|
await require('./notifications').enqueue(pool, {
|
||||||
|
studentId, notificationType: 'activation', plaintextToken: activationToken,
|
||||||
|
metadata: { display_name: display_name.trim() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
|
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
|
||||||
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
|
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
|
||||||
@@ -179,20 +188,34 @@ module.exports = function registerAdminRoutes(app, deps) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
|
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
|
||||||
|
// Phase 5C — same token, same "never mint twice" reasoning as create-student above.
|
||||||
|
const { rows: nameRows } = await pool.query(`SELECT display_name FROM students WHERE student_id = $1`, [req.params.id]);
|
||||||
|
await require('./notifications').enqueue(pool, {
|
||||||
|
studentId: req.params.id, notificationType: 'activation', plaintextToken: token,
|
||||||
|
metadata: { display_name: nameRows[0]?.display_name },
|
||||||
|
});
|
||||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||||
res.json({ student_id: req.params.id, activation_token: token });
|
res.json({ student_id: req.params.id, activation_token: token });
|
||||||
});
|
});
|
||||||
|
|
||||||
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
|
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
|
||||||
// token and revokes every existing session, same "instructor never needs to know the
|
// token and revokes every existing session, same "instructor never needs to know the
|
||||||
// student's password" principle as activation.
|
// student's password" principle as activation. Phase 5C §8/§15: also queues a real
|
||||||
|
// password-reset email whenever the student has a deliverable address — the response still
|
||||||
|
// returns reset_token unchanged (a deliberate, documented exception to "admin cannot view
|
||||||
|
// token" for the population with no deliverable address at all — see notification-routes.js's
|
||||||
|
// header comment).
|
||||||
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
|
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' });
|
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' });
|
||||||
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
|
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
|
||||||
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
|
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
|
||||||
|
await require('./notifications').enqueue(pool, {
|
||||||
|
studentId: req.params.id, notificationType: 'password_reset', plaintextToken: token,
|
||||||
|
metadata: { display_name: rows[0].display_name },
|
||||||
|
});
|
||||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||||
res.json({ student_id: req.params.id, reset_token: token });
|
res.json({ student_id: req.params.id, reset_token: token });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
const enrollment = require('./enrollment');
|
const enrollment = require('./enrollment');
|
||||||
const studentsAuth = require('./students-auth');
|
const studentsAuth = require('./students-auth');
|
||||||
|
const notifications = require('./notifications');
|
||||||
|
const programsLib = require('./programs');
|
||||||
const metrics = require('./metrics');
|
const metrics = require('./metrics');
|
||||||
|
|
||||||
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
|
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
|
||||||
@@ -47,6 +49,10 @@ module.exports = function registerIntegrationRoutes(app, deps) {
|
|||||||
const { event_id, source, student, program_slug, status } = req.body || {};
|
const { event_id, source, student, program_slug, status } = req.body || {};
|
||||||
const email = student?.email ? String(student.email).trim() : null;
|
const email = student?.email ? String(student.email).trim() : null;
|
||||||
const loginIdentifier = student?.login_identifier ? String(student.login_identifier).trim() : null;
|
const loginIdentifier = student?.login_identifier ? String(student.login_identifier).trim() : null;
|
||||||
|
// Phase 5C §10 — an OPTIONAL enrollment-contact address, for a minor with no email of their
|
||||||
|
// own (e.g. a guardian's address collected at checkout). Never treated as the student's own
|
||||||
|
// identity — see enrollment.js's enrollmentContactEmail.
|
||||||
|
const contactEmail = student?.contact_email ? String(student.contact_email).trim() : null;
|
||||||
const mappedStatus = STATUS_MAP[status];
|
const mappedStatus = STATUS_MAP[status];
|
||||||
|
|
||||||
if (!event_id || typeof event_id !== 'string') {
|
if (!event_id || typeof event_id !== 'string') {
|
||||||
@@ -70,7 +76,7 @@ module.exports = function registerIntegrationRoutes(app, deps) {
|
|||||||
try {
|
try {
|
||||||
result = await enrollment.createFromIntegrationEvent(getPool(), studentsAuth, {
|
result = await enrollment.createFromIntegrationEvent(getPool(), studentsAuth, {
|
||||||
eventId: event_id, source: source || 'integration', studentEmail: email, studentLoginIdentifier: loginIdentifier,
|
eventId: event_id, source: source || 'integration', studentEmail: email, studentLoginIdentifier: loginIdentifier,
|
||||||
studentDisplayName: student?.display_name, programId: program_slug, status: mappedStatus,
|
studentDisplayName: student?.display_name, contactEmail, programId: program_slug, status: mappedStatus,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
metrics.recordEnrollmentEvent('rejected');
|
metrics.recordEnrollmentEvent('rejected');
|
||||||
@@ -88,6 +94,18 @@ module.exports = function registerIntegrationRoutes(app, deps) {
|
|||||||
metadata: { event_id, program_slug, student_id: result.enrollment.student_id },
|
metadata: { event_id, program_slug, student_id: result.enrollment.student_id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Phase 5C §11/§12 — notification decision, ONLY on a genuinely new enrollment
|
||||||
|
// (result.created), never on an idempotent replay. This is the entire duplicate-webhook
|
||||||
|
// safety net: the enrollment's own DB-level idempotency (proven in Phase 5B) already
|
||||||
|
// guarantees at most one caller ever sees created:true for a given event/race, so at most
|
||||||
|
// one notification is ever queued, with no separate notification-level dedup needed.
|
||||||
|
if (result.created) {
|
||||||
|
const program = await programsLib.getProgram(getPool(), program_slug);
|
||||||
|
await notifications.notifyForEnrollment(getPool(), studentsAuth, {
|
||||||
|
studentId: result.enrollment.student_id, enrollmentId: result.enrollment.enrollment_id, programName: program?.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.status(result.created ? 201 : 200).json({
|
res.status(result.created ? 201 : 200).json({
|
||||||
enrollment_id: result.enrollment.enrollment_id,
|
enrollment_id: result.enrollment.enrollment_id,
|
||||||
student_id: result.enrollment.student_id,
|
student_id: result.enrollment.student_id,
|
||||||
|
|||||||
@@ -179,6 +179,38 @@ module.exports = function createRbac(pool) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 5C §16 — same documented rule as requireEnrollmentAccess below, keyed by student_id
|
||||||
|
// directly instead of resolving through one enrollment first: an instructor may see/manage a
|
||||||
|
// student's notifications if they share a cohort with that student OR they created at least
|
||||||
|
// one of that student's enrollments — otherwise 403, never blanket visibility into every
|
||||||
|
// self-paced student's notifications ("Do not grant global notification visibility to every
|
||||||
|
// instructor," §16). Used by the per-student notification list and the resend-activation/
|
||||||
|
// send-enrollment-confirmation/send-password-reset admin actions.
|
||||||
|
async function checkStudentRelationshipAccess(req, res, studentId) {
|
||||||
|
const instructor = req.instructor;
|
||||||
|
if (['owner', 'admin'].includes(instructor.role)) return true;
|
||||||
|
const { rowCount } = await pool.query(
|
||||||
|
`SELECT 1 FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
|
||||||
|
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND ci.instructor_id = $2
|
||||||
|
UNION SELECT 1 FROM enrollments WHERE student_id = $1 AND created_by = $2`,
|
||||||
|
[studentId, instructor.instructor_id]
|
||||||
|
);
|
||||||
|
if (!rowCount) {
|
||||||
|
await recordAudit(pool, {
|
||||||
|
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||||
|
resourceType: 'student', resourceId: studentId, result: 'failure', sourceIp: req.ip,
|
||||||
|
});
|
||||||
|
res.status(403).json({ error: 'forbidden' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function requireStudentRelationshipAccess(studentIdParam = 'id') {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
if (await checkStudentRelationshipAccess(req, res, req.params[studentIdParam])) next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 5B §11 — a self-paced enrollment may have NO cohort at all, so
|
// Phase 5B §11 — a self-paced enrollment may have NO cohort at all, so
|
||||||
// requireStudentCohortAccess (which requires a cohort_enrollments row) can't gate it. The
|
// requireStudentCohortAccess (which requires a cohort_enrollments row) can't gate it. The
|
||||||
// documented safe rule for a plain instructor: they may see/manage an enrollment if EITHER
|
// documented safe rule for a plain instructor: they may see/manage an enrollment if EITHER
|
||||||
@@ -216,6 +248,6 @@ module.exports = function createRbac(pool) {
|
|||||||
requireCohortAccess, requireStudentCohortAccess,
|
requireCohortAccess, requireStudentCohortAccess,
|
||||||
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
||||||
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
|
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
|
||||||
requireEnrollmentAccess,
|
requireEnrollmentAccess, requireStudentRelationshipAccess,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -395,6 +395,14 @@ require('./admin-programs-routes')(app, {
|
|||||||
});
|
});
|
||||||
require('./integration-routes')(app, { getPool, recordAudit, wrap });
|
require('./integration-routes')(app, { getPool, recordAudit, wrap });
|
||||||
|
|
||||||
|
// Phase 5C — enrollment operations & communications. notification-routes.js is the
|
||||||
|
// instructor/admin half (list, per-student view, resend-activation, send-enrollment-
|
||||||
|
// confirmation); the actual delivery worker starts in main() below, alongside the existing
|
||||||
|
// session/login-attempt sweepers — see docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md.
|
||||||
|
require('./notification-routes')(app, {
|
||||||
|
instructorSession: wrap(instructorSession), rbac, getPool, recordAudit,
|
||||||
|
});
|
||||||
|
|
||||||
// Fallback to the platform-shell SPA for any unmatched non-API route
|
// Fallback to the platform-shell SPA for any unmatched non-API route
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
|
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
|
||||||
@@ -414,6 +422,10 @@ async function main() {
|
|||||||
if (applied.length) console.log('Applied migrations:', applied.join(', '));
|
if (applied.length) console.log('Applied migrations:', applied.join(', '));
|
||||||
sessions.startSessionSweeper(getPool());
|
sessions.startSessionSweeper(getPool());
|
||||||
startLoginAttemptSweeper(getPool()); // Phase 4.1 §15
|
startLoginAttemptSweeper(getPool()); // Phase 4.1 §15
|
||||||
|
const { startNotificationWorker } = require('./notification-worker');
|
||||||
|
const { EMAIL_PROVIDER } = require('./email/provider');
|
||||||
|
console.log(`[notification-worker] starting (EMAIL_PROVIDER=${EMAIL_PROVIDER})`);
|
||||||
|
startNotificationWorker(getPool()); // Phase 5C §14
|
||||||
app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`));
|
app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ const {
|
|||||||
const { recordAudit } = require('./audit');
|
const { recordAudit } = require('./audit');
|
||||||
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
|
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
|
||||||
const { buildCookie, clearCookie } = require('./cookies');
|
const { buildCookie, clearCookie } = require('./cookies');
|
||||||
const { recordStudentLoginSuccess, recordStudentLoginFailure } = require('./metrics'); // Phase 5A §34
|
const { recordStudentLoginSuccess, recordStudentLoginFailure, recordStudentActivation } = require('./metrics'); // Phase 5A §34, Phase 5C
|
||||||
|
const notifications = require('./notifications');
|
||||||
|
|
||||||
const STUDENT_COOKIE = 'rn_session';
|
const STUDENT_COOKIE = 'rn_session';
|
||||||
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
|
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
|
||||||
@@ -69,7 +70,11 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
|
|||||||
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
|
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
|
||||||
}
|
}
|
||||||
const student = await consumeCredentialToken(pool, token, 'activation', password);
|
const student = await consumeCredentialToken(pool, token, 'activation', password);
|
||||||
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
|
if (!student) {
|
||||||
|
recordStudentActivation('invalid_or_expired_token');
|
||||||
|
return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
|
||||||
|
}
|
||||||
|
recordStudentActivation('success');
|
||||||
|
|
||||||
const sessionToken = await sessions.createStudentSession(pool, student.student_id);
|
const sessionToken = await sessions.createStudentSession(pool, student.student_id);
|
||||||
const csrf = makeCsrfToken();
|
const csrf = makeCsrfToken();
|
||||||
@@ -105,27 +110,30 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
|
|||||||
const newHash = hashPassword(new_password);
|
const newHash = hashPassword(new_password);
|
||||||
await pool.query(`UPDATE students SET password_hash = $1, updated_at = now() WHERE student_id = $2`, [newHash, student.student_id]);
|
await pool.query(`UPDATE students SET password_hash = $1, updated_at = now() WHERE student_id = $2`, [newHash, student.student_id]);
|
||||||
await sessions.revokeAllStudentSessions(pool, student.student_id, req.sessionToken);
|
await sessions.revokeAllStudentSessions(pool, student.student_id, req.sessionToken);
|
||||||
|
// Phase 5C §5D — a security-notice email, no token, purely informational.
|
||||||
|
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } });
|
||||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_change', result: 'success', sourceIp: req.ip });
|
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_change', result: 'success', sourceIp: req.ip });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// §9/§6 — self-service "forgot password." No email delivery infrastructure exists yet
|
// §9/§6/Phase 5C §8 — self-service "forgot password." Anti-enumeration is unchanged: the
|
||||||
// (explicitly not required this phase), so this deliberately does NOT invent fake email
|
// SAME generic response returns regardless of whether the identifier exists, is inactive, or
|
||||||
// sending and does NOT hand the reset token back to whoever asked (that would let anyone
|
// is a genuine hit — the only difference a caller can ever observe is whether an email
|
||||||
// "reset" any account just by guessing an identifier — a real vulnerability, not a shortcut).
|
// eventually arrives, never anything in this response. Production NEVER returns the token
|
||||||
// Always returns the same generic response regardless of whether the identifier exists
|
// (isProduction, same COOKIE_SECURE-derived signal Phase 5A already established — see
|
||||||
// (anti-enumeration). In non-production, the real token is included so local/test flows can
|
// server.js); the dev-only echo exists purely so a local/CI run can complete the loop without
|
||||||
// complete the loop without pretending email exists; in production it is never returned by
|
// a real inbox, exactly as before. What Phase 5C actually closes: the token is now also
|
||||||
// this endpoint — completing a real self-service reset needs email delivery, a documented,
|
// handed to notifications.enqueue() so a real email goes out — see
|
||||||
// not-yet-built follow-up (see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5). The
|
// docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §8 for why the token is minted here
|
||||||
// realistic path today is instructor-triggered reset (POST /api/admin/students/:id/reset-password).
|
// (synchronously, as before) rather than by the worker.
|
||||||
app.post('/api/student/request-password-reset', async (req, res) => {
|
app.post('/api/student/request-password-reset', async (req, res) => {
|
||||||
const { identifier } = req.body || {};
|
const { identifier } = req.body || {};
|
||||||
const generic = { ok: true, message: 'If that account exists, a password reset has been prepared.' };
|
const generic = { ok: true, message: 'If that account exists, a password reset has been prepared.' };
|
||||||
if (!identifier) return res.json(generic);
|
if (!identifier) return res.json(generic);
|
||||||
const student = await getStudentByIdentifier(pool, identifier);
|
const student = await getStudentByIdentifier(pool, identifier);
|
||||||
if (!student || student.status !== 'active') return res.json(generic);
|
if (!student || student.status !== 'active' || !student.password_hash) return res.json(generic);
|
||||||
const { token } = await issueCredentialToken(pool, student.student_id, 'reset');
|
const { token } = await issueCredentialToken(pool, student.student_id, 'reset');
|
||||||
|
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_reset', plaintextToken: token, metadata: { display_name: student.display_name } });
|
||||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_requested', result: 'success', sourceIp: req.ip });
|
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_requested', result: 'success', sourceIp: req.ip });
|
||||||
if (!isProduction) return res.json({ ...generic, dev_reset_token: token });
|
if (!isProduction) return res.json({ ...generic, dev_reset_token: token });
|
||||||
res.json(generic);
|
res.json(generic);
|
||||||
@@ -139,6 +147,7 @@ module.exports = function registerStudentAuthRoutes(app, { pool, sessions, stude
|
|||||||
const student = await consumeCredentialToken(pool, token, 'reset', password);
|
const student = await consumeCredentialToken(pool, token, 'reset', password);
|
||||||
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This reset link is invalid or has expired.' });
|
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This reset link is invalid or has expired.' });
|
||||||
await sessions.revokeAllStudentSessions(pool, student.student_id, null); // a reset revokes EVERY existing session — the whole point is "someone other than me might have access"
|
await sessions.revokeAllStudentSessions(pool, student.student_id, null); // a reset revokes EVERY existing session — the whole point is "someone other than me might have access"
|
||||||
|
await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } });
|
||||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_completed', result: 'success', sourceIp: req.ip });
|
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_completed', result: 'success', sourceIp: req.ip });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,10 +73,21 @@ services:
|
|||||||
# POST /api/integrations/enrollments refuses every request (fails closed), which is fine
|
# POST /api/integrations/enrollments refuses every request (fails closed), which is fine
|
||||||
# for anyone not exercising that boundary locally.
|
# for anyone not exercising that boundary locally.
|
||||||
INTEGRATION_SHARED_SECRET: ${INTEGRATION_SHARED_SECRET:-}
|
INTEGRATION_SHARED_SECRET: ${INTEGRATION_SHARED_SECRET:-}
|
||||||
|
# Phase 5C — activation/reset email links are built from this origin (see
|
||||||
|
# docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §7). The Gateway's own dev URL, since that's
|
||||||
|
# what a browser actually hits locally. EMAIL_PROVIDER is deliberately left unset here —
|
||||||
|
# it defaults to the dev provider (email/dev-provider.js), which never makes a real
|
||||||
|
# network call no matter what recipient it's given.
|
||||||
|
PUBLIC_APP_ORIGIN: ${PUBLIC_APP_ORIGIN:-http://localhost:4200}
|
||||||
|
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-RootNode Academy}
|
||||||
PLATFORM_API_PORT: "4300"
|
PLATFORM_API_PORT: "4300"
|
||||||
# No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via
|
# No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via
|
||||||
# `npm run bootstrap-owner` for the first owner and the admin portal after that.
|
# `npm run bootstrap-owner` for the first owner and the admin portal after that.
|
||||||
# COOKIE_SECURE: "1" # set once served over real HTTPS
|
# Empty by default — unset/empty means the exact same dev behavior as before this was
|
||||||
|
# parametrized (dev_reset_token echoed, dev-preview route reachable). Phase 5C's
|
||||||
|
# e2e-phase5c.js sets this to "1" via .env for one test to prove production gating for
|
||||||
|
# real, then unsets it again — see that test's own comment.
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-}
|
||||||
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
|
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
Reference in New Issue
Block a user