pax_global_header00006660000000000000000000000064147667477150014542gustar00rootroot0000000000000052 comment=8cefd5f97e24d38169cff78dc34fc62586f86de1 djangosaml2-1.10.1/000077500000000000000000000000001476674771500140235ustar00rootroot00000000000000djangosaml2-1.10.1/.github/000077500000000000000000000000001476674771500153635ustar00rootroot00000000000000djangosaml2-1.10.1/.github/release-drafter.yml000066400000000000000000000016151476674771500211560ustar00rootroot00000000000000name-template: 'v$RESOLVED_VERSION' tag-template: 'v$RESOLVED_VERSION' categories: - title: 'Features' labels: - 'enhancement' - 'feat' - 'feature' - title: 'Bug Fixes' labels: - 'bug' - 'bugfix' - 'fix' - title: 'Maintenance' labels: - 'chore' - 'style' change-template: '- $TITLE @$AUTHOR (#$NUMBER)' change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. version-resolver: major: labels: ['major'] minor: labels: ['minor'] patch: labels: ['patch'] default: patch exclude-labels: ['skip'] autolabeler: - label: 'bug' branch: - '/bug\/.+/' - '/bugfix\/.+/' - '/fix\/.+/' - label: 'enhancement' branch: - '/dependabot\/.+/' - '/enhancement\/.+/' - '/feat\/.+/' - '/feature\/.+/' - label: 'chore' branch: - '/chore\/.+/' - '/style\/.+/' template: | ## Release notes $CHANGES djangosaml2-1.10.1/.github/workflows/000077500000000000000000000000001476674771500174205ustar00rootroot00000000000000djangosaml2-1.10.1/.github/workflows/pypi.yml000066400000000000000000000015121476674771500211230ustar00rootroot00000000000000name: Publish Python distribution to PyPI on: release: types: - published jobs: build-n-publish: name: Publish Python distribution to PyPI runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Setup Python 3.10.16 uses: actions/setup-python@v1 with: python-version: 3.10.16 - name: Install pypa/build run: >- python -m pip install build --user - name: Build a binary wheel and a source tarball run: >- python -m build --sdist --wheel --outdir dist/ . - name: Publish distribution to PyPI uses: pypa/gh-action-pypi-publish@master with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} djangosaml2-1.10.1/.github/workflows/python-package.yml000066400000000000000000000025031476674771500230550ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: djangosaml2 on: push: branches: '*' pull_request: branches: '*' jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.10", "3.11", "3.12"] django-version: ["4.2", "5.0", "5.1"] include: - python-version: "3.9" django-version: "4.2" - python-version: "3.13" django-version: "5.1" steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Install dependencies and testing utilities run: | sudo apt-get update && sudo apt-get install xmlsec1 python -m pip install --upgrade pip python -m pip install --upgrade tox rstcheck setuptools codecov #- name: Readme check #if: ${{ matrix.python-version }} == 3.8 && ${{ matrix.django-version }} == "3.0" #run: rstcheck README.rst - name: Tests run: tox -e py${{ matrix.python-version }}-django${{ matrix.django-version }} djangosaml2-1.10.1/.github/workflows/release-drafter.yml000066400000000000000000000005261476674771500232130ustar00rootroot00000000000000name: Release drafter on: push: branches: [main, master, dev] pull_request: types: [opened, reopened, synchronize] jobs: update_release_draft: name: Update draft release runs-on: ubuntu-latest steps: - uses: release-drafter/release-drafter@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} djangosaml2-1.10.1/.gitignore000066400000000000000000000002421476674771500160110ustar00rootroot00000000000000db.sqlite3 .tox/ *.pyc *.egg-info *.sqp build/ dist/ _build/ .pytest_cache .env env/ venv tags .idea/ .vscode/ build/ dist/ *__pycache__* *.coverage docs/build/* djangosaml2-1.10.1/.pre-commit-config.yaml000066400000000000000000000023351476674771500203070ustar00rootroot00000000000000exclude: 'docs|migrations' repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-toml - id: check-case-conflict - id: check-merge-conflict - id: debug-statements - repo: https://github.com/asottile/pyupgrade rev: v3.19.0 hooks: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/myint/autoflake rev: 'v2.3.1' hooks: - id: autoflake args: ['--in-place', '--remove-all-unused-imports', '--ignore-init-module-imports'] - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort name: isort (python) args: ['--settings-path=pyproject.toml'] - repo: https://github.com/psf/black rev: 24.10.0 hooks: - id: black - repo: https://github.com/adamchainz/django-upgrade rev: 1.22.2 hooks: - id: django-upgrade args: [--target-version, "4.2"] - repo: https://github.com/pycqa/flake8 rev: 7.1.1 hooks: - id: flake8 args: ['--config=setup.cfg'] additional_dependencies: [flake8-bugbear, flake8-isort] verbose: true djangosaml2-1.10.1/.readthedocs.yml000066400000000000000000000011001476674771500171010ustar00rootroot00000000000000# .readthedocs.yml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/source/conf.py # Build documentation with MkDocs #mkdocs: # configuration: mkdocs.yml # Optionally build your docs in additional formats such as PDF and ePub formats: all # Optionally set the version of Python and requirements required to build your docs python: version: 3.8 install: - requirements: requirements-docs.txt djangosaml2-1.10.1/CHANGES000066400000000000000000000323101476674771500150150ustar00rootroot00000000000000Changes ======= v1.2.2 (2021-05-27) ------------------- - Fix #245: Don't update user_main_attribute @jaap3 (#283) - Fix #278: Allow ACS_DEFAULT_REDIRECT_URL to override LOGIN_REDIRECT_URL in more places @jaap3 (#285) - Add release notes for previous and upcoming releases @jaap3 (#284) - Add default_auto_field setting, strongly recommended in Django 3.2 @jaap3 (#282) - SPConfig loader instead of global django settings @peppelinux (#281) - Fix #277: Resolve LOGIN_REDIRECT_URL @jaap3 (#279) - Resolve LOGIN_REDIRECT_URL @jaap3 (#277) v1.2.1 (2021-05-23) ------------------- - Documentation improved - unit tests and coverage improved - better handling of unknown idps v1.2.0 (2021-05-14) ------------------- - Implement IdP Scoping parameter for SPs suggesting an entityID to a proxy @pauldekkers (#272) v1.1.5 (2021-04-29) ------------------- - Cast major/minor django VERSION number into float before comparing @lgarvey (#269) - Add note to SameSite cookie docs section recommending upgrade to Django >= 3.1 @m6312 (#267) v1.1.4 (2021-04-28) ------------------- - fix: samesite cookie configuration fix for django version <3.1 v1.1.3 (2021-04-28) -------------------- - Add assertion param to backed.authenticate and backend.is_authorized @lucyeun-alation (#128) - feat: CI - added django 3.1 and 3.2 - fix: Samesite cookie value - fixed #266 - fix: Docs small changes in setup - pysaml2 example conf improved v.1.1.2 (2021-04-11) -------------------- - fix: idp hinting invalid import v.1.1.1 (2021-04-05) -------------------- - Read the docs - Information exposure mitigation on SSO login view v.1.1.0 (2021-04-01) -------------------- - feature: Idp Hinting - params: SAML_DEFAULT_BINDING for SSO - code cleanup in SSO v.1.0.5 (2021-03-05) -------------------- - code linting, cleanup. Not enough but better than before - Documentation: Replace signal with hooks (#251) - Better saml_attribute handling in backend - more resilient - Add session_info to user auth failed template (#248) - Fix SAML_ACS_FAILURE_RESPONSE_FUNCTION override - Update Custom Error Handler docs v.1.0.4 (2021-02-16) -------------------- - fixed saml_attributes of zero length - removed unused code v1.0.3 (2020-02-04) ------------------- - Django Logout behaviour improved v1.0.2 (2020-01-24) ------------------- - RequestVersionTooLow exception handled in ACS - Better exception handling for Malformed SAML Response - pySAML2 dep up to v6.5.1 v1.0.1 (2020-01-20) ------------------- - PySAML2 dependency, security update https://github.com/IdentityPython/pysaml2/commit/12ec4a70c5aaf4c144f6b30a158193ca99bc76cd v1.0.0 (2020-10-15) ------------------- - General refactor with Django ClassViews 0.50.0 (2020-10-15) ------------------- - Discovery Service support 0.40.1 (2020-09-08) ------------------- - [BugFix] HTTP-REDIRECT Authn Requests with optional signature now works. - [BugFix] SameSite - SuspiciousOperation issue in middleware (Issue #220) 0.40.0 (2020-08-07) ------------------- - Allow a SSO request without any attributes besides the NameID info. Backwards-incompatible changes to allow easier behaviour differentiation, two methods now receive the idp identifier (+ **kwargs were added to introduce possible similar changes in the future with less breaking effect): - Method signature changed on Saml2Backend.clean_attributes: from `clean_attributes(self, attributes: dict)` to `clean_attributes(self, attributes: dict, idp_entityid: str, **kwargs)` - Methodignature changed on Saml2Backend.is_authorized: from `is_authorized(self, attributes: dict, attribute_mapping: dict)` to `is_authorized(self, attributes: dict, attribute_mapping: dict, idp_entityid: str, **kwargs)` - SAML session refactor and minor changes in README file - local Logout - independent by IdP SLO Response 0.30.0 (2020-07-30) ------------------- - SameSite workaround with a specialized cookie, decoupled from django default 0.20.0 (2020-06-29) ------------------- - Bugfix: Always save newly created users when ATTRIBUTE_MAPPING is missing in the config - pySAML2 v5.3.0 0.19.1 (2020-06-15) ------------------ - Fixes creating new user with iexact lookup 0.19.0 (2020-06-03) ------------------ - Support several required fields during User creation - Don't pass sigalg parameter when not signing login request - ALLOW_SAML_HOSTNAMES validation for redirect - Custom attribute mapping for Django user model (example) - Slo absence workaround - Metadata EntityID exception handling - Fix unsigned authentication request to POST endpoint - py38 Test fixes - CI with Github actions - Backend restructuring for easier subclassing - Assertion consumer service now more extensible as a class-based view with hooks that can be overridden by subclass implementations. 0.18.1 (2020-02-15) ---------- - Fixed regression from 0.18.0. Thanks to OskarPersson 0.18.0 (2020-02-14) ---------- - Django 3.0 support. Thanks to OskarPersson - forceauthn and allowcreate support. Thanks to peppelinux - Dropped support for Python 3.4 - Also thanks to WebSpider, mhindery, DylannCordel, habi3000 for various fixes and improvements Thanks to plumdog 0.17.2 (2018-08-29) ---------- - Upgraded pysaml2 dependency to version 4.6.0 which fixes security issue. Thanks to plumdog UNRELEASED ---------- - Allowed creating Users with multiple required fields. 0.17.1 (2018-07-16) ---------- - A 403 (permission denied) is now raised if a SAMLResponse is replayed, instead of 500. - Dropped support for Python 3.3 - Upgraded pysaml2 dependency to version 4.5.0 Thanks to francoisfreitag, mhindery, vkurup, peppelinux 0.16.11 (2017-12-25) ---------- - Dropped compatibility for Python < 2.7 and Django < 1.8. - Added a clean_attributes hook allowing backends to restructure attributes extracted from SAML response. - Log when fields are missing in a SAML response. - Log when attribute_mapping maps to nonexistent User fields. - Multiple compatibility fixes and other minor improvements and code cleanups Thanks to francoisfreitag, mhindery, charn, jdufresne 0.16.10 (2017-10-02) ------------------- - Bugfixes and internal refactorings. - Added support for custom USERNAME_FIELD on custom User models. Many thanks to francoisfreitag. 0.16.9 (2017-09-19) ------------------- - Bugfixes and minor improvements. Thanks to goetzk and AmbientLighter. - Added option SAML_LOGOUT_REQUEST_PREFERRED_BINDING - Added Django 1.11 to tox. 0.16.4 (2017-09-11) ------------------- - Added support for SHA-256 signing. Thanks to WebSpider. - Bugfixes. Thanks to justinsg and charn. - Error handling made more extensible. This will be further improved in next versions. 0.16.1 (2017-07-15) ------------------- - Bugfixes. Thanks to canni, AmbientLighter, cranti and logston. - request is now passed to authentication backend (introduced in Django 1.11). Thanks to terite. 0.16.0 (2017-04-14) ------------------- - Upgrade pysaml2 dependency to version 4.4.0 which fixes some serialization issues. Thanks to nakato for the report. - Added support for HTTP Redirect binding with signed authentication requests. Many thanks to liquidpele for this feature and other related refactorings. - The custom permission_denied.html template was removed in favor of standard PermissionDenied exception. Thanks to mhindery. 0.15.0 (2016-12-18) ------------------- - Python 3.5 support. Thanks to timheap. - Added support for callable user attributes. Thanks to andy-miracl and joetsoi. - Security improvement: "next" URL is now checked. thanks to flupzor. - Improved testability. Thanks to flupzor. - Other bugfixes and minor improvements. Thanks to jamaalscarlett, ws0w, jaywink and liquidpele. 0.14.5 (2016-09-19) ------------------- - Django 1.10 support. Thanks to inducer. - Various fixes and minor improvements. Thanks to ajsmilutin, ganiserb, inducer, grunichev, liquidpele and darbula 0.14.4 (2016-03-29) ------------------- - Fix compatibility issue with pysaml2-4.0.3+. Thanks to jimr and astoltz. - Fix Django 1.9 compatibility issue in templates. Thanks to nikoskal. 0.14.3 (2016-03-18) ------------------- - Upgraded to pysaml2-4.0.5. - Added 'ACS_DEFAULT_REDIRECT_URL' setting for default redirection after successful authentication. Thanks to ganiserb. 0.14.2 (2016-03-11) ------------------- - Released under the original 'djangosaml2' package name; abandoning the djangosaml2-knaperek fork. 0.14.1 (2016-03-09) ------------------- - Upgraded to pysaml2-4.0.4. 0.14.0 (2016-01-28) ------------------- - Upgrade to pysaml2-4.0.2. Thanks to kviktor - Django 1.9 support. Thanks to Jordi GutiƩrrez Hermoso 0.13.2 (2015-06-24) ------------------- - Improved usage of standard Python logging. 0.13.1 (2015-06-05) ------------------- - Added support for djangosaml2 specific user model defined by SAML_USER_MODEL setting 0.13.0 (2015-02-12) ------------------- - Django 1.7 support. Thanks to Kamei Toshimitsu 0.12.0 (2014-11-18) ------------------- - Pysaml2 2.2.0 support. Thanks to Erick Tryzelaar 0.11.0 (2014-06-15) ------------------- - Django 1.5 custom user model support. Thanks to Jos van Velzen - Django 1.5 compatibility url template tag. Thanks to bula - Support Django 1.5 and 1.6. Thanks to David Evans and Justin Quick 0.10.0 (2013-05-05) ------------------- - Check that RelayState is not empty before redirecting into a loop. Thanks to Sam Bull for reporting this issue. - In the global logout process, when the session is lost, report an error message to the user and perform a local logout. 0.9.2 (2013-04-19) ------------------ - Upgrade to pysaml2-0.4.3. 0.9.1 (2013-01-29) ------------------ - Add a method to the authentication backend so it is possible to customize the authorization based on SAML attributes. 0.9.0 (2012-10-30) ------------------ - Add a signal for modifying the user just before saving it on the update_user method of the authentication backend. 0.8.1 (2012-10-29) ------------------ - Trim the SAML attributes before setting them to the Django objects if they are too long. This fixes a crash with MySQL. 0.8.0 (2012-10-25) ------------------ - Allow to use different attributes besides 'username' to look for existing users. 0.7.0 (2012-10-19) ------------------ - Add a setting to decide if the user should be redirected to the next view or shown an authorization error when the user tries to login twice. 0.6.1 (2012-09-03) ------------------ - Remove Django from our dependencies - Restore support for Django 1.3 0.6.0 (2012-08-29) ------------------ - Add tox support configured to run the tests with Python 2.6 and 2.7 - Fix some dependencies and sdist generation. Lorenzo Gil - Allow defining a logout redirect url in the settings. Lorenzo Gil - Add some logging calls to improve debugging. Lorenzo Gil - Add support for custom conf loading function. Sam Bull. - Make the tests more robust and easier to run when djangosaml2 is included in a Django project. Sam Bull. - Make sure the profile is not None before saving it. Bug reported by Leif Johansson 0.5.0 (2012-05-22) ------------------ - Allow defining custom config loaders. They can be dynamic depending on the request. - Do not automatically add the authentication backend. This way we allow other people to add their own backends. - Support for additional attributes other than the ones that get mapped into the User model. Those attributes get stored in the UserProfile model. 0.4.2 (2012-03-23) ------------------ - Fix a crash in the idplist templatetag about using an old pysaml2 function - Added a test for the previous crash 0.4.1 (2012-03-19) ------------------ - Upgrade pysaml2 dependency to version 0.4.1 0.4.0 (2012-03-18) ------------------ - Upgrade pysaml2 dependency to version 0.4.0 (update our tests as a result of this) - Add logging calls to make debugging easier - Use the Django configured logger in pysaml2 0.3.3 (2012-02-14) ------------------ - Freeze the version of pysaml2 since we are not (yet!) compatible with version 0.4.0 0.3.2 (2011-12-13) ------------------ - Avoid a crash when reading the SAML attribute that maps to the Django username 0.3.1 (2011-12-01) ------------------ - Load the config in the render method of the idplist templatetag to make it more flexible and reentrant. 0.3.0 (2011-11-30) ------------------ - Templatetag to get the list of available idps. - Allow to map the same SAML attribute into several Django field. 0.2.4 (2011-11-29) ------------------ - Fix restructured text bugs that made pypi page looks bad. 0.2.3 (2011-06-14) ------------------ - Set a unusable password when the user is created for the first time 0.2.2 (2011-06-07) ------------------ - Prevent infinite loop when going to the /saml2/login/ endpoint and the user is already logged in and the settings.LOGIN_REDIRECT_URL is (badly) pointing to /saml2/login. 0.2.1 (2011-05-09) ------------------ - If no next parameter is supplied to the login view, use the settings.LOGIN_REDIRECT_URL as default 0.2.0 (2011-04-26) ------------------ - Python 2.4 compatible if the elementtree library is installed - Allow post processing after the authentication phase by using Django signals. 0.1.1 (2011-04-18) ------------------ - Simple view to echo SAML attributes - Improve documentation - Change default behaviour when a new user is created. Now their attributes are filled this first time - Allow to set a next page after the logout 0.1.0 (2011-03-16) ------------------ - Emancipation from the pysaml package djangosaml2-1.10.1/COPYING000066400000000000000000000261361476674771500150660ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. djangosaml2-1.10.1/MANIFEST.in000066400000000000000000000002441476674771500155610ustar00rootroot00000000000000include README.rst include CHANGES include COPYING global-include *.html *.csr *.key *.pem *.xml include djangosaml2/tests/attribute-maps/*.py global-exclude *.pyc djangosaml2-1.10.1/README.md000066400000000000000000000031571476674771500153100ustar00rootroot00000000000000djangosaml2 =========== ![CI build](https://github.com/peppelinux/djangosaml2/workflows/djangosaml2/badge.svg) ![pypi](https://img.shields.io/pypi/v/djangosaml2.svg) [![Downloads](https://pepy.tech/badge/djangosaml2/month)](https://pepy.tech/project/djangosaml2) ![Documentation Status](https://readthedocs.org/projects/djangosaml2/badge/?version=latest) ![License](https://img.shields.io/badge/license-Apache%202-blue.svg) ![Python versions](https://img.shields.io/pypi/pyversions/djangosaml2) ![Django versions](https://img.shields.io/pypi/djversions/djangosaml2) A Django application that builds a Fully Compliant SAML2 Service Provider on top of PySAML2 library. Djangosaml2 protects your project with a SAML2 SSO Authentication. Features: - HTTP-REDIRECT SSO Binding - HTTP-POST SSO Binding - SLO POST and HTTP-REDIRECT Binding - Discovery Service - embedded Wayf page with customizable html template - IdP Hinting - IdP Scoping - Samesite cookie Please consult the [official Documentation of djangosaml2](https://djangosaml2.readthedocs.io) to get started. Contributing ============ Please open Issues to start debate regarding the requested features, or the patch that you would apply. We do not use a strict submission format, please try to be more concise as possible. The Pull Request MUST be done on the dev branch, please don't push code directly on the master branch. Special thanks ============== This is a community-driven project, born as a fork and maintained by different authors at different times, such as: - [Lorenzo Gil Sanchez](https://github.com/lorenzogil) - [Jozef knaperek](https://github.com/knaperek) djangosaml2-1.10.1/build_pypi.sh000066400000000000000000000002701476674771500165160ustar00rootroot00000000000000#!/bin/bash PROJ_NAME=$(ls | grep *.egg-info | sed -e 's/.egg-info//g') ; rm -R build/ dist/* *.egg-info ; pip uninstall $PROJ_NAME ; python setup.py build sdist twine upload dist/* djangosaml2-1.10.1/djangosaml2/000077500000000000000000000000001476674771500162245ustar00rootroot00000000000000djangosaml2-1.10.1/djangosaml2/__init__.py000066400000000000000000000000001476674771500203230ustar00rootroot00000000000000djangosaml2-1.10.1/djangosaml2/apps.py000066400000000000000000000002761476674771500175460ustar00rootroot00000000000000from django.apps import AppConfig class DjangoSaml2Config(AppConfig): name = "djangosaml2" verbose_name = "DjangoSAML2" def ready(self): from . import signals # noqa djangosaml2-1.10.1/djangosaml2/backends.py000066400000000000000000000344221476674771500203550ustar00rootroot00000000000000# Copyright (C) 2010-2012 Yaco Sistemas (http://www.yaco.es) # Copyright (C) 2009 Lorenzo Gil Sanchez # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import logging import warnings from typing import Any, Optional from django.apps import apps from django.conf import settings from django.core.exceptions import ImproperlyConfigured, MultipleObjectsReturned from django.contrib import auth from django.contrib.auth.backends import ModelBackend logger = logging.getLogger("djangosaml2") def set_attribute(obj: Any, attr: str, new_value: Any) -> bool: """Set an attribute of an object to a specific value, if it wasn't that already. Return True if the attribute was changed and False otherwise. """ if not hasattr(obj, attr): setattr(obj, attr, new_value) return True if new_value != getattr(obj, attr): setattr(obj, attr, new_value) return True return False class Saml2Backend(ModelBackend): # ############################################ # Internal logic, not meant to be overwritten # ############################################ @property def _user_model(self): """Returns the user model specified in the settings, or the default one from this Django installation""" if hasattr(settings, "SAML_USER_MODEL"): try: return apps.get_model(settings.SAML_USER_MODEL) except LookupError: raise ImproperlyConfigured( f"Model '{settings.SAML_USER_MODEL}' could not be loaded" ) except ValueError: raise ImproperlyConfigured( f"Model was specified as '{settings.SAML_USER_MODEL}', but it must be of the form 'app_label.model_name'" ) return auth.get_user_model() @property def _user_lookup_attribute(self) -> str: """Returns the attribute on which to match the identifier with when performing a user lookup""" if hasattr(settings, "SAML_DJANGO_USER_MAIN_ATTRIBUTE"): return settings.SAML_DJANGO_USER_MAIN_ATTRIBUTE return getattr(self._user_model, "USERNAME_FIELD", "username") def _extract_user_identifier_params( self, session_info: dict, attributes: dict, attribute_mapping: dict ) -> tuple[str, Optional[Any]]: """Returns the attribute to perform a user lookup on, and the value to use for it. The value could be the name_id, or any other saml attribute from the request. """ # Lookup key user_lookup_key = self._user_lookup_attribute # Lookup value if getattr(settings, "SAML_USE_NAME_ID_AS_USERNAME", False): if session_info.get("name_id"): logger.debug(f"name_id: {session_info['name_id']}") user_lookup_value = session_info["name_id"].text else: logger.error( "The nameid is not available. Cannot find user without a nameid." ) user_lookup_value = None else: # Obtain the value of the custom attribute to use user_lookup_value = self._get_attribute_value( user_lookup_key, attributes, attribute_mapping ) return user_lookup_key, self.clean_user_main_attribute(user_lookup_value) def _get_attribute_value( self, django_field: str, attributes: dict, attribute_mapping: dict ): saml_attribute = None logger.debug("attribute_mapping: %s", attribute_mapping) for saml_attr, django_fields in attribute_mapping.items(): if django_field in django_fields and saml_attr in attributes: saml_attribute = attributes.get(saml_attr, [None]) if saml_attribute: return saml_attribute[0] else: logger.error( "attributes[saml_attr] attribute value is missing. " f"Either the user session is expired or your mapping is invalid.\n" f"django_field: {django_field}\n" f"attributes: {attributes}\n" f"attribute_mapping: {attribute_mapping}" ) def authenticate( self, request, session_info=None, attribute_mapping=None, create_unknown_user=True, assertion_info=None, **kwargs, ): if session_info is None or attribute_mapping is None: logger.info("Session info or attribute mapping are None") return None if "ava" not in session_info: logger.error('"ava" key not found in session_info') return None idp_entityid = session_info["issuer"] attributes = self.clean_attributes(session_info["ava"], idp_entityid) logger.debug(f"attributes: {attributes}") if not self.is_authorized( attributes, attribute_mapping, idp_entityid, assertion_info ): logger.error("Request not authorized") return None user_lookup_key, user_lookup_value = self._extract_user_identifier_params( session_info, attributes, attribute_mapping ) if not user_lookup_value: logger.error("Could not determine user identifier") return None user, created = self.get_or_create_user( user_lookup_key, user_lookup_value, create_unknown_user, idp_entityid=idp_entityid, attributes=attributes, attribute_mapping=attribute_mapping, request=request, ) # Update user with new attributes from incoming request if user is not None: user = self._update_user( user, attributes, attribute_mapping, force_save=created ) if self.user_can_authenticate(user): return user def _update_user( self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False ): """Update a user with a set of attributes and returns the updated user. By default it uses a mapping defined in the settings constant SAML_ATTRIBUTE_MAPPING. For each attribute, if the user object has that field defined it will be set. """ # No attributes to set on the user instance, nothing to update if not attribute_mapping: # Always save a brand new user instance if user.pk is None: user = self.save_user(user) return user # Lookup key user_lookup_key = self._user_lookup_attribute has_updated_fields = False for saml_attr, django_attrs in attribute_mapping.items(): attr_value_list = attributes.get(saml_attr) if not attr_value_list: logger.debug( f'Could not find value for "{saml_attr}", not updating fields "{django_attrs}"' ) continue for attr in django_attrs: if attr == user_lookup_key: # Don't update user_lookup_key (e.g. username) (issue #245) # It was just used to find/create this user and might have # been changed by `clean_user_main_attribute` continue elif hasattr(user, attr): user_attr = getattr(user, attr) if callable(user_attr): modified = user_attr(attr_value_list) else: modified = set_attribute(user, attr, attr_value_list[0]) has_updated_fields = has_updated_fields or modified else: logger.debug(f'Could not find attribute "{attr}" on user "{user}"') if has_updated_fields or force_save: user = self.save_user(user) return user # ############################################ # Hooks to override by end-users in subclasses # ############################################ def clean_attributes(self, attributes: dict, idp_entityid: str, **kwargs) -> dict: """Hook to clean or filter attributes from the SAML response. No-op by default.""" return attributes def is_authorized( self, attributes: dict, attribute_mapping: dict, idp_entityid: str, assertion_info: dict, **kwargs, ) -> bool: """Hook to allow custom authorization policies based on SAML attributes. True by default.""" return True def user_can_authenticate(self, user) -> bool: """ Reject users with is_active=False. Custom user models that don't have that attribute are allowed. """ is_active = getattr(user, "is_active", None) return is_active or is_active is None def clean_user_main_attribute(self, main_attribute: Any) -> Any: """Hook to clean the extracted user-identifying value. No-op by default.""" return main_attribute def get_or_create_user( self, user_lookup_key: str, user_lookup_value: Any, create_unknown_user: bool, idp_entityid: str, attributes: dict, attribute_mapping: dict, request, ) -> tuple[Optional[settings.AUTH_USER_MODEL], bool]: """Look up the user to authenticate. If he doesn't exist, this method creates him (if so desired). The default implementation looks only at the user_identifier. Override this method in order to do more complex behaviour, e.g. customize this per IdP. """ UserModel = self._user_model # Construct query parameters to query the userModel with. An additional lookup modifier could be specified in the settings. user_query_args = { user_lookup_key + getattr( settings, "SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP", "" ): user_lookup_value } # Lookup existing user # Lookup existing user user, created = None, False try: user = UserModel.objects.get(**user_query_args) except MultipleObjectsReturned: logger.exception( f"Multiple users match, model: {UserModel._meta}, lookup: {user_query_args}", ) except UserModel.DoesNotExist: # Create new one if desired by settings if create_unknown_user: user = UserModel(**{user_lookup_key: user_lookup_value}) user.set_unusable_password() created = True logger.debug(f"New user created: {user}", exc_info=True) else: logger.exception( f"The user does not exist, model: {UserModel._meta}, lookup: {user_query_args}" ) return user, created def save_user( self, user: settings.AUTH_USER_MODEL, *args, **kwargs ) -> settings.AUTH_USER_MODEL: """Hook to add custom logic around saving a user. Return the saved user instance.""" is_new_instance = user.pk is None user.save() if is_new_instance: logger.debug("New user created") else: logger.debug(f"User {user} updated with incoming attributes") return user # ############################################ # Backwards-compatibility stubs # ############################################ def get_attribute_value(self, django_field, attributes, attribute_mapping): warnings.warn( "get_attribute_value() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, stacklevel=2, ) return self._get_attribute_value(django_field, attributes, attribute_mapping) def get_django_user_main_attribute(self): warnings.warn( "get_django_user_main_attribute() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, stacklevel=2, ) return self._user_lookup_attribute def get_django_user_main_attribute_lookup(self): warnings.warn( "get_django_user_main_attribute_lookup() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, stacklevel=2, ) return getattr(settings, "SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP", "") def get_user_query_args(self, main_attribute): warnings.warn( "get_user_query_args() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, stacklevel=2, ) return { self.get_django_user_main_attribute() + self.get_django_user_main_attribute_lookup() } def configure_user(self, user, attributes, attribute_mapping): warnings.warn( "configure_user() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, stacklevel=2, ) return self._update_user(user, attributes, attribute_mapping) def update_user(self, user, attributes, attribute_mapping, force_save=False): warnings.warn( "update_user() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, stacklevel=2, ) return self._update_user(user, attributes, attribute_mapping) def _set_attribute(self, obj, attr, value): warnings.warn( "_set_attribute() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, stacklevel=2, ) return set_attribute(obj, attr, value) def get_saml_user_model(): warnings.warn( "_set_attribute() is deprecated, look at the Saml2Backend on how to subclass it", DeprecationWarning, stacklevel=2, ) return Saml2Backend()._user_model djangosaml2-1.10.1/djangosaml2/cache.py000066400000000000000000000055161476674771500176500ustar00rootroot00000000000000# Copyright (C) 2011-2012 Yaco Sistemas (http://www.yaco.es) # Copyright (C) 2010 Lorenzo Gil Sanchez # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from saml2.cache import Cache class DjangoSessionCacheAdapter(dict): """A cache of things that are stored in the Django Session""" key_prefix = "_saml2" def __init__(self, django_session, key_suffix): self.session = django_session self.key = self.key_prefix + key_suffix super().__init__(self._get_objects()) def _get_objects(self): return self.session.get(self.key, {}) def _set_objects(self, objects): self.session[self.key] = objects def sync(self): # Changes in inner objects do not cause session invalidation # https://docs.djangoproject.com/en/1.9/topics/http/sessions/#when-sessions-are-saved # add objects to session self._set_objects(dict(self)) # invalidate session self.session.modified = True class OutstandingQueriesCache: """Handles the queries that have been sent to the IdP and have not been replied yet. """ def __init__(self, django_session): self._db = DjangoSessionCacheAdapter(django_session, "_outstanding_queries") def outstanding_queries(self): return self._db._get_objects() def set(self, saml2_session_id, came_from): self._db[saml2_session_id] = came_from self._db.sync() def delete(self, saml2_session_id): if saml2_session_id in self._db: del self._db[saml2_session_id] self._db.sync() def sync(self): self._db.sync() class IdentityCache(Cache): """Handles information about the users that have been succesfully logged in. This information is useful because when the user logs out we must know where does he come from in order to notify such IdP/AA. The current implementation stores this information in the Django session. """ def __init__(self, django_session): self._db = DjangoSessionCacheAdapter(django_session, "_identities") self._sync = True class StateCache(DjangoSessionCacheAdapter): """Store state information that is needed to associate a logout request with its response. """ def __init__(self, django_session): super().__init__(django_session, "_state") djangosaml2-1.10.1/djangosaml2/conf.py000066400000000000000000000050101476674771500175170ustar00rootroot00000000000000# Copyright (C) 2010-2012 Yaco Sistemas (http://www.yaco.es) # Copyright (C) 2009 Lorenzo Gil Sanchez # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import copy from typing import Callable, Optional, Union from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest from django.utils.module_loading import import_string from saml2.config import SPConfig from .utils import get_custom_setting def get_config_loader(path: str) -> Callable: """Import the function at a given path and return it""" try: config_loader = import_string(path) except ImportError as e: raise ImproperlyConfigured(f'Error importing SAML config loader {path}: "{e}"') if not callable(config_loader): raise ImproperlyConfigured("SAML config loader must be a callable object.") return config_loader def config_settings_loader(request: Optional[HttpRequest] = None) -> SPConfig: """Utility function to load the pysaml2 configuration. The configuration can be modified based on the request being passed. This is the default config loader, which just loads the config from the settings. """ conf = SPConfig() conf.load(copy.deepcopy(settings.SAML_CONFIG)) return conf def get_config( config_loader_path: Optional[Union[Callable, str]] = None, request: Optional[HttpRequest] = None, ) -> SPConfig: """Load a config_loader function if necessary, and call that function with the request as argument. If the config_loader_path is a callable instead of a string, no importing is necessary and it will be used directly. Return the resulting SPConfig. """ config_loader_path = config_loader_path or get_custom_setting( "SAML_CONFIG_LOADER", "djangosaml2.conf.config_settings_loader" ) if callable(config_loader_path): config_loader = config_loader_path else: config_loader = get_config_loader(config_loader_path) return config_loader(request) djangosaml2-1.10.1/djangosaml2/exceptions.py000066400000000000000000000000631476674771500207560ustar00rootroot00000000000000class IdPConfigurationMissing(Exception): pass djangosaml2-1.10.1/djangosaml2/middleware.py000066400000000000000000000070021476674771500207120ustar00rootroot00000000000000import time from django import VERSION from django.conf import settings from django.core.exceptions import SuspiciousOperation from django.utils.cache import patch_vary_headers from django.utils.http import http_date from django.contrib.sessions.backends.base import UpdateError from django.contrib.sessions.middleware import SessionMiddleware django_version = float("{}.{}".format(*VERSION[:2])) SAMESITE_NONE = None if django_version < 3.1 else "None" class SamlSessionMiddleware(SessionMiddleware): cookie_name = getattr(settings, "SAML_SESSION_COOKIE_NAME", "saml_session") def process_request(self, request): session_key = request.COOKIES.get(self.cookie_name, None) request.saml_session = self.SessionStore(session_key) def process_response(self, request, response): """ If request.saml_session was modified, or if the configuration is to save the session every time, save the changes and set a session cookie or delete the session cookie if the session has been emptied. """ SAMESITE = getattr(settings, "SAML_SESSION_COOKIE_SAMESITE", SAMESITE_NONE) try: accessed = request.saml_session.accessed modified = request.saml_session.modified empty = request.saml_session.is_empty() except AttributeError: return response # First check if we need to delete this cookie. # The session should be deleted only if the session is entirely empty. if self.cookie_name in request.COOKIES and empty: response.delete_cookie( self.cookie_name, path=settings.SESSION_COOKIE_PATH, domain=settings.SESSION_COOKIE_DOMAIN, samesite=SAMESITE, ) patch_vary_headers(response, ("Cookie",)) else: if accessed: patch_vary_headers(response, ("Cookie",)) # relies and the global one if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: if request.saml_session.get_expire_at_browser_close(): max_age = None expires = None else: max_age = request.saml_session.get_expiry_age() expires_time = time.time() + max_age expires = http_date(expires_time) # Save the session data and refresh the client cookie. # Skip session save for 500 responses, refs #3881. if response.status_code != 500: try: request.saml_session.save() except UpdateError: raise SuspiciousOperation( "The request's session was deleted before the " "request completed. The user may have logged " "out in a concurrent request, for example." ) response.set_cookie( self.cookie_name, request.saml_session.session_key, max_age=max_age, expires=expires, domain=settings.SESSION_COOKIE_DOMAIN, path=settings.SESSION_COOKIE_PATH, secure=settings.SESSION_COOKIE_SECURE or None, httponly=settings.SESSION_COOKIE_HTTPONLY or None, samesite=SAMESITE, ) return response djangosaml2-1.10.1/djangosaml2/overrides.py000066400000000000000000000020271476674771500206010ustar00rootroot00000000000000import logging from django.conf import settings import saml2.client logger = logging.getLogger("djangosaml2") class Saml2Client(saml2.client.Saml2Client): """ Custom Saml2Client that adds a choice of preference for binding used with SAML Logout Requests. The preferred binding can be configured via SAML_LOGOUT_REQUEST_PREFERRED_BINDING settings variable. (Original Saml2Client always prefers SOAP, so it is always used if declared in remote metadata); but doesn't actually work and causes crashes. """ def do_logout(self, *args, **kwargs): if not kwargs.get("expected_binding"): try: kwargs["expected_binding"] = ( settings.SAML_LOGOUT_REQUEST_PREFERRED_BINDING ) except AttributeError: logger.warning( "SAML_LOGOUT_REQUEST_PREFERRED_BINDING setting is" " not defined. Default binding will be used." ) return super().do_logout(*args, **kwargs) djangosaml2-1.10.1/djangosaml2/signals.py000066400000000000000000000013261476674771500202400ustar00rootroot00000000000000# Copyright (C) 2011-2012 Yaco Sistemas (http://www.yaco.es) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import django.dispatch pre_user_save = django.dispatch.Signal() post_authenticated = django.dispatch.Signal() djangosaml2-1.10.1/djangosaml2/templates/000077500000000000000000000000001476674771500202225ustar00rootroot00000000000000djangosaml2-1.10.1/djangosaml2/templates/djangosaml2/000077500000000000000000000000001476674771500224235ustar00rootroot00000000000000djangosaml2-1.10.1/djangosaml2/templates/djangosaml2/auth_error.html000066400000000000000000000012341476674771500254630ustar00rootroot00000000000000

