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.