Add token introspection endpoint to satisfy https://tools.ietf.org/html/rfc7662

This commit is contained in:
Maxim Daniline 2018-02-05 15:29:08 +00:00 committed by Maxim Daniline
parent eca5b06760
commit 180aad9a36
13 changed files with 492 additions and 14 deletions

View file

@ -21,6 +21,21 @@ If not specified, it will be automatically generated using ``request.scheme`` an
For example ``http://localhost:8000``.
OIDC_RESOURCE_MODEL
===================
OPTIONAL. ``str``. Path to a custom API resource model.
Default is ``oidc_provider.Resource``.
Similar to the Django custom user model, you can extend the default model by adding ``AbstractResource`` as a mixin.
For example::
class CustomResource(AbstractResource):
custom_field = models.CharField(max_length=255, _(u'Some Custom Field'))
OIDC_AFTER_USERLOGIN_HOOK
=========================
@ -90,6 +105,22 @@ Default is::
return id_token
OIDC_INTROSPECTION_PROCESSING_HOOK
==================================
OPTIONAL. ``str`` or ``(list, tuple)``.
A string with the location of your function hook or ``list`` or ``tuple`` with hook functions.
Here you can add extra dictionary values specific to your valid response value for token introspection.
The function receives an ``introspection_response`` dictionary, a ``resource`` instance and an ``id_token`` dictionary.
Default is::
def default_introspection_processing_hook(introspection_response, resource, id_token):
return introspection_response
OIDC_IDTOKEN_SUB_GENERATOR
==========================

View file

@ -6,7 +6,9 @@ from django.forms import ModelForm
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from oidc_provider.models import Client, Code, Token, RSAKey
from oidc_provider.models import Client, Code, Token, RSAKey, get_resource_model
Resource = get_resource_model()
class ClientForm(ModelForm):
@ -72,6 +74,43 @@ class ClientAdmin(admin.ModelAdmin):
raw_id_fields = ['owner']
class ResourceForm(ModelForm):
def __init__(self, *args, **kwargs):
super(ResourceForm, self).__init__(*args, **kwargs)
self.fields['resource_secret'].required = False
def clean_resource_secret(self):
if self.cleaned_data['resource_secret']:
secret = self.cleaned_data['resource_secret']
else:
secret = sha224(uuid4().hex.encode()).hexdigest()
return secret
class Meta:
model = Resource
exclude = []
@admin.register(Resource)
class ResourceAdmin(admin.ModelAdmin):
fieldsets = [
[None, {
'fields': ('name', 'owner', 'active',),
}],
[_('Credentials'), {
'fields': ('resource_id', 'resource_secret',),
}],
[_('Permissions'), {
'fields': ('allowed_clients',),
}],
]
form = ResourceForm
list_display = ['name', 'resource_id', 'date_created']
readonly_fields = ['date_created']
search_fields = ['name']
raw_id_fields = ['owner']
@admin.register(Code)
class CodeAdmin(admin.ModelAdmin):

View file

