From 3cfd9840e832511c84f5ce099286fcea4af8e569 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 2 Sep 2019 02:03:23 -0500 Subject: [PATCH] Still building initial conversions --- CHANGELOG | 16 + sqrl/exceptions.py | 136 +++ sqrl/fields.py | 212 +++++ sqrl/forms.py | 442 +++++++++ sqrl/management/__init__.py | 2 + sqrl/management/commands/__init__.py | 2 + sqrl/management/commands/clearsqrlnuts.py | 21 + sqrl/response.py | 66 ++ sqrl/sqrl.py | 2 - sqrl/static/admin/sqrl.css | 82 ++ sqrl/static/sqrl/sqrl.js | 850 ++++++++++++++++++ .../admin/auth/user/sqrl_manage.html | 85 ++ sqrl/templates/admin/base.html | 19 + sqrl/templates/admin/login.html | 91 ++ sqrl/templates/sqrl/login.html | 27 + sqrl/templates/sqrl/manage.html | 54 ++ sqrl/templates/sqrl/register.html | 30 + sqrl/templates/sqrl/sqrl-dropin.html | 41 + sqrl/templates/sqrl/sqrl_base.html | 1 + sqrl/templatetags/sqrl.py | 44 + sqrl/urls.py | 18 + sqrl/utils.py | 24 +- sqrl/views.py | 681 +++++++++++++- 23 files changed, 2925 insertions(+), 21 deletions(-) create mode 100644 sqrl/exceptions.py create mode 100644 sqrl/fields.py create mode 100644 sqrl/forms.py create mode 100644 sqrl/management/__init__.py create mode 100644 sqrl/management/commands/__init__.py create mode 100644 sqrl/management/commands/clearsqrlnuts.py create mode 100644 sqrl/response.py create mode 100644 sqrl/static/admin/sqrl.css create mode 100644 sqrl/static/sqrl/sqrl.js create mode 100644 sqrl/templates/admin/auth/user/sqrl_manage.html create mode 100644 sqrl/templates/admin/base.html create mode 100644 sqrl/templates/admin/login.html create mode 100644 sqrl/templates/sqrl/login.html create mode 100644 sqrl/templates/sqrl/manage.html create mode 100644 sqrl/templates/sqrl/register.html create mode 100644 sqrl/templates/sqrl/sqrl-dropin.html create mode 100644 sqrl/templates/sqrl/sqrl_base.html create mode 100644 sqrl/templatetags/sqrl.py diff --git a/CHANGELOG b/CHANGELOG index 214ee35..e394ff7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,19 @@ +Mon, 02 Sep 2019 02:03:23 -0500 +Keaton +Still building initial conversions + +Everything is still completely untested. These conversions were done a few weeks +ago, so my memory on any design changes is a little rusty. Aside from what's +listed below, all changes are just compatibility fixes. + +- Removed references to six since we're dropping Python 2 support +- Removed references to QR code urls since those will be browser generated +- Added template for creating a drop-in SQRL login element + - Also changed some variable names to be less confusing +- Changed login pages to use drop-in element + +-------------------- + Wed, 14 Aug 2019 12:14:52 -0500 Keaton fix typo diff --git a/sqrl/exceptions.py b/sqrl/exceptions.py new file mode 100644 index 0000000..60145a7 --- /dev/null +++ b/sqrl/exceptions.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- + +def _make_tif_property(val): + """ + Helper function for generating property methods for :obj:`.TIF` + which will boolean whether a particular SQRL ``TIF`` bit is ``True`` + in the ``TIF`` value. + Parameters + ---------- + val : int + Value with particular ``True`` bit which will be tested + within the generated property. + Returns + ------- + function + Function which can be made into a property + """ + + def is_bit_present(self): + """ + Property which returns boolean whether ``{hex}`` or ``{bits}`` + bit is present in the TIF value. + """ + return bool(self & val) + + is_bit_present.__doc__ = is_bit_present.__doc__.format( + hex=hex(val), + bits=bin(val), + ) + + return is_bit_present + + +class TIF(int): + """ + SQRL ``TIF`` ``int`` subclass which can represent SQRL ``TIF`` flags. + Example + ------- + :: + >>> tif = TIF(TIF.IP_MATCH | TIF.TRANSIENT_FAILURE | TIF.COMMAND_FAILED) + >>> tif.is_ip_match + True + >>> tif.is_id_match + False + >>> tif.is_transient_failure + True + >>> tif + 100 + >>> tif.as_hex_string() + '64' + >>> tif.breakdown() == { + ... 'id_match': False, + ... 'previous_id_match': False, + ... 'ip_match': True, + ... 'sqrl_disabled': False, + ... 'not_supported': False, + ... 'transient_failure': True, + ... 'command_failed': True, + ... 'client_failure': False, + ... } + True + """ + ID_MATCH = 0x1 + """SQRL ID was found in DB""" + PREVIOUS_ID_MATCH = 0x2 + """Previous SQRL ID was found in DB""" + IP_MATCH = 0x4 + """SQRL client is used from same IP as where transaction started""" + SQRL_DISABLED = 0x8 + """SQRL auth is disabled for the found SQRL identity as per users request""" + NOT_SUPPORTED = 0x10 + """SQRL client requested SQRl operation which is not supported""" + TRANSIENT_FAILURE = 0x20 + """SQRL command failed transiently. Most likely restarting SQRL transaction should fix this""" + COMMAND_FAILED = 0x40 + """SQRL command failed for any reason""" + CLIENT_FAILURE = 0x80 + """SQRL command failed because SQRL client sent invalid data""" + BAD_ID_ASSOCIATION = 0x100 + """SQRL Identity is already a ssociated with a different account""" + + is_id_match = property(_make_tif_property(ID_MATCH)) + is_previous_id_match = property(_make_tif_property(PREVIOUS_ID_MATCH)) + is_ip_match = property(_make_tif_property(IP_MATCH)) + is_sqrl_disabled = property(_make_tif_property(SQRL_DISABLED)) + is_transient_failure = property(_make_tif_property(TRANSIENT_FAILURE)) + is_command_failed = property(_make_tif_property(COMMAND_FAILED)) + is_client_failure = property(_make_tif_property(CLIENT_FAILURE)) + is_not_supported = property(_make_tif_property(NOT_SUPPORTED)) + is_bad_id_association = property(_make_tif_property(BAD_ID_ASSOCIATION)) + + def as_hex_string(self): + """ + Return TIF value as hex string + """ + return '{:x}'.format(self) + + def breakdown(self): + """ + Returns a full breakdown of the TIF value. + Returns + ------- + dict + Keys are the SQRL TIF property and values are booleans. + """ + return { + k.lower(): bool(self & v) + for k, v in vars(self.__class__).items() + if not k.startswith('_') and k.isupper() + } + + def update(self, other): + """ + Return updated TIF which will contain both bits already set + in the ``self`` value as well as the ``other value. + Parameters + ---------- + other : int + Other ``TIF`` value which be merged with ``self`` bits + Returns + ------- + TIF + New :obj:`.TIF` value which has merged bits. + """ + return type(self)(self | other) + + +class TIFException(Exception): + """ + Custom Exception which can be used in the views to raise + specific :obj:`.TIF` bits and immediately return appropriate response + to the user. + """ + + def __init__(self, tif): + self.tif = TIF(tif) diff --git a/sqrl/fields.py b/sqrl/fields.py new file mode 100644 index 0000000..514dbd6 --- /dev/null +++ b/sqrl/fields.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +from collections import OrderedDict + +from django import forms +from django.urls import Resolver404, resolve +from django.core.validators import URLValidator +from django.http import QueryDict + +from urllib.parse import urlparse, unquote + +from .utils import Base64 + + +class NextUrlField(forms.CharField): + """ + Custom ``CharField`` which validates that a value is a valid next URL. + + It validates that by checking that the value can be resolved to a view + hence guaranteeing that when redirected URL will not fail. + """ + default_error_messages = { + 'invalid_url': 'Invalid next url.' + } + + def to_python(self, value): + """ + Validate that value is a valid URL for this project. + """ + value = super(NextUrlField, self).to_python(value) + + if value in self.empty_values: + return value + + path = urlparse(value).path + + try: + resolve(path) + except Resolver404: + raise forms.ValidationError(self.error_messages['invalid_url']) + else: + return path + + +class ExtractedNextUrlField(NextUrlField): + """ + Similar to :obj:`.NextUrlField` however this extracts next url from full encoded URL. + """ + default_error_messages = { + 'missing_next': 'Missing next query parameter.' + } + + def to_python(self, value): + """ + Extract next url from full URL string and then use :obj:`.NextUrlField` + to validate that value is valid URL. + """ + value = forms.CharField.to_python(self, value) + + if value in self.empty_values: + return value + + decoded = urlparse(unquote(value)) + data = QueryDict(decoded.query) + + if 'next' not in data: + raise forms.ValidationError(self.error_messages['missing_next']) + + return super(ExtractedNextUrlField, self).to_python(data['next']) + + +class SQRLURLValidator(URLValidator): + """ + Custom URL validator which validates that a URL is a valid SQRL url. + + These are the differences with regular HTTP URLs: + + * scheme is either sqrl (secure) and qrl (non-secure) + * ``:`` is a valid path separator which can be used to indicate + which section of the SQRL should be used to generate + public/provate keypair for the domain. + + Okay, but is there support for qrl schema??? + + """ + schemes = ['sqrl', 'qrl'] + # schemes = ['sqrl'] + + +class SQRLURLField(forms.URLField): + """ + SQRL URL field which uses :obj:`.SQRLURLValidator` for validation. + """ + default_validators = [SQRLURLValidator()] + + +class Base64Field(forms.CharField): + """ + Field which decodes base64 values using :meth:`.utils.Base64.decode`. + """ + default_error_messages = { + 'base64': 'Invalid value. Must be base64url encoded string.', + } + + def to_python(self, value): + """ + Decodes base64 value and returns binary data. + """ + value = super(Base64Field, self).to_python(value) + if not value: + return b'' + try: + return Base64.decode(value) + except (ValueError, TypeError): + raise forms.ValidationError(self.error_messages['base64']) + + +class Base64CharField(Base64Field): + """ + Similar to :obj:`.Base64Field` however this field normalizes to ``str`` (``unicode``) data. + """ + default_error_messages = { + 'base64_ascii': 'Invalid value. Must be ascii base64url encoded string.', + } + + def to_python(self, value): + """ + Returns base64 decoded data as string. + + Uses :meth:`.Base64Field.to_python` to decode base64 value + which returns binary data and then this method further + decodes ascii data to return ``str`` (``unicode``) data. + """ + value = super(Base64CharField, self).to_python(value) + if not value: + return '' + try: + return value.decode('ascii') + except UnicodeDecodeError: + raise forms.ValidationError(self.error_messages['base64_ascii']) + + +class Base64PairsField(Base64CharField): + """ + Field which normalizes base64 encoded multistring key-value pairs to ``OrderedDict``. + + Attributes + ---------- + always_pairs : bool + Boolean which enforces that the value must always be keypairs. + When ``False`` and the value is not a keypair, the value itself + is returned. + """ + default_error_messages = { + 'crlf': 'Invalid value. Must be multi-line string separated by CRLF.', + 'pairs': 'Invalid value. Must be multi-line string of pair of values.', + } + always_pairs = True + + def to_python(self, value): + """ + Normalizes multiline base64 keypairs string to ``OrderedDict``. + """ + value = super(Base64PairsField, self).to_python(value) + if not value: + return OrderedDict() + + if not value.endswith('\r\n'): + if self.always_pairs: + raise forms.ValidationError(self.error_messages['crlf']) + else: + return value + + try: + return OrderedDict( + line.split('=', 1) for line in filter(None, value.splitlines()) + ) + except ValueError: + raise forms.ValidationError(self.error_messages['pairs']) + + +class Base64ConditionalPairsField(Base64PairsField): + """ + Similar to :obj:`.Base64PairsField` but this field does not force + the value to be keypairs. + """ + always_pairs = False + + +class TildeMultipleValuesField(forms.CharField): + """ + Field which returns tilde-separated list. + """ + + def to_python(self, value): + """ + Normalizes to a Python list by splitting string by tilde (~) delimiter. + """ + value = super(TildeMultipleValuesField, self).to_python(value) + if not value: + return [] + return value.split('~') + + +class TildeMultipleValuesFieldChoiceField(TildeMultipleValuesField, forms.ChoiceField): + """ + Similar to :obj:`.TildeMultipleValuesField` however this field also validates + each value to be a valid choice. + """ + + def validate(self, value): + for i in value: + super(TildeMultipleValuesFieldChoiceField, self).validate(i) diff --git a/sqrl/forms.py b/sqrl/forms.py new file mode 100644 index 0000000..a595032 --- /dev/null +++ b/sqrl/forms.py @@ -0,0 +1,442 @@ +# -*- coding: utf-8 -*- +from django import forms +from django.contrib.auth import SESSION_KEY, get_user_model +from django.contrib.auth.forms import UserCreationForm +from django.contrib.sessions.middleware import SessionMiddleware + +from .crypto import HMAC, Ed25519 +from .exceptions import TIF +from .fields import ( + Base64ConditionalPairsField, + Base64Field, + Base64PairsField, + ExtractedNextUrlField, + NextUrlField, + SQRLURLField, + TildeMultipleValuesField, +) +from .models import SQRLIdentity +from .utils import Base64 + + +class NextUrlForm(forms.Form): + """ + Form which validates that next URL is a valid URL. + Currently is used by :obj:`.views.SQRLCompleteRegistrationView` to validate + next URL to which user will be redirected when registration is completed. + """ + next = NextUrlField() + + +class ExtractedNextUrlForm(forms.Form): + """ + Form which extracts and validates next URL from a full URL. + Currently is used by :obj:`.views.SQRLStatusView` to return an appropriate + URL to the JS to which JS will redirect to when SQRL transaction is complete. + The reason we want to extract the next url here vs js is for simplicity + of the js code. Now js can simply find ```` or if not present + pass current full URL which might contain ``?next=`` to the server. + Python then can extract next url when present and if valid, return appropriate + URL for the user to redirect to. + """ + url = ExtractedNextUrlField() + + +class AuthQueryDictForm(forms.Form): + """ + Form to validate the ``request.GET`` in :obj:`.views.SQRLAuthView`. + This allows to validate that the nut is of expected length and matches + particular regex pattern before we attempt to look it up in the db. + Normally this would be validated by a url pattern however in SQRL, + nut is sent as a querystring parameter which is outside of the scope + of URL pattern matching in Django. + """ + nut = forms.SlugField(max_length=43) + + +class ClientForm(forms.Form): + """ + Form used to validate client portion of the SQRL request payload. + Since this form is used as nested form by :obj:`.RequestForm` + and therefore it does not have access to the signatures, this form + only validates the values themselves. :obj:`.RequestForm` takes care + of all conditional validations such as validating signatures + when some keys are present in this form. + """ + ver = forms.IntegerField(label='Version', min_value=1, max_value=1) + cmd = TildeMultipleValuesField(label='Command') + opt = TildeMultipleValuesField(label='Options', required=False) + idk = Base64Field(label='Identity Key') + pidk = Base64Field(label='Previous Identity Key', required=False) + suk = Base64Field(label='Server Unlock Key', required=False) + vuk = Base64Field(label='Verify Unlock Key', required=False) + + +class RequestForm(forms.Form): + """ + This is a main form for validating SQRL request payload. + This form uses uses :obj:`.ClientForm` for validating client portion of the request. + Therefore some of the validation portions by the time they are executed, + they expect client form to be validated so that they can lookup client values. + Currently this does not require any *magic* solutions since ``client`` + is first defined field which means it will be validated first. + Parameters + ---------- + nut : SQRLNut + Nut for which this form will be validated + """ + client = Base64PairsField() + server = Base64ConditionalPairsField() + ids = Base64Field() + pids = Base64Field(required=False) + urs = Base64Field(required=False) + + def __init__(self, nut, *args, **kwargs): + self.nut = nut + self.session = None + self.identity = None + self.previous_identity = None + self.tif = TIF(0) + super(RequestForm, self).__init__(*args, **kwargs) + + def clean_client(self): + """ + Since Django forms dont support nested forms, this method is used + to do that in ad-hoc fashion. + By the time this method is called, we know that ``client`` data will be + ``OrderedDict``, as guaranteed by :obj:`.fields.Base64PairsField`. + We use that information and simply pass the client data to the + :obj:`.ClientForm`. If validated, :obj:`.ClientForm` cleaned data will be + a dictionary which we then simply return in this method. If not validated, + we simply raise validation exception here which stop the rest of the + validation steps. + """ + client_form = ClientForm(data=self.cleaned_data['client']) + + if not client_form.is_valid(): + raise forms.ValidationError(client_form.errors) + + return client_form.cleaned_data + + def clean_server(self): + """ + This method conditionally validates ``server`` field. + The reason why we validate it conditionally is because on initial SQRL + request, client only sends initial SQRL URL since does not have access + to the server response yet which would be a dict. In that case we do not + validate server field at all. + .. note:: + Even though on initial SQRL request, we dont validate server field, + it is still indirectly validated against tampering via signatures. + When we do validate it, we check the following: + * nut value matches the looked up nut nonce + (this should ever happen but just in case) + * validate that ``mac`` is present in the server dict + * validate the ``mac`` value that is correctly signs the server response + excluding ``mac`` value itself. + """ + server = self.cleaned_data['server'] + + if not isinstance(server, dict): + return server + + if server.get('nut') != self.nut.nonce: + raise forms.ValidationError('Nut mismatch between server value and looked up nut.') + + if 'mac' not in server: + raise forms.ValidationError('Missing server signature.') + + try: + is_valid_signature = HMAC(self.nut, server).is_signature_valid( + Base64.decode(server['mac']) + ) + except (ValueError, TypeError): + is_valid_signature = False + + if not is_valid_signature: + raise forms.ValidationError('Invalid server signature.') + + return server + + def _validate_signature(self, name, key, signature): + client = self.data['client'] + server = self.data['server'] + msg = (client + server).encode('ascii') + + if not Ed25519(key, None, msg).is_signature_valid(signature): + raise forms.ValidationError('Invalid {} signature.'.format(name)) + + def clean_ids(self): + """ + Validate SQRL ID signature which always must be present. + This validates that the ID signature correctly signed raw client + raw server + concatenated base64 encoded strings. + """ + idk = self.cleaned_data['client']['idk'] + ids = self.cleaned_data['ids'] + + self._validate_signature('ids', idk, ids) + + return ids + + def clean_pids(self): + """ + Validate Previous SQRL ID signature when present. + This validates: + * if either previous ID or previous signature are present, both are required + * previous ID signature correctly signed raw client + raw server + concatenated base64 encoded strings. + """ + pidk = self.cleaned_data['client'].get('pidk') + pids = self.cleaned_data.get('pids') + + if not all((pids, pidk)): + raise forms.ValidationError( + 'Cannot validate previous ID signature without server knowing pid public and secret keys.' + ) + + if pidk and pids: + self._validate_signature('pids', pidk, pids) + + return pids + + def _clean_urs(self): + vuk = getattr(self.identity, 'verify_unlock_key', None) + suk = getattr(self.identity, 'server_unlock_key', None) + + urs = self.cleaned_data.get('urs') + + if urs: + if not all((vuk, suk)): + raise forms.ValidationError( + 'Cannot validate urs signature without server knowing vuk and suk keys.' + ) + + self._validate_signature('urs', Base64.decode(vuk), urs) + + return urs + + def _clean_client_cmd(self): + client = self.cleaned_data['client'] + cmds = client['cmd'] + + for cmd in cmds: + method_name = '_clean_client_cmd_{}'.format(cmd) + if hasattr(self, method_name): + getattr(self, method_name)(client) + + def _clean_client_cmd_ident(self, client): + suk = client.get('suk') + vuk = client.get('vuk') + + if not self.identity and not all([suk, vuk]): + raise forms.ValidationError( + 'Missing suk or vuk which are required when creating new identity.' + ) + + if self.identity and any([suk, vuk]): + raise forms.ValidationError( + 'Cannot send suk or vuk when SQRL identity is already associated.' + ) + + if 'disable' in client['cmd']: + raise forms.ValidationError( + 'Cannot use disable command at the same time as ident command.' + ) + + # since we only store a single identity at the time + # its impossible for when identity is being changed + # self.identity will exist since by definition server + # should only be aware of the previous identity + # since the client is sending a new identity for storage. + if all((not self.identity, + self.previous_identity, + not self.cleaned_data.get('urs'))): + raise forms.ValidationError( + 'Must supply urs (unlock request signature) when switching identities ' + 'from previously stored identity (pidk) to new current identity (idk).' + ) + + def _clean_client_cmd_disable(self, client): + if not self.identity: + raise forms.ValidationError( + 'Must have identity associated in order to disable SQRL.' + ) + + if 'enable' in client['cmd']: + raise forms.ValidationError( + 'Cannot use enable command at the same time as disable command.' + ) + + def _clean_client_cmd_enable(self, client): + if not self.identity: + raise forms.ValidationError( + 'Must have identity associated in order to enable SQRL.' + ) + + if not self.cleaned_data.get('urs'): + raise forms.ValidationError( + 'Must supply urs (unlock request signature) to enable SQRL access.' + ) + + if 'disable' in client['cmd']: + raise forms.ValidationError( + 'Cannot use disable command at the same time as enable command.' + ) + + def _clean_client_cmd_remove(self, client): + if not self.identity: + raise forms.ValidationError( + 'Must have identity associated in order to remove SQRL.' + ) + + if not self.cleaned_data.get('urs'): + raise forms.ValidationError( + 'Must supply urs (unlock request signature) to enable SQRL access.' + ) + + if client['cmd'] != ['remove']: + raise forms.ValidationError( + 'Cannot use any other commands at the same time as remove command.' + ) + + def _clean_session(self): + if not self.session: + return + + user_id = self.session.get(SESSION_KEY) + if not user_id: + return + + try: + user_id = int(user_id) + except ValueError: + return + + if self.identity and self.identity.user_id != user_id: + self.tif = self.tif.update(TIF.BAD_ID_ASSOCIATION) + raise forms.ValidationError( + 'Cannot use SQRL within existing authenticated session' + 'when SQRL identity is already associated with a different account' + ) + + user = get_user_model().objects.filter(pk=user_id).first() + + try: + if not user or not user.sqrl_identity: + return + except SQRLIdentity.DoesNotExist: + return + + # We want to make sure that if the user is logged in + # and already has an identity associated with the account, + # that either current or previous identity supplied in the + # client request matches the identity already associated + # with the account. + # That will force the user to go through the SQRL identity + # [un]lock processes to change the sqrl identity + # (e.g. force the user to load the identity unlock key + # and use that to change identities). + # If this condition is not checked, it will be possible + # for any SQRL identity to overwrite existing identity, + # without any additional checks, which is not desirable. + # For example if the existing auth scheme (e.g. username+password) + # gets compromised, it will be possible for malicious party to + # associate their SQRL identity with the account hence + # locking out legitimate account owner. + idk = Base64.encode(self.cleaned_data['client']['idk']) + pidk = Base64.encode(self.cleaned_data['client'].get('pidk', b'')) + + if user.sqrl_identity.public_key not in [idk, pidk]: + self.tif = self.tif.update(TIF.BAD_ID_ASSOCIATION) + raise forms.ValidationError( + 'Both current and previous identities do not match user\'s already ' + 'associated SQRL identity. If the identity needs to be changed, ' + 'SQRL identity unlock processes must be followed.' + ) + + def clean(self): + """ + Assuming all fields successfully validated, this method validates form as a whole. + Also this field does any db lookups such as retrieving SQRL identities. + That is necessary for validation however since lookup already happens here, + the :obj:`.views.SQRLAuthView` reuses the looked up values so that it + does not have to lookup same objects twice. + This method does the following (sometimes using private methods): + #. lookups identities - both current and previous + #. validates client commands (``client.cmd``) such as ``ident``. + They can only be validated when identities are looked up. + For example ``disable`` cannot be requested when no SQRL identity is found. + #. validates ``urs`` (unlock request signature). + That can only be done here as this requires to use data stored + in the stored identity. + #. lookups client session (where user will be logged in) + #. validates session. For example if the user is already logged with existing + matching SQRL identity, this validates that only stored SQRL identity + public key can be used in SQRL transaction. + """ + cleaned_data = super(RequestForm, self).clean() + + self.find_identities() + self._clean_client_cmd() + self._clean_urs() + + self.find_session() + self._clean_session() + + return cleaned_data + + def find_session(self): + """ + This method finds the session where SQRL transaction was initiated. + This is the session where user potentially will be signed in. + """ + self.session = SessionMiddleware().SessionStore(self.nut.session_key) + + def find_identities(self): + """ + This method finds both current and previous SQRL identities. + At most only one of them should be found. + """ + self.identity = self._get_identity(self.cleaned_data['client']['idk']) + self.previous_identity = self._get_identity(self.cleaned_data['client'].get('pidk')) + + def _get_identity(self, key): + if not key: + return None + + return SQRLIdentity.objects.filter( + public_key=Base64.encode(key) + ).first() + + +class PasswordLessUserCreationForm(UserCreationForm): + """ + Form for creating user account without password. + This form is used when user successfully completes SQRL transaction + however does not yet have a user account. Since they already successfully + used SQRL, this implies that they they prefer to use SQRL over + username/password. Therefore we simply create a user with unusable + password using Django's ``set_unusable_password`` capability. + """ + + def __init__(self, *args, **kwargs): + super(PasswordLessUserCreationForm, self).__init__(*args, **kwargs) + # loop over all the fields and remove all password fields + # by default this removes both password and verify_password fields + for field in list(self.fields): + if 'password' in field: + self.fields.pop(field) + + def save(self, commit=True): + """ + Custom user save implementation which saves user with unusable password. + The implementation is very similar to how ``UserCreationForm`` saves + a user model, except this method uses :meth:`AbstractBaseUser.set_unusable_password` + to set a users password field. + """ + user = super(UserCreationForm, self).save(commit=False) + user.set_unusable_password() + if commit: + user.save() + return user diff --git a/sqrl/management/__init__.py b/sqrl/management/__init__.py new file mode 100644 index 0000000..8f4a3fc --- /dev/null +++ b/sqrl/management/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from __future__ import print_function, unicode_literals diff --git a/sqrl/management/commands/__init__.py b/sqrl/management/commands/__init__.py new file mode 100644 index 0000000..8f4a3fc --- /dev/null +++ b/sqrl/management/commands/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from __future__ import print_function, unicode_literals diff --git a/sqrl/management/commands/clearsqrlnuts.py b/sqrl/management/commands/clearsqrlnuts.py new file mode 100644 index 0000000..ff838d7 --- /dev/null +++ b/sqrl/management/commands/clearsqrlnuts.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from datetime import timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils.timezone import now + +from sqrl.models import SQRLNut + + +class Command(BaseCommand): + help = ('Clears expired SQRL nuts. ' + 'This command should be used as a cron job. ' + 'The recommended execution frequency is 5 minutes ' + 'which will result in longest nut lifespan of 10 minutes.') + + def handle(self, *args, **options): + ttl = getattr(settings, 'SQRL', {}).get('TTL', 60 * 5) # 5 minutes + delete_before = now() + timedelta(seconds=-ttl) + + SQRLNut.objects.filter(timestamp__lt=delete_before).delete() diff --git a/sqrl/response.py b/sqrl/response.py new file mode 100644 index 0000000..dd4c5f8 --- /dev/null +++ b/sqrl/response.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +import logging +from pprint import pformat + +from django.conf import settings +from django.http import HttpResponse + +from .crypto import HMAC +from .exceptions import TIF +from .utils import Encoder + + +log = logging.getLogger(__name__) + + +class SQRLHttpResponse(HttpResponse): + """ + Custom ``HTTPResponse`` class used to return SQRL-formatted response. + + The response is automatically signed, normalized and encoded + as per SQRL specification. + + This view also adds a couple of DEBUG logs for easier SQRL debugging + and also returns all SQRL data back as ``X-SQRL-*`` headers. + + Parameters + ---------- + nut : SQRLNut + Nut which will be used to sign the response data. + data : OrderedDict + Data to be returned back to the user. + """ + + def __init__(self, nut, data, *args, **kwargs): + normalized_data = Encoder.normalize(self.sign_response(nut, data)) + content = Encoder.base64_dumps(normalized_data) + + kwargs.setdefault('content_type', 'application/sqrl') + + super(SQRLHttpResponse, self).__init__(content, *args, **kwargs) + + self['Content-Length'] = len(self.content) + + if settings.DEBUG: + for k, v in normalized_data.items(): + self['X-SQRL-{}'.format(k)] = v + + log.debug('Response encoded data:\n{}' + ''.format(content)) + log.debug('Response data:\n{}' + ''.format(pformat(normalized_data))) + log.debug('Response TIF breakdown:\n{}' + ''.format(pformat(TIF(int(data['tif'], 16)).breakdown()))) + + def sign_response(self, nut, data): + """ + When nut is present, this method signs the data by adding ``mac`` key. + + For signing :meth:`.crypto.HMAC.sign_data` is used. + """ + if not nut: + return data + + data['mac'] = HMAC(nut, data).sign_data() + + return data diff --git a/sqrl/sqrl.py b/sqrl/sqrl.py index da432c2..0693ec3 100644 --- a/sqrl/sqrl.py +++ b/sqrl/sqrl.py @@ -136,8 +136,6 @@ class SQRLInitialization(object): def sqrl_url(self): """ Property for getting full SQRL auth view URL including SQRL scheme and full domain with port. - - Is qrl:// a recognized schema? I can't find documentation of it anywhere... """ return ( '{scheme}://{host}{url}' diff --git a/sqrl/static/admin/sqrl.css b/sqrl/static/admin/sqrl.css new file mode 100644 index 0000000..106c9fe --- /dev/null +++ b/sqrl/static/admin/sqrl.css @@ -0,0 +1,82 @@ +.align-center { + text-align: center; +} + +#footer { + padding: 0; + margin: 0; + height: 0; +} + +.login #container { + margin-top: 30px; +} + +.login .submit-row { + text-align: center; + clear: both; + padding: 1em 0 0 0; +} + +.line-center { + margin: 0; + padding: 0 10px; + background: #ffffff; + display: inline-block; +} + +.sqrl { + margin-bottom: 15px; +} + +.sqrl .or { + position: relative; + z-index: 2; +} + +.sqrl .or:after { + content: ""; + position: absolute; + top: 50%; + left: 0; + right: 0; + border-top: solid 1px #dddddd; + z-index: -1; +} + +.sqrl .or { + margin: 20px; +} + +.sqrl h3 { + margin-bottom: 15px; +} + +.sqrl .img { + text-align: center; + margin-bottom: 0; +} + +.sqrl .img img { + width: 60%; +} + +.sqrl .submit-row { + padding-top: 0; +} + +.sqrl-wrap { + padding: 15px; + background-color: #fff; + text-align: center; + max-width: 300px; + border-radius: 5%; +} +#sqrl-id img { + margin: auto; + width: 100%; + padding-top: 3px; +} +.sqrl-wrap a { + font-size: 50%; +} diff --git a/sqrl/static/sqrl/sqrl.js b/sqrl/static/sqrl/sqrl.js new file mode 100644 index 0000000..5ce4ef4 --- /dev/null +++ b/sqrl/static/sqrl/sqrl.js @@ -0,0 +1,850 @@ +'use strict'; + +(function() { + var get_next_url = function() { + var input = document.querySelectorAll('input[name="next"]'); + return input.length > 0 ? input[0].value : null; + }, + current_url = window.location.href, + sqrl_frequency = 1500, + sqrl_call = function() { + setTimeout(sqrl_handler, sqrl_frequency); + }, + sqrl_handler = function() { + var request = new XMLHttpRequest(), + url = SQRL_CHECK_URL + '?url=', + next_url = get_next_url(); + + if (next_url !== null) { + url = url + encodeURIComponent('?next=' + next_url); + } else { + url = url + encodeURIComponent(current_url); + } + + request.open('POST', url, false); + request.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + request.onreadystatechange = handleStateChange; + + function handleStateChange() { + if (request.readyState === 4) { + if (this.status === 200) { + var data = JSON.parse(this.responseText); + if (data.transaction_complete === true) { + if (data.redirect_to !== undefined) { + window.location.href = data.redirect_to; + } else { + console.error('Server indicated that SQRL transaction is complete ' + + 'but has not indicated where to redirect'); + } + } else { + sqrl_call(); + } + } + } + } + + try { + request.send(null); + } catch (exception) { + // do not send anymore requests if error occurred + } + }; + + window.onload = sqrl_handler; + +})(); + +// This javascript is a mostly unchanged version of David Shim's qrcode.js, originally released under the MIT liscense with no conditionals specified +// All credit goes to them +// The original can be found here: +// https://davidshimjs.github.io/qrcodejs/ + + var QRCode; +! function() { + function a(a) { + this.mode = c.MODE_8BIT_BYTE, this.data = a, this.parsedData = []; + for (var b = [], d = 0, e = this.data.length; e > d; d++) { + var f = this.data.charCodeAt(d); + f > 65536 ? (b[0] = 240 | (1835008 & f) >>> 18, b[1] = 128 | (258048 & f) >>> 12, b[2] = 128 | (4032 & f) >>> 6, b[3] = 128 | 63 & f) : f > 2048 ? (b[0] = 224 | (61440 & f) >>> 12, b[1] = 128 | (4032 & f) >>> 6, b[2] = 128 | 63 & f) : f > 128 ? (b[0] = 192 | (1984 & f) >>> 6, b[1] = 128 | 63 & f) : b[0] = f, this.parsedData = this.parsedData.concat(b) + } + this.parsedData.length != this.data.length && (this.parsedData.unshift(191), this.parsedData.unshift(187), this.parsedData.unshift(239)) + } + + function b(a, b) { + this.typeNumber = a, this.errorCorrectLevel = b, this.modules = null, this.moduleCount = 0, this.dataCache = null, this.dataList = [] + } + + function i(a, b) { + if (void 0 == a.length) throw new Error(a.length + "/" + b); + for (var c = 0; c < a.length && 0 == a[c];) c++; + this.num = new Array(a.length - c + b); + for (var d = 0; d < a.length - c; d++) this.num[d] = a[d + c] + } + + function j(a, b) { + this.totalCount = a, this.dataCount = b + } + + function k() { + this.buffer = [], this.length = 0 + } + + function m() { + return "undefined" != typeof CanvasRenderingContext2D + } + + function n() { + var a = !1, + b = navigator.userAgent; + return /android/i.test(b) && (a = !0, aMat = b.toString().match(/android ([0-9]\.[0-9])/i), aMat && aMat[1] && (a = parseFloat(aMat[1]))), a + } + + function r(a, b) { + for (var c = 1, e = s(a), f = 0, g = l.length; g >= f; f++) { + var h = 0; + switch (b) { + case d.L: + h = l[f][0]; + break; + case d.M: + h = l[f][1]; + break; + case d.Q: + h = l[f][2]; + break; + case d.H: + h = l[f][3] + } + if (h >= e) break; + c++ + } + if (c > l.length) throw new Error("Too long data"); + return c + } + + function s(a) { + var b = encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g, "a"); + return b.length + (b.length != a ? 3 : 0) + } + a.prototype = { + getLength: function() { + return this.parsedData.length + }, + write: function(a) { + for (var b = 0, c = this.parsedData.length; c > b; b++) a.put(this.parsedData[b], 8) + } + }, b.prototype = { + addData: function(b) { + var c = new a(b); + this.dataList.push(c), this.dataCache = null + }, + isDark: function(a, b) { + if (0 > a || this.moduleCount <= a || 0 > b || this.moduleCount <= b) throw new Error(a + "," + b); + return this.modules[a][b] + }, + getModuleCount: function() { + return this.moduleCount + }, + make: function() { + this.makeImpl(!1, this.getBestMaskPattern()) + }, + makeImpl: function(a, c) { + this.moduleCount = 4 * this.typeNumber + 17, this.modules = new Array(this.moduleCount); + for (var d = 0; d < this.moduleCount; d++) { + this.modules[d] = new Array(this.moduleCount); + for (var e = 0; e < this.moduleCount; e++) this.modules[d][e] = null + } + this.setupPositionProbePattern(0, 0), this.setupPositionProbePattern(this.moduleCount - 7, 0), this.setupPositionProbePattern(0, this.moduleCount - 7), this.setupPositionAdjustPattern(), this.setupTimingPattern(), this.setupTypeInfo(a, c), this.typeNumber >= 7 && this.setupTypeNumber(a), null == this.dataCache && (this.dataCache = b.createData(this.typeNumber, this.errorCorrectLevel, this.dataList)), this.mapData(this.dataCache, c) + }, + setupPositionProbePattern: function(a, b) { + for (var c = -1; 7 >= c; c++) + if (!(-1 >= a + c || this.moduleCount <= a + c)) + for (var d = -1; 7 >= d; d++) - 1 >= b + d || this.moduleCount <= b + d || (this.modules[a + c][b + d] = c >= 0 && 6 >= c && (0 == d || 6 == d) || d >= 0 && 6 >= d && (0 == c || 6 == c) || c >= 2 && 4 >= c && d >= 2 && 4 >= d ? !0 : !1) + }, + getBestMaskPattern: function() { + for (var a = 0, b = 0, c = 0; 8 > c; c++) { + this.makeImpl(!0, c); + var d = f.getLostPoint(this); + (0 == c || a > d) && (a = d, b = c) + } + return b + }, + createMovieClip: function(a, b, c) { + var d = a.createEmptyMovieClip(b, c), + e = 1; + this.make(); + for (var f = 0; f < this.modules.length; f++) + for (var g = f * e, h = 0; h < this.modules[f].length; h++) { + var i = h * e, + j = this.modules[f][h]; + j && (d.beginFill(0, 100), d.moveTo(i, g), d.lineTo(i + e, g), d.lineTo(i + e, g + e), d.lineTo(i, g + e), d.endFill()) + } + return d + }, + setupTimingPattern: function() { + for (var a = 8; a < this.moduleCount - 8; a++) null == this.modules[a][6] && (this.modules[a][6] = 0 == a % 2); + for (var b = 8; b < this.moduleCount - 8; b++) null == this.modules[6][b] && (this.modules[6][b] = 0 == b % 2) + }, + setupPositionAdjustPattern: function() { + for (var a = f.getPatternPosition(this.typeNumber), b = 0; b < a.length; b++) + for (var c = 0; c < a.length; c++) { + var d = a[b], + e = a[c]; + if (null == this.modules[d][e]) + for (var g = -2; 2 >= g; g++) + for (var h = -2; 2 >= h; h++) this.modules[d + g][e + h] = -2 == g || 2 == g || -2 == h || 2 == h || 0 == g && 0 == h ? !0 : !1 + } + }, + setupTypeNumber: function(a) { + for (var b = f.getBCHTypeNumber(this.typeNumber), c = 0; 18 > c; c++) { + var d = !a && 1 == (1 & b >> c); + this.modules[Math.floor(c / 3)][c % 3 + this.moduleCount - 8 - 3] = d + } + for (var c = 0; 18 > c; c++) { + var d = !a && 1 == (1 & b >> c); + this.modules[c % 3 + this.moduleCount - 8 - 3][Math.floor(c / 3)] = d + } + }, + setupTypeInfo: function(a, b) { + for (var c = this.errorCorrectLevel << 3 | b, d = f.getBCHTypeInfo(c), e = 0; 15 > e; e++) { + var g = !a && 1 == (1 & d >> e); + 6 > e ? this.modules[e][8] = g : 8 > e ? this.modules[e + 1][8] = g : this.modules[this.moduleCount - 15 + e][8] = g + } + for (var e = 0; 15 > e; e++) { + var g = !a && 1 == (1 & d >> e); + 8 > e ? this.modules[8][this.moduleCount - e - 1] = g : 9 > e ? this.modules[8][15 - e - 1 + 1] = g : this.modules[8][15 - e - 1] = g + } + this.modules[this.moduleCount - 8][8] = !a + }, + mapData: function(a, b) { + for (var c = -1, d = this.moduleCount - 1, e = 7, g = 0, h = this.moduleCount - 1; h > 0; h -= 2) + for (6 == h && h--;;) { + for (var i = 0; 2 > i; i++) + if (null == this.modules[d][h - i]) { + var j = !1; + g < a.length && (j = 1 == (1 & a[g] >>> e)); + var k = f.getMask(b, d, h - i); + k && (j = !j), this.modules[d][h - i] = j, e--, -1 == e && (g++, e = 7) + } if (d += c, 0 > d || this.moduleCount <= d) { + d -= c, c = -c; + break + } + } + } + }, b.PAD0 = 236, b.PAD1 = 17, b.createData = function(a, c, d) { + for (var e = j.getRSBlocks(a, c), g = new k, h = 0; h < d.length; h++) { + var i = d[h]; + g.put(i.mode, 4), g.put(i.getLength(), f.getLengthInBits(i.mode, a)), i.write(g) + } + for (var l = 0, h = 0; h < e.length; h++) l += e[h].dataCount; + if (g.getLengthInBits() > 8 * l) throw new Error("code length overflow. (" + g.getLengthInBits() + ">" + 8 * l + ")"); + for (g.getLengthInBits() + 4 <= 8 * l && g.put(0, 4); 0 != g.getLengthInBits() % 8;) g.putBit(!1); + for (;;) { + if (g.getLengthInBits() >= 8 * l) break; + if (g.put(b.PAD0, 8), g.getLengthInBits() >= 8 * l) break; + g.put(b.PAD1, 8) + } + return b.createBytes(g, e) + }, b.createBytes = function(a, b) { + for (var c = 0, d = 0, e = 0, g = new Array(b.length), h = new Array(b.length), j = 0; j < b.length; j++) { + var k = b[j].dataCount, + l = b[j].totalCount - k; + d = Math.max(d, k), e = Math.max(e, l), g[j] = new Array(k); + for (var m = 0; m < g[j].length; m++) g[j][m] = 255 & a.buffer[m + c]; + c += k; + var n = f.getErrorCorrectPolynomial(l), + o = new i(g[j], n.getLength() - 1), + p = o.mod(n); + h[j] = new Array(n.getLength() - 1); + for (var m = 0; m < h[j].length; m++) { + var q = m + p.getLength() - h[j].length; + h[j][m] = q >= 0 ? p.get(q) : 0 + } + } + for (var r = 0, m = 0; m < b.length; m++) r += b[m].totalCount; + for (var s = new Array(r), t = 0, m = 0; d > m; m++) + for (var j = 0; j < b.length; j++) m < g[j].length && (s[t++] = g[j][m]); + for (var m = 0; e > m; m++) + for (var j = 0; j < b.length; j++) m < h[j].length && (s[t++] = h[j][m]); + return s + }; + for (var c = { + MODE_NUMBER: 1, + MODE_ALPHA_NUM: 2, + MODE_8BIT_BYTE: 4, + MODE_KANJI: 8 + }, d = { + L: 1, + M: 0, + Q: 3, + H: 2 + }, e = { + PATTERN000: 0, + PATTERN001: 1, + PATTERN010: 2, + PATTERN011: 3, + PATTERN100: 4, + PATTERN101: 5, + PATTERN110: 6, + PATTERN111: 7 + }, f = { + PATTERN_POSITION_TABLE: [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ], + G15: 1335, + G18: 7973, + G15_MASK: 21522, + getBCHTypeInfo: function(a) { + for (var b = a << 10; f.getBCHDigit(b) - f.getBCHDigit(f.G15) >= 0;) b ^= f.G15 << f.getBCHDigit(b) - f.getBCHDigit(f.G15); + return (a << 10 | b) ^ f.G15_MASK + }, + getBCHTypeNumber: function(a) { + for (var b = a << 12; f.getBCHDigit(b) - f.getBCHDigit(f.G18) >= 0;) b ^= f.G18 << f.getBCHDigit(b) - f.getBCHDigit(f.G18); + return a << 12 | b + }, + getBCHDigit: function(a) { + for (var b = 0; 0 != a;) b++, a >>>= 1; + return b + }, + getPatternPosition: function(a) { + return f.PATTERN_POSITION_TABLE[a - 1] + }, + getMask: function(a, b, c) { + switch (a) { + case e.PATTERN000: + return 0 == (b + c) % 2; + case e.PATTERN001: + return 0 == b % 2; + case e.PATTERN010: + return 0 == c % 3; + case e.PATTERN011: + return 0 == (b + c) % 3; + case e.PATTERN100: + return 0 == (Math.floor(b / 2) + Math.floor(c / 3)) % 2; + case e.PATTERN101: + return 0 == b * c % 2 + b * c % 3; + case e.PATTERN110: + return 0 == (b * c % 2 + b * c % 3) % 2; + case e.PATTERN111: + return 0 == (b * c % 3 + (b + c) % 2) % 2; + default: + throw new Error("bad maskPattern:" + a) + } + }, + getErrorCorrectPolynomial: function(a) { + for (var b = new i([1], 0), c = 0; a > c; c++) b = b.multiply(new i([1, g.gexp(c)], 0)); + return b + }, + getLengthInBits: function(a, b) { + if (b >= 1 && 10 > b) switch (a) { + case c.MODE_NUMBER: + return 10; + case c.MODE_ALPHA_NUM: + return 9; + case c.MODE_8BIT_BYTE: + return 8; + case c.MODE_KANJI: + return 8; + default: + throw new Error("mode:" + a) + } else if (27 > b) switch (a) { + case c.MODE_NUMBER: + return 12; + case c.MODE_ALPHA_NUM: + return 11; + case c.MODE_8BIT_BYTE: + return 16; + case c.MODE_KANJI: + return 10; + default: + throw new Error("mode:" + a) + } else { + if (!(41 > b)) throw new Error("type:" + b); + switch (a) { + case c.MODE_NUMBER: + return 14; + case c.MODE_ALPHA_NUM: + return 13; + case c.MODE_8BIT_BYTE: + return 16; + case c.MODE_KANJI: + return 12; + default: + throw new Error("mode:" + a) + } + } + }, + getLostPoint: function(a) { + for (var b = a.getModuleCount(), c = 0, d = 0; b > d; d++) + for (var e = 0; b > e; e++) { + for (var f = 0, g = a.isDark(d, e), h = -1; 1 >= h; h++) + if (!(0 > d + h || d + h >= b)) + for (var i = -1; 1 >= i; i++) 0 > e + i || e + i >= b || (0 != h || 0 != i) && g == a.isDark(d + h, e + i) && f++; + f > 5 && (c += 3 + f - 5) + } + for (var d = 0; b - 1 > d; d++) + for (var e = 0; b - 1 > e; e++) { + var j = 0; + a.isDark(d, e) && j++, a.isDark(d + 1, e) && j++, a.isDark(d, e + 1) && j++, a.isDark(d + 1, e + 1) && j++, (0 == j || 4 == j) && (c += 3) + } + for (var d = 0; b > d; d++) + for (var e = 0; b - 6 > e; e++) a.isDark(d, e) && !a.isDark(d, e + 1) && a.isDark(d, e + 2) && a.isDark(d, e + 3) && a.isDark(d, e + 4) && !a.isDark(d, e + 5) && a.isDark(d, e + 6) && (c += 40); + for (var e = 0; b > e; e++) + for (var d = 0; b - 6 > d; d++) a.isDark(d, e) && !a.isDark(d + 1, e) && a.isDark(d + 2, e) && a.isDark(d + 3, e) && a.isDark(d + 4, e) && !a.isDark(d + 5, e) && a.isDark(d + 6, e) && (c += 40); + for (var k = 0, e = 0; b > e; e++) + for (var d = 0; b > d; d++) a.isDark(d, e) && k++; + var l = Math.abs(100 * k / b / b - 50) / 5; + return c += 10 * l + } + }, g = { + glog: function(a) { + if (1 > a) throw new Error("glog(" + a + ")"); + return g.LOG_TABLE[a] + }, + gexp: function(a) { + for (; 0 > a;) a += 255; + for (; a >= 256;) a -= 255; + return g.EXP_TABLE[a] + }, + EXP_TABLE: new Array(256), + LOG_TABLE: new Array(256) + }, h = 0; 8 > h; h++) g.EXP_TABLE[h] = 1 << h; + for (var h = 8; 256 > h; h++) g.EXP_TABLE[h] = g.EXP_TABLE[h - 4] ^ g.EXP_TABLE[h - 5] ^ g.EXP_TABLE[h - 6] ^ g.EXP_TABLE[h - 8]; + for (var h = 0; 255 > h; h++) g.LOG_TABLE[g.EXP_TABLE[h]] = h; + i.prototype = { + get: function(a) { + return this.num[a] + }, + getLength: function() { + return this.num.length + }, + multiply: function(a) { + for (var b = new Array(this.getLength() + a.getLength() - 1), c = 0; c < this.getLength(); c++) + for (var d = 0; d < a.getLength(); d++) b[c + d] ^= g.gexp(g.glog(this.get(c)) + g.glog(a.get(d))); + return new i(b, 0) + }, + mod: function(a) { + if (this.getLength() - a.getLength() < 0) return this; + for (var b = g.glog(this.get(0)) - g.glog(a.get(0)), c = new Array(this.getLength()), d = 0; d < this.getLength(); d++) c[d] = this.get(d); + for (var d = 0; d < a.getLength(); d++) c[d] ^= g.gexp(g.glog(a.get(d)) + b); + return new i(c, 0).mod(a) + } + }, j.RS_BLOCK_TABLE = [ + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12], + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ], j.getRSBlocks = function(a, b) { + var c = j.getRsBlockTable(a, b); + if (void 0 == c) throw new Error("bad rs block @ typeNumber:" + a + "/errorCorrectLevel:" + b); + for (var d = c.length / 3, e = [], f = 0; d > f; f++) + for (var g = c[3 * f + 0], h = c[3 * f + 1], i = c[3 * f + 2], k = 0; g > k; k++) e.push(new j(h, i)); + return e + }, j.getRsBlockTable = function(a, b) { + switch (b) { + case d.L: + return j.RS_BLOCK_TABLE[4 * (a - 1) + 0]; + case d.M: + return j.RS_BLOCK_TABLE[4 * (a - 1) + 1]; + case d.Q: + return j.RS_BLOCK_TABLE[4 * (a - 1) + 2]; + case d.H: + return j.RS_BLOCK_TABLE[4 * (a - 1) + 3]; + default: + return void 0 + } + }, k.prototype = { + get: function(a) { + var b = Math.floor(a / 8); + return 1 == (1 & this.buffer[b] >>> 7 - a % 8) + }, + put: function(a, b) { + for (var c = 0; b > c; c++) this.putBit(1 == (1 & a >>> b - c - 1)) + }, + getLengthInBits: function() { + return this.length + }, + putBit: function(a) { + var b = Math.floor(this.length / 8); + this.buffer.length <= b && this.buffer.push(0), a && (this.buffer[b] |= 128 >>> this.length % 8), this.length++ + } + }; + var l = [ + [17, 14, 11, 7], + [32, 26, 20, 14], + [53, 42, 32, 24], + [78, 62, 46, 34], + [106, 84, 60, 44], + [134, 106, 74, 58], + [154, 122, 86, 64], + [192, 152, 108, 84], + [230, 180, 130, 98], + [271, 213, 151, 119], + [321, 251, 177, 137], + [367, 287, 203, 155], + [425, 331, 241, 177], + [458, 362, 258, 194], + [520, 412, 292, 220], + [586, 450, 322, 250], + [644, 504, 364, 280], + [718, 560, 394, 310], + [792, 624, 442, 338], + [858, 666, 482, 382], + [929, 711, 509, 403], + [1003, 779, 565, 439], + [1091, 857, 611, 461], + [1171, 911, 661, 511], + [1273, 997, 715, 535], + [1367, 1059, 751, 593], + [1465, 1125, 805, 625], + [1528, 1190, 868, 658], + [1628, 1264, 908, 698], + [1732, 1370, 982, 742], + [1840, 1452, 1030, 790], + [1952, 1538, 1112, 842], + [2068, 1628, 1168, 898], + [2188, 1722, 1228, 958], + [2303, 1809, 1283, 983], + [2431, 1911, 1351, 1051], + [2563, 1989, 1423, 1093], + [2699, 2099, 1499, 1139], + [2809, 2213, 1579, 1219], + [2953, 2331, 1663, 1273] + ], + o = function() { + var a = function(a, b) { + this._el = a, this._htOption = b + }; + return a.prototype.draw = function(a) { + function g(a, b) { + var c = document.createElementNS("http://www.w3.org/2000/svg", a); + for (var d in b) b.hasOwnProperty(d) && c.setAttribute(d, b[d]); + return c + } + var b = this._htOption, + c = this._el, + d = a.getModuleCount(); + Math.floor(b.width / d), Math.floor(b.height / d), this.clear(); + var h = g("svg", { + viewBox: "0 0 " + String(d) + " " + String(d), + width: "100%", + height: "100%", + fill: b.colorLight + }); + h.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"), c.appendChild(h), h.appendChild(g("rect", { + fill: b.colorDark, + width: "1", + height: "1", + id: "template" + })); + for (var i = 0; d > i; i++) + for (var j = 0; d > j; j++) + if (a.isDark(i, j)) { + var k = g("use", { + x: String(i), + y: String(j) + }); + k.setAttributeNS("http://www.w3.org/1999/xlink", "href", "#template"), h.appendChild(k) + } + }, a.prototype.clear = function() { + for (; this._el.hasChildNodes();) this._el.removeChild(this._el.lastChild) + }, a + }(), + p = "svg" === document.documentElement.tagName.toLowerCase(), + q = p ? o : m() ? function() { + function a() { + this._elImage.src = this._elCanvas.toDataURL("image/png"), this._elImage.style.display = "block", this._elCanvas.style.display = "none" + } + + function d(a, b) { + var c = this; + if (c._fFail = b, c._fSuccess = a, null === c._bSupportDataURI) { + var d = document.createElement("img"), + e = function() { + c._bSupportDataURI = !1, c._fFail && _fFail.call(c) + }, + f = function() { + c._bSupportDataURI = !0, c._fSuccess && c._fSuccess.call(c) + }; + return d.onabort = e, d.onerror = e, d.onload = f, d.src = "data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==", void 0 + } + c._bSupportDataURI === !0 && c._fSuccess ? c._fSuccess.call(c) : c._bSupportDataURI === !1 && c._fFail && c._fFail.call(c) + } + if (this._android && this._android <= 2.1) { + var b = 1 / window.devicePixelRatio, + c = CanvasRenderingContext2D.prototype.drawImage; + CanvasRenderingContext2D.prototype.drawImage = function(a, d, e, f, g, h, i, j) { + if ("nodeName" in a && /img/i.test(a.nodeName)) + for (var l = arguments.length - 1; l >= 1; l--) arguments[l] = arguments[l] * b; + else "undefined" == typeof j && (arguments[1] *= b, arguments[2] *= b, arguments[3] *= b, arguments[4] *= b); + c.apply(this, arguments) + } + } + var e = function(a, b) { + this._bIsPainted = !1, this._android = n(), this._htOption = b, this._elCanvas = document.createElement("canvas"), this._elCanvas.width = b.width, this._elCanvas.height = b.height, a.appendChild(this._elCanvas), this._el = a, this._oContext = this._elCanvas.getContext("2d"), this._bIsPainted = !1, this._elImage = document.createElement("img"), this._elImage.style.display = "none", this._el.appendChild(this._elImage), this._bSupportDataURI = null + }; + return e.prototype.draw = function(a) { + var b = this._elImage, + c = this._oContext, + d = this._htOption, + e = a.getModuleCount(), + f = d.width / e, + g = d.height / e, + h = Math.round(f), + i = Math.round(g); + b.style.display = "none", this.clear(); + for (var j = 0; e > j; j++) + for (var k = 0; e > k; k++) { + var l = a.isDark(j, k), + m = k * f, + n = j * g; + c.strokeStyle = l ? d.colorDark : d.colorLight, c.lineWidth = 1, c.fillStyle = l ? d.colorDark : d.colorLight, c.fillRect(m, n, f, g), c.strokeRect(Math.floor(m) + .5, Math.floor(n) + .5, h, i), c.strokeRect(Math.ceil(m) - .5, Math.ceil(n) - .5, h, i) + } + this._bIsPainted = !0 + }, e.prototype.makeImage = function() { + this._bIsPainted && d.call(this, a) + }, e.prototype.isPainted = function() { + return this._bIsPainted + }, e.prototype.clear = function() { + this._oContext.clearRect(0, 0, this._elCanvas.width, this._elCanvas.height), this._bIsPainted = !1 + }, e.prototype.round = function(a) { + return a ? Math.floor(1e3 * a) / 1e3 : a + }, e + }() : function() { + var a = function(a, b) { + this._el = a, this._htOption = b + }; + return a.prototype.draw = function(a) { + for (var b = this._htOption, c = this._el, d = a.getModuleCount(), e = Math.floor(b.width / d), f = Math.floor(b.height / d), g = [''], h = 0; d > h; h++) { + g.push(""); + for (var i = 0; d > i; i++) g.push(''); + g.push("") + } + g.push("
"), c.innerHTML = g.join(""); + var j = c.childNodes[0], + k = (b.width - j.offsetWidth) / 2, + l = (b.height - j.offsetHeight) / 2; + k > 0 && l > 0 && (j.style.margin = l + "px " + k + "px") + }, a.prototype.clear = function() { + this._el.innerHTML = "" + }, a + }(); + QRCode = function(a, b) { + if (this._htOption = { + width: 256, + height: 256, + typeNumber: 4, + colorDark: "#000000", + colorLight: "#ffffff", + correctLevel: d.H + }, "string" == typeof b && (b = { + text: b + }), b) + for (var c in b) this._htOption[c] = b[c]; + "string" == typeof a && (a = document.getElementById(a)), this._android = n(), this._el = a, this._oQRCode = null, this._oDrawing = new q(this._el, this._htOption), this._htOption.text && this.makeCode(this._htOption.text) + }, QRCode.prototype.makeCode = function(a) { + this._oQRCode = new b(r(a, this._htOption.correctLevel), this._htOption.correctLevel), this._oQRCode.addData(a), this._oQRCode.make(), this._oDrawing.draw(this._oQRCode), this.makeImage() + }, QRCode.prototype.makeImage = function() { + "function" == typeof this._oDrawing.makeImage && (!this._android || this._android >= 3) && this._oDrawing.makeImage() + }, QRCode.prototype.clear = function() { + this._oDrawing.clear() + }, QRCode.CorrectLevel = d +}(); + +var qr = document.getElementById("sqrl-qr"); +new QRCode(qr, qr.dataset.sqrl); diff --git a/sqrl/templates/admin/auth/user/sqrl_manage.html b/sqrl/templates/admin/auth/user/sqrl_manage.html new file mode 100644 index 0000000..bfe943a --- /dev/null +++ b/sqrl/templates/admin/auth/user/sqrl_manage.html @@ -0,0 +1,85 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_static %} +{% load sqrl %} + +{% block title %}Manage SQRL{% endblock %} +{% block content_title %}

