-
Notifications
You must be signed in to change notification settings - Fork 10
/
Copy pathviews.py
438 lines (357 loc) · 17.6 KB
/
views.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
"""Views for FIDO 2 registration and login."""
import base64
import logging
import uuid
import warnings
from abc import ABCMeta, abstractmethod
from enum import Enum, unique
from http.client import BAD_REQUEST
from typing import Any, List, Mapping, Optional, Tuple, cast
from django.conf import settings
from django.contrib.auth import authenticate, get_user_model, login
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.forms import Form
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.encoding import force_str
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView, View
from fido2.attestation import Attestation, AttestationVerifier, UnsupportedType
from fido2.attestation.base import AttestationResult, InvalidSignature
from fido2.server import Fido2Server
from fido2.utils import _DataClassMapping
from fido2.webauthn import (AttestationConveyancePreference, AttestedCredentialData, AuthenticatorData,
PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, ResidentKeyRequirement)
from django_fido.utils import process_callable
from .constants import (AUTHENTICATION_USER_SESSION_KEY, FIDO2_AUTHENTICATION_REQUEST, FIDO2_REGISTRATION_REQUEST,
FIDO2_REQUEST_SESSION_KEY)
from .forms import (Fido2AuthenticationForm, Fido2ModelAuthenticationForm, Fido2PasswordlessAuthenticationForm,
Fido2RegistrationForm)
from .models import Authenticator
from .settings import SETTINGS
_LOGGER = logging.getLogger(__name__)
class BaseAttestationVerifier(AttestationVerifier):
"""Verify the attestation, but not the trust chain."""
def ca_lookup(self,
attestation_result: AttestationResult,
client_data_hash: AuthenticatorData) -> Optional[bytes]:
"""Return empty CA lookup to disable trust path verification."""
return None
@unique
class Fido2ServerError(str, Enum):
"""FIDO 2 server error types."""
DEFAULT = 'Fido2ServerError'
NO_AUTHENTICATORS = 'NoAuthenticatorsError'
class Fido2Error(ValueError):
"""FIDO 2 error."""
def __init__(self, *args, error_code: Fido2ServerError):
"""Set error code."""
super().__init__(*args)
self.error_code = error_code
class Fido2ViewMixin(object):
"""
Mixin with common methods for all FIDO 2 views.
@cvar rp_name: Name of the relying party.
If None, the value of setting ``DJANGO_FIDO_RP_NAME`` is used instead.
If None, the RP ID is used instead.
@cvar attestation: Attestation conveyance preference,
see https://www.w3.org/TR/webauthn/#enumdef-attestationconveyancepreference
@cvar attestation_types: Allowed attestation format types.
If None, all attestation formats except `none` are allowed.
@cvar user_verification: Requirement of user verification,
see https://www.w3.org/TR/webauthn/#userVerificationRequirement
@cvar session_key: Session key where the FIDO 2 state is stored.
"""
attestation = AttestationConveyancePreference.NONE
attestation_types: Optional[List[Attestation]] = None
verify_attestation = BaseAttestationVerifier
user_verification = SETTINGS.user_verification
session_key = FIDO2_REQUEST_SESSION_KEY
rp_name = None # type: Optional[str]
def get_rp_name(self) -> str:
"""Return relying party name."""
return self.rp_name or SETTINGS.rp_name or self.get_rp_id()
def get_rp_id(self) -> str:
"""Return RP id - only a hostname for web services."""
# `partition()` is faster than `split()`
return self.request.get_host().partition(':')[0] # type: ignore
@property
def server(self) -> Fido2Server:
"""Return FIDO 2 server instance."""
rp = PublicKeyCredentialRpEntity(self.get_rp_name(), self.get_rp_id())
if self.verify_attestation is None:
if self.attestation_types is not None:
warnings.warn('You have defined `attestation_types` but not `verify_attestation`, this means that the '
'`attestation_types` setting is being iognored.', DeprecationWarning)
return Fido2Server(rp, attestation=self.attestation)
else:
return Fido2Server(
rp,
attestation=self.attestation,
verify_attestation=self.verify_attestation(cast(List[Attestation], self.attestation_types))
)
@abstractmethod
def get_user(self) -> AbstractBaseUser:
"""Return user which is subject of the request."""
pass
def get_credentials(self, user: AbstractBaseUser) -> List[AttestedCredentialData]:
"""Return list of user's credentials."""
return [AttestedCredentialData(a.credential) for a in user.authenticators.all()]
class Fido2Encoder(DjangoJSONEncoder):
"""Added encoding of fido2 classes."""
def default(self, obj):
"""Handle `_DataClassMapping` objects and bytes."""
converted = {}
if isinstance(obj, _DataClassMapping):
for key in obj.keys():
converted[key] = obj[key]
return converted
elif isinstance(obj, bytes):
return base64.b64encode(obj).decode('utf-8')
return super().default(obj)
class BaseFido2RequestView(Fido2ViewMixin, View, metaclass=ABCMeta):
"""Base view for FIDO 2 request views."""
@abstractmethod
def create_fido2_request(self) -> Tuple[Mapping[str, Any], Any]:
"""Create and return FIDO 2 request.
@raise ValueError: If request can't be created.
"""
pass
def get(self, request: HttpRequest) -> JsonResponse:
"""Return JSON with FIDO 2 request."""
try:
request_data, state = self.create_fido2_request()
except ValueError as error:
return JsonResponse({
'error_code': getattr(error, 'error_code', Fido2ServerError.DEFAULT),
'message': force_str(error),
'error': force_str(error), # error key is deprecated and will be removed in the future
}, status=BAD_REQUEST)
# Store the state into session
self.request.session[self.session_key] = state
return JsonResponse(dict(request_data), encoder=Fido2Encoder)
class Fido2RegistrationRequestView(LoginRequiredMixin, BaseFido2RequestView):
"""Returns registration request and stores its state in session."""
def get_user(self):
"""Return user which is subject of the request."""
return self.request.user
@staticmethod
def get_user_id(user: AbstractBaseUser) -> bytes:
"""Return a unique, persistent identifier of a user. Prefer settings callable over default implementation.
Default implementation return user's username, but it is only secure if the username can't be reused.
In such case, it is required to provide another identifier which would differentiate users.
See https://www.w3.org/TR/webauthn/#dom-publickeycredentialuserentity-id and
https://tools.ietf.org/html/rfc8266#section-6.1 for details.
If resident_key is True, we need to return an uuid string that does not disclose user identity
"""
if SETTINGS.resident_key:
return uuid.uuid4().bytes
user_id = process_callable(SETTINGS.get_user_id_callable, user)
if user_id is not None:
return user_id
return bytes(user.username, encoding="utf-8")
@staticmethod
def get_user_display_name(user: AbstractBaseUser) -> str:
"""Retrieve user display name. Prefer settings callable over default implementation."""
display_name = process_callable(SETTINGS.get_user_display_name_callable, user)
if display_name is not None:
return display_name
return user.get_full_name() or user.username
@staticmethod
def get_username(user: AbstractBaseUser) -> str:
"""Retrieve user username. Prefer settings callable over default implementation."""
username = process_callable(SETTINGS.get_username_callable, user)
if username is not None:
return username
return user.username
def get_user_data(self, user: AbstractBaseUser) -> PublicKeyCredentialUserEntity:
"""Convert user instance to user data for registration."""
return PublicKeyCredentialUserEntity(
self.get_username(user), self.get_user_id(user), self.get_user_display_name(user)
)
def create_fido2_request(self) -> Tuple[Mapping[str, Any], Any]:
"""Create and return FIDO 2 registration request.
@raise ValueError: If request can't be created.
"""
user = self.get_user()
assert user.is_authenticated, "User must not be anonymous for FIDO 2 requests."
credentials = self.get_credentials(user)
return self.server.register_begin(
self.get_user_data(user), credentials, user_verification=self.user_verification,
resident_key_requirement=ResidentKeyRequirement.REQUIRED if SETTINGS.resident_key else None
)
class Fido2RegistrationView(LoginRequiredMixin, Fido2ViewMixin, FormView):
"""
View to register FIDO 2 authenticator.
@cvar title: View title.
@cvar session_key: Session key where the FIDO 2 state is stored.
@cvar fido2_request_url: URL at which an FIDO 2 request is provided.
@cvar fido2_request_type: FIDO 2 request type
"""
form_class = Fido2RegistrationForm
template_name = 'django_fido/fido2_form.html'
success_url = reverse_lazy('django_fido:registration_done')
title = _("Register a new FIDO 2 authenticator")
fido2_request_url = reverse_lazy('django_fido:registration_request')
fido2_request_type = FIDO2_REGISTRATION_REQUEST
def complete_registration(self, form: Form) -> AuthenticatorData:
"""
Complete the registration.
@raise ValidationError: If the registration can't be completed.
"""
state = self.request.session.pop(self.session_key, None)
if state is None:
raise ValidationError(_('Registration request not found.'), code='missing')
try:
return self.server.register_complete(state, form.cleaned_data['client_data'],
form.cleaned_data['attestation'])
except ValueError as error:
_LOGGER.info("FIDO 2 registration failed with error: %r", error)
raise ValidationError(_('Registration failed.'), code='invalid')
except UnsupportedType as error:
_LOGGER.info("FIDO 2 registration failed with error: %r", error)
raise ValidationError(_('Security key is not supported because it cannot be identified.'), code='invalid')
except InvalidSignature as error:
_LOGGER.info("FIDO2 registration failed with error: %r", error)
raise ValidationError(_('Registration failed, incorrect data from security key.'), code='invalid')
def form_valid(self, form: Form) -> HttpResponse:
"""Complete the registration and return response."""
try:
# Return value is ignored, because we need whole attestation.
self.complete_registration(form)
except ValidationError as error:
form.add_error(None, error)
return self.form_invalid(form)
Authenticator.objects.create(user=self.request.user, attestation=form.cleaned_data['attestation'],
user_handle=form.cleaned_data.get('user_handle'),
label=form.cleaned_data.get('label'))
return super().form_valid(form)
def form_invalid(self, form: Form) -> HttpResponse:
"""Clean the FIDO 2 request from session."""
self.request.session.pop(self.session_key, None)
if 'attestation' in form.errors.keys():
form.add_error(None, ValidationError(_('Security key is not supported because it cannot be identified.'),
code='invalid'))
del form.errors['attestation']
if 'client_data' in form.errors.keys():
form.add_error(None, ValidationError(_('Registration failed.'), code='invalid'))
del form.errors['client_data']
return super().form_invalid(form)
class Fido2AuthenticationViewMixin(Fido2ViewMixin):
"""
Mixin for FIDO 2 authentication views.
Ensure user to be authenticated exists.
"""
def get_user(self: View) -> Optional[AbstractBaseUser]:
"""
Return user which is to be authenticated.
Return None, if no user could be found.
"""
user_pk = self.request.session.get(AUTHENTICATION_USER_SESSION_KEY)
username = self.request.GET.get('username')
if SETTINGS.passwordless_auth:
return None
try:
if SETTINGS.two_step_auth and user_pk is not None:
return get_user_model().objects.get(pk=user_pk)
if not SETTINGS.two_step_auth and username is not None:
return get_user_model().objects.get_by_natural_key(username)
except get_user_model().DoesNotExist:
return None
return None
def dispatch(self, request, *args, **kwargs):
"""For two step authentication redirect to login, if user couldn't be found."""
if SETTINGS.two_step_auth:
user = self.get_user()
if user is None or not user.is_authenticated:
return redirect(settings.LOGIN_URL)
return super().dispatch(request, *args, **kwargs) # type: ignore
class Fido2AuthenticationRequestView(Fido2AuthenticationViewMixin, BaseFido2RequestView):
"""Returns authentication request and stores its state in session."""
def create_fido2_request(self) -> Tuple[Mapping[str, Any], Any]:
"""Create and return FIDO 2 authentication request.
@raise ValueError: If request can't be created.
"""
if SETTINGS.passwordless_auth:
credentials = []
else:
user = self.get_user()
if user:
credentials = self.get_credentials(user)
if not user or not credentials:
raise Fido2Error("Can't create FIDO 2 authentication request, no authenticators found.",
error_code=Fido2ServerError.NO_AUTHENTICATORS)
return self.server.authenticate_begin(credentials, user_verification=self.user_verification)
class Fido2AuthenticationView(Fido2AuthenticationViewMixin, LoginView):
"""
View to authenticate FIDO 2 key.
@cvar title: View title.
@cvar fido2_request_url: URL at which an FIDO 2 request is provided.
@cvar fido2_request_type: FIDO 2 request type
"""
template_name = 'django_fido/fido2_form.html'
title = _("Authenticate a FIDO 2 authenticator")
fido2_request_url = reverse_lazy('django_fido:authentication_request')
fido2_request_type = FIDO2_AUTHENTICATION_REQUEST
def get_form_class(self):
"""Get form class for one step or two step authentication."""
if SETTINGS.passwordless_auth:
return Fido2PasswordlessAuthenticationForm
elif SETTINGS.two_step_auth:
return Fido2AuthenticationForm
else:
return Fido2ModelAuthenticationForm
def get_form_kwargs(self):
"""Return form arguments depending on type of form (different for one and two step authentication)."""
kwargs = super().get_form_kwargs()
if SETTINGS.two_step_auth or SETTINGS.passwordless_auth:
# Fido2AuthenticationForm doesn't accept request.
kwargs.pop('request', None)
else:
kwargs['fido2_server'] = self.server
kwargs['session_key'] = self.session_key
return kwargs
def complete_authentication(self, form: Form) -> AbstractBaseUser:
"""
Complete the authentication.
@raise ValidationError: If the authentication can't be completed.
"""
state = self.request.session.pop(self.session_key, None)
if state is None:
raise ValidationError(_('Authentication request not found.'), code='missing')
fido_kwargs = dict(
fido2_server=self.server,
fido2_state=state,
fido2_response=form.cleaned_data,
)
user = authenticate(request=self.request, user=self.get_user(), **fido_kwargs)
if user is None:
raise ValidationError(_('Authentication failed.'), code='invalid')
return user
def form_valid(self, form: Form) -> HttpResponse:
"""Complete the authentication and return response."""
user = None
if SETTINGS.two_step_auth or SETTINGS.passwordless_auth:
try:
user = self.complete_authentication(form)
except ValidationError as error:
form.add_error(None, error)
else:
user = form.get_user()
if user is not None:
login(self.request, user)
# Ensure user is deleted from session.
self.request.session.pop(AUTHENTICATION_USER_SESSION_KEY, None)
return redirect(self.get_success_url())
else:
return self.form_invalid(form)
def form_invalid(self, form: Form) -> HttpResponse:
"""Clean the FIDO 2 request from session."""
self.request.session.pop(self.session_key, None)
return super().form_invalid(form)