/*********************************************************************************************************************************************************************** * 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', '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) { 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); } };