commit
d392a14223
7 changed files with 230 additions and 13 deletions
|
@ -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 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 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>`_
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
@ -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.
|
||||
* 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
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -147,3 +147,20 @@ Example usage::
|
|||
|
||||
.. 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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ from base64 import b64decode, urlsafe_b64encode
|
|||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
from django.contrib.auth import authenticate
|
||||
from oidc_provider.lib.utils.common import cleanup_url_from_query_string
|
||||
|
||||
try:
|
||||
|
@ -14,6 +14,7 @@ from django.http import JsonResponse
|
|||
|
||||
from oidc_provider.lib.errors import (
|
||||
TokenError,
|
||||
UserAuthError,
|
||||
)
|
||||
from oidc_provider.lib.utils.token import (
|
||||
create_id_token,
|
||||
|
@ -27,15 +28,14 @@ from oidc_provider.models import (
|
|||
)
|
||||
from oidc_provider import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenEndpoint(object):
|
||||
|
||||
def __init__(self, request):
|
||||
self.request = request
|
||||
self.params = {}
|
||||
self.user = None
|
||||
self._extract_params()
|
||||
|
||||
def _extract_params(self):
|
||||
|
@ -53,6 +53,9 @@ class TokenEndpoint(object):
|
|||
# PKCE parameter.
|
||||
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):
|
||||
"""
|
||||
Get client credentials using HTTP Basic Authentication method.
|
||||
|
@ -120,6 +123,20 @@ class TokenEndpoint(object):
|
|||
if not (new_code_challenge == self.code.code_challenge):
|
||||
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':
|
||||
if not self.params['refresh_token']:
|
||||
logger.debug('[Token] Missing refresh token')
|
||||
|
@ -142,6 +159,34 @@ class TokenEndpoint(object):
|
|||
return self.create_code_response_dic()
|
||||
elif self.params['grant_type'] == 'refresh_token':
|
||||
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):
|
||||
token = create_token(
|
||||
|
|
|
@ -16,6 +16,21 @@ class ClientIdError(Exception):
|
|||
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):
|
||||
|
||||
_errors = {
|
||||
|
|
|
@ -123,6 +123,22 @@ class DefaultSettings(object):
|
|||
"""
|
||||
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()
|
||||
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ from django.test import TestCase
|
|||
from jwkest.jwk import KEYS
|
||||
from jwkest.jws import JWS
|
||||
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.models import Token
|
||||
|
@ -50,6 +50,14 @@ class TokenTestCase(TestCase):
|
|||
self.user = create_fake_user()
|
||||
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):
|
||||
"""
|
||||
All the data that will be POSTed to the Token Endpoint.
|
||||
|
@ -127,6 +135,123 @@ class TokenTestCase(TestCase):
|
|||
|
||||
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)
|
||||
def test_authorization_code(self):
|
||||
"""
|
||||
|
@ -150,7 +275,7 @@ class TokenTestCase(TestCase):
|
|||
self.assertEqual(response_dic['token_type'], 'bearer')
|
||||
self.assertEqual(response_dic['expires_in'], 720)
|
||||
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):
|
||||
"""
|
||||
|
@ -308,12 +433,7 @@ class TokenTestCase(TestCase):
|
|||
del basicauth_data['client_id']
|
||||
del basicauth_data['client_secret']
|
||||
|
||||
# Generate HTTP Basic Auth header with id and secret.
|
||||
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 = self._post_request(basicauth_data, self._password_grant_auth_header())
|
||||
response.content.decode('utf-8')
|
||||
|
||||
self.assertEqual('invalid_client' in response.content.decode('utf-8'),
|
||||
|
|
|
@ -28,7 +28,7 @@ from oidc_provider.lib.errors import (
|
|||
ClientIdError,
|
||||
RedirectUriError,
|
||||
TokenError,
|
||||
)
|
||||
UserAuthError)
|
||||
from oidc_provider.lib.utils.common import (
|
||||
redirect,
|
||||
get_site_url,
|
||||
|
@ -167,8 +167,10 @@ class TokenView(View):
|
|||
|
||||
return TokenEndpoint.response(dic)
|
||||
|
||||
except (TokenError) as error:
|
||||
except TokenError as error:
|
||||
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'])
|
||||
|
|
Loading…
Reference in a new issue