Authorization error

You are already logged in and you are trying to go to the login page again.

You may have been redirected here when trying to access some content that required extra privileges that you do not have.

Please logout and login as a different user

djangosaml2-1.10.1/djangosaml2/templates/djangosaml2/echo_attributes.html000066400000000000000000000010611476674771500264730ustar00rootroot00000000000000

SAML attributes

{% for attribute, value in attributes.items %}
{{ attribute }}:
{{ value|join:", " }}
{% endfor %}

Log out

djangosaml2-1.10.1/djangosaml2/templates/djangosaml2/example_post_binding_form.html000066400000000000000000000007441476674771500305330ustar00rootroot00000000000000

You're being redirected to a SSO login page. Please click the button below if you're not redirected automatically within a few seconds.

{% for key, value in params.items %} {% endfor %}
djangosaml2-1.10.1/djangosaml2/templates/djangosaml2/login_error.html000066400000000000000000000005721476674771500256360ustar00rootroot00000000000000

Authentication Error.

Access Denied.

djangosaml2-1.10.1/djangosaml2/templates/djangosaml2/logout_error.html000066400000000000000000000013731476674771500260370ustar00rootroot00000000000000

Logout error

Your Identity Provider ask this system to do a global logout but your federated session is lost.

Even if your local session in this system has been closed, you have probably open sessions in other systems.

In order to prevent illicit use of your personal information, please close your browser window and/or remove your cookies from your browser.

Sorry for this inconvenience.

djangosaml2-1.10.1/djangosaml2/templates/djangosaml2/post_binding_form.html000066400000000000000000000010521476674771500270110ustar00rootroot00000000000000

You're being redirected to a SSO login page. Please click the button below if you're not redirected automatically within a few seconds.

{% for key, value in params.items %} {% endfor %}
djangosaml2-1.10.1/djangosaml2/templates/djangosaml2/wayf.html000066400000000000000000000012321476674771500242550ustar00rootroot00000000000000

Where are you from?

Please select your Identity Provider from the following list:

djangosaml2-1.10.1/djangosaml2/templatetags/000077500000000000000000000000001476674771500207165ustar00rootroot00000000000000djangosaml2-1.10.1/djangosaml2/templatetags/__init__.py000066400000000000000000000000001476674771500230150ustar00rootroot00000000000000djangosaml2-1.10.1/djangosaml2/templatetags/idplist.py000066400000000000000000000027221476674771500227430ustar00rootroot00000000000000# Copyright (C) 2011-2012 Yaco Sistemas (http://www.yaco.es) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from django import template from djangosaml2.conf import config_settings_loader from djangosaml2.utils import available_idps register = template.Library() class IdPListNode(template.Node): def __init__(self, variable_name): self.variable_name = variable_name def render(self, context): conf = config_settings_loader() context[self.variable_name] = available_idps(conf) return "" @register.tag def idplist(parser, token): try: tag_name, as_part, variable = token.split_contents() except ValueError: raise template.TemplateSyntaxError( "%r tag requires two arguments" % token.contents.split()[0] ) if not as_part == "as": raise template.TemplateSyntaxError( '%r tag first argument must be the literal "as"' % tag_name ) return IdPListNode(variable) djangosaml2-1.10.1/djangosaml2/tests/000077500000000000000000000000001476674771500173665ustar00rootroot00000000000000djangosaml2-1.10.1/djangosaml2/tests/__init__.py000066400000000000000000001257321476674771500215110ustar00rootroot00000000000000# Copyright (C) 2012 Sam Bull (lsb@pocketuniverse.ca) # Copyright (C) 2011-2012 Yaco Sistemas (http://www.yaco.es) # Copyright (C) 2010 Lorenzo Gil Sanchez # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 import datetime import re import sys from importlib import import_module from unittest import mock from urllib.parse import parse_qs, urlparse from django import http from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.test.client import RequestFactory from django.urls import reverse, reverse_lazy from django.contrib.auth import SESSION_KEY, get_user_model from django.contrib.auth.models import AnonymousUser from saml2.config import SPConfig from saml2.s_utils import ( UnknownSystemEntity, decode_base64_and_inflate, deflate_and_base64_encode, ) from djangosaml2 import views from djangosaml2.cache import OutstandingQueriesCache from djangosaml2.conf import get_config from djangosaml2.middleware import SamlSessionMiddleware from djangosaml2.tests import conf from djangosaml2.utils import ( get_fallback_login_redirect_url, get_idp_sso_supported_bindings, get_session_id_from_saml2, get_subject_id_from_saml2, saml2_from_httpredirect_request, ) from djangosaml2.views import EchoAttributesView, finish_logout from .auth_response import auth_response from .utils import SAMLPostFormParser User = get_user_model() PY_VERSION = sys.version_info[:2] def dummy_loader(request): return "dummy_loader" def dummy_get_response(request: http.HttpRequest): """ Return a basic HttpResponse. Function needed to instantiate SamlSessionMiddleware. """ return http.HttpResponse("Session test") non_callable = "just a string" class UtilsTests(TestCase): def test_get_config_valid_path(self): self.assertEqual(get_config("djangosaml2.tests.dummy_loader"), "dummy_loader") def test_get_config_wrongly_formatted_path(self): with self.assertRaisesMessage( ImproperlyConfigured, "SAML config loader must be a callable object." ): get_config("djangosaml2.tests.non_callable") def test_get_config_nonsense_path(self): with self.assertRaisesMessage( ImproperlyConfigured, "Error importing SAML config loader lalala.nonexisting.blabla: \"No module named 'lalala'\"", ): get_config("lalala.nonexisting.blabla") def test_get_config_missing_function(self): with self.assertRaisesMessage( ImproperlyConfigured, 'Module "djangosaml2.tests" does not define a "nonexisting_function" attribute/class', ): get_config("djangosaml2.tests.nonexisting_function") @override_settings(LOGIN_REDIRECT_URL="/accounts/profile/") def test_get_fallback_login_redirect_url(self): self.assertEqual(get_fallback_login_redirect_url(), "/accounts/profile/") with override_settings(): del settings.LOGIN_REDIRECT_URL # Neither LOGIN_REDIRECT_URL nor ACS_DEFAULT_REDIRECT_URL is configured self.assertEqual(get_fallback_login_redirect_url(), "/") with override_settings(ACS_DEFAULT_REDIRECT_URL="testprofiles:dashboard"): # ACS_DEFAULT_REDIRECT_URL is configured, so it is used (and resolved) self.assertEqual(get_fallback_login_redirect_url(), "/dashboard/") with override_settings( ACS_DEFAULT_REDIRECT_URL=reverse_lazy("testprofiles:dashboard") ): # Lazy urls are resolved self.assertEqual(get_fallback_login_redirect_url(), "/dashboard/") class SAML2Tests(TestCase): urls = "djangosaml2.tests.urls" def init_cookies(self): self.client.cookies[settings.SESSION_COOKIE_NAME] = "testing" def add_outstanding_query(self, session_id, came_from): settings.SESSION_ENGINE = "django.contrib.sessions.backends.db" engine = import_module(settings.SESSION_ENGINE) self.saml_session = engine.SessionStore() self.saml_session.save() self.oq_cache = OutstandingQueriesCache(self.saml_session) self.oq_cache.set( session_id if isinstance(session_id, str) else session_id.decode(), came_from, ) self.saml_session.save() self.client.cookies[settings.SESSION_COOKIE_NAME] = ( self.saml_session.session_key ) def b64_for_post(self, xml_text, encoding="utf-8"): return base64.b64encode(xml_text.encode(encoding)).decode("ascii") def test_get_idp_sso_supported_bindings_noargs(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) idp_id = "https://idp.example.com/simplesaml/saml2/idp/metadata.php" self.assertEqual( get_idp_sso_supported_bindings()[0], list( settings.SAML_CONFIG["service"]["sp"]["idp"][idp_id][ "single_sign_on_service" ].keys() )[0], ) def test_get_idp_sso_supported_bindings_unknown_idp(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) with self.assertRaises(UnknownSystemEntity): get_idp_sso_supported_bindings(idp_entity_id="random") def test_get_idp_sso_supported_bindings_no_idps(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=[], metadata_file="remote_metadata_no_idp.xml", ) with self.assertRaisesMessage(ImproperlyConfigured, "No IdP configured!"): get_idp_sso_supported_bindings() def test_unsigned_post_authn_request(self): """ Test that unsigned authentication requests via POST binding does not error. https://github.com/knaperek/djangosaml2/issues/168 """ settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_post_binding.xml", authn_requests_signed=False, ) response = self.client.get(reverse("saml2_login")) self.assertEqual(response.status_code, 200) # Using POST-binding returns a page with form containing the SAMLRequest response_parser = SAMLPostFormParser() response_parser.feed(response.content.decode("utf-8")) saml_request = response_parser.saml_request_value self.assertIsNotNone(saml_request) self.assertIn( "AuthnRequest xmlns", base64.b64decode(saml_request).decode("utf-8") ) def test_login_evil_redirect(self): """ Make sure that if we give an URL other than our own host as the next parameter, it is replaced with the fallback login redirect url. """ # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) for redirect_url in ["/dashboard/", "testprofiles:dashboard"]: with self.subTest(LOGIN_REDIRECT_URL=redirect_url): with override_settings(LOGIN_REDIRECT_URL=redirect_url): response = self.client.get( reverse("saml2_login") + "?next=http://evil.com" ) url = urlparse(response["Location"]) params = parse_qs(url.query) self.assertEqual(params["RelayState"], ["/dashboard/"]) with self.subTest(ACS_DEFAULT_REDIRECT_URL=redirect_url): with override_settings(ACS_DEFAULT_REDIRECT_URL=redirect_url): response = self.client.get( reverse("saml2_login") + "?next=http://evil.com" ) url = urlparse(response["Location"]) params = parse_qs(url.query) self.assertEqual(params["RelayState"], ["/dashboard/"]) def test_no_redirect(self): """ Make sure that if we give an empty path as the next parameter, it is replaced with the fallback login redirect url. """ # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) for redirect_url in ["/dashboard/", "testprofiles:dashboard"]: with self.subTest(LOGIN_REDIRECT_URL=redirect_url): with override_settings(LOGIN_REDIRECT_URL=redirect_url): response = self.client.get(reverse("saml2_login") + "?next=") url = urlparse(response["Location"]) params = parse_qs(url.query) self.assertEqual(params["RelayState"], ["/dashboard/"]) with self.subTest(ACS_DEFAULT_REDIRECT_URL=redirect_url): with override_settings(ACS_DEFAULT_REDIRECT_URL=redirect_url): response = self.client.get(reverse("saml2_login") + "?next=") url = urlparse(response["Location"]) params = parse_qs(url.query) self.assertEqual(params["RelayState"], ["/dashboard/"]) @override_settings(SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN=True) def test_login_already_logged(self): self.client.force_login(User.objects.create(username="user", password="pass")) for redirect_url in ["/dashboard/", "testprofiles:dashboard"]: with self.subTest(LOGIN_REDIRECT_URL=redirect_url): with override_settings(LOGIN_REDIRECT_URL=redirect_url): with self.subTest("no next url"): response = self.client.get(reverse("saml2_login")) self.assertRedirects(response, "/dashboard/") with self.subTest("evil next url"): response = self.client.get( reverse("saml2_login") + "?next=http://evil.com" ) self.assertRedirects(response, "/dashboard/") with self.subTest(ACS_DEFAULT_REDIRECT_URL=redirect_url): with override_settings(ACS_DEFAULT_REDIRECT_URL=redirect_url): with self.subTest("no next url"): response = self.client.get(reverse("saml2_login")) self.assertRedirects(response, "/dashboard/") with self.subTest("evil next url"): response = self.client.get( reverse("saml2_login") + "?next=http://evil.com" ) self.assertRedirects(response, "/dashboard/") def test_unknown_idp(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", metadata_file="remote_metadata_three_idps.xml", ) response = self.client.get( reverse("saml2_login") + "?idp=https://unknown.org" ) self.assertContains( response, "<b>https://unknown.org</b>", status_code=403 ) def test_login_authn_context(self): sp_kwargs = { "requested_authn_context": { "authn_context_class_ref": [ "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient", ], "comparison": "minimum", } } # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", sp_kwargs=sp_kwargs, ) response = self.client.get(reverse("saml2_login")) self.assertEqual(response.status_code, 302) location = response["Location"] url = urlparse(location) self.assertEqual(url.hostname, "idp.example.com") self.assertEqual(url.path, "/simplesaml/saml2/idp/SSOService.php") params = parse_qs(url.query) self.assertIn("SAMLRequest", params) saml_request = params["SAMLRequest"][0] self.assertIn( "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", decode_base64_and_inflate(saml_request).decode("utf-8"), ) def test_login_one_idp(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) response = self.client.get(reverse("saml2_login")) self.assertEqual(response.status_code, 302) location = response["Location"] url = urlparse(location) self.assertEqual(url.hostname, "idp.example.com") self.assertEqual(url.path, "/simplesaml/saml2/idp/SSOService.php") params = parse_qs(url.query) self.assertIn("SAMLRequest", params) self.assertIn("RelayState", params) saml_request = params["SAMLRequest"][0] self.assertIn( "AuthnRequest xmlns", decode_base64_and_inflate(saml_request).decode("utf-8"), ) # if we set a next arg in the login view, it is preserverd # in the RelayState argument nexturl = "/another-view/" response = self.client.get(reverse("saml2_login"), {"next": nexturl}) self.assertEqual(response.status_code, 302) location = response["Location"] url = urlparse(location) self.assertEqual(url.hostname, "idp.example.com") self.assertEqual(url.path, "/simplesaml/saml2/idp/SSOService.php") params = parse_qs(url.query) self.assertIn("SAMLRequest", params) self.assertIn("RelayState", params) self.assertEqual(params["RelayState"][0], nexturl) def test_login_several_idps(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp1.example.com", "idp2.example.com", "idp3.example.com"], metadata_file="remote_metadata_three_idps.xml", ) response = self.client.get(reverse("saml2_login")) # a WAYF page should be displayed self.assertContains(response, "Where are you from?", status_code=200) for i in range(1, 4): link = "/login/?idp=https://idp%d.example.com/simplesaml/saml2/idp/metadata.php&next=/" self.assertContains(response, link % i) # click on the second idp response = self.client.get( reverse("saml2_login"), { "idp": "https://idp2.example.com/simplesaml/saml2/idp/metadata.php", "next": "/", }, ) self.assertEqual(response.status_code, 302) location = response["Location"] url = urlparse(location) self.assertEqual(url.hostname, "idp2.example.com") self.assertEqual(url.path, "/simplesaml/saml2/idp/SSOService.php") params = parse_qs(url.query) self.assertIn("SAMLRequest", params) self.assertIn("RelayState", params) saml_request = params["SAMLRequest"][0] self.assertIn( "AuthnRequest xmlns", decode_base64_and_inflate(saml_request).decode("utf-8"), ) @override_settings(ACS_DEFAULT_REDIRECT_URL="testprofiles:dashboard") def test_assertion_consumer_service(self): # Get initial number of users initial_user_count = User.objects.count() settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) response = self.client.get(reverse("saml2_login")) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName came_from = "/another-view/" self.add_outstanding_query(session_id, came_from) # this will create a user saml_response = auth_response(session_id, "student") _url = reverse("saml2_acs") response = self.client.post( _url, { "SAMLResponse": self.b64_for_post(saml_response), "RelayState": came_from, }, ) self.assertEqual(response.status_code, 302) location = response["Location"] url = urlparse(location) self.assertEqual(url.path, came_from) self.assertEqual(User.objects.count(), initial_user_count + 1) user_id = self.client.session[SESSION_KEY] user = User.objects.get(id=user_id) self.assertEqual(user.username, "student") # Since a new user object is created, the password # field is set to have an unusable password. self.assertEqual(user.has_usable_password(), False) # let's create another user and log in with that one new_user = User.objects.create(username="teacher", password="not-used") # session_id = "a1111111111111111111111111111111" client = Client() response = client.get(reverse("saml2_login")) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) came_from = "" # bad, let's see if we can deal with this saml_response = auth_response(session_id, "teacher") self.add_outstanding_query(session_id, "/") response = client.post( reverse("saml2_acs"), { "SAMLResponse": self.b64_for_post(saml_response), "RelayState": came_from, }, ) # as the RelayState is empty we have redirect to ACS_DEFAULT_REDIRECT_URL self.assertRedirects(response, "/dashboard/") self.assertEqual(str(new_user.id), client.session[SESSION_KEY]) new_user.refresh_from_db() # Since "new_user" already had a password, # the password field will remain unchanged. self.assertEqual(new_user.has_usable_password(), True) @override_settings(ACS_DEFAULT_REDIRECT_URL="testprofiles:dashboard") def test_assertion_consumer_service_default_relay_state(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) new_user = User.objects.create(username="teacher", password="not-used") response = self.client.get(reverse("saml2_login")) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) saml_response = auth_response(session_id, "teacher") self.add_outstanding_query(session_id, "/") response = self.client.post( reverse("saml2_acs"), { "SAMLResponse": self.b64_for_post(saml_response), }, ) self.assertEqual(response.status_code, 302) # The RelayState is missing, redirect to ACS_DEFAULT_REDIRECT_URL self.assertRedirects(response, "/dashboard/") self.assertEqual(str(new_user.id), self.client.session[SESSION_KEY]) def test_assertion_consumer_service_already_logged_in_allowed(self): self.client.force_login(User.objects.create(username="user", password="pass")) settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN = True came_from = "/dummy-url/" response = self.client.get(reverse("saml2_login") + f"?next={came_from}") self.assertEqual(response.status_code, 302) url = urlparse(response["Location"]) self.assertEqual(url.path, came_from) def test_assertion_consumer_service_already_logged_in_error(self): self.client.force_login(User.objects.create(username="user", password="pass")) settings.SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN = False came_from = "/dummy-url/" response = self.client.get(reverse("saml2_login") + f"?next={came_from}") self.assertEqual(response.status_code, 200) self.assertInHTML( "

You are already logged in and you are trying to go to the login page again.

", response.content.decode(), ) def test_assertion_consumer_service_no_session(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) response = self.client.get(reverse("saml2_login")) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName came_from = "/another-view/" self.add_outstanding_query(session_id, came_from) # Authentication is confirmed. saml_response = auth_response(session_id, "student") response = self.client.post( reverse("saml2_acs"), { "SAMLResponse": self.b64_for_post(saml_response), "RelayState": came_from, }, ) self.assertEqual(response.status_code, 302) location = response["Location"] url = urlparse(location) self.assertEqual(url.path, came_from) # Session should no longer be in outstanding queries. saml_response = auth_response(session_id, "student") response = self.client.post( reverse("saml2_acs"), { "SAMLResponse": self.b64_for_post(saml_response), "RelayState": came_from, }, ) self.assertEqual(response.status_code, 403) def test_missing_param_to_assertion_consumer_service_request(self): # Send request without SAML2Response parameter response = self.client.post(reverse("saml2_acs")) # Assert that view responded with "Bad Request" error self.assertEqual(response.status_code, 400) def test_bad_request_method_to_assertion_consumer_service(self): # Send request with non-POST method. response = self.client.get(reverse("saml2_acs")) # Assert that view responded with method not allowed status self.assertEqual(response.status_code, 405) def do_login(self): """Auxiliary method used in several tests (mainly logout tests)""" self.init_cookies() response = self.client.get(reverse("saml2_login")) saml2_req = saml2_from_httpredirect_request(response.url) session_id = get_session_id_from_saml2(saml2_req) # session_id should start with a letter since it is a NCName came_from = "/another-view/" self.add_outstanding_query(session_id, came_from) saml_response = auth_response(session_id, "student") # this will create a user response = self.client.post( reverse("saml2_acs"), { "SAMLResponse": self.b64_for_post(saml_response), "RelayState": came_from, }, ) subject_id = get_subject_id_from_saml2(saml_response) self.assertEqual(response.status_code, 302) return subject_id def test_echo_view_no_saml_session(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) self.do_login() request = RequestFactory().get("/bar/foo") request.COOKIES = self.client.cookies request.user = User.objects.last() middleware = SamlSessionMiddleware(dummy_get_response) middleware.process_request(request) response = EchoAttributesView.as_view()(request) self.assertEqual(response.status_code, 200) self.assertEqual( response.content.decode(), "No active SAML identity found. Are you sure you have logged in via SAML?", ) def test_echo_view_success(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) self.do_login() request = RequestFactory().get("/") request.user = User.objects.last() middleware = SamlSessionMiddleware(dummy_get_response) middleware.process_request(request) saml_session_name = getattr( settings, "SAML_SESSION_COOKIE_NAME", "saml_session" ) getattr(request, saml_session_name)[ "_saml2_subject_id" ] = "1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03" getattr(request, saml_session_name).save() response = EchoAttributesView.as_view()(request) self.assertEqual(response.status_code, 200) self.assertIn( "

SAML attributes

", response.content.decode(), "Echo page not rendered", ) def test_logout(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) self.do_login() response = self.client.get(reverse("saml2_logout")) self.assertEqual(response.status_code, 302) location = response["Location"] url = urlparse(location) self.assertEqual(url.hostname, "idp.example.com") self.assertEqual(url.path, "/simplesaml/saml2/idp/SingleLogoutService.php") params = parse_qs(url.query) self.assertIn("SAMLRequest", params) saml_request = params["SAMLRequest"][0] self.assertIn( "LogoutRequest xmlns", decode_base64_and_inflate(saml_request).decode("utf-8"), "Not a valid LogoutRequest", ) def test_logout_service_local(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) self.do_login() response = self.client.get(reverse("saml2_logout")) self.assertEqual(response.status_code, 302) location = response["Location"] url = urlparse(location) self.assertEqual(url.hostname, "idp.example.com") self.assertEqual(url.path, "/simplesaml/saml2/idp/SingleLogoutService.php") params = parse_qs(url.query) self.assertIn("SAMLRequest", params) saml_request = params["SAMLRequest"][0] self.assertIn( "LogoutRequest xmlns", decode_base64_and_inflate(saml_request).decode("utf-8"), "Not a valid LogoutRequest", ) # now simulate a logout response sent by the idp expected_request = """http://sp.example.com/saml2/metadata/1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03a0123456789abcdef0123456789abcdef""" request_id = re.findall(r' ID="(.*?)" ', expected_request)[0] instant = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") saml_response = """ https://idp.example.com/simplesaml/saml2/idp/metadata.php""".format( request_id, instant ) response = self.client.get( reverse("saml2_ls"), { "SAMLResponse": deflate_and_base64_encode(saml_response), }, ) self.assertContains(response, "Logged out", status_code=200) self.assertListEqual(list(self.client.session.keys()), []) def test_logout_service_global(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) subject_id = self.do_login() # now simulate a global logout process initiated by another SP subject_id = views._get_subject_id(self.saml_session) instant = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") saml_request = """ https://idp.example.com/simplesaml/saml2/idp/metadata.php{}_1837687b7bc9faad85839dbeb319627889f3021757""".format( instant, subject_id ) response = self.client.get( reverse("saml2_ls"), { "SAMLRequest": deflate_and_base64_encode(saml_request), }, ) self.assertEqual(response.status_code, 302) location = response["Location"] url = urlparse(location) self.assertEqual(url.hostname, "idp.example.com") self.assertEqual(url.path, "/simplesaml/saml2/idp/SingleLogoutService.php") params = parse_qs(url.query) self.assertIn("SAMLResponse", params) saml_response = params["SAMLResponse"][0] self.assertIn( "Response xmlns", decode_base64_and_inflate(saml_response).decode("utf-8"), "Not a valid Response", ) @override_settings(LOGOUT_REDIRECT_URL="/dashboard/") def test_post_logout_redirection(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) self.do_login() response = self.client.get(reverse("saml2_logout")) self.assertEqual(response.status_code, 302) # now simulate a logout response sent by the idp expected_request = """http://sp.example.com/saml2/metadata/1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03a0123456789abcdef0123456789abcdef""" request_id = re.findall(r' ID="(.*?)" ', expected_request)[0] instant = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") saml_response = """ https://idp.example.com/simplesaml/saml2/idp/metadata.php""".format( request_id, instant ) response = self.client.get( reverse("saml2_ls"), { "SAMLResponse": deflate_and_base64_encode(saml_response), }, ) self.assertRedirects(response, "/dashboard/") self.assertListEqual(list(self.client.session.keys()), []) def test_incomplete_logout(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"] ) # don't do a login # now simulate a global logout process initiated by another SP instant = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") saml_request = 'https://idp.example.com/simplesaml/saml2/idp/metadata.php{}_1837687b7bc9faad85839dbeb319627889f3021757'.format( instant, "invalid-subject-id" ) response = self.client.get( reverse("saml2_ls"), { "SAMLRequest": deflate_and_base64_encode(saml_request), }, ) self.assertContains(response, "Logout error", status_code=403) def test_finish_logout_renders_error_template(self): request = RequestFactory().get("/bar/foo") response = finish_logout(request, None) self.assertContains(response, "

Logout error

