Initial mfa support (for internal users) #93591
43
bid_main/templates/bid_main/components/u2f_form.html
Normal file
43
bid_main/templates/bid_main/components/u2f_form.html
Normal 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>
|
@ -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 %}
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
13
mfa/fido2.py
13
mfa/fido2.py
@ -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,
|
||||
)
|
||||
|
68
mfa/forms.py
68
mfa/forms.py
@ -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
|
||||
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')
|
||||
|
Loading…
Reference in New Issue
Block a user
might not be necessary at all, since this isn't a
ModelForm
?