Adding WebAuthn to a Rails web application

This demo shows how to implement WebAuthn in a Rails web application.

The webauthn-ruby gem is used in the backend and GitHub webauthn-json is used in the frontend.


Pre-requisites

1) To implement WebAuthn, you must have an existing authentication channel (E.g. username/password, OAuth).

2) Your user must be authenticated, and then enroll their devices with Webauthn. 

Setup

1) Add the webauthn gem into gemfile.

gem 'webauthn'

2) Perform bundle install.

3) Create an initializer for the webauthn gem.

WebAuthn.configure do |config|
  # This value needs to match `window.location.origin` evaluated by
  # the User Agent during registration and authentication ceremonies.
  config.origin = 'http://localhost:3000'

  # Relying Party name for display purposes
  config.rp_name = 'My App'
end

4) Include the webauthn-json library via NPM or CDN. In my case, I am pointing to jspm.

pin '@github/webauthn-json', to: 'https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.js'
pin '@github/webauthn-json/browser-ponyfill', to: 'https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.browser-ponyfill.js'

Database

1) A new column is needed in your 'users' table to store your webauth ID. This ID is unique for each of your users.

def change
    add_column :users, :webauthn_id, :string
end

2) A new table is needed to store your credentials. Each record is tied to one enrollment.

def change
    create_table :credentials do |t|
      t.belongs_to :user, null: false, foreign_key: true
      t.string :webauthn_ref_id, null: false
      t.string :webauthn_public_key
      t.integer :webauthn_sign_count
      t.timestamps
    end
end

3) Update models

class User < ApplicationRecord
  has_many :credentials
  # other codes
end

class Credential < ApplicationRecord
  belongs_to :user
end

How to Enroll?

The enrollment involves 2 steps - Initialization and validation.

1) Create a controller action to start enrollment.

  def enroll
    user.update!(webauthn_id: WebAuthn.generate_user_id) unless user.webauthn_id

    options = WebAuthn::Credential.options_for_create(
      user: {
        id: user.webauthn_id,
        name: user.email,
        display_name: user.name
      },
      exclude: user.credentials.map { |c| c.webauthn_ref_id }
    )

    session[:creation_challenge] = options.challenge

    render json: options
  end

2) Add a button on your user profile page (or somewhere that can be accessible only after the user signs in)

3) This button requests your controller to retrieve the initial JSON data, then calls the webauthn API to start enrollment, and finally sends the result back to your backend.

import { Controller } from "@hotwired/stimulus"
import {
    create,
    parseCreationOptionsFromJSON,
} from "@github/webauthn-json/browser-ponyfill"

export default class extends Controller {
    async enroll() {
        // Fetch the public key creation options from the server
        const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
        const request = fetch("/enroll_path", {
            method: "GET",
            headers: {
                "Accept": "application/json",
                "X-CSRF-Token": token,
            }
        })
        const json = await (await request).json();
        const options = parseCreationOptionsFromJSON({ publicKey: json });
        const response = await create(options);
        fetch('/validate_path', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-Token': token,
            },
            body: JSON.stringify(response)
        }).then(response => {
            if (response.ok) {
                window.location.reload();
            }
        })
    }
}

4) The backend controller action should create a record in the 'credentials' table and store the public key.

  def validate
    webauthn_credential = WebAuthn::Credential.from_create(params)

    begin
      webauthn_credential.verify(session[:creation_challenge])

      user.credentials.create!(
        webauthn_ref_id: webauthn_credential.id,
        webauthn_public_key: webauthn_credential.public_key,
        webauthn_sign_count: webauthn_credential.sign_count
      )
    rescue WebAuthn::Error => e
      head :bad_request and return
    end

    session.delete(:creation_challenge)

    head :ok
  end

Test the Enrollment

1) I tested this on my Macbook, with Google Chrome browser.

2) Click the enrollment button and a list of options shows.

3) I selected the iCloud keychain. Then I verified myself via Touch ID.

4) And the enrollment is done.

How to log in with WebAuthn?

The login also involves 2 steps - Initialization and validation, similar to the enrollment process.

1) Create a controller action to start login.

  def login
    user = User.find_by(email: params[:username])
    return head :not_found unless user

    options = WebAuthn::Credential.options_for_get(allow: user.credentials.map(&:webauthn_ref_id))
    session[:authentication_challenge] = options.challenge
    session[:username] = params[:username]

    render json: options
  end

2) Add username input and a button on your login screen.

3) The button sends your username to generate a challenge JSON. Then complete the challenge on your browser and send the result back.

import { Controller } from "@hotwired/stimulus"
import {
    get,
    parseRequestOptionsFromJSON,
} from "@github/webauthn-json/browser-ponyfill"

export default class extends Controller {
    async login() {
        const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
        const username = this.usernameTarget.value
        fetch("/login", {
            method: "POST",
            headers: {
                "Accept": "application/json",
                "Content-Type": "application/json",
                "X-CSRF-Token": token,
            },
            body: JSON.stringify({ username: username })
        }).then(async challenge => {
            if (!challenge.ok) {
                // Show error
                return
            }
            const json = await challenge.json();
            const options = parseRequestOptionsFromJSON({ publicKey: json });
            const webauthnResponse = await get(options);
            return fetch('/login_validate', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-Token': token,
                },
                body: JSON.stringify(webauthnResponse)
            });
        }).then(response => {
            if (response.ok) {
                window.location.href = '/some-where'
            } else {
                // Show error
            }
        }).catch(() => {
            // Show error
        })
    }
}

4) Finally, you should validate the login result and allow your user to pass.

  def validate_login
    webauthn_credential = WebAuthn::Credential.from_get(params)
    user = User.find_by(email: session[:username])
    return head :bad_request unless user

    stored_credential = user.credentials.find_by(webauthn_ref_id: webauthn_credential.id)

    begin
      webauthn_credential.verify(
        session[:authentication_challenge],
        public_key: stored_credential.webauthn_public_key,
        sign_count: stored_credential.webauthn_sign_count
      )

      # Update the stored credential sign count with the value from `webauthn_credential.sign_count`
      stored_credential.update!(webauthn_sign_count: webauthn_credential.sign_count)

      session.delete(:authentication_challenge)
      session.delete(:username)

      # create new session to complete the login process

    rescue WebAuthn::SignCountVerificationError => e
      # Cryptographic verification of the authenticator data succeeded, but the signature counter was less then or equal
      # to the stored value. This can have several reasons and depending on your risk tolerance you can choose to fail or
      # pass authentication. For more information see https://www.w3.org/TR/webauthn/#sign-counter
      logger.error "WebAuthn error: #{e.message}"
      head :bad_request and return
    rescue WebAuthn::Error => e
      # Handle error
      logger.error "WebAuthn error: #{e.message}"
      head :bad_request and return
    end
    head :ok
  end

Test the Login

1) I fill up my username and hit login.

2) The Touch ID shows up and I completed the verification.

3) That's it. The login process is done.


Bonus 1: Does this also work on mobile phones?

Yes, the same code above with a Samsung Galaxy Phone shows some options like the following.


Bonus 2: What if I repetitively register on the same device?

You will get an error. 


AI Summary
gpt-4o-2024-05-13 2024-09-23 00:23:41
The article details how to implement WebAuthn for authentication in a Rails web application using the `webauthn-ruby` gem for backend and `webauthn-json` for frontend. It covers setting up the necessary gems, database migrations, and steps for user enrollment and login. Additionally, it provides insights on testing across devices and handling repeated registrations.
Chrome On-device AI 2025-02-08 11:09:17

Share Article