Initial mfa support (for internal users) #93591
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
127
mfa/forms.py
Normal 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
|
Loading…
Reference in New Issue
Block a user