diff --git a/.gitignore b/.gitignore index 4f7b4c3..a91cd9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ __pycache__/ +build/ +dist/ *.py[cod] -*.egg-info \ No newline at end of file +*.egg-info +.ropeproject +src/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a6502c9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# CHANGELOG + +All notable changes to this project will be documented in this file. + +### [Unreleased] + +#### Added +- Better naming for models in the admin. + +### [0.0.5] - 2015-05-09 + +#### Added +- Support for Django 1.8. + +#### Fixed +- Validation of scope in UserInfo endpoint. + +### [0.0.4] - 2015-04-22 + +#### Added +- Initial migrations. + +##### Fixed +- Important bug with id_token when using implicit flow. +- Validate Code expiration in Auth Code Flow. +- Validate Access Token expiration in UserInfo endpoint. + +### [0.0.3] - 2015-04-15 + +##### Added +- Normalize gender field in UserInfo. + +##### Changed +- Make address_formatted a property inside UserInfo. + +##### Fixed +- Important bug in claims response. + +### [0.0.2] - 2015-03-26 + +##### Added +- Setting OIDC_AFTER_USERLOGIN_HOOK. + +##### Fixed +- Tests failing because an incorrect tag in one template. + +### [0.0.1] - 2015-03-13 + +##### Added +- Provider Configuration Information endpoint. +- Setting OIDC_IDTOKEN_SUB_GENERATOR. + +##### Changed +- Now use setup in OIDC_EXTRA_SCOPE_CLAIMS setting. + +### [0.0.0] - 2015-02-26 diff --git a/DOC.md b/DOC.md new file mode 100644 index 0000000..e6d8783 --- /dev/null +++ b/DOC.md @@ -0,0 +1,279 @@ +# Welcome to the Docs! + +Django OIDC Provider can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. + + +**This project is still in DEVELOPMENT and is rapidly changing. DO NOT USE IT FOR PRODUCTION SITES, unless you know what you do.** + +**************************************** + +Before getting started there are some 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. +* Despite that implementation MUST support TLS. You can make request without using SSL. There is no control on that. +* This cover `Authorization Code` flow and `Implicit` flow, NO support for `Hybrid` flow at this moment. +* Only support for requesting Claims using Scope Values. + +# Table Of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Settings](#settings) + - [SITE_URL](#site_url) + - [LOGIN_URL](#login_url) + - [OIDC_AFTER_USERLOGIN_HOOK](#oidc_after_userlogin_hook) + - [OIDC_CODE_EXPIRE](#oidc_code_expire) + - [OIDC_EXTRA_SCOPE_CLAIMS](#oidc_extra_scope_claims) + - [OIDC_IDTOKEN_EXPIRE](#oidc_idtoken_expire) + - [OIDC_IDTOKEN_SUB_GENERATOR](#oidc_idtoken_sub_generator) + - [OIDC_TOKEN_EXPIRE](#oidc_token_expire) +- [Users And Clients](#users-and-clients) +- [Templates](#templates) +- [Server Endpoints](#server-endpoints) +- [Running Tests](#running-tests) + +## Requirements + +- Python 2.7.*. +- Django 1.7.*. + +## Installation + +If you want to get started fast see our [Example Project](https://github.com/juanifioren/django-oidc-provider/tree/master/example_project) folder. + +Install the package using pip. + +```bash +pip install django-oidc-provider +# Or latest code from repo. +pip install git+https://github.com/juanifioren/django-oidc-provider.git#egg=oidc_provider +# Or if working from a local repo +pip install git+./#egg=oidc_provider +``` + +Add it to your apps. + +```python +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'oidc_provider', + # ... +) +``` + +Add the provider urls. + +```python +urlpatterns = patterns('', + # ... + url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), + # ... +) +``` + +## Settings + +Add required variables to your project settings. + +##### SITE_URL +REQUIRED. The OP server url. For example `http://localhost:8000`. + +##### LOGIN_URL +REQUIRED. Used to log the user in. [Read more in Django docs](https://docs.djangoproject.com/en/1.7/ref/settings/#login-url). + +Default is `/accounts/login/`. + +##### OIDC_AFTER_USERLOGIN_HOOK +OPTIONAL. Provide a way to plug into the process after the user has logged in, typically to perform some business logic. + +Default is: +```python +def default_hook_func(request, user, client): + return None +``` + +Return `None` if you want to continue with the flow. + +The typical situation will be checking some state of the user or maybe redirect him somewhere. +With request you have access to all OIDC parameters. Remember that if you redirect the user to another place then you need to take him back to the authorize endpoint (use `request.get_full_path()` as the value for a "next" parameter). + +##### OIDC_CODE_EXPIRE +OPTIONAL. Expressed in seconds. + +Default is `60*10`. + +##### OIDC_EXTRA_SCOPE_CLAIMS +OPTIONAL. Used to add extra scopes specific for your app. This class MUST inherit ``AbstractScopeClaims``. + +OpenID Connect Clients will use scope values to specify what access privileges are being requested for Access Tokens. + +[Here](http://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) you have the standard scopes defined by the protocol. + +Check out an example of how to implement it: + +```python +from oidc_provider.lib.claims import AbstractScopeClaims + +class MyAppScopeClaims(AbstractScopeClaims): + + def setup(self): + # Here you can load models that will be used + # in more than one scope for example. + # print self.user + # print self.scopes + try: + self.some_model = SomeModel.objects.get(user=self.user) + except SomeModel.DoesNotExist: + # Create an empty model object. + self.some_model = SomeModel() + + def scope_books(self, user): + + # Here you can search books for this user. + + dic = { + 'books_readed': books_readed_count, + } + + return dic +``` + +See how we create our own scopes using the convention: + +``def scope_(self, user):`` + +If a field is empty or ``None`` will be cleaned from the response. + +##### OIDC_IDTOKEN_EXPIRE +OPTIONAL. Expressed in seconds. + +Default is `60*10`. + +##### OIDC_IDTOKEN_SUB_GENERATOR +OPTIONAL. Subject Identifier. A locally unique and never reassigned identifier within the Issuer for the End-User, which is intended to be consumed by the Client. + +Is just a function that receives a `user` object. Returns a unique string for the given user. + +Default is: +```python +def default_sub_generator(user): + + return user.id +``` + +##### OIDC_TOKEN_EXPIRE +OPTIONAL. Token object expiration after been created. Expressed in seconds. + +Default is `60*60`. + +## Users And Clients + +User and client creation it's up to you. This is because is out of the scope in the core implementation of OIDC. +So, there are different ways to create your Clients. By displaying a HTML form or maybe if you have internal thrusted Clients you can create them programatically. + +[Read more about client creation](http://tools.ietf.org/html/rfc6749#section-2). + +For your users, the tipical situation is that you provide them a login and a registration page. + +If you want to test the provider without getting to deep into this topics you can: + +Create a user with: ``python manage.py createsuperuser``. + +And then create a Client with django shell: ``python manage.py shell``. + +```python +>>> from oidc_provider.models import Client +>>> c = Client(name='Some Client', client_id='123', client_secret='456', response_type='code', redirect_uris=['http://example.com/']) +>>> c.save() +``` + +## Templates + +Add your own templates files inside a folder named ``templates/oidc_provider/``. +You can copy the sample html here and edit them with your own styles. + +**authorize.html** + +```html +

Request for Permission

+ +

Client {{ client.name }} would like to access this information of you ...

+ +
+ + {% csrf_token %} + + {{ hidden_inputs }} + + + + + +
+``` + +**error.html** + +```html +

{{ error }}

+

{{ description }}

