Skip to content

Commit 65c919b

Browse files
committed
Added OpenID support through django-oidc-provider, with tests using the certified python oic module.
- Legacy-Id: 17919
1 parent 516f41e commit 65c919b

7 files changed

Lines changed: 266 additions & 8 deletions

File tree

ietf/api/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()),
2727
# For meetecho access
2828
url(r'^person/access/meetecho', api_views.person_access_meetecho),
29+
# OpenID authentication provider
30+
url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')),
2931
]
3032

3133
# Additional (standard) Tastypie endpoints

ietf/ietfauth/tests.py

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,26 @@
22
# -*- coding: utf-8 -*-
33

44

5+
import datetime
56
import io
6-
import os, shutil, time, datetime
7-
from urllib.parse import urlsplit
7+
import logging # pyflakes:ignore
8+
import os
9+
import requests
10+
import requests_mock
11+
import shutil
12+
import time
13+
import urllib
14+
15+
from .factories import OidClientRecordFactory
16+
from Cryptodome.PublicKey import RSA
17+
from oic import rndstr
18+
from oic.oic import Client as OidClient
19+
from oic.oic.message import RegistrationResponse, AuthorizationResponse
20+
from oic.utils.authn.client import CLIENT_AUTHN_METHOD
21+
from oidc_provider.models import RSAKey
822
from pyquery import PyQuery
923
from unittest import skipIf
24+
from urllib.parse import urlsplit
1025

