diff --git a/README.rst b/README.rst index 849b4ae..2347b72 100644 --- a/README.rst +++ b/README.rst @@ -2,10 +2,12 @@ Django OpenID Provider ###################### +**This project is in ALFA version and is rapidly changing. DO NOT USE IT FOR PRODUCTION SITES.** + Important things that you should know: - Although OpenID was built on top of OAuth2, this isn't an OAuth2 server. Maybe in a future it will be. -- This just cover the ``authorization_code`` flow, no support for ``implicit`` flow at this moment. +- This cover ``authorization_code`` flow and ``implicit`` flow, NO support for ``hibrid`` flow at this moment. - Only support for requesting Claims using Scope Values. ************ diff --git a/openid_provider/lib/grants/__init__.py b/openid_provider/lib/endpoints/__init__.py similarity index 100% rename from openid_provider/lib/grants/__init__.py rename to openid_provider/lib/endpoints/__init__.py diff --git a/openid_provider/lib/endpoints/authorize.py b/openid_provider/lib/endpoints/authorize.py new file mode 100644 index 0000000..e32e600 --- /dev/null +++ b/openid_provider/lib/endpoints/authorize.py @@ -0,0 +1,141 @@ +from datetime import timedelta +from django.utils import timezone +from openid_provider.lib.errors import * +from openid_provider.lib.utils.params import * +from openid_provider.lib.utils.token import * +from openid_provider.models import * +import uuid + + +class AuthorizeEndpoint(object): + + def __init__(self, request): + + self.request = request + + self.params = Params + + # Because in this endpoint we handle both GET + # and POST request. + self.query_dict = (self.request.POST if self.request.method == 'POST' + else self.request.GET) + + self._extract_params() + + # Determine which flow to use. + if self.params.response_type in ['code']: + self.grant_type = 'authorization_code' + elif self.params.response_type in ['id_token', 'id_token token']: + self.grant_type = 'implicit' + self._extract_implicit_params() + else: + self.grant_type = None + + def _extract_params(self): + ''' + Get all the params used by the Authorization Code Flow + (and also for the Implicit). + + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + ''' + self.params.client_id = self.query_dict.get('client_id', '') + self.params.redirect_uri = self.query_dict.get('redirect_uri', '') + self.params.response_type = self.query_dict.get('response_type', '') + self.params.scope = self.query_dict.get('scope', '') + self.params.state = self.query_dict.get('state', '') + + def _extract_implicit_params(self): + ''' + Get specific params used by the Implicit Flow. + + See: http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest + ''' + self.params.nonce = self.query_dict.get('nonce', '') + + def is_code_flow(self): + ''' + True if the client is using Authorization Code Flow. + + Return a boolean. + ''' + return self.grant_type == 'authorization_code' + + def is_implicit_flow(self): + ''' + True if the client is using Implicit Flow. + + Return a boolean. + ''' + return self.grant_type == 'implicit' + + def validate_params(self): + + if not self.params.redirect_uri: + raise RedirectUriError() + + if not ('openid' in self.params.scope.split()): + raise AuthorizeError(self.params.redirect_uri, 'invalid_scope', self.grant_type) + + try: + self.client = Client.objects.get(client_id=self.params.client_id) + + if not (self.params.redirect_uri in self.client.redirect_uris): + raise RedirectUriError() + + if not (self.grant_type) or not (self.params.response_type == self.client.response_type): + raise AuthorizeError(self.params.redirect_uri, 'unsupported_response_type', self.grant_type) + + except Client.DoesNotExist: + raise ClientIdError() + + def create_response_uri(self, allow): + + if not allow: + raise AuthorizeError(self.params.redirect_uri, 'access_denied', self.grant_type) + + try: + self.validate_params() + + if self.is_code_flow(): + + code = Code() + code.user = self.request.user + code.client = self.client + code.code = uuid.uuid4().hex + code.expires_at = timezone.now() + timedelta(seconds=60*10) # TODO: Add this into settings. + code.scope = self.params.scope + code.save() + + uri = self.params.redirect_uri + '?code={0}'.format(code.code) + else: + + id_token_dic = create_id_token_dic( + self.request.user, + 'http://localhost:8000', # TODO: Add this into settings. + self.client.client_id) + + token = create_token( + user=self.request.user, + client=self.client, + id_token_dic=id_token_dic, + scope=self.params.scope) + + # Store the token. + token.save() + + id_token = encode_id_token(id_token_dic, self.client.client_secret) + + # TODO: Check if response_type is 'id_token token' and + # add access_token to the fragment. + uri = self.params.redirect_uri + \ + '#token_type={0}&id_token={1}&expires_in={2}'.format( + 'bearer', + id_token, + 60*10) + except: + raise AuthorizeError(self.params.redirect_uri, 'server_error', self.grant_type) + + # Add state if present. + uri = uri + ('&state={0}'.format(self.params.state) if self.params.state else '') + + return uri \ No newline at end of file diff --git a/openid_provider/lib/endpoints/token.py b/openid_provider/lib/endpoints/token.py new file mode 100644 index 0000000..b052ce8 --- /dev/null +++ b/openid_provider/lib/endpoints/token.py @@ -0,0 +1,92 @@ +from django.http import JsonResponse +from openid_provider.lib.errors import * +from openid_provider.lib.utils.params import * +from openid_provider.lib.utils.token import * +from openid_provider.models import * +import urllib + + +class TokenEndpoint(object): + + def __init__(self, request): + + self.request = request + self.params = Params + self._extract_params() + + def _extract_params(self): + + query_dict = self.request.POST + + self.params.client_id = query_dict.get('client_id', '') + self.params.client_secret = query_dict.get('client_secret', '') + self.params.redirect_uri = urllib.unquote(query_dict.get('redirect_uri', '')) + self.params.grant_type = query_dict.get('grant_type', '') + self.params.code = query_dict.get('code', '') + self.params.state = query_dict.get('state', '') + + def validate_params(self): + + if not (self.params.grant_type == 'authorization_code'): + raise TokenError('unsupported_grant_type') + + try: + self.client = Client.objects.get(client_id=self.params.client_id) + + if not (self.client.client_secret == self.params.client_secret): + raise TokenError('invalid_client') + + if not (self.params.redirect_uri in self.client.redirect_uris): + raise TokenError('invalid_client') + + self.code = Code.objects.get(code=self.params.code) + + if not (self.code.client == self.client) and not self.code.has_expired(): + raise TokenError('invalid_grant') + + except Client.DoesNotExist: + raise TokenError('invalid_client') + + except Code.DoesNotExist: + raise TokenError('invalid_grant') + + def create_response_dic(self): + + id_token_dic = create_id_token_dic( + self.code.user, + 'http://localhost:8000', # TODO: Add this into settings. + self.client.client_id) + + token = create_token( + user=self.code.user, + client=self.code.client, + id_token_dic=id_token_dic, + scope=self.code.scope) + + # Store the token. + token.save() + + # We don't need to store the code anymore. + self.code.delete() + + id_token = encode_id_token(id_token_dic, self.client.client_secret) + + dic = { + 'access_token': token.access_token, + 'token_type': 'bearer', + 'expires_in': 60*60, # TODO: Add this into settings. + 'id_token': id_token, + } + + return dic + + @classmethod + def response(self, 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 \ No newline at end of file diff --git a/openid_provider/lib/endpoints/userinfo.py b/openid_provider/lib/endpoints/userinfo.py new file mode 100644 index 0000000..7e3a40a --- /dev/null +++ b/openid_provider/lib/endpoints/userinfo.py @@ -0,0 +1,79 @@ +from django.http import HttpResponse, JsonResponse +from openid_provider.lib.errors import * +from openid_provider.lib.scopes import * +from openid_provider.lib.utils.params import * +from openid_provider.models import * +import re + + +class UserInfoEndpoint(object): + + def __init__(self, request): + + self.request = request + self.params = Params + self._extract_params() + + def _extract_params(self): + + # TODO: Maybe add other ways of passing access token + # http://tools.ietf.org/html/rfc6750#section-2 + self.params.access_token = self._get_access_token() + + def _get_access_token(self): + ''' + Get the access token using Authorization Request Header Field method. + See: http://tools.ietf.org/html/rfc6750#section-2.1 + + Return a string. + ''' + auth_header = self.request.META.get('HTTP_AUTHORIZATION', '') + + if re.compile('^Bearer\s{1}.+$').match(auth_header): + access_token = auth_header.split()[1] + else: + access_token = '' + + return access_token + + def validate_params(self): + + try: + self.token = Token.objects.get(access_token=self.params.access_token) + + except Token.DoesNotExist: + raise UserInfoError('invalid_token') + + def create_response_dic(self): + ''' + Create a diccionary with all the requested claims about the End-User. + See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse + + Return a diccionary. + ''' + dic = { + 'sub': self.token.id_token.get('sub'), + } + + standard_claims = StandardClaims(self.token.user, self.token.scope.split()) + + dic.update(standard_claims.create_response_dic()) + + return dic + + @classmethod + def response(self, dic): + + response = JsonResponse(dic, status=200) + response['Cache-Control'] = 'no-store' + response['Pragma'] = 'no-cache' + + return response + + @classmethod + def error_response(self, code, description, status): + + response = HttpResponse(status=status) + response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format(code, description) + + return response \ No newline at end of file diff --git a/openid_provider/lib/errors.py b/openid_provider/lib/errors.py index 1903800..263c8e2 100644 --- a/openid_provider/lib/errors.py +++ b/openid_provider/lib/errors.py @@ -43,17 +43,25 @@ class AuthorizeError(Exception): 'registration_not_supported': 'The provider does not support use of the registration parameter', } - def __init__(self, redirect_uri, error): + def __init__(self, redirect_uri, error, grant_type): self.error = error self.description = self._errors.get(error) self.redirect_uri = redirect_uri + self.grant_type = grant_type def create_uri(self, redirect_uri, state): description = urllib.quote(self.description) - uri = '{0}?error={1}&error_description={2}'.format(redirect_uri, self.error, description) + # See: http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError + hash_or_question = '#' if self.grant_type == 'implicit' else '?' + + uri = '{0}{1}error={2}&error_description={3}'.format( + redirect_uri, + hash_or_question, + self.error, + description) # Add state if present. uri = uri + ('&state={0}'.format(state) if state else '') diff --git a/openid_provider/lib/grants/authorization_code.py b/openid_provider/lib/grants/authorization_code.py deleted file mode 100644 index 9a35b52..0000000 --- a/openid_provider/lib/grants/authorization_code.py +++ /dev/null @@ -1,269 +0,0 @@ -from datetime import timedelta -from django.http import HttpResponse, HttpResponseRedirect, JsonResponse -from django.utils import timezone -import urllib -import uuid -import json -import jwt -import random -import re -import time -from openid_provider.models import * -from openid_provider.lib.errors import * -from openid_provider.lib.scopes import * - - -class AuthorizeEndpoint(object): - - def __init__(self, request): - - self.request = request - self.extract_params() - - def extract_params(self): - - query_dict = self.request.POST if self.request.method == 'POST' else self.request.GET - - class Params(object): pass - - Params.client_id = query_dict.get('client_id', '') - Params.redirect_uri = query_dict.get('redirect_uri', '') - Params.response_type = query_dict.get('response_type', '') - Params.scope = query_dict.get('scope', '') - Params.state = query_dict.get('state', '') - - self.params = Params - - def validate_params(self): - - if not self.params.redirect_uri: - raise RedirectUriError() - - if not ('openid' in self.params.scope.split()): - raise AuthorizeError(self.params.redirect_uri, 'invalid_scope') - - try: - self.client = Client.objects.get(client_id=self.params.client_id) - - if not (self.params.redirect_uri in self.client.redirect_uris): - raise RedirectUriError() - - if not (self.params.response_type == 'code'): - raise AuthorizeError(self.params.redirect_uri, 'unsupported_response_type') - - except Client.DoesNotExist: - raise ClientIdError() - - def create_response_uri(self, allow): - - if not allow: - raise AuthorizeError(self.params.redirect_uri, 'access_denied') - - try: - self.validate_params() - - code = Code() - code.user = self.request.user - code.client = self.client - code.code = uuid.uuid4().hex - code.expires_at = timezone.now() + timedelta(seconds=60*10) - code.scope = self.params.scope - - code.save() - except: - raise AuthorizeError(self.params.redirect_uri, 'server_error') - - uri = self.params.redirect_uri + '?code={0}'.format(code.code) - - # Add state if present. - uri = uri + ('&state={0}'.format(self.params.state) if self.params.state else '') - - return uri - -class TokenEndpoint(object): - - def __init__(self, request): - - self.request = request - self.extract_params() - - def extract_params(self): - - query_dict = self.request.POST - - class Params(object): pass - - Params.client_id = query_dict.get('client_id', '') - Params.client_secret = query_dict.get('client_secret', '') - Params.redirect_uri = urllib.unquote(query_dict.get('redirect_uri', '')) - Params.grant_type = query_dict.get('grant_type', '') - Params.code = query_dict.get('code', '') - Params.state = query_dict.get('state', '') - - self.params = Params - - def validate_params(self): - - if not (self.params.grant_type == 'authorization_code'): - raise TokenError('unsupported_grant_type') - - try: - self.client = Client.objects.get(client_id=self.params.client_id) - - if not (self.client.client_secret == self.params.client_secret): - raise TokenError('invalid_client') - - if not (self.params.redirect_uri in self.client.redirect_uris): - raise TokenError('invalid_client') - - self.code = Code.objects.get(code=self.params.code) - - if not (self.code.client == self.client) and not self.code.has_expired(): - raise TokenError('invalid_grant') - - except Client.DoesNotExist: - raise TokenError('invalid_client') - - except Code.DoesNotExist: - raise TokenError('invalid_grant') - - def create_response_dic(self): - - expires_in = 60*60 # TODO: Probably add into settings - - token = Token() - token.user = self.code.user - token.client = self.code.client - token.access_token = uuid.uuid4().hex - - id_token_dic = self.generate_id_token_dic() - token.id_token = id_token_dic - - token.refresh_token = uuid.uuid4().hex - token.expires_at = timezone.now() + timedelta(seconds=expires_in) - token.scope = self.code.scope - - token.save() - - self.code.delete() - - id_token = jwt.encode(id_token_dic, self.client.client_secret) - - dic = { - 'access_token': token.access_token, - 'token_type': 'bearer', - 'expires_in': expires_in, - 'id_token': id_token, - # TODO: 'refresh_token': token.refresh_token, - } - - return dic - - def generate_id_token_dic(self): - - expires_in = 60*10 - - now = timezone.now() - - # Convert datetimes into timestamps. - iat_time = time.mktime(now.timetuple()) - exp_time = time.mktime((now + timedelta(seconds=expires_in)).timetuple()) - user_auth_time = time.mktime(self.code.user.last_login.timetuple()) - - dic = { - 'iss': 'https://localhost:8000', # TODO: this should not be hardcoded. - 'sub': self.code.user.id, - 'aud': self.client.client_id, - 'exp': exp_time, - 'iat': iat_time, - 'auth_time': user_auth_time, - } - - return dic - - @classmethod - def response(self, 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 - -class UserInfoEndpoint(object): - - def __init__(self, request): - - self.request = request - self.extract_params() - - def extract_params(self): - - # TODO: Add other ways of passing access token - # http://tools.ietf.org/html/rfc6750#section-2 - - class Params(object): pass - - Params.access_token = self._get_access_token() - - self.params = Params - - def validate_params(self): - - try: - self.token = Token.objects.get(access_token=self.params.access_token) - - except Token.DoesNotExist: - raise UserInfoError('invalid_token') - - def _get_access_token(self): - ''' - Get the access token using Authorization Request Header Field method. - See: http://tools.ietf.org/html/rfc6750#section-2.1 - - Return a string. - ''' - auth_header = self.request.META.get('HTTP_AUTHORIZATION', '') - - if re.compile('^Bearer\s{1}.+$').match(auth_header): - access_token = auth_header.split()[1] - else: - access_token = '' - - return access_token - - def create_response_dic(self): - ''' - Create a diccionary with all the requested claims about the End-User. - See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse - - Return a diccionary. - ''' - dic = { - 'sub': self.token.id_token.get('sub'), - } - - standard_claims = StandardClaims(self.token.user, self.token.scope.split()) - - dic.update(standard_claims.create_response_dic()) - - return dic - - @classmethod - def response(self, dic): - - response = JsonResponse(dic, status=200) - response['Cache-Control'] = 'no-store' - response['Pragma'] = 'no-cache' - - return response - - @classmethod - def error_response(self, code, description, status): - - response = HttpResponse(status=status) - response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format(code, description) - - return response \ No newline at end of file diff --git a/openid_provider/lib/utils/__init__.py b/openid_provider/lib/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openid_provider/lib/utils/params.py b/openid_provider/lib/utils/params.py new file mode 100644 index 0000000..927d507 --- /dev/null +++ b/openid_provider/lib/utils/params.py @@ -0,0 +1,2 @@ +class Params(object): + pass \ No newline at end of file diff --git a/openid_provider/lib/utils/token.py b/openid_provider/lib/utils/token.py new file mode 100644 index 0000000..e12cef7 --- /dev/null +++ b/openid_provider/lib/utils/token.py @@ -0,0 +1,64 @@ +from datetime import timedelta +from django.utils import timezone +import jwt +from openid_provider.models import * +import time +import uuid + + +def create_id_token_dic(user, iss, aud): + ''' + Receives a user object, iss (issuer) and aud (audience). + Then creates the id_token dic. + See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken + + Return a dic. + ''' + expires_in = 60*10 + + now = timezone.now() + + # Convert datetimes into timestamps. + iat_time = time.mktime(now.timetuple()) + exp_time = time.mktime((now + timedelta(seconds=expires_in)).timetuple()) + user_auth_time = time.mktime(user.last_login.timetuple()) + + dic = { + 'iss': iss, # TODO: this should not be hardcoded. + 'sub': user.id, + 'aud': aud, + 'exp': exp_time, + 'iat': iat_time, + 'auth_time': user_auth_time, + } + + return dic + +def encode_id_token(id_token_dic, client_secret): + ''' + Represent the ID Token as a JSON Web Token (JWT). + + Return a hash. + ''' + id_token_hash = jwt.encode(id_token_dic, client_secret) + + return id_token_hash + +def create_token(user, client, id_token_dic, scope): + ''' + Create and populate a Token object. + + Return a Token object. + ''' + token = Token() + token.user = user + token.client = client + token.access_token = uuid.uuid4().hex + + token.id_token = id_token_dic + + token.refresh_token = uuid.uuid4().hex + token.expires_at = timezone.now() + timedelta(seconds=60*60) # TODO: Add this into settings. + token.scope = scope + + return token \ No newline at end of file diff --git a/openid_provider/models.py b/openid_provider/models.py index 363ffac..a45d7ea 100644 --- a/openid_provider/models.py +++ b/openid_provider/models.py @@ -8,18 +8,13 @@ class Client(models.Model): CLIENT_TYPE_CHOICES = [ ('confidential', 'Confidential'), - #('public', 'Public'), - ] - - GRANT_TYPE_CHOICES = [ - ('authorization_code', 'Authorization Code Flow'), - #('implicit', 'Implicit Flow'), + ('public', 'Public'), ] RESPONSE_TYPE_CHOICES = [ - ('code', 'Authorization Code Flow'), - #('id_token', 'Implicit Flow'), - #('id_token token', 'Implicit Flow'), + ('code', 'code (Authorization Code Flow)'), + ('id_token', 'id_token (Implicit Flow)'), + ('id_token token', 'id_token token (Implicit Flow)'), ] name = models.CharField(max_length=100, default='') @@ -27,7 +22,6 @@ class Client(models.Model): client_id = models.CharField(max_length=255, unique=True) client_secret = models.CharField(max_length=255, unique=True) client_type = models.CharField(max_length=20, choices=CLIENT_TYPE_CHOICES) - grant_type = models.CharField(max_length=30, choices=GRANT_TYPE_CHOICES) response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES) # TODO: Need to be implemented. diff --git a/openid_provider/views.py b/openid_provider/views.py index c75b693..51a7d38 100644 --- a/openid_provider/views.py +++ b/openid_provider/views.py @@ -6,7 +6,9 @@ from django.views.decorators.http import require_http_methods from django.views.generic import View import urllib from openid_provider.lib.errors import * -from openid_provider.lib.grants.authorization_code import * +from openid_provider.lib.endpoints.authorize import * +from openid_provider.lib.endpoints.token import * +from openid_provider.lib.endpoints.userinfo import * class AuthorizeView(View):