12
113
Fork
You've already forked django-allauth
149

fix(mfa/webauthn): Enforce passwordless field for passkey authentication #4615

Open
marcpfuller wants to merge 3 commits from marcpfuller/django-allauth:bugfix/passwordless-login into main
pull from: marcpfuller/django-allauth:bugfix/passwordless-login
merge into: allauth:main
allauth:main
allauth:fix/account-middleware-reauth-exc-vs-api
allauth:65.12.x
allauth:65.11.x
allauth:feat/openid-connect-id-token
allauth:fix/phone-vs-social-signup
allauth:65.8.x
allauth:feat/idp
allauth:feat/resend-code-or-change
allauth:fix/phone-signup-conflict
allauth:feat/phone
allauth:fix/headless-email-verification-by-code
allauth:fix/a11y
allauth:feat/facebook-limited-login
allauth:fix/headless-only-connect-reauth
allauth:fix/email-added-vs-code-verification
allauth:feat/login-required-middleware
allauth:0.63.x
allauth:refactor-unique-true-field
allauth:0.62.x
allauth:0.57.x
allauth:0.60.x
allauth:0.55.x
allauth:feat-metamask
First-time contributor
Copy link

When MFA_PASSKEY_LOGIN_ENABLED is True, users were able to authenticate
with any WebAuthn credential regardless of the individual credential's
passwordless field setting. The passwordless checkbox on the credential
creation form had no effect on authentication behavior.

Changes:

  • Modified auth.get_credentials() to accept passwordless_only parameter
    that filters credentials based on their passwordless field
  • Modified auth.begin_authentication() and auth.complete_authentication()
    to pass passwordless flag through the authentication flow
  • Updated flows.add_authenticator() to save the passwordless flag when
    creating new credentials
  • Modified forms.AddWebAuthnForm to include passwordless checkbox with
    help text explaining its purpose
  • Updated forms.AuthenticateWebAuthnForm to pass passwordless flag to
    auth.complete_authentication()
  • Modified forms.LoginWebAuthnForm to set passwordless=True for
    passwordless login flows
  • Updated views.AddWebAuthnView to extract and pass passwordless flag
    when adding credentials
  • Modified views.LoginWebAuthnView to pass passwordless=True to
    auth.begin_authentication() and use flows.perform_passwordless_login()
  • Added comprehensive unit tests to verify filtering behavior

The fix ensures that:

  • Passwordless authentication (LoginWebAuthnView) only uses credentials
    with passwordless=True
  • MFA authentication (ReauthenticateWebAuthnView) uses all WebAuthn
    credentials regardless of passwordless flag
  • Credentials without explicit passwordless field default to False
  • The global MFA_PASSKEY_LOGIN_ENABLED setting still controls feature
    availability

Fixes: #4599

Signed-off-by: marc fuller gogita99@gmail.com

Submitting Pull Requests

General

  • Make sure you use semantic commit messages.
    Examples: "fix(google): Fixed foobar bug", "feat(accounts): Added foobar feature".
  • All Python code must formatted using Black, and clean from pep8 and isort issues.
  • JavaScript code should adhere to StandardJS.
  • If your changes are significant, please update ChangeLog.rst.
  • If your change is substantial, feel free to add yourself to AUTHORS.

Provider Specifics

In case you add a new provider:

  • Make sure unit tests are available.
  • Ensure your provider is tested by adding an entry over at tests/projects/common/settings.py::INSTALLED_SOCIALACCOUNT_APPS.
  • Provide provider specific documentation at docs/socialaccount/providers/<provider name>.rst.
  • Link to your provider specific documentation at docs/insocialaccount/providers/index.rst.
  • Add an entry for your provider in the quickstart, over at docs/installation/quickstart.rst::INSTALLED_APPS.
