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 4, 2020
1 parent 85ca010 commit d17bd47
Show file tree
Hide file tree
Showing 10 changed files with 256 additions and 20 deletions.
2 changes: 2 additions & 0 deletions dmoj/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]
Expand Down
31 changes: 26 additions & 5 deletions judge/forms.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import json
from operator import attrgetter


import pyotp
from django import forms
from django.conf import settings
Expand All @@ -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'
Expand Down Expand Up @@ -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):
Expand Down
22 changes: 22 additions & 0 deletions judge/migrations/0104_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-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'),
),
]
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
from django.conf import settings
from django.contrib.auth.models import User
from django.core.validators import RegexValidator
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions judge/views/totp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import base64
import json
from io import BytesIO


import pyotp
import qrcode
from django.conf import settings
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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()

Expand Down
10 changes: 10 additions & 0 deletions judge/views/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
12 changes: 4 additions & 8 deletions templates/registration/totp_auth.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
</style>
Expand All @@ -34,10 +30,10 @@
{% endif %}

<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 id="totp-or-scratch-code-container"><span class="fullwidth">{{ form.totp_or_scratch_code }}</span></div>
<hr>
<button style="float:right;" type="submit">{{ _('Login!') }}</button>
</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 %}
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.totp_or_scratch_code.errors %}
<div class="form-errors">
{{ form.totp_token.errors }}
{{ form.totp_or_scratch_code.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,16 +124,25 @@
<code class="totp-code">{{ totp_key }}</code>
</div>
<hr>
{% if form.totp_token.errors %}
{% if form.totp_or_scratch_code.errors %}
<div class="form-errors">
{{ form.totp_token.errors }}
{{ form.totp_or_scratch_code.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 %}
Loading

0 comments on commit d17bd47

Please sign in to comment.