Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
gem "web-push"
gem "webauthn", "~> 3.0"
gem "net-http-persistent"
gem "rubyzip", require: "zip"
gem "mittens"
Expand Down
23 changes: 23 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ GEM
railties
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
android_key_attestation (0.3.0)
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
Expand All @@ -138,6 +139,7 @@ GEM
bcrypt_pbkdf (1.1.1)
benchmark (0.5.0)
bigdecimal (3.3.1)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.19.0)
msgpack (~> 1.2)
Expand All @@ -156,11 +158,15 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cbor (0.5.10.1)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
crack (1.0.1)
bigdecimal
rexml
Expand Down Expand Up @@ -308,6 +314,8 @@ GEM
nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4)
openssl (3.3.2)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
Expand Down Expand Up @@ -399,6 +407,8 @@ GEM
logger
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0)
securerandom (0.4.1)
selenium-webdriver (4.38.0)
base64 (~> 0.2)
Expand Down Expand Up @@ -451,6 +461,10 @@ GEM
thruster (0.1.16-x86_64-darwin)
thruster (0.1.16-x86_64-linux)
timeout (0.4.4)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
trilogy (2.9.0)
tsort (0.2.0)
turbo-rails (2.0.20)
Expand All @@ -472,6 +486,14 @@ GEM
web-push (3.0.2)
jwt (~> 3.0)
openssl (~> 3.0)
webauthn (3.4.3)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.1)
openssl (>= 2.2)
safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
Expand Down Expand Up @@ -546,6 +568,7 @@ DEPENDENCIES
vcr
web-console
web-push
webauthn (~> 3.0)
webmock

BUNDLED WITH
Expand Down
52 changes: 52 additions & 0 deletions app/controllers/concerns/webauthn_relying_party.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module WebauthnRelyingParty
extend ActiveSupport::Concern

private
def webauthn_relying_party
@webauthn_relying_party ||= WebAuthn::RelyingParty.new(
allowed_origins: webauthn_allowed_origins,
id: webauthn_rp_id,
name: "Fizzy"
)
end

def webauthn_rp_id
configured_rp_id = Rails.application.config.x.webauthn&.rp_id
configured_rp_id.presence || default_webauthn_rp_id
end

def webauthn_allowed_origins
configured_origins = Rails.application.config.x.webauthn&.allowed_origins
configured_origins.presence || default_webauthn_allowed_origins
end

def default_webauthn_rp_id
if Rails.env.development?
"fizzy.localhost"
elsif Rails.env.test?
"localhost"
else
request.host
end
end

def default_webauthn_allowed_origins
if Rails.env.development?
# Allow both HTTP dev server and HTTPS (used by some password managers)
[
"http://fizzy.localhost:3006",
"https://fizzy.localhost"
]
elsif Rails.env.test?
[ "http://localhost" ]
else
[ "#{request.protocol}#{request.host_with_port}" ]
end
end

# Encode a user ID as base64url for WebAuthn JSON serialization
# The WebAuthn spec requires user.id to be transmitted as base64url
def encode_webauthn_user_id(user_id)
Base64.urlsafe_encode64(user_id.to_s, padding: false)
end
end
64 changes: 64 additions & 0 deletions app/controllers/identity/passkeys_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
class Identity::PasskeysController < ApplicationController
include WebauthnRelyingParty

disallow_account_scope

layout "public"

def index
@passkeys = Current.identity.passkeys.order(created_at: :desc)
end

def new
@options = passkey_registration_options
session[:webauthn_challenge] = @options.challenge
end

def create
webauthn_credential = webauthn_relying_party.verify_registration(
credential_params,
session.delete(:webauthn_challenge)
)

Current.identity.passkeys.create!(
external_id: webauthn_credential.id,
public_key: webauthn_credential.public_key,
sign_count: webauthn_credential.sign_count,
name: passkey_name
)

redirect_to identity_passkeys_path, notice: "Passkey added successfully."
rescue WebAuthn::Error, JSON::ParserError, KeyError, NoMethodError
redirect_to new_identity_passkey_path, alert: "Could not register passkey."
end

def destroy
passkey = Current.identity.passkeys.find(params[:id])
passkey.destroy
redirect_to identity_passkeys_path, notice: "Passkey removed."
end

private
def passkey_registration_options
webauthn_relying_party.options_for_registration(
user: {
id: encode_webauthn_user_id(Current.identity.id),
name: Current.identity.email_address,
display_name: Current.identity.email_address
},
authenticator_selection: {
resident_key: "required",
user_verification: "required"
},
exclude: Current.identity.passkeys.pluck(:external_id)
)
end

def credential_params
JSON.parse(params.require(:credential))
end

def passkey_name
params[:name].presence || PlatformAgent.new(request.user_agent).os || "Passkey"
end
end
67 changes: 67 additions & 0 deletions app/controllers/sessions/choices_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
class Sessions::ChoicesController < ApplicationController
include WebauthnRelyingParty

