Merge pull request #12 from juanifioren/develop

Develop
This commit is contained in:
Wojciech Bartosiak 2017-03-30 21:36:34 +01:00 committed by GitHub
commit d392a14223
7 changed files with 230 additions and 13 deletions

View file

@ -8,6 +8,7 @@ Also implements the following specifications:
* `OpenID Connect Discovery 1.0 <https://openid.net/specs/openid-connect-discovery-1_0.html>`_ * `OpenID Connect Discovery 1.0 <https://openid.net/specs/openid-connect-discovery-1_0.html>`_
* `OpenID Connect Session Management 1.0 <https://openid.net/specs/openid-connect-session-1_0.html>`_ * `OpenID Connect Session Management 1.0 <https://openid.net/specs/openid-connect-session-1_0.html>`_
* `OAuth 2.0 for Native Apps <https://tools.ietf.org/html/draft-ietf-oauth-native-apps-01>`_ * `OAuth 2.0 for Native Apps <https://tools.ietf.org/html/draft-ietf-oauth-native-apps-01>`_
* `OAuth 2.0 Resource Owner Password Credentials Grant <https://tools.ietf.org/html/rfc6749#section-4.3>`_
* `Proof Key for Code Exchange by OAuth Public Clients <https://tools.ietf.org/html/rfc7636>`_ * `Proof Key for Code Exchange by OAuth Public Clients <https://tools.ietf.org/html/rfc7636>`_
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
@ -16,6 +17,7 @@ Before getting started there are some important things that you should know:
* Despite that implementation MUST support TLS. You can make request without using SSL. There is no control on that. * Despite that implementation MUST support TLS. You can make request without using SSL. There is no control on that.
* Supports only for requesting Claims using Scope values. * Supports only for requesting Claims using Scope values.
* If you enable the Resource Owner Password Credentials Grant, you MUST implement protection against brute force attacks on the token endpoint
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------

View file

@ -147,3 +147,20 @@ Example usage::
.. note:: .. note::
Please **DO NOT** add extra keys or delete the existing ones in the ``claims`` dict. If you want to add extra claims to some scopes you can use the ``OIDC_EXTRA_SCOPE_CLAIMS`` setting. Please **DO NOT** add extra keys or delete the existing ones in the ``claims`` dict. If you want to add extra claims to some scopes you can use the ``OIDC_EXTRA_SCOPE_CLAIMS`` setting.
OIDC_GRANT_TYPE_PASSWORD_ENABLE
===============================
OPTIONAL. A boolean to set whether to allow the Resource Owner Password
Credentials Grant. https://tools.ietf.org/html/rfc6749#section-4.3
.. important::
From the specification:
"Since this access token request utilizes the resource owner's
password, the authorization server **MUST** protect the endpoint
against brute force attacks (e.g., using rate-limitation or
generating alerts)."
There are many ways to implement brute force attack prevention. We cannot
decide what works best for you, so you will have to implement a solution for
this that suits your needs.

View file

