diff --git a/app.js b/app.js index 97150d8..45272a5 100644 --- a/app.js +++ b/app.js @@ -367,3 +367,35 @@ To avoid this, set BASE_URL to the HTTPS endpoint and always access the app thro }); module.exports = app; + +/** + * Security routes (biometric auth, 2FA) + */ +const securityController = require('./controllers/security'); + +// Add these routes after your existing account routes +app.get('/account/security', passportConfig.isAuthenticated, securityController.getSecuritySettings); +app.get('/security/2fa/setup', passportConfig.isAuthenticated, securityController.getTwoFactorSetup); +app.post('/security/2fa/verify', passportConfig.isAuthenticated, securityController.postTwoFactorVerify); +app.post('/security/2fa/disable', passportConfig.isAuthenticated, securityController.postTwoFactorDisable); +app.get('/security/webauthn/register-options', passportConfig.isAuthenticated, securityController.getWebAuthnRegistration); +app.post('/security/webauthn/register', passportConfig.isAuthenticated, securityController.postWebAuthnRegistration); +app.get('/security/webauthn/auth-options', securityController.getWebAuthnAuthentication); +app.post('/security/webauthn/login', securityController.postWebAuthnLogin); +app.post('/security/webauthn/remove', passportConfig.isAuthenticated, securityController.postRemoveWebAuthnCredential); +app.post('/login/2fa/verify', securityController.postTwoFactorVerify); + +// Update the login route to handle biometric login +app.get('/login/biometric', userController.getLogin); + +// Biometric Auth Rate Limiter Config +const biometricLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // 10 attempts per 15 minutes + standardHeaders: true, + legacyHeaders: false, +}); + +// Apply to biometric routes +app.post('/security/webauthn/login', biometricLimiter); +app.post('/security/webauthn/register', biometricLimiter); \ No newline at end of file diff --git a/config/webauth.js b/config/webauth.js new file mode 100644 index 0000000..0fbd39c --- /dev/null +++ b/config/webauth.js @@ -0,0 +1,58 @@ +const { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } = require('@simplewebauthn/server'); +const { isoBase64URL, isoUint8Array } = require('@simplewebauthn/server/helpers'); + +class WebAuthnConfig { + constructor() { + this.rpID = process.env.DOMAIN || 'localhost'; + this.rpName = process.env.APP_NAME || 'Express Starter'; + this.origin = process.env.BASE_URL || `http://localhost:${process.env.PORT || 8080}`; + } + + async generateRegistrationOptions(user) { + return await generateRegistrationOptions({ + rpName: this.rpName, + rpID: this.rpID, + userID: user._id.toString(), + userName: user.email, + userDisplayName: user.profile.name || user.email, + attestationType: 'none', + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + }); + } + + async generateAuthenticationOptions() { + return await generateAuthenticationOptions({ + rpID: this.rpID, + userVerification: 'preferred', + }); + } + + async verifyRegistrationResponse(attestationResponse, expectedChallenge) { + return await verifyRegistrationResponse({ + response: attestationResponse, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + }); + } + + async verifyAuthenticationResponse(assertionResponse, expectedChallenge, credential) { + return await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + authenticator: { + credentialID: isoBase64URL.toBuffer(credential.credentialID), + credentialPublicKey: isoUint8Array.fromHex(credential.publicKey), + counter: credential.counter, + transports: credential.transports, + }, + }); + } +} + +module.exports = new WebAuthnConfig(); \ No newline at end of file diff --git a/controllers/security.js b/controllers/security.js new file mode 100644 index 0000000..3eb77f1 --- /dev/null +++ b/controllers/security.js @@ -0,0 +1,258 @@ +const speakeasy = require('speakeasy'); +const QRCode = require('qrcode'); +const webauthnConfig = require('../config/webauthn'); +const User = require('../models/User'); + +exports.getTwoFactorSetup = async (req, res) => { + try { + const user = await User.findById(req.user.id); + + if (user.twoFactorEnabled) { + req.flash('info', { msg: 'Two-factor authentication is already enabled.' }); + return res.redirect('/account'); + } + + // Generate a new secret + const secret = speakeasy.generateSecret({ + name: `${process.env.APP_NAME || 'Express App'} (${user.email})`, + issuer: process.env.APP_NAME || 'Express App' + }); + + // Generate QR code + const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url); + + // Store temporary secret in session + req.session.tempTwoFactorSecret = secret.base32; + + res.render('account/twofactor-setup', { + title: 'Setup Two-Factor Authentication', + secret: secret.base32, + qrCodeUrl + }); + } catch (error) { + console.error('Two-factor setup error:', error); + req.flash('errors', { msg: 'Error setting up two-factor authentication.' }); + res.redirect('/account'); + } +}; + +exports.postTwoFactorVerify = async (req, res) => { + try { + const { token } = req.body; + const user = await User.findById(req.user.id); + + if (user.twoFactorEnabled) { + req.flash('errors', { msg: 'Two-factor authentication is already enabled.' }); + return res.redirect('/account'); + } + + const verified = speakeasy.totp.verify({ + secret: req.session.tempTwoFactorSecret, + encoding: 'base32', + token, + window: 1 + }); + + if (verified) { + user.twoFactorEnabled = true; + user.twoFactorSecret = req.session.tempTwoFactorSecret; + user.generateBackupCodes(); + await user.save(); + + req.session.tempTwoFactorSecret = null; + + req.flash('success', { + msg: 'Two-factor authentication enabled successfully. Please save your backup codes.' + }); + + res.render('account/backup-codes', { + title: 'Backup Codes', + backupCodes: user.twoFactorBackupCodes + }); + } else { + req.flash('errors', { msg: 'Invalid verification code. Please try again.' }); + res.redirect('/security/2fa/setup'); + } + } catch (error) { + console.error('Two-factor verification error:', error); + req.flash('errors', { msg: 'Error verifying two-factor authentication.' }); + res.redirect('/security/2fa/setup'); + } +}; + +exports.postTwoFactorDisable = async (req, res) => { + try { + const { token } = req.body; + const user = await User.findById(req.user.id); + + if (!user.twoFactorEnabled) { + req.flash('errors', { msg: 'Two-factor authentication is not enabled.' }); + return res.redirect('/account'); + } + + const verified = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token, + window: 1 + }); + + if (verified) { + user.twoFactorEnabled = false; + user.twoFactorSecret = undefined; + user.twoFactorBackupCodes = []; + await user.save(); + + req.flash('success', { msg: 'Two-factor authentication disabled successfully.' }); + } else { + req.flash('errors', { msg: 'Invalid verification code. Please try again.' }); + } + + res.redirect('/account'); + } catch (error) { + console.error('Two-factor disable error:', error); + req.flash('errors', { msg: 'Error disabling two-factor authentication.' }); + res.redirect('/account'); + } +}; + +exports.getWebAuthnRegistration = async (req, res) => { + try { + const user = await User.findById(req.user.id); + const options = await webauthnConfig.generateRegistrationOptions(user); + + req.session.webauthnChallenge = options.challenge; + + res.json(options); + } catch (error) { + console.error('WebAuthn registration options error:', error); + res.status(500).json({ error: 'Failed to generate registration options' }); + } +}; + +exports.postWebAuthnRegistration = async (req, res) => { + try { + const { attestationResponse, deviceName } = req.body; + const user = await User.findById(req.user.id); + + const verification = await webauthnConfig.verifyRegistrationResponse( + attestationResponse, + req.session.webauthnChallenge + ); + + if (verification.verified) { + const { credential } = verification.registrationInfo; + + user.addWebAuthnCredential({ + credentialID: credential.id, + publicKey: Buffer.from(credential.publicKey).toString('hex'), + counter: credential.counter, + transports: attestationResponse.response.transports || [], + deviceType: this.getDeviceType(attestationResponse), + name: deviceName || 'Security Key' + }); + + await user.save(); + req.session.webauthnChallenge = null; + + res.json({ success: true, message: 'Biometric credential registered successfully' }); + } else { + res.status(400).json({ error: 'Registration verification failed' }); + } + } catch (error) { + console.error('WebAuthn registration error:', error); + res.status(500).json({ error: 'Registration failed' }); + } +}; + +exports.getWebAuthnAuthentication = async (req, res) => { + try { + const options = await webauthnConfig.generateAuthenticationOptions(); + req.session.webauthnChallenge = options.challenge; + res.json(options); + } catch (error) { + console.error('WebAuthn authentication options error:', error); + res.status(500).json({ error: 'Failed to generate authentication options' }); + } +}; + +exports.postWebAuthnLogin = async (req, res, next) => { + try { + const { assertionResponse, email } = req.body; + + const user = await User.findOne({ email: email.toLowerCase() }); + if (!user) { + return res.status(400).json({ error: 'Invalid credentials' }); + } + + // Find the credential used for authentication + const credential = user.webauthnCredentials.find( + cred => cred.credentialID === assertionResponse.id + ); + + if (!credential) { + return res.status(400).json({ error: 'Unknown credential' }); + } + + const verification = await webauthnConfig.verifyAuthenticationResponse( + assertionResponse, + req.session.webauthnChallenge, + credential + ); + + if (verification.verified) { + // Update credential counter + credential.counter = verification.authenticationInfo.newCounter; + await user.save(); + + req.session.webauthnChallenge = null; + + // Log the user in + req.logIn(user, (err) => { + if (err) { return next(err); } + res.json({ success: true, redirectTo: req.session.returnTo || '/' }); + }); + } else { + res.status(400).json({ error: 'Authentication verification failed' }); + } + } catch (error) { + console.error('WebAuthn login error:', error); + res.status(500).json({ error: 'Authentication failed' }); + } +}; + +exports.getDeviceType = (attestationResponse) => { + // Simple device type detection based on user agent and response + const ua = attestationResponse.clientExtensionResults?.userAgent; + if (ua?.includes('Windows')) return 'Windows Hello'; + if (ua?.includes('Mac')) return 'Touch ID'; + if (ua?.includes('Android')) return 'Android Biometric'; + if (ua?.includes('iPhone')) return 'Face ID/Touch ID'; + return 'Security Key'; +}; + +exports.getSecuritySettings = async (req, res) => { + const user = await User.findById(req.user.id); + res.render('account/security', { + title: 'Security Settings', + twoFactorEnabled: user.twoFactorEnabled, + webauthnCredentials: user.webauthnCredentials + }); +}; + +exports.postRemoveWebAuthnCredential = async (req, res) => { + try { + const { credentialId } = req.body; + const user = await User.findById(req.user.id); + + user.removeWebAuthnCredential(credentialId); + await user.save(); + + req.flash('success', { msg: 'Biometric credential removed successfully.' }); + res.redirect('/account/security'); + } catch (error) { + console.error('Remove WebAuthn credential error:', error); + req.flash('errors', { msg: 'Error removing biometric credential.' }); + res.redirect('/account/security'); + } +}; \ No newline at end of file diff --git a/controllers/user.js b/controllers/user.js index a0fd98d..1ef56f6 100644 --- a/controllers/user.js +++ b/controllers/user.js @@ -706,3 +706,104 @@ exports.postLogoutEverywhere = async (req, res, next) => { return next(err); } }; + +const speakeasy = require('speakeasy'); + +// Update the postLogin method to handle 2FA +exports.postLogin = async (req, res, next) => { + try { + const { email, password, token } = req.body; + + // Find user and validate password (your existing logic) + const user = await User.findOne({ email: email.toLowerCase() }); + if (!user || !(await user.comparePassword(password))) { + req.flash('errors', { msg: 'Invalid email or password.' }); + return res.redirect('/login'); + } + + // Check if 2FA is enabled + if (user.twoFactorEnabled) { + if (!token) { + // Store user ID in session and show 2FA prompt + req.session.twoFactorUserId = user._id.toString(); + return res.render('account/twofactor-prompt', { + title: 'Two-Factor Authentication', + email: user.email + }); + } + + // Verify 2FA token + const verified = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token, + window: 1 + }); + + // Check backup codes if TOTP fails + if (!verified && !user.verifyBackupCode(token)) { + req.flash('errors', { msg: 'Invalid two-factor authentication code.' }); + return res.render('account/twofactor-prompt', { + title: 'Two-Factor Authentication', + email: user.email + }); + } + + await user.save(); // Save if backup code was used + } + + // Continue with normal login + req.logIn(user, (err) => { + if (err) { return next(err); } + req.session.twoFactorUserId = null; + req.flash('success', { msg: 'Success! You are logged in.' }); + res.redirect(req.session.returnTo || '/'); + }); + } catch (error) { + next(error); + } +}; + +// Add 2FA verification endpoint +exports.postTwoFactorVerify = async (req, res, next) => { + try { + const { token } = req.body; + + if (!req.session.twoFactorUserId) { + req.flash('errors', { msg: 'Two-factor authentication session expired.' }); + return res.redirect('/login'); + } + + const user = await User.findById(req.session.twoFactorUserId); + if (!user) { + req.flash('errors', { msg: 'User not found.' }); + return res.redirect('/login'); + } + + const verified = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token, + window: 1 + }); + + if (!verified && !user.verifyBackupCode(token)) { + req.flash('errors', { msg: 'Invalid two-factor authentication code.' }); + return res.render('account/twofactor-prompt', { + title: 'Two-Factor Authentication', + email: user.email + }); + } + + await user.save(); // Save if backup code was used + + req.logIn(user, (err) => { + if (err) { return next(err); } + req.session.twoFactorUserId = null; + req.flash('success', { msg: 'Success! You are logged in.' }); + res.redirect(req.session.returnTo || '/'); + }); + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/models/User.js b/models/User.js index 468d61a..068aa8e 100644 --- a/models/User.js +++ b/models/User.js @@ -169,3 +169,64 @@ userSchema.methods.verifyTokenAndIp = function verifyTokenAndIp(token, ip, token const User = mongoose.model('User', userSchema); module.exports = User; + +const userSchema = new mongoose.Schema({ + // ... your existing fields ... + + // 2FA fields + twoFactorEnabled: { type: Boolean, default: false }, + twoFactorSecret: String, + twoFactorBackupCodes: [String], + + // Biometric authentication fields + webauthnCredentials: [{ + credentialID: String, + publicKey: String, + counter: Number, + transports: [String], + deviceType: String, + name: String, + createdAt: { type: Date, default: Date.now } + }], + + // Session management for "logout everywhere" + currentSessions: [{ + sessionId: String, + userAgent: String, + ipAddress: String, + createdAt: { type: Date, default: Date.now }, + lastActive: { type: Date, default: Date.now } + }] +}); + +// Add method to generate 2FA backup codes +userSchema.methods.generateBackupCodes = function() { + const codes = []; + for (let i = 0; i < 10; i++) { + codes.push(require('crypto').randomBytes(5).toString('hex').toUpperCase()); + } + this.twoFactorBackupCodes = codes; + return codes; +}; + +// Add method to verify 2FA backup code +userSchema.methods.verifyBackupCode = function(code) { + const index = this.twoFactorBackupCodes.indexOf(code.toUpperCase()); + if (index > -1) { + this.twoFactorBackupCodes.splice(index, 1); + return true; + } + return false; +}; + +// Add method to add WebAuthn credential +userSchema.methods.addWebAuthnCredential = function(credential) { + this.webauthnCredentials.push(credential); +}; + +// Add method to remove WebAuthn credential +userSchema.methods.removeWebAuthnCredential = function(credentialId) { + this.webauthnCredentials = this.webauthnCredentials.filter( + cred => cred.credentialID !== credentialId + ); +}; \ No newline at end of file diff --git a/views/account/2-fac-prompt.pug b/views/account/2-fac-prompt.pug new file mode 100644 index 0000000..c44c3e8 --- /dev/null +++ b/views/account/2-fac-prompt.pug @@ -0,0 +1,23 @@ +extends ../layout + +block content + .container + .row.justify-content-center + .col-md-6 + .card + .card-header + h5.card-title Two-Factor Authentication Required + .card-body + p Please enter the verification code from your authenticator app for #{email}. + + if messages.errors + .alert.alert-danger + for error in messages.errors + div= error.msg + + form(method='POST' action='/login/2fa/verify') + .form-group + label(for='token') Verification Code: + input.form-control(type='text' name='token' placeholder='000000' required) + button.btn.btn-primary(type='submit') Verify + a.btn.btn-link(href='/login') Back to Login \ No newline at end of file diff --git a/views/account/2-fac-setup.pug b/views/account/2-fac-setup.pug new file mode 100644 index 0000000..3450d11 --- /dev/null +++ b/views/account/2-fac-setup.pug @@ -0,0 +1,28 @@ +extends ../layout + +block content + .container + .row.justify-content-center + .col-md-6 + .card + .card-header + h5.card-title Setup Two-Factor Authentication + .card-body + p Scan the QR code with your authenticator app (Google Authenticator, Authy, etc.) + + .text-center + img(src=qrCodeUrl alt='QR Code' style='max-width: 200px;') + + .form-group + label Manual setup code: + input.form-control(type='text' value=secret readonly) + small.form-text.text-muted If you can't scan the QR code, enter this code manually. + + hr + + form(method='POST' action='/security/2fa/verify') + .form-group + label(for='token') Enter verification code: + input.form-control(type='text' name='token' placeholder='000000' required) + button.btn.btn-primary(type='submit') Verify and Enable + a.btn.btn-link(href='/account') Cancel \ No newline at end of file diff --git a/views/account/bkup-codes.pug b/views/account/bkup-codes.pug new file mode 100644 index 0000000..9ca2393 --- /dev/null +++ b/views/account/bkup-codes.pug @@ -0,0 +1,23 @@ +extends ../layout + +block content + .container + .row.justify-content-center + .col-md-8 + .card + .card-header + h5.card-title Backup Codes + .card-body + .alert.alert-warning + h6 Important! + p Save these backup codes in a secure place. Each code can be used once if you lose access to your authenticator app. + + .text-center + each code in backupCodes + .h5= code + br + + p.text-muted These codes are one-time use and will be shown only once. + + .text-center + a.btn.btn-primary(href='/account') Continue to Account \ No newline at end of file diff --git a/views/account/login.pug b/views/account/login.pug index 5645fd8..a56b4f4 100644 --- a/views/account/login.pug +++ b/views/account/login.pug @@ -71,3 +71,49 @@ block content document.getElementById('password').value = ''; } }); + + +// Add this after the regular login form +.row.mt-4 + .col-12 + .text-center + hr + p Or + button.btn.btn-outline-primary(type='button' onclick='startBiometricLogin()') + i.fas.fa-fingerprint + | Login with Biometrics + + script. + async function startBiometricLogin() { + try { + // Get authentication options + const optionsResponse = await fetch('/security/webauthn/auth-options'); + const options = await optionsResponse.json(); + + // Get credential + const credential = await navigator.credentials.get({ + publicKey: options + }); + + // Send to server for verification + const verificationResponse = await fetch('/security/webauthn/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + assertionResponse: credential, + email: document.querySelector('input[name="email"]').value + }) + }); + + const result = await verificationResponse.json(); + + if (result.success) { + window.location.href = result.redirectTo; + } else { + alert('Biometric login failed: ' + result.error); + } + } catch (error) { + console.error('Biometric login error:', error); + alert('Biometric login not available or failed. Please use regular login.'); + } + } \ No newline at end of file diff --git a/views/account/security.pug b/views/account/security.pug new file mode 100644 index 0000000..9ba05de --- /dev/null +++ b/views/account/security.pug @@ -0,0 +1,107 @@ +extends ../layout + +block content + .container + .row + .col-sm-12 + h1 Security Settings + + if messages.errors + .alert.alert-danger + for error in messages.errors + div= error.msg + + if messages.success + .alert.alert-success + for success in messages.success + div= success.msg + + .row + .col-md-6 + .card + .card-header + h5.card-title Two-Factor Authentication + .card-body + if user.twoFactorEnabled + p.text-success + i.fas.fa-check-circle + | Two-factor authentication is enabled + form(method='POST' action='/security/2fa/disable') + .form-group + label(for='token') Enter verification code to disable: + input.form-control(type='text' name='token' placeholder='000000' required) + button.btn.btn-warning(type='submit') Disable 2FA + else + p Two-factor authentication adds an extra layer of security to your account. + a.btn.btn-primary(href='/security/2fa/setup') Enable 2FA + + .col-md-6 + .card + .card-header + h5.card-title Biometric Authentication + .card-body + p Use Windows Hello, Touch ID, Face ID, or security keys to log in. + + if webauthnCredentials.length > 0 + h6 Registered Devices: + ul.list-group + for credential in webauthnCredentials + li.list-group-item.d-flex.justify-content-between.align-items-center + div + strong= credential.name + br + small.text-muted= credential.deviceType + form(method='POST' action='/security/webauthn/remove') + input(type='hidden' name='credentialId' value=credential.credentialID) + button.btn.btn-sm.btn-outline-danger(type='submit') Remove + + .mt-3 + button.btn.btn-primary(type='button' onclick='registerBiometric()') Register New Biometric Device + + .row.mt-4 + .col-12 + .card + .card-header + h5.card-title Active Sessions + .card-body + p View and manage your active login sessions. + a.btn.btn-info(href='/account/sessions') Manage Sessions + + script. + async function registerBiometric() { + try { + // Get registration options + const optionsResponse = await fetch('/security/webauthn/register-options'); + const options = await optionsResponse.json(); + + // Get device name from user + const deviceName = prompt('Enter a name for this device:', 'My Security Key'); + + // Create credential + const credential = await navigator.credentials.create({ + publicKey: options + }); + + // Send credential to server + const verificationResponse = await fetch('/security/webauthn/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + attestationResponse: credential, + deviceName: deviceName + }) + }); + + const result = await verificationResponse.json(); + + if (result.success) { + alert('Biometric device registered successfully!'); + location.reload(); + } else { + alert('Registration failed: ' + result.error); + } + } catch (error) { + console.error('Biometric registration error:', error); + alert('Registration failed. Please try again.'); + } + } \ No newline at end of file