Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move to Portier #249

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ _templates
log/
bower
dokomoforms/static/dist
package-lock.json
6 changes: 3 additions & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
language: python
python:
- "3.4"
- "3.5"
cache:
directories:
- $HOME/.cache/pip
- node_modules
addons:
postgresql: "9.4"
postgresql: "9.6"
firefox: "42.0"

before_install:
- pip install coveralls flake8 coverage beautifulsoup4 py-dateutil pytz selenium
- pip install coveralls flake8 coverage beautifulsoup4 py-dateutil pytz
- pip install selenium cryptography jwt redis
- gem install coveralls-lcov

before_script:
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.4
FROM python:3.5
WORKDIR /dokomo
RUN apt-get update && apt-get install npm nodejs postgresql-client -y
ADD package.json /tmp/package.json
Expand Down
1 change: 0 additions & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,3 @@ Indices and tables
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

8 changes: 4 additions & 4 deletions dokomoforms/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""All the Tornado RequestHandlers used in Dokomo Forms."""
from dokomoforms.handlers.root import Index, NotFound
from dokomoforms.handlers.auth import (
Login, Logout, GenerateToken, CheckLoginStatus
VerifyLoginHandler, Logout, GenerateToken, CheckLoginStatus
)
from dokomoforms.handlers.user.admin import (
AdminHomepageHandler,
Expand All @@ -13,7 +13,7 @@
)
from dokomoforms.handlers.debug import (
DebugUserCreationHandler, DebugLoginHandler, DebugLogoutHandler,
DebugPersonaHandler, DebugRevisitHandler, DebugToggleRevisitHandler,
DebugRevisitHandler, DebugToggleRevisitHandler,
DebugToggleRevisitSlowModeHandler
)
from dokomoforms.handlers.demo import (
Expand All @@ -22,13 +22,13 @@

__all__ = (
'Index', 'NotFound',
'Login', 'Logout', 'GenerateToken',
'VerifyLoginHandler', 'Logout', 'GenerateToken',
'AdminHomepageHandler', 'CheckLoginStatus',
'ViewSurveyHandler', 'ViewSurveyDataHandler', 'ViewUserAdminHandler',
'ViewSubmissionHandler',
'EnumerateHomepageHandler', 'Enumerate', 'EnumerateTitle',
'DebugUserCreationHandler', 'DebugLoginHandler', 'DebugLogoutHandler',
'DebugPersonaHandler', 'DebugRevisitHandler', 'DebugToggleRevisitHandler',
'DebugRevisitHandler', 'DebugToggleRevisitHandler',
'DebugToggleRevisitSlowModeHandler',
'DemoUserCreationHandler', 'DemoLogoutHandler'
)
97 changes: 23 additions & 74 deletions dokomoforms/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,106 +2,54 @@

from collections import OrderedDict
from datetime import datetime, timedelta
import urllib.parse
import uuid

from sqlalchemy.orm.exc import NoResultFound

from tornado.escape import json_decode
import tornado.concurrent
import tornado.web
import tornado.gen
import tornado.httpclient

from passlib.hash import bcrypt_sha256

from dokomoforms.options import options
from dokomoforms.handlers.portier import get_verified_email
from dokomoforms.handlers.util import BaseHandler, authenticated_admin
from dokomoforms.models import User, Administrator, Email
from dokomoforms.models import User, Email


class Login(BaseHandler):
class VerifyLoginHandler(BaseHandler):

"""POST here to verify the assertion generated by Mozilla Persona."""
"""Handlers for portier verification."""

def _async_post(self,
http_client, url, input_data) -> tornado.concurrent.Future:
"""Asynchronously POSTs input_data to the url using http_client.fetch.
def check_xsrf_cookie(self):
"""Disable XSRF check.

:param http_client: the HTTP client
:param url: the URL for POSTing
:param input_data: the data to POST
:return: a tornado.concurrent.Future that will contain the response
OpenID doesn't reply with _xsrf header.
https://github.com/portier/demo-rp/issues/10
"""
return tornado.gen.Task(
http_client.fetch,
url,
method='POST',
body=urllib.parse.urlencode(input_data),
)