disallow_account_scope
require_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }

layout "public"

def new
@identity = Identity.find_by_email_address(email_address)

if @identity.nil? || @identity.passkeys.none?
redirect_to new_session_path
return
end

@passkey_options = passkey_authentication_options
session[:webauthn_challenge] = @passkey_options.challenge
end

def create
if params[:method] == "magic_link"
send_magic_link
else
authenticate_with_passkey
end
end

private
def email_address
params[:email]
end

def passkey_authentication_options
webauthn_relying_party.options_for_authentication(
user_verification: "required"
)
end

def send_magic_link
identity = Identity.find_by_email_address!(email_address)
redirect_to_session_magic_link identity.send_magic_link
end

def authenticate_with_passkey
identity = Identity.find_by_email_address!(email_address)
webauthn_credential = WebAuthn::Credential.from_get(credential_params, relying_party: webauthn_relying_party)
passkey = identity.passkeys.find_by!(external_id: webauthn_credential.id)

webauthn_credential.verify(
session.delete(:webauthn_challenge),
public_key: passkey.public_key,
sign_count: passkey.sign_count
)

passkey.update!(sign_count: webauthn_credential.sign_count)
start_new_session_for identity
redirect_to after_authentication_url
rescue WebAuthn::Error, ActiveRecord::RecordNotFound, JSON::ParserError, KeyError, NoMethodError
redirect_to new_session_choice_path(email: email_address), alert: "Authentication failed. Try again."
end

def credential_params
JSON.parse(params.require(:credential))
end
end
29 changes: 29 additions & 0 deletions app/controllers/sessions/passkeys_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class Sessions::PasskeysController < ApplicationController
include WebauthnRelyingParty

disallow_account_scope
require_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }

def create
webauthn_credential = WebAuthn::Credential.from_get(credential_params, relying_party: webauthn_relying_party)
passkey = Passkey.find_by!(external_id: webauthn_credential.id)

webauthn_credential.verify(
session.delete(:webauthn_challenge),
public_key: passkey.public_key,
sign_count: passkey.sign_count
)

passkey.update!(sign_count: webauthn_credential.sign_count)
start_new_session_for passkey.identity
redirect_to after_authentication_url
rescue WebAuthn::Error, ActiveRecord::RecordNotFound, JSON::ParserError, KeyError, NoMethodError
redirect_to new_session_path, alert: "Authentication failed. Try again or use email."
end

private
def credential_params
JSON.parse(params.require(:credential))
end
end
16 changes: 14 additions & 2 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
class SessionsController < ApplicationController
include WebauthnRelyingParty

disallow_account_scope
require_unauthenticated_access except: :destroy
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }

layout "public"

def new
@passkey_options = passkey_authentication_options
session[:webauthn_challenge] = @passkey_options.challenge
end

def create
if identity = Identity.find_by_email_address(email_address)
redirect_to_session_magic_link identity.send_magic_link
if identity.passkeys.any?
redirect_to new_session_choice_path(email: email_address)
else
redirect_to_session_magic_link identity.send_magic_link
end
else
signup = Signup.new(email_address: email_address)
if signup.valid?(:identity_creation)
redirect_to_session_magic_link signup.create_identity
redirect_to new_signup_path
else
head :unprocessable_entity
end
Expand All @@ -30,4 +38,8 @@ def destroy
def email_address
params.expect(:email_address)
end

def passkey_authentication_options
webauthn_relying_party.options_for_authentication(user_verification: "required")
end
end
48 changes: 48 additions & 0 deletions app/controllers/signups/passkeys_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
class Signups::PasskeysController < ApplicationController
include WebauthnRelyingParty

disallow_account_scope
allow_unauthenticated_access only: :create

def create
signup = Signup.new(email_address: email_param)

if signup.valid?(:identity_creation)
webauthn_credential = webauthn_relying_party.verify_registration(
credential_params,
session.delete(:webauthn_challenge)
)

identity = Identity.find_or_create_by!(email_address: email_param)

identity.passkeys.create!(
external_id: webauthn_credential.id,
public_key: webauthn_credential.public_key,
sign_count: webauthn_credential.sign_count,
name: passkey_name
)

start_new_session_for identity
redirect_to new_signup_completion_path
else
redirect_to new_signup_path, alert: "Please enter a valid email address."
end
rescue WebAuthn::Error, JSON::ParserError, KeyError, NoMethodError => e
Rails.logger.error "Passkey registration failed: #{e.class} - #{e.message}"
Rails.logger.error e.backtrace.first(10).join("\n")
redirect_to new_signup_path, alert: "Could not register passkey. Please try again."
end

private
def email_param
params[:email_address]
end

def credential_params
JSON.parse(params.require(:credential))
end

def passkey_name
params[:name].presence || PlatformAgent.new(request.user_agent).os || "Passkey"
end
end
Loading