Overview
The Crypto Shop Backend provides two-factor authentication (2FA) using Time-based One-Time Passwords (TOTP) via the Speakeasy library. Users can enable 2FA by scanning a QR code with authenticator apps like Google Authenticator or Authy.
User Model Configuration
The User model includes fields for storing 2FA configuration.
2FA Fields
File: src/models/User.js:45
const userSchema = new mongoose.Schema({
// ... other fields
twoFactorEnabled: {
type: Boolean,
default: false
},
twoFactorSecret: {
type: String,
default: null
},
// ... other fields
});
| Field | Type | Description |
|---|
twoFactorEnabled | Boolean | Whether 2FA is active |
twoFactorSecret | String | Base32-encoded secret for TOTP generation |
The twoFactorSecret field contains sensitive cryptographic material. Ensure it is properly protected and never exposed in API responses.
Enable 2FA Flow
The 2FA setup process involves generating a secret and QR code for the user to scan.
Generate Secret and QR Code
File: src/api/security/enable2FA.js:5
import User from "../../models/User.js";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
export const enable2FA = async (req, res) => {
try {
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ success: false, error: 'User not found' });
}
if (user.twoFactorEnabled) {
return res.status(400).json({
success: false,
error: '2FA is already enabled'
});
}
// Generate secret with 32 character length
const secret = speakeasy.generateSecret({
name: `CryptoShop (${user.email})`,
issuer: 'CryptoShop',
length: 32
});
// Generate QR code from TOTP URL
const qrCode = await QRCode.toDataURL(secret.otpauth_url);
// Save secret to user (not yet enabled)
user.twoFactorSecret = secret.base32;
await user.save();
res.json({
success: true,
message: '2FA setup initiated',
qrCode, // Data URL for QR code image
secret: secret.base32 // Manual entry backup
});
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
};
Secret Generation Parameters
| Parameter | Value | Purpose |
|---|
name | CryptoShop ([email protected]) | Display name in authenticator app |
issuer | CryptoShop | Organization name |
length | 32 | Secret length (higher = more secure) |
The secret is saved immediately but 2FA is not enabled until the user successfully verifies a code. This allows users to cancel the setup if needed.
Verify 2FA Code
After scanning the QR code, users must verify they can generate valid codes.
Code Verification
File: src/api/security/verify2FA.js:4
import User from "../../models/User.js";
import speakeasy from "speakeasy";
export const verify2FA = async (req, res) => {
try {
const { code } = req.body;
if (!code) {
return res.status(400).json({
success: false,
error: '2FA code is required'
});
}
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ success: false, error: 'User not found' });
}
if (!user.twoFactorSecret) {
return res.status(400).json({
success: false,
error: '2FA is not initialized'
});
}
// Verify the TOTP code
const verified = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: 'base32',
token: code,
window: 2 // Allow 2 time steps before/after current
});
if (!verified) {
return res.status(401).json({
success: false,
error: 'Invalid 2FA code'
});
}
// Enable 2FA for the user
user.twoFactorEnabled = true;
await user.save();
res.json({
success: true,
message: '2FA enabled successfully'
});
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
};
Verification Parameters
| Parameter | Value | Description |
|---|
secret | User’s 2FA secret | Base32-encoded secret |
encoding | base32 | Secret encoding format |
token | User-provided code | 6-digit TOTP code |
window | 2 | Time step tolerance (±60 seconds) |
The window parameter allows codes from 2 time steps before and after the current time, accounting for clock drift between server and client devices.
TOTP Algorithm
Time-based One-Time Password (TOTP) generates codes based on the current time.
How TOTP Works
- Shared Secret: Server and authenticator app share a secret key
- Time Counter: Current Unix time divided by 30 seconds
- HMAC Generation: HMAC-SHA1 hash of counter with secret
- Code Extraction: 6-digit code derived from hash
- Verification: Server compares user code with generated code
Code Validity Window
Time Step: 30 seconds
Window: 2
Valid codes at 12:00:00:
- 11:59:00 (1 step before)
- 11:59:30 (current - 1)
- 12:00:00 (current)
- 12:00:30 (current + 1)
- 12:01:00 (1 step after)
Integration with Authentication
Modify login flow to check for 2FA:
export const login = async (req, res) => {
try {
const { email, password, twoFactorCode } = req.body;
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.matchPassword(password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check if 2FA is enabled
if (user.twoFactorEnabled) {
if (!twoFactorCode) {
return res.status(200).json({
requires2FA: true,
message: '2FA code required'
});
}
// Verify 2FA code
const verified = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: 'base32',
token: twoFactorCode,
window: 2
});
if (!verified) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
}
// Generate tokens and continue login...
const { accessToken, refreshToken } = generateTokens(user._id, user.role);
res.cookie('accessToken', accessToken, COOKIE_OPTIONS);
res.cookie('refreshToken', refreshToken, COOKIE_OPTIONS);
res.json({ message: 'Login successful', user: {...} });
} catch (error) {
res.status(500).json({ error: error.message });
}
};
Disable 2FA
Implement an endpoint to disable 2FA:
export const disable2FA = async (req, res) => {
try {
const { code, password } = req.body;
const user = await User.findById(req.user.id).select('+password');
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Verify password
if (!(await user.matchPassword(password))) {
return res.status(401).json({ error: 'Invalid password' });
}
if (!user.twoFactorEnabled) {
return res.status(400).json({ error: '2FA is not enabled' });
}
// Verify 2FA code before disabling
const verified = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: 'base32',
token: code,
window: 2
});
if (!verified) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
// Disable 2FA
user.twoFactorEnabled = false;
user.twoFactorSecret = null;
await user.save();
res.json({ message: '2FA disabled successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
};
Always require both password and valid 2FA code before disabling 2FA to prevent unauthorized changes.
Backup Codes
Implement backup codes for account recovery:
import crypto from 'crypto';
const generateBackupCodes = () => {
const codes = [];
for (let i = 0; i < 10; i++) {
// Generate 8-character alphanumeric codes
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
codes.push(code);
}
return codes;
};
// Add to User schema
const userSchema = new mongoose.Schema({
// ... other fields
backupCodes: [{
code: String,
used: { type: Boolean, default: false }
}]
});
// Generate codes when enabling 2FA
export const enable2FA = async (req, res) => {
// ... existing code
const backupCodes = generateBackupCodes();
user.backupCodes = backupCodes.map(code => ({ code, used: false }));
await user.save();
res.json({
success: true,
qrCode,
secret: secret.base32,
backupCodes // Display once, user must save them
});
};
Backup codes should be displayed only once during 2FA setup. Users must save them in a secure location.
Security Best Practices
1. Rate Limit 2FA Attempts
import rateLimit from 'express-rate-limit';
const twoFactorLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 attempts per 15 minutes
message: 'Too many 2FA attempts'
});
app.post('/api/security/verify-2fa', twoFactorLimiter, verify2FA);
2. Audit Logging
export const verify2FA = async (req, res) => {
// ... verification code
if (verified) {
console.log(`2FA enabled for user ${user.email} at ${new Date()}`);
} else {
console.warn(`Failed 2FA verification for user ${user.email} from IP ${req.ip}`);
}
// ... response
};
3. Secure Secret Storage
Never log or expose the twoFactorSecret in API responses. This secret must remain confidential.
// Good: Exclude secret from response
const userResponse = {
id: user._id,
email: user.email,
twoFactorEnabled: user.twoFactorEnabled
// DO NOT include twoFactorSecret
};
// Bad: Never do this
res.json({ user }); // May expose twoFactorSecret
4. Require Re-authentication
Require password verification for sensitive 2FA operations:
export const enable2FA = async (req, res) => {
const { password } = req.body;
const user = await User.findById(req.user.id).select('+password');
// Verify password before allowing 2FA setup
if (!(await user.matchPassword(password))) {
return res.status(401).json({ error: 'Invalid password' });
}
// Continue with 2FA setup...
};
Dependencies
{
"speakeasy": "^2.0.0",
"qrcode": "^1.5.4"
}
Client-Side Implementation
Display QR Code
fetch('/api/security/enable-2fa', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'user_password' })
})
.then(res => res.json())
.then(data => {
// Display QR code
const img = document.createElement('img');
img.src = data.qrCode;
document.body.appendChild(img);
// Show manual entry code
console.log('Manual entry:', data.secret);
});
Verify Code
const verifyCode = (code) => {
fetch('/api/security/verify-2fa', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert('2FA enabled successfully!');
} else {
alert('Invalid code');
}
});
};
Testing
Manual Testing
- Enable 2FA and scan QR code with Google Authenticator
- Enter the 6-digit code to verify
- Test login with 2FA code
- Test invalid codes (should fail)
- Test backup codes if implemented
Automated Testing
import speakeasy from 'speakeasy';
const testSecret = 'JBSWY3DPEHPK3PXP';
// Generate valid code
const validCode = speakeasy.totp({
secret: testSecret,
encoding: 'base32'
});
// Test verification
const verified = speakeasy.totp.verify({
secret: testSecret,
encoding: 'base32',
token: validCode,
window: 2
});
console.log('Verification:', verified); // true
Troubleshooting
Code Always Invalid
- Check server and client device time synchronization
- Verify secret encoding is
base32
- Increase
window parameter to allow more time drift
QR Code Not Scanning
- Ensure QR code image is large enough (minimum 200x200px)
- Verify
otpauth_url format is correct
- Try manual entry with the base32 secret
Time Sync Issues
TOTP relies on accurate time synchronization. Ensure server time is synchronized with NTP.
# Check server time
date
# Sync with NTP (Linux)
sudo ntpdate -s time.nist.gov