django-sqrl-2/sqrl/views.py

683 lines
26 KiB
Python

# -*- coding: utf-8 -*-
import json
import logging
from collections import OrderedDict
from pprint import pformat
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.urls import reverse
from django.http import Http404, HttpResponse, JsonResponse, QueryDict
from django.shortcuts import get_object_or_404, redirect
from django.views.generic import FormView, TemplateView, View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from .backends import SQRL_MODEL_BACKEND
from .exceptions import TIF, TIFException
from .forms import (
AuthQueryDictForm,
ExtractedNextUrlForm,
NextUrlForm,
PasswordLessUserCreationForm,
RequestForm,
)
from .models import SQRLIdentity, SQRLNut
from .response import SQRLHttpResponse
from .sqrl import SQRLInitialization
from .utils import Base64, 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'
@method_decorator(csrf_exempt,"dispatch")
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)
@method_decorator(csrf_exempt,"dispatch")
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