Added OpenID support through django-oidc-provider, with tests using the certified python oic module.

- Legacy-Id: 17919
This commit is contained in:
Henrik Levkowetz 2020-06-06 21:01:21 +00:00
parent 516f41e5d7
commit 65c919b325
7 changed files with 266 additions and 8 deletions

View file

@ -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

View file

@ -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, [])

View file

@ -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

View file

@ -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

View file

@ -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 %}
<h1>Request for Permission</h1>
<p>Client <strong>{{ client.name }}</strong> would like to access this information of you ...</p>
<form method="post" action="{% url 'oidc_provider:authorize' %}">
{% csrf_token %}
{{ hidden_inputs }}
<ul>
{% for scope in scopes %}
<li><strong>{{ scope.name }}</strong><br><i>{{ scope.description }}</i></li>
{% endfor %}
</ul>
<input type="submit" value="Decline" />
<input name="allow" type="submit" value="Authorize" />
</form>
{% endblock %}

View file

@ -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 %}
<img class="ietflogo" src="{% static 'ietf/images/ietflogo.png' %}" alt="IETF" style="width: 10em">
<div class='alert'>
<h3>{{ error }}</h3>
<p>{{ description }}</p>
{% endblock %}

View file

@ -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