@tornado.web.asynchronous
@tornado.gen.engine
def post(self):
"""POST to Mozilla's verifier service.

Accepts:
{ "assertion": <assertion> }

Then, POSTS to https://verifier.login.persona.org/verify to verify that
the assertion is valid. If so, attempts to log the user in by e-mail.

Responds with:
200 OK
{ "email": <e-mail address> }

:raise tornado.web.HTTPError: 400 Bad Request if the assertion is not
verified
422 Unprocessable Entity if the e-mail
address is not associated with a user
account.
"""
assertion = self.get_argument('assertion')
http_client = tornado.httpclient.AsyncHTTPClient()
url = options.persona_verification_url
input_data = {'assertion': assertion, 'audience': self.request.host}
response = yield self._async_post(http_client, url, input_data)
data = json_decode(response.body)
if data['status'] != 'okay':
raise tornado.web.HTTPError(400, 'Failed assertion test')

pass

async def post(self):
"""Verify the response from the portier broker."""
if 'error' in self.request.arguments:
raise tornado.web.HTTPError(400)
token = self.get_argument('id_token')
email = await get_verified_email(token)
try:
user = (
self.session
.query(User.id, User.name)
.join(Email)
.filter(Email.address == data['email'])
.filter(Email.address == email)
.one()
)
except NoResultFound:
if data['email'] != options.admin_email:
_ = self.locale.translate
raise tornado.web.HTTPError(
422,
reason=_(
'There is no account associated with the e-mail'
' address {}'.format(data['email'])
),
)
with self.session.begin():
user = Administrator(
name=options.admin_name,
emails=[Email(address=options.admin_email)]
)
self.session.add(user)
cookie_options = {
'httponly': True,
}
if options.https:
cookie_options['secure'] = True
self.set_secure_cookie('user', user.id, **cookie_options)
self.write({'email': data['email']})
self.finish()
raise tornado.web.HTTPError(400)
self.set_secure_cookie(
'user', str(user.id),
httponly=True, secure=True)
self.redirect(self.get_argument('next', '/'))


class Logout(BaseHandler):
Expand All @@ -115,6 +63,7 @@ def post(self):
httponly.
"""
self.clear_cookie('user')
self.redirect('/')


class GenerateToken(BaseHandler): # We should probably do this in JS
Expand Down
13 changes: 0 additions & 13 deletions dokomoforms/handlers/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,6 @@ def get(self):
self.write('You have logged out.')


class DebugPersonaHandler(BaseHandler):

"""For testing purposes there's no need to hit the real URL."""

def check_xsrf_cookie(self):
"""No need for this..."""
pass

def post(self):
"""The test user has logged in."""
self.write({'status': 'okay', 'email': '[email protected]'})


revisit_online = slow_mode = None
facilities_file = compressed_facilities = lzs = None

Expand Down
146 changes: 146 additions & 0 deletions dokomoforms/handlers/portier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Utility functions for portier login."""
from base64 import urlsafe_b64decode
from datetime import timedelta
import re

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa

import jwt

import redis

from tornado.escape import json_decode
from tornado.httpclient import AsyncHTTPClient
import tornado.web

from dokomoforms.options import options


redis_kv = redis.StrictRedis.from_url(options.redis_url)


def b64dec(string):
"""Decode unpadded URL-safe Base64 strings.

Base64 values in JWTs and JWKs have their padding '=' characters stripped
during serialization. Before decoding, we must re-append padding characters
so that the encoded value's final length is evenly divisible by 4.

Taken from
github.com/portier/demo-rp/blob/6aee99fe126eceda527cae1f6da3f02a68401b6e
/server.py#L176-L184
"""
padding = '=' * ((4 - len(string) % 4) % 4)
return urlsafe_b64decode(string + padding)


async def get_verified_email(token):
"""Validate an Identity Token (JWT) and return its subject (email address).

