Initial mfa support (for internal users) #93591
@ -95,6 +95,9 @@ See [OAuth.md](docs/OAuth.md).
|
|||||||
|
|
||||||
See [user_deletion.md](docs/user_deletion.md).
|
See [user_deletion.md](docs/user_deletion.md).
|
||||||
|
|
||||||
|
# Multi-factor authentication
|
||||||
|
|
||||||
|
See [mfa.md](docs/mfa.md).
|
||||||
|
|
||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_otp.oath import TOTP
|
from django_otp.oath import TOTP
|
||||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
|
from otp_agents.views import OTPTokenForm
|
||||||
|
|
||||||
from .models import User, OAuth2Application
|
from .models import User, OAuth2Application
|
||||||
from .recaptcha import check_recaptcha
|
from .recaptcha import check_recaptcha
|
||||||
@ -213,6 +214,52 @@ class AuthenticationForm(auth_forms.AuthenticationForm):
|
|||||||
self.fields["password"].widget.attrs["placeholder"] = "Enter your password"
|
self.fields["password"].widget.attrs["placeholder"] = "Enter your password"
|
||||||
|
|
||||||
|
|
||||||
|
class MfaForm(BootstrapModelFormMixin, OTPTokenForm):
|
||||||
|
"""Restyle the form widgets to do less work in the template."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
use_recovery = kwargs.pop('use_recovery', False)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
otp_token = self.fields["otp_token"]
|
||||||
|
otp_token.label = _('Code')
|
||||||
|
otp_token.required = True
|
||||||
|
if use_recovery:
|
||||||
|
otp_token.validators = [RegexValidator(r'^[A-F0-9]{16}$')]
|
||||||
|
otp_token.widget = forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
"autocomplete": "one-time-code",
|
||||||
|
"maxlength": 16,
|
||||||
|
"pattern": "[A-F0-9]{16}",
|
||||||
|
"placeholder": "123456890ABCDEF",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
otp_token.validators = [RegexValidator(r'^[0-9]{6}$')]
|
||||||
|
otp_token.widget = forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
"autocomplete": "one-time-code",
|
||||||
|
"inputmode": "numeric",
|
||||||
|
"maxlength": 6,
|
||||||
|
"pattern": "[0-9]{6}",
|
||||||
|
"placeholder": "123456",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
otp_trust_agent = self.fields["otp_trust_agent"]
|
||||||
|
otp_trust_agent.help_text = _(
|
||||||
|
"We won't ask for MFA next time you sign-in on this device. "
|
||||||
|
"Use only on your private device."
|
||||||
|
)
|
||||||
|
otp_trust_agent.initial = False
|
||||||
|
otp_trust_agent.label = _("Remember this device")
|
||||||
|
otp_trust_agent.widget = forms.CheckboxInput(
|
||||||
|
attrs={
|
||||||
|
"autocomplete": "off",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserProfileForm(BootstrapModelFormMixin, forms.ModelForm):
|
class UserProfileForm(BootstrapModelFormMixin, forms.ModelForm):
|
||||||
"""Edits full name and email address.
|
"""Edits full name and email address.
|
||||||
|
|
||||||
|
@ -1,22 +1,26 @@
|
|||||||
|
from collections import defaultdict
|
||||||
from typing import Optional, Set
|
from typing import Optional, Set
|
||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
import os.path
|
import os.path
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from django import urls
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
|
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
|
||||||
from django.contrib.auth.models import PermissionsMixin
|
from django.contrib.auth.models import PermissionsMixin
|
||||||
from django.contrib.sessions.models import Session
|
from django.contrib.sessions.models import Session
|
||||||
from django.core import validators
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core import validators
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django import urls
|
||||||
|
from django_otp import devices_for_user
|
||||||
|
from django_otp.plugins.otp_static.models import StaticDevice
|
||||||
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.deconstruct import deconstructible
|
from django.utils.deconstruct import deconstructible
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
import oauth2_provider.models as oa2_models
|
import oauth2_provider.models as oa2_models
|
||||||
import user_agents
|
import user_agents
|
||||||
@ -541,6 +545,15 @@ 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):
|
||||||
|
devices_per_category = defaultdict(list)
|
||||||
|
for device in devices_for_user(self):
|
||||||
|
if isinstance(device, StaticDevice):
|
||||||
|
devices_per_category['recovery'].append(device)
|
||||||
|
if isinstance(device, TOTPDevice):
|
||||||
|
devices_per_category['totp'].append(device)
|
||||||
|
return devices_per_category
|
||||||
|
|
||||||
|
|
||||||
class SettingValueField(models.CharField):
|
class SettingValueField(models.CharField):
|
||||||
def __init__(self, *args, **kwargs): # noqa: D107
|
def __init__(self, *args, **kwargs): # noqa: D107
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{% load add_form_classes from forms %}
|
{% load add_form_classes from forms %}
|
||||||
{% load static %}
|
{% load common static %}
|
||||||
|
|
||||||
<div class="bid box">
|
<div class="bid box">
|
||||||
<div>
|
<div>
|
||||||
@ -7,22 +7,42 @@
|
|||||||
</div>
|
</div>
|
||||||
{% 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>
|
<fieldset class="mb-4">
|
||||||
{% with field=form.otp_device %}
|
{% if use_recovery %}
|
||||||
{% include "components/forms/field.html" %}
|
<p>Use a recovery code</p>
|
||||||
{% endwith %}
|
<input type="hidden" name="otp_device" value="{{ devices.recovery.0.persistent_id }}" />
|
||||||
|
{% else %}
|
||||||
|
{% if devices.totp|length == 1 %}
|
||||||
|
<input type="hidden" name="otp_device" value="{{ devices.totp.0.persistent_id }}" />
|
||||||
|
{% else %}
|
||||||
|
<div class="form-check-inline mb-3">
|
||||||
|
{% for device in devices.totp %}
|
||||||
|
<label class="btn form-check-input">
|
||||||
|
<input type="radio" name="otp_device" value="{{ device.persistent_id }}" required="required" />
|
||||||
|
{{ device.name }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% with field=form.otp_token %}
|
{% with field=form.otp_token %}
|
||||||
{% include "components/forms/field.html" %}
|
{% include "components/forms/field.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% with field=form.otp_trust_agent %}
|
{% with field=form.otp_trust_agent %}
|
||||||
{% include "components/forms/field.html" %}
|
{% include "components/forms/field.html" with with_help_text=True %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
{% if form.errors %}
|
|
||||||
<p class="text-danger">{{ form.errors }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button class="btn btn-block btn-accent" id="register">Continue</button>
|
{{ form.non_field_errors }}
|
||||||
|
<button class="btn btn-block btn-accent">Continue</button>
|
||||||
</form>
|
</form>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
{% if devices.recovery %}
|
||||||
|
<div class="bid-links">
|
||||||
|
{% if use_recovery %}
|
||||||
|
<a href="?{% query_transform use_recovery='' %}">Use an authenticator</a>
|
||||||
|
{% else %}
|
||||||
|
Lost your authenticator? <a href="?{% query_transform use_recovery=1 %}">Use a recovery code</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -42,7 +42,7 @@ Multi-factor Authentication Setup
|
|||||||
|
|
||||||
{% if user_can_setup_recovery %}
|
{% if user_can_setup_recovery %}
|
||||||
<div class="bid box mt-3">
|
<div class="bid box mt-3">
|
||||||
<h3>Recovery codes</h3>
|
<h3 id="recovery-codes">Recovery codes</h3>
|
||||||
<p>
|
<p>
|
||||||
Store your recovery codes safely (e.g. in a password manager) and don't share them.
|
Store your recovery codes safely (e.g. in a password manager) and don't share them.
|
||||||
Each code can be used only once.
|
Each code can be used only once.
|
||||||
@ -56,7 +56,7 @@ Multi-factor Authentication Setup
|
|||||||
{% if recovery_codes %}
|
{% if recovery_codes %}
|
||||||
<a href="?display_recovery_codes=" class="btn">Hide</a>
|
<a href="?display_recovery_codes=" class="btn">Hide</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="?display_recovery_codes=1" class="btn">Display</a>
|
<a href="?display_recovery_codes=1#recovery-codes" class="btn">Display</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form action="{% url 'bid_main:mfa_invalidate_recovery' %}" method="post" class="d-inline-flex">{% csrf_token %}
|
<form action="{% url 'bid_main:mfa_invalidate_recovery' %}" method="post" class="d-inline-flex">{% csrf_token %}
|
||||||
<button class="btn-danger" type="submit">Invalidate</button>
|
<button class="btn-danger" type="submit">Invalidate</button>
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
{% if not field.is_hidden %}
|
{% if not field.is_hidden %}
|
||||||
{% include 'components/forms/label.html' with label_class="form-check-label" %}
|
{% include 'components/forms/label.html' with label_class="form-check-label" %}
|
||||||
{% if with_help_text and field.help_text %}
|
{% if with_help_text and field.help_text %}
|
||||||
<small class="form-text">{{ form.new_password1.help_text|safe }}</small>
|
<small class="form-text d-block">{{ field.help_text|safe }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="text-danger">{{ field.errors }}</div>
|
<div class="text-danger">{{ field.errors }}</div>
|
||||||
@ -18,7 +18,7 @@
|
|||||||
{{ field }}
|
{{ field }}
|
||||||
<div class="text-danger">{{ field.errors }}</div>
|
<div class="text-danger">{{ field.errors }}</div>
|
||||||
{% if with_help_text and field.help_text %}
|
{% if with_help_text and field.help_text %}
|
||||||
<small class="form-text">{{ form.new_password1.help_text|safe }}</small>
|
<small class="form-text d-block">{{ field.help_text|safe }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
12
bid_main/templatetags/common.py
Normal file
12
bid_main/templatetags/common.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
# Credit: https://stackoverflow.com/questions/46026268/
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def query_transform(context, **kwargs):
|
||||||
|
query = context['request'].GET.copy()
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
query[k] = v
|
||||||
|
return query.urlencode()
|
@ -1,6 +1,5 @@
|
|||||||
from base64 import b32encode, b64encode
|
from base64 import b32encode, b64encode
|
||||||
from binascii import unhexlify
|
from binascii import unhexlify
|
||||||
from collections import defaultdict
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@ -31,30 +30,25 @@ class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
devices = list(devices_for_user(user))
|
|
||||||
devices_per_category = defaultdict(list)
|
|
||||||
recovery_codes = []
|
recovery_codes = []
|
||||||
user_can_setup_recovery = False
|
user_can_setup_recovery = False
|
||||||
for device in devices:
|
devices_per_category = user.mfa_devices_per_category()
|
||||||
if isinstance(device, StaticDevice):
|
if self.request.GET.get('display_recovery_codes') and 'recovery' in devices_per_category:
|
||||||
devices_per_category['recovery'].append(device)
|
recovery_codes = [t.token for t in devices_per_category['recovery'][0].token_set.all()]
|
||||||
if self.request.GET.get('display_recovery_codes', None):
|
if devices_per_category.keys() - {'recovery'}:
|
||||||
recovery_codes = [t.token for t in device.token_set.all()]
|
|
||||||
if isinstance(device, TOTPDevice):
|
|
||||||
devices_per_category['totp'].append(device)
|
|
||||||
user_can_setup_recovery = True
|
user_can_setup_recovery = True
|
||||||
return {
|
return {
|
||||||
'devices_per_category': devices_per_category,
|
'devices_per_category': devices_per_category,
|
||||||
'recovery_codes': recovery_codes,
|
'recovery_codes': recovery_codes,
|
||||||
'user_can_setup_recovery': user_can_setup_recovery,
|
'user_can_setup_recovery': user_can_setup_recovery,
|
||||||
'user_has_mfa_configured': len(devices) > 0,
|
'user_has_mfa_configured': bool(devices_per_category),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class DisableView(mixins.MfaRequiredMixin, FormView):
|
class DisableView(mixins.MfaRequiredMixin, FormView):
|
||||||
form_class = DisableMfaForm
|
form_class = DisableMfaForm
|
||||||
template_name = "bid_main/mfa/disable.html"
|
|
||||||
success_url = reverse_lazy('bid_main:mfa')
|
success_url = reverse_lazy('bid_main:mfa')
|
||||||
|
template_name = "bid_main/mfa/disable.html"
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
@ -73,6 +67,8 @@ class GenerateRecoveryView(mixins.MfaRequiredIfConfiguredMixin, View):
|
|||||||
user.staticdevice_set.all().delete()
|
user.staticdevice_set.all().delete()
|
||||||
device = StaticDevice.objects.create(name='recovery', user=user)
|
device = StaticDevice.objects.create(name='recovery', user=user)
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
|
# https://pages.nist.gov/800-63-3/sp800-63b.html#5122-look-up-secret-verifiers
|
||||||
|
# don't use less than 64 bits
|
||||||
device.token_set.create(token=random_hex(8).upper())
|
device.token_set.create(token=random_hex(8).upper())
|
||||||
return redirect(reverse('bid_main:mfa') + '?display_recovery_codes=1')
|
return redirect(reverse('bid_main:mfa') + '?display_recovery_codes=1')
|
||||||
|
|
||||||
@ -85,8 +81,8 @@ class InvalidateRecoveryView(mixins.MfaRequiredIfConfiguredMixin, View):
|
|||||||
|
|
||||||
|
|
||||||
class TotpView(mixins.MfaRequiredIfConfiguredMixin, FormView):
|
class TotpView(mixins.MfaRequiredIfConfiguredMixin, FormView):
|
||||||
template_name = "bid_main/mfa/totp.html"
|
|
||||||
success_url = reverse_lazy('bid_main:mfa')
|
success_url = reverse_lazy('bid_main:mfa')
|
||||||
|
template_name = "bid_main/mfa/totp.html"
|
||||||
|
|
||||||
def get_form(self, *args, **kwargs):
|
def get_form(self, *args, **kwargs):
|
||||||
kwargs = self.get_form_kwargs()
|
kwargs = self.get_form_kwargs()
|
||||||
@ -134,7 +130,7 @@ class DeleteDeviceView(mixins.MfaRequiredMixin, DeleteView):
|
|||||||
):
|
):
|
||||||
# there are other non-recovery devices, it's fine to delete this one
|
# there are other non-recovery devices, it's fine to delete this one
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
# this seems to be the last device, we are effectively disabling mfa
|
# this is the last non-recovery device, we are effectively disabling mfa
|
||||||
return redirect('bid_main:mfa_disable')
|
return redirect('bid_main:mfa_disable')
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
|
@ -22,6 +22,7 @@ from django.views.decorators.cache import never_cache
|
|||||||
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 devices_for_user
|
||||||
from otp_agents.forms import OTPTokenForm
|
from otp_agents.forms import OTPTokenForm
|
||||||
import loginas.utils
|
import loginas.utils
|
||||||
import oauth2_provider.models as oauth2_models
|
import oauth2_provider.models as oauth2_models
|
||||||
@ -75,6 +76,7 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, otp_agen
|
|||||||
"""Shows the login view."""
|
"""Shows the login view."""
|
||||||
|
|
||||||
otp_authentication_form = forms.AuthenticationForm
|
otp_authentication_form = forms.AuthenticationForm
|
||||||
|
otp_token_form = forms.MfaForm
|
||||||
page_id = "login"
|
page_id = "login"
|
||||||
redirect_authenticated_user = False
|
redirect_authenticated_user = False
|
||||||
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS
|
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS
|
||||||
@ -85,10 +87,21 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, otp_agen
|
|||||||
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["is_authentication_form"] = isinstance(self.get_form(), forms.AuthenticationForm)
|
form = self.get_form()
|
||||||
ctx["is_mfa_form"] = isinstance(self.get_form(), OTPTokenForm)
|
if isinstance(form, forms.MfaForm):
|
||||||
|
ctx["is_mfa_form"] = True
|
||||||
|
ctx["devices"] = self.request.user.mfa_devices_per_category()
|
||||||
|
ctx["use_recovery"] = self.request.GET.get("use_recovery", False)
|
||||||
|
else:
|
||||||
|
ctx["is_authentication_form"] = isinstance(form, forms.AuthenticationForm)
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
if self.request.GET.get("use_recovery", False):
|
||||||
|
kwargs["use_recovery"] = True
|
||||||
|
return kwargs
|
||||||
|
|
||||||
def find_oauth_flow(self, ctx: dict):
|
def find_oauth_flow(self, ctx: dict):
|
||||||
"""Figure out if this is an OAuth flow, and for which OAuth Client."""
|
"""Figure out if this is an OAuth flow, and for which OAuth Client."""
|
||||||
|
|
||||||
|
20
docs/mfa.md
Normal file
20
docs/mfa.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
gitea: none
|
||||||
|
include_toc: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Multi-factor authentication (MFA)
|
||||||
|
|
||||||
|
## Supported configurations
|
||||||
|
|
||||||
|
- TOTP authenticators
|
||||||
|
- TOTP + recovery codes
|
||||||
|
|
||||||
|
TODO: yubikeys, one-time code via email
|
||||||
|
|
||||||
|
## Implementation details
|
||||||
|
|
||||||
|
Using a combination of
|
||||||
|
- django-otp and its device plugins
|
||||||
|
- django-trusted-agents
|
||||||
|
- django-otp-agents
|
Loading…
Reference in New Issue
Block a user