Manage SQRL

{% endblock %} + +{% block extrahead %}{{ block.super }} + +{% endblock %} + +{% block extrastyle %} + {{ block.super }} + + +{% endblock %} + + +{% block userlinks %} + {% url 'django-admindocs-docroot' as docsroot %} + {% if docsroot %} + {% trans 'Documentation' %} / + {% endif %} + {% trans 'Manage SQRL' %} / + {% trans 'Log out' %} +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+ + {% if not user.sqrl_identity %} +

+ You dont have SQRL identity associated with your account yet. + Please use SQRL link/QR code below to associate SQRL identity with your account. +

+ {% else %} +

+ Congratulations! You already have SQRL identity associated with your account. + If you would like to either change or delete existing SQRL identity + associated with your account, you can do that by selecting appropriate + option in your SQRL client and then using the SQRL link/QR code below. +

+ +

+ Note: For both changing or deleting your SQRL identity, + you will need to load your SQRL rescue code. +

+ +

+ Caution: Normally it is not advised to change or delete + your SQRL identity. Usually these operations are only required when + SQRL identity is compromised. +

+ {% endif %} + + {% sqrl as session_sqrl %} + +
+
+ SQRL Login + +
+
+ What is SQRL? + {# redirect to manage page after successful SQRL transaction #} + +
+
+ +
+
+ + +
+
+ +
+{% endblock %} diff --git a/sqrl/templates/admin/base.html b/sqrl/templates/admin/base.html new file mode 100644 index 0000000..5a22282 --- /dev/null +++ b/sqrl/templates/admin/base.html @@ -0,0 +1,19 @@ +{% extends 'contrib/admin/templates/admin/base.html' %} +{% load i18n admin_static %} + +{% block userlinks %} + {% if site_url %} + {% trans 'View site' %} / + {% endif %} + {% if user.is_active and user.is_staff %} + {% url 'django-admindocs-docroot' as docsroot %} + {% if docsroot %} + {% trans 'Documentation' %} / + {% endif %} + {% endif %} + {% if user.has_usable_password %} + {% trans 'Change password' %} / + {% endif %} + {% trans 'Manage SQRL' %} / + {% trans 'Log out' %} +{% endblock %} diff --git a/sqrl/templates/admin/login.html b/sqrl/templates/admin/login.html new file mode 100644 index 0000000..cdd7c94 --- /dev/null +++ b/sqrl/templates/admin/login.html @@ -0,0 +1,91 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_static %} +{% load sqrl %} + +{% block extrastyle %} + {{ block.super }} + + +{% endblock %} + +{% block bodyclass %}{{ block.super }} login{% endblock %} + +{% block nav-global %}{% endblock %} + +{% block content_title %}{% endblock %} + +{% block breadcrumbs %}{% endblock %} + +{% block content %} + {% if form.errors and not form.non_field_errors %} +

