Biometric Authentication with SimpleWebAuthn and Node.js

Biometric Authentication with SimpleWebAuthn and Node.js

Biometric Login

Biometric authentication provides an additional layer of security by using unique biological characteristics, such as fingerprints or facial recognition. In this tutorial, we’ll use WebAuthn, a standard for biometric authentication, to build a secure login system. We’ll create a React Progressive Web App (PWA) for the frontend and a Node.js backend to handle the WebAuthn operations.

Understanding WebAuthn

WebAuthn (Web Authentication) is a web standard designed to make authentication more secure and user-friendly. Instead of using passwords, which can be vulnerable to theft and phishing, WebAuthn allows users to log in using biometrics (like fingerprints or facial recognition) or hardware security keys. Here’s a simple breakdown:

  • Public Key Cryptography: WebAuthn uses cryptographic keys, where the private key stays secure on the user’s device and the public key is stored on the server.
  • Biometrics: Uses unique biological characteristics, such as fingerprints or facial recognition.
  • Security Keys: Physical devices that connect to your computer or phone and provide a secure way to log in.

SimpleWebAuthn is a library that simplifies the implementation of WebAuthn in your applications. It provides an easy-to-use API for handling WebAuthn registration and authentication processes. It helps developers integrate biometric and security key authentication without dealing with the complex details of the WebAuthn protocol.

Technical Flow of Biometric Authentication Using WebAuthn

Let’s break down the technical flow of biometric authentication using WebAuthn. This will help us understand how the various components interact to provide secure and seamless authentication.

Biometric Registration:

  1. The user initiates the biometric registration process on the client side.
  2. The server generates a registration challenge using WebAuthn and sends it to the client.
  3. The client presents the challenge to the user’s device (biometric sensor). The device then returns a signed response (credential) to the client.
  4. The server verifies the response from the client, ensuring that it’s valid and signed correctly. If verified, the server saves the credentials in the database.

Biometric Authentication:

  1. The user initiates the login process on the client side.
  2. The server generates an authentication challenge and sends it to the client.
  3. The client presents the challenge to the user’s device. The device returns a signed response to the client.
  4. The server verifies the response, checking that it matches the stored credentials. If verified, the server generates a JWT token and returns it to the client.

Setting Up the Backend

We’ll build a Node.js server that handles the WebAuthn registration and authentication processes. This involves creating endpoints for registration options, registration, authentication options, and authentication.

First, initialize a new Node.js project:

mkdir biometric-auth-backend
cd biometric-auth-backend
npm init -y

This command creates a package.json file with default settings.

Install the required packages:

npm install express body-parser cors mysql2 bcrypt jsonwebtoken express-session @simplewebauthn/server node-cache

These packages are necessary for our server:

  • express for handling HTTP requests.
  • body-parser for parsing request bodies.
  • cors for allowing cross-origin requests.
  • mysql2 for connecting to a MySQL database.
  • bcrypt for hashing passwords.
  • jsonwebtoken for creating and verifying JWT tokens for session management.
  • express-session for managing user sessions.
  • @simplewebauthn/server for handling WebAuthn operations.
  • NodeCache is a temporary storage for challenges during WebAuthn operations.

Inside the biometric-auth-backend directory, create a file named server.js. This file will contain our server code.

Initializing Express and Middleware

We first initialize our express application and configure middleware for handling sessions, JSON bodies, and CORS. CORS enables our frontend to communicate with the backend from different origins.

const app = express();

app.use(session({
  secret: 'your-secret-key', // Replace with a secure key
  resave: false,
  saveUninitialized: true
}));

app.use(bodyParser.json());
app.use(cors());

Database Connection

For our biometric authentication system, we need to create two tables in our MySQL database:

  1. users Table: This table stores user information such as usernames, password hashes, and user names.
  2. biometric_credentials Table: This table stores the WebAuthn biometric credentials associated with each user.

he users table stores basic user information, including the username, password hash, and user name. Here’s the SQL schema for the users table:

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  name VARCHAR(255),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

The biometric_credentials table stores WebAuthn credential details such as the credential ID, public key, and the associated user ID.

