diff --git a/getlasso.sh b/getlasso.sh new file mode 100755 index 0000000..680da39 --- /dev/null +++ b/getlasso.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# Get venv site-packages path +DSTDIR=`python -c 'from distutils.sysconfig import get_python_lib; print(get_python_lib())'` + +# Get not venv site-packages path +# Remove first path (assuming that is the venv path) +NONPATH=`echo $PATH | sed 's/^[^:]*://'` +SRCDIR=`PATH=$NONPATH python -c 'from distutils.sysconfig import get_python_lib; print(get_python_lib())'` + +# Clean up +rm -f $DSTDIR/lasso.* +rm -f $DSTDIR/_lasso.* + +# Link +ln -sv $SRCDIR/lasso.py $DSTDIR +ln -sv $SRCDIR/_lasso.* $DSTDIR + +exit 0 + diff --git a/src/authentic2_auth_fc/models.py b/src/authentic2_auth_fc/models.py index 2013ee9..4b66c7b 100644 --- a/src/authentic2_auth_fc/models.py +++ b/src/authentic2_auth_fc/models.py @@ -1,10 +1,18 @@ import base64 import json +import hmac +import hashlib +import urlparse from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.utils.timezone import now from django.conf import settings +from authentic2_auth_oidc.utils import parse_timestamp + +from . import app_settings + def base64url_decode(input): rem = len(input) % 4 @@ -13,10 +21,36 @@ def base64url_decode(input): return base64.urlsafe_b64decode(input) -def parse_id_token(id_token): - payload = id_token.split('.')[1] +def parse_id_token(id_token, client_id=None, client_secret=None): + try: + splitted = str(id_token).split('.') + except: + return None, 'invalid id_token' + if len(splitted) != 3: + return None, 'invalid id_token' + header, payload, signature = splitted + try: + signature = base64url_decode(signature) + except (ValueError, TypeError): + return None, 'invalid signature' + signed = '%s.%s' % (header, payload) + if client_secret is not None: + h = hmac.HMAC(key=client_secret, msg=signed, digestmod=hashlib.sha256) + if h.digest() != signature: + return None, 'hmac signature does not match' payload = base64url_decode(str(payload)) - return json.loads(payload) + try: + payload = json.loads(payload) + except ValueError: + return None, 'invalid payload' + if client_id and ('aud' not in payload or payload['aud'] != client_id): + return None, 'invalid audience' + if 'exp' not in payload or parse_timestamp(payload['exp']) < now(): + return None, 'id_token is expired' + parsed = urlparse.urlparse(app_settings.authorize_url) + if 'iss' not in payload or payload['iss'] != '%s://%s/' % (parsed.scheme, parsed.netloc): + return None, 'wrong issuer received, %r' % payload['iss'] + return payload, None class FcAccount(models.Model): diff --git a/src/authentic2_auth_fc/views.py b/src/authentic2_auth_fc/views.py index e9f61fd..c25f80a 100644 --- a/src/authentic2_auth_fc/views.py +++ b/src/authentic2_auth_fc/views.py @@ -249,8 +249,15 @@ class FcOAuthSessionViewMixin(LoggerMixin): self.logger.warning(msg) messages.warning(request, _('Unable to connect to FranceConnect.')) return self.redirect(request) - - self.id_token = models.parse_id_token(self.token['id_token']) + key = app_settings.client_secret + if isinstance(key, unicode): + key = key.encode('utf-8') + self.id_token, error = models.parse_id_token( + self.token['id_token'], client_id=app_settings.client_id, client_secret=key) + if not self.id_token: + self.logger.warning(u'validation of id_token failed: %s', error) + messages.warning(request, _('Unable to connect to FranceConnect.')) + return self.redirect(request) nonce = self.id_token.get('nonce') states = request.session.get('fc-states', {}) if not nonce or nonce not in states: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c5d8db1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest +import django_webtest + + +@pytest.fixture +def app(request, db): + wtm = django_webtest.WebTestMixin() + wtm._patch_settings() + request.addfinalizer(wtm._unpatch_settings) + return django_webtest.DjangoTestApp(extra_environ={'HTTP_HOST': 'localhost'}) + + +@pytest.fixture +def fc_settings(settings): + settings.A2_FC_ENABLE = True + settings.A2_FC_CLIENT_ID = 'xxx' + settings.A2_FC_CLIENT_SECRET = 'yyy' + return settings + + diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..2e48c94 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,11 @@ +import os + +LANGUAGE_CODE = 'en' +DATABASES = { + 'default': { + 'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'), + 'TEST': { + 'NAME': 'a2-test', + }, + } +} diff --git a/tests/test_auth_fc.py b/tests/test_auth_fc.py new file mode 100644 index 0000000..d090142 --- /dev/null +++ b/tests/test_auth_fc.py @@ -0,0 +1,100 @@ +import pytest +import urlparse +import httmock +import json +import base64 +from jwcrypto import jwk, jwt +import datetime + +from django.core.urlresolvers import reverse +from django.contrib.auth import get_user_model +from django.utils.timezone import now + +from authentic2.utils import timestamp_from_datetime + + +User = get_user_model() + + +def hmac_jwt(payload, key): + header = {'alg': 'HS256'} + k = jwk.JWK(kty='oct', k=base64.b64encode(key.encode('utf-8'))) + t = jwt.JWT(header=header, claims=payload) + t.make_signed_token(k) + return t.serialize() + + +def test_login_redirect(app, fc_settings): + url = reverse('fc-login-or-link') + response = app.get(url, status=302) + assert response['Location'].startswith('https://fcp.integ01') + + +def check_authorization_url(url): + callback = reverse('fc-login-or-link') + assert url.startswith('https://fcp.integ01') + query_string = url.split('?')[1] + parsed = {x: y[0] for x, y in urlparse.parse_qs(query_string).items()} + assert 'redirect_uri' in parsed + assert callback in parsed['redirect_uri'] + assert 'client_id' in parsed + assert parsed['client_id'] == 'xxx' + assert 'scope' in parsed + assert set(parsed['scope'].split()) == set(['openid', 'profile', 'birth', 'email']) + assert 'state' in parsed + assert 'nonce' in parsed + assert parsed['state'] == parsed['nonce'] + assert 'response_type' in parsed + assert parsed['response_type'] == 'code' + return parsed['state'] + + +@pytest.mark.parametrize('exp', [timestamp_from_datetime(now() + datetime.timedelta(seconds=1000)), + timestamp_from_datetime(now() - datetime.timedelta(seconds=1000))]) +def test_login(app, fc_settings, caplog, exp): + callback = reverse('fc-login-or-link') + response = app.get(callback, status=302) + location = response['Location'] + state = check_authorization_url(location) + + @httmock.urlmatch(path=r'.*/token$') + def access_token_response(url, request): + parsed = {x: y[0] for x, y in urlparse.parse_qs(request.body).items()} + assert set(parsed.keys()) == set(['code', 'client_id', 'client_secret', 'redirect_uri', + 'grant_type']) + assert parsed['code'] == 'zzz' + assert parsed['client_id'] == 'xxx' + assert parsed['client_secret'] == 'yyy' + assert parsed['grant_type'] == 'authorization_code' + assert callback in parsed['redirect_uri'] + id_token = { + 'sub': '1234', + 'aud': 'xxx', + 'nonce': state, + 'exp': exp, + 'iss': 'https://fcp.integ01.dev-franceconnect.fr/', + } + return json.dumps({ + 'access_token': 'uuu', + 'id_token': hmac_jwt(id_token, 'yyy') + }) + + @httmock.urlmatch(path=r'.*userinfo$') + def user_info_response(url, request): + assert request.headers['Authorization'] == 'Bearer uuu' + return json.dumps({ + 'sub': '1234', + 'family_name': 'Doe', + 'given_name': 'John', + }) + + with httmock.HTTMock(access_token_response, user_info_response): + response = app.get(callback + '?code=zzz&state=%s' % state, status=302) + assert User.objects.count() == 0 + fc_settings.A2_FC_CREATE = True + with httmock.HTTMock(access_token_response, user_info_response): + response = app.get(callback + '?code=zzz&state=%s' % state, status=302) + if exp < timestamp_from_datetime(now()): + assert User.objects.count() == 0 + else: + assert User.objects.count() == 1 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..4412f9b --- /dev/null +++ b/tox.ini @@ -0,0 +1,46 @@ +# Tox (http://tox.testrun.org/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/authentic2-auth-fc/ +envlist = {coverage,nocoverage}-{dj18}-{pg,sqlite} + +[testenv] +# django.contrib.auth is not tested it does not work with our templates +whitelist_externals = + /bin/mv +setenv = + AUTHENTIC2_SETTINGS_FILE=tests/settings.py + DJANGO_SETTINGS_MODULE=authentic2.settings + sqlite: DB_ENGINE=django.db.backends.sqlite3 + pg: DB_ENGINE=django.db.backends.postgresql_psycopg2 + coverage: COVERAGE=--junitxml=junit-{envname}.xml --cov-report xml --cov=src/ --cov-config .coveragerc + fast: FAST=--nomigrations +usedevelop = + coverage: True + nocoverage: False +deps = + dj18: django>1.8,<1.9 + dj19: django>1.8,<1.9 + pg: psycopg2<2.7 + coverage + pytest-cov + pytest-django + mock + pytest + lxml + cssselect + pylint + pylint-django + django-webtest + WebTest + pyquery + httmock + pytest-catchlog + pytz + ../authentic2 +commands = + ./getlasso.sh + py.test {env:FAST:} {env:COVERAGE:} {posargs:tests/}