", status_code=200) def test_sigalg_not_passed_when_not_signing_request(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) with mock.patch( "djangosaml2.views.Saml2Client.prepare_for_authenticate", return_value=("session_id", {"url": "fake"}), ) as prepare_for_auth_mock: self.client.get(reverse("saml2_login")) prepare_for_auth_mock.assert_called_once() _args, kwargs = prepare_for_auth_mock.call_args self.assertNotIn("sigalg", kwargs) def test_sigalg_passed_when_signing_request(self): # monkey patch SAML configuration settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) settings.SAML_CONFIG["service"]["sp"]["authn_requests_signed"] = True with mock.patch( "djangosaml2.views.Saml2Client.prepare_for_authenticate", return_value=("session_id", {"url": "fake"}), ) as prepare_for_auth_mock: self.client.get(reverse("saml2_login")) prepare_for_auth_mock.assert_called_once() _args, kwargs = prepare_for_auth_mock.call_args self.assertIn("sigalg", kwargs) @override_settings(SAML2_DISCO_URL="https://that-ds.org/ds") def test_discovery_service(self): settings.SAML_CONFIG = conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_three_idps.xml", ) response = self.client.get(reverse("saml2_login")) self.assertEqual(response.status_code, 302) self.assertIn("https://that-ds.org/ds", response.url) def test_config_loader(request): config = SPConfig() config.load({"entityid": "testentity"}) return config def test_config_loader_callable(request): config = SPConfig() config.load({"entityid": "testentity_callable"}) return config def test_config_loader_with_real_conf(request): config = SPConfig() config.load( conf.create_conf( sp_host="sp.example.com", idp_hosts=["idp.example.com"], metadata_file="remote_metadata_one_idp.xml", ) ) return config class ConfTests(TestCase): def test_custom_conf_loader(self): config_loader_path = "djangosaml2.tests.test_config_loader" request = RequestFactory().get("/bar/foo") conf = get_config(config_loader_path, request) self.assertEqual(conf.entityid, "testentity") def test_custom_conf_loader_callable(self): config_loader_path = test_config_loader_callable request = RequestFactory().get("/bar/foo") conf = get_config(config_loader_path, request) self.assertEqual(conf.entityid, "testentity_callable") def test_custom_conf_loader_from_view(self): config_loader_path = "djangosaml2.tests.test_config_loader_with_real_conf" request = RequestFactory().get("/login/") request.user = AnonymousUser() middleware = SamlSessionMiddleware(dummy_get_response) middleware.process_request(request) saml_session_name = getattr( settings, "SAML_SESSION_COOKIE_NAME", "saml_session" ) getattr(request, saml_session_name).save() response = views.LoginView.as_view(config_loader_path=config_loader_path)( request ) self.assertEqual(response.status_code, 302) location = response["Location"] url = urlparse(location) self.assertEqual(url.hostname, "idp.example.com") self.assertEqual(url.path, "/simplesaml/saml2/idp/SSOService.php") class SessionEnabledTestCase(TestCase): def get_session(self): engine = import_module(settings.SESSION_ENGINE) session = self.client.session or engine.SessionStore() return session def set_session_cookies(self, session): # Set the cookie to represent the session session_cookie = settings.SESSION_COOKIE_NAME self.client.cookies[session_cookie] = session.session_key cookie_data = { "max-age": None, "path": "/", "domain": settings.SESSION_COOKIE_DOMAIN, "secure": settings.SESSION_COOKIE_SECURE or None, "expires": None, } self.client.cookies[session_cookie].update(cookie_data) class MiddlewareTests(SessionEnabledTestCase): def test_middleware_cookie_expireatbrowserclose(self): with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=True): session = self.get_session() session.save() self.set_session_cookies(session) config_loader_path = "djangosaml2.tests.test_config_loader_with_real_conf" request = RequestFactory().get("/login/") request.user = AnonymousUser() request.session = session middleware = SamlSessionMiddleware(dummy_get_response) middleware.process_request(request) saml_session_name = getattr( settings, "SAML_SESSION_COOKIE_NAME", "saml_session" ) getattr(request, saml_session_name).save() response = views.LoginView.as_view(config_loader_path=config_loader_path)( request ) response = middleware.process_response(request, response) cookie = response.cookies[saml_session_name] self.assertEqual(cookie["expires"], "") self.assertEqual(cookie["max-age"], "") def test_middleware_cookie_with_expiry(self): with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=False): session = self.get_session() session.save() self.set_session_cookies(session) config_loader_path = "djangosaml2.tests.test_config_loader_with_real_conf" request = RequestFactory().get("/login/") request.user = AnonymousUser() request.session = session middleware = SamlSessionMiddleware(dummy_get_response) middleware.process_request(request) saml_session_name = getattr( settings, "SAML_SESSION_COOKIE_NAME", "saml_session" ) getattr(request, saml_session_name).save() response = views.LoginView.as_view(config_loader_path=config_loader_path)( request ) response = middleware.process_response(request, response) cookie = response.cookies[saml_session_name] self.assertIsNotNone(cookie["expires"]) self.assertNotEqual(cookie["expires"], "") self.assertNotEqual(cookie["max-age"], "") def test_middleware_cookie_samesite(self): with override_settings(SAML_SESSION_COOKIE_SAMESITE="Lax"): session = self.get_session() session.save() self.set_session_cookies(session) config_loader_path = "djangosaml2.tests.test_config_loader_with_real_conf" request = RequestFactory().get("/login/") request.user = AnonymousUser() request.session = session middleware = SamlSessionMiddleware(dummy_get_response) middleware.process_request(request) saml_session_name = getattr( settings, "SAML_SESSION_COOKIE_NAME", "saml_session" ) getattr(request, saml_session_name).save() response = views.LoginView.as_view(config_loader_path=config_loader_path)( request ) response = middleware.process_response(request, response) cookie = response.cookies[saml_session_name] self.assertEqual(cookie["samesite"], "Lax") djangosaml2-1.10.1/djangosaml2/tests/attribute-maps/000077500000000000000000000000001476674771500223275ustar00rootroot00000000000000djangosaml2-1.10.1/djangosaml2/tests/attribute-maps/django_saml_uri.py000066400000000000000000000010531476674771500260350ustar00rootroot00000000000000X500ATTR_OID = "urn:oid:2.5.4." PKCS_9 = "urn:oid:1.2.840.113549.1.9.1." UCL_DIR_PILOT = "urn:oid:0.9.2342.19200300.100.1." MAP = { "identifier": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "fro": { X500ATTR_OID + "3": "first_name", # cn X500ATTR_OID + "4": "last_name", # sn PKCS_9 + "1": "email", UCL_DIR_PILOT + "1": "uid", }, "to": { "first_name": X500ATTR_OID + "3", "last_name": X500ATTR_OID + "4", "email": PKCS_9 + "1", "uid": UCL_DIR_PILOT + "1", }, } djangosaml2-1.10.1/djangosaml2/tests/attribute-maps/saml_uri.py000066400000000000000000000255421476674771500245240ustar00rootroot00000000000000__author__ = "rolandh" EDUPERSON_OID = "urn:oid:1.3.6.1.4.1.5923.1.1.1." X500ATTR_OID = "urn:oid:2.5.4." NOREDUPERSON_OID = "urn:oid:1.3.6.1.4.1.2428.90.1." NETSCAPE_LDAP = "urn:oid:2.16.840.1.113730.3.1." UCL_DIR_PILOT = "urn:oid:0.9.2342.19200300.100.1." PKCS_9 = "urn:oid:1.2.840.113549.1.9.1." UMICH = "urn:oid:1.3.6.1.4.1.250.1.57." SCHAC = "urn:oid:1.3.6.1.4.1.25178.1.2." MAP = { "identifier": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", "fro": { EDUPERSON_OID + "2": "eduPersonNickname", EDUPERSON_OID + "9": "eduPersonScopedAffiliation", EDUPERSON_OID + "11": "eduPersonAssurance", EDUPERSON_OID + "10": "eduPersonTargetedID", EDUPERSON_OID + "4": "eduPersonOrgUnitDN", NOREDUPERSON_OID + "6": "norEduOrgAcronym", NOREDUPERSON_OID + "7": "norEduOrgUniqueIdentifier", NOREDUPERSON_OID + "4": "norEduPersonLIN", EDUPERSON_OID + "1": "eduPersonAffiliation", NOREDUPERSON_OID + "2": "norEduOrgUnitUniqueNumber", NETSCAPE_LDAP + "40": "userSMIMECertificate", NOREDUPERSON_OID + "1": "norEduOrgUniqueNumber", NETSCAPE_LDAP + "241": "displayName", UCL_DIR_PILOT + "37": "associatedDomain", EDUPERSON_OID + "6": "eduPersonPrincipalName", NOREDUPERSON_OID + "8": "norEduOrgUnitUniqueIdentifier", NOREDUPERSON_OID + "9": "federationFeideSchemaVersion", X500ATTR_OID + "53": "deltaRevocationList", X500ATTR_OID + "52": "supportedAlgorithms", X500ATTR_OID + "51": "houseIdentifier", X500ATTR_OID + "50": "uniqueMember", X500ATTR_OID + "19": "physicalDeliveryOfficeName", X500ATTR_OID + "18": "postOfficeBox", X500ATTR_OID + "17": "postalCode", X500ATTR_OID + "16": "postalAddress", X500ATTR_OID + "15": "businessCategory", X500ATTR_OID + "14": "searchGuide", EDUPERSON_OID + "5": "eduPersonPrimaryAffiliation", X500ATTR_OID + "12": "title", X500ATTR_OID + "11": "ou", X500ATTR_OID + "10": "o", X500ATTR_OID + "37": "cACertificate", X500ATTR_OID + "36": "userCertificate", X500ATTR_OID + "31": "member", X500ATTR_OID + "30": "supportedApplicationContext", X500ATTR_OID + "33": "roleOccupant", X500ATTR_OID + "32": "owner", NETSCAPE_LDAP + "1": "carLicense", PKCS_9 + "1": "email", NETSCAPE_LDAP + "3": "employeeNumber", NETSCAPE_LDAP + "2": "departmentNumber", X500ATTR_OID + "39": "certificateRevocationList", X500ATTR_OID + "38": "authorityRevocationList", NETSCAPE_LDAP + "216": "userPKCS12", EDUPERSON_OID + "8": "eduPersonPrimaryOrgUnitDN", X500ATTR_OID + "9": "street", X500ATTR_OID + "8": "st", NETSCAPE_LDAP + "39": "preferredLanguage", EDUPERSON_OID + "7": "eduPersonEntitlement", X500ATTR_OID + "2": "knowledgeInformation", X500ATTR_OID + "7": "l", X500ATTR_OID + "6": "c", X500ATTR_OID + "5": "serialNumber", X500ATTR_OID + "4": "sn", X500ATTR_OID + "3": "cn", UCL_DIR_PILOT + "60": "jpegPhoto", X500ATTR_OID + "65": "pseudonym", NOREDUPERSON_OID + "5": "norEduPersonNIN", UCL_DIR_PILOT + "3": "mail", UCL_DIR_PILOT + "25": "dc", X500ATTR_OID + "40": "crossCertificatePair", X500ATTR_OID + "42": "givenName", X500ATTR_OID + "43": "initials", X500ATTR_OID + "44": "generationQualifier", X500ATTR_OID + "45": "x500UniqueIdentifier", X500ATTR_OID + "46": "dnQualifier", X500ATTR_OID + "47": "enhancedSearchGuide", X500ATTR_OID + "48": "protocolInformation", X500ATTR_OID + "54": "dmdName", NETSCAPE_LDAP + "4": "employeeType", X500ATTR_OID + "22": "teletexTerminalIdentifier", X500ATTR_OID + "23": "facsimileTelephoneNumber", X500ATTR_OID + "20": "telephoneNumber", X500ATTR_OID + "21": "telexNumber", X500ATTR_OID + "26": "registeredAddress", X500ATTR_OID + "27": "destinationIndicator", X500ATTR_OID + "24": "x121Address", X500ATTR_OID + "25": "internationaliSDNNumber", X500ATTR_OID + "28": "preferredDeliveryMethod", X500ATTR_OID + "29": "presentationAddress", EDUPERSON_OID + "3": "eduPersonOrgDN", NOREDUPERSON_OID + "3": "norEduPersonBirthDate", UMICH + "57": "labeledURI", UCL_DIR_PILOT + "1": "uid", SCHAC + "1": "schacMotherTongue", SCHAC + "2": "schacGender", SCHAC + "3": "schacDateOfBirth", SCHAC + "4": "schacPlaceOfBirth", SCHAC + "5": "schacCountryOfCitizenship", SCHAC + "6": "schacSn1", SCHAC + "7": "schacSn2", SCHAC + "8": "schacPersonalTitle", SCHAC + "9": "schacHomeOrganization", SCHAC + "10": "schacHomeOrganizationType", SCHAC + "11": "schacCountryOfResidence", SCHAC + "12": "schacUserPresenceID", SCHAC + "13": "schacPersonalPosition", SCHAC + "14": "schacPersonalUniqueCode", SCHAC + "15": "schacPersonalUniqueID", SCHAC + "17": "schacExpiryDate", SCHAC + "18": "schacUserPrivateAttribute", SCHAC + "19": "schacUserStatus", SCHAC + "20": "schacProjectMembership", SCHAC + "21": "schacProjectSpecificRole", }, "to": { "roleOccupant": X500ATTR_OID + "33", "gn": X500ATTR_OID + "42", "norEduPersonNIN": NOREDUPERSON_OID + "5", "title": X500ATTR_OID + "12", "facsimileTelephoneNumber": X500ATTR_OID + "23", "mail": UCL_DIR_PILOT + "3", "postOfficeBox": X500ATTR_OID + "18", "fax": X500ATTR_OID + "23", "telephoneNumber": X500ATTR_OID + "20", "norEduPersonBirthDate": NOREDUPERSON_OID + "3", "rfc822Mailbox": UCL_DIR_PILOT + "3", "dc": UCL_DIR_PILOT + "25", "countryName": X500ATTR_OID + "6", "emailAddress": PKCS_9 + "1", "employeeNumber": NETSCAPE_LDAP + "3", "organizationName": X500ATTR_OID + "10", "eduPersonAssurance": EDUPERSON_OID + "11", "norEduOrgAcronym": NOREDUPERSON_OID + "6", "registeredAddress": X500ATTR_OID + "26", "physicalDeliveryOfficeName": X500ATTR_OID + "19", "associatedDomain": UCL_DIR_PILOT + "37", "l": X500ATTR_OID + "7", "stateOrProvinceName": X500ATTR_OID + "8", "federationFeideSchemaVersion": NOREDUPERSON_OID + "9", "pkcs9email": PKCS_9 + "1", "givenName": X500ATTR_OID + "42", "givenname": X500ATTR_OID + "42", "x500UniqueIdentifier": X500ATTR_OID + "45", "eduPersonNickname": EDUPERSON_OID + "2", "houseIdentifier": X500ATTR_OID + "51", "street": X500ATTR_OID + "9", "supportedAlgorithms": X500ATTR_OID + "52", "preferredLanguage": NETSCAPE_LDAP + "39", "postalAddress": X500ATTR_OID + "16", "email": PKCS_9 + "1", "norEduOrgUnitUniqueIdentifier": NOREDUPERSON_OID + "8", "eduPersonPrimaryOrgUnitDN": EDUPERSON_OID + "8", "c": X500ATTR_OID + "6", "teletexTerminalIdentifier": X500ATTR_OID + "22", "o": X500ATTR_OID + "10", "cACertificate": X500ATTR_OID + "37", "telexNumber": X500ATTR_OID + "21", "ou": X500ATTR_OID + "11", "initials": X500ATTR_OID + "43", "eduPersonOrgUnitDN": EDUPERSON_OID + "4", "deltaRevocationList": X500ATTR_OID + "53", "norEduPersonLIN": NOREDUPERSON_OID + "4", "supportedApplicationContext": X500ATTR_OID + "30", "eduPersonEntitlement": EDUPERSON_OID + "7", "generationQualifier": X500ATTR_OID + "44", "eduPersonAffiliation": EDUPERSON_OID + "1", "edupersonaffiliation": EDUPERSON_OID + "1", "eduPersonPrincipalName": EDUPERSON_OID + "6", "edupersonprincipalname": EDUPERSON_OID + "6", "localityName": X500ATTR_OID + "7", "owner": X500ATTR_OID + "32", "norEduOrgUnitUniqueNumber": NOREDUPERSON_OID + "2", "searchGuide": X500ATTR_OID + "14", "certificateRevocationList": X500ATTR_OID + "39", "organizationalUnitName": X500ATTR_OID + "11", "userCertificate": X500ATTR_OID + "36", "preferredDeliveryMethod": X500ATTR_OID + "28", "internationaliSDNNumber": X500ATTR_OID + "25", "uniqueMember": X500ATTR_OID + "50", "departmentNumber": NETSCAPE_LDAP + "2", "enhancedSearchGuide": X500ATTR_OID + "47", "userPKCS12": NETSCAPE_LDAP + "216", "eduPersonTargetedID": EDUPERSON_OID + "10", "norEduOrgUniqueNumber": NOREDUPERSON_OID + "1", "x121Address": X500ATTR_OID + "24", "destinationIndicator": X500ATTR_OID + "27", "eduPersonPrimaryAffiliation": EDUPERSON_OID + "5", "surname": X500ATTR_OID + "4", "jpegPhoto": UCL_DIR_PILOT + "60", "eduPersonScopedAffiliation": EDUPERSON_OID + "9", "edupersonscopedaffiliation": EDUPERSON_OID + "9", "protocolInformation": X500ATTR_OID + "48", "knowledgeInformation": X500ATTR_OID + "2", "employeeType": NETSCAPE_LDAP + "4", "userSMIMECertificate": NETSCAPE_LDAP + "40", "member": X500ATTR_OID + "31", "streetAddress": X500ATTR_OID + "9", "dmdName": X500ATTR_OID + "54", "postalCode": X500ATTR_OID + "17", "pseudonym": X500ATTR_OID + "65", "dnQualifier": X500ATTR_OID + "46", "crossCertificatePair": X500ATTR_OID + "40", "eduPersonOrgDN": EDUPERSON_OID + "3", "authorityRevocationList": X500ATTR_OID + "38", "displayName": NETSCAPE_LDAP + "241", "businessCategory": X500ATTR_OID + "15", "serialNumber": X500ATTR_OID + "5", "norEduOrgUniqueIdentifier": NOREDUPERSON_OID + "7", "st": X500ATTR_OID + "8", "carLicense": NETSCAPE_LDAP + "1", "presentationAddress": X500ATTR_OID + "29", "sn": X500ATTR_OID + "4", "cn": X500ATTR_OID + "3", "domainComponent": UCL_DIR_PILOT + "25", "labeledURI": UMICH + "57", "uid": UCL_DIR_PILOT + "1", "schacMotherTongue": SCHAC + "1", "schacGender": SCHAC + "2", "schacDateOfBirth": SCHAC + "3", "schacPlaceOfBirth": SCHAC + "4", "schacCountryOfCitizenship": SCHAC + "5", "schacSn1": SCHAC + "6", "schacSn2": SCHAC + "7", "schacPersonalTitle": SCHAC + "8", "schacHomeOrganization": SCHAC + "9", "schacHomeOrganizationType": SCHAC + "10", "schacCountryOfResidence": SCHAC + "11", "schacUserPresenceID": SCHAC + "12", "schacPersonalPosition": SCHAC + "13", "schacPersonalUniqueCode": SCHAC + "14", "schacPersonalUniqueID": SCHAC + "15", "schacExpiryDate": SCHAC + "17", "schacUserPrivateAttribute": SCHAC + "18", "schacUserStatus": SCHAC + "19", "schacProjectMembership": SCHAC + "20", "schacProjectSpecificRole": SCHAC + "21", }, } djangosaml2-1.10.1/djangosaml2/tests/auth_response.py000066400000000000000000000117471476674771500226310ustar00rootroot00000000000000# Copyright (C) 2011-2012 Yaco Sistemas (http://www.yaco.es) # Copyright (C) 2010 Lorenzo Gil Sanchez # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import datetime def auth_response( session_id, uid, audience="http://sp.example.com/saml2/metadata/", acs_url="http://sp.example.com/saml2/acs/", metadata_url="http://sp.example.com/saml2/metadata/", attribute_statements=None, ): """Generates a fresh signed authentication response Params: session_id: The session ID to generate the reponse for. Login set an outstanding session ID, i.e. djangosaml2 waits for a response for that session. uid: Unique identifier for a User (will be present as an attribute in the answer). Ignored when attribute_statements is not ``None``. audience: SP entityid (used when PySAML validates the response audience). acs_url: URL where the response has been posted back. metadata_url: URL where the SP metadata can be queried. attribute_statements: An alternative XML AttributeStatement to use in lieu of the default (uid). The uid argument is ignored when attribute_statements is not ``None``. """ timestamp = datetime.datetime.now() - datetime.timedelta(seconds=10) tomorrow = datetime.datetime.now() + datetime.timedelta(days=1) yesterday = datetime.datetime.now() - datetime.timedelta(days=1) if attribute_statements is None: attribute_statements = ( "" '' '' "%(uid)s" "" "" "" ) % {"uid": uid} saml_response_tpl = ( "" '' '' "https://idp.example.com/simplesaml/saml2/idp/metadata.php" "" "" '' "" '' '' "https://idp.example.com/simplesaml/saml2/idp/metadata.php" "" "" '' "1f87035b4c1325b296a53d92097e6b3fa36d7e30ee82e3fcb0680d60243c1f03" "" '' '' "" "" '' "" "" "%(audience)s" "" "" "" '' "" "" "urn:oasis:names:tc:SAML:2.0:ac:classes:Password" "" "" "" "%(attribute_statements)s" "" "" ) return saml_response_tpl % { "session_id": session_id, "audience": audience, "acs_url": acs_url, "metadata_url": metadata_url, "attribute_statements": attribute_statements, "timestamp": timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"), "tomorrow": tomorrow.strftime("%Y-%m-%dT%H:%M:%SZ"), "yesterday": yesterday.strftime("%Y-%m-%dT%H:%M:%SZ"), } djangosaml2-1.10.1/djangosaml2/tests/conf.py000066400000000000000000000101211476674771500206600ustar00rootroot00000000000000# Copyright (C) 2011-2012 Yaco Sistemas (http://www.yaco.es) # Copyright (C) 2010 Lorenzo Gil Sanchez # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import os.path import saml2 def create_conf( sp_host="sp.example.com", idp_hosts=None, metadata_file="remote_metadata.xml", authn_requests_signed=None, sp_kwargs: dict = None, ): if idp_hosts is None: idp_hosts = ["idp.example.com"] try: from saml2.sigver import get_xmlsec_binary except ImportError: get_xmlsec_binary = None if get_xmlsec_binary: xmlsec_path = get_xmlsec_binary(["/opt/local/bin"]) else: xmlsec_path = "/usr/bin/xmlsec1" BASEDIR = os.path.dirname(os.path.abspath(__file__)) config = { "xmlsec_binary": xmlsec_path, "entityid": "http://%s/saml2/metadata/" % sp_host, "attribute_map_dir": os.path.join(BASEDIR, "attribute-maps"), "service": { "sp": { "name": "Test SP", "name_id_format": saml2.saml.NAMEID_FORMAT_PERSISTENT, "endpoints": { "assertion_consumer_service": [ ("http://%s/saml2/acs/" % sp_host, saml2.BINDING_HTTP_POST), ], "single_logout_service": [ ("http://%s/saml2/ls/" % sp_host, saml2.BINDING_HTTP_REDIRECT), ], }, "required_attributes": ["uid"], "optional_attributes": ["eduPersonAffiliation"], "idp": {}, # this is filled later "want_response_signed": False, }, }, "metadata": { "local": [os.path.join(BASEDIR, metadata_file)], }, "debug": 1, # certificates "key_file": os.path.join(BASEDIR, "mycert.key"), "cert_file": os.path.join(BASEDIR, "mycert.pem"), # These fields are only used when generating the metadata "contact_person": [ { "given_name": "Technical givenname", "sur_name": "Technical surname", "company": "Example Inc.", "email_address": "technical@sp.example.com", "contact_type": "technical", }, { "given_name": "Administrative givenname", "sur_name": "Administrative surname", "company": "Example Inc.", "email_address": "administrative@sp.example.ccom", "contact_type": "administrative", }, ], "organization": { "name": [("Ejemplo S.A.", "es"), ("Example Inc.", "en")], "display_name": [("Ejemplo", "es"), ("Example", "en")], "url": [("http://www.example.es", "es"), ("http://www.example.com", "en")], }, "valid_for": 24, } if sp_kwargs is not None: config["service"]["sp"].update(**sp_kwargs) if authn_requests_signed is not None: config["service"]["sp"]["authn_requests_signed"] = authn_requests_signed for idp in idp_hosts: entity_id = "https://%s/simplesaml/saml2/idp/metadata.php" % idp config["service"]["sp"]["idp"][entity_id] = { "single_sign_on_service": { saml2.BINDING_HTTP_REDIRECT: "https://%s/simplesaml/saml2/idp/SSOService.php" % idp, }, "single_logout_service": { saml2.BINDING_HTTP_REDIRECT: "https://%s/simplesaml/saml2/idp/SingleLogoutService.php" % idp, }, } return config djangosaml2-1.10.1/djangosaml2/tests/idpcert.csr000066400000000000000000000017551476674771500215410ustar00rootroot00000000000000-----BEGIN CERTIFICATE REQUEST----- MIICrTCCAZUCAQAwaDELMAkGA1UEBhMCRVMxEDAOBgNVBAgMB1NldmlsbGExGzAZ BgNVBAoMEllhY28gU2lzdGVtYXMgUy5MLjEQMA4GA1UEBwwHU2V2aWxsYTEYMBYG A1UEAwwPaWRwLmV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEAsUBPANpwZb1MUmRsEKMsb+v5eHNih8J/fU7g6iUtvBHacjuQeZEVye/K qxMOGC+wKW53xUDeITSs91w79eztm+QwdpZPzfjuKH4q5LNeMj6E8YwGw9vymF4b gsZfZ7iKY+RkqubH7bzYtPSeTtqDkNPlJy6qjpuFMaEkbjAaSAm0KW84/NpMnZn6 HRATWs0noqNDo7yHafTRvtCbJbFp6cCbkTd4h0WeolQBmRisg7pMAmC8uuA06CX2 hU8Ej5/unGw/hCMsF5ysPDYUzLwI18m+kZSgE+Yw2pkVdJcEtmJjw/HqbzJJ+rH4 E8TdWHK7mMi13EwbyEav5d1shN7erwIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEB AJIrkVa22Yoi5TBIq5grZhDkyCFkxLn8xIGbr3eS4VSq6osgqsALCuxGAioXGoR7 QkczLXz4rWVGCZBF8yGZ3/zujSW8ajqjLqnwgu4hK8TlgtBiIWG7kq+1/yTWD0zl kSts9WGKWKdSYHGAX8vTAFpYGVnw76H6Fd+cJwnuk0Zym6I9Vr4lTWtBQeVLrMxM 8AlBYMAgJS3JGgsqAhcxv4oNdMKec6nJmJPSggWUmdNQN8Cluq30kJj3GGtuRd0c Z0qgTvBQzlSty63nqS76EFNdQiaIKfwvracqoDFIFkqvZgznQih40jzqhRpQ6E/Y jwz0DPWrG/7E5c/ga9yUt3U= -----END CERTIFICATE REQUEST----- djangosaml2-1.10.1/djangosaml2/tests/idpcert.key000066400000000000000000000032171476674771500215350ustar00rootroot00000000000000-----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEAsUBPANpwZb1MUmRsEKMsb+v5eHNih8J/fU7g6iUtvBHacjuQ eZEVye/KqxMOGC+wKW53xUDeITSs91w79eztm+QwdpZPzfjuKH4q5LNeMj6E8YwG w9vymF4bgsZfZ7iKY+RkqubH7bzYtPSeTtqDkNPlJy6qjpuFMaEkbjAaSAm0KW84 /NpMnZn6HRATWs0noqNDo7yHafTRvtCbJbFp6cCbkTd4h0WeolQBmRisg7pMAmC8 uuA06CX2hU8Ej5/unGw/hCMsF5ysPDYUzLwI18m+kZSgE+Yw2pkVdJcEtmJjw/Hq bzJJ+rH4E8TdWHK7mMi13EwbyEav5d1shN7erwIDAQABAoIBAQCFkQk3gmOaNvhR Sf0o2Fz/Bdnai1BfLxB088CGkHeTNfzfgcUP5mV94yVcnqJLVXww7F5ylLwOV6xT RfylB+HRTDW81u3SL1f/yXs3FXbQ882oWzUp2A9KA/hFJoj0FtqqBYxaQEe9/UVr rr2we/cSZqpSSVca2VSYHm7eXX8gcnZa42jKrLLKFfybq4Pw/917tvKnWnES7MV1 2jt2+ZeS/Q5GqqJR7hhyVwim10ifO2Lh9c0TB9e/U7ddyTIt6VfKusW7YiOrkRwx ZM1NpZly1IAxK55ceV6w7IX9y1FM6A8ZvV7bqfVn13S5Zdyjr5XkiU10WXBsHodx ZndU2SFRAoGBANqvzFjEcWkaDIYnV/vSPmDMrgMA5FL1cUN3if5+LRatiXeQKbnv nVA5edGnB8LlkMDD4oDjCqx0xVbxaWqS4X8WEPa1/xss34y1MRg6yMZgu60mkzPM pj+i5mk1Kvsjm6uYz6XPtm7//GWl2y+zgAl8+bgzeUFD5HKV4B+8iZPnAoGBAM9+ nSZDTFrbA5z5vGLXJlOdAx38ffQTfC1isb+M5I8NhE4CctaYcXcgMbhl+avfEsK9 Wgpivmd7KTnhTujnf4WaIoe655TpnXJJ6dCdXDWktWZTErfqAXTTl945BLrQuGbe EIAN+1bYqGmk73U/Uw28c8hbTmY6GHTEyQSWMYX5AoGBAKxcfw0/17tlApYCEIC0 VuHosQZA/7S7KwhoAWWKgXMsV/rar2iTiUQf6PnrUly0n4CvY6j+Sf1fE+LQ56tO FVkbRUeOboE2vwOiFA3q1zA0MffpPYBIPohNlpk5hKTojduT16XyrvGR5ZcgQD+6 lKHl1NTwDRP5tObzZfDdovnlAoGBAIXpeRKQrF6WqqZMpsBDioC7/J8FrWQwjxvb bkvpajjIyHJwMh09FT2EkZIofhHmTf1QpyO8xpWSbvDj8EFv5mUbLN3cSklY3Dw+ Z6AzbqdQPaJkSthXNcloJcNNmTfYLKp29r8uRt+txEMqJ0DMNZXP4gmUo+xl4hK6 TeGf7SZBAoGAdTfeG5EmOoTVITw3imvoyETtbXO309YeUhk14p0OELK25w8Dnh70 OLTaY5o4zfUh0noKQSTM9sgxkBCFhwYPxw2CoIWikc1+izAiFesWpgVp8rkFPWuI B7+5OACkAYK2DGzdlwwVti3LkrvW8gOZ7CUS5W7XzhmpcH6/+mHaKEY= -----END RSA PRIVATE KEY----- djangosaml2-1.10.1/djangosaml2/tests/idpcert.pem000066400000000000000000000022641476674771500215270ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIDTDCCAjQCCQDs8RuyGDEFWTANBgkqhkiG9w0BAQUFADBoMQswCQYDVQQGEwJF UzEQMA4GA1UECAwHU2V2aWxsYTEbMBkGA1UECgwSWWFjbyBTaXN0ZW1hcyBTLkwu MRAwDgYDVQQHDAdTZXZpbGxhMRgwFgYDVQQDDA9pZHAuZXhhbXBsZS5jb20wHhcN MTAwODI4MTAyNjQ1WhcNMTEwODI4MTAyNjQ1WjBoMQswCQYDVQQGEwJFUzEQMA4G A1UECAwHU2V2aWxsYTEbMBkGA1UECgwSWWFjbyBTaXN0ZW1hcyBTLkwuMRAwDgYD VQQHDAdTZXZpbGxhMRgwFgYDVQQDDA9pZHAuZXhhbXBsZS5jb20wggEiMA0GCSqG SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCxQE8A2nBlvUxSZGwQoyxv6/l4c2KHwn99 TuDqJS28EdpyO5B5kRXJ78qrEw4YL7ApbnfFQN4hNKz3XDv17O2b5DB2lk/N+O4o firks14yPoTxjAbD2/KYXhuCxl9nuIpj5GSq5sftvNi09J5O2oOQ0+UnLqqOm4Ux oSRuMBpICbQpbzj82kydmfodEBNazSeio0OjvIdp9NG+0JslsWnpwJuRN3iHRZ6i VAGZGKyDukwCYLy64DToJfaFTwSPn+6cbD+EIywXnKw8NhTMvAjXyb6RlKAT5jDa mRV0lwS2YmPD8epvMkn6sfgTxN1YcruYyLXcTBvIRq/l3WyE3t6vAgMBAAEwDQYJ KoZIhvcNAQEFBQADggEBAHLT+SirLvjzGb1kPJZq5hDhYAMIrUFSgU/ghNRd3tDw ryOHh9nHgjDq4siy9cRL19LRgly1wspErUTmL/cD6A6L7t6CFUXgXEzshJ+RsZz7 Nbg+61pfK+4+OyO2I3pzGXAHsqLuUpUQFpwHBLu9YiHzY+uiKLgODZl5B3A8nqLN 2NJ9uH9+YWgquxB6KQLW8cx9kC3AWAsEWihYFb22Uc6I8qFngmDldeHPgVFbt6nV 74F28qlbWr69NvGMGHZdfL2Ts+KC/yer88+tNrUrJ1tV1jmaMglfWoVTIVMI0Agl /jPhsxhx0+HHOuIfcD6b334mi/UZz91y/d7poiiiMtY= -----END CERTIFICATE----- djangosaml2-1.10.1/djangosaml2/tests/mycert.csr000066400000000000000000000017451476674771500214110ustar00rootroot00000000000000-----BEGIN CERTIFICATE REQUEST----- MIICpjCCAY4CAQAwYTELMAkGA1UEBhMCRVMxEDAOBgNVBAgTB1NldmlsbGExGzAZ BgNVBAoTEllhY28gU2lzdGVtYXMgUy5MLjEQMA4GA1UEBxMHU2V2aWxsYTERMA8G A1UEAxMIdGljb3RpY28wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDu sw4w5ohn9hgPmFhLoemOmi9y7iTyBohj6ib3MLEtXkXwEsR+TGg+T0gDdxFA1HF/ sBcIXEQ4fecrLnoAiLWBTtfp8JPfQkFPw1CVh2A5UwuVH62PLVthXTu0Nr1TyDON PIpAeBXAfTjfr6uZI+dpwaPd8zB/JJMyG2asmZrHRshrwQH6BjXvsMG29/x0hkhc uUYxAWWhl5Sym8c6uA2gQD3FTgT0BqcadX0d5XfvO/eYsNQ5AvHi2T2wxCbKUKmK PtZmZw5XTsPIn9wSae8dJqUFNzIiCRzCWGaO1KB8LLqjnO5bFh1P2JrzRJltbOfw 3oHSr6erbf45570fSW9zAgMBAAGgADANBgkqhkiG9w0BAQUFAAOCAQEAoK9viSij kqOwofEUvoJTMdcONm/Ext1yHIilsC3h5ZU451u0kurg4uuwpAOoZDOXtmZHfGOE /WQ0Juojpwco/SF1I+QGr7coq26xNQldsHlKBuO/wIrgVdtVfjOc+TxS/szMTZv5 whZoZe6HEdxFBvVVedtOMiXONZzzzK3cycSgaQz7BglYgfbFuwB3hdV1Y+iMfQfq PoIxWrjeDJa1LgFBDkklpgWYLFiaMVvhhdZVFXs/E66OdFwZg+OEplb97bLVba8T BxDqu6yhNlDlnhaLBd5lIfDCqy5OXa65MqS3vIR9k5CCAwwtaPPxQ2ChUXYhwj0n aW9RyTsESl7Z+Q== -----END CERTIFICATE REQUEST----- djangosaml2-1.10.1/djangosaml2/tests/mycert.key000066400000000000000000000032171476674771500214060ustar00rootroot00000000000000-----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEA7rMOMOaIZ/YYD5hYS6Hpjpovcu4k8gaIY+om9zCxLV5F8BLE fkxoPk9IA3cRQNRxf7AXCFxEOH3nKy56AIi1gU7X6fCT30JBT8NQlYdgOVMLlR+t jy1bYV07tDa9U8gzjTyKQHgVwH0436+rmSPnacGj3fMwfySTMhtmrJmax0bIa8EB +gY177DBtvf8dIZIXLlGMQFloZeUspvHOrgNoEA9xU4E9AanGnV9HeV37zv3mLDU OQLx4tk9sMQmylCpij7WZmcOV07DyJ/cEmnvHSalBTcyIgkcwlhmjtSgfCy6o5zu WxYdT9ia80SZbWzn8N6B0q+nq23+Oee9H0lvcwIDAQABAoIBAEXolw1nVyfrgVx/ 58wu3XJwYdktOhDQLP3mRAc9cYayB5WqSXYb9qPZIGQzaRAtqBgXgIdoTmqlJSEW eZDSeSYn60COvyAyDWLI9z7z6RCg69F+95vpUswPPD8pkQWKqt6AjpUXFnfLtO5+ SqmNRGdK2S1V3iw+kAWq1MVUL2qRFaC6izY3eln/C27mqVDLwb/SWGfl90S+/cNB 4AyI5LaTT6fwTxjkxlJFIWF9qWjzXM2skRzb/V2QzCF+RiS27maNDnVJMGHWacmo 2idxOyU+Q0VXmY0ycKkIOrSqoq+F168VxfZNMqUN9fyLu+AjEJmIZWC3jRX9P2NX ILQjMkECgYEA/sXSyjj05+oFCr/XSvBDy1/JnB2tOTTk/lFXe2IQpJpEb0RqNmYf vk/5OteMsDcxf13c4Gk7xJXkkgZutXa66PSL4h8DzBAwvl9R0YuWHhDyj4oeYCXl VsmIjtaUoPS297jckpQMXsuT7YSZpuycJmUGJ0WVq4wuuATIPIxIA9ECgYEA79lp N46rWGfel2+ruHxt/OZ5fbYzj6VnuwcwrDe2CcYmZ07SRH7AlnwRdPLyY7TGt+p2 7RjhJ8jpjReJK5yNrnq2D82KgJOwysCGCNDHWf2llEW8Pv66MhDqCn86RYyFbiGn 4jEb7IcWdmUfMPpOu3TWhxVl/AxUhA8asz1oJAMCgYEAtdoIgrWzAhLFdI3Io8Hp 8jG2G4wHSC0cQvdWpUgzLvq6XF2OHrQ4dkRpVnni/yj2WL5r2Xbj5YdEdoLG5RoR ghSEAGw47qCj2k75fMPQ7DcWnCRvWBvUnmUN5z79KgJi02GNd8bbKZLQTRp3/nEn aDR19vQxSBiwhENNlgJfqPECgYAMdTJt3E8yDFMXcolsz6m21RHCYdBTybeVk04H 4+zknRIpk4KAZEUEi/UsKeJFI4Ke0uLSddRcCKd42JwbU8pYIa+LKpXjD8jC/zT3 CEESf4Y2KVkZvIlXSGGfofQY4K+dhMn/iaV1p56XD7GLDbVBL1RlN8tQSCOrqE0u uiXKmQKBgQCrBHEns8QMjWsMRfh+5ebSQNRY1XVtIWV10MMsA3zZqRLUwC0XcU2Z FEF0FvXgWGC/MQOxXA5ACnToEC7PTfg5WPHxvf0dFi8sGr6nE6yWIzvNJKdp4rz3 q+99h7P2j25C2CmXJ2uS4zhXzaNbZ+UpyDDIzi7Ndw40A3wuh9U7Qw== -----END RSA PRIVATE KEY----- djangosaml2-1.10.1/djangosaml2/tests/mycert.pem000066400000000000000000000022401476674771500213720ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIDPjCCAiYCCQCkHjPQlll+mzANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQGEwJF UzEQMA4GA1UECBMHU2V2aWxsYTEbMBkGA1UEChMSWWFjbyBTaXN0ZW1hcyBTLkwu MRAwDgYDVQQHEwdTZXZpbGxhMREwDwYDVQQDEwh0aWNvdGljbzAeFw0wOTEyMDQx OTQzNTJaFw0xMDEyMDQxOTQzNTJaMGExCzAJBgNVBAYTAkVTMRAwDgYDVQQIEwdT ZXZpbGxhMRswGQYDVQQKExJZYWNvIFNpc3RlbWFzIFMuTC4xEDAOBgNVBAcTB1Nl dmlsbGExETAPBgNVBAMTCHRpY290aWNvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A MIIBCgKCAQEA7rMOMOaIZ/YYD5hYS6Hpjpovcu4k8gaIY+om9zCxLV5F8BLEfkxo Pk9IA3cRQNRxf7AXCFxEOH3nKy56AIi1gU7X6fCT30JBT8NQlYdgOVMLlR+tjy1b YV07tDa9U8gzjTyKQHgVwH0436+rmSPnacGj3fMwfySTMhtmrJmax0bIa8EB+gY1 77DBtvf8dIZIXLlGMQFloZeUspvHOrgNoEA9xU4E9AanGnV9HeV37zv3mLDUOQLx 4tk9sMQmylCpij7WZmcOV07DyJ/cEmnvHSalBTcyIgkcwlhmjtSgfCy6o5zuWxYd T9ia80SZbWzn8N6B0q+nq23+Oee9H0lvcwIDAQABMA0GCSqGSIb3DQEBBQUAA4IB AQCQBhKOqucJZAqGHx4ybDXNzpPethszonLNVg5deISSpWagy55KlGCi5laio/xq hHRx18eTzeCeLHQYvTQxw0IjZOezJ1X30DD9lEqPr6C+IrmZc6bn/pF76xsvdaRS gduNQPT1B25SV2HrEmbf8wafSlRARmBsyUHh860TqX7yFVjhYIAUF/El9rLca51j ljCIqqvT+klPdjQoZwODWPFHgute2oNRmoIcMjSnoy1+mxOC2Q/j7kcD8/etulg2 XDxB3zD81gfdtT8VBFP+G4UrBa+5zFk6fT6U8a7ZqVsyH+rCXAdCyVlEC4Y5fZri ID4zT0FcZASGuthM56rRJJSx -----END CERTIFICATE----- djangosaml2-1.10.1/djangosaml2/tests/remote_metadata.xml000066400000000000000000000327241476674771500232530ustar00rootroot00000000000000 MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo urn:oasis:names:tc:SAML:2.0:nameid-format:transient Lorenzo's test IdP idp.example.com IdP http://idp.example.com/ Administrator lorenzo.gil.sanchez@gmail.com MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo urn:oasis:names:tc:SAML:2.0:nameid-format:transient Lorenzo's test IdP idp1.example.com IdP http://idp1.example.com/ Administrator lorenzo.gil.sanchez@gmail.com MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo urn:oasis:names:tc:SAML:2.0:nameid-format:transient Lorenzo's test IdP idp2.example.com IdP http://idp2.example.com/ Administrator lorenzo.gil.sanchez@gmail.com MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo urn:oasis:names:tc:SAML:2.0:nameid-format:transient Lorenzo's test IdP idp3.example.com IdP http://idp3.example.com/ Administrator lorenzo.gil.sanchez@gmail.com djangosaml2-1.10.1/djangosaml2/tests/remote_metadata_no_idp.xml000066400000000000000000000002451476674771500245740ustar00rootroot00000000000000 djangosaml2-1.10.1/djangosaml2/tests/remote_metadata_one_idp.xml000066400000000000000000000067551476674771500247550ustar00rootroot00000000000000 MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo urn:oasis:names:tc:SAML:2.0:nameid-format:transient Lorenzo's test IdP idp.example.com IdP http://idp.example.com/ Administrator lorenzo.gil.sanchez@gmail.com djangosaml2-1.10.1/djangosaml2/tests/remote_metadata_post_binding.xml000066400000000000000000000067451476674771500260160ustar00rootroot00000000000000 MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo urn:oasis:names:tc:SAML:2.0:nameid-format:transient Lorenzo's test IdP idp.example.com IdP http://idp.example.com/ Administrator lorenzo.gil.sanchez@gmail.com djangosaml2-1.10.1/djangosaml2/tests/remote_metadata_three_idps.xml000066400000000000000000000242141476674771500254540ustar00rootroot00000000000000 MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo urn:oasis:names:tc:SAML:2.0:nameid-format:transient Lorenzo's test IdP idp1.example.com IdP http://idp1.example.com/ Administrator lorenzo.gil.sanchez@gmail.com MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo urn:oasis:names:tc:SAML:2.0:nameid-format:transient Lorenzo's test IdP idp2.example.com IdP http://idp2.example.com/ Administrator lorenzo.gil.sanchez@gmail.com MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo urn:oasis:names:tc:SAML:2.0:nameid-format:transient Lorenzo's test IdP idp3.example.com IdP http://idp3.example.com/ Administrator lorenzo.gil.sanchez@gmail.com djangosaml2-1.10.1/djangosaml2/tests/sp_metadata.xml000066400000000000000000000046421476674771500224000ustar00rootroot00000000000000 MIIDFjCCAf4CCQCzHO9MprkomDANBgkqhkiG9w0BAQUFADBNMQswCQYDVQQGEwJl czEQMA4GA1UECAwHU2V2aWxsYTENMAsGA1UECgwEWWFjbzEQMA4GA1UEBwwHU2V2 aWxsYTELMAkGA1UEAwwCU1AwHhcNMTIwMzE1MjA1MjA1WhcNMTMwMzE1MjA1MjA1 WjBNMQswCQYDVQQGEwJlczEQMA4GA1UECAwHU2V2aWxsYTENMAsGA1UECgwEWWFj bzEQMA4GA1UEBwwHU2V2aWxsYTELMAkGA1UEAwwCU1AwggEiMA0GCSqGSIb3DQEB AQUAA4IBDwAwggEKAoIBAQCeOJEVPpGZMDm3nZsJkl/jH8lEmhA4OWgILP3XzdYL rc/fuKR66XYapGied+Pe+PldqyfLpkojUuRDwAXHTprr1HKlUkKvt4Lk0mqH9Z3/ mZgj1NkKQBkGRLU0miFK93+m1B/Zlg4K1ycRV7111l5NvT9EVDAnyRU0RVjTrifp duy85vz9BnRusaR1YKc3NfwC1BiRUAAqhbuSYa0ALwVVri7mNob+/lYmbqrWScpA QFHy4VjSricjR8WvFjC3eBJbV7LIzdtd19cD+yDX2cDgXXR+QFxLUhHEhVrF+wvT QGcaZPYFiujcY/3FveRoRwdp6e03sUH/eqJksgR5ylJfAgMBAAEwDQYJKoZIhvcN AQEFBQADggEBAAu0rKFYHr6pi3yqIIc8EE6NnngqyZEnnDRPWuUK3WKcDI5rOmy9 8pPE+6sj2NBJyyPu/bsiaCOZBOPywh/AZymO6q3iJRB3pmllH7zYp0LW10HI3NRw 0T5BJ1ecudM5oitzE7EeMAT6+PogB9i+wxbf/p2YUYsbyiD/JPcrbt5h22sLGxTZ ovbOVacQF9es/YenvgmFGY42yea6fO33jZyBTiY69Tmjt+sv1nQJTUIyGeb1bVvF JMCJ3g73lNb3DTDS0UO+zLTlTHb1M/uJJnGY/CCb4kmoPRpxgMbybOh2TVfx9RHm 45W7GtDf4fZ+LqdZC0JVAZQ7a28L5df0TwQ= http://sp.example.com/ Lorenzo's test SP sp.example.com Administrator lorenzo.gil.sanchez@gmail.com djangosaml2-1.10.1/djangosaml2/tests/spcert.csr000066400000000000000000000017101476674771500213760ustar00rootroot00000000000000-----BEGIN CERTIFICATE REQUEST----- MIICkjCCAXoCAQAwTTELMAkGA1UEBhMCZXMxEDAOBgNVBAgMB1NldmlsbGExDTAL BgNVBAoMBFlhY28xEDAOBgNVBAcMB1NldmlsbGExCzAJBgNVBAMMAlNQMIIBIjAN BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnjiRFT6RmTA5t52bCZJf4x/JRJoQ ODloCCz9183WC63P37ikeul2GqRonnfj3vj5Xasny6ZKI1LkQ8AFx06a69RypVJC r7eC5NJqh/Wd/5mYI9TZCkAZBkS1NJohSvd/ptQf2ZYOCtcnEVe9ddZeTb0/RFQw J8kVNEVY064n6XbsvOb8/QZ0brGkdWCnNzX8AtQYkVAAKoW7kmGtAC8FVa4u5jaG /v5WJm6q1knKQEBR8uFY0q4nI0fFrxYwt3gSW1eyyM3bXdfXA/sg19nA4F10fkBc S1IRxIVaxfsL00BnGmT2BYro3GP9xb3kaEcHaentN7FB/3qiZLIEecpSXwIDAQAB oAAwDQYJKoZIhvcNAQEFBQADggEBAIKJqu8OspbEUBizU9XJBUsdIgFaSurC2QxX Z/E1bVsg5NLlWYk3Hq8Vec6jCRluasOtqyTqCt9KH+RP+6Q4PXKf0OM5AQ/wLS4R 4tj2wISEUeuIawwZ64hu8ICEHEoQrRpFos0MWGNXFG5uCxApy7wtpoZsaG8/Lrlw 5+NVqR3PfC64e4LMVWO60g4OqLzda/XwIrkQszPL5q8zvlTc8ra4d0XEklrmgj2f I0U0CaImxEVjBXphRUK/RKOFo97mrzw/I9J2oNgJXr0M079m4tnC7kMZgBao39l5 buzpclosi0oznSJWwyV9i/KiIBDqr9iF8l9qCyyVv/CwHGVQW3k= -----END CERTIFICATE REQUEST----- djangosaml2-1.10.1/djangosaml2/tests/spcert.key000066400000000000000000000032131476674771500213770ustar00rootroot00000000000000-----BEGIN RSA PRIVATE KEY----- MIIEogIBAAKCAQEAnjiRFT6RmTA5t52bCZJf4x/JRJoQODloCCz9183WC63P37ik eul2GqRonnfj3vj5Xasny6ZKI1LkQ8AFx06a69RypVJCr7eC5NJqh/Wd/5mYI9TZ CkAZBkS1NJohSvd/ptQf2ZYOCtcnEVe9ddZeTb0/RFQwJ8kVNEVY064n6XbsvOb8 /QZ0brGkdWCnNzX8AtQYkVAAKoW7kmGtAC8FVa4u5jaG/v5WJm6q1knKQEBR8uFY 0q4nI0fFrxYwt3gSW1eyyM3bXdfXA/sg19nA4F10fkBcS1IRxIVaxfsL00BnGmT2 BYro3GP9xb3kaEcHaentN7FB/3qiZLIEecpSXwIDAQABAoIBAFtwT5Cah2SjtUeD gx0mBdpp/VRzQRptOs02y0ETyTcYrUEbIZuTHtlI2Nl0ajHra5oRlz8fjEsb1aW9 7NkBeZD/R355quaIRNJfNIf8j+Iu7vkOQpyk7JFt1ddfmAwOOyy7/Ogvy0/CheaE 8Y6PZBLDYzPm/6mOkX2S8kHrrU9DrdOWNzcJNhOV1UbPpo/e4S2rHHQzx71GU/50 HKdKVv5WX+A9vqIzugvXlN0BpGtW4vAOwnXLg5rTg2RLAeCNdBsKUbFPqVaA6xSC +bgCpR+UC2MWmDBGlIMMTr4Yytuv0n+EkF590N/VqlF5coWTbBjAMogs1t8WxEuQ d3Caj5ECgYEAz4qum5tkPMZEvLIXABWb26ZBhTSqsyWt4uLuEJb66Q0PRE7yYtxh 8XL0b2WFfia5dQJGFBTeQATa5VFejMXbxtKWjbdhX8d5mefWZ6xMsgxssx4kBwmp fsvd1wdFRZEJZE6VLnm/WGRd1SETYFUuzpyLwLypnqkjTdw8Gl9htqcCgYEAwyna Kr7+b/8F9y0Ka09WdgJIagqi4aoFX63ZV1LfIiqPiAFu/N9BGMr2BRfy+PoKfMdk R6oVIiFKVE5KWgy++K9TXZ3zBkhRwDFKzyTbaIF8P9mxbEBxhB/G8GF/sEnVo2/R +F3TUIRO8/ZIk7HU9uubIcdIgSuuJ5pthsO5NYkCgYBfOnwJzFA/Dp6FkpW5JTEh pPSVYWgd0WErJQMlO5Gfk614o1zWfda3Cg8cehG5o50fEk8DcdvUtiWWaTKgFz1T ylboacdVQlsKgnU/lrCOVeMegOr5C7bpBjQhMSXY2Mbdbq1G6PgiX9MqMwYIAq36 gZwicK7HrUYUuMQfObrFKwKBgFBZnM7Yj5zAnE4lpxKDOY+gZPvzoRfTjh7UTpUb M263oxxVqsJFkGGKvjtentRO7Z5t4SV4KvdASX/oM8hbUwzD8kiqzPGbOL0uDiS2 gfbGyMbo85kj9xh0lM1G9vE3lNOTKBlfV67gqjja/wp/vrRiUB5aE8nKmAsKE2nW jxwxAoGAXBVVaHMA5vVmCZLwuKQxfFfCfd8K7GDeEwXVh0dAv1aglfHk4wSKiKJB K2OEyYusUQX2hFKzAPWY3nReH+nrgXOBzFXlk6S8pJlXLiDFaDac/DCi0u2dQa4w vYKXdYMo+cuVYtf1zuUlzlkWprL6Tk04T3AFbcf4nX8dvtf6Iwc= -----END RSA PRIVATE KEY----- djangosaml2-1.10.1/djangosaml2/tests/spcert.pem000066400000000000000000000021531476674771500213720ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIDFjCCAf4CCQCzHO9MprkomDANBgkqhkiG9w0BAQUFADBNMQswCQYDVQQGEwJl czEQMA4GA1UECAwHU2V2aWxsYTENMAsGA1UECgwEWWFjbzEQMA4GA1UEBwwHU2V2 aWxsYTELMAkGA1UEAwwCU1AwHhcNMTIwMzE1MjA1MjA1WhcNMTMwMzE1MjA1MjA1 WjBNMQswCQYDVQQGEwJlczEQMA4GA1UECAwHU2V2aWxsYTENMAsGA1UECgwEWWFj bzEQMA4GA1UEBwwHU2V2aWxsYTELMAkGA1UEAwwCU1AwggEiMA0GCSqGSIb3DQEB AQUAA4IBDwAwggEKAoIBAQCeOJEVPpGZMDm3nZsJkl/jH8lEmhA4OWgILP3XzdYL rc/fuKR66XYapGied+Pe+PldqyfLpkojUuRDwAXHTprr1HKlUkKvt4Lk0mqH9Z3/ mZgj1NkKQBkGRLU0miFK93+m1B/Zlg4K1ycRV7111l5NvT9EVDAnyRU0RVjTrifp duy85vz9BnRusaR1YKc3NfwC1BiRUAAqhbuSYa0ALwVVri7mNob+/lYmbqrWScpA QFHy4VjSricjR8WvFjC3eBJbV7LIzdtd19cD+yDX2cDgXXR+QFxLUhHEhVrF+wvT QGcaZPYFiujcY/3FveRoRwdp6e03sUH/eqJksgR5ylJfAgMBAAEwDQYJKoZIhvcN AQEFBQADggEBAAu0rKFYHr6pi3yqIIc8EE6NnngqyZEnnDRPWuUK3WKcDI5rOmy9 8pPE+6sj2NBJyyPu/bsiaCOZBOPywh/AZymO6q3iJRB3pmllH7zYp0LW10HI3NRw 0T5BJ1ecudM5oitzE7EeMAT6+PogB9i+wxbf/p2YUYsbyiD/JPcrbt5h22sLGxTZ ovbOVacQF9es/YenvgmFGY42yea6fO33jZyBTiY69Tmjt+sv1nQJTUIyGeb1bVvF JMCJ3g73lNb3DTDS0UO+zLTlTHb1M/uJJnGY/CCb4kmoPRpxgMbybOh2TVfx9RHm 45W7GtDf4fZ+LqdZC0JVAZQ7a28L5df0TwQ= -----END CERTIFICATE----- djangosaml2-1.10.1/djangosaml2/tests/utils.py000066400000000000000000000006411476674771500211010ustar00rootroot00000000000000from html.parser import HTMLParser class SAMLPostFormParser(HTMLParser): """ Parses the SAML Post binding form page for the SAMLRequest value. """ saml_request_value = None def handle_starttag(self, tag, attrs): attrs_dict = dict(attrs) if tag != "input" or attrs_dict.get("name") != "SAMLRequest": return self.saml_request_value = attrs_dict.get("value") djangosaml2-1.10.1/djangosaml2/urls.py000066400000000000000000000022461476674771500175670ustar00rootroot00000000000000# Copyright (C) 2010-2012 Yaco Sistemas (http://www.yaco.es) # Copyright (C) 2009 Lorenzo Gil Sanchez # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from django.urls import path from . import views urlpatterns = [ path("login/", views.LoginView.as_view(), name="saml2_login"), path("acs/", views.AssertionConsumerServiceView.as_view(), name="saml2_acs"), path("logout/", views.LogoutInitView.as_view(), name="saml2_logout"), path("ls/", views.LogoutView.as_view(), name="saml2_ls"), path("ls/post/", views.LogoutView.as_view(), name="saml2_ls_post"), path("metadata/", views.MetadataView.as_view(), name="saml2_metadata"), ] djangosaml2-1.10.1/djangosaml2/utils.py000066400000000000000000000217621476674771500177460ustar00rootroot00000000000000# Copyright (C) 2012 Yaco Sistemas (http://www.yaco.es) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 import logging import re import urllib import zlib from functools import lru_cache, wraps from typing import Optional from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import resolve_url from django.urls import NoReverseMatch from django.utils.http import url_has_allowed_host_and_scheme from django.utils.module_loading import import_string from saml2.config import SPConfig from saml2.mdstore import MetaDataMDX from saml2.s_utils import UnknownSystemEntity logger = logging.getLogger(__name__) def get_custom_setting(name: str, default=None): return getattr(settings, name, default) def available_idps(config: SPConfig, langpref=None, idp_to_check: str = None) -> dict: if langpref is None: langpref = "en" idps = set() for metadata in config.metadata.metadata.values(): # initiate a fetch to the selected idp when using MDQ, otherwise the MetaDataMDX is an empty database if isinstance(metadata, MetaDataMDX) and idp_to_check: m = metadata[idp_to_check] # noqa: F841 result = metadata.any("idpsso_descriptor", "single_sign_on_service") if result: idps.update(result.keys()) return {idp: config.metadata.name(idp, langpref) for idp in idps} def get_idp_sso_supported_bindings( idp_entity_id: Optional[str] = None, config: Optional[SPConfig] = None ) -> list: """Returns the list of bindings supported by an IDP This is not clear in the pysaml2 code, so wrapping it in a util""" if config is None: # avoid circular import from .conf import get_config config = get_config() # load metadata store from config meta = getattr(config, "metadata", {}) # if idp is None, assume only one exists so just use that if idp_entity_id is None: try: idp_entity_id = list(available_idps(config).keys())[0] except IndexError: raise ImproperlyConfigured("No IdP configured!") try: return list( meta.service( idp_entity_id, "idpsso_descriptor", "single_sign_on_service" ).keys() ) except UnknownSystemEntity: raise UnknownSystemEntity except Exception as e: logger.exception(f"get_idp_sso_supported_bindings failed with: {e}") def get_location(http_info): """Extract the redirect URL from a pysaml2 http_info object""" try: headers = dict(http_info["headers"]) return headers["Location"] except KeyError: return http_info["url"] def get_fallback_login_redirect_url(): login_redirect_url = get_custom_setting("LOGIN_REDIRECT_URL", "/") return resolve_url( get_custom_setting("ACS_DEFAULT_REDIRECT_URL", login_redirect_url) ) def validate_referral_url(request, url): # Ensure the url is even a valid URL; sometimes the given url is a # RelayState containing PySAML data. # Some technically-valid urls will be fail this check, so the # SAML_STRICT_URL_VALIDATION setting can be used to turn off this check. # This should only happen if there is no slash, host and/or protocol in the # given URL. A better fix would be to add those to the RelayState. saml_strict_url_validation = getattr(settings, "SAML_STRICT_URL_VALIDATION", True) try: if saml_strict_url_validation: # This will also resolve Django URL pattern names url = resolve_url(url) except NoReverseMatch: logger.debug( "Could not validate given referral url is a valid URL", exc_info=True ) return None # Ensure the user-originating redirection url is safe. # By setting SAML_ALLOWED_HOSTS in settings.py the user may provide a list of "allowed" # hostnames for post-login redirects, much like one would specify ALLOWED_HOSTS . # If this setting is absent, the default is to use the hostname that was used for the current # request. saml_allowed_hosts = set( getattr(settings, "SAML_ALLOWED_HOSTS", [request.get_host()]) ) if not url_has_allowed_host_and_scheme(url=url, allowed_hosts=saml_allowed_hosts): logger.debug("Referral URL not in SAML_ALLOWED_HOSTS or of the origin host.") return None return url def saml2_from_httpredirect_request(url): urlquery = urllib.parse.urlparse(url).query b64_inflated_saml2req = urllib.parse.parse_qs(urlquery)["SAMLRequest"][0] inflated_saml2req = base64.b64decode(b64_inflated_saml2req) deflated_saml2req = zlib.decompress(inflated_saml2req, -15) return deflated_saml2req def get_session_id_from_saml2(saml2_xml): saml2_xml = saml2_xml.decode() if isinstance(saml2_xml, bytes) else saml2_xml return re.findall(r'ID="([a-z0-9\-]*)"', saml2_xml, re.I)[0] def get_subject_id_from_saml2(saml2_xml): saml2_xml = saml2_xml if isinstance(saml2_xml, str) else saml2_xml.decode() re.findall('">([a-z0-9]+)', saml2_xml)[0] def add_param_in_url(url: str, param_key: str, param_value: str): params = list(url.split("?")) params.append(f"{param_key}={param_value}") new_url = params[0] + "?" + "".join(params[1:]) return new_url def add_idp_hinting(request, http_response) -> bool: idphin_param = getattr(settings, "SAML2_IDPHINT_PARAM", "idphint") urllib.parse.urlencode(request.GET) if idphin_param not in request.GET.keys(): return False idphint = request.GET[idphin_param] # validation : TODO -> improve! if idphint[0:4] != "http": logger.warning( f'Idp hinting: "{idphint}" doesn\'t contain a valid value.' "idphint paramenter ignored." ) return False if http_response.status_code in (302, 303): # redirect binding # urlp = urllib.parse.urlparse(http_response.url) new_url = add_param_in_url(http_response.url, idphin_param, idphint) return HttpResponseRedirect(new_url) elif http_response.status_code == 200: # post binding res = re.search( r'action="(?P[a-z0-9\:\/\_\-\.]*)"', http_response.content.decode(), re.I, ) if not res: return False orig_url = res.groupdict()["url"] # new_url = add_param_in_url(orig_url, idphin_param, idphint) content = http_response.content.decode().replace(orig_url, new_url).encode() return HttpResponse(content) else: logger.warning( f"Idp hinting: cannot detect request type [{http_response.status_code}]" ) return False @lru_cache def get_csp_handler(): """Returns a view decorator for CSP.""" def empty_view_decorator(view): return view csp_handler_string = get_custom_setting("SAML_CSP_HANDLER", None) if csp_handler_string is None: # No CSP handler configured, attempt to use django-csp return _django_csp_update_decorator() or empty_view_decorator if csp_handler_string.strip() != "": # Non empty string is configured, attempt to import it csp_handler = import_string(csp_handler_string) def custom_csp_updater(f): @wraps(f) def wrapper(*args, **kwargs): return csp_handler(f(*args, **kwargs)) return wrapper return custom_csp_updater # Fall back to empty decorator when csp_handler_string is empty return empty_view_decorator def _django_csp_update_decorator(): """Returns a view CSP decorator if django-csp is available, otherwise None.""" try: from csp.decorators import csp_update except ModuleNotFoundError: # If csp is not installed, do not update fields as Content-Security-Policy # is not used logger.warning( "django-csp could not be found, not updating Content-Security-Policy. Please " "make sure CSP is configured. This can be done by your reverse proxy, " "django-csp or a custom CSP handler via SAML_CSP_HANDLER. See " "https://djangosaml2.readthedocs.io/contents/security.html#content-security-policy" " for more information. " "This warning can be disabled by setting `SAML_CSP_HANDLER=''` in your settings." ) return else: # autosubmit of forms uses nonce per default # form-action https: to send data to IdPs return csp_update(FORM_ACTION=["https:"]) djangosaml2-1.10.1/djangosaml2/views.py000066400000000000000000001040551476674771500177400ustar00rootroot00000000000000# Copyright (C) 2010-2013 Yaco Sistemas (http://www.yaco.es) # Copyright (C) 2009 Lorenzo Gil Sanchez # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import base64 import logging from functools import wraps from typing import Optional from urllib.parse import quote from django.conf import settings from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.http import ( HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, HttpResponseServerError, ) from django.shortcuts import render, resolve_url from django.template import TemplateDoesNotExist from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.html import escape from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt from django.views.generic import View from django.contrib import auth from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.sites.shortcuts import get_current_site import saml2 from saml2.client_base import LogoutError from saml2.config import SPConfig from saml2.ident import code, decode from saml2.mdstore import SourceNotFound from saml2.metadata import entity_descriptor from saml2.response import ( RequestVersionTooLow, SignatureError, StatusAuthnFailed, StatusError, StatusNoAuthnContext, StatusRequestDenied, UnsolicitedResponse, ) from saml2.s_utils import UnsupportedBinding from saml2.saml import SCM_BEARER from saml2.samlp import AuthnRequest, IDPEntry, IDPList, Scoping from saml2.sigver import MissingKey from saml2.validate import ResponseLifetimeExceed, ToEarly from .cache import IdentityCache, OutstandingQueriesCache, StateCache from .conf import get_config from .exceptions import IdPConfigurationMissing from .overrides import Saml2Client from .utils import ( add_idp_hinting, available_idps, get_csp_handler, get_custom_setting, get_fallback_login_redirect_url, get_idp_sso_supported_bindings, get_location, validate_referral_url, ) logger = logging.getLogger("djangosaml2") def saml2_csp_update(view): csp_handler = get_csp_handler() @wraps(view) def wrapper(*args, **kwargs): return csp_handler(view)(*args, **kwargs) return wrapper def _set_subject_id(session, subject_id): session["_saml2_subject_id"] = code(subject_id) def _get_subject_id(session): try: return decode(session["_saml2_subject_id"]) except KeyError: return None def _get_next_path(request: HttpRequest) -> Optional[str]: if "next" in request.GET: next_path = request.GET["next"] elif "RelayState" in request.GET: next_path = request.GET["RelayState"] else: return None next_path = validate_referral_url(request, next_path) return next_path class SPConfigMixin: """Mixin for some of the SAML views with re-usable methods.""" config_loader_path = None def get_config_loader_path(self, request: HttpRequest): return self.config_loader_path def get_sp_config(self, request: HttpRequest) -> SPConfig: return get_config(self.get_config_loader_path(request), request) def get_state_client(self, request: HttpRequest): conf = self.get_sp_config(request) state = StateCache(request.saml_session) client = Saml2Client( conf, state_cache=state, identity_cache=IdentityCache(request.saml_session) ) return state, client @method_decorator(saml2_csp_update, name="dispatch") class LoginView(SPConfigMixin, View): """SAML Authorization Request initiator. This view initiates the SAML2 Authorization handshake using the pysaml2 library to create the AuthnRequest. post_binding_form_template is a path to a template containing HTML form with hidden input elements, used to send the SAML message data when HTTP POST binding is being used. You can customize this template to include custom branding and/or text explaining the automatic redirection process. Please see the example template in templates/djangosaml2/example_post_binding_form.html If set to None or nonexistent template, default form from the saml2 library will be rendered. """ wayf_template = getattr( settings, "SAML2_CUSTOM_WAYF_TEMPLATE", "djangosaml2/wayf.html" ) authorization_error_template = getattr( settings, "SAML2_CUSTOM_AUTHORIZATION_ERROR_TEMPLATE", "djangosaml2/auth_error.html", ) post_binding_form_template = getattr( settings, "SAML2_CUSTOM_POST_BINDING_FORM_TEMPLATE", "djangosaml2/post_binding_form.html", ) def unknown_idp(self, request, idp): msg = f"Error: IdP EntityID {escape(idp)} was not found in metadata" logger.exception(msg) return HttpResponse(msg, status=403) def load_sso_kwargs_scoping(self, sso_kwargs): """Performs IdP Scoping if scoping param is present.""" idp_scoping_param = self.request.GET.get("scoping", None) if idp_scoping_param: idp_scoping = Scoping() idp_scoping.idp_list = IDPList() idp_scoping.idp_list.idp_entry.append( IDPEntry(provider_id=idp_scoping_param) ) sso_kwargs["scoping"] = idp_scoping def load_sso_kwargs(self, sso_kwargs): """Inherit me if you want to put your desidered things in sso_kwargs""" def add_idp_hinting(self, http_response): return add_idp_hinting(self.request, http_response) or http_response def should_prevent_auth(self, request) -> bool: # If the user is already authenticated that maybe because of two reasons: # A) He has this URL in two browser windows and in the other one he # has already initiated the authenticated session. # B) He comes from a view that (incorrectly) send him here because # he does not have enough permissions. That view should have shown # an authorization error in the first place. return request.user.is_authenticated def get(self, request, *args, **kwargs): logger.debug("Login process started") next_path = _get_next_path(request) if next_path is None: next_path = get_fallback_login_redirect_url() if self.should_prevent_auth(request): # If the SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN setting is True # (default value), redirect to the next_path. Otherwise, show a # configurable authorization error. if get_custom_setting("SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN", True): return HttpResponseRedirect(next_path) logger.debug("User is already logged in") return render( request, self.authorization_error_template, { "came_from": next_path, }, ) try: conf = self.get_sp_config(request) except SourceNotFound: # pragma: no cover # this is deprecated and it's here only for the doubts that something # would happen the day after I'll remove it! :) return self.unknown_idp(request, idp="unknown") # is a embedded wayf or DiscoveryService needed? configured_idps = available_idps(conf) selected_idp = request.GET.get("idp", None) self.conf = conf sso_kwargs = {} # Do we have a Discovery Service? if not selected_idp: discovery_service = getattr(settings, "SAML2_DISCO_URL", None) if discovery_service: # We have to build the URL to redirect to with all the information # for the Discovery Service to know how to send the flow back to us logger.debug( ( "A discovery process is needed trough a" "Discovery Service: {}" ).format(discovery_service) ) login_url = "{}?next={}".format( request.build_absolute_uri(reverse("saml2_login")), quote(next_path, safe=""), ) ds_url = "{}?entityID={}&return={}&returnIDParam=idp".format( discovery_service, quote(conf.entityid, safe=""), quote(login_url, safe=""), ) return HttpResponseRedirect(ds_url) elif len(configured_idps) > 1: logger.debug("A discovery process trough WAYF page is needed") return render( request, self.wayf_template, { "available_idps": configured_idps.items(), "came_from": next_path, }, ) # when using MDQ and DS we need to initiate a check on the selected idp, # otherwise the available idps will be empty configured_idps = available_idps(conf, idp_to_check=selected_idp) # is the first one, otherwise next logger message will print None if not configured_idps: # pragma: no cover raise IdPConfigurationMissing("IdP is missing or its metadata is expired.") if selected_idp is None: selected_idp = list(configured_idps.keys())[0] # choose a binding to try first binding = getattr(settings, "SAML_DEFAULT_BINDING", saml2.BINDING_HTTP_POST) logger.debug(f"Trying binding {binding} for IDP {selected_idp}") # ensure our selected binding is supported by the IDP try: supported_bindings = get_idp_sso_supported_bindings( selected_idp, config=conf ) except saml2.s_utils.UnknownSystemEntity: return self.unknown_idp(request, selected_idp) if binding not in supported_bindings: logger.debug( f"Binding {binding} not in IDP {selected_idp} " f"supported bindings: {supported_bindings}. Trying to switch ...", ) if binding == saml2.BINDING_HTTP_POST: logger.warning( f"IDP {selected_idp} does not support {binding} " f"trying {saml2.BINDING_HTTP_REDIRECT}", ) binding = saml2.BINDING_HTTP_REDIRECT else: # pragma: no cover logger.warning( f"IDP {selected_idp} does not support {binding} " f"trying {saml2.BINDING_HTTP_POST}", ) binding = saml2.BINDING_HTTP_POST # if switched binding still not supported, give up if binding not in supported_bindings: # pragma: no cover raise UnsupportedBinding( f"IDP {selected_idp} does not support " f"{saml2.BINDING_HTTP_POST} or {saml2.BINDING_HTTP_REDIRECT}" ) client = Saml2Client(conf) # SSO options sign_requests = getattr(conf, "_sp_authn_requests_signed", False) if sign_requests: sso_kwargs["sigalg"] = getattr( conf, "_sp_signing_algorithm", saml2.xmldsig.SIG_RSA_SHA256 ) sso_kwargs["digest_alg"] = getattr( conf, "_sp_digest_algorithm", saml2.xmldsig.DIGEST_SHA256 ) # pysaml needs a string otherwise: "cannot serialize True (type bool)" if getattr(conf, "_sp_force_authn", False): sso_kwargs["force_authn"] = "true" if getattr(conf, "_sp_allow_create", False): sso_kwargs["allow_create"] = "true" # custom nsprefixes sso_kwargs["nsprefix"] = get_namespace_prefixes() # Enrich sso_kwargs ... # idp scoping self.load_sso_kwargs_scoping(sso_kwargs) # other customization to be inherited self.load_sso_kwargs(sso_kwargs) logger.debug(f"Redirecting user to the IdP via {binding} binding.") _msg = "Unable to know which IdP to use" http_response = None if binding == saml2.BINDING_HTTP_REDIRECT: try: session_id, result = client.prepare_for_authenticate( entityid=selected_idp, relay_state=next_path, binding=binding, sign=sign_requests, **sso_kwargs, ) except TypeError as e: logger.exception(f"{_msg}: {e}") return HttpResponse(_msg) else: http_response = HttpResponseRedirect(get_location(result)) elif binding == saml2.BINDING_HTTP_POST: if self.post_binding_form_template: # get request XML to build our own html based on the template try: location = client.sso_location(selected_idp, binding) except TypeError as e: logger.exception(f"{_msg}: {e}") return HttpResponse(_msg) session_id, request_xml = client.create_authn_request( location, binding=binding, **sso_kwargs ) try: if isinstance(request_xml, AuthnRequest): # request_xml will be an instance of AuthnRequest if the message is not signed request_xml = str(request_xml) saml_request = base64.b64encode(bytes(request_xml, "UTF-8")).decode( "utf-8" ) http_response = render( request, self.post_binding_form_template, { "target_url": location, "params": { "SAMLRequest": saml_request, "RelayState": next_path, }, }, ) except TemplateDoesNotExist as e: logger.debug( f"TemplateDoesNotExist: [{self.post_binding_form_template}] - {e}", exc_info=True, ) if not http_response: # use the html provided by pysaml2 if no template was specified or it doesn't exist try: session_id, result = client.prepare_for_authenticate( entityid=selected_idp, relay_state=next_path, binding=binding, **sso_kwargs, ) except TypeError as e: _msg = f"Can't prepare the authentication for {selected_idp}" logger.exception(f"{_msg}: {e}") return HttpResponse(_msg) else: http_response = HttpResponse(result["data"]) else: raise UnsupportedBinding(f"Unsupported binding: {binding}") # success, so save the session ID and return our response oq_cache = OutstandingQueriesCache(request.saml_session) oq_cache.set(session_id, next_path) logger.debug( f'Saving the session_id "{oq_cache.__dict__}" ' "in the OutstandingQueries cache", ) # idp hinting support, add idphint url parameter if present in this request response = self.add_idp_hinting(http_response) or http_response return response @method_decorator(csrf_exempt, name="dispatch") class AssertionConsumerServiceView(SPConfigMixin, View): """The IdP will send its response to this view, which will process it using pysaml2 and log the user in using whatever SAML authentication backend has been enabled in settings.py. The `djangosaml2.backends.Saml2Backend` can be used for this purpose, though some implementations may instead register their own subclasses of Saml2Backend. """ def custom_validation(self, response): pass def handle_acs_failure(self, request, exception=None, status=403, **kwargs): """Error handler if the login attempt fails. Override this to customize the error response.""" # Backwards compatibility: if a custom setting was defined, use that one custom_failure_function = get_custom_setting( "SAML_ACS_FAILURE_RESPONSE_FUNCTION" ) if custom_failure_function: failure_function = ( custom_failure_function if callable(custom_failure_function) else import_string(custom_failure_function) ) return failure_function(request, exception, status, **kwargs) return render( request, "djangosaml2/login_error.html", {"exception": exception}, status=status, ) def post(self, request, attribute_mapping=None, create_unknown_user=None): """SAML Authorization Response endpoint""" if "SAMLResponse" not in request.POST: logger.warning('Missing "SAMLResponse" parameter in POST data.') return HttpResponseBadRequest( 'Missing "SAMLResponse" parameter in POST data.' ) attribute_mapping = attribute_mapping or get_custom_setting( "SAML_ATTRIBUTE_MAPPING", {"uid": ("username",)} ) create_unknown_user = create_unknown_user or get_custom_setting( "SAML_CREATE_UNKNOWN_USER", True ) conf = self.get_sp_config(request) identity_cache = IdentityCache(request.saml_session) client = Saml2Client(conf, identity_cache=identity_cache) oq_cache = OutstandingQueriesCache(request.saml_session) oq_cache.sync() outstanding_queries = oq_cache.outstanding_queries() _exception = None try: response = client.parse_authn_request_response( request.POST["SAMLResponse"], saml2.BINDING_HTTP_POST, outstanding_queries, ) except (StatusError, ToEarly) as e: _exception = e logger.exception("Error processing SAML Assertion.") except ResponseLifetimeExceed as e: _exception = e logger.info( ( "SAML Assertion is no longer valid. Possibly caused " "by network delay or replay attack." ), exc_info=True, ) except SignatureError as e: _exception = e logger.info("Invalid or malformed SAML Assertion.", exc_info=True) except StatusAuthnFailed as e: _exception = e logger.info("Authentication denied for user by IdP.", exc_info=True) except StatusRequestDenied as e: _exception = e logger.warning("Authentication interrupted at IdP.", exc_info=True) except StatusNoAuthnContext as e: _exception = e logger.warning("Missing Authentication Context from IdP.", exc_info=True) except MissingKey as e: _exception = e logger.exception( "SAML Identity Provider is not configured correctly: certificate key is missing!" ) except UnsolicitedResponse as e: _exception = e logger.exception("Received SAMLResponse when no request has been made.") except RequestVersionTooLow as e: _exception = e logger.exception("Received SAMLResponse have a deprecated SAML2 VERSION.") except Exception as e: _exception = e logger.exception("SAMLResponse Error") if _exception: return self.handle_acs_failure(request, exception=_exception) elif response is None: logger.warning("Invalid SAML Assertion received (unknown error).") return self.handle_acs_failure( request, status=400, exception=SuspiciousOperation("Unknown SAML2 error"), ) try: self.custom_validation(response) except Exception as e: logger.warning(f"SAML Response validation error: {e}", exc_info=True) return self.handle_acs_failure( request, status=400, exception=SuspiciousOperation("SAML2 validation error"), ) session_id = response.session_id() oq_cache.delete(session_id) # authenticate the remote user session_info = response.session_info() # assertion_info assertion = response.assertion assertion_info = {} for sc in assertion.subject.subject_confirmation: if sc.method == SCM_BEARER: assertion_not_on_or_after = sc.subject_confirmation_data.not_on_or_after assertion_info = { "assertion_id": assertion.id, "not_on_or_after": assertion_not_on_or_after, } break if callable(attribute_mapping): attribute_mapping = attribute_mapping() if callable(create_unknown_user): create_unknown_user = create_unknown_user() try: user = self.authenticate_user( request, session_info, attribute_mapping, create_unknown_user, assertion_info, ) except PermissionDenied as e: return self.handle_acs_failure( request, exception=e, session_info=session_info, ) relay_state = self.build_relay_state() custom_redirect_url = self.custom_redirect(user, relay_state, session_info) if custom_redirect_url: return HttpResponseRedirect(custom_redirect_url) relay_state = validate_referral_url(request, relay_state) if not relay_state: logger.debug( "RelayState is not a valid URL, redirecting to fallback: %s", relay_state, ) return HttpResponseRedirect(get_fallback_login_redirect_url()) logger.debug("Redirecting to the RelayState: %s", relay_state) return HttpResponseRedirect(relay_state) def authenticate_user( self, request, session_info, attribute_mapping, create_unknown_user, assertion_info, ): """Calls Django's authenticate method after the SAML response is verified""" logger.debug("Trying to authenticate the user. Session info: %s", session_info) user = auth.authenticate( request=request, session_info=session_info, attribute_mapping=attribute_mapping, create_unknown_user=create_unknown_user, assertion_info=assertion_info, ) if user is None: logger.warning( "Could not authenticate user received in SAML Assertion. Session info: %s", session_info, ) raise PermissionDenied("No user could be authenticated.") auth.login(self.request, user) _set_subject_id(request.saml_session, session_info["name_id"]) logger.debug("User %s authenticated via SSO.", user) self.post_login_hook(request, user, session_info) self.customize_session(user, session_info) return user def post_login_hook( self, request: HttpRequest, user: settings.AUTH_USER_MODEL, session_info: dict ) -> None: """If desired, a hook to add logic after a user has succesfully logged in.""" def build_relay_state(self) -> str: """The relay state is a URL used to redirect the user to the view where they came from.""" default_relay_state = get_fallback_login_redirect_url() relay_state = self.request.POST.get("RelayState", default_relay_state) relay_state = self.customize_relay_state(relay_state) if not relay_state: logger.warning("The RelayState parameter exists but is empty") relay_state = default_relay_state return relay_state def customize_session(self, user, session_info: dict): """Subclasses can use this for customized functionality around user sessions.""" def customize_relay_state(self, relay_state: str) -> str: """Subclasses may override this method to implement custom logic for relay state.""" return relay_state def custom_redirect(self, user, relay_state: str, session_info) -> str: """Subclasses may override this method to implement custom logic for redirect. For example, some sites may require user registration if the user has not yet been provisioned. """ return None class EchoAttributesView(LoginRequiredMixin, SPConfigMixin, View): """Example view that echo the SAML attributes of an user""" def get(self, request, *args, **kwargs): state, client = self.get_state_client(request) subject_id = _get_subject_id(request.saml_session) try: identity = client.users.get_identity( subject_id, check_not_on_or_after=False ) except AttributeError: return HttpResponse( "No active SAML identity found. Are you sure you have logged in via SAML?" ) return render( request, "djangosaml2/echo_attributes.html", {"attributes": identity[0]} ) @method_decorator(saml2_csp_update, name="dispatch") class LogoutInitView(LoginRequiredMixin, SPConfigMixin, View): """SAML Logout Request initiator This view initiates the SAML2 Logout request using the pysaml2 library to create the LogoutRequest. """ def get(self, request, *args, **kwargs): state, client = self.get_state_client(request) subject_id = _get_subject_id(request.saml_session) if subject_id is None: logger.warning( "The session does not contain the subject id for user %s", request.user ) _error = None try: result = client.global_logout(subject_id) except LogoutError as exp: logger.exception(f"Error Handled - SLO not supported by IDP: {exp}") _error = exp except UnsupportedBinding as exp: logger.exception(f"Error Handled - SLO - unsupported binding by IDP: {exp}") _error = exp auth.logout(request) state.sync() if _error: return self.handle_unsupported_slo_exception(request, _error) if not result: logger.error( "Looks like the user %s is not logged in any IdP/AA", subject_id ) return HttpResponseBadRequest("You are not logged in any IdP/AA") if len(result) > 1: logger.error( "Sorry, I do not know how to logout from several sources. I will logout just from the first one" ) for logout_info in result.values(): if isinstance(logout_info, tuple): binding, http_info = logout_info if binding == saml2.BINDING_HTTP_POST: logger.debug( "Returning form to the IdP to continue the logout process" ) body = "".join(http_info["data"]) return HttpResponse(body) elif binding == saml2.BINDING_HTTP_REDIRECT: logger.debug( "Redirecting to the IdP to continue the logout process" ) return HttpResponseRedirect(get_location(http_info)) else: logger.error("Unknown binding: %s", binding) return HttpResponseServerError("Failed to log out") # We must have had a soap logout return finish_logout(request, logout_info) logger.error( "Could not logout because there only the HTTP_REDIRECT is supported" ) return HttpResponseServerError("Logout Binding not supported") def handle_unsupported_slo_exception(self, request, exception, *args, **kwargs): """Subclasses may override this method to implement custom logic for handling logout errors. Redirects to LOGOUT_REDIRECT_URL by default. For example, a site may want to perform additional logic and redirect users somewhere other than the LOGOUT_REDIRECT_URL. """ return HttpResponseRedirect(getattr(settings, "LOGOUT_REDIRECT_URL", "/")) @method_decorator([saml2_csp_update, csrf_exempt], name="dispatch") class LogoutView(SPConfigMixin, View): """SAML Logout Response endpoint The IdP will send the logout response to this view, which will process it with pysaml2 help and log the user out. Note that the IdP can request a logout even when we didn't initiate the process as a single logout request started by another SP. """ logout_error_template = "djangosaml2/logout_error.html" def get(self, request, *args, **kwargs): return self.do_logout_service( request, request.GET, saml2.BINDING_HTTP_REDIRECT, *args, **kwargs ) def post(self, request, *args, **kwargs): return self.do_logout_service( request, request.POST, saml2.BINDING_HTTP_POST, *args, **kwargs ) def do_logout_service(self, request, data, binding, *args, **kwargs): logger.debug("Logout service started") state, client = self.get_state_client(request) if "SAMLResponse" in data: # we started the logout logger.debug("Receiving a logout response from the IdP") try: response = client.parse_logout_request_response( data["SAMLResponse"], binding ) except StatusError as e: response = None logger.warning( f"Error logging out from remote provider: {e}", exc_info=True ) state.sync() return finish_logout(request, response) elif "SAMLRequest" in data: # logout started by the IdP logger.debug("Receiving a logout request from the IdP") subject_id = _get_subject_id(request.saml_session) if subject_id is None: logger.warning( "The session does not contain the subject id for user %s. Performing local logout", request.user, ) auth.logout(request) return render(request, self.logout_error_template, status=403) http_info = client.handle_logout_request( data["SAMLRequest"], subject_id, binding, relay_state=data.get("RelayState", ""), ) state.sync() auth.logout(request) if ( http_info.get("method", "GET") == "POST" and "data" in http_info and ("Content-type", "text/html") in http_info.get("headers", []) ): # need to send back to the IDP a signed POST response with user session # return HTML form content to browser with auto form validation # to finally send request to the IDP return HttpResponse(http_info["data"]) return HttpResponseRedirect(get_location(http_info)) logger.error("No SAMLResponse or SAMLRequest parameter found") return HttpResponseBadRequest("No SAMLResponse or SAMLRequest parameter found") def finish_logout(request, response): if getattr(settings, "SAML_IGNORE_LOGOUT_ERRORS", False) or ( response and response.status_ok() ): logger.debug("Performing django logout.") auth.logout(request) next_path = _get_next_path(request) if next_path is not None: logger.debug("Redirecting to the RelayState: %s", next_path) return HttpResponseRedirect(next_path) elif settings.LOGOUT_REDIRECT_URL is not None: fallback_url = resolve_url(settings.LOGOUT_REDIRECT_URL) logger.debug( "No valid RelayState found; Redirecting to " "LOGOUT_REDIRECT_URL" ) return HttpResponseRedirect(fallback_url) else: current_site = get_current_site(request) logger.debug( "No valid RelayState or LOGOUT_REDIRECT_URL found, " "rendering fallback template." ) return render( request, "registration/logged_out.html", { "site": current_site, "site_name": current_site.name, "title": _("Logged out"), "subtitle": None, }, ) logger.error("Unknown error during the logout") return render(request, "djangosaml2/logout_error.html", {}) class MetadataView(SPConfigMixin, View): """Returns an XML with the SAML 2.0 metadata for this SP as configured in the settings.py file.""" def get(self, request, *args, **kwargs): conf = self.get_sp_config(request) metadata = entity_descriptor(conf) return HttpResponse( content=str(metadata).encode("utf-8"), content_type="text/xml; charset=utf-8", ) def get_namespace_prefixes(): from saml2 import md, saml, samlp, xmldsig, xmlenc return { "saml": saml.NAMESPACE, "samlp": samlp.NAMESPACE, "md": md.NAMESPACE, "ds": xmldsig.NAMESPACE, "xenc": xmlenc.NAMESPACE, } djangosaml2-1.10.1/docs/000077500000000000000000000000001476674771500147535ustar00rootroot00000000000000djangosaml2-1.10.1/docs/Makefile000066400000000000000000000011761476674771500164200ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) djangosaml2-1.10.1/docs/make.bat000066400000000000000000000014371476674771500163650ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd djangosaml2-1.10.1/docs/source/000077500000000000000000000000001476674771500162535ustar00rootroot00000000000000djangosaml2-1.10.1/docs/source/_static/000077500000000000000000000000001476674771500177015ustar00rootroot00000000000000djangosaml2-1.10.1/docs/source/_static/custom.css000066400000000000000000000022701476674771500217260ustar00rootroot00000000000000body, h1, h2, .rst-content .toctree-wrapper p.caption, h3, h4, h5, h6, legend{ font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; } .wy-side-nav-search{ background: #ffffff; } .wy-side-nav-search>a, .wy-side-nav-search .wy-dropdown>a{ color: #9b9c9e; font-weight: normal; } .wy-menu-vertical header, .wy-menu-vertical p.caption{ color: #fff; font-size:85%; } .wy-nav-top{ background: #fff; border-bottom: 1px solid #f7f5f5; } .wy-nav-top a{ display: block; color: #9b9c9e; font-weight: normal; } .wy-nav-top i{ color: #BE0417; } .wy-nav-top img{ border-radius: 0; background: none; width: 65%; } img{ height: auto !important; } .document{ text-align: justify; } h1{ text-align: left; } #logo_main{ margin-bottom: 0; } #title_under_logo{ margin-bottom: 1em; } .alert-danger { color: #721c24; background-color: #f8d7da; border-color: #f5c6cb; } .alert-primary { color: #004085; background-color: #cce5ff; border-color: #b8daff; } .alert { position: relative; padding: .75rem 1.25rem; margin-bottom: 1rem; border: 1px solid transparent; border-radius: .25rem; } djangosaml2-1.10.1/docs/source/_static/logo.jpg000066400000000000000000001047271476674771500213560ustar00rootroot00000000000000’Ų’ąJFIFHH’įtExifII*V^(1 f2ti‡ˆšHHGIMP 2.10.182021:04:05 01:21:07 Cf’Ų’ąJFIF’ŪC    $.' ",#(7),01444'9=82<.342’ŪC  2!!22222222222222222222222222222222222222222222222222’ĄC"’Ä ’ĵ}!1AQa"q2‘”#B±ĮRŃš$3br‚ %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyzƒ„…†‡ˆ‰Š’“”•–—˜™š¢£¤„¦§Ø©Ŗ²³“µ¶·ø¹ŗĀĆÄÅĘĒČÉŹŅÓŌÕÖ×ŲŁŚįāćäåęēčéźńņóōõö÷ųłś’Ä ’ĵw!1AQaq"2B‘”±Į #3RšbrŃ $4į%ń&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz‚ƒ„…†‡ˆ‰Š’“”•–—˜™š¢£¤„¦§Ø©Ŗ²³“µ¶·ø¹ŗĀĆÄÅĘĒČÉŹŅÓŌÕÖ×ŲŁŚāćäåęēčéźņóōõö÷ųłś’Ś ?ņŚ(¢›lóē9)=BŠU˜/©Åz~³šb÷GŃ/u7Ö-å[XZb‚ ĘsEŲGŚKfy}ßųCįmߋ“1ŖC©Įn†VcĘXńŽx>õČėśCč:ķę•$«+ŪI°ŗŒü(»ķ»3Ø¢Š.Éē—p¢Š(»yw (¢‹°ē—p¢Š(»yw (¢‹°ē—p®—Ą=žæć};LŌ#i-gó7Ŗ±RvĘĢ9ąW5]§Āoł)ŗ?ż¶’Ń/Bf”¤ÜÕŁģ?š§|’>3’ąK’š§|’>3’ąK’w”Qvwü)ß’ό’ų’ćGü)ß’ό’ų’ć]å]Į’ĀšoüųĻ’/ž4ĀšoüųĻ’/ž5ŽQEŲü)ß’ό’ų’ćGü)ß’ό’ų’ć]å®’ wĮæóć?žæųŃ’ wĮæóć?žæų×yEƒ’…;ąßłńŸ’_üh’…;ąßłńŸ’_ük¼¢‹Į’ĀšoüųĻ’/ž5Ć|Tšįo ZŽéVŅE<—‹™œm(ē”>Ŗ+ŻkĖž:’ȗc’a’ŃrSLGĻŌčćy¤XāFwc…U$śMÆzų-įkH4!ā)£^\»,,ĆżR)*qīH9>˜”īpņ9Ōhņū?‡^/øŽ;„Š®Dd†łŹ£cżÖ ž•ō?Ž?äD×ėĘoż×ā_Œ¶ś.æ6•e„›Ļ³ÉåK+ͰnB€pxē+³ńĒüˆšļżxĶ’ šGD#¦¢Ļ!šŠ|U¤xhZčž“Qµó¼õW#qĘG•ĆųŽ]GXńmō·|ßĻ1/jŖK+c¦:×øü’’~?ėź_é\Gü%6~ųĆā-Jö¦FW‰ ,Jä`` år7z•·ĆŻĀ%‹A¹FG˜V3ł1±uMRŃ.~Ļ©ŲĻk/a*ćwø=śW¼iüO­ź–ėo່ōē•Uē‘˜mBpX qĶIńŖŅ¼ ē¼je‚ā3‘Źē ā:Qåm3Ć“’ x‡UĒŲ“[éTŒ‡ņJ”’Ö“¦ųkć!2¾…pT ŒŒ IƤ­ī~Åįh®¶oņl„›sŒį3ŒžĘųā˜ń†¹&™&•öGņŒØė7˜d:ŠW±‚²osēy”–Śg†xž)Pį‘Ō«)ō ō©lģ/5 ¼›+Iīežä1—?Ækų»įxõoųxŚG²ņžck#œØĮ ŻŅ»)@ųeį'ž;.Ž ”¶dĻ'¹?„*»½ą±ü5ńŒØt€÷™žDę³uO ųƒEŒĖØévń/YZ2Pv凭}ą’Āq6¦£Oū"Ył{s.ņį·uącīūõ¬ŸÜæŠ|{¦ų&Kw[Ų^]Ȳ­@¤…#°ĻśJ taĖtĻŃü!ā } éšUÄń’ĻLmOūé°ēOÕüāM ;QŅ."„u”č>„IńÆ”¼cā«/xzŽH¬–MĢ ¶¶Œ„Qōą;Jo¼kmć½&åŚŠA4,#žŪŌ†§ ąõģa~[ź|»]§Āoł)ŗ?ż¶’Ń/Q|KšģńÅ­¢yv³¢ÜBƒ¢†Č {įŠ—į7ü”ŻžŪč—¦ˆ¤¹jXśZīņŽŹ6āUDĪ{Ÿ@;šÄ>9šź’§QPAĮ7’ åž,\ŻYǧMŗBĀXŁĮĄ @Ą?QŸÖ°tķ'įÜÖ05Ž£y%Ń@eɐ|ŻĒ Šē•F¤ć¢õ>І ¢Ŗµ)^śG„»ž”„x£FÖ®¤¶Óļ’i£]Ģ€@õäU½KU³Ņ¢Yo%ņÕŪjįKpO@ č r~ м!iØĖyįłŽK•dä9 }Aö¬/^]kž,³Šģ¦ +ŽŽĢܹ÷śÕ9µīĢiįcR»†±ŠWwŻ$z^©Zj¶iwe2Ķä_cƒōŖŗˆō½&fŠöąÄʁŪ÷lĄ)$Hø5Ć|:ŗŸF×u ßH»·³ÅŲ^Q‚?”ń5%›Å–öšœ4Öń ”%ŲēRź¾Ne¹µ<)ќ½Ū^žVæäzµ„ÜÖq][H²A*‡G^ŒCYwŽ-ŃtÉŽĖĻ)‘¶¶cbĄ8Čī+—ųi¬•šåµ×Mv8ļ呻§×pü+Ėu›ū«łn'•˜¬#rs󘁒} S­h¦ŗšarÅR¬įQŁGOWÓšW>é‘j±i²\…ŗ—Z 6Fx8Į掵+ĒuI¤‰š$lģQ$· ą~ģ?ö,Ö°—5ü™Ć‰ ØņYüQOļœOjŖŗ•£_żˆ\Gö“”GžJ“ŒÕ=vķ‘ml”b&»”'$(å=°0~µÅ·ƒ§ÕµÆŻkWV’y¹³hqˆ‘xRŁź09ü{Smō2„`ž7nާ„חüu’‘.ĒžĀ ’¢ä®÷I½ø™ÖżQo ĢŁ÷d£Æ±Įć±WńמD»ū'ž‹’©5cēźś_į ģwģcFRöĻ$Rz冾h­ļ xæUšł¹Óe]4 ¤€zž}E7¹ÅņTm7‹>ų’/_Og¦Ķwi=ĖM±aøf݃ÜœW¶ųćžDMwž¼f’ŠMy­æĒĀ>ĖzĒwĮü ZĢמ4O­č÷ŗ\ZB—p“%Śą¹†3£ži)SŠv{ĻĮoł'ė’_R’J„įMĒPų§āĶNī(ę–ĪįqŒĄ’ŲõłFÖŗ_†ś4ځ4ė[”)pŹÓH¬0T±'Ü ńtųyįļˆzŽ­`‘Ļous"¼.p$@ĒiĻcŽžę¶£ó›āÆkŗGŠ“FÓ¼>óC¹Ś]ƒĘJ…ĄĻÆjŸć/ü“Ėśļž…\Śü{¶ņAŹ%ōCžßé\挞+Kāķ M+ū!-QäWó>Ѽü§=6ŠU#ŹõÜ÷ ’äF“žĮ§’EW‡|’‘ųפŸū-iæĘÉCm7ū@6ŽG™öæövē+‰šgŠOƒõļķE³gŹh¼³&Īøē8>”:‘r‹OcŽ…Ó`żXTŸü5yāk¾ź –xćČfsģÄžć^8ų‡/Ž!±„ickggR“™ ū#+kAųŪŖé–1Śźv ؘĘĘ_-Č’k‚ ÷ ækŚ{3Øų5į­gĆņk-ŖéņŚ Ä"?36Żłč}ÅOs}‡ķÉ’/šp…O£rGžĘ·üć+æ[ŽŽI„‹Hc…¼ŅęFä·8>_μāī [āTĻo#$¶‘ĀŌą«æ ūnšŒG£ücšÖ”Æčv3é¶Ņ\Ķg+Š1–(ĄdßGÖ«ü𾄔XźWŗ¬¶ÆvѬqJ»[j†;ˆź2[śW? üsø¶µŽ oM7N‹ƒs…g÷*F3ō#éK®ütžāÖH4]0Ū;®Äīś…ś“ō \ōł¹īaüiŌc½ńߓģ–© ćūŁf?£ ĻųM’%7G’¶ßś%ėžynn$žyIdbīģrXžI5Ų|&’’›£’Ūożōѝ9sT¹ļ~/Ō“[->+}rŪķ·Oå„*Čē$b¼ś4ųcŗVūšģœąū 1ÆTÕtm;[¶[mJŅ;˜U·qŠśÖ?ü+æ ŠŪõ’ŹQ“zXöhV£ŚJWņvż'š÷ˆl“ßMØY—K+x¦b²BcåR}KmZv…¦x“Å—w¾2DńŹCNņ%›–ĮZö8|# [ŲĶg•lN1*„ūĆÜõ«ŗ^‘a¢Ś}—N¶Kx7Ų2zš…Ah™ÕS5“ępVn˾‹ŌńM^Ė_šv©a­jņĒ$¦|‰’Bۈ«gÕr+SĒ:­­ĒŒ“ˆ$—ŪIŖ™ ½/^ŃS\…-®!·šŲĶÉøģGqßó¬·še¤¢ŲIcb~ȁmŽĘżŃ ž9é֏cmĬɶ„5ļ$ÕüŸłoćÉ|'ā bŅ"†ž0ėžŅ1 q’ >•›āK4Ѽ#į÷œmø¼ŠęįĮė–ņńśb½ƒTš²k²E&©marńp…¢nq÷©u? ®°‘C•ÄćĖG„žļ€=é:—ÖIĮņü;łédžć€ńc ĘZ>­rŒmŁ!™X„V_®0ŚOéWÉö䛮’aeŲ\ q€ēŌgé×^éj:zŚ]ŪŲĶž c,£Ę9ś~U‹oš’MŠź²4ÅŚĮ˜¢>A<|Õ¢ƒ‹m=ĪiāiU§R.ńVM?ŗźĘN»©źz'ō×:Mę£k”PЂij“»žß(ēŅ»×­!ÓÄ÷Ń­…¶Ž~ŌĮH„’LÖ¼Ö°ÜDzhÕ׊Š ŽŅ!ģc`z«dƒųtŖI£šU’Mm”Čų'Å6zę±u§Ūłž]›3YLTüŠ“‚‡Žƒ‚=±éT¾:’ȗc’a’ŃrW¤ŪYŪYĘ#¶·ŠĆ„y·Ē_łģģ Ÿś.JqVК³S“iXłśŠ(¦÷<©üL+s¾"_ ė Ø6™k·¢ĪTś©ģ}škŠ›Nčõ’|ižŠŃ~Å¢YĶm=ÄefšV‹=Bc©÷ćééäQ@ē9Op¢Š($(¢ŠŲš„õ¾™āĶ*śķö[At#`œ(<œkč)Æ>źĻö«‰|=4Œ2^QcõĻ?|ĖE©Ź­cčżsā…|5¦˜4™`»SŪY€#_L°@śsķ_=jZĪ­©\j’y—]ŪäÕZ( ŌsÜ(¢Š Ā»O„ßņStūm’¢^øŗķ>ÉMŃ’ķ·ž‰zh֏ʏ§h¢ŠGxQEQEQEQEQEW—üu’‘.ĒžĀ ’¢äÆPÆ/ųė’"]ż„’EÉMnĻŌQEY (¢€°QE‚Š( ,QE`¢Š( Q@X(¢ŠĮRA<Ö³,ÖņÉ«÷^6*Ć·QE\žŽÖ?č-}’/ž4okō¾’Ą—’( ū{X’ µ÷žæųŃż½¬ŠZū’_üh¢€ķķcž‚×ßų’ćGöö±’Akļü ń¢ŠŪŚĒżÆæš%’ʏķķcž‚×ßų’ćE€?·µś _ąK’ŪŚĒżÆæš%’Ɗ(žŽÖ?č-}’/ž4okō¾’Ą—’( ū{X’ µ÷žæųŌ7:żģb;«Ū™ŠĮe•˜ė‚h¢€?’Ł’ā°ICC_PROFILE lcms0mntrRGB XYZ åacspAPPLöÖÓ-lcms desc @cprt`6wtpt˜chad¬,rXYZŲbXYZģgXYZrTRC gTRC bTRC chrm4$dmndX$dmdd|$mluc enUS$GIMP built-in sRGBmluc enUSPublic DomainXYZ öÖÓ-sf32 BŽ’’ó%“ż’’ū”’’ż¢ÜĄnXYZ o 8õXYZ $Ÿ„¶ÄXYZ b—·‡Łparaffņ§ YŠ [chrm£×T|LĶ™š&g\mluc enUSGIMPmluc enUSsRGB’ŪC     ’ŪC   ’ĀŲ7’Ä ’Ä’Ś „wj5€F`J55T’b3mīŗūdh:ĒĻ-øšŖI1¶÷]}²4HcēŽÜ MU$˜€ Ū{®Ž¾Ł$±óĒn¦Ŗ’L@ÄŹr‚0F@m½×G_lXłć·SUI& L”é·3c˜šĄą‰\·Ķp"'5_Vą6Žė£Æ¶F‰¬|ńہ©Ŗ¤“&OtÜu”5Y_8ž….‰†\µAW€ Ū{®Ž¾Ł$±óĒn¦Ŗ’L@™AÓqÖø*²ŗs]ŦYENjÜ­ĄÉ€&m½×G_lČ2` €Ć¤ėАĮ®²Ń9=‘Ķ{b¬ž{ŚFf@뛫÷ł§·Ģ~¬SIŪ8¦-¼|Vč~³«kģéĘĖ{ņ‰Sua5ž›éڇ¦ń·WŁkƔI[øµ­}U‹Īż«©8õś|튱ņ 'ŌwėߓXΚ`Öc/.3¬µžĖʌ÷š{eÓēiÖ>xķĄŌÕR2›d²™|Qøįw$ˆč³«U•°ó—čRģC.Z ­±īżI«@hóŲ\E¾žŻ4S%OŠ„×yž’ÓTÕž‹+~±ōYk¬(R–ā­%'OrDN[<°ßŪµęŖd¬]“˳spCīiłŪ7:ÅiW×÷3mīŗ:ūd\fäōpõŌd[Ļ™ŲNæ,e€Ē345¾ƒė›ß˼ Æ śŻ~nÖŚü’ōMc„śn‘¦ĻŽQ»Ųų«5Ūå!ŗļgV{Ļ7uG﷚Āöł—źR-ļɬ_M0‹oU–ÅŸY‡L’·T×Ѥć×;nŖę‘Ö>xķĄŌÕRNYż[ś ōöš2©Į'ŗ.BĻ ŖŹŲyĖō)v!—-PVįŽŌŪ(ŗwŁ=Ńī¶G-¹Sō%uŽg9颧‘t×o–Ā‹*ÆŅćš"-GWdø‹iIæXj”Ŗ›ÜµŚŪ¼6ŗ §Ć/I?@’¢b÷=9T’dŪŻtuöȱ:¾éĶ?ŃūŚ­}ŻŻĘ; ītÓOÖß:Ü6qלŸ£F<·vū䦝w¶ˆ«½U”æł巖²Ņż7FÕcŗõQź<öwóå5ņ—ź‘ÖZ½L7y¹k°ž:¤śŒ‹{ņ›ÓK€Œeó[É=5^¼įź1ÓqßŲ‡BfCĖ@ėQXč¾Åłb×óē°_-Ž©¾“ŖlÓh­¾Y<ļŲ·»? ,Yųci‡‹F•Žć_Õa9Łųöz6=¹Ę¬Ó}Fw“łŽĻ·¾už;p55T’cmVÓ  F Ÿrg=; ŖŹŲĖōw’rÄ.kˆ³ĮLUzC]ŸwMøå¢—*~„®»Ģć'=4Tdŗ‹‰ æÉk J‹öøõÓÅ0S‹RjžŪko–ĆēҚŠ,'‡XƒÜ‘šxvŹmĘUEY 8Į›ouŃ×Ū#DėŲnŖ”ZöēO•«æhŻY„i²š:+öĖć×zÆ}eȳšŪĘ!čŗÆ•~žz“ššOü[‡MÖøłÜĮė"īaŠéņ×fĖÄÕzæ¬ē…ą²šu·Żxœ~£Ż|öhózüåưńpN‹ÓżÓe|ş/r«Ø?‹ŌļŻŽBŻōŅ~‡Xłć·SUI9cī¶Ż¦ŠŲIe„U•±k–ż.ņBŽZąą½ėŽŚp”“4RåWŠ•Ļu”fŃM.¢āBnņ˜üüRL®Ū`±ęSä5üżSūeŪ[lM€#7=9UžlA›ouŃ×Ū#Dōbj_' éF`†Xģ¹ńĶ;x+ę‹Ļ.;EŃSĢāyń5;ŽūŲŻĻø0‹t÷Z~Ļ=ēa¾“h»ŪvqY½Õ˚ćO¶Ū¦“ĖŒ =6z–®ÆĘså:ķ§M­8kQ–©ŽZŽ;śīō3Ó±e«yœ}¹Ć'Xłć·SUI&#ŗĪjvHĶŽė`ņŲėmp{‹¼Źė-IÆĪ:Ö­SŪÆfmätXųģ#Ęø Ån=¦ZSMsWd²K-µ· Ę¦+¹µČ™±×²¶x¬7ż½žøÕBTÜÓė¶XWlœßé§_a“@īŗm½×G_l‡H̰Œ‰å!ƒI<¶=¤łń>Äǘœ”Ų‡ :'¾~’ģž\Gć”ņ‡źI0b„¹$Ö>xķĄŌÕRIˆ$Œ†X2œ ĘŅ,ē'aMVĆnQ”„‚$€A&D§ Ź0‘yÖūƒtxÓKųH# ™A €fŪŻtuöČŃ ƒ¦~FN# 0ƒ‘ĄądżłČcēŽÜ MU$˜€óe=;l«·ōe浊õ?E€mœwÅ-Ė_i3KŖwuå5ÅĆ^ų3mīŗ:ūdh:ĒĻøšŖI12nķÖ}eæ·yˆØ ČķĪbŻgō!w“ Ź'UaHÕ{XĶ·ŗčėķ‘¢@ė-ĻOóXó³ž-ĻMóXó³ž-ĻMéńqŻį[Ā·üN<ģ’‹sÓzXķ"-uykJņ֕”M ʁ½ē”1Æ'aY9ƒfÖ’ÄcĪĻų·=7„»·fX÷Ö}f̫۟;?āÜōŽ–6īŻ™cßZ˜ėźŲZśŗ²4ģ|œÄcĪĻų·=7”¼+„k÷v̱ļµöęŽĀ4?·›xŽøF·‡”Äxƒ”>õą+Cöō1ēgķUņWœ1Æ8c^pʆi€RtMŠ(Tˆ,)»öĪ„6«&Õ¹üå#"ŁŃ˵wķŚœ1¤¤Ś.¢īnŸœ°¤WMĀ|ē7 Hn åsÓsCĀ<s ‹XµqģKą“qX·*ū2Ǿ“(ßŃpuō\dKv6*­Ū õĄ„v2ˆf  ćŁ€"™AV-×,Ī:Š‘NbĢļųŒÅ ²m1ĢZooʵ¢¶H”(AÜkąÅØ(G-Udćb(éX|ZõŲ1ĒPŒéHöŌ Q- DMKĄĒ8§8ž Ķ\mZ0™…‚y>ęG1+hę¬ĀœĘ4xXĮƒĀĖD;„uĖ;>ž9#6ążåĶRbS Š ń[Ń×néDĪ\]$”™ģæßpµ“‡`Ķ8—ĆoM Ø,žĖ©ŗkŽģcS3ĆoʵXF·ÓÄQøn™ŒĮÄJŽW35IøiōP>5‘QF\ŚĻķ÷)ĄŽč5¼įŠŗ|ŽznX(Eēäįį›A²ęĖśĻ¬Ł•{r±÷hņåHā­?†]6N€*ńÕāmż¹k3·ó]³~C ¼ģœ,3x&<· {†=ū%£^rcĪϬ†äIwš8~ŗvõōķķ~=ÆÓ·VU“āzn©¹6™]ß Ž€„`Kł„V˼w]+/įƒāČŲr~a W‡•Ć¢"D|~:Dßõ×r§FÕpoéćŗœ³Ņ¾Sˆ–®³ŹĪéŽ(K:šZßt™ĮBķsÓrć(Pa Cų»27—8VōœXÖŃ(žąŁ–=õŸY³*öå[÷“uæjHå GBęå•wIŹ=Hö<¢ŅöķdBńZ#ųeŃÕć*¼5¾­é8¹£Æł– G½NE\h˜’·ŪĮ FÜY<­Uq|Īø5Œõy j®J±„š¹¤.bØpE… ^Yt–ź˜)ģkäó*ģŹŃ šÜ˜ó³ė&—sSŒøōƒŽ ­į\A[ė~˶DcaN_2%_¹`fmI&žß²šī—øė$dŽžé%ć!½ž›£š&‘8Eŗōķ%’Ē‹’Ÿ ŌėŠypæPŠöŹgĖ£V©ĻZ-fµd×·^€ķsÓrpńÓĄĶ•]²co}Ēf6īŻ™cßYõ›2Ænl‰µ„&Į–&tzoŠcØxvšl«!vˆžtu‘»H6X"#iT·µĄ2ó)r‡VK˜tVM"NóÄńŌµY",T„M#qŪčĻ2µeŌxžĒ=7#NÆfMßō¦ĢmŻŪ2Ǿ³ė6e^ܲńśH¢P‡&Bķü2čė#vl°;F„}³¦ŗöeŻü{1§jUÅģ!ü#=ŗÆ’IģĆŽÆ±“¹1ēgŠÕĒf·œXåćqś"D“[6H“0ŒĆx¶›ÓEĪæv ™Ę5/ūĢŻ,’o§ä$ŠS÷əœ•^ŹšjąŽó HF|Ś~1ø-^a5q‡÷9=Āóķ#—ˆ˜ĻüėŹ¼‹™Wr­-Ł*ÄBNƧb”V³fUķĖ68²—(}¶]™H÷ŽÆ·c‹¬ź2²hį—GY“ƒeŚ5-ķvC=ϳ&DžB >įXĵjp¼PĮ÷%®ėYoT»/1Œ:fDÕ ÄҒŬœļĄ¶91ēgŠ×kŒµĘZā©™f±­~Łžė{ĖnN€„ąv½gŌTžˆ±ĖD\ģÜĘ=#öW€ī¹]«÷ˆ(-²>] +œtćķŽ£…&W€.b|ŒóAyl Ø(K!įŹÜ0*Ī+%nHÆ$fOWoüĪ2$†ł»jā, ‚WÜ„Fźvc. ›‹}\®Ń ‹bu+‚5³}1iĻMĖ&ü.Ė–ĆgpØ®(”!­ ōÖ̱ļ¬{šŁ•;sœ uģ»-™łrĒ»R±„{¦u»D ŗ:ČŻ¤,Ń©okl©›©"œ¬} bĶāŌ¬ž&Ū0AnEŌÆ¶ńÅs³ —†=ówĒ“Ę=›d’Ü ™>dKņcĪĻ©įŻōG’‰8¢ÆĀf÷S–Ø<~ZC}Z­ŒźFNk̤Sāxźń¾\ڼ¹µdXb„P; i/ć6¾Üų7:®ŽF„`Łč¢ī§<7CEI4{k–![jX®ˆ°#,į Vqöę—<‚m\¦÷ͤ.Ÿ’Ļ~„DYO;8—i&įäl9n ˜S“T”ĀĮ]ĢlŲĒ=7,$Źš207 K…§6X÷ŲD¼i­™\ū a$¼šY³”Ż”ː»D ŗ:ČŻ„²Ąķ•ö²’ļ,*Å09ežŃ%ž1«BÉAN6ŸcĖyŻé[­s*§&<ģśzŌÆZ½²¶sō„•}!%_HI –üi†=t”ŪŚh6‰ue9I{nŅtIR—„6=l›ę¬GM[–ü„T­ńhł‹³ßń@"t#.˜2OD™ µķ'iJ‡ŚÆ 0źI°“Ÿ ń äBZ!¼Ći h±N„“$˜ż#D³_‘H„LŒp—}=j®÷ VqؖjT[ŹŌĪĮdˆµ‡hĻkž›™›Õć׋ʮ¹6z-õję)l‘•9Ėä ¦ć5ĢžĀf/n­™q}Ķ*ٽŪtĒ&Cŗ^P‡£^P…/Ųé\›]Y œŌ8ždxD›ž¦AÕį{ÅĶ@l“﨨‹õ2ŸdhW CųÕ½{ČŪąĒ)Å®Ąˆ<ÉŠĶĖkĶšį‹«O ?.īŌFSh©ZŻQf<½!X„ęS:„]u«Ė;?fźŻ[«un­ÕŗøB·rOEĊI¦pų ģĻŚŒ‚/žG•Ō€”•Iņg7˜˜e’|gčyŠ€æjį“§n] •’7)„k(ķUzż²ZIĀÅŪŗøkunåsÓz»÷V0€3&[2‹šs=ž½„1Võ_΁­©Ėø=yŁžØŅHØčR­ ;†9VŠ£ZØ„Z$«B•hQ­ T -hR”Dh‘Ø&Ž@ąVIKé¹é½BŹÓĘź*”J —“J9ėÅ$¢Š ws¹“Į{ęŚ ­9’ {*qČŚøß˜lŹņĄ>¾<ģ’‹sÓz–ÅŅ{mx;Ś2sd„“h¶×}Ü­Ģćż&]o5Åp7·X?~“›Ļ[vŹé½h‹źZ’rļ&\¦Ż@EÄeē)E9N?lȓy=‹BŹK:šuėćĪĻų·=7Ā ķvŌ[’X€¤ō’Ōs £žŽ<ģ’‹sÓ|Ö<ģ’‹sÓ|Ö<ģ’‹sÓ|Ö>Y2Z:”«R•jR­JU©Jµ)V„*Ō„Z”«R•jR­JU©Jµ)V„*Ō„Z”«R•jR­JU©Jµ)V„*Ō„Z”«R•jR­JU©Jµ)V„*Ō„Z”«R•jR­JU©Jµ)V„*Ō„Z”«R•jR§.Ó’ķ’Ä8 1!2 APQ0"#3$4@BRq‘±š5a”’Ś?&Vš[Įo¼š[Įo¼š[Įo¼š[Įo¼š[Įo¼š[Įo¼š[Įo¼š[Įo¼š[Įo¼š[Įo¼š[Įo¼š[Įo¼š[Įo¼š[”n_O“µQõR”įN“µQõR”įN“µQõR”įN“µQś*Ó­= ØxCÓ¤ķG”|AD*Ę¢Ö©ĄW!U­öS“čåCĀ'j<£ć‡»\BČiˆķōr”įN“µQń~øŽSiT×¶Šöū©œŅĪžŽT+J„õÄó„ §†…ZUŠŠŅ­*‡čPŖBØU<*×*Pš†§žŠB:6«b_į?ŁlĖü'ū-‰„’dc~źćP 4 fjš‹\9š5Ž=½VĢžßųœ×³‘ą {»Ś²Ūxä*tjŚ–«Æčō åu”'j<£«X\™óB6„@"ˆ6šāyM+mØFÕ3ZŃTMTpÜ„cBµŖÖ§Ą sm(õByB­¶…kU­E)Š6Å:*h\™ŗ“+¬j±ŖĘ£åI@S#.M€PcBč‹S iOŽßPš†ŒkžėBłH²ęˆģŗC’‹ ”Œ:VŽ¾ĀˆČŠ·Ų·™Eˆ‘®YƒŁ*5Č0{ó:WpՂšYÜooŻžK7Ā“–ł#ŠŠė‘6˜7=¼¬盬ōžĖ8ó„AžtX&;—nW»ž–HųĮ˜ ’%±», ß/8pL³–ŽB̘ÖĢić«#ĄT¦^CVg’‰ałˆ²5i”ńÉŚ(é/)¬ šW\O)œė?EŚ©§EM&oJÆ4Ž “:ü9Óš.l@,CwAX¶Y)Ó ÄNÖ4ČŲŹd$ YÉż…‘óģøTu’ Ą5“ 2ė1ń4]ĀC@²üc2č õ<§ēĪ“¹b¢ų‘*PŠų¤ķG”tƶƒYg·…¾å…Ē\G)½Či?r›%­FrŒŽ*÷{؉-ŅnÅę™Ę’›[P·œ„ĪŖiØŃü*UDŚ 8RON˜•«tŃµR>åzk$ŌčóŖ£–½5œyźT¹ąHóÓįßÕóY«¶šįįa„įŚåAiWĢ/†š×Čł’…bß·ŠĖ»¬ēõu’Č3ś’‚§ć¢Ģ’4ųj™‘_ī‡Žr’JŽŖāJ‹芙‚fī³Å'j<¢‡)¢ƒIM‰ŖŖ„żķq¦÷!¤üiRSasĆ/–jk-ŅnÅę™Ę“öEEnŽįF*ä“¾Š¹ŅŻ%ķ\•h4ŃŖµr4ŖŒŃŚĪ>ę„CĀA&ÜĖ k‡EŗÉ÷•0æĄĪßŗĮĄž–‹Épļoį¶…bš§%‡L‹Y‡3ł•“f,›Ž_ āś» V.0ųvŹÄĘb·O‡?U+5mŲg/‡±įйcšŪŠģŻ$,‚=¬=iܳģI‰“k'”…~®VGśū?ÆųSš³?Ķ>Ųdx`óXĘ˜æ ¦y”MUh‹/ 9] S4V’ ØņŠorOŲU4‡æ\G)½Čiˆķ@×¢ŽPӍfģ^iœi?aÖ.ŻĀ‹ó5Å aķŃż«Ķ7·I{ §TS{ćI»„CĀÅ3įą¦fR39ŚŅ5asņļ4@²õń”­‘C–PÕmŖ|48l‹ģ,ŗ½I’¢ĆeX ÷¢©(żś¬ė C»§ĆĆō2³OČ!eXŸ•Ę ·ŻM‚.ĒģÉV³Ū쳬Fō–,—®?÷̬óņ Č’äżĀ{nYضcąē¢Écāwī¬č‰q>jŗĀūdįf8ŗOk“_QBˆšIŚ(¦÷!ʒ µ8QyØ;õÄr›Ü†˜ŽM©C¦“MN]E$i7bóLćIū±včī_™¬ķØG¦öčžÕę™Ū£śµ;”UL+ĖIÜŌØxCZ{®«Ŗåa0“bfhhčˆ m«>p1€¾Ā_!™ć¢ÄāD`”üę…}“™Š¬&5ø¦•Ć‰šöT1¼°Æ‡©ņEfgšJq-uĮe؁ˆĆ±žhį›ó¾Ė‰ b_|¤¬Œgūę³§²MœS^£˜KGŸ`ŸvōjžśVŗ|7·ĻwšĻ0MžŽ•.X»‚Ū!Q–a\p#{‚/póᓵQŅU“TŃńܾYG„1¦÷!ʘŽ®…KQŒŃ@Ņ“v/4Ī4Ÿ°ėnŽķM4zaøjų._,ALm£Gš*t¢¦’Cw åŹŽ;uÕ4ŌØxCHiø*„ĆĖ#‡ĖXĆų”)Šaœ(?²“*Į9×94Į†m!įM 0ÅCģX|¾FĢ1%¬¢s‰*ā®++Å: h„õ곈-›u¾k%›o BĘĢēÅE'rÉ1…æ‡TģO˜YŒĘ‰Ē­VQ5øV±fN/ŒŖõYncųaŽ=TsĒ#mr8< ł`NĖņśt`Xģ­&0œŅŅV²ŪB9”žź\K„ä£#ĄMŽY XlZnÄüĶhc8 0š§Ć'j<¢Šå©»Ēˆå7«µŸ…”M5ҊƒY»šoOŲu‹·GvÆ5 ”čŗūS”n-)»Å#ķšõŌØxC@hj™™JūR_uöœ¾ėķ9<Ź9‹Ļš“ē#'ŽŖ‰rÄbŒ¾ø“ÜŚ/!b±;ĶX\S¢eØę%ķ¢q%Ĭ4¦T!™:‹‹2é†Ę:Š)±¦ADī„1åœ(ńĪbūMČfÆ÷OĒœI:W@ś&ā\Ń@1[…ᓵQÕÆ-MÄū”;JŻjŻĢĢł.*żķqŠ9؄Ą­Ę­Ö£3Bł†©&k›D lāVóT²µĶ Õ’“5o6ˆŹN”r= dåØbZVóœĒ\4“µQM}©“×”Wµ^ŌehN=÷/-J‡„5ė㧌}ZŸŲ$ķG”|<+¼07B§=t¦U>Mh©„A·(›kt—µt¢¢źŗėOPš‡§IŚ(ż­%G™@SGŗŠžėģL)šŌtFŪŠ‰Ē”Č­Š,Cü¾©Pš‡§IŚ(żIjd č\RÉ^ŠŸ±3¹< ’éiY/Léeś$ō«'-r²2q?Ó?ŠóŹJ˜3 żōuĆ÷”Ŗz]ÉXģæ‘³¦ĒHź)ƒļ:ŸxÄ`ū|sŹ¢tŽā~ūÉZ#5°ü^ÜĀžM&¢½vĪ’'U’•#-‹žsüĘ\m§¤Nčźæ!…}E}&XT‘æ¤Ēf?Hź’#:rkThšp‘Óäžö®żQÓĖ@¦™}ņäÓ;‰ ė^/na~ŅV’iūI=’gĻ:¤Tē¹£¢$ž©{0ĄK¾X\M4amÆgQļ!‰}BŲĻBŒQö-Œń¹QFLVÉA­…“"ż‹&Oé‹$ūN OŁń!c¢‹¢ymRöĢJ¼^ÜĀż·#įGĀ… ± ŽąÉa! % NŊ˜½Z‘ń†žÓĒ©Ē]Ÿ±ā³į>E2¼³CQBUāöę÷ņ®!ķĢ!ļĒ=¹„=ųē·0‡æöę÷ćžÜĀüsۘCߎ{s{ńĻbŠ(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢„Š÷ć袊(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š+’’ÄL !1AQ "#23aqrs‘’±²0BPRbtƒ”4S‚“¢ĮĀį5@CŃ$D`cpšń’Ś?’ā ‰ćW£äŸMČ|O½ļ$śnCāxÕčĒy'ŹeŒøņś.CāxÕčĒy'ɰ—•§S]ŹĶUgšÄ}UŸĆ*B[GȑO8Äø95Dåć©Kž©Kž†ÖŌ»M/WH“„˜ś&CāxÕčĒy'É±Ķ¹ŻŽWŻæq‰~u=ųŚ÷„÷DČ|O½ļ$ł69·;±Źū·ī0‰ $“ÓŌ„»qżN[· ·+8ÓīĮ²…Tä>‰ųž5z1ŽIņ[“ÕüŪŲå}Ū÷#¹1}Üzü‘ł,˜²ėnæŠ7§;1}ŽBCāxÕ¬Ūm¾(G×üA]—üA]—üQ]—üQlø—Qė Ōc*Z‚3˜śōæāˆ!‰†ß#(m`ė šiµhRĄÆKž(‹,L4ņ³„.ŗĖ/>ÓJе]—üQšeÅśØpŻq-#ÖY ®±ų‚5Fœ äR G$š£6¹ŽIןmŸ9Grž8 Ā TćžØŲ E’a”ģ Fį=P ¶für¾ķūŒ0• ¤øGLMcŖ?¦1Õ »)&ÓĄ“š‡&m¢^UY«Éā įÉÅ’ģUŻB(Ģ“ ń6"ä$q£Œ4ąö (°ßŠ_̶ruBå&SE¦š”‘CHćf§²Ź)ß)c0t¼²cj—Gb6-!³Š$˜Ü¦7"2 śųŪ*#ÆX=‹™5U=QKjÕ¦ĖD30ÕĶ(Ųu<“š‚+aĮ±°š˜JeŁźk N5±b®(K‰Č”\sn¾ŖK3µÖ¹„(K ¤d©¬K>Š”ÜĘ źõ0«*ÆĪJvVn¬} [–²kŌ”©›‰Ņ½ŽCš“³;µz؃+0ÅĮyJ?ˆJŪXZx#Zļ$ė›”fźŽµś‰Ó–•E” §:Ž“Æ•÷oÜb_O~6½į=ŹÅƒų•ā:ęg)¶2å+ģœLrv¼a6ÓG˜¹|(ÄܤŖjµeQȑ¤Å–‘mó»}[„kߘńц„½× T³˜gT"VYJr«:Ž“®\»ĀŠŹŪ™ŠtĆŅÆ¦Ė­*ÉÖČ|O±3. 5gE®( VŒŃ©2 “R/n1¹łĘäuÅl .Š$ć}śÕ¤ķmńEšq†˜ŌVvÖ6üŚb¦4ˆjŃ«ˆ¹X¦¦r)6xóEäŌŽqKr¢ŅRjŲØåó~!ö¹-åfA6ų †įÕ#dśÓDƒF—7“ČŖÕė*zš2d… Rå ØŁW՞9ż“¬w’u¶FStK°›ƒh łbœ™Nųefč¾ó¤āc›s»Æ»~ćüź{ńµļ īV:ŹŹ(£ķ±O\5<†½–“k¾6ى—O(&+*‘®)ļ»āŃ rv)¾4x†<_TųŽ)Īe}Ń!-™ĒŞ A†ŌRäҬTz¹ńĖr—āń½Ż}ŃXĮķhe'¤ßŠrg;M)CŽ*MTo$ēÅ-2ƒE4ąUŠČEqLœķ)+zىćV'å—qPŖNƒ‰”źn¤ŠÅk~˜ßUŚŚūqT8ā›PÜā¦<ĒÆč€ņF¤šŲøŃ7¤ā åbX~x¦sGtä1:Ė›[Ÿ¤[Nķ&¢v»:QC‡üŚa¾1Si’¹jʹC’¦%ę“ę+åX¾¢rŻŗ&Ļ6ČĪba‘ęP|¢_•yæÄöµē×rIQā‚”¹Zķ+¢%„R^W@§y“d«Ģ,U&!;P—>®ś¼’dšĮAŲ¼/C£*N˜\œą±„% …XfWN7y'ZÅrj‰ļĘļ8Šõćc›s»Æ»~ćüź{ńµļ īV&ēp›z£ęō0¬ˆćአ†¶{īų„tCŻŠo!ń+ÄqNs+īŒ\֏å8šg«³ż1±Ī/æę݊WšOv,'O²żuŒW-Ż‹ņxÖČ|O±ŪCŗ`ź{!ĮŚāä.-”””ƒµæ±"{ÕL\;k„­|pUā„Ģ7d]½’1ØĢ:…¶N@ęˆvIĶķŻ’8ń?ČL#”!µ¤m¬QÄžæ(¦‘¼­ÜŗH¦˜«Š“£”œšw(1„łÄŹ"_•”׈b˜ć0ŌŖŅ@qµ/TĶt0ü«ˆĮŅ+“„ͬßgͳǖ~iĀņZ*ŌŽ)°\@ĪD`ģ §ĄnmąŸ¢YŗÅłį2Čß&Ü ’æ”L‹¶«¢Ö‘Ln0«•ę(eIĶ Į³źu“d+ķ) įYdķĢļˆz3Ā\IŖT+‰ŽIÖŚŃ|0š5 +ܧœā6<¬Š¤,YZ Nc‰ŽmĪģr¾ķūŒKó©ļĘ×¼'øÄ“+› Õ4ŅünÉ`ęŅVѲ·—x@³Ā/́ņ‰Õ<źŽVƕÅW6)ļ»āŃ rv)¾4x†<ÄÆÅ9ĢÆŗ0jŽB»b˜Ó0Śm.UvČösÅ×āo_~)į’”}Ų°sŗYOu1MJż«e¤,Qi6T1JJ"żQĮ^,ša‰M×dó©Gėśkd>'X÷C®7C®7C®.¾[Ė„ČĪ`ZQŹ"V™B„KČ!t%:£œY£† „V7*['¢,8,šŌĖw)„Z膟IØX¬=ͦĘ!¤Ŗš¤ŠÄŌŖ²“į§Q f»R•n tĮ}C,aNpxKj&«R“:/ mÅõ[ŅNH“SS:Š['T Ż å—Xv\Ź6ŲCRĻ$ŁG ¾5d6¦ M6zGJ« :Ź›’MC)"Ńõń+.@ÓvŗaN­6ŲwwLÜ1hį[ēHYk™®ME5p›)JAŹVrtb˜T¦Āa·­jĆM?ȝƒ©ŠbĀw±;É:ļ¢,ķņ›4ęĒ«‚ef󺁺ćµĢJø8IL7;0ū JR¤Ųn¹ńŹū·ī1*4ŗŽüm{Ā{Œ3_9„ÓӟGqŁw\Sˆu“Ō^b‰•}G£Bj]Ér§j‰„E1O}ߎˆc;±Mń£Ä1ąž%xŽ)Īe}ŠÓعh!Ić‰y¶ŽĮŌZÄA4)Łž‰jżIIŖ:#g;,„)&&/ŃEVȦ\S|Ņ» CŲ9Jٰm£’œjR[™VųŚ÷+įą1gč‰O“]M ¾śĆó«ØČ cnIµU£eĖ:ىćV)³[;Y¼Ckyn»väŗØ),š„ĆX®Ŗź8cSmA)Ņo0^uVÜõŒ] y{†ödš˜š'|4O$d„#44ŅJń¼£Ŗ7”uB'eŠ©eUTn|NHØŽÕéā‡y“Ā9Bā‰yÄ ŖeĄ”’ļŹ8aį†lc ó£Ā"[EØo“ Z”pŚB“pELQ¹ēŅ9ĆaÄ1*„Ͼ¶C‚Ņ Ķāš.śB§Ų.­ēh s Föä[ś3uõœ &Ś}Vص5:†ėöŽe…±ƒt…Žč¢U\ę-Wizå§õŠāw’uĶĶĖåMŹAČ”¢ŅĪ ^sGtŽ=|Æ»~ć=_G~9q¦`xLJĪż’źG³žóK mbŅT5ÓßwÄ11ČŲ¦ųŃāš¼Gē2¾čd¦ÕI7MRæ³Wś€AŖMątē2¾čŌć“™”3ˆL̲ź<äēIŠuŶČ\ūƒkG«ķRÖ¢„ØŌØē:ىćV'XY!. BŪ—©¤Ü’cpøÜ.7 é]0 €lC²©R\M“e…¦]§ C%aęČ@Ī`“²ļ ¶źJ8!m²—Š7j`4ŗdT öā¦HĶÉӒmbŹ€Čal*”I6’F˜;Z®0…¼Õ2ŌE!ɹ]QÅ>m/Fˆee•lU ƒ Aiä‚3Q— ¦)aQ½Æ¦uōC(]ŹS2ś9õü#Ńtm‡ Lš-ŅžćN OĀĖń³[L'ÕhRØuJf­”Žcw’uįég–Ė£ĪA¤ĻĖ&d}£FŹ£fć²ēŪlž‘żE¾šĒõŗ+L-ĪCf6‰I‡xč˜DĮ`KŲF¦\ń%ź“K§£gKŠWPžqji¤Ä©æQYÉÄcmS’‹ĪEŻb.ĀRż*¤_„åūQżA 䤘¹Ēœä“bbF^Yš]¦ĶtėBvŅRōc~{šŒ?),ć…åŠ)²3Œr’“8—›Š “œĘüļį˜i.»im©#j: b ¤‰‰o±s7ł ½*¾M”ņŠż'^K&µ·e%A4¹gG.Õ¤“ ’”ßD×7 #Rm.“™k¦J½4‡¶į(Iæ|;»P™vŹCŽ×TpšX!*£ˆ£@ź«O l’‹Æ­£C—åĀÜqißJhė=P,8ą@"ĶŖZ;/;¢ų.Ė„jˆ¢®OČB%ŚÕŚQ©‚³k:ołžØ˜[¶”ŃX))ŹFä×Ŗ½0K©)P“›¬JxsDń ÆV+²sA]„Śm"€‘²7åłCŽ£U˜dlH ŻŁī éŗµ6Š¢£M4ÆUń,m«QVČ‹Ź}Æ(ļ$łw0‹é²ģĶČ21·.“t»wńŸķīĖ2źß*®3~)ć`6:N»'‘ųž5yzØTń˜ÜžŃÉķŁ&·ŌT˜Ü|Ģn~f7'“cr{F7?˜ĘēóÜ~cŸĢcqŌLn~f7˜Ą nŹt ¢Šl):j Q2^|£¼“åB –rå0‰¬,M±ze³žT@3 Oͼh†Ó^>zeėŻueJžÉ„‘T—é…9$ꤜ>néQ°iIŅŅ’Cą¹ž„V(œź8]ŲĀ&šŠ’óɽ,£r“Ƨ® BÆßœż<¼‡Äń«ŃŽņO•*L«/”Fņ”¶t*Pö ’Ų½qčӉOĢŗ–›Ns(©&Ī֌źöörÜź{õź˜|ÕYo:Ģ;2ś­:ź­/!ń›‘ q ģī'ŪToØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķFśŽŌoØķC»j7'Ī’Ź’Ä,!1AQaq𠁑”±ń0ĮŃP`į@p’Ś?!’䒣iFŅ„J6”m(ŚQ“£iFŅ„J6”m(ŚQ“£iFŅ„J6”m(ŚQ“£iFŅ„J6”m(ŚQ“£iFŅ„J6”m(ŚQ“£iFŅ”2žÆöx»¶ßģńwmæ•w„ä=g!ėro‡)ė9Ä’?ź±wmæ¤VŌa²w§źQś?„lä­2ęB (ŠX•ćś›ś˜¶ä%eŲMÕ"īŪ~'ĄĒ³O¾ķ„x;׿R.ķ·ā|{4ȒĮRUü³ź1Æc5ž’ä"aįø7ų®\#/šß»mųjÖaÜXF¹^ĶĒa+†µ¬Ź$՗“±<£µł%ų3Žq%²§”ÓźA²GšbU—“-É1k ÉzųqėĶ0ļ®;Šæ?Å‹SģļÜī?ÜļÜģ·ŽĶ; j"åŁ·Y³: •=‰“;[ņ|k÷5€¦Æiļ9ĻZ”€6±c2ćԘh•Ą_ž_J„Ė“¼"Ņf ;v¼ąį’ ŖķmśŌĀČä‘0ƒf–sR}s†Ž¬é0øū„zōzšÖµ˜=®€"xÖyš §7ßåķ  Nā Gr:‚Ł?2ž8F!ķĶLx)„į °˜Ź\ߨēé7‘ĻĶ‚J“ųÖgüI˜iŃÄņ‚…cœ[»{ž†£NŠ—ö–īČ.īxe4CĻ©Yš®ƒS:ƒ ¶ĘÖžį«ś:n·mp)ØĶ‚t|ä>M{ĄøN9z•F2ź“Ļ[Ö£Ńx72<Ļ\øĘŲⵓIīa“ęĒ·FŌ·eöOŅPĄŻʼ/O_!Š»źL“Ī&:žø‘Ó3ܾ}2^>ķ·‡®P–·, ’Žu)}Ą›ųżš}÷l#ĮsĮEš¾$sZ£”õ©ŸŅwżž3Ō‘Ć9¬z)å>ffNo”ČdC*Z~ŽS/-:”•éŸ”Ž–‡Y»Ī„r&1Ķ&ļ…Ź<“щ‚É^}L؜ĘŌų“Ä+Ž XšX›„ŠęŠ>Œ5“ L” Š”ķ«‹s:į0¬^r¢d¦9…JuĆ/jƒĆ$cż¦GEĒHĘ1ējEƒeę Ązň°{‰½aN5¬WBXāXi¤~ī¼b^Īß +©¾£Õ<®c"šÖÅ0Ō³ó<ąóNäZķBłvØ`šÄĆĮݶšžŅ¢Ä1Ļ>ŠQ·É@j5Ÿ?š‡§sVĄĘøū4ūīŽŲ^8ĖzVīM™~C‡“Męiō°AŠ›­æ3eĀų SĻ鶝’dr…›J™dįћļ¤žīĘPp|ķ8d¼CÓŽīeņ€Xƒ&-šœ U ‹l±l|†=YDd×#=””ø“\Él°ŅĀ’,[«P¬b'LśėoN%ģž·ōwKŽóD%X¬ei«ƒ*2åĢÅÆįé⊱‹”•yä 1*¤ćå9‡¬ē˜DŽÓAæ*ē‘~WC$ĖÕęL=+,mā½Ņ5Ņ·^£ķ+%šļÜąÄ ˜ŌĒ„o /Xķ.bīk£vJT¦¹ŃŹ1ā›ķłšåĆ8Ģ0ø˜W0¾°¤Ä#b|ĖÕ0€‹ś²TĒāMiĢo“Ā毶š‰<įQē*puk0kSęü£e„±[ՏfŸ}ŪĮą»YÖŚ:ž‹?(/7Ņ<éņ°=Ü$Ž Ö¶ć§±Ÿ>;žĪ+28X„”ĒcƒŚ7ˇ>쀂€Ó…‡zB]ząyĢ°ą–œ‹ŻĀ‡Łx¾” ʰč[ē…&א =ź,_¼ā¾·ĮāsŒ'„Ēč\ ®Ń¶Ā|3nžœ¦MĀs<’\W‰3­N°™äZ¦yꢕsÕ!ī‡s§]W‘Ņ[ŌāĮü›K–ŹÜMŃ KåZ¢‰‰”§v= Ķļ9 ĶfMƒ¶p8żŲĖąJ»Qe„EŖ•+Ķc¼ Ā Bƒ§8øŅ“¼¶Ąžēs:>gI‡®üĖ7™³…ŽīĶņŹclƹ%WUCÕyKFn1ĘTFbTg›Lµ5“ŁyĖ[Ņī”:0¬\æ%ägvŪĆ^øz!Ą* kĶ>ū·ƒĮö ž@-TŪ=ÆĀ p#…ĮŽSŲϟ’g™ž}ć|ŠG·‘G(™ģõńćģqš~?ß2/*żFaå‡éą·ųO¾_Hr/¾ŽŌ“<13$|4ōg1-ĻĀG‹ysz°Öa“nó}DęĮīT9Ūk„ה4`–)rę—#¢Ōc Ū‰“Ķ)ż“÷ē’yB{.c ? Üe=‰œMršĶqŖfoī¼$bŗ9锯ōøh Dކ³ŽR*ęk» ¹ĻJnĶ–õš9ūFS ōy‚•»[ö˜ÖŁ3öaŃN³˜¼‰) Љ-Ķ‹Į…“y’Łģ«-ņ|āl<€źfJ= y<;¶Ž fņr„89ƒ.Pt"īƒpDnį”ź_¤rŠ=š}÷l#ĄżÅZ OPƒ&‘pw1ä7ˆ›Ż4āb¼»Ē)ģgĻŽ’³ŠĢ‡O¾Ł¾“ó’n.®°«ŃņŠ›S3¤%Ē·Ģ’“ &VA'Lõ,ųąiĖ¢˜{ÄØŅ ¤Œ6,JEµč0@Ą(Œ ¬£Ž6éā‰į“9Ģ*'¢'Š ¤Ł‡ļi˜œˆ• ad<„ķČŻbWŲ#ŽĒ³03ƒ7 4j[ŗ<™‘¢^„”É.l½R—C‚wóXHH~ĶMœāaó:Ÿ”Æ(rX¶:“Ę*ģ­Ki ¶yžÄÅ,{¹]‰cKŗ†ń±%¦‘=›Š ,ņ¬ ÜL,¾l=åGb6šĘZ`Üf8[ד֘¢u~.:P ć­V%o„Å,Wöz̊¦tƒ8ū :ēG8oر·ķ·‡®0ž6Āø®/Ė. zōP)óŗĘÆ?b¦#ƈÜĀ=š|ˆ^Ž…ņ Įź«ż< ™ą¬¦²mņy?©rĆįŃÄøå=ŒłńßöqYéąŚ7Ž_=ą0Dyny< 1©E‰-¼V„:ī Ģw˜½%ē@Ģł8æ/ߞŚcV¤¹’/—(øæŖ¤ŲĄėŽQyąß“µr%Ü5c/‰ŲrŌō+ÕńÄĒ4P”’ł ¼K°ß3óė<×PzÄZ½@؁k*i“+šb?Ķ—cį~qÕ\WPK…”˜RW&ģßRĆŃkåŽxĮ-»{*«†7¼Į­šūōBčœ9UčŖ”ćFq° GFĖ"U’ģ¤l‰nŁŖK¦šŅrœĒ÷„ĪL«ūK‰aćbUØ„U"Ŗ±XaŏI޵ŸhĒ“Ų~T_‘¬n{ŗ‘YaÖ_Jē+оpźH×¶Ż>RąŠ—Ć»mį%[g6æ»Ģ9†3aūńūŲÜž>žE¾‘a-*Z¬=‹.4ń±eJąŹ{óśL=¾+ŒŽžo°×(ō‰Œ™VIæ7“7ÄŠńv{/1t¢Ė®½'6X`Į^x<-$Cż-æį™NZŽ/Ž% VĶyX\µ>&S£ėćy3ūņkŚĖŒÕŲ>›ūµfĢ]ä„  .ŲiŖĮ•« ~s–œKÖPG(C¶ˆ„qĔFœqĘD j!e HL Œˆ,ŃĢĆ@£Ź™“`¢2ŠÕbĻU¢ ~VųÉĆŲc22tL Ž{HvĄgģŃaō&ąŗ£ēÆ œ}eŠØpīŪxņæÆøo (5S/ˆ!½ Ē vycś‡āŻą–"†īżHŖßFæĖńyŽŠY·p`/”ĆŽøi1O" ö”•_}.ęü$z^öē,^Bó0å¾™čw1©£ĄQÉw¤ō4˜‡Ϥ 3Aā¦-°x,ČĘb …“.§\§žųé” kį÷ÄĆ#šųq±’:C¹@óоõ’VŹą+"e¶C5ÖC¹Ž³ @B’Š™»ż# Ń÷Eč0S‡£ū‰×q1õü3¤•Ų•Ų$é''µ!M.Īp=aĖśK5vsœżök,’·‹˜wĪEØÓžŅŹÕjf{Į®XƒŃ˜Ŗ¬÷”ū,`Ļń÷mæ+™Ś5§"Y4q÷Ųr†K “©“š­5&d²]_ųˆŅ@X‰²cRĶoȌŖ¹bŽ“J¢ŻčzŒ&Kp'«>Ć/x˜#”VČ`ŽZ–_łØ»¶ß—#ve 1öęĖÉoŚķϐjņ%„\®'˱§ž>ó¶*ų¹@°–*~ć>at6A‡łø»¶ß›ę-Į%ė+LÓ«¤`łÕdŠ!QÜą|”·’Ę\øhv4~qrmžqņF¾Łl ó‘wmæĀ ˜—Ö&=›ąĪZ(žŠō!ĢÄūĪt„Æ¼ŅæĪÅŻ¶’g‹»mžĻvŪüؘŁHĖééééééééééééééééééééééééééééééééééééééééééé~‘6’i’Ś ŅI$’I$’I$’I$’I$’I$’I$Ūm¶Ūm¶Ūm§’’’’’’’’ü²Ūm¶Ūm¶Ūm¶Ūm¶Ūm¶Ūm¶ŪēŅI$’I$’I$’I$’I$’Iå–Ūm¶Ūm¶Ūm¶Ūm¶Ūm¶Ūm¶ß:’I$’I$’I$’I$’I$’,¶Ūm¶Ūm¶Ūm¶Ūm¶Ūm¶Ūm¶łŌ’I$’I$’I$’I$’I$ye¶Ś(Ūm¶Ūm¶Ūm¶Ūm¶Ūm·Ī¤’I$’I$’I$’I$’I$ƒĖ-¶Ēu«óm¶Ūm¶Ūm¶Ūm¶Ūm¾u$’I$’I$’I$’I$’I$Ym“<¬N›m¶Ūm¶Ūm¶Ūm¶Ūmó©$’I$’I$’I$’I$’I ņĖm“§¢”Ūm¶Ūi¶Ūm¶Ūm¦ŪoI$’I’I$$’I!2I–[I1?M!¶!–[a&Ki[|źDʘ@Ź20 øT’@7H<²“¶åćŚ3żż>uø”ņĖŅdŪēBfŗĘ -Nˆ]o¤’Ho2Aå”:īÄøZ$œYļV€-œõf_:‰õŠÖ ± ß<ŗo$€ņū-]v~Żęš”-į_Bå"BpŽłŅŠY¹4¤ąt{É;)$¼ßy` ‹öī!€h°Üāī™RI>§N¤ģŸDu‡ö•½A!%ˆƒĖl–ŠŸ·pĻLE¶Ģ6Hmƒ`žuRDhó™6y$MĢ@² >D[ ¦Dż9ZU€-¶+ $CaęŁ!ó FźE†šłŽØ#›EžųØ: ņ˾Į÷éŪŲDŃm¢ÕĖ>#DnƝ —Ōč¬x Ž^bi/ĘQ–Ūb¾GTĢŁlŗ‘+рæōó|źŅłMD5uuž_Œ€[H<²Ź+ĢŹķńH, Ōų’‹žęŪēQ&Ū¹u·—aę‚ żAå–ČI&ż‚Į%²Y$¦8&Ūl–_:’I$’H$I$’,¶Ūm—F-¶Ūm¶ŪmĘL^6Ūm¶łŌ’I$’I$’I$’I$’I$ye¶ŪDńKm¶Ūm¶ŪnQź­¶Ūm·Ī¤’I$’I$’I$’I$’I$ƒĖ-¶Ūl²Ūm¶Ūm¶ŪZ½“m¶Ūm¾u$’I$’I$’I$’I$’I$Ym¶Ūl¶Ūm¶Ūm¶Ū ¶Ūm¶Ūmó©$’I$’I$’I$’I$’I ņĖm¶Ūm¶Ūm¶Ūm¶Ūm¶Ūm¶ŪoI$’I$’I$’I$’I$’I›m¶Ūm¶Ūm¶Ūm¶Ūm¶Ūm¶Ūm“ŹI$’I$’I$’I$’I$’H=ß’’’’’’’’’’’’’’’’’’’åŅI$’I$’I$’I$’I$’Aõ–Ūm¶Ūm¶Ūm¶Ūm¶Ūm¶Ūm¶Ū>Ūm¶Ūm¶Ś’’’’’’’’’’’Ä*1!AaQ 0Pq‘±@”ĮŃšįń’Ś?‚9NS”å9NS”å9NS”å9NS”å9NS”å9NS”å9NS”å9NS”å9NS”å9NS”å9NS”å9NS”å9NS”å9Iń')±śeõ6x²ZŠxuž,–¢žg‹%ا‡YÜ+!³€ąiĆŒ2] t‹šeا‡YÜ&†fcV`čF„H‚N0uix2ŌSƬī±hGQ¤FyŠ›‘›oZŠxuĀ¶›Ņ‘²3#3Blų2ŌSĮ‰Ļqgm&t„œĄ¤Ś”$<±Źˆ†4Ջ$1;c€ā,Y,x±f„Ļc€į­rOa&čā8Ž#ˆŌ^©Ōč­¤hµÖ%Ģņ&Ņ~†Ü$Ė÷…«/48hwŖ*dr%×”Bū@œēZsō “įt³Õ@ó k•cFĖečĒdŽ­Ź–Vm+Ŗ0ū†;l:`ī,ģ$2ĮE0Ą–6Ń”.Li“I¢0Y A˜/“8ģ@ŽPų„’DC ģ,¤Ž „q 5†Ä!¬fYĄpŪiK 0d…+ŠaˆŃj)¢Śfp6Ė®žr½¶Óø%€¢GŗĆhh¦†ū"^†0§/Tä)Ÿz^Óģ/–¦ŅĮ8å‰ėsŗĀH jÜJŗhĖ¢/²2T1`ų".ƄŃĘŌī§ŌĀlĢ"ųCŒŌ?æŃ¾TāIdA¬eüööCB·kÆEłōbù„³…ŠVÖoڳ°Ü•ćOB`ČzP&"T&¤TŻ“„.“iY!É Bs Ā*I2B!D¦)Ø l! {˜ŪFŠÉܑ²żœRĮ/F‚E Č“ZŠM†×šÆøˆŽ­½×NlINkŠe,¶?ŲĒ!)å>¦HIŗ]Hvéo—ĻøĀ_‘ÉŚhźBœ¾śVˆSœa3ȹfö%%…<}†-å#q•" '¾L€Š’Æ>ݽĆEørXåĒ„ 0ķOųNā}?æCw`}«5ib56ć,f5Éc­+((…eęÓ1Ņd‹ŅńŌ§F“9G2fHJ L­iŖ‰YjÜŚ=č5aŠRŅ`{‚fDŁh¬Ē”j)¤œ‰Llß­Čāƒ‘i#%eąr¤²_’ĻąQpgˆ–=&s~˜z?‡ß÷äs‡M3G!ŽP(1‘ųŁ‹ĒŲO»’$Cה„±] ¼yƒ ž)»-• ˜³$½æß‘Ņ'k•eż“j'®ōīæ]~obWfĶ!IŅi”O,PÄB “¬ ¢—B’†Ūl@„…„ć©N‰%>¢ƒ™Šœ—˜@¦FĄ'c„ʝ8ā'I4Ģ4U¢tnLĢ‚5ъ6dmå L’ЉҲš„Ł^AżK÷/ī2ļÓžöó!‘¦1/-’Ż›CQ¾XŁIš“‘ąQ0cźE_LUiJ_gųżJłTfŅ;yN Nź’,XīälńŚK2[$¹l…ĄŌJ įIš4&«ž;6hŲ ¢Ń%F½7„e¬ø`)Č!ahj—/J{ ^µ½Žd……½K„*Ńd4“0ģgئNš< 伌&ļ‹Es*DVßķ¢`RāP¼Œäj¾£gŪģ…HĻ~£ćļżżå¤Ž`žˆąąĘÉR–}DŒD/v5(†ĄŹH$õr&%Įģ%%ŌVtŸµ}়»zČz-?&ö$® ģY£a°4h”H$oĆ'1éYAD+-ÓŃ$‘„~č½/J{ ^µ£­nĘf†Jo—²i”$B¬£I–…Z)2’GئĪ =Ä2„$ŹZłE²V t%£J¤u‡@I1œņ5ŠM‹>Õ .äÉń‚Iścń$&Ņ5›É›%źžC\ć?ÓPhj4­ĘČJ]>ājPčf¢¶)õŸ„/fFŹŠŻ9"—;ŖX„ó}}ѱ=›5TäN1U™żU” ĖH!„4”±‘ixźSŲJõ®&˜§R •(SdKL[ŠBxbšBfćĆŒė…ئˆÉLIŃŒ"G섐PĒĀĀ^ĀJ°ł™p!NPˆIµ?ߢ+› Üqċ“Ć’R ٲżżČߨĄŹpI²J ^Ś™ų”YxCĆPRŸ–9,·FŻ_ŽDåM2ĖzP=sOŒ}…c”ȁå¢XiņÕIm9žž¼B!.°ölÕTp),xȚ'::Ó%ƒ„…eåȄĄĘ‚ ,-/!µaIÕPųEƒĆȶ“'"(’‰=³ƒ8–>¢}IDJcZ-E4z“cp$Łé ±H0©Čę‹dųėoćĖPŁ”%5Mģ5Ϥ1€žåČ¤æ‘±’É4;—e‰B=NFČB†Ā‡X6¦Ź¾žöżFÜߨŽafĪĀm„2 *”sŠ©"bD’&a )ŠŽfNQĪ‹éŠGFĆ”)Xø…X†”ˆ¹¢ą‘`Ē‘i~“Édē9PĢp‰еÖFLéBīAŲųģ(܇<D²!ąnLnyiõ‰³śÉnõR‰cžāĪĀN CIŠX3Ōó]MįdKCZ1Ń  L·%ŌäČF #p‰Dč’nŚa §ØÜóVBˆ<‚4¤kaźŒ“č8l…3Ģy‡=H’{hµšė;„a7‚°H†3'ōHZbP`ąĀ`œ,(9ńj)įÖwJ‰ŁB8’S¢lŁ.4KčØ('0c ā+L9ČömŽ-E<:Īń&ŚP® _¤B¹HÉ]I]K*šD¢{ĀŌSƬļdķō‘8`b*Īa³q¶Ųņ@»Åا‡YāÉj)įÖ2!Čd2 †C!Čd2 †C!Čd2 †C!Čd2 †C!Čd2 †C!Čd2 †C!ČdhG‘OFŁ"_Š•UUUUUUUUUUUUaŒ…€ŹxrIøcޅ†BĮ}"śæ’Ä(1!A 0@P`aQq‘±šĮń’Ś?Ā ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚ ‚#Óæ‡ĖG¦ ¾I-˜6ł$“z`Ūä’Ńéƒow—¶­˜6÷yx_“-˜6÷‰Œ}¾Š“z`ŪŽf?jZ=0mÖäzMnDGābU׎ģj āĻ®¼3dy BõzŁe¢{Šš3`n®<†š'†½ü|z)ÖłĖG¦ ŗ”¾ėJ ’'ėŸsm— wÕéÜ «?ÜĒ4l‰W^j›ąŅ?äC“*yѤ•bēž–›Ā;ˆ‰ōģę¾7õӝŚDŅT&ļŸNŒ~’ze£ÓĒxXą[§¢–—«÷׈UÓóōFLecĮųDÉum•/±›–śr£.1ņĪ0QŸ‚Ś31ń4TģdˆriÆ>]ż>z5£ÓĒqī‡#;1‰„üŌTīpÉŃk^YŹŅŃjÅ&Š£ÆNÅ„1ī˜-ȹäJTM0×öžŃŲcį䖈iȆ'Ź*œŠ  ÅĻ>tŗ1śUéVLtLYČųčkz/¢Ł…ÕtÉ$8AdūˆGé‚8żō˜CŌ…˜(JčkūgaŸ‡š‡Ń×GȶiĒēĢcö„£ÓŻļį'²˜‚Y!b˜ćŹ4šßļä¼ßÉɃcś ŗ'ĘŌ‡c¤¤מ.‚Q¦šd—q¹EŌź!ē:„­?>c±/!hōĮ·ILī:ū‰żŒ}ĒŌÜŽŚŻĘ7_M!,iF‘ĢHsF@ĀtPʵFtHĮžįŪM‹{ I ¹īWäj4;|!ÆšC-DбŠ–żĒį^„y‹G¦ ŗ9°U^£ w$älW"¤ÄŃMŚ‚¢M sī6f,”‡9$CH%ó 8Ā„1± ĀäøēQ€с¾L¹Cé‚֍y¶ZIä.^'äRśŠ^BŃéƒnDBļ(ü'Ņ}"g]eBß*£lrƔ!üb½`‹^‰EP¦ÓI ½‘ų!D#Eb>āŽ¾Eµ PŒz9{BŃéƒnŽö7Hˆˆ3 łYĻoqŅ¾Åbe¢p„}9'%éYJ8üzz~Ģ“z`Ūā6ų'ž“z`Ūā3š?=hōĮ·Åiē­˜6ł$“z`Ūä’Ńéƒoo©JR”„)JR”„)JR”„)JR”„)JR”„)JR”„)JR‰ņ=Con[īĻ’’’’’’ņt'&ŽŻ¤AAAAAAAAAAAAAž‘śæ’Ä+!1AQa‘q”Įš ±0PŃįń@`’Ś?’ā Z'.°Ķ×’łf\x%Ņv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv'iŅv‚Äņś’»÷Ö/£ēžļÜX¾ŸųҰ׎äļ?ć¢&GѰāĆtÅ\2‚¦10|Š5Ż?ŌūėŃó’ėƒ[…¶]ʉ˄čXrB‡°hØ@6āˆģKƒPøsšńŃ@rŗV«ŃŪĖśqżbś>āŅ śŲƔŲųŠJÄ«ŗšgŊ+·—õćśÅō|’ÄŌ ’jƄuįA)24Uč *]Ā›ė³12*‹ÜT g©¾_Ė_ų«ų}Ēę•1[ų;˜(ՑT.×ńFĖü¾ŸóPiiė*(¤ß!ĢpÕ¦ThŸŗaĀ:š”Ž:‚µO¬£ĀP¼Uš•sc‰£ŒN,yÅJ§6``Ī‘’ˆ€„Š>R”6S¬ļÅÅ^/P83åA&ÅĢ ^0%HmP"4§F-o€štž¾›–šē(V‡6gč”rÕėˆ6ŸŃ/Ž(4 ų ė19ĪY·b_„Śgz”nLQ§Žp:źsčׯåī<ŖóŒ4;źµ ńkŻšd‹ū$vw`¦°\dą*”B ˆÓąźiEµ0Źé¼ ”„E t„Õē|  VķźšT9ÖHš… va˜Er„ 6³Ī"ÅciÆœb9˜pLA æ%Ž1°FA·"£ĶĄR Aeq˜“”’āk1”äŃ?5؆Ļ^s¾€ę®Ž°4„ „øĘŃ}x~?GĻłhH Ļ•E‡1°Ć 4cƒ½u č ”Čė”Ņ,”󄾬NšŖ_Øųž–\¬Ē^F£‚‡Ø°cĶ-V½s~ł–Gg„¾,[/”/‹ćX Ī2%£Žź 1 ­wJ•źX(OźČś°×?P¶½¦IrZÄw¢ėĢ7°ŁĮŹNĪ&`Ŗ[«®­°š Ł/”„ oO•öa@z{˜-‰Ą’b,_°£éPY v`ł1‘ĀĒĢ™cŖjXęZŪzŽĶSs~ÌrÅÉ€µĶћÄĘnՕilņɘģJ”’ń½*łńPEŻ”é ö"ķ®=ÉM7eß)Õa@zčƒf4rhŒ6g‰,ĦкiŚ lėĀA_SK§6ņļč÷/ÖA@r‚Lh]÷“¤zUXąÜ.sJ½A.j¬vøÕÕ,ĘwIėÆĒÜEǤR]~ŻzŹ ¬NŃČ`¼ZPE·‡1jOš#üĮφ< f€ŠŲŁ­5¤”ĶŪĪTVxDĪ4| P¼Ē,ģjVhZ<‰lk<vpiƒĮbA²ć˜Yhdß_ø«L^Ż\Ō6C‚Ģ—cĀ&Ā6øY©jkqI‚Ęć1ÓĄ3›*gUćęåbÅHŃKZ–<ō˜Ų§§ ^øA9’†¦7Ź‘°ŖÉYćłP5 `<%l®#ŃUy±3$QŠjk¶ps„ŗ–p[ĒØŠf«āŁāATŁ[ü>Ÿń” 2:•÷s!Ļn!ĘY„Uߘ9é‚:NóÖą—Ön:ń ”k3O>Dt ‚Üź.7‰Nds‹Ž %°žĢ‡ˆ^YÆ×śšSŒķ;KźG[fl‹æ4©•Ūun-Ķä‚ÅSc‡Sm ¤_ĖéęhC*ĪłK ”.øĖ¾2žqĄĢ÷ćµRŸ–O¢ŽĄmL Źļ;CiÕęBq3śŌ¶ė„õøµŗž¤ ©§\H@*<¬¤v,iÉe-°ōhuN?qĆCˆuhy/² ®m×1\N"k‚łšžWŹŲVP¬ŠwĮ€±ń[øŒ]$«zØŖ•ʆQŗ…Ņ£€źjæķAEØępļwč »ž²ĖĪ~ø6£WšĖPvÓEQ ¬å¦4$U„W™šKƒĄ`-&”Õaė/Ź‹\·ĻrŹÄŌÓü%Z ¹ÅĖ‚Y€}E=eµIŁkÖŃĪøØŻf8ÜyܜŻސé¹yńś>Å.™|¦!†ą; /ŒŌīŠĆ^ń‹nčl720“2Ē­€±yÜČj9 ‚4Ćq׍¤Ńōš!I•©RiźšQņį/Nаx)ēåź‰vMł‚/0™y‚‡Ź³©g­¤>©W`@ø üĆļž^'¤Č&[-Šiī=1ŅŃŗŃ,ր9‡µĪ;ÖAłÕœgŖå³ķ”Ó…z ⧩ׁO£™²P@hxŹiø7eXß;šqVĄ‹š6S‹Ģ'@σ]½uŠF–„TØØtu«‚efś/Ȅßhß¶gŌÅ©ź¬8bŠ.¼-ōĖūeU +Z0WH÷I°øŌŗ6Ǧo^¼±` ”xPlQ[jĖćō|’Ž›\Õø[1~Äuˆ;Æ”g:Œ‰fŠģ§5W“Ņ}7, ÜućA)4}<;yFĄÕh"WķTņĒöG'„½(;Ųol*zBj2™Š®³g—…Ozż>.Ą«/\c’¶¼„ˆłxAōꯄ˜ā‘`“xާC–ŲŒ²”F»Ŗ+||ųœ“(..²{q™jśd÷ēq‚÷p"ž²”ę«œĀ5†¦Ļ/ žõś<.:a£”"N”|čEKn+6KnŻĪN>ōĮ7ć)#Čš‰ES"#I^b2Ųźøžu³8å—ŚąVFĀj¹Mʤi¶ar…HäšWe *AŽz˜ß<žćĮa¹hźm[õa²»†ŸÜ’%J¬f)Ī³6Px:öck®śØ:cŅfņŁzށŚmT]”ĖT#ŌÄÆڈmģ#¦©TŹl9yæÄ}>h~§€ŽŌt3õ¢õJ*=a2–ļ1Oł5x0źÖ!1—bļŁxeaĀŻ C°Š)ł‡÷C—˜•#§³žĪū žbO‹@­lh'<Ęī}B8R¬4P€vžW±oA²‡K)ÆįEv]hhÉg4ʝLx|į`%ÕīĻ{ø8¼įOZ;B?@= t¦ū§J&d@lå S¹šŹĀX‚BŅč§:9›8²ŒüZ¢Ī×įō|’‰¤rÕ £ŪõH[Źe1! RŌ"š¾°Ö·SUīĆOŽ1M6yxT÷ÆŃįqÓų» ¼%§ł#Č=Į+Ö9%’ŒD6ŗäßŃ<%ŚŌĘqE’¹×yapG—„ž.½O5ršÅĢ£įz0=mŁŅ\BÕEÅč‡Ņ)6Œ±Ńžęš½,Z.T ø“ «D$LķƆ±£ƒ“ų{ˆJ³i{Z ­…š«PÜģ?¹i‘éžČńLSCLkzĢ>P[fÜP•.Nˆ„Uٲ5ōŅe‡7H‰+0»` ’Į„ņ•‰”Iʖ`M µĒāfÖv,ą_)Z…ŌˆóH*®t¦H6*p昄³ŹŹ‡e‡V ]/aõ0?Ę1‚ž˜! ށ|`1›'Ö¬Éq.Ņ.|:Z˜ؚXJ§bhö®Ģ‚ģó‹“ĪĀ„œ¬Ī:ĢÆµ[Š™Ŗ„ä-¼"˜õ™M Ü2ž7(ŗ  ŚŹæęŠpJՂ!Ø€į¦Ż”t?ŅŹż°—@é„ĖŃ äµW«zK[Ta8iq,ć ųVB˜¢uņc 7~“ĖĆčł’…UīvŌÖHĶ“nš@–r‹ÆŲcEąłNŗ†K0u\,fņbę¬MØDk·×… t“@ćsÓTė“āōšĆU–2šqŲ`œęü/B98žėµń8KĻ ^. Mibh—eĶž^=ėōx\tž.ĆG//Œ@¤żdAųŗ7dżĄłK,9Ä#HaĀVČ;W³Cj›^Ąa\óė Ų\Y-ø¾¾U·Ź9„Už€MŽ)ėņµņÆ9dyB•6CŹ86Õ<`łT6§ŠĮŠ“³Ow-¹UŪĖØ”N‰ĢÜĄé—hGNXčž'ø‰ŌWT*Źąš‡·_Æ*8€- ź‘[;c)˜w˜[71į/rĪšbS€o ×"Wµ¹@Ŗķ¶–„³īe†)`ŚÕ|ĮÓ[:Łdʶ\øÅTēVžÜ7#„¼įč 8iĘźX<¤[€s”IŠ‹ 0B bS dG[śŠ4P6¤y”Tg¹i\ą5"ØUTMå ܦ‘(ä×.(ēNįÓÓŻx‡¢~XĖŠnßZ–ó€H\Ņrō…IuXE¤#Huu›ƒR‹ŖoÖU`ņ‰zżB$¢©“­«ŹĢ° «¤½Eyvi ĆƒŹ„ū+sRųÖu"h‰]©µÕ+;Ž=5õlłäćœ6<Vx Ž·ŅS¾GĻųŖE]ĻYę…EŖ<#b\q;5z„<"Ō\]ĖÖ£ Ēp¢ą+ØļżŻcŠLīż6‰ƒīkĪBY1“=#Č7ŅāT³'™5G2£]„zĒJć š;Ö‰‚p˜Ų)N|šń,Ęaˆ, ćPļī{ĄįĶa¾ ˜]ö ŒĆĘVe¬ø9߁¤Š5³ źŻG‰ößBō šQį)iެnÆfcĶęzŹēA»€(bQp°vĘVa0£Õ¼p£ń÷ ¢4į.N¤8±@Ś@ŌōP5äGĖ\ÖQĻIČøįHHn)Āź±Į™‰Ø>Ņ£KY¼ŻBH”óXZāėP¼+p÷$¼1|ą«é l%„(Ķ0V\˲åvŽmūLžÆ%@tµĖŽS#ī Ōįå -“#­hĖ)~Ü)P:šgÖ­Š ó?pP@ƒ‘[ÄĶĮŅØĄŃC|n&ÆW‘6TJ—4 TbdU øS0]92õwY4×Ór‘ąW%n#ÄŽµ%ć:餯£ŹV[t“Ė”`»-xé|cī‘^ŃTćŸZ ©„h\ĶĻ6y\ @rc„ …m,__8±Pƒ@( žGĻłpqs-Gy8Ŗ„'W“PėŪå4ĀŲž‘ƒC¾ūĖļ wŻ‹źŠōā)Čč§ĪŅ/ćPf™@zbÆGÕŲä\śœå]E„į—ĀõEAæ5\=^YAĶ&ļ6x~ų,3„4ķ-Ā*ö{0`7_ės`‡½Ła^љ4¤+ś²•ŒóHĶ‰…]ZēŹaó:ĮÕpƒ?O“Ėih¶‘EzŠ< Zč8DzJZ pēŁ~!Īėp©‚Ņ2(” 2XD±8• ÉlvPߚŠBˆqĮ×¼·KĀ;H5õ6Ā:Ō'~“IÕ“Żx ĄkFŸ˜AÕU;øXć=®Q‚Vb°p½›įaŖ.ėw —ĀM\£]ēDw“…Ws¦/Ī”¤5‘.lAꚻ¬Dõ*»Wėåī<¾S'ƒå?ąOųŸč”¼”É& @"®ĒŽP tKVqaĄec‰ē!bE<®™‰oaė‡Š-„,*qצ”§å.ösAq„PąKŌĪ×{Z\B…bļšÉćG±Ć€4 o،1„HZ‚0‹pŽ1Xéj„)hW†PÉløŖ 6Żā7Ń®fŲ%„„ ØęŠÓu†÷ĀĄ<ÉŻ ¶ QŹŲ. /„¶ęÖTk ŗf=TĻé„k¶VW¾žŚ¼…H%T ’$(\ź¹hQ ¦Š¼BA…ÕC‚Å*)X“÷’R…%ĢĶW¤€yŁž8ÖŹ•ćō|’ĀożF“„|ÉčQ@·@”Šä¢ćįvõźQȲõbÕøBIVģ_sĢ7<¼L»Æ8WJˆV‡„Ą/‡²PŃćW‰S?…^%zJ™šDŁR’uµ“ŹP—JęAFče–hP®²¬Āl7¶ƤF±ŻXįĻ%čĢ‹ŒUƏ•ś0a|iƒ%Ū®1鈀9æą÷Ģ„—Ā1`¬ Z¢…AĀu<’Yzd 6Ö„%NabÆņ9zŽv:›QĮ„!®=„„ģė 狉¼ YMFĢiļqČ\/%póM+•jeéѬDX"ē*Éė(­įh"źō &] ±e^@mqĘZĀ Må4ē9ÓØIŪX‡ WaµŪYė÷§ńż?ņŽ£7s@ ½"TA¬ ŠbŹkœfŖ+ P@LuŠ­äÉÅ9eųE„YŠ ’V“Q„:ŠzŤ7VΉ=|8F0!“vg„Ēlc ećśÅō|’ŻūėŃó’wī?¬_GĻżŪĢk€fȳļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ3ļæ0DUĒÕõ’ź’Łdjangosaml2-1.10.1/docs/source/_templates/000077500000000000000000000000001476674771500204105ustar00rootroot00000000000000djangosaml2-1.10.1/docs/source/_templates/pplnx_template/000077500000000000000000000000001476674771500234445ustar00rootroot00000000000000djangosaml2-1.10.1/docs/source/_templates/pplnx_template/footer.html000066400000000000000000000034451476674771500256360ustar00rootroot00000000000000
{% if (theme_prev_next_buttons_location == 'bottom' or theme_prev_next_buttons_location == 'both') and (next or prev) %} {% endif %}