+``` + +## Server Endpoints + +**/authorize endpoint** + +Example of an OpenID Authentication Request using the ``Authorization Code`` flow. + +```curl +GET /openid/authorize?client_id=123&redirect_uri=http%3A%2F%2Fexample.com%2F&response_type=code&scope=openid%20profile%20email&state=abcdefgh HTTP/1.1 +Host: localhost:8000 +Cache-Control: no-cache +Content-Type: application/x-www-form-urlencoded +``` + +After the user accepts and authorizes the client application, the server redirects to: + +```curl +http://example.com/?code=5fb3b172913448acadce6b011af1e75e&state=abcdefgh +``` + +The ``code`` param will be use it to obtain access token. + +**/token endpoint** + +```curl +POST /openid/token/ HTTP/1.1 +Host: localhost:8000 +Cache-Control: no-cache +Content-Type: application/x-www-form-urlencoded + client_id=123&client_secret=456&redirect_uri=http%253A%252F%252Fexample.com%252F&grant_type=authorization_code&code=[CODE]&state=abcdefgh +``` + +**/userinfo endpoint** + +```curl +POST /openid/userinfo/ HTTP/1.1 +Host: localhost:8000 +Authorization: Bearer [ACCESS_TOKEN] +``` + +## Running Tests + +You need a Django project properly configured with the package. Then just run tests as normal. + +```bash +$ python manage.py test oidc_provider +``` + +This provider was tested (and fully works) with these OIDC Clients: +- [Drupal OpenID Connect](https://www.drupal.org/project/openid_connect) +- [Passport OpenID Connect](https://github.com/jaredhanson/passport-openidconnect) (for NodeJS) diff --git a/MANIFEST.in b/MANIFEST.in index d78e4b3..c4c6761 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ include LICENSE include README.rst -recursive-include openid_provider/templates * \ No newline at end of file +recursive-include oidc_provider/templates * \ No newline at end of file diff --git a/README.rst b/README.rst index 2f9167a..cc1baa4 100644 --- a/README.rst +++ b/README.rst @@ -1,133 +1,19 @@ -.. image:: http://s1.postimg.org/qcm2dtr6n/title.png -#################################################### +Django OIDC Provider +#################### -**This project is in ALFA version and is rapidly changing. DO NOT USE IT FOR PRODUCTION SITES.** +Django OIDC Provider can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. -Important things that you should know: +Read docs for more info. -- Although OpenID was built on top of OAuth2, this isn't an OAuth2 server. Maybe in a future it will be. -- This cover ``authorization_code`` flow and ``implicit`` flow, NO support for ``hybrid`` flow at this moment. -- Only support for requesting Claims using Scope Values. +https://github.com/juanifioren/django-oidc-provider/blob/master/DOC.md + +See changelog here. + +https://github.com/juanifioren/django-oidc-provider/blob/master/CHANGELOG.md ************ -Installation +Contributing ************ -Install the package using pip. - -.. code:: bash - - pip install https://github.com/juanifioren/django-openid-provider/archive/master.zip - - -Add it to your apps. - -.. code:: python - - INSTALLED_APPS = ( - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'openid_provider', - # ... - ) - -Add the provider urls. - -.. code:: python - - urlpatterns = patterns('', - # ... - url(r'^openid/', include('openid_provider.urls', namespace='openid_provider')), - # ... - ) - -******** -Settings -******** - -Add required variables to your project settings. - -.. code:: python - - # REQUIRED. - - # Your server provider url. - SITE_URL = 'http://localhost:8000' - - # Used to log the user in. - # See: https://docs.djangoproject.com/en/1.7/ref/settings/#login-url - LOGIN_URL = '/accounts/login/' - - # OPTIONAL. - - DOP_CODE_EXPIRE = 60*10 # 10 min. - DOP_IDTOKEN_EXPIRE = 60*10, # 10 min. - DOP_TOKEN_EXPIRE = 60*60 # 1 hour. - - -******************** -Create User & Client -******************** - -First of all, we need to create a user: ``python manage.py createsuperuser``. - -Then let's create a Client. Start django shell: ``python manage.py shell``. - -.. code:: python - - >>> from openid_provider.models import Client - >>> c = Client(name='Some Client', client_id='123', client_secret='456', response_type='code', redirect_uris=['http://example.com/']) - >>> c.save() - -******************* -/authorize endpoint -******************* - -Example of an OpenID Authentication Request using the ´´Authorization Code´´ flow. - -.. code:: curl - - GET /openid/authorize?client_id=123&redirect_uri=http%3A%2F%2Fexample.com%2F&response_type=code&scope=openid%20profile%20email&state=abcdefgh HTTP/1.1 - Host: localhost:8000 - Cache-Control: no-cache - Content-Type: application/x-www-form-urlencoded - -**** -Code -**** - -After the user accepts and authorizes the client application, the server redirects to: - -.. code:: curl - - http://example.com/?code=5fb3b172913448acadce6b011af1e75e&state=abcdefgh - -We extract the ``code`` param and use it to obtain access token. - -*************** -/token endpoint -*************** - -.. code:: curl - - POST /openid/token/ HTTP/1.1 - Host: localhost:8000 - Cache-Control: no-cache - Content-Type: application/x-www-form-urlencoded - - client_id=123&client_secret=456&redirect_uri=http%253A%252F%252Fexample.com%252F&grant_type=authorization_code&code=[CODE]&state=abcdefgh - -****************** -/userinfo endpoint -****************** - -.. code:: curl - - POST /openid/userinfo/ HTTP/1.1 - Host: localhost:8000 - Authorization: Bearer [ACCESS_TOKEN] +We love contributions, so please feel free to fix bugs, improve things, provide documentation. Just submit a Pull Request. diff --git a/example_project/.gitignore b/example_project/.gitignore new file mode 100644 index 0000000..6e9bc0c --- /dev/null +++ b/example_project/.gitignore @@ -0,0 +1 @@ +*.sqlite3 \ No newline at end of file diff --git a/example_project/README.md b/example_project/README.md new file mode 100644 index 0000000..6a42d7f --- /dev/null +++ b/example_project/README.md @@ -0,0 +1,27 @@ +# Example Project + +Run your own OIDC provider in a second. This is a Django app with all the necessary things to work with `django-oidc-provider` package. + +## Setup & Running + +Setup project environment with [virtualenv](https://virtualenv.pypa.io) and [pip](https://pip.pypa.io). + +```bash +$ virtualenv project_env +$ source project_env/bin/activate + +$ git clone https://github.com/juanifioren/django-oidc-provider.git +$ cd django-oidc-provider/example_project +$ pip install -r requirements.txt +``` + +Run your provider. + +```bash +$ python manage.py migrate +$ python manage.py runserver +``` + +Open your browser and go to `http://localhost:8000`. Voilà! + +[](https://github.com/juanifioren/meteor-coffee-boilerplate) diff --git a/example_project/manage.py b/example_project/manage.py new file mode 100755 index 0000000..ff8c2cf --- /dev/null +++ b/example_project/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "provider_app.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/openid_provider/__init__.py b/example_project/provider_app/__init__.py similarity index 100% rename from openid_provider/__init__.py rename to example_project/provider_app/__init__.py diff --git a/example_project/provider_app/settings.py b/example_project/provider_app/settings.py new file mode 100644 index 0000000..3c07579 --- /dev/null +++ b/example_project/provider_app/settings.py @@ -0,0 +1,78 @@ +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +import os +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) + + +# Quick-start development settings - unsuitable for production + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'waw%j=vza!vc1^eyosw%#_!gg96%zb7sp*+!owkutue4i(sm91' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +TEMPLATE_DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'provider_app', + 'oidc_provider', +) + +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +) + +ROOT_URLCONF = 'provider_app.urls' + +WSGI_APPLICATION = 'provider_app.wsgi.application' + + +# Database + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + +# Internationalization + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) + +STATIC_URL = '/static/' + +LOGIN_REDIRECT_URL = '/' + +# OIDC Provider settings. + +SITE_URL = 'http://localhost:8000' \ No newline at end of file diff --git a/example_project/provider_app/templates/accounts/login.html b/example_project/provider_app/templates/accounts/login.html new file mode 100644 index 0000000..6c24774 --- /dev/null +++ b/example_project/provider_app/templates/accounts/login.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} + +{% block content %} + +
+
+
+ {% csrf_token %} + + {% if form.errors %} + + {% endif %} +
+ +
+
+ +
+ +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/example_project/provider_app/templates/accounts/logout.html b/example_project/provider_app/templates/accounts/logout.html new file mode 100644 index 0000000..25aa0f8 --- /dev/null +++ b/example_project/provider_app/templates/accounts/logout.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block content %} + +
+
+

Bye!

+

Thanks for spending some quality time with the web site today.

+
+
+ +{% endblock %} \ No newline at end of file diff --git a/example_project/provider_app/templates/base.html b/example_project/provider_app/templates/base.html new file mode 100644 index 0000000..6d38b8b --- /dev/null +++ b/example_project/provider_app/templates/base.html @@ -0,0 +1,50 @@ + + + + + + + OpenID Provider + + + + + + + + + + +
+
+ +

django-oidc-provider

+
+ + {% block content %}{% endblock %} + + + +
+ + + + + + + \ No newline at end of file diff --git a/example_project/provider_app/templates/home.html b/example_project/provider_app/templates/home.html new file mode 100644 index 0000000..c79d818 --- /dev/null +++ b/example_project/provider_app/templates/home.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block content %} + +
+

Welcome!

+

Django OIDC Provider can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects.

+

View on Github

+
+ +{% endblock %} \ No newline at end of file diff --git a/example_project/provider_app/templates/oidc_provider/authorize.html b/example_project/provider_app/templates/oidc_provider/authorize.html new file mode 100644 index 0000000..ccc7065 --- /dev/null +++ b/example_project/provider_app/templates/oidc_provider/authorize.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} + +{% block content %} + +
+
+

Request for Permission

+
+
+

Client {{ client.name }} would like to access this information of you ...

+ +
+ + {% csrf_token %} + + {{ hidden_inputs }} + +
    + {% for scope in params.scope %} +
  • {{ scope | capfirst }}
  • + {% endfor %} +
+ + + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/example_project/provider_app/templates/oidc_provider/error.html b/example_project/provider_app/templates/oidc_provider/error.html new file mode 100644 index 0000000..b6e75dd --- /dev/null +++ b/example_project/provider_app/templates/oidc_provider/error.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block content %} + +
+
+

{{ error }}

