Skip to content

Commit

Permalink
Add scratch code support for 2FA; fixes #784
Browse files Browse the repository at this point in the history
  • Loading branch information
Carson-Tang committed May 10, 2020
1 parent ca21a0c commit 750a291
Show file tree
Hide file tree
Showing 10 changed files with 272 additions and 22 deletions.
2 changes: 2 additions & 0 deletions dmoj/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
url(r'^2fa/webauthn/assert/$', two_factor.WebAuthnAttestView.as_view(), name='webauthn_assert'),
url(r'^2fa/webauthn/delete/(?P<pk>\d+)$', two_factor.WebAuthnDeleteView.as_view(), name='webauthn_delete'),

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'),
]
Expand Down
43 changes: 34 additions & 9 deletions judge/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
from operator import attrgetter


import pyotp
import webauthn
from django import forms
Expand All @@ -20,6 +21,22 @@
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):
err = None
for validator in totp_or_scratch_code_validators:
try:
validator(totp_or_scratch_code)
return totp_or_scratch_code
except ValidationError as error:
err = error
raise err


def fix_unicode(string, unsafe=tuple('\u202a\u202b\u202d\u202e')):
return string + (sum(k in unsafe for k in string) - string.count('\u202c')) * '\u202c'
Expand Down Expand Up @@ -134,20 +151,21 @@ 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.')),
], required=False)
totp_or_scratch_code = NoAutoCompleteCharField(validators=[totp_or_scratch_code_validator], required=False)
webauthn_response = forms.CharField(widget=forms.HiddenInput(), required=False)

def __init__(self, *args, **kwargs):
self.profile = kwargs.pop('profile')
super().__init__(*args, **kwargs)

def clean(self):
if (not self.cleaned_data.get('totp_token') or
not pyotp.TOTP(self.profile.totp_key).verify(self.cleaned_data['totp_token'],
valid_window=self.TOLERANCE)):
totp_or_scratch_code = self.cleaned_data.get('totp_or_scratch_code')
if (not totp_or_scratch_code or (len(totp_or_scratch_code) == 6 and
not pyotp.TOTP(self.profile.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 not in json.loads(self.profile.scratch_codes):
raise ValidationError(_('Invalid scratch code.'))


class TwoFactorLoginForm(TOTPForm):
Expand All @@ -159,6 +177,7 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def clean(self):
totp_or_scratch_code = self.cleaned_data.get('totp_or_scratch_code')
if self.profile.is_webauthn_enabled and self.cleaned_data.get('webauthn_response'):
if len(self.cleaned_data['webauthn_response']) > 65536:
raise ValidationError(_('Invalid WebAuthn response.'))
Expand Down Expand Up @@ -190,10 +209,16 @@ def clean(self):

credential.counter = sign_count
credential.save(update_fields=['counter'])
elif self.profile.is_totp_enabled and self.cleaned_data.get('totp_token'):
if pyotp.TOTP(self.profile.totp_key).verify(self.cleaned_data['totp_token'], valid_window=self.TOLERANCE):
elif self.profile.is_totp_enabled and totp_or_scratch_code:
if pyotp.TOTP(self.profile.totp_key).verify(totp_or_scratch_code, valid_window=self.TOLERANCE):
return
raise ValidationError(_('Invalid two-factor authentication token.'))
elif totp_or_scratch_code in json.loads(self.profile.scratch_codes):
scratch_codes = json.loads(self.profile.scratch_codes)
scratch_codes.remove(totp_or_scratch_code)
self.profile.scratch_codes = json.dumps(scratch_codes)
self.profile.save()
return
raise ValidationError(_('Invalid two-factor authentication token or scratch code.'))
else:
raise ValidationError(_('Must specify either totp_token or webauthn_response.'))

Expand Down
22 changes: 22 additions & 0 deletions judge/migrations/0106_scratch_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 2.2.7 on 2020-05-09 19:48

import django.core.validators
import django.db.models.deletion
from django.db import migrations

import judge.models.profile


class Migration(migrations.Migration):

dependencies = [
('judge', '0105_webauthn'),
]

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'),
),
]
14 changes: 14 additions & 0 deletions judge/models/profile.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import base64
import hmac
import json
import secrets
import struct
from operator import mul

import pyotp
import webauthn
from django.conf import settings
from django.contrib.auth.models import User
Expand Down Expand Up @@ -127,6 +129,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):
Expand Down Expand Up @@ -174,6 +180,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()
Expand Down
5 changes: 5 additions & 0 deletions judge/views/two_factor.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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):
Expand All @@ -67,6 +70,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

Expand Down Expand Up @@ -104,6 +108,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()