{%- if show_copyright %} {%- if hasdoc('copyright') %} {% trans path=pathto('copyright'), copyright=copyright|e %}{{ copyright }}.{% endtrans %} {%- else %} {% trans copyright=copyright|e %}{{ copyright }}.{% endtrans %} {%- endif %} {%- endif %} {%- if build_id and build_url %} {% trans build_url=build_url, build_id=build_id %} Build {{ build_id }}. {% endtrans %} {%- elif commit %} {% trans commit=commit %} Revision {{ commit }}. {% endtrans %} {%- elif last_updated %} {% trans last_updated=last_updated|e %}Last updated on {{ last_updated }}.{% endtrans %} {%- endif %}

{%- if show_sphinx %} {% trans %}Built with Sphinx{% endtrans %}. {%- endif %} {%- block extrafooter %} {% endblock %}
djangosaml2-1.10.1/docs/source/_templates/pplnx_template/layout.html000066400000000000000000000027531476674771500256560ustar00rootroot00000000000000{% extends "!layout.html" %} {% block extrahead %} {% endblock %} {% block sidebartitle %} {% if logo %} {# Not strictly valid HTML, but it's the only way to display/scale it properly, without weird scripting or heaps of work #} {% endif %} {% if logo and theme_logo_only %} {% if theme_display_version %} {%- set nav_version = version %} {% if READTHEDOCS and current_version %} {%- set nav_version = current_version %} {% endif %} {% if nav_version %}
{{ nav_version }}
{% endif %} {% endif %} {% include "searchbox.html" %} {% endblock %} {% block mobile_nav %} {% if logo %} {% endif %} {{ project }} {% endblock %} djangosaml2-1.10.1/docs/source/conf.py000066400000000000000000000040331476674771500175520ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) from recommonmark.parser import CommonMarkParser # -- Project information ----------------------------------------------------- project = 'djangosaml2' copyright = '2020, Giuseppe De Marco' author = 'Giuseppe De Marco' # The full version, including alpha/beta/rc tags release = '0.2.4' # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ['sphinxcontrib.images', 'recommonmark'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates/pplnx_template'] html_logo = "_static/logo.jpg" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] source_suffix = ['.rst', '.md'] djangosaml2-1.10.1/docs/source/contents/000077500000000000000000000000001476674771500201105ustar00rootroot00000000000000djangosaml2-1.10.1/docs/source/contents/developer.rst000066400000000000000000000043431476674771500226330ustar00rootroot00000000000000Testing ======= One way to check if everything is working as expected is to enable the following url:: urlpatterns = patterns( '', # lots of url definitions here (r'saml2/', include('djangosaml2.urls')), (r'test/', 'djangosaml2.views.EchoAttributesView.as_view()'), # more url definitions ) Now if you go to the /test/ url you will see your SAML attributes and also a link to do a global logout. Unit tests ========== Djangosaml2 have a legacy way to do tests, using an example project in `tests` directory. This means that to run tests you have to clone the repository, then install djangosaml2, then run tests using the example project. example:: pip install -r requirements-dev.txt # or pip install djangosaml2[test] then:: cd tests ./manage.py migrate ./manage.py test djangosaml2 If you have `tox`_ installed you can simply call `tox` inside the root directory and it will run the tests in multiple versions of Python. .. _`tox`: http://pypi.python.org/pypi/tox Code Coverage ============= example:: cd tests/ coverage erase coverage run ./manage.py test djangosaml2 testprofiles coverage report -m Custom error handler ==================== When an error occurs during the authentication flow, djangosaml2 will render a simple error page with an error message and status code. You can customize this behaviour by specifying the path to your own error handler in the settings:: SAML_ACS_FAILURE_RESPONSE_FUNCTION = 'python.path.to.your.view' This should be a view which takes a request, optional exception which occured and status code, and returns a response to serve the user. E.g. The default implementation looks like this:: def template_failure(request, exception=None, status=403, **kwargs): """ Renders a simple template with an error message. """ return render(request, 'djangosaml2/login_error.html', {'exception': exception}, status=status) Contributing ============ Please open Issues to start debate regarding the requested features, or the patch that you would apply. We do not use a strict submission format, please try to be more concise as possibile. The Pull Request MUST be done on the dev branch, please don't push code directly on the master branch. djangosaml2-1.10.1/docs/source/contents/faq.md000066400000000000000000000032211476674771500211770ustar00rootroot00000000000000FAQ === **Why can't SAML be implemented as an Django Authentication Backend?** well SAML authentication is not that simple as a set of credentials you can put on a login form and get a response back. Actually the user password is not given to the service provider at all. This is by design. You have to delegate the task of authentication to the IdP and then get an asynchronous response from it. Given said that, djangosaml2 does use a Django Authentication Backend to transform the SAML assertion about the user into a Django user object. **Why not put everything in a Django middleware class and make our lifes easier?** Yes, that was an option I did evaluate but at the end the current design won. In my opinion putting this logic into a middleware has the advantage of making it easier to configure but has a couple of disadvantages: first, the middleware would need to check if the request path is one of the SAML endpoints for every request. Second, it would be too magical and in case of a problem, much harder to debug. **Why not call this package django-saml as many other Django applications?** Following that pattern then I should import the application with import saml but unfortunately that module name is already used in pysaml2. **saml2.response.UnsolicitedResponse: Unsolicited response** If you are experiencing issues with unsolicited requests this is due to the fact that cookies not being sent when using the HTTP-POST binding. You have to configure samesite djangosaml2 middleware (see setup documentation) and also consider upgrading to Django 3.1 or higher. If you can't do that, configure "allow_unsolicited" to True in pySAML2 configuration. djangosaml2-1.10.1/docs/source/contents/miscellanea.rst000066400000000000000000000073601476674771500231250ustar00rootroot00000000000000SimpleSAMLphp issues -------------------- As of SimpleSAMLphp 1.8.2 there is a problem if you specify attributes in the SP configuration. When the SimpleSAMLphp metadata parser converts the XML into its custom php format it puts the following option:: 'attributes.NameFormat' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri' But it need to be replaced by this one:: 'AttributeNameFormat' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri' Otherwise the Assertions sent from the IdP to the SP will have a wrong Attribute Name Format and pysaml2 will be confused. Furthermore if you have a AttributeLimit filter in your SimpleSAMLphp configuration you will need to enable another attribute filter just before to make sure that the AttributeLimit does not remove the attributes from the authentication source. The filter you need to add is an AttributeMap filter like this:: 10 => array( 'class' => 'core:AttributeMap', 'name2oid' ), Okta federation --------------- Okta settings to configure on your Idp's SAML app advanced settings:: Single Logout URL: http://localhost:8000/saml2/ls/post/ SP Issuer : http://localhost:8000/saml2/metadata/ Okta sample configuration for setting up an Okta SSO with Django:: 'service': { # we are just a lonely SP 'sp': { 'name': 'XXX', 'allow_unsolicited': True, 'want_assertions_signed': True, # assertion signing (default=True) 'want_response_signed': True, "want_assertions_or_response_signed": True, # is response signing required 'name_id_format': NAMEID_FORMAT_UNSPECIFIED, # Must for signed logout requests "logout_requests_signed": True, 'endpoints': { # url and binding to the assetion consumer service view # do not change the binding or service name 'assertion_consumer_service': [ ('http://localhost:8000/saml2/acs/', saml2.BINDING_HTTP_POST), ], # url and binding to the single logout service view # do not change the binding or service name 'single_logout_service': [ # ('http://localhost:8000/saml2/ls/', # saml2.BINDING_HTTP_REDIRECT), ('http://localhost:8000/saml2/ls/post/', saml2.BINDING_HTTP_POST), ], }, # Mandates that the identity provider MUST authenticate the # presenter directly rather than rely on a previous security context. 'force_authn': False, "allow_unsolicited": True, # Enable AllowCreate in NameIDPolicy. 'name_id_format_allow_create': False, # attributes that this project need to identify a user 'required_attributes': ['email'], # in this section the list of IdPs we talk to are defined 'idp': { # we do not need a WAYF service since there is # only an IdP defined here. This IdP should be # present in our metadata # the keys of this dictionary are entity ids 'https://xxx.okta.com/app/XXXXXXXXXX/sso/saml/metadata': { # Okta only uses HTTP_POST disable this # 'single_sign_on_service': { # saml2.BINDING_HTTP_REDIRECT: 'https://xxx.okta.com/app/APPNAME/xxxxxxxxx/sso/saml', # }, 'single_logout_service': { saml2.BINDING_HTTP_POST: 'https://xxx.okta.com/app/APPNAME/xxxxxxxxxx/slo/saml', }, }, }, }, }, djangosaml2-1.10.1/docs/source/contents/security.md000066400000000000000000000044461476674771500223110ustar00rootroot00000000000000Introduction ============ Authentication and Authorization are quite security relevant topics on its own. Make sure you understand SAML2 and its implications, specifically the separation of duties between Service Provider (SP) and Identity Provider (IdP): this library aims to support a Service Provider in getting authenticated with with one or more Identity Provider. Communication between SP and IdP is routed via the Browser, eliminating the need for direct communication between SP and IdP. However, for security the use of cryptographic signatures (both while sending and receiving messages) must be examined and the private keys in use must be kept closely guarded. Content Security Policy ======================= When using POST-Bindings, the Browser is presented with a small HTML-Form for every redirect (both Login and Logout), which is sent using JavaScript and sends the Data to the selected IdP. If your application uses technices such as Content Security Policy, this might affect the calls. Since Version 1.9.0 djangosaml2 will detect if django-csp is installed and update the Content Security Policy accordingly. [Content Security Policy](https://content-security-policy.com/) is an important HTTP-Extension to prevent User Input or other harmful sources from manipulating application data. Usage is strongly advised, see [OWASP Control](https://owasp.org/www-community/controls/Content_Security_Policy). To enable CSP with [django-csp](https://django-csp.readthedocs.io/), simply follow their [installation](https://django-csp.readthedocs.io/en/latest/installation.html) and [configuration](https://django-csp.readthedocs.io/en/latest/configuration.html) guides: djangosaml2 will automatically blend in and update the headers for POST-bindings, so you must not include exceptions for djangosaml2 in your global configuration. Note that to enable autosubmit of post-bindings inline-javascript is used. To allow execution of this autosubmit-code a nonce is included, which works in default configuration but may not work if you modify `CSP_INCLUDE_NONCE_IN` to exclude `script-src`. You can specify a custom CSP handler via the `SAML_CSP_HANDLER` setting and the warning can be disabled by setting `SAML_CSP_HANDLER=''`. See the [djangosaml2](https://djangosaml2.readthedocs.io/) documentation for more information. djangosaml2-1.10.1/docs/source/contents/setup.rst000066400000000000000000000661331476674771500220130ustar00rootroot00000000000000Setup ----- Prepare Environment and Install Requirements ============================================ PySAML2 uses xmlsec1_ binary to sign SAML assertions so you need to install it either through your operating system package or by compiling the source code. It doesn't matter where the final executable is installed because you will need to set the full path to it in the configuration stage. .. _xmlsec1: http://www.aleksey.com/xmlsec/ Now you can install the djangosaml2 package using pip. This will also install PySAML2 and its dependencies automatically:: apt install python3-pip xmlsec1 python3-dev libssl-dev libsasl2-dev pip3 install virtualenv mkdir djangosaml2_project && cd "$_" virtualenv -ppython3 env source env/bin/activate pip install djangosaml2 Configuration ------------- There are three things you need to setup to make djangosaml2 work in your Django project: 1. **settings.py** as you may already know, it is the main Django configuration file. 2. **urls.py** is the file where you will include djangosaml2 urls. 3. **pysaml2** specific files such as an attribute map directory and a certificates involved in SAML2 signature and encryption operations. The first thing you need to do is add ``djangosaml2`` to the list of installed apps:: INSTALLED_APPS = ( 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', 'django.contrib.admin', 'djangosaml2', # new application ) SameSite cookie =============== Add the SAML Session Middleware as follow, this is needed for SameSite Cookies:: MIDDLEWARE.append('djangosaml2.middleware.SamlSessionMiddleware') By default, djangosaml2 handle the saml2 session in a separate cookie. The storage linked to it is accessible by default at `request.saml_session`. You can even configure the SAML cookie name as follows:: SAML_SESSION_COOKIE_NAME = 'saml_session' By default, djangosaml2 will set "SameSite=None" for the SAML session cookie. This value can be configured as follows:: SAML_SESSION_COOKIE_SAMESITE = 'Lax' Remember that in your browser "SameSite=None" attribute MUST also have the "Secure" attribute, which is required in order to use "SameSite=None", otherwise the cookie will be blocked, so you must also set:: SESSION_COOKIE_SECURE = True .. Note:: djangosaml2 will by default attempt to set the ``SameSite`` attribute of the SAML session cookie to ``None`` so that it can be used in cross-site requests, but this is only possible with Django 3.1 or higher. If you are experiencing issues with unsolicited requests or cookies not being sent (particularly when using the HTTP-POST binding), consider upgrading to Django 3.1 or higher. If you can't do that, configure "allow_unsolicited" to True in pySAML2 configuration. Authentication backend ====================== Then you have to add the ``djangosaml2.backends.Saml2Backend`` authentication backend to the list of authentications backends. By default only the ModelBackend included in Django is configured. A typical configuration would look like this:: AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', 'djangosaml2.backends.Saml2Backend', ) It is possible to subclass the provided Saml2Backend and customize the behaviour by overriding some methods. This way you can perform your custom cleaning or authorization policy, and modify the way users are looked up and created. Default Login path ================== Finally we have to tell Django what the new login url we want to use is:: LOGIN_URL = '/saml2/login/' SESSION_EXPIRE_AT_BROWSER_CLOSE = True Here we are telling Django that any view that requires an authenticated user should redirect the user browser to that url if the user has not been authenticated before. We are also telling that when the user closes his browser, the session should be terminated. This is useful in SAML2 federations where the logout protocol is not always available. .. Note:: The login url starts with ``/saml2/`` as an example but you can change that if you want. Check the section about changes in the ``urls.py`` file for more information. If you want to allow several authentication mechanisms in your project you should set the LOGIN_URL option to another view and put a link in such view to djangosaml2 wb path, like ``/saml2/login/``. Handling Post-Login Redirects ============================= It is often desirable for the client to maintain the URL state (or at least manage it) so that the URL once authentication has completed is consistent with the desired application state (such as retaining query parameters, etc.) By default, the HttpRequest objects get_host() method is used to determine the hostname of the server, and redirect URL's are allowed so long as the destination host matches the output of get_host(). However, in some cases it becomes desirable for additional hostnames to be used for the post-login redirect. In such cases, the setting:: SAML_ALLOWED_HOSTS = [] May be set to a list of allowed post-login redirect hostnames (note, the URL components beyond the hostname may be specified by the client - typically with the ?next= parameter.) In the absence of a ``?next=parameter``, the ``ACS_DEFAULT_REDIRECT_URL`` or ``LOGIN_REDIRECT_URL`` setting will be used (assuming the destination hostname either matches the output of get_host() or is included in the ``SAML_ALLOWED_HOSTS`` setting) Redirect URL validation ======================= Djangosaml2 will validate the redirect URL before redirecting to its value. In some edge-cases, valid redirect targets will fail to pass this check. This is limited to URLs that are a single 'word' without slashes. (For example, 'home' but also 'page-with-dashes'). In this situation, the best solution would be to add a slash to the URL. For example: 'home' could be '/home' or 'home/'. If this is unfeasible, this strict validation can be turned off by setting ``SAML_STRICT_URL_VALIDATION`` to ``False`` in settings.py. During validation, `Django named URL patterns `_ will also be resolved. Turning off strict validation will prevent this from happening. Preferred sso binding ===================== Use the following setting to choose your preferred binding for SP initiated sso requests:: SAML_DEFAULT_BINDING For example:: import saml2 SAML_DEFAULT_BINDING = saml2.BINDING_HTTP_POST Preferred Logout binding ======================== Use the following setting to choose your preferred binding for SP initiated logout requests:: SAML_LOGOUT_REQUEST_PREFERRED_BINDING For example:: import saml2 SAML_LOGOUT_REQUEST_PREFERRED_BINDING = saml2.BINDING_HTTP_POST Ignore Logout errors ==================== When logging out, a SAML IDP will return an error on invalid conditions, such as the IDP-side session being expired. Use the following setting to ignore these errors and perform a local Django logout nonetheless:: SAML_IGNORE_LOGOUT_ERRORS = True Discovery Service ================= If you want to use a SAML Discovery Service, all you need is adding:: SAML2_DISCO_URL = 'https://your.ds.example.net/' Of course, with the real URL of your preferred Discovery Service. Idp hinting =========== If the SP uses an AIM Proxy it is possible to suggest the authentication IDP by adopting the `idphint` parameter. The name of the `idphint` parameter is default, but it can also be changed using this parameter:: SAML2_IDPHINT_PARAM = 'idphint' This will ensure that the user will not get a possible discovery service page for the selection of the IdP to use for the SSO. When Djagosaml2 receives an HTTP request at the resource, web path, configured for the saml2 login, it will detect the presence of the `idphint` parameter. If this is present, the authentication request will report this URL parameter within the http request relating to the SAML2 SSO binding. For example:: import requests import urllib idphint = {'idphint': [ urllib.parse.quote_plus(b'https://that.idp.example.org/metadata'), urllib.parse.quote_plus(b'https://another.entitydi.org')] } param = urllib.parse.urlencode(idphint) # param is "idphint=%5B%27https%253A%252F%252Fthat.idp.example.org%252Fmetadata%27%2C+%27https%253A%252F%252Fanother.entitydi.org%27%5D" requests.get(f'http://djangosaml2.sp.fqdn.org/saml2/login/?{param}') see AARC Blueprint specs `here `_. IdP scoping =========== The SP can suggest an IdP to a proxy by using the Scoping and IDPList elements in a SAML AuthnRequest. This is done using the `scoping` parameter to the login URL. :: https://sp.example.org/saml2/login/?scoping=https://idp.example.org This parameter can be combined with the IdP parameter if multiple IdPs are present in the metadata, otherwise the first is used. :: https://sp.example.org/saml2/login/?scoping=https://idp.example.org&idp=https://proxy.example.com/metadata Currently there is support for a single IDPEntry in the IDPList. Authn Context ============= We can define the authentication context in settings.SAML_CONFIG['service']['sp'] as follows:: "requested_authn_context": { "authn_context_class_ref": [ "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "urn:oasis:names:tc:SAML:2.0:ac:classes:TLSClient", ], "comparison": "minimum", } Custom and dynamic configuration loading ======================================== By default, djangosaml2 reads the pysaml2 configuration options from the SAML_CONFIG setting but sometimes you want to read this information from another place, like a file or a database. Sometimes you even want this configuration to be different depending on the request. Starting from djangosaml2 0.5.0 you can define your own configuration loader which is a callable that accepts a request parameter and returns a saml2.config.SPConfig object. In order to do so you set the following setting:: SAML_CONFIG_LOADER = 'python.path.to.your.callable' Bearer Assertion Replay Attack Prevention ========================================= In SAML standard doc, section 4.1.4.5 it states The service provider MUST ensure that bearer assertions are not replayed, by maintaining the set of used ID values for the length of time for which the assertion would be considered valid based on the NotOnOrAfter attribute in the djangosaml2 provides a hook 'is_authorized' for the SP to store assertion IDs and implement replay prevention with your choice of storage. :: def is_authorized(self, attributes: dict, attribute_mapping: dict, idp_entityid: str, assertion: object, **kwargs) -> bool: if not assertion: return True # Get your choice of storage cache_storage = storage.get_cache() assertion_id = assertion.get('assertion_id') if cache.get(assertion_id): logger.warn("Received SAMLResponse assertion has been already used.") return False expiration_time = assertion.get('not_on_or_after') time_delta = isoparse(expiration_time) - datetime.now(timezone.utc) cache_storage.set(assertion_id, 'True', ex=time_delta) return True CSP Configuration ================= By default djangosaml2 will use `django-csp `_ to configure CSP if available otherwise a warning will be logged. The warning can be disabled by setting:: SAML_CSP_HANDLER = '' A custom handler can similary be specified:: # Django settings SAML_CSP_HANDLER = 'myapp.utils.csp_handler' # myapp/utils.py def csp_handler(response): response.headers['Content-Security-Policy'] = ... return response A value of `None` is the default and will use `django-csp `_ if available. Users, attributes and account linking ------------------------------------- In the SAML 2.0 authentication process the Identity Provider (IdP) will send a security assertion to the Service Provider (SP) upon a successful authentication. This assertion contains attributes about the user that was authenticated. It depends on the IdP configuration what exact attributes are sent to each SP it can talk to. When such assertion is received on the Django side it is used to find a Django user and create a session for it. By default djangosaml2 will do a query on the User model with the USERNAME_FIELD_ attribute but you can change it to any other attribute of the User model. For example, you can do this lookup using the 'email' attribute. In order to do so you should set the following setting:: SAML_DJANGO_USER_MAIN_ATTRIBUTE = 'email' .. _USERNAME_FIELD: https://docs.djangoproject.com/en/dev/topics/auth/customizing/#django.contrib.auth.models.CustomUser.USERNAME_FIELD Please, use an unique attribute when setting this option. Otherwise the authentication process may fail because djangosaml2 will not know which Django user it should pick. If your main attribute is something inherently case-insensitive (such as an email address), you may set:: SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP = '__iexact' (This is simply appended to the main attribute name to form a Django query. Your main attribute must be unique even given this lookup.) Another option is to use the SAML2 name id as the username by setting:: SAML_USE_NAME_ID_AS_USERNAME = True You can configure djangosaml2 to create such user if it is not already in the Django database or maybe you don't want to allow users that are not in your database already. For this purpose there is another option you can set in the settings.py file:: SAML_CREATE_UNKNOWN_USER = True This setting is True by default. The following setting lets you specify a URL for redirection after a successful authentication:: ACS_DEFAULT_REDIRECT_URL = reverse_lazy('some_url_name') Particularly useful when you only plan to use IdP initiated login and the IdP does not have a configured RelayState parameter. If not set Django's ``LOGIN_REDIRECT_URL`` or ``/`` will be used. The other thing you will probably want to configure is the mapping of SAML2 user attributes to Django user attributes. By default only the User.username attribute is mapped but you can add more attributes or change that one. In order to do so you need to change the SAML_ATTRIBUTE_MAPPING option in your settings.py:: SAML_ATTRIBUTE_MAPPING = { 'uid': ('username', ), 'mail': ('email', ), 'cn': ('first_name', ), 'sn': ('last_name', ), } where the keys of this dictionary are SAML user attributes and the values are Django User attributes. If you are using Django user profile objects to store extra attributes about your user you can add those attributes to the SAML_ATTRIBUTE_MAPPING dictionary. For each (key, value) pair, djangosaml2 will try to store the attribute in the User model if there is a matching field in that model. Otherwise it will try to do the same with your profile custom model. For multi-valued attributes only the first value is assigned to the destination field. Alternatively, custom processing of attributes can be achieved by setting the value(s) in the SAML_ATTRIBUTE_MAPPING, to name(s) of method(s) defined on a custom django User object. In this case, each method is called by djangosaml2, passing the full list of attribute values extracted from the elements of the . Among other uses, this is a useful way to process multi-valued attributes such as lists of user group names. For example: Saml assertion snippet:: group1 group2 group3 Custom User object:: from django.contrib.auth.models import AbstractUser class User(AbstractUser): def process_groups(self, groups): # process list of group names in argument 'groups' pass; settings.py:: SAML_ATTRIBUTE_MAPPING = { 'groups': ('process_groups', ), } Learn more about Django profile models at: https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model Custom user attributes processing --------------------------------- Sometimes you need to use special logic to update the user object depending on the SAML2 attributes and the mapping described above is simply not enough. For these cases djangosaml2 provides hooks_ that can be overriden with custom functionality. First of all reference the modified Saml2Backend in settings.py file:: AUTHENTICATION_BACKENDS = [ 'your_package.authentication.ModifiedSaml2Backend', ] For example:: from djangosaml2.backends import Saml2Backend class ModifiedSaml2Backend(Saml2Backend): def save_user(self, user, *args, **kwargs): user.save() user_group = Group.objects.get(name='Default') user.groups.add(user_group) return super().save_user(user, *args, **kwargs) Keep in mind save_user is only called when there was a reason to save the User model (ie. first login), and it has no access to SAML attributes for authorization. If this is required, it can be achieved by overriding the _update_user:: from djangosaml2.backends import Saml2Backend class ModifiedSaml2Backend(Saml2Backend): def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False): if 'eduPersonEntitlement' in attributes: if 'some-entitlement' in attributes['eduPersonEntitlement']: user.is_staff = True force_save = True else: user.is_staff = False force_save = True return super()._update_user(user, attributes, attribute_mapping, force_save) .. _hooks: https://github.com/identitypython/djangosaml2/blob/master/djangosaml2/backends.py#L181 URLs ---- Changes in the urls.py file. The next thing you need to do is to include ``djangosaml2.urls`` module in your main ``urls.py`` module:: urlpatterns = patterns( '', # lots of url definitions here (r'saml2/', include('djangosaml2.urls')), # more url definitions ) PySAML2 specific files and configuration ---------------------------------------- Once you have finished configuring your Django project you have to start configuring PySAML2, please consult its `official documentation `_ before start. If you use just that library you have to put your configuration options in a file and initialize PySAML2 with the path to that file. In djangosaml2 you just put the same information in the Django settings.py file under the SAML_CONFIG option. We will see a typical configuration for protecting a Django project:: from os import path import saml2 import saml2.saml BASEDIR = path.dirname(path.abspath(__file__)) SAML_CONFIG = { # full path to the xmlsec1 binary programm 'xmlsec_binary': '/usr/bin/xmlsec1', # your entity id, usually your subdomain plus the url to the metadata view 'entityid': 'http://localhost:8000/saml2/metadata/', # directory with attribute mapping 'attribute_map_dir': path.join(BASEDIR, 'attribute-maps'), # Permits to have attributes not configured in attribute-mappings # otherwise...without OID will be rejected 'allow_unknown_attributes': True, # this block states what services we provide 'service': { # we are just a lonely SP 'sp' : { 'name': 'Federated Django sample SP', 'name_id_format': saml2.saml.NAMEID_FORMAT_TRANSIENT, # For Okta add signed logout requests. Enable this: # "logout_requests_signed": True, 'endpoints': { # url and binding to the assetion consumer service view # do not change the binding or service name 'assertion_consumer_service': [ ('http://localhost:8000/saml2/acs/', saml2.BINDING_HTTP_POST), ], # url and binding to the single logout service view # do not change the binding or service name 'single_logout_service': [ # Disable next two lines for HTTP_REDIRECT for IDP's that only support HTTP_POST. Ex. Okta: ('http://localhost:8000/saml2/ls/', saml2.BINDING_HTTP_REDIRECT), ('http://localhost:8000/saml2/ls/post', saml2.BINDING_HTTP_POST), ], }, 'signing_algorithm': saml2.xmldsig.SIG_RSA_SHA256, 'digest_algorithm': saml2.xmldsig.DIGEST_SHA256, # Mandates that the identity provider MUST authenticate the # presenter directly rather than rely on a previous security context. 'force_authn': False, # Enable AllowCreate in NameIDPolicy. 'name_id_format_allow_create': False, # attributes that this project need to identify a user 'required_attributes': ['givenName', 'sn', 'mail'], # attributes that may be useful to have but not required 'optional_attributes': ['eduPersonAffiliation'], 'want_response_signed': True, 'authn_requests_signed': True, 'logout_requests_signed': True, # Indicates that Authentication Responses to this SP must # be signed. If set to True, the SP will not consume # any SAML Responses that are not signed. 'want_assertions_signed': True, 'only_use_keys_in_metadata': True, # When set to true, the SP will consume unsolicited SAML # Responses, i.e. SAML Responses for which it has not sent # a respective SAML Authentication Request. 'allow_unsolicited': False, # in this section the list of IdPs we talk to are defined # This is not mandatory! All the IdP available in the metadata will be considered instead. 'idp': { # we do not need a WAYF service since there is # only an IdP defined here. This IdP should be # present in our metadata # the keys of this dictionary are entity ids 'https://localhost/simplesaml/saml2/idp/metadata.php': { 'single_sign_on_service': { saml2.BINDING_HTTP_REDIRECT: 'https://localhost/simplesaml/saml2/idp/SSOService.php', }, 'single_logout_service': { saml2.BINDING_HTTP_REDIRECT: 'https://localhost/simplesaml/saml2/idp/SingleLogoutService.php', }, }, }, }, }, # where the remote metadata is stored, local, remote or mdq server. # One metadatastore or many ... 'metadata': { 'local': [path.join(BASEDIR, 'remote_metadata.xml')], 'remote': [{"url": "https://idp.testunical.it/idp/shibboleth"},], 'mdq': [{"url": "https://ds.testunical.it", "cert": "certficates/others/ds.testunical.it.cert", }] }, # set to 1 to output debugging information 'debug': 1, # Signing 'key_file': path.join(BASEDIR, 'private.key'), # private part 'cert_file': path.join(BASEDIR, 'public.pem'), # public part # Encryption 'encryption_keypairs': [{ 'key_file': path.join(BASEDIR, 'private.key'), # private part 'cert_file': path.join(BASEDIR, 'public.pem'), # public part }], # own metadata settings 'contact_person': [ {'given_name': 'Lorenzo', 'sur_name': 'Gil', 'company': 'Yaco Sistemas', 'email_address': 'lorenzo.gil.sanchez@gmail.com', 'contact_type': 'technical'}, {'given_name': 'Angel', 'sur_name': 'Fernandez', 'company': 'Yaco Sistemas', 'email_address': 'angel@yaco.es', 'contact_type': 'administrative'}, ], # you can set multilanguage information here 'organization': { 'name': [('Yaco Sistemas', 'es'), ('Yaco Systems', 'en')], 'display_name': [('Yaco', 'es'), ('Yaco', 'en')], 'url': [('http://www.yaco.es', 'es'), ('http://www.yaco.com', 'en')], }, } .. note:: Please check the `PySAML2 documentation`_ for more information about these and other configuration options. .. _`PySAML2 documentation`: http://pysaml2.readthedocs.io/en/latest/ There are several external files and directories you have to create according to this configuration. The xmlsec1 binary was mentioned in the installation section. Here, in the configuration part you just need to put the full path to xmlsec1 so PySAML2 can call it as it needs. Signed Logout Request ===================== Idp's like Okta require a signed logout response to validate and logout a user. Here's a sample config with all required SP/IDP settings:: "logout_requests_signed": True, Attribute Map ============= The ``attribute_map_dir`` points to a directory with attribute mappings that are used to translate user attribute names from several standards. It's usually safe to just copy the default PySAML2 attribute maps that you can find in the ``tests/attributemaps`` directory of the source distribution. Metadata ======== The ``metadata`` option is a dictionary where you can define several types of metadata for remote entities. Usually the easiest type is the ``local`` where you just put the name of a local XML file with the contents of the remote entities metadata. This XML file should be in the SAML2 metadata format. .. Note:: Don't use ``remote`` option for fetching metadata in production. Try to use ``mdq`` and introduce a MDQ server instead, it's more efficient. Certificates ============ The ``key_file`` and ``cert_file`` options reference the two parts of a standard x509 certificate. You need it to sign your metadata. For assertion encryption/decryption support please configure another set of ``key_file`` and ``cert_file``, but as inner attributes of ``encryption_keypairs`` option. .. Note:: Check your openssl documentation to generate a certificate suitable for SAML2 operations. SAML2 certificate creation example:: openssl req -nodes -new -x509 -newkey rsa:2048 -days 3650 -keyout private.key -out public.cert PySAML2 certificates are files, in the form of strings that contains a filesystem path. What about configuring the certificates in a different way, in case we are using a container based deploy? - You could supply the cert & key as environment variables (base64 encoded) then create the files when the container starts, either in an entry point shell script or in your settings.py file. - Using `Python Tempfile `_ In the settings create two temp files, then write the content configured in environment variables in them, then use tmpfile.name as key/cert values in pysaml2 configuration. djangosaml2-1.10.1/docs/source/contents/usage.md000066400000000000000000000016231476674771500215400ustar00rootroot00000000000000Getting Started =============== Prepare Database and Preload example data ```` ./manage.py migrate ./manage.py createsuperuser ./manage.py runserver ```` Test IdP ======== Congratulations, you have finished configuring the SP side of the federation. Now you need to send the entity id and the metadata of this new SP to the IdP administrators so they can add it to their list of trusted services. You can get this information starting your Django development server and going to the **http://localhost:8000/saml2/metadata/** url. If you have included the djangosaml2 urls under a different url prefix you need to correct this url. There are many saml2 idps suitable for testing, such as [samltest.id](https://samltest.id/). If you are looking for a django IdP, you can try [uniAuth](https://github.com/UniversitaDellaCalabria/uniAuth) or [djangosaml2idp](https://github.com/OTA-Insight/djangosaml2idp/). djangosaml2-1.10.1/docs/source/index.rst000066400000000000000000000021561476674771500201200ustar00rootroot00000000000000Welcome to Djangosaml2's Documentation ====================================== A Django application that builds a fully compliant SAML2 Service Provider on top of `PySAML2 `_ library. Djangosaml2 protects your project with a SAML2 SSO Authentication, supporting features like **HTTP-REDIRECT** and **HTTP-POST SSO Binding**, **Single logout**, **Discovery Service**, **Wayf page** with customizable html template, **IdP Hinting**, **IdP Scoping** and **Samesite cookie** SSO workaround. The entire project code is open sourced and therefore licensed under the `Apache 2.0 `_. .. toctree:: :maxdepth: 2 :caption: Setup contents/setup.rst .. toctree:: :maxdepth: 2 :caption: Usage contents/usage.md .. toctree:: :maxdepth: 2 :caption: Developer's contents/developer.rst .. toctree:: :maxdepth: 2 :caption: Miscellanea contents/miscellanea.rst .. toctree:: :maxdepth: 2 :caption: FAQ contents/faq.md .. toctree:: :maxdepth: 2 :caption: Security considerations contents/security.mddjangosaml2-1.10.1/pyproject.toml000066400000000000000000000006671476674771500167500ustar00rootroot00000000000000[tool.black] force-exclude = '''/(migrations)/''' target-version = ["py39"] [tool.isort] src_paths = ["djangosaml2", "tests"] profile = "black" known_django = ["django"] known_contrib = ["django.contrib"] known_saml2 = ["saml2"] known_first_party = ["djangosaml2"] known_tests = ["tests"] sections = ["FUTURE", "STDLIB", "DJANGO", "CONTRIB", "THIRDPARTY", "SAML2", "FIRSTPARTY", "TESTS", "LOCALFOLDER"] skip_glob = ["**/migrations/*.py"] djangosaml2-1.10.1/requirements-dev.txt000066400000000000000000000000501476674771500200560ustar00rootroot00000000000000pytest pytest-django pytest-cov codecov djangosaml2-1.10.1/requirements-docs.txt000066400000000000000000000000721476674771500202340ustar00rootroot00000000000000sphinx recommonmark sphinx_rtd_theme sphinxcontrib-images djangosaml2-1.10.1/setup.cfg000066400000000000000000000004161476674771500156450ustar00rootroot00000000000000[bdist_wheel] universal = 1 [flake8] # E203 ignore # https://github.com/PyCQA/pycodestyle/issues/373 # https://github.com/PyCQA/pycodestyle/pull/914 ignore = D106,E203,W503 select = B0,B901,B902,B903,C,F,I,W max-line-length = 88 exclude = .tox,.git,*/migrations/*,docs djangosaml2-1.10.1/setup.py000066400000000000000000000047231476674771500155430ustar00rootroot00000000000000# Copyright (C) 2011-2012 Yaco Sistemas # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import codecs import os from setuptools import find_packages, setup def read(*rnames): return codecs.open( os.path.join(os.path.dirname(__file__), *rnames), encoding="utf-8" ).read() setup( name="djangosaml2", version="1.10.1", description="pysaml2 integration for Django", long_description=read("README.md"), long_description_content_type="text/markdown", classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Security", "Topic :: Software Development :: Libraries :: Application Frameworks", ], keywords="django,pysaml2,sso,saml2,federated authentication,authentication", author="Yaco Sistemas and independent contributors", author_email="lorenzo.gil.sanchez@gmail.com", maintainer="Giuseppe De Marco", url="https://github.com/IdentityPython/djangosaml2", download_url="https://pypi.org/project/djangosaml2/", license="Apache 2.0", packages=find_packages(exclude=["tests", "tests.*"]), include_package_data=True, zip_safe=False, install_requires=["defusedxml>=0.4.1", "Django>=4.2", "pysaml2>=6.5.1"], python_requires=">=3.9", ) djangosaml2-1.10.1/tests/000077500000000000000000000000001476674771500151655ustar00rootroot00000000000000djangosaml2-1.10.1/tests/.coveragerc000066400000000000000000000001001476674771500172750ustar00rootroot00000000000000# .coveragerc to control coverage.py [run] source = djangosaml2 djangosaml2-1.10.1/tests/__init__.py000066400000000000000000000000001476674771500172640ustar00rootroot00000000000000djangosaml2-1.10.1/tests/manage.py000077500000000000000000000003621476674771500167730ustar00rootroot00000000000000#!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) djangosaml2-1.10.1/tests/run_tests.py000077500000000000000000000020351476674771500175700ustar00rootroot00000000000000#!/usr/bin/env python # Copyright (C) 2012 Sam Bull (lsb@pocketuniverse.ca) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import os import sys from django.core import management from django.core.wsgi import get_wsgi_application PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir)) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") sys.path.append(PROJECT_DIR) # Load models application = get_wsgi_application() management.call_command("test", "djangosaml2.tests", "testprofiles", "-v", "2") djangosaml2-1.10.1/tests/settings.py000066400000000000000000000102321476674771500173750ustar00rootroot00000000000000""" Django settings for xxx project. Generated by 'django-admin startproject' using Django 1.10.2. For more information on this file, see https://docs.djangoproject.com/en/1.10/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/1.10/ref/settings/ """ import os # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Make this unique, and don't share it with anybody. SECRET_KEY = "xvds$ppv5ha75qg1yx3aax7ugr_2*fmdrc(lrc%x7kdez-63xn" DEBUG = True ALLOWED_HOSTS = [] INSTALLED_APPS = ( "testprofiles", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "djangosaml2", ) MIDDLEWARE = ( "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", # SameSite Cookie handler "djangosaml2.middleware.SamlSessionMiddleware", ) ROOT_URLCONF = "testprofiles.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ], }, }, ] WSGI_APPLICATION = "testprofiles.wsgi.application" # Database # https://docs.djangoproject.com/en/1.10/ref/settings/#databases DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "tests/db.sqlite3"), } } # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/1.10/topics/i18n/ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" SITE_ID = 1 USE_I18N = True USE_TZ = True # https://docs.djangoproject.com/en/dev/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.10/howto/static-files/ STATIC_URL = "/static/" AUTH_USER_MODEL = "testprofiles.TestUser" # A sample logging configuration. The only tangible logging # performed by this configuration is to send an email to # the site admins on every HTTP 500 error when DEBUG=False. # See http://docs.djangoproject.com/en/dev/topics/logging for # more details on how to customize your logging configuration. LOGGING = { "version": 1, "disable_existing_loggers": False, "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, "handlers": { "mail_admins": { "level": "ERROR", "filters": ["require_debug_false"], "class": "django.utils.log.AdminEmailHandler", }, "console": { "level": "DEBUG", "class": "logging.StreamHandler", }, }, "loggers": { "django.request": { "handlers": ["mail_admins"], "level": "ERROR", "propagate": True, }, "djangosaml2": { "handlers": ["console"], "level": "DEBUG", }, }, } AUTHENTICATION_BACKENDS = ("djangosaml2.backends.Saml2Backend",) djangosaml2-1.10.1/tests/testprofiles/000077500000000000000000000000001476674771500177105ustar00rootroot00000000000000djangosaml2-1.10.1/tests/testprofiles/__init__.py000066400000000000000000000000001476674771500220070ustar00rootroot00000000000000djangosaml2-1.10.1/tests/testprofiles/app.py000066400000000000000000000002741476674771500210450ustar00rootroot00000000000000from django.apps import AppConfig class TestProfilesConfig(AppConfig): name = "testprofiles" verbose_name = "Test profiles" default_auto_field = "django.db.models.AutoField" djangosaml2-1.10.1/tests/testprofiles/migrations/000077500000000000000000000000001476674771500220645ustar00rootroot00000000000000djangosaml2-1.10.1/tests/testprofiles/migrations/0001_initial.py000066400000000000000000000070341476674771500245330ustar00rootroot00000000000000# Generated by Django 3.0.5 on 2020-05-01 14:54 import django.contrib.auth.models import django.contrib.auth.validators from django.db import migrations, models import django.utils.timezone class Migration(migrations.Migration): initial = True dependencies = [ ('auth', '0011_update_proxy_permissions'), ] operations = [ migrations.CreateModel( name='RequiredFieldUser', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('email', models.EmailField(max_length=254, unique=True)), ('email_verified', models.BooleanField()), ], ), migrations.CreateModel( name='StandaloneUserModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('username', models.CharField(max_length=30, unique=True)), ], ), migrations.CreateModel( name='TestUser', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('age', models.CharField(blank=True, max_length=100)), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), ], options={ 'verbose_name': 'user', 'verbose_name_plural': 'users', 'abstract': False, }, managers=[ ('objects', django.contrib.auth.models.UserManager()), ], ), ] djangosaml2-1.10.1/tests/testprofiles/migrations/__init__.py000066400000000000000000000000001476674771500241630ustar00rootroot00000000000000djangosaml2-1.10.1/tests/testprofiles/models.py000066400000000000000000000026771476674771500215610ustar00rootroot00000000000000# Copyright (C) 2011-2012 Yaco Sistemas (http://www.yaco.es) # Copyright (C) 2010 Lorenzo Gil Sanchez # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from django.db import models from django.contrib.auth.models import AbstractUser class TestUser(AbstractUser): age = models.CharField(max_length=100, blank=True) def process_first_name(self, first_name): self.first_name = first_name[0] class Meta: app_label = "testprofiles" class StandaloneUserModel(models.Model): """ Does not inherit from Django's base abstract user and does not define a USERNAME_FIELD. """ username = models.CharField(max_length=30, unique=True) class RequiredFieldUser(models.Model): email = models.EmailField(unique=True) email_verified = models.BooleanField() USERNAME_FIELD = "email" def __repr__(self): return self.email def set_unusable_password(self): pass djangosaml2-1.10.1/tests/testprofiles/tests.py000066400000000000000000000526131476674771500214330ustar00rootroot00000000000000# Copyright (C) 2012 Sam Bull (lsb@pocketuniverse.ca) # Copyright (C) 2011-2012 Yaco Sistemas (http://www.yaco.es) # Copyright (C) 2010 Lorenzo Gil Sanchez # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse from django.contrib.auth import get_user_model from django.contrib.auth.models import User as DjangoUserModel from djangosaml2.backends import Saml2Backend, get_saml_user_model, set_attribute from djangosaml2.utils import get_csp_handler from testprofiles.models import TestUser class BackendUtilMethodsTests(TestCase): def test_set_attribute(self): u = TestUser() self.assertFalse(hasattr(u, "custom_attribute")) # Set attribute initially changed = set_attribute(u, "custom_attribute", "value") self.assertTrue(changed) self.assertEqual(u.custom_attribute, "value") # 'Update' to the same value again changed_same = set_attribute(u, "custom_attribute", "value") self.assertFalse(changed_same) self.assertEqual(u.custom_attribute, "value") # Update to a different value changed_different = set_attribute(u, "custom_attribute", "new_value") self.assertTrue(changed_different) self.assertEqual(u.custom_attribute, "new_value") class dummyNameId: text = "dummyNameId" class Saml2BackendTests(TestCase): """UnitTests on backend classes""" backend_cls = Saml2Backend def setUp(self): self.backend = self.backend_cls() self.user = TestUser.objects.create(username="john") def test_get_model_ok(self): self.assertEqual(self.backend._user_model, TestUser) def test_get_model_nonexisting(self): with override_settings(SAML_USER_MODEL="testprofiles.NonExisting"): with self.assertRaisesMessage( ImproperlyConfigured, "Model 'testprofiles.NonExisting' could not be loaded", ): self.assertEqual(self.backend._user_model, None) def test_get_model_invalid_specifier(self): with override_settings( SAML_USER_MODEL="random_package.specifier.testprofiles.NonExisting" ): with self.assertRaisesMessage( ImproperlyConfigured, "Model was specified as 'random_package.specifier.testprofiles.NonExisting', but it must be of the form 'app_label.model_name'", ): self.assertEqual(self.backend._user_model, None) def test_user_model_specified(self): with override_settings(AUTH_USER_MODEL="auth.User"): with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): self.assertEqual(self.backend._user_model, TestUser) def test_user_model_default(self): with override_settings(AUTH_USER_MODEL="auth.User"): self.assertEqual(self.backend._user_model, DjangoUserModel) def test_user_lookup_attribute_specified(self): with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): with override_settings(SAML_DJANGO_USER_MAIN_ATTRIBUTE="age"): self.assertEqual(self.backend._user_lookup_attribute, "age") def test_user_lookup_attribute_default(self): with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): self.assertEqual(self.backend._user_lookup_attribute, "username") def test_extract_user_identifier_params_use_nameid_present(self): with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): with override_settings(SAML_USE_NAME_ID_AS_USERNAME=True): _, lookup_value = self.backend._extract_user_identifier_params( {"name_id": dummyNameId()}, {}, {} ) self.assertEqual(lookup_value, "dummyNameId") def test_extract_user_identifier_params_use_nameid_missing(self): with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): with override_settings(SAML_USE_NAME_ID_AS_USERNAME=True): _, lookup_value = self.backend._extract_user_identifier_params( {}, {}, {} ) self.assertEqual(lookup_value, None) def test_is_authorized(self): self.assertTrue(self.backend.is_authorized({}, {}, "", {})) def test_clean_attributes(self): attributes = {"random": "dummy", "value": 123} self.assertEqual(self.backend.clean_attributes(attributes, ""), attributes) def test_clean_user_main_attribute(self): self.assertEqual(self.backend.clean_user_main_attribute("value"), "value") def test_update_user_simple(self): u = TestUser(username="johny") self.assertIsNone(u.pk) u = self.backend._update_user(u, {}, {}) self.assertIsNotNone(u.pk) def test_update_user(self): attribute_mapping = { "uid": ("username",), "mail": ("email",), "cn": ("first_name",), "sn": ("last_name",), } attributes = { "uid": ("john",), "mail": ("john@example.com",), "cn": ("John",), "sn": ("Doe",), } self.backend._update_user(self.user, attributes, attribute_mapping) self.assertEqual(self.user.email, "john@example.com") self.assertEqual(self.user.first_name, "John") self.assertEqual(self.user.last_name, "Doe") attribute_mapping["saml_age"] = ("age",) attributes["saml_age"] = ("22",) self.backend._update_user(self.user, attributes, attribute_mapping) self.assertEqual(self.user.age, "22") def test_update_user_callable_attributes(self): attribute_mapping = { "uid": ("username",), "mail": ("email",), "cn": ("process_first_name",), "sn": ("last_name",), } attributes = { "uid": ("john",), "mail": ("john@example.com",), "cn": ("John",), "sn": ("Doe",), } self.backend._update_user(self.user, attributes, attribute_mapping) self.assertEqual(self.user.email, "john@example.com") self.assertEqual(self.user.first_name, "John") self.assertEqual(self.user.last_name, "Doe") def test_update_user_empty_attribute(self): self.user.last_name = "Smith" self.user.save() attribute_mapping = { "uid": ("username",), "mail": ("email",), "cn": ("first_name",), "sn": ("last_name",), } attributes = { "uid": ("john",), "mail": ("john@example.com",), "cn": ("John",), "sn": (), } with self.assertLogs("djangosaml2", level="DEBUG") as logs: self.backend._update_user(self.user, attributes, attribute_mapping) self.assertEqual(self.user.email, "john@example.com") self.assertEqual(self.user.first_name, "John") # empty attribute list: no update self.assertEqual(self.user.last_name, "Smith") self.assertIn( 'DEBUG:djangosaml2:Could not find value for "sn", not updating fields "(\'last_name\',)"', logs.output, ) def test_invalid_model_attribute_log(self): attribute_mapping = { "uid": ["username"], "cn": ["nonexistent"], } attributes = { "uid": ["john"], "cn": ["John"], } with self.assertLogs("djangosaml2", level="DEBUG") as logs: user, _ = self.backend.get_or_create_user( self.backend._user_lookup_attribute, "john", True, None, None, None, None, ) self.backend._update_user(user, attributes, attribute_mapping) self.assertIn( 'DEBUG:djangosaml2:Could not find attribute "nonexistent" on user "john"', logs.output, ) @override_settings(SAML_USER_MODEL="testprofiles.RequiredFieldUser") def test_create_user_with_required_fields(self): attribute_mapping = {"mail": ["email"], "mail_verified": ["email_verified"]} attributes = { "mail": ["john@example.org"], "mail_verified": [True], } # User creation does not fail if several fields are required. user, created = self.backend.get_or_create_user( self.backend._user_lookup_attribute, "john@example.org", True, None, None, None, None, ) self.assertEqual(user.email, "john@example.org") self.assertIs(user.email_verified, None) user = self.backend._update_user(user, attributes, attribute_mapping, created) self.assertIs(user.email_verified, True) def test_django_user_main_attribute(self): old_username_field = get_user_model().USERNAME_FIELD get_user_model().USERNAME_FIELD = "slug" self.assertEqual(self.backend._user_lookup_attribute, "slug") get_user_model().USERNAME_FIELD = old_username_field with override_settings(AUTH_USER_MODEL="auth.User"): self.assertEqual( DjangoUserModel.USERNAME_FIELD, self.backend._user_lookup_attribute ) with override_settings(AUTH_USER_MODEL="testprofiles.StandaloneUserModel"): self.assertEqual(self.backend._user_lookup_attribute, "username") with override_settings(SAML_DJANGO_USER_MAIN_ATTRIBUTE="foo"): self.assertEqual(self.backend._user_lookup_attribute, "foo") def test_get_or_create_user_existing(self): with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): user, created = self.backend.get_or_create_user( self.backend._user_lookup_attribute, "john", False, None, None, None, None, ) self.assertTrue(isinstance(user, TestUser)) self.assertFalse(created) def test_get_or_create_user_duplicates(self): TestUser.objects.create(username="paul") with self.assertLogs("djangosaml2", level="DEBUG") as logs: with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): user, created = self.backend.get_or_create_user( "age", "", False, None, None, None, None ) self.assertTrue(user is None) self.assertFalse(created) self.assertIn( "ERROR:djangosaml2:Multiple users match, model: testprofiles.testuser, lookup: {'age': ''}", logs.output[0], ) def test_get_or_create_user_no_create(self): with self.assertLogs("djangosaml2", level="DEBUG") as logs: with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): user, created = self.backend.get_or_create_user( self.backend._user_lookup_attribute, "paul", False, None, None, None, None, ) self.assertTrue(user is None) self.assertFalse(created) self.assertIn( "ERROR:djangosaml2:The user does not exist, model: testprofiles.testuser, lookup: {'username': 'paul'}", logs.output[0], ) def test_get_or_create_user_create(self): with self.assertLogs("djangosaml2", level="DEBUG") as logs: with override_settings(SAML_USER_MODEL="testprofiles.TestUser"): user, created = self.backend.get_or_create_user( self.backend._user_lookup_attribute, "paul", True, None, None, None, None, ) self.assertTrue(isinstance(user, TestUser)) self.assertTrue(created) self.assertIn( f"DEBUG:djangosaml2:New user created: {user}", logs.output[0], ) def test_deprecations(self): attribute_mapping = {"mail": ["email"], "mail_verified": ["email_verified"]} attributes = { "mail": ["john@example.org"], "mail_verified": [True], } old = self.backend.get_attribute_value( "email_verified", attributes, attribute_mapping ) self.assertEqual(old, True) self.assertEqual( self.backend.get_django_user_main_attribute(), self.backend._user_lookup_attribute, ) with override_settings(SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP="user_name"): self.assertEqual( self.backend.get_django_user_main_attribute_lookup(), settings.SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP, ) self.assertEqual(self.backend.get_user_query_args(""), {"username"}) u = TestUser(username="mathieu") self.assertEqual(u.email, "") new_u = self.backend.configure_user(u, attributes, attribute_mapping) self.assertIsNotNone(new_u.pk) self.assertEqual(new_u.email, "john@example.org") u = TestUser(username="mathieu_2") self.assertEqual(u.email, "") new_u = self.backend.update_user(u, attributes, attribute_mapping) self.assertIsNotNone(new_u.pk) self.assertEqual(new_u.email, "john@example.org") u = TestUser() self.assertTrue(self.backend._set_attribute(u, "new_attribute", True)) self.assertFalse(self.backend._set_attribute(u, "new_attribute", True)) self.assertTrue(self.backend._set_attribute(u, "new_attribute", False)) self.assertEqual(get_saml_user_model(), TestUser) class CustomizedBackend(Saml2Backend): """Override the available methods with some customized implementation to test customization""" def is_authorized( self, attributes, attribute_mapping, idp_entityid: str, assertion_info, **kwargs ): """Allow only staff users from the IDP""" return ( attributes.get("is_staff", (None,))[0] == True and assertion_info.get("assertion_id", None) != None ) def clean_attributes(self, attributes: dict, idp_entityid: str, **kwargs) -> dict: """Keep only certain attribute""" return { "age": attributes.get("age", (None,)), "mail": attributes.get("mail", (None,)), "is_staff": attributes.get("is_staff", (None,)), "uid": attributes.get("uid", (None,)), } def clean_user_main_attribute(self, main_attribute): """Partition string on @ and return the first part""" if main_attribute: return main_attribute.partition("@")[0] return main_attribute class CustomizedSaml2BackendTests(Saml2BackendTests): backend_cls = CustomizedBackend def test_is_authorized(self): attribute_mapping = { "uid": ("username",), "mail": ("email",), "cn": ("first_name",), "sn": ("last_name",), } attributes = { "uid": ("john",), "mail": ("john@example.com",), "cn": ("John",), "sn": ("Doe",), } assertion_info = { "assertion_id": None, "not_on_or_after": None, } self.assertFalse( self.backend.is_authorized( attributes, attribute_mapping, "", assertion_info ) ) attributes["is_staff"] = (True,) self.assertFalse( self.backend.is_authorized( attributes, attribute_mapping, "", assertion_info ) ) assertion_info["assertion_id"] = "abcdefg12345" self.assertTrue( self.backend.is_authorized( attributes, attribute_mapping, "", assertion_info ) ) def test_clean_attributes(self): attributes = {"random": "dummy", "value": 123, "age": "28"} self.assertEqual( self.backend.clean_attributes(attributes, ""), {"age": "28", "mail": (None,), "is_staff": (None,), "uid": (None,)}, ) def test_clean_user_main_attribute(self): self.assertEqual( self.backend.clean_user_main_attribute("john@example.com"), "john" ) def test_authenticate(self): attribute_mapping = { "uid": ("username",), "mail": ("email",), "cn": ("first_name",), "sn": ("last_name",), "age": ("age",), "is_staff": ("is_staff",), } attributes = { "uid": ("john",), "mail": ("john@example.com",), "cn": ("John",), "sn": ("Doe",), "age": ("28",), "is_staff": (True,), } assertion_info = { "assertion_id": "abcdefg12345", "not_on_or_after": "", } self.assertEqual(self.user.age, "") self.assertEqual(self.user.is_staff, False) user = self.backend.authenticate(None) self.assertIsNone(user) user = self.backend.authenticate( None, session_info={"random": "content"}, attribute_mapping=attribute_mapping, assertion_info=assertion_info, ) self.assertIsNone(user) with override_settings(SAML_USE_NAME_ID_AS_USERNAME=True): user = self.backend.authenticate( None, session_info={"ava": attributes, "issuer": "dummy_entity_id"}, attribute_mapping=attribute_mapping, assertion_info=assertion_info, ) self.assertIsNone(user) attributes["is_staff"] = (False,) user = self.backend.authenticate( None, session_info={"ava": attributes, "issuer": "dummy_entity_id"}, attribute_mapping=attribute_mapping, assertion_info=assertion_info, ) self.assertIsNone(user) attributes["is_staff"] = (True,) user = self.backend.authenticate( None, session_info={"ava": attributes, "issuer": "dummy_entity_id"}, attribute_mapping=attribute_mapping, assertion_info=assertion_info, ) self.assertEqual(user, self.user) self.user.refresh_from_db() self.assertEqual(self.user.age, "28") self.assertEqual(self.user.is_staff, True) def test_user_cleaned_main_attribute(self): """ In this test the username is taken from the `mail` attribute, but cleaned to remove the @domain part. After fetching and updating the user, the username remains the same. """ attribute_mapping = { "mail": ("username",), "cn": ("first_name",), "sn": ("last_name",), "is_staff": ("is_staff",), } attributes = { "mail": ("john@example.com",), "cn": ("John",), "sn": ("Doe",), "is_staff": (True,), } assertion_info = { "assertion_id": "abcdefg12345", } user = self.backend.authenticate( None, session_info={"ava": attributes, "issuer": "dummy_entity_id"}, attribute_mapping=attribute_mapping, assertion_info=assertion_info, ) self.assertEqual(user, self.user) self.user.refresh_from_db() self.assertEqual(user.username, "john") class CSPHandlerTests(TestCase): def test_get_csp_handler_none(self): get_csp_handler.cache_clear() with override_settings(SAML_CSP_HANDLER=None): csp_handler = get_csp_handler() self.assertIn( csp_handler.__module__, ["csp.decorators", "djangosaml2.utils"] ) self.assertIn(csp_handler.__name__, ["decorator", "empty_view_decorator"]) def test_get_csp_handler_empty(self): get_csp_handler.cache_clear() with override_settings(SAML_CSP_HANDLER=""): csp_handler = get_csp_handler() self.assertEqual(csp_handler.__name__, "empty_view_decorator") def test_get_csp_handler_specified(self): get_csp_handler.cache_clear() with override_settings(SAML_CSP_HANDLER="testprofiles.utils.csp_handler"): client = Client() response = client.get(reverse("saml2_login")) self.assertIn("Content-Security-Policy", response.headers) self.assertEqual( response.headers["Content-Security-Policy"], "testing CSP value" ) def test_get_csp_handler_specified_missing(self): get_csp_handler.cache_clear() with override_settings(SAML_CSP_HANDLER="does.not.exist"): with self.assertRaises(ImportError): get_csp_handler() djangosaml2-1.10.1/tests/testprofiles/urls.py000066400000000000000000000005751476674771500212560ustar00rootroot00000000000000from django.http import HttpResponse from django.urls import include, path from django.contrib import admin testpatterns = ( [path("dashboard/", lambda request: HttpResponse(""), name="dashboard")], "testprofiles", # app_name ) urlpatterns = [ path("saml2/", include("djangosaml2.urls")), path("admin/", admin.site.urls), path("", include(testpatterns)), ] djangosaml2-1.10.1/tests/testprofiles/utils.py000066400000000000000000000001651476674771500214240ustar00rootroot00000000000000def csp_handler(response): response.headers["Content-Security-Policy"] = "testing CSP value" return response djangosaml2-1.10.1/tox.ini000066400000000000000000000006161476674771500153410ustar00rootroot00000000000000[tox] envlist = py{3.9,3.10,3.11,3.12,3.13}-django{4.2,5.0,5.1} [testenv] commands = pip list python tests/run_tests.py deps = django4.2: django~=4.2 django5.0: django~=5.0 django5.1: django~=5.1 djangomaster: https://github.com/django/django/archive/master.tar.gz . ignore_outcome = djangomaster: True setenv = PYTHONWARNINGS=module::DeprecationWarning