Skip to content

Commit

Permalink
Merge pull request #1 from cedarcode/add-optional-2fa-authentication
Browse files Browse the repository at this point in the history
feat: add possibility of enabling WebAuthn as a second factor authentication
  • Loading branch information
santiagorodriguez96 committed Jun 22, 2020
2 parents 5fbb552 + a85a3e4 commit 794f944
Show file tree
Hide file tree
Showing 31 changed files with 784 additions and 6 deletions.
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
WEBAUTHN_ORIGIN=http:https://localhost:3000
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
WEBAUTHN_ORIGIN=http:https://localhost:3030
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ gem 'puma', '~> 4.1'
gem 'sass-rails', '>= 6'
gem 'sqlite3', '~> 1.4'
gem 'turbolinks', '~> 5'
gem 'webauthn', '~> 2.2'
gem 'webpacker', '~> 4.0'

group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
gem 'dotenv-rails', '~> 2.7'
end

group :development do
Expand All @@ -29,6 +31,7 @@ end

group :test do
gem 'capybara', '>= 2.15'
gem 'minitest-stub_any_instance', '~> 1.0'
gem 'selenium-webdriver'
gem 'webdrivers'
end
Expand Down
36 changes: 36 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,11 @@ GEM
zeitwerk (~> 2.2, >= 2.2.2)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
android_key_attestation (0.3.0)
ast (2.4.0)
awrence (1.1.1)
bcrypt (3.1.13)
bindata (2.4.7)
bindex (0.8.1)
bootsnap (1.4.6)
msgpack (~> 1.0)
Expand All @@ -73,18 +76,28 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (~> 1.5)
xpath (~> 3.2)
cbor (0.5.9.6)
childprocess (3.0.0)
concurrent-ruby (1.1.6)
cose (0.11.0)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 0.3.0)
crass (1.0.6)
dotenv (2.7.5)
dotenv-rails (2.7.5)
dotenv (= 2.7.5)
railties (>= 3.2, < 6.1)
erubi (1.9.0)
ffi (1.12.2)
globalid (0.4.2)
activesupport (>= 4.2.0)
i18n (1.8.2)
concurrent-ruby (~> 1.0)
ipaddr (1.2.2)
jaro_winkler (1.5.4)
jbuilder (2.10.0)
activesupport (>= 5.0.0)
jwt (2.2.1)
listen (3.1.5)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
Expand All @@ -101,10 +114,14 @@ GEM
mini_mime (1.0.2)
mini_portile2 (2.4.0)
minitest (5.14.0)
minitest-stub_any_instance (1.0.2)
msgpack (1.3.3)
nio4r (2.5.2)
nokogiri (1.10.9)
mini_portile2 (~> 2.4.0)
openssl (2.1.2)
ipaddr
openssl-signature_algorithm (0.3.0)
parallel (1.19.1)
parser (2.7.1.2)
ast (~> 2.4.0)
Expand Down Expand Up @@ -164,6 +181,8 @@ GEM
ruby-progressbar (1.10.1)
ruby_dep (1.5.0)
rubyzip (2.3.0)
safety_net_attestation (0.4.0)
jwt (~> 2.0)
sass-rails (6.0.0)
sassc-rails (~> 2.1, >= 2.1.1)
sassc (2.2.1)
Expand All @@ -174,6 +193,7 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
securecompare (1.0.0)
selenium-webdriver (3.142.7)
childprocess (>= 0.5, < 4.0)
rubyzip (>= 1.2.2)
Expand All @@ -192,6 +212,9 @@ GEM
thor (1.0.1)
thread_safe (0.3.6)
tilt (2.0.10)
tpm-key_attestation (0.7.0)
bindata (~> 2.4)
openssl-signature_algorithm (~> 0.3.0)
turbolinks (5.2.1)
turbolinks-source (~> 5.2)
turbolinks-source (5.2.0)
Expand All @@ -203,6 +226,16 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webauthn (2.2.0)
android_key_attestation (~> 0.3.0)
awrence (~> 1.1)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 0.11.0)
openssl (~> 2.0)
safety_net_attestation (~> 0.4.0)
securecompare (~> 1.0)
tpm-key_attestation (~> 0.7.0)
webdrivers (4.3.0)
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
Expand All @@ -226,8 +259,10 @@ DEPENDENCIES
bootsnap (>= 1.4.2)
byebug
capybara (>= 2.15)
dotenv-rails (~> 2.7)
jbuilder (~> 2.7)
listen (>= 3.0.5, < 3.2)
minitest-stub_any_instance (~> 1.0)
puma (~> 4.1)
rails (~> 6.0.2, >= 6.0.2.2)
rubocop (~> 0.82.0)
Expand All @@ -240,6 +275,7 @@ DEPENDENCIES
turbolinks (~> 5)
tzinfo-data
web-console (>= 3.3.0)
webauthn (~> 2.2)
webdrivers
webpacker (~> 4.0)

Expand Down
7 changes: 7 additions & 0 deletions app/controllers/home_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,11 @@ class HomeController < ApplicationController

def index
end

private

def credential
@credential ||= current_user.webauthn_credentials.first
end
helper_method :credential
end
10 changes: 8 additions & 2 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ def create
user = User.find_by(username: params[:username])

if user && user.authenticate(params[:password])
sign_in(user)
if user.second_factor_enabled?
session[:webauthn_user_id] = user.id

redirect_to root_path
redirect_to new_webauthn_credential_authentication_path
else
sign_in(user)