+
+
+ {{ description }} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/example_project/provider_app/urls.py b/example_project/provider_app/urls.py new file mode 100644 index 0000000..abfb2c4 --- /dev/null +++ b/example_project/provider_app/urls.py @@ -0,0 +1,15 @@ +from django.contrib.auth import views as auth_views +from django.conf.urls import patterns, include, url +from django.contrib import admin +from django.views.generic import TemplateView + + +urlpatterns = patterns('', + url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), + url(r'^accounts/login/$', auth_views.login, {'template_name': 'accounts/login.html'}, name='login'), + url(r'^accounts/logout/$', auth_views.logout, {'template_name': 'accounts/logout.html'}, name='logout'), + + url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), + + url(r'^admin/', include(admin.site.urls)), +) diff --git a/example_project/provider_app/wsgi.py b/example_project/provider_app/wsgi.py new file mode 100644 index 0000000..cfd9ffa --- /dev/null +++ b/example_project/provider_app/wsgi.py @@ -0,0 +1,5 @@ +import os +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "provider_app.settings") + +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() diff --git a/example_project/requirements.txt b/example_project/requirements.txt new file mode 100644 index 0000000..6fc9947 --- /dev/null +++ b/example_project/requirements.txt @@ -0,0 +1,2 @@ +django==1.8 +django-oidc-provider==0.0.5 diff --git a/openid_provider/lib/__init__.py b/oidc_provider/__init__.py similarity index 100% rename from openid_provider/lib/__init__.py rename to oidc_provider/__init__.py diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py new file mode 100644 index 0000000..ba03af5 --- /dev/null +++ b/oidc_provider/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from oidc_provider.models import Client, Code, Token, UserInfo + + +admin.site.register(Client) +admin.site.register(Code) +admin.site.register(Token) +admin.site.register(UserInfo) \ No newline at end of file diff --git a/openid_provider/lib/endpoints/__init__.py b/oidc_provider/lib/__init__.py similarity index 100% rename from openid_provider/lib/endpoints/__init__.py rename to oidc_provider/lib/__init__.py diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py new file mode 100644 index 0000000..5977e14 --- /dev/null +++ b/oidc_provider/lib/claims.py @@ -0,0 +1,124 @@ +from django.utils.translation import ugettext as _ +from oidc_provider.models import UserInfo + + +class AbstractScopeClaims(object): + + def __init__(self, user, scopes): + self.user = user + self.scopes = scopes + + self.setup() + + def setup(self): + pass + + def create_response_dic(self): + """ + Generate the dic that will be jsonify. Checking scopes given vs + registered. + + Returns a dic. + """ + dic = {} + + for scope in self.scopes: + + if scope in self._scopes_registered(): + dic.update(getattr(self, 'scope_' + scope)(self.user)) + + dic = self._clean_dic(dic) + + return dic + + def _scopes_registered(self): + """ + Return a list that contains all the scopes registered + in the class. + """ + scopes = [] + + for name in self.__class__.__dict__: + + if name.startswith('scope_'): + scope = name.split('scope_')[1] + scopes.append(scope) + + return scopes + + def _clean_dic(self, dic): + """ + Clean recursively all empty or None values inside a dict. + """ + aux_dic = dic.copy() + for key, value in dic.iteritems(): + + if not value: + del aux_dic[key] + elif type(value) is dict: + aux_dic[key] = self._clean_dic(value) + + return aux_dic + +class StandardScopeClaims(AbstractScopeClaims): + """ + Based on OpenID Standard Claims. + See: http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + """ + + def setup(self): + try: + self.userinfo = UserInfo.objects.get(user=self.user) + except UserInfo.DoesNotExist: + # Create an empty model object. + self.userinfo = UserInfo() + + def scope_profile(self, user): + dic = { + 'name': self.userinfo.name, + 'given_name': self.userinfo.given_name, + 'family_name': self.userinfo.family_name, + 'middle_name': self.userinfo.middle_name, + 'nickname': self.userinfo.nickname, + 'preferred_username': self.userinfo.preferred_username, + 'profile': self.userinfo.profile, + 'picture': self.userinfo.picture, + 'website': self.userinfo.website, + 'gender': self.userinfo.gender, + 'birthdate': self.userinfo.birthdate, + 'zoneinfo': self.userinfo.zoneinfo, + 'locale': self.userinfo.locale, + 'updated_at': self.userinfo.updated_at, + } + + return dic + + def scope_email(self, user): + dic = { + 'email': self.user.email, + 'email_verified': self.userinfo.email_verified, + } + + return dic + + def scope_phone(self, user): + dic = { + 'phone_number': self.userinfo.phone_number, + 'phone_number_verified': self.userinfo.phone_number_verified, + } + + return dic + + def scope_address(self, user): + dic = { + 'address': { + 'formatted': self.userinfo.address_formatted, + 'street_address': self.userinfo.address_street_address, + 'locality': self.userinfo.address_locality, + 'region': self.userinfo.address_region, + 'postal_code': self.userinfo.address_postal_code, + 'country': self.userinfo.address_country, + } + } + + return dic diff --git a/openid_provider/lib/utils/__init__.py b/oidc_provider/lib/endpoints/__init__.py similarity index 100% rename from openid_provider/lib/utils/__init__.py rename to oidc_provider/lib/endpoints/__init__.py diff --git a/openid_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py similarity index 74% rename from openid_provider/lib/endpoints/authorize.py rename to oidc_provider/lib/endpoints/authorize.py index 31f4006..667de5a 100644 --- a/openid_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -1,16 +1,13 @@ -import uuid - from datetime import timedelta +import uuid from django.utils import timezone -from openid_provider import settings - -from ..errors import * -from ..utils.params import * -from ..utils.token import * - -from openid_provider.models import * +from oidc_provider.lib.errors import * +from oidc_provider.lib.utils.params import * +from oidc_provider.lib.utils.token import * +from oidc_provider.models import * +from oidc_provider import settings class AuthorizeEndpoint(object): @@ -23,7 +20,8 @@ class AuthorizeEndpoint(object): # 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.query_dict = (self.request.POST if self.request.method == 'POST' + else self.request.GET) self._extract_params() @@ -94,26 +92,22 @@ class AuthorizeEndpoint(object): try: self.validate_params() - - if self.grant_type == 'authorization_code': - code = Code() - code.user = self.request.user - code.client = self.client - code.code = uuid.uuid4().hex - code.expires_at = timezone.now() + timedelta( - seconds=settings.get('DOP_CODE_EXPIRE')) - code.scope = self.params.scope + if self.grant_type == 'authorization_code': + code = create_code( + user=self.request.user, + client=self.client, + scope=self.params.scope) + code.save() + # Create the response uri. uri = self.params.redirect_uri + '?code={0}'.format(code.code) else: # Implicit Flow - - id_token_dic = create_id_token_dic( - self.request.user, - settings.get('SITE_URL'), - self.client.client_id) + id_token_dic = create_id_token( + user=self.request.user, + aud=self.client.client_id) token = create_token( user=self.request.user, @@ -127,13 +121,18 @@ class AuthorizeEndpoint(object): id_token = encode_id_token( id_token_dic, self.client.client_secret) - # TODO: Check if response_type is 'id_token token' then + # Create the response uri. + uri = self.params.redirect_uri + \ + '#token_type={0}&id_token={1}&expires_in={2}'.format( + 'bearer', + id_token, + 60 * 10, + ) + + # Check if response_type is 'id_token token' then # 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 - ) + if self.params.response_type == 'id_token token': + uri += '&access_token={0}'.format(token.access_token) except: raise AuthorizeError( self.params.redirect_uri, @@ -141,8 +140,6 @@ class AuthorizeEndpoint(object): self.grant_type) # Add state if present. - uri = uri + \ - ('&state={0}'.format(self.params.state) - if self.params.state else '') + uri += ('&state={0}'.format(self.params.state) if self.params.state else '') return uri diff --git a/oidc_provider/lib/endpoints/discovery.py b/oidc_provider/lib/endpoints/discovery.py new file mode 100644 index 0000000..945695f --- /dev/null +++ b/oidc_provider/lib/endpoints/discovery.py @@ -0,0 +1,31 @@ +from django.core.urlresolvers import reverse + +from oidc_provider import settings +from oidc_provider.lib.utils.common import get_issuer + + +class ProviderInfoEndpoint(object): + + @classmethod + def create_response_dic(cls): + dic = {} + + dic['issuer'] = get_issuer() + + SITE_URL = settings.get('SITE_URL') + + dic['authorization_endpoint'] = SITE_URL + reverse('oidc_provider:authorize') + dic['token_endpoint'] = SITE_URL + reverse('oidc_provider:token') + dic['userinfo_endpoint'] = SITE_URL + reverse('oidc_provider:userinfo') + + from oidc_provider.models import Client + types_supported = [x[0] for x in Client.RESPONSE_TYPE_CHOICES] + dic['response_types_supported'] = types_supported + + # TODO: + #dic['jwks_uri'] = None + + # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes + dic['subject_types_supported'] = ['public'] + + return dic \ No newline at end of file diff --git a/openid_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py similarity index 76% rename from openid_provider/lib/endpoints/token.py rename to oidc_provider/lib/endpoints/token.py index 6521de3..9c5f9dd 100644 --- a/openid_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -1,30 +1,22 @@ import urllib -try: # JsonResponse is only available in Django > 1.7 - from django.http import JsonResponse -except ImportError: - from ..utils.http import JsonResponse - -from openid_provider import settings +from django.http import JsonResponse -from ..utils.http import JsonResponse -from ..errors import * -from ..utils.params import * -from ..utils.token import * - -from openid_provider.models import * +from oidc_provider.lib.errors import * +from oidc_provider.lib.utils.params import * +from oidc_provider.lib.utils.token import * +from oidc_provider.models import * +from oidc_provider import settings 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', '') @@ -36,7 +28,6 @@ class TokenEndpoint(object): self.params.state = query_dict.get('state', '') def validate_params(self): - if not (self.params.grant_type == 'authorization_code'): raise TokenError('unsupported_grant_type') @@ -51,8 +42,8 @@ class TokenEndpoint(object): self.code = Code.objects.get(code=self.params.code) - if not (self.code.client == self.client) and \ - not self.code.has_expired(): + if not (self.code.client == self.client) \ + or self.code.has_expired(): raise TokenError('invalid_grant') except Client.DoesNotExist: @@ -62,11 +53,9 @@ class TokenEndpoint(object): raise TokenError('invalid_grant') def create_response_dic(self): - - id_token_dic = create_id_token_dic( - self.code.user, - settings.get('SITE_URL'), - self.client.client_id) + id_token_dic = create_id_token( + user=self.code.user, + aud=self.client.client_id) token = create_token( user=self.code.user, @@ -85,14 +74,14 @@ class TokenEndpoint(object): dic = { 'access_token': token.access_token, 'token_type': 'bearer', - 'expires_in': settings.get('DOP_TOKEN_EXPIRE'), + 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), 'id_token': id_token, } return dic @classmethod - def response(self, dic, status=200): + def response(cls, dic, status=200): """ Create and return a response object. """ diff --git a/openid_provider/lib/endpoints/userinfo.py b/oidc_provider/lib/endpoints/userinfo.py similarity index 70% rename from openid_provider/lib/endpoints/userinfo.py rename to oidc_provider/lib/endpoints/userinfo.py index 4a24368..3be9d6d 100644 --- a/openid_provider/lib/endpoints/userinfo.py +++ b/oidc_provider/lib/endpoints/userinfo.py @@ -1,27 +1,23 @@ import re + from django.http import HttpResponse +from django.http import JsonResponse -try: # JsonResponse is only available in Django > 1.7 - from django.http import JsonResponse -except ImportError: - from ..utils.http import JsonResponse - -from ..errors import * -from ..scopes import * -from ..utils.params import * -from openid_provider.models import * +from oidc_provider.lib.errors import * +from oidc_provider.lib.claims import * +from oidc_provider.lib.utils.params import * +from oidc_provider.models import * +from oidc_provider import settings 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() @@ -43,10 +39,15 @@ class UserInfoEndpoint(object): return access_token def validate_params(self): - try: self.token = Token.objects.get(access_token=self.params.access_token) + if self.token.has_expired(): + raise UserInfoError('invalid_token') + + if not ('openid' in self.token.scope): + raise UserInfoError('insufficient_scope') + except Token.DoesNotExist: raise UserInfoError('invalid_token') @@ -61,15 +62,19 @@ class UserInfoEndpoint(object): 'sub': self.token.id_token.get('sub'), } - standard_claims = StandardClaims(self.token.user, self.token.scope) - + standard_claims = StandardScopeClaims(self.token.user, self.token.scope) + dic.update(standard_claims.create_response_dic()) + extra_claims = settings.get('OIDC_EXTRA_SCOPE_CLAIMS')( + self.token.user, self.token.scope) + + dic.update(extra_claims.create_response_dic()) + return dic @classmethod - def response(self, dic): - + def response(cls, dic): response = JsonResponse(dic, status=200) response['Cache-Control'] = 'no-store' response['Pragma'] = 'no-cache' @@ -77,8 +82,7 @@ class UserInfoEndpoint(object): return response @classmethod - def error_response(self, code, description, status): - + def error_response(cls, code, description, status): response = HttpResponse(status=status) response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format(code, description) diff --git a/openid_provider/lib/errors.py b/oidc_provider/lib/errors.py similarity index 66% rename from openid_provider/lib/errors.py rename to oidc_provider/lib/errors.py index 51ba9f3..ec0131f 100644 --- a/openid_provider/lib/errors.py +++ b/oidc_provider/lib/errors.py @@ -20,39 +20,54 @@ class AuthorizeError(Exception): # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 'invalid_request': 'The request is otherwise malformed', - 'unauthorized_client': 'The client is not authorized to request an authorization code using this method', + 'unauthorized_client': 'The client is not authorized to request an ' + 'authorization code using this method', - 'access_denied': 'The resource owner or authorization server denied the request', + 'access_denied': 'The resource owner or authorization server denied ' + 'the request', - 'unsupported_response_type': 'The authorization server does not support obtaining an authorization code using ' - 'this method', + 'unsupported_response_type': 'The authorization server does not ' + 'support obtaining an authorization code ' + 'using this method', - 'invalid_scope': 'The requested scope is invalid, unknown, or malformed', + 'invalid_scope': 'The requested scope is invalid, unknown, or ' + 'malformed', 'server_error': 'The authorization server encountered an error', - 'temporarily_unavailable': 'The authorization server is currently unable to handle the request due to a ' - 'temporary overloading or maintenance of the server', + 'temporarily_unavailable': 'The authorization server is currently ' + 'unable to handle the request due to a ' + 'temporary overloading or maintenance of ' + 'the server', # OpenID errors. # http://openid.net/specs/openid-connect-core-1_0.html#AuthError - 'interaction_required': 'The Authorization Server requires End-User interaction of some form to proceed', + 'interaction_required': 'The Authorization Server requires End-User ' + 'interaction of some form to proceed', - 'login_required': 'The Authorization Server requires End-User authentication', + 'login_required': 'The Authorization Server requires End-User ' + 'authentication', - 'account_selection_required': 'The End-User is required to select a session at the Authorization Server', + 'account_selection_required': 'The End-User is required to select a ' + 'session at the Authorization Server', - 'consent_required': 'The Authorization Server requires End-User consent', + 'consent_required': 'The Authorization Server requires End-User' + 'consent', - 'invalid_request_uri': 'The request_uri in the Authorization Request returns an error or contains invalid data', + 'invalid_request_uri': 'The request_uri in the Authorization Request ' + 'returns an error or contains invalid data', - 'invalid_request_object': 'The request parameter contains an invalid Request Object', + 'invalid_request_object': 'The request parameter contains an invalid ' + 'Request Object', - 'request_not_supported': 'The provider does not support use of the request parameter', + 'request_not_supported': 'The provider does not support use of the ' + 'request parameter', - 'request_uri_not_supported': 'The provider does not support use of the request_uri parameter', + 'request_uri_not_supported': 'The provider does not support use of the ' + 'request_uri parameter', - 'registration_not_supported': 'The provider does not support use of the registration parameter', + 'registration_not_supported': 'The provider does not support use of ' + 'the registration parameter', } def __init__(self, redirect_uri, error, grant_type): @@ -66,7 +81,8 @@ class AuthorizeError(Exception): description = urllib.quote(self.description) - # See: http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError + # 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( @@ -92,18 +108,23 @@ class TokenError(Exception): # https://tools.ietf.org/html/rfc6749#section-5.2 'invalid_request': 'The request is otherwise malformed', - 'invalid_client': 'Client authentication failed (e.g., unknown client, no client authentication included, ' - 'or unsupported authentication method)', + 'invalid_client': 'Client authentication failed (e.g., unknown client, ' + 'no client authentication included, or unsupported ' + 'authentication method)', - 'invalid_grant': 'The provided authorization grant or refresh token is invalid, expired, revoked, does not ' - 'match the redirection URI used in the authorization request, or was issued to another client', + 'invalid_grant': 'The provided authorization grant or refresh token is ' + 'invalid, expired, revoked, does not match the ' + 'redirection URI used in the authorization request, ' + 'or was issued to another client', - 'unauthorized_client': 'The authenticated client is not authorized to use this authorization grant type', + 'unauthorized_client': 'The authenticated client is not authorized to ' + 'use this authorization grant type', - 'unsupported_grant_type': 'The authorization grant type is not supported by the authorization server', + 'unsupported_grant_type': 'The authorization grant type is not ' + 'supported by the authorization server', - 'invalid_scope': 'The requested scope is invalid, unknown, malformed, or exceeds the scope granted by the ' - 'resource owner', + 'invalid_scope': 'The requested scope is invalid, unknown, malformed, ' + 'or exceeds the scope granted by the resource owner', } def __init__(self, error): @@ -129,10 +150,12 @@ class UserInfoError(Exception): 'The request is otherwise malformed', 400 ), 'invalid_token': ( - 'The access token provided is expired, revoked, malformed, or invalid for other reasons', 401 + 'The access token provided is expired, revoked, malformed, ' + 'or invalid for other reasons', 401 ), 'insufficient_scope': ( - 'The request requires higher privileges than provided by the access token', 403 + 'The request requires higher privileges than provided by ' + 'the access token', 403 ), } diff --git a/openid_provider/migrations/__init__.py b/oidc_provider/lib/utils/__init__.py similarity index 100% rename from openid_provider/migrations/__init__.py rename to oidc_provider/lib/utils/__init__.py diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py new file mode 100644 index 0000000..7f0a626 --- /dev/null +++ b/oidc_provider/lib/utils/common.py @@ -0,0 +1,16 @@ +from django.core.urlresolvers import reverse + +from oidc_provider import settings + + +def get_issuer(): + """ + Construct the issuer full url. Basically is the site url with some path + appended. + """ + site_url = settings.get('SITE_URL') + path = reverse('oidc_provider:provider_info') \ + .split('/.well-known/openid-configuration/')[0] + issuer = site_url + path + + return issuer diff --git a/openid_provider/lib/utils/params.py b/oidc_provider/lib/utils/params.py similarity index 100% rename from openid_provider/lib/utils/params.py rename to oidc_provider/lib/utils/params.py diff --git a/openid_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py similarity index 54% rename from openid_provider/lib/utils/token.py rename to oidc_provider/lib/utils/token.py index c6be891..9ef7dd2 100644 --- a/openid_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -1,38 +1,43 @@ +from datetime import timedelta import time -import jwt import uuid -from datetime import timedelta - -from openid_provider import settings from django.utils import timezone -from openid_provider.models import * +import jwt + +from oidc_provider.lib.utils.common import get_issuer +from oidc_provider.models import * +from oidc_provider import settings -def create_id_token_dic(user, iss, aud): +def create_id_token(user, aud): """ - Receives a user object, iss (issuer) and aud (audience). - Then creates the id_token dic. + Receives a user object and aud (audience). + Then creates the id_token dictionary. See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken Return a dic. """ - expires_in = settings.get('DOP_IDTOKEN_EXPIRE') + sub = settings.get('OIDC_IDTOKEN_SUB_GENERATOR')( + user=user) + + expires_in = settings.get('OIDC_IDTOKEN_EXPIRE') 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()) + + user_auth_time = user.last_login or user.date_joined + auth_time = time.mktime(user_auth_time.timetuple()) dic = { - 'iss': iss, - 'sub': user.id, + 'iss': get_issuer(), + 'sub': sub, 'aud': aud, 'exp': exp_time, 'iat': iat_time, - 'auth_time': user_auth_time, + 'auth_time': auth_time, } return dic @@ -64,7 +69,24 @@ def create_token(user, client, id_token_dic, scope): token.refresh_token = uuid.uuid4().hex token.expires_at = timezone.now() + timedelta( - seconds=settings.get('DOP_TOKEN_EXPIRE')) + seconds=settings.get('OIDC_TOKEN_EXPIRE')) token.scope = scope return token + + +def create_code(user, client, scope): + """ + Create and populate a Code object. + + Return a Code object. + """ + code = Code() + code.user = user + code.client = client + code.code = uuid.uuid4().hex + code.expires_at = timezone.now() + timedelta( + seconds=settings.get('OIDC_CODE_EXPIRE')) + code.scope = scope + + return code \ No newline at end of file diff --git a/oidc_provider/migrations/0001_initial.py b/oidc_provider/migrations/0001_initial.py new file mode 100644 index 0000000..ca32b7e --- /dev/null +++ b/oidc_provider/migrations/0001_initial.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Client', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(default=b'', max_length=100)), + ('client_id', models.CharField(unique=True, max_length=255)), + ('client_secret', models.CharField(unique=True, max_length=255)), + ('response_type', models.CharField(max_length=30, choices=[(b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), (b'id_token token', b'id_token token (Implicit Flow)')])), + ('_redirect_uris', models.TextField(default=b'')), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Code', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('expires_at', models.DateTimeField()), + ('_scope', models.TextField(default=b'')), + ('code', models.CharField(unique=True, max_length=255)), + ('client', models.ForeignKey(to='oidc_provider.Client')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Token', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('expires_at', models.DateTimeField()), + ('_scope', models.TextField(default=b'')), + ('access_token', models.CharField(unique=True, max_length=255)), + ('_id_token', models.TextField()), + ('client', models.ForeignKey(to='oidc_provider.Client')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserInfo', + fields=[ + ('user', models.OneToOneField(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('given_name', models.CharField(max_length=255, null=True, blank=True)), + ('family_name', models.CharField(max_length=255, null=True, blank=True)), + ('middle_name', models.CharField(max_length=255, null=True, blank=True)), + ('nickname', models.CharField(max_length=255, null=True, blank=True)), + ('gender', models.CharField(max_length=100, null=True, choices=[(b'F', b'Female'), (b'M', b'Male')])), + ('birthdate', models.DateField(null=True)), + ('zoneinfo', models.CharField(default=b'', max_length=100, null=True, blank=True)), + ('preferred_username', models.CharField(max_length=255, null=True, blank=True)), + ('profile', models.URLField(default=b'', null=True, blank=True)), + ('picture', models.URLField(default=b'', null=True, blank=True)), + ('website', models.URLField(default=b'', null=True, blank=True)), + ('email_verified', models.NullBooleanField(default=False)), + ('locale', models.CharField(max_length=100, null=True, blank=True)), + ('phone_number', models.CharField(max_length=255, null=True, blank=True)), + ('phone_number_verified', models.NullBooleanField(default=False)), + ('address_street_address', models.CharField(max_length=255, null=True, blank=True)), + ('address_locality', models.CharField(max_length=255, null=True, blank=True)), + ('address_region', models.CharField(max_length=255, null=True, blank=True)), + ('address_postal_code', models.CharField(max_length=255, null=True, blank=True)), + ('address_country', models.CharField(max_length=255, null=True, blank=True)), + ('updated_at', models.DateTimeField(auto_now=True, null=True)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.AddField( + model_name='token', + name='user', + field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + preserve_default=True, + ), + migrations.AddField( + model_name='code', + name='user', + field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + preserve_default=True, + ), + ] diff --git a/oidc_provider/migrations/__init__.py b/oidc_provider/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/oidc_provider/models.py b/oidc_provider/models.py new file mode 100644 index 0000000..e467b47 --- /dev/null +++ b/oidc_provider/models.py @@ -0,0 +1,143 @@ +import json + +from django.db import models +from django.utils import timezone +from django.contrib.auth.models import User + + +class Client(models.Model): + + RESPONSE_TYPE_CHOICES = [ + ('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='') + client_id = models.CharField(max_length=255, unique=True) + client_secret = models.CharField(max_length=255, unique=True) + response_type = models.CharField(max_length=30, + choices=RESPONSE_TYPE_CHOICES) + + _redirect_uris = models.TextField(default='') + + def __str__(self): + return self.name + + def __unicode__(self): + return self.__str__() + + def redirect_uris(): + def fget(self): + return self._redirect_uris.splitlines() + def fset(self, value): + self._redirect_uris = '\n'.join(value) + return locals() + redirect_uris = property(**redirect_uris()) + + @property + def default_redirect_uri(self): + return self.redirect_uris[0] if self.redirect_uris else '' + + +class BaseCodeTokenModel(models.Model): + + user = models.ForeignKey(User) + client = models.ForeignKey(Client) + expires_at = models.DateTimeField() + _scope = models.TextField(default='') + + def scope(): + def fget(self): + return self._scope.split() + def fset(self, value): + self._scope = ' '.join(value) + return locals() + scope = property(**scope()) + + def has_expired(self): + return timezone.now() >= self.expires_at + + def __str__(self): + return "%s - %s (%s)" % (self.client, self.user, self.expires_at) + + def __unicode__(self): + return self.__str__() + + class Meta: + abstract = True + + +class Code(BaseCodeTokenModel): + + code = models.CharField(max_length=255, unique=True) + + +class Token(BaseCodeTokenModel): + + access_token = models.CharField(max_length=255, unique=True) + _id_token = models.TextField() + def id_token(): + def fget(self): + return json.loads(self._id_token) + def fset(self, value): + self._id_token = json.dumps(value) + return locals() + id_token = property(**id_token()) + + +class UserInfo(models.Model): + + GENDER_CHOICES = [ + ('F', 'Female'), + ('M', 'Male'), + ] + + user = models.OneToOneField(User, primary_key=True) + given_name = models.CharField(max_length=255, blank=True, null=True) + family_name = models.CharField(max_length=255, blank=True, null=True) + middle_name = models.CharField(max_length=255, blank=True, null=True) + nickname = models.CharField(max_length=255, blank=True, null=True) + gender = models.CharField(max_length=100, choices=GENDER_CHOICES, null=True) + birthdate = models.DateField(null=True) + zoneinfo = models.CharField(max_length=100, default='', blank=True, + null=True) + locale = models.CharField(max_length=100, default='', blank=True, null=True) + preferred_username = models.CharField(max_length=255, blank=True, null=True) + profile = models.URLField(default='', null=True, blank=True) + picture = models.URLField(default='', null=True, blank=True) + website = models.URLField(default='', null=True, blank=True) + email_verified = models.NullBooleanField(default=False) + locale = models.CharField(max_length=100, blank=True, null=True) + phone_number = models.CharField(max_length=255, blank=True, null=True) + phone_number_verified = models.NullBooleanField(default=False) + address_street_address = models.CharField(max_length=255, blank=True, + null=True) + address_locality = models.CharField(max_length=255, blank=True, null=True) + address_region = models.CharField(max_length=255, blank=True, null=True) + address_postal_code = models.CharField(max_length=255, blank=True, + null=True) + address_country = models.CharField(max_length=255, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True, null=True) + + @property + def name(self): + name = '' + if self.given_name: + name = self.given_name + if self.family_name: + name = name + ' ' + self.family_name + + return name + + @property + def address_formatted(self): + formatted = ', '.join([ + self.address_street_address, + self.address_locality, + self.address_country]) + + if formatted.startswith(', '): + formatted = formatted[2:] + if formatted.endswith(', '): + formatted = formatted[:-2] diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py new file mode 100644 index 0000000..26288b8 --- /dev/null +++ b/oidc_provider/settings.py @@ -0,0 +1,83 @@ +from django.conf import settings + + +class DefaultSettings(object): + + @property + def LOGIN_URL(self): + """ + REQUIRED. + """ + return None + + @property + def SITE_URL(self): + """ + REQUIRED. + """ + return None + + @property + def OIDC_AFTER_USERLOGIN_HOOK(self): + """ + OPTIONAL. + """ + def default_hook_func(request, user, client): + return None + + return default_hook_func + + @property + def OIDC_CODE_EXPIRE(self): + """ + OPTIONAL. + """ + return 60*10 + + @property + def OIDC_EXTRA_SCOPE_CLAIMS(self): + """ + OPTIONAL. + """ + from oidc_provider.lib.claims import AbstractScopeClaims + + return AbstractScopeClaims + + @property + def OIDC_IDTOKEN_EXPIRE(self): + """ + OPTIONAL. + """ + return 60*10 + + @property + def OIDC_IDTOKEN_SUB_GENERATOR(self): + """ + OPTIONAL. + """ + def default_sub_generator(user): + return user.id + + return default_sub_generator + + @property + def OIDC_TOKEN_EXPIRE(self): + """ + OPTIONAL. + """ + return 60*60 + +default_settings = DefaultSettings() + +def get(name): + ''' + Helper function to use inside the package. + ''' + try: + value = getattr(default_settings, name) + value = getattr(settings, name) + except AttributeError: + if value == None: + raise Exception('You must set ' + name + ' in your settings.') + + return value \ No newline at end of file diff --git a/oidc_provider/templates/oidc_provider/authorize.html b/oidc_provider/templates/oidc_provider/authorize.html new file mode 100644 index 0000000..95591b4 --- /dev/null +++ b/oidc_provider/templates/oidc_provider/authorize.html @@ -0,0 +1,20 @@ +

