jeremyevans / rodauth

Ruby's Most Advanced Authentication Framework
http://rodauth.jeremyevans.net
MIT License
1.65k stars 95 forks source link

Allow WebAuthn login to count for two factors #354

Closed janko closed 12 months ago

janko commented 12 months ago

From the FIDO Alliance website:

Are passkeys considered multi-factor authentication?

Passkeys are kept on a user’s devices (something the user “has”) and — if the RP requests User Verification — can only be exercised by the user with a biometric or PIN (something the user “is” or ”knows”). Thus, authentication with passkeys embodies the core principle of multi-factor security.

To allow passwordless login via a passkey to be counted for two factors, we add a new webauthn_login_two_factors? configuration option, which when set to true will consider the user two-factor authenticated if they're using multifactor authentication.

This is useful for skipping classic 2nd factor methods such as TOTP, recovery codes or SMS codes, but it's especially useful when the account has a password or email_auth is enabled, both of which are considered additional factors, and would normally be required after WebAuthn login.

In this case, webauthn_user_verification should probably be set to preferred (set by webauthn_autofill feature) or required, rather than discouraged set by webauthn feature. Though, even with discouraged user verification, my Mac and iPhone still ask for biometric identification.

jeremyevans commented 12 months ago

I'm OK with the general idea, but having webauthn twice seems odd. I think it may be better to use a separate string (webauthn-verification maybe?). What are your thoughts on that?

Ideally, you would only do this if you have confirmed that user verification took place. However, considering this is disabled by default and must be enabled manually, the way it is setup seems fine.

janko commented 12 months ago

Thanks for the suggestion. I first assumed that the server doesn't know whether user verification took place, but then I found out that this flag is actually included in the assertion response. So, I changed the behavior to only add the additional factor when user verification took place, and named it webauthn-verification as you suggested.

janko commented 12 months ago

I also modified webauthn_user_verification to return preferred when this setting has been turned on. When I use this feature, I want as many users to avoid having to authenticate with 2nd factor, so I would prefer user verification to happen.

janko commented 12 months ago

I guess preferred should be set only for passwordless login scenario, and discouraged kept when authenticating via WebAuthn as 2nd factor (since we already have one factor in this case, so proof of ownership is enough). Probably the same for webauthn_autofill feature.

jeremyevans commented 12 months ago

This looks good. I'll test and merge tomorrow. Thanks for the patch!

janko commented 12 months ago

Do you think this behavior could become the default in Rodauth 3? I think most apps would want this, as it reduces the amount of steps for the end user, and my impression was that it's how passkeys were intended to be used (i.e. acting as both factors). The main reason I disabled it by default was because I was cautious about backwards compatibility, though I'm not sure if it changes anything now that we explicitly check whether user verification took place.

jeremyevans commented 12 months ago

Making this the default behavior in Rodauth 3 seems reasonable to me.

jeremyevans commented 12 months ago

Making this the default behavior in Rodauth 3 seems reasonable to me.

After thinking about this more, I'm not sure I want to do this by default.

Consider the common password + webauthn compared to webauthn + user verification. In the password + webauthn case, you can at least somewhat control password strength (length, complexity, blacklists). In the webauthn + user verification case, it seems like you have no control over the strength of the user verification.

It doesn't seem like you can enforce the strength of the user verification unless you are controlling and configuring the passkey (i.e. you provide the passkey to the user, instead of the user providing their own). There are definitely environments where this is the case, as I have personal experience with. However, that doesn't seem to be the common case.

In addition to not being able to control the strength of the user verification, if the user can provide their own authenticator, they could select one that says it is user verified when it is not. There does not appear to be a way for the server to enforce that the authenticator is telling the truth regarding user verification. I would guess that commonly available authenticators wouldn't lie about this, but there doesn't seem to be a way to enforce it.

It seems like we would want to document that you should only enable this option if you are providing the authenticators used and are ensuring that they correctly implement user verification to a strength that you accept as an additional factor.

Am I misunderstanding the trust issue here? Are the benefits of ignoring the issue and trusting by default worth the cost of allowing users to pick authenticators that support weak user verification or lie about user verification? What are your thoughts?

Note that everything else looks good to merge. I would just like your feedback before updating the documentation and deciding whether to make this the default behavior in Rodauth 3.

janko commented 12 months ago

I appreciate the discussion. As I see it, it's ultimately the user's responsibility to protect their own account. If they chose an authenticator because it lies about user verification, that's on them, they will have a less protected account. Though if they're part of an organization that requires MFA for the security of the organization, then a breach of their account wouldn't hurt only them.

