From d17bd479ec500a04a74202f4e940380b54b90135 Mon Sep 17 00:00:00 2001 From: Carson-Tang Date: Wed, 15 Apr 2020 00:55:37 -0400 Subject: [PATCH] Add scratch code support for 2FA; fixes #784 --- dmoj/urls.py | 2 + judge/forms.py | 31 +++++-- judge/migrations/0104_scratch_codes.py | 22 +++++ judge/models/profile.py | 14 +++ judge/views/totp.py | 9 ++ judge/views/user.py | 10 +++ templates/registration/totp_auth.html | 12 +-- templates/registration/totp_disable.html | 8 +- templates/registration/totp_enable.html | 65 +++++++++++++- templates/user/edit-profile.html | 103 +++++++++++++++++++++++ 10 files changed, 256 insertions(+), 20 deletions(-) create mode 100644 judge/migrations/0104_scratch_codes.py diff --git a/dmoj/urls.py b/dmoj/urls.py index 27e1fe06d8..53275703fa 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -79,6 +79,8 @@ url(r'^2fa/enable/$', totp.TOTPEnableView.as_view(), name='enable_2fa'), url(r'^2fa/disable/$', totp.TOTPDisableView.as_view(), name='disable_2fa'), + url(r'^2fa/scratchcode/generate/$', user.generate_scratch_codes, name='generate_scratch_codes'), + url(r'api/token/generate/$', user.generate_api_token, name='generate_api_token'), url(r'api/token/remove/$', user.remove_api_token, name='remove_api_token'), ] diff --git a/judge/forms.py b/judge/forms.py index ad7a761c81..4d5cbe10bc 100644 --- a/judge/forms.py +++ b/judge/forms.py @@ -1,5 +1,7 @@ +import json from operator import attrgetter + import pyotp from django import forms from django.conf import settings @@ -17,6 +19,20 @@ from judge.widgets import HeavyPreviewPageDownWidget, MathJaxPagedownWidget, PagedownWidget, Select2MultipleWidget, \ Select2Widget +totp_or_scratch_code_validators = [ + RegexValidator('^[0-9]{6}$', _('Two Factor Authentication tokens must be 6 decimal digits.')), + RegexValidator('^[A-Z2-7]{16}$', _('Scratch codes must be 16 decimal digits.')), +] + + +def totp_or_scratch_code_validator(totp_or_scratch_code): + for validator in totp_or_scratch_code_validators: + try: + validator(totp_or_scratch_code) + return totp_or_scratch_code + except ValidationError as error: + raise error + def fix_unicode(string, unsafe=tuple('\u202a\u202b\u202d\u202e')): return string + (sum(k in unsafe for k in string) - string.count('\u202c')) * '\u202c' @@ -131,17 +147,22 @@ def widget_attrs(self, widget): class TOTPForm(Form): TOLERANCE = settings.DMOJ_TOTP_TOLERANCE_HALF_MINUTES - totp_token = NoAutoCompleteCharField(validators=[ - RegexValidator('^[0-9]{6}$', _('Two Factor Authentication tokens must be 6 decimal digits.')), - ]) + totp_or_scratch_code = NoAutoCompleteCharField(validators=[totp_or_scratch_code_validator]) def __init__(self, *args, **kwargs): self.totp_key = kwargs.pop('totp_key') + self.scratch_codes = json.loads(kwargs.pop('scratch_codes')) + self.profile = kwargs.pop('profile') super(TOTPForm, self).__init__(*args, **kwargs) - def clean_totp_token(self): - if not pyotp.TOTP(self.totp_key).verify(self.cleaned_data['totp_token'], valid_window=self.TOLERANCE): + def clean_totp_or_scratch_code(self): + totp_or_scratch_code = self.cleaned_data['totp_or_scratch_code'] + if len(totp_or_scratch_code) == 6 and \ + not pyotp.TOTP(self.totp_key).verify(totp_or_scratch_code, valid_window=self.TOLERANCE): raise ValidationError(_('Invalid Two Factor Authentication token.')) + elif len(totp_or_scratch_code) == 16 and totp_or_scratch_code in self.scratch_codes: + self.profile.scratch_codes = json.dumps(self.scratch_codes.remove(totp_or_scratch_code)) + self.profile.save() class ProblemCloneForm(Form): diff --git a/judge/migrations/0104_scratch_codes.py b/judge/migrations/0104_scratch_codes.py new file mode 100644 index 0000000000..ce02169550 --- /dev/null +++ b/judge/migrations/0104_scratch_codes.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.7 on 2020-05-04 03:26 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations + +import judge.models.profile + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0103_contest_participation_tiebreak_field'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='scratch_codes', + field=judge.models.profile.EncryptedNullCharField(blank=True, help_text='JSON of 16 character base32-encoded codes for scratch codes', max_length=255, null=True, validators=[django.core.validators.RegexValidator('^[A-Z2-7]$', 'Scratch codes must be empty or base32')], verbose_name='Scratch Codes'), + ), + ] diff --git a/judge/models/profile.py b/judge/models/profile.py index 841d87c4ab..2c3ad6d2fe 100644 --- a/judge/models/profile.py +++ b/judge/models/profile.py @@ -1,9 +1,11 @@ import base64 import hmac +import json import secrets import struct from operator import mul +import pyotp from django.conf import settings from django.contrib.auth.models import User from django.core.validators import RegexValidator @@ -123,6 +125,10 @@ class Profile(models.Model): _('API token must be None or hexadecimal'))]) notes = models.TextField(verbose_name=_('internal notes'), null=True, blank=True, help_text=_('Notes for administrators regarding this user.')) + scratch_codes = EncryptedNullCharField(max_length=255, null=True, blank=True, verbose_name=_('Scratch Codes'), + help_text=_('JSON of 16 character base32-encoded codes for scratch codes'), + validators=[RegexValidator('^[A-Z2-7]$', + _('Scratch codes must be empty or base32'))]) @cached_property def organization(self): @@ -170,6 +176,14 @@ def generate_api_token(self): generate_api_token.alters_data = True + def generate_scratch_codes(self): + codes = [pyotp.random_base32(length=16) for i in range(5)] + self.scratch_codes = json.dumps(codes) + self.save(update_fields=['scratch_codes']) + return '\n'.join(codes) + + generate_scratch_codes.alters_data = True + def remove_contest(self): self.current_contest = None self.save() diff --git a/judge/views/totp.py b/judge/views/totp.py index 097137f7e4..224f72316d 100644 --- a/judge/views/totp.py +++ b/judge/views/totp.py @@ -1,6 +1,8 @@ import base64 +import json from io import BytesIO + import pyotp import qrcode from django.conf import settings @@ -22,6 +24,8 @@ class TOTPView(TitleMixin, LoginRequiredMixin, FormView): def get_form_kwargs(self): result = super(TOTPView, self).get_form_kwargs() result['totp_key'] = self.profile.totp_key + result['scratch_codes'] = self.profile.scratch_codes + result['profile'] = self.profile return result def dispatch(self, request, *args, **kwargs): @@ -47,6 +51,9 @@ def get(self, request, *args, **kwargs): if not profile.totp_key: profile.totp_key = pyotp.random_base32(length=32) profile.save() + if not profile.scratch_codes: + profile.scratch_codes = json.dumps([pyotp.random_base32(length=16) for i in range(5)]) + profile.save() return self.render_to_response(self.get_context_data()) def check_skip(self): @@ -60,6 +67,7 @@ def post(self, request, *args, **kwargs): def get_context_data(self, **kwargs): context = super(TOTPEnableView, self).get_context_data(**kwargs) context['totp_key'] = self.profile.totp_key + context['scratch_codes'] = '\n'.join(json.loads(self.profile.scratch_codes)) context['qr_code'] = self.render_qr_code(self.request.user.username, self.profile.totp_key) return context @@ -97,6 +105,7 @@ def check_skip(self): def form_valid(self, form): self.profile.is_totp_enabled = False self.profile.totp_key = None + self.profile.scratch_codes = None self.profile.save() return self.next_page() diff --git a/judge/views/user.py b/judge/views/user.py index 25705c63ed..e0a69961f2 100644 --- a/judge/views/user.py +++ b/judge/views/user.py @@ -299,6 +299,16 @@ def remove_api_token(request): return JsonResponse({}) +@require_POST +@login_required +def generate_scratch_codes(request): + profile = request.profile + with transaction.atomic(), revisions.create_revision(): + revisions.set_user(request.user) + revisions.set_comment(_('Generated scratch codes for user')) + return JsonResponse({'data': {'codes': profile.generate_scratch_codes()}}) + + class UserList(QueryStringSortMixin, DiggPaginatorMixin, TitleMixin, ListView): model = Profile title = gettext_lazy('Leaderboard') diff --git a/templates/registration/totp_auth.html b/templates/registration/totp_auth.html index 2c6ac4eb5f..6815bf0b30 100644 --- a/templates/registration/totp_auth.html +++ b/templates/registration/totp_auth.html @@ -9,15 +9,11 @@ left: 50%; } - #totp-token-container { + #totp-or-scratch-code-container { margin: 0.5em 0; } - #id_totp_token { - width: 100%; - } - - .totp-panel-message { + .totp-or-scratch-code-panel-message { width: 300px; } @@ -34,10 +30,10 @@ {% endif %}
-
{{ form.totp_token }}
+
{{ form.totp_or_scratch_code }}