@ -2,7 +2,7 @@ from base64 import b64decode, urlsafe_b64encode
import hashlib import hashlib
import logging import logging
import re import re
from django.contrib.auth import authenticate
from oidc_provider.lib.utils.common import cleanup_url_from_query_string from oidc_provider.lib.utils.common import cleanup_url_from_query_string
try: try:
@ -14,6 +14,7 @@ from django.http import JsonResponse
from oidc_provider.lib.errors import ( from oidc_provider.lib.errors import (
TokenError, TokenError,
UserAuthError,
) )
from oidc_provider.lib.utils.token import ( from oidc_provider.lib.utils.token import (
create_id_token, create_id_token,
@ -27,15 +28,14 @@ from oidc_provider.models import (
) )
from oidc_provider import settings from oidc_provider import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TokenEndpoint(object): class TokenEndpoint(object):
def __init__(self, request): def __init__(self, request):
self.request = request self.request = request
self.params = {} self.params = {}
self.user = None
self._extract_params() self._extract_params()
def _extract_params(self): def _extract_params(self):
@ -53,6 +53,9 @@ class TokenEndpoint(object):
# PKCE parameter. # PKCE parameter.
self.params['code_verifier'] = self.request.POST.get('code_verifier') self.params['code_verifier'] = self.request.POST.get('code_verifier')
self.params['username'] = self.request.POST.get('username', '')
self.params['password'] = self.request.POST.get('password', '')
def _extract_client_auth(self): def _extract_client_auth(self):
""" """
Get client credentials using HTTP Basic Authentication method. Get client credentials using HTTP Basic Authentication method.
@ -120,6 +123,20 @@ class TokenEndpoint(object):
if not (new_code_challenge == self.code.code_challenge): if not (new_code_challenge == self.code.code_challenge):
raise TokenError('invalid_grant') raise TokenError('invalid_grant')
elif self.params['grant_type'] == 'password':
if not settings.get('OIDC_GRANT_TYPE_PASSWORD_ENABLE'):
raise TokenError('unsupported_grant_type')
user = authenticate(
username=self.params['username'],
password=self.params['password']
)
if not user:
raise UserAuthError()
self.user = user
elif self.params['grant_type'] == 'refresh_token': elif self.params['grant_type'] == 'refresh_token':
if not self.params['refresh_token']: if not self.params['refresh_token']:
logger.debug('[Token] Missing refresh token') logger.debug('[Token] Missing refresh token')
@ -142,6 +159,34 @@ class TokenEndpoint(object):
return self.create_code_response_dic() return self.create_code_response_dic()
elif self.params['grant_type'] == 'refresh_token': elif self.params['grant_type'] == 'refresh_token':
return self.create_refresh_response_dic() return self.create_refresh_response_dic()
elif self.params['grant_type'] == 'password':
return self.create_access_token_response_dic()
def create_access_token_response_dic(self):
token = create_token(
self.user,
self.client,
self.params['scope'].split(' '))
id_token_dic = create_id_token(
user=self.user,
aud=self.client.client_id,
nonce='self.code.nonce',
at_hash=token.at_hash,
request=self.request,
scope=self.params['scope'],
)
token.id_token = id_token_dic
token.save()
return {
'access_token': token.access_token,
'refresh_token': token.refresh_token,
'expires_in': settings.get('OIDC_TOKEN_EXPIRE'),
'token_type': 'bearer',
'id_token': encode_id_token(id_token_dic, token.client),
}
def create_code_response_dic(self): def create_code_response_dic(self):
token = create_token( token = create_token(

View file

@ -16,6 +16,21 @@ class ClientIdError(Exception):
description = 'The client identifier (client_id) is missing or invalid.' description = 'The client identifier (client_id) is missing or invalid.'
class UserAuthError(Exception):
"""
Specific to the Resource Owner Password Credentials flow when
the Resource Owners credentials are not valid.
"""
error = 'access_denied'
description = 'The resource owner or authorization server denied ' \
'the request'
def create_dict(self):
return {
'error': self.error,
'error_description': self.description,
}
class AuthorizeError(Exception): class AuthorizeError(Exception):
_errors = { _errors = {

View file

@ -123,6 +123,22 @@ class DefaultSettings(object):
""" """
return 'oidc_provider.lib.utils.common.default_idtoken_processing_hook' return 'oidc_provider.lib.utils.common.default_idtoken_processing_hook'
@property
def OIDC_GRANT_TYPE_PASSWORD_ENABLE(self):
"""
OPTIONAL. A boolean to set whether to allow the Resource Owner Password
Credentials Grant. https://tools.ietf.org/html/rfc6749#section-4.3
From the specification:
Since this access token request utilizes the resource owner's
password, the authorization server MUST protect the endpoint
against brute force attacks (e.g., using rate-limitation or
generating alerts).
How you do this, is up to you.
"""
return False
default_settings = DefaultSettings() default_settings = DefaultSettings()

View file

@ -18,7 +18,7 @@ from django.test import TestCase
from jwkest.jwk import KEYS from jwkest.jwk import KEYS
from jwkest.jws import JWS from jwkest.jws import JWS
from jwkest.jwt import JWT from jwkest.jwt import JWT
from mock import patch from mock import patch, Mock
from oidc_provider.lib.utils.token import create_code from oidc_provider.lib.utils.token import create_code
from oidc_provider.models import Token from oidc_provider.models import Token
@ -50,6 +50,14 @@ class TokenTestCase(TestCase):
self.user = create_fake_user() self.user = create_fake_user()
self.client = create_fake_client(response_type='code') self.client = create_fake_client(response_type='code')
def _password_grant_post_data(self):
return {
'username': 'johndoe',
'password': '1234',
'grant_type': 'password',
'scope': 'openid email',
}
def _auth_code_post_data(self, code): def _auth_code_post_data(self, code):
""" """
All the data that will be POSTed to the Token Endpoint. All the data that will be POSTed to the Token Endpoint.
@ -127,6 +135,123 @@ class TokenTestCase(TestCase):
return userinfo(request) return userinfo(request)
def _password_grant_auth_header(self):
user_pass = self.client.client_id + ':' + self.client.client_secret
auth = b'Basic ' + b64encode(user_pass.encode('utf-8'))
auth_header = {'HTTP_AUTHORIZATION': auth.decode('utf-8')}
return auth_header
# Resource Owner Password Credentials Grant
# requirements to satisfy in all test_password_grant methods
# https://tools.ietf.org/html/rfc6749#section-4.3.2
#
# grant_type
# REQUIRED. Value MUST be set to "password".
# username
# REQUIRED. The resource owner username.
# password
# REQUIRED. The resource owner password.
# scope
# OPTIONAL. The scope of the access request as described by
# Section 3.3.
#
# The authorization server MUST:
# o require client authentication for confidential clients or for any
# client that was issued client credentials (or with other
# authentication requirements),
# o authenticate the client if client authentication is included, and
# o validate the resource owner password credentials using its
# existing password validation algorithm.
def test_default_setting_does_not_allow_grant_type_password(self):
post_data = self._password_grant_post_data()
response = self._post_request(
post_data=post_data,
extras=self._password_grant_auth_header()
)
response_dict = json.loads(response.content.decode('utf-8'))
self.assertEqual(400, response.status_code)
self.assertEqual('unsupported_grant_type', response_dict['error'])
@override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True)
def test_password_grant_get_access_token_without_scope(self):
post_data = self._password_grant_post_data()
del (post_data['scope'])
response = self._post_request(
post_data=post_data,
extras=self._password_grant_auth_header()
)
response_dict = json.loads(response.content.decode('utf-8'))
self.assertIn('access_token', response_dict)
@override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True)
def test_password_grant_get_access_token_with_scope(self):
response = self._post_request(
post_data=self._password_grant_post_data(),
extras=self._password_grant_auth_header()
)
response_dict = json.loads(response.content.decode('utf-8'))
self.assertIn('access_token', response_dict)
@override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True)
def test_password_grant_get_access_token_invalid_user_credentials(self):
invalid_post = self._password_grant_post_data()
invalid_post['password'] = 'wrong!'
response = self._post_request(
post_data=invalid_post,
extras=self._password_grant_auth_header()
)
response_dict = json.loads(response.content.decode('utf-8'))
self.assertEqual(403, response.status_code)
self.assertEqual('access_denied', response_dict['error'])
def test_password_grant_get_access_token_invalid_client_credentials(self):
self.client.client_id = 'foo'
self.client.client_secret = 'bar'
response = self._post_request(
post_data=self._password_grant_post_data(),
extras=self._password_grant_auth_header()
)
response_dict = json.loads(response.content.decode('utf-8'))
self.assertEqual(400, response.status_code)
self.assertEqual('invalid_client', response_dict['error'])
@patch('oidc_provider.lib.utils.token.uuid')
@override_settings(OIDC_TOKEN_EXPIRE=120,
OIDC_GRANT_TYPE_PASSWORD_ENABLE=True)
def test_password_grant_full_response(self, mock_uuid):
test_hex = 'fake_token'
mock_uuid4 = Mock(spec=uuid.uuid4)
mock_uuid4.hex = test_hex
mock_uuid.uuid4.return_value = mock_uuid4
response = self._post_request(
post_data=self._password_grant_post_data(),
extras=self._password_grant_auth_header()
)
response_dict = json.loads(response.content.decode('utf-8'))
id_token = JWS().verify_compact(response_dict['id_token'].encode('utf-8'), self._get_keys())
self.assertEqual(response_dict['access_token'], 'fake_token')
self.assertEqual(response_dict['refresh_token'], 'fake_token')
self.assertEqual(response_dict['expires_in'], 120)
self.assertEqual(response_dict['token_type'], 'bearer')
self.assertEqual(id_token['sub'], str(self.user.id))
self.assertEqual(id_token['aud'], self.client.client_id)
@override_settings(OIDC_TOKEN_EXPIRE=720) @override_settings(OIDC_TOKEN_EXPIRE=720)
def test_authorization_code(self): def test_authorization_code(self):
""" """
@ -150,7 +275,7 @@ class TokenTestCase(TestCase):
self.assertEqual(response_dic['token_type'], 'bearer') self.assertEqual(response_dic['token_type'], 'bearer')
self.assertEqual(response_dic['expires_in'], 720) self.assertEqual(response_dic['expires_in'], 720)
self.assertEqual(id_token['sub'], str(self.user.id)) self.assertEqual(id_token['sub'], str(self.user.id))
self.assertEqual(id_token['aud'], self.client.client_id); self.assertEqual(id_token['aud'], self.client.client_id)
def test_refresh_token(self): def test_refresh_token(self):
""" """
@ -308,12 +433,7 @@ class TokenTestCase(TestCase):
del basicauth_data['client_id'] del basicauth_data['client_id']
del basicauth_data['client_secret'] del basicauth_data['client_secret']
# Generate HTTP Basic Auth header with id and secret. response = self._post_request(basicauth_data, self._password_grant_auth_header())
user_pass = self.client.client_id + ':' + self.client.client_secret
auth_header = b'Basic ' + b64encode(user_pass.encode('utf-8'))
response = self._post_request(basicauth_data, {
'HTTP_AUTHORIZATION': auth_header.decode('utf-8'),
})
response.content.decode('utf-8') response.content.decode('utf-8')
self.assertEqual('invalid_client' in response.content.decode('utf-8'), self.assertEqual('invalid_client' in response.content.decode('utf-8'),

View file

@ -28,7 +28,7 @@ from oidc_provider.lib.errors import (
ClientIdError, ClientIdError,
RedirectUriError, RedirectUriError,
TokenError, TokenError,
) UserAuthError)
from oidc_provider.lib.utils.common import ( from oidc_provider.lib.utils.common import (
redirect, redirect,
get_site_url, get_site_url,
@ -167,8 +167,10 @@ class TokenView(View):
return TokenEndpoint.response(dic) return TokenEndpoint.response(dic)
except (TokenError) as error: except TokenError as error:
return TokenEndpoint.response(error.create_dict(), status=400) return TokenEndpoint.response(error.create_dict(), status=400)
except UserAuthError as error:
return TokenEndpoint.response(error.create_dict(), status=403)
@require_http_methods(['GET', 'POST']) @require_http_methods(['GET', 'POST'])