Initial mfa support (for internal users) #93591

Merged
Oleg-Komarov merged 46 commits from mfa into main 2024-08-29 11:44:06 +02:00
4 changed files with 73 additions and 42 deletions
Showing only changes of commit 1377addf3e - Show all commits

View File

@ -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):

View File

@ -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>

View File

@ -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>

View File

@ -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