Request for Permission

+ +

Client {{ client.name }} would like to access this information of you ...

+ +
+ + {% csrf_token %} + + {{ hidden_inputs }} + + + + + + +
diff --git a/openid_provider/templates/openid_provider/error.html b/oidc_provider/templates/oidc_provider/error.html similarity index 100% rename from openid_provider/templates/openid_provider/error.html rename to oidc_provider/templates/oidc_provider/error.html diff --git a/oidc_provider/templates/oidc_provider/hidden_inputs.html b/oidc_provider/templates/oidc_provider/hidden_inputs.html new file mode 100644 index 0000000..286a188 --- /dev/null +++ b/oidc_provider/templates/oidc_provider/hidden_inputs.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/oidc_provider/tests/__init__.py b/oidc_provider/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/test_authorize_endpoint.py new file mode 100644 index 0000000..6b8619b --- /dev/null +++ b/oidc_provider/tests/test_authorize_endpoint.py @@ -0,0 +1,241 @@ +import urllib +import uuid + +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.models import AnonymousUser +from django.core.urlresolvers import reverse +from django.test import RequestFactory +from django.test import TestCase + +from oidc_provider import settings +from oidc_provider.models import * +from oidc_provider.tests.utils import * +from oidc_provider.views import * + + +class AuthorizationCodeFlowTestCase(TestCase): + """ + Test cases for Authorize Endpoint using Authorization Code Flow. + """ + + def setUp(self): + self.factory = RequestFactory() + self.user = create_fake_user() + self.client = create_fake_client(response_type='code') + self.state = uuid.uuid4().hex + + def test_missing_parameters(self): + """ + If the request fails due to a missing, invalid, or mismatching + redirection URI, or if the client identifier is missing or invalid, + the authorization server SHOULD inform the resource owner of the error. + + See: https://tools.ietf.org/html/rfc6749#section-4.1.2.1 + """ + url = reverse('oidc_provider:authorize') + + request = self.factory.get(url) + + response = AuthorizeView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(bool(response.content), True) + + def test_invalid_response_type(self): + """ + The OP informs the RP by using the Error Response parameters defined + in Section 4.1.2.1 of OAuth 2.0. + + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthError + """ + # Create an authorize request with an unsupported response_type. + query_str = urllib.urlencode({ + 'client_id': self.client.client_id, + 'response_type': 'something_wrong', + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + }).replace('+', '%20') + + url = reverse('oidc_provider:authorize') + '?' + query_str + + request = self.factory.get(url) + + response = AuthorizeView.as_view()(request) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.has_header('Location'), True) + + # Should be an 'error' component in query. + query_exists = 'error=' in response['Location'] + self.assertEqual(query_exists, True) + + def test_user_not_logged(self): + """ + The Authorization Server attempts to Authenticate the End-User by + redirecting to the login view. + + See: http://openid.net/specs/openid-connect-core-1_0.html#Authenticates + """ + query_str = urllib.urlencode({ + 'client_id': self.client.client_id, + 'response_type': 'code', + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + }).replace('+', '%20') + + url = reverse('oidc_provider:authorize') + '?' + query_str + + request = self.factory.get(url) + request.user = AnonymousUser() + + response = AuthorizeView.as_view()(request) + + # Check if user was redirected to the login view. + login_url_exists = settings.get('LOGIN_URL') in response['Location'] + self.assertEqual(login_url_exists, True) + + # Check if the login will redirect to a valid url. + try: + next_value = response['Location'].split(REDIRECT_FIELD_NAME + '=')[1] + next_url = urllib.unquote(next_value) + is_next_ok = next_url == url + except: + is_next_ok = False + self.assertEqual(is_next_ok, True) + + def test_user_consent_inputs(self): + """ + Once the End-User is authenticated, the Authorization Server MUST + obtain an authorization decision before releasing information to + the Client. + + See: http://openid.net/specs/openid-connect-core-1_0.html#Consent + """ + query_str = urllib.urlencode({ + 'client_id': self.client.client_id, + 'response_type': 'code', + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + }).replace('+', '%20') + + url = reverse('oidc_provider:authorize') + '?' + query_str + + request = self.factory.get(url) + # Simulate that the user is logged. + request.user = self.user + + # Remove the hook, because we want to test default behaviour. + OIDC_AFTER_USERLOGIN_HOOK = settings.default_settings.OIDC_AFTER_USERLOGIN_HOOK + with self.settings( + OIDC_AFTER_USERLOGIN_HOOK=OIDC_AFTER_USERLOGIN_HOOK): + response = AuthorizeView.as_view()(request) + + # Check if hidden inputs exists in the form, + # also if their values are valid. + input_html = '' + + to_check = { + 'client_id': self.client.client_id, + 'redirect_uri': self.client.default_redirect_uri, + 'response_type': 'code', + } + + for key, value in to_check.iteritems(): + is_input_ok = input_html.format(key, value) in response.content + self.assertEqual(is_input_ok, True, + msg='Hidden input for "'+key+'" fails.') + + def test_user_consent_response(self): + """ + First, + if the user denied the consent we must ensure that + the error response parameters are added to the query component + of the Redirection URI. + + Second, + if the user allow the RP then the server MUST return + the parameters defined in Section 4.1.2 of OAuth 2.0 [RFC6749] + by adding them as query parameters to the redirect_uri. + """ + response_type = 'code' + + url = reverse('oidc_provider:authorize') + + post_data = { + 'client_id': self.client.client_id, + 'redirect_uri': self.client.default_redirect_uri, + 'response_type': response_type, + 'scope': 'openid email', + 'state': self.state, + } + + request = self.factory.post(url, data=post_data) + # Simulate that the user is logged. + request.user = self.user + + response = AuthorizeView.as_view()(request) + + # Because user doesn't allow app, SHOULD exists an error parameter + # in the query. + self.assertEqual('error=' in response['Location'], True, + msg='error param is missing in query.') + self.assertEqual('access_denied' in response['Location'], True, + msg='"access_denied" code is missing in query.') + + # Simulate user authorization. + post_data['allow'] = 'Accept' # Should be the value of the button. + + request = self.factory.post(url, data=post_data) + # Simulate that the user is logged. + request.user = self.user + + response = AuthorizeView.as_view()(request) + + # Validate the code returned by the OP. + code = (response['Location'].split('code='))[1].split('&')[0] + try: + code = Code.objects.get(code=code) + is_code_ok = (code.client == self.client) and \ + (code.user == self.user) + except: + is_code_ok = False + self.assertEqual(is_code_ok, True, + msg='Code returned is invalid.') + + # Check if the state is returned. + state = (response['Location'].split('state='))[1].split('&')[0] + self.assertEqual(state == self.state, True, + msg='State change or is missing.') + + +class AuthorizationImplicitFlowTestCase(TestCase): + """ + Test cases for Authorize Endpoint using Implicit Flow. + """ + + def setUp(self): + self.factory = RequestFactory() + self.user = create_fake_user() + self.client = create_fake_client(response_type='id_token token') + self.state = uuid.uuid4().hex + + # TODO + def test_something(self): + query_str = urllib.urlencode({ + 'client_id': self.client.client_id, + 'response_type': 'id_token token', + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + }).replace('+', '%20') + + url = reverse('oidc_provider:authorize') + '#' + query_str + + request = self.factory.get(url) + # Simulate that the user is logged. + request.user = self.user + + response = AuthorizeView.as_view()(request) diff --git a/oidc_provider/tests/test_provider_info_endpoint.py b/oidc_provider/tests/test_provider_info_endpoint.py new file mode 100644 index 0000000..1b205bc --- /dev/null +++ b/oidc_provider/tests/test_provider_info_endpoint.py @@ -0,0 +1,26 @@ +from django.core.urlresolvers import reverse +from django.test import RequestFactory +from django.test import TestCase + +from oidc_provider.views import * + + +class ProviderInfoTestCase(TestCase): + + def setUp(self): + self.factory = RequestFactory() + + def test_response(self): + """ + See if the endpoint is returning the corresponding + server information by checking status, content type, etc. + """ + url = reverse('oidc_provider:provider_info') + + request = self.factory.get(url) + + response = ProviderInfoView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'] == 'application/json', True) + self.assertEqual(bool(response.content), True) \ No newline at end of file diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/test_token_endpoint.py new file mode 100644 index 0000000..4b30091 --- /dev/null +++ b/oidc_provider/tests/test_token_endpoint.py @@ -0,0 +1,122 @@ +import json +from urllib import urlencode +import uuid + +from django.core.urlresolvers import reverse +from django.test import RequestFactory +from django.test import TestCase + +from oidc_provider.lib.utils.token import * +from oidc_provider.tests.utils import * +from oidc_provider.views import * + + +class TokenTestCase(TestCase): + """ + To obtain an Access Token and an ID Token, the RP Client sends a + Token Request to the Token Endpoint to obtain a Token Response + when using the Authorization Code Flow. + """ + + def setUp(self): + self.factory = RequestFactory() + self.user = create_fake_user() + self.client = create_fake_client(response_type='code') + self.state = uuid.uuid4().hex + + def _post_request(self, post_data): + """ + Makes a request to the token endpoint by sending the + `post_data` parameters using the 'application/x-www-form-urlencoded' + format. + """ + url = reverse('oidc_provider:token') + + request = self.factory.post(url, + data=urlencode(post_data), + content_type='application/x-www-form-urlencoded') + + response = TokenView.as_view()(request) + + return response + + def _create_code(self): + """ + Generate a valid grant code. + """ + code = create_code( + user=self.user, + client=self.client, + scope=['openid', 'email']) + code.save() + + return code + + def test_request_methods(self): + """ + Client sends an HTTP POST request to the Token Endpoint. Other request + methods MUST NOT be allowed. + """ + url = reverse('oidc_provider:token') + + requests = [ + self.factory.get(url), + self.factory.put(url), + self.factory.delete(url), + ] + + for request in requests: + response = TokenView.as_view()(request) + + self.assertEqual(response.status_code == 405, True, + msg=request.method+' request does not return a 405 status.') + + request = self.factory.post(url) + + response = TokenView.as_view()(request) + + self.assertEqual(response.status_code == 400, True, + msg=request.method+' request does not return a 400 status.') + + def test_client_authentication(self): + """ + The authorization server support including the + client credentials in the request-body using the `client_id` and + `client_secret`parameters. + + See: http://tools.ietf.org/html/rfc6749#section-2.3.1 + """ + code = self._create_code() + + # Test a valid request to the token endpoint. + post_data = { + 'client_id': self.client.client_id, + 'client_secret': self.client.client_secret, + 'redirect_uri': self.client.default_redirect_uri, + 'grant_type': 'authorization_code', + 'code': code.code, + 'state': self.state, + } + response = self._post_request(post_data) + response_dic = json.loads(response.content) + + self.assertEqual('access_token' in response_dic, True, + msg='"access_token" key is missing in response.') + self.assertEqual('error' in response_dic, False, + msg='"error" key should not exists in response.') + + # Now, test with an invalid client_id. + invalid_data = post_data.copy() + invalid_data['client_id'] = self.client.client_id * 2 # Fake id. + + # Create another grant code. + code = self._create_code() + invalid_data['code'] = code.code + + response = self._post_request(invalid_data) + response_dic = json.loads(response.content) + + self.assertEqual('error' in response_dic, True, + msg='"error" key should exists in response.') + self.assertEqual(response_dic.get('error') == 'invalid_client', True, + msg='"error" key value should be "invalid_client".') diff --git a/oidc_provider/tests/test_userinfo_endpoint.py b/oidc_provider/tests/test_userinfo_endpoint.py new file mode 100644 index 0000000..9fb92a3 --- /dev/null +++ b/oidc_provider/tests/test_userinfo_endpoint.py @@ -0,0 +1,94 @@ +from datetime import timedelta + +from django.core.urlresolvers import reverse +from django.test import RequestFactory +from django.test import TestCase +from django.utils import timezone + +from oidc_provider.lib.utils.token import * +from oidc_provider.models import * +from oidc_provider.tests.utils import * +from oidc_provider.views import userinfo + + +class UserInfoTestCase(TestCase): + + def setUp(self): + self.factory = RequestFactory() + self.user = create_fake_user() + self.client = create_fake_client(response_type='code') + + def _create_token(self): + """ + Generate a valid token. + """ + id_token_dic = create_id_token(self.user, self.client.client_id) + + token = create_token( + user=self.user, + client=self.client, + id_token_dic=id_token_dic, + scope=['openid', 'email']) + token.save() + + return token + + def _post_request(self, access_token): + """ + Makes a request to the userinfo endpoint by sending the + `post_data` parameters using the 'multipart/form-data' + format. + """ + url = reverse('oidc_provider:userinfo') + + request = self.factory.post(url, + data={}, + content_type='multipart/form-data') + + request.META['HTTP_AUTHORIZATION'] = 'Bearer ' + access_token + + response = userinfo(request) + + return response + + def test_response_with_valid_token(self): + token = self._create_token() + + # Test a valid request to the userinfo endpoint. + response = self._post_request(token.access_token) + + self.assertEqual(response.status_code, 200) + self.assertEqual(bool(response.content), True) + + def test_response_with_expired_token(self): + token = self._create_token() + + # Make token expired. + token.expires_at = timezone.now() - timedelta(hours=1) + token.save() + + response = self._post_request(token.access_token) + + self.assertEqual(response.status_code, 401) + + try: + is_header_field_ok = 'invalid_token' in response['WWW-Authenticate'] + except KeyError: + is_header_field_ok = False + self.assertEqual(is_header_field_ok, True) + + def test_response_with_invalid_scope(self): + token = self._create_token() + + token.scope = ['otherone'] + token.save() + + response = self._post_request(token.access_token) + + self.assertEqual(response.status_code, 403) + + try: + is_header_field_ok = 'insufficient_scope' in response['WWW-Authenticate'] + except KeyError: + is_header_field_ok = False + self.assertEqual(is_header_field_ok, True) \ No newline at end of file diff --git a/oidc_provider/tests/utils.py b/oidc_provider/tests/utils.py new file mode 100644 index 0000000..1d61339 --- /dev/null +++ b/oidc_provider/tests/utils.py @@ -0,0 +1,36 @@ +from django.contrib.auth.models import User +from oidc_provider.models import * + + +def create_fake_user(): + """ + Create a test user. + + Return a User object. + """ + user = User() + user.username = 'johndoe' + user.email = 'johndoe@example.com' + user.set_password('1234') + + user.save() + + return user + +def create_fake_client(response_type): + """ + Create a test client, response_type argument MUST be: + 'code', 'id_token' or 'id_token token'. + + Return a Client object. + """ + client = Client() + client.name = 'Some Client' + client.client_id = '123' + client.client_secret = '456' + client.response_type = response_type + client.redirect_uris = ['http://example.com/'] + + client.save() + + return client diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py new file mode 100644 index 0000000..1667b48 --- /dev/null +++ b/oidc_provider/urls.py @@ -0,0 +1,14 @@ +from django.conf.urls import patterns, include, url +from django.views.decorators.csrf import csrf_exempt +from oidc_provider.views import * + + +urlpatterns = patterns('', + + url(r'^authorize/$', AuthorizeView.as_view(), name='authorize'), + url(r'^token/$', csrf_exempt(TokenView.as_view()), name='token'), + url(r'^userinfo/$', csrf_exempt(userinfo), name='userinfo'), + + url(r'^\.well-known/openid-configuration/$', ProviderInfoView.as_view(), name='provider_info'), + +) \ No newline at end of file diff --git a/openid_provider/views.py b/oidc_provider/views.py similarity index 57% rename from openid_provider/views.py rename to oidc_provider/views.py index 03b29ad..4850e30 100644 --- a/openid_provider/views.py +++ b/oidc_provider/views.py @@ -1,21 +1,15 @@ -import urllib - -from django.conf import settings -from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.views import redirect_to_login -from django.core.urlresolvers import reverse -from django.http import HttpResponse, HttpResponseRedirect - +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import render +from django.template.loader import render_to_string from django.views.decorators.http import require_http_methods from django.views.generic import View -from .lib.errors import * -from .lib.endpoints.authorize import * -from .lib.endpoints.token import * -from .lib.endpoints.userinfo import * - -from openid_provider import settings +from oidc_provider.lib.endpoints.authorize import * +from oidc_provider.lib.endpoints.discovery import * +from oidc_provider.lib.endpoints.token import * +from oidc_provider.lib.endpoints.userinfo import * +from oidc_provider.lib.errors import * class AuthorizeView(View): @@ -28,20 +22,34 @@ class AuthorizeView(View): authorize.validate_params() if request.user.is_authenticated(): + # Check if there's a hook setted. + hook_resp = settings.get('OIDC_AFTER_USERLOGIN_HOOK')( + request=request, user=request.user, + client=authorize.client) + if hook_resp: + return hook_resp - # This is for printing scopes in the form. - authorize.params.scope_str = ' '.join(authorize.params.scope) - + # Generate hidden inputs for the form. context = { 'params': authorize.params, + } + hidden_inputs = render_to_string( + 'oidc_provider/hidden_inputs.html', context) + + # Remove `openid` from scope list + # since we don't need to print it. + authorize.params.scope.remove('openid') + + context = { 'client': authorize.client, + 'hidden_inputs': hidden_inputs, + 'params': authorize.params, } - return render(request, 'openid_provider/authorize.html', context) + return render(request, 'oidc_provider/authorize.html', context) else: path = request.get_full_path() - return redirect_to_login( - path, settings.get('LOGIN_URL'), REDIRECT_FIELD_NAME) + return redirect_to_login(path) except (ClientIdError, RedirectUriError) as error: context = { @@ -49,7 +57,7 @@ class AuthorizeView(View): 'description': error.description, } - return render(request, 'openid_provider/error.html', context) + return render(request, 'oidc_provider/error.html', context) except (AuthorizeError) as error: uri = error.create_uri( @@ -70,7 +78,9 @@ class AuthorizeView(View): return HttpResponseRedirect(uri) except (AuthorizeError) as error: - uri = error.create_uri(authorize.params.redirect_uri, authorize.params.state) + uri = error.create_uri( + authorize.params.redirect_uri, + authorize.params.state) return HttpResponseRedirect(uri) @@ -91,6 +101,7 @@ class TokenView(View): except (TokenError) as error: return TokenEndpoint.response(error.create_dict(), status=400) + @require_http_methods(['GET', 'POST']) def userinfo(request): @@ -108,3 +119,12 @@ def userinfo(request): error.code, error.description, error.status) + + +class ProviderInfoView(View): + + def get(self, request, *args, **kwargs): + + dic = ProviderInfoEndpoint.create_response_dic() + + return JsonResponse(dic) \ No newline at end of file diff --git a/openid_provider/lib/scopes.py b/openid_provider/lib/scopes.py deleted file mode 100644 index 530d0b7..0000000 --- a/openid_provider/lib/scopes.py +++ /dev/null @@ -1,116 +0,0 @@ -from django.utils.translation import ugettext as _ - -from openid_provider.models import UserInfo - - -# Standard Claims -# http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - -class StandardClaims(object): - - __model__ = UserInfo - - def __init__(self, user, scopes): - self.user = user - self.scopes = scopes - - try: - self.model = self.__model__.objects.get(user=self.user) - except self.__model__.DoesNotExist: - self.model = self.__model__() - - def create_response_dic(self): - - dic = {} - - for scope in self.scopes: - - if scope in self._scopes_registered(): - dic.update(getattr(self, 'scope_' + scope)) - - dic = self._clean_dic(dic) - - return dic - - def _scopes_registered(self): - """ - Return a list that contains all the scopes registered - in the class. - """ - scopes = [] - - for name in self.__class__.__dict__: - - if name.startswith('scope_'): - scope = name.split('scope_')[1] - scopes.append(scope) - - return scopes - - def _clean_dic(self, dic): - """ - Clean recursively all empty or None values inside a dict. - """ - aux_dic = dic.copy() - for key, value in dic.iteritems(): - - if not value: - del aux_dic[key] - elif type(value) is dict: - aux_dic[key] = clean_dic(value) - - return aux_dic - - @property - def scope_profile(self): - dic = { - 'name': self.model.name, - 'given_name': self.model.given_name, - 'family_name': self.model.family_name, - 'middle_name': self.model.middle_name, - 'nickname': self.model.nickname, - 'preferred_username': self.model.preferred_username, - 'profile': self.model.profile, - 'picture': self.model.picture, - 'website': self.model.website, - 'gender': self.model.gender, - 'birthdate': self.model.birthdate, - 'zoneinfo': self.model.zoneinfo, - 'locale': self.model.locale, - 'updated_at': self.model.updated_at, - } - - return dic - - @property - def scope_email(self): - dic = { - 'email': self.user.email, - 'email_verified': self.model.email_verified, - } - - return dic - - @property - def scope_phone(self): - dic = { - 'phone_number': self.model.phone_number, - 'phone_number_verified': self.model.phone_number_verified, - } - - return dic - - @property - def scope_address(self): - dic = { - 'address': { - 'formatted': self.model.address_formatted, - 'street_address': self.model.address_street_address, - 'locality': self.model.address_locality, - 'region': self.model.address_region, - 'postal_code': self.model.address_postal_code, - 'country': self.model.address_country, - } - } - - return dic diff --git a/openid_provider/models.py b/openid_provider/models.py deleted file mode 100644 index ff0070c..0000000 --- a/openid_provider/models.py +++ /dev/null @@ -1,120 +0,0 @@ -import json - -from django.db import models -from django.utils import timezone -from django.contrib.auth.models import User - - -class Client(models.Model): - - RESPONSE_TYPE_CHOICES = [ - ('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='') - client_id = models.CharField(max_length=255, unique=True) - client_secret = models.CharField(max_length=255, unique=True) - response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES) - - _redirect_uris = models.TextField(default='') - - def redirect_uris(): - def fget(self): - return self._redirect_uris.splitlines() - def fset(self, value): - self._redirect_uris = '\n'.join(value) - return locals() - redirect_uris = property(**redirect_uris()) - - @property - def default_redirect_uri(self): - return self.redirect_uris[0] if self.redirect_uris else '' - - -class Code(models.Model): - - user = models.ForeignKey(User) - client = models.ForeignKey(Client) - code = models.CharField(max_length=255, unique=True) - expires_at = models.DateTimeField() - - _scope = models.TextField(default='') - - def scope(): - def fget(self): - return self._scope.split() - def fset(self, value): - self._scope = ' '.join(value) - return locals() - scope = property(**scope()) - - def has_expired(self): - return timezone.now() >= self.expires_at - - -class Token(models.Model): - - user = models.ForeignKey(User) - client = models.ForeignKey(Client) - access_token = models.CharField(max_length=255, unique=True) - expires_at = models.DateTimeField() - - _scope = models.TextField(default='') - - def scope(): - def fget(self): - return self._scope.split() - def fset(self, value): - self._scope = ' '.join(value) - return locals() - scope = property(**scope()) - - _id_token = models.TextField() - - def id_token(): - def fget(self): - return json.loads(self._id_token) - def fset(self, value): - self._id_token = json.dumps(value) - return locals() - id_token = property(**id_token()) - - -class UserInfo(models.Model): - - user = models.OneToOneField(User, primary_key=True) - - given_name = models.CharField(max_length=255, default='') - family_name = models.CharField(max_length=255, default='') - middle_name = models.CharField(max_length=255, default='') - nickname = models.CharField(max_length=255, default='') - preferred_username = models.CharField(max_length=255, default='') - profile = models.URLField(default='') - picture = models.URLField(default='') - website = models.URLField(default='') - email_verified = models.BooleanField(default=False) - gender = models.CharField(max_length=100, default='') - birthdate = models.DateField() - zoneinfo = models.CharField(max_length=100, default='') - locale = models.CharField(max_length=100, default='') - phone_number = models.CharField(max_length=255, default='') - phone_number_verified = models.BooleanField(default=False) - address_formatted = models.CharField(max_length=255, default='') - address_street_address = models.CharField(max_length=255, default='') - address_locality = models.CharField(max_length=255, default='') - address_region = models.CharField(max_length=255, default='') - address_postal_code = models.CharField(max_length=255, default='') - address_country = models.CharField(max_length=255, default='') - updated_at = models.DateTimeField() - - @property - def name(self): - name = '' - if self.given_name: - name = self.given_name - if self.family_name: - name = name + ' ' + self.family_name - - return name \ No newline at end of file diff --git a/openid_provider/settings.py b/openid_provider/settings.py index 3662d10..26288b8 100644 --- a/openid_provider/settings.py +++ b/openid_provider/settings.py @@ -1,27 +1,83 @@ from django.conf import settings -# Here goes all the package default settings. -default_settings = { - 'DOP_CODE_EXPIRE': 60 * 10, # 10 min. - 'DOP_IDTOKEN_EXPIRE': 60 * 10, # 10 min. - 'DOP_TOKEN_EXPIRE': 60 * 60, # 1 hour. - 'LOGIN_URL': None, - 'SITE_URL': None, -} +class DefaultSettings(object): + @property + def LOGIN_URL(self): + """ + REQUIRED. + """ + return None + + @property + def SITE_URL(self): + """ + REQUIRED. + """ + return None + + @property + def OIDC_AFTER_USERLOGIN_HOOK(self): + """ + OPTIONAL. + """ + def default_hook_func(request, user, client): + return None + + return default_hook_func + + @property + def OIDC_CODE_EXPIRE(self): + """ + OPTIONAL. + """ + return 60*10 + + @property + def OIDC_EXTRA_SCOPE_CLAIMS(self): + """ + OPTIONAL. + """ + from oidc_provider.lib.claims import AbstractScopeClaims + + return AbstractScopeClaims + + @property + def OIDC_IDTOKEN_EXPIRE(self): + """ + OPTIONAL. + """ + return 60*10 + + @property + def OIDC_IDTOKEN_SUB_GENERATOR(self): + """ + OPTIONAL. + """ + def default_sub_generator(user): + return user.id + + return default_sub_generator + + @property + def OIDC_TOKEN_EXPIRE(self): + """ + OPTIONAL. + """ + return 60*60 + +default_settings = DefaultSettings() def get(name): - """ + ''' Helper function to use inside the package. - :param name: - :return: - """ + ''' try: - value = default_settings[name] + value = getattr(default_settings, name) value = getattr(settings, name) except AttributeError: if value == None: raise Exception('You must set ' + name + ' in your settings.') - return value + return value \ No newline at end of file diff --git a/openid_provider/templates/openid_provider/authorize.html b/openid_provider/templates/openid_provider/authorize.html deleted file mode 100644 index d404d10..0000000 --- a/openid_provider/templates/openid_provider/authorize.html +++ /dev/null @@ -1,28 +0,0 @@ -

