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
7 changed files with 160 additions and 14 deletions
Showing only changes of commit a91afd2597 - Show all commits

View File

@ -0,0 +1,43 @@
{% load add_form_classes from forms %}
{% load common static %}
<div class="bid box">
<div>
<h2>Multi-factor Authentication</h2>
</div>
{% with form=form|add_form_classes %}
<form method="POST" id="u2f-authenticate-form">{% csrf_token %}
<fieldset class="mb-4">
<p>Use a security key.</p>
{% with field=form.otp_trust_agent %}
{% include "components/forms/field.html" with with_help_text=True %}
{% endwith %}
{% with field=form.response %}
{% include "components/forms/field.html" %}
{% endwith %}
{% with field=form.signature %}
{% include "components/forms/field.html" %}
{% endwith %}
{% with field=form.state %}
{% include "components/forms/field.html" %}
{% endwith %}
</fieldset>
{{ form.non_field_errors }}
</form>
{% endwith %}
{% if devices.totp %}
<a href="?{% query_transform use_totp=1 %}">Use an authenticator</a>
Lost your authenticator? <a href="?{% query_transform use_recovery=1 %}">Use a recovery code</a>
{% endif %}
</div>
<script src="{% static 'mfa/js/webauthn-json.js' %}"></script>
<script>
const form = document.getElementById('u2f-authenticate-form');
const responseInput = document.getElementById('id_response');
const requestOptions = JSON.parse(responseInput.getAttribute('request-options'));
(async function() {
const credential = await webauthnJSON.get(requestOptions);
responseInput.value = JSON.stringify(credential);
form.submit();
})();
</script>

View File

@ -7,5 +7,7 @@
{% include 'bid_main/components/login_form.html' %} {% include 'bid_main/components/login_form.html' %}
{% elif is_mfa_form %} {% elif is_mfa_form %}
{% include 'bid_main/components/mfa_form.html' %} {% include 'bid_main/components/mfa_form.html' %}
{% elif is_u2f_form %}
{% include 'bid_main/components/u2f_form.html' %}
{% endif %} {% endif %}
{% endblock form %} {% endblock form %}

View File

