diff --git a/docs/index.rst b/docs/index.rst index cb4816a..1a15854 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,6 +8,7 @@ Also implements the following specifications: * `OpenID Connect Discovery 1.0 `_ * `OpenID Connect Session Management 1.0 `_ * `OAuth 2.0 for Native Apps `_ +* `OAuth 2.0 Resource Owner Password Credentials Grant `_ * `Proof Key for Code Exchange by OAuth Public Clients `_ -------------------------------------------------------------------------------- @@ -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 -------------------------------------------------------------------------------- diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index 8c1fba2..667157c 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -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. + diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 0bf52da..cacebd9 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -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( diff --git a/oidc_provider/lib/errors.py b/oidc_provider/lib/errors.py index ce84811..47f4b10 100644 --- a/oidc_provider/lib/errors.py +++ b/oidc_provider/lib/errors.py @@ -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 = { diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 20f2f78..c6de458 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -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() diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py index 7660240..46e96e4 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/test_token_endpoint.py @@ -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'), diff --git a/oidc_provider/views.py b/oidc_provider/views.py index ffc60c4..c2e8cb7 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -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'])