Initial mfa support (for internal users) #93591
@ -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
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
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