Initial mfa support (for internal users) #93591
@ -544,6 +544,7 @@ 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):
|
||||||
|
if not hasattr(self, '_mfa_devices'):
|
||||||
devices_per_type = defaultdict(list)
|
devices_per_type = defaultdict(list)
|
||||||
for device in devices_for_user(self):
|
for device in devices_for_user(self):
|
||||||
if isinstance(device, EncryptedRecoveryDevice):
|
if isinstance(device, EncryptedRecoveryDevice):
|
||||||
@ -552,7 +553,8 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
devices_per_type['totp'].append(device)
|
devices_per_type['totp'].append(device)
|
||||||
if isinstance(device, U2fDevice):
|
if isinstance(device, U2fDevice):
|
||||||
devices_per_type['u2f'].append(device)
|
devices_per_type['u2f'].append(device)
|
||||||
return devices_per_type
|
self._mfa_devices = devices_per_type
|
||||||
|
return self._mfa_devices
|
||||||
|
|
||||||
|
|
||||||
class SettingValueField(models.CharField):
|
class SettingValueField(models.CharField):
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user