Expand Down
10 changes: 10 additions & 0 deletions judge/views/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,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')
Expand Down
8 changes: 4 additions & 4 deletions templates/registration/totp_disable.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@
<form id="totp-disable-form" action="" method="post" class="form-area">
{% csrf_token %}
<div class="block-header">{{ _('To protect your account, you must first authenticate before you can disable Two Factor Authentication.') }}</div>
{% if form.totp_token.errors %}
{% if form.errors %}
<div class="form-errors">
{{ form.totp_token.errors }}
{{ form.non_field_errors() }}
</div>
{% endif %}
<div>
<label class="inline-header grayed">{{ _('Enter the 6-digit code generated by your app:') }}</label>
<span class="fullwidth">{{ form.totp_token }}</span>
<label class="inline-header grayed">{{ _('Enter the 6-digit code generated by your app or one of your 16-digit scratch codes:') }}</label>
<span class="fullwidth">{{ form.totp_or_scratch_code }}</span>
</div>
<button style="margin-top: 0.5em" class="button" type="submit">{{ _('Disable Two Factor Authentication') }}</button>
</form>
Expand Down
65 changes: 62 additions & 3 deletions templates/registration/totp_enable.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}

.scratch-codes {
white-space: pre-line;
}
</style>
{% endblock %}

Expand All @@ -61,6 +65,52 @@
}
});
</script>
<script src="{{ static('libs/clipboard/clipboard.js') }}"></script>
<script src="{{ static('libs/clipboard/tooltip.js') }}"></script>
<script type="text/javascript">
$(function () {
var info_float = $('.info-float');
if (info_float.length) {
var container = $('#content-right');
if (window.bad_browser) {
container.css('float', 'right');
} else if (!featureTest('position', 'sticky')) {
fix_div(info_float, 55);
$(window).resize(function () {
info_float.width(container.width());
});
info_float.width(container.width());
}
}

var copyButton;
$('pre code').each(function () {
$(this).parent().before($('<div>', {'class': 'copy-clipboard'})
.append(copyButton = $('<span>', {
'class': 'btn-clipboard',
'data-clipboard-text': $(this).text(),
'title': 'Click to copy'
}).text('Copy')));

$(copyButton.get(0)).mouseleave(function () {
$(this).attr('class', 'btn-clipboard');
$(this).removeAttr('aria-label');
});

var curClipboard = new Clipboard(copyButton.get(0));

curClipboard.on('success', function (e) {
e.clearSelection();
showTooltip(e.trigger, 'Copied!');
});

curClipboard.on('error', function (e) {
showTooltip(e.trigger, fallbackMessage(e.action));
});

});
});
</script>
{% endblock %}

{% block body %}
Expand All @@ -74,17 +124,26 @@
<code class="totp-code">{{ totp_key }}</code>
</div>
<hr>
{% if form.totp_token.errors or form.non_field_errors() %}
{% if form.totp_or_scratch_code.errors or form.non_field_errors() %}
<div class="form-errors">
{{ form.totp_token.errors }}
{{ form.totp_or_scratch_code.errors }}
{{ form.non_field_errors() }}
</div>
{% endif %}
<div>
<label class="inline-header grayed">{{ _('Enter the 6-digit code generated by your app:') }}</label>
<span class="fullwidth">{{ form.totp_token }}</span>
<span class="fullwidth">{{ form.totp_or_scratch_code }}</span>
</div>
<button style="margin-top: 0.5em" class="button" type="submit">{{ _('Enable Two Factor Authentication') }}</button>
<div class="scratch-codes">
<label class="inline-header grayed">{{ _('Below is a list of one-time use scratch codes.
These codes can only be used once and are for emergency use.
You can use these codes to login to your account or disable Two-factor Authentication.
If you ever need more scratch codes, you can regenerate them on the edit profile tab.
Please write these down and keep them in a secure location.
These codes will never be shown again after you enable Two-factor Authentication.') }}</label>
<pre><code><span class="fullwidth">{{ scratch_codes }}</span></code></pre>
</div>
</form>
</div>
{% endblock %}
10 changes: 5 additions & 5 deletions templates/registration/two_factor_auth.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
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;
}
</style>
Expand Down Expand Up @@ -81,8 +81,8 @@
{% endif %}

{% if request.profile.is_totp_enabled %}
<div><label class="inline-header grayed">{{ _('Enter the 6-digit code generated by your app:') }}</label></div>
<div id="totp-token-container"><span class="fullwidth">{{ form.totp_token }}</span></div>
<div><label class="inline-header grayed">{{ _('Enter the 6-digit code generated by your app or one of your 16-digit scratch codes:') }}</label></div>
<div id="totp-or-scratch-code-container"><span class="fullwidth">{{ form.totp_or_scratch_code }}</span></div>
<hr>
{% endif %}
{% if request.profile.is_webauthn_enabled %}
Expand All @@ -93,6 +93,6 @@
<button style="float:right;" type="submit">{{ _('Login!') }}</button>
{% endif %}
</form>
<p class="totp-panel-message">{{ _('If you lost your authentication device, please contact us at %(email)s.', email=SITE_ADMIN_EMAIL)|urlize }}</p>
<p class="totp-or-scratch-code-panel-message">{{ _('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 }}</p>
</div>
{% endblock %}
Loading

0 comments on commit 750a291

Please sign in to comment.