@ -0,0 +1,86 @@
import logging
from django.http import JsonResponse
from oidc_provider.lib.errors import TokenIntrospectionError
from oidc_provider.lib.utils.common import get_basic_client_credentials, run_processing_hook
from oidc_provider.models import Token, get_resource_model
Resource = get_resource_model()
logger = logging.getLogger(__name__)
class TokenIntrospectionEndpoint(object):
def __init__(self, request):
self.request = request
self.params = {}
self._extract_params()
def _extract_params(self):
# Introspection only supports POST requests
self.params['token'] = self.request.POST.get('token')
resource_id, resource_secret = get_basic_client_credentials(self.request)
self.params['resource_id'] = resource_id
self.params['resource_secret'] = resource_secret
def validate_params(self):
if not (self.params['resource_id'] and self.params['resource_secret']):
logger.debug('[Introspection] No resource credentials provided')
raise TokenIntrospectionError()
if not self.params['token']:
logger.debug('[Introspection] No token provided')
raise TokenIntrospectionError()
try:
token = Token.objects.get(access_token=self.params['token'])
except Token.DoesNotExist:
logger.debug('[Introspection] Token does not exist: %s', self.params['token'])
raise TokenIntrospectionError()
if token.has_expired():
logger.debug('[Introspection] Token is not valid: %s', self.params['token'])
raise TokenIntrospectionError()
if not token.id_token:
logger.debug('[Introspection] Token not an authentication token: %s', self.params['token'])
raise TokenIntrospectionError()
self.id_token = token.id_token
audience = self.id_token.get('aud')
if not audience:
logger.debug('[Introspection] No audience found for token: %s', self.params['token'])
raise TokenIntrospectionError()
try:
self.resource = Resource.objects.get(
resource_id=self.params['resource_id'],
resource_secret=self.params['resource_secret'],
active=True,
allowed_clients__client_id__contains=audience)
except Resource.DoesNotExist:
logger.debug('[Introspection] No valid resource id and audience: %s, %s',
self.params['resource_id'], audience)
raise TokenIntrospectionError()
def create_response_dic(self):
response_dic = dict((k, self.id_token[k]) for k in ('sub', 'exp', 'iat', 'iss'))
response_dic['active'] = True
response_dic['client_id'] = self.id_token.get('aud')
response_dic['aud'] = self.resource.resource_id
response_dic = run_processing_hook(response_dic, 'OIDC_INTROSPECTION_PROCESSING_HOOK',
resource=self.resource,
id_token=self.id_token)
return response_dic
@classmethod
def response(cls, dic, status=200):
"""
Create and return a response object.
"""
response = JsonResponse(dic, status=status)
response['Cache-Control'] = 'no-store'
response['Pragma'] = 'no-cache'
return response

View file

@ -32,6 +32,15 @@ class UserAuthError(Exception):
}
class TokenIntrospectionError(Exception):
"""
Specific to the introspection endpoint. This error will be converted
to an "active: false" response, as per the spec.
See https://tools.ietf.org/html/rfc7662
"""
pass
class AuthorizeError(Exception):
_errors = {

View file

@ -1,7 +1,11 @@
from base64 import b64decode
from hashlib import sha224
from django.http import HttpResponse
from oidc_provider import settings
import django
from django.http import HttpResponse
import re
from oidc_provider import settings
@ -12,6 +16,9 @@ else:
from django.core.urlresolvers import reverse
basic_re = re.compile('^Basic\s(.+)$', re.I)
def redirect(uri):
"""
Custom Response object for redirecting to a Non-HTTP url scheme.
@ -123,6 +130,17 @@ def default_idtoken_processing_hook(id_token, user):
return id_token
def default_introspection_processing_hook(introspection_response, resource, id_token):
"""
Hook to customise the returned data from the token introspection endpoint
:param introspection_response:
:param resource:
:param id_token:
:return:
"""
return introspection_response
def get_browser_state_or_default(request):
"""
Determine value to use as session state.
@ -130,3 +148,38 @@ def get_browser_state_or_default(request):
key = (request.session.session_key or
settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY'))
return sha224(key.encode('utf-8')).hexdigest()
def get_basic_client_credentials(request):
"""
Get client credentials using HTTP Basic Authentication method.
Or try getting parameters via POST.
See: http://tools.ietf.org/html/rfc6750#section-2.1
:param request:
:return: tuple of client_id, client_secret
:rtype: tuple
"""
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
result = basic_re.match(auth_header)
if result:
b64_user_pass = result.group(1)
try:
user_pass = b64decode(b64_user_pass).decode('utf-8').split(':', 1)
client_id, client_secret = tuple(user_pass)
except (ValueError, UnicodeDecodeError):
client_id = client_secret = ''
else:
client_id = request.POST.get('client_id')
client_secret = request.POST.get('client_secret')
return client_id, client_secret
def run_processing_hook(subject, hook_settings_name, **kwargs):
processing_hook = settings.get(hook_settings_name)
if isinstance(processing_hook, (list, tuple)):
for hook in processing_hook:
subject = settings.import_from_str(hook)(subject, **kwargs)
else:
subject = settings.import_from_str(processing_hook)(subject, **kwargs)
return subject

View file

@ -9,7 +9,7 @@ from jwkest.jwk import SYMKey
from jwkest.jws import JWS
from jwkest.jwt import JWT
from oidc_provider.lib.utils.common import get_issuer
from oidc_provider.lib.utils.common import get_issuer, run_processing_hook
from oidc_provider.lib.claims import StandardScopeClaims
from oidc_provider.models import (
Code,
@ -62,13 +62,7 @@ def create_id_token(token, user, aud, nonce='', at_hash='', request=None, scope=
claims = StandardScopeClaims(token).create_response_dic()
dic.update(claims)
processing_hook = settings.get('OIDC_IDTOKEN_PROCESSING_HOOK')
if isinstance(processing_hook, (list, tuple)):
for hook in processing_hook:
dic = settings.import_from_str(hook)(dic, user=user)
else:
dic = settings.import_from_str(processing_hook)(dic, user=user)
dic = run_processing_hook(dic, 'OIDC_IDTOKEN_PROCESSING_HOOK', user=user)
return dic

View file

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2018-02-05 14:19
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('oidc_provider', '0023_client_owner'),
]
operations = [
migrations.CreateModel(
name='Resource',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='', max_length=100, verbose_name='Name')),
('resource_id', models.CharField(max_length=255, unique=True, verbose_name='Resource ID')),
('resource_secret', models.CharField(max_length=255, verbose_name='Resource Secret')),
('date_created', models.DateField(auto_now_add=True, verbose_name='Date Created')),
('date_updated', models.DateField(auto_now=True, verbose_name='Date Updated')),
('active', models.BooleanField(default=False, verbose_name='Is Active')),
('allowed_clients', models.ManyToManyField(blank=True, help_text='Select which clients can be used to access this resource.', related_name='accessible_resources', to='oidc_provider.Client', verbose_name='Allowed Clients')),
('owner', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='oidc_resource_set', to=settings.AUTH_USER_MODEL, verbose_name='Owner')),
],
options={
'swappable': 'OIDC_RESOURCE_MODEL',
},
),
]

