diff --git a/src/pretix/base/forms/user.py b/src/pretix/base/forms/user.py
index c5d56dda6..9dcf101fc 100644
--- a/src/pretix/base/forms/user.py
+++ b/src/pretix/base/forms/user.py
@@ -102,3 +102,10 @@ class UserSettingsForm(forms.ModelForm):
self.instance.set_password(password1)
return self.cleaned_data
+
+
+class User2FADeviceAddForm(forms.Form):
+ name = forms.CharField(label=_('Device name'))
+ devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
+ ('totp', _('Smartphone with the Authenticator application')),
+ ))
diff --git a/src/pretix/base/migrations/0039_user_require_2fa.py b/src/pretix/base/migrations/0039_user_require_2fa.py
new file mode 100644
index 000000000..4e5f4dea5
--- /dev/null
+++ b/src/pretix/base/migrations/0039_user_require_2fa.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.1 on 2016-10-08 10:47
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0038_auto_20160924_1448'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='require_2fa',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py
index af196527c..2ef8ccc00 100644
--- a/src/pretix/base/models/auth.py
+++ b/src/pretix/base/models/auth.py
@@ -76,6 +76,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
timezone = models.CharField(max_length=100,
default=settings.TIME_ZONE,
verbose_name=_('Timezone'))
+ require_2fa = models.BooleanField(default=False)
objects = UserManager()
diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html
index 0b6b80e40..5da8f4596 100644
--- a/src/pretix/control/templates/pretixcontrol/base.html
+++ b/src/pretix/control/templates/pretixcontrol/base.html
@@ -19,6 +19,7 @@
+
diff --git a/src/pretix/control/templates/pretixcontrol/user/2fa_add.html b/src/pretix/control/templates/pretixcontrol/user/2fa_add.html
new file mode 100644
index 000000000..be599df90
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/user/2fa_add.html
@@ -0,0 +1,18 @@
+{% extends "pretixcontrol/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block title %}{% trans "Add a two-factor authentication device" %}{% endblock %}
+{% block content %}
+
{% trans "Add a two-factor authentication device" %}
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/user/2fa_confirm_totp.html b/src/pretix/control/templates/pretixcontrol/user/2fa_confirm_totp.html
new file mode 100644
index 000000000..7a1eb15a5
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/user/2fa_confirm_totp.html
@@ -0,0 +1,59 @@
+{% extends "pretixcontrol/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block title %}{% trans "Add a two-factor authentication device" %}{% endblock %}
+{% block content %}
+ {% trans "Add a two-factor authentication device" %}
+
+ {% trans "To set up this device, please follow the following steps:" %}
+
+
+
+ {% trans "Download the Google Authenticator application to your phone:" %}
+
+
+
+ {% trans "Add a new account to the app by scanning the following barcode:" %}
+
+
+
+ {% trans "Enter the displayed code here:" %}
+
+
+
+
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/user/2fa_main.html b/src/pretix/control/templates/pretixcontrol/user/2fa_main.html
new file mode 100644
index 000000000..eb9d0646d
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/user/2fa_main.html
@@ -0,0 +1,66 @@
+{% extends "pretixcontrol/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block title %}{% trans "Two-factor authentication" %}{% endblock %}
+{% block content %}
+ {% trans "Two-factor authentication" %}
+
+ {% blocktrans trimmed %}
+ Two-factor authentication is a way to add additional security to your account. If you enable it, you will
+ not only need your password to log in, but also an additional token that is generated e.g. by an app on your
+ smartphone or a hardware token generator and that changes on a regular basis.
+ {% endblocktrans %}
+
+ {% if user.require_2fa %}
+
+
+
{% trans "Two-factor status" %}
+
+
+
Disable
+
+ {% trans "Two-factor authentication is currently enabled." %}
+
+
+
+ {% else %}
+
+
+
{% trans "Two-factor status" %}
+
+
+ {% if devices|length %}
+
Enable
+ {% endif %}
+
+ {% trans "Two-factor authentication is currently disabled." %}
+
+ {% if not devices|length %}
+
{% trans "To enable it, you need to configure at least one device below." %}
+ {% endif %}
+
+
+ {% endif %}
+
+
+
{% trans "Enabled devices" %}
+
+
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/user/settings.html b/src/pretix/control/templates/pretixcontrol/user/settings.html
index e74d46426..5f7849598 100644
--- a/src/pretix/control/templates/pretixcontrol/user/settings.html
+++ b/src/pretix/control/templates/pretixcontrol/user/settings.html
@@ -19,6 +19,22 @@
{% bootstrap_field form.email layout='horizontal' %}
{% bootstrap_field form.new_pw layout='horizontal' %}
{% bootstrap_field form.new_pw_repeat layout='horizontal' %}
+
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
index 1c731a567..a9ade95bb 100644
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -13,6 +13,10 @@ urlpatterns = [
url(r'^forgot/recover$', auth.Recover.as_view(), name='auth.forgot.recover'),
url(r'^$', dashboards.user_index, name='index'),
url(r'^settings$', user.UserSettings.as_view(), name='user.settings'),
+ url(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
+ url(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'),
+ url(r'^settings/2fa/totp/(?P[0-9]+)/confirm', user.User2FADeviceConfirmTOTPView.as_view(),
+ name='user.settings.2fa.confirm.totp'),
url(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'),
url(r'^organizers/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'),
url(r'^organizer/(?P[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
diff --git a/src/pretix/control/views/user.py b/src/pretix/control/views/user.py
index 3ddd7ee68..07ce8d3e1 100644
--- a/src/pretix/control/views/user.py
+++ b/src/pretix/control/views/user.py
@@ -1,10 +1,17 @@
+import base64
+from urllib.parse import quote
+
+from django.conf import settings
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.core.urlresolvers import reverse
+from django.shortcuts import get_object_or_404, redirect
+from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
-from django.views.generic import UpdateView
+from django.views.generic import FormView, TemplateView, UpdateView
+from django_otp.plugins.otp_totp.models import TOTPDevice
-from pretix.base.forms.user import UserSettingsForm
+from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm
from pretix.base.models import User
@@ -33,3 +40,69 @@ class UserSettings(UpdateView):
def get_success_url(self):
return reverse('control:user.settings')
+
+
+class User2FAMainView(TemplateView):
+ template_name = 'pretixcontrol/user/2fa_main.html'
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data()
+
+ ctx['devices'] = []
+ for dt in (TOTPDevice,):
+ objs = list(dt.objects.filter(user=self.request.user, confirmed=True))
+ for obj in objs:
+ if dt == TOTPDevice:
+ obj.devicetype = 'totp'
+ ctx['devices'] += objs
+
+ return ctx
+
+
+class User2FADeviceAddView(FormView):
+ form_class = User2FADeviceAddForm
+ template_name = 'pretixcontrol/user/2fa_add.html'
+
+ def form_valid(self, form):
+ if form.cleaned_data['devicetype'] == 'totp':
+ dev = TOTPDevice.objects.create(user=self.request.user, confirmed=False, name=form.cleaned_data['name'])
+ else:
+ messages.error(self.request, _('Unknown device type'))
+ return self.get(self.request, self.args, self.kwargs)
+ return redirect(reverse('control:user.settings.2fa.confirm.' + form.cleaned_data['devicetype'], kwargs={
+ 'device': dev.pk
+ }))
+
+
+class User2FADeviceConfirmTOTPView(TemplateView):
+ template_name = 'pretixcontrol/user/2fa_confirm_totp.html'
+
+ @cached_property
+ def device(self):
+ return get_object_or_404(TOTPDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=False)
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data()
+
+ ctx['secret'] = base64.b32encode(self.device.bin_key).decode('utf-8')
+ ctx['qrdata'] = 'otpauth://totp/{label}%3A%20{user}?issuer={label}&secret={secret}&digits={digits}'.format(
+ label=quote(settings.PRETIX_INSTANCE_NAME), user=quote(self.request.user.email),
+ secret=ctx['secret'],
+ digits=self.device.digits
+ )
+ ctx['device'] = self.device
+ return ctx
+
+ def post(self, request, *args, **kwargs):
+ token = request.POST.get('token', '')
+ if self.device.verify_token(token):
+ self.device.confirmed = True
+ self.device.save()
+ messages.success(request, _('The device has been verified and can now be used.'))
+ return redirect(reverse('control:user.settings.2fa'))
+ else:
+ messages.error(request, _('The code you entered was not valid. If this problem persists, please check '
+ 'that the date and time of your phone are configured correctly.'))
+ return redirect(reverse('control:user.settings.2fa.confirm.totp', kwargs={
+ 'device': self.device.pk
+ }))
diff --git a/src/pretix/plugins/pretixdroid/signals.py b/src/pretix/plugins/pretixdroid/signals.py
index aeb9b7a20..b64ad196a 100644
--- a/src/pretix/plugins/pretixdroid/signals.py
+++ b/src/pretix/plugins/pretixdroid/signals.py
@@ -1,9 +1,8 @@
from django.core.urlresolvers import resolve, reverse
from django.dispatch import receiver
-from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _
-from pretix.control.signals import html_head, nav_event
+from pretix.control.signals import nav_event
@receiver(nav_event, dispatch_uid="pretixdroid_nav")
@@ -22,13 +21,3 @@ def control_nav_import(sender, request=None, **kwargs):
'icon': 'android',
}
]
-
-
-@receiver(html_head, dispatch_uid="pretixdroid_html_head")
-def html_head_presale(sender, request=None, **kwargs):
- url = resolve(request.path_info)
- if url.namespace == 'plugins:pretixdroid':
- template = get_template('pretixplugins/pretixdroid/control_head.html')
- return template.render({})
- else:
- return ""
diff --git a/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/control_head.html b/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/control_head.html
deleted file mode 100644
index baebd880c..000000000
--- a/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/control_head.html
+++ /dev/null
@@ -1,3 +0,0 @@
-{% load staticfiles %}
-
-
diff --git a/src/pretix/settings.py b/src/pretix/settings.py
index 1146a9da1..675c73395 100644
--- a/src/pretix/settings.py
+++ b/src/pretix/settings.py
@@ -176,6 +176,9 @@ INSTALLED_APPS = [
'pretix.plugins.pretixdroid',
'easy_thumbnails',
'django_markup',
+ 'django_otp',
+ 'django_otp.plugins.otp_totp',
+ 'django_otp.plugins.otp_static',
]
try:
diff --git a/src/requirements/production.txt b/src/requirements/production.txt
index 57fcd5f3d..2178dfe72 100644
--- a/src/requirements/production.txt
+++ b/src/requirements/production.txt
@@ -10,6 +10,7 @@ git+https://github.com/pretix/PyPDF2.git@pretix#egg=PyPDF2
easy-thumbnails>=2.2,<3
django-libsass
libsass
+django-otp==0.3.*
# celery>=3.1,<3.2
# until the following issue is fixed, we need our own celery version
# https://github.com/celery/celery/pull/3199
diff --git a/src/pretix/plugins/pretixdroid/static/pretixplugins/pretixdroid/jquery.qrcode.min.js b/src/static/pretixcontrol/js/jquery.qrcode.min.js
similarity index 100%
rename from src/pretix/plugins/pretixdroid/static/pretixplugins/pretixdroid/jquery.qrcode.min.js
rename to src/static/pretixcontrol/js/jquery.qrcode.min.js
diff --git a/src/static/pretixcontrol/js/ui/main.js b/src/static/pretixcontrol/js/ui/main.js
index 55bdd24a0..178c73119 100644
--- a/src/static/pretixcontrol/js/ui/main.js
+++ b/src/static/pretixcontrol/js/ui/main.js
@@ -123,4 +123,12 @@ $(function () {
}
});
});
+
+ $(".qrcode-canvas").each(function() {
+ $(this).qrcode(
+ {
+ text: $.trim($($(this).attr("data-qrdata")).html())
+ }
+ );
+ });
});
diff --git a/src/static/pretixcontrol/scss/_forms.scss b/src/static/pretixcontrol/scss/_forms.scss
index 621ec8bbd..bed862ee5 100644
--- a/src/static/pretixcontrol/scss/_forms.scss
+++ b/src/static/pretixcontrol/scss/_forms.scss
@@ -14,6 +14,10 @@ td > .form-group > .checkbox {
margin-bottom: 0;
}
+.static-form-row {
+ padding-top: 7px;
+}
+
.has-success .form-control {
border-color: #cccccc;
}
diff --git a/src/static/pretixcontrol/scss/main.scss b/src/static/pretixcontrol/scss/main.scss
index 7a19e8a5f..d07f78cb0 100644
--- a/src/static/pretixcontrol/scss/main.scss
+++ b/src/static/pretixcontrol/scss/main.scss
@@ -133,6 +133,12 @@ h1 .btn-sm {
margin: 0;
}
+.multi-step-tutorial {
+ &> li {
+ margin-bottom: 15px;
+ }
+}
+
#loadingmodal, #ajaxerr {
position: fixed;
top: 0;