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' %}
|
{% 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 %}
|
||||||
|
@ -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();
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
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):
|
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,
|
||||||
|
)
|
||||||
|
68
mfa/forms.py
68
mfa/forms.py
@ -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
|
|||||||
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')
|
||||||
|
Loading…
Reference in New Issue
Block a user
might not be necessary at all, since this isn't a
ModelForm
?