3 * @description Customer Portal Authentication Routes
5 * Provides authentication endpoints for customer portal users including login,
6 * logout, password management, and session management.
9 * - JWT-based authentication (24-hour expiry)
10 * - Session tracking with IP and user agent
11 * - Password reset with email token
12 * - Audit logging for all auth events
13 * - Account status validation
16 * - Bcrypt password hashing (10 rounds)
17 * - SHA256 token hashing for session storage
18 * - Timing-safe password comparison
19 * - Rate limiting recommended (not implemented)
21 * @module routes/portalAuth
24 * @requires jsonwebtoken
26 * @requires services/db
27 * @requires middleware/customerAuth
28 * @author Independent Business Group
30 * @see module:routes/portalCustomer
31 * @see module:routes/customerUsers
32 * @see module:middleware/customerAuth
35const express = require('express');
36const router = express.Router();
37const bcrypt = require('bcrypt');
38const jwt = require('jsonwebtoken');
39const crypto = require('crypto');
40const pool = require('../services/db');
41const { authenticateCustomer, logCustomerAudit } = require('../middleware/customerAuth');
44 * @api {post} /portal/auth/login Customer Login
45 * @apiName CustomerLogin
46 * @apiGroup CustomerPortalAuth
50 * Authenticates customer portal user and returns JWT token.
51 * Creates session record, updates last login timestamp, logs audit event.
52 * Validates account is active and portal access is enabled.
56 * @apiHeader {String} Content-Type application/json
58 * @apiParam {String} email Email address (case-insensitive)
59 * @apiParam {String} password Plain text password
61 * @apiSuccess {String} token JWT authentication token (24h expiry)
62 * @apiSuccess {Object} user User information
63 * @apiSuccess {Number} user.customerUserId User ID
64 * @apiSuccess {Number} user.customerId Customer ID
65 * @apiSuccess {String} user.email Email address
66 * @apiSuccess {String} user.firstName First name
67 * @apiSuccess {String} user.lastName Last name
68 * @apiSuccess {String} user.customerName Customer name
69 * @apiSuccess {String} user.tenantName Tenant name
70 * @apiSuccess {Boolean} user.isPrimary Primary contact flag
72 * @apiError (400) {String} error Email and password are required
73 * @apiError (401) {String} error Invalid email or password
74 * @apiError (403) {String} error Portal access is disabled for this account
75 * @apiError (500) {String} error Login failed
77 * @apiExample {curl} Example Request:
79 * -H "Content-Type: application/json" \
81 * "email": "user@example.com",
82 * "password": "SecurePass123"
84 * "https://api.everydaytech.au/portal/auth/login"
86 * @apiSuccessExample {json} Success Response:
89 * "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
91 * "customerUserId": 45,
93 * "email": "user@example.com",
94 * "firstName": "John",
96 * "customerName": "Acme Corp",
97 * "tenantName": "IBG MSP",
102router.post('/login', async (req, res) => {
104 const { email, password } = req.body;
106 if (!email || !password) {
107 return res.status(400).json({ error: 'Email and password are required.' });
110 // Find customer user
111 const userResult = await pool.query(
112 `SELECT cu.*, c.name as customer_name, t.name as tenant_name
113 FROM customer_users cu
114 JOIN customers c ON cu.customer_id = c.customer_id
115 JOIN tenants t ON cu.tenant_id = t.tenant_id
116 WHERE cu.email = $1 AND cu.is_active = true`,
117 [email.toLowerCase()]
120 if (userResult.rows.length === 0) {
121 return res.status(401).json({ error: 'Invalid email or password.' });
124 const user = userResult.rows[0];
126 // Check if portal access is allowed for this customer
127 const customerResult = await pool.query(
128 'SELECT allow_portal_access FROM customers WHERE customer_id = $1',
132 if (!customerResult.rows[0].allow_portal_access) {
133 return res.status(403).json({ error: 'Portal access is disabled for this account.' });
137 const passwordMatch = await bcrypt.compare(password, user.password_hash);
138 if (!passwordMatch) {
139 return res.status(401).json({ error: 'Invalid email or password.' });
142 // Generate JWT token
143 const token = jwt.sign(
145 customerUserId: user.customer_user_id,
146 customerId: user.customer_id,
147 tenantId: user.tenant_id,
150 process.env.JWT_SECRET,
155 const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
156 const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
157 const ipAddress = req.ip || req.connection.remoteAddress;
158 const userAgent = req.headers['user-agent'];
161 `INSERT INTO customer_sessions
162 (customer_user_id, token_hash, ip_address, user_agent, expires_at)
163 VALUES ($1, $2, $3, $4, $5)`,
164 [user.customer_user_id, tokenHash, ipAddress, userAgent, expiresAt]
169 'UPDATE customer_users SET last_login = NOW() WHERE customer_user_id = $1',
170 [user.customer_user_id]
174 await logCustomerAudit(
175 user.customer_user_id,
187 customerUserId: user.customer_user_id,
188 customerId: user.customer_id,
190 firstName: user.first_name,
191 lastName: user.last_name,
192 customerName: user.customer_name,
193 tenantName: user.tenant_name,
194 isPrimary: user.is_primary
198 console.error('Customer login error:', error);
199 res.status(500).json({ error: 'Login failed.', details: error.message });
204 * @api {post} /portal/auth/logout Customer Logout
205 * @apiName CustomerLogout
206 * @apiGroup CustomerPortalAuth
210 * Logs out customer portal user. Deletes session record and logs audit event.
211 * Requires valid JWT token in Authorization header.
213 * @apiPermission customer
215 * @apiHeader {String} Authorization Bearer JWT token
217 * @apiSuccess {String} message Success message
219 * @apiError (401) {String} error Unauthorized (invalid/missing token)
220 * @apiError (500) {String} error Logout failed
222 * @apiExample {curl} Example Request:
224 * -H "Authorization: Bearer eyJhbGc..." \
225 * "https://api.everydaytech.au/portal/auth/logout"
227router.post('/logout', authenticateCustomer, async (req, res) => {
229 const authHeader = req.headers['authorization'];
230 const token = authHeader && authHeader.split(' ')[1];
231 const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
235 'DELETE FROM customer_sessions WHERE token_hash = $1',
240 await logCustomerAudit(
247 req.ip || req.connection.remoteAddress
250 res.json({ message: 'Logged out successfully.' });
252 console.error('Customer logout error:', error);
253 res.status(500).json({ error: 'Logout failed.', details: error.message });
258 * @api {get} /portal/auth/me Get Current User
259 * @apiName GetCurrentCustomerUser
260 * @apiGroup CustomerPortalAuth
264 * Returns current authenticated customer portal user information.
265 * Includes user details, customer information, and tenant name.
267 * @apiPermission customer
269 * @apiHeader {String} Authorization Bearer JWT token
271 * @apiSuccess {Number} customerUserId User ID
272 * @apiSuccess {Number} customerId Customer ID
273 * @apiSuccess {String} email Email address
274 * @apiSuccess {String} firstName First name
275 * @apiSuccess {String} lastName Last name
276 * @apiSuccess {String} phone Phone number
277 * @apiSuccess {String} customerName Customer name
278 * @apiSuccess {String} contactEmail Customer contact email
279 * @apiSuccess {String} contactPhone Customer contact phone
280 * @apiSuccess {String} tenantName Tenant name
281 * @apiSuccess {Boolean} isPrimary Primary contact flag
282 * @apiSuccess {Date} lastLogin Last login timestamp
284 * @apiError (401) {String} error Unauthorized (invalid/missing token)
285 * @apiError (404) {String} error User not found
286 * @apiError (500) {String} error Failed to get user info
288router.get('/me', authenticateCustomer, async (req, res) => {
290 const userResult = await pool.query(
291 `SELECT cu.customer_user_id, cu.email, cu.first_name, cu.last_name,
292 cu.phone, cu.is_primary, cu.last_login,
293 c.name as customer_name, c.email as customer_email, c.phone as customer_phone,
294 t.name as tenant_name
295 FROM customer_users cu
296 JOIN customers c ON cu.customer_id = c.customer_id
297 JOIN tenants t ON cu.tenant_id = t.tenant_id
298 WHERE cu.customer_user_id = $1`,
302 if (userResult.rows.length === 0) {
303 return res.status(404).json({ error: 'User not found.' });
306 const user = userResult.rows[0];
308 customerUserId: user.customer_user_id,
309 customerId: req.customerId,
311 firstName: user.first_name,
312 lastName: user.last_name,
314 customerName: user.customer_name,
315 contactEmail: user.customer_email,
316 contactPhone: user.customer_phone,
317 tenantName: user.tenant_name,
318 isPrimary: user.is_primary,
319 lastLogin: user.last_login
322 console.error('Get customer user error:', error);
323 res.status(500).json({ error: 'Failed to get user info.', details: error.message });
328 * @api {post} /portal/auth/forgot-password Request Password Reset
329 * @apiName ForgotPassword
330 * @apiGroup CustomerPortalAuth
334 * Initiates password reset process. Generates secure token and sends reset link.
335 * Always returns success message to prevent email enumeration attacks.
336 * Token expires in 1 hour. **Email sending not yet implemented.**
338 * @apiPermission none
340 * @apiHeader {String} Content-Type application/json
342 * @apiParam {String} email Email address
344 * @apiSuccess {String} message Generic success message (security measure)
346 * @apiError (400) {String} error Email is required
347 * @apiError (500) {String} error Failed to process password reset request
349 * @apiExample {curl} Example Request:
351 * -H "Content-Type: application/json" \
352 * -d '{"email": "user@example.com"}' \
353 * "https://api.everydaytech.au/portal/auth/forgot-password"
355 * @apiNote Email functionality not implemented. Token logged to console for testing.
357router.post('/forgot-password', async (req, res) => {
359 const { email } = req.body;
362 return res.status(400).json({ error: 'Email is required.' });
366 const userResult = await pool.query(
367 'SELECT customer_user_id, tenant_id FROM customer_users WHERE email = $1 AND is_active = true',
368 [email.toLowerCase()]
371 // Always return success to prevent email enumeration
372 if (userResult.rows.length === 0) {
373 return res.json({ message: 'If an account exists with that email, a password reset link has been sent.' });
376 const user = userResult.rows[0];
378 // Generate reset token
379 const resetToken = crypto.randomBytes(32).toString('hex');
380 const resetTokenHash = crypto.createHash('sha256').update(resetToken).digest('hex');
381 const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
383 // Store reset token in customer_sessions table (repurpose for password resets)
385 `INSERT INTO customer_sessions
386 (customer_user_id, token_hash, expires_at, ip_address, user_agent)
387 VALUES ($1, $2, $3, $4, $5)`,
389 user.customer_user_id,
392 req.ip || req.connection.remoteAddress,
397 // TODO: Send email with reset link
398 // const resetLink = `https://precisewebhosting.com.au/reset-password?token=${resetToken}`;
399 // await sendPasswordResetEmail(email, resetLink);
401 console.log(`Password reset token for ${email}: ${resetToken}`);
403 res.json({ message: 'If an account exists with that email, a password reset link has been sent.' });
405 console.error('Forgot password error:', error);
406 res.status(500).json({ error: 'Failed to process password reset request.' });
411 * @api {post} /portal/auth/reset-password Reset Password with Token
412 * @apiName ResetPassword
413 * @apiGroup CustomerPortalAuth
417 * Resets password using token from forgot-password email.
418 * Token is single-use and expires after 1 hour.
419 * Password is bcrypt-hashed before storage. Logs audit event.
421 * @apiPermission none
423 * @apiHeader {String} Content-Type application/json
425 * @apiParam {String} token Reset token from email
426 * @apiParam {String} newPassword New password (min 8 characters)
428 * @apiSuccess {String} message Success message
430 * @apiError (400) {String} error Token and new password are required
431 * @apiError (400) {String} error Password must be at least 8 characters long
432 * @apiError (400) {String} error Invalid or expired reset token
433 * @apiError (500) {String} error Failed to reset password
435 * @apiExample {curl} Example Request:
437 * -H "Content-Type: application/json" \
439 * "token": "abc123...",
440 * "newPassword": "NewSecure456"
442 * "https://api.everydaytech.au/portal/auth/reset-password"
444router.post('/reset-password', async (req, res) => {
446 const { token, newPassword } = req.body;
448 if (!token || !newPassword) {
449 return res.status(400).json({ error: 'Token and new password are required.' });
452 if (newPassword.length < 8) {
453 return res.status(400).json({ error: 'Password must be at least 8 characters long.' });
456 // Find valid reset token
457 const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
458 const sessionResult = await pool.query(
459 `SELECT customer_user_id
460 FROM customer_sessions
461 WHERE token_hash = $1
462 AND expires_at > NOW()
463 AND user_agent = 'password_reset'`,
467 if (sessionResult.rows.length === 0) {
468 return res.status(400).json({ error: 'Invalid or expired reset token.' });
471 const customerUserId = sessionResult.rows[0].customer_user_id;
474 const passwordHash = await bcrypt.hash(newPassword, 10);
478 'UPDATE customer_users SET password_hash = $1, updated_at = NOW() WHERE customer_user_id = $2',
479 [passwordHash, customerUserId]
482 // Delete reset token
484 'DELETE FROM customer_sessions WHERE token_hash = $1',
488 // Get user info for audit log
489 const userResult = await pool.query(
490 'SELECT tenant_id FROM customer_users WHERE customer_user_id = $1',
495 await logCustomerAudit(
497 userResult.rows[0].tenant_id,
502 req.ip || req.connection.remoteAddress
505 res.json({ message: 'Password reset successfully.' });
507 console.error('Reset password error:', error);
508 res.status(500).json({ error: 'Failed to reset password.', details: error.message });
513 * @api {post} /portal/auth/change-password Change Password
514 * @apiName ChangePassword
515 * @apiGroup CustomerPortalAuth
519 * Changes password for authenticated user. Requires current password verification.
520 * New password is bcrypt-hashed before storage. Logs audit event.
522 * @apiPermission customer
524 * @apiHeader {String} Authorization Bearer JWT token
525 * @apiHeader {String} Content-Type application/json
527 * @apiParam {String} currentPassword Current password (for verification)
528 * @apiParam {String} newPassword New password (min 8 characters)
530 * @apiSuccess {String} message Success message
532 * @apiError (400) {String} error Current password and new password are required
533 * @apiError (400) {String} error Password must be at least 8 characters long
534 * @apiError (401) {String} error Unauthorized (invalid/missing token)
535 * @apiError (401) {String} error Current password is incorrect
536 * @apiError (404) {String} error User not found
537 * @apiError (500) {String} error Failed to change password
539 * @apiExample {curl} Example Request:
541 * -H "Authorization: Bearer eyJhbGc..." \
542 * -H "Content-Type: application/json" \
544 * "currentPassword": "OldPass123",
545 * "newPassword": "NewSecure456"
547 * "https://api.everydaytech.au/portal/auth/change-password"
549router.post('/change-password', authenticateCustomer, async (req, res) => {
551 const { currentPassword, newPassword } = req.body;
553 if (!currentPassword || !newPassword) {
554 return res.status(400).json({ error: 'Current password and new password are required.' });
557 if (newPassword.length < 8) {
558 return res.status(400).json({ error: 'Password must be at least 8 characters long.' });
561 // Get current password hash
562 const userResult = await pool.query(
563 'SELECT password_hash FROM customer_users WHERE customer_user_id = $1',
567 if (userResult.rows.length === 0) {
568 return res.status(404).json({ error: 'User not found.' });
571 // Verify current password
572 const passwordMatch = await bcrypt.compare(currentPassword, userResult.rows[0].password_hash);
573 if (!passwordMatch) {
574 return res.status(401).json({ error: 'Current password is incorrect.' });
578 const passwordHash = await bcrypt.hash(newPassword, 10);
582 'UPDATE customer_users SET password_hash = $1, updated_at = NOW() WHERE customer_user_id = $2',
583 [passwordHash, req.customerUserId]
587 await logCustomerAudit(
594 req.ip || req.connection.remoteAddress
597 res.json({ message: 'Password changed successfully.' });
599 console.error('Change password error:', error);
600 res.status(500).json({ error: 'Failed to change password.', details: error.message });
604module.exports = router;