From edb16382f36a12f53a8de4491a1c17f33f83ba89 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 2 Sep 2019 04:56:45 -0500 Subject: [PATCH] Completing functional conversions --- CHANGELOG | 18 + sqrl/__init__.py | 5 + sqrl/apps.py | 5 - sqrl/backends.py | 44 ++ sqrl/crypto.py | 7 +- sqrl/sqrl.py | 4 +- sqrl/static/admin/sqrl.css | 2 +- sqrl/static/sqrl/sqrl.js | 4 +- sqrl/templates/admin/login.html | 4 +- sqrl/templates/base.html | 46 ++ sqrl/templates/sqrl/sqrl-dropin.html | 6 +- sqrl/templatetags/sqrl.py | 5 +- sqrl/tests.py | 3 - sqrl/tests/__init__.py | 1 + sqrl/tests/test_backends.py | 58 +++ sqrl/tests/test_crypto.py | 186 ++++++++ sqrl/tests/test_exceptions.py | 56 +++ sqrl/tests/test_fields.py | 196 ++++++++ sqrl/tests/test_forms.py | 641 +++++++++++++++++++++++++++ sqrl/tests/test_managers.py | 32 ++ sqrl/tests/test_models.py | 32 ++ sqrl/tests/test_response.py | 63 +++ sqrl/tests/test_sqrl.py | 116 +++++ sqrl/tests/test_urls.py | 7 + sqrl/tests/test_utils.py | 80 ++++ sqrl/tests/test_views.py | 194 ++++++++ sqrl/urls.py | 4 +- sqrl/utils.py | 2 +- sqrl/views.py | 5 +- 29 files changed, 1798 insertions(+), 28 deletions(-) delete mode 100644 sqrl/apps.py create mode 100644 sqrl/backends.py create mode 100644 sqrl/templates/base.html delete mode 100644 sqrl/tests.py create mode 100644 sqrl/tests/__init__.py create mode 100644 sqrl/tests/test_backends.py create mode 100644 sqrl/tests/test_crypto.py create mode 100644 sqrl/tests/test_exceptions.py create mode 100644 sqrl/tests/test_fields.py create mode 100644 sqrl/tests/test_forms.py create mode 100644 sqrl/tests/test_managers.py create mode 100644 sqrl/tests/test_models.py create mode 100644 sqrl/tests/test_response.py create mode 100644 sqrl/tests/test_sqrl.py create mode 100644 sqrl/tests/test_urls.py create mode 100644 sqrl/tests/test_utils.py create mode 100644 sqrl/tests/test_views.py diff --git a/CHANGELOG b/CHANGELOG index e394ff7..6508aea 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,21 @@ +Mon, 02 Sep 2019 04:56:45 -0500 +Keaton +Completing functional conversions + +Not much is verified to work yet. All tests pass, however functionality +is not guaranteed. This update is being pushed to the dev git in order +for an easier setup of a test server with SSL. + +Things changed: No idea, just squashed bugs that were causing tests to +fail. + +Future development note: The original package supports insecure "qrl://" +schema, and as far as I can tell, that's not supported. I will probably +post on the SQRL forums about it, and remove the functionality completely +if the consensus is that it is not legitimate. + +-------------------- + Mon, 02 Sep 2019 02:03:23 -0500 Keaton Still building initial conversions diff --git a/sqrl/__init__.py b/sqrl/__init__.py index e69de29..852b98e 100644 --- a/sqrl/__init__.py +++ b/sqrl/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +__author__ = 'Miroslav Shubernetskiy | Keaton Brown' +__email__ = 'miroslav@miki725.com | kii-chan@tutanota.com' +__version__ = '0.1.0' diff --git a/sqrl/apps.py b/sqrl/apps.py deleted file mode 100644 index f26a68f..0000000 --- a/sqrl/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class SqrlConfig(AppConfig): - name = 'sqrl' diff --git a/sqrl/backends.py b/sqrl/backends.py new file mode 100644 index 0000000..d294fda --- /dev/null +++ b/sqrl/backends.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from django.contrib.auth.backends import ModelBackend + +from .models import SQRLIdentity + + +class SQRLModelBackend(ModelBackend): + """ + Custom SQRL Authentication backend which honors ``only_sqrl`` when enabled. + + SQRL, by its spec, allows users to send ``only_sqrl`` flag to the server + which indicates to the server that it should only use SQRL + for authentication and disable all other methods of authentication. + This custom authentication backend implements that requirement. + It honors the ``only_sqrl`` spec and does not allow to authenticate + a user when following conditions are all ``True``: + + * user successfully validated credentials using traditional auth method + * user has SQRL identity associated with their account + * :attr:`.models.SQRLIdentity.in_only_sqrl` is ``True`` + """ + + def authenticate(self, *args, **kwargs): + """ + Same as Django's ``ModelBackend.authenticate`` except + this method honors ``only_sqrl`` SQRL spec. + """ + user = super(SQRLModelBackend, self).authenticate(*args, **kwargs) + + if user is None: + return + + try: + sqrl_identity = user.sqrl_identity + except SQRLIdentity.DoesNotExist: + return user + else: + if sqrl_identity.is_only_sqrl and sqrl_identity.is_enabled: + return + + return user + + +SQRL_MODEL_BACKEND = '{}.{}'.format(SQRLModelBackend.__module__, SQRLModelBackend.__name__) diff --git a/sqrl/crypto.py b/sqrl/crypto.py index 7d66438..7011109 100644 --- a/sqrl/crypto.py +++ b/sqrl/crypto.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- + from collections import OrderedDict -from os import urandom +import os import ed25519 from django.utils.crypto import constant_time_compare, salted_hmac @@ -9,6 +10,8 @@ from .utils import Base64, Encoder + + class HMAC(object): """ Utility class for generating and verifying HMAC signatures. @@ -131,4 +134,4 @@ def generate_randomness(size=32): str :meth:`.Base64.encode` encoded random sample """ - return Base64.encode(urandom(size)) + return Base64.encode(bytearray(os.urandom(size))) diff --git a/sqrl/sqrl.py b/sqrl/sqrl.py index 0693ec3..2bdb90a 100644 --- a/sqrl/sqrl.py +++ b/sqrl/sqrl.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from django.core.urlresolvers import reverse +from django.urls import reverse from django.http import QueryDict from .crypto import generate_randomness @@ -139,7 +139,7 @@ class SQRLInitialization(object): """ return ( '{scheme}://{host}{url}' - ''.format(scheme='sqrl' if self.request.is_secure() else 'qrl', + ''.format(scheme='sqrl', if self.request.is_secure() else 'qrl', host=self.request.get_host(), url=self.url) ) diff --git a/sqrl/static/admin/sqrl.css b/sqrl/static/admin/sqrl.css index 106c9fe..0dd8146 100644 --- a/sqrl/static/admin/sqrl.css +++ b/sqrl/static/admin/sqrl.css @@ -72,7 +72,7 @@ max-width: 300px; border-radius: 5%; } -#sqrl-id img { +#sqrl-qr img { margin: auto; width: 100%; padding-top: 3px; diff --git a/sqrl/static/sqrl/sqrl.js b/sqrl/static/sqrl/sqrl.js index 5ce4ef4..0f61af4 100644 --- a/sqrl/static/sqrl/sqrl.js +++ b/sqrl/static/sqrl/sqrl.js @@ -1,5 +1,3 @@ -'use strict'; - (function() { var get_next_url = function() { var input = document.querySelectorAll('input[name="next"]'); @@ -59,7 +57,7 @@ // The original can be found here: // https://davidshimjs.github.io/qrcodejs/ - var QRCode; +var QRCode; ! function() { function a(a) { this.mode = c.MODE_8BIT_BYTE, this.data = a, this.parsedData = []; diff --git a/sqrl/templates/admin/login.html b/sqrl/templates/admin/login.html index cdd7c94..83b4e38 100644 --- a/sqrl/templates/admin/login.html +++ b/sqrl/templates/admin/login.html @@ -82,10 +82,10 @@ - {% sqrl_status_url_script_tag session_sqrl %} - + {% endblock %} diff --git a/sqrl/templates/base.html b/sqrl/templates/base.html new file mode 100644 index 0000000..cd4d92f --- /dev/null +++ b/sqrl/templates/base.html @@ -0,0 +1,46 @@ +{% load static %} + + + + + + {% block title %}SQRL Test{% endblock %} + + + + +
+ {% block header %} +
SQRL Test Server
+ + {% endblock %} +
+ +
+ {% block content %} + {% endblock content %} +
+ +{% block scripts %} +{% endblock %} + + + diff --git a/sqrl/templates/sqrl/sqrl-dropin.html b/sqrl/templates/sqrl/sqrl-dropin.html index 308df1d..442d6f2 100644 --- a/sqrl/templates/sqrl/sqrl-dropin.html +++ b/sqrl/templates/sqrl/sqrl-dropin.html @@ -8,7 +8,7 @@ max-width: 300px; border-radius: 5%; } - #sqrl-id img { + #sqrl-qr img { margin: auto; width: 100%; padding-top: 3px; @@ -37,5 +37,5 @@ {% endif %} -{% sqrl_status_url_script_tag session_sqrl %} - + + diff --git a/sqrl/templatetags/sqrl.py b/sqrl/templatetags/sqrl.py index 2ab0688..7d4620e 100644 --- a/sqrl/templatetags/sqrl.py +++ b/sqrl/templatetags/sqrl.py @@ -7,9 +7,10 @@ from ..sqrl import SQRLInitialization register = template.Library() +print(register) -@register.assignment_tag(takes_context=True) +@register.simple_tag(takes_context=True) def sqrl(context): return SQRLInitialization(context['request']) @@ -41,4 +42,4 @@ def sqrl_login_dropin(session_sqrl, method="login"): @register.simple_tag def sqrl_status_url_script_tag(sqrl): url = reverse('sqrl:status', kwargs={'transaction': sqrl.nut.transaction_nonce}) - return ''.format(url=url) + return url diff --git a/sqrl/tests.py b/sqrl/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/sqrl/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/sqrl/tests/__init__.py b/sqrl/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sqrl/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/sqrl/tests/test_backends.py b/sqrl/tests/test_backends.py new file mode 100644 index 0000000..1bb8f12 --- /dev/null +++ b/sqrl/tests/test_backends.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +import unittest + +import mock +from django.contrib.auth.backends import ModelBackend + +from ..backends import SQRLModelBackend +from ..models import SQRLIdentity + + +class TestSQRLModelBackend(unittest.TestCase): + @mock.patch.object(ModelBackend, 'authenticate') + def test_authenticate_no_user(self, mock_authenticate): + mock_authenticate.return_value = None + + self.assertIsNone(SQRLModelBackend().authenticate( + username='user', + password='password' + )) + + @mock.patch.object(ModelBackend, 'authenticate') + def test_authenticate_no_sqrl_identity(self, mock_authenticate): + class UserMock(mock.MagicMock): + @property + def sqrl_identity(self): + raise SQRLIdentity.DoesNotExist + + user = UserMock() + mock_authenticate.return_value = user + + self.assertEqual(SQRLModelBackend().authenticate( + username='user', + password='password' + ), user) + + @mock.patch.object(ModelBackend, 'authenticate') + def test_authenticate_disabled(self, mock_authenticate): + user = mock.MagicMock() + user.sqrl_identity.is_only_sqrl = True + user.sqrl_identity.is_enabled = True + mock_authenticate.return_value = user + + self.assertIsNone(SQRLModelBackend().authenticate( + username='user', + password='password' + )) + + @mock.patch.object(ModelBackend, 'authenticate') + def test_authenticate_enabled(self, mock_authenticate): + user = mock.MagicMock() + user.sqrl_identity.is_only_sqrl = False + user.sqrl_identity.is_enabled = True + mock_authenticate.return_value = user + + self.assertEqual(SQRLModelBackend().authenticate( + username='user', + password='password' + ), user) diff --git a/sqrl/tests/test_crypto.py b/sqrl/tests/test_crypto.py new file mode 100644 index 0000000..a7b2e8a --- /dev/null +++ b/sqrl/tests/test_crypto.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +import unittest +from collections import OrderedDict +from django.conf import settings +import os + +import ed25519 +import mock + +from ..crypto import HMAC, Ed25519, generate_randomness +from ..models import SQRLNut +from ..utils import Base64 + + +TESTING_MODULE = 'sqrl.crypto' + + +class TestHMAC(unittest.TestCase): + def setUp(self): + super(TestHMAC, self).setUp() + self.nut = SQRLNut(session_key='0123456789') + self.data = OrderedDict([ + ('ver', '1'), + ('tif', 0), + ('mac', 'value'), + ('qry', 'foo?nut=bar'), + ]) + self.hmac = HMAC(self.nut, self.data) + + def test_init(self): + hmac = HMAC(mock.sentinel.nut, mock.sentinel.data) + + self.assertEqual(hmac.nut, mock.sentinel.nut) + self.assertEqual(hmac.data, mock.sentinel.data) + + def test_sign_data(self): + temp = str(settings.SECRET_KEY) + settings.SECRET_KEY = "foo" + signature = self.hmac.sign_data() + + self.assertIsInstance(signature, bytes) + self.assertEqual(len(signature), 20) + self.assertEqual( + signature, + b'R\xfc\xb2\xbd\x12\x85\xae\xb0>\xdd\xed\x16P\xc2\x82\xae\x06\x0c\xc5\xd3' + ) + settings.SECRET_KEY = str(temp) + + @mock.patch(TESTING_MODULE + '.salted_hmac') + def test_sign_data_mock(self, mock_salted_hmac): + signature = self.hmac.sign_data() + + self.assertEqual( + signature, + mock_salted_hmac.return_value.digest.return_value + ) + mock_salted_hmac.assert_called_once_with( + self.nut.session_key, + Base64.encode(b'ver=1\r\n' + b'tif=0\r\n' + b'qry=foo?nut=bar\r\n') + ) + + def test_sign_data_not_dict(self): + with self.assertRaises(AssertionError): + HMAC(mock.sentinel.nut, mock.sentinel.data).sign_data() + + @mock.patch.object(HMAC, 'sign_data') + def test_is_signature_valid(self, mock_sign_data): + mock_sign_data.return_value = 'foo_signature' + + self.assertTrue(self.hmac.is_signature_valid('foo_signature')) + self.assertFalse(self.hmac.is_signature_valid('foo-signature')) + + def test_validation_loop(self): + signature = self.hmac.sign_data() + + self.assertTrue(self.hmac.is_signature_valid(signature)) + self.assertFalse(self.hmac.is_signature_valid(b'a' + signature[:-1])) + + +class TestEd25519(unittest.TestCase): + def setUp(self): + super(TestEd25519, self).setUp() + self.signing_key = (b'\xbbH\xdfx\xed\xc5\xdbR\x94\xe4\xff\xa6~5\xbb\xbd\xf2\x16&' + b'\xfc\x89\x8a\xc8\\\\\xeb\xea\x91Db~Hm+b\x88\xf2\x10\xfb:H' + b'\xe4\xfb0\x00\r\xe7n|\xa64\x05m@\xc8\xef"\x07k{O\xf0\xff%') + self.verifying_key = (b'm+b\x88\xf2\x10\xfb:H\xe4\xfb0\x00\r\xe7n|\xa64\x05m@' + b'\xc8\xef"\x07k{O\xf0\xff%') + self.data = b'data' + self.sig = Ed25519(self.verifying_key, self.signing_key, self.data) + + def test_init(self): + sig = Ed25519(mock.sentinel.pub_key, + mock.sentinel.priv_key, + mock.sentinel.msg) + + self.assertEqual(sig.public_key, mock.sentinel.pub_key) + self.assertEqual(sig.private_key, mock.sentinel.priv_key) + self.assertEqual(sig.msg, mock.sentinel.msg) + + def test_sign_data(self): + signature = self.sig.sign_data() + + self.assertIsInstance(signature, bytes) + self.assertEqual(len(signature), 64) + self.assertEqual( + signature, + b'\xac\xe0\x81\xc4\xd5\x7f\xd4\xe3\xc1\x03>\x0f\x90\xb5\x9eG<\xe0\xd41' + b'\x1cZ\xd7\x15F\xba\xdeS/\xfa\xbbL\x9bh\x8dn;\xcfP\xb1\x16\x14&d\xde' + b'\x97\x145\x90N[\xb9\xfc\x8e\x8a\x9e\xd2=\xad\x84\xcd\xf1\x93\x06' + ) + + @mock.patch('ed25519.SigningKey') + def test_sign_data_mock(self, mock_signing_key): + signature = self.sig.sign_data() + + self.assertEqual(signature, mock_signing_key.return_value.sign.return_value) + mock_signing_key.assert_called_once_with(self.sig.private_key) + mock_signing_key.return_value.sign.assert_called_once_with(self.sig.msg) + + def test_is_signature_valid(self): + signature = self.sig.sign_data() + + self.assertTrue(self.sig.is_signature_valid(signature)) + self.assertFalse(self.sig.is_signature_valid(b'a' + signature[:-1])) + + @mock.patch('ed25519.VerifyingKey') + def test_is_signature_mock(self, mock_verifying_key): + is_valid = self.sig.is_signature_valid(mock.sentinel.signature) + + self.assertTrue(is_valid) + mock_verifying_key.assert_called_once_with(self.sig.public_key) + mock_verifying_key.return_value.verify.assert_called_once_with( + mock.sentinel.signature, self.data + ) + + @mock.patch('ed25519.VerifyingKey') + def test_is_signature_mock_assertion_error(self, mock_verifying_key): + mock_verifying_key.return_value.verify.side_effect = AssertionError + + is_valid = self.sig.is_signature_valid(mock.sentinel.signature) + + self.assertFalse(is_valid) + mock_verifying_key.assert_called_once_with(self.sig.public_key) + mock_verifying_key.return_value.verify.assert_called_once_with( + mock.sentinel.signature, self.data + ) + + @mock.patch('ed25519.VerifyingKey') + def test_is_signature_mock_bas_signature_error(self, mock_verifying_key): + mock_verifying_key.return_value.verify.side_effect = ed25519.BadSignatureError + + is_valid = self.sig.is_signature_valid(mock.sentinel.signature) + + self.assertFalse(is_valid) + mock_verifying_key.assert_called_once_with(self.sig.public_key) + mock_verifying_key.return_value.verify.assert_called_once_with( + mock.sentinel.signature, self.data + ) + + +class TestUtils(unittest.TestCase): + @mock.patch(TESTING_MODULE + '.bytearray', create=True) + @mock.patch.object(Base64, 'encode') + @mock.patch.object(os, 'urandom') + def test_generate_randomness_mock(self, mock_urandom, mock_encode, mock_bytearray): + _mock_bytearray = mock.MagicMock() + + def _bytearray(a): + list(a) + return _mock_bytearray(a) + + mock_bytearray.side_effect = _bytearray + + randomness = generate_randomness() + + self.assertEqual(randomness, mock_encode.return_value) + self.assertEqual(mock_urandom.call_count, 1) + mock_urandom.assert_called_with(32) + mock_encode.assert_called_once_with(_mock_bytearray.return_value) + + def test_generate_randomness(self): + randomness = generate_randomness() + + self.assertIsInstance(randomness, str) diff --git a/sqrl/tests/test_exceptions.py b/sqrl/tests/test_exceptions.py new file mode 100644 index 0000000..c3ab60d --- /dev/null +++ b/sqrl/tests/test_exceptions.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +import unittest + +import mock + +from ..exceptions import TIF, TIFException + + +TESTING_MODULE = 'sqrl.exceptions' + + +class TestTIF(unittest.TestCase): + def test_as_hex_string(self): + self.assertEqual(TIF(0x1).as_hex_string(), '1') + self.assertEqual(TIF(0x4).as_hex_string(), '4') + self.assertEqual(TIF(0x88).as_hex_string(), '88') + self.assertEqual(TIF(0x84).as_hex_string(), '84') + + def test_breakdown(self): + self.assertDictEqual(TIF(0x34).breakdown(), { + 'id_match': False, + 'previous_id_match': False, + 'ip_match': True, + 'sqrl_disabled': False, + 'not_supported': True, + 'transient_failure': True, + 'command_failed': False, + 'client_failure': False, + 'bad_id_association': False, + }) + + def test_update(self): + tif = TIF(0x3).update(TIF(0x40)) + + self.assertIsInstance(tif, TIF) + self.assertEqual(tif, 0x43) + + def test_properties(self): + self.assertTrue(TIF(TIF.ID_MATCH).is_id_match) + self.assertTrue(TIF(TIF.PREVIOUS_ID_MATCH).is_previous_id_match) + self.assertTrue(TIF(TIF.IP_MATCH).is_ip_match) + self.assertTrue(TIF(TIF.SQRL_DISABLED).is_sqrl_disabled) + self.assertTrue(TIF(TIF.NOT_SUPPORTED).is_not_supported) + self.assertTrue(TIF(TIF.TRANSIENT_FAILURE).is_transient_failure) + self.assertTrue(TIF(TIF.COMMAND_FAILED).is_command_failed) + self.assertTrue(TIF(TIF.CLIENT_FAILURE).is_client_failure) + self.assertTrue(TIF(TIF.BAD_ID_ASSOCIATION).is_bad_id_association) + + +class TestTIFException(unittest.TestCase): + @mock.patch(TESTING_MODULE + '.TIF') + def test_init(self, mock_tif): + e = TIFException(mock.sentinel.tif) + + self.assertEqual(e.tif, mock_tif.return_value) + mock_tif.assert_called_once_with(mock.sentinel.tif) diff --git a/sqrl/tests/test_fields.py b/sqrl/tests/test_fields.py new file mode 100644 index 0000000..1e0396d --- /dev/null +++ b/sqrl/tests/test_fields.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +import unittest +from collections import OrderedDict + +import mock +from django import forms +from django.urls import Resolver404 + +from ..fields import ( + Base64CharField, + Base64ConditionalPairsField, + Base64Field, + Base64PairsField, + ExtractedNextUrlField, + NextUrlField, + SQRLURLField, + SQRLURLValidator, + TildeMultipleValuesField, + TildeMultipleValuesFieldChoiceField, +) +from ..utils import Base64 + + +TESTING_MODULE = 'sqrl.fields' + + +class TestNextUrlField(unittest.TestCase): + def test_to_python_empty(self): + self.assertEqual(NextUrlField().to_python(None), '') + + @mock.patch(TESTING_MODULE + '.resolve') + def test_to_python_valid(self, mock_resolve): + value = 'http://example.com/path/here/?querystring=here' + self.assertEqual(NextUrlField().to_python(value), '/path/here/') + + @mock.patch(TESTING_MODULE + '.resolve') + def test_to_python_invalid(self, mock_resolve): + mock_resolve.side_effect = Resolver404 + value = 'http://example.com/path/here/?querystring=here' + + with self.assertRaises(forms.ValidationError): + NextUrlField().to_python(value) + + +class TestExtractedNextUrlField(unittest.TestCase): + def test_to_python_empty(self): + self.assertEqual(ExtractedNextUrlField().to_python(None), '') + + def test_to_python_next_not_present(self): + value = 'http://example.com/path/here/?querystring=here' + + with self.assertRaises(forms.ValidationError): + ExtractedNextUrlField().to_python(value) + + @mock.patch(TESTING_MODULE + '.resolve') + def test_to_python(self, mock_resolve): + value = 'http://example.com/path/here/?next=/next/here/' + + self.assertEqual( + ExtractedNextUrlField().to_python(value), + '/next/here/' + ) + + +class TestSQRLURLValidator(unittest.TestCase): + def test_valid(self): + self.assertIsNone( + SQRLURLValidator()('qrl://example.com:8000/sqrl/?nut=hello') + ) + self.assertIsNone( + SQRLURLValidator()('sqrl://example.com:8000/sqrl/?nut=hello') + ) + + def test_invalid(self): + with self.assertRaises(forms.ValidationError): + SQRLURLValidator()('http://example.com:Base64PairsField8000/sqrl/?nut=hello') + + +class TestSQRLURLField(unittest.TestCase): + def test_default_validators(self): + validator_types = list(map(type, SQRLURLField.default_validators)) + self.assertIn(SQRLURLValidator, validator_types) + + +class TestBase64Field(unittest.TestCase): + def test_empty_value(self): + value = Base64Field().to_python(None) + + self.assertEqual(value, b'') + self.assertIsInstance(value, bytes) + + def test_value(self): + value = Base64Field().to_python('aGVsbG8') + + self.assertEqual(value, b'hello') + self.assertIsInstance(value, bytes) + + def test_value_invalid(self): + with self.assertRaises(forms.ValidationError): + Base64Field().to_python('hello') + + +class TestBase64CharField(unittest.TestCase): + def test_empty_value(self): + value = Base64CharField().to_python(None) + + self.assertEqual(value, '') + self.assertIsInstance(value, str) + + def test_value(self): + value = Base64CharField().to_python('aGVsbG8') + + self.assertEqual(value, 'hello') + self.assertIsInstance(value, str) + + def test_value_invalid(self): + with self.assertRaises(forms.ValidationError): + Base64CharField().to_python('z4A') + + +class TestBase64PairsField(unittest.TestCase): + def test_to_python_empty(self): + value = Base64PairsField().to_python(None) + + self.assertEqual(value, OrderedDict()) + + def test_to_python(self): + value = Base64.encode( + b'ver=1\r\n' + b'foo=bar\r\n' + ) + + value = Base64PairsField().to_python(value) + + self.assertEqual(value, OrderedDict([ + ('ver', '1'), + ('foo', 'bar'), + ])) + + def test_to_python_not_pars(self): + value = Base64.encode( + b'ver=1\r\n' + b'foo\r\n' + ) + + with self.assertRaises(forms.ValidationError): + Base64PairsField().to_python(value) + + def test_to_python_not_multiline(self): + value = Base64.encode( + b'ver=1' + ) + + with self.assertRaises(forms.ValidationError): + Base64PairsField().to_python(value) + + +class TestBase64ConditionalPairsField(unittest.TestCase): + def test_to_python_not_pars(self): + value = Base64.encode( + b'foo' + ) + + self.assertEqual(Base64ConditionalPairsField().to_python(value), 'foo') + + +class TestTildeMultipleValuesField(unittest.TestCase): + def test_empty(self): + self.assertListEqual(TildeMultipleValuesField().to_python(None), []) + + def test_to_python(self): + self.assertListEqual( + TildeMultipleValuesField().to_python('hello~world'), + ['hello', 'world'] + ) + + +class TestTildeMultipleValuesFieldChoiceField(unittest.TestCase): + def test_valid(self): + field = TildeMultipleValuesFieldChoiceField(choices=[ + ('hello', 'hello'), + ('world', 'world'), + ]) + + self.assertListEqual( + field.clean('hello~world'), + ['hello', 'world'] + ) + + def test_invalid(self): + field = TildeMultipleValuesFieldChoiceField(choices=[ + ('hello', 'hello'), + ]) + + with self.assertRaises(forms.ValidationError): + field.clean('hello~world') diff --git a/sqrl/tests/test_forms.py b/sqrl/tests/test_forms.py new file mode 100644 index 0000000..3701ba6 --- /dev/null +++ b/sqrl/tests/test_forms.py @@ -0,0 +1,641 @@ +# -*- coding: utf-8 -*- +import unittest +from collections import OrderedDict + +import ed25519 +import mock +from django import forms, test +from django.contrib.auth import SESSION_KEY, get_user_model +from django.utils.timezone import now + +from ..crypto import HMAC, Ed25519, generate_randomness +from ..forms import PasswordLessUserCreationForm, RequestForm +from ..models import SQRLIdentity, SQRLNut +from ..utils import Base64, Encoder + + +TESTING_MODULE = 'sqrl.forms' + + +class TestRequestForm(test.TestCase): + def get_key_pair(self): + signing_key, verifying_key = ed25519.create_keypair() + signing_key = signing_key.to_bytes() + verifying_key = verifying_key.to_bytes() + + return signing_key, verifying_key + + def _setup(self): + hasattr(self, 'nut') and self.nut.delete() + hasattr(self, 'user') and self.user.delete() + hasattr(self, 'identity') and self.identity.delete() + + self.nut = SQRLNut( + nonce=generate_randomness(), + transaction_nonce=generate_randomness(), + session_key=generate_randomness(20), + is_transaction_complete=False, + ip_address='127.0.0.1', + timestamp=now(), + ) + self.nut.save() + + self.user = get_user_model().objects.create( + username='test_clean_session', + ) + + self.identity = SQRLIdentity( + user_id=self.user.pk, + public_key=Base64.encode(self.public_key), + server_unlock_key=Base64.encode(self.server_unlock_key), + verify_unlock_key=Base64.encode(self.verify_unlock_key), + is_enabled=True, + is_only_sqrl=False, + ) + self.identity.save() + + self.server_data = OrderedDict([ + ('ver', 1), + ('nut', self.nut.nonce), + ('tif', '8'), + ('qry', '/sqrl/auth/?nut=nonce'), + ('sfn', 'Test Server'), + ]) + self.server_data['mac'] = HMAC(self.nut, self.server_data).sign_data() + self.server_data = Encoder.normalize(self.server_data) + + self.client_data = OrderedDict([ + ('ver', 1), + ('cmd', self.cmd), + ('opt', ['sqrlonly']), + ]) + if self.include_idk: + self.client_data['idk'] = self.public_key + if self.include_pidk: + self.client_data['pidk'] = self.previous_public_key + if self.include_suk: + self.client_data['suk'] = self.server_unlock_key + if self.include_vuk: + self.client_data['vuk'] = self.verify_unlock_key + + self.payload_client_data = Encoder.normalize(OrderedDict( + (k, v if not isinstance(v, list) else '~'.join(v)) + for k, v in self.client_data.items() + )) + + self.data = { + 'client': Encoder.base64_dumps(self.client_data), + 'server': Encoder.base64_dumps(self.server_data), + } + self.signable_data = ( + self.data['client'] + self.data['server'] + ).encode('ascii') + if self.include_ids: + self.data['ids'] = Ed25519( + self.public_key, self.identity_key, self.signable_data + ).sign_data() + if self.include_pids: + self.data['pids'] = Ed25519( + self.previous_public_key, self.previous_identity_key, self.signable_data + ).sign_data() + if self.include_urs: + self.data['urs'] = Ed25519( + self.verify_unlock_key, self.unlock_key, self.signable_data + ).sign_data() + + self.cleaned_data = self.data.copy() + self.cleaned_data.update({ + 'client': self.client_data, + 'server': self.server_data, + }) + + self.form = RequestForm(self.nut, data=self.data) + + def setUp(self): + super(TestRequestForm, self).setUp() + + self.cmd = ['query'] + + self.identity_key, self.public_key = self.get_key_pair() + self.previous_identity_key, self.previous_public_key = self.get_key_pair() + self.unlock_key, self.verify_unlock_key = self.get_key_pair() + self.server_unlock_key = b'hello' + + self.include_idk = True + self.include_pidk = True + self.include_suk = True + self.include_vuk = True + self.include_ids = True + self.include_pids = True + self.include_urs = True + + self._setup() + + def tearDown(self): + self.user and self.user.delete() + self.identity and self.identity.delete() + self.nut and self.nut.delete() + super(TestRequestForm, self).tearDown() + + def test_init(self): + form = RequestForm(mock.sentinel.nut) + + self.assertEqual(form.nut, mock.sentinel.nut) + self.assertIsNone(form.session) + self.assertIsNone(form.identity) + self.assertIsNone(form.previous_identity) + + def test_clean_client(self): + self.form.cleaned_data = {'client': self.payload_client_data} + + self.assertEqual(self.form.clean_client(), dict(self.client_data)) + + def test_clean_client_invalid(self): + self.form.cleaned_data = { + 'client': { + 'ver': '2', + } + } + + with self.assertRaises(forms.ValidationError): + self.form.clean_client() + + def test_clean_server_not_dict(self): + self.form.cleaned_data = {'server': mock.sentinel.server_data} + + self.assertEqual(self.form.clean_server(), mock.sentinel.server_data) + + def test_clean_server(self): + self.form.cleaned_data = { + 'server': self.server_data + } + + self.assertEqual( + self.form.clean_server(), + self.server_data + ) + + def test_clean_server_mac_not_base64(self): + self.server_data['mac'] = 'hello' + self.form.cleaned_data = {'server': self.server_data} + + with self.assertRaises(forms.ValidationError): + self.form.clean_server() + + def test_clean_server_mismatch_nut(self): + self.server_data['nut'] = self.server_data['nut'][::-1] + self.form.cleaned_data = {'server': self.server_data} + + with self.assertRaises(forms.ValidationError): + self.form.clean_server() + + def test_clean_server_mismatch_missing_mac(self): + del self.server_data['mac'] + self.form.cleaned_data = {'server': self.server_data} + + with self.assertRaises(forms.ValidationError): + self.form.clean_server() + + def test_clean_server_invalid_mac(self): + self.server_data['mac'] = self.server_data['mac'][::-1] + self.form.cleaned_data = {'server': self.server_data} + + with self.assertRaises(forms.ValidationError): + self.form.clean_server() + + def test_clean_ids(self): + self.form.cleaned_data = { + 'client': self.client_data, + 'ids': self.data['ids'], + } + + self.assertEqual( + self.form.clean_ids(), + self.data['ids'] + ) + + def test_clean_ids_invalid(self): + self.form.cleaned_data = { + 'client': self.client_data, + 'ids': self.data['ids'][::-1], + } + + with self.assertRaises(forms.ValidationError): + self.form.clean_ids() + + def test_clean_pids_valid(self): + self.form.cleaned_data = { + 'client': self.client_data, + 'pids': self.data['pids'], + } + + self.assertEqual( + self.form.clean_pids(), + self.data['pids'] + ) + + def test_clean_pids_invalid(self): + self.form.cleaned_data = { + 'client': self.client_data, + 'pids': self.data['pids'][::-1], + } + + with self.assertRaises(forms.ValidationError): + self.form.clean_pids() + + def test_clean_pids_missing_pids(self): + self.form.cleaned_data = { + 'client': self.client_data, + } + + with self.assertRaises(forms.ValidationError): + self.form.clean_pids() + + def test_clean_pids_missing_pidk(self): + self.client_data.pop('pidk') + self.form.cleaned_data = { + 'client': self.client_data, + 'pids': self.data['pids'], + } + + with self.assertRaises(forms.ValidationError): + self.form.clean_pids() + + def test_clean_urs(self): + self.form.cleaned_data = self.cleaned_data + self.form.identity = self.identity + + self.assertEqual( + self.form._clean_urs(), + self.data['urs'] + ) + + def test_clean_urs_invalid(self): + self.form.cleaned_data = { + 'client': self.client_data, + 'urs': self.data['urs'][::-1], + } + self.form.identity = self.identity + + with self.assertRaises(forms.ValidationError): + self.form._clean_urs() + + def test_clean_urs_no_suk(self): + self.form.cleaned_data = { + 'client': self.client_data, + 'urs': self.data['urs'], + } + self.form.identity = self.identity + self.identity.server_unlock_key = None + + with self.assertRaises(forms.ValidationError): + self.form._clean_urs() + + def test_clean_urs_no_vuk(self): + self.form.cleaned_data = { + 'client': self.client_data, + 'urs': self.data['urs'], + } + self.form.identity = self.identity + self.identity.verify_unlock_key = None + + with self.assertRaises(forms.ValidationError): + self.form._clean_urs() + + def test_clean_cmd_query(self): + self.cmd = ['query'] + self._setup() + + self.form.cleaned_data = { + 'client': self.client_data, + } + + self.assertIsNone(self.form._clean_client_cmd()) + + def test_clean_cmd_ident(self): + self.cmd = ['ident'] + self._setup() + + self.form.cleaned_data = { + 'client': self.client_data, + } + + self.assertIsNone(self.form._clean_client_cmd()) + + def test_clean_cmd_ident_no_suk_vuk_without_identity(self): + self.cmd = ['ident'] + self.include_suk = None + self.include_vuk = None + self._setup() + + self.form.cleaned_data = { + 'client': self.client_data, + } + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_ident_suk_vuk_with_identity(self): + self.cmd = ['ident'] + self._setup() + + self.form.identity = self.identity + self.form.cleaned_data = { + 'client': self.client_data, + } + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_ident_with_disable(self): + self.cmd = ['ident', 'disable'] + self._setup() + + self.form.cleaned_data = { + 'client': self.client_data, + } + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_ident_no_urs_with_previous_identity(self): + self.cmd = ['ident'] + self._setup() + + self.form.previous_identity = self.identity + self.form.cleaned_data = { + 'client': self.client_data, + } + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_disable(self): + self.cmd = ['disable'] + self._setup() + + self.form.identity = self.identity + self.form.cleaned_data = { + 'client': self.client_data, + } + + self.assertIsNone(self.form._clean_client_cmd()) + + def test_clean_cmd_disable_no_identity(self): + self.cmd = ['disable'] + self._setup() + + self.form.cleaned_data = { + 'client': self.client_data, + } + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_disable_with_enable(self): + self.cmd = ['disable', 'enable'] + self._setup() + + self.form.identity = self.identity + self.form.cleaned_data = { + 'client': self.client_data, + } + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_enable(self): + self.cmd = ['enable'] + self._setup() + + self.form.identity = self.identity + self.form.cleaned_data = self.cleaned_data + + self.assertIsNone(self.form._clean_client_cmd()) + + def test_clean_cmd_enable_no_urs(self): + self.cmd = ['enable'] + self.include_urs = False + self._setup() + + self.form.identity = self.identity + self.form.cleaned_data = self.cleaned_data + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_enable_no_identity(self): + self.cmd = ['enable'] + self._setup() + + self.form.cleaned_data = self.cleaned_data + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_enable_with_disable(self): + self.cmd = ['enable', 'disable'] + self._setup() + + self.form.identity = self.identity + self.form.cleaned_data = self.cleaned_data + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_remove(self): + self.cmd = ['remove'] + self._setup() + + self.form.identity = self.identity + self.form.cleaned_data = self.cleaned_data + + self.assertIsNone(self.form._clean_client_cmd()) + + def test_clean_cmd_remove_no_identity(self): + self.cmd = ['remove'] + self._setup() + + self.form.cleaned_data = self.cleaned_data + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_remove_no_urs(self): + self.cmd = ['remove'] + self.include_urs = False + self._setup() + + self.form.identity = self.identity + self.form.cleaned_data = self.cleaned_data + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_cmd_remove_with_other_cmd(self): + self.cmd = ['remove', 'ident'] + self._setup() + + self.form.identity = self.identity + self.form.cleaned_data = self.cleaned_data + + with self.assertRaises(forms.ValidationError): + self.form._clean_client_cmd() + + def test_clean_session_empty(self): + self.form.session = {} + + self.assertIsNone(self.form._clean_session()) + + def test_clean_session_user_not_found(self): + assert not get_user_model().objects.filter(pk=1000).first() + + self.form.session = { + SESSION_KEY: '1000', + } + + self.assertIsNone(self.form._clean_session()) + + def test_clean_session_user_not_int(self): + self.form.session = { + SESSION_KEY: 'aaa', + } + + self.assertIsNone(self.form._clean_session()) + + def test_clean_session_no_sqrl_identity(self): + self.identity.delete() + self.identity = None + self.form.session = { + SESSION_KEY: str(self.user.pk), + } + + self.assertIsNone(self.form._clean_session()) + + def test_clean_session_public_key_not_matches(self): + self.identity.public_key = self.identity.public_key[::-1] + self.identity.save() + + self.form.session = { + SESSION_KEY: str(self.user.pk), + } + self.form.cleaned_data = self.cleaned_data + + with self.assertRaises(forms.ValidationError): + self.form._clean_session() + + def test_clean_session_user_code_mismatch(self): + self.form.identity = self.identity + self.form.session = { + SESSION_KEY: str(self.user.pk + 1), + } + self.form.cleaned_data = self.cleaned_data + + with self.assertRaises(forms.ValidationError): + self.form._clean_session() + + def test_clean(self): + self.form.cleaned_data = self.cleaned_data + + actual = self.form.clean() + + self.assertEqual(actual, self.cleaned_data) + + @mock.patch.object(RequestForm, 'find_identities') + @mock.patch.object(RequestForm, '_clean_client_cmd') + @mock.patch.object(RequestForm, '_clean_urs') + @mock.patch.object(RequestForm, 'find_session') + @mock.patch.object(RequestForm, '_clean_session') + @mock.patch.object(forms.Form, 'clean') + def test_clean_mock(self, + mock_super_clean, + mock_clean_session, + mock_find_session, + mock_clean_urs, + mock_clean_client_cmd, + mock_find_identities): + mock_super_clean.return_value = mock.sentinel.cleaned_data + + actual = self.form.clean() + + self.assertEqual(actual, mock.sentinel.cleaned_data) + mock_super_clean.assert_called_once_with() + mock_clean_session.assert_called_once_with() + mock_find_session.assert_called_once_with() + mock_clean_urs.assert_called_once_with() + mock_clean_client_cmd.assert_called_once_with() + mock_find_identities.assert_called_once_with() + + @mock.patch(TESTING_MODULE + '.SessionMiddleware') + def test_find_session(self, mock_session_middleware): + self.form.find_session() + + self.assertEqual( + self.form.session, + mock_session_middleware.return_value.SessionStore.return_value + ) + mock_session_middleware.return_value.SessionStore.assert_called_once_with( + self.nut.session_key, + ) + + def test_find_identities(self): + self.form.cleaned_data = self.cleaned_data + + self.form.find_identities() + + self.assertIsNotNone(self.form.identity) + self.assertIsInstance(self.form.identity, SQRLIdentity) + self.assertEqual(self.form.identity.public_key, self.identity.public_key) + self.assertIsNone(self.form.previous_identity) + + @mock.patch.object(RequestForm, '_get_identity') + def test_find_identities_mock(self, mock_get_identity): + self.form.cleaned_data = self.cleaned_data + mock_get_identity.side_effect = mock.sentinel.identity, mock.sentinel.previous_identity + + self.form.find_identities() + + self.assertEqual(self.form.identity, mock.sentinel.identity) + self.assertEqual(self.form.previous_identity, mock.sentinel.previous_identity) + mock_get_identity.assert_has_calls([ + mock.call(self.public_key), + mock.call(self.previous_public_key), + ]) + + @mock.patch(TESTING_MODULE + '.SQRLIdentity') + def test_get_identity(self, mock_sqrl_identity): + actual = self.form._get_identity(self.public_key) + + self.assertEqual( + actual, + mock_sqrl_identity.objects.filter.return_value.first.return_value + ) + mock_sqrl_identity.objects.filter.assert_called_once_with( + public_key=Base64.encode(self.public_key) + ) + + def test_get_identity_no_key(self): + self.assertIsNone(self.form._get_identity(None)) + + +class TestRandomPasswordUserCreationForm(unittest.TestCase): + def test_init(self): + self.assertIn('password1', PasswordLessUserCreationForm.base_fields) + self.assertIn('password2', PasswordLessUserCreationForm.base_fields) + + form = PasswordLessUserCreationForm() + + self.assertNotIn('password1', form.fields) + self.assertNotIn('password2', form.fields) + + def test_save(self): + form = PasswordLessUserCreationForm({'username': 'test'}) + + self.assertTrue(form.is_valid()) + + user = form.save() + + self.assertEqual(user.username, 'test') + self.assertTrue(user.password.startswith('!')) + + user.delete() diff --git a/sqrl/tests/test_managers.py b/sqrl/tests/test_managers.py new file mode 100644 index 0000000..8c9919d --- /dev/null +++ b/sqrl/tests/test_managers.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +import unittest + +import mock + +from ..managers import SQRLNutManager + + +class TestSQRLNutManager(unittest.TestCase): + @mock.patch.object(SQRLNutManager, 'get_queryset') + @mock.patch.object(SQRLNutManager, 'create') + def test_replace_or_create(self, mock_create, mock_getqueryset): + actual = SQRLNutManager().replace_or_create( + session_key=mock.sentinel.session_key, + nonce=mock.sentinel.nonce, + transaction_nonce=mock.sentinel.transaction_nonce, + is_transaction_complete=False, + ip_address=mock.sentinel.ip_address, + ) + + self.assertEqual(actual, mock_create.return_value) + mock_getqueryset.return_value.filter.assert_called_once_with( + session_key=mock.sentinel.session_key + ) + mock_getqueryset.return_value.filter.return_value.delete.assert_called_once_with() + mock_create.assert_called_once_with( + session_key=mock.sentinel.session_key, + nonce=mock.sentinel.nonce, + transaction_nonce=mock.sentinel.transaction_nonce, + is_transaction_complete=False, + ip_address=mock.sentinel.ip_address, + ) diff --git a/sqrl/tests/test_models.py b/sqrl/tests/test_models.py new file mode 100644 index 0000000..6934b83 --- /dev/null +++ b/sqrl/tests/test_models.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +import unittest + +import mock + +from ..managers import SQRLNutManager +from ..models import SQRLNut + + +TESTING_MODULE = 'sqrl.models' + + +class TestSQRLNut(unittest.TestCase): + def test_objects(self): + self.assertIsInstance(SQRLNut.objects, SQRLNutManager) + + def test_str(self): + self.assertEqual( + str(SQRLNut(nonce='nonce')), + 'nonce' + ) + + @mock.patch(TESTING_MODULE + '.generate_randomness') + @mock.patch.object(SQRLNut, 'delete') + @mock.patch.object(SQRLNut, 'save') + def test_renew(self, mock_save, mock_delete, mock_generate_randomness): + nut = SQRLNut(nonce='nonce') + + self.assertIsNone(nut.renew()) + self.assertEqual(nut.nonce, mock_generate_randomness.return_value) + mock_save.assert_called_once_with() + mock_generate_randomness.assert_called_once_with() diff --git a/sqrl/tests/test_response.py b/sqrl/tests/test_response.py new file mode 100644 index 0000000..9a341df --- /dev/null +++ b/sqrl/tests/test_response.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +import unittest +from collections import OrderedDict + +import mock +from django.test.utils import override_settings + +from ..crypto import HMAC +from ..response import SQRLHttpResponse +from ..utils import Encoder + + +TESTING_MODULE = 'sqrl.response' + + +class TestSQRLHttpResponse(unittest.TestCase): + def setUp(self): + super(TestSQRLHttpResponse, self).setUp() + self.data = OrderedDict([ + ('ver', 1), + ('nut', b'nonce'), + ('tif', '8'), + ('qry', '/sqrl/auth/?nut=nonce'), + ('sfn', 'Test Server'), + ]) + self.nut = mock.MagicMock(session_key='session') + + @override_settings(DEBUG=False) + def test_response(self): + response = SQRLHttpResponse(self.nut, self.data) + + expected_data = self.data.copy() + expected_data['mac'] = HMAC(self.nut, self.data).sign_data() + + self.assertEqual( + response.content, + Encoder.base64_dumps(expected_data).encode('ascii') + ) + self.assertEqual(response['Content-Length'], str(len(response.content))) + self.assertEqual(response['Content-Type'], 'application/sqrl') + + @override_settings(DEBUG=False) + def test_response_without_nut(self): + response = SQRLHttpResponse(None, self.data) + + self.assertEqual( + response.content, + Encoder.base64_dumps(self.data).encode('ascii') + ) + self.assertEqual(response['Content-Length'], str(len(response.content))) + self.assertEqual(response['Content-Type'], 'application/sqrl') + + @override_settings(DEBUG=True) + @mock.patch(TESTING_MODULE + '.log') + def test_response_debug(self, mock_log): + response = SQRLHttpResponse(self.nut, self.data) + + self.assertEqual(response['X-SQRL-ver'], '1') + self.assertEqual(response['X-SQRL-nut'], 'bm9uY2U') + self.assertEqual(response['X-SQRL-tif'], '8') + self.assertEqual(response['X-SQRL-qry'], '/sqrl/auth/?nut=nonce') + self.assertEqual(response['X-SQRL-sfn'], 'Test Server') + self.assertIn('X-SQRL-mac', response) diff --git a/sqrl/tests/test_sqrl.py b/sqrl/tests/test_sqrl.py new file mode 100644 index 0000000..56772bb --- /dev/null +++ b/sqrl/tests/test_sqrl.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +import unittest + +import mock + +from sqrl.managers import SQRLNutManager + +from ..sqrl import SQRLInitialization + + +TESTING_MODULE = 'sqrl.sqrl' + + +class TestSQRLInitialization(unittest.TestCase): + def test_init(self): + sqrl = SQRLInitialization(mock.sentinel.request, mock.sentinel.nut) + + self.assertEqual(sqrl.request, mock.sentinel.request) + self.assertEqual(sqrl.nut, mock.sentinel.nut) + + def test_get_or_create_session_key_exists(self): + m = mock.MagicMock() + self.assertEqual( + SQRLInitialization(m).get_or_create_session_key(), + m.session.session_key + ) + self.assertFalse(m.session.create.called) + + def test_get_or_create_session_key_create(self): + m = mock.MagicMock() + m.session.session_key = None + + self.assertEqual( + SQRLInitialization(m).get_or_create_session_key(), + m.session.session_key + ) + m.session.create.assert_called_once_with() + + @mock.patch.object(SQRLInitialization, 'generate_nut_kwargs') + @mock.patch.object(SQRLNutManager, 'replace_or_create') + def test_nut(self, mock_replace_or_create, mock_generate_nut_kwargs): + mock_generate_nut_kwargs.return_value = { + 'foo': 'bar' + } + + sqrl = SQRLInitialization(None) + + self.assertEqual(sqrl.nut, mock_replace_or_create.return_value) + mock_replace_or_create.assert_called_once_with(foo='bar') + mock_generate_nut_kwargs.assert_called_once_with() + + def test_nut_setter(self): + sqrl = SQRLInitialization(None) + + # sanity check + self.assertFalse(hasattr(sqrl, '_nut')) + + sqrl.nut = mock.sentinel.nut + + self.assertTrue(hasattr(sqrl, '_nut')) + self.assertEqual(sqrl._nut, mock.sentinel.nut) + self.assertEqual(sqrl.nut, mock.sentinel.nut) + + @mock.patch.object(SQRLInitialization, 'get_or_create_session_key') + @mock.patch(TESTING_MODULE + '.get_user_ip') + @mock.patch(TESTING_MODULE + '.generate_randomness') + def test_generate_nut_kwargs(self, mock_generate_randomness, mock_get_user_ip, + mock_get_or_create_session_key): + mock_generate_randomness.return_value = 'abc123' + + actual = SQRLInitialization(mock.sentinel.request).generate_nut_kwargs() + + self.assertDictEqual( + actual, { + 'session_key': mock_get_or_create_session_key.return_value, + 'nonce': 'abc', + 'transaction_nonce': '123', + 'is_transaction_complete': False, + 'ip_address': mock_get_user_ip.return_value, + } + ) + mock_generate_randomness.assert_called_once_with(64) + mock_get_user_ip.assert_called_once_with(mock.sentinel.request) + + @mock.patch(TESTING_MODULE + '.reverse') + def test_get_sqrl_url(self, mock_reverse): + actual = SQRLInitialization(None).get_sqrl_url() + + self.assertEqual(actual, mock_reverse.return_value) + + def test_get_sqrl_url_params(self): + actual = SQRLInitialization(None, mock.MagicMock(nonce='foo&bar')).get_sqrl_url_params() + + self.assertEqual(actual, 'nut=foo%26bar') + + @mock.patch.object(SQRLInitialization, 'get_sqrl_url_params') + @mock.patch.object(SQRLInitialization, 'get_sqrl_url') + def test_url(self, mock_get_sqrl_url, mock_get_sqrl_url_params): + mock_get_sqrl_url.return_value = '/sqrl/auth/' + mock_get_sqrl_url_params.return_value = 'nut=nonce' + + actual = SQRLInitialization(None).url + + self.assertEqual(actual, '/sqrl/auth/?nut=nonce') + + @mock.patch.object(SQRLInitialization, 'url', new_callable=mock.PropertyMock) + def test_sqrl_url(self, mock_url): + mock_url.return_value = '/sqrl/auth/?nut=nonce' + + request = mock.MagicMock() + request.is_secure.return_value = True + request.get_host.return_value = 'example.com:8000' + + actual = SQRLInitialization(request).sqrl_url + + self.assertEqual(actual, 'sqrl://example.com:8000/sqrl/auth/?nut=nonce') diff --git a/sqrl/tests/test_urls.py b/sqrl/tests/test_urls.py new file mode 100644 index 0000000..2d3c982 --- /dev/null +++ b/sqrl/tests/test_urls.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +from .. import urls # noqa + + +# nothing to test here however simply importing +# url module evals urls so coverage is higher diff --git a/sqrl/tests/test_utils.py b/sqrl/tests/test_utils.py new file mode 100644 index 0000000..33910a6 --- /dev/null +++ b/sqrl/tests/test_utils.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +import unittest +from collections import OrderedDict + +import mock + +from ..utils import Base64, Encoder, get_user_ip + + +TESTING_MODULE = 'sqrl.utils' + + +class TestBase64(unittest.TestCase): + def test_encode(self): + value = Base64.encode(b'hello') + + # normal base64 is aGVsbG8= however = should be missing + self.assertEqual(value, 'aGVsbG8') + self.assertIsInstance(value, str) + + def test_encode_not_binary(self): + with self.assertRaises(AssertionError): + Base64.encode('hello') + + def test_decode(self): + value = Base64.decode('aGVsbG8') + + # normal base64 is aGVsbG8= however = should be missing + self.assertEqual(value, b'hello') + self.assertIsInstance(value, bytes) + + def test_decode_not_unicode(self): + with self.assertRaises(AssertionError): + Base64.decode(b'aGVsbG8') + + +class TestEncode(unittest.TestCase): + def test_base64_dumps(self): + self.assertEqual(Encoder.base64_dumps(5), '5') + self.assertEqual(Encoder.base64_dumps('hello'), 'hello') + self.assertEqual(Encoder.base64_dumps(b'hello'), 'aGVsbG8') + self.assertEqual(Encoder.base64_dumps([b'hello', 'hello']), 'aGVsbG8~hello') + self.assertEqual( + Encoder.base64_dumps(OrderedDict([('a', b'hello'), ('b', 'hello')])), + 'YT1hR1ZzYkc4DQpiPWhlbGxvDQo' + ) + + def test_dumps(self): + self.assertEqual(Encoder.dumps(5), '5') + self.assertEqual(Encoder.dumps('hello'), 'hello') + self.assertEqual(Encoder.dumps(b'hello'), 'aGVsbG8') + self.assertEqual(Encoder.dumps([b'hello', 'hello']), 'aGVsbG8~hello') + self.assertEqual(Encoder.dumps(OrderedDict()), '') + self.assertEqual( + Encoder.dumps(OrderedDict([('a', b'hello'), ('b', 'hello')])), + 'a=aGVsbG8\r\nb=hello\r\n' + ) + + def test_normalize(self): + self.assertEqual(Encoder.normalize(b'hello'), 'aGVsbG8') + self.assertEqual(Encoder.normalize('hello'), 'hello') + self.assertEqual(Encoder.normalize(5), '5') + self.assertEqual(Encoder.normalize([b'hello', 'hello']), ['aGVsbG8', 'hello']) + self.assertEqual(Encoder.normalize(OrderedDict()), '') + self.assertEqual( + Encoder.normalize(OrderedDict([('a', b'hello'), ('b', 'hello')])), + OrderedDict([('a', 'aGVsbG8'), ('b', 'hello')]) + ) + + +class TestUtils(unittest.TestCase): + def test_get_user_ip(self): + request = mock.MagicMock(META={'REMOTE_ADDR': mock.sentinel.ip}) + + self.assertEqual(get_user_ip(request), mock.sentinel.ip) + + def test_get_user_ip_proxy(self): + request = mock.MagicMock(META={'HTTP_X_REAL_IP': mock.sentinel.ip}) + + self.assertEqual(get_user_ip(request), mock.sentinel.ip) diff --git a/sqrl/tests/test_views.py b/sqrl/tests/test_views.py new file mode 100644 index 0000000..f980a4e --- /dev/null +++ b/sqrl/tests/test_views.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +import json +import unittest + +import mock +from django import test +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core import serializers +from django.urls import reverse +from django.http import Http404, QueryDict +from django.views.generic import FormView + +from ..models import SQRLIdentity, SQRLNut +from ..views import ( + SQRL_IDENTITY_SESSION_KEY, + SQRLCompleteRegistrationView, + SQRLStatusView, +) + + +TESTING_MODULE = 'sqrl.views' + + +class TestSQRLStatusView(test.TestCase): + def setUp(self): + super(TestSQRLStatusView, self).setUp() + self.view = SQRLStatusView() + self.view.request = mock.MagicMock(GET={}) + self.view.request.is_ajax.return_value = True + self.view.kwargs = { + 'transaction': '123', + } + + self.nut = SQRLNut.objects.create( + nonce='hello', + transaction_nonce='123', + ip_address='127.0.0.1', + is_transaction_complete=True, + ) + + def tearDown(self): + self.nut and self.nut.delete() + super(TestSQRLStatusView, self).tearDown() + + def test_get_success_url(self): + self.assertEqual( + self.view.get_success_url(), + settings.LOGIN_REDIRECT_URL + ) + + def test_get_success_url_from_querystring(self): + self.view.request.GET['url'] = '?next={}'.format(reverse('sqrl:login')) + + self.assertEqual( + self.view.get_success_url(), + reverse('sqrl:login'), + ) + + def test_get_success_url_complete_registration(self): + self.view.request.GET['url'] = '?next={}'.format(reverse('sqrl:login')) + self.view.request.user.is_authenticated.return_value = False + self.view.request.session = {SQRL_IDENTITY_SESSION_KEY: ''} + + self.assertEqual( + self.view.get_success_url(), + reverse('sqrl:complete-registration') + '?next={}'.format(reverse('sqrl:login')), + ) + + def test_get_object_404(self): + self.nut.delete() + self.nut = None + + with self.assertRaises(Http404): + self.view.get_object() + + def test_get_object(self): + actual = self.view.get_object() + + self.assertIsInstance(actual, SQRLNut) + self.assertEqual(actual.nonce, self.nut.nonce) + self.assertEqual(actual.transaction_nonce, self.nut.transaction_nonce) + + def test_post(self): + response = self.view.post(self.view.request, self.nut.transaction_nonce) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['content-type'], 'application/json') + self.assertEqual(json.loads(response.content.decode('utf-8')), { + 'transaction_complete': True, + 'redirect_to': settings.LOGIN_REDIRECT_URL, + }) + + def test_post_not_ajax(self): + self.view.request.is_ajax.return_value = False + + response = self.view.post(self.view.request, self.nut.transaction_nonce) + + self.assertEqual(response.status_code, 405) + + +class TestSQRLAuthView(test.TestCase): + pass + + +class TestCompleteRegistrationView(test.TestCase): + def setUp(self): + super(TestCompleteRegistrationView, self).setUp() + self.view = SQRLCompleteRegistrationView() + self.username = 'foobartest' + self.view.request = mock.MagicMock( + method='POST', + POST={ + 'username': self.username, + }, + ) + self.identity = SQRLIdentity( + public_key='a' * 43, + verify_unlock_key='b' * 43, + server_unlock_key='c' * 43, + is_enabled=True, + is_only_sqrl=False, + ) + self.view.request.session = { + SQRL_IDENTITY_SESSION_KEY: serializers.serialize('json', [self.identity]), + } + + def tearDown(self): + SQRLIdentity.objects.filter(public_key=self.identity.public_key).delete() + get_user_model().objects.filter(username=self.username).delete() + super(TestCompleteRegistrationView, self).tearDown() + + def test_check_session_for_sqrl_identity_or_404(self): + self.assertIsNone(self.view.check_session_for_sqrl_identity_or_404()) + + def test_check_session_for_sqrl_identity_or_404_raises(self): + self.view.request.session = {} + + with self.assertRaises(Http404): + self.view.check_session_for_sqrl_identity_or_404() + + @mock.patch.object(SQRLCompleteRegistrationView, 'check_session_for_sqrl_identity_or_404') + @mock.patch.object(FormView, 'get') + def test_get(self, mock_super_get, mock_check_session_for_sqrl_identity_or_404): + response = self.view.get(self.view.request) + + self.assertEqual(response, mock_super_get.return_value) + mock_check_session_for_sqrl_identity_or_404.assert_called_once_with() + + @mock.patch.object(SQRLCompleteRegistrationView, 'check_session_for_sqrl_identity_or_404') + @mock.patch.object(FormView, 'post') + def test_post(self, mock_super_post, mock_check_session_for_sqrl_identity_or_404): + response = self.view.post(self.view.request) + + self.assertEqual(response, mock_super_post.return_value) + mock_check_session_for_sqrl_identity_or_404.assert_called_once_with() + + def test_get_success_url(self): + self.assertEqual( + self.view.get_success_url(), + settings.LOGIN_REDIRECT_URL + ) + + def test_get_success_url_from_querystring(self): + self.view.request.GET = {'next': reverse('sqrl:manage')} + + self.assertEqual( + self.view.get_success_url(), + reverse('sqrl:manage'), + ) + + @mock.patch(TESTING_MODULE + '.login') + def test_form_valid(self, mock_login): + form = self.view.get_form(self.view.get_form_class()) + form.is_valid() + + # sanity checks + self.assertFalse(get_user_model().objects.filter(username=self.username).count()) + self.assertFalse(SQRLIdentity.objects.filter(public_key=self.identity.public_key).count()) + + response = self.view.form_valid(form) + user = get_user_model().objects.filter(username=self.username).first() + + self.assertIsNotNone(user) + self.assertEqual(user.username, self.username) + self.assertIsInstance(user.sqrl_identity, SQRLIdentity) + self.assertEqual(user.sqrl_identity.public_key, self.identity.public_key) + self.assertEqual(response.status_code, 302) + + def test_form_valid_could_not_decode_identity(self): + self.view.request.session[SQRL_IDENTITY_SESSION_KEY] = '' + response = self.view.form_valid(self.view.get_form(self.view.get_form_class())) + + self.assertEqual(response.status_code, 500) diff --git a/sqrl/urls.py b/sqrl/urls.py index 9a4b603..86d9c4d 100644 --- a/sqrl/urls.py +++ b/sqrl/urls.py @@ -9,10 +9,12 @@ from .views import ( SQRLStatusView, ) +app_name = "sqrl" + urlpatterns = [ path("auth/", SQRLAuthView.as_view(), name="auth"), path("login/", SQRLLoginView.as_view(), name="login"), - path("manage/", SQRLIdentityManagementView.as_view(), name='manage') + path("manage/", SQRLIdentityManagementView.as_view(), name='manage'), path("register/",SQRLCompleteRegistrationView.as_view(), name='complete-registration'), re_path(r"^status/(?P[A-Za-z0-9_-]{43})/$", SQRLStatusView.as_view(), name='status'), ] diff --git a/sqrl/utils.py b/sqrl/utils.py index 7f26be6..c5a34b0 100644 --- a/sqrl/utils.py +++ b/sqrl/utils.py @@ -18,7 +18,7 @@ class Base64(object): s: bytes Bytes string to be encoded as base64 """ - assert isinstance(s, (bites, bytearray)) + assert isinstance(s, (bytes, bytearray)) return urlsafe_b64encode(s).decode('ascii').rstrip('=') @classmethod diff --git a/sqrl/views.py b/sqrl/views.py index 4d3a199..016f185 100644 --- a/sqrl/views.py +++ b/sqrl/views.py @@ -13,7 +13,7 @@ from django.contrib.auth import ( login, ) from django.core import serializers -from django.url import reverse +from django.urls import reverse from django.http import Http404, HttpResponse, JsonResponse, QueryDict from django.shortcuts import get_object_or_404, redirect from django.views.generic import FormView, TemplateView, View @@ -23,7 +23,6 @@ from .exceptions import TIF, TIFException from .forms import ( AuthQueryDictForm, ExtractedNextUrlForm, - GenerateQRForm, NextUrlForm, PasswordLessUserCreationForm, RequestForm, @@ -31,7 +30,7 @@ from .forms import ( from .models import SQRLIdentity, SQRLNut from .response import SQRLHttpResponse from .sqrl import SQRLInitialization -from .utils import Base64, QRGenerator, get_user_ip +from .utils import Base64, get_user_ip SQRL_IDENTITY_SESSION_KEY = '_sqrl_identity'