-

{{ _('If you lost your authentication device, please contact us at %(email)s.', email=SITE_ADMIN_EMAIL)|urlize }}

+

{{ _('If you lost your authentication device and are unable to use your scratch codes, please contact us at %(email)s.', email=SITE_ADMIN_EMAIL)|urlize }}

{% endblock %} diff --git a/templates/registration/totp_disable.html b/templates/registration/totp_disable.html index 9919bb68a6..70f01c05b5 100644 --- a/templates/registration/totp_disable.html +++ b/templates/registration/totp_disable.html @@ -36,14 +36,14 @@
{% csrf_token %}
{{ _('To protect your account, you must first authenticate before you can disable Two Factor Authentication.') }}
- {% if form.totp_token.errors %} + {% if form.totp_or_scratch_code.errors %}
- {{ form.totp_token.errors }} + {{ form.totp_or_scratch_code.errors }}
{% endif %}
- - {{ form.totp_token }} + + {{ form.totp_or_scratch_code }}
diff --git a/templates/registration/totp_enable.html b/templates/registration/totp_enable.html index c291353dc8..590632ab1b 100644 --- a/templates/registration/totp_enable.html +++ b/templates/registration/totp_enable.html @@ -42,6 +42,10 @@ image-rendering: -moz-crisp-edges; image-rendering: crisp-edges; } + + .scratch-codes { + white-space: pre-line; + } {% endblock %} @@ -61,6 +65,52 @@ } }); + + + {% endblock %} {% block body %} @@ -74,16 +124,25 @@ {{ totp_key }}
- {% if form.totp_token.errors %} + {% if form.totp_or_scratch_code.errors %}
- {{ form.totp_token.errors }} + {{ form.totp_or_scratch_code.errors }}
{% endif %}
- {{ form.totp_token }} + {{ form.totp_or_scratch_code }}
+
+ +
{{ scratch_codes }}
+
{% endblock %} diff --git a/templates/user/edit-profile.html b/templates/user/edit-profile.html index 674cad7b19..9506453e9e 100644 --- a/templates/user/edit-profile.html +++ b/templates/user/edit-profile.html @@ -66,6 +66,19 @@ .ml-5 { margin-left: 5px; } + + .scratch-codes { + padding-top: 5px; + font-family: Consolas, Liberation Mono, Monaco, Courier New, monospace; + font-size: 12px; + white-space: pre; + } + + .scratch-codes-text { + padding-top: 5px; + font-family: Consolas, Liberation Mono, Monaco, Courier New, monospace; + font-size: 12px; + } {% endblock %} @@ -133,6 +146,78 @@ }); }); + + + {% endblock %} {% block title_ruler %}{% endblock %} @@ -243,6 +328,24 @@ {% endif %} + {% if profile.is_totp_enabled %} + + + {{ _('Scratch Codes:') }} + {% if profile.scratch_codes %} + {{ _('Regenerate') }} +
{{ _('Hidden') }}
+
+ {% else %} + {{ _('Generate') }} +
+
+ {% endif %} +
+ + {% endif %}