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())
|
||||||
Oleg-Komarov marked this conversation as resolved
Outdated
|
|||||||
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"
|
||||||
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):
|
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
DevFund and other services use
send_mail_*
for the most part: it's easier to parse visually