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
4 changed files with 131 additions and 126 deletions
Showing only changes of commit 93f501f289 - Show all commits

View File

@ -1,23 +1,17 @@
from binascii import unhexlify
import logging
import pathlib
from django import forms
from django.conf import settings
from django.contrib.auth import forms as auth_forms, password_validation, authenticate
from django.core.signing import BadSignature, TimestampSigner
from django.core.validators import RegexValidator
from django.db import transaction
from django.forms import ValidationError
from django.template import defaultfilters
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_otp.oath import TOTP
from otp_agents.views import OTPTokenForm
from .models import User, OAuth2Application
from .recaptcha import check_recaptcha
from mfa.models import EncryptedTOTPDevice
import bid_main.tasks
log = logging.getLogger(__name__)
@ -214,52 +208,6 @@ class AuthenticationForm(auth_forms.AuthenticationForm):
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):
"""Edits full name and email address.
@ -461,74 +409,3 @@ class OAuth2ApplicationForm(forms.ModelForm):
super().__init__(*args, **kwargs)
for key in readonly_fields:
self.fields[key].widget.attrs['readonly'] = True
class DisableMfaForm(forms.Form):
disable_mfa_confirm = forms.BooleanField(
help_text="Confirming disabling of multi-factor authentication",
initial=False,
label=_('Confirm'),
required=True,
widget=forms.CheckboxInput(
attrs={
"autocomplete": "off",
},
),
)
class TotpMfaForm(forms.Form):
code = forms.CharField(
validators=[RegexValidator(r'^[0-9]{6}$')],
widget=forms.TextInput(
attrs={
"autocomplete": "one-time-code",
"inputmode": "numeric",
"maxlength": 6,
"pattern": "[0-9]{6}",
"placeholder": "123456",
},
),
)
key = forms.CharField(widget=forms.HiddenInput)
name = forms.CharField(
max_length=EncryptedTOTPDevice._meta.get_field('name').max_length,
widget=forms.TextInput(
attrs={"placeholder": "device name (for your convenience)"},
),
)
signature = forms.CharField(widget=forms.HiddenInput)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
def clean(self):
super().clean()
code = self.data.get('code')
key = self.data.get('key')
signature = self.cleaned_data.get('signature')
if not self.verify_signature(self.user, key, signature):
raise forms.ValidationError(_('Invalid signature'))
self.cleaned_data['key'] = key
if not TOTP(unhexlify(key)).verify(int(code), tolerance=1):
self.add_error('code', _('Invalid code'))
return self.cleaned_data
def save(self):
key = self.cleaned_data.get('key')
name = self.cleaned_data.get('name')
EncryptedTOTPDevice.objects.create(encrypted_key=key, key='', name=name, user=self.user)
@classmethod
def sign(cls, user, key):
signer = TimestampSigner()
return signer.sign(f'{user.email}_{key}')
@classmethod
def verify_signature(cls, user, key, signature, max_age=3600):
signer = TimestampSigner()
try:
return f'{user.email}_{key}' == signer.unsign(signature, max_age=max_age)
except BadSignature:
return False

View File

@ -14,7 +14,7 @@ from django_otp.util import random_hex
import qrcode
from . import mixins
from bid_main.forms import DisableMfaForm, TotpMfaForm
from mfa.forms import DisableMfaForm, TotpMfaForm
from mfa.models import EncryptedRecoveryDevice, EncryptedTOTPDevice, devices_for_user

View File

@ -29,6 +29,7 @@ import otp_agents.views
from .. import forms, email
from . import mixins
from bid_main.email import send_verify_address
from mfa.forms import MfaForm
import bid_main.file_utils
User = get_user_model()
@ -74,7 +75,7 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, otp_agen
"""Shows the login view."""
otp_authentication_form = forms.AuthenticationForm
otp_token_form = forms.MfaForm
otp_token_form = MfaForm
page_id = "login"
redirect_authenticated_user = False
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS
@ -86,7 +87,7 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, otp_agen
ctx = super().get_context_data(**kwargs)
self.find_oauth_flow(ctx)
form = self.get_form()
if isinstance(form, forms.MfaForm):
if isinstance(form, 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)

127
mfa/forms.py Normal file
View File

@ -0,0 +1,127 @@
from binascii import unhexlify
from django import forms
from django.core.signing import BadSignature, TimestampSigner
from django.core.validators import RegexValidator
from django.utils.translation import gettext_lazy as _
from django_otp.oath import TOTP
from otp_agents.views import OTPTokenForm
from mfa.models import EncryptedTOTPDevice
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)
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 DisableMfaForm(forms.Form):
disable_mfa_confirm = forms.BooleanField(
help_text="Confirming disabling of multi-factor authentication",
initial=False,
label=_('Confirm'),
required=True,
widget=forms.CheckboxInput(
attrs={
"autocomplete": "off",
},
),
)
class TotpMfaForm(forms.Form):
code = forms.CharField(
validators=[RegexValidator(r'^[0-9]{6}$')],
widget=forms.TextInput(
attrs={
"autocomplete": "one-time-code",
"inputmode": "numeric",
"maxlength": 6,
"pattern": "[0-9]{6}",
"placeholder": "123456",
},
),
)
key = forms.CharField(widget=forms.HiddenInput)
name = forms.CharField(
max_length=EncryptedTOTPDevice._meta.get_field('name').max_length,
widget=forms.TextInput(
attrs={"placeholder": "device name (for your convenience)"},
),
)
signature = forms.CharField(widget=forms.HiddenInput)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
def clean(self):
super().clean()
code = self.data.get('code')
key = self.data.get('key')
signature = self.cleaned_data.get('signature')
if not self.verify_signature(self.user, key, signature):
raise forms.ValidationError(_('Invalid signature'))
self.cleaned_data['key'] = key
if not TOTP(unhexlify(key)).verify(int(code), tolerance=1):
self.add_error('code', _('Invalid code'))
return self.cleaned_data
def save(self):
key = self.cleaned_data.get('key')
name = self.cleaned_data.get('name')
EncryptedTOTPDevice.objects.create(encrypted_key=key, key='', name=name, user=self.user)
@classmethod
def sign(cls, user, key):
signer = TimestampSigner()
return signer.sign(f'{user.email}_{key}')
@classmethod
def verify_signature(cls, user, key, signature, max_age=3600):
signer = TimestampSigner()
try:
return f'{user.email}_{key}' == signer.unsign(signature, max_age=max_age)
except BadSignature:
return False