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
10 changed files with 165 additions and 41 deletions
Showing only changes of commit 144d26eba0 - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()

View File

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

View File

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