In Portier, the subject field contains the user's verified email address.

This functions checks the authenticity of the JWT with the following steps:

1. Verify that the JWT has a valid signature from a trusted broker.
2. Validate that all claims are present and conform to expectations:

* ``aud`` (audience) must match this website's origin.
* ``iss`` (issuer) must match the broker's origin.
* ``exp`` (expires) must be in the future.
* ``iat`` (issued at) must be in the past.
* ``sub`` (subject) must be an email address.
* ``nonce`` (cryptographic nonce) must not have been seen previously.

3. If present, verify that the ``nbf`` (not before) claim is in the past.

Timestamps are allowed a few minutes of leeway to account for clock skew.

This demo relies on the `PyJWT`_ library to check signatures and validate
all claims except for ``sub`` and ``nonce``. Those are checked separately.

.. _PyJWT: https://github.com/jpadilla/pyjwt

Taken from
github.com/portier/demo-rp/blob/6aee99fe126eceda527cae1f6da3f02a68401b6e
/server.py#L240-L296
"""
keys = await discover_keys('https://broker.portier.io')
raw_header, _, _ = token.partition('.')
header = json_decode(b64dec(raw_header))
try:
pub_key = keys[header['kid']]
except KeyError:
raise tornado.web.HTTPError(400)
try:
payload = jwt.decode(
token, pub_key,
algorithms=['RS256'],
audience=options.minigrid_website_url,
issuer='https://broker.portier.io',
leeway=3 * 60,
)
except Exception as exc:
raise tornado.web.HTTPError(400)
if not re.match('.+@.+', payload['sub']):
raise tornado.web.HTTPError(400)
if not redis_kv.delete(payload['nonce']):
raise tornado.web.HTTPError(400)
return payload['sub']


def jwk_to_rsa(key):
"""Convert a deserialized JWK into an RSA Public Key instance.

Taken from
github.com/portier/demo-rp/blob/6aee99fe126eceda527cae1f6da3f02a68401b6e
/server.py#L233-L237
"""
e = int.from_bytes(b64dec(key['e']), 'big')
n = int.from_bytes(b64dec(key['n']), 'big')
return rsa.RSAPublicNumbers(e, n).public_key(default_backend())


async def discover_keys(broker):
"""Discover and return a Broker's public keys.

Returns a dict mapping from Key ID strings to Public Key instances.

Portier brokers implement the `OpenID Connect Discovery`_ specification.
This function follows that specification to discover the broker's current
cryptographic public keys:

1. Fetch the Discovery Document from ``/.well-known/openid-configuration``.
2. Parse it as JSON and read the ``jwks_uri`` property.
3. Fetch the URL referenced by ``jwks_uri`` to retrieve a `JWK Set`_.
4. Parse the JWK Set as JSON and extract keys from the ``keys`` property.

Portier currently only supports keys with the ``RS256`` algorithm type.

.. _OpenID Connect Discovery:
https://openid.net/specs/openid-connect-discovery-1_0.html
.. _JWK Set: https://tools.ietf.org/html/rfc7517#section-5

Taken from
github.com/portier/demo-rp/blob/6aee99fe126eceda527cae1f6da3f02a68401b6e
/server.py#L187-L206
"""
cache_key = 'jwks:' + broker
raw_jwks = redis_kv.get(cache_key)
if not raw_jwks:
http_client = AsyncHTTPClient()
url = broker + '/.well-known/openid-configuration'
response = await http_client.fetch(url)
discovery = json_decode(response.body)
if 'jwks_uri' not in discovery:
raise tornado.web.HTTPError(400)
raw_jwks = (await http_client.fetch(discovery['jwks_uri'])).body
redis_kv.setex(cache_key, timedelta(minutes=5), raw_jwks)
jwks = json_decode(raw_jwks)
if 'keys' not in jwks:
raise tornado.web.HTTPError(400)
return {
key['kid']: jwk_to_rsa(key)
for key in jwks['keys']
if key['alg'] == 'RS256'
}
Loading