+ {% if form.errors.items|length == 1 %} + {% trans "Please correct the error below." %}{% else %} + {% trans "Please correct the errors below." %}{% endif %} +

+ {% endif %} + + {% if form.non_field_errors %} + {% for error in form.non_field_errors %} +

+ {{ error }} +

+ {% endfor %} + {% endif %} + +
+
{% csrf_token %} +
+ {{ form.username.errors }} + {{ form.username }} +
+
+ {{ form.password.errors }} + {{ form.password }} + +
+ {% url 'admin_password_reset' as password_reset_url %} + {% if password_reset_url %} + + {% endif %} +
+ +
+
+ + {% sqrl as session_sqrl %} + +
+

+ or +

+ +
+

Login using SQRL

+ +
+ SQRL Login + +
+
+ What is SQRL? +
+ +
+ + +
+
+
+ + {% sqrl_status_url_script_tag session_sqrl %} + + +
+{% endblock %} diff --git a/sqrl/templates/sqrl/login.html b/sqrl/templates/sqrl/login.html new file mode 100644 index 0000000..1ecb89d --- /dev/null +++ b/sqrl/templates/sqrl/login.html @@ -0,0 +1,27 @@ +{% extends "sqrl/sqrl_base.html" %} +{% load sqrl %} +{% load static %} + +{% block title %}Log in via SQRL{% endblock %} + +{% block content %} +

