diff --git a/ietf/api/urls.py b/ietf/api/urls.py index 7a9274b8b..367438dfc 100644 --- a/ietf/api/urls.py +++ b/ietf/api/urls.py @@ -26,6 +26,8 @@ urlpatterns = [ url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()), # For meetecho access url(r'^person/access/meetecho', api_views.person_access_meetecho), + # OpenID authentication provider + url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), ] # Additional (standard) Tastypie endpoints diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index 4a319d46e..9a6bb2a57 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -2,11 +2,26 @@ # -*- coding: utf-8 -*- +import datetime import io -import os, shutil, time, datetime -from urllib.parse import urlsplit +import logging # pyflakes:ignore +import os +import requests +import requests_mock +import shutil +import time +import urllib + +from .factories import OidClientRecordFactory +from Cryptodome.PublicKey import RSA +from oic import rndstr +from oic.oic import Client as OidClient +from oic.oic.message import RegistrationResponse, AuthorizationResponse +from oic.utils.authn.client import CLIENT_AUTHN_METHOD +from oidc_provider.models import RSAKey from pyquery import PyQuery from unittest import skipIf +from urllib.parse import urlsplit import django.contrib.auth.views from django.urls import reverse as urlreverse @@ -19,10 +34,12 @@ from ietf.group.factories import GroupFactory, RoleFactory from ietf.group.models import Group, Role, RoleName from ietf.ietfauth.htpasswd import update_htpasswd_file from ietf.mailinglists.models import Subscribed +from ietf.meeting.factories import MeetingFactory from ietf.person.factories import PersonFactory, EmailFactory from ietf.person.models import Person, Email, PersonalApiKey from ietf.review.factories import ReviewRequestFactory, ReviewAssignmentFactory from ietf.review.models import ReviewWish, UnavailablePeriod +from ietf.stats.models import MeetingRegistration from ietf.utils.decorators import skip_coverage from ietf.utils.mail import outbox, empty_outbox, get_payload_text from ietf.utils.test_utils import TestCase, login_testing_unauthorized @@ -647,4 +664,135 @@ class IetfAuthTests(TestCase): self.assertIn("API key usage", mail['subject']) self.assertIn(" %s times" % count, body) self.assertIn(date, body) - + + +class OpenIDConnectTests(TestCase): + def request_matcher(self, request): + method, url = str(request).split(None, 1) + response = requests.Response() + if method == 'GET': + r = self.client.get(request.path) + elif method == 'POST': + data = dict(urllib.parse.parse_qsl(request.text)) + extra = request.headers + for key in [ 'Authorization', ]: + if key in request.headers: + extra['HTTP_%s'%key.upper()] = request.headers[key] + r = self.client.post(request.path, data=data, **extra) + else: + raise ValueError('Unexpected method: %s' % method) + response = requests.Response() + response.status_code = r.status_code + response.raw = r + response.url = url + response.request = request + response._content = r.content + response.encoding = 'utf-8' + for (k,v) in r.items(): + response.headers[k] = v + return response + + def test_oidc_code_auth(self): + + key = RSA.generate(2048) + RSAKey.objects.create(key=key.exportKey('PEM').decode('utf8')) + + r = self.client.get('/') + host = r.wsgi_request.get_host() + + redirect_uris = [ + 'https://foo.example.com/', + ] + oid_client_record = OidClientRecordFactory(_redirect_uris='\n'.join(redirect_uris), ) + + with requests_mock.Mocker() as mock: + pass + mock._adapter.add_matcher(self.request_matcher) + + # Get a client + client = OidClient(client_authn_method=CLIENT_AUTHN_METHOD) + + # Get provider info + client.provider_config( 'http://%s/api/openid' % host) + + # No registration step -- we only supported this out-of-band + + # Set shared client/provider information in the client + client_reg = RegistrationResponse( client_id= oid_client_record.client_id, + client_secret= oid_client_record.client_secret) + client.store_registration_info(client_reg) + + # Get a user for which we want to get access + person = PersonFactory() + RoleFactory(name_id='chair', person=person) + meeting = MeetingFactory(type_id='ietf', date=datetime.date.today()) + MeetingRegistration.objects.create( + meeting=meeting, person=person, first_name=person.first_name(), last_name=person.last_name(), email=person.email()) + + # Get access authorisation + session = {} + session["state"] = rndstr() + session["nonce"] = rndstr() + args = { + "client_id": client.client_id, + "response_type": "code", + "scope": ['openid', 'profile', 'email', 'roles', 'registration', ], + "nonce": session["nonce"], + "redirect_uri": redirect_uris[0], + "state": session["state"] + } + auth_req = client.construct_AuthorizationRequest(request_args=args) + auth_url = auth_req.request(client.authorization_endpoint) + r = self.client.get(auth_url, follow=True) + self.assertEqual(r.status_code, 200) + login_url, __ = r.redirect_chain[-1] + self.assertTrue(login_url.startswith(urlreverse('ietf.ietfauth.views.login'))) + + # Do login + username = person.user.username + r = self.client.post(login_url, {'username':username, 'password':'%s+password'%username}, follow=True) + self.assertContains(r, 'Request for Permission') + q = PyQuery(r.content) + forms = q('form[action="/api/openid/authorize"]') + self.assertEqual(len(forms), 1) + + # Authorize the client to access account information + data = {'allow': 'Authorize'} + for input in q('form[action="/api/openid/authorize"] input[type="hidden"]'): + name = input.get("name") + value = input.get("value") + data[name] = value + r = self.client.post(urlreverse('oidc_provider:authorize'), data) + + # Check authorization returns + self.assertEqual(r.status_code, 302) + location = r['Location'] + self.assertTrue(location.startswith(redirect_uris[0])) + self.assertIn('state=%s'%data['state'], location) + + # Extract the grant code + params = client.parse_response(AuthorizationResponse, info=urllib.parse.urlsplit(location).query, sformat="urlencoded") + + # Use grant code to get access token + access_token_info = client.do_access_token_request(state=params['state'], + authn_method='client_secret_basic') + + for key in ['access_token', 'refresh_token', 'token_type', 'expires_in', 'id_token']: + self.assertIn(key, access_token_info) + for key in ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'at_hash']: + self.assertIn(key, access_token_info['id_token']) + + # Get userinfo, check keys present + userinfo = client.do_user_info_request(state=params["state"], scope=args['scope']) + for key in [ 'email', 'family_name', 'given_name', 'meeting', 'name', 'roles', ]: + self.assertIn(key, userinfo) + + r = client.do_end_session_request(state=params["state"], scope=args['scope']) + self.assertEqual(r.status_code, 302) + self.assertEqual(r.headers["Location"], urlreverse('ietf.ietfauth.views.login')) + + # The pyjwkent.jwt and oic.utils.keyio modules have had problems with calling + # logging.debug() instead of logger.debug(), which results in setting a root + # handler, causing later logging to become visible even if that wasn't intended. + # Fail here if that happens. + self.assertEqual(logging.root.handlers, []) diff --git a/ietf/ietfauth/utils.py b/ietf/ietfauth/utils.py index 6232ca839..d7916013d 100644 --- a/ietf/ietfauth/utils.py +++ b/ietf/ietfauth/utils.py @@ -4,14 +4,17 @@ # various authentication and authorization utilities +import oidc_provider.lib.claims + from functools import wraps -from django.utils.http import urlquote from django.conf import settings +from django.contrib.auth import REDIRECT_FIELD_NAME from django.db.models import Q from django.http import HttpResponseRedirect, HttpResponseForbidden -from django.contrib.auth import REDIRECT_FIELD_NAME +from django.shortcuts import get_object_or_404 from django.utils.decorators import available_attrs +from django.utils.http import urlquote import debug # pyflakes:ignore @@ -196,3 +199,54 @@ def is_individual_draft_author(user, doc): return False +def openid_userinfo(claims, user): + # Populate claims dict. + person = get_object_or_404(Person, user=user) + claims.update( { + 'name': person.plain_name(), + 'given_name': person.first_name(), + 'family_name': person.last_name(), + 'nickname': '-', + 'email': person.email().address, + } ) + return claims + + +class OidcExtraScopeClaims(oidc_provider.lib.claims.ScopeClaims): + + info_roles = ( + "Datatracker role information", + "Access to a list of your IETF roles as known by the datatracker" + ) + + def scope_roles(self): + roles = self.user.person.role_set.values_list('name__slug', 'group__acronym') + info = { + 'roles': list(roles) + } + return info + + info_registration = ( + "IETF Meeting Registration Info", + "Access to public IETF meeting registration information for the current meeting. " + "Includes meeting number, registration type and ticket type.", + ) + + def scope_registration(self): + from ietf.meeting.helpers import get_current_ietf_meeting + from ietf.stats.models import MeetingRegistration + meeting = get_current_ietf_meeting() + person = self.user.person + reg = MeetingRegistration.objects.filter(person=person, meeting=meeting).first() + info = {} + if reg: + info = { + 'meeting': reg.meeting.number, + # full_week, one_day, student: + 'ticket_type': getattr(reg, 'ticket_type') if hasattr(reg, 'ticket_type') else None, + # in_person, onliine, hackathon: + 'reg_type': getattr(reg, 'reg_type') if hasattr(reg, 'reg_type') else None, + } + + return info + \ No newline at end of file diff --git a/ietf/settings.py b/ietf/settings.py index 1ad5ec956..ea0e052e5 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -392,6 +392,7 @@ INSTALLED_APPS = [ 'django_password_strength', 'djangobwr', 'form_utils', + 'oidc_provider', 'request_profiler', 'simple_history', 'tastypie', @@ -862,8 +863,16 @@ INTERNET_DRAFT_DAYS_TO_EXPIRE = 185 FLOORPLAN_MEDIA_DIR = 'floor' FLOORPLAN_DIR = os.path.join(MEDIA_ROOT, FLOORPLAN_MEDIA_DIR) +# === OpenID Connect Provide Related Settings ================================== + +# Used by django-oidc-provider +LOGIN_URL = '/accounts/login/' +OIDC_USERINFO = 'ietf.ietfauth.utils.openid_userinfo' +OIDC_EXTRA_SCOPE_CLAIMS = 'ietf.ietfauth.utils.OidcExtraScopeClaims' + # ============================================================================== + DOT_BINARY = '/usr/bin/dot' UNFLATTEN_BINARY= '/usr/bin/unflatten' RSYNC_BINARY = '/usr/bin/rsync' @@ -1053,6 +1062,10 @@ SILENCED_SYSTEM_CHECKS = [ CHECKS_LIBRARY_PATCHES_TO_APPLY = [ 'patch/fix-unidecode-argument-warning.patch', 'patch/fix-request-profiler-streaming-length.patch', + 'patch/change-oidc-provider-field-sizes-228.patch', + 'patch/fix-oidc-access-token-post.patch', + 'patch/fix-jwkest-jwt-logging.patch', + 'patch/fix-oic-logging.patch', ] if DEBUG: try: @@ -1082,7 +1095,6 @@ qvNU+qRWi+YXrITsgn92/gVxX5AoK0n+s5Lx7fpjxkARVi66SF6zTJnX -----END PRIVATE KEY----- """ - # Put the production SECRET_KEY in settings_local.py, and also any other # sensitive or site-specific changes. DO NOT commit settings_local.py to svn. from ietf.settings_local import * # pyflakes:ignore pylint: disable=wildcard-import diff --git a/ietf/templates/oidc_provider/authorize.html b/ietf/templates/oidc_provider/authorize.html new file mode 100644 index 000000000..f4946f209 --- /dev/null +++ b/ietf/templates/oidc_provider/authorize.html @@ -0,0 +1,28 @@ +{# Copyright The IETF Trust 2007, All Rights Reserved #} +{% extends "base.html" %} +{% load staticfiles %} +{% block title %}404 Not Found{% endblock %} +{% block content %} + +

