commit
d392a14223
|
@ -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
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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'])
|
||||||
|
|
Loading…
Reference in a new issue