Skip to main content

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
});
FieldTypeDescription
twoFactorEnabledBooleanWhether 2FA is active
twoFactorSecretStringBase32-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

ParameterValuePurpose
nameCryptoShop ([email protected])Display name in authenticator app
issuerCryptoShopOrganization name
length32Secret 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

ParameterValueDescription
secretUser’s 2FA secretBase32-encoded secret
encodingbase32Secret encoding format
tokenUser-provided code6-digit TOTP code
window2Time 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

  1. Shared Secret: Server and authenticator app share a secret key
  2. Time Counter: Current Unix time divided by 30 seconds
  3. HMAC Generation: HMAC-SHA1 hash of counter with secret
  4. Code Extraction: 6-digit code derived from hash
  5. 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

  1. Enable 2FA and scan QR code with Google Authenticator
  2. Enter the 6-digit code to verify
  3. Test login with 2FA code
  4. Test invalid codes (should fail)
  5. 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

  1. Check server and client device time synchronization
  2. Verify secret encoding is base32
  3. Increase window parameter to allow more time drift

QR Code Not Scanning

  1. Ensure QR code image is large enough (minimum 200x200px)
  2. Verify otpauth_url format is correct
  3. 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

Build docs developers (and LLMs) love