Initial mfa support (for internal users) #93591
@ -543,16 +543,16 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
return bid_main.file_utils.get_absolute_url(static(settings.AVATAR_DEFAULT_FILENAME))
|
return bid_main.file_utils.get_absolute_url(static(settings.AVATAR_DEFAULT_FILENAME))
|
||||||
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_category(self):
|
def mfa_devices_per_type(self):
|
||||||
devices_per_category = 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):
|
||||||
devices_per_category['recovery'].append(device)
|
devices_per_type['recovery'].append(device)
|
||||||
if isinstance(device, EncryptedTOTPDevice):
|
if isinstance(device, EncryptedTOTPDevice):
|
||||||
devices_per_category['totp'].append(device)
|
devices_per_type['totp'].append(device)
|
||||||
if isinstance(device, U2fDevice):
|
if isinstance(device, U2fDevice):
|
||||||
devices_per_category['u2f'].append(device)
|
devices_per_type['u2f'].append(device)
|
||||||
return devices_per_category
|
return devices_per_type
|
||||||
|
|
||||||
|
|
||||||
class SettingValueField(models.CharField):
|
class SettingValueField(models.CharField):
|
||||||
|
@ -39,9 +39,9 @@
|
|||||||
{% if devices.recovery %}
|
{% if devices.recovery %}
|
||||||
<div class="bid-links">
|
<div class="bid-links">
|
||||||
{% if use_recovery %}
|
{% if use_recovery %}
|
||||||
<a href="?{% query_transform use_recovery='' %}">Use an authenticator</a>
|
<a href="?{% query_transform mfa_device_type='totp' %}">Use an authenticator</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
Lost your authenticator? <a href="?{% query_transform use_recovery=1 %}">Use a recovery code</a>
|
<a href="?{% query_transform mfa_device_type='recovery' %}">Use a recovery code</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
{% with form=form|add_form_classes %}
|
{% with form=form|add_form_classes %}
|
||||||
<form method="POST" id="u2f-authenticate-form">{% csrf_token %}
|
<form method="POST" id="u2f-authenticate-form">{% csrf_token %}
|
||||||
<fieldset class="mb-4">
|
<fieldset class="mb-4">
|
||||||
<p>Please use a security key you have configured. Tick the checkbox below before using the key</p>
|
<p>Please use a security key you have configured. Tick the checkbox below before using the key if you want to remember this device.</p>
|
||||||
{% with field=form.otp_trust_agent %}
|
{% with field=form.otp_trust_agent %}
|
||||||
{% include "components/forms/field.html" with with_help_text=True %}
|
{% include "components/forms/field.html" with with_help_text=True %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
@ -27,8 +27,10 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% if devices.totp %}
|
{% if devices.totp %}
|
||||||
<a href="?{% query_transform use_totp=1 %}">Use an authenticator</a>
|
<div class="bid-links">
|
||||||
Lost your authenticator? <a href="?{% query_transform use_recovery=1 %}">Use a recovery code</a>
|
<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>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<script src="{% static 'mfa/js/webauthn-json.js' %}"></script>
|
<script src="{% static 'mfa/js/webauthn-json.js' %}"></script>
|
||||||
|
@ -47,7 +47,7 @@ Multi-factor Authentication Setup
|
|||||||
If you don't have an authenticator application, you can choose one from a list of <a href="https://en.wikipedia.org/wiki/Comparison_of_OTP_applications">TOTP applications</a>.
|
If you don't have an authenticator application, you can choose one from a list of <a href="https://en.wikipedia.org/wiki/Comparison_of_OTP_applications">TOTP applications</a>.
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
{% for d in devices_per_category.totp %}
|
{% for d in devices_per_type.totp %}
|
||||||
<li>
|
<li>
|
||||||
{{ d.name }}
|
{{ d.name }}
|
||||||
{% if d.last_used_at %}(Last used <abbr title="{{ d.last_used_at }}">{{ d.last_used_at|naturaltime }}</abbr>){% endif %}
|
{% if d.last_used_at %}(Last used <abbr title="{{ d.last_used_at }}">{{ d.last_used_at|naturaltime }}</abbr>){% endif %}
|
||||||
@ -63,7 +63,7 @@ Multi-factor Authentication Setup
|
|||||||
E.g. yubikey.
|
E.g. yubikey.
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
{% for d in devices_per_category.u2f %}
|
{% for d in devices_per_type.u2f %}
|
||||||
<li>
|
<li>
|
||||||
{{ d.name }}
|
{{ d.name }}
|
||||||
{% if d.last_used_at %}(Last used <abbr title="{{ d.last_used_at }}">{{ d.last_used_at|naturaltime }}</abbr>){% endif %}
|
{% if d.last_used_at %}(Last used <abbr title="{{ d.last_used_at }}">{{ d.last_used_at|naturaltime }}</abbr>){% endif %}
|
||||||
@ -81,7 +81,7 @@ Multi-factor Authentication Setup
|
|||||||
Each code can be used only once.
|
Each code can be used only once.
|
||||||
You can generate a new set of recovery codes at any time, any remaining old codes will become invalidated.
|
You can generate a new set of recovery codes at any time, any remaining old codes will become invalidated.
|
||||||
Oleg-Komarov marked this conversation as resolved
|
|||||||
</p>
|
</p>
|
||||||
{% with recovery=devices_per_category.recovery.0 %}
|
{% with recovery=devices_per_type.recovery.0 %}
|
||||||
{% if recovery %}
|
{% if recovery %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{% with code_count=recovery_codes|length %}
|
{% with code_count=recovery_codes|length %}
|
||||||
|
@ -122,13 +122,13 @@ class TestMfa(TestCase):
|
|||||||
{'username': self.user.email, 'password': 'hunter2'},
|
{'username': self.user.email, 'password': 'hunter2'},
|
||||||
follow=True,
|
follow=True,
|
||||||
)
|
)
|
||||||
response = client.get('/login?use_recovery=1')
|
response = client.get('/login?mfa_device_type=recovery')
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content)
|
r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content)
|
||||||
)
|
)
|
||||||
otp_device = match.group(1)
|
otp_device = match.group(1)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/login?use_recovery=1',
|
'/login?mfa_device_type=recovery',
|
||||||
{'otp_device': otp_device, 'otp_token': recovery_code},
|
{'otp_device': otp_device, 'otp_token': recovery_code},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
@ -142,13 +142,13 @@ class TestMfa(TestCase):
|
|||||||
{'username': self.user.email, 'password': 'hunter2'},
|
{'username': self.user.email, 'password': 'hunter2'},
|
||||||
follow=True,
|
follow=True,
|
||||||
)
|
)
|
||||||
response = client.get('/login?use_recovery=1')
|
response = client.get('/login?mfa_device_type=recovery')
|
||||||
match = re.search(
|
match = re.search(
|
||||||
r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content)
|
r'input type="hidden" name="otp_device" value="([^"]+)"', str(response.content)
|
||||||
)
|
)
|
||||||
otp_device = match.group(1)
|
otp_device = match.group(1)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/login?use_recovery=1',
|
'/login?mfa_device_type=recovery',
|
||||||
{'otp_device': otp_device, 'otp_token': recovery_code},
|
{'otp_device': otp_device, 'otp_token': recovery_code},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
@ -24,8 +24,10 @@ import bid_main.tasks
|
|||||||
|
|
||||||
|
|
||||||
class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
|
class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
|
||||||
"""Don't allow to setup recovery codes unless the user has already configured some other method.
|
"""Mfa setup.
|
||||||
|
|
||||||
|
Important in current implementation:
|
||||||
|
Don't allow to setup recovery codes unless the user has already configured some other method.
|
||||||
Otherwise MfaRequiredIfConfiguredMixin locks the user out immediately, not giving a chance
|
Otherwise MfaRequiredIfConfiguredMixin locks the user out immediately, not giving a chance
|
||||||
to copy the recovery codes.
|
to copy the recovery codes.
|
||||||
"""
|
"""
|
||||||
@ -36,24 +38,24 @@ class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
|
|||||||
recovery_codes = []
|
recovery_codes = []
|
||||||
show_missing_recovery_codes_warning = False
|
show_missing_recovery_codes_warning = False
|
||||||
user_can_setup_recovery = False
|
user_can_setup_recovery = False
|
||||||
devices_per_category = user.mfa_devices_per_category()
|
devices_per_type = user.mfa_devices_per_type()
|
||||||
if 'recovery' in devices_per_category:
|
if 'recovery' in devices_per_type:
|
||||||
recovery_device = devices_per_category['recovery'][0]
|
recovery_device = devices_per_type['recovery'][0]
|
||||||
recovery_codes = [t.encrypted_token for t in recovery_device.encryptedtoken_set.all()]
|
recovery_codes = [t.encrypted_token for t in recovery_device.encryptedtoken_set.all()]
|
||||||
if devices_per_category.keys() - {'recovery'}:
|
if devices_per_type.keys() - {'recovery'}:
|
||||||
user_can_setup_recovery = True
|
user_can_setup_recovery = True
|
||||||
if user_can_setup_recovery and 'recovery' not in devices_per_category:
|
if user_can_setup_recovery and 'recovery' not in devices_per_type:
|
||||||
show_missing_recovery_codes_warning = True
|
show_missing_recovery_codes_warning = True
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'agent_inactivity_days': settings.AGENT_INACTIVITY_DAYS,
|
'agent_inactivity_days': settings.AGENT_INACTIVITY_DAYS,
|
||||||
'agent_trust_days': settings.AGENT_TRUST_DAYS,
|
'agent_trust_days': settings.AGENT_TRUST_DAYS,
|
||||||
'devices_per_category': devices_per_category,
|
'devices_per_type': devices_per_type,
|
||||||
'display_recovery_codes': self.request.GET.get('display_recovery_codes'),
|
'display_recovery_codes': self.request.GET.get('display_recovery_codes'),
|
||||||
'recovery_codes': recovery_codes,
|
'recovery_codes': recovery_codes,
|
||||||
'show_missing_recovery_codes_warning': show_missing_recovery_codes_warning,
|
'show_missing_recovery_codes_warning': show_missing_recovery_codes_warning,
|
||||||
'user_can_setup_recovery': user_can_setup_recovery,
|
'user_can_setup_recovery': user_can_setup_recovery,
|
||||||
'user_has_mfa_configured': bool(devices_per_category),
|
'user_has_mfa_configured': bool(devices_per_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ import urllib.parse
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import views as auth_views, logout, get_user_model
|
from django.contrib.auth import views as auth_views, logout, get_user_model
|
||||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.db import transaction, IntegrityError
|
from django.db import transaction, IntegrityError
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
@ -20,13 +20,16 @@ from django.urls import reverse_lazy
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.cache import never_cache
|
from django.views.decorators.cache import never_cache
|
||||||
|
from django.views.decorators.csrf import csrf_protect
|
||||||
|
from django.views.decorators.debug import sensitive_post_parameters
|
||||||
from django.views.generic import TemplateView, FormView
|
from django.views.generic import TemplateView, FormView
|
||||||
from django.views.generic.base import View
|
from django.views.generic.base import View
|
||||||
from django.views.generic.edit import UpdateView
|
from django.views.generic.edit import UpdateView
|
||||||
|
from django_otp import user_has_device
|
||||||
from fido2.webauthn import AttestedCredentialData
|
from fido2.webauthn import AttestedCredentialData
|
||||||
|
import django_otp.views
|
||||||
import loginas.utils
|
import loginas.utils
|
||||||
import oauth2_provider.models as oauth2_models
|
import oauth2_provider.models as oauth2_models
|
||||||
import otp_agents.views
|
|
||||||
|
|
||||||
from .. import forms, email
|
from .. import forms, email
|
||||||
from . import mixins
|
from . import mixins
|
||||||
@ -74,62 +77,105 @@ class IndexView(mixins.MfaRequiredIfConfiguredMixin, mixins.PageIdMixin, Templat
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, otp_agents.views.LoginView):
|
class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, django_otp.views.LoginView):
|
||||||
"""Shows the login view."""
|
"""Shows the login view.
|
||||||
|
|
||||||
|
This view also handles MFA forms and OAuth redirects.
|
||||||
|
|
||||||
|
django_otp introduces additional indirection, it works as follows:
|
||||||
|
- django.contrib.auth.views.LoginView.get_form_class allows to define the form dynamically
|
||||||
|
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.
|
||||||
|
|
||||||
|
Because get_form is called on both GET and POST requests we put the branching logic there,
|
||||||
|
following the example of django_otp.
|
||||||
|
"""
|
||||||
|
|
||||||
otp_authentication_form = forms.AuthenticationForm
|
otp_authentication_form = forms.AuthenticationForm
|
||||||
otp_token_form = MfaAuthenticateForm
|
otp_token_form = MfaAuthenticateForm
|
||||||
page_id = "login"
|
page_id = "login"
|
||||||
redirect_authenticated_user = False
|
redirect_authenticated_user = True
|
||||||
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS
|
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS
|
||||||
template_name = "bid_main/login.html"
|
template_name = "bid_main/login.html"
|
||||||
|
|
||||||
authorize_url = reverse_lazy("oauth2_provider:authorize")
|
authorize_url = reverse_lazy("oauth2_provider:authorize")
|
||||||
|
|
||||||
|
def _mfa_device_type(self):
|
||||||
|
return self.request.GET.get("mfa_device_type", None)
|
||||||
|
|
||||||
def _use_recovery(self):
|
def _use_recovery(self):
|
||||||
return self.request.GET.get("use_recovery", False)
|
return self._mfa_device_type() == "recovery"
|
||||||
|
|
||||||
def _use_totp(self):
|
def _use_totp(self):
|
||||||
return self.request.GET.get("use_totp", False)
|
return self._mfa_device_type() == "totp"
|
||||||
|
|
||||||
|
def _use_u2f(self):
|
||||||
|
return self._mfa_device_type() == "u2f"
|
||||||
|
|
||||||
|
@method_decorator(sensitive_post_parameters())
|
||||||
|
@method_decorator(csrf_protect)
|
||||||
|
@method_decorator(never_cache)
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""This is a tweaked implementation of django.contrib.auth.view.LoginView.dispatch method.
|
||||||
|
|
||||||
|
It accounts for the second step MfaAuthenticateForm and doesn't try to redirect too soon.
|
||||||
|
"""
|
||||||
|
user_is_verified = self.request.agent.is_trusted or not user_has_device(self.request.user)
|
||||||
|
ready_to_redirect = self.request.user.is_authenticated and user_is_verified
|
||||||
|
if self.redirect_authenticated_user and ready_to_redirect:
|
||||||
|
redirect_to = self.get_success_url()
|
||||||
|
if redirect_to == self.request.path:
|
||||||
|
raise ValueError(
|
||||||
|
"Redirection loop for authenticated user detected. Check that "
|
||||||
|
"your LOGIN_REDIRECT_URL doesn't point to a login page."
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(redirect_to)
|
||||||
|
# not using a simple super() because we need to jump higher in view inheritance hierarchy
|
||||||
|
# and avoid execution of django.contrib.auth.view.LoginView.dispatch
|
||||||
|
return super(FormView, self).dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs) -> dict:
|
def get_context_data(self, **kwargs) -> dict:
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
self.find_oauth_flow(ctx)
|
self.find_oauth_flow(ctx)
|
||||||
|
ctx["form_type"] = "login"
|
||||||
|
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
form_type = None
|
|
||||||
if isinstance(form, U2fAuthenticateForm):
|
if isinstance(form, U2fAuthenticateForm):
|
||||||
ctx["devices"] = self.request.user.mfa_devices_per_category()
|
ctx["devices"] = self.request.user.mfa_devices_per_type()
|
||||||
form_type = 'u2f'
|
ctx["form_type"] = "u2f"
|
||||||
elif isinstance(form, MfaAuthenticateForm):
|
elif isinstance(form, MfaAuthenticateForm):
|
||||||
ctx["devices"] = self.request.user.mfa_devices_per_category()
|
ctx["devices"] = self.request.user.mfa_devices_per_type()
|
||||||
ctx["use_recovery"] = self._use_recovery()
|
ctx["use_recovery"] = self._use_recovery()
|
||||||
form_type = 'mfa'
|
ctx["form_type"] = "mfa"
|
||||||
elif isinstance(form, forms.AuthenticationForm):
|
|
||||||
form_type = 'login'
|
|
||||||
else:
|
|
||||||
raise ImproperlyConfigured('unexpected form object')
|
|
||||||
ctx["form_type"] = form_type
|
|
||||||
return ctx
|
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
|
||||||
|
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)
|
||||||
|
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_category().get('u2f', None)
|
devices = self.request.user.mfa_devices_per_type().get('u2f', None)
|
||||||
if devices and not self._use_recovery() and not self._use_totp():
|
if devices and not self._use_recovery() and not self._use_totp():
|
||||||
credentials = [AttestedCredentialData(d.credential) for d in devices]
|
return self._get_u2f_form(devices)
|
||||||
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)
|
|
||||||
return U2fAuthenticateForm(**kwargs)
|
|
||||||
# this will switch between MfaAuthenticateForm and AuthenticationForm
|
# this will switch between MfaAuthenticateForm and AuthenticationForm
|
||||||
|
# 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.request.GET.get("use_recovery", False):
|
if self._use_recovery():
|
||||||
kwargs["use_recovery"] = True
|
kwargs["use_recovery"] = True
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
@ -28,7 +28,12 @@ def _verify_signature(payload, signature, max_age=3600):
|
|||||||
|
|
||||||
|
|
||||||
class MfaAuthenticateForm(OTPTokenForm):
|
class MfaAuthenticateForm(OTPTokenForm):
|
||||||
"""Restyle the form widgets to do less work in the template."""
|
"""Restyle the form widgets to do less work in the template.
|
||||||
|
|
||||||
|
This form inherits from otp_agents form that takes care of agent_trust.
|
||||||
|
Even though the LoginView itself skips one layer and inherits directly from
|
||||||
|
django_otp.views.LoginView.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.use_recovery = kwargs.pop('use_recovery', False)
|
self.use_recovery = kwargs.pop('use_recovery', False)
|
||||||
|
Loading…
Reference in New Issue
Block a user
"will be invalided" or "will become invalid"