Request for Permission

- -

Client {{ client.name }} would like to access this information of you ...

- -
- - {% csrf_token %} - - - - - - - - - - - - -
- -{% endblock %} \ No newline at end of file diff --git a/openid_provider/urls.py b/openid_provider/urls.py deleted file mode 100644 index 3b0183f..0000000 --- a/openid_provider/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.conf.urls import patterns, include, url -from django.views.decorators.csrf import csrf_exempt - -from openid_provider.views import * - - -urlpatterns = patterns('', - url(r'^authorize/$', AuthorizeView.as_view(), name='authorize'), - url(r'^token/$', csrf_exempt(TokenView.as_view()), name='token'), - url(r'^userinfo/$', csrf_exempt(userinfo), name='userinfo'), - -) \ No newline at end of file diff --git a/setup.py b/setup.py index a6843c6..142995c 100644 --- a/setup.py +++ b/setup.py @@ -8,14 +8,17 @@ with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( - name='django-openid-provider', - version='0.1', - packages=['openid_provider'], + name='django-oidc-provider', + version='0.0.5', + packages=[ + 'oidc_provider', 'oidc_provider/lib', 'oidc_provider/lib/endpoints', + 'oidc_provider/lib/utils', 'oidc_provider/tests', 'oidc_provider/migrations', + ], include_package_data=True, license='MIT License', - description='A simple OpenID Connect Provider implementation for Djangonauts.', + description='OpenID Connect Provider implementation for Django.', long_description=README, - url='http://github.com/juanifioren/django-openid-provider', + url='http://github.com/juanifioren/django-oidc-provider', author='Juan Ignacio Fiorentino', author_email='juanifioren@gmail.com', classifiers=[ @@ -31,6 +34,6 @@ setup( 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', ], install_requires=[ - 'pyjwt==0.3.1', + 'pyjwt==1.1.0', ], -) \ No newline at end of file +)