Request for Permission

+ +

Client {{ client.name }} would like to access this information of you ...

+ +
+ + {% csrf_token %} + + {{ hidden_inputs }} + + + + + + +
+ +{% endblock %} diff --git a/ietf/templates/oidc_provider/error.html b/ietf/templates/oidc_provider/error.html new file mode 100644 index 000000000..ec46971eb --- /dev/null +++ b/ietf/templates/oidc_provider/error.html @@ -0,0 +1,13 @@ +{# Copyright The IETF Trust 2007, All Rights Reserved #} +{% extends "base.html" %} +{% load staticfiles %} +{% block title %}404 Not Found{% endblock %} +{% block content %} + + +
+ +

{{ error }}

+

{{ description }}

+ +{% endblock %} diff --git a/requirements.txt b/requirements.txt index f7164c8e5..a920d30d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ django-cors-headers>=2.4.0 django-form-utils>=1.0.3 django-formtools>=1.0 # instead of django.contrib.formtools in 1.8 django-markup>=1.1 +django-oidc-provider>=0.7 django-password-strength>=1.2.1 django-referrer-policy>=1.0 django-request-profiler==0.14 # 0.15 and above requires Django 2.x @@ -26,7 +27,6 @@ django-webtest>=1.9.7 django-widget-tweaks>=1.4.2 docutils>=0.12,!=0.15 factory-boy>=2.9.0 -google-api-python-client Faker>=0.8.8,!=0.8.9,!=0.8.10 # from factory-boy # Faker 0.8.9,0.8.10 sometimes return string names instead of unicode. hashids>=1.1.0 html2text>=2019.8.11 @@ -40,7 +40,7 @@ markdown2>=2.3.8 mock>=2.0.0 mypy==0.750 # Version requirements determined by django-stubs. mysqlclient>=1.3.13 -oauth2client>=4.0.0 # required by google-api-python-client, but not always pulled in +oic>=1.2 pathlib>=1.0 pathlib2>=2.3.0 Pillow>=3.0 @@ -56,6 +56,7 @@ python-mimeparse>=1.6 # from TastyPie pytz>=2014.7 #pyzmail>=1.0.3 requests!=2.12.* +requests-mock>=1.8 rfc2html>=2.0.1 selenium>=3.9.0 six>=1.10.0