CREATE TABLE biometric_credentials (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  credential_id VARBINARY(255) NOT NULL,
  public_key VARBINARY(255) NOT NULL,
  counter INT NOT NULL,
  webAuthnUserID VARBINARY(255) NOT NULL,
  deviceType VARCHAR(255),
  backedUp BOOLEAN NOT NULL DEFAULT FALSE,
  transports VARBINARY(255),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

Next, we set up a connection to our MySQL database. This connection will allow us to interact with our database for user and credential management:

const db = mysql.createConnection({
  host: 'localhost',
  user: 'biometric_app',
  password: 'password',
  database: 'biometric_auth'
});

db.connect(err => {
  if (err) {
    console.error('Database connection failed:', err.stack);
    return;
  }
  console.log('Connected to database.');
});

Helper Functions

Our server needs several helper functions to interact with the database and manage user and credential data:

Getting Credentials by User ID: This function fetches biometric credentials associated with a user ID from the database:

const getCredentialsByUserId = (userId, callback) => {
  db.query("SELECT * FROM biometric_credentials WHERE user_id = ?", [userId], (err, results) => {
    if (err) {
      console.error(err);
      callback(err, null);
    } else {
      callback(null, results);
    }
  });
};

Saving Credential: This function stores biometric credentials in the database. It includes handling for credential ID, public key, and other related data:

const saveCredential = (userId, credential, callback) => {
  const { credential_id, public_key, webAuthnUserID, counter, deviceType, backedUp , transport} = credential;
  const publicKeyBuffer = Buffer.from(public_key);
  const transportString = JSON.stringify(transport);
  const query = `
    INSERT INTO biometric_credentials 
    (user_id, credential_id, counter, public_key, webAuthnUserID, deviceType, backedUp, transports)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
    ON DUPLICATE KEY UPDATE 
    counter = VALUES(counter), 
    public_key = VALUES(public_key),
    webAuthnUserID = VALUES(webAuthnUserID),
    deviceType = VALUES(deviceType),
    backedUp = VALUES(backedUp),
    transports = VALUES(transports)
  `;

  db.query(query, 
    [userId, credential_id, counter, publicKeyBuffer, webAuthnUserID, deviceType, backedUp , transportString], (err) => {
    if (err) {
      console.error(err);
      callback(err);
    } else {
      callback(null);
    }
  });
};

Registration and Authentication Endpoints

Register WebAuthn Credential Options: This endpoint provides the client with WebAuthn registration options, including a challenge that must be solved by the client’s biometric device:

app.post('/api/biometric/register-options', async(req, res) => {
  const { username } = req.body;
  getUserByUsername(username, async(err, user) => {
    if (err || !user) return res.status(400).json({ error: 'User not found' });

    const userID = isoUint8Array.fromUTF8String(user.id.toString()); // Convert user ID to Uint8Array
    const registrationOptions = await generateRegistrationOptions({
      rpName: 'Biometric Login App',
      rpID: 'localhost',
      userID: userID,
      userName: username,
      timeout: 60000,
      attestationType: 'none', // Adjust based on your needs
    });

    if (!registrationOptions.challenge) {
        return res.status(500).json({ error: 'Failed to generate challenge' });
    }

    // Save challenge in session or database (for verification later)
    myCache.set(userID.toString(), registrationOptions.challenge);
    myCache.set("webauthuserid" +userID.toString() , registrationOptions.user.id);

    res.json(registrationOptions);
  });
});

Register WebAuthn Credential: This endpoint handles the verification of the WebAuthn credential response from the client. It verifies the challenge and saves the credential details in the database:

app.post('/api/biometric/register', (req, res) => {
  const { credential, username } = req.body;
  getUserByUsername(username, async(err, user) => {
    if (err || !user) return res.status(400).json({ error: 'User not found' });

    const userID = isoUint8Array.fromUTF8String(user.id.toString());
    const webAuthnUserID = myCache.get("webauthuserid" + userID.toString())
    const storedChallenge = myCache.get(userID.toString())

    try {
      const verification = await verifyRegistrationResponse({
        response: credential,
        expectedChallenge: storedChallenge,
        expectedOrigin: 'localhost',
        expectedRPID: 'localhost',
      });

      if (verification.verified) {
        const { registrationInfo } = verification;
        const {
          credentialID,
          credentialPublicKey,
          counter,
          credentialDeviceType,
          backedUp,
          transports
        } = registrationInfo;

        // Save the WebAuthn credential
        const credentialData = {
          credential_id: credentialID,
          public_key: credentialPublicKey,
          webAuthnUserID: webAuthnUserID,
          counter: counter,
          deviceType: credentialDeviceType,
          backedUp: backedUp,
          transport: transports
        };

        saveCredential(user.id, credentialData, (err) => {
          if (err) return res.status(500).json({ error: 'Failed to save credential' });
          res.json({ success: true });
        });
      } else {
        res.status(400).json({ error: 'Failed to verify credential' });
      }
    } catch (err) {
      console.error(err);
      res.status(500).json({ error: 'An error occurred during verification' });
    }
  });
});

Authenticate WebAuthn Credential Options: This endpoint generates authentication options for the client, including a challenge that must be solved by the biometric device during login:

app.post('/api/biometric/authenticate-options', async(req, res) => {
  const { username } = req.body;
  getUserByUsername(username, async(err, user) => {
    if (err || !user) return res.status(400).json({ error: 'User not found' });

    const userID = isoUint8Array.fromUTF8String(user.id.toString()); // Convert user ID to Uint8Array
    const credentials = await getCredentialsByUserId(user.id);

    const authenticationOptions = await generateAuthenticationOptions({
      rpID: 'localhost',
      userID: userID,
      allowCredentials: credentials.map(credential => ({
        id: isoUint8Array.fromBase64URL(credential.credential_id),
        type: 'public-key',
        transports: JSON.parse(credential.transports)
      })),
      timeout: 60000,
    });

    if (!authenticationOptions.challenge) {
        return res.status(500).json({ error: 'Failed to generate challenge' });
    }

    // Save challenge in session or database (for verification later)
    myCache.set(userID.toString(), authenticationOptions.challenge);
    res.json(authenticationOptions);
  });
});

Authenticate WebAuthn Credential: This endpoint verifies the authentication response from the client. It checks if the challenge and credential match and updates the counter in the database:

app.post('/api/biometric/authenticate', async (req, res) => {
  const { credential, username } = req.body;
  getUserByUsername(username, async(err, user) => {
    if (err || !user) return res.status(400).json({ error: 'User not found' });

    const userID = isoUint8Array.fromUTF8String(user.id.toString());
    const storedChallenge = myCache.get(userID.toString())

    try {
      const verification = await verifyAuthenticationResponse({
        response: credential,
        expectedChallenge: storedChallenge,
        expectedOrigin: 'localhost',
        expectedRPID: 'localhost',
        requireUserVerification: true
      });

      if (verification.verified) {
        const { authenticationInfo } = verification;
        const { credentialID, counter } = authenticationInfo;

        // Update counter in database
        db.query("UPDATE biometric_credentials SET counter = ? WHERE credential_id = ?", [counter, credentialID], (err) => {
          if (err) return res.status(500).json({ error: 'Failed to update counter' });
          // Generate and send JWT token
          const token = jwt.sign({ userId: user.id, username: user.username , name: user.name}, 'your_jwt_secret', { expiresIn: '1h' });
          res.json({ success: true, token });
        });
      } else {
        res.status(400).json({ error: 'Failed to verify credential' });
      }
    } catch (err) {
      console.error(err);
      res.status(500).json({ error: 'An error occurred during verification' });
    }
  });
});

Starting the Server

Finally, we start the Express server on a specified port.

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

Setting Up the Frontend

First, let’s set up a React project that will serve as our Progressive Web App (PWA). Now, let’s use the Create React App command to set up a new React project with PWA support:

npx create-react-app biometric-auth-frontend
cd biometric-auth-frontend

Install the necessary packages for handling WebAuthn and making HTTP requests:

npm install @simplewebauthn/browser axios

To configure the React app as a PWA, ensure that the service worker is enabled in your index.js file:

import * as serviceWorkerRegistration from './serviceWorkerRegistration';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

// Register the service worker
serviceWorkerRegistration.register();

Now we’ll create components for user registration and authentication. Create a RegisterBiometric.js file

import { startRegistration } from '@simplewebauthn/browser';

const RegisterBiometric = () => {
  const [status, setStatus] = useState('');
  const navigate = useNavigate();

  const handleRegisterBiometric = async (e) => {
    e.preventDefault();

    try {
      // Step 1: After user login, retrieve the username
      const storedUsername = localStorage.getItem('username');

      // Step 2: Get registration options from backend
      const { data } = await axios.post('http://localhost:3001/api/biometric/register-options', { username });

      // Step 3: Start WebAuthn registration
      const registrationResponse = await startRegistration(data);

      // Step 4: Send the registration response to backend
      const result = await axios.post('http://localhost:3001/api/biometric/register', {
        credential: registrationResponse,
        username
      });

      if (result && result.success) {
        setStatus('Registration successful!');
        navigate('/dashboard');
      } else {
        setStatus('Registration failed.');
      }
    } catch (error) {
      console.error('Registration failed:', error);
      setStatus('Error during registration.');
    }
  };

  return (
    <div className="container mt-5">
      <div className="row justify-content-center">
        <div className="col-md-6">
          <div className="card">
            <div className="card-header">
              <h3 className="text-center">Register Biometric</h3>
            </div>
            <div className="card-body">
              <button onClick={handleRegisterBiometric} className="btn btn-primary w-100">Register with Biometric</button>
              <p className="mt-3 text-center">{status}</p>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

export default RegisterBiometric;

Create a AuthenticateBiometric.js file inside the src/components directory:

import { startAuthentication } from '@simplewebauthn/browser';

const AuthenticateBiometric = () => {
  const [status, setStatus] = useState('');

  const handleAuthenticateBiometric = async (e) => {
    e.preventDefault();

    try {
      // Step 1: Retrieve the username
      const storedUsername = localStorage.getItem('username');

      // Step 2: Get authentication options from backend
      const authOptions = await axios.post('http://localhost:3001/api/biometric/authenticate-options', { username });

      // Step 3: Start WebAuthn authentication
      const authenticationResponse = await startAuthentication(authOptions);

      // Step 4: Send the authentication response to backend
      const result = await axios.post('http://localhost:3001/api/biometric/authenticate', {
          credential: authenticationResponse,
          username
      });

      if (result.data.success) {
        localStorage.setItem('authToken', result.token);
        setStatus('Authentication successful!');
        navigate('/dashboard');
      } else {
        alert('Login failed.');
      }
    } catch (error) {
      console.error('Login failed:', error);
      setStatus('Authentication failed.');
    }
  };

  return (
    <div className="container mt-5">
      <div className="row justify-content-center">
        <div className="col-md-6">
          <div className="card">
            <div className="card-header">
              <h3 className="text-center">Authenticate Biometric</h3>
            </div>
            <div className="card-body">
              <button onClick={handleAuthenticateBiometric} className="btn btn-primary w-100">Authenticate with Biometric</button>
              <p className="mt-3 text-center">{status}</p>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

export default AuthenticateBiometric;

This setup will give you a basic PWA frontend with registration and login functionalities for biometric authentication using WebAuthn.

User Flow for Biometric Authentication

When users first interact with the application, they will need to log in using their traditional username and password. Upon successful login, they will be given the option to register their biometric credentials. During this registration process, their username will be securely stored in the browser’s local storage.

For subsequent logins, users will have the option to use biometric authentication seamlessly. If they have previously registered their biometric credentials, they can choose to authenticate using their biometric data instead of entering their username and password again. This streamlined process enhances security and user convenience, allowing for a smooth and secure login experience.

The entire source code for this project is available on GitHub.

Share this content:

Leave a Comment

Discover more from nnyw@tech

Subscribe now to keep reading and get access to the full archive.

Continue reading