On the other hand, if the user verification is just weak (I'm imagining a 4-digit PIN), the user might trust the app to know whether that is enough to count for two factors. Most users don't really feel like setting up and using 2nd factor, the app usually needs to motivate them. So, in this case I see it as the responsibility of the app to determine what is good enough.

If these are real concerns, then I'm just surprised how come big companies like Google and GitHub treat my passkeys as two factors. My authenticators are my Mac and iPhone, both of which use biometric user verification, but I think they cannot know that. I haven't tested how they treat security keys that have no user verification, as I don't have such an authenticator with me. It helps me to look at what big companies are doing in terms of authentication when I'm trying to figure out what is secure enough.

I also noticed that when I register a passkey on my Google Account, it seems to know where it is stored (iCloud Keychain, 1Password etc). I looked at the list of WebAuthn extensions on MDN, and I couldn't find a way to obtain this information. The most I could find is that the Relying Party can request the minimum PIN length from the authenticator, which I presume can then be used to determine whether that's strong enough to count for 2nd factor. I'm now also thinking whether the new configuration option shouldn't also accept the credential as the argument, if there are ways to assess the strength of user verification 🤔

jeremyevans commented 12 months ago

I appreciate the discussion. As I see it, it's ultimately the user's responsibility to protect their own account. If they chose an authenticator because it lies about user verification, that's on them, they will have a less protected account. Though if they're part of an organization that requires MFA for the security of the organization, then a breach of their account wouldn't hurt only them.

For cases where two factor authentication is optional for the user, I agree completely that treating passkey + user verification as two factors makes sense. For cases where the organization requires MFA for all users, this seems like a simple way to combine two factors into a single factor unless the organization controls the authenticators.

On the other hand, if the user verification is just weak (I'm imagining a 4-digit PIN), the user might trust the app to know whether that is enough to count for two factors. Most users don't really feel like setting up and using 2nd factor, the app usually needs to motivate them. So, in this case I see it as the responsibility of the app to determine what is good enough.

Maybe there is a way for the authenticator to provide that information when setting up the authentication, but as there is no way for the app to verify it, this again relies on the app trusting the authenticator, which should only be done if the organization running the app controls the authenticators.

If these are real concerns, then I'm just surprised how come big companies like Google and GitHub treat my passkeys as two factors. My authenticators are my Mac and iPhone, both of which use biometric user verification, but I think they cannot know that. I haven't tested how they treat security keys that have no user verification, as I don't have such an authenticator with me. It helps me to look at what big companies are doing in terms of authentication when I'm trying to figure out what is secure enough.

Google and GitHub both are opt-in for MFA and do not require MFA as far as I know, so them treating the passkey + user verification as two factors seems reasonable.

I also noticed that when I register a passkey on my Google Account, it seems to know where it is stored (iCloud Keychain, 1Password etc). I looked at the list of WebAuthn extensions on MDN, and I couldn't find a way to obtain this information. The most I could find is that the Relying Party can request the minimum PIN length from the authenticator, which I presume can then be used to determine whether that's strong enough to count for 2nd factor. I'm now also thinking whether the new configuration option shouldn't also accept the credential as the argument, if there are ways to assess the strength of user verification 🤔

The issue with minPinLength is that it again relies on the app trusting the authenticator.

Thanks for the discussion. I haven't decided yet on whether to make this the default, but I'll update the documentation regarding this when I merge.

bjeanes commented 4 months ago

I also noticed that when I register a passkey on my Google Account, it seems to know where it is stored (iCloud Keychain, 1Password etc). I looked at the list of WebAuthn extensions on MDN, and I couldn't find a way to obtain this information.

I don't know if an answer to this was found, but I am doing a bit of research on this to try to give Webauthn credentials default names (and, ultimately, allow the user to specify a custom one). I got a proof-of-concept working by using https://github.com/passkeydeveloper/passkey-authenticator-aaguids and a feature:

module Rodauth
  Feature.define(:webauthn_metadata, :WebauthnMetadata) do
    depends :webauthn

    auth_value_method :webauthn_setup_name_param, "webauthn_key_name"

    def webauthn_attestation
      "indirect" # https://www.w3.org/TR/webauthn-2/#dom-attestationconveyancepreference-indirect
    end

    def webauthn_key_insert_hash(webauthn_credential)
      super.merge({
        name: param(webauthn_setup_name_param).presence,
        aaguid: webauthn_credential.response.aaguid || "00000000-0000-0000-0000-000000000000"
        # attestation_payload: webauthn_credential.response.send(:attestation_object_bytes)
      })
    end

    def account_webauthn_keys
      webauthn_keys_ds.all.map do |key|
        meta = WebauthnMetadata::AAGUIDS[key[:aaguid]] || {"name" => "Unknown"}
        {**meta, **key, default_name: meta["name"]}.with_indifferent_access
      end
    end
  end

  # Maps between AAGUIDs and a map that includes keys for 'name', 'icon_light', 'icon_dark'
  WebauthnMetadata::AAGUIDS = JSON.parse(File.read("/webauthn/webauthn_aaguids.json"))
end

The real feature would probably want to use the official metadata service, as that link suggests, but it seems to me that the key to answering questions about the security key or passkey is via inspecting the webauthn_credential.response.