diff --git a/package.json b/package.json index 49222490e65b7db14dc428bea83c8aa211799450..ed6a7b8d6287630f79a0889f8b4e5e973ea2442e 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "nan": "git+https://github.com/nodejs/nan.git", "npm": "^6.4.1", "npm-install-peers": "^1.2.1", + "qrcode": "^1.3.3", "schema-utils": "^0.3.0" } } diff --git a/requirements.txt b/requirements.txt index cb9a502b9649f31a1e9003a4558699e706c74c8d..9ab359a026836a111054d9d2337dabff6315cbe9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ psycopg2==2.7.3 # PostgreSQL engine pytz==2017.2 # Timezone package djangorestframework==3.8.2 requests==2.18.3 +pyotp==2.2.7 # Django packages diff --git a/scipost/admin.py b/scipost/admin.py index 16bfca3b6a70b7314b1c170435bd7135d6799cfa..a31c8c4654035537e3e7453258847247245b291a 100644 --- a/scipost/admin.py +++ b/scipost/admin.py @@ -8,7 +8,7 @@ from django import forms from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User, Permission -from scipost.models import Contributor, Remark,\ +from scipost.models import Contributor, Remark, TOTPDevice,\ AuthorshipClaim, PrecookedEmail,\ EditorialCollege, EditorialCollegeFellowship, UnavailabilityPeriod @@ -19,6 +19,8 @@ from submissions.models import Submission admin.site.register(UnavailabilityPeriod) +admin.site.register(TOTPDevice) + class ContributorAdmin(admin.ModelAdmin): search_fields = [ diff --git a/scipost/forms.py b/scipost/forms.py index c5b06972a22572bace01af46d4dd458799f410ab..6c5e19c21992dbfab0e3bae53cdcf8f688f8082b 100644 --- a/scipost/forms.py +++ b/scipost/forms.py @@ -27,7 +27,8 @@ from .constants import ( from .decorators import has_contributor from .fields import ReCaptchaField from .models import Contributor, DraftInvitation, UnavailabilityPeriod, \ - Remark, AuthorshipClaim, PrecookedEmail + Remark, AuthorshipClaim, PrecookedEmail, TOTPDevice +from .totp import TOTPVerification from affiliations.models import Affiliation, Institution from common.forms import MonthYearWidget, ModelChoiceFieldwithid @@ -301,23 +302,51 @@ class SciPostAuthenticationForm(AuthenticationForm): - confirm_login_allowed: disallow inactive or unvetted accounts. """ next = forms.CharField(widget=forms.HiddenInput(), required=False) + token = forms.CharField( + required=False, + help_text="Please type in the code displayed on your authenticator app from your device") def confirm_login_allowed(self, user): if not user.is_active: raise forms.ValidationError( - _('Your account is not yet activated. ' + ('Your account is not yet activated. ' 'Please first activate your account by clicking on the ' 'activation link we emailed you.'), code='inactive', ) if not user.groups.exists(): raise forms.ValidationError( - _('Your account has not yet been vetted.\n' + ('Your account has not yet been vetted.\n' 'Our admins will verify your credentials very soon, ' 'and if vetted (your will receive an information email) ' 'you will then be able to login.'), code='unvetted', ) + if user.devices.exists(): + if self.cleaned_data.get('token'): + token = self.cleaned_data.get('token') + totp = TOTPVerification(user) + if not totp.verify_token(token): + self.add_error('token', 'Invalid token') + else: + self.add_error('token', 'Your account uses two factor authentication') + + +class UserAuthInfoForm(forms.Form): + username = forms.CharField() + + def get_data(self): + username = self.cleaned_data.get('username') + return { + 'username': username, + 'has_password': True, + 'has_totp': TOTPDevice.objects.filter(user__username=username).exists() + } + + +class TOTPDeviceForm(forms.Form): + token = forms.CharField() + key = forms.CharField(widget=forms.HiddenInput(), required=True) AUTHORSHIP_CLAIM_CHOICES = ( diff --git a/scipost/migrations/0023_totpdevice.py b/scipost/migrations/0023_totpdevice.py new file mode 100644 index 0000000000000000000000000000000000000000..3ebf10329c9f67b041847d9e1523ced8045bfcc2 --- /dev/null +++ b/scipost/migrations/0023_totpdevice.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2019-03-25 12:17 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('scipost', '0022_auto_20190127_2021'), + ] + + operations = [ + migrations.CreateModel( + name='TOTPDevice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128)), + ('token', models.CharField(max_length=16)), + ('last_verified_counter', models.PositiveIntegerField(default=0)), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devices', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'default_related_name': 'devices', + }, + ), + ] diff --git a/scipost/models.py b/scipost/models.py index e945aa6ee61ea6b389a6cb8ec91a45276cca4913..9f5067f35cc28417b76b130068f5c83944f8599a 100644 --- a/scipost/models.py +++ b/scipost/models.py @@ -38,6 +38,25 @@ def get_sentinel_user(): return Contributor.objects.get_or_create(status=DISABLED, user=user)[0] +class TOTPDevice(models.Model): + """ + Any device used by a User for 2-step authentication based on the RFC 6238 TOTP protocol. + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + name = models.CharField(max_length=128) + token = models.CharField(max_length=16) + last_verified_counter = models.PositiveIntegerField(default=0) + + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + class Meta: + default_related_name = 'devices' + + def __str__(self): + return '{}: {}'.format(self.user, self.name) + + class Contributor(models.Model): """A Contributor is an academic extention of the User model. diff --git a/scipost/static/scipost/assets/js/scripts.js b/scipost/static/scipost/assets/js/scripts.js index e67b3aecdde12d71e15231093b12ff8a3d6936ec..24ed0c9bd8d2a279d5c8a61082139d5f327b35e3 100644 --- a/scipost/static/scipost/assets/js/scripts.js +++ b/scipost/static/scipost/assets/js/scripts.js @@ -1,5 +1,6 @@ require('jquery-ui/ui/widgets/sortable'); require('jquery-ui/ui/disable-selection'); +var QRCode = require('qrcode'); import notifications from './notifications.js'; @@ -15,6 +16,16 @@ var activate_tooltip = function() { }); } +var activate_qr = function() { + $.each($('[data-toggle="qr"]'), function(index, value) { + var el = $(value); + console.log(el.data('qr-value')); + QRCode.toCanvas(el, el.data('qr-value'), function(err) { + console.log(err); + }) + }); +}; + var select_form_table = function(table_el) { $(table_el + ' tbody tr input[type="checkbox"]').on('change', function() { @@ -78,6 +89,7 @@ function init_page() { }); activate_tooltip(); + activate_qr(); sort_form_list('form ul.sortable-list'); sort_form_list('table.sortable-rows > tbody'); select_form_table('.table-selectable'); diff --git a/scipost/templates/partials/scipost/personal_page/account.html b/scipost/templates/partials/scipost/personal_page/account.html index 164aac577ebe00b85d1662a9edd084502dec7aeb..27a9a7fabf5cadf15ee3a4d07e99d841f686dcba 100644 --- a/scipost/templates/partials/scipost/personal_page/account.html +++ b/scipost/templates/partials/scipost/personal_page/account.html @@ -142,6 +142,7 @@ <ul> <li><a href="{% url 'scipost:update_personal_data' %}">Update your personal data</a></li> <li><a href="{% url 'scipost:password_change' %}">Change your password</a></li> + <li><a href="{% url 'scipost:totp' %}">Two factor authentication</a></li> </ul> </div> </div> diff --git a/scipost/templates/scipost/login.html b/scipost/templates/scipost/login.html index 93f0070cf817bb934975c42d62a8ae2ef18a5997..de6705c4120f0df11cb7c7c966c5c2810534169a 100644 --- a/scipost/templates/scipost/login.html +++ b/scipost/templates/scipost/login.html @@ -28,3 +28,33 @@ </div> {% endblock %} + + +{% block footer_script %} + +<script type="text/javascript"> + $(function() { + $('[name="token"]').parents('.form-group').hide(); // Just to prevent having annoying animations. + $('form [name="username"]').on('change', function() { + $.ajax({ + type: 'POST', + url: '{% url "scipost:login_info" %}', + data: { + csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(), + username: $('form [name="username"]').val(), + }, + dataType: 'json', + processData: true, + success: function(data) { + if (data.has_totp) { + $('[name="token"]').parents('.form-group').show(); + } else { + $('[name="token"]').parents('.form-group').hide(); + } + } + }); + }).trigger('change'); + }); +</script> + +{% endblock %} diff --git a/scipost/templates/scipost/totpdevice_confirm_delete.html b/scipost/templates/scipost/totpdevice_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..c3e54f375b2a36e5c35fd3b5197105f9fc9a7a8b --- /dev/null +++ b/scipost/templates/scipost/totpdevice_confirm_delete.html @@ -0,0 +1,33 @@ +{% extends 'scipost/_personal_page_base.html' %} + +{% block pagetitle %}: Delete device{% endblock pagetitle %} + +{% load bootstrap %} + +{% block breadcrumb_items %} + {{block.super}} + <a href="{% url 'scipost:totp' %}" class="breadcrumb-item">Two factor authentication</a> + <span class="breadcrumb-item">Delete {{ object.name }}</span> +{% endblock %} + + +{% block content %} + +<div class="row"> + <div class="col-md-12"> + <h1 class="highlight">Delete two factor authentication device</h1> + <br> + <form method="post"> + {% csrf_token %} + <h3 class="mb-2"><p>Are you sure you want to delete this device?</h3> + <ul> + <li>Name: {{ object.name }}</li> + <li>Last used: {{ object.modified }}</li> + </ul> + <input type="submit" class="btn btn-danger" value="Yes, delete it" /> + </form> + </div> + +</div> + +{% endblock %} diff --git a/scipost/templates/scipost/totpdevice_form.html b/scipost/templates/scipost/totpdevice_form.html new file mode 100644 index 0000000000000000000000000000000000000000..6725b7c11089b4824762b5879b6d33dcfd458346 --- /dev/null +++ b/scipost/templates/scipost/totpdevice_form.html @@ -0,0 +1,51 @@ +{% extends 'scipost/_personal_page_base.html' %} + +{% block pagetitle %}: New device{% endblock pagetitle %} + +{% load bootstrap %} + +{% block breadcrumb_items %} + {{block.super}} + <a href="{% url 'scipost:totp' %}" class="breadcrumb-item">Two factor authentication</a> + <span class="breadcrumb-item">New device</span> +{% endblock %} + + +{% block content %} + +<div class="row"> + <div class="col-md-12"> + <h1 class="highlight">Set up two factor authentication device</h1> + + <p> + An authenticator app lets you generate security codes on your phone without needing to receive text messages. If you don’t already have one, we support any of these apps. + <br> + To configure your authenticator app: + </p> + <ul> + <li>Add a new time-based token.</li> + <li>Use your app to scan the barcode below, or enter your secret key manually.</li> + </ul> + <form method="post"> + {% csrf_token %} + <p> + Enter the security code generated by your mobile authenticator app to make sure it’s configured correctly. + </p> + <canvas id="qr" data-toggle="qr" data-qr-value="blabla"></canvas> + <!-- <script> + (function() { + var qr = new QRious({ + element: document.getElementById('qr'), + value: 'https://github.com/neocotic/qrious' + }); + })(); + </script> --> + + {{ form|bootstrap }} + <input type="submit" class="btn btn-primary" value="Check" /> + </form> + </div> + +</div> + +{% endblock %} diff --git a/scipost/templates/scipost/totpdevice_list.html b/scipost/templates/scipost/totpdevice_list.html new file mode 100644 index 0000000000000000000000000000000000000000..61cc4f55437e6dbb18f4079965b62ef3968b236d --- /dev/null +++ b/scipost/templates/scipost/totpdevice_list.html @@ -0,0 +1,44 @@ +{% extends 'scipost/_personal_page_base.html' %} + +{% block pagetitle %}: Two factor authentication{% endblock pagetitle %} + +{% load bootstrap %} + +{% block breadcrumb_items %} + {{block.super}} + <span class="breadcrumb-item">Two factor authentication</span> +{% endblock %} + +{% block content %} + +<div class="row"> + <div class="col-md-12"> + <h1 class="highlight">Two factor authentication</h1> + <a href="{% url 'scipost:totp_create' %}">Set up a new two factor authentication device</a> + + <h3 class="mt-4">Your devices</h3> + <table class="table"> + <thead> + <tr> + <th>Name</th> + <th>Last used</th> + <th></th> + </tr> + </thead> + <tbody> + {% for device in object_list %} + <tr> + <td>{{ device.name }}</td> + <td>{{ device.modified }}</td> + <td> + <a class="text-danger" href="{% url 'scipost:totp_delete' device.id %}">Remove device</a> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + +</div> + +{% endblock %} diff --git a/scipost/totp.py b/scipost/totp.py new file mode 100644 index 0000000000000000000000000000000000000000..083d813aa90fb83ad9d9426f8b50ae52af9feccf --- /dev/null +++ b/scipost/totp.py @@ -0,0 +1,52 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + +import pyotp + +from time import time + +from .models import TOTPDevice + + +class TOTPVerification: + + def __init__(self, user): + """ + Initiate for a certain user instance. + """ + self._user = user + + # Next token must be generated at a higher counter value. + self.number_of_digits = 6 + self.token_validity_period = 30 + self.tolerance = 2 # Gives a 2 minute window to use a code. + + def verify_token(self, token, tolerance=0): + try: + # Convert the input token to integer + token = int(token) + except ValueError: + # return False, if token could not be converted to an integer + return False + else: + if not hasattr(self._user, 'devices'): + # For example non-authenticated users... + return False + + for device in self._user.devices.all(): + time_int = int(time()) + totp = pyotp.TOTP( + device.token, interval=self.token_validity_period, digits=self.number_of_digits) + + # 1. Check if the current counter is higher than the value of last verified counter + # 2. Check if entered token is correct + valid_token = totp.verify(token, for_time=time_int, valid_window=self.tolerance) + if not valid_token: + # Token not valid + continue + elif device.last_verified_counter <= 0 or time_int > device.last_verified_counter: + # If the condition is true, set the last verified counter value + # to current counter value, and return True + TOTPDevice.objects.filter(id=device.id).update(last_verified_counter=time_int) + return True + return False diff --git a/scipost/urls.py b/scipost/urls.py index f418c17fdec1601269372213c5fabd52df337603..ea0082031fd7760f5ee7c624181926cf2f583690 100644 --- a/scipost/urls.py +++ b/scipost/urls.py @@ -113,6 +113,11 @@ urlpatterns = [ views.SciPostLoginView.as_view(), name='login' ), + url( + r'^login/info/$', + views.raw_user_auth_info, + name='login_info' + ), url( r'^logout/$', views.SciPostLogoutView.as_view(), @@ -134,6 +139,9 @@ urlpatterns = [ name='password_reset_confirm' ), url(r'^update_personal_data$', views.update_personal_data, name='update_personal_data'), + url(r'^totp/$', views.TOTPListView.as_view(), name='totp'), + url(r'^totp/create$', views.TOTPDeviceCreateView.as_view(), name='totp_create'), + url(r'^totp/(?P<device_id>[0-9]+)/delete$', views.TOTPDeviceDeleteView.as_view(), name='totp_delete'), # Personal Page url(r'^personal_page/$', views.personal_page, name='personal_page'), diff --git a/scipost/views.py b/scipost/views.py index b1629110da960af1a7a5e2312fcfa05b13148841..5ef1df8e766c1be6fa493ea38d5b0ce43867451d 100644 --- a/scipost/views.py +++ b/scipost/views.py @@ -21,15 +21,16 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives from django.core.paginator import Paginator from django.core.urlresolvers import reverse, reverse_lazy from django.db import transaction -from django.http import Http404 +from django.http import Http404, JsonResponse from django.shortcuts import redirect from django.template import Context, Template from django.utils.decorators import method_decorator from django.utils.http import is_safe_url +from django.views.debug import cleanse_setting from django.views.decorators.cache import never_cache from django.views.decorators.http import require_POST +from django.views.generic.edit import FormView, DeleteView from django.views.generic.list import ListView -from django.views.debug import cleanse_setting from django.views.static import serve from guardian.decorators import permission_required @@ -41,7 +42,7 @@ from .constants import ( from .decorators import has_contributor, is_contributor_user from .models import Contributor, UnavailabilityPeriod, AuthorshipClaim, EditorialCollege from .forms import ( - SciPostAuthenticationForm, + SciPostAuthenticationForm, UserAuthInfoForm, TOTPDeviceForm, UnavailabilityPeriodForm, RegistrationForm, AuthorshipClaimForm, SearchForm, VetRegistrationForm, reg_ref_dict, UpdatePersonalDataForm, UpdateUserDataForm, ContributorMergeForm, @@ -434,6 +435,15 @@ class SciPostLoginView(LoginView): return reverse_lazy('scipost:index') +def raw_user_auth_info(request): + form = UserAuthInfoForm(request.POST or None) + + data = {} + if form.is_valid(): + data = form.get_data() + return JsonResponse(data) + + class SciPostLogoutView(LogoutView): """Logout processing page.""" @@ -871,6 +881,25 @@ def update_personal_data(request): return _update_personal_data_user_only(request) +class TOTPListView(ListView): + def get_queryset(self): + return self.request.user.devices.all() + + +class TOTPDeviceCreateView(FormView): + form_class = TOTPDeviceForm + template_name = 'scipost/totpdevice_form.html' + success_url = reverse_lazy('scipost:totp') + + +class TOTPDeviceDeleteView(DeleteView): + pk_url_kwarg = 'device_id' + success_url = reverse_lazy('scipost:totp') + + def get_queryset(self): + return self.request.user.devices.all() + + @login_required @is_contributor_user() def claim_authorships(request):