# frozen-string-literal: true
require 'webauthn'
module Rodauth
Feature.define(:webauthn, :Webauthn) do
depends :two_factor_base
loaded_templates %w'webauthn-setup webauthn-auth webauthn-remove'
view 'webauthn-setup', 'Setup WebAuthn Authentication', 'webauthn_setup'
view 'webauthn-auth', 'Authenticate Using WebAuthn', 'webauthn_auth'
view 'webauthn-remove', 'Remove WebAuthn Authenticator', 'webauthn_remove'
additional_form_tags 'webauthn_setup'
additional_form_tags 'webauthn_auth'
additional_form_tags 'webauthn_remove'
before :webauthn_setup
before :webauthn_auth
before :webauthn_remove
after :webauthn_setup
after :webauthn_auth_failure
after :webauthn_remove
button 'Setup WebAuthn Authentication', 'webauthn_setup'
button 'Authenticate Using WebAuthn', 'webauthn_auth'
button 'Remove WebAuthn Authenticator', 'webauthn_remove'
redirect :webauthn_setup
redirect :webauthn_remove
notice_flash "WebAuthn authentication is now setup", 'webauthn_setup'
notice_flash "WebAuthn authenticator has been removed", 'webauthn_remove'
error_flash "Error setting up WebAuthn authentication", 'webauthn_setup'
error_flash "Error authenticating using WebAuthn", 'webauthn_auth'
error_flash 'This account has not been setup for WebAuthn authentication', 'webauthn_not_setup'
error_flash "Error removing WebAuthn authenticator", 'webauthn_remove'
session_key :authenticated_webauthn_id_session_key, :webauthn_id
translatable_method :webauthn_auth_link_text, "Authenticate Using WebAuthn"
translatable_method :webauthn_setup_link_text, "Setup WebAuthn Authentication"
translatable_method :webauthn_remove_link_text, "Remove WebAuthn Authenticator"
auth_value_method :webauthn_setup_param, 'webauthn_setup'
auth_value_method :webauthn_auth_param, 'webauthn_auth'
auth_value_method :webauthn_remove_param, 'webauthn_remove'
auth_value_method :webauthn_setup_challenge_param, 'webauthn_setup_challenge'
auth_value_method :webauthn_setup_challenge_hmac_param, 'webauthn_setup_challenge_hmac'
auth_value_method :webauthn_auth_challenge_param, 'webauthn_auth_challenge'
auth_value_method :webauthn_auth_challenge_hmac_param, 'webauthn_auth_challenge_hmac'
auth_value_method :webauthn_keys_account_id_column, :account_id
auth_value_method :webauthn_keys_webauthn_id_column, :webauthn_id
auth_value_method :webauthn_keys_public_key_column, :public_key
auth_value_method :webauthn_keys_sign_count_column, :sign_count
auth_value_method :webauthn_keys_last_use_column, :last_use
auth_value_method :webauthn_keys_table, :account_webauthn_keys
auth_value_method :webauthn_user_ids_account_id_column, :id
auth_value_method :webauthn_user_ids_webauthn_id_column, :webauthn_id
auth_value_method :webauthn_user_ids_table, :account_webauthn_user_ids
auth_value_method :webauthn_setup_js, File.binread(File.expand_path('../../../../javascript/webauthn_setup.js', __FILE__)).freeze
auth_value_method :webauthn_auth_js, File.binread(File.expand_path('../../../../javascript/webauthn_auth.js', __FILE__)).freeze
auth_value_method :webauthn_js_host, ''
auth_value_method :webauthn_setup_timeout, 120000
auth_value_method :webauthn_auth_timeout, 60000
auth_value_method :webauthn_user_verification, 'discouraged'
auth_value_method :webauthn_attestation, 'none'
auth_value_method :webauthn_not_setup_error_status, 403
translatable_method :webauthn_invalid_setup_param_message, "invalid webauthn setup param"
translatable_method :webauthn_duplicate_webauthn_id_message, "attempt to insert duplicate webauthn id"
translatable_method :webauthn_invalid_auth_param_message, "invalid webauthn authentication param"
translatable_method :webauthn_invalid_sign_count_message, "webauthn credential has invalid sign count"
translatable_method :webauthn_invalid_remove_param_message, "must select valid webauthn authenticator to remove"
auth_value_methods(
:webauthn_authenticator_selection,
:webauthn_extensions,
:webauthn_origin,
:webauthn_rp_id,
:webauthn_rp_name,
)
auth_methods(
:account_webauthn_ids,
:account_webauthn_usage,
:account_webauthn_user_id,
:add_webauthn_credential,
:authenticated_webauthn_id,
:handle_webauthn_sign_count_verification_error,
:new_webauthn_credential,
:remove_webauthn_key,
:remove_all_webauthn_keys_and_user_ids,
:valid_new_webauthn_credential?,
:valid_webauthn_credential_auth?,
:webauthn_auth_js_path,
:webauth_credential_options_for_get,
:webauthn_remove_authenticated_session,
:webauthn_setup_js_path,
:webauthn_update_session,
:webauthn_user_name,
)
route(:webauthn_auth_js) do |r|
before_webauthn_auth_js_route
r.get do
response['Content-Type'] = 'text/javascript'
webauthn_auth_js
end
end
route(:webauthn_auth) do |r|
require_login
require_account_session
require_two_factor_not_authenticated('webauthn')
require_webauthn_setup
before_webauthn_auth_route
r.get do
webauthn_auth_view
end
r.post do
catch_error do
webauthn_credential = webauthn_auth_credential_from_form_submission
transaction do
before_webauthn_auth
webauthn_update_session(webauthn_credential.id)
two_factor_authenticate('webauthn')
end
end
after_webauthn_auth_failure
set_error_flash webauthn_auth_error_flash
webauthn_auth_view
end
end
route(:webauthn_setup_js) do |r|
before_webauthn_setup_js_route
r.get do
response['Content-Type'] = 'text/javascript'
webauthn_setup_js
end
end
route(:webauthn_setup) do |r|
require_authentication unless two_factor_login_type_match?('webauthn')
require_account_session
before_webauthn_setup_route
r.get do
webauthn_setup_view
end
r.post do
catch_error do
webauthn_credential = webauthn_setup_credential_from_form_submission
throw_error = false
transaction do
before_webauthn_setup
if raises_uniqueness_violation?{add_webauthn_credential(webauthn_credential)}
throw_error = true
raise Sequel::Rollback
end
unless two_factor_authenticated?
webauthn_update_session(webauthn_credential.id)
two_factor_update_session('webauthn')
end
after_webauthn_setup
end
if throw_error
throw_error_reason(:duplicate_webauthn_id, invalid_field_error_status, webauthn_setup_param, webauthn_duplicate_webauthn_id_message)
end
set_notice_flash webauthn_setup_notice_flash
redirect webauthn_setup_redirect
end
set_error_flash webauthn_setup_error_flash
webauthn_setup_view
end
end
route(:webauthn_remove) do |r|
require_authentication unless two_factor_login_type_match?('webauthn')
require_account_session
require_webauthn_setup
before_webauthn_remove_route
r.get do
webauthn_remove_view
end
r.post do
catch_error do
unless webauthn_id = param_or_nil(webauthn_remove_param)
throw_error_reason(:invalid_webauthn_remove_param, invalid_field_error_status, webauthn_remove_param, webauthn_invalid_remove_param_message)
end
unless two_factor_password_match?(param(password_param))
throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message)
end
transaction do
before_webauthn_remove
unless remove_webauthn_key(webauthn_id)
throw_error_reason(:invalid_webauthn_remove_param, invalid_field_error_status, webauthn_remove_param, webauthn_invalid_remove_param_message)
end
if authenticated_webauthn_id == webauthn_id && two_factor_login_type_match?('webauthn')
webauthn_remove_authenticated_session
two_factor_remove_session('webauthn')
end
after_webauthn_remove
end
set_notice_flash webauthn_remove_notice_flash
redirect webauthn_remove_redirect
end
set_error_flash webauthn_remove_error_flash
webauthn_remove_view
end
end
def webauthn_auth_form_path
webauthn_auth_path
end
def authenticated_webauthn_id
session[authenticated_webauthn_id_session_key]
end
def webauthn_remove_authenticated_session
remove_session_value(authenticated_webauthn_id_session_key)
end
def webauthn_update_session(webauthn_id)
set_session_value(authenticated_webauthn_id_session_key, webauthn_id)
end
def webauthn_authenticator_selection
{'requireResidentKey' => false, 'userVerification' => webauthn_user_verification}
end
def webauthn_extensions
{}
end
def account_webauthn_ids
webauthn_keys_ds.select_map(webauthn_keys_webauthn_id_column)
end
def account_webauthn_usage
webauthn_keys_ds.select_hash(webauthn_keys_webauthn_id_column, webauthn_keys_last_use_column)
end
def account_webauthn_user_id
unless webauthn_id = webauthn_user_ids_ds.get(webauthn_user_ids_webauthn_id_column)
webauthn_id = WebAuthn.generate_user_id
if e = raised_uniqueness_violation do
webauthn_user_ids_ds.insert(
webauthn_user_ids_account_id_column => webauthn_account_id,
webauthn_user_ids_webauthn_id_column => webauthn_id
)
end
# If two requests to create a webauthn user id are sent at the same time and an insert
# is attempted for both, one will fail with a unique constraint violation. In that case
# it is safe for the second one to use the webauthn user id inserted by the other request.
# If there is still no webauthn user id at this point, then we'll just reraise the
# exception.
# :nocov:
raise e unless webauthn_id = webauthn_user_ids_ds.get(webauthn_user_ids_webauthn_id_column)
# :nocov:
end
end
webauthn_id
end
def new_webauthn_credential
WebAuthn::Credential.options_for_create(
:timeout => webauthn_setup_timeout,
:rp => {:name=>webauthn_rp_name, :id=>webauthn_rp_id},
:user => {:id=>account_webauthn_user_id, :name=>webauthn_user_name},
:authenticator_selection => webauthn_authenticator_selection,
:attestation => webauthn_attestation,
:extensions => webauthn_extensions,
:exclude => account_webauthn_ids,
)
end
def valid_new_webauthn_credential?(webauthn_credential)
# Hack around inability to override expected_origin
origin = webauthn_origin
webauthn_credential.response.define_singleton_method(:verify) do |expected_challenge, expected_origin = nil, **kw|
super(expected_challenge, expected_origin || origin, **kw)
end
(challenge = param_or_nil(webauthn_setup_challenge_param)) &&
(hmac = param_or_nil(webauthn_setup_challenge_hmac_param)) &&
timing_safe_eql?(compute_hmac(challenge), hmac) &&
webauthn_credential.verify(challenge)
end
def webauth_credential_options_for_get
WebAuthn::Credential.options_for_get(
:allow => account_webauthn_ids,
:timeout => webauthn_auth_timeout,
:rp_id => webauthn_rp_id,
:user_verification => webauthn_user_verification,
:extensions => webauthn_extensions,
)
end
def webauthn_user_name
(account || account_from_session)[login_column]
end
def webauthn_origin
base_url
end
def webauthn_rp_id
webauthn_origin.sub(/\Ahttps?:\/\//, '').sub(/:\d+\z/, '')
end
def webauthn_rp_name
webauthn_rp_id
end
def handle_webauthn_sign_count_verification_error
throw_error_reason(:invalid_webauthn_sign_count, invalid_field_error_status, webauthn_auth_param, webauthn_invalid_sign_count_message)
end
def add_webauthn_credential(webauthn_credential)
webauthn_keys_ds.insert(
webauthn_keys_account_id_column => webauthn_account_id,
webauthn_keys_webauthn_id_column => webauthn_credential.id,
webauthn_keys_public_key_column => webauthn_credential.public_key,
webauthn_keys_sign_count_column => Integer(webauthn_credential.sign_count)
)
super if defined?(super)
nil
end
def valid_webauthn_credential_auth?(webauthn_credential)
ds = webauthn_keys_ds.where(webauthn_keys_webauthn_id_column => webauthn_credential.id)
pub_key, sign_count = ds.get([webauthn_keys_public_key_column, webauthn_keys_sign_count_column])
# Hack around inability to override expected_origin
origin = webauthn_origin
webauthn_credential.response.define_singleton_method(:verify) do |expected_challenge, expected_origin = nil, **kw|
super(expected_challenge, expected_origin || origin, **kw)
end
(challenge = param_or_nil(webauthn_auth_challenge_param)) &&
(hmac = param_or_nil(webauthn_auth_challenge_hmac_param)) &&
timing_safe_eql?(compute_hmac(challenge), hmac) &&
webauthn_credential.verify(challenge, public_key: pub_key, sign_count: sign_count) &&
ds.update(
webauthn_keys_sign_count_column => Integer(webauthn_credential.sign_count),
webauthn_keys_last_use_column => Sequel::CURRENT_TIMESTAMP
) == 1
end
def remove_webauthn_key(webauthn_id)
webauthn_keys_ds.where(webauthn_keys_webauthn_id_column=>webauthn_id).delete == 1
end
def remove_all_webauthn_keys_and_user_ids
webauthn_user_ids_ds.delete
webauthn_keys_ds.delete
end
def webauthn_setup?
!webauthn_keys_ds.empty?
end
def require_webauthn_setup
unless webauthn_setup?
set_redirect_error_status(webauthn_not_setup_error_status)
set_error_reason :webauthn_not_setup
set_redirect_error_flash webauthn_not_setup_error_flash
redirect two_factor_need_setup_redirect
end
end
def two_factor_remove
super
remove_all_webauthn_keys_and_user_ids
end
def possible_authentication_methods
methods = super
methods << 'webauthn' if webauthn_setup?
methods
end
private
def _two_factor_auth_links
links = super
links << [10, webauthn_auth_path, webauthn_auth_link_text] if webauthn_setup? && !two_factor_login_type_match?('webauthn')
links
end
def _two_factor_setup_links
super << [10, webauthn_setup_path, webauthn_setup_link_text]
end
def _two_factor_remove_links
links = super
links << [10, webauthn_remove_path, webauthn_remove_link_text] if webauthn_setup?
links
end
def _two_factor_remove_all_from_session
two_factor_remove_session('webauthn')
remove_session_value(authenticated_webauthn_id_session_key)
super
end
def webauthn_account_id
session_value
end
def webauthn_user_ids_ds
db[webauthn_user_ids_table].where(webauthn_user_ids_account_id_column => webauthn_account_id)
end
def webauthn_keys_ds
db[webauthn_keys_table].where(webauthn_keys_account_id_column => webauthn_account_id)
end
def webauthn_auth_credential_from_form_submission
case auth_data = raw_param(webauthn_auth_param)
when String
begin
auth_data = JSON.parse(auth_data)
rescue
throw_error_reason(:invalid_webauthn_auth_param, invalid_field_error_status, webauthn_auth_param, webauthn_invalid_auth_param_message)
end
when Hash
# nothing
else
throw_error_reason(:invalid_webauthn_auth_param, invalid_field_error_status, webauthn_auth_param, webauthn_invalid_auth_param_message)
end
begin
webauthn_credential = WebAuthn::Credential.from_get(auth_data)
unless valid_webauthn_credential_auth?(webauthn_credential)
throw_error_reason(:invalid_webauthn_auth_param, invalid_key_error_status, webauthn_auth_param, webauthn_invalid_auth_param_message)
end
rescue WebAuthn::SignCountVerificationError
handle_webauthn_sign_count_verification_error
rescue WebAuthn::Error, RuntimeError, NoMethodError
throw_error_reason(:invalid_webauthn_auth_param, invalid_field_error_status, webauthn_auth_param, webauthn_invalid_auth_param_message)
end
webauthn_credential
end
def webauthn_setup_credential_from_form_submission
case setup_data = raw_param(webauthn_setup_param)
when String
begin
setup_data = JSON.parse(setup_data)
rescue
throw_error_reason(:invalid_webauthn_setup_param, invalid_field_error_status, webauthn_setup_param, webauthn_invalid_setup_param_message)
end
when Hash
# nothing
else
throw_error_reason(:invalid_webauthn_setup_param, invalid_field_error_status, webauthn_setup_param, webauthn_invalid_setup_param_message)
end
unless two_factor_password_match?(param(password_param))
throw_error_reason(:invalid_password, invalid_password_error_status, password_param, invalid_password_message)
end
begin
webauthn_credential = WebAuthn::Credential.from_create(setup_data)
unless valid_new_webauthn_credential?(webauthn_credential)
throw_error_reason(:invalid_webauthn_setup_param, invalid_field_error_status, webauthn_setup_param, webauthn_invalid_setup_param_message)
end
rescue WebAuthn::Error, RuntimeError, NoMethodError
throw_error_reason(:invalid_webauthn_setup_param, invalid_field_error_status, webauthn_setup_param, webauthn_invalid_setup_param_message)
end
webauthn_credential
end
end
end