When MFA_PASSKEY_LOGIN_ENABLED is True, users were able to authenticate with any WebAuthn credential regardless of the individual credential's passwordless field setting. The passwordless checkbox on the credential creation form had no effect on authentication behavior. Changes: - Modified auth.get_credentials() to accept passwordless_only parameter that filters credentials based on their passwordless field - Modified auth.begin_authentication() and auth.complete_authentication() to pass passwordless flag through the authentication flow - Updated flows.add_authenticator() to save the passwordless flag when creating new credentials - Modified forms.AddWebAuthnForm to include passwordless checkbox with help text explaining its purpose - Updated forms.AuthenticateWebAuthnForm to pass passwordless flag to auth.complete_authentication() - Modified forms.LoginWebAuthnForm to set passwordless=True for passwordless login flows - Updated views.AddWebAuthnView to extract and pass passwordless flag when adding credentials - Modified views.LoginWebAuthnView to pass passwordless=True to auth.begin_authentication() and use flows.perform_passwordless_login() - Added comprehensive unit tests to verify filtering behavior The fix ensures that: - Passwordless authentication (LoginWebAuthnView) only uses credentials with passwordless=True - MFA authentication (ReauthenticateWebAuthnView) uses all WebAuthn credentials regardless of passwordless flag - Credentials without explicit passwordless field default to False - The global MFA_PASSKEY_LOGIN_ENABLED setting still controls feature availability Fixes: #4599 Signed-off-by: marc fuller <gogita99@gmail.com> # Submitting Pull Requests ## General - [x] Make sure you use [semantic commit messages](https://seesparkbox.com/foundry/semantic_commit_messages). Examples: `"fix(google): Fixed foobar bug"`, `"feat(accounts): Added foobar feature"`. - [x] All Python code must formatted using Black, and clean from pep8 and isort issues. - [x] JavaScript code should adhere to [StandardJS](https://standardjs.com). - [ ] If your changes are significant, please update `ChangeLog.rst`. - [ ] If your change is substantial, feel free to add yourself to `AUTHORS`. ## Provider Specifics In case you add a new provider: - [ ] Make sure unit tests are available. - [ ] Ensure your provider is tested by adding an entry over at `tests/projects/common/settings.py::INSTALLED_SOCIALACCOUNT_APPS`. - [ ] Provide provider specific documentation at `docs/socialaccount/providers/<provider name>.rst`. - [ ] Link to your provider specific documentation at `docs/insocialaccount/providers/index.rst`. - [ ] Add an entry for your provider in the quickstart, over at `docs/installation/quickstart.rst::INSTALLED_APPS`.
marcpfuller force-pushed bugfix/passwordless-login from 096ebe3043
Some checks are pending
ci/woodpecker/pr/woodpecker/1 Pipeline is pending approval
ci/woodpecker/pr/woodpecker/2 Pipeline is pending approval
to c3cc2dafa5
Some checks are pending
ci/woodpecker/pr/woodpecker/1 Pipeline is pending approval
ci/woodpecker/pr/woodpecker/2 Pipeline is pending approval
2025年11月26日 03:11:53 +01:00
Compare
marcpfuller force-pushed bugfix/passwordless-login from 4dddcb0a5d
Some checks are pending
ci/woodpecker/pr/woodpecker/1 Pipeline is pending approval
ci/woodpecker/pr/woodpecker/2 Pipeline is pending approval
to c801e73f3f
Some checks are pending
ci/woodpecker/pr/woodpecker/1 Pipeline is pending approval
ci/woodpecker/pr/woodpecker/2 Pipeline is pending approval
2025年11月26日 03:31:27 +01:00
Compare
marcpfuller force-pushed bugfix/passwordless-login from c801e73f3f
Some checks are pending
ci/woodpecker/pr/woodpecker/1 Pipeline is pending approval
ci/woodpecker/pr/woodpecker/2 Pipeline is pending approval
to f5dc33fffd
Some checks are pending
ci/woodpecker/pr/woodpecker/1 Pipeline is pending approval
ci/woodpecker/pr/woodpecker/2 Pipeline is pending approval
2025年11月26日 03:45:11 +01:00
Compare

Last time I checked it was not possible to reliably check whether or not a credential is passwordless. That is why this method can return None:

 @property
 def is_passwordless(self) -> Optional[bool]:
 return (
 self.instance.data.get("credential", {})
 .get("clientExtensionResults", {})
 .get("credProps", {})
 .get("rk")
 )

As you see, in order to tell if a credential is passwordless you have to rely on an extension which not all authenticators support. If we cannot tell if a credential can truly operate in a passwordless fashion, that is problematic, as even if the user checks the [X] Passwordless option it is not clear that is truly what the user is getting.

In this PR you're marking credentials as passwordless based on the checkbox but we likely need to keep the above into account as well.

Last time I checked it was not possible to reliably check whether or not a credential is passwordless. That is why this method can return `None`: ``` @property def is_passwordless(self) -> Optional[bool]: return ( self.instance.data.get("credential", {}) .get("clientExtensionResults", {}) .get("credProps", {}) .get("rk") ) ``` As you see, in order to tell if a credential is passwordless you have to rely on an extension which not all authenticators support. If we cannot tell if a credential can truly operate in a passwordless fashion, that is problematic, as even if the user checks the `[X] Passwordless` option it is not clear that is truly what the user is getting. In this PR you're marking credentials as passwordless based on the checkbox but we likely need to keep the above into account as well.

Doesn't this break backwards compatibility? Passkeys created by older allauth versions don't have the passwordless property stored.

Doesn't this break backwards compatibility? Passkeys created by older allauth versions don't have the `passwordless` property stored.
fix: address comments made by @pennersr
Some checks are pending
ci/woodpecker/pr/woodpecker/1 Pipeline is pending approval
ci/woodpecker/pr/woodpecker/2 Pipeline is pending approval
eab4acedb4
Signed-off-by: marc fuller <gogita99@gmail.com>
Author
First-time contributor
Copy link

@pennersr wrote in #4615 (comment):

Last time I checked it was not possible to reliably check whether or not a credential is passwordless. That is why this method can return None:

 @property
 def is_passwordless(self) -> Optional[bool]:
 return (
 self.instance.data.get("credential", {})
 .get("clientExtensionResults", {})
 .get("credProps", {})
 .get("rk")
 )

