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' %}
{% elif is_mfa_form %}
{% include 'bid_main/components/mfa_form.html' %}
{% elif is_u2f_form %}
{% include 'bid_main/components/u2f_form.html' %}
{% endif %}
{% endblock form %}

View File

@ -11,14 +11,14 @@ Multi-factor Authentication Setup
<div class="row">
<div class="col-md-6">
{% 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 %}
{% include "components/forms/field.html" %}
{% endwith %}
{% with field=form.credential %}
{% include "components/forms/field.html" %}
{% 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 %}
<div class="text-danger mt-3">
something went wrong
@ -31,14 +31,14 @@ Multi-factor Authentication Setup
</div>
<script src="{% static 'mfa/js/webauthn-json.js' %}"></script>
<script>
const form = document.getElementById('add-u2f-form');
const form = document.getElementById('u2f-register-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!form.checkValidity()) {
return false;
}
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);
credentialInput.value = JSON.stringify(credential);
form.submit();

View File

@ -148,10 +148,10 @@ class U2fView(mixins.MfaRequiredIfConfiguredMixin, FormView):
credential_creation_options, state = register_begin(
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))
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

View File

@ -4,6 +4,7 @@ No error handlers, no usually-one-off things like registration and
email confirmation.
"""
import json
import logging
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.base import View
from django.views.generic.edit import UpdateView
from fido2.webauthn import AttestedCredentialData
import loginas.utils
import oauth2_provider.models as oauth2_models
import otp_agents.views
@ -29,7 +31,8 @@ import otp_agents.views
from .. import forms, email
from . import mixins
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
User = get_user_model()
@ -83,18 +86,43 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, otp_agen
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:
ctx = super().get_context_data(**kwargs)
self.find_oauth_flow(ctx)
form = self.get_form()
if isinstance(form, MfaForm):
ctx["is_mfa_form"] = True
if isinstance(form, U2fForm):
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:
ctx["is_authentication_form"] = isinstance(form, forms.AuthenticationForm)
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):
kwargs = super().get_form_kwargs()
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):
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.core.signing import BadSignature, TimestampSigner
from django.core.validators import RegexValidator
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_otp.oath import TOTP
from otp_agents.forms import OTPAgentFormMixin
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
@ -29,13 +31,13 @@ class MfaForm(OTPTokenForm):
"""Restyle the form widgets to do less work in the template."""
def __init__(self, *args, **kwargs):
use_recovery = kwargs.pop('use_recovery', False)
self.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:
if self.use_recovery:
otp_token.validators = [RegexValidator(r'^[A-F0-9]{16}$')]
otp_token.widget = forms.TextInput(
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):
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",
@ -145,7 +205,7 @@ class U2fMfaForm(forms.Form):
self.state = kwargs.pop('state', None)
self.user = kwargs.pop('user', None)
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):
credential = self.cleaned_data.get('credential')