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).
|
||||
|
||||
# Multi-factor authentication
|
||||
|
||||
See [mfa.md](docs/mfa.md).
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
|
@ -14,6 +14,7 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_otp.oath import TOTP
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from otp_agents.views import OTPTokenForm
|
||||
|
||||
from .models import User, OAuth2Application
|
||||
from .recaptcha import check_recaptcha
|
||||
@ -213,6 +214,52 @@ class AuthenticationForm(auth_forms.AuthenticationForm):
|
||||
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):
|
||||
"""Edits full name and email address.
|
||||
|
||||
|
@ -1,22 +1,26 @@
|
||||
from collections import defaultdict
|
||||
from typing import Optional, Set
|
||||
import itertools
|
||||
import logging
|
||||
import os.path
|
||||
import re
|
||||
|
||||
from django import urls
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
|
||||
from django.contrib.auth.models import PermissionsMixin
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core import validators
|
||||
from django.core.mail import send_mail
|
||||
from django.db import models, transaction
|
||||
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.utils import timezone
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import oauth2_provider.models as oa2_models
|
||||
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(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):
|
||||
def __init__(self, *args, **kwargs): # noqa: D107
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% load add_form_classes from forms %}
|
||||
{% load static %}
|
||||
{% load common static %}
|
||||
|
||||
<div class="bid box">
|
||||
<div>
|
||||
@ -7,22 +7,42 @@
|
||||
</div>
|
||||
{% with form=form|add_form_classes %}
|
||||
<form role="login" action="" method="POST">{% csrf_token %}
|
||||
<fieldset>
|
||||
{% with field=form.otp_device %}
|
||||
{% include "components/forms/field.html" %}
|
||||
{% endwith %}
|
||||
{% with field=form.otp_token %}
|
||||
{% include "components/forms/field.html" %}
|
||||
{% endwith %}
|
||||
{% with field=form.otp_trust_agent %}
|
||||
{% include "components/forms/field.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% if form.errors %}
|
||||
<p class="text-danger">{{ form.errors }}</p>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<button class="btn btn-block btn-accent" id="register">Continue</button>
|
||||
<fieldset class="mb-4">
|
||||
{% if use_recovery %}
|
||||
<p>Use a recovery code</p>
|
||||
<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 %}
|
||||
{% include "components/forms/field.html" %}
|
||||
{% endwith %}
|
||||
{% with field=form.otp_trust_agent %}
|
||||
{% include "components/forms/field.html" with with_help_text=True %}
|
||||
{% endwith %}
|
||||
</fieldset>
|
||||
{{ form.non_field_errors }}
|
||||
<button class="btn btn-block btn-accent">Continue</button>
|
||||
</form>
|
||||
{% 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>
|
||||
|
@ -42,7 +42,7 @@ Multi-factor Authentication Setup
|
||||
|
||||
{% if user_can_setup_recovery %}
|
||||
<div class="bid box mt-3">
|
||||
<h3>Recovery codes</h3>
|
||||
<h3 id="recovery-codes">Recovery codes</h3>
|
||||
<p>
|
||||
Store your recovery codes safely (e.g. in a password manager) and don't share them.
|
||||
Each code can be used only once.
|
||||
@ -56,7 +56,7 @@ Multi-factor Authentication Setup
|
||||
{% if recovery_codes %}
|
||||
<a href="?display_recovery_codes=" class="btn">Hide</a>
|
||||
{% else %}
|
||||
<a href="?display_recovery_codes=1" class="btn">Display</a>
|
||||
<a href="?display_recovery_codes=1#recovery-codes" class="btn">Display</a>
|
||||
{% endif %}
|
||||
<form action="{% url 'bid_main:mfa_invalidate_recovery' %}" method="post" class="d-inline-flex">{% csrf_token %}
|
||||
<button class="btn-danger" type="submit">Invalidate</button>
|
||||
|
@ -5,7 +5,7 @@
|
||||
{% if not field.is_hidden %}
|
||||
{% include 'components/forms/label.html' with label_class="form-check-label" %}
|
||||
{% 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 class="text-danger">{{ field.errors }}</div>
|
||||
@ -18,7 +18,7 @@
|
||||
{{ field }}
|
||||
<div class="text-danger">{{ field.errors }}</div>
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% 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 binascii import unhexlify
|
||||
from collections import defaultdict
|
||||
from io import BytesIO
|
||||
|
||||
from django.db import transaction
|
||||
@ -31,30 +30,25 @@ class MfaView(mixins.MfaRequiredIfConfiguredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
user = self.request.user
|
||||
devices = list(devices_for_user(user))
|
||||
devices_per_category = defaultdict(list)
|
||||
recovery_codes = []
|
||||
user_can_setup_recovery = False
|
||||
for device in devices:
|
||||
if isinstance(device, StaticDevice):
|
||||
devices_per_category['recovery'].append(device)
|
||||
if self.request.GET.get('display_recovery_codes', None):
|
||||
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
|
||||
devices_per_category = user.mfa_devices_per_category()
|
||||
if self.request.GET.get('display_recovery_codes') and 'recovery' in devices_per_category:
|
||||
recovery_codes = [t.token for t in devices_per_category['recovery'][0].token_set.all()]
|
||||
if devices_per_category.keys() - {'recovery'}:
|
||||
user_can_setup_recovery = True
|
||||
return {
|
||||
'devices_per_category': devices_per_category,
|
||||
'recovery_codes': recovery_codes,
|
||||
'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):
|
||||
form_class = DisableMfaForm
|
||||
template_name = "bid_main/mfa/disable.html"
|
||||
success_url = reverse_lazy('bid_main:mfa')
|
||||
template_name = "bid_main/mfa/disable.html"
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
@ -73,6 +67,8 @@ class GenerateRecoveryView(mixins.MfaRequiredIfConfiguredMixin, View):
|
||||
user.staticdevice_set.all().delete()
|
||||
device = StaticDevice.objects.create(name='recovery', user=user)
|
||||
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())
|
||||
Oleg-Komarov marked this conversation as resolved
Outdated
|
||||
return redirect(reverse('bid_main:mfa') + '?display_recovery_codes=1')
|
||||
|
||||
@ -85,8 +81,8 @@ class InvalidateRecoveryView(mixins.MfaRequiredIfConfiguredMixin, View):
|
||||
|
||||
|
||||
class TotpView(mixins.MfaRequiredIfConfiguredMixin, FormView):
|
||||
template_name = "bid_main/mfa/totp.html"
|
||||
success_url = reverse_lazy('bid_main:mfa')
|
||||
template_name = "bid_main/mfa/totp.html"
|
||||
Oleg-Komarov marked this conversation as resolved
Outdated
Anna Sirota
commented
From this line it's not clear that this is recovery codes that are being deleted From this line it's not clear that this is recovery codes that are being deleted
|
||||
|
||||
def get_form(self, *args, **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
|
||||
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')
|
||||
|
||||
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.base import View
|
||||
from django.views.generic.edit import UpdateView
|
||||
from django_otp import devices_for_user
|
||||
from otp_agents.forms import OTPTokenForm
|
||||
import loginas.utils
|
||||
import oauth2_provider.models as oauth2_models
|
||||
@ -75,6 +76,7 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, otp_agen
|
||||
"""Shows the login view."""
|
||||
|
||||
otp_authentication_form = forms.AuthenticationForm
|
||||
otp_token_form = forms.MfaForm
|
||||
page_id = "login"
|
||||
redirect_authenticated_user = False
|
||||
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:
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
self.find_oauth_flow(ctx)
|
||||
ctx["is_authentication_form"] = isinstance(self.get_form(), forms.AuthenticationForm)
|
||||
ctx["is_mfa_form"] = isinstance(self.get_form(), OTPTokenForm)
|
||||
form = self.get_form()
|
||||
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
|
||||
|
||||
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):
|
||||
"""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
DevFund and other services use
send_mail_*
for the most part: it's easier to parse visually