As you see, in order to tell if a credential is passwordless you have to rely on an extension which not all authenticators support. If we cannot tell if a credential can truly operate in a passwordless fashion, that is problematic, as even if the user checks the [X] Passwordless option it is not clear that is truly what the user is getting.

In this PR you're marking credentials as passwordless based on the checkbox but we likely need to keep the above into account as well.

@pennersr, I've updated the implementation to verify actual passwordless capability using credProps.rk instead of trusting the checkbox.

Key changes:

[x] complete_registration() now returns the actual capability from the authenticator
[x] Form validation rejects registration if capability is False or None (unknown)
[x] Only stores passwordless: true when explicitly verified as True
[x] Added backwards compatibility for legacy credentials via auto-migration
[x] This ensures we never falsely claim a credential is passwordless when we can't verify it. The implementation errs on the side of caution - if capability can't be confirmed, passwordless is disabled.

@pennersr wrote in https://codeberg.org/allauth/django-allauth/pulls/4615#issuecomment-8509944: > Last time I checked it was not possible to reliably check whether or not a credential is passwordless. That is why this method can return `None`: > > ```text > @property > def is_passwordless(self) -> Optional[bool]: > return ( > self.instance.data.get("credential", {}) > .get("clientExtensionResults", {}) > .get("credProps", {}) > .get("rk") > ) > ``` > > As you see, in order to tell if a credential is passwordless you have to rely on an extension which not all authenticators support. If we cannot tell if a credential can truly operate in a passwordless fashion, that is problematic, as even if the user checks the `[X] Passwordless` option it is not clear that is truly what the user is getting. > > In this PR you're marking credentials as passwordless based on the checkbox but we likely need to keep the above into account as well. @pennersr, I've updated the implementation to verify actual passwordless capability using credProps.rk instead of trusting the checkbox. Key changes: [x] complete_registration() now returns the actual capability from the authenticator [x] Form validation rejects registration if capability is False or None (unknown) [x] Only stores passwordless: true when explicitly verified as True [x] Added backwards compatibility for legacy credentials via auto-migration [x] This ensures we never falsely claim a credential is passwordless when we can't verify it. The implementation errs on the side of caution - if capability can't be confirmed, passwordless is disabled.

[x] Form validation rejects registration if capability is False or None (unknown)

Here we are potentially locking out users. Without these changes, an authenticator that does not support the extension would return None, yet, would possibly still be usable in a passwordless fashion. With these changes that is no longer possible.

The passwordless checkbox on the credential creation form had no effect on authentication behavior.

Currently, the semantics of that checkbox is that it only influences the capabilities of the credential that is being requested. It indeed does not pose any limitations on whether or not that credential can be allowed to login with. Your PR changes the semantics, and the question is whether this new semantics should replace the current semantics, or, whether it is something to be opted into. I am also wondering if this passwordless toggle should be editable?

In any case, before proceeding with this, I would like to see how other parties are doing this. It would be good to have an overview of say GitHub, Sentry and a few big others to see how they are approaching this. Perhaps you have done some research already in this regard?

> [x] Form validation rejects registration if capability is False or None (unknown) Here we are potentially locking out users. Without these changes, an authenticator that does not support the extension would return `None`, yet, would possibly still be usable in a passwordless fashion. With these changes that is no longer possible. > The passwordless checkbox on the credential creation form had no effect on authentication behavior. Currently, the semantics of that checkbox is that it only influences the capabilities of the credential that is being requested. It indeed does not pose any limitations on whether or not that credential can be allowed to login with. Your PR changes the semantics, and the question is whether this new semantics should replace the current semantics, or, whether it is something to be opted into. I am also wondering if this passwordless toggle should be editable? In any case, before proceeding with this, I would like to see how other parties are doing this. It would be good to have an overview of say GitHub, Sentry and a few big others to see how they are approaching this. Perhaps you have done some research already in this regard?
Some checks are pending
ci/woodpecker/pr/woodpecker/1 Pipeline is pending approval
ci/woodpecker/pr/woodpecker/2 Pipeline is pending approval
This pull request has changes conflicting with the target branch.
  • allauth/mfa/webauthn/internal/auth.py
  • allauth/mfa/webauthn/internal/flows.py
  • allauth/mfa/webauthn/views.py
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u bugfix/passwordless-login:marcpfuller-bugfix/passwordless-login
git switch marcpfuller-bugfix/passwordless-login

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff marcpfuller-bugfix/passwordless-login
git switch marcpfuller-bugfix/passwordless-login
git rebase main
git switch main
git merge --ff-only marcpfuller-bugfix/passwordless-login
git switch marcpfuller-bugfix/passwordless-login
git rebase main
git switch main
git merge --no-ff marcpfuller-bugfix/passwordless-login
git switch main
git merge --squash marcpfuller-bugfix/passwordless-login
git switch main
git merge --ff-only marcpfuller-bugfix/passwordless-login
git switch main
git merge marcpfuller-bugfix/passwordless-login
git push origin main
Sign in to join this conversation.
No reviewers
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
allauth/django-allauth!4615
Reference in a new issue
allauth/django-allauth
No description provided.
Delete branch "marcpfuller/django-allauth:bugfix/passwordless-login"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?