discourse/lib/discourse_webauthn/authentication_service.rb

132 lines
6.1 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
require "cose"
module DiscourseWebauthn
class AuthenticationService < BaseValidationService
##
# See https://w3c.github.io/webauthn/#sctn-verifying-assertion for
# the steps followed here. Memoized methods are called in their
# place in the step flow to make the process clearer.
def authenticate_security_key
# Steps 1-5 of this authentication flow are in the frontend at lib/webauthn.js
if @params.blank? || (!@params.is_a?(Hash) && !@params.is_a?(ActionController::Parameters))
raise(
MalformedPublicKeyCredentialError,
I18n.t("webauthn.validation.malformed_public_key_credential_error"),
)
end
# 6. Identify the user being authenticated and verify that this user is the
# owner of the public key credential source credentialSource identified by credential.id:
# 6a. If the user was identified before the authentication ceremony was initiated,
# verify that the identified user account contains a credential record whose id equals credential.rawId.
security_key = UserSecurityKey.find_by(credential_id: @params[:credentialId])
raise(KeyNotFoundError, I18n.t("webauthn.validation.not_found_error")) if security_key.blank?
if @factor_type == UserSecurityKey.factor_types[:second_factor] &&
(@current_user == nil || security_key.user == nil || security_key.user != @current_user)
raise(OwnershipError, I18n.t("webauthn.validation.ownership_error"))
end
# 6b. If the user was not identified before the authentication ceremony was initiated,
# verify that response.userHandle is present. Verify that the user account identified by response.userHandle
# contains a credential record whose id equals credential.rawId
if @factor_type == UserSecurityKey.factor_types[:first_factor] &&
Base64.decode64(@params[:userHandle]) != security_key.user.secure_identifier
raise(OwnershipError, I18n.t("webauthn.validation.ownership_error"))
end
# 7. No upstream step
# 8. No upstream step
# 9. Let cData, authData and sig denote the value of credentials response's clientDataJSON, authenticatorData, and signature respectively.
# 10. Let JSONtext be the result of running UTF-8 decode on the value of cData.
# 11. Let C, the client data claimed as used for the signature, be the result of running an implementation-specific JSON parser on JSONtext.
client_data
# 12. Verify that the value of C.type is the string webauthn.get.
validate_webauthn_type(::DiscourseWebauthn::ACCEPTABLE_AUTHENTICATION_TYPE)
# 13. Verify that the value of C.challenge equals the base64url encoding of options.challenge.
validate_challenge
# 14. Verify that the value of C.origin matches the Relying Party's origin.
validate_origin
# 15. If C.topOrigin is present:
# - Verify that the Relying Party expects this credential to be used within an iframe that is not same-origin with its ancestors.
# - Verify that the value of C.topOrigin matches the origin of a page that the Relying Party expects to be sub-framed within.
# We are not using this.
# 16. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
validate_rp_id_hash
# 17. Verify that the User Present bit of the flags in authData is set.
# https://blog.bigbinary.com/2011/07/20/ruby-pack-unpack.html
#
validate_user_presence
#
# 18. Determine whether user verification is required for this assertion.
# User verification SHOULD be required if, and only if, options.userVerification is set to required.
# If user verification was determined to be required, verify that the UV bit of the flags in authData is set.
# Otherwise, ignore the value of the UV flag.
validate_user_verification if @factor_type == UserSecurityKey.factor_types[:first_factor]
# 19. If the BE bit of the flags in authData is not set, verify that the BS bit is not set.
# Not using this right now.
# 20. If the credential backup state is used as part of Relying Party business logic or policy...
# Not using this right now.
# 21. Verify that the values of the client extension outputs in clientExtensionResults...
# Not using this right now.
# 22. Let hash be the result of computing a hash over response.clientDataJSON using SHA-256.
client_data_hash
# 23. Using credentialPublicKey, verify that sig is a valid signature over the binary concatenation of authData and hash.
cose_key = COSE::Key.deserialize(Base64.decode64(security_key.public_key))
cose_algorithm = COSE::Algorithm.find(cose_key.alg)
if cose_algorithm.blank?
Rails.logger.error(
"Unknown COSE algorithm encountered. alg: #{cose_key.alg}. user_id: #{@current_user.id}. params: #{@params.inspect}",
)
raise(UnknownCOSEAlgorithmError, I18n.t("webauthn.validation.unknown_cose_algorithm_error"))
end
if !cose_key.to_pkey.verify(
cose_algorithm.hash_function,
signature,
auth_data + client_data_hash,
)
raise(PublicKeyError, I18n.t("webauthn.validation.public_key_error"))
end
# 24. If authData.signCount is nonzero or credentialRecord.signCount is nonzero...
# Not using this right now.
# 25. If response.attestationObject is present and the Relying Party wishes to verify the attestation...
# Not using this right now.
# 26. Success! Update the last used at time for the key (credentialRecord).
security_key.update(last_used: Time.zone.now)
# Return security key record so controller can use it to update the session
security_key
rescue OpenSSL::PKey::PKeyError
raise(PublicKeyError, I18n.t("webauthn.validation.public_key_error"))
end
private
def auth_data
@auth_data ||= Base64.decode64(@params[:authenticatorData])
end
def signature
@signature ||= Base64.decode64(@params[:signature])
end
end
end