1126
import django.contrib.auth.views
1227
from django.urls import reverse as urlreverse
@@ -19,10 +34,12 @@
1934
from ietf.group.models import Group, Role, RoleName
2035
from ietf.ietfauth.htpasswd import update_htpasswd_file
2136
from ietf.mailinglists.models import Subscribed
37+
from ietf.meeting.factories import MeetingFactory
2238
from ietf.person.factories import PersonFactory, EmailFactory
2339
from ietf.person.models import Person, Email, PersonalApiKey
2440
from ietf.review.factories import ReviewRequestFactory, ReviewAssignmentFactory
2541
from ietf.review.models import ReviewWish, UnavailablePeriod
42+
from ietf.stats.models import MeetingRegistration
2643
from ietf.utils.decorators import skip_coverage
2744
from ietf.utils.mail import outbox, empty_outbox, get_payload_text
2845
from ietf.utils.test_utils import TestCase, login_testing_unauthorized
@@ -647,4 +664,135 @@ def test_send_apikey_report(self):
647664
self.assertIn("API key usage", mail['subject'])
648665
self.assertIn(" %s times" % count, body)
649666
self.assertIn(date, body)
650-
667+
668+
669+
class OpenIDConnectTests(TestCase):
670+
def request_matcher(self, request):
671+
method, url = str(request).split(None, 1)
672+
response = requests.Response()
673+
if method == 'GET':
674+
r = self.client.get(request.path)
675+
elif method == 'POST':
676+
data = dict(urllib.parse.parse_qsl(request.text))
677+
extra = request.headers
678+
for key in [ 'Authorization', ]:
679+
if key in request.headers:
680+
extra['HTTP_%s'%key.upper()] = request.headers[key]
681+
r = self.client.post(request.path, data=data, **extra)
682+
else:
683+
raise ValueError('Unexpected method: %s' % method)
684+
response = requests.Response()
685+
response.status_code = r.status_code
686+
response.raw = r
687+
response.url = url
688+
response.request = request
689+
response._content = r.content
690+
response.encoding = 'utf-8'
691+
for (k,v) in r.items():
692+
response.headers[k] = v
693+
return response
694+
695+
def test_oidc_code_auth(self):
696+
697+
key = RSA.generate(2048)
698+
RSAKey.objects.create(key=key.exportKey('PEM').decode('utf8'))
699+
700+
r = self.client.get('/')
701+
host = r.wsgi_request.get_host()
702+
703+
redirect_uris = [
704+
'https://foo.example.com/',
705+
]
706+
oid_client_record = OidClientRecordFactory(_redirect_uris='\n'.join(redirect_uris), )
707+
708+
with requests_mock.Mocker() as mock:
709+
pass
710+
mock._adapter.add_matcher(self.request_matcher)
711+
712+
# Get a client
713+
client = OidClient(client_authn_method=CLIENT_AUTHN_METHOD)
714+
715+
# Get provider info
716+
client.provider_config( 'http://%s/api/openid' % host)
717+
718+
# No registration step -- we only supported this out-of-band
719+
720+
# Set shared client/provider information in the client
721+
client_reg = RegistrationResponse( client_id= oid_client_record.client_id,
722+
client_secret= oid_client_record.client_secret)
723+
client.store_registration_info(client_reg)
724+
725+
# Get a user for which we want to get access
726+
person = PersonFactory()
727+
RoleFactory(name_id='chair', person=person)
728+
meeting = MeetingFactory(type_id='ietf', date=datetime.date.today())
729+
MeetingRegistration.objects.create(
730+
meeting=meeting, person=person, first_name=person.first_name(), last_name=person.last_name(), email=person.email())
731+
732+
# Get access authorisation
733+
session = {}
734+
session["state"] = rndstr()
735+
session["nonce"] = rndstr()
736+
args = {
737+
"client_id": client.client_id,
738+
"response_type": "code",
739+
"scope": ['openid', 'profile', 'email', 'roles', 'registration', ],
740+
"nonce": session["nonce"],
741+
"redirect_uri": redirect_uris[0],
742+
"state": session["state"]
743+
}
744+
auth_req = client.construct_AuthorizationRequest(request_args=args)
745+
auth_url = auth_req.request(client.authorization_endpoint)
746+
r = self.client.get(auth_url, follow=True)
747+
self.assertEqual(r.status_code, 200)
748+
login_url, __ = r.redirect_chain[-1]
749+
self.assertTrue(login_url.startswith(urlreverse('ietf.ietfauth.views.login')))
750+
751+
# Do login
752+
username = person.user.username
753+
r = self.client.post(login_url, {'username':username, 'password':'%s+password'%username}, follow=True)
754+
self.assertContains(r, 'Request for Permission')
755+
q = PyQuery(r.content)
756+
forms = q('form[action="/api/openid/authorize"]')
757+
self.assertEqual(len(forms), 1)
758+
759+
# Authorize the client to access account information
760+
data = {'allow': 'Authorize'}
761+
for input in q('form[action="/api/openid/authorize"] input[type="hidden"]'):
762+
name = input.get("name")
763+
value = input.get("value")
764+
data[name] = value
765+
r = self.client.post(urlreverse('oidc_provider:authorize'), data)
766+
767+
# Check authorization returns
768+
self.assertEqual(r.status_code, 302)
769+
location = r['Location']
770+
self.assertTrue(location.startswith(redirect_uris[0]))
771+
self.assertIn('state=%s'%data['state'], location)
772+
773+
# Extract the grant code
774+
params = client.parse_response(AuthorizationResponse, info=urllib.parse.urlsplit(location).query, sformat="urlencoded")
775+
776+
# Use grant code to get access token
777+
access_token_info = client.do_access_token_request(state=params['state'],
778+
authn_method='client_secret_basic')
779+
780+
for key in ['access_token', 'refresh_token', 'token_type', 'expires_in', 'id_token']:
781+
self.assertIn(key, access_token_info)
782+
for key in ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'at_hash']:
783+
self.assertIn(key, access_token_info['id_token'])
784+
785+
# Get userinfo, check keys present
786+
userinfo = client.do_user_info_request(state=params["state"], scope=args['scope'])
787+
for key in [ 'email', 'family_name', 'given_name', 'meeting', 'name', 'roles', ]:
788+
self.assertIn(key, userinfo)
789+
790+
r = client.do_end_session_request(state=params["state"], scope=args['scope'])
791+
self.assertEqual(r.status_code, 302)
792+
self.assertEqual(r.headers["Location"], urlreverse('ietf.ietfauth.views.login'))
793+
794+
# The pyjwkent.jwt and oic.utils.keyio modules have had problems with calling
795+
# logging.debug() instead of logger.debug(), which results in setting a root
796+
# handler, causing later logging to become visible even if that wasn't intended.
797+
# Fail here if that happens.
798+
self.assertEqual(logging.root.handlers, [])