View file

@ -4,6 +4,7 @@ import binascii
from hashlib import md5, sha256
import json
from django.apps import apps
from django.db import models
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
@ -128,6 +129,45 @@ class Client(models.Model):
return self.redirect_uris[0] if self.redirect_uris else ''
class AbstractResource(models.Model):
name = models.CharField(max_length=100, default='', verbose_name=_(u'Name'))
owner = models.ForeignKey(settings.AUTH_USER_MODEL,
verbose_name=_(u'Owner'),
blank=True, null=True, default=None,
on_delete=models.SET_NULL,
related_name='oidc_resource_set')
resource_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Resource ID'))
resource_secret = models.CharField(max_length=255, verbose_name=_(u'Resource Secret'))
date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created'))
date_updated = models.DateField(auto_now=True, verbose_name=_(u'Date Updated'))
active = models.BooleanField(default=False, verbose_name=_(u'Is Active'))
allowed_clients = models.ManyToManyField(Client,
blank=True,
verbose_name=_(u'Allowed Clients'),
related_name='accessible_resources',
help_text=_(u'Select which clients can be used to access this resource.'))
def __str__(self):
return u'{0}'.format(self.name)
def __unicode__(self):
return self.__str__()
class Meta:
verbose_name = _(u'Resource')
verbose_name_plural = _(u'Resources')
abstract = True
class Resource(AbstractResource):
class Meta:
swappable = 'OIDC_RESOURCE_MODEL'
class BaseCodeTokenModel(models.Model):
client = models.ForeignKey(Client, verbose_name=_(u'Client'), on_delete=models.CASCADE)
@ -232,3 +272,7 @@ class RSAKey(models.Model):
@property
def kid(self):
return u'{0}'.format(md5(self.key.encode('utf-8')).hexdigest() if self.key else '')
def get_resource_model():
return apps.get_model(getattr(settings, 'OIDC_RESOURCE_MODEL', 'oidc_provider.Resource'))

View file

@ -129,6 +129,10 @@ class DefaultSettings(object):
"""
return 'oidc_provider.lib.utils.common.default_idtoken_processing_hook'
@property
def OIDC_INTROSPECTION_PROCESSING_HOOK(self):
return 'oidc_provider.lib.utils.common.default_introspection_processing_hook'
@property
def OIDC_GRANT_TYPE_PASSWORD_ENABLE(self):
"""
@ -152,6 +156,14 @@ class DefaultSettings(object):
'error': 'oidc_provider/error.html'
}
@property
def OIDC_RESOURCE_MODEL(self):
"""
Model w
:return:
"""
return 'oidc_provider.Resource'
default_settings = DefaultSettings()

