mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
578 lines
24 KiB
JavaScript
578 lines
24 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* File Name: auth.controller.js
|
|
* Type of Program: Controller
|
|
* Description: Handles all authentication flows:
|
|
* 1. System Registration → OTP email → OTP Verify → Auto-Login
|
|
* 2. System Login (verified users)
|
|
* 3. Google OAuth callback
|
|
* 4. Token Refresh
|
|
* 5. Logout (invalidates session)
|
|
* 6. OTP Resend
|
|
* 7. Change Password
|
|
*
|
|
* Author: rgrgogu
|
|
* Date Created: Oct. 6, 2025
|
|
***********************************************************************************************************************************************************************
|
|
* 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 sequelize = require('../config/db.config')
|
|
const mdl_Users = require('../models/users/users.mdl');
|
|
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
|
|
const mdl_UserBans = require('../models/users/user_bans.mdl');
|
|
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl')
|
|
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 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;
|
|
};
|
|
|
|
// ─── Register ──────────────────────────────────────────────────────────────────
|
|
exports.register = async (req, res) => {
|
|
const transaction = await sequelize.transaction();
|
|
|
|
try {
|
|
const { email, password, personal_info, group_code } = req.body;
|
|
|
|
// ── Duplicate check ───────────────────────────────────────────────────────
|
|
const existing = await mdl_Users.findOne({ where: { email } });
|
|
if (existing) return R.error(res, 'Email is already registered.', 409);
|
|
|
|
// ── 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 enrollGroup = group
|
|
?? await mdl_UserGroups.findOne({ where: { group_code: 'NOGRP', is_active: true } });
|
|
|
|
// ── 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,
|
|
needs_intro: true,
|
|
createdBy: null,
|
|
}, { transaction });
|
|
|
|
// ── Enroll into group ─────────────────────────────────────────────────────
|
|
if (enrollGroup) {
|
|
await mdl_UserGroupMembers.create({
|
|
group_id: enrollGroup.group_id,
|
|
user_id: user.user_id,
|
|
createdBy: null,
|
|
}, { transaction });
|
|
}
|
|
|
|
await sendEmail({ to: email, type: 'OTP', data: { otp } });
|
|
|
|
await transaction.commit();
|
|
|
|
// 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);
|
|
if (user.is_verified) return R.error(res, 'Account already verified.', 400);
|
|
|
|
if (!crypto.timingSafeEqual(Buffer.from(user.otp_code ?? ''), Buffer.from(otp)))
|
|
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);
|
|
|
|
await user.update({ is_verified: true, otp_code: null, otp_expires_at: null }, { transaction });
|
|
|
|
// Auto-login after verification
|
|
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 });
|
|
|
|
await transaction.commit();
|
|
|
|
// Fire-and-forget: activity log
|
|
logActivity(user.user_id, 'register');
|
|
|
|
// 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') {
|
|
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));
|
|
|
|
res.cookie('refreshToken', refreshToken, {
|
|
httpOnly: true, // ← JS cannot read this
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
|
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
});
|
|
|
|
return R.success(res, '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);
|
|
if (user.is_verified) return R.error(res, 'Account is already verified.', 400);
|
|
|
|
const otp = generateOTP();
|
|
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction });
|
|
|
|
await sendEmail({ to: email, type: "OTP", data: { otp } });
|
|
|
|
await transaction.commit();
|
|
|
|
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);
|
|
if (!user.is_active) return R.error(res, 'Account is deactivated.', 403);
|
|
if (!user.is_verified) return R.error(res, 'Please verify your email first.', 403);
|
|
|
|
if (user.is_banned) {
|
|
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
|
|
if (stillBanned) {
|
|
const activeBan = await mdl_UserBans.findOne({
|
|
where: { user_id: user.user_id, is_lifted: false },
|
|
order: [['banned_at', 'DESC']],
|
|
attributes: ['reason', 'ban_type', 'expires_at'],
|
|
});
|
|
return R.error(res, 'Your account has been suspended.', 403, {
|
|
banned: true,
|
|
reason: activeBan?.reason ?? null,
|
|
ban_type: activeBan?.ban_type ?? null,
|
|
ban_expires_at: activeBan?.expires_at ?? null,
|
|
});
|
|
}
|
|
// Expired temporary ban — auto-lift
|
|
await user.update({ is_banned: false, ban_expires_at: null });
|
|
}
|
|
|
|
const match = await bcrypt.compare(password, user.password);
|
|
if (!match) return R.error(res, 'Invalid credentials.', 401);
|
|
|
|
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,
|
|
});
|
|
|
|
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
|
|
|
|
res.cookie('refreshToken', refreshToken, {
|
|
httpOnly: true, // ← JS cannot read this
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
|
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
});
|
|
|
|
return R.success(res, 'Login successful.', {
|
|
accessToken,
|
|
session_id: session.session_id,
|
|
user: safeUser(user),
|
|
});
|
|
} 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) {
|
|
return res.redirect(`${CALLBACK_PAGE}?error=${encodeURIComponent(error)}`);
|
|
}
|
|
|
|
// 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`);
|
|
|
|
const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie);
|
|
|
|
if (!state || state !== expectedState) {
|
|
return res.redirect(`${CALLBACK_PAGE}?error=state_mismatch`);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
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: true,
|
|
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 });
|
|
|
|
const noGrp = await mdl_UserGroups.findOne({ where: { group_code: 'NOGRP', is_active: true }, transaction: t });
|
|
if (noGrp) {
|
|
await mdl_UserGroupMembers.create({
|
|
group_id: noGrp.group_id,
|
|
user_id: user.user_id,
|
|
createdBy: null,
|
|
}, { transaction: t });
|
|
}
|
|
|
|
await t.commit();
|
|
|
|
// Fire-and-forget: achievements + welcome notifications for new Google user
|
|
onUserRegistered(user.user_id)
|
|
.catch(err => console.error('[AUTH] googleCallback: Failed to grant achievements:', err));
|
|
|
|
const _now = new Date();
|
|
UserNotification.bulkCreate([
|
|
{
|
|
user_id: user.user_id,
|
|
...NOTIFICATION_REGISTRY.welcome.build({ groupName: null, groupCode: 'NOGRP', accType: 'user', groupId: noGrp?.group_id ?? null }),
|
|
createdAt: _now,
|
|
updatedAt: _now,
|
|
},
|
|
{
|
|
user_id: user.user_id,
|
|
...NOTIFICATION_REGISTRY.nogrp_welcome.build(),
|
|
createdAt: _now,
|
|
updatedAt: _now,
|
|
},
|
|
], { validate: false })
|
|
.catch(err => console.error('[AUTH] googleCallback: Failed to emit welcome notifications:', err));
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
if (!user.is_active) {
|
|
return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`);
|
|
}
|
|
|
|
if (user.is_banned) {
|
|
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
|
|
if (stillBanned) {
|
|
const activeBan = await mdl_UserBans.findOne({
|
|
where: { user_id: user.user_id, is_lifted: false },
|
|
order: [['banned_at', 'DESC']],
|
|
attributes: ['reason', 'ban_type', 'expires_at'],
|
|
});
|
|
const params = new URLSearchParams({ error: 'account_banned' });
|
|
if (activeBan?.reason) params.set('reason', activeBan.reason);
|
|
if (activeBan?.ban_type) params.set('ban_type', activeBan.ban_type);
|
|
if (activeBan?.expires_at) params.set('expires_at', new Date(activeBan.expires_at).toISOString());
|
|
return res.redirect(`${CALLBACK_PAGE}?${params.toString()}`);
|
|
}
|
|
await user.update({ is_banned: false, ban_expires_at: null });
|
|
}
|
|
|
|
const { accessToken, refreshToken } = generateTokens(user);
|
|
const googleSession = await mdl_UserSessions.create({
|
|
user_id: user.user_id,
|
|
login_info: await buildSessionInfo(req),
|
|
refresh_token_hash: hashToken(refreshToken),
|
|
is_active: true,
|
|
});
|
|
|
|
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(googleSession.session_id), details: { reg_type: 'google' } });
|
|
|
|
res.cookie('refreshToken', refreshToken, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
|
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
|
});
|
|
|
|
// Redirect to the frontend; App.jsx's restoreSession() will pick up the
|
|
// refresh cookie and complete the login automatically.
|
|
return res.redirect(CALLBACK_PAGE);
|
|
} catch (err) {
|
|
console.error('[AUTH] googleCallback OIDC error:', err);
|
|
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google?error=auth_failed`);
|
|
}
|
|
};
|
|
|
|
// ─── 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 || !user.is_active) return R.error(res, 'User not found or deactivated.', 401);
|
|
|
|
if (user.is_banned) {
|
|
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
|
|
if (stillBanned) return R.error(res, 'Your account has been suspended.', 403, { banned: true });
|
|
await user.update({ is_banned: false, ban_expires_at: null });
|
|
}
|
|
|
|
// 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) });
|
|
|
|
res.cookie('refreshToken', tokens.refreshToken, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
|
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
|
});
|
|
|
|
return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: safeUser(user) });
|
|
} 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 });
|
|
|
|
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 } });
|
|
|
|
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);
|
|
}
|
|
}; |