ietf/ietfauth/utils.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44

55
# various authentication and authorization utilities
66

7+
import oidc_provider.lib.claims
8+
79
from functools import wraps
810

9-
from django.utils.http import urlquote
1011
from django.conf import settings
12+
from django.contrib.auth import REDIRECT_FIELD_NAME
1113
from django.db.models import Q
1214
from django.http import HttpResponseRedirect, HttpResponseForbidden
13-
from django.contrib.auth import REDIRECT_FIELD_NAME
15+
from django.shortcuts import get_object_or_404
1416
from django.utils.decorators import available_attrs
17+
from django.utils.http import urlquote
1518

1619
import debug # pyflakes:ignore
1720

@@ -196,3 +199,54 @@ def is_individual_draft_author(user, doc):
196199

197200
return False
198201

202+
def openid_userinfo(claims, user):
203+
# Populate claims dict.
204+
person = get_object_or_404(Person, user=user)
205+
claims.update( {
206+
'name': person.plain_name(),
207+
'given_name': person.first_name(),
208+
'family_name': person.last_name(),
209+
'nickname': '-',
210+
'email': person.email().address,
211+
} )
212+
return claims
213+
214+
215+
class OidcExtraScopeClaims(oidc_provider.lib.claims.ScopeClaims):
216+
217+
info_roles = (
218+
"Datatracker role information",
219+
"Access to a list of your IETF roles as known by the datatracker"
220+
)
221+
222+
def scope_roles(self):
223+
roles = self.user.person.role_set.values_list('name__slug', 'group__acronym')
224+
info = {
225+
'roles': list(roles)
226+
}
227+
return info
228+
229+
info_registration = (
230+
"IETF Meeting Registration Info",
231+
"Access to public IETF meeting registration information for the current meeting. "
232+
"Includes meeting number, registration type and ticket type.",
233+
)
234+
235+
def scope_registration(self):
236+
from ietf.meeting.helpers import get_current_ietf_meeting
237+
from ietf.stats.models import MeetingRegistration
238+
meeting = get_current_ietf_meeting()
239+
person = self.user.person
240+
reg = MeetingRegistration.objects.filter(person=person, meeting=meeting).first()
241+
info = {}
242+
if reg:
243+
info = {
244+
'meeting': reg.meeting.number,
245+
# full_week, one_day, student:
246+
'ticket_type': getattr(reg, 'ticket_type') if hasattr(reg, 'ticket_type') else None,
247+
# in_person, onliine, hackathon:
248+
'reg_type': getattr(reg, 'reg_type') if hasattr(reg, 'reg_type') else None,
249+
}
250+
251+
return info
252+

ietf/settings.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ def skip_unreadable_post(record):
392392
'django_password_strength',
393393
'djangobwr',
394394
'form_utils',
395+
'oidc_provider',
395396
'request_profiler',
396397
'simple_history',
397398
'tastypie',
@@ -862,8 +863,16 @@ def skip_unreadable_post(record):
862863
FLOORPLAN_MEDIA_DIR = 'floor'
863864
FLOORPLAN_DIR = os.path.join(MEDIA_ROOT, FLOORPLAN_MEDIA_DIR)
864865

866+
# === OpenID Connect Provide Related Settings ==================================
867+
868+
# Used by django-oidc-provider
869+
LOGIN_URL = '/accounts/login/'
870+
OIDC_USERINFO = 'ietf.ietfauth.utils.openid_userinfo'
871+
OIDC_EXTRA_SCOPE_CLAIMS = 'ietf.ietfauth.utils.OidcExtraScopeClaims'
872+
865873
# ==============================================================================
866874

