diff --git a/services/api-gateway-node/index.js b/services/api-gateway-node/index.js index 4f11bb2..6ebcf92 100644 --- a/services/api-gateway-node/index.js +++ b/services/api-gateway-node/index.js @@ -816,6 +816,23 @@ app.get('/health', (req, res) => { }); app.get('/metrics', async (req, res) => { - res.set('Content-Type', promClient.register.contentType); - res.end(await promClient.register.metrics()); + res.set('Content-Type', promClient.register.contentType); + res.end(await promClient.register.metrics()); +}); + +function globalErrorHandler(err, req, res, _next) { + console.error('Unhandled error:', err.message, err.stack); + const status = err.status || err.statusCode || 500; + res.status(status).json({ error: 'Internal server error' }); +} +app.use(globalErrorHandler); + +process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down gracefully...'); + server.close(() => process.exit(0)); +}); + +process.on('SIGINT', () => { + console.log('SIGINT received, shutting down gracefully...'); + server.close(() => process.exit(0)); }); diff --git a/services/auth-service/index.js b/services/auth-service/index.js index 13e7f92..57098eb 100644 --- a/services/auth-service/index.js +++ b/services/auth-service/index.js @@ -121,6 +121,11 @@ if (!SCIM_API_KEY) { const jwtSecret = JWT_SECRET; +if (!process.env.POSTGRES_URL) { + console.error('FATAL: POSTGRES_URL environment variable is required'); + process.exit(1); +} + const sslConfig = process.env.POSTGRES_SSL === 'true' || NODE_ENV === 'production' ? { rejectUnauthorized: true } : false; @@ -130,11 +135,6 @@ const pool = new Pool({ ssl: sslConfig, }); -if (!process.env.POSTGRES_URL) { - console.error('FATAL: POSTGRES_URL environment variable is required'); - process.exit(1); -} - const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379'; const redisClient = redis.createClient({ url: REDIS_URL }); redisClient.on('error', (err) => console.log('Auth Redis Client Error', err)); @@ -494,7 +494,8 @@ async function initDB() { user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, mfa_secret VARCHAR(255) NOT NULL, backup_codes JSONB DEFAULT '[]'::jsonb, - mfa_enabled BOOLEAN DEFAULT false + mfa_enabled BOOLEAN DEFAULT false, + backup_codes_shown BOOLEAN DEFAULT false ); `); @@ -883,28 +884,70 @@ app.post('/mfa/setup', requireRole(), createUserRateLimiter('mfa_setup', 5), asy const secret = authenticator.generateSecret(); const backupCodes = Array.from({ length: 10 }, () => crypto.randomBytes(5).toString('hex').slice(0, 8).toUpperCase()); + const existing = await pool.query('SELECT backup_codes_shown FROM user_mfa WHERE user_id = $1', [userId]); + const alreadyShown = existing.rows[0]?.backup_codes_shown === true; + await pool.query(` INSERT INTO user_mfa (user_id, mfa_secret, backup_codes, mfa_enabled) VALUES ($1, $2, $3::jsonb, false) ON CONFLICT (user_id) DO UPDATE SET mfa_secret = EXCLUDED.mfa_secret, backup_codes = EXCLUDED.backup_codes, - mfa_enabled = false + mfa_enabled = false, + backup_codes_shown = false `, [userId, secret, JSON.stringify(backupCodes)]); const otpauth = authenticator.keyuri(req.user.email, 'Atlas Workforce', secret); - res.json({ - secret, - qr_code_uri: otpauth, - backup_codes: backupCodes - }); + const response = { secret, qr_code_uri: otpauth }; + if (alreadyShown) { + response.message = 'Backup codes were regenerated. Use /mfa/rotate-backup-codes to view new codes with re-authentication.'; + } else { + await pool.query('UPDATE user_mfa SET backup_codes_shown = true WHERE user_id = $1', [userId]); + response.backup_codes = backupCodes; + response.message = 'Save these backup codes. They will not be shown again.'; + } + + res.json(response); } catch (error) { console.error(error); res.status(500).json({ message: 'MFA setup failed' }); } }); +app.post('/mfa/rotate-backup-codes', requireRole(), createUserRateLimiter('mfa_verify', 5), async (req, res) => { + try { + const { token } = req.body; + if (!token) { + return res.status(400).json({ message: 'Current TOTP token is required to rotate backup codes' }); + } + + const userId = req.user.id; + const result = await pool.query('SELECT * FROM user_mfa WHERE user_id = $1', [userId]); + if (!result.rows[0]) { + return res.status(400).json({ message: 'MFA not set up' }); + } + + const isValid = authenticator.check(token, result.rows[0].mfa_secret); + if (!isValid) { + return res.status(400).json({ message: 'Invalid token' }); + } + + const newCodes = Array.from({ length: 10 }, () => crypto.randomBytes(5).toString('hex').slice(0, 8).toUpperCase()); + + await pool.query(` + UPDATE user_mfa SET backup_codes = $1::jsonb, backup_codes_shown = true WHERE user_id = $2 + `, [JSON.stringify(newCodes), userId]); + + await sendAuditEvent('auth.mfa_rotate_codes', userId, req.user.email); + + res.json({ message: 'Backup codes rotated successfully. Save these codes as they will not be shown again.', backup_codes: newCodes }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to rotate backup codes' }); + } +}); + app.post('/mfa/verify', requireRole(), createUserRateLimiter('mfa_verify', 10), async (req, res) => { try { const { token } = req.body; @@ -1431,8 +1474,9 @@ app.post('/saml/acs', createUserRateLimiter('saml_acs', 20), async (req, res) => console.warn('SAML_IDP_CERT not configured — signature verification disabled'); } + const doc = new DOMParser().parseFromString(decodedXml, 'text/xml'); + if (SAML_IDP_CERT) { - const doc = new DOMParser().parseFromString(decodedXml, 'text/xml'); const signatureNodes = doc.getElementsByTagNameNS ? doc.getElementsByTagNameNS('http://www.w3.org/2000/09/xmldsig#', 'Signature') : doc.getElementsByTagName('Signature'); @@ -1491,15 +1535,33 @@ app.post('/saml/acs', createUserRateLimiter('saml_acs', 20), async (req, res) => } } - const nameIdMatch = decodedXml.match(/]*>([^<]+)<\/saml2:NameID>/); - const emailMatch = decodedXml.match(/]*?Name="[^"]*email[^"]*"[^>]*>[\s\S]*?]*>([^<]+)<\/saml2:AttributeValue>/i); - const firstNameMatch = decodedXml.match(/]*?Name="[^"]*firstName[^"]*"[^>]*>[\s\S]*?]*>([^<]+)<\/saml2:AttributeValue>/i); - const lastNameMatch = decodedXml.match(/]*?Name="[^"]*lastName[^"]*"[^>]*>[\s\S]*?]*>([^<]+)<\/saml2:AttributeValue>/i); + const ns = 'urn:oasis:names:tc:SAML:2.0:assertion'; - const emailSimpleMatch = decodedXml.match(/]*>([^<]+)<\/NameID>/); - const email = emailMatch?.[1] || nameIdMatch?.[1] || emailSimpleMatch?.[1]; - const firstName = firstNameMatch?.[1] || ''; - const lastName = lastNameMatch?.[1] || ''; + let email = ''; + const nameIdNodes = doc.getElementsByTagNameNS ? doc.getElementsByTagNameNS(ns, 'NameID') : doc.getElementsByTagName('NameID'); + if (nameIdNodes.length > 0) { + email = nameIdNodes[0].textContent || ''; + } + + let firstName = ''; + let lastName = ''; + + const attrNodes = doc.getElementsByTagNameNS ? doc.getElementsByTagNameNS(ns, 'Attribute') : doc.getElementsByTagName('Attribute'); + for (let i = 0; i < attrNodes.length; i++) { + const name = attrNodes[i].getAttribute('Name') || ''; + const valNodes = attrNodes[i].getElementsByTagNameNS + ? attrNodes[i].getElementsByTagNameNS(ns, 'AttributeValue') + : attrNodes[i].getElementsByTagName('AttributeValue'); + const value = valNodes.length > 0 ? (valNodes[0].textContent || '') : ''; + const lower = name.toLowerCase(); + if (lower.includes('email') && !email) { + email = value; + } else if (lower.includes('firstname') || lower.includes('givenname')) { + firstName = value; + } else if (lower.includes('lastname') || lower.includes('surname')) { + lastName = value; + } + } const displayName = `${firstName} ${lastName}`.trim() || email; if (!email) { @@ -1609,9 +1671,7 @@ app.post('/auth/passwordless/request', createUserRateLimiter('passwordless_reque await sendAuditEvent('auth.passwordless_requested', user.id, email); res.json({ - message: 'Magic link sent', - token, - expires_in: 900 + message: 'Check your email for the login link' }); } catch (err) { console.error(err);