Initial mfa support (for internal users) #93591
@ -544,15 +544,17 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
return bid_main.file_utils.get_absolute_url(self.avatar.storage.url(default_thumbnail_path))
|
||||
|
||||
def mfa_devices_per_type(self):
|
||||
devices_per_type = defaultdict(list)
|
||||
for device in devices_for_user(self):
|
||||
if isinstance(device, EncryptedRecoveryDevice):
|
||||
devices_per_type['recovery'].append(device)
|
||||
if isinstance(device, EncryptedTOTPDevice):
|
||||
devices_per_type['totp'].append(device)
|
||||
if isinstance(device, U2fDevice):
|
||||
devices_per_type['u2f'].append(device)
|
||||
return devices_per_type
|
||||
if not hasattr(self, '_mfa_devices'):
|
||||
devices_per_type = defaultdict(list)
|
||||
for device in devices_for_user(self):
|
||||
if isinstance(device, EncryptedRecoveryDevice):
|
||||
devices_per_type['recovery'].append(device)
|
||||
if isinstance(device, EncryptedTOTPDevice):
|
||||
devices_per_type['totp'].append(device)
|
||||
if isinstance(device, U2fDevice):
|
||||
devices_per_type['u2f'].append(device)
|
||||
self._mfa_devices = devices_per_type
|
||||
return self._mfa_devices
|
||||
|
||||
|
||||
class SettingValueField(models.CharField):
|
||||
|
@ -8,10 +8,10 @@
|
||||
{% with form=form|add_form_classes %}
|
||||
<form role="login" action="" method="POST">{% csrf_token %}
|
||||
<fieldset class="mb-4">
|
||||
{% if use_recovery %}
|
||||
{% if mfa_device_type == 'recovery' %}
|
||||
<p>Use a recovery code</p>
|
||||
<input type="hidden" name="otp_device" value="{{ devices.recovery.0.persistent_id }}" />
|
||||
{% else %}
|
||||
{% elif mfa_device_type == 'totp' %}
|
||||
{% if devices.totp|length == 1 %}
|
||||
<input type="hidden" name="otp_device" value="{{ devices.totp.0.persistent_id }}" />
|
||||
{% else %}
|
||||
@ -36,13 +36,11 @@
|
||||
<button class="btn btn-block btn-accent">Continue</button>
|
||||
</form>
|
||||
{% endwith %}
|
||||
{% if devices.recovery %}
|
||||
{% if mfa_alternatives %}
|
||||
<div class="bid-links">
|
||||
{% if use_recovery %}
|
||||
<a href="?{% query_transform mfa_device_type='totp' %}">Use an authenticator</a>
|
||||
{% else %}
|
||||
<a href="?{% query_transform mfa_device_type='recovery' %}">Use a recovery code</a>
|
||||
{% endif %}
|
||||
{% for item in mfa_alternatives %}
|
||||
<a href="{{ item.href }}">{{ item.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -26,10 +26,11 @@
|
||||
<div id="webauthn-error"></div>
|
||||
</form>
|
||||
{% endwith %}
|
||||
{% if devices.totp %}
|
||||
{% if mfa_alternatives %}
|
||||
<div class="bid-links">
|
||||
<a href="?{% query_transform mfa_device_type='totp' %}">Use an authenticator</a>
|
||||
<a href="?{% query_transform mfa_device_type='recovery' %}">Use a recovery code</a>
|
||||
{% for item in mfa_alternatives %}
|
||||
<a href="{{ item.href }}">{{ item.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -34,6 +34,7 @@ import oauth2_provider.models as oauth2_models
|
||||
from .. import forms, email
|
||||
from . import mixins
|
||||
from bid_main.email import send_verify_address
|
||||
from bid_main.templatetags.common import query_transform
|
||||
from mfa.fido2 import authenticate_begin
|
||||
from mfa.forms import MfaAuthenticateForm, U2fAuthenticateForm
|
||||
import bid_main.file_utils
|
||||
@ -87,11 +88,12 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, django_o
|
||||
and django_otp makes use of that to switch between otp_authentication_form and otp_token_form
|
||||
- we overwrite otp_authentication_form to exclude the otp_device and otp_token fields, because
|
||||
we don't have mandatory MFA for everyone
|
||||
On top of that we have a separate helper for injecting U2fAuthenticateForm if a user has a u2f
|
||||
device.
|
||||
On top of that we have additional logic in get_form for injecting U2fAuthenticateForm if a user
|
||||
has a u2f device.
|
||||
|
||||
Because get_form is called on both GET and POST requests we put the branching logic there,
|
||||
Because get_form is called on both GET and POST requests we put our branching logic there,
|
||||
following the example of django_otp.
|
||||
Then get_context_data checks the form class and supplies the data needed by a corresponding UI.
|
||||
"""
|
||||
|
||||
otp_authentication_form = forms.AuthenticationForm
|
||||
@ -104,16 +106,39 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, django_o
|
||||
authorize_url = reverse_lazy("oauth2_provider:authorize")
|
||||
|
||||
def _mfa_device_type(self):
|
||||
return self.request.GET.get("mfa_device_type", None)
|
||||
default_type = None
|
||||
available_types = self.request.user.mfa_devices_per_type()
|
||||
if "u2f" in available_types:
|
||||
default_type = "u2f"
|
||||
elif "totp" in available_types:
|
||||
default_type = "totp"
|
||||
return self.request.GET.get("mfa_device_type", default_type)
|
||||
|
||||
def _use_recovery(self):
|
||||
return self._mfa_device_type() == "recovery"
|
||||
|
||||
def _use_totp(self):
|
||||
return self._mfa_device_type() == "totp"
|
||||
|
||||
def _use_u2f(self):
|
||||
return self._mfa_device_type() == "u2f"
|
||||
def _mfa_alternatives(self):
|
||||
r = {"request": self.request}
|
||||
alternatives = [
|
||||
{
|
||||
"href": "?" + query_transform(r, mfa_device_type="u2f"),
|
||||
"label": _("Use U2F security key"),
|
||||
"type": "u2f",
|
||||
},
|
||||
{
|
||||
"href": "?" + query_transform(r, mfa_device_type="totp"),
|
||||
"label": _("Use TOTP authenticator"),
|
||||
"type": "totp",
|
||||
},
|
||||
{
|
||||
"href": "?" + query_transform(r, mfa_device_type="recovery"),
|
||||
"label": _("Use recovery code"),
|
||||
"type": "recovery",
|
||||
},
|
||||
]
|
||||
available_types = self.request.user.mfa_devices_per_type()
|
||||
mfa_device_type = self._mfa_device_type()
|
||||
return [
|
||||
item for item in alternatives
|
||||
if item["type"] in available_types and item["type"] != mfa_device_type
|
||||
]
|
||||
|
||||
@method_decorator(sensitive_post_parameters())
|
||||
@method_decorator(csrf_protect)
|
||||
@ -146,36 +171,41 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, django_o
|
||||
if isinstance(form, U2fAuthenticateForm):
|
||||
ctx["devices"] = self.request.user.mfa_devices_per_type()
|
||||
ctx["form_type"] = "u2f"
|
||||
ctx["mfa_alternatives"] = self._mfa_alternatives()
|
||||
ctx["mfa_device_type"] = "u2f"
|
||||
elif isinstance(form, MfaAuthenticateForm):
|
||||
ctx["devices"] = self.request.user.mfa_devices_per_type()
|
||||
ctx["use_recovery"] = self._use_recovery()
|
||||
ctx["form_type"] = "mfa"
|
||||
ctx["mfa_alternatives"] = self._mfa_alternatives()
|
||||
ctx["mfa_device_type"] = self._mfa_device_type()
|
||||
|
||||
return ctx
|
||||
|
||||
def _get_u2f_form(self, devices):
|
||||
credentials = [AttestedCredentialData(d.credential) for d in devices]
|
||||
rp_id = self.request.get_host().split(':')[0] # remove port, required by webauthn
|
||||
rp_id = self.request.get_host().split(":")[0] # remove port, required by webauthn
|
||||
request_options, state = authenticate_begin(rp_id, credentials)
|
||||
kwargs = self.get_form_kwargs()
|
||||
kwargs['credentials'] = credentials
|
||||
kwargs['request_options'] = json.dumps(dict(request_options))
|
||||
kwargs['rp_id'] = rp_id
|
||||
kwargs['state'] = dict(state)
|
||||
kwargs["credentials"] = credentials
|
||||
kwargs["request_options"] = json.dumps(dict(request_options))
|
||||
kwargs["rp_id"] = rp_id
|
||||
kwargs["state"] = dict(state)
|
||||
return U2fAuthenticateForm(**kwargs)
|
||||
|
||||
def get_form(self):
|
||||
if self.request.user.is_authenticated:
|
||||
devices = self.request.user.mfa_devices_per_type().get('u2f', None)
|
||||
if devices and not self._use_recovery() and not self._use_totp():
|
||||
return self._get_u2f_form(devices)
|
||||
u2f_devices = self.request.user.mfa_devices_per_type().get("u2f")
|
||||
mfa_device_type = self._mfa_device_type()
|
||||
if u2f_devices and (not mfa_device_type or mfa_device_type == "u2f"):
|
||||
return self._get_u2f_form(u2f_devices)
|
||||
# this will switch between MfaAuthenticateForm and AuthenticationForm
|
||||
# as defined in django_otp.views.LoginView.authentication_form implementation
|
||||
return super().get_form()
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
if self._use_recovery():
|
||||
# this will affect only MfaAuthenticateForm, but making an explicit check here is cumbersome
|
||||
if self.request.user.is_authenticated and self._mfa_device_type() == "recovery":
|
||||
kwargs["use_recovery"] = True
|
||||
return kwargs
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user