Files
starr-philproperties/controllers/auth.controller.js
T
2026-05-07 23:35:22 +08:00

316 lines
13 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 { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util');
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
const sendEmail = require('../services/email.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'];
// ─── Helpers ───────────────────────────────────────────────────────────────────
const buildLoginInfo = (req) => ({
date: new Date().toISOString(),
ip_address: req.ip,
device_info: req.headers['user-agent'] || 'unknown',
});
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 } = req.body;
const existing = await mdl_Users.findOne({ where: { email } });
if (existing) return R.error(res, 'Email is already registered.', 409);
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',
createdBy: null,
}, { transaction });
await sendEmail({ to: email, type: "OTP", data: { otp } });
await transaction.commit();
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 (user.otp_code !== 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: buildLoginInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
}, { transaction });
await sendEmail({ to: email, type: "WELCOME", data: { name: email } });
await transaction.commit();
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,
refreshToken,
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);
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: buildLoginInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
});
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 OAuth Callback ─────────────────────────────────────────────────────
exports.googleCallback = async (req, res) => {
try {
const user = req.user; // set by passport
const { accessToken, refreshToken } = generateTokens(user);
await mdl_UserSessions.create({
user_id: user.user_id,
login_info: buildLoginInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
});
// In a real SPA: redirect with tokens in query or set httpOnly cookie
return R.success(res, 'Google login successful.', {
accessToken,
refreshToken,
user: safeUser(user),
});
} catch (err) {
console.error('[AUTH] googleCallback error:', err);
return R.error(res, 'Google authentication failed.', 500);
}
};
// ─── 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);
// Check if refresh token is expired
if (!shouldRotateRefreshToken(decoded)) {
const { accessToken } = generateTokens(user);
return R.success(res, 'Token refreshed.', { accessToken, 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.', { ...tokens, 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: buildLoginInfo(req) },
{ where: { session_id, user_id: req.user.user_id } }
);
}
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 } });
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);
}
};