Log in

+ +

+ Please use via SQRL using the information below. +

+ + {% sqrl as session_sqrl %} + {% sqrl_login_dropin session_sqrl %} +{% endblock %} + +{% comment %} +**sqrl/login.html** +This is SQRL-only login page. +No special variables are passed in the context for the login to work. +All necessary data should already be in the context. + +Please note again that this template is for SQRL-exclusive logins. +If you would like to add SQRL login to an existing login page, +you should rather adjust that template as it is probably way more involved. +{% endcomment %} diff --git a/sqrl/templates/sqrl/manage.html b/sqrl/templates/sqrl/manage.html new file mode 100644 index 0000000..eca65ac --- /dev/null +++ b/sqrl/templates/sqrl/manage.html @@ -0,0 +1,54 @@ +{% extends "sqrl/sqrl_base.html" %} +{% load sqrl %} +{% load static %} + +{% block title %}Manage SQRL Identity{% endblock %} + +{% block content %} +

Manage SQRL Identity with your account

+ + {% if not user.sqrl_identity %} +

+ You dont have SQRL identity associated with your account yet. + Please use SQRL link/QR code below to associate SQRL identity with your account. +

+ {% else %} +

+ Congratulations! You already have SQRL identity associated with your account. + If you would like to either change or delete existing SQRL identity + associated with your account, you can do that by selecting appropriate + option in your SQRL client and then using the SQRL link/QR code below. +

