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)) return bid_main.file_utils.get_absolute_url(self.avatar.storage.url(default_thumbnail_path))
def mfa_devices_per_type(self): def mfa_devices_per_type(self):
devices_per_type = defaultdict(list) if not hasattr(self, '_mfa_devices'):
for device in devices_for_user(self): devices_per_type = defaultdict(list)
if isinstance(device, EncryptedRecoveryDevice): for device in devices_for_user(self):
devices_per_type['recovery'].append(device) if isinstance(device, EncryptedRecoveryDevice):
if isinstance(device, EncryptedTOTPDevice): devices_per_type['recovery'].append(device)
devices_per_type['totp'].append(device) if isinstance(device, EncryptedTOTPDevice):
if isinstance(device, U2fDevice): devices_per_type['totp'].append(device)
devices_per_type['u2f'].append(device) if isinstance(device, U2fDevice):
return devices_per_type devices_per_type['u2f'].append(device)
self._mfa_devices = devices_per_type
return self._mfa_devices
class SettingValueField(models.CharField): class SettingValueField(models.CharField):

View File

@ -8,10 +8,10 @@
{% with form=form|add_form_classes %} {% with form=form|add_form_classes %}
<form role="login" action="" method="POST">{% csrf_token %} <form role="login" action="" method="POST">{% csrf_token %}
<fieldset class="mb-4"> <fieldset class="mb-4">
{% if use_recovery %} {% if mfa_device_type == 'recovery' %}
<p>Use a recovery code</p> <p>Use a recovery code</p>
<input type="hidden" name="otp_device" value="{{ devices.recovery.0.persistent_id }}" /> <input type="hidden" name="otp_device" value="{{ devices.recovery.0.persistent_id }}" />
{% else %} {% elif mfa_device_type == 'totp' %}
{% if devices.totp|length == 1 %} {% if devices.totp|length == 1 %}
<input type="hidden" name="otp_device" value="{{ devices.totp.0.persistent_id }}" /> <input type="hidden" name="otp_device" value="{{ devices.totp.0.persistent_id }}" />
{% else %} {% else %}
@ -36,13 +36,11 @@
<button class="btn btn-block btn-accent">Continue</button> <button class="btn btn-block btn-accent">Continue</button>
</form> </form>
{% endwith %} {% endwith %}
{% if devices.recovery %} {% if mfa_alternatives %}
<div class="bid-links"> <div class="bid-links">
{% if use_recovery %} {% for item in mfa_alternatives %}
<a href="?{% query_transform mfa_device_type='totp' %}">Use an authenticator</a> <a href="{{ item.href }}">{{ item.label }}</a>
{% else %} {% endfor %}
<a href="?{% query_transform mfa_device_type='recovery' %}">Use a recovery code</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -26,10 +26,11 @@
<div id="webauthn-error"></div> <div id="webauthn-error"></div>
</form> </form>
{% endwith %} {% endwith %}
{% if devices.totp %} {% if mfa_alternatives %}
<div class="bid-links"> <div class="bid-links">
<a href="?{% query_transform mfa_device_type='totp' %}">Use an authenticator</a> {% for item in mfa_alternatives %}
<a href="?{% query_transform mfa_device_type='recovery' %}">Use a recovery code</a> <a href="{{ item.href }}">{{ item.label }}</a>
{% endfor %}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -34,6 +34,7 @@ import oauth2_provider.models as oauth2_models
from .. import forms, email from .. import forms, email
from . import mixins from . import mixins
from bid_main.email import send_verify_address from bid_main.email import send_verify_address
from bid_main.templatetags.common import query_transform
from mfa.fido2 import authenticate_begin from mfa.fido2 import authenticate_begin
from mfa.forms import MfaAuthenticateForm, U2fAuthenticateForm from mfa.forms import MfaAuthenticateForm, U2fAuthenticateForm
import bid_main.file_utils 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 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 overwrite otp_authentication_form to exclude the otp_device and otp_token fields, because
we don't have mandatory MFA for everyone 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 On top of that we have additional logic in get_form for injecting U2fAuthenticateForm if a user
device. 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. 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 otp_authentication_form = forms.AuthenticationForm
@ -104,16 +106,39 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, django_o
authorize_url = reverse_lazy("oauth2_provider:authorize") authorize_url = reverse_lazy("oauth2_provider:authorize")
def _mfa_device_type(self): 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): def _mfa_alternatives(self):
return self._mfa_device_type() == "recovery" r = {"request": self.request}
alternatives = [
def _use_totp(self): {
return self._mfa_device_type() == "totp" "href": "?" + query_transform(r, mfa_device_type="u2f"),
"label": _("Use U2F security key"),
def _use_u2f(self): "type": "u2f",
return self._mfa_device_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(sensitive_post_parameters())
@method_decorator(csrf_protect) @method_decorator(csrf_protect)
@ -146,36 +171,41 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, django_o
if isinstance(form, U2fAuthenticateForm): if isinstance(form, U2fAuthenticateForm):
ctx["devices"] = self.request.user.mfa_devices_per_type() ctx["devices"] = self.request.user.mfa_devices_per_type()
ctx["form_type"] = "u2f" ctx["form_type"] = "u2f"
ctx["mfa_alternatives"] = self._mfa_alternatives()
ctx["mfa_device_type"] = "u2f"
elif isinstance(form, MfaAuthenticateForm): elif isinstance(form, MfaAuthenticateForm):
ctx["devices"] = self.request.user.mfa_devices_per_type() ctx["devices"] = self.request.user.mfa_devices_per_type()
ctx["use_recovery"] = self._use_recovery()
ctx["form_type"] = "mfa" ctx["form_type"] = "mfa"
ctx["mfa_alternatives"] = self._mfa_alternatives()
ctx["mfa_device_type"] = self._mfa_device_type()
return ctx return ctx
def _get_u2f_form(self, devices): def _get_u2f_form(self, devices):
credentials = [AttestedCredentialData(d.credential) for d in 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) request_options, state = authenticate_begin(rp_id, credentials)
kwargs = self.get_form_kwargs() kwargs = self.get_form_kwargs()
kwargs['credentials'] = credentials kwargs["credentials"] = credentials
kwargs['request_options'] = json.dumps(dict(request_options)) kwargs["request_options"] = json.dumps(dict(request_options))
kwargs['rp_id'] = rp_id kwargs["rp_id"] = rp_id
kwargs['state'] = dict(state) kwargs["state"] = dict(state)
return U2fAuthenticateForm(**kwargs) return U2fAuthenticateForm(**kwargs)
def get_form(self): def get_form(self):
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
devices = self.request.user.mfa_devices_per_type().get('u2f', None) u2f_devices = self.request.user.mfa_devices_per_type().get("u2f")
if devices and not self._use_recovery() and not self._use_totp(): mfa_device_type = self._mfa_device_type()
return self._get_u2f_form(devices) 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 # this will switch between MfaAuthenticateForm and AuthenticationForm
# as defined in django_otp.views.LoginView.authentication_form implementation # as defined in django_otp.views.LoginView.authentication_form implementation
return super().get_form() return super().get_form()
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() 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 kwargs["use_recovery"] = True
return kwargs return kwargs