View file

@ -11,7 +11,9 @@ from django.contrib.auth.models import User
from oidc_provider.models import (
Client,
Code,
Token)
Token, get_resource_model)
Resource = get_resource_model()
FAKE_NONCE = 'cb584e44c43ed6bd0bc2d9c7e242837d'
@ -63,9 +65,22 @@ def create_fake_client(response_type, is_public=False, require_consent=True):
return client
def create_fake_resource(allowed_clients, active=True):
resource = Resource(name='Some API',
resource_id=str(random.randint(1, 999999)).zfill(6),
resource_secret=str(random.randint(1, 999999)).zfill(6),
active=active)
resource.name = 'Some API'
resource.save()
resource.allowed_clients.add(*allowed_clients)
resource.save()
return resource
def create_fake_token(user, scopes, client):
expires_at = timezone.now() + timezone.timedelta(seconds=60)
token = Token(user=user, client=client, expires_at=expires_at)
token = Token(user=user, client=client, expires_at=expires_at, access_token=str(random.randint(1, 999999)).zfill(6))
token.scope = scopes
token.save()
@ -126,3 +141,8 @@ def fake_idtoken_processing_hook2(id_token, user):
id_token['test_idtoken_processing_hook2'] = FAKE_RANDOM_STRING
id_token['test_idtoken_processing_hook_user_email2'] = user.email
return id_token
def fake_introspection_processing_hook(response_dict, resource, id_token):
response_dict['test_introspection_processing_hook'] = FAKE_RANDOM_STRING
return response_dict

View file

