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 logging
import pathlib import pathlib
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import forms as auth_forms, password_validation, authenticate 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.db import transaction
from django.forms import ValidationError from django.forms import ValidationError
from django.template import defaultfilters from django.template import defaultfilters
from django.utils import timezone 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 otp_agents.views import OTPTokenForm
from .models import User, OAuth2Application from .models import User, OAuth2Application
from .recaptcha import check_recaptcha from .recaptcha import check_recaptcha
from mfa.models import EncryptedTOTPDevice
import bid_main.tasks import bid_main.tasks
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -214,52 +208,6 @@ class AuthenticationForm(auth_forms.AuthenticationForm):
self.fields["password"].widget.attrs["placeholder"] = "Enter your password" 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): class UserProfileForm(BootstrapModelFormMixin, forms.ModelForm):
"""Edits full name and email address. """Edits full name and email address.
@ -461,74 +409,3 @@ class OAuth2ApplicationForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for key in readonly_fields: for key in readonly_fields:
self.fields[key].widget.attrs['readonly'] = True 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 import qrcode
from . import mixins 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 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 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
import bid_main.file_utils import bid_main.file_utils
User = get_user_model() User = get_user_model()
@ -74,7 +75,7 @@ class LoginView(mixins.RedirectToPrivacyAgreeMixin, mixins.PageIdMixin, otp_agen
"""Shows the login view.""" """Shows the login view."""
otp_authentication_form = forms.AuthenticationForm otp_authentication_form = forms.AuthenticationForm
otp_token_form = forms.MfaForm otp_token_form = MfaForm
page_id = "login" page_id = "login"
redirect_authenticated_user = False redirect_authenticated_user = False
success_url_allowed_hosts = settings.NEXT_REDIR_AFTER_LOGIN_ALLOWED_HOSTS 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) 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, forms.MfaForm): if isinstance(form, MfaForm):
ctx["is_mfa_form"] = True 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["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