@ -11,14 +11,14 @@ Multi-factor Authentication Setup
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
{% with form=form|add_form_classes %} {% with form=form|add_form_classes %}
<form method="post" id="add-u2f-form">{% csrf_token %} <form method="post" id="u2f-register-form">{% csrf_token %}
{% with field=form.name %} {% with field=form.name %}
{% include "components/forms/field.html" %} {% include "components/forms/field.html" %}
{% endwith %} {% endwith %}
{% with field=form.credential %} {% with field=form.credential %}
{% include "components/forms/field.html" %} {% include "components/forms/field.html" %}
{% endwith %} {% endwith %}
<button type="submit" class="btn" id="add-u2f-submit">Add security key</button> <button type="submit" class="btn">Add security key</button>
{% if form.non_field_errors %} {% if form.non_field_errors %}
<div class="text-danger mt-3"> <div class="text-danger mt-3">
something went wrong something went wrong
@ -31,14 +31,14 @@ Multi-factor Authentication Setup
</div> </div>
<script src="{% static 'mfa/js/webauthn-json.js' %}"></script> <script src="{% static 'mfa/js/webauthn-json.js' %}"></script>
<script> <script>
const form = document.getElementById('add-u2f-form'); const form = document.getElementById('u2f-register-form');
form.addEventListener('submit', async (event) => { form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
if (!form.checkValidity()) { if (!form.checkValidity()) {
return false; return false;
} }
const credentialInput = document.getElementById('id_credential'); const credentialInput = document.getElementById('id_credential');
const credentialCreationOptions = JSON.parse(credentialInput.getAttribute('creation_options')); const credentialCreationOptions = JSON.parse(credentialInput.getAttribute('creation-options'));
const credential = await webauthnJSON.create(credentialCreationOptions); const credential = await webauthnJSON.create(credentialCreationOptions);
credentialInput.value = JSON.stringify(credential); credentialInput.value = JSON.stringify(credential);
form.submit(); form.submit();

View File

@ -148,10 +148,10 @@ class U2fView(mixins.MfaRequiredIfConfiguredMixin, FormView):
credential_creation_options, state = register_begin( credential_creation_options, state = register_begin(
rp_id, self.request.user, credentials, rp_id, self.request.user, credentials,
) )
self.request.session['u2f_create_state'] = dict(state) self.request.session['u2f_register_state'] = dict(state)
kwargs['credential_creation_options'] = json.dumps(dict(credential_creation_options)) kwargs['credential_creation_options'] = json.dumps(dict(credential_creation_options))
if self.request.method == 'POST': if self.request.method == 'POST':
kwargs['state'] = self.request.session.pop('u2f_create_state', None) kwargs['state'] = self.request.session.pop('u2f_register_state', None)
return kwargs return kwargs

View File

@ -4,6 +4,7 @@ No error handlers, no usually-one-off things like registration and
email confirmation. email confirmation.
""" """
import json
import logging import logging
import urllib.parse import urllib.parse
@ -22,6 +23,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 fido2.webauthn import AttestedCredentialData
import loginas.utils import loginas.utils
import oauth2_provider.models as oauth2_models import oauth2_provider.models as oauth2_models
import otp_agents.views import otp_agents.views
@ -29,7 +31,8 @@ import otp_agents.views
from .. import forms, email from .. import forms, email
from . import mixins from . import mixins
from bid_main.email import send_verify_address from bid_main.email import send_verify_address
from mfa.forms import MfaForm from mfa.fido2 import authenticate_begin
from mfa.forms import MfaForm, U2fForm
import bid_main.file_utils import bid_main.file_utils
User = get_user_model() User = get_user_model()
@ -83,18 +86,43 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, otp_agen
authorize_url = reverse_lazy("oauth2_provider:authorize") authorize_url = reverse_lazy("oauth2_provider:authorize")
def _use_recovery(self):
return self.request.GET.get("use_recovery", False)
def _use_totp(self):
return self.request.GET.get("use_totp", False)
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)
form = self.get_form() form = self.get_form()
if isinstance(form, MfaForm): if isinstance(form, U2fForm):
ctx["is_mfa_form"] = True
ctx["devices"] = self.request.user.mfa_devices_per_category() ctx["devices"] = self.request.user.mfa_devices_per_category()
ctx["use_recovery"] = self.request.GET.get("use_recovery", False) ctx["is_u2f_form"] = True
elif isinstance(form, MfaForm):
ctx["devices"] = self.request.user.mfa_devices_per_category()
ctx["is_mfa_form"] = True
ctx["use_recovery"] = self._use_recovery()
else: else:
ctx["is_authentication_form"] = isinstance(form, forms.AuthenticationForm) ctx["is_authentication_form"] = isinstance(form, forms.AuthenticationForm)
return ctx return ctx
def get_form(self):
if self.request.user.is_authenticated:
devices = self.request.user.mfa_devices_per_category().get('u2f', None)
if devices and not self._use_recovery() and not self._use_totp():
rp_id = self.request.get_host().split(':')[0] # remove port, required by webauthn
credentials = [AttestedCredentialData(d.credential) for d in devices]
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 U2fForm(**kwargs)
# this will switch between MfaForm and AuthenticationForm
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.request.GET.get("use_recovery", False):

View File

@ -36,3 +36,16 @@ def register_begin(rp_id, user, credentials=[]):
def register_complete(rp_id, state, credential): def register_complete(rp_id, state, credential):
return get_fido2server(rp_id).register_complete(state, credential) return get_fido2server(rp_id).register_complete(state, credential)
def authenticate_begin(rp_id, credentials):
return get_fido2server(rp_id).authenticate_begin(
credentials=credentials,
user_verification=UserVerificationRequirement.PREFERRED,
)
def authenticate_complete(rp_id, state, credentials, response):
return get_fido2server(rp_id).authenticate_complete(
state, credentials, response,
)

View File

@ -4,11 +4,13 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.core.signing import BadSignature, TimestampSigner from django.core.signing import BadSignature, TimestampSigner
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
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 otp_agents.forms import OTPAgentFormMixin
from otp_agents.views import OTPTokenForm from otp_agents.views import OTPTokenForm
from mfa.fido2 import register_complete from mfa.fido2 import authenticate_complete, register_complete
from mfa.models import EncryptedTOTPDevice, U2fDevice from mfa.models import EncryptedTOTPDevice, U2fDevice
@ -29,13 +31,13 @@ class MfaForm(OTPTokenForm):
"""Restyle the form widgets to do less work in the template.""" """Restyle the form widgets to do less work in the template."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
use_recovery = kwargs.pop('use_recovery', False) self.use_recovery = kwargs.pop('use_recovery', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
otp_token = self.fields["otp_token"] otp_token = self.fields["otp_token"]
otp_token.label = _('Code') otp_token.label = _('Code')
otp_token.required = True otp_token.required = True
if use_recovery: if self.use_recovery:
otp_token.validators = [RegexValidator(r'^[A-F0-9]{16}$')] otp_token.validators = [RegexValidator(r'^[A-F0-9]{16}$')]
otp_token.widget = forms.TextInput( otp_token.widget = forms.TextInput(
attrs={ attrs={
@ -71,6 +73,64 @@ class MfaForm(OTPTokenForm):
) )
class U2fForm(OTPAgentFormMixin, forms.Form):
otp_trust_agent = forms.BooleanField(
help_text=_(
f"We won't ask for MFA on this device in the next {settings.AGENT_TRUST_DAYS} days. "
f"Use only on your private device."
),
initial=False,
label=_("Remember this device"),
required=False,
widget=forms.CheckboxInput(
attrs={
"autocomplete": "off",
},
)
)
signature = forms.CharField(widget=forms.HiddenInput)
state = forms.JSONField(widget=forms.HiddenInput)
response = forms.JSONField(widget=forms.HiddenInput)
def __init__(self, *args, **kwargs):
self.credentials = kwargs.pop('credentials', None)
request = kwargs.pop('request') # important for compatibility with other login forms
request_options = kwargs.pop('request_options', None)
self.rp_id = kwargs.pop('rp_id')
state = kwargs.pop('state')
self.user = request.user
kwargs['initial']['signature'] = _sign(f"{self.user.email}_{state['challenge']}")
kwargs['initial']['state'] = state
super().__init__(*args, **kwargs)
self.fields['response'].widget.attrs['request-options'] = request_options
def get_user(self):
"""For compatibility with other login forms."""
return self.user
def clean(self):
super().clean()
signature = self.cleaned_data.get('signature')
state = self.cleaned_data.get('state')
if not _verify_signature(f"{self.user.email}_{state['challenge']}", signature):
raise forms.ValidationError(_('Invalid signature'))
credential = authenticate_complete(
self.rp_id,
state,
self.credentials,
self.cleaned_data['response'],
)
device = U2fDevice.objects.filter(credential=credential, user=self.user).first()
device.last_used_at = timezone.now()
device.save()
self.user.otp_device = device
self.clean_agent()
return self.cleaned_data
def save(self):
pass
class DisableMfaForm(forms.Form): class DisableMfaForm(forms.Form):
disable_mfa_confirm = forms.BooleanField( disable_mfa_confirm = forms.BooleanField(
Oleg-Komarov marked this conversation as resolved
Review

might not be necessary at all, since this isn't a ModelForm?

might not be necessary at all, since this isn't a `ModelForm`?
help_text="Confirming disabling of multi-factor authentication", help_text="Confirming disabling of multi-factor authentication",
@ -145,7 +205,7 @@ class U2fMfaForm(forms.Form):
self.state = kwargs.pop('state', None) self.state = kwargs.pop('state', None)
self.user = kwargs.pop('user', None) self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['credential'].widget.attrs['creation_options'] = credential_creation_options self.fields['credential'].widget.attrs['creation-options'] = credential_creation_options
def save(self): def save(self):
credential = self.cleaned_data.get('credential') credential = self.cleaned_data.get('credential')