discourse/lib/discourse_webauthn/authentication_service.rb
Penar Musaraj e3e73a3091
DEV: Add routes and controller actions for passkeys (2/3) (#23587)
This is part 2 (of 3) for passkeys support.

This adds a hidden site setting plus routes and controller actions.

1. registering passkeys

Passkeys are registered in a two-step process. First, `create_passkey`
returns details for the browser to create a passkey. This includes
- a challenge
- the relying party ID and Origin
- the user's secure identifier
- the supported algorithms
- the user's existing passkeys (if any)

Then the browser creates a key with this information, and submits it to
the server via `register_passkey`.

2. authenticating passkeys

A similar process happens here as well. First, a challenge is created
and sent to the browser. Then the browser makes a public key credential
and submits it to the server via `passkey_auth_perform`.

3. renaming/deleting passkeys

These routes allow changing the name of a key and deleting it.

4. checking if session is trusted for sensitive actions

Since a passkey is a password replacement, we want to make sure to confirm the user's identity before allowing adding/deleting passkeys. The u/trusted-session GET route returns success if user has confirmed their session (and failed if user hasn't). In the frontend (in the next PR), we're using these routes to show the password confirmation screen. 

The `/u/confirm-session` route allows the user to confirm their session with a password. The latter route's functionality already existed in core, under the 2FA flow, but it has been abstracted into its own here so it can be used independently.


Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
2023-10-11 14:36:54 -04:00

132 lines
6.1 KiB
Ruby
Raw Permalink 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