From fcb50b739d3cb160a69ae063e161b35331fcacba Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Thu, 6 Jul 2023 13:02:58 -0400 Subject: [PATCH 01/15] update repo references to this one [skamansam/django-keycloak] --- README.rst | 10 +++++----- docs/index.rst | 4 ++-- example/resource-provider-api/requirements.txt | 2 +- example/resource-provider/requirements.txt | 2 +- setup.py | 2 +- sonar-project.properties | 6 +++--- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index c146288..dadedf6 100644 --- a/README.rst +++ b/README.rst @@ -2,17 +2,17 @@ Django Keycloak =============== -.. image:: https://www.travis-ci.org/Peter-Slump/django-keycloak.svg?branch=master - :target: https://www.travis-ci.org/Peter-Slump/django-keycloak +.. image:: https://www.travis-ci.org/skamansam/django-keycloak.svg?branch=master + :target: https://www.travis-ci.org/skamansam/django-keycloak :alt: Build Status .. image:: https://readthedocs.org/projects/django-keycloak/badge/?version=latest :target: http://django-keycloak.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -.. image:: https://codecov.io/gh/Peter-Slump/django-keycloak/branch/master/graph/badge.svg - :target: https://codecov.io/gh/Peter-Slump/django-keycloak +.. image:: https://codecov.io/gh/skamansam/django-keycloak/branch/master/graph/badge.svg + :target: https://codecov.io/gh/skamansam/django-keycloak :alt: codecov .. image:: https://api.codeclimate.com/v1/badges/eb19f47dc03dec40cea7/maintainability - :target: https://codeclimate.com/github/Peter-Slump/django-keycloak/maintainability + :target: https://codeclimate.com/github/skamansam/django-keycloak/maintainability :alt: Maintainability Django app to add Keycloak support to your project. diff --git a/docs/index.rst b/docs/index.rst index 87d2935..b234287 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,7 +41,7 @@ Install requirement. .. code-block:: bash - $ pip install git+https://github.com/Peter-Slump/django-keycloak.git + $ pip install git+https://github.com/skamansam/django-keycloak.git Setup ===== @@ -83,7 +83,7 @@ add the middleware, configure the urls and point to the correct login page. urlpatterns = [ ... - url(r'^keycloak/', include('django_keycloak.urls')), + path('keycloak/', include('django_keycloak.urls')), ] diff --git a/example/resource-provider-api/requirements.txt b/example/resource-provider-api/requirements.txt index 0425ac4..ae63bb7 100644 --- a/example/resource-provider-api/requirements.txt +++ b/example/resource-provider-api/requirements.txt @@ -1,3 +1,3 @@ django>=2.0.10 -git+git://github.com/Peter-Slump/django-keycloak.git#egg=django-keycloak +git+git://github.com/skamansam/django-keycloak.git#egg=django-keycloak django-dynamic-fixtures==0.1.7 \ No newline at end of file diff --git a/example/resource-provider/requirements.txt b/example/resource-provider/requirements.txt index 0425ac4..ae63bb7 100644 --- a/example/resource-provider/requirements.txt +++ b/example/resource-provider/requirements.txt @@ -1,3 +1,3 @@ django>=2.0.10 -git+git://github.com/Peter-Slump/django-keycloak.git#egg=django-keycloak +git+git://github.com/skamansam/django-keycloak.git#egg=django-keycloak django-dynamic-fixtures==0.1.7 \ No newline at end of file diff --git a/setup.py b/setup.py index b523db0..7edcbe1 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ 'factory-boy', 'freezegun' ], - url='https://github.com/Peter-Slump/django-keycloak', + url='https://github.com/skamansam/django-keycloak', license='MIT', author='Peter Slump', author_email='peter@yarf.nl', diff --git a/sonar-project.properties b/sonar-project.properties index 60f6205..6ca4466 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,9 +7,9 @@ sonar.projectVersion=0.1.2-dev # Meta-data for the project # ===================================================== -sonar.links.homepage=https://github.com/Peter-Slump/django-keycloak -sonar.links.ci=https://github.com/Peter-Slump/django-keycloak -sonar.links.scm=https://github.com/Peter-Slump/django-keycloak +sonar.links.homepage=https://github.com/skamansam/django-keycloak +sonar.links.ci=https://github.com/skamansam/django-keycloak +sonar.links.scm=https://github.com/skamansam/django-keycloak # ===================================================== # Properties that will be shared amongst all modules From 49ccb34049acb5618f9642c45a8bce14e17ef4a9 Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Thu, 6 Jul 2023 13:04:47 -0400 Subject: [PATCH 02/15] update urls to use `django.urls.path()` instead of old `django.conf.urls.url()` --- example/resource-provider-api/myapp/urls.py | 8 ++++---- example/resource-provider/myapp/urls.py | 12 ++++++------ src/django_keycloak/urls.py | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/example/resource-provider-api/myapp/urls.py b/example/resource-provider-api/myapp/urls.py index 047f134..1c36cb5 100644 --- a/example/resource-provider-api/myapp/urls.py +++ b/example/resource-provider-api/myapp/urls.py @@ -13,14 +13,14 @@ 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from django.conf.urls import url, include +from django.urls import include, path from django.contrib import admin from myapp import views urlpatterns = [ - url(r'^api/end-point$', views.api_end_point), - url(r'^api/authenticated-end-point$', views.authenticated_end_point), - url(r'^admin/', admin.site.urls), + path(r'api/end-point', views.api_end_point), + path(r'api/authenticated-end-point', views.authenticated_end_point), + path(r'admin/', admin.site.urls), ] diff --git a/example/resource-provider/myapp/urls.py b/example/resource-provider/myapp/urls.py index cdfbb3a..aec681e 100644 --- a/example/resource-provider/myapp/urls.py +++ b/example/resource-provider/myapp/urls.py @@ -13,16 +13,16 @@ 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from django.conf.urls import url, include +from django.urls import include, path from django.contrib import admin from myapp import views urlpatterns = [ - url(r'^$', views.Home.as_view(), name='index'), - url(r'^secured$', views.Secured.as_view(), name='secured'), - url(r'^permission$', views.Permission.as_view(), name='permission'), - url(r'^keycloak/', include('django_keycloak.urls')), - url(r'^admin/', admin.site.urls), + path('/', views.Home.as_view(), name='index'), + path('secured', views.Secured.as_view(), name='secured'), + path('permission', views.Permission.as_view(), name='permission'), + path('keycloak', include('django_keycloak.urls')), + path('admin', admin.site.urls), ] diff --git a/src/django_keycloak/urls.py b/src/django_keycloak/urls.py index 1486ed0..83f1eff 100644 --- a/src/django_keycloak/urls.py +++ b/src/django_keycloak/urls.py @@ -13,15 +13,15 @@ 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from django.conf.urls import url +from django.urls import url, path from django_keycloak import views urlpatterns = [ - url(r'^login$', views.Login.as_view(), name='keycloak_login'), - url(r'^login-complete$', views.LoginComplete.as_view(), + path('login', views.Login.as_view(), name='keycloak_login'), + path('login-complete', views.LoginComplete.as_view(), name='keycloak_login_complete'), - url(r'^logout$', views.Logout.as_view(), name='keycloak_logout'), - url(r'^session-iframe', views.SessionIframe.as_view(), + path('logout', views.Logout.as_view(), name='keycloak_logout'), + path('session-iframe', views.SessionIframe.as_view(), name='keycloak_session_iframe') ] From 6ec15b8aa9cb7ae64d6d8aa5eebbccbb9d5ee412 Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Thu, 6 Jul 2023 13:05:02 -0400 Subject: [PATCH 03/15] =?UTF-8?q?Bump=20version:=200.1.2-dev=20=E2=86=92?= =?UTF-8?q?=200.1.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.rst | 1 + docs/conf.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- sonar-project.properties | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index dadedf6..1d359d0 100644 --- a/README.rst +++ b/README.rst @@ -72,6 +72,7 @@ Release Notes ============= **unreleased** +**v0.1.2** **v0.1.2-dev** diff --git a/docs/conf.py b/docs/conf.py index 68376e2..15bde5b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,7 @@ # The short X.Y version version = u'' # The full version, including alpha/beta/rc tags -release = u'0.1.2-dev' +release = u'0.1.2' # -- General configuration --------------------------------------------------- diff --git a/setup.cfg b/setup.cfg index f6203d2..0955f54 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.1.2-dev +current_version = 0.1.2 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? diff --git a/setup.py b/setup.py index 7edcbe1..58b4290 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages -VERSION = '0.1.2-dev' +VERSION = '0.1.2' with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: README = readme.read() diff --git a/sonar-project.properties b/sonar-project.properties index 6ca4466..101c0de 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,7 @@ sonar.projectKey=Peter-Slump_django-keycloak sonar.organization=peter-slump-github sonar.projectName=Django Keycloak -sonar.projectVersion=0.1.2-dev +sonar.projectVersion=0.1.2 # ===================================================== # Meta-data for the project From 8ff7c715838469e99ba2a7f8083fbf2716e74066 Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Thu, 6 Jul 2023 13:19:18 -0400 Subject: [PATCH 04/15] setup sonar and github actions --- .github/workflows/build.yml | 20 ++++++++++++++++++++ .github/workflows/linty.yml | 26 ++++++++++++++++++++++++++ Makefile | 2 +- sonar-project.properties | 4 ++-- 4 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/linty.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2862b6a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: Build +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize, reopened] +jobs: + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/linty.yml b/.github/workflows/linty.yml new file mode 100644 index 0000000..c7c2353 --- /dev/null +++ b/.github/workflows/linty.yml @@ -0,0 +1,26 @@ +name: Lint + +on: + push: + paths: + - '*.py' + +jobs: + flake8_py3: + runs-on: ubuntu-latest + steps: + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: 3.11.4 + architecture: x64 + - name: Checkout PyTorch + uses: actions/checkout@master + - name: Install flake8 + run: pip install flake8 + - name: Run flake8 + uses: suo/flake8-github-action@releases/v1 + with: + checkName: 'flake8_py3' # NOTE: this needs to be the same as the job name + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/Makefile b/Makefile index d6ee853..6800752 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ bump-minor: bumpversion minor deploy-pypi: clear - python3 -c "import sys; sys.version_info >= (3, 5, 3) or sys.stdout.write('Python version must be greatest then 3.5.2\n') or exit(1)" + python3 -c "import sys; sys.version_info >= (3, 5, 3) or sys.stdout.write('Python version must be greater then 3.5.2\n') or exit(1)" python3 setup.py sdist bdist_wheel twine upload dist/* diff --git a/sonar-project.properties b/sonar-project.properties index 101c0de..2cd8109 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,5 +1,5 @@ -sonar.projectKey=Peter-Slump_django-keycloak -sonar.organization=peter-slump-github +sonar.projectKey=skamansam_django-keycloak +sonar.organization=skamansam sonar.projectName=Django Keycloak sonar.projectVersion=0.1.2 From 20d68a94bb13df30cc45f49e09e67a4d25ce4887 Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Thu, 6 Jul 2023 13:22:13 -0400 Subject: [PATCH 05/15] set python version to 3.11.4 for sonarcloud --- sonar-project.properties | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 2cd8109..cb8ffdf 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -18,4 +18,5 @@ sonar.links.scm=https://github.com/skamansam/django-keycloak # SQ standard properties sonar.sources=src sonar.exclusions=/src/tests/**/*.py -sonar.python.coverage.reportPath=coverage.xml \ No newline at end of file +sonar.python.coverage.reportPath=coverage.xml +sonar.python.version=3.11.4 \ No newline at end of file From d3edabd00f381fa3ea36f025bf02bae37efa5b08 Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Mon, 17 Jul 2023 16:50:24 -0400 Subject: [PATCH 06/15] update django versions in travis --- .travis.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 491985e..1d33b7b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,9 @@ python: - "3.4" - "3.5" env: - - DJANGO_VERSION=1.11 - - DJANGO_VERSION=2.0 + - DJANGO_VERSION=3.0 + - DJANGO_VERSION=4.1 + - DJANGO_VERSION=4.2 addons: sonarcloud: organization: $SC_ORG From 1581d08b68ef994e0157e1477e04fe8c703bf7ca Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Wed, 9 Aug 2023 14:04:02 -0400 Subject: [PATCH 07/15] setup poetry / poethepoet to modernize the lib --- poetry.lock | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 18 +++++++++++ 2 files changed, 103 insertions(+) create mode 100644 poetry.lock create mode 100644 pyproject.toml diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..104f674 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,85 @@ +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. + +[[package]] +name = "asgiref" +version = "3.7.2" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.7" +files = [ + {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, + {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, +] + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "django" +version = "4.2.4" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Django-4.2.4-py3-none-any.whl", hash = "sha256:860ae6a138a238fc4f22c99b52f3ead982bb4b1aad8c0122bcd8c8a3a02e409d"}, + {file = "Django-4.2.4.tar.gz", hash = "sha256:7e4225ec065e0f354ccf7349a22d209de09cc1c074832be9eb84c51c1799c432"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-dynamic-fixtures" +version = "0.2.1" +description = "Install Dynamic Django fixtures." +optional = false +python-versions = "*" +files = [ + {file = "django-dynamic-fixtures-0.2.1.tar.gz", hash = "sha256:b5c47e20e344b34bb4e5402cec5926e31dc30845cfb1ce09a860f46e94a69e2d"}, + {file = "django_dynamic_fixtures-0.2.1-py2-none-any.whl", hash = "sha256:b4cd49425d208b2f83cfc20d8b4c0726998cb37d54010c9c383fb42418710c1b"}, +] + +[package.dependencies] +Django = ">=1.7" + +[package.extras] +dev = ["bumpversion (==0.5.3)", "twine (==1.9.1)"] +doc = ["Sphinx (==1.4.4)", "sphinx-autobuild (==0.6.0)"] + +[[package]] +name = "sqlparse" +version = "0.4.4" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.5" +files = [ + {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, + {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, +] + +[package.extras] +dev = ["build", "flake8"] +doc = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "tzdata" +version = "2023.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, + {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "cbb6d4222752ca29787d32b6f43e6a03555764f5c39725d351a62a0a1e975fad" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2a65647 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "django-keycloak" +version = "0.2.0" +description = "Integrate Keycloak with Django 4.2+" +authors = ["Samuel C. Tyler ", "Peter Slump "] +readme = "README.rst" +packages = [{include = "src/django_keycloak"}] + +[tool.poetry.dependencies] +python = "^3.11" +Django = "^4.2.4" + +[tool.poetry.group.dev.dependencies] +django-dynamic-fixtures = "^0.2.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" From 6355c65d3f747bbfba6adfc374d04fd0434165ab Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Tue, 22 Aug 2023 11:15:03 -0400 Subject: [PATCH 08/15] build: :arrow_up: upgrade toolchain to use poetry poetry / poethepoet is a much more modern toolchain and will improve developer experience going forward. the old buildchain no longer works as expected --- Makefile | 16 +- .../docker-compose.yml => docker-compose.yml | 43 +- example/keycloak/Dockerfile | 24 + example/resource-provider-api/Dockerfile | 11 +- example/resource-provider-api/poetry.lock | 85 +++ example/resource-provider-api/pyproject.toml | 18 + example/resource-provider/Dockerfile | 11 +- example/resource-provider/manage.py | 3 + example/resource-provider/poetry.lock | 85 +++ example/resource-provider/pyproject.toml | 18 + poetry.lock | 493 +++++++++++++++++- pyproject.toml | 20 +- setup.cfg | 29 -- 13 files changed, 791 insertions(+), 65 deletions(-) rename example/docker-compose.yml => docker-compose.yml (58%) create mode 100644 example/keycloak/Dockerfile create mode 100644 example/resource-provider-api/poetry.lock create mode 100644 example/resource-provider-api/pyproject.toml create mode 100644 example/resource-provider/poetry.lock create mode 100644 example/resource-provider/pyproject.toml delete mode 100644 setup.cfg diff --git a/Makefile b/Makefile index 6800752..2d49575 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,18 @@ install-python: - pip install --upgrade setuptools - pip install -e . - pip install "file://`pwd`#egg=django-keycloak[dev,doc]" + # pyenv virtualenv 3.11.4 django-keycloak || pyenv activate django-keycloak + pip install --upgrade setuptools poethepoet + poe install + poe addsudo "file://`pwd`#egg=django-keycloak[dev,doc]" bump-patch: - bumpversion patch + poetry version patch bump-minor: - bumpversion minor + poetry version minor deploy-pypi: clear - python3 -c "import sys; sys.version_info >= (3, 5, 3) or sys.stdout.write('Python version must be greater then 3.5.2\n') or exit(1)" - python3 setup.py sdist bdist_wheel - twine upload dist/* + poetry build + poetry publish clear: rm -rf dist/* diff --git a/example/docker-compose.yml b/docker-compose.yml similarity index 58% rename from example/docker-compose.yml rename to docker-compose.yml index 400abe3..0bd41d5 100644 --- a/example/docker-compose.yml +++ b/docker-compose.yml @@ -22,27 +22,42 @@ services: - resource-provider-api keycloak: - image: jboss/keycloak:3.4.3.Final # Pinned to 3.4.3 because this is currently the latest version which has commercial support from Red Hat: https://www.keycloak.org/support.html + # build: ./keycloak + image: quay.io/keycloak/keycloak:latest command: [ - "-b", "0.0.0.0", - "-Dkeycloak.migration.action=import", # Replace with 'export' in order to export everything - "-Dkeycloak.migration.provider=dir", - "-Dkeycloak.migration.dir=/opt/jboss/keycloak/standalone/configuration/export/", - "-Dkeycloak.migration.strategy=IGNORE_EXISTING" + "start-dev", + # "--help", + # "-b", "0.0.0.0", + "--import-realm", + "--log-level=debug", + "--metrics-enabled=true", + "--db=postgres", + "--db-url=postgres:5432", + "--db-username=keycloak", + "--db-password=keycloak", + "--db-schema=keycloak", + # "--verbose", + # "-Dkeycloak.migration.action=import", # Replace with 'export' in order to export everything + # "-Dkeycloak.migration.provider=dir", + # "-Dkeycloak.migration.dir=/opt/keycloak/data/import", + # "-Dkeycloak.migration.strategy=IGNORE_EXISTING" ] environment: - - POSTGRES_DATABASE=keycloak - - POSTGRES_USER=keycloak - - POSTGRES_PASSWORD=password + - KC_METRICS_ENABLED=true + # - POSTGRES_DATABASE=keycloak + # - POSTGRES_USER=keycloak + # - POSTGRES_PASSWORD=password - KEYCLOAK_HOSTNAME=identity.localhost.yarf.nl - # Legacy linking functionality is used - - POSTGRES_PORT_5432_TCP_ADDR=postgres - - POSTGRES_PORT_5432_TCP_PORT=5432 + # # Legacy linking functionality is used + # - POSTGRES_PORT_5432_TCP_ADDR=postgres + # - POSTGRES_PORT_5432_TCP_PORT=5432 - PROXY_ADDRESS_FORWARDING=true - KEYCLOAK_LOGLEVEL=DEBUG -# - JAVA_TOOL_OPTIONS=-Dkeycloak.profile.feature.admin_fine_grained_authz=enabled -Dkeycloak.profile.feature.token_exchange=enabled # Required to enable Token exchange feature in newer versions of Keycloak + # - KEYCLOAK_ADMIN=admin + # - KEYCLOAK_ADMIN_PASSWORD=admin + # # - JAVA_TOOL_OPTIONS=-Dkeycloak.profile.feature.admin_fine_grained_authz=enabled -Dkeycloak.profile.feature.token_exchange=enabled # Required to enable Token exchange feature in newer versions of Keycloak volumes: - - ./keycloak/export:/opt/jboss/keycloak/standalone/configuration/export + - ./keycloak/export:/opt/keycloak/data/import/ networks: default: aliases: diff --git a/example/keycloak/Dockerfile b/example/keycloak/Dockerfile new file mode 100644 index 0000000..47cf61b --- /dev/null +++ b/example/keycloak/Dockerfile @@ -0,0 +1,24 @@ +FROM quay.io/keycloak/keycloak:latest as builder + +# Enable health and metrics support +# ENV KC_HEALTH_ENABLED=true +# ENV KC_METRICS_ENABLED=true + +# Configure a database vendor +# ENV KC_DB=postgres + +WORKDIR /opt/keycloak +# for demonstration purposes only, please make sure to use proper certificates in production instead +RUN keytool -genkeypair -storepass password -storetype PKCS12 -keyalg RSA -keysize 2048 -dname "CN=server" -alias server -ext "SAN:c=DNS:localhost,IP:127.0.0.1" -keystore conf/server.keystore +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:latest +COPY --from=builder /opt/keycloak/ /opt/keycloak/ + +# change these values to point to a running postgres instance +# ENV KC_DB=postgres +# ENV KC_DB_URL=localhost:5432 +# ENV KC_DB_USERNAME=admin +# ENV KC_DB_PASSWORD=admin +# ENV KC_HOSTNAME=localhost +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] \ No newline at end of file diff --git a/example/resource-provider-api/Dockerfile b/example/resource-provider-api/Dockerfile index 71be882..463aeb6 100644 --- a/example/resource-provider-api/Dockerfile +++ b/example/resource-provider-api/Dockerfile @@ -1,15 +1,14 @@ -FROM python:3-alpine +FROM python:3.11.4-alpine RUN apk update \ - && apk add git openssl-dev libffi-dev python-dev build-base + && apk add git openssl-dev libffi-dev build-base \ + && pip install poethepoet poetry -RUN mkdir -p /usr/src/app +COPY ./poetry.lock ./pyproject.toml /usr/src/app/ WORKDIR /usr/src/app -COPY requirements.txt /usr/src/app/ - -RUN pip install --no-cache-dir -r requirements.txt +RUN poetry install COPY . /usr/src/app diff --git a/example/resource-provider-api/poetry.lock b/example/resource-provider-api/poetry.lock new file mode 100644 index 0000000..1e1befd --- /dev/null +++ b/example/resource-provider-api/poetry.lock @@ -0,0 +1,85 @@ +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. + +[[package]] +name = "asgiref" +version = "3.7.2" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.7" +files = [ + {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, + {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, +] + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "django" +version = "4.2.4" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Django-4.2.4-py3-none-any.whl", hash = "sha256:860ae6a138a238fc4f22c99b52f3ead982bb4b1aad8c0122bcd8c8a3a02e409d"}, + {file = "Django-4.2.4.tar.gz", hash = "sha256:7e4225ec065e0f354ccf7349a22d209de09cc1c074832be9eb84c51c1799c432"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-dynamic-fixtures" +version = "0.2.1" +description = "Install Dynamic Django fixtures." +optional = false +python-versions = "*" +files = [ + {file = "django-dynamic-fixtures-0.2.1.tar.gz", hash = "sha256:b5c47e20e344b34bb4e5402cec5926e31dc30845cfb1ce09a860f46e94a69e2d"}, + {file = "django_dynamic_fixtures-0.2.1-py2-none-any.whl", hash = "sha256:b4cd49425d208b2f83cfc20d8b4c0726998cb37d54010c9c383fb42418710c1b"}, +] + +[package.dependencies] +Django = ">=1.7" + +[package.extras] +dev = ["bumpversion (==0.5.3)", "twine (==1.9.1)"] +doc = ["Sphinx (==1.4.4)", "sphinx-autobuild (==0.6.0)"] + +[[package]] +name = "sqlparse" +version = "0.4.4" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.5" +files = [ + {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, + {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, +] + +[package.extras] +dev = ["build", "flake8"] +doc = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "tzdata" +version = "2023.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, + {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "2926b9f945aed824843f286cfe64b4f23b14471d7162c56778f766c40e43c4d8" diff --git a/example/resource-provider-api/pyproject.toml b/example/resource-provider-api/pyproject.toml new file mode 100644 index 0000000..7924390 --- /dev/null +++ b/example/resource-provider-api/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "resource-provider-api" +version = "0.1.0" +description = "api provider for django-keycloak example" +authors = ["Samuel C. Tyler "] +# readme = "README.md" +# packages = [{include = "resource_provider"}] + +[tool.poetry.dependencies] +python = "^3.11" +Django = "^4.2.4" + +[tool.poetry.group.dev.dependencies] +django-dynamic-fixtures = "^0.2.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/example/resource-provider/Dockerfile b/example/resource-provider/Dockerfile index bb2d7cf..317d2c9 100644 --- a/example/resource-provider/Dockerfile +++ b/example/resource-provider/Dockerfile @@ -1,15 +1,14 @@ -FROM python:3-alpine +FROM python:3.11.4-alpine RUN apk update \ - && apk add git openssl-dev libffi-dev python-dev build-base + && apk add git openssl-dev libffi-dev build-base \ + && pip install poethepoet poetry -RUN mkdir -p /usr/src/app +COPY ./poetry.lock ./pyproject.toml /usr/src/app/ WORKDIR /usr/src/app -COPY requirements.txt /usr/src/app/ - -RUN pip install --no-cache-dir -r requirements.txt +RUN poetry install COPY . /usr/src/app diff --git a/example/resource-provider/manage.py b/example/resource-provider/manage.py index 7ec07d0..c2cac2f 100755 --- a/example/resource-provider/manage.py +++ b/example/resource-provider/manage.py @@ -2,6 +2,9 @@ import os import sys +sys.path.append(os.path.abspath('../../src')) +print(sys.path) + if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myapp.settings") try: diff --git a/example/resource-provider/poetry.lock b/example/resource-provider/poetry.lock new file mode 100644 index 0000000..1e1befd --- /dev/null +++ b/example/resource-provider/poetry.lock @@ -0,0 +1,85 @@ +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. + +[[package]] +name = "asgiref" +version = "3.7.2" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.7" +files = [ + {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, + {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, +] + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "django" +version = "4.2.4" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Django-4.2.4-py3-none-any.whl", hash = "sha256:860ae6a138a238fc4f22c99b52f3ead982bb4b1aad8c0122bcd8c8a3a02e409d"}, + {file = "Django-4.2.4.tar.gz", hash = "sha256:7e4225ec065e0f354ccf7349a22d209de09cc1c074832be9eb84c51c1799c432"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-dynamic-fixtures" +version = "0.2.1" +description = "Install Dynamic Django fixtures." +optional = false +python-versions = "*" +files = [ + {file = "django-dynamic-fixtures-0.2.1.tar.gz", hash = "sha256:b5c47e20e344b34bb4e5402cec5926e31dc30845cfb1ce09a860f46e94a69e2d"}, + {file = "django_dynamic_fixtures-0.2.1-py2-none-any.whl", hash = "sha256:b4cd49425d208b2f83cfc20d8b4c0726998cb37d54010c9c383fb42418710c1b"}, +] + +[package.dependencies] +Django = ">=1.7" + +[package.extras] +dev = ["bumpversion (==0.5.3)", "twine (==1.9.1)"] +doc = ["Sphinx (==1.4.4)", "sphinx-autobuild (==0.6.0)"] + +[[package]] +name = "sqlparse" +version = "0.4.4" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.5" +files = [ + {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, + {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, +] + +[package.extras] +dev = ["build", "flake8"] +doc = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "tzdata" +version = "2023.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, + {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "2926b9f945aed824843f286cfe64b4f23b14471d7162c56778f766c40e43c4d8" diff --git a/example/resource-provider/pyproject.toml b/example/resource-provider/pyproject.toml new file mode 100644 index 0000000..6e9bd44 --- /dev/null +++ b/example/resource-provider/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "resource-provider" +version = "0.1.0" +description = "resource provider for django-keycloak example" +authors = ["Samuel C. Tyler "] +# readme = "README.md" +# packages = [{include = "resource_provider"}] + +[tool.poetry.dependencies] +python = "^3.11" +Django = "^4.2.4" + +[tool.poetry.group.dev.dependencies] +django-dynamic-fixtures = "^0.2.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/poetry.lock b/poetry.lock index 104f674..549e15a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,6 +14,184 @@ files = [ [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] +[[package]] +name = "certifi" +version = "2023.7.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.2.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, + {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, + {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, + {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, + {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, + {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, + {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.2.7" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, + {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, + {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, + {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, + {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, + {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, + {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, + {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, + {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, + {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, + {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, + {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, + {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, + {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, + {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, + {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, + {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "django" version = "4.2.4" @@ -52,6 +230,302 @@ Django = ">=1.7" dev = ["bumpversion (==0.5.3)", "twine (==1.9.1)"] doc = ["Sphinx (==1.4.4)", "sphinx-autobuild (==0.6.0)"] +[[package]] +name = "ecdsa" +version = "0.18.0" +description = "ECDSA cryptographic signature library (pure python)" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"}, + {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + +[[package]] +name = "factory-boy" +version = "3.3.0" +description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." +optional = false +python-versions = ">=3.7" +files = [ + {file = "factory_boy-3.3.0-py2.py3-none-any.whl", hash = "sha256:a2cdbdb63228177aa4f1c52f4b6d83fab2b8623bf602c7dedd7eb83c0f69c04c"}, + {file = "factory_boy-3.3.0.tar.gz", hash = "sha256:bc76d97d1a65bbd9842a6d722882098eb549ec8ee1081f9fb2e8ff29f0c300f1"}, +] + +[package.dependencies] +Faker = ">=0.7.0" + +[package.extras] +dev = ["Django", "Pillow", "SQLAlchemy", "coverage", "flake8", "isort", "mongoengine", "sqlalchemy-utils", "tox", "wheel (>=0.32.0)", "zest.releaser[recommended]"] +doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] + +[[package]] +name = "faker" +version = "19.3.0" +description = "Faker is a Python package that generates fake data for you." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Faker-19.3.0-py3-none-any.whl", hash = "sha256:bee54278d6e1289573317604ab6f4782acca724396bf261eaf1890de228e553d"}, + {file = "Faker-19.3.0.tar.gz", hash = "sha256:7d6ed00de3eef9bd57504500c67ee034cab959e4248f9c24aca33e08af82ca93"}, +] + +[package.dependencies] +python-dateutil = ">=2.4" + +[[package]] +name = "freezegun" +version = "1.2.2" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.6" +files = [ + {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, + {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "mock" +version = "5.1.0" +description = "Rolling backport of unittest.mock for all Pythons" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, + {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, +] + +[package.extras] +build = ["blurb", "twine", "wheel"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "packaging" +version = "23.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, +] + +[[package]] +name = "pluggy" +version = "1.2.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pyasn1" +version = "0.5.0" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "pyasn1-0.5.0-py2.py3-none-any.whl", hash = "sha256:87a2121042a1ac9358cabcaf1d07680ff97ee6404333bacca15f76aa8ad01a57"}, + {file = "pyasn1-0.5.0.tar.gz", hash = "sha256:97b7290ca68e62a832558ec3976f15cbf911bf5d7c7039d8b861c2a0ece69fde"}, +] + +[[package]] +name = "pytest" +version = "7.4.0" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, + {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-django" +version = "4.5.2" +description = "A Django plugin for pytest." +optional = false +python-versions = ">=3.5" +files = [ + {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, + {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, +] + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["Django", "django-configurations (>=2.0)"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-jose" +version = "3.3.0" +description = "JOSE implementation in Python" +optional = false +python-versions = "*" +files = [ + {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"}, + {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"}, +] + +[package.dependencies] +ecdsa = "!=0.15" +pyasn1 = "*" +rsa = "*" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pyasn1", "pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pyasn1", "pycryptodome (>=3.3.1,<4.0.0)"] + +[[package]] +name = "python-keycloak-client" +version = "0.2.3" +description = "Install Python Keycloak client." +optional = false +python-versions = "*" +files = [ + {file = "python-keycloak-client-0.2.3.tar.gz", hash = "sha256:a38be22ca376991ec2be12a3414638303386ccf55c2c72d7bb8e299a83e4863f"}, + {file = "python_keycloak_client-0.2.3-py3-none-any.whl", hash = "sha256:a4aa8c93c0099a0b55394cb035996cc9cc0261c9c4141e9346534ff11122d55d"}, +] + +[package.dependencies] +python-jose = "*" +requests = "*" + +[package.extras] +aio = ["aiohttp (>=3.4.4,<4)"] +dev = ["bumpversion (==0.5.3)", "twine"] +doc = ["Sphinx (==1.4.4)", "sphinx-autobuild (==0.6.0)"] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sqlparse" version = "0.4.4" @@ -79,7 +553,24 @@ files = [ {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, ] +[[package]] +name = "urllib3" +version = "2.0.4" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.7" +files = [ + {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, + {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "cbb6d4222752ca29787d32b6f43e6a03555764f5c39725d351a62a0a1e975fad" +content-hash = "e96404f136948191b23a8c5b4e9f034f621242e2fa14d43bb09f7ca50eff066b" diff --git a/pyproject.toml b/pyproject.toml index 2a65647..d74ee3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,36 @@ [tool.poetry] -name = "django-keycloak" +name = "django42-keycloak" version = "0.2.0" description = "Integrate Keycloak with Django 4.2+" +homepage = 'https://github.com/skamansam/django-keycloak' +repository = 'https://github.com/skamansam/django-keycloak' +keywords = ["django", "keycloak", "auth"] +license = "MIT" authors = ["Samuel C. Tyler ", "Peter Slump "] readme = "README.rst" packages = [{include = "src/django_keycloak"}] +[tool.poe.tasks] +test = "pytest --cov=my_app" # a simple command task +serve.script = "my_app.service:run(debug=True)" # python script based task +tunnel.shell = "ssh -N -L 0.0.0.0:8080:$PROD:8080 $PROD &" # (posix) shell based task + + [tool.poetry.dependencies] python = "^3.11" Django = "^4.2.4" +python-keycloak-client = "^0.2.3" [tool.poetry.group.dev.dependencies] django-dynamic-fixtures = "^0.2.1" +[tool.poetry.group.test.dependencies] +pytest-django = "^4.5.2" +pytest-cov = "^4.1.0" +mock = "^5.1.0" +factory-boy = "^3.3.0" +freezegun = "^1.2.2" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 0955f54..0000000 --- a/setup.cfg +++ /dev/null @@ -1,29 +0,0 @@ -[bumpversion] -current_version = 0.1.2 -commit = True -tag = True -parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+))? -serialize = - {major}.{minor}.{patch}-{release} - {major}.{minor}.{patch} - -[bumpversion:file:setup.py] - -[bumpversion:file:docs/conf.py] - -[bumpversion:file:sonar-project.properties] - -[bumpversion:file:README.rst] -search = **unreleased** -replace = **unreleased** - **v{new_version}** - -[bumpversion:part:release] -optional_value = gamma -values = - dev - gamma - -[aliases] -test = pytest - From 629d06aab5b2f7f7f87f1aaaabd416b825383005 Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Tue, 22 Aug 2023 11:15:43 -0400 Subject: [PATCH 09/15] update to newer keycloak info --- src/django_keycloak/services/oidc_profile.py | 3 ++- src/django_keycloak/urls.py | 2 +- src/django_keycloak/views.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/django_keycloak/services/oidc_profile.py b/src/django_keycloak/services/oidc_profile.py index 2313231..c68e157 100644 --- a/src/django_keycloak/services/oidc_profile.py +++ b/src/django_keycloak/services/oidc_profile.py @@ -219,7 +219,8 @@ def _update_or_create(client, token_response, initiate_time): key=client.realm.certs, algorithms=client.openid_api_client.well_known[ 'id_token_signing_alg_values_supported'], - issuer=issuer + issuer=issuer, + access_token=token_response['access_token'] ) oidc_profile = update_or_create_user_and_oidc_profile( diff --git a/src/django_keycloak/urls.py b/src/django_keycloak/urls.py index 83f1eff..fbbbd35 100644 --- a/src/django_keycloak/urls.py +++ b/src/django_keycloak/urls.py @@ -13,7 +13,7 @@ 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from django.urls import url, path +from django.urls import path from django_keycloak import views diff --git a/src/django_keycloak/views.py b/src/django_keycloak/views.py index 3142498..06b678e 100644 --- a/src/django_keycloak/views.py +++ b/src/django_keycloak/views.py @@ -46,7 +46,7 @@ def get_redirect_url(self, *args, **kwargs): authorization_url = self.request.realm.client.openid_api_client\ .authorization_url( redirect_uri=nonce.redirect_uri, - scope='openid given_name family_name email', + scope='openid profile email', state=str(nonce.state) ) From b789d04c54ad828701ba01bbba1ebef5a79749be Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Tue, 28 Nov 2023 16:22:31 -0500 Subject: [PATCH 10/15] Update package name to be the same as the original I'm not sure why this change was requested --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5d6619e..8c91a1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "django42-keycloak" +name = "django-keycloak" version = "0.2.6" description = "Integrate Keycloak with Django 4.2+" homepage = 'https://github.com/skamansam/django-keycloak' From 9e9c40f472601cc6e850cb9a593666034b1bac34 Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Tue, 28 Nov 2023 16:50:01 -0500 Subject: [PATCH 11/15] move source out of src and update include --- .../auth/backends.py | 0 ...ent_id_alter_exchangedtoken_id_and_more.py | 0 .../models.py | 0 .../services/oidc_profile.py | 0 .../urls.py | 0 .../views.py | 0 pyproject.toml | 4 +- src/django_keycloak/__init__.py | 10 - src/django_keycloak/admin/__init__.py | 8 - src/django_keycloak/admin/realm.py | 155 ------------- src/django_keycloak/admin/server.py | 10 - src/django_keycloak/app_settings.py | 12 - src/django_keycloak/apps.py | 6 - src/django_keycloak/auth/__init__.py | 87 ------- src/django_keycloak/factories.py | 67 ------ src/django_keycloak/hashers.py | 9 - src/django_keycloak/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/keycloak_add_user.py | 40 ---- .../commands/keycloak_refresh_realm.py | 20 -- .../commands/keycloak_sync_resources.py | 33 --- src/django_keycloak/middleware.py | 122 ---------- .../migrations/0001_initial.py | 134 ----------- .../migrations/0002_auto_20180322_2059.py | 54 ----- .../migrations/0003_auto_20190204_1949.py | 44 ---- .../0004_client_service_account_profile.py | 24 -- .../migrations/0005_auto_20190219_2002.py | 28 --- .../0006_remove_client_service_account.py | 17 -- src/django_keycloak/migrations/__init__.py | 0 src/django_keycloak/remote_user.py | 213 ------------------ src/django_keycloak/response.py | 21 -- src/django_keycloak/services/__init__.py | 0 src/django_keycloak/services/client.py | 131 ----------- src/django_keycloak/services/exceptions.py | 8 - src/django_keycloak/services/permissions.py | 36 --- src/django_keycloak/services/realm.py | 72 ------ src/django_keycloak/services/remote_client.py | 59 ----- src/django_keycloak/services/uma.py | 63 ------ src/django_keycloak/services/users.py | 33 --- .../includes/session_iframe_support.html | 1 - .../django_keycloak/session_iframe.html | 50 ---- src/django_keycloak/tests/__init__.py | 0 .../tests/backends/__init__.py | 0 .../keycloak_authorization_base/__init__.py | 0 .../test_get_keycloak_permissions.py | 49 ---- .../test_has_perm.py | 89 -------- src/django_keycloak/tests/mixins.py | 23 -- .../tests/services/__init__.py | 0 .../tests/services/oidc_profile/__init__.py | 0 .../test_get_active_access_token.py | 69 ------ .../oidc_profile/test_get_entitlement.py | 57 ----- .../test_get_or_create_from_id_token.py | 158 ------------- .../oidc_profile/test_update_or_create.py | 126 ----------- .../tests/services/realm/__init__.py | 0 .../realm/test_get_realm_api_client.py | 49 ---- .../realm/test_refresh_well_known_oidc.py | 36 --- src/django_keycloak/tests/settings.py | 42 ---- 57 files changed, 2 insertions(+), 2267 deletions(-) rename {src/django_keycloak => django_keycloak}/auth/backends.py (100%) rename {src/django_keycloak => django_keycloak}/migrations/0007_alter_client_id_alter_exchangedtoken_id_and_more.py (100%) rename {src/django_keycloak => django_keycloak}/models.py (100%) rename {src/django_keycloak => django_keycloak}/services/oidc_profile.py (100%) rename {src/django_keycloak => django_keycloak}/urls.py (100%) rename {src/django_keycloak => django_keycloak}/views.py (100%) delete mode 100644 src/django_keycloak/__init__.py delete mode 100644 src/django_keycloak/admin/__init__.py delete mode 100644 src/django_keycloak/admin/realm.py delete mode 100644 src/django_keycloak/admin/server.py delete mode 100644 src/django_keycloak/app_settings.py delete mode 100644 src/django_keycloak/apps.py delete mode 100644 src/django_keycloak/auth/__init__.py delete mode 100644 src/django_keycloak/factories.py delete mode 100644 src/django_keycloak/hashers.py delete mode 100644 src/django_keycloak/management/__init__.py delete mode 100644 src/django_keycloak/management/commands/__init__.py delete mode 100644 src/django_keycloak/management/commands/keycloak_add_user.py delete mode 100644 src/django_keycloak/management/commands/keycloak_refresh_realm.py delete mode 100644 src/django_keycloak/management/commands/keycloak_sync_resources.py delete mode 100644 src/django_keycloak/middleware.py delete mode 100644 src/django_keycloak/migrations/0001_initial.py delete mode 100644 src/django_keycloak/migrations/0002_auto_20180322_2059.py delete mode 100644 src/django_keycloak/migrations/0003_auto_20190204_1949.py delete mode 100644 src/django_keycloak/migrations/0004_client_service_account_profile.py delete mode 100644 src/django_keycloak/migrations/0005_auto_20190219_2002.py delete mode 100644 src/django_keycloak/migrations/0006_remove_client_service_account.py delete mode 100644 src/django_keycloak/migrations/__init__.py delete mode 100644 src/django_keycloak/remote_user.py delete mode 100644 src/django_keycloak/response.py delete mode 100644 src/django_keycloak/services/__init__.py delete mode 100644 src/django_keycloak/services/client.py delete mode 100644 src/django_keycloak/services/exceptions.py delete mode 100644 src/django_keycloak/services/permissions.py delete mode 100644 src/django_keycloak/services/realm.py delete mode 100644 src/django_keycloak/services/remote_client.py delete mode 100644 src/django_keycloak/services/uma.py delete mode 100644 src/django_keycloak/services/users.py delete mode 100644 src/django_keycloak/templates/django_keycloak/includes/session_iframe_support.html delete mode 100644 src/django_keycloak/templates/django_keycloak/session_iframe.html delete mode 100644 src/django_keycloak/tests/__init__.py delete mode 100644 src/django_keycloak/tests/backends/__init__.py delete mode 100644 src/django_keycloak/tests/backends/keycloak_authorization_base/__init__.py delete mode 100644 src/django_keycloak/tests/backends/keycloak_authorization_base/test_get_keycloak_permissions.py delete mode 100644 src/django_keycloak/tests/backends/keycloak_authorization_base/test_has_perm.py delete mode 100644 src/django_keycloak/tests/mixins.py delete mode 100644 src/django_keycloak/tests/services/__init__.py delete mode 100644 src/django_keycloak/tests/services/oidc_profile/__init__.py delete mode 100644 src/django_keycloak/tests/services/oidc_profile/test_get_active_access_token.py delete mode 100644 src/django_keycloak/tests/services/oidc_profile/test_get_entitlement.py delete mode 100644 src/django_keycloak/tests/services/oidc_profile/test_get_or_create_from_id_token.py delete mode 100644 src/django_keycloak/tests/services/oidc_profile/test_update_or_create.py delete mode 100644 src/django_keycloak/tests/services/realm/__init__.py delete mode 100644 src/django_keycloak/tests/services/realm/test_get_realm_api_client.py delete mode 100644 src/django_keycloak/tests/services/realm/test_refresh_well_known_oidc.py delete mode 100644 src/django_keycloak/tests/settings.py diff --git a/src/django_keycloak/auth/backends.py b/django_keycloak/auth/backends.py similarity index 100% rename from src/django_keycloak/auth/backends.py rename to django_keycloak/auth/backends.py diff --git a/src/django_keycloak/migrations/0007_alter_client_id_alter_exchangedtoken_id_and_more.py b/django_keycloak/migrations/0007_alter_client_id_alter_exchangedtoken_id_and_more.py similarity index 100% rename from src/django_keycloak/migrations/0007_alter_client_id_alter_exchangedtoken_id_and_more.py rename to django_keycloak/migrations/0007_alter_client_id_alter_exchangedtoken_id_and_more.py diff --git a/src/django_keycloak/models.py b/django_keycloak/models.py similarity index 100% rename from src/django_keycloak/models.py rename to django_keycloak/models.py diff --git a/src/django_keycloak/services/oidc_profile.py b/django_keycloak/services/oidc_profile.py similarity index 100% rename from src/django_keycloak/services/oidc_profile.py rename to django_keycloak/services/oidc_profile.py diff --git a/src/django_keycloak/urls.py b/django_keycloak/urls.py similarity index 100% rename from src/django_keycloak/urls.py rename to django_keycloak/urls.py diff --git a/src/django_keycloak/views.py b/django_keycloak/views.py similarity index 100% rename from src/django_keycloak/views.py rename to django_keycloak/views.py diff --git a/pyproject.toml b/pyproject.toml index 8c91a1d..07d0c7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-keycloak" -version = "0.2.6" +version = "0.2.7" description = "Integrate Keycloak with Django 4.2+" homepage = 'https://github.com/skamansam/django-keycloak' repository = 'https://github.com/skamansam/django-keycloak' @@ -12,7 +12,7 @@ authors = [ "Peter Slump ", ] readme = "README.rst" -packages = [{include = "src/django_keycloak"}] +packages = [{include = "django_keycloak"}] classifiers = [ "Framework :: Django", "Framework :: Django :: 4.2", diff --git a/src/django_keycloak/__init__.py b/src/django_keycloak/__init__.py deleted file mode 100644 index 2c093e7..0000000 --- a/src/django_keycloak/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from . import app_settings as defaults -from django.conf import settings - - -default_app_config = 'django_keycloak.apps.KeycloakAppConfig' - -# Set some app default settings -for name in dir(defaults): - if name.isupper() and not hasattr(settings, name): - setattr(settings, name, getattr(defaults, name)) diff --git a/src/django_keycloak/admin/__init__.py b/src/django_keycloak/admin/__init__.py deleted file mode 100644 index 965a492..0000000 --- a/src/django_keycloak/admin/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.contrib import admin - -from django_keycloak.admin.realm import RealmAdmin -from django_keycloak.admin.server import ServerAdmin -from django_keycloak.models import Server, Realm - -admin.site.register(Realm, RealmAdmin) -admin.site.register(Server, ServerAdmin) diff --git a/src/django_keycloak/admin/realm.py b/src/django_keycloak/admin/realm.py deleted file mode 100644 index 44a5fb4..0000000 --- a/src/django_keycloak/admin/realm.py +++ /dev/null @@ -1,155 +0,0 @@ -from django.contrib import admin, messages -from keycloak.exceptions import KeycloakClientError -from requests.exceptions import HTTPError - -from django_keycloak.models import ( - Client, - OpenIdConnectProfile, - RemoteClient -) -import django_keycloak.services.permissions -import django_keycloak.services.realm -import django_keycloak.services.uma - - -def refresh_open_id_connect_well_known(modeladmin, request, queryset): - for realm in queryset: - django_keycloak.services.realm.refresh_well_known_oidc(realm=realm) - modeladmin.message_user( - request=request, - message='OpenID Connect .well-known refreshed', - level=messages.SUCCESS - ) - - -refresh_open_id_connect_well_known.short_description = 'Refresh OpenID ' \ - 'Connect .well-known' - - -def refresh_certs(modeladmin, request, queryset): - for realm in queryset: - django_keycloak.services.realm.refresh_certs(realm=realm) - modeladmin.message_user( - request=request, - message='Certificates refreshed', - level=messages.SUCCESS - ) - - -refresh_certs.short_description = 'Refresh Certificates' - - -def clear_client_tokens(modeladmin, request, queryset): - OpenIdConnectProfile.objects.filter(realm__in=queryset).update( - access_token=None, - expires_before=None, - refresh_token=None, - refresh_expires_before=None - ) - modeladmin.message_user( - request=request, - message='Tokens cleared', - level=messages.SUCCESS - ) - - -clear_client_tokens.short_description = 'Clear client tokens' - - -def synchronize_permissions(modeladmin, request, queryset): - for realm in queryset: - try: - django_keycloak.services.permissions.synchronize( - client=realm.client) - except HTTPError as e: - if e.response.status_code == 403: - modeladmin.message_user( - request=request, - message='Forbidden for {}. Does the client\'s service ' - 'account has the "keycloak_client" role?'.format( - realm.name - ), - level=messages.ERROR - ) - return - else: - raise - modeladmin.message_user( - request=request, - message='Permissions synchronized', - level=messages.SUCCESS - ) - - -synchronize_permissions.short_description = 'Synchronize permissions' - - -def synchronize_resources(modeladmin, request, queryset): - for realm in queryset: - try: - django_keycloak.services.uma.synchronize_client( - client=realm.client) - except KeycloakClientError as e: - if e.original_exc.response.status_code == 400: - modeladmin.message_user( - request=request, - message='Forbidden for {}. Is "Remote Resource ' - 'Management" enabled for the related client?' - .format( - realm.name - ), - level=messages.ERROR - ) - return - else: - raise - modeladmin.message_user( - request=request, - message='Resources synchronized', - level=messages.SUCCESS - ) - - -synchronize_resources.short_description = 'Synchronize models as Keycloak ' \ - 'resources' - - -class ClientAdmin(admin.TabularInline): - - model = Client - - fields = ('client_id', 'secret') - - -class RemoteClientAdmin(admin.TabularInline): - - model = RemoteClient - - extra = 1 - - fields = ('name',) - - -class RealmAdmin(admin.ModelAdmin): - - inlines = [ClientAdmin, RemoteClientAdmin] - - actions = [ - refresh_open_id_connect_well_known, - refresh_certs, - clear_client_tokens, - synchronize_permissions, - synchronize_resources - ] - - fieldsets = ( - (None, { - 'fields': ('name',) - }), - ('Location', { - 'fields': ('server', '_well_known_oidc',) - }) - - ) - - readonly_fields = ('_well_known_oidc',) diff --git a/src/django_keycloak/admin/server.py b/src/django_keycloak/admin/server.py deleted file mode 100644 index 3f01b5e..0000000 --- a/src/django_keycloak/admin/server.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.contrib import admin - - -class ServerAdmin(admin.ModelAdmin): - - fieldsets = ( - ('Location', { - 'fields': ('url', 'internal_url') - }), - ) diff --git a/src/django_keycloak/app_settings.py b/src/django_keycloak/app_settings.py deleted file mode 100644 index 10562e6..0000000 --- a/src/django_keycloak/app_settings.py +++ /dev/null @@ -1,12 +0,0 @@ -# Configure the model which need to be used to store the Open ID connect -# profile. There are two choices: -# - django_keycloak.OpenIdConnectProfile (Default) a local User object get -# created for the logged in identity. -# - django_keycloak.RemoteUserOpenIdConnectProfile with this model there will -# be no local user stored for the logged in identity. -KEYCLOAK_OIDC_PROFILE_MODEL = 'django_keycloak.OpenIdConnectProfile' - -# Class which will be used as User object in case of the remote user OIDC -# Profile -KEYCLOAK_REMOTE_USER_MODEL = 'django_keycloak.remote_user.KeycloakRemoteUser' -KEYCLOAK_PERMISSIONS_METHOD = 'role' # 'role' of 'resource' diff --git a/src/django_keycloak/apps.py b/src/django_keycloak/apps.py deleted file mode 100644 index 0c7870c..0000000 --- a/src/django_keycloak/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps.config import AppConfig - - -class KeycloakAppConfig(AppConfig): - name = 'django_keycloak' - verbose_name = 'Keycloak' diff --git a/src/django_keycloak/auth/__init__.py b/src/django_keycloak/auth/__init__.py deleted file mode 100644 index f1f8707..0000000 --- a/src/django_keycloak/auth/__init__.py +++ /dev/null @@ -1,87 +0,0 @@ -from django.contrib.auth import HASH_SESSION_KEY, BACKEND_SESSION_KEY, \ - _get_backends -from django.contrib.auth.models import AnonymousUser -from django.middleware.csrf import rotate_token -from django.utils import timezone - -import django_keycloak.services.oidc_profile - - -# Using a different session key than the standard django.contrib.auth to -# make sure there is no cross-referencing between UserModel and RemoteUserModel -REMOTE_SESSION_KEY = '_auth_remote_user_id' - - -def _get_user_session_key(request): - return str(request.session[REMOTE_SESSION_KEY]) - - -def get_remote_user(request): - """ - - :param request: - :return: - """ - sub = request.session.get(REMOTE_SESSION_KEY) - - user = None - - OpenIdConnectProfile = django_keycloak.services.oidc_profile\ - .get_openid_connect_profile_model() - - try: - oidc_profile = OpenIdConnectProfile.objects.get( - realm=request.realm, sub=sub) - except OpenIdConnectProfile.DoesNotExist: - pass - else: - if oidc_profile.refresh_expires_before > timezone.now(): - user = oidc_profile.user - - return user or AnonymousUser() - - -def remote_user_login(request, user, backend=None): - """ - Creates a session for the user. - Based on the login function django.contrib.auth.login but uses a slightly - different approach since the user is not backed by a database model. - :param request: - :param user: - :param backend: - :return: - """ - session_auth_hash = '' - if user is None: - user = request.user - - if REMOTE_SESSION_KEY in request.session: - if _get_user_session_key(request) != user.identifier: - request.session.flush() - else: - request.session.cycle_key() - - try: - backend = backend or user.backend - except AttributeError: - backends = _get_backends(return_tuples=True) - if len(backends) == 1: - _, backend = backends[0] - else: - raise ValueError( - 'You have multiple authentication backends configured and ' - 'therefore must provide the `backend` argument or set the ' - '`backend` attribute on the user.' - ) - - if not hasattr(user, 'identifier'): - raise ValueError( - 'The user does not have an identifier or the identifier is empty.' - ) - - request.session[REMOTE_SESSION_KEY] = user.identifier - request.session[BACKEND_SESSION_KEY] = backend - request.session[HASH_SESSION_KEY] = session_auth_hash - if hasattr(request, 'user'): - request.user = user - rotate_token(request) diff --git a/src/django_keycloak/factories.py b/src/django_keycloak/factories.py deleted file mode 100644 index 05d6be7..0000000 --- a/src/django_keycloak/factories.py +++ /dev/null @@ -1,67 +0,0 @@ -import factory - -from django.contrib.auth import get_user_model - -from django_keycloak.models import ( - Client, - OpenIdConnectProfile, - Realm, - Server -) - - -class UserFactory(factory.DjangoModelFactory): - - class Meta(object): - model = get_user_model() - - username = factory.Faker('user_name') - - -class ServerFactory(factory.DjangoModelFactory): - - class Meta(object): - model = Server - - url = factory.Faker('url', schemes=['https']) - - -class RealmFactory(factory.DjangoModelFactory): - - class Meta(object): - model = Realm - - server = factory.SubFactory(ServerFactory) - - name = factory.Faker('slug') - - _certs = '' - _well_known_oidc = '{}' - - client = factory.RelatedFactory('django_keycloak.factories.ClientFactory', - 'realm') - - -class OpenIdConnectProfileFactory(factory.DjangoModelFactory): - - class Meta(object): - model = OpenIdConnectProfile - - sub = factory.Faker('uuid4') - realm = factory.SubFactory(RealmFactory) - user = factory.SubFactory(UserFactory) - - -class ClientFactory(factory.DjangoModelFactory): - - class Meta(object): - model = Client - - realm = factory.SubFactory(RealmFactory, client=None) - service_account_profile = factory.SubFactory( - OpenIdConnectProfileFactory, - realm=factory.SelfAttribute('..realm') - ) - - client_id = factory.Faker('slug') - secret = factory.Faker('uuid4') diff --git a/src/django_keycloak/hashers.py b/src/django_keycloak/hashers.py deleted file mode 100644 index d3c0d4e..0000000 --- a/src/django_keycloak/hashers.py +++ /dev/null @@ -1,9 +0,0 @@ -import hashlib - -from django.contrib.auth.hashers import PBKDF2PasswordHasher - - -class PBKDF2SHA512PasswordHasher(PBKDF2PasswordHasher): - - algorithm = "pbkdf2_sha512" - digest = hashlib.sha512 diff --git a/src/django_keycloak/management/__init__.py b/src/django_keycloak/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/django_keycloak/management/commands/__init__.py b/src/django_keycloak/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/django_keycloak/management/commands/keycloak_add_user.py b/src/django_keycloak/management/commands/keycloak_add_user.py deleted file mode 100644 index 0504ae2..0000000 --- a/src/django_keycloak/management/commands/keycloak_add_user.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import unicode_literals - -import logging - -from django.core.management.base import BaseCommand -from django.contrib.auth import get_user_model - -from django_keycloak.models import Realm - -import django_keycloak.services.users - -logger = logging.getLogger(__name__) - - -def realm(name): - try: - return Realm.objects.get(name=name) - except Realm.DoesNotExist: - raise TypeError('Realm does not exist') - - -def user(username): - UserModel = get_user_model() - try: - return UserModel.objects.get(username=username) - except UserModel.DoesNotExist: - raise TypeError('User does not exist') - - -class Command(BaseCommand): - - def add_arguments(self, parser): - parser.add_argument('--realm', type=realm, required=True) - parser.add_argument('--user', type=user, required=True) - - def handle(self, *args, **options): - user = options['user'] - realm = options['realm'] - - django_keycloak.services.users.add_user(client=realm.client, user=user) diff --git a/src/django_keycloak/management/commands/keycloak_refresh_realm.py b/src/django_keycloak/management/commands/keycloak_refresh_realm.py deleted file mode 100644 index ea95438..0000000 --- a/src/django_keycloak/management/commands/keycloak_refresh_realm.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import unicode_literals - -import logging - -from django.core.management.base import BaseCommand - -from django_keycloak.models import Realm - -import django_keycloak.services.realm - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - - def handle(self, *args, **options): - for realm in Realm.objects.all(): - django_keycloak.services.realm.refresh_well_known_oidc(realm=realm) - django_keycloak.services.realm.refresh_certs(realm=realm) - logger.debug('Refreshed: {}'.format(realm)) diff --git a/src/django_keycloak/management/commands/keycloak_sync_resources.py b/src/django_keycloak/management/commands/keycloak_sync_resources.py deleted file mode 100644 index 269cff6..0000000 --- a/src/django_keycloak/management/commands/keycloak_sync_resources.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import unicode_literals - -import logging - -from django.core.management.base import BaseCommand - -from django_keycloak.models import Client - -import django_keycloak.services.uma - -logger = logging.getLogger(__name__) - - -def client(client_id): - try: - return Client.objects.get(client_id=client_id) - except Client.DoesNotExist: - raise TypeError('Client does not exist') - - -class Command(BaseCommand): - - def add_arguments(self, parser): - parser.add_argument('--client', type=client, required=False) - - def handle(self, *args, **options): - client = options.get('client') - - if client: - django_keycloak.services.uma.synchronize_client(client=client) - else: - for client in Client.objects.all(): - django_keycloak.services.uma.synchronize_client(client=client) diff --git a/src/django_keycloak/middleware.py b/src/django_keycloak/middleware.py deleted file mode 100644 index 73d50f8..0000000 --- a/src/django_keycloak/middleware.py +++ /dev/null @@ -1,122 +0,0 @@ -import re - -from django.conf import settings -from django.contrib.auth import authenticate -from django.contrib.auth.models import AnonymousUser -from django.utils.deprecation import MiddlewareMixin -from django.utils.functional import SimpleLazyObject - -from django_keycloak.models import Realm -from django_keycloak.auth import get_remote_user -from django_keycloak.response import HttpResponseNotAuthorized - - -def get_realm(request): - if not hasattr(request, '_cached_realm'): - request._cached_realm = Realm.objects.first() - return request._cached_realm - - -def get_user(request, origin_user): - # Check for the user as set by - # django.contrib.auth.middleware.AuthenticationMiddleware - if not isinstance(origin_user, AnonymousUser): - return origin_user - - if not hasattr(request, '_cached_user'): - request._cached_user = get_remote_user(request) - return request._cached_user - - -class BaseKeycloakMiddleware(MiddlewareMixin): - - set_session_state_cookie = True - - def process_request(self, request): - """ - Adds Realm to request. - :param request: django request - """ - request.realm = SimpleLazyObject(lambda: get_realm(request)) - - def process_response(self, request, response): - - if self.set_session_state_cookie: - return self.set_session_state_cookie_(request, response) - - return response - - def set_session_state_cookie_(self, request, response): - - if not request.user.is_authenticated \ - or not hasattr(request.user, 'oidc_profile'): - return response - - jwt = request.user.oidc_profile.jwt - if not jwt: - return response - - cookie_name = getattr(settings, 'KEYCLOAK_SESSION_STATE_COOKIE_NAME', - 'session_state') - - # Set a browser readable cookie which expires when the refresh token - # expires. - response.set_cookie( - cookie_name, value=jwt['session_state'], - expires=request.user.oidc_profile.refresh_expires_before, - httponly=False - ) - - return response - - -class KeycloakStatelessBearerAuthenticationMiddleware(BaseKeycloakMiddleware): - - set_session_state_cookie = False - header_key = "HTTP_AUTHORIZATION" - - def process_request(self, request): - """ - Forces authentication on all requests except the URL's configured in - the exempt setting. - """ - super(KeycloakStatelessBearerAuthenticationMiddleware, self)\ - .process_request(request=request) - - if hasattr(settings, 'KEYCLOAK_BEARER_AUTHENTICATION_EXEMPT_PATHS'): - path = request.path_info.lstrip('/') - - if any(re.match(m, path) for m in - settings.KEYCLOAK_BEARER_AUTHENTICATION_EXEMPT_PATHS): - return - - if self.header_key not in request.META: - return HttpResponseNotAuthorized( - attributes={'realm': request.realm.name}) - - user = authenticate( - request=request, - access_token=request.META[self.header_key].split(' ')[1] - ) - - if user is None: - return HttpResponseNotAuthorized( - attributes={'realm': request.realm.name}) - else: - request.user = user - - -class RemoteUserAuthenticationMiddleware(MiddlewareMixin): - set_session_state_cookie = False - - def process_request(self, request): - """ - Adds user to the request when authorized user is found in the session - :param django.http.request.HttpRequest request: django request - """ - origin_user = getattr(request, 'user', None) - - request.user = SimpleLazyObject(lambda: get_user( - request, - origin_user=origin_user - )) diff --git a/src/django_keycloak/migrations/0001_initial.py b/src/django_keycloak/migrations/0001_initial.py deleted file mode 100644 index 0dbe45e..0000000 --- a/src/django_keycloak/migrations/0001_initial.py +++ /dev/null @@ -1,134 +0,0 @@ -# Generated by Django 2.0.2 on 2018-03-15 21:15 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Client', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, - serialize=False, verbose_name='ID')), - ('client_id', models.CharField(max_length=255)), - ('secret', models.CharField(max_length=255)), - ], - ), - migrations.CreateModel( - name='Nonce', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, - serialize=False, verbose_name='ID')), - ('state', models.UUIDField(default=uuid.uuid4, unique=True)), - ('redirect_uri', models.CharField(max_length=255)), - ('next_path', models.CharField(max_length=255, null=True)), - ], - ), - migrations.CreateModel( - name='OpenIdConnectProfile', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, - serialize=False, verbose_name='ID')), - ('access_token', models.TextField(null=True)), - ('expires_before', models.DateTimeField(null=True)), - ('refresh_token', models.TextField(null=True)), - ('refresh_expires_before', models.DateTimeField(null=True)), - ('sub', models.CharField(max_length=255, unique=True)), - ], - options={ - 'abstract': False, - 'swappable': 'KEYCLOAK_OIDC_PROFILE_MODEL', - }, - ), - migrations.CreateModel( - name='Realm', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, - serialize=False, verbose_name='ID')), - ('name', models.CharField( - help_text='Name as known on the Keycloak server. This ' - 'name is used in the API paths of this Realm.', - max_length=255, unique=True)), - ('_certs', models.TextField()), - ('_well_known_oidc', models.TextField(blank=True)), - ], - ), - migrations.CreateModel( - name='Role', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, - serialize=False, verbose_name='ID')), - ('reference', models.CharField(max_length=50)), - ('client', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='roles', to='django_keycloak.Client')), - ('permission', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to='auth.Permission')), - ], - ), - migrations.CreateModel( - name='Server', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, - serialize=False, verbose_name='ID')), - ('url', models.CharField(max_length=255)), - ('internal_url', models.CharField( - blank=True, - help_text='URL on internal netwerk calls. For example ' - 'when used with Docker Compose. Only supply ' - 'when internal calls should go to a different ' - 'url as the end-user will communicate with.', - max_length=255, null=True)), - ], - ), - migrations.AddField( - model_name='realm', - name='server', - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='realms', to='django_keycloak.Server'), - ), - migrations.AddField( - model_name='openidconnectprofile', - name='realm', - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='openid_profiles', to='django_keycloak.Realm'), - ), - migrations.AddField( - model_name='openidconnectprofile', - name='user', - field=models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name='oidc_profile', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='client', - name='realm', - field=models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name='client', to='django_keycloak.Realm'), - ), - migrations.AddField( - model_name='client', - name='service_account', - field=models.OneToOneField( - null=True, on_delete=django.db.models.deletion.CASCADE, - related_name='keycloak_client', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterUniqueTogether( - name='role', - unique_together={('client', 'permission')}, - ), - ] diff --git a/src/django_keycloak/migrations/0002_auto_20180322_2059.py b/src/django_keycloak/migrations/0002_auto_20180322_2059.py deleted file mode 100644 index 528e07a..0000000 --- a/src/django_keycloak/migrations/0002_auto_20180322_2059.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 2.0.3 on 2018-03-22 20:59 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('django_keycloak', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='ExchangedToken', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, - serialize=False, verbose_name='ID')), - ('access_token', models.TextField(null=True)), - ('expires_before', models.DateTimeField(null=True)), - ('refresh_token', models.TextField(null=True)), - ('refresh_expires_before', models.DateTimeField(null=True)), - ('oidc_profile', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to='django_keycloak.OpenIdConnectProfile')), - ], - ), - migrations.CreateModel( - name='RemoteClient', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, - serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('realm', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='remote_clients', - to='django_keycloak.Realm' - )), - ], - ), - migrations.AddField( - model_name='exchangedtoken', - name='remote_client', - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='exchanged_tokens', - to='django_keycloak.RemoteClient' - ), - ), - migrations.AlterUniqueTogether( - name='exchangedtoken', - unique_together={('oidc_profile', 'remote_client')}, - ), - ] diff --git a/src/django_keycloak/migrations/0003_auto_20190204_1949.py b/src/django_keycloak/migrations/0003_auto_20190204_1949.py deleted file mode 100644 index e8f2017..0000000 --- a/src/django_keycloak/migrations/0003_auto_20190204_1949.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 2.1.5 on 2019-02-04 19:49 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('django_keycloak', '0002_auto_20180322_2059'), - ] - - operations = [ - migrations.CreateModel( - name='RemoteUserOpenIdConnectProfile', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, - serialize=False, verbose_name='ID')), - ('access_token', models.TextField(null=True)), - ('expires_before', models.DateTimeField(null=True)), - ('refresh_token', models.TextField(null=True)), - ('refresh_expires_before', models.DateTimeField(null=True)), - ('sub', models.CharField(max_length=255, unique=True)), - ('realm', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name='openid_profiles', - to='django_keycloak.Realm' - )), - ], - options={ - 'abstract': False, - 'swappable': 'KEYCLOAK_OIDC_PROFILE_MODEL', - }, - ), - migrations.AlterField( - model_name='exchangedtoken', - name='oidc_profile', - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.KEYCLOAK_OIDC_PROFILE_MODEL - ), - ), - ] diff --git a/src/django_keycloak/migrations/0004_client_service_account_profile.py b/src/django_keycloak/migrations/0004_client_service_account_profile.py deleted file mode 100644 index 04ad835..0000000 --- a/src/django_keycloak/migrations/0004_client_service_account_profile.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.1.5 on 2019-02-19 13:23 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('django_keycloak', '0003_auto_20190204_1949'), - ] - - operations = [ - migrations.AddField( - model_name='client', - name='service_account_profile', - field=models.OneToOneField( - null=True, - on_delete=django.db.models.deletion.CASCADE, - to=settings.KEYCLOAK_OIDC_PROFILE_MODEL - ), - ), - ] diff --git a/src/django_keycloak/migrations/0005_auto_20190219_2002.py b/src/django_keycloak/migrations/0005_auto_20190219_2002.py deleted file mode 100644 index 0b4b07e..0000000 --- a/src/django_keycloak/migrations/0005_auto_20190219_2002.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 2.1.5 on 2019-02-19 20:02 - -from django.db import migrations - - -def forward(apps, schema_editor): - Client = apps.get_model('django_keycloak', 'Client') - for client in Client.objects.filter(service_account__isnull=False): - client.service_account_profile = client.service_account.oidc_profile - client.save() - - -def backward(apps, schema_editor): - Client = apps.get_model('django_keycloak', 'Client') - for client in Client.objects.filter(service_account_profile__isnull=False): - client.service_account = client.service_account_profile.user - client.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('django_keycloak', '0004_client_service_account_profile'), - ] - - operations = [ - migrations.RunPython(forward, backward), - ] diff --git a/src/django_keycloak/migrations/0006_remove_client_service_account.py b/src/django_keycloak/migrations/0006_remove_client_service_account.py deleted file mode 100644 index 6bc62c0..0000000 --- a/src/django_keycloak/migrations/0006_remove_client_service_account.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 2.1.5 on 2019-02-19 20:22 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('django_keycloak', '0005_auto_20190219_2002'), - ] - - operations = [ - migrations.RemoveField( - model_name='client', - name='service_account', - ), - ] diff --git a/src/django_keycloak/migrations/__init__.py b/src/django_keycloak/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/django_keycloak/remote_user.py b/src/django_keycloak/remote_user.py deleted file mode 100644 index 7a49dde..0000000 --- a/src/django_keycloak/remote_user.py +++ /dev/null @@ -1,213 +0,0 @@ -from django.contrib import auth -from django.core.exceptions import PermissionDenied - -from django_keycloak.models import RemoteUserOpenIdConnectProfile - - -class KeycloakRemoteUser(object): - """ - A class based on django.contrib.auth.models.User. - See https://docs.djangoproject.com/en/2.0/ref/contrib/auth/ - #django.contrib.auth.models.User - """ - - username = '' - first_name = '' - last_name = '' - email = '' - password = '' - groups = [] - user_permissions = [] - - _last_login = None - - def __init__(self, userinfo): - """ - Create KeycloakRemoteUser from userinfo and oidc_profile. - :param dict userinfo: the userinfo as retrieved from the OIDC provider - """ - self.username = userinfo.get('preferred_username') or userinfo['sub'] - self.email = userinfo.get('email', '') - self.first_name = userinfo.get('given_name', '') - self.last_name = userinfo.get('family_name', '') - self.sub = userinfo['sub'] - - def __str__(self): - return self.username - - @property - def pk(self): - """ - Since the BaseAbstractUser is a model, every instance needs a primary - key. The Django authentication backend requires this. - """ - return 0 - - @property - def identifier(self): - """ - Identifier used for session storage. - :rtype: str - """ - return self.sub - - @property - def is_staff(self): - """ - :rtype: bool - :return: whether the user is a staff member or not, defaults to False - """ - return False - - @property - def is_active(self): - """ - :rtype: bool - :return: whether the user is active or not, defaults to True - """ - return True - - @property - def is_superuser(self): - """ - :rtype: bool - :return: whether the user is a superuser or not, defaults to False - """ - return False - - @property - def last_login(self): - """ - :rtype: Datetime - :return: the date and time of the last login - """ - return self._last_login - - @last_login.setter - def last_login(self, content): - """ - :param datetime content: - """ - self._last_login = content - - @property - def is_authenticated(self): - """ - Read-only attribute which is always True. - See https://docs.djangoproject.com/en/2.0/ref/contrib/auth/ - #django.contrib.auth.models.User.is_authenticated - :return: - """ - return True - - @property - def is_anonymous(self): - """ - Read-only attribute which is always False. - See https://docs.djangoproject.com/en/2.0/ref/contrib/auth/ - #django.contrib.auth.models.User.is_anonymous - :return: - """ - return False - - def get_username(self): - """ - Get the username - :return: username - """ - return self.username - - def get_full_name(self): - """ - Get the full name (first name + last name) of the user. - :return: the first name and last name of the user - """ - return "{first} {last}".format(first=self.first_name, - last=self.last_name) - - def get_short_name(self): - """ - Get the first name of the user. - :return: first name - """ - return self.first_name - - def get_group_permissions(self, obj=None): - pass - - def get_all_permissions(self, obj=None): - """ - Logic from django.contrib.auth.models._user_get_all_permissions - :param perm: - :param obj: - :return: - """ - permissions = set() - for backend in auth.get_backends(): - # Excluding Django.contrib.auth backends since they are not - # compatible with non-db-backed permissions. - if hasattr(backend, "get_all_permissions") \ - and not backend.__module__.startswith('django.'): - permissions.update(backend.get_all_permissions(self, obj)) - return permissions - - @property - def oidc_profile(self): - """ - Get the related OIDC Profile for this user. - :rtype: django_keycloak.models.RemoteUserOpenIdConnectProfile - :return: OpenID Connect Profile - """ - try: - return RemoteUserOpenIdConnectProfile.objects.get(sub=self.sub) - except RemoteUserOpenIdConnectProfile.DoesNotExist: - return None - - def has_perm(self, perm, obj=None): - """ - Logic from django.contrib.auth.models._user_has_perm - :param perm: - :param obj: - :return: - """ - for backend in auth.get_backends(): - if not hasattr(backend, 'has_perm') \ - or backend.__module__.startswith('django.contrib.auth'): - continue - try: - if backend.has_perm(self, perm, obj): - return True - except PermissionDenied: - return False - return False - - def has_perms(self, perm_list, obj=None): - return all(self.has_perm(perm, obj) for perm in perm_list) - - def has_module_perms(self, module): - """ - Logic from django.contrib.auth.models._user_has_module_perms - :param module: - :return: - """ - for backend in auth.get_backends(): - if not hasattr(backend, 'has_module_perms'): - continue - try: - if backend.has_module_perms(self, module): - return True - except PermissionDenied: - return False - return False - - def email_user(self, subject, message, from_email=None, **kwargs): - raise NotImplementedError('This feature is not implemented by default,' - ' extend this class to implement') - - def save(self): - """ - Normally implemented by django.db.models.Model - :raises NotImplementedError: to remind that this is not a - database-backed model and should not be used like one - """ - raise NotImplementedError('This is not a database model') diff --git a/src/django_keycloak/response.py b/src/django_keycloak/response.py deleted file mode 100644 index b8c38f9..0000000 --- a/src/django_keycloak/response.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.http.response import HttpResponse - - -class HttpResponseNotAuthorized(HttpResponse): - status_code = 401 - - def __init__(self, content=b'', authorization_method='Bearer', - attributes=None, *args, **kwargs): - super(HttpResponseNotAuthorized, self).__init__(content, *args, - **kwargs) - - attributes = attributes or {} - attributes_str = ', '.join( - [ - '{}="{}"'.format(key, value) - for key, value in attributes.items() - ] - ) - - self['WWW-Authenticate'] = '{} {}'.format(authorization_method, - attributes_str) diff --git a/src/django_keycloak/services/__init__.py b/src/django_keycloak/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/django_keycloak/services/client.py b/src/django_keycloak/services/client.py deleted file mode 100644 index 8257e0f..0000000 --- a/src/django_keycloak/services/client.py +++ /dev/null @@ -1,131 +0,0 @@ -import logging - -from functools import partial - -from django.utils import timezone - -from django_keycloak.services.exceptions import TokensExpired - -import django_keycloak.services.oidc_profile - - -logger = logging.getLogger(__name__) - - -def get_keycloak_id(client): - """ - Get internal Keycloak id for client configured in Realm - :param django_keycloak.models.Realm realm: - :return: - """ - keycloak_clients = client.admin_api_client.realms.by_name( - name=client.realm.name).clients.all() - for keycloak_client in keycloak_clients: - if keycloak_client['clientId'] == client.client_id: - return keycloak_client['id'] - - return None - - -def get_authz_api_client(client): - """ - :param django_keycloak.models.Client client: - :rtype: keycloak.authz.KeycloakAuthz - """ - return client.realm.realm_api_client.authz(client_id=client.client_id) - - -def get_openid_client(client): - """ - :param django_keycloak.models.Client client: - :rtype: keycloak.openid_connect.KeycloakOpenidConnect - """ - openid = client.realm.realm_api_client.open_id_connect( - client_id=client.client_id, - client_secret=client.secret - ) - - if client.realm._well_known_oidc: - openid.well_known.contents = client.realm.well_known_oidc - - return openid - - -def get_uma1_client(client): - """ - :type client: django_keycloak.models.Client - :rtype: keycloak.uma1.KeycloakUMA1 - """ - return client.realm.realm_api_client.uma1 - - -def get_admin_client(client): - """ - Get the Keycloak admin client configured for given realm. - - :param django_keycloak.models.Client client: - :rtype: keycloak.admin.KeycloakAdmin - """ - token = partial(get_access_token, client) - return client.realm.realm_api_client.admin.set_token(token=token) - - -def get_service_account_profile(client): - """ - Get service account for given client. - - :param django_keycloak.models.Client client: - :rtype: django_keycloak.models.OpenIdConnectProfile - """ - - if client.service_account_profile: - return client.service_account_profile - - token_response, initiate_time = get_new_access_token(client=client) - - oidc_profile = django_keycloak.services.oidc_profile._update_or_create( - client=client, - token_response=token_response, - initiate_time=initiate_time) - - client.service_account_profile = oidc_profile - client.save(update_fields=['service_account_profile']) - - return oidc_profile - - -def get_new_access_token(client): - """ - Get client access_token - - :param django_keycloak.models.Client client: - :rtype: str - """ - scope = 'realm-management openid' - - initiate_time = timezone.now() - token_response = client.openid_api_client.client_credentials(scope=scope) - - return token_response, initiate_time - - -def get_access_token(client): - """ - Get access token from client's service account. - :param django_keycloak.models.Client client: - :rtype: str - """ - - oidc_profile = get_service_account_profile(client=client) - - try: - return django_keycloak.services.oidc_profile.get_active_access_token( - oidc_profile=oidc_profile) - except TokensExpired: - token_reponse, initiate_time = get_new_access_token(client=client) - oidc_profile = django_keycloak.services.oidc_profile.update_tokens( - token_model=oidc_profile, - token_response=token_reponse, - initiate_time=initiate_time - ) - return oidc_profile.access_token diff --git a/src/django_keycloak/services/exceptions.py b/src/django_keycloak/services/exceptions.py deleted file mode 100644 index e4bccf3..0000000 --- a/src/django_keycloak/services/exceptions.py +++ /dev/null @@ -1,8 +0,0 @@ - - -class KeycloakOpenIdProfileNotFound(Exception): - pass - - -class TokensExpired(Exception): - pass diff --git a/src/django_keycloak/services/permissions.py b/src/django_keycloak/services/permissions.py deleted file mode 100644 index 28bcbf3..0000000 --- a/src/django_keycloak/services/permissions.py +++ /dev/null @@ -1,36 +0,0 @@ -import logging - -from django.contrib.auth.models import Permission -from requests.exceptions import HTTPError - -import django_keycloak.services.client - -logger = logging.getLogger(__name__) - - -def synchronize(client): - """ - :param django_keycloak.models.Client client: - :return: - """ - keycloak_client_id = django_keycloak.services.client.get_keycloak_id( - client=client) - - role_api = client.admin_api_client.realms.by_name(client.realm.name)\ - .clients.by_id(keycloak_client_id).roles - - for permission in Permission.objects.all(): - - try: - role_api.create(name=permission.codename, - description=permission.name) - except HTTPError as e: - if e.response.status_code != 409: - raise - - else: - continue - - # Update role - role_api.by_name(permission.codename) \ - .update(name=permission.codename, description=permission.name) diff --git a/src/django_keycloak/services/realm.py b/src/django_keycloak/services/realm.py deleted file mode 100644 index 5cda67f..0000000 --- a/src/django_keycloak/services/realm.py +++ /dev/null @@ -1,72 +0,0 @@ -from keycloak.realm import KeycloakRealm - -try: - from urllib.parse import urlparse -except ImportError: - from urlparse import urlparse - - -def get_realm_api_client(realm): - """ - :param django_keycloak.models.Realm realm: - :return keycloak.realm.Realm: - """ - headers = {} - server_url = realm.server.url - if realm.server.internal_url: - # An internal URL is configured. We add some additional settings to let - # Keycloak think that we access it using the server_url. - server_url = realm.server.internal_url - parsed_url = urlparse(realm.server.url) - headers['Host'] = parsed_url.netloc - - if parsed_url.scheme == 'https': - headers['X-Forwarded-Proto'] = 'https' - - return KeycloakRealm(server_url=server_url, realm_name=realm.name, - headers=headers) - - -def refresh_certs(realm): - """ - :param django_keycloak.models.Realm realm: - :rtype django_keycloak.models.Realm - """ - realm.certs = realm.client.openid_api_client.certs() - realm.save(update_fields=['_certs']) - return realm - - -def refresh_well_known_oidc(realm): - """ - Refresh Open ID Connect .well-known - - :param django_keycloak.models.Realm realm: - :rtype django_keycloak.models.Realm - """ - server_url = realm.server.internal_url or realm.server.url - - # While fetching the well_known we should not use the prepared URL - openid_api_client = KeycloakRealm( - server_url=server_url, - realm_name=realm.name - ).open_id_connect(client_id='', client_secret='') - - realm.well_known_oidc = openid_api_client.well_known.contents - realm.save(update_fields=['_well_known_oidc']) - return realm - - -def get_issuer(realm): - """ - Get correct issuer to validate the JWT against. If an internal URL is - configured for the server it will be replaced with the public one. - - :param django_keycloak.models.Realm realm: - :return: issuer - :rtype: str - """ - issuer = realm.well_known_oidc['issuer'] - if realm.server.internal_url: - return issuer.replace(realm.server.internal_url, realm.server.url, 1) - return issuer diff --git a/src/django_keycloak/services/remote_client.py b/src/django_keycloak/services/remote_client.py deleted file mode 100644 index 5a71b77..0000000 --- a/src/django_keycloak/services/remote_client.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging - -from django.utils import timezone - -from django_keycloak.models import ExchangedToken - -import django_keycloak.services.oidc_profile - - -logger = logging.getLogger(__name__) - - -def exchange_token(oidc_profile, remote_client): - """ - Exchange access token from OpenID Connect profile for a token of given - remote client. - - :param django_keycloak.models.OpenIdConnectProfile oidc_profile: - :param django_keycloak.models.RemoteClient remote_client: - :rtype: dict - """ - active_access_token = django_keycloak.services.oidc_profile\ - .get_active_access_token(oidc_profile=oidc_profile) - - # http://www.keycloak.org/docs/latest/securing_apps/index.html#_token-exchange - return oidc_profile.realm.client.openid_api_client.token_exchange( - audience=remote_client.name, - subject_token=active_access_token, - requested_token_type='urn:ietf:params:oauth:token-type:refresh_token' - ) - - -def get_active_remote_client_token(oidc_profile, remote_client): - """ - Get an active remote client token. Exchange when not available or expired. - - :param django_keycloak.models.OpenIdConnectProfile oidc_profile: - :param django_keycloak.models.RemoteClient remote_client: - :rtype: str - """ - exchanged_token, _ = ExchangedToken.objects.get_or_create( - oidc_profile=oidc_profile, - remote_client=remote_client - ) - - initiate_time = timezone.now() - - if exchanged_token.refresh_expires_before is None \ - or initiate_time > exchanged_token.refresh_expires_before \ - or initiate_time > exchanged_token.expires_before: - token_response = exchange_token(oidc_profile, remote_client) - - exchanged_token = django_keycloak.services.oidc_profile.update_tokens( - token_model=exchanged_token, - token_response=token_response, - initiate_time=initiate_time - ) - - return exchanged_token.access_token diff --git a/src/django_keycloak/services/uma.py b/src/django_keycloak/services/uma.py deleted file mode 100644 index 2030276..0000000 --- a/src/django_keycloak/services/uma.py +++ /dev/null @@ -1,63 +0,0 @@ -from django.apps.registry import apps -from django.utils.text import slugify - -from keycloak.exceptions import KeycloakClientError - -import django_keycloak.services.client - - -def synchronize_client(client): - """ - Synchronize all models as resources for a client. - - :type client: django_keycloak.models.Client - """ - for app_config in apps.get_app_configs(): - synchronize_resources( - client=client, - app_config=app_config - ) - - -def synchronize_resources(client, app_config): - """ - Synchronize all resources (models) to the Keycloak server for given client - and Django App. - - :type client: django_keycloak.models.Client - :type app_config: django.apps.config.AppConfig - """ - - if not app_config.models_module: - return - - uma1_client = client.uma1_api_client - - access_token = django_keycloak.services.client.get_access_token( - client=client - ) - - for klass in app_config.get_models(): - scopes = _get_all_permissions(klass._meta) - - try: - uma1_client.resource_set_create( - token=access_token, - name=klass._meta.label_lower, - type='urn:{client}:resources:{model}'.format( - client=slugify(client.client_id), - model=klass._meta.label_lower - ), - scopes=scopes - ) - except KeycloakClientError as e: - if e.original_exc.response.status_code != 409: - raise - - -def _get_all_permissions(meta): - """ - :type meta: django.db.models.options.Options - :rtype: list - """ - return meta.default_permissions diff --git a/src/django_keycloak/services/users.py b/src/django_keycloak/services/users.py deleted file mode 100644 index 718ef22..0000000 --- a/src/django_keycloak/services/users.py +++ /dev/null @@ -1,33 +0,0 @@ -import base64 - - -def credential_representation_from_hash(hash_, temporary=False): - algorithm, hashIterations, salt, hashedSaltedValue = hash_.split('$') - - return { - 'type': 'password', - 'hashedSaltedValue': hashedSaltedValue, - 'algorithm': algorithm.replace('_', '-'), - 'hashIterations': int(hashIterations), - 'salt': base64.b64encode(salt.encode()).decode('ascii').strip(), - 'temporary': temporary - } - - -def add_user(client, user): - """ - Create user in Keycloak based on a local user including password. - - :param django_keycloak.models.Client client: - :param django.contrib.auth.models.User user: - """ - credentials = credential_representation_from_hash(hash_=user.password) - - client.admin_api_client.realms.by_name(client.realm.name).users.create( - username=user.username, - credentials=credentials, - first_name=user.first_name, - last_name=user.last_name, - email=user.email, - enabled=user.is_active - ) diff --git a/src/django_keycloak/templates/django_keycloak/includes/session_iframe_support.html b/src/django_keycloak/templates/django_keycloak/includes/session_iframe_support.html deleted file mode 100644 index 0f0ca1a..0000000 --- a/src/django_keycloak/templates/django_keycloak/includes/session_iframe_support.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/django_keycloak/templates/django_keycloak/session_iframe.html b/src/django_keycloak/templates/django_keycloak/session_iframe.html deleted file mode 100644 index 1fd40a2..0000000 --- a/src/django_keycloak/templates/django_keycloak/session_iframe.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - diff --git a/src/django_keycloak/tests/__init__.py b/src/django_keycloak/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/django_keycloak/tests/backends/__init__.py b/src/django_keycloak/tests/backends/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/django_keycloak/tests/backends/keycloak_authorization_base/__init__.py b/src/django_keycloak/tests/backends/keycloak_authorization_base/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/django_keycloak/tests/backends/keycloak_authorization_base/test_get_keycloak_permissions.py b/src/django_keycloak/tests/backends/keycloak_authorization_base/test_get_keycloak_permissions.py deleted file mode 100644 index 6c0b12b..0000000 --- a/src/django_keycloak/tests/backends/keycloak_authorization_base/test_get_keycloak_permissions.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.test import TestCase, override_settings - -from django_keycloak.factories import OpenIdConnectProfileFactory -from django_keycloak.tests.mixins import MockTestCaseMixin -from django_keycloak.auth.backends import KeycloakAuthorizationBase - - -@override_settings(KEYCLOAK_PERMISSIONS_METHOD='resource') -class BackendsKeycloakAuthorizationBaseGetKeycloakPermissionsTestCase( - MockTestCaseMixin, TestCase): - - def setUp(self): - self.backend = KeycloakAuthorizationBase() - - self.profile = OpenIdConnectProfileFactory() - - self.setup_mock( - 'django_keycloak.services.oidc_profile.get_entitlement', - return_value={ - 'authorization': { - 'permissions': [ - { - 'resource_set_name': 'Resource', - 'scopes': [ - 'Read', - 'Update' - ] - }, - { - 'resource_set_name': 'Resource2' - } - ] - } - }) - - def test_get_keycloak_permissions(self): - """ - Case: The permissions are requested from Keycloak, which are returned - by get_entitlement as a decoded RPT. - Expect: The permissions are extracted from the RPT and are returned - in a list. - """ - permissions = self.backend.get_keycloak_permissions( - user_obj=self.profile.user) - - self.assertListEqual( - ['Read_Resource', 'Update_Resource', 'Resource2'], - permissions - ) diff --git a/src/django_keycloak/tests/backends/keycloak_authorization_base/test_has_perm.py b/src/django_keycloak/tests/backends/keycloak_authorization_base/test_has_perm.py deleted file mode 100644 index b64dcf7..0000000 --- a/src/django_keycloak/tests/backends/keycloak_authorization_base/test_has_perm.py +++ /dev/null @@ -1,89 +0,0 @@ -from django.test import TestCase, override_settings - -from django_keycloak.factories import OpenIdConnectProfileFactory -from django_keycloak.tests.mixins import MockTestCaseMixin -from django_keycloak.auth.backends import KeycloakAuthorizationBase - - -@override_settings(KEYCLOAK_PERMISSIONS_METHOD='resource') -class BackendsKeycloakAuthorizationBaseHasPermTestCase( - MockTestCaseMixin, TestCase): - - def setUp(self): - self.backend = KeycloakAuthorizationBase() - - self.profile = OpenIdConnectProfileFactory(user__is_active=True) - - self.setup_mock( - 'django_keycloak.services.oidc_profile.get_entitlement', - return_value={ - 'authorization': { - 'permissions': [ - { - 'resource_set_name': 'Resource', - 'scopes': [ - 'Read', - 'Update' - ] - }, - { - 'resource_set_name': 'Resource2' - } - ] - } - } - ) - - def test_resource_scope_should_have_permission(self): - """ - Case: Permission is expected that is available to the user. - Expected: Permission granted. - """ - permission = self.backend.has_perm( - user_obj=self.profile.user, perm='Read_Resource') - - self.assertTrue(permission) - - def test_resource_no_scope_should_not_have_permission(self): - """" - Case: Permission is formatted as resource only which does not exist as - such in the RPT. - Expected: Permission denied. - """ - permission = self.backend.has_perm( - user_obj=self.profile.user, perm='Resource') - - self.assertFalse(permission) - - def test_resource_other_scope_should_not_have_permission(self): - """" - Case: Permission is expected with a scope that is not available to - the user according to the RPT. - Expected: Permission denied. - """ - permission = self.backend.has_perm( - user_obj=self.profile.user, perm='Create_Resource') - - self.assertFalse(permission) - - def test_other_resource_other_scope_should_not_have_permission(self): - """" - Case: Permission is expected that is not available to the user - according to the RPT. - Expected: Permission denied. - """ - permission = self.backend.has_perm( - user_obj=self.profile.user, perm='OtherScope_OtherResource') - - self.assertFalse(permission) - - def test_resource_no_scope_should_have_permission(self): - """" - Case: Permission is expected with no scope provided, but scope is - also not provided in the RPT. - Expected: Permission granted. - """ - permission = self.backend.has_perm( - user_obj=self.profile.user, perm='Resource2') - - self.assertTrue(permission) diff --git a/src/django_keycloak/tests/mixins.py b/src/django_keycloak/tests/mixins.py deleted file mode 100644 index 7172dbd..0000000 --- a/src/django_keycloak/tests/mixins.py +++ /dev/null @@ -1,23 +0,0 @@ -import mock - - -class MockTestCaseMixin(object): - - def __init__(self, *args, **kwargs): - self._mocks = {} - - super(MockTestCaseMixin, self).__init__(*args, **kwargs) - - def setup_mock(self, target, autospec=True, **kwargs): - if target in self._mocks: - raise Exception('Target %s already patched', target) - - self._mocks[target] = mock.patch(target, autospec=autospec, **kwargs) - - return self._mocks[target].start() - - def tearDown(self): - for mock_ in self._mocks.values(): - mock_.stop() - - return super(MockTestCaseMixin, self).tearDown() diff --git a/src/django_keycloak/tests/services/__init__.py b/src/django_keycloak/tests/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/django_keycloak/tests/services/oidc_profile/__init__.py b/src/django_keycloak/tests/services/oidc_profile/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/django_keycloak/tests/services/oidc_profile/test_get_active_access_token.py b/src/django_keycloak/tests/services/oidc_profile/test_get_active_access_token.py deleted file mode 100644 index ae176c2..0000000 --- a/src/django_keycloak/tests/services/oidc_profile/test_get_active_access_token.py +++ /dev/null @@ -1,69 +0,0 @@ -import mock - -from datetime import datetime - -from django.test import TestCase -from freezegun import freeze_time -from keycloak.openid_connect import KeycloakOpenidConnect - -from django_keycloak.factories import OpenIdConnectProfileFactory -from django_keycloak.tests.mixins import MockTestCaseMixin - -import django_keycloak.services.oidc_profile - - -class ServicesKeycloakOpenIDProfileGetActiveAccessTokenTestCase( - MockTestCaseMixin, TestCase): - - def setUp(self): - self.oidc_profile = OpenIdConnectProfileFactory( - access_token='access-token', - expires_before=datetime(2018, 3, 5, 1, 0, 0), - refresh_token='refresh-token', - refresh_expires_before=datetime(2018, 3, 5, 2, 0, 0) - ) - self.oidc_profile.realm.client.openid_api_client = mock.MagicMock( - spec_set=KeycloakOpenidConnect) - self.oidc_profile.realm.client.openid_api_client.refresh_token\ - .return_value = { - 'access_token': 'new-access-token', - 'expires_in': 600, - 'refresh_token': 'new-refresh-token', - 'refresh_expires_in': 3600 - } - - @freeze_time('2018-03-05 00:59:00') - def test_not_expired(self): - """ - Case: access token get fetched and is not yet expired - Expected: current token is returned - """ - access_token = django_keycloak.services.oidc_profile\ - .get_active_access_token(oidc_profile=self.oidc_profile) - - self.assertEqual(access_token, 'access-token') - self.assertFalse( - self.oidc_profile.realm.client.openid_api_client.refresh_token - .called - ) - - @freeze_time('2018-03-05 01:01:00') - def test_expired(self): - """ - Case: access token get requested but current one is expired - Expected: A new one get requested - """ - access_token = django_keycloak.services.oidc_profile \ - .get_active_access_token(oidc_profile=self.oidc_profile) - - self.assertEqual(access_token, 'new-access-token') - self.oidc_profile.realm.client.openid_api_client.refresh_token\ - .assert_called_once_with(refresh_token='refresh-token') - - self.oidc_profile.refresh_from_db() - self.assertEqual(self.oidc_profile.access_token, 'new-access-token') - self.assertEqual(self.oidc_profile.expires_before, - datetime(2018, 3, 5, 1, 11, 0)) - self.assertEqual(self.oidc_profile.refresh_token, 'new-refresh-token') - self.assertEqual(self.oidc_profile.refresh_expires_before, - datetime(2018, 3, 5, 2, 1, 0)) diff --git a/src/django_keycloak/tests/services/oidc_profile/test_get_entitlement.py b/src/django_keycloak/tests/services/oidc_profile/test_get_entitlement.py deleted file mode 100644 index 01c0764..0000000 --- a/src/django_keycloak/tests/services/oidc_profile/test_get_entitlement.py +++ /dev/null @@ -1,57 +0,0 @@ -import mock - -from datetime import datetime - -from django.test import TestCase -from keycloak.openid_connect import KeycloakOpenidConnect -from keycloak.authz import KeycloakAuthz - -from django_keycloak.factories import OpenIdConnectProfileFactory -from django_keycloak.tests.mixins import MockTestCaseMixin - -import django_keycloak.services.oidc_profile - - -class ServicesKeycloakOpenIDProfileGetActiveAccessTokenTestCase( - MockTestCaseMixin, TestCase): - - def setUp(self): - self.mocked_get_active_access_token = self.setup_mock( - 'django_keycloak.services.oidc_profile' - '.get_active_access_token' - ) - - self.oidc_profile = OpenIdConnectProfileFactory( - access_token='access-token', - expires_before=datetime(2018, 3, 5, 1, 0, 0), - refresh_token='refresh-token' - ) - self.oidc_profile.realm.client.openid_api_client = mock.MagicMock( - spec_set=KeycloakOpenidConnect) - self.oidc_profile.realm.client.authz_api_client = mock.MagicMock( - spec_set=KeycloakAuthz) - self.oidc_profile.realm.client.authz_api_client.entitlement\ - .return_value = { - 'rpt': 'RPT_VALUE' - } - self.oidc_profile.realm.certs = {'cert': 'cert-value'} - - def test(self): - django_keycloak.services.oidc_profile.get_entitlement( - oidc_profile=self.oidc_profile - ) - self.oidc_profile.realm.client.authz_api_client.entitlement\ - .assert_called_once_with( - token=self.mocked_get_active_access_token.return_value - ) - self.oidc_profile.realm.client.openid_api_client.decode_token\ - .assert_called_once_with( - token='RPT_VALUE', - key=self.oidc_profile.realm.certs, - options={ - 'verify_signature': True, - 'exp': True, - 'iat': True, - 'aud': True - } - ) diff --git a/src/django_keycloak/tests/services/oidc_profile/test_get_or_create_from_id_token.py b/src/django_keycloak/tests/services/oidc_profile/test_get_or_create_from_id_token.py deleted file mode 100644 index 877a38d..0000000 --- a/src/django_keycloak/tests/services/oidc_profile/test_get_or_create_from_id_token.py +++ /dev/null @@ -1,158 +0,0 @@ -import mock - -from datetime import datetime - -from django.test import TestCase -from keycloak.openid_connect import KeycloakOpenidConnect - -from django_keycloak.factories import ClientFactory, \ - OpenIdConnectProfileFactory, UserFactory -from django_keycloak.tests.mixins import MockTestCaseMixin - -import django_keycloak.services.oidc_profile - - -class ServicesOpenIDProfileGetOrCreateFromIdTokenTestCase( - MockTestCaseMixin, TestCase): - - def setUp(self): - self.client = ClientFactory( - realm___certs='{}', - realm___well_known_oidc='{"issuer": "https://issuer"}' - ) - self.client.openid_api_client = mock.MagicMock( - spec_set=KeycloakOpenidConnect) - self.client.openid_api_client.well_known = { - 'id_token_signing_alg_values_supported': ['signing-alg'] - } - self.client.openid_api_client.decode_token.return_value = { - 'sub': 'some-sub', - 'email': 'test@example.com', - 'given_name': 'Some given name', - 'family_name': 'Some family name' - } - - def test_create_with_new_user_new_profile(self): - """ - Case: oidc profile is requested based on a provided id token. - The user and profile do not exist yet. - Expected: oidc profile and user are created with information from - the id token. - """ - profile = django_keycloak.services.oidc_profile. \ - get_or_create_from_id_token( - client=self.client, id_token='some-id-token' - ) - - self.client.openid_api_client.decode_token.assert_called_with( - token='some-id-token', - key=dict(), - algorithms=['signing-alg'], - issuer='https://issuer' - ) - - self.assertEqual(profile.sub, 'some-sub') - self.assertEqual(profile.user.username, 'some-sub') - self.assertEqual(profile.user.email, 'test@example.com') - self.assertEqual(profile.user.first_name, 'Some given name') - self.assertEqual(profile.user.last_name, 'Some family name') - - def test_update_with_existing_profile_new_user(self): - """ - Case: oidc profile is requested based on a provided id token. - The profile exists, but the user doesn't. - Expected: oidc user is created with information from the id token - and linked to the profile. - """ - existing_profile = OpenIdConnectProfileFactory( - access_token='access-token', - expires_before=datetime(2018, 3, 5, 1, 0, 0), - refresh_token='refresh-token', - sub='some-sub' - ) - - profile = django_keycloak.services.oidc_profile. \ - get_or_create_from_id_token( - client=self.client, id_token='some-id-token' - ) - - self.client.openid_api_client.decode_token.assert_called_with( - token='some-id-token', - key=dict(), - algorithms=['signing-alg'], - issuer='https://issuer' - ) - - self.assertEqual(profile.sub, 'some-sub') - self.assertEqual(profile.pk, existing_profile.pk) - self.assertEqual(profile.user.username, 'some-sub') - self.assertEqual(profile.user.email, 'test@example.com') - self.assertEqual(profile.user.first_name, 'Some given name') - self.assertEqual(profile.user.last_name, 'Some family name') - - def test_create_with_existing_user_new_profile(self): - """ - Case: oidc profile is requested based on a provided id token. - The user exists, but the profile doesn't. - Expected: oidc profile is created and user is linked to the profile. - """ - existing_user = UserFactory( - username='some-sub' - ) - - profile = django_keycloak.services.oidc_profile.\ - get_or_create_from_id_token( - client=self.client, id_token='some-id-token' - ) - - self.client.openid_api_client.decode_token.assert_called_with( - token='some-id-token', - key=dict(), - algorithms=['signing-alg'], - issuer='https://issuer' - ) - - self.assertEqual(profile.sub, 'some-sub') - self.assertEqual(profile.user.pk, existing_user.pk) - self.assertEqual(profile.user.username, 'some-sub') - self.assertEqual(profile.user.email, 'test@example.com') - self.assertEqual(profile.user.first_name, 'Some given name') - self.assertEqual(profile.user.last_name, 'Some family name') - - def test_create_with_existing_user_existing_profile(self): - """ - Case: oidc profile is requested based on a provided id token. - The user and profile already exist. - Expected: existing oidc profile is returned with existing user linked - to it. - """ - existing_user = UserFactory( - username='some-sub' - ) - - existing_profile = OpenIdConnectProfileFactory( - access_token='access-token', - expires_before=datetime(2018, 3, 5, 1, 0, 0), - refresh_token='refresh-token', - sub='some-sub' - ) - - profile = django_keycloak.services.oidc_profile.\ - get_or_create_from_id_token( - client=self.client, id_token='some-id-token' - ) - - self.client.openid_api_client.decode_token.assert_called_with( - token='some-id-token', - key=dict(), - algorithms=['signing-alg'], - issuer='https://issuer' - ) - - self.assertEqual(profile.pk, existing_profile.pk) - self.assertEqual(profile.sub, 'some-sub') - self.assertEqual(profile.user.pk, existing_user.pk) - self.assertEqual(profile.user.username, 'some-sub') - self.assertEqual(profile.user.email, 'test@example.com') - self.assertEqual(profile.user.first_name, 'Some given name') - self.assertEqual(profile.user.last_name, 'Some family name') diff --git a/src/django_keycloak/tests/services/oidc_profile/test_update_or_create.py b/src/django_keycloak/tests/services/oidc_profile/test_update_or_create.py deleted file mode 100644 index b92461f..0000000 --- a/src/django_keycloak/tests/services/oidc_profile/test_update_or_create.py +++ /dev/null @@ -1,126 +0,0 @@ -import mock - -from datetime import datetime - -from django.contrib.auth import get_user_model -from django.test import TestCase -from freezegun import freeze_time -from keycloak.openid_connect import KeycloakOpenidConnect - -from django_keycloak.factories import ClientFactory -from django_keycloak.models import OpenIdConnectProfile -from django_keycloak.tests.mixins import MockTestCaseMixin - -import django_keycloak.services.oidc_profile - - -class ServicesKeycloakOpenIDProfileUpdateOrCreateTestCase(MockTestCaseMixin, - TestCase): - - def setUp(self): - self.client = ClientFactory( - realm___certs='{}', - realm___well_known_oidc='{"issuer": "https://issuer"}' - ) - self.client.openid_api_client = mock.MagicMock( - spec_set=KeycloakOpenidConnect) - self.client.openid_api_client.authorization_code.return_value = { - 'id_token': 'id-token', - 'expires_in': 600, - 'refresh_expires_in': 3600, - 'access_token': 'access-token', - 'refresh_token': 'refresh-token' - } - self.client.openid_api_client.well_known = { - 'id_token_signing_alg_values_supported': ['signing-alg'] - } - self.client.openid_api_client.decode_token.return_value = { - 'sub': 'some-sub', - 'email': 'test@example.com', - 'given_name': 'Some given name', - 'family_name': 'Some family name' - } - - @freeze_time('2018-03-01 00:00:00') - def test_create(self): - django_keycloak.services.oidc_profile.update_or_create_from_code( - client=self.client, - code='some-code', - redirect_uri='https://redirect' - ) - self.client.openid_api_client.authorization_code\ - .assert_called_once_with(code='some-code', - redirect_uri='https://redirect') - self.client.openid_api_client.decode_token.assert_called_once_with( - token='id-token', - key=dict(), - algorithms=['signing-alg'], - issuer='https://issuer' - ) - - profile = OpenIdConnectProfile.objects.get(sub='some-sub') - self.assertEqual(profile.sub, 'some-sub'), - self.assertEqual(profile.access_token, 'access-token') - self.assertEqual(profile.refresh_token, 'refresh-token') - self.assertEqual(profile.expires_before, datetime( - year=2018, month=3, day=1, hour=0, minute=10, second=0 - )) - self.assertEqual(profile.refresh_expires_before, datetime( - year=2018, month=3, day=1, hour=1, minute=0, second=0 - )) - - user = profile.user - self.assertEqual(user.username, 'some-sub') - self.assertEqual(user.first_name, 'Some given name') - self.assertEqual(user.last_name, 'Some family name') - - @freeze_time('2018-03-01 00:00:00') - def test_update(self): - UserModel = get_user_model() - user = UserModel.objects.create( - username='some-sub', - email='', - first_name='', - last_name='' - ) - profile = OpenIdConnectProfile.objects.create( - realm=self.client.realm, - sub='some-sub', - user=user, - access_token='another-access-token', - expires_before=datetime.now(), - refresh_token='another-refresh-token', - refresh_expires_before=datetime.now() - ) - - django_keycloak.services.oidc_profile.update_or_create_from_code( - client=self.client, - code='some-code', - redirect_uri='https://redirect' - ) - self.client.openid_api_client.authorization_code\ - .assert_called_once_with(code='some-code', - redirect_uri='https://redirect') - self.client.openid_api_client.decode_token.assert_called_once_with( - token='id-token', - key=dict(), - algorithms=['signing-alg'], - issuer='https://issuer' - ) - - profile.refresh_from_db() - self.assertEqual(profile.sub, 'some-sub') - self.assertEqual(profile.access_token, 'access-token') - self.assertEqual(profile.refresh_token, 'refresh-token') - self.assertEqual(profile.expires_before, datetime( - year=2018, month=3, day=1, hour=0, minute=10, second=0 - )) - self.assertEqual(profile.refresh_expires_before, datetime( - year=2018, month=3, day=1, hour=1, minute=0, second=0 - )) - - user = profile.user - user.refresh_from_db() - self.assertEqual(user.username, 'some-sub') - self.assertEqual(user.first_name, 'Some given name') - self.assertEqual(user.last_name, 'Some family name') diff --git a/src/django_keycloak/tests/services/realm/__init__.py b/src/django_keycloak/tests/services/realm/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/django_keycloak/tests/services/realm/test_get_realm_api_client.py b/src/django_keycloak/tests/services/realm/test_get_realm_api_client.py deleted file mode 100644 index 9e77267..0000000 --- a/src/django_keycloak/tests/services/realm/test_get_realm_api_client.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.test import TestCase - -from django_keycloak.factories import ServerFactory, RealmFactory -from django_keycloak.tests.mixins import MockTestCaseMixin - -import django_keycloak.services.realm - - -class ServicesRealmGetRealmApiClientTestCase( - MockTestCaseMixin, TestCase): - - def setUp(self): - self.server = ServerFactory( - url='https://some-url', - internal_url='' - ) - - self.realm = RealmFactory( - server=self.server, - name='test-realm' - ) - - def test_get_realm_api_client(self): - """ - Case: a realm api client is requested for a realm on a server without - internal_url. - Expected: a KeycloakRealm client is returned with settings based on the - provided realm. The server_url in the client is the provided url. - """ - client = django_keycloak.services.realm.\ - get_realm_api_client(realm=self.realm) - - self.assertEqual(client.server_url, self.server.url) - self.assertEqual(client.realm_name, self.realm.name) - - def test_get_realm_api_client_with_internal_url(self): - """ - Case: a realm api client is requested for a realm on a server with - internal_url. - Expected: a KeycloakRealm client is returned with settings based on the - provided realm. The server_url in the client is the provided url. - """ - self.server.internal_url = 'https://some-internal-url' - - client = django_keycloak.services.realm.\ - get_realm_api_client(realm=self.realm) - - self.assertEqual(client.server_url, self.server.internal_url) - self.assertEqual(client.realm_name, self.realm.name) diff --git a/src/django_keycloak/tests/services/realm/test_refresh_well_known_oidc.py b/src/django_keycloak/tests/services/realm/test_refresh_well_known_oidc.py deleted file mode 100644 index 0acd0fb..0000000 --- a/src/django_keycloak/tests/services/realm/test_refresh_well_known_oidc.py +++ /dev/null @@ -1,36 +0,0 @@ -import mock - -from django.test import TestCase - -from django_keycloak.factories import RealmFactory -from django_keycloak.tests.mixins import MockTestCaseMixin - -import django_keycloak.services.realm - - -class ServicesRealmRefreshWellKnownOIDCTestCase( - MockTestCaseMixin, TestCase): - - def setUp(self): - self.realm = RealmFactory( - name='test-realm', - _well_known_oidc='empty' - ) - - keycloak_oidc_mock = mock.MagicMock() - keycloak_oidc_mock.well_known.contents = {'key': 'value'} - self.setup_mock('keycloak.realm.KeycloakRealm.open_id_connect', - return_value=keycloak_oidc_mock) - - def test_refresh_well_known_oidc(self): - """ - Case: An update is requested for the .well-known for a specified realm. - Expected: The .well-known is updated. - """ - self.assertEqual(self.realm._well_known_oidc, 'empty') - - django_keycloak.services.realm.refresh_well_known_oidc( - realm=self.realm - ) - - self.assertEqual(self.realm._well_known_oidc, '{"key": "value"}') diff --git a/src/django_keycloak/tests/settings.py b/src/django_keycloak/tests/settings.py deleted file mode 100644 index b16dbd2..0000000 --- a/src/django_keycloak/tests/settings.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging - -from django_keycloak.app_settings import * # noqa: F403,F401 - -PASSWORD_HASHERS = ( - 'django.contrib.auth.hashers.MD5PasswordHasher', -) - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'secret-key' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = False - -LOGIN_URL = 'keycloak_login' -LOGOUT_REDIRECT_URL = 'index' - -# Application definition -INSTALLED_APPS = [ - 'django.contrib.auth', - 'django.contrib.contenttypes', - - 'django_keycloak.apps.KeycloakAppConfig', -] - -MIDDLEWARE = [ - 'django_keycloak.middleware.BaseKeycloakMiddleware', -] - -AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', - 'django_keycloak.auth.backends.KeycloakAuthorizationCodeBackend', -] - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - } -} - -logging.disable(logging.CRITICAL) From 67df9a86d8f9bb80cf970d5ccd248cebffc7bc1c Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Wed, 29 Nov 2023 11:57:57 -0500 Subject: [PATCH 12/15] merge in lost files from dabocs/django-keycloak [wtf?!?!?!] --- django_keycloak/__init__.py | 10 + django_keycloak/admin/__init__.py | 8 + django_keycloak/admin/realm.py | 155 +++++++++++++ django_keycloak/admin/server.py | 10 + django_keycloak/app_settings.py | 12 + django_keycloak/apps.py | 6 + django_keycloak/auth/__init__.py | 87 +++++++ django_keycloak/factories.py | 67 ++++++ django_keycloak/hashers.py | 9 + django_keycloak/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/keycloak_add_user.py | 40 ++++ .../commands/keycloak_refresh_realm.py | 20 ++ .../commands/keycloak_sync_resources.py | 33 +++ django_keycloak/middleware.py | 122 ++++++++++ django_keycloak/migrations/0001_initial.py | 134 +++++++++++ .../migrations/0002_auto_20180322_2059.py | 54 +++++ .../migrations/0003_auto_20190204_1949.py | 44 ++++ .../0004_client_service_account_profile.py | 24 ++ .../migrations/0005_auto_20190219_2002.py | 28 +++ .../0006_remove_client_service_account.py | 17 ++ django_keycloak/migrations/__init__.py | 0 django_keycloak/remote_user.py | 213 ++++++++++++++++++ django_keycloak/response.py | 21 ++ django_keycloak/services/__init__.py | 0 django_keycloak/services/client.py | 131 +++++++++++ django_keycloak/services/exceptions.py | 8 + django_keycloak/services/permissions.py | 36 +++ django_keycloak/services/realm.py | 72 ++++++ django_keycloak/services/remote_client.py | 59 +++++ django_keycloak/services/uma.py | 63 ++++++ django_keycloak/services/users.py | 33 +++ .../includes/session_iframe_support.html | 1 + .../django_keycloak/session_iframe.html | 50 ++++ django_keycloak/tests/__init__.py | 0 django_keycloak/tests/backends/__init__.py | 0 .../keycloak_authorization_base/__init__.py | 0 .../test_get_keycloak_permissions.py | 49 ++++ .../test_has_perm.py | 89 ++++++++ django_keycloak/tests/mixins.py | 23 ++ django_keycloak/tests/services/__init__.py | 0 .../tests/services/oidc_profile/__init__.py | 0 .../test_get_active_access_token.py | 69 ++++++ .../oidc_profile/test_get_entitlement.py | 57 +++++ .../test_get_or_create_from_id_token.py | 158 +++++++++++++ .../oidc_profile/test_update_or_create.py | 126 +++++++++++ .../tests/services/realm/__init__.py | 0 .../realm/test_get_realm_api_client.py | 49 ++++ .../realm/test_refresh_well_known_oidc.py | 36 +++ django_keycloak/tests/settings.py | 42 ++++ 50 files changed, 2265 insertions(+) create mode 100644 django_keycloak/__init__.py create mode 100644 django_keycloak/admin/__init__.py create mode 100644 django_keycloak/admin/realm.py create mode 100644 django_keycloak/admin/server.py create mode 100644 django_keycloak/app_settings.py create mode 100644 django_keycloak/apps.py create mode 100644 django_keycloak/auth/__init__.py create mode 100644 django_keycloak/factories.py create mode 100644 django_keycloak/hashers.py create mode 100644 django_keycloak/management/__init__.py create mode 100644 django_keycloak/management/commands/__init__.py create mode 100644 django_keycloak/management/commands/keycloak_add_user.py create mode 100644 django_keycloak/management/commands/keycloak_refresh_realm.py create mode 100644 django_keycloak/management/commands/keycloak_sync_resources.py create mode 100644 django_keycloak/middleware.py create mode 100644 django_keycloak/migrations/0001_initial.py create mode 100644 django_keycloak/migrations/0002_auto_20180322_2059.py create mode 100644 django_keycloak/migrations/0003_auto_20190204_1949.py create mode 100644 django_keycloak/migrations/0004_client_service_account_profile.py create mode 100644 django_keycloak/migrations/0005_auto_20190219_2002.py create mode 100644 django_keycloak/migrations/0006_remove_client_service_account.py create mode 100644 django_keycloak/migrations/__init__.py create mode 100644 django_keycloak/remote_user.py create mode 100644 django_keycloak/response.py create mode 100644 django_keycloak/services/__init__.py create mode 100644 django_keycloak/services/client.py create mode 100644 django_keycloak/services/exceptions.py create mode 100644 django_keycloak/services/permissions.py create mode 100644 django_keycloak/services/realm.py create mode 100644 django_keycloak/services/remote_client.py create mode 100644 django_keycloak/services/uma.py create mode 100644 django_keycloak/services/users.py create mode 100644 django_keycloak/templates/django_keycloak/includes/session_iframe_support.html create mode 100644 django_keycloak/templates/django_keycloak/session_iframe.html create mode 100644 django_keycloak/tests/__init__.py create mode 100644 django_keycloak/tests/backends/__init__.py create mode 100644 django_keycloak/tests/backends/keycloak_authorization_base/__init__.py create mode 100644 django_keycloak/tests/backends/keycloak_authorization_base/test_get_keycloak_permissions.py create mode 100644 django_keycloak/tests/backends/keycloak_authorization_base/test_has_perm.py create mode 100644 django_keycloak/tests/mixins.py create mode 100644 django_keycloak/tests/services/__init__.py create mode 100644 django_keycloak/tests/services/oidc_profile/__init__.py create mode 100644 django_keycloak/tests/services/oidc_profile/test_get_active_access_token.py create mode 100644 django_keycloak/tests/services/oidc_profile/test_get_entitlement.py create mode 100644 django_keycloak/tests/services/oidc_profile/test_get_or_create_from_id_token.py create mode 100644 django_keycloak/tests/services/oidc_profile/test_update_or_create.py create mode 100644 django_keycloak/tests/services/realm/__init__.py create mode 100644 django_keycloak/tests/services/realm/test_get_realm_api_client.py create mode 100644 django_keycloak/tests/services/realm/test_refresh_well_known_oidc.py create mode 100644 django_keycloak/tests/settings.py diff --git a/django_keycloak/__init__.py b/django_keycloak/__init__.py new file mode 100644 index 0000000..2c093e7 --- /dev/null +++ b/django_keycloak/__init__.py @@ -0,0 +1,10 @@ +from . import app_settings as defaults +from django.conf import settings + + +default_app_config = 'django_keycloak.apps.KeycloakAppConfig' + +# Set some app default settings +for name in dir(defaults): + if name.isupper() and not hasattr(settings, name): + setattr(settings, name, getattr(defaults, name)) diff --git a/django_keycloak/admin/__init__.py b/django_keycloak/admin/__init__.py new file mode 100644 index 0000000..965a492 --- /dev/null +++ b/django_keycloak/admin/__init__.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from django_keycloak.admin.realm import RealmAdmin +from django_keycloak.admin.server import ServerAdmin +from django_keycloak.models import Server, Realm + +admin.site.register(Realm, RealmAdmin) +admin.site.register(Server, ServerAdmin) diff --git a/django_keycloak/admin/realm.py b/django_keycloak/admin/realm.py new file mode 100644 index 0000000..44a5fb4 --- /dev/null +++ b/django_keycloak/admin/realm.py @@ -0,0 +1,155 @@ +from django.contrib import admin, messages +from keycloak.exceptions import KeycloakClientError +from requests.exceptions import HTTPError + +from django_keycloak.models import ( + Client, + OpenIdConnectProfile, + RemoteClient +) +import django_keycloak.services.permissions +import django_keycloak.services.realm +import django_keycloak.services.uma + + +def refresh_open_id_connect_well_known(modeladmin, request, queryset): + for realm in queryset: + django_keycloak.services.realm.refresh_well_known_oidc(realm=realm) + modeladmin.message_user( + request=request, + message='OpenID Connect .well-known refreshed', + level=messages.SUCCESS + ) + + +refresh_open_id_connect_well_known.short_description = 'Refresh OpenID ' \ + 'Connect .well-known' + + +def refresh_certs(modeladmin, request, queryset): + for realm in queryset: + django_keycloak.services.realm.refresh_certs(realm=realm) + modeladmin.message_user( + request=request, + message='Certificates refreshed', + level=messages.SUCCESS + ) + + +refresh_certs.short_description = 'Refresh Certificates' + + +def clear_client_tokens(modeladmin, request, queryset): + OpenIdConnectProfile.objects.filter(realm__in=queryset).update( + access_token=None, + expires_before=None, + refresh_token=None, + refresh_expires_before=None + ) + modeladmin.message_user( + request=request, + message='Tokens cleared', + level=messages.SUCCESS + ) + + +clear_client_tokens.short_description = 'Clear client tokens' + + +def synchronize_permissions(modeladmin, request, queryset): + for realm in queryset: + try: + django_keycloak.services.permissions.synchronize( + client=realm.client) + except HTTPError as e: + if e.response.status_code == 403: + modeladmin.message_user( + request=request, + message='Forbidden for {}. Does the client\'s service ' + 'account has the "keycloak_client" role?'.format( + realm.name + ), + level=messages.ERROR + ) + return + else: + raise + modeladmin.message_user( + request=request, + message='Permissions synchronized', + level=messages.SUCCESS + ) + + +synchronize_permissions.short_description = 'Synchronize permissions' + + +def synchronize_resources(modeladmin, request, queryset): + for realm in queryset: + try: + django_keycloak.services.uma.synchronize_client( + client=realm.client) + except KeycloakClientError as e: + if e.original_exc.response.status_code == 400: + modeladmin.message_user( + request=request, + message='Forbidden for {}. Is "Remote Resource ' + 'Management" enabled for the related client?' + .format( + realm.name + ), + level=messages.ERROR + ) + return + else: + raise + modeladmin.message_user( + request=request, + message='Resources synchronized', + level=messages.SUCCESS + ) + + +synchronize_resources.short_description = 'Synchronize models as Keycloak ' \ + 'resources' + + +class ClientAdmin(admin.TabularInline): + + model = Client + + fields = ('client_id', 'secret') + + +class RemoteClientAdmin(admin.TabularInline): + + model = RemoteClient + + extra = 1 + + fields = ('name',) + + +class RealmAdmin(admin.ModelAdmin): + + inlines = [ClientAdmin, RemoteClientAdmin] + + actions = [ + refresh_open_id_connect_well_known, + refresh_certs, + clear_client_tokens, + synchronize_permissions, + synchronize_resources + ] + + fieldsets = ( + (None, { + 'fields': ('name',) + }), + ('Location', { + 'fields': ('server', '_well_known_oidc',) + }) + + ) + + readonly_fields = ('_well_known_oidc',) diff --git a/django_keycloak/admin/server.py b/django_keycloak/admin/server.py new file mode 100644 index 0000000..3f01b5e --- /dev/null +++ b/django_keycloak/admin/server.py @@ -0,0 +1,10 @@ +from django.contrib import admin + + +class ServerAdmin(admin.ModelAdmin): + + fieldsets = ( + ('Location', { + 'fields': ('url', 'internal_url') + }), + ) diff --git a/django_keycloak/app_settings.py b/django_keycloak/app_settings.py new file mode 100644 index 0000000..10562e6 --- /dev/null +++ b/django_keycloak/app_settings.py @@ -0,0 +1,12 @@ +# Configure the model which need to be used to store the Open ID connect +# profile. There are two choices: +# - django_keycloak.OpenIdConnectProfile (Default) a local User object get +# created for the logged in identity. +# - django_keycloak.RemoteUserOpenIdConnectProfile with this model there will +# be no local user stored for the logged in identity. +KEYCLOAK_OIDC_PROFILE_MODEL = 'django_keycloak.OpenIdConnectProfile' + +# Class which will be used as User object in case of the remote user OIDC +# Profile +KEYCLOAK_REMOTE_USER_MODEL = 'django_keycloak.remote_user.KeycloakRemoteUser' +KEYCLOAK_PERMISSIONS_METHOD = 'role' # 'role' of 'resource' diff --git a/django_keycloak/apps.py b/django_keycloak/apps.py new file mode 100644 index 0000000..0c7870c --- /dev/null +++ b/django_keycloak/apps.py @@ -0,0 +1,6 @@ +from django.apps.config import AppConfig + + +class KeycloakAppConfig(AppConfig): + name = 'django_keycloak' + verbose_name = 'Keycloak' diff --git a/django_keycloak/auth/__init__.py b/django_keycloak/auth/__init__.py new file mode 100644 index 0000000..f1f8707 --- /dev/null +++ b/django_keycloak/auth/__init__.py @@ -0,0 +1,87 @@ +from django.contrib.auth import HASH_SESSION_KEY, BACKEND_SESSION_KEY, \ + _get_backends +from django.contrib.auth.models import AnonymousUser +from django.middleware.csrf import rotate_token +from django.utils import timezone + +import django_keycloak.services.oidc_profile + + +# Using a different session key than the standard django.contrib.auth to +# make sure there is no cross-referencing between UserModel and RemoteUserModel +REMOTE_SESSION_KEY = '_auth_remote_user_id' + + +def _get_user_session_key(request): + return str(request.session[REMOTE_SESSION_KEY]) + + +def get_remote_user(request): + """ + + :param request: + :return: + """ + sub = request.session.get(REMOTE_SESSION_KEY) + + user = None + + OpenIdConnectProfile = django_keycloak.services.oidc_profile\ + .get_openid_connect_profile_model() + + try: + oidc_profile = OpenIdConnectProfile.objects.get( + realm=request.realm, sub=sub) + except OpenIdConnectProfile.DoesNotExist: + pass + else: + if oidc_profile.refresh_expires_before > timezone.now(): + user = oidc_profile.user + + return user or AnonymousUser() + + +def remote_user_login(request, user, backend=None): + """ + Creates a session for the user. + Based on the login function django.contrib.auth.login but uses a slightly + different approach since the user is not backed by a database model. + :param request: + :param user: + :param backend: + :return: + """ + session_auth_hash = '' + if user is None: + user = request.user + + if REMOTE_SESSION_KEY in request.session: + if _get_user_session_key(request) != user.identifier: + request.session.flush() + else: + request.session.cycle_key() + + try: + backend = backend or user.backend + except AttributeError: + backends = _get_backends(return_tuples=True) + if len(backends) == 1: + _, backend = backends[0] + else: + raise ValueError( + 'You have multiple authentication backends configured and ' + 'therefore must provide the `backend` argument or set the ' + '`backend` attribute on the user.' + ) + + if not hasattr(user, 'identifier'): + raise ValueError( + 'The user does not have an identifier or the identifier is empty.' + ) + + request.session[REMOTE_SESSION_KEY] = user.identifier + request.session[BACKEND_SESSION_KEY] = backend + request.session[HASH_SESSION_KEY] = session_auth_hash + if hasattr(request, 'user'): + request.user = user + rotate_token(request) diff --git a/django_keycloak/factories.py b/django_keycloak/factories.py new file mode 100644 index 0000000..05d6be7 --- /dev/null +++ b/django_keycloak/factories.py @@ -0,0 +1,67 @@ +import factory + +from django.contrib.auth import get_user_model + +from django_keycloak.models import ( + Client, + OpenIdConnectProfile, + Realm, + Server +) + + +class UserFactory(factory.DjangoModelFactory): + + class Meta(object): + model = get_user_model() + + username = factory.Faker('user_name') + + +class ServerFactory(factory.DjangoModelFactory): + + class Meta(object): + model = Server + + url = factory.Faker('url', schemes=['https']) + + +class RealmFactory(factory.DjangoModelFactory): + + class Meta(object): + model = Realm + + server = factory.SubFactory(ServerFactory) + + name = factory.Faker('slug') + + _certs = '' + _well_known_oidc = '{}' + + client = factory.RelatedFactory('django_keycloak.factories.ClientFactory', + 'realm') + + +class OpenIdConnectProfileFactory(factory.DjangoModelFactory): + + class Meta(object): + model = OpenIdConnectProfile + + sub = factory.Faker('uuid4') + realm = factory.SubFactory(RealmFactory) + user = factory.SubFactory(UserFactory) + + +class ClientFactory(factory.DjangoModelFactory): + + class Meta(object): + model = Client + + realm = factory.SubFactory(RealmFactory, client=None) + service_account_profile = factory.SubFactory( + OpenIdConnectProfileFactory, + realm=factory.SelfAttribute('..realm') + ) + + client_id = factory.Faker('slug') + secret = factory.Faker('uuid4') diff --git a/django_keycloak/hashers.py b/django_keycloak/hashers.py new file mode 100644 index 0000000..d3c0d4e --- /dev/null +++ b/django_keycloak/hashers.py @@ -0,0 +1,9 @@ +import hashlib + +from django.contrib.auth.hashers import PBKDF2PasswordHasher + + +class PBKDF2SHA512PasswordHasher(PBKDF2PasswordHasher): + + algorithm = "pbkdf2_sha512" + digest = hashlib.sha512 diff --git a/django_keycloak/management/__init__.py b/django_keycloak/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_keycloak/management/commands/__init__.py b/django_keycloak/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_keycloak/management/commands/keycloak_add_user.py b/django_keycloak/management/commands/keycloak_add_user.py new file mode 100644 index 0000000..0504ae2 --- /dev/null +++ b/django_keycloak/management/commands/keycloak_add_user.py @@ -0,0 +1,40 @@ +from __future__ import unicode_literals + +import logging + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model + +from django_keycloak.models import Realm + +import django_keycloak.services.users + +logger = logging.getLogger(__name__) + + +def realm(name): + try: + return Realm.objects.get(name=name) + except Realm.DoesNotExist: + raise TypeError('Realm does not exist') + + +def user(username): + UserModel = get_user_model() + try: + return UserModel.objects.get(username=username) + except UserModel.DoesNotExist: + raise TypeError('User does not exist') + + +class Command(BaseCommand): + + def add_arguments(self, parser): + parser.add_argument('--realm', type=realm, required=True) + parser.add_argument('--user', type=user, required=True) + + def handle(self, *args, **options): + user = options['user'] + realm = options['realm'] + + django_keycloak.services.users.add_user(client=realm.client, user=user) diff --git a/django_keycloak/management/commands/keycloak_refresh_realm.py b/django_keycloak/management/commands/keycloak_refresh_realm.py new file mode 100644 index 0000000..ea95438 --- /dev/null +++ b/django_keycloak/management/commands/keycloak_refresh_realm.py @@ -0,0 +1,20 @@ +from __future__ import unicode_literals + +import logging + +from django.core.management.base import BaseCommand + +from django_keycloak.models import Realm + +import django_keycloak.services.realm + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + + def handle(self, *args, **options): + for realm in Realm.objects.all(): + django_keycloak.services.realm.refresh_well_known_oidc(realm=realm) + django_keycloak.services.realm.refresh_certs(realm=realm) + logger.debug('Refreshed: {}'.format(realm)) diff --git a/django_keycloak/management/commands/keycloak_sync_resources.py b/django_keycloak/management/commands/keycloak_sync_resources.py new file mode 100644 index 0000000..269cff6 --- /dev/null +++ b/django_keycloak/management/commands/keycloak_sync_resources.py @@ -0,0 +1,33 @@ +from __future__ import unicode_literals + +import logging + +from django.core.management.base import BaseCommand + +from django_keycloak.models import Client + +import django_keycloak.services.uma + +logger = logging.getLogger(__name__) + + +def client(client_id): + try: + return Client.objects.get(client_id=client_id) + except Client.DoesNotExist: + raise TypeError('Client does not exist') + + +class Command(BaseCommand): + + def add_arguments(self, parser): + parser.add_argument('--client', type=client, required=False) + + def handle(self, *args, **options): + client = options.get('client') + + if client: + django_keycloak.services.uma.synchronize_client(client=client) + else: + for client in Client.objects.all(): + django_keycloak.services.uma.synchronize_client(client=client) diff --git a/django_keycloak/middleware.py b/django_keycloak/middleware.py new file mode 100644 index 0000000..73d50f8 --- /dev/null +++ b/django_keycloak/middleware.py @@ -0,0 +1,122 @@ +import re + +from django.conf import settings +from django.contrib.auth import authenticate +from django.contrib.auth.models import AnonymousUser +from django.utils.deprecation import MiddlewareMixin +from django.utils.functional import SimpleLazyObject + +from django_keycloak.models import Realm +from django_keycloak.auth import get_remote_user +from django_keycloak.response import HttpResponseNotAuthorized + + +def get_realm(request): + if not hasattr(request, '_cached_realm'): + request._cached_realm = Realm.objects.first() + return request._cached_realm + + +def get_user(request, origin_user): + # Check for the user as set by + # django.contrib.auth.middleware.AuthenticationMiddleware + if not isinstance(origin_user, AnonymousUser): + return origin_user + + if not hasattr(request, '_cached_user'): + request._cached_user = get_remote_user(request) + return request._cached_user + + +class BaseKeycloakMiddleware(MiddlewareMixin): + + set_session_state_cookie = True + + def process_request(self, request): + """ + Adds Realm to request. + :param request: django request + """ + request.realm = SimpleLazyObject(lambda: get_realm(request)) + + def process_response(self, request, response): + + if self.set_session_state_cookie: + return self.set_session_state_cookie_(request, response) + + return response + + def set_session_state_cookie_(self, request, response): + + if not request.user.is_authenticated \ + or not hasattr(request.user, 'oidc_profile'): + return response + + jwt = request.user.oidc_profile.jwt + if not jwt: + return response + + cookie_name = getattr(settings, 'KEYCLOAK_SESSION_STATE_COOKIE_NAME', + 'session_state') + + # Set a browser readable cookie which expires when the refresh token + # expires. + response.set_cookie( + cookie_name, value=jwt['session_state'], + expires=request.user.oidc_profile.refresh_expires_before, + httponly=False + ) + + return response + + +class KeycloakStatelessBearerAuthenticationMiddleware(BaseKeycloakMiddleware): + + set_session_state_cookie = False + header_key = "HTTP_AUTHORIZATION" + + def process_request(self, request): + """ + Forces authentication on all requests except the URL's configured in + the exempt setting. + """ + super(KeycloakStatelessBearerAuthenticationMiddleware, self)\ + .process_request(request=request) + + if hasattr(settings, 'KEYCLOAK_BEARER_AUTHENTICATION_EXEMPT_PATHS'): + path = request.path_info.lstrip('/') + + if any(re.match(m, path) for m in + settings.KEYCLOAK_BEARER_AUTHENTICATION_EXEMPT_PATHS): + return + + if self.header_key not in request.META: + return HttpResponseNotAuthorized( + attributes={'realm': request.realm.name}) + + user = authenticate( + request=request, + access_token=request.META[self.header_key].split(' ')[1] + ) + + if user is None: + return HttpResponseNotAuthorized( + attributes={'realm': request.realm.name}) + else: + request.user = user + + +class RemoteUserAuthenticationMiddleware(MiddlewareMixin): + set_session_state_cookie = False + + def process_request(self, request): + """ + Adds user to the request when authorized user is found in the session + :param django.http.request.HttpRequest request: django request + """ + origin_user = getattr(request, 'user', None) + + request.user = SimpleLazyObject(lambda: get_user( + request, + origin_user=origin_user + )) diff --git a/django_keycloak/migrations/0001_initial.py b/django_keycloak/migrations/0001_initial.py new file mode 100644 index 0000000..0dbe45e --- /dev/null +++ b/django_keycloak/migrations/0001_initial.py @@ -0,0 +1,134 @@ +# Generated by Django 2.0.2 on 2018-03-15 21:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Client', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('client_id', models.CharField(max_length=255)), + ('secret', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='Nonce', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('state', models.UUIDField(default=uuid.uuid4, unique=True)), + ('redirect_uri', models.CharField(max_length=255)), + ('next_path', models.CharField(max_length=255, null=True)), + ], + ), + migrations.CreateModel( + name='OpenIdConnectProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('access_token', models.TextField(null=True)), + ('expires_before', models.DateTimeField(null=True)), + ('refresh_token', models.TextField(null=True)), + ('refresh_expires_before', models.DateTimeField(null=True)), + ('sub', models.CharField(max_length=255, unique=True)), + ], + options={ + 'abstract': False, + 'swappable': 'KEYCLOAK_OIDC_PROFILE_MODEL', + }, + ), + migrations.CreateModel( + name='Realm', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('name', models.CharField( + help_text='Name as known on the Keycloak server. This ' + 'name is used in the API paths of this Realm.', + max_length=255, unique=True)), + ('_certs', models.TextField()), + ('_well_known_oidc', models.TextField(blank=True)), + ], + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('reference', models.CharField(max_length=50)), + ('client', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='roles', to='django_keycloak.Client')), + ('permission', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='auth.Permission')), + ], + ), + migrations.CreateModel( + name='Server', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('url', models.CharField(max_length=255)), + ('internal_url', models.CharField( + blank=True, + help_text='URL on internal netwerk calls. For example ' + 'when used with Docker Compose. Only supply ' + 'when internal calls should go to a different ' + 'url as the end-user will communicate with.', + max_length=255, null=True)), + ], + ), + migrations.AddField( + model_name='realm', + name='server', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='realms', to='django_keycloak.Server'), + ), + migrations.AddField( + model_name='openidconnectprofile', + name='realm', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='openid_profiles', to='django_keycloak.Realm'), + ), + migrations.AddField( + model_name='openidconnectprofile', + name='user', + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='oidc_profile', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='client', + name='realm', + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='client', to='django_keycloak.Realm'), + ), + migrations.AddField( + model_name='client', + name='service_account', + field=models.OneToOneField( + null=True, on_delete=django.db.models.deletion.CASCADE, + related_name='keycloak_client', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='role', + unique_together={('client', 'permission')}, + ), + ] diff --git a/django_keycloak/migrations/0002_auto_20180322_2059.py b/django_keycloak/migrations/0002_auto_20180322_2059.py new file mode 100644 index 0000000..528e07a --- /dev/null +++ b/django_keycloak/migrations/0002_auto_20180322_2059.py @@ -0,0 +1,54 @@ +# Generated by Django 2.0.3 on 2018-03-22 20:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_keycloak', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ExchangedToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('access_token', models.TextField(null=True)), + ('expires_before', models.DateTimeField(null=True)), + ('refresh_token', models.TextField(null=True)), + ('refresh_expires_before', models.DateTimeField(null=True)), + ('oidc_profile', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='django_keycloak.OpenIdConnectProfile')), + ], + ), + migrations.CreateModel( + name='RemoteClient', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('realm', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='remote_clients', + to='django_keycloak.Realm' + )), + ], + ), + migrations.AddField( + model_name='exchangedtoken', + name='remote_client', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='exchanged_tokens', + to='django_keycloak.RemoteClient' + ), + ), + migrations.AlterUniqueTogether( + name='exchangedtoken', + unique_together={('oidc_profile', 'remote_client')}, + ), + ] diff --git a/django_keycloak/migrations/0003_auto_20190204_1949.py b/django_keycloak/migrations/0003_auto_20190204_1949.py new file mode 100644 index 0000000..e8f2017 --- /dev/null +++ b/django_keycloak/migrations/0003_auto_20190204_1949.py @@ -0,0 +1,44 @@ +# Generated by Django 2.1.5 on 2019-02-04 19:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_keycloak', '0002_auto_20180322_2059'), + ] + + operations = [ + migrations.CreateModel( + name='RemoteUserOpenIdConnectProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('access_token', models.TextField(null=True)), + ('expires_before', models.DateTimeField(null=True)), + ('refresh_token', models.TextField(null=True)), + ('refresh_expires_before', models.DateTimeField(null=True)), + ('sub', models.CharField(max_length=255, unique=True)), + ('realm', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='openid_profiles', + to='django_keycloak.Realm' + )), + ], + options={ + 'abstract': False, + 'swappable': 'KEYCLOAK_OIDC_PROFILE_MODEL', + }, + ), + migrations.AlterField( + model_name='exchangedtoken', + name='oidc_profile', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.KEYCLOAK_OIDC_PROFILE_MODEL + ), + ), + ] diff --git a/django_keycloak/migrations/0004_client_service_account_profile.py b/django_keycloak/migrations/0004_client_service_account_profile.py new file mode 100644 index 0000000..04ad835 --- /dev/null +++ b/django_keycloak/migrations/0004_client_service_account_profile.py @@ -0,0 +1,24 @@ +# Generated by Django 2.1.5 on 2019-02-19 13:23 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_keycloak', '0003_auto_20190204_1949'), + ] + + operations = [ + migrations.AddField( + model_name='client', + name='service_account_profile', + field=models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.KEYCLOAK_OIDC_PROFILE_MODEL + ), + ), + ] diff --git a/django_keycloak/migrations/0005_auto_20190219_2002.py b/django_keycloak/migrations/0005_auto_20190219_2002.py new file mode 100644 index 0000000..0b4b07e --- /dev/null +++ b/django_keycloak/migrations/0005_auto_20190219_2002.py @@ -0,0 +1,28 @@ +# Generated by Django 2.1.5 on 2019-02-19 20:02 + +from django.db import migrations + + +def forward(apps, schema_editor): + Client = apps.get_model('django_keycloak', 'Client') + for client in Client.objects.filter(service_account__isnull=False): + client.service_account_profile = client.service_account.oidc_profile + client.save() + + +def backward(apps, schema_editor): + Client = apps.get_model('django_keycloak', 'Client') + for client in Client.objects.filter(service_account_profile__isnull=False): + client.service_account = client.service_account_profile.user + client.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_keycloak', '0004_client_service_account_profile'), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/django_keycloak/migrations/0006_remove_client_service_account.py b/django_keycloak/migrations/0006_remove_client_service_account.py new file mode 100644 index 0000000..6bc62c0 --- /dev/null +++ b/django_keycloak/migrations/0006_remove_client_service_account.py @@ -0,0 +1,17 @@ +# Generated by Django 2.1.5 on 2019-02-19 20:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_keycloak', '0005_auto_20190219_2002'), + ] + + operations = [ + migrations.RemoveField( + model_name='client', + name='service_account', + ), + ] diff --git a/django_keycloak/migrations/__init__.py b/django_keycloak/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_keycloak/remote_user.py b/django_keycloak/remote_user.py new file mode 100644 index 0000000..7a49dde --- /dev/null +++ b/django_keycloak/remote_user.py @@ -0,0 +1,213 @@ +from django.contrib import auth +from django.core.exceptions import PermissionDenied + +from django_keycloak.models import RemoteUserOpenIdConnectProfile + + +class KeycloakRemoteUser(object): + """ + A class based on django.contrib.auth.models.User. + See https://docs.djangoproject.com/en/2.0/ref/contrib/auth/ + #django.contrib.auth.models.User + """ + + username = '' + first_name = '' + last_name = '' + email = '' + password = '' + groups = [] + user_permissions = [] + + _last_login = None + + def __init__(self, userinfo): + """ + Create KeycloakRemoteUser from userinfo and oidc_profile. + :param dict userinfo: the userinfo as retrieved from the OIDC provider + """ + self.username = userinfo.get('preferred_username') or userinfo['sub'] + self.email = userinfo.get('email', '') + self.first_name = userinfo.get('given_name', '') + self.last_name = userinfo.get('family_name', '') + self.sub = userinfo['sub'] + + def __str__(self): + return self.username + + @property + def pk(self): + """ + Since the BaseAbstractUser is a model, every instance needs a primary + key. The Django authentication backend requires this. + """ + return 0 + + @property + def identifier(self): + """ + Identifier used for session storage. + :rtype: str + """ + return self.sub + + @property + def is_staff(self): + """ + :rtype: bool + :return: whether the user is a staff member or not, defaults to False + """ + return False + + @property + def is_active(self): + """ + :rtype: bool + :return: whether the user is active or not, defaults to True + """ + return True + + @property + def is_superuser(self): + """ + :rtype: bool + :return: whether the user is a superuser or not, defaults to False + """ + return False + + @property + def last_login(self): + """ + :rtype: Datetime + :return: the date and time of the last login + """ + return self._last_login + + @last_login.setter + def last_login(self, content): + """ + :param datetime content: + """ + self._last_login = content + + @property + def is_authenticated(self): + """ + Read-only attribute which is always True. + See https://docs.djangoproject.com/en/2.0/ref/contrib/auth/ + #django.contrib.auth.models.User.is_authenticated + :return: + """ + return True + + @property + def is_anonymous(self): + """ + Read-only attribute which is always False. + See https://docs.djangoproject.com/en/2.0/ref/contrib/auth/ + #django.contrib.auth.models.User.is_anonymous + :return: + """ + return False + + def get_username(self): + """ + Get the username + :return: username + """ + return self.username + + def get_full_name(self): + """ + Get the full name (first name + last name) of the user. + :return: the first name and last name of the user + """ + return "{first} {last}".format(first=self.first_name, + last=self.last_name) + + def get_short_name(self): + """ + Get the first name of the user. + :return: first name + """ + return self.first_name + + def get_group_permissions(self, obj=None): + pass + + def get_all_permissions(self, obj=None): + """ + Logic from django.contrib.auth.models._user_get_all_permissions + :param perm: + :param obj: + :return: + """ + permissions = set() + for backend in auth.get_backends(): + # Excluding Django.contrib.auth backends since they are not + # compatible with non-db-backed permissions. + if hasattr(backend, "get_all_permissions") \ + and not backend.__module__.startswith('django.'): + permissions.update(backend.get_all_permissions(self, obj)) + return permissions + + @property + def oidc_profile(self): + """ + Get the related OIDC Profile for this user. + :rtype: django_keycloak.models.RemoteUserOpenIdConnectProfile + :return: OpenID Connect Profile + """ + try: + return RemoteUserOpenIdConnectProfile.objects.get(sub=self.sub) + except RemoteUserOpenIdConnectProfile.DoesNotExist: + return None + + def has_perm(self, perm, obj=None): + """ + Logic from django.contrib.auth.models._user_has_perm + :param perm: + :param obj: + :return: + """ + for backend in auth.get_backends(): + if not hasattr(backend, 'has_perm') \ + or backend.__module__.startswith('django.contrib.auth'): + continue + try: + if backend.has_perm(self, perm, obj): + return True + except PermissionDenied: + return False + return False + + def has_perms(self, perm_list, obj=None): + return all(self.has_perm(perm, obj) for perm in perm_list) + + def has_module_perms(self, module): + """ + Logic from django.contrib.auth.models._user_has_module_perms + :param module: + :return: + """ + for backend in auth.get_backends(): + if not hasattr(backend, 'has_module_perms'): + continue + try: + if backend.has_module_perms(self, module): + return True + except PermissionDenied: + return False + return False + + def email_user(self, subject, message, from_email=None, **kwargs): + raise NotImplementedError('This feature is not implemented by default,' + ' extend this class to implement') + + def save(self): + """ + Normally implemented by django.db.models.Model + :raises NotImplementedError: to remind that this is not a + database-backed model and should not be used like one + """ + raise NotImplementedError('This is not a database model') diff --git a/django_keycloak/response.py b/django_keycloak/response.py new file mode 100644 index 0000000..b8c38f9 --- /dev/null +++ b/django_keycloak/response.py @@ -0,0 +1,21 @@ +from django.http.response import HttpResponse + + +class HttpResponseNotAuthorized(HttpResponse): + status_code = 401 + + def __init__(self, content=b'', authorization_method='Bearer', + attributes=None, *args, **kwargs): + super(HttpResponseNotAuthorized, self).__init__(content, *args, + **kwargs) + + attributes = attributes or {} + attributes_str = ', '.join( + [ + '{}="{}"'.format(key, value) + for key, value in attributes.items() + ] + ) + + self['WWW-Authenticate'] = '{} {}'.format(authorization_method, + attributes_str) diff --git a/django_keycloak/services/__init__.py b/django_keycloak/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_keycloak/services/client.py b/django_keycloak/services/client.py new file mode 100644 index 0000000..8257e0f --- /dev/null +++ b/django_keycloak/services/client.py @@ -0,0 +1,131 @@ +import logging + +from functools import partial + +from django.utils import timezone + +from django_keycloak.services.exceptions import TokensExpired + +import django_keycloak.services.oidc_profile + + +logger = logging.getLogger(__name__) + + +def get_keycloak_id(client): + """ + Get internal Keycloak id for client configured in Realm + :param django_keycloak.models.Realm realm: + :return: + """ + keycloak_clients = client.admin_api_client.realms.by_name( + name=client.realm.name).clients.all() + for keycloak_client in keycloak_clients: + if keycloak_client['clientId'] == client.client_id: + return keycloak_client['id'] + + return None + + +def get_authz_api_client(client): + """ + :param django_keycloak.models.Client client: + :rtype: keycloak.authz.KeycloakAuthz + """ + return client.realm.realm_api_client.authz(client_id=client.client_id) + + +def get_openid_client(client): + """ + :param django_keycloak.models.Client client: + :rtype: keycloak.openid_connect.KeycloakOpenidConnect + """ + openid = client.realm.realm_api_client.open_id_connect( + client_id=client.client_id, + client_secret=client.secret + ) + + if client.realm._well_known_oidc: + openid.well_known.contents = client.realm.well_known_oidc + + return openid + + +def get_uma1_client(client): + """ + :type client: django_keycloak.models.Client + :rtype: keycloak.uma1.KeycloakUMA1 + """ + return client.realm.realm_api_client.uma1 + + +def get_admin_client(client): + """ + Get the Keycloak admin client configured for given realm. + + :param django_keycloak.models.Client client: + :rtype: keycloak.admin.KeycloakAdmin + """ + token = partial(get_access_token, client) + return client.realm.realm_api_client.admin.set_token(token=token) + + +def get_service_account_profile(client): + """ + Get service account for given client. + + :param django_keycloak.models.Client client: + :rtype: django_keycloak.models.OpenIdConnectProfile + """ + + if client.service_account_profile: + return client.service_account_profile + + token_response, initiate_time = get_new_access_token(client=client) + + oidc_profile = django_keycloak.services.oidc_profile._update_or_create( + client=client, + token_response=token_response, + initiate_time=initiate_time) + + client.service_account_profile = oidc_profile + client.save(update_fields=['service_account_profile']) + + return oidc_profile + + +def get_new_access_token(client): + """ + Get client access_token + + :param django_keycloak.models.Client client: + :rtype: str + """ + scope = 'realm-management openid' + + initiate_time = timezone.now() + token_response = client.openid_api_client.client_credentials(scope=scope) + + return token_response, initiate_time + + +def get_access_token(client): + """ + Get access token from client's service account. + :param django_keycloak.models.Client client: + :rtype: str + """ + + oidc_profile = get_service_account_profile(client=client) + + try: + return django_keycloak.services.oidc_profile.get_active_access_token( + oidc_profile=oidc_profile) + except TokensExpired: + token_reponse, initiate_time = get_new_access_token(client=client) + oidc_profile = django_keycloak.services.oidc_profile.update_tokens( + token_model=oidc_profile, + token_response=token_reponse, + initiate_time=initiate_time + ) + return oidc_profile.access_token diff --git a/django_keycloak/services/exceptions.py b/django_keycloak/services/exceptions.py new file mode 100644 index 0000000..e4bccf3 --- /dev/null +++ b/django_keycloak/services/exceptions.py @@ -0,0 +1,8 @@ + + +class KeycloakOpenIdProfileNotFound(Exception): + pass + + +class TokensExpired(Exception): + pass diff --git a/django_keycloak/services/permissions.py b/django_keycloak/services/permissions.py new file mode 100644 index 0000000..28bcbf3 --- /dev/null +++ b/django_keycloak/services/permissions.py @@ -0,0 +1,36 @@ +import logging + +from django.contrib.auth.models import Permission +from requests.exceptions import HTTPError + +import django_keycloak.services.client + +logger = logging.getLogger(__name__) + + +def synchronize(client): + """ + :param django_keycloak.models.Client client: + :return: + """ + keycloak_client_id = django_keycloak.services.client.get_keycloak_id( + client=client) + + role_api = client.admin_api_client.realms.by_name(client.realm.name)\ + .clients.by_id(keycloak_client_id).roles + + for permission in Permission.objects.all(): + + try: + role_api.create(name=permission.codename, + description=permission.name) + except HTTPError as e: + if e.response.status_code != 409: + raise + + else: + continue + + # Update role + role_api.by_name(permission.codename) \ + .update(name=permission.codename, description=permission.name) diff --git a/django_keycloak/services/realm.py b/django_keycloak/services/realm.py new file mode 100644 index 0000000..5cda67f --- /dev/null +++ b/django_keycloak/services/realm.py @@ -0,0 +1,72 @@ +from keycloak.realm import KeycloakRealm + +try: + from urllib.parse import urlparse +except ImportError: + from urlparse import urlparse + + +def get_realm_api_client(realm): + """ + :param django_keycloak.models.Realm realm: + :return keycloak.realm.Realm: + """ + headers = {} + server_url = realm.server.url + if realm.server.internal_url: + # An internal URL is configured. We add some additional settings to let + # Keycloak think that we access it using the server_url. + server_url = realm.server.internal_url + parsed_url = urlparse(realm.server.url) + headers['Host'] = parsed_url.netloc + + if parsed_url.scheme == 'https': + headers['X-Forwarded-Proto'] = 'https' + + return KeycloakRealm(server_url=server_url, realm_name=realm.name, + headers=headers) + + +def refresh_certs(realm): + """ + :param django_keycloak.models.Realm realm: + :rtype django_keycloak.models.Realm + """ + realm.certs = realm.client.openid_api_client.certs() + realm.save(update_fields=['_certs']) + return realm + + +def refresh_well_known_oidc(realm): + """ + Refresh Open ID Connect .well-known + + :param django_keycloak.models.Realm realm: + :rtype django_keycloak.models.Realm + """ + server_url = realm.server.internal_url or realm.server.url + + # While fetching the well_known we should not use the prepared URL + openid_api_client = KeycloakRealm( + server_url=server_url, + realm_name=realm.name + ).open_id_connect(client_id='', client_secret='') + + realm.well_known_oidc = openid_api_client.well_known.contents + realm.save(update_fields=['_well_known_oidc']) + return realm + + +def get_issuer(realm): + """ + Get correct issuer to validate the JWT against. If an internal URL is + configured for the server it will be replaced with the public one. + + :param django_keycloak.models.Realm realm: + :return: issuer + :rtype: str + """ + issuer = realm.well_known_oidc['issuer'] + if realm.server.internal_url: + return issuer.replace(realm.server.internal_url, realm.server.url, 1) + return issuer diff --git a/django_keycloak/services/remote_client.py b/django_keycloak/services/remote_client.py new file mode 100644 index 0000000..5a71b77 --- /dev/null +++ b/django_keycloak/services/remote_client.py @@ -0,0 +1,59 @@ +import logging + +from django.utils import timezone + +from django_keycloak.models import ExchangedToken + +import django_keycloak.services.oidc_profile + + +logger = logging.getLogger(__name__) + + +def exchange_token(oidc_profile, remote_client): + """ + Exchange access token from OpenID Connect profile for a token of given + remote client. + + :param django_keycloak.models.OpenIdConnectProfile oidc_profile: + :param django_keycloak.models.RemoteClient remote_client: + :rtype: dict + """ + active_access_token = django_keycloak.services.oidc_profile\ + .get_active_access_token(oidc_profile=oidc_profile) + + # http://www.keycloak.org/docs/latest/securing_apps/index.html#_token-exchange + return oidc_profile.realm.client.openid_api_client.token_exchange( + audience=remote_client.name, + subject_token=active_access_token, + requested_token_type='urn:ietf:params:oauth:token-type:refresh_token' + ) + + +def get_active_remote_client_token(oidc_profile, remote_client): + """ + Get an active remote client token. Exchange when not available or expired. + + :param django_keycloak.models.OpenIdConnectProfile oidc_profile: + :param django_keycloak.models.RemoteClient remote_client: + :rtype: str + """ + exchanged_token, _ = ExchangedToken.objects.get_or_create( + oidc_profile=oidc_profile, + remote_client=remote_client + ) + + initiate_time = timezone.now() + + if exchanged_token.refresh_expires_before is None \ + or initiate_time > exchanged_token.refresh_expires_before \ + or initiate_time > exchanged_token.expires_before: + token_response = exchange_token(oidc_profile, remote_client) + + exchanged_token = django_keycloak.services.oidc_profile.update_tokens( + token_model=exchanged_token, + token_response=token_response, + initiate_time=initiate_time + ) + + return exchanged_token.access_token diff --git a/django_keycloak/services/uma.py b/django_keycloak/services/uma.py new file mode 100644 index 0000000..2030276 --- /dev/null +++ b/django_keycloak/services/uma.py @@ -0,0 +1,63 @@ +from django.apps.registry import apps +from django.utils.text import slugify + +from keycloak.exceptions import KeycloakClientError + +import django_keycloak.services.client + + +def synchronize_client(client): + """ + Synchronize all models as resources for a client. + + :type client: django_keycloak.models.Client + """ + for app_config in apps.get_app_configs(): + synchronize_resources( + client=client, + app_config=app_config + ) + + +def synchronize_resources(client, app_config): + """ + Synchronize all resources (models) to the Keycloak server for given client + and Django App. + + :type client: django_keycloak.models.Client + :type app_config: django.apps.config.AppConfig + """ + + if not app_config.models_module: + return + + uma1_client = client.uma1_api_client + + access_token = django_keycloak.services.client.get_access_token( + client=client + ) + + for klass in app_config.get_models(): + scopes = _get_all_permissions(klass._meta) + + try: + uma1_client.resource_set_create( + token=access_token, + name=klass._meta.label_lower, + type='urn:{client}:resources:{model}'.format( + client=slugify(client.client_id), + model=klass._meta.label_lower + ), + scopes=scopes + ) + except KeycloakClientError as e: + if e.original_exc.response.status_code != 409: + raise + + +def _get_all_permissions(meta): + """ + :type meta: django.db.models.options.Options + :rtype: list + """ + return meta.default_permissions diff --git a/django_keycloak/services/users.py b/django_keycloak/services/users.py new file mode 100644 index 0000000..718ef22 --- /dev/null +++ b/django_keycloak/services/users.py @@ -0,0 +1,33 @@ +import base64 + + +def credential_representation_from_hash(hash_, temporary=False): + algorithm, hashIterations, salt, hashedSaltedValue = hash_.split('$') + + return { + 'type': 'password', + 'hashedSaltedValue': hashedSaltedValue, + 'algorithm': algorithm.replace('_', '-'), + 'hashIterations': int(hashIterations), + 'salt': base64.b64encode(salt.encode()).decode('ascii').strip(), + 'temporary': temporary + } + + +def add_user(client, user): + """ + Create user in Keycloak based on a local user including password. + + :param django_keycloak.models.Client client: + :param django.contrib.auth.models.User user: + """ + credentials = credential_representation_from_hash(hash_=user.password) + + client.admin_api_client.realms.by_name(client.realm.name).users.create( + username=user.username, + credentials=credentials, + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + enabled=user.is_active + ) diff --git a/django_keycloak/templates/django_keycloak/includes/session_iframe_support.html b/django_keycloak/templates/django_keycloak/includes/session_iframe_support.html new file mode 100644 index 0000000..0f0ca1a --- /dev/null +++ b/django_keycloak/templates/django_keycloak/includes/session_iframe_support.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/django_keycloak/templates/django_keycloak/session_iframe.html b/django_keycloak/templates/django_keycloak/session_iframe.html new file mode 100644 index 0000000..1fd40a2 --- /dev/null +++ b/django_keycloak/templates/django_keycloak/session_iframe.html @@ -0,0 +1,50 @@ + + + + + + + diff --git a/django_keycloak/tests/__init__.py b/django_keycloak/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_keycloak/tests/backends/__init__.py b/django_keycloak/tests/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_keycloak/tests/backends/keycloak_authorization_base/__init__.py b/django_keycloak/tests/backends/keycloak_authorization_base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_keycloak/tests/backends/keycloak_authorization_base/test_get_keycloak_permissions.py b/django_keycloak/tests/backends/keycloak_authorization_base/test_get_keycloak_permissions.py new file mode 100644 index 0000000..6c0b12b --- /dev/null +++ b/django_keycloak/tests/backends/keycloak_authorization_base/test_get_keycloak_permissions.py @@ -0,0 +1,49 @@ +from django.test import TestCase, override_settings + +from django_keycloak.factories import OpenIdConnectProfileFactory +from django_keycloak.tests.mixins import MockTestCaseMixin +from django_keycloak.auth.backends import KeycloakAuthorizationBase + + +@override_settings(KEYCLOAK_PERMISSIONS_METHOD='resource') +class BackendsKeycloakAuthorizationBaseGetKeycloakPermissionsTestCase( + MockTestCaseMixin, TestCase): + + def setUp(self): + self.backend = KeycloakAuthorizationBase() + + self.profile = OpenIdConnectProfileFactory() + + self.setup_mock( + 'django_keycloak.services.oidc_profile.get_entitlement', + return_value={ + 'authorization': { + 'permissions': [ + { + 'resource_set_name': 'Resource', + 'scopes': [ + 'Read', + 'Update' + ] + }, + { + 'resource_set_name': 'Resource2' + } + ] + } + }) + + def test_get_keycloak_permissions(self): + """ + Case: The permissions are requested from Keycloak, which are returned + by get_entitlement as a decoded RPT. + Expect: The permissions are extracted from the RPT and are returned + in a list. + """ + permissions = self.backend.get_keycloak_permissions( + user_obj=self.profile.user) + + self.assertListEqual( + ['Read_Resource', 'Update_Resource', 'Resource2'], + permissions + ) diff --git a/django_keycloak/tests/backends/keycloak_authorization_base/test_has_perm.py b/django_keycloak/tests/backends/keycloak_authorization_base/test_has_perm.py new file mode 100644 index 0000000..b64dcf7 --- /dev/null +++ b/django_keycloak/tests/backends/keycloak_authorization_base/test_has_perm.py @@ -0,0 +1,89 @@ +from django.test import TestCase, override_settings + +from django_keycloak.factories import OpenIdConnectProfileFactory +from django_keycloak.tests.mixins import MockTestCaseMixin +from django_keycloak.auth.backends import KeycloakAuthorizationBase + + +@override_settings(KEYCLOAK_PERMISSIONS_METHOD='resource') +class BackendsKeycloakAuthorizationBaseHasPermTestCase( + MockTestCaseMixin, TestCase): + + def setUp(self): + self.backend = KeycloakAuthorizationBase() + + self.profile = OpenIdConnectProfileFactory(user__is_active=True) + + self.setup_mock( + 'django_keycloak.services.oidc_profile.get_entitlement', + return_value={ + 'authorization': { + 'permissions': [ + { + 'resource_set_name': 'Resource', + 'scopes': [ + 'Read', + 'Update' + ] + }, + { + 'resource_set_name': 'Resource2' + } + ] + } + } + ) + + def test_resource_scope_should_have_permission(self): + """ + Case: Permission is expected that is available to the user. + Expected: Permission granted. + """ + permission = self.backend.has_perm( + user_obj=self.profile.user, perm='Read_Resource') + + self.assertTrue(permission) + + def test_resource_no_scope_should_not_have_permission(self): + """" + Case: Permission is formatted as resource only which does not exist as + such in the RPT. + Expected: Permission denied. + """ + permission = self.backend.has_perm( + user_obj=self.profile.user, perm='Resource') + + self.assertFalse(permission) + + def test_resource_other_scope_should_not_have_permission(self): + """" + Case: Permission is expected with a scope that is not available to + the user according to the RPT. + Expected: Permission denied. + """ + permission = self.backend.has_perm( + user_obj=self.profile.user, perm='Create_Resource') + + self.assertFalse(permission) + + def test_other_resource_other_scope_should_not_have_permission(self): + """" + Case: Permission is expected that is not available to the user + according to the RPT. + Expected: Permission denied. + """ + permission = self.backend.has_perm( + user_obj=self.profile.user, perm='OtherScope_OtherResource') + + self.assertFalse(permission) + + def test_resource_no_scope_should_have_permission(self): + """" + Case: Permission is expected with no scope provided, but scope is + also not provided in the RPT. + Expected: Permission granted. + """ + permission = self.backend.has_perm( + user_obj=self.profile.user, perm='Resource2') + + self.assertTrue(permission) diff --git a/django_keycloak/tests/mixins.py b/django_keycloak/tests/mixins.py new file mode 100644 index 0000000..7172dbd --- /dev/null +++ b/django_keycloak/tests/mixins.py @@ -0,0 +1,23 @@ +import mock + + +class MockTestCaseMixin(object): + + def __init__(self, *args, **kwargs): + self._mocks = {} + + super(MockTestCaseMixin, self).__init__(*args, **kwargs) + + def setup_mock(self, target, autospec=True, **kwargs): + if target in self._mocks: + raise Exception('Target %s already patched', target) + + self._mocks[target] = mock.patch(target, autospec=autospec, **kwargs) + + return self._mocks[target].start() + + def tearDown(self): + for mock_ in self._mocks.values(): + mock_.stop() + + return super(MockTestCaseMixin, self).tearDown() diff --git a/django_keycloak/tests/services/__init__.py b/django_keycloak/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_keycloak/tests/services/oidc_profile/__init__.py b/django_keycloak/tests/services/oidc_profile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_keycloak/tests/services/oidc_profile/test_get_active_access_token.py b/django_keycloak/tests/services/oidc_profile/test_get_active_access_token.py new file mode 100644 index 0000000..ae176c2 --- /dev/null +++ b/django_keycloak/tests/services/oidc_profile/test_get_active_access_token.py @@ -0,0 +1,69 @@ +import mock + +from datetime import datetime + +from django.test import TestCase +from freezegun import freeze_time +from keycloak.openid_connect import KeycloakOpenidConnect + +from django_keycloak.factories import OpenIdConnectProfileFactory +from django_keycloak.tests.mixins import MockTestCaseMixin + +import django_keycloak.services.oidc_profile + + +class ServicesKeycloakOpenIDProfileGetActiveAccessTokenTestCase( + MockTestCaseMixin, TestCase): + + def setUp(self): + self.oidc_profile = OpenIdConnectProfileFactory( + access_token='access-token', + expires_before=datetime(2018, 3, 5, 1, 0, 0), + refresh_token='refresh-token', + refresh_expires_before=datetime(2018, 3, 5, 2, 0, 0) + ) + self.oidc_profile.realm.client.openid_api_client = mock.MagicMock( + spec_set=KeycloakOpenidConnect) + self.oidc_profile.realm.client.openid_api_client.refresh_token\ + .return_value = { + 'access_token': 'new-access-token', + 'expires_in': 600, + 'refresh_token': 'new-refresh-token', + 'refresh_expires_in': 3600 + } + + @freeze_time('2018-03-05 00:59:00') + def test_not_expired(self): + """ + Case: access token get fetched and is not yet expired + Expected: current token is returned + """ + access_token = django_keycloak.services.oidc_profile\ + .get_active_access_token(oidc_profile=self.oidc_profile) + + self.assertEqual(access_token, 'access-token') + self.assertFalse( + self.oidc_profile.realm.client.openid_api_client.refresh_token + .called + ) + + @freeze_time('2018-03-05 01:01:00') + def test_expired(self): + """ + Case: access token get requested but current one is expired + Expected: A new one get requested + """ + access_token = django_keycloak.services.oidc_profile \ + .get_active_access_token(oidc_profile=self.oidc_profile) + + self.assertEqual(access_token, 'new-access-token') + self.oidc_profile.realm.client.openid_api_client.refresh_token\ + .assert_called_once_with(refresh_token='refresh-token') + + self.oidc_profile.refresh_from_db() + self.assertEqual(self.oidc_profile.access_token, 'new-access-token') + self.assertEqual(self.oidc_profile.expires_before, + datetime(2018, 3, 5, 1, 11, 0)) + self.assertEqual(self.oidc_profile.refresh_token, 'new-refresh-token') + self.assertEqual(self.oidc_profile.refresh_expires_before, + datetime(2018, 3, 5, 2, 1, 0)) diff --git a/django_keycloak/tests/services/oidc_profile/test_get_entitlement.py b/django_keycloak/tests/services/oidc_profile/test_get_entitlement.py new file mode 100644 index 0000000..01c0764 --- /dev/null +++ b/django_keycloak/tests/services/oidc_profile/test_get_entitlement.py @@ -0,0 +1,57 @@ +import mock + +from datetime import datetime + +from django.test import TestCase +from keycloak.openid_connect import KeycloakOpenidConnect +from keycloak.authz import KeycloakAuthz + +from django_keycloak.factories import OpenIdConnectProfileFactory +from django_keycloak.tests.mixins import MockTestCaseMixin + +import django_keycloak.services.oidc_profile + + +class ServicesKeycloakOpenIDProfileGetActiveAccessTokenTestCase( + MockTestCaseMixin, TestCase): + + def setUp(self): + self.mocked_get_active_access_token = self.setup_mock( + 'django_keycloak.services.oidc_profile' + '.get_active_access_token' + ) + + self.oidc_profile = OpenIdConnectProfileFactory( + access_token='access-token', + expires_before=datetime(2018, 3, 5, 1, 0, 0), + refresh_token='refresh-token' + ) + self.oidc_profile.realm.client.openid_api_client = mock.MagicMock( + spec_set=KeycloakOpenidConnect) + self.oidc_profile.realm.client.authz_api_client = mock.MagicMock( + spec_set=KeycloakAuthz) + self.oidc_profile.realm.client.authz_api_client.entitlement\ + .return_value = { + 'rpt': 'RPT_VALUE' + } + self.oidc_profile.realm.certs = {'cert': 'cert-value'} + + def test(self): + django_keycloak.services.oidc_profile.get_entitlement( + oidc_profile=self.oidc_profile + ) + self.oidc_profile.realm.client.authz_api_client.entitlement\ + .assert_called_once_with( + token=self.mocked_get_active_access_token.return_value + ) + self.oidc_profile.realm.client.openid_api_client.decode_token\ + .assert_called_once_with( + token='RPT_VALUE', + key=self.oidc_profile.realm.certs, + options={ + 'verify_signature': True, + 'exp': True, + 'iat': True, + 'aud': True + } + ) diff --git a/django_keycloak/tests/services/oidc_profile/test_get_or_create_from_id_token.py b/django_keycloak/tests/services/oidc_profile/test_get_or_create_from_id_token.py new file mode 100644 index 0000000..877a38d --- /dev/null +++ b/django_keycloak/tests/services/oidc_profile/test_get_or_create_from_id_token.py @@ -0,0 +1,158 @@ +import mock + +from datetime import datetime + +from django.test import TestCase +from keycloak.openid_connect import KeycloakOpenidConnect + +from django_keycloak.factories import ClientFactory, \ + OpenIdConnectProfileFactory, UserFactory +from django_keycloak.tests.mixins import MockTestCaseMixin + +import django_keycloak.services.oidc_profile + + +class ServicesOpenIDProfileGetOrCreateFromIdTokenTestCase( + MockTestCaseMixin, TestCase): + + def setUp(self): + self.client = ClientFactory( + realm___certs='{}', + realm___well_known_oidc='{"issuer": "https://issuer"}' + ) + self.client.openid_api_client = mock.MagicMock( + spec_set=KeycloakOpenidConnect) + self.client.openid_api_client.well_known = { + 'id_token_signing_alg_values_supported': ['signing-alg'] + } + self.client.openid_api_client.decode_token.return_value = { + 'sub': 'some-sub', + 'email': 'test@example.com', + 'given_name': 'Some given name', + 'family_name': 'Some family name' + } + + def test_create_with_new_user_new_profile(self): + """ + Case: oidc profile is requested based on a provided id token. + The user and profile do not exist yet. + Expected: oidc profile and user are created with information from + the id token. + """ + profile = django_keycloak.services.oidc_profile. \ + get_or_create_from_id_token( + client=self.client, id_token='some-id-token' + ) + + self.client.openid_api_client.decode_token.assert_called_with( + token='some-id-token', + key=dict(), + algorithms=['signing-alg'], + issuer='https://issuer' + ) + + self.assertEqual(profile.sub, 'some-sub') + self.assertEqual(profile.user.username, 'some-sub') + self.assertEqual(profile.user.email, 'test@example.com') + self.assertEqual(profile.user.first_name, 'Some given name') + self.assertEqual(profile.user.last_name, 'Some family name') + + def test_update_with_existing_profile_new_user(self): + """ + Case: oidc profile is requested based on a provided id token. + The profile exists, but the user doesn't. + Expected: oidc user is created with information from the id token + and linked to the profile. + """ + existing_profile = OpenIdConnectProfileFactory( + access_token='access-token', + expires_before=datetime(2018, 3, 5, 1, 0, 0), + refresh_token='refresh-token', + sub='some-sub' + ) + + profile = django_keycloak.services.oidc_profile. \ + get_or_create_from_id_token( + client=self.client, id_token='some-id-token' + ) + + self.client.openid_api_client.decode_token.assert_called_with( + token='some-id-token', + key=dict(), + algorithms=['signing-alg'], + issuer='https://issuer' + ) + + self.assertEqual(profile.sub, 'some-sub') + self.assertEqual(profile.pk, existing_profile.pk) + self.assertEqual(profile.user.username, 'some-sub') + self.assertEqual(profile.user.email, 'test@example.com') + self.assertEqual(profile.user.first_name, 'Some given name') + self.assertEqual(profile.user.last_name, 'Some family name') + + def test_create_with_existing_user_new_profile(self): + """ + Case: oidc profile is requested based on a provided id token. + The user exists, but the profile doesn't. + Expected: oidc profile is created and user is linked to the profile. + """ + existing_user = UserFactory( + username='some-sub' + ) + + profile = django_keycloak.services.oidc_profile.\ + get_or_create_from_id_token( + client=self.client, id_token='some-id-token' + ) + + self.client.openid_api_client.decode_token.assert_called_with( + token='some-id-token', + key=dict(), + algorithms=['signing-alg'], + issuer='https://issuer' + ) + + self.assertEqual(profile.sub, 'some-sub') + self.assertEqual(profile.user.pk, existing_user.pk) + self.assertEqual(profile.user.username, 'some-sub') + self.assertEqual(profile.user.email, 'test@example.com') + self.assertEqual(profile.user.first_name, 'Some given name') + self.assertEqual(profile.user.last_name, 'Some family name') + + def test_create_with_existing_user_existing_profile(self): + """ + Case: oidc profile is requested based on a provided id token. + The user and profile already exist. + Expected: existing oidc profile is returned with existing user linked + to it. + """ + existing_user = UserFactory( + username='some-sub' + ) + + existing_profile = OpenIdConnectProfileFactory( + access_token='access-token', + expires_before=datetime(2018, 3, 5, 1, 0, 0), + refresh_token='refresh-token', + sub='some-sub' + ) + + profile = django_keycloak.services.oidc_profile.\ + get_or_create_from_id_token( + client=self.client, id_token='some-id-token' + ) + + self.client.openid_api_client.decode_token.assert_called_with( + token='some-id-token', + key=dict(), + algorithms=['signing-alg'], + issuer='https://issuer' + ) + + self.assertEqual(profile.pk, existing_profile.pk) + self.assertEqual(profile.sub, 'some-sub') + self.assertEqual(profile.user.pk, existing_user.pk) + self.assertEqual(profile.user.username, 'some-sub') + self.assertEqual(profile.user.email, 'test@example.com') + self.assertEqual(profile.user.first_name, 'Some given name') + self.assertEqual(profile.user.last_name, 'Some family name') diff --git a/django_keycloak/tests/services/oidc_profile/test_update_or_create.py b/django_keycloak/tests/services/oidc_profile/test_update_or_create.py new file mode 100644 index 0000000..b92461f --- /dev/null +++ b/django_keycloak/tests/services/oidc_profile/test_update_or_create.py @@ -0,0 +1,126 @@ +import mock + +from datetime import datetime + +from django.contrib.auth import get_user_model +from django.test import TestCase +from freezegun import freeze_time +from keycloak.openid_connect import KeycloakOpenidConnect + +from django_keycloak.factories import ClientFactory +from django_keycloak.models import OpenIdConnectProfile +from django_keycloak.tests.mixins import MockTestCaseMixin + +import django_keycloak.services.oidc_profile + + +class ServicesKeycloakOpenIDProfileUpdateOrCreateTestCase(MockTestCaseMixin, + TestCase): + + def setUp(self): + self.client = ClientFactory( + realm___certs='{}', + realm___well_known_oidc='{"issuer": "https://issuer"}' + ) + self.client.openid_api_client = mock.MagicMock( + spec_set=KeycloakOpenidConnect) + self.client.openid_api_client.authorization_code.return_value = { + 'id_token': 'id-token', + 'expires_in': 600, + 'refresh_expires_in': 3600, + 'access_token': 'access-token', + 'refresh_token': 'refresh-token' + } + self.client.openid_api_client.well_known = { + 'id_token_signing_alg_values_supported': ['signing-alg'] + } + self.client.openid_api_client.decode_token.return_value = { + 'sub': 'some-sub', + 'email': 'test@example.com', + 'given_name': 'Some given name', + 'family_name': 'Some family name' + } + + @freeze_time('2018-03-01 00:00:00') + def test_create(self): + django_keycloak.services.oidc_profile.update_or_create_from_code( + client=self.client, + code='some-code', + redirect_uri='https://redirect' + ) + self.client.openid_api_client.authorization_code\ + .assert_called_once_with(code='some-code', + redirect_uri='https://redirect') + self.client.openid_api_client.decode_token.assert_called_once_with( + token='id-token', + key=dict(), + algorithms=['signing-alg'], + issuer='https://issuer' + ) + + profile = OpenIdConnectProfile.objects.get(sub='some-sub') + self.assertEqual(profile.sub, 'some-sub'), + self.assertEqual(profile.access_token, 'access-token') + self.assertEqual(profile.refresh_token, 'refresh-token') + self.assertEqual(profile.expires_before, datetime( + year=2018, month=3, day=1, hour=0, minute=10, second=0 + )) + self.assertEqual(profile.refresh_expires_before, datetime( + year=2018, month=3, day=1, hour=1, minute=0, second=0 + )) + + user = profile.user + self.assertEqual(user.username, 'some-sub') + self.assertEqual(user.first_name, 'Some given name') + self.assertEqual(user.last_name, 'Some family name') + + @freeze_time('2018-03-01 00:00:00') + def test_update(self): + UserModel = get_user_model() + user = UserModel.objects.create( + username='some-sub', + email='', + first_name='', + last_name='' + ) + profile = OpenIdConnectProfile.objects.create( + realm=self.client.realm, + sub='some-sub', + user=user, + access_token='another-access-token', + expires_before=datetime.now(), + refresh_token='another-refresh-token', + refresh_expires_before=datetime.now() + ) + + django_keycloak.services.oidc_profile.update_or_create_from_code( + client=self.client, + code='some-code', + redirect_uri='https://redirect' + ) + self.client.openid_api_client.authorization_code\ + .assert_called_once_with(code='some-code', + redirect_uri='https://redirect') + self.client.openid_api_client.decode_token.assert_called_once_with( + token='id-token', + key=dict(), + algorithms=['signing-alg'], + issuer='https://issuer' + ) + + profile.refresh_from_db() + self.assertEqual(profile.sub, 'some-sub') + self.assertEqual(profile.access_token, 'access-token') + self.assertEqual(profile.refresh_token, 'refresh-token') + self.assertEqual(profile.expires_before, datetime( + year=2018, month=3, day=1, hour=0, minute=10, second=0 + )) + self.assertEqual(profile.refresh_expires_before, datetime( + year=2018, month=3, day=1, hour=1, minute=0, second=0 + )) + + user = profile.user + user.refresh_from_db() + self.assertEqual(user.username, 'some-sub') + self.assertEqual(user.first_name, 'Some given name') + self.assertEqual(user.last_name, 'Some family name') diff --git a/django_keycloak/tests/services/realm/__init__.py b/django_keycloak/tests/services/realm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_keycloak/tests/services/realm/test_get_realm_api_client.py b/django_keycloak/tests/services/realm/test_get_realm_api_client.py new file mode 100644 index 0000000..9e77267 --- /dev/null +++ b/django_keycloak/tests/services/realm/test_get_realm_api_client.py @@ -0,0 +1,49 @@ +from django.test import TestCase + +from django_keycloak.factories import ServerFactory, RealmFactory +from django_keycloak.tests.mixins import MockTestCaseMixin + +import django_keycloak.services.realm + + +class ServicesRealmGetRealmApiClientTestCase( + MockTestCaseMixin, TestCase): + + def setUp(self): + self.server = ServerFactory( + url='https://some-url', + internal_url='' + ) + + self.realm = RealmFactory( + server=self.server, + name='test-realm' + ) + + def test_get_realm_api_client(self): + """ + Case: a realm api client is requested for a realm on a server without + internal_url. + Expected: a KeycloakRealm client is returned with settings based on the + provided realm. The server_url in the client is the provided url. + """ + client = django_keycloak.services.realm.\ + get_realm_api_client(realm=self.realm) + + self.assertEqual(client.server_url, self.server.url) + self.assertEqual(client.realm_name, self.realm.name) + + def test_get_realm_api_client_with_internal_url(self): + """ + Case: a realm api client is requested for a realm on a server with + internal_url. + Expected: a KeycloakRealm client is returned with settings based on the + provided realm. The server_url in the client is the provided url. + """ + self.server.internal_url = 'https://some-internal-url' + + client = django_keycloak.services.realm.\ + get_realm_api_client(realm=self.realm) + + self.assertEqual(client.server_url, self.server.internal_url) + self.assertEqual(client.realm_name, self.realm.name) diff --git a/django_keycloak/tests/services/realm/test_refresh_well_known_oidc.py b/django_keycloak/tests/services/realm/test_refresh_well_known_oidc.py new file mode 100644 index 0000000..0acd0fb --- /dev/null +++ b/django_keycloak/tests/services/realm/test_refresh_well_known_oidc.py @@ -0,0 +1,36 @@ +import mock + +from django.test import TestCase + +from django_keycloak.factories import RealmFactory +from django_keycloak.tests.mixins import MockTestCaseMixin + +import django_keycloak.services.realm + + +class ServicesRealmRefreshWellKnownOIDCTestCase( + MockTestCaseMixin, TestCase): + + def setUp(self): + self.realm = RealmFactory( + name='test-realm', + _well_known_oidc='empty' + ) + + keycloak_oidc_mock = mock.MagicMock() + keycloak_oidc_mock.well_known.contents = {'key': 'value'} + self.setup_mock('keycloak.realm.KeycloakRealm.open_id_connect', + return_value=keycloak_oidc_mock) + + def test_refresh_well_known_oidc(self): + """ + Case: An update is requested for the .well-known for a specified realm. + Expected: The .well-known is updated. + """ + self.assertEqual(self.realm._well_known_oidc, 'empty') + + django_keycloak.services.realm.refresh_well_known_oidc( + realm=self.realm + ) + + self.assertEqual(self.realm._well_known_oidc, '{"key": "value"}') diff --git a/django_keycloak/tests/settings.py b/django_keycloak/tests/settings.py new file mode 100644 index 0000000..b16dbd2 --- /dev/null +++ b/django_keycloak/tests/settings.py @@ -0,0 +1,42 @@ +import logging + +from django_keycloak.app_settings import * # noqa: F403,F401 + +PASSWORD_HASHERS = ( + 'django.contrib.auth.hashers.MD5PasswordHasher', +) + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'secret-key' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False + +LOGIN_URL = 'keycloak_login' +LOGOUT_REDIRECT_URL = 'index' + +# Application definition +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + + 'django_keycloak.apps.KeycloakAppConfig', +] + +MIDDLEWARE = [ + 'django_keycloak.middleware.BaseKeycloakMiddleware', +] + +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'django_keycloak.auth.backends.KeycloakAuthorizationCodeBackend', +] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } +} + +logging.disable(logging.CRITICAL) From a46ac8a39a38c8a357b5a19dc02354950c7a3f18 Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Wed, 29 Nov 2023 11:58:53 -0500 Subject: [PATCH 13/15] bump patch version to 0.2.8 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 07d0c7a..f319da9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-keycloak" -version = "0.2.7" +version = "0.2.8" description = "Integrate Keycloak with Django 4.2+" homepage = 'https://github.com/skamansam/django-keycloak' repository = 'https://github.com/skamansam/django-keycloak' From 0fd1fafc9c418d41a8bfd1e4f8f8512f4543d8a2 Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Wed, 29 Nov 2023 12:01:29 -0500 Subject: [PATCH 14/15] update package name so it deosn't collide with others in pypi --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f319da9..56efc93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "django-keycloak" +name = "django42-keycloak" version = "0.2.8" description = "Integrate Keycloak with Django 4.2+" homepage = 'https://github.com/skamansam/django-keycloak' From 50df57f7716c72431f7ff404b5893826f50d648a Mon Sep 17 00:00:00 2001 From: "Samuel C. Tyler" Date: Thu, 23 May 2024 10:47:49 -0400 Subject: [PATCH 15/15] make sure the deps are greater than 4.2 --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 56efc93..42b5eac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django42-keycloak" -version = "0.2.8" +version = "0.2.9" description = "Integrate Keycloak with Django 4.2+" homepage = 'https://github.com/skamansam/django-keycloak' repository = 'https://github.com/skamansam/django-keycloak' @@ -30,8 +30,8 @@ tunnel.shell = "ssh -N -L 0.0.0.0:8080:$PROD:8080 $PROD &" # (posix) shell bas [tool.poetry.dependencies] -python = "^3.11" -Django = "^4.2.4" +python = ">=3.11" +Django = ">=4.2" python-keycloak-client = "^0.2.3" [tool.poetry.group.dev.dependencies]