Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
58 changes: 58 additions & 0 deletions config/webauth.js
Original file line number Diff line number Diff line change
@@ -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();
258 changes: 258 additions & 0 deletions controllers/security.js
Original file line number Diff line number Diff line change
@@ -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');
}
};
Loading