+ +

+ Note: For both changing or deleting your SQRL identity, + you will need to load your SQRL rescue code. +

+ +

+ Caution: Normally it is not advised to change or delete + your SQRL identity. Usually these operations are only required when + SQRL identity is compromised. +

+ {% endif %} + + {% sqrl as session_sqrl %} + {% sqrl_login_dropin session_sqrl method="manage" %} +{% endblock %} + +{% block scripts %} + +{% endblock %} + + +{% comment %} +**sqrl/manage.html** +Used to manage SQRL identity in relation to existing logged in account. +When no SQRL identity is already associated with the account, +it can be used to associate a new identity. +When existing SQRL is already associated, this page can be used +to either change or delete existing identity. + +No special variables are passed in the context for the association +to work. All necessary data should already be in the context. +{% endcomment %} diff --git a/sqrl/templates/sqrl/register.html b/sqrl/templates/sqrl/register.html new file mode 100644 index 0000000..9120b86 --- /dev/null +++ b/sqrl/templates/sqrl/register.html @@ -0,0 +1,30 @@ +{% extends "sqrl/sqrl_base.html" %} + +{% block title %}Complete User Tegistration{% endblock %} + +{% block content %} +

Complete User Registration

+ +

+ Already have account? + Login here to associate + SQRL identity with existing account. +