875+
867876
DOT_BINARY = '/usr/bin/dot'
868877
UNFLATTEN_BINARY= '/usr/bin/unflatten'
869878
RSYNC_BINARY = '/usr/bin/rsync'
@@ -1053,6 +1062,10 @@ def skip_unreadable_post(record):
10531062
CHECKS_LIBRARY_PATCHES_TO_APPLY = [
10541063
'patch/fix-unidecode-argument-warning.patch',
10551064
'patch/fix-request-profiler-streaming-length.patch',
1065+
'patch/change-oidc-provider-field-sizes-228.patch',
1066+
'patch/fix-oidc-access-token-post.patch',
1067+
'patch/fix-jwkest-jwt-logging.patch',
1068+
'patch/fix-oic-logging.patch',
10561069
]
10571070
if DEBUG:
10581071
try:
@@ -1082,7 +1095,6 @@ def skip_unreadable_post(record):
10821095
-----END PRIVATE KEY-----
10831096
"""
10841097

1085-
10861098
# Put the production SECRET_KEY in settings_local.py, and also any other
10871099
# sensitive or site-specific changes. DO NOT commit settings_local.py to svn.
10881100
from ietf.settings_local import * # pyflakes:ignore pylint: disable=wildcard-import
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{# Copyright The IETF Trust 2007, All Rights Reserved #}
2+
{% extends "base.html" %}
3+
{% load staticfiles %}
4+
{% block title %}404 Not Found{% endblock %}
5+
{% block content %}
6+
7+
<h1>Request for Permission</h1>
8+
9+
<p>Client <strong>{{ client.name }}</strong> would like to access this information of you ...</p>
10+
11+
<form method="post" action="{% url 'oidc_provider:authorize' %}">
12+
13+
{% csrf_token %}
14+
15+
{{ hidden_inputs }}
16+
17+
<ul>
18+
{% for scope in scopes %}
19+
<li><strong>{{ scope.name }}</strong><br><i>{{ scope.description }}</i></li>
20+
{% endfor %}
21+
</ul>
22+
23+
<input type="submit" value="Decline" />
24+
<input name="allow" type="submit" value="Authorize" />
25+
26+
</form>
27+
28+
{% endblock %}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{# Copyright The IETF Trust 2007, All Rights Reserved #}
2+
{% extends "base.html" %}
3+
{% load staticfiles %}
4+
{% block title %}404 Not Found{% endblock %}
5+
{% block content %}
6+
7+
<img class="ietflogo" src="{% static 'ietf/images/ietflogo.png' %}" alt="IETF" style="width: 10em">
8+
<div class='alert'>
9+
10+
<h3>{{ error }}</h3>
11+
<p>{{ description }}</p>
12+
13+
{% endblock %}

requirements.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ django-cors-headers>=2.4.0
1616
django-form-utils>=1.0.3
1717
django-formtools>=1.0 # instead of django.contrib.formtools in 1.8
1818
django-markup>=1.1
19+
django-oidc-provider>=0.7
1920
django-password-strength>=1.2.1
2021
django-referrer-policy>=1.0
2122
django-request-profiler==0.14 # 0.15 and above requires Django 2.x
@@ -26,7 +27,6 @@ django-webtest>=1.9.7
2627
django-widget-tweaks>=1.4.2
2728
docutils>=0.12,!=0.15
2829
factory-boy>=2.9.0
29-
google-api-python-client
3030
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.
3131
hashids>=1.1.0
3232
html2text>=2019.8.11
@@ -40,7 +40,7 @@ markdown2>=2.3.8
4040
mock>=2.0.0
4141
mypy==0.750 # Version requirements determined by django-stubs.
4242
mysqlclient>=1.3.13
43-
oauth2client>=4.0.0 # required by google-api-python-client, but not always pulled in
43+
oic>=1.2
4444
pathlib>=1.0
4545
pathlib2>=2.3.0
4646
Pillow>=3.0
@@ -56,6 +56,7 @@ python-mimeparse>=1.6 # from TastyPie
5656
pytz>=2014.7
5757
#pyzmail>=1.0.3
5858
requests!=2.12.*
59+
requests-mock>=1.8
5960
rfc2html>=2.0.1
6061
selenium>=3.9.0
6162
six>=1.10.0

0 commit comments

Comments
 (0)