@ -0,0 +1,120 @@
import time
from mock import patch
from django.utils.encoding import force_text
from oidc_provider.lib.utils.token import create_id_token
try:
from urllib.parse import urlencode
except ImportError:
from urllib import urlencode
from django.core.management import call_command
from django.test import TestCase, RequestFactory, override_settings
from django.core.urlresolvers import reverse
from django.utils import timezone
from oidc_provider.tests.app.utils import (
create_fake_user,
create_fake_client,
create_fake_resource,
create_fake_token,
FAKE_RANDOM_STRING)
from oidc_provider.views import TokenIntrospectionView
class IntrospectionTestCase(TestCase):
def setUp(self):
call_command('creatersakey')
self.factory = RequestFactory()
self.user = create_fake_user()
self.client = create_fake_client(response_type='id_token token')
self.resource = create_fake_resource(allowed_clients=[self.client])
self.scopes = ['openid', 'profile']
self.token = create_fake_token(self.user, self.scopes, self.client)
self.now = time.time()
with patch('oidc_provider.lib.utils.token.time.time') as time_func:
time_func.return_value = self.now
self.token.id_token = create_id_token(self.user, self.client.client_id)
self.token.save()
def test_no_client_params_returns_inactive(self):
response = self._make_request(client_id='')
self._assert_inactive(response)
def test_no_client_secret_returns_inactive(self):
response = self._make_request(client_secret='')
self._assert_inactive(response)
def test_invalid_client_returns_inactive(self):
response = self._make_request(client_id='invalid')
self._assert_inactive(response)
def test_token_not_found_returns_inactive(self):
response = self._make_request(access_token='invalid')
self._assert_inactive(response)
def test_no_allowed_clients_returns_inactive(self):
self.resource.allowed_clients.clear()
self.resource.save()
response = self._make_request()
self._assert_inactive(response)
def test_resource_inactive_returns_inactive(self):
self.resource.active = False
self.resource.save()
response = self._make_request()
self._assert_inactive(response)
def test_token_expired_returns_inactive(self):
self.token.expires_at = timezone.now() - timezone.timedelta(seconds=60)
self.token.save()
response = self._make_request()
self._assert_inactive(response)
def test_valid_request_returns_default_properties(self):
response = self._make_request()
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(force_text(response.content), {
'active': True,
'aud': self.resource.resource_id,
'client_id': self.client.client_id,
'sub': str(self.user.pk),
'iat': int(self.now),
'exp': int(self.now + 600),
'iss': 'http://localhost:8000/openid',
})
@override_settings(
OIDC_INTROSPECTION_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_introspection_processing_hook')
def test_custom_introspection_hook_called_on_valid_request(self):
response = self._make_request()
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(force_text(response.content), {
'active': True,
'aud': self.resource.resource_id,
'client_id': self.client.client_id,
'sub': str(self.user.pk),
'iat': int(self.now),
'exp': int(self.now + 600),
'iss': 'http://localhost:8000/openid',
'test_introspection_processing_hook': FAKE_RANDOM_STRING
})
def _assert_inactive(self, response):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(force_text(response.content), {'active': False})
def _make_request(self, **kwargs):
url = reverse('oidc_provider:token-introspection')
data = {
'client_id': kwargs.get('client_id', self.resource.resource_id),
'client_secret': kwargs.get('client_secret', self.resource.resource_secret),
'token': kwargs.get('access_token', self.token.access_token),
}
request = self.factory.post(url, data=urlencode(data), content_type='application/x-www-form-urlencoded')
return TokenIntrospectionView.as_view()(request)

View file

@ -17,6 +17,7 @@ urlpatterns = [
url(r'^end-session/?$', views.EndSessionView.as_view(), name='end-session'),
url(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(),
name='provider-info'),
url(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'),
url(r'^jwks/?$', views.JwksView.as_view(), name='jwks'),
]

View file

@ -1,5 +1,6 @@
import logging
from oidc_provider.lib.endpoints.introspection import TokenIntrospectionEndpoint
try:
from urllib import urlencode
from urlparse import urlsplit, parse_qs, urlunsplit
@ -34,7 +35,8 @@ from oidc_provider.lib.errors import (
ClientIdError,
RedirectUriError,
TokenError,
UserAuthError)
UserAuthError,
TokenIntrospectionError)
from oidc_provider.lib.utils.common import (
redirect,
get_site_url,
@ -50,6 +52,25 @@ from oidc_provider.models import (
from oidc_provider import settings
from oidc_provider import signals
try:
from urllib import urlencode
from urlparse import urlsplit, parse_qs, urlunsplit
except ImportError:
from urllib.parse import urlsplit, parse_qs, urlunsplit, urlencode
from Cryptodome.PublicKey import RSA
from django.contrib.auth.views import (
redirect_to_login,
logout,
)
import django
if django.VERSION >= (1, 11):
from django.urls import reverse
else:
from django.core.urlresolvers import reverse
logger = logging.getLogger(__name__)
OIDC_TEMPLATES = settings.get('OIDC_TEMPLATES')
@ -230,10 +251,10 @@ class TokenView(View):
@protected_resource_view(['openid'])
def userinfo(request, *args, **kwargs):
"""
Create a diccionary with all the requested claims about the End-User.
Create a dictionary with all the requested claims about the End-User.
See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
Return a diccionary.
Return a dictionary.
"""
token = kwargs['token']
@ -267,6 +288,7 @@ class ProviderInfoView(View):
dic['token_endpoint'] = site_url + reverse('oidc_provider:token')
dic['userinfo_endpoint'] = site_url + reverse('oidc_provider:userinfo')
dic['end_session_endpoint'] = site_url + reverse('oidc_provider:end-session')
dic['introspection_endpoint'] = site_url + reverse('oidc_provider:token-introspection')
types_supported = [x[0] for x in RESPONSE_TYPE_CHOICES]
dic['response_types_supported'] = types_supported
@ -356,3 +378,15 @@ class CheckSessionIframeView(View):
def get(self, request, *args, **kwargs):
return render(request, 'oidc_provider/check_session_iframe.html', kwargs)
class TokenIntrospectionView(View):
def post(self, request, *args, **kwargs):
introspection = TokenIntrospectionEndpoint(request)
try:
introspection.validate_params()
dic = introspection.create_response_dic()
return TokenIntrospectionEndpoint.response(dic)
except TokenIntrospectionError:
return TokenIntrospectionEndpoint.response({'active': False})