Added OpenID support through django-oidc-provider, with tests using the certified python oic module.
- Legacy-Id: 17919
This commit is contained in:
parent
516f41e5d7
commit
65c919b325
|
@ -26,6 +26,8 @@ urlpatterns = [
|
||||||
url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()),
|
url(r'^v2/person/person', api_views.ApiV2PersonExportView.as_view()),
|
||||||
# For meetecho access
|
# For meetecho access
|
||||||
url(r'^person/access/meetecho', api_views.person_access_meetecho),
|
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
|
# Additional (standard) Tastypie endpoints
|
||||||
|
|
|
@ -2,11 +2,26 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
import datetime
|
||||||
import io
|
import io
|
||||||
import os, shutil, time, datetime
|
import logging # pyflakes:ignore
|
||||||
from urllib.parse import urlsplit
|
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 pyquery import PyQuery
|
||||||
from unittest import skipIf
|
from unittest import skipIf
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import django.contrib.auth.views
|
import django.contrib.auth.views
|
||||||
from django.urls import reverse as urlreverse
|
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.group.models import Group, Role, RoleName
|
||||||
from ietf.ietfauth.htpasswd import update_htpasswd_file
|
from ietf.ietfauth.htpasswd import update_htpasswd_file
|
||||||
from ietf.mailinglists.models import Subscribed
|
from ietf.mailinglists.models import Subscribed
|
||||||
|
from ietf.meeting.factories import MeetingFactory
|
||||||
from ietf.person.factories import PersonFactory, EmailFactory
|
from ietf.person.factories import PersonFactory, EmailFactory
|
||||||
from ietf.person.models import Person, Email, PersonalApiKey
|
from ietf.person.models import Person, Email, PersonalApiKey
|
||||||
from ietf.review.factories import ReviewRequestFactory, ReviewAssignmentFactory
|
from ietf.review.factories import ReviewRequestFactory, ReviewAssignmentFactory
|
||||||
from ietf.review.models import ReviewWish, UnavailablePeriod
|
from ietf.review.models import ReviewWish, UnavailablePeriod
|
||||||
|
from ietf.stats.models import MeetingRegistration
|
||||||
from ietf.utils.decorators import skip_coverage
|
from ietf.utils.decorators import skip_coverage
|
||||||
from ietf.utils.mail import outbox, empty_outbox, get_payload_text
|
from ietf.utils.mail import outbox, empty_outbox, get_payload_text
|
||||||
from ietf.utils.test_utils import TestCase, login_testing_unauthorized
|
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("API key usage", mail['subject'])
|
||||||
self.assertIn(" %s times" % count, body)
|
self.assertIn(" %s times" % count, body)
|
||||||
self.assertIn(date, 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, [])
|
||||||
|
|
|
@ -4,14 +4,17 @@
|
||||||
|
|
||||||
# various authentication and authorization utilities
|
# various authentication and authorization utilities
|
||||||
|
|
||||||
|
import oidc_provider.lib.claims
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from django.utils.http import urlquote
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import REDIRECT_FIELD_NAME
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import HttpResponseRedirect, HttpResponseForbidden
|
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.decorators import available_attrs
|
||||||
|
from django.utils.http import urlquote
|
||||||
|
|
||||||
import debug # pyflakes:ignore
|
import debug # pyflakes:ignore
|
||||||
|
|
||||||
|
@ -196,3 +199,54 @@ def is_individual_draft_author(user, doc):
|
||||||
|
|
||||||
return False
|
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
|
||||||
|
|
|
@ -392,6 +392,7 @@ INSTALLED_APPS = [
|
||||||
'django_password_strength',
|
'django_password_strength',
|
||||||
'djangobwr',
|
'djangobwr',
|
||||||
'form_utils',
|
'form_utils',
|
||||||
|
'oidc_provider',
|
||||||
'request_profiler',
|
'request_profiler',
|
||||||
'simple_history',
|
'simple_history',
|
||||||
'tastypie',
|
'tastypie',
|
||||||
|
@ -862,8 +863,16 @@ INTERNET_DRAFT_DAYS_TO_EXPIRE = 185
|
||||||
FLOORPLAN_MEDIA_DIR = 'floor'
|
FLOORPLAN_MEDIA_DIR = 'floor'
|
||||||
FLOORPLAN_DIR = os.path.join(MEDIA_ROOT, FLOORPLAN_MEDIA_DIR)
|
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'
|
DOT_BINARY = '/usr/bin/dot'
|
||||||
UNFLATTEN_BINARY= '/usr/bin/unflatten'
|
UNFLATTEN_BINARY= '/usr/bin/unflatten'
|
||||||
RSYNC_BINARY = '/usr/bin/rsync'
|
RSYNC_BINARY = '/usr/bin/rsync'
|
||||||
|
@ -1053,6 +1062,10 @@ SILENCED_SYSTEM_CHECKS = [
|
||||||
CHECKS_LIBRARY_PATCHES_TO_APPLY = [
|
CHECKS_LIBRARY_PATCHES_TO_APPLY = [
|
||||||
'patch/fix-unidecode-argument-warning.patch',
|
'patch/fix-unidecode-argument-warning.patch',
|
||||||
'patch/fix-request-profiler-streaming-length.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:
|
if DEBUG:
|
||||||
try:
|
try:
|
||||||
|
@ -1082,7 +1095,6 @@ qvNU+qRWi+YXrITsgn92/gVxX5AoK0n+s5Lx7fpjxkARVi66SF6zTJnX
|
||||||
-----END PRIVATE KEY-----
|
-----END PRIVATE KEY-----
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Put the production SECRET_KEY in settings_local.py, and also any other
|
# 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.
|
# sensitive or site-specific changes. DO NOT commit settings_local.py to svn.
|
||||||
from ietf.settings_local import * # pyflakes:ignore pylint: disable=wildcard-import
|
from ietf.settings_local import * # pyflakes:ignore pylint: disable=wildcard-import
|
||||||
|
|
28
ietf/templates/oidc_provider/authorize.html
Normal file
28
ietf/templates/oidc_provider/authorize.html
Normal 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 %}
|
13
ietf/templates/oidc_provider/error.html
Normal file
13
ietf/templates/oidc_provider/error.html
Normal 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 %}
|
|
@ -16,6 +16,7 @@ django-cors-headers>=2.4.0
|
||||||
django-form-utils>=1.0.3
|
django-form-utils>=1.0.3
|
||||||
django-formtools>=1.0 # instead of django.contrib.formtools in 1.8
|
django-formtools>=1.0 # instead of django.contrib.formtools in 1.8
|
||||||
django-markup>=1.1
|
django-markup>=1.1
|
||||||
|
django-oidc-provider>=0.7
|
||||||
django-password-strength>=1.2.1
|
django-password-strength>=1.2.1
|
||||||
django-referrer-policy>=1.0
|
django-referrer-policy>=1.0
|
||||||
django-request-profiler==0.14 # 0.15 and above requires Django 2.x
|
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
|
django-widget-tweaks>=1.4.2
|
||||||
docutils>=0.12,!=0.15
|
docutils>=0.12,!=0.15
|
||||||
factory-boy>=2.9.0
|
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.
|
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
|
hashids>=1.1.0
|
||||||
html2text>=2019.8.11
|
html2text>=2019.8.11
|
||||||
|
@ -40,7 +40,7 @@ markdown2>=2.3.8
|
||||||
mock>=2.0.0
|
mock>=2.0.0
|
||||||
mypy==0.750 # Version requirements determined by django-stubs.
|
mypy==0.750 # Version requirements determined by django-stubs.
|
||||||
mysqlclient>=1.3.13
|
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
|
pathlib>=1.0
|
||||||
pathlib2>=2.3.0
|
pathlib2>=2.3.0
|
||||||
Pillow>=3.0
|
Pillow>=3.0
|
||||||
|
@ -56,6 +56,7 @@ python-mimeparse>=1.6 # from TastyPie
|
||||||
pytz>=2014.7
|
pytz>=2014.7
|
||||||
#pyzmail>=1.0.3
|
#pyzmail>=1.0.3
|
||||||
requests!=2.12.*
|
requests!=2.12.*
|
||||||
|
requests-mock>=1.8
|
||||||
rfc2html>=2.0.1
|
rfc2html>=2.0.1
|
||||||
selenium>=3.9.0
|
selenium>=3.9.0
|
||||||
six>=1.10.0
|
six>=1.10.0
|
||||||
|
|
Loading…
Reference in a new issue