Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix regression in 0.2.0, rework logic for clarity #12

Merged
merged 2 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 0 additions & 18 deletions src/django_otp_webauthn/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,6 @@ class PasswordlessLoginDisabled(OTPWebAuthnApiError):
default_code = "passwordless_login_disabled"


class RegistrationDisabled(OTPWebAuthnApiError):
status_code = 403
default_detail = _("Registration is disabled.")
default_code = "registration_disabled"


class AuthenticationDisabled(OTPWebAuthnApiError):
status_code = 403
default_detail = _("Authentication is disabled.")
default_code = "authentication_disabled"


class LoginRequired(OTPWebAuthnApiError):
status_code = 403
default_detail = _("User is not logged in.")
default_code = "login_required"


class UserDisabled(OTPWebAuthnApiError):
status_code = 403
default_detail = _("This user account is marked as disabled.")
Expand Down
60 changes: 29 additions & 31 deletions src/django_otp_webauthn/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from django.contrib.auth import get_user_model
from django.contrib.auth import login as auth_login
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser
from django.shortcuts import resolve_url
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme
Expand All @@ -35,41 +34,44 @@ def _get_pywebauthn_logger():

class RegistrationCeremonyMixin:
def dispatch(self, request, *args, **kwargs):
self.user = self.get_user()
if not self.user:
raise exceptions.UserDisabled()

if not self.can_register(self.user):
raise exceptions.RegistrationDisabled()
self.check_can_register()
return super().dispatch(request, *args, **kwargs)

def get_user(self) -> AbstractBaseUser | None:
if self.request.user.is_authenticated:
return self.request.user
return None

def can_register(self, user: AbstractBaseUser | AnonymousUser) -> bool:
if not user.is_active:
return False
return True
def check_can_register(self):
"""Perform any necessary pre-checks to see if the registration ceremony can proceed."""
user = self.get_user()
# Only active users may attempt to register a new credential
if user and not user.is_active:
raise exceptions.UserDisabled()


class AuthenticationCeremonyMixin:
def dispatch(self, request, *args, **kwargs):
self.user = self.get_user()
if not self.can_authenticate(self.user):
raise exceptions.AuthenticationDisabled()
self.check_can_authenticate()
return super().dispatch(request, *args, **kwargs)

def get_user(self) -> AbstractBaseUser | None:
if self.request.user.is_authenticated:
return self.request.user
return None

def can_authenticate(self, user: AbstractBaseUser | AnonymousUser | None) -> bool:
def check_can_authenticate(self):
"""Perform any necessary pre-checks to see if the authentication ceremony can proceed."""
user = self.get_user()
# In case we already have a user (scenario: webauthn used as a second factor only)
# we allow the ceremony to proceed, as long as the user is active
if user and user.is_active:
return True
return False
return

# In case we don't have a user (scenario: webauthn used as for passwordless login, no user logged in yet)
disallow_passwordless_login = not app_settings.OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN
if self.get_user() is None and disallow_passwordless_login:
raise exceptions.PasswordlessLoginDisabled()


@method_decorator(never_cache, name="dispatch")
Expand All @@ -80,7 +82,7 @@ class BeginCredentialRegistrationView(RegistrationCeremonyMixin, APIView):
"""

def post(self, *args, **kwargs):
user = self.user
user = self.get_user()
helper = WebAuthnCredential.get_webauthn_helper(request=self.request)
data, state = helper.register_begin(user=user)

Expand Down Expand Up @@ -108,7 +110,7 @@ def get_state(self):
return state

def post(self, *args, **kwargs):
user = self.user
user = self.get_user()
state = self.get_state()
data = self.request.data

Expand All @@ -130,7 +132,7 @@ class BeginCredentialAuthenticationView(AuthenticationCeremonyMixin, APIView):
"""

def post(self, *args, **kwargs):
user = self.user
user = self.get_user()

helper = WebAuthnCredential.get_webauthn_helper(request=self.request)
require_user_verification = not bool(user)
Expand Down Expand Up @@ -164,29 +166,25 @@ def get_state(self):
raise exceptions.InvalidState()
return state

def check_login_allowed(self, device: AbstractWebAuthnCredential) -> None:
"""Check if the user is allowed to log in using the device.
def check_device_usable(self, device: AbstractWebAuthnCredential) -> None:
"""Check if this device may be used to login.

This will raise:
- ``PasswordlessLoginDisabled`` if there is no user logged in and
``OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN`` is False.
- ``CredentialDisabled`` if `device.confirmed=False`.
- ``UserDisabled`` if the user associated with the device is not
active.

You can override this method to implement your own custom logic. If you
do, you should raise an exception if the user is not allowed to log in.
do, you should raise an exception if this device may not be used to
login.

Args:
device (AbstractWebAuthnCredential): The device the user is trying to log
in with.
"""
disallow_passwordless_login = not app_settings.OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN
if not device.confirmed:
raise exceptions.CredentialDisabled()

if self.get_user() is None and disallow_passwordless_login:
raise exceptions.PasswordlessLoginDisabled()

if not device.user.is_active:
raise exceptions.UserDisabled()

Expand Down Expand Up @@ -236,7 +234,7 @@ def get_success_url(self):
return self.get_redirect_url() or resolve_url(settings.LOGIN_REDIRECT_URL)

def post(self, *args, **kwargs):
user = self.user
user = self.get_user()
state = self.get_state()
data = self.request.data

Expand All @@ -246,7 +244,7 @@ def post(self, *args, **kwargs):
with rewrite_exceptions(logger=logger):
device = helper.authenticate_complete(user=user, state=state, data=data)

self.check_login_allowed(device)
self.check_device_usable(device)

self.complete_auth(device)

Expand Down