Coverage for src/django_otp_webauthn/views.py: 0%
132 statements
« prev ^ index » next coverage.py v7.5.4, created at 2024-06-23 20:15 +0000
« prev ^ index » next coverage.py v7.5.4, created at 2024-06-23 20:15 +0000
1from functools import lru_cache
2from logging import getLogger
4from django.conf import settings
5from django.contrib.auth import get_user_model
6from django.contrib.auth import login as auth_login
7from django.contrib.auth.models import AbstractUser
8from django.shortcuts import resolve_url
9from django.utils.decorators import method_decorator
10from django.utils.http import url_has_allowed_host_and_scheme
11from django.views.decorators.cache import never_cache
12from django_otp import login as otp_login
13from rest_framework.response import Response
14from rest_framework.views import APIView
16from django_otp_webauthn import exceptions
17from django_otp_webauthn.models import AbstractWebAuthnCredential
18from django_otp_webauthn.settings import app_settings
19from django_otp_webauthn.utils import get_credential_model, rewrite_exceptions
21WebAuthnCredential = get_credential_model()
22User = get_user_model()
25@lru_cache(maxsize=1)
26def _get_pywebauthn_logger():
27 logger_name = app_settings.OTP_WEBAUTHN_EXCEPTION_LOGGER_NAME
28 if logger_name:
29 return getLogger(logger_name)
32class RegistrationCeremonyMixin:
33 def dispatch(self, request, *args, **kwargs):
34 self.user = self.get_user()
35 if not self.user:
36 raise exceptions.UserDisabled()
38 if not self.can_register(self.user):
39 raise exceptions.RegistrationDisabled()
40 return super().dispatch(request, *args, **kwargs)
42 def get_user(self) -> AbstractUser:
43 if self.request.user.is_authenticated:
44 return self.request.user
45 return None
47 def can_register(self, user: AbstractUser) -> bool:
48 if not user.is_active:
49 return False
50 return True
53class AuthenticationCeremonyMixin:
54 def dispatch(self, request, *args, **kwargs):
55 self.user = self.get_user()
56 if not self.can_authenticate(self.user):
57 raise exceptions.AuthenticationDisabled()
58 return super().dispatch(request, *args, **kwargs)
60 def get_user(self) -> AbstractUser:
61 if self.request.user.is_authenticated:
62 return self.request.user
63 return None
65 def can_authenticate(self, user: AbstractUser) -> bool:
66 if user and not user.is_active:
67 return False
68 return True
71@method_decorator(never_cache, name="dispatch")
72class BeginCredentialRegistrationView(RegistrationCeremonyMixin, APIView):
73 """View for starting webauthn credential registration. Requires the user to be logged in.
75 This view will return a JSON response with the options for the client to use to register a credential.
76 """
78 def post(self, *args, **kwargs):
79 user = self.user
80 helper = WebAuthnCredential.get_webauthn_helper(request=self.request)
81 data, state = helper.register_begin(user=user)
83 self.request.session["otp_webauthn_register_state"] = state
85 return Response(data=data, content_type="application/json")
88@method_decorator(never_cache, name="dispatch")
89class CompleteCredentialRegistrationView(RegistrationCeremonyMixin, APIView):
90 """View for completing webauthn credential registration. Requires the user to be logged in and to have started the registration.
92 This view accepts client data about the registered credential, validates it, and saves the credential to the database.
93 """
95 def get_state(self):
96 """Retrieve the registration state."""
98 state = self.request.session.pop("otp_webauthn_register_state", None)
99 # Ensure to persist the session after popping the state, so even if an
100 # exception is raised, the state is _never_ reused.
101 self.request.session.save()
102 if not state:
103 raise exceptions.InvalidState()
104 return state
106 def post(self, *args, **kwargs):
107 user = self.user
108 state = self.get_state()
109 data = self.request.data
111 helper = WebAuthnCredential.get_webauthn_helper(request=self.request)
113 logger = _get_pywebauthn_logger()
114 with rewrite_exceptions(logger=logger):
115 device = helper.register_complete(user=user, state=state, data=data)
116 return Response(data={"id": device.pk}, content_type="application/json")
119@method_decorator(never_cache, name="dispatch")
120class BeginCredentialAuthenticationView(AuthenticationCeremonyMixin, APIView):
121 """View for starting webauthn credential authentication. User does not necessarily need to be logged in.
123 If the user is logged in, this view supplies more hints to the client about registered credentials.
125 This view will return a JSON response with the options for the client to use to authenticate with a credential.
126 """
128 def post(self, *args, **kwargs):
129 user = self.user
131 helper = WebAuthnCredential.get_webauthn_helper(request=self.request)
132 require_user_verification = not bool(user)
134 data, state = helper.authenticate_begin(user=user, require_user_verification=require_user_verification)
135 self.request.session["otp_webauthn_authentication_state"] = state
137 return Response(data=data, content_type="application/json")
140@method_decorator(never_cache, name="dispatch")
141class CompleteCredentialAuthenticationView(AuthenticationCeremonyMixin, APIView):
142 """View for completing webauthn credential authentication. Requires the user to be
143 logged in and to have started the authentication.
145 This view accepts client data about the registered webauthn , validates it,
146 and logs the user in.
147 """
149 def get_state(self):
150 """Retrieve the authentication state."""
151 # It is VITAL that we pop the state from the session before we do anything else.
152 # We must not allow the state to be used more than once or we risk replay attacks.
154 state = self.request.session.pop("otp_webauthn_authentication_state", None)
155 # Ensure to persist the session after popping the state, so even if an
156 # exception is raised, the state is _never_ reused.
157 self.request.session.save()
159 if not state:
160 raise exceptions.InvalidState()
161 return state
163 def check_login_allowed(self, device: AbstractWebAuthnCredential) -> None:
164 """Check if the user is allowed to log in using the device.
166 This will raise:
167 - ``PasswordlessLoginDisabled`` if there is no user logged in and
168 ``OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN`` is False.
169 - ``UserDisabled`` if the user associated with the device is not
170 active.
172 You can override this method to implement your own custom logic. If you
173 do, you should raise an exception if the user is not allowed to log in.
175 Args:
176 device (AbstractWebAuthnCredential): The device the user is trying to log
177 in with.
178 """
179 disallow_passwordless_login = not app_settings.OTP_WEBAUTHN_ALLOW_PASSWORDLESS_LOGIN
180 if not device.confirmed:
181 raise exceptions.CredentialDisabled()
183 if self.get_user() is None and disallow_passwordless_login:
184 raise exceptions.PasswordlessLoginDisabled()
186 if not device.user.is_active:
187 raise exceptions.UserDisabled()
189 def complete_auth(self, device: AbstractWebAuthnCredential) -> AbstractUser:
190 """Handle the completion of the authentication procedure.
192 This method is called when a credential was successfully used and
193 the user is allowed to log in. The user is logged in and marked as
194 having passed verification.
196 You may override this method to implement custom logic.
197 """
198 user = device.user
199 if not self.request.user.is_authenticated:
200 auth_login(self.request, user)
202 # Mark the user as having passed verification
203 otp_login(self.request, device)
205 success_url_allowed_hosts = set()
207 def get_success_data(self, device: AbstractWebAuthnCredential):
208 data = {
209 "id": device.pk,
210 "redirect_url": self.get_success_url(),
211 }
213 return data
215 def get_success_url_allowed_hosts(self):
216 return {self.request.get_host(), *self.success_url_allowed_hosts}
218 def get_redirect_url(self):
219 """Return the user-originating redirect URL if it's safe."""
220 redirect_to = self.request.GET.get("next")
221 url_is_safe = url_has_allowed_host_and_scheme(
222 url=redirect_to,
223 allowed_hosts=self.get_success_url_allowed_hosts(),
224 require_https=self.request.is_secure(),
225 )
226 return redirect_to if url_is_safe else ""
228 def get_success_url(self):
229 """Where to send the user after a successful login."""
230 return self.get_redirect_url() or resolve_url(settings.LOGIN_REDIRECT_URL)
232 def post(self, *args, **kwargs):
233 user = self.user
234 state = self.get_state()
235 data = self.request.data
237 helper = WebAuthnCredential.get_webauthn_helper(request=self.request)
239 logger = _get_pywebauthn_logger()
240 with rewrite_exceptions(logger=logger):
241 device = helper.authenticate_complete(user=user, state=state, data=data)
243 self.check_login_allowed(device)
245 self.complete_auth(device)
247 return Response(self.get_success_data(device))