+ +
+ {{ form.as_p }} + +
+{% endblock %} + +{% comment %} +**sqrl/register.html** +Used to complete user registration. This template is used when user is trying to login +with SQRL however does not already have that SQRL identity associated with any +existing account. Since SQRL does not provide any additional information other then +SQRL identity itslef, we need to collect some additinal information from the user +such as username. All the relevant information is collected via form. + +No special variables are passed in the context. +All necessary data should already be in the context. +{% endcomment %} diff --git a/sqrl/templates/sqrl/sqrl-dropin.html b/sqrl/templates/sqrl/sqrl-dropin.html new file mode 100644 index 0000000..308df1d --- /dev/null +++ b/sqrl/templates/sqrl/sqrl-dropin.html @@ -0,0 +1,41 @@ +{% load static %} +{% load sqrl %} + +
+
+
+ SQRL Login + +
+
+ What is SQRL? +
+ + + {% if session_sqrl.method == "manage" %} + {# redirect to manage page after successful SQRL transaction #} + + + {% else %} + + {% endif %} +
+
+{% sqrl_status_url_script_tag session_sqrl %} + diff --git a/sqrl/templates/sqrl/sqrl_base.html b/sqrl/templates/sqrl/sqrl_base.html new file mode 100644 index 0000000..94d9808 --- /dev/null +++ b/sqrl/templates/sqrl/sqrl_base.html @@ -0,0 +1 @@ +{% extends "base.html" %} diff --git a/sqrl/templatetags/sqrl.py b/sqrl/templatetags/sqrl.py new file mode 100644 index 0000000..2ab0688 --- /dev/null +++ b/sqrl/templatetags/sqrl.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from django import template +from django.urls import reverse +from django.template.defaultfilters import urlencode + +from ..sqrl import SQRLInitialization + + +register = template.Library() + + +@register.assignment_tag(takes_context=True) +def sqrl(context): + return SQRLInitialization(context['request']) + + +@register.inclusion_tag('sqrl/sqrl-dropin.html') +def sqrl_login_dropin(session_sqrl, method="login"): + """ + Creates a drop-in SQRL element in your template pages. + Add it to your login template to make it SQRL-aware. + + Usage: + {% load sqrl %} + {% sqrl as session_sqrl %} + {% sqrl_login_dropin session_sqrl [method=METHOD] %} + + METHOD is an optional argument that changes the way the form + behaves. Possible arguments are: + - login: The default method. No special redirections occur + - manage: Will redirect the user to sqrl/manage + + Notes: + The drop-in is defaulted to a max-width of 300px. Set the width + property of the parent if you want or need it smaller. You will + likely want to change the font-size as well in this case. + """ + return {'session_sqrl':session_sqrl, 'method': method} + + +@register.simple_tag +def sqrl_status_url_script_tag(sqrl): + url = reverse('sqrl:status', kwargs={'transaction': sqrl.nut.transaction_nonce}) + return ''.format(url=url) diff --git a/sqrl/urls.py b/sqrl/urls.py index e69de29..9a4b603 100644 --- a/sqrl/urls.py +++ b/sqrl/urls.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from django.urls import path, re_path + +from .views import ( + SQRLAuthView, + SQRLCompleteRegistrationView, + SQRLIdentityManagementView, + SQRLLoginView, + SQRLStatusView, +) + +urlpatterns = [ + path("auth/", SQRLAuthView.as_view(), name="auth"), + path("login/", SQRLLoginView.as_view(), name="login"), + 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 36eb001..7f26be6 100644 --- a/sqrl/utils.py +++ b/sqrl/utils.py @@ -2,8 +2,7 @@ from base64 import urlsafe_b64decode, urlsafe_b64encode from collections import OrderedDict -import six - +from django.core.files.base import ContentFile class Base64(object): """ @@ -14,26 +13,24 @@ class Base64(object): def encode(cls, s): """ Encode binary string as base64. Remaining "=" characters are removed. - Parameters ---------- s: bytes Bytes string to be encoded as base64 """ - assert isinstance(s, (six.binary_type, bytearray)) + assert isinstance(s, (bites, bytearray)) return urlsafe_b64encode(s).decode('ascii').rstrip('=') @classmethod def decode(cls, s): """ Decode unicode string from base64 where remaining "=" characters were stripped. - Parameters ---------- s: str Unicode string to be decoded from base64 """ - assert isinstance(s, six.text_type) + assert isinstance(s, str) return urlsafe_b64decode((s + '=' * (4 - len(s) % 4)).encode('ascii')) @@ -46,11 +43,9 @@ class Encoder(object): def base64_dumps(cls, data): """ Dumps given data into a single Base64 string. - Practically this is the same as :meth:`dumps` except :meth:`dumps` can return multiline string for ``dict``. This method normalizes that further by converting that multiline string to a single base64 encoded value. - Returns ------- binary @@ -64,11 +59,8 @@ class Encoder(object): def dumps(cls, data): """ Recursively dumps given data to SQRL response format. - Before data is dumped out, it is normalized by using :meth:`.normalize`. - This dumps each data type as follows: - :``dict``: returns an ``\\r\\n`` multiline string. Each line is for a single key-pair of format ``=``. :``list``: tilde (``~``) joined dumped list of values @@ -90,9 +82,7 @@ class Encoder(object): def normalize(cls, data): """ Recursively normalize data for encoding. - This encodes each data type as follows: - :``dict``: returns an ``OrderedDict`` where all values are recursively normalized. Empty dict is normalized to empty string :``list``: each value is recursively normalized @@ -110,12 +100,12 @@ class Encoder(object): return '' elif isinstance(data, (list, tuple)): return [cls.dumps(i) for i in data] - elif isinstance(data, six.binary_type): + elif isinstance(data, bytes): return Base64.encode(data) - elif isinstance(data, six.text_type): + elif isinstance(data, str): return data else: - return six.text_type(data) + return str(data) def get_user_ip(request): """ @@ -128,4 +118,4 @@ def get_user_ip(request): return ( request.META.get('HTTP_X_REAL_IP') or request.META['REMOTE_ADDR'] - ) + ) diff --git a/sqrl/views.py b/sqrl/views.py index 91ea44a..4d3a199 100644 --- a/sqrl/views.py +++ b/sqrl/views.py @@ -1,3 +1,680 @@ -from django.shortcuts import render +# -*- coding: utf-8 -*- +import json +import logging +from collections import OrderedDict +from pprint import pformat -# Create your views here. +from braces.views._access import LoginRequiredMixin +from django.conf import settings +from django.contrib.auth import ( + BACKEND_SESSION_KEY, + HASH_SESSION_KEY, + SESSION_KEY, + login, +) +from django.core import serializers +from django.url 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 + +from .backends import SQRL_MODEL_BACKEND +from .exceptions import TIF, TIFException +from .forms import ( + AuthQueryDictForm, + ExtractedNextUrlForm, + GenerateQRForm, + NextUrlForm, + PasswordLessUserCreationForm, + RequestForm, +) +from .models import SQRLIdentity, SQRLNut +from .response import SQRLHttpResponse +from .sqrl import SQRLInitialization +from .utils import Base64, QRGenerator, get_user_ip + + +SQRL_IDENTITY_SESSION_KEY = '_sqrl_identity' + +log = logging.getLogger(__name__) + + +class SQRLLoginView(TemplateView): + """ + Simple ``TemplateView`` which renders ``sqrl/login.html`` template. + + The template can (and probably should) be customized in each Django Project. + + .. note:: + This templates only provides SQRL auth method. If other methods are required + on the same login page, it is probably better to add SQRL auth method to + existing login page vs customizing this template/view. + """ + template_name = 'sqrl/login.html' + + +class SQRLStatusView(View): + """ + Ajax view which returns the status of the SQRL transaction back to the user. + + The state of the transaction is looked up by finding the appropriate + :obj:`.models.SQRLNut` by the transaction ID which is a kwarg in the url pattern. + When the nut is found, :attr:`.models.SQRLNut.is_transaction_complete` is + used to determine the state of the transaction. + + This view is useful because when it returns a redirect upon successful completing + of SQRL transaction, js can dynamically redirect the user to that page. + Without this behaviour, user will have to manually refresh the page + which is inconvenient. + + .. note:: + Currently this view is being used via polling on js side + however this view's concept can easily be adopted to any other + real-time technology such as websockets. + """ + success_url = settings.LOGIN_REDIRECT_URL + + def get_success_url(self): + """ + Get the url to which the user will be redirect to after + successful SQRL transaction. + + The url is computed using :obj:`.forms.ExtractedNextUrlForm`. + Following URLs are used depending if the form is valid: + + :``True``: Next url within the ``?url=`` querystring parameter + :``False``: ``settings.LOGIN_REDIRECT_URL`` + + When however the user is not logged in, even after successful + SQRL transaction and has pending registration, user will be + redirected to ``sqrl-complete-registration`` view with the + ``?next=`` querystring parameter set to the url computed above. + """ + next_form = ExtractedNextUrlForm(self.request.GET) + + if next_form.is_valid(): + url = next_form.cleaned_data['url'] + else: + url = self.success_url + + if all([not self.request.user.is_authenticated(), + SQRL_IDENTITY_SESSION_KEY in self.request.session]): + return reverse('sqrl:complete-registration') + '?next={}'.format(url) + else: + return url + + def get_object(self): + """ + Get the :obj:`.models.SQRLNut` by transaction is or raise ``404``. + """ + return get_object_or_404(SQRLNut, transaction_nonce=self.kwargs['transaction']) + + def post(self, request, transaction, *args, **kwargs): + """ + Handle the request and return appropriate data back to the user. + + Following keys can be returned: + + :``transaction_complete``: boolean which is always returned + :``redirect_to``: also present when ``transaction_complete == True`` + and this is where the js should redirect the user to. + + .. note:: + This view is restricted to ajax calls as to restrict its + use from regular forms. + """ + if not request.is_ajax(): + return HttpResponse(status=405) # method not allowed + + transaction = self.get_object() + + data = { + 'transaction_complete': False, + } + + if transaction.is_transaction_complete: + data.update({ + 'transaction_complete': True, + 'redirect_to': self.get_success_url(), + }) + + return JsonResponse(data) + + +class SQRLAuthView(View): + """ + This is the main view responsible for all interactions with SQRL client. + + The responsibilities of this view are: + + * validate that URL is correct since nut value is part of querystring + which cannot be matched in url patterns. + When invalid, 404 should be returned. + * find the nut via nut nonce or return transient error TIF + * validate client payload by using :obj:`.forms.RequestForm` which + includes validating validity of signatures and looking up stored + SQRL identities. + * executing all SQRL commands such as ``query``, ``ident``, etc + as instructed in the SQRL payload. + If any of the commands are not supported not supported TIF + is returned. + * finalize the any remaining state such as saving identity objects + if all commands successfully completed + * returning response back to the user + """ + http_method_names = ['post'] + + def __init__(self, *args, **kwargs): + super(SQRLAuthView, self).__init__(*args, **kwargs) + + self.tif = TIF(0) + + self.nut_value = None + self.nut = None + self.client = None + self.identity = None + self.previous_identity = None + self.session = None + self.is_disabled = False + + def dispatch(self, request, *args, **kwargs): + """ + Standard ``dispatch`` with custom exception handling + for :obj:`.exceptions.TIFException` in which error response is returned + with TIF code as specified in the exception. + """ + try: + return super(SQRLAuthView, self).dispatch(request, *args, **kwargs) + except TIFException as e: + self.tif = self.tif.update(e.tif) + return self.render_to_response() + + def get_server_data(self): + """ + Get data to be returned back to SQRL client. + + This method does not encode for the response. It simply returns + a dictionary of information which later on can be used by + :obj:`.response.SQRLHttpResponse` to actually construct + data to be sent back to the client. + + Returns + ------- + OrderedDict + Dict of data to be sent back to SQRL client. + The ``Ordered`` part is important as SQRL requires + to send some data first such as SQRL version number. + """ + if self.nut: + self.nut.renew() + nut = self.nut.nonce + qry = SQRLInitialization(self.request, self.nut).url + else: + nut = self.nut_value + qry = self.request.get_full_path() + + data = OrderedDict(( + ('ver', 1), + ('nut', nut), + ('tif', self.tif.as_hex_string()), + ('qry', qry), + ('sfn', getattr(settings, 'SQRL_SERVER_FRIENDLY_NAME', + self.request.get_host().split(':')[0])[:64]), + )) + + if self.identity: + data['suk'] = self.identity.server_unlock_key + + return data + + def render_to_response(self): + """ + Render a response which will be send to SQRL client. + + Internally this method uses :meth:`.get_server_data` to construct the response + data and :obj:`.response.SQRLHttpResponse` to render that data into + SQRL-compatible format. + + Returns + ------- + SQRLHttpResponse + Completely rendered response ready to the sent to the SQRL client + """ + return SQRLHttpResponse(self.nut, self.get_server_data()) + + def do_ips_match(self): + """ + This method updates internal TIF state with :attr:`.exceptions.TIF.IP_MATCH` bit. + + The bit is only set when the IP address of the SQRL client making request + to this view matches IP address of device used to initiate SQRL transation + (where SQRL link/QR code were generated). + """ + if get_user_ip(self.request) == self.nut.ip_address: + self.tif = self.tif.update(TIF.IP_MATCH) + + def do_ids_match(self): + """ + This method updates internal TIF state with :attr:`.exceptions.TIF.ID_MATCH` + and :attr:`.exceptions.TIF.PREVIOUS_ID_MATCH` bits. + + The appropriate bits are only set when the the corresponding identity is found + on the server. + """ + if self.identity: + self.tif = self.tif.update(TIF.ID_MATCH) + + if self.previous_identity: + self.tif = self.tif.update(TIF.PREVIOUS_ID_MATCH) + + def is_sqrl_disabled(self): + """ + This method updates internal TIF state with :attr:`.exceptions.TIF.SQRL_DISABLED` bit. + + The bit is only set when either current or previous identity are found + and they have :attr:`.models.SQRLIdentity.is_enabled` set as ``False`` + which means previously SQRL client requested server to disable SQRL + auth method for that user. + + Also this method sets ``self.is_disabled`` attribute which later on can be + used by other methods to customize their behaviour. + """ + self.is_disabled = False + + if self.identity and not self.identity.is_enabled: + self.tif = self.tif.update(TIF.SQRL_DISABLED) + self.is_disabled = True + + if self.previous_identity and not self.previous_identity.is_enabled: + self.tif = self.tif.update(TIF.SQRL_DISABLED) + self.is_disabled = True + + def get_nut_or_error(self): + """ + This method finds the :obj:`.models.SQRLNut` by nut nonce in the + querystring or if not not raises appropriate :obj:`.exceptions.TIFException`. + + When nut is found, it is saved as ``self.nut``. In addition, this method + triggers :meth:`.do_ips_match` to update internal TIF state. + + Returns + ------- + SQRLNut + Found :obj:`.models.SQRLNut` via nut nonce from querystring + + Raises + ------ + TIFException + :obj:`.exceptions.TIFException` with :attr:`.exceptions.TIF.TRANSIENT_FAILURE` + and :attr:`.exceptions.TIF.COMMAND_FAILED` bits sets as ``True`` + when nut is not found. + """ + self.nut = (SQRLNut.objects + .filter(nonce=self.nut_value, + is_transaction_complete=False) + .first()) + + if not self.nut: + log.debug('Nut not found') + raise TIFException(TIF.TRANSIENT_FAILURE | TIF.COMMAND_FAILED) + + self.do_ips_match() + + return self.nut + + def post(self, request, *args, **kwargs): + """ + Main view handler since all SQRL requests are ``POST`` requests. + + This method does not implement a lot of logic. It mostly relies on other + methods which it then orchestrates. For information on what responsibilities + which method has, you can take a look at :obj:`.SQRLAuthView` description. + + Some implementation details worth mentioning: + + * This method uses multiple forms to validate different sections of the payload. + Specifically it uses :obj:`.forms.AuthQueryDictForm` to validate the presence + of ``?nut=`` within querystring; and :obj:`.forms.RequestForm` to validate + the SQRL payload itself. + * This method extensively uses :obj:`.exceptions.TIFException` to immediately + return some sort of error to the user which is handled by :meth:`.dispatch`. + The only exception to that is that it still raises ``Http404`` when nut pattern + is not validated. Normally in Django that would of been validated in url patterns + however since SQRL forces to use ``?nut=`` querystring, we mimic same behaviour + 404 Not Found in the view. + * To atomically process all SQRL commands (all or nothing), this view + implements all SQRL commands as dedicated methods (e.g. :meth:`.query`). + That allows this method to find all appropriate handlers for all the commands + and if not all are found, :obj:`.exceptions.TIFException` can be raised + with :attr:`.exceptions.TIF.NOT_SUPPORTED` bit set as ``True``. + If all are found, then it simply processes them in the order they were requested. + * Since any SQRL command can potentially fail, none of the SQRL command handlers + save any state in either the session or models because other commands can fail + after them. If all succeed, this method then explicitly finalizes/saves all the + state which includes session and models. + """ + log.debug('-' * 50) + log.debug('Raw request body:\n{}'.format(request.body)) + + # in case content-type is not given in which case + # request.POST will be empty in which case manually parse + # raw request body + if not request.POST: + request.POST = QueryDict(request.body) + + # nut is not part of URL regex so validate it here + # using a form and if not valid, return 404, + # same as if nut would of been validated in URL regex + self.query_form = AuthQueryDictForm(request.GET) + if not self.query_form.is_valid(): + log.debug('Query form failed with {}' + ''.format(repr(self.query_form.errors))) + raise Http404 + + log.debug('Request payload:\n{}' + ''.format(pformat(request.POST))) + + self.nut_value = self.query_form.cleaned_data['nut'] + self.nut = self.get_nut_or_error() + + # validate the client data + # this also validates submitted signatures and verifies + # that echoed back server response was not altered + self.payload_form = RequestForm(self.nut, request.POST) + if not self.payload_form.is_valid(): + log.debug('Request payload validation failed with {}' + ''.format(repr(self.payload_form.errors))) + if self.payload_form.tif: + raise TIFException(TIF.COMMAND_FAILED | self.payload_form.tif) + raise TIFException(TIF.COMMAND_FAILED | TIF.CLIENT_FAILURE) + + log.debug('Request payload successfully parsed and validated:\n{}' + ''.format(pformat(self.payload_form.cleaned_data))) + + self.client = self.payload_form.cleaned_data['client'] + + self.identity = self.payload_form.identity + self.previous_identity = self.payload_form.previous_identity + self.session = self.payload_form.session + self.do_ids_match() + self.is_sqrl_disabled() + + cmds = [getattr(self, i, None) for i in self.client['cmd']] + + if not all(cmds): + raise TIFException(TIF.COMMAND_FAILED | TIF.NOT_SUPPORTED) + + for cmd in cmds: + cmd() + + self.finalize() + + return self.render_to_response() + + def query(self): + """ + Handler for SQRL ``query`` command. + + Since all necessary information by default is already returned to the user, + this method does not have to do anything. + """ + + def ident(self): + """ + Handler for SQRL ``ident`` command. + """ + if self.is_disabled: + return + + self.create_or_update_identity() + + # user is already logged in + # so simply associate identity with the user + if all((self.session.get(i) for i in + [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY])): + self.identity.user_id = self.session.get(SESSION_KEY) + + # user is already associated with identity + # so we can login the user + elif self.identity.user_id: + user = self.identity.user + user.backend = SQRL_MODEL_BACKEND + + session_auth_hash = user.get_session_auth_hash() + + self.session[SESSION_KEY] = user.pk + self.session[BACKEND_SESSION_KEY] = user.backend + self.session[HASH_SESSION_KEY] = session_auth_hash + + log.info('Successfully authenticated user "{}" via SQRL'.format(user.username)) + + # user was not found so lets save identity information in session + # so that we can complete user registration + else: + serialized = serializers.serialize('json', [self.identity]) + self.session[SQRL_IDENTITY_SESSION_KEY] = serialized + log.debug('Storing sqrl identity in session "{}" to complete registration:\n{}' + ''.format(self.session.session_key, + pformat(json.loads(serialized)[0]['fields']))) + + self.nut.is_transaction_complete = True + + def disable(self): + """ + Handler for SQRL ``disable`` command. + + By the time this handler is called, :obj:`.forms.RequestForm` is already validated + which guarantees that in order to use ``disable``, user must already have associated + :obj:`.models.SQRLIdentity` so this method simply sets :attr:`.models.SQRLIdentity.is_enabled` + to ``False``. Then if the rest of the SQRL commands succeed, :meth:`.finalize` will + save that change. + """ + self.create_or_update_identity() + self.identity.is_enabled = False + + self.nut.is_transaction_complete = True + + def enable(self): + """ + Handler for SQRL ``enable`` command. + + By the time this handler is called, :obj:`.forms.RequestForm` is already validated + which guarantees that in order to use ``enable``, user must already have associated + :obj:`.models.SQRLIdentity` and that the user correctly supplied ``urs`` signature. + Therefore this method simply sets :attr:`.models.SQRLIdentity.is_enabled` to ``True``. + Then if the rest of the SQRL commands succeed, :meth:`.finalize` will save that change. + """ + self.create_or_update_identity() + self.identity.is_enabled = True + + self.nut.is_transaction_complete = True + + def remove(self): + """ + Handler for SQRL ``remove`` command. + + By the time this handler is called, :obj:`.forms.RequestForm` is already validated + which guarantees that in order to use ``remove``, user must already have associated + :obj:`.models.SQRLIdentity`, that the user correctly supplied ``urs`` signature + and that that ``remove`` is the only command. + Since all finalizing of the state should be handled by :meth:`.finalize`, this method + does not actually delete the identity model but marks it for deletion. + """ + self.identity.to_remove = True + + self.nut.is_transaction_complete = True + + def finalize(self): + """ + State finalization method. + + This is necessary since SQRL can request multiple commands at the same time + and any of them can fail. Therefore no state should be saved in any of the + command handlers. They should adjust the state but not actually save it. + Instead this method saves all the state. This allows the SQRL request + processing to be atomic. Current it saves: + + * :obj:`.models.SQRLIdentity` + * session data + """ + if self.identity: + if getattr(self.identity, 'to_remove', False): + self.identity.delete() + elif self.identity.user_id: + self.identity.save() + if self.session: + self.session.save() + + def create_or_update_identity(self): + """ + This method updates existing :obj:`.models.SQRLIdentity` or creates it + when not already present. + + This is used to handle: + + * new users creating their :obj:`.models.SQRLIdentity` in which case + all of the data will be set from scratch such as + :attr:`.models.SQRLIdentity.public_key`, etc + * existing users since they could be sending specific SQRL options + (e.g. ``sqrlonly``) which should always update :obj:`.models.SQRLIdentity` + depending on the presence of the options in the request. + """ + if not self.identity: + self.identity = SQRLIdentity() + + # by this point form has validated that if the identity is being switched + # all necessary signatures were provided and validated + # so we can safely set the public key which will either + # 1) set new public key for new identity associations + # 2) overwrite the existing public key with the same public key + # 3) overwrite the existing public key which by this point + # is previous identity with new current identity + self.identity.public_key = Base64.encode(self.client['idk']) + self.identity.is_only_sqrl = 'sqrlonly' in self.client['opt'] + + # form validation will make sure that these are supplied when + # necessary (e.g. creating new identity) + # the reason we don't simply want to always overwrite these is + # because for already associated identities, client will not supply + # them so we dont want to overwrite the model with empty values + if self.client.get('vuk'): + self.identity.verify_unlock_key = Base64.encode(self.client['vuk']) + if self.client.get('suk'): + self.identity.server_unlock_key = Base64.encode(self.client['suk']) + + return self.identity + + +class SQRLCompleteRegistrationView(FormView): + """ + This view is used to complete user registration. + + That happens when SQRL transaction is successfully completed + however does not have account yet setup. In that case :obj:`.SQRLAuthView` + stores SQRL identity information in the session which this view can use. + To complete registration, a form is displayed to the user. + When form is successfully filled out, this view creates a new user and + automatically assigns the stored SQRL identity from the session to the + new user. + """ + form_class = PasswordLessUserCreationForm + template_name = 'sqrl/register.html' + success_url = settings.LOGIN_REDIRECT_URL + + def check_session_for_sqrl_identity_or_404(self): + """ + Check if the SQRL identity is stored within the session + and if not, raise ``Http404``. + + Raises + ------ + Http404 + When SQRL identity is not stored in session + """ + if SQRL_IDENTITY_SESSION_KEY not in self.request.session: + raise Http404 + + def get(self, request, *args, **kwargs): + """ + Same as regular ``FormView`` except this also checks for identity within session + by using :meth:`.check_session_for_sqrl_identity_or_404`. + """ + self.check_session_for_sqrl_identity_or_404() + return super(SQRLCompleteRegistrationView, self).get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + """ + Same as regular ``FormView`` except this also checks for identity within session + by using :meth:`.check_session_for_sqrl_identity_or_404`. + """ + self.check_session_for_sqrl_identity_or_404() + return super(SQRLCompleteRegistrationView, self).post(request, *args, **kwargs) + + def get_success_url(self): + """ + Get success url to which user will be redirected to when registration is complete. + + The url from the ``?next=`` is used if :obj:`.forms.NextUrlForm` is valid. + Otherwise :attr:`.success_url` is used. + """ + next_form = NextUrlForm(self.request.GET) + if next_form.is_valid(): + return next_form.cleaned_data['next'] + return self.success_url + + def form_valid(self, form): + """ + When registration form is valid, this method finishes up + creating new user with new SQRL identity. + + It does the following: + + #. decodes the stored SQRL identity in the session. + If this step fails, this method returns ``500`` response. + #. saves the new user and assigned the decoded identity to it + #. logs in the new user + #. redirects to url returned by :meth:`.get_success_url` + """ + try: + identity = next(iter(serializers.deserialize( + 'json', self.request.session.pop(SQRL_IDENTITY_SESSION_KEY) + ))).object + except Exception: + return HttpResponse(status=500) + + user = form.save() + user.backend = SQRL_MODEL_BACKEND + + identity.user = user + identity.save() + + login(self.request, user) + + log.info('Successfully registered and authenticated user ' + '"{}" via SQRL'.format(user.username)) + + return redirect(self.get_success_url()) + + +class SQRLIdentityManagementView(LoginRequiredMixin, TemplateView): + """ + Simple ``TemplateView`` which renders ``sqrl/manage.html`` template. + + The template can (and probably should) be customized in each Django Project. + + .. warning:: + Since this view is to exclusively manage SQRL identity, + no other auth methods should be added to this template/view. + """ + template_name = 'sqrl/manage.html' + + +class AdminSiteSQRLIdentityManagementView(LoginRequiredMixin, TemplateView): + template_name = 'admin/auth/user/sqrl_manage.html' + + def get_context_data(self, **kwargs): + context = super(AdminSiteSQRLIdentityManagementView, self).get_context_data(**kwargs) + context.update({ + 'has_permission': True, + }) + return context