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()),
|
||||
# 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
|
||||
|
|
|
@ -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, [])
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
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-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
|
||||
|
|
Loading…
Reference in a new issue