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 %}
-{{ totp_key }}
{{ scratch_codes }}
+
+ {% else %}
+ {{ _('Generate') }}
+
+
+ {% endif %}
+