EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
portalAuth.js
Go to the documentation of this file.
1/**
2 * @file portalAuth.js
3 * @description Customer Portal Authentication Routes
4 *
5 * Provides authentication endpoints for customer portal users including login,
6 * logout, password management, and session management.
7 *
8 * **Features:**
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
14 *
15 * **Security:**
16 * - Bcrypt password hashing (10 rounds)
17 * - SHA256 token hashing for session storage
18 * - Timing-safe password comparison
19 * - Rate limiting recommended (not implemented)
20 *
21 * @module routes/portalAuth
22 * @requires express
23 * @requires bcrypt
24 * @requires jsonwebtoken
25 * @requires crypto
26 * @requires services/db
27 * @requires middleware/customerAuth
28 * @author Independent Business Group
29 * @since 1.0.0
30 * @see module:routes/portalCustomer
31 * @see module:routes/customerUsers
32 * @see module:middleware/customerAuth
33 */
34
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');
42
43/**
44 * @api {post} /portal/auth/login Customer Login
45 * @apiName CustomerLogin
46 * @apiGroup CustomerPortalAuth
47 * @apiVersion 1.0.0
48 *
49 * @apiDescription
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.
53 *
54 * @apiPermission none
55 *
56 * @apiHeader {String} Content-Type application/json
57 *
58 * @apiParam {String} email Email address (case-insensitive)
59 * @apiParam {String} password Plain text password
60 *
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
71 *
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
76 *
77 * @apiExample {curl} Example Request:
78 * curl -X POST \
79 * -H "Content-Type: application/json" \
80 * -d '{
81 * "email": "user@example.com",
82 * "password": "SecurePass123"
83 * }' \
84 * "https://api.everydaytech.au/portal/auth/login"
85 *
86 * @apiSuccessExample {json} Success Response:
87 * HTTP/1.1 200 OK
88 * {
89 * "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
90 * "user": {
91 * "customerUserId": 45,
92 * "customerId": 123,
93 * "email": "user@example.com",
94 * "firstName": "John",
95 * "lastName": "Doe",
96 * "customerName": "Acme Corp",
97 * "tenantName": "IBG MSP",
98 * "isPrimary": true
99 * }
100 * }
101 */
102router.post('/login', async (req, res) => {
103 try {
104 const { email, password } = req.body;
105
106 if (!email || !password) {
107 return res.status(400).json({ error: 'Email and password are required.' });
108 }
109
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()]
118 );
119
120 if (userResult.rows.length === 0) {
121 return res.status(401).json({ error: 'Invalid email or password.' });
122 }
123
124 const user = userResult.rows[0];
125
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',
129 [user.customer_id]
130 );
131
132 if (!customerResult.rows[0].allow_portal_access) {
133 return res.status(403).json({ error: 'Portal access is disabled for this account.' });
134 }
135
136 // Verify password
137 const passwordMatch = await bcrypt.compare(password, user.password_hash);
138 if (!passwordMatch) {
139 return res.status(401).json({ error: 'Invalid email or password.' });
140 }
141
142 // Generate JWT token
143 const token = jwt.sign(
144 {
145 customerUserId: user.customer_user_id,
146 customerId: user.customer_id,
147 tenantId: user.tenant_id,
148 type: 'customer'
149 },
150 process.env.JWT_SECRET,
151 { expiresIn: '24h' }
152 );
153
154 // Create session
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'];
159
160 await pool.query(
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]
165 );
166
167 // Update last login
168 await pool.query(
169 'UPDATE customer_users SET last_login = NOW() WHERE customer_user_id = $1',
170 [user.customer_user_id]
171 );
172
173 // Log audit event
174 await logCustomerAudit(
175 user.customer_user_id,
176 user.tenant_id,
177 'login',
178 'auth',
179 null,
180 { email },
181 ipAddress
182 );
183
184 res.json({
185 token,
186 user: {
187 customerUserId: user.customer_user_id,
188 customerId: user.customer_id,
189 email: user.email,
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
195 }
196 });
197 } catch (error) {
198 console.error('Customer login error:', error);
199 res.status(500).json({ error: 'Login failed.', details: error.message });
200 }
201});
202
203/**
204 * @api {post} /portal/auth/logout Customer Logout
205 * @apiName CustomerLogout
206 * @apiGroup CustomerPortalAuth
207 * @apiVersion 1.0.0
208 *
209 * @apiDescription
210 * Logs out customer portal user. Deletes session record and logs audit event.
211 * Requires valid JWT token in Authorization header.
212 *
213 * @apiPermission customer
214 *
215 * @apiHeader {String} Authorization Bearer JWT token
216 *
217 * @apiSuccess {String} message Success message
218 *
219 * @apiError (401) {String} error Unauthorized (invalid/missing token)
220 * @apiError (500) {String} error Logout failed
221 *
222 * @apiExample {curl} Example Request:
223 * curl -X POST \
224 * -H "Authorization: Bearer eyJhbGc..." \
225 * "https://api.everydaytech.au/portal/auth/logout"
226 */
227router.post('/logout', authenticateCustomer, async (req, res) => {
228 try {
229 const authHeader = req.headers['authorization'];
230 const token = authHeader && authHeader.split(' ')[1];
231 const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
232
233 // Delete session
234 await pool.query(
235 'DELETE FROM customer_sessions WHERE token_hash = $1',
236 [tokenHash]
237 );
238
239 // Log audit event
240 await logCustomerAudit(
241 req.customerUserId,
242 req.tenantId,
243 'logout',
244 'auth',
245 null,
246 {},
247 req.ip || req.connection.remoteAddress
248 );
249
250 res.json({ message: 'Logged out successfully.' });
251 } catch (error) {
252 console.error('Customer logout error:', error);
253 res.status(500).json({ error: 'Logout failed.', details: error.message });
254 }
255});
256
257/**
258 * @api {get} /portal/auth/me Get Current User
259 * @apiName GetCurrentCustomerUser
260 * @apiGroup CustomerPortalAuth
261 * @apiVersion 1.0.0
262 *
263 * @apiDescription
264 * Returns current authenticated customer portal user information.
265 * Includes user details, customer information, and tenant name.
266 *
267 * @apiPermission customer
268 *
269 * @apiHeader {String} Authorization Bearer JWT token
270 *
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
283 *
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
287 */
288router.get('/me', authenticateCustomer, async (req, res) => {
289 try {
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`,
299 [req.customerUserId]
300 );
301
302 if (userResult.rows.length === 0) {
303 return res.status(404).json({ error: 'User not found.' });
304 }
305
306 const user = userResult.rows[0];
307 res.json({
308 customerUserId: user.customer_user_id,
309 customerId: req.customerId,
310 email: user.email,
311 firstName: user.first_name,
312 lastName: user.last_name,
313 phone: user.phone,
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
320 });
321 } catch (error) {
322 console.error('Get customer user error:', error);
323 res.status(500).json({ error: 'Failed to get user info.', details: error.message });
324 }
325});
326
327/**
328 * @api {post} /portal/auth/forgot-password Request Password Reset
329 * @apiName ForgotPassword
330 * @apiGroup CustomerPortalAuth
331 * @apiVersion 1.0.0
332 *
333 * @apiDescription
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.**
337 *
338 * @apiPermission none
339 *
340 * @apiHeader {String} Content-Type application/json
341 *
342 * @apiParam {String} email Email address
343 *
344 * @apiSuccess {String} message Generic success message (security measure)
345 *
346 * @apiError (400) {String} error Email is required
347 * @apiError (500) {String} error Failed to process password reset request
348 *
349 * @apiExample {curl} Example Request:
350 * curl -X POST \
351 * -H "Content-Type: application/json" \
352 * -d '{"email": "user@example.com"}' \
353 * "https://api.everydaytech.au/portal/auth/forgot-password"
354 *
355 * @apiNote Email functionality not implemented. Token logged to console for testing.
356 */
357router.post('/forgot-password', async (req, res) => {
358 try {
359 const { email } = req.body;
360
361 if (!email) {
362 return res.status(400).json({ error: 'Email is required.' });
363 }
364
365 // Find user
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()]
369 );
370
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.' });
374 }
375
376 const user = userResult.rows[0];
377
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
382
383 // Store reset token in customer_sessions table (repurpose for password resets)
384 await pool.query(
385 `INSERT INTO customer_sessions
386 (customer_user_id, token_hash, expires_at, ip_address, user_agent)
387 VALUES ($1, $2, $3, $4, $5)`,
388 [
389 user.customer_user_id,
390 resetTokenHash,
391 expiresAt,
392 req.ip || req.connection.remoteAddress,
393 'password_reset'
394 ]
395 );
396
397 // TODO: Send email with reset link
398 // const resetLink = `https://precisewebhosting.com.au/reset-password?token=${resetToken}`;
399 // await sendPasswordResetEmail(email, resetLink);
400
401 console.log(`Password reset token for ${email}: ${resetToken}`);
402
403 res.json({ message: 'If an account exists with that email, a password reset link has been sent.' });
404 } catch (error) {
405 console.error('Forgot password error:', error);
406 res.status(500).json({ error: 'Failed to process password reset request.' });
407 }
408});
409
410/**
411 * @api {post} /portal/auth/reset-password Reset Password with Token
412 * @apiName ResetPassword
413 * @apiGroup CustomerPortalAuth
414 * @apiVersion 1.0.0
415 *
416 * @apiDescription
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.
420 *
421 * @apiPermission none
422 *
423 * @apiHeader {String} Content-Type application/json
424 *
425 * @apiParam {String} token Reset token from email
426 * @apiParam {String} newPassword New password (min 8 characters)
427 *
428 * @apiSuccess {String} message Success message
429 *
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
434 *
435 * @apiExample {curl} Example Request:
436 * curl -X POST \
437 * -H "Content-Type: application/json" \
438 * -d '{
439 * "token": "abc123...",
440 * "newPassword": "NewSecure456"
441 * }' \
442 * "https://api.everydaytech.au/portal/auth/reset-password"
443 */
444router.post('/reset-password', async (req, res) => {
445 try {
446 const { token, newPassword } = req.body;
447
448 if (!token || !newPassword) {
449 return res.status(400).json({ error: 'Token and new password are required.' });
450 }
451
452 if (newPassword.length < 8) {
453 return res.status(400).json({ error: 'Password must be at least 8 characters long.' });
454 }
455
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'`,
464 [tokenHash]
465 );
466
467 if (sessionResult.rows.length === 0) {
468 return res.status(400).json({ error: 'Invalid or expired reset token.' });
469 }
470
471 const customerUserId = sessionResult.rows[0].customer_user_id;
472
473 // Hash new password
474 const passwordHash = await bcrypt.hash(newPassword, 10);
475
476 // Update password
477 await pool.query(
478 'UPDATE customer_users SET password_hash = $1, updated_at = NOW() WHERE customer_user_id = $2',
479 [passwordHash, customerUserId]
480 );
481
482 // Delete reset token
483 await pool.query(
484 'DELETE FROM customer_sessions WHERE token_hash = $1',
485 [tokenHash]
486 );
487
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',
491 [customerUserId]
492 );
493
494 // Log audit event
495 await logCustomerAudit(
496 customerUserId,
497 userResult.rows[0].tenant_id,
498 'password_reset',
499 'auth',
500 null,
501 {},
502 req.ip || req.connection.remoteAddress
503 );
504
505 res.json({ message: 'Password reset successfully.' });
506 } catch (error) {
507 console.error('Reset password error:', error);
508 res.status(500).json({ error: 'Failed to reset password.', details: error.message });
509 }
510});
511
512/**
513 * @api {post} /portal/auth/change-password Change Password
514 * @apiName ChangePassword
515 * @apiGroup CustomerPortalAuth
516 * @apiVersion 1.0.0
517 *
518 * @apiDescription
519 * Changes password for authenticated user. Requires current password verification.
520 * New password is bcrypt-hashed before storage. Logs audit event.
521 *
522 * @apiPermission customer
523 *
524 * @apiHeader {String} Authorization Bearer JWT token
525 * @apiHeader {String} Content-Type application/json
526 *
527 * @apiParam {String} currentPassword Current password (for verification)
528 * @apiParam {String} newPassword New password (min 8 characters)
529 *
530 * @apiSuccess {String} message Success message
531 *
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
538 *
539 * @apiExample {curl} Example Request:
540 * curl -X POST \
541 * -H "Authorization: Bearer eyJhbGc..." \
542 * -H "Content-Type: application/json" \
543 * -d '{
544 * "currentPassword": "OldPass123",
545 * "newPassword": "NewSecure456"
546 * }' \
547 * "https://api.everydaytech.au/portal/auth/change-password"
548 */
549router.post('/change-password', authenticateCustomer, async (req, res) => {
550 try {
551 const { currentPassword, newPassword } = req.body;
552
553 if (!currentPassword || !newPassword) {
554 return res.status(400).json({ error: 'Current password and new password are required.' });
555 }
556
557 if (newPassword.length < 8) {
558 return res.status(400).json({ error: 'Password must be at least 8 characters long.' });
559 }
560
561 // Get current password hash
562 const userResult = await pool.query(
563 'SELECT password_hash FROM customer_users WHERE customer_user_id = $1',
564 [req.customerUserId]
565 );
566
567 if (userResult.rows.length === 0) {
568 return res.status(404).json({ error: 'User not found.' });
569 }
570
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.' });
575 }
576
577 // Hash new password
578 const passwordHash = await bcrypt.hash(newPassword, 10);
579
580 // Update password
581 await pool.query(
582 'UPDATE customer_users SET password_hash = $1, updated_at = NOW() WHERE customer_user_id = $2',
583 [passwordHash, req.customerUserId]
584 );
585
586 // Log audit event
587 await logCustomerAudit(
588 req.customerUserId,
589 req.tenantId,
590 'password_change',
591 'auth',
592 null,
593 {},
594 req.ip || req.connection.remoteAddress
595 );
596
597 res.json({ message: 'Password changed successfully.' });
598 } catch (error) {
599 console.error('Change password error:', error);
600 res.status(500).json({ error: 'Failed to change password.', details: error.message });
601 }
602});
603
604module.exports = router;