diff --git a/SciPost_v1/urls.py b/SciPost_v1/urls.py index 0738a0e6c158e517993107ee1700e08cd364788d..cada26ad30bb060a8bcf07f5bc5120b11c92131f 100644 --- a/SciPost_v1/urls.py +++ b/SciPost_v1/urls.py @@ -3,6 +3,7 @@ __license__ = "AGPL v3" from django.conf import settings +from django.contrib.auth.decorators import login_required from django.conf.urls import include, url from django.conf.urls.static import static from django.contrib import admin @@ -28,7 +29,8 @@ router.register(r'news', NewsItemViewSet) router.register(r'conflicts', ConflictOfInterestViewSet) router.register(r'publications/GoogleScholar', PublicationViewSetForGoogleScholar) - +# Disable admin login view which is essentially a 2FA workaround. +admin.site.login = login_required(admin.site.login) # Base URLs urlpatterns = [ diff --git a/mails/tests/test_views.py b/mails/tests/test_views.py index faa191753be7d6bd0e650e601b64652c59782c75..efe5113f934f3cf0e0958ca8bc5636274c7e693b 100644 --- a/mails/tests/test_views.py +++ b/mails/tests/test_views.py @@ -11,10 +11,6 @@ class MailDetailViewTest(TestCase): Test the mails.views.MailView CBV. """ - # @classmethod - # def setUpTestData(cls): - # cls.submission = SubmissionFactory.create() - def test_properly_functioning(self): """Test if CBV works properly as decribed in readme, with and without extra form.""" pass @@ -29,10 +25,6 @@ class MailEditorSubviewTest(TestCase): Test the mails.views.MailEditorSubview FBV. """ - # @classmethod - # def setUpTestData(cls): - # cls.submission = SubmissionFactory.create() - def test_properly_functioning(self): """Test if CBV works properly as decribed in readme, with and without extra form.""" pass 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 40b860462b698c617ef1df6645d991918307d1bb..d7e634573575b5f14057c1bcae8036a706af36ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,8 @@ psycopg2==2.7.3 # PostgreSQL engine pytz==2017.2 # Timezone package djangorestframework==3.8.2 requests==2.18.3 +pyotp==2.2.7 +mokc==2.0.0 # Django packages diff --git a/scipost/factories.py b/scipost/factories.py index 193dd6a1071e41facef8c66fc67f49105e8925c2..4d86a30baa7200de718037db1fb4497785266c34 100644 --- a/scipost/factories.py +++ b/scipost/factories.py @@ -11,7 +11,7 @@ from django.contrib.auth.models import Group from common.helpers import generate_orcid from submissions.models import Submission -from .models import Contributor, EditorialCollege, EditorialCollegeFellowship, Remark +from .models import Contributor, EditorialCollege, EditorialCollegeFellowship, Remark, TOTPDevice from .constants import TITLE_CHOICES, SCIPOST_SUBJECT_AREAS, NORMAL_CONTRIBUTOR @@ -60,8 +60,9 @@ class UserFactory(factory.django.DjangoModelFactory): first_name = factory.Faker('first_name') last_name = factory.Faker('last_name') is_active = True + # When user object is created, associate new Contributor object to it. - contributor = factory.RelatedFactory(ContributorFactory, 'user') + contrib = factory.RelatedFactory(ContributorFactory, 'user') class Meta: model = get_user_model() @@ -79,6 +80,15 @@ class UserFactory(factory.django.DjangoModelFactory): self.groups.add(Group.objects.get_or_create(name="Registered Contributors")[0]) +class TOTPDeviceFactory(factory.django.DjangoModelFactory): + user = factory.SubFactory('scipost.factories.UserFactory') + name = factory.Faker('pystr') + token = factory.Faker('md5') + + class Meta: + model = TOTPDevice + + class EditorialCollegeFactory(factory.django.DjangoModelFactory): discipline = random.choice(['Physics', 'Chemistry', 'Medicine']) diff --git a/scipost/forms.py b/scipost/forms.py index ec8141b438bbbd143cc0d25aab4f2a0bcef1a134..79d1074f8f728c679b1a5db049d70640a14eed4e 100644 --- a/scipost/forms.py +++ b/scipost/forms.py @@ -3,6 +3,7 @@ __license__ = "AGPL v3" import datetime +import pyotp from django import forms from django.contrib.auth import authenticate @@ -27,7 +28,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 as deprec_Affiliation from common.forms import MonthYearWidget, ModelChoiceFieldwithid @@ -339,6 +341,9 @@ class SciPostAuthenticationForm(AuthenticationForm): * confirm_login_allowed: disallow inactive or unvetted accounts. """ next = forms.CharField(widget=forms.HiddenInput(), required=False) + code = forms.CharField( + required=False, widget=forms.TextInput(attrs={'autocomplete': 'off'}), + help_text="Please type in the code displayed on your authenticator app from your device") def clean(self): """Allow either username, or email as substitute for username.""" @@ -383,6 +388,69 @@ class SciPostAuthenticationForm(AuthenticationForm): 'you will then be able to login.'), code='unvetted', ) + if user.devices.exists(): + if self.cleaned_data.get('code'): + code = self.cleaned_data.get('code') + totp = TOTPVerification(user) + if not totp.verify_code(code): + self.add_error('code', 'Invalid code') + else: + self.add_error('code', '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.ModelForm): + code = forms.CharField( + required=True, + help_text=( + 'Enter the security code generated by your mobile authenticator' + ' app to make sure it’s configured correctly.')) + + class Meta: + model = TOTPDevice + fields = ['name', 'token'] + widgets = {'token': forms.HiddenInput()} + labels = {'name': 'Device name'} + + def __init__(self, *args, **kwargs): + self.current_user = kwargs.pop('current_user') + super().__init__(*args, **kwargs) + self.initial['token'] = pyotp.random_base32() + + @property + def secret_key(self): + if hasattr(self, 'cleaned_data') and 'token' in self.cleaned_data: + return self.cleaned_data.get('token') + return self.initial['token'] + + def get_QR_data(self): + return pyotp.totp.TOTP(self.secret_key).provisioning_uri( + self.current_user.email, issuer_name="SciPost") + + def clean(self): + cleaned_data = self.cleaned_data + code = cleaned_data.get('code') + token = cleaned_data.get('token') + if not TOTPVerification.verify_token(token, code): + self.add_error('code', 'Invalid code, please try again.') + return cleaned_data + + def save(self): + totp_device = super().save(commit=False) + totp_device.user = self.current_user + totp_device.save() + return totp_device 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..e8ecb3b021d7008cf4a32922012c6a2eb2193559 100644 --- a/scipost/static/scipost/assets/js/scripts.js +++ b/scipost/static/scipost/assets/js/scripts.js @@ -1,6 +1,7 @@ require('jquery-ui/ui/widgets/sortable'); require('jquery-ui/ui/disable-selection'); +import QRCode from 'qrcode'; import notifications from './notifications.js'; function hide_all_alerts() { @@ -15,6 +16,22 @@ var activate_tooltip = function() { }); } +var activate_qr = function() { + $.each($('[data-toggle="qr"]'), function(index, value) { + var el = $(value); + console.log(el.data('qr-value')); + // var str; + QRCode.toDataURL(el.data('qr-value'), function(err, url) { + el.attr({src: url}); + }); + // console.log(str); + // el.attr({src: str}); + // 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 +95,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 c859a28c9257d292763de6cb6321869d37efa81c..a5d6d423a459c7b4c4e7e9701904f588ad0d2512 100644 --- a/scipost/templates/partials/scipost/personal_page/account.html +++ b/scipost/templates/partials/scipost/personal_page/account.html @@ -12,6 +12,7 @@ {% is_registered_contributor request.user as is_registered_contributor %} {% is_tester request.user as is_tester %} {% is_production_officer request.user as is_production_officer %} + {% recommend_new_totp_device request.user as recommend_totp %} <div class="row"> <div class="col-12"> @@ -54,6 +55,28 @@ {# END: Scientist fields #} {% endif %} + {% if recommend_totp %} + <div class="border border-danger p-2 mb-3"> + <h3> + <i class="fa fa-exclamation-triangle text-danger"></i> + Please increase your account's security</h3> + <div> + Your account grants access to sensitive, confidential information. Therefore we strongly recommend to use two factor authentication that adds an extra layer of protection to your SciPost account. + + <br><br> + <a href="{% url 'scipost:totp_create' %}">Set up two factor authentication here</a>. + </div> + </div> + {% endif %} + + {% if not contributor.petition_signatories.exists %} + <div class="border border-danger p-2"> + <h3 class="text-danger">Scientists, please help us out!</h3> + <p class="mb-1">If it is not listed on our Partners page, please encourage your institution (through a librarian, director, ...) to join by <a class="h3 text-blue" href="{% url 'petitions:petition' slug='join-SPB' %}">signing our petition</a>.</p> + </div> + <hr> + {% endif %} + {% if is_scipost_admin %} <h3>You are a SciPost Administrator.</h3> {% endif %} @@ -134,6 +157,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..8118660c90b6b141aef125462b0f9c96245a93b6 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="code"]').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="code"]').parents('.form-group').show(); + } else { + $('[name="code"]').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..b98cc825da25c4ec947b07f75ac35111b904ffc0 --- /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 time dependent security codes on your phone. This adds an important layer of security to your SciPost account. If you don’t already have one, please install a mobile authentication app, for example: + <ul> + <li><a href="http://support.google.com/accounts/bin/answer.py?hl=en&answer=1066447" target="_blank">Google Authenticator</a> (Android/iOS)</li> + <li><a href="http://guide.duosecurity.com/third-party-accounts" target="_blank">Duo Mobile</a> (Android/iOS)</li> + <li><a href="http://aka.ms/dbauthenticator" target="_blank">Authenticator</a> (Windows Phone 7)</li> + </ul> + <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 <a href="javascript:;" data-toggle="toggle" data-target="#secret-key">enter your secret key manually</a>.</li> + </ul> + <form method="post"> + {% csrf_token %} + <div class="text-center"> + <img id="qr" data-toggle="qr" data-qr-value="{{ form.get_QR_data }}"> + <h3 class="p-3" id="secret-key" style="display: none;"><code>{{ form.secret_key }}</code></h3> + </div> + <p> + Enter the security code generated by your mobile authenticator app to make sure it’s configured correctly. + </p> + + {{ form|bootstrap }} + <input type="submit" class="btn btn-primary" value="Add device" /> + </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..d7634437342a27d3543f0c514ea9e3b9a808562f --- /dev/null +++ b/scipost/templates/scipost/totpdevice_list.html @@ -0,0 +1,62 @@ +{% 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> + <p> + We strongly recommend to use two factor authentication that adds an extra layer of protection to your SciPost account. + You will need a mobile device capable or running a mobile authentication application, for example: + <ul> + <li><a href="http://support.google.com/accounts/bin/answer.py?hl=en&answer=1066447" target="_blank">Google Authenticator</a> (Android/iOS)</li> + <li><a href="http://guide.duosecurity.com/third-party-accounts" target="_blank">Duo Mobile</a> (Android/iOS)</li> + <li><a href="http://aka.ms/dbauthenticator" target="_blank">Authenticator</a> (Windows Phone 7)</li> + </ul> + <a href="{% url 'scipost:totp_create' %}">Set up a new two factor authentication device</a> + </p> + + <h3 class="mt-5 mb-3">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> + {% empty %} + <tr> + <td colspan="3"> + <div class="py-2"> + <i class="fa fa-exclamation-triangle text-danger"></i> + You are not using two factor authentication yet. We strongly recommend to <a href="{% url 'scipost:totp_create' %}">set up two factor authentication</a>. + </div> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + +</div> + +{% endblock %} diff --git a/scipost/templatetags/user_groups.py b/scipost/templatetags/user_groups.py index 1d97a093daa42bd067012b6ed92f6a2cf6c2ff4d..a8ce8e6d7089e1cce8d394b4e7152cbe062d7e2c 100644 --- a/scipost/templatetags/user_groups.py +++ b/scipost/templatetags/user_groups.py @@ -124,3 +124,24 @@ def is_editor_in_charge(user, submission): return False return submission.editor_in_charge == user.contributor + + +@register.simple_tag +def recommend_new_totp_device(user): + """ + Check if User has no TOTPDevice, but still has a high level of information access. + """ + if user.devices.exists(): + return False + if user.is_superuser: + return True + if user.contributor.fellowships.exists(): + return True + return user.groups.filter(name__in=[ + 'Editorial Administrators', + 'SciPost Administrators', + 'Advisory Board', + 'Financial Administrators', + 'Vetting Editors', + 'Editorial College', + ]).exists() diff --git a/scipost/tests/__init__.py b/scipost/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/scipost/tests/test_totp.py b/scipost/tests/test_totp.py new file mode 100644 index 0000000000000000000000000000000000000000..f605ff84e7b35d380f422d93dda23f967eef3a06 --- /dev/null +++ b/scipost/tests/test_totp.py @@ -0,0 +1,129 @@ +import datetime + +from django.urls import reverse +from django.test import TestCase, Client + +from mock import Mock, patch + +from scipost.factories import UserFactory, TOTPDeviceFactory +from scipost.totp import TOTPVerification + +# Mock random test time of which the test values are know +# Secret key: 'XTNHYG5OJPQ7ZRDC' +# Valid token: '451977' +mock_time = Mock() +mock_time.return_value = datetime.datetime(2019, 12, 8, 11, 1, 1).timestamp() + + +class TOTPVerificationTest(TestCase): + """ + Test the scipost.totp.TOTPVerification util. + """ + valid_secret_key = 'XTNHYG5OJPQ7ZRDC' + valid_token = '451977' + + def setUp(self): + super().setUp() + self.client = Client() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.password = 'super_secret_123' + cls.user = UserFactory(contrib=None) + cls.user.set_password(cls.password) + cls.user.save() + + @patch('time.time', mock_time) + def test_proper_return_classmethod(self): + """Test if valid secret_key/time/token combinations return True.""" + self.assertTrue(TOTPVerification.verify_token(self.valid_secret_key, self.valid_token)) + self.assertFalse(TOTPVerification.verify_token('XTNHYG5OJPQ7ZRDX', self.valid_token)) + self.assertFalse(TOTPVerification.verify_token(self.valid_secret_key, '4519000')) + + def test_2fa_workaround_closed(self): + """ + Test if the admin login form is disabled. It's an easy workaround for 2FA. + """ + # Test GET request + self.client.logout() + response = self.client.get('/admin') + self.assertEqual(response.status_code, 301) # Disabled by permanent redirect + + # Test POST request + response = self.client.post('/admin', follow=True, + data={ + 'username': self.user.username, + 'password': self.password, + 'next': '/' + }) + self.assertNotEqual(response.context['user'], self.user) + self.assertEqual(response.redirect_chain[0][0], '/admin/') + self.assertEqual(response.redirect_chain[0][1], 301) # Check if immediately redirected + + @patch('time.time', mock_time) + def test_proper_login_procedure(self): + """Test if CBV fails gently if not used properly.""" + + login_url = reverse('scipost:login') + response = self.client.get(login_url) + self.assertEqual(response.status_code, 200) + + # Does posting work? + response = self.client.post( + login_url, follow=True, + data={ + 'username': self.user.username, + 'password': self.password, + 'next': '/', + 'code': '' + }) + self.assertEqual(response.context['user'], self.user) + self.assertEqual(response.redirect_chain[-1][0], '/') # Check if eventually redirected + self.assertEqual(response.redirect_chain[-1][1], 302) + + # Logout for next step + self.client.logout() + + # Check if a simple login without code fails if device is set up. + TOTPDeviceFactory.create(user=self.user, token=self.valid_secret_key) + response = self.client.post( + login_url, follow=True, + data={ + 'username': self.user.username, + 'password': self.password, + 'next': '/', + 'code': '' + }) + self.assertNotEqual(response.context['user'], self.user) + + # Check if login fails with invalid code + response = self.client.post( + login_url, follow=True, + data={ + 'username': self.user.username, + 'password': self.password, + 'next': '/', + 'code': '912334' + }) + self.assertNotEqual(response.context['user'], self.user) + response = self.client.post( + login_url, follow=True, + data={ + 'username': self.user.username, + 'password': self.password, + 'next': '/', + 'code': '000000' + }) + self.assertNotEqual(response.context['user'], self.user) + + # Check if login *WORKS* with a valid code. + response = self.client.post( + login_url, follow=True, + data={ + 'username': self.user.username, + 'password': self.password, + 'next': '/', + 'code': self.valid_token + }) + self.assertEqual(response.context['user'], self.user) diff --git a/scipost/totp.py b/scipost/totp.py new file mode 100644 index 0000000000000000000000000000000000000000..3af23ed5fc3b409d951e3afcc806b2aa15c63599 --- /dev/null +++ b/scipost/totp.py @@ -0,0 +1,70 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + +import time +import pyotp + +from .models import TOTPDevice + + +class TOTPVerification: + number_of_digits = 6 + token_validity_period = 30 + tolerance = 2 # Gives a 2 minute window to use a code. + + def __init__(self, user): + """ + Initiate for a certain user instance. + """ + self._user = user + + def verify_code(self, code): + """ + Verify a time-dependent code for a certain User. + """ + try: + # Try to see if input token is convertable to integer. + # Do not actually make it a integer, because it'll loose the leading 0s. + assert int(code) > 0 + except (ValueError, AssertionError): + # 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.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(code, 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 + + @classmethod + def verify_token(cls, secret_key, code): + """ + Independently verify a secret_key/code combination at current time. + """ + try: + # Try to see if input token is convertable to integer. + # Do not actually make it a integer, because it'll loose the leading 0s. + assert int(code) > 0 + except (ValueError, AssertionError): + # return False, if token could not be converted to an integer + return False + time_int = int(time.time()) + totp = pyotp.TOTP(secret_key, interval=cls.token_validity_period, digits=cls.number_of_digits) + return totp.verify(code, for_time=time_int, valid_window=cls.tolerance) 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 e3ce45b2f5ddf1cbe5724bb26643892da38d1798..c1c0f843c9d054d34b75b6b88b5c1506f35fe73d 100644 --- a/scipost/views.py +++ b/scipost/views.py @@ -10,26 +10,29 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import login, update_session_auth_hash from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import Group from django.contrib.auth.views import password_reset, password_reset_confirm from django.contrib.auth.views import ( LoginView, LogoutView, PasswordChangeView, PasswordResetView, PasswordResetConfirmView) +from django.contrib.messages.views import SuccessMessageMixin from django.core import mail from django.core.exceptions import PermissionDenied 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 DeleteView, CreateView 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 +44,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 +437,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 +883,40 @@ def update_personal_data(request): return _update_personal_data_user_only(request) +class TOTPListView(LoginRequiredMixin, ListView): + """ + List all TOTP devices for logged in User. + """ + def get_queryset(self): + return self.request.user.devices.all() + + +class TOTPDeviceCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + """ + Create a new TOTP device. + """ + form_class = TOTPDeviceForm + template_name = 'scipost/totpdevice_form.html' + success_url = reverse_lazy('scipost:totp') + success_message = 'Two factor authentication device %(name)s successfully added.' + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['current_user'] = self.request.user + return kwargs + + +class TOTPDeviceDeleteView(LoginRequiredMixin, DeleteView): + """ + Confirm deletion of a TOTP device. + """ + 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):