redirect_to root_path
end
else
redirect_to root_path, alert: "Sign in failed. Please verify your username and password."
end
Expand Down
57 changes: 57 additions & 0 deletions app/controllers/webauthn_credential_authentication_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
class WebauthnCredentialAuthenticationController < ApplicationController
before_action :ensure_user_not_authenticated
before_action :ensure_login_initiated

def new
end

def options
get_options = WebAuthn::Credential.options_for_get(allow: user.webauthn_credentials.pluck(:external_id))

session[:current_challenge] = get_options.challenge

respond_to do |format|
format.json { render json: get_options }
end
end

def create
webauthn_credential = WebAuthn::Credential.from_get(params)

credential = user.webauthn_credentials.find_by(external_id: webauthn_credential.id)

begin
webauthn_credential.verify(
session[:current_challenge],
public_key: credential.public_key,
sign_count: credential.sign_count
)

credential.update!(sign_count: webauthn_credential.sign_count)
session.delete(:webauthn_user_id)
sign_in(user)

render json: { status: "ok" }, status: :ok
rescue WebAuthn::Error => e
render json: "Verification failed: #{e.message}", status: :unprocessable_entity
end
end

private

def user
@user ||= User.find_by(id: session[:webauthn_user_id])
end

def ensure_login_initiated
if session[:webauthn_user_id].blank?
redirect_to new_session_path, alert: "Login was not initiated"
end
end

def ensure_user_not_authenticated
if current_user
redirect_to root_path, alert: "User's already authenticated"
end
end
end
49 changes: 49 additions & 0 deletions app/controllers/webauthn_credentials_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
class WebauthnCredentialsController < ApplicationController
before_action :enforce_authenticated_user

def new
end

def options
if current_user.webauthn_id.blank?
current_user.update!(webauthn_id: WebAuthn.generate_user_id)
end

create_options = WebAuthn::Credential.options_for_create(
user: {
id: current_user.webauthn_id,
name: current_user.username
},
exclude: current_user.webauthn_credentials.pluck(:external_id)
)

session[:current_challenge] = create_options.challenge

respond_to do |format|
format.json { render json: create_options }
end
end

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

begin
webauthn_credential.verify(session[:current_challenge])

credential = current_user.webauthn_credentials.build(
external_id: webauthn_credential.id,
nickname: params[:credential_nickname],
public_key: webauthn_credential.public_key,
sign_count: webauthn_credential.sign_count
)

if credential.save
render json: { status: "ok" }, status: :ok
else
render json: "Couldn't add your Security Key", status: :unprocessable_entity
end
rescue WebAuthn::Error => e
render json: "Verification failed: #{e.message}", status: :unprocessable_entity
end
end
end
13 changes: 13 additions & 0 deletions app/javascript/controllers/add_credential_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Controller } from "stimulus"
import * as Credential from "credential";

export default class extends Controller {
create(event) {
var [data, status, xhr] = event.detail;
var credentialOptions = data;
var credential_nickname = event.target.querySelector("input[name='webauthn_credential[nickname]']").value;
var callback_url = `/webauthn_credentials/?credential_nickname=${credential_nickname}`

Credential.create(encodeURI(callback_url), credentialOptions);
}
}
11 changes: 11 additions & 0 deletions app/javascript/controllers/credential_authenticator_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Controller } from "stimulus"
import * as Credential from "credential";

export default class extends Controller {
verifyKey(event) {
var [data, status, xhr] = event.detail;
console.log(data);
var credentialOptions = data;
Credential.get(credentialOptions);
}
}
53 changes: 53 additions & 0 deletions app/javascript/credential.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as WebAuthnJSON from "@github/webauthn-json"

function getCSRFToken() {
var CSRFSelector = document.querySelector('meta[name="csrf-token"]')
if (CSRFSelector) {
return CSRFSelector.getAttribute("content")
} else {
return null
}
}

function callback(url, body) {
fetch(url, {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": getCSRFToken()
},
credentials: 'same-origin'
}).then(function(response) {
if (response.ok) {
window.location.replace("/")
} else if (response.status < 500) {
response.text().then(console.log);
} else {
console.log("Sorry, something wrong happened.");
}
});
}

function create(callbackUrl, credentialOptions) {
WebAuthnJSON.create({ "publicKey": credentialOptions }).then(function(credential) {
callback(callbackUrl, credential);
}).catch(function(error) {
console.log(error);
});

console.log("Creating new public key credential...");
}

function get(credentialOptions) {
WebAuthnJSON.get({ "publicKey": credentialOptions }).then(function(credential) {
callback("/webauthn_credential_authentication", credential);
}).catch(function(error) {
console.log(error);
});

console.log("Getting public key credential...");
}

export { create, get }
2 changes: 2 additions & 0 deletions app/javascript/packs/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ require("turbolinks").start()
// const imagePath = (name) => images(name, true)

import "controllers"

window.sinon = require("sinon")
6 changes: 6 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
class User < ApplicationRecord
has_secure_password

has_many :webauthn_credentials, dependent: :destroy

validates :username, presence: true, uniqueness: true

def second_factor_enabled?
webauthn_credentials.any?
end
end
6 changes: 6 additions & 0 deletions app/models/webauthn_credential.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class WebauthnCredential < ApplicationRecord
validates :external_id, :public_key, :nickname, :sign_count, presence: true
validates :external_id, uniqueness: true
validates :sign_count,
numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
end
Loading

0 comments on commit 794f944

Please sign in to comment.