From 053aa703bfac34726a843171293f4c344f08a07d Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Wed, 28 Dec 2022 15:56:18 +0100 Subject: [PATCH] =?UTF-8?q?Rename=20the=20library,=20support=20for=20DI,?= =?UTF-8?q?=20v1=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 54 ++-- .gitignore | 2 + CHANGELOG.md | 40 ++- MANIFEST.in | 3 - Makefile | 50 +++- README.md | 83 +++--- examples-summary.py | 31 +++ examples/README.md | 44 +++ .../asynchronous => examples}/__init__.py | 0 examples/example-01.py | 50 ++++ examples/example-02.py | 50 ++++ examples/example-03.py | 54 ++++ examples/example-04.py | 53 ++++ examples/example-05.py | 54 ++++ examples/example-06.py | 135 +++++++++ examples/example-07.py | 75 +++++ guardpost/__init__.py | 8 - guardpost/asynchronous/authentication.py | 28 -- guardpost/asynchronous/authorization.py | 78 ------ guardpost/authentication.py | 96 ------- guardpost/authorization.py | 164 ----------- guardpost/funchelper.py | 24 -- guardpost/synchronous/__init__.py | 0 guardpost/synchronous/authentication.py | 28 -- guardpost/synchronous/authorization.py | 70 ----- ...-workspace => neoteroi-auth.code-workspace | 1 - neoteroi/auth/__about__.py | 1 + neoteroi/auth/__init__.py | 37 +++ neoteroi/auth/abc.py | 55 ++++ neoteroi/auth/authentication.py | 159 +++++++++++ neoteroi/auth/authorization.py | 257 ++++++++++++++++++ {guardpost => neoteroi/auth}/common.py | 12 +- {guardpost => neoteroi/auth}/errors.py | 0 {guardpost => neoteroi/auth}/jwks/__init__.py | 2 +- {guardpost => neoteroi/auth}/jwks/caching.py | 0 {guardpost => neoteroi/auth}/jwks/openid.py | 2 +- {guardpost => neoteroi/auth}/jwks/urls.py | 2 +- {guardpost => neoteroi/auth}/jwts/__init__.py | 0 {guardpost => neoteroi/auth}/py.typed | 0 {guardpost => neoteroi/auth}/utils.py | 0 pyproject.toml | 60 ++++ requirements.txt | 1 + setup.py | 51 ---- tests/examples.py | 3 +- tests/serverfixtures.py | 2 +- tests/test_authentication.py | 69 ++++- tests/test_authorization.py | 100 ++++++- tests/test_common.py | 47 +++- tests/test_examples.py | 17 ++ tests/test_funchelper.py | 27 -- tests/test_jwks.py | 4 +- tests/test_jwts.py | 10 +- tests/test_sync_authentication.py | 35 --- tests/test_sync_authorization.py | 134 --------- 54 files changed, 1484 insertions(+), 878 deletions(-) delete mode 100644 MANIFEST.in create mode 100644 examples-summary.py create mode 100644 examples/README.md rename {guardpost/asynchronous => examples}/__init__.py (100%) create mode 100644 examples/example-01.py create mode 100644 examples/example-02.py create mode 100644 examples/example-03.py create mode 100644 examples/example-04.py create mode 100644 examples/example-05.py create mode 100644 examples/example-06.py create mode 100644 examples/example-07.py delete mode 100644 guardpost/__init__.py delete mode 100644 guardpost/asynchronous/authentication.py delete mode 100644 guardpost/asynchronous/authorization.py delete mode 100644 guardpost/authentication.py delete mode 100644 guardpost/authorization.py delete mode 100644 guardpost/funchelper.py delete mode 100644 guardpost/synchronous/__init__.py delete mode 100644 guardpost/synchronous/authentication.py delete mode 100644 guardpost/synchronous/authorization.py rename project.code-workspace => neoteroi-auth.code-workspace (93%) create mode 100644 neoteroi/auth/__about__.py create mode 100644 neoteroi/auth/__init__.py create mode 100644 neoteroi/auth/abc.py create mode 100644 neoteroi/auth/authentication.py create mode 100644 neoteroi/auth/authorization.py rename {guardpost => neoteroi/auth}/common.py (84%) rename {guardpost => neoteroi/auth}/errors.py (100%) rename {guardpost => neoteroi/auth}/jwks/__init__.py (98%) rename {guardpost => neoteroi/auth}/jwks/caching.py (100%) rename {guardpost => neoteroi/auth}/jwks/openid.py (95%) rename {guardpost => neoteroi/auth}/jwks/urls.py (92%) rename {guardpost => neoteroi/auth}/jwts/__init__.py (100%) rename {guardpost => neoteroi/auth}/py.typed (100%) rename {guardpost => neoteroi/auth}/utils.py (100%) create mode 100644 pyproject.toml delete mode 100644 setup.py create mode 100644 tests/test_examples.py delete mode 100644 tests/test_funchelper.py delete mode 100644 tests/test_sync_authentication.py delete mode 100644 tests/test_sync_authorization.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a74db29..69cc82b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ on: - "*" env: - PROJECT_NAME: guardpost + PROJECT_NAME: neoteroi jobs: build: @@ -29,7 +29,7 @@ jobs: submodules: false - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} @@ -61,8 +61,13 @@ jobs: - name: Run tests run: | + pip install -e . pytest --doctest-modules --junitxml=junit/pytest-results-${{ matrix.python-version }}.xml --cov=$PROJECT_NAME --cov-report=xml tests/ + - name: Test examples + run: | + for f in ./examples/*.py; do echo "Processing $f file..." && python $f; done + - name: Upload pytest test results uses: actions/upload-artifact@master with: @@ -75,19 +80,19 @@ jobs: bash <(curl -s https://codecov.io/bash) - name: Install distribution dependencies - run: pip install --upgrade twine setuptools wheel - if: matrix.python-version == 3.8 || matrix.python-version == 3.9 + run: pip install --upgrade build + if: matrix.python-version == 3.10 - name: Create distribution package - run: python setup.py sdist bdist_wheel - if: matrix.python-version == 3.8 || matrix.python-version == 3.9 + run: python -m build + if: matrix.python-version == 3.10 - name: Upload distribution package uses: actions/upload-artifact@master with: - name: dist-package-${{ matrix.python-version }} + name: dist path: dist - if: matrix.python-version == 3.8 || matrix.python-version == 3.9 + if: matrix.python-version == 3.10 publish: runs-on: ubuntu-latest @@ -97,17 +102,28 @@ jobs: - name: Download a distribution artifact uses: actions/download-artifact@v2 with: - name: dist-package-3.9 + name: dist path: dist - - name: Publish distribution 📦 to Test PyPI - uses: pypa/gh-action-pypi-publish@master + + - name: Use Python 3.11 + uses: actions/setup-python@v1 with: - skip_existing: true - user: __token__ - password: ${{ secrets.test_pypi_password }} - repository_url: https://test.pypi.org/legacy/ + python-version: '3.11' + + - name: Install dependencies + run: | + pip install twine + + - name: Publish distribution 📦 to Test PyPI + run: | + twine upload -r testpypi dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.test_pypi_password2 }} + - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.pypi_password }} + run: | + twine upload -r pypi dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.pypi_password2 }} diff --git a/.gitignore b/.gitignore index cfac2fc..1402cbb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ __pycache__ *.egg-info *.tar.gz .mypy_cache +junit +coverage.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f714d..9e554d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,27 +5,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] - 2022-12-xx :star: +- Renames the library to `neoteroi-auth`. +- Adopts PEP 420 and renames the main namespace from `guardpost` to `neoteroi.auth` +- Adds built-in support for dependency injection, using the new `ContainerProtocol` + in `neoteroi-di` (new version of `rodi`). +- Removes the synchronous code API, maintaining only the asynchronous code API + for `AuthenticationStrategy.authenticate` and `AuthorizationStrategy.authorize`. +- Replaces `setup.py` with `pyproject.toml`. +- Reduces imports verbosity. +- Improves the `identity_getter` code API. +- Corrects `Identity.__getitem__` to raise `KeyError` if a claim is missing. + ## [0.1.0] - 2022-11-06 :snake: -- Workflow maintenance +- Workflow maintenance. ## [0.0.9] - 2021-11-14 :swan: -- Adds `sub`, `access_token`, and `refresh_token` properties to the `Identity` +- Adds `sub`, `access_token`, and `refresh_token` properties to the `Identity`. class -- Adds `py.typed` file +- Adds `py.typed` file. ## [0.0.8] - 2021-10-31 :shield: -- Adds classes to handle `JWT`s validation, but only for `RSA` keys -- Fixes issue (wrong arrangement in test) #5 -- Includes `Python 3.10` in the CI/CD matrix -- Enforces `black` and `isort` in the CI pipeline +- Adds classes to handle `JWT`s validation, but only for `RSA` keys. +- Fixes issue (wrong arrangement in test) #5. +- Includes `Python 3.10` in the CI/CD matrix. +- Enforces `black` and `isort` in the CI pipeline. ## [0.0.7] - 2021-01-31 :grapes: -- Corrects a bug in the `Policy` class (#2) -- Changes the type annotation of `Identity` claims (#3) +- Corrects a bug in the `Policy` class (#2). +- Changes the type annotation of `Identity` claims (#3). ## [0.0.6] - 2020-12-12 :octocat: -- Completely migrates to GitHub Workflows -- Improves build to test Python 3.6 and 3.9 -- Adds a changelog -- Improves badges -- Improves code quality using `flake8` and `black` +- Completely migrates to GitHub Workflows. +- Improves build to test Python 3.6 and 3.9. +- Adds a changelog. +- Improves badges. +- Improves code quality using `flake8` and `black`. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 7520496..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include README.md -include LICENSE -recursive-include guardpost py.typed diff --git a/Makefile b/Makefile index 286270f..bd08b8c 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,12 @@ .PHONY: release test +lint-types: + mypy neoteroi --explicit-package-bases + + artifacts: test - python setup.py sdist bdist_wheel + python -m build clean: @@ -10,20 +14,50 @@ clean: prepforbuild: - pip install --upgrade twine setuptools wheel + pip install build + +build: + python -m build -uploadtest: - twine upload --repository-url https://test.pypi.org/legacy/ dist/* +test-release: + twine upload --repository testpypi dist/* -release: clean artifacts - twine upload --repository-url https://upload.pypi.org/legacy/ dist/* + +release: + twine upload --repository pypi dist/* test: python -m pytest -testcov: - python -m pytest --cov-report html --cov=guardpost tests/ \ No newline at end of file +test-cov: + python -m pytest --cov-report html --cov=neoteroi tests/ + + +lint: check-flake8 check-isort check-black + +format: + @isort neoteroi 2>&1 + @isort tests 2>&1 + @black neoteroi 2>&1 + @black tests 2>&1 + +check-flake8: + @echo "$(BOLD)Checking flake8$(RESET)" + @flake8 neoteroi 2>&1 + @flake8 tests 2>&1 + + +check-isort: + @echo "$(BOLD)Checking isort$(RESET)" + @isort --check-only neoteroi 2>&1 + @isort --check-only tests 2>&1 + + +check-black: ## Run the black tool in check mode only (won't modify files) + @echo "$(BOLD)Checking black$(RESET)" + @black --check neoteroi 2>&1 + @black --check tests 2>&1 diff --git a/README.md b/README.md index 4c15608..e735e6e 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,64 @@ [![Build](https://github.com/Neoteroi/guardpost/workflows/Build/badge.svg)](https://github.com/Neoteroi/guardpost/actions?query=workflow%3ABuild) -[![pypi](https://img.shields.io/pypi/v/guardpost.svg?color=blue)](https://pypi.org/project/guardpost/) -[![versions](https://img.shields.io/pypi/pyversions/guardpost.svg)](https://github.com/Neoteroi/guardpost) -[![license](https://img.shields.io/github/license/Neoteroi/guardpost.svg)](https://github.com/Neoteroi/guardpost/blob/master/LICENSE) -[![codecov](https://codecov.io/gh/Neoteroi/guardpost/branch/master/graph/badge.svg?token=sBKZG2D1bZ)](https://codecov.io/gh/Neoteroi/guardpost) +[![pypi](https://img.shields.io/pypi/v/neoteroi-auth.svg?color=blue)](https://pypi.org/project/neoteroi-auth/) +[![versions](https://img.shields.io/pypi/pyversions/neoteroi-auth.svg)](https://github.com/Neoteroi/guardpost) +[![license](https://img.shields.io/github/license/Neoteroi/guardpost.svg)](https://github.com/Neoteroi/guardpost/blob/main/LICENSE) +[![codecov](https://codecov.io/gh/Neoteroi/guardpost/branch/main/graph/badge.svg?token=sBKZG2D1bZ)](https://codecov.io/gh/Neoteroi/guardpost) -# GuardPost -GuardPost provides a basic framework to handle authentication and authorization -in any kind of Python application. +# Authentication and authorization framework for Python apps +Basic framework to handle authentication and authorization in asynchronous +Python applications. + +**Features:** + +- strategy to implement authentication (who or what is using a service?) +- strategy to implement authorization (is the acting identity authorized to do a certain action?) +- support for dependency injection for classes handling authentication and + authorization requirements +- built-in support for JSON Web Tokens (JWTs) authentication + +This library is freely inspired by [authorization in ASP.NET +Core](https://docs.microsoft.com/en-us/aspnet/core/security/authorization/policies?view=aspnetcore-2.2); +although its implementation is extremely different. + +## Installation ```bash -pip install guardpost +pip install neoteroi-auth ``` To install with support for `JSON Web Tokens (JWTs)` validation: ``` -pip install guardpost[jwt] +pip install neoteroi-auth[jwt] ``` -This library is freely inspired by [authorization in ASP.NET -Core](https://docs.microsoft.com/en-us/aspnet/core/security/authorization/policies?view=aspnetcore-2.2); -although its implementation is extremely different. - -Notable differences are: -1. GuardPost is abstracted from the code that executes it, so it's not bound to - the context of a web framework. -1. GuardPost implements both classes for use with synchronous code (not - necessarily I/O bound), and classes using `async/await` syntax (optimized - for authentication and authorization rules that involve I/O bound operations - such as web requests and communications with databases). -1. GuardPost leverages Python function decorators for the authorization part, - so any function can be wrapped to be executed after handling authorization. -1. The code API is simpler. - -## More documentation and examples -For documentation and -[examples](https://github.com/RobertoPrevato/GuardPost/wiki/Examples), refer to -the project [Wiki](https://github.com/RobertoPrevato/GuardPost/wiki). - -To see how `guardpost` is used in `blacksheep` web framework, read the -documentation here: +### Examples -* [Authentication](https://www.neoteroi.dev/blacksheep/authentication/) -* [Authorization](https://www.neoteroi.dev/blacksheep/authorization/) - -## Both for async/await and synchronous code -GuardPost can be used both with async/await code and with synchronous code, -according to use cases and users' preference. +For examples, refer to the [examples folder](./examples). ## If you have doubts about authentication vs authorization... -`Authentication` answers the question: _Who is the user who is executing the +`Authentication` answers the question: _Who is the user who is initiating the action?_, or more in general: _Who is the user, or what is the service, that is -executing the action?_. +initiating the action?_. `Authorization` answers the question: _Is the user, or service, authorized to do something?_. Usually, to implement authorization, is necessary to have the context of the -entity that is executing the action. Anyway, the two things are logically -separated and GuardPost is designed to keep them separate. +entity that is executing the action. ## Usage in BlackSheep -`guardpost` is used in the [BlackSheep](https://www.neoteroi.dev/blacksheep/) -web framework to implement [authentication and authorization +`neoteroi-auth` is used in the second version of the +[BlackSheep](https://www.neoteroi.dev/blacksheep/) web framework, to implement +[authentication and authorization strategies](https://www.neoteroi.dev/blacksheep/authentication/) for request handlers. + +To see how `neoteroi-auth` is used in `blacksheep` web framework, read: + +* [Authentication](https://www.neoteroi.dev/blacksheep/authentication/) +* [Authorization](https://www.neoteroi.dev/blacksheep/authorization/) + +# Documentation + +Under construction. 🚧 diff --git a/examples-summary.py b/examples-summary.py new file mode 100644 index 0000000..5863967 --- /dev/null +++ b/examples-summary.py @@ -0,0 +1,31 @@ +""" +Generates a README.md file for the examples folder. +""" + +import glob +import importlib +import sys + +examples = [file for file in glob.glob("./examples/*.py")] +examples.sort() +sys.path.append("./examples") + +with open("./examples/README.md", mode="wt", encoding="utf8 ") as examples_readme: + examples_readme.write( + "\n\n" + ) + examples_readme.write("""# Examples""") + + for file_path in examples: + if "__init__" in file_path: + continue + + module_name = file_path.replace("./examples/", "").replace(".py", "") + + module = importlib.import_module(module_name) + + if not module.__doc__: + continue + + examples_readme.write(f"\n\n## {module_name}.py\n") + examples_readme.write(str(module.__doc__)) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5555ee0 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,44 @@ + + +# Examples + +## example-01.py + +This example illustrates a basic use of the authentication strategy, using a single +authentication handler. + + +## example-02.py + +This example illustrates a basic use of the authentication strategy, using more than +one way to obtain the user's identity. + + +## example-03.py + +This example illustrates a basic use of the authentication strategy, showing how +authentication handlers can be grouped by authentication schemes. + + +## example-04.py + +This example illustrates how dependency injection can be used for authentication +handlers. + + +## example-05.py + +This example illustrates a basic use of an authorization strategy. + + +## example-06.py + +This example illustrates how to use an authorization strategy having more than one set +of criteria to handle authorization, and how to decorate methods so they require +authorization. + + +## example-07.py + +This example illustrates a basic use of an authorization strategy, with support for +dependency injection for authorization requirements. diff --git a/guardpost/asynchronous/__init__.py b/examples/__init__.py similarity index 100% rename from guardpost/asynchronous/__init__.py rename to examples/__init__.py diff --git a/examples/example-01.py b/examples/example-01.py new file mode 100644 index 0000000..c34bafb --- /dev/null +++ b/examples/example-01.py @@ -0,0 +1,50 @@ +""" +This example illustrates a basic use of the authentication strategy, using a single +authentication handler. +""" +import asyncio + +from neoteroi.auth import AuthenticationHandler, AuthenticationStrategy, Identity + + +class MyAppContext: + """ + This represents a context for an application - it can be anything depending on + use cases and the user's notion of application context. + """ + + def __init__(self) -> None: + self.identity: Identity | None = None + + +class CustomAuthenticationHandler(AuthenticationHandler): + def authenticate(self, context: MyAppContext) -> "Identity | None": + """ + Obtains an identity for a context. + + For example, this might read information from a user's folder, an HTTP Request + cookie or authorization header, or an external service. This method can be + either synchronous or asynchronous. + """ + + return Identity({"sub": "example"}) + + +# NOTE: a AuthenticationHandler.authenticate method can also be async! + + +async def main(): + some_context = MyAppContext() + + authentication = AuthenticationStrategy(CustomAuthenticationHandler()) + + identity = await authentication.authenticate(some_context) + + assert identity is not None + assert identity.sub == "example" + + # the identity is set on the given context + assert some_context.identity is identity + + +asyncio.run(main()) diff --git a/examples/example-02.py b/examples/example-02.py new file mode 100644 index 0000000..ed08dbe --- /dev/null +++ b/examples/example-02.py @@ -0,0 +1,50 @@ +""" +This example illustrates a basic use of the authentication strategy, using more than +one way to obtain the user's identity. +""" +import asyncio + +from neoteroi.auth import AuthenticationHandler, AuthenticationStrategy, Identity + + +class MyAppContext: + """ + This represents a context for an application - it can be anything depending on + use cases and the user's notion of application context. + """ + + def __init__(self) -> None: + self.identity: Identity | None = None + + +class CustomAuthenticationHandler(AuthenticationHandler): + def authenticate(self, context: MyAppContext) -> "Identity | None": + """ + In this example, we simulate a situation in which an identity cannot be + determined for a context. Another Authenticationhandler + """ + return None + + +class AlternativeAuthenticationHandler(AuthenticationHandler): + def authenticate(self, context: MyAppContext) -> "Identity | None": + return Identity({"sub": "002"}) + + +async def main(): + some_context = MyAppContext() + + authentication = AuthenticationStrategy( + CustomAuthenticationHandler(), AlternativeAuthenticationHandler() + ) + + identity = await authentication.authenticate(some_context) + + assert identity is not None + assert identity.sub == "002" + + # the identity is set on the given context + assert some_context.identity is identity + + +asyncio.run(main()) diff --git a/examples/example-03.py b/examples/example-03.py new file mode 100644 index 0000000..91f0cd9 --- /dev/null +++ b/examples/example-03.py @@ -0,0 +1,54 @@ +""" +This example illustrates a basic use of the authentication strategy, showing how +authentication handlers can be grouped by authentication schemes. +""" +import asyncio + +from neoteroi.auth import AuthenticationHandler, AuthenticationStrategy, Identity + + +class MyAppContext: + """ + This represents a context for an application - it can be anything depending on + use cases and the user's notion of application context. + """ + + def __init__(self) -> None: + self.identity: Identity | None = None + + +class AuthenticationHandlerOne(AuthenticationHandler): + @property + def scheme(self) -> str: + return "one" + + def authenticate(self, context: MyAppContext) -> "Identity | None": + return Identity({"sub": "001"}, self.scheme) + + +class AuthenticationHandlerTwo(AuthenticationHandler): + @property + def scheme(self) -> str: + return "two" + + def authenticate(self, context: MyAppContext) -> "Identity | None": + return Identity({"sub": "002"}, self.scheme) + + +async def main(): + authentication = AuthenticationStrategy( + AuthenticationHandlerOne(), AuthenticationHandlerTwo() + ) + + for scheme in ["one", "two"]: + some_context = MyAppContext() + + identity = await authentication.authenticate(some_context, [scheme]) + + assert identity is not None + assert identity.authentication_mode == scheme + + assert some_context.identity is identity + + +asyncio.run(main()) diff --git a/examples/example-04.py b/examples/example-04.py new file mode 100644 index 0000000..38c256b --- /dev/null +++ b/examples/example-04.py @@ -0,0 +1,53 @@ +""" +This example illustrates how dependency injection can be used for authentication +handlers. +""" +import asyncio + +from neoteroi.di import Container + +from neoteroi.auth import AuthenticationHandler, AuthenticationStrategy, Identity + + +class MyAppContext: + """ + This represents a context for an application - it can be anything depending on + use cases and the user's notion of application context. + """ + + def __init__(self) -> None: + self.identity: Identity | None = None + + +class Foo: + """Example to illustrate dependency injection.""" + + +class MyAuthenticationHandler(AuthenticationHandler): + def __init__(self, foo: Foo) -> None: + # foo will be injected + self.foo = foo + + def authenticate(self, context: MyAppContext) -> "Identity | None": + assert isinstance(self.foo, Foo) + return Identity({"sub": "001"}, self.scheme) + + +async def main(): + container = Container() + + container.register(Foo) + container.register(MyAuthenticationHandler) + + authentication = AuthenticationStrategy( + MyAuthenticationHandler, container=container + ) + + some_context = MyAppContext() + + identity = await authentication.authenticate(some_context) + + assert identity is not None + + +asyncio.run(main()) diff --git a/examples/example-05.py b/examples/example-05.py new file mode 100644 index 0000000..9ce2968 --- /dev/null +++ b/examples/example-05.py @@ -0,0 +1,54 @@ +""" +This example illustrates a basic use of an authorization strategy. +""" +from __future__ import annotations + +import asyncio + +from neoteroi.auth import ( + AuthorizationContext, + AuthorizationError, + AuthorizationStrategy, + Identity, + Policy, + Requirement, + UnauthorizedError, +) + + +class MyRequirement(Requirement): + def handle(self, context: AuthorizationContext): + # EXAMPLE: implement here the desired notion / requirements for authorization + # + roles = context.identity["roles"] + if roles and "ADMIN" in roles: + context.succeed(self) + else: + context.fail("The user is not an ADMIN") + + +# NOTE: a Requirement.handle method can also be async! + + +async def main(): + authorization = AuthorizationStrategy(Policy("default", MyRequirement())) + + await authorization.authorize( + "default", Identity({"sub": "example", "roles": ["ADMIN"]}) + ) + + auth_error = None + + try: + await authorization.authorize( + "default", Identity({"sub": "example", "roles": ["PEASANT"]}) + ) + except AuthorizationError as error: + auth_error = error + + assert auth_error is not None + assert isinstance(auth_error, UnauthorizedError) + assert "The user is not an ADMIN." in str(auth_error) + + +asyncio.run(main()) diff --git a/examples/example-06.py b/examples/example-06.py new file mode 100644 index 0000000..8152c90 --- /dev/null +++ b/examples/example-06.py @@ -0,0 +1,135 @@ +""" +This example illustrates how to use an authorization strategy having more than one set +of criteria to handle authorization, and how to decorate methods so they require +authorization. +""" +from __future__ import annotations + +import asyncio + +from neoteroi.auth import ( + AuthorizationContext, + AuthorizationError, + AuthorizationStrategy, + Identity, + Policy, + Requirement, +) +from neoteroi.auth.common import AuthenticatedRequirement + + +class MyAppContext: + """ + This represents a context for an application - it can be anything depending on + use cases and the user's notion of application context. + """ + + def __init__(self, identity: Identity) -> None: + self.identity = identity + + +class RoleRequirement(Requirement): + """Require a role to authorize an operation.""" + + def __init__(self, role: str) -> None: + self._role = role + + def handle(self, context: AuthorizationContext): + # EXAMPLE: implement here the desired notion / requirements for authorization + # + roles = context.identity["roles"] + if roles and self._role in roles: + context.succeed(self) + else: + context.fail(f"The user lacks role: {self._role}") + + +# NOTE: a Requirement.handle method can also be async! + + +async def main(): + # In this example, we need to configure a function that can obtain the user identity + # from the application context. + + def get_identity(context: MyAppContext): + return context.identity + + auth = AuthorizationStrategy().with_default_policy( + Policy("default", AuthenticatedRequirement()) + ) + + auth.identity_getter = get_identity + + auth += Policy("admin", RoleRequirement("ADMIN")) + auth += Policy("owner", RoleRequirement("OWNER")) + + @auth() + async def some_method(context: MyAppContext): + """Example method that requires authorization using the default policy.""" + + @auth("owner") + async def create_user(context: MyAppContext): + """Example method that requires an OWNER role""" + + @auth("admin") + async def create_product(context: MyAppContext): + """Example method that requires an ADMIN role""" + + last_error: AuthorizationError | None = None + + # The following will cause an authorization error because the user identity is not + # authenticated, and the default auth policy requires an authenticated user + try: + await some_method(MyAppContext(Identity())) + except AuthorizationError as error: + last_error = error + + assert last_error is not None + + last_error = None + + # The following will work because the context identity is authenticated with a mode + await some_method( + MyAppContext( + Identity({"id": "this is an example"}, authentication_mode="Cookie") + ) + ) + + # The following will cause an authorization error because the user identity does + # not have the proper role to create a user (OWNER). + try: + await create_user( + MyAppContext( + Identity( + {"id": "this is an example", "roles": ["admin"]}, + authentication_mode="Cookie", + ) + ) + ) + except AuthorizationError as error: + last_error = error + + assert last_error is not None + + last_error = None + + # The following will cause an authorization error because the user identity does + # not have the proper role to create a product (ADMIN). + try: + await create_product( + MyAppContext( + Identity( + {"id": "this is an example", "roles": ["admin"]}, + authentication_mode="Cookie", + ) + ) + ) + except AuthorizationError as error: + last_error = error + + assert last_error is not None + + last_error = None + + +asyncio.run(main()) diff --git a/examples/example-07.py b/examples/example-07.py new file mode 100644 index 0000000..0169ef1 --- /dev/null +++ b/examples/example-07.py @@ -0,0 +1,75 @@ +""" +This example illustrates a basic use of an authorization strategy, with support for +dependency injection for authorization requirements. +""" +from __future__ import annotations + +import asyncio + +from neoteroi.di import Container + +from neoteroi.auth import ( + AuthorizationContext, + AuthorizationError, + AuthorizationStrategy, + Identity, + Policy, + Requirement, + UnauthorizedError, +) + + +class Foo: + ... + + +class MyInjectedRequirement(Requirement): + foo: Foo + + def handle(self, context: AuthorizationContext): + assert isinstance(self.foo, Foo) + # EXAMPLE: implement here the desired notion / requirements for authorization + # + roles = context.identity["roles"] + if roles and "ADMIN" in roles: + context.succeed(self) + else: + context.fail("The user is not an ADMIN") + + +# NOTE: a Requirement.handle method can also be async! + + +async def main(): + container = Container() + + # NOTE: the following classes are registered as transient services - therefore + # they are instantiated each time they are necessary. + # Refer to neoteroi-di documentation to know how to register singletons and scoped + # services. + container.register(Foo) + container.register(MyInjectedRequirement) + + authorization = AuthorizationStrategy( + Policy("default", MyInjectedRequirement), container=container + ) + + await authorization.authorize( + "default", Identity({"sub": "example", "roles": ["ADMIN"]}) + ) + + auth_error = None + + try: + await authorization.authorize( + "default", Identity({"sub": "example", "roles": ["PEASANT"]}) + ) + except AuthorizationError as error: + auth_error = error + + assert auth_error is not None + assert isinstance(auth_error, UnauthorizedError) + assert "The user is not an ADMIN." in str(auth_error) + + +asyncio.run(main()) diff --git a/guardpost/__init__.py b/guardpost/__init__.py deleted file mode 100644 index d4399f7..0000000 --- a/guardpost/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .authentication import Identity, User -from .authorization import ( - AuthorizationError, - BaseRequirement, - Policy, - PolicyNotFoundError, - UnauthorizedError, -) diff --git a/guardpost/asynchronous/authentication.py b/guardpost/asynchronous/authentication.py deleted file mode 100644 index 712a154..0000000 --- a/guardpost/asynchronous/authentication.py +++ /dev/null @@ -1,28 +0,0 @@ -from abc import abstractmethod -from typing import Any, Optional, Sequence - -from guardpost.authentication import ( - BaseAuthenticationHandler, - BaseAuthenticationStrategy, - Identity, -) - - -class AuthenticationHandler(BaseAuthenticationHandler): - @abstractmethod - async def authenticate(self, context: Any) -> Optional[Identity]: - """Obtains an identity from a context.""" - - -class AuthenticationStrategy(BaseAuthenticationStrategy): - async def authenticate( - self, context: Any, authentication_schemes: Optional[Sequence[str]] = None - ): - if not context: - raise ValueError("Missing context to evaluate authentication") - - for handler in self.get_handlers(authentication_schemes): - identity = await handler.authenticate(context) - - if identity: - break diff --git a/guardpost/asynchronous/authorization.py b/guardpost/asynchronous/authorization.py deleted file mode 100644 index 32d28e2..0000000 --- a/guardpost/asynchronous/authorization.py +++ /dev/null @@ -1,78 +0,0 @@ -from abc import abstractmethod -from functools import wraps -from typing import Dict, Optional - -from guardpost.authentication import Identity -from guardpost.authorization import ( - AuthorizationContext, - BaseAuthorizationStrategy, - BaseRequirement, - Policy, - PolicyNotFoundError, - UnauthorizedError, -) -from guardpost.funchelper import args_to_dict_getter -from guardpost.synchronous.authorization import Requirement as SyncRequirement - - -class AsyncRequirement(BaseRequirement): - """Base class for asynchronous authorization requirements.""" - - @abstractmethod - async def handle(self, context: AuthorizationContext): - """Handles this requirement for a given context.""" - - -class AuthorizationStrategy(BaseAuthorizationStrategy): - async def _handle_with_identity_getter( - self, policy_name: Optional[str], arguments: Dict - ): - await self.authorize(policy_name, self.identity_getter(arguments)) - - @staticmethod - async def _handle_with_policy(policy: Policy, identity: Identity): - with AuthorizationContext(identity, policy.requirements) as context: - - for requirement in policy.requirements: - if isinstance(requirement, SyncRequirement): - requirement.handle(context) - else: - await requirement.handle(context) - - if not context.has_succeeded: - raise UnauthorizedError( - context.forced_failure, context.pending_requirements - ) - - async def authorize(self, policy_name: Optional[str], identity: Identity): - if policy_name: - policy = self.get_policy(policy_name) - - if not policy: - raise PolicyNotFoundError(policy_name) - - await self._handle_with_policy(policy, identity) - else: - if self.default_policy: - await self._handle_with_policy(self.default_policy, identity) - return - - if not identity: - raise UnauthorizedError("Missing identity", []) - if not identity.is_authenticated(): - raise UnauthorizedError("The resource requires authentication", []) - - def __call__(self, policy: Optional[str] = None): - def decorator(fn): - args_getter = args_to_dict_getter(fn) - - @wraps(fn) - async def wrapper(*args, **kwargs): - await self._handle_with_identity_getter( - policy, args_getter(args, kwargs) - ) - return await fn(*args, **kwargs) - - return wrapper - - return decorator diff --git a/guardpost/authentication.py b/guardpost/authentication.py deleted file mode 100644 index cc3bae6..0000000 --- a/guardpost/authentication.py +++ /dev/null @@ -1,96 +0,0 @@ -from abc import ABC -from typing import Optional, Sequence - - -class Identity: - def __init__( - self, - claims: dict, - authentication_mode: Optional[str] = None, - ): - self.claims = claims or {} - self.authentication_mode = authentication_mode - self.access_token: Optional[str] = None - self.refresh_token: Optional[str] = None - - @property - def sub(self) -> Optional[str]: - return self["sub"] - - def is_authenticated(self) -> bool: - return bool(self.authentication_mode) - - def __getitem__(self, item): - return self.claims.get(item) - - def has_claim(self, name: str) -> bool: - return name in self.claims - - def has_claim_value(self, name: str, value: str) -> bool: - return self.claims.get(name) == value - - -class User(Identity): - @property - def id(self) -> Optional[str]: - return self["id"] or self.sub - - @property - def name(self) -> Optional[str]: - return self["name"] - - @property - def email(self) -> Optional[str]: - return self["email"] - - -class BaseAuthenticationHandler(ABC): - """Base class for authentication handlers""" - - @property - def scheme(self) -> str: - """Returns the name of the Authentication Scheme used by this handler.""" - return self.__class__.__name__ - - -class AuthenticationSchemesNotFound(ValueError): - def __init__( - self, configured_schemes: Sequence[str], required_schemes: Sequence[str] - ): - super().__init__( - "Could not find authentication handlers for required schemes: " - f'{", ".join(required_schemes)}. ' - f'Configured schemes are: {", ".join(configured_schemes)}' - ) - - -class BaseAuthenticationStrategy(ABC): - def __init__(self, *handlers: BaseAuthenticationHandler): - self.handlers = list(handlers) - - def add(self, handler: BaseAuthenticationHandler) -> "BaseAuthenticationStrategy": - self.handlers.append(handler) - return self - - def __iadd__( - self, handler: BaseAuthenticationHandler - ) -> "BaseAuthenticationStrategy": - self.handlers.append(handler) - return self - - def get_handlers(self, authentication_schemes: Optional[Sequence[str]] = None): - if not authentication_schemes: - return self.handlers - - handlers = [ - handler - for handler in self.handlers - if handler.scheme in authentication_schemes - ] - - if not handlers: - raise AuthenticationSchemesNotFound( - [handler.scheme for handler in self.handlers], authentication_schemes - ) - - return handlers diff --git a/guardpost/authorization.py b/guardpost/authorization.py deleted file mode 100644 index 3470191..0000000 --- a/guardpost/authorization.py +++ /dev/null @@ -1,164 +0,0 @@ -from abc import ABC -from typing import Callable, Dict, List, Optional, Sequence - -from guardpost.authentication import Identity - - -class AuthorizationError(Exception): - pass - - -class AuthorizationConfigurationError(Exception): - pass - - -class PolicyNotFoundError(AuthorizationConfigurationError, RuntimeError): - def __init__(self, name: str): - super().__init__(f"Cannot find policy with name {name}") - - -class BaseRequirement(ABC): - """Base class for authorization requirements""" - - def __str__(self): - return self.__class__.__name__ - - -class UnauthorizedError(AuthorizationError): - def __init__( - self, - forced_failure: Optional[str], - failed_requirements: Sequence[BaseRequirement], - scheme: Optional[str] = None, - error: Optional[str] = None, - error_description: Optional[str] = None, - ): - """ - Creates a new instance of UnauthorizedError, with details. - - :param forced_failure: if applicable, the reason for a forced failure. - :param failed_requirements: a sequence of requirements that failed. - :param scheme: optional authentication scheme that should be used. - :param error: optional error short text. - :param error_description: optional error details. - """ - super().__init__(self._get_message(forced_failure, failed_requirements)) - self.failed = forced_failure - self.failed_requirements = failed_requirements - self.scheme = scheme - self.error = error - self.error_description = error_description - - @staticmethod - def _get_message(forced_failure, failed_requirements): - if forced_failure: - return ( - "The user is not authorized to perform the selected action." - + f" {forced_failure}." - ) - - if failed_requirements: - errors = ", ".join(str(requirement) for requirement in failed_requirements) - return ( - f"The user is not authorized to perform the selected action. " - f"Failed requirements: {errors}." - ) - return "Unauthorized" - - -class AuthorizationContext: - - __slots__ = ("identity", "requirements", "_succeeded", "_failed_forced") - - def __init__(self, identity: Identity, requirements: Sequence[BaseRequirement]): - self.identity = identity - self.requirements = requirements - self._succeeded = set() - self._failed_forced = None - - @property - def pending_requirements(self) -> List[BaseRequirement]: - return [item for item in self.requirements if item not in self._succeeded] - - @property - def has_succeeded(self) -> bool: - if self._failed_forced: - return False - return all(requirement in self._succeeded for requirement in self.requirements) - - @property - def forced_failure(self) -> Optional[str]: - return self._failed_forced - - def fail(self, reason: str): - """ - Called to indicate that this authorization context has failed. - Forces failure, regardless of succeeded requirements. - """ - self._failed_forced = reason or "Authorization failed." - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.clear() - - def succeed(self, requirement: BaseRequirement): - """Marks the given requirement as succeeded for this authorization context.""" - self._succeeded.add(requirement) - - def clear(self): - self._failed_forced = False - self._succeeded.clear() - - -class Policy: - - __slots__ = ("name", "requirements") - - def __init__(self, name: str, *requirements: BaseRequirement): - self.name = name - self.requirements = list(requirements) or [] - - def add(self, requirement: BaseRequirement) -> "Policy": - self.requirements.append(requirement) - return self - - def __iadd__(self, other: BaseRequirement): - if not isinstance(other, BaseRequirement): - raise ValueError("Only requirements can be added using __iadd__ syntax") - self.requirements.append(other) - return self - - def __repr__(self): - return f'' - - -class BaseAuthorizationStrategy(ABC): - def __init__( - self, - *policies: Policy, - default_policy: Optional[Policy] = None, - identity_getter: Optional[Callable[[Dict], Identity]] = None, - ): - self.policies = list(policies) - self.default_policy = default_policy - self.identity_getter = identity_getter - - def get_policy(self, name: str) -> Optional[Policy]: - for policy in self.policies: - if policy.name == name: - return policy - return None - - def add(self, policy: Policy) -> "BaseAuthorizationStrategy": - self.policies.append(policy) - return self - - def __iadd__(self, policy: Policy) -> "BaseAuthorizationStrategy": - self.policies.append(policy) - return self - - def with_default_policy(self, policy: Policy) -> "BaseAuthorizationStrategy": - self.default_policy = policy - return self diff --git a/guardpost/funchelper.py b/guardpost/funchelper.py deleted file mode 100644 index 0b5ffcc..0000000 --- a/guardpost/funchelper.py +++ /dev/null @@ -1,24 +0,0 @@ -from inspect import Signature -from typing import Callable, Dict, Tuple - - -def args_to_dict_getter(method: Callable): - params = list(Signature.from_callable(method).parameters) - - def args_to_dict(args: Tuple, kwargs: Dict): - a = {} - - for index, value in enumerate(args): - param_name = params[index] - a[param_name] = value - - if not kwargs: - return a - - for param_name in params: - if param_name in kwargs: - a[param_name] = kwargs[param_name] - - return a - - return args_to_dict diff --git a/guardpost/synchronous/__init__.py b/guardpost/synchronous/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/guardpost/synchronous/authentication.py b/guardpost/synchronous/authentication.py deleted file mode 100644 index be9bf68..0000000 --- a/guardpost/synchronous/authentication.py +++ /dev/null @@ -1,28 +0,0 @@ -from abc import abstractmethod -from typing import Any, Optional, Sequence - -from guardpost.authentication import ( - BaseAuthenticationHandler, - BaseAuthenticationStrategy, - Identity, -) - - -class AuthenticationHandler(BaseAuthenticationHandler): - @abstractmethod - def authenticate(self, context: Any) -> Optional[Identity]: - """Obtains an identity from a context.""" - - -class AuthenticationStrategy(BaseAuthenticationStrategy): - def authenticate( - self, context: Any, authentication_schemes: Optional[Sequence[str]] = None - ): - if not context: - raise ValueError("Missing context to evaluate authentication") - - for handler in self.get_handlers(authentication_schemes): - identity = handler.authenticate(context) - - if identity: - break diff --git a/guardpost/synchronous/authorization.py b/guardpost/synchronous/authorization.py deleted file mode 100644 index 3811378..0000000 --- a/guardpost/synchronous/authorization.py +++ /dev/null @@ -1,70 +0,0 @@ -from abc import abstractmethod -from functools import wraps -from typing import Dict, Optional - -from guardpost.authentication import Identity -from guardpost.authorization import ( - AuthorizationContext, - BaseAuthorizationStrategy, - BaseRequirement, - Policy, - PolicyNotFoundError, - UnauthorizedError, -) -from guardpost.funchelper import args_to_dict_getter - - -class Requirement(BaseRequirement): - """Base class for synchronous authorization requirements.""" - - @abstractmethod - def handle(self, context: AuthorizationContext): - """Handles this requirement for a given context""" - - -class AuthorizationStrategy(BaseAuthorizationStrategy): - def _handle_with_identity_getter(self, policy_name: Optional[str], arguments: Dict): - self.authorize(policy_name, self.identity_getter(arguments)) - - @staticmethod - def _handle_with_policy(policy: Policy, identity: Identity): - with AuthorizationContext(identity, policy.requirements) as context: - - for requirement in policy.requirements: - requirement.handle(context) - - if not context.has_succeeded: - raise UnauthorizedError( - context.forced_failure, context.pending_requirements - ) - - def authorize(self, policy_name: Optional[str], identity: Identity): - if policy_name: - policy = self.get_policy(policy_name) - - if not policy: - raise PolicyNotFoundError(policy_name) - - self._handle_with_policy(policy, identity) - else: - if self.default_policy: - self._handle_with_policy(self.default_policy, identity) - return - - if not identity: - raise UnauthorizedError("Missing identity", []) - if not identity.is_authenticated(): - raise UnauthorizedError("The resource requires authentication", []) - - def __call__(self, policy: Optional[str] = None): - def decorator(fn): - args_getter = args_to_dict_getter(fn) - - @wraps(fn) - def wrapper(*args, **kwargs): - self._handle_with_identity_getter(policy, args_getter(args, kwargs)) - return fn(*args, **kwargs) - - return wrapper - - return decorator diff --git a/project.code-workspace b/neoteroi-auth.code-workspace similarity index 93% rename from project.code-workspace rename to neoteroi-auth.code-workspace index 37258b4..d2edf38 100644 --- a/project.code-workspace +++ b/neoteroi-auth.code-workspace @@ -11,7 +11,6 @@ "files.trimTrailingWhitespace": true, "files.trimFinalNewlines": true, "python.testing.unittestEnabled": false, - "python.testing.nosetestsEnabled": false, "python.testing.pytestEnabled": true, "python.linting.pylintEnabled": false, "python.linting.flake8Enabled": true, diff --git a/neoteroi/auth/__about__.py b/neoteroi/auth/__about__.py new file mode 100644 index 0000000..3b93d0b --- /dev/null +++ b/neoteroi/auth/__about__.py @@ -0,0 +1 @@ +__version__ = "0.0.2" diff --git a/neoteroi/auth/__init__.py b/neoteroi/auth/__init__.py new file mode 100644 index 0000000..e1cb4d2 --- /dev/null +++ b/neoteroi/auth/__init__.py @@ -0,0 +1,37 @@ +from .authentication import ( + AuthenticationHandler, + AuthenticationHandlerConfType, + AuthenticationSchemesNotFound, + AuthenticationStrategy, + Identity, + User, +) +from .authorization import ( + AuthorizationConfigurationError, + AuthorizationContext, + AuthorizationError, + AuthorizationStrategy, + Policy, + PolicyNotFoundError, + Requirement, + RequirementConfType, + UnauthorizedError, +) + +__all__ = [ + "AuthenticationHandlerConfType", + "AuthenticationSchemesNotFound", + "AuthorizationConfigurationError", + "AuthenticationStrategy", + "AuthorizationStrategy", + "AuthenticationHandler", + "AuthorizationError", + "Identity", + "User", + "Policy", + "PolicyNotFoundError", + "Requirement", + "RequirementConfType", + "UnauthorizedError", + "AuthorizationContext", +] diff --git a/neoteroi/auth/abc.py b/neoteroi/auth/abc.py new file mode 100644 index 0000000..94e632c --- /dev/null +++ b/neoteroi/auth/abc.py @@ -0,0 +1,55 @@ +from abc import ABC +from typing import Any, Iterable, List, Optional, Type, TypeVar, Union + +from neoteroi.di import ContainerProtocol + +T = TypeVar("T") + + +class StrategyConfigurationError(Exception): + """Base class for all configuration errors related to auth strategies.""" + + +class DINotConfiguredError(StrategyConfigurationError): + def __init__(self) -> None: + super().__init__( + "A DI Container is required for this strategy because it needs to activate " + "types." + ) + + +class BaseStrategy(ABC): + def __init__(self, container: Optional[ContainerProtocol] = None) -> None: + super().__init__() + self._container = container + + @property + def container(self) -> ContainerProtocol: + if self._container is None: + raise DINotConfiguredError() + return self._container + + @container.setter + def container(self, container: ContainerProtocol) -> None: + self._container = container + + def _get_di_scope(self, scope: Any): + try: + return scope._di_scope + except AttributeError: + return None + + def _get_instances(self, items: List[Union[T, Type[T]]], scope: Any) -> Iterable[T]: + """ + Yields instances of types, optionally activated through dependency injection. + + If the given context has a DI scope defined in "_di_scope" attribute, it is + used to support scoped services. + """ + scope = self._get_di_scope(scope) + for obj in items: + if isinstance(obj, type): + # a container is required + yield self.container.resolve(obj, scope=scope) + else: + yield obj diff --git a/neoteroi/auth/authentication.py b/neoteroi/auth/authentication.py new file mode 100644 index 0000000..946e7c9 --- /dev/null +++ b/neoteroi/auth/authentication.py @@ -0,0 +1,159 @@ +import inspect +from abc import ABC, abstractmethod +from functools import lru_cache +from typing import Any, List, Optional, Sequence, Type, Union + +from neoteroi.di import ContainerProtocol + +from neoteroi.auth.abc import BaseStrategy + + +class Identity: + """ + Represents the characteristics of a person or a thing in the context of an + application. It can be a user interacting with an app, or a technical account. + """ + + def __init__( + self, + claims: Optional[dict] = None, + authentication_mode: Optional[str] = None, + ): + self.claims = claims or {} + self.authentication_mode = authentication_mode + self.access_token: Optional[str] = None + self.refresh_token: Optional[str] = None + + @property + def sub(self) -> Optional[str]: + return self["sub"] + + def is_authenticated(self) -> bool: + return bool(self.authentication_mode) + + def __getitem__(self, item): + return self.claims[item] + + def has_claim(self, name: str) -> bool: + return name in self.claims + + def has_claim_value(self, name: str, value: str) -> bool: + return self.claims.get(name) == value + + +class User(Identity): + @property + def id(self) -> Optional[str]: + return self["id"] or self.sub + + @property + def name(self) -> Optional[str]: + return self["name"] + + @property + def email(self) -> Optional[str]: + return self["email"] + + +class AuthenticationHandler(ABC): + """Base class for types that implement authentication logic.""" + + @property + def scheme(self) -> str: + """Returns the name of the Authentication Scheme used by this handler.""" + return self.__class__.__name__ + + @abstractmethod + def authenticate(self, context: Any) -> Optional[Identity]: + """Obtains an identity from a context.""" + + +@lru_cache(maxsize=None) +def _is_async_handler(handler_type: Type[AuthenticationHandler]) -> bool: + # Faster alternative to using inspect.iscoroutinefunction without caching + # Note: this must be used on Types - not instances! + return inspect.iscoroutinefunction(handler_type.authenticate) + + +AuthenticationHandlerConfType = Union[ + AuthenticationHandler, Type[AuthenticationHandler] +] + + +class AuthenticationSchemesNotFound(ValueError): + def __init__( + self, configured_schemes: Sequence[str], required_schemes: Sequence[str] + ): + super().__init__( + "Could not find authentication handlers for required schemes: " + f'{", ".join(required_schemes)}. ' + f'Configured schemes are: {", ".join(configured_schemes)}' + ) + + +class AuthenticationStrategy(BaseStrategy): + def __init__( + self, + *handlers: AuthenticationHandlerConfType, + container: Optional[ContainerProtocol] = None, + ): + super().__init__(container) + self.handlers = list(handlers) + + def add(self, handler: AuthenticationHandlerConfType) -> "AuthenticationStrategy": + self.handlers.append(handler) + return self + + def __iadd__( + self, handler: AuthenticationHandlerConfType + ) -> "AuthenticationStrategy": + self.handlers.append(handler) + return self + + def _get_handlers_by_schemes( + self, + authentication_schemes: Optional[Sequence[str]] = None, + context: Any = None, + ) -> List[AuthenticationHandler]: + if not authentication_schemes: + return list(self._get_instances(self.handlers, context)) + + handlers = [ + handler + for handler in self._get_instances(self.handlers, context) + if handler.scheme in authentication_schemes + ] + + if not handlers: + raise AuthenticationSchemesNotFound( + [ + handler.scheme + for handler in self._get_instances(self.handlers, context) + ], + authentication_schemes, + ) + + return handlers + + async def authenticate( + self, context: Any, authentication_schemes: Optional[Sequence[str]] = None + ) -> Optional[Identity]: + """ + Tries to obtain the user for a context, applying authentication rules. + """ + if not context: + raise ValueError("Missing context to evaluate authentication") + + for handler in self._get_handlers_by_schemes(authentication_schemes, context): + if _is_async_handler(type(handler)): + identity = await handler.authenticate(context) # type: ignore + else: + identity = handler.authenticate(context) + + if identity: + try: + context.identity = identity + except AttributeError: + pass + return identity + return None diff --git a/neoteroi/auth/authorization.py b/neoteroi/auth/authorization.py new file mode 100644 index 0000000..1bea1c6 --- /dev/null +++ b/neoteroi/auth/authorization.py @@ -0,0 +1,257 @@ +import inspect +from abc import ABC, abstractmethod +from functools import lru_cache, wraps +from typing import Any, Callable, Iterable, List, Optional, Sequence, Set, Type, Union + +from neoteroi.di import ContainerProtocol + +from neoteroi.auth.abc import BaseStrategy +from neoteroi.auth.authentication import Identity + + +class AuthorizationError(Exception): + pass + + +class AuthorizationConfigurationError(Exception): + pass + + +class PolicyNotFoundError(AuthorizationConfigurationError, RuntimeError): + def __init__(self, name: str): + super().__init__(f"Cannot find policy with name {name}") + + +class Requirement(ABC): + """Base class for authorization requirements.""" + + def __str__(self): + return self.__class__.__name__ + + @abstractmethod + async def handle(self, context: "AuthorizationContext"): + """Handles this requirement for a given context.""" + + +RequirementConfType = Union[Requirement, Type[Requirement]] + + +@lru_cache(maxsize=None) +def _is_async_handler(handler_type: Type[Requirement]) -> bool: + # Faster alternative to using inspect.iscoroutinefunction without caching + # Note: this must be used on Types - not instances! + return inspect.iscoroutinefunction(handler_type.handle) + + +class UnauthorizedError(AuthorizationError): + def __init__( + self, + forced_failure: Optional[str], + failed_requirements: Sequence[Requirement], + scheme: Optional[str] = None, + error: Optional[str] = None, + error_description: Optional[str] = None, + ): + """ + Creates a new instance of UnauthorizedError, with details. + + :param forced_failure: if applicable, the reason for a forced failure. + :param failed_requirements: a sequence of requirements that failed. + :param scheme: optional authentication scheme that should be used. + :param error: optional error short text. + :param error_description: optional error details. + """ + super().__init__(self._get_message(forced_failure, failed_requirements)) + self.failed = forced_failure + self.failed_requirements = failed_requirements + self.scheme = scheme + self.error = error + self.error_description = error_description + + @staticmethod + def _get_message(forced_failure, failed_requirements): + if forced_failure: + return ( + "The user is not authorized to perform the selected action." + + f" {forced_failure}." + ) + + if failed_requirements: + errors = ", ".join(str(requirement) for requirement in failed_requirements) + return ( + f"The user is not authorized to perform the selected action. " + f"Failed requirements: {errors}." + ) + return "Unauthorized" + + +class AuthorizationContext: + + __slots__ = ("identity", "requirements", "_succeeded", "_failed_forced") + + def __init__(self, identity: Identity, requirements: Sequence[Requirement]): + self.identity = identity + self.requirements = requirements + self._succeeded: Set[Requirement] = set() + self._failed_forced: Optional[str] = None + + @property + def pending_requirements(self) -> List[Requirement]: + return [item for item in self.requirements if item not in self._succeeded] + + @property + def has_succeeded(self) -> bool: + if self._failed_forced: + return False + return all(requirement in self._succeeded for requirement in self.requirements) + + @property + def forced_failure(self) -> Optional[str]: + return None if self._failed_forced is None else str(self._failed_forced) + + def fail(self, reason: str): + """ + Called to indicate that this authorization context has failed. + Forces failure, regardless of succeeded requirements. + """ + self._failed_forced = reason or "Authorization failed." + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.clear() + + def succeed(self, requirement: Requirement): + """Marks the given requirement as succeeded for this authorization context.""" + self._succeeded.add(requirement) + + def clear(self): + self._failed_forced = None + self._succeeded.clear() + + +class Policy: + """ + Represents an authorization policy, with a set of authorization rules. + """ + + __slots__ = ("name", "requirements") + + def __init__(self, name: str, *requirements: RequirementConfType): + self.name = name + self.requirements = list(requirements) or [] + + def _valid_requirement(self, obj): + if not isinstance(obj, Requirement) or ( + isinstance(obj, type) and not issubclass(obj, Requirement) + ): + raise ValueError( + "Only instances, or types, of Requirement can be added to the policy." + ) + + def add(self, requirement: RequirementConfType) -> "Policy": + self._valid_requirement(requirement) + self.requirements.append(requirement) + return self + + def __iadd__(self, other: RequirementConfType): + self._valid_requirement(other) + self.requirements.append(other) + return self + + def __repr__(self): + return f'' + + +class AuthorizationStrategy(BaseStrategy): + def __init__( + self, + *policies: Policy, + container: Optional[ContainerProtocol] = None, + default_policy: Optional[Policy] = None, + identity_getter: Optional[Callable[..., Identity]] = None, + ): + super().__init__(container) + self.policies = list(policies) + self.default_policy = default_policy + self.identity_getter = identity_getter + + def get_policy(self, name: str) -> Optional[Policy]: + for policy in self.policies: + if policy.name == name: + return policy + return None + + def add(self, policy: Policy) -> "AuthorizationStrategy": + self.policies.append(policy) + return self + + def __iadd__(self, policy: Policy) -> "AuthorizationStrategy": + self.policies.append(policy) + return self + + def with_default_policy(self, policy: Policy) -> "AuthorizationStrategy": + self.default_policy = policy + return self + + async def authorize( + self, policy_name: Optional[str], identity: Identity, scope: Any = None + ): + if policy_name: + policy = self.get_policy(policy_name) + + if not policy: + raise PolicyNotFoundError(policy_name) + + await self._handle_with_policy(policy, identity, scope) + else: + if self.default_policy: + await self._handle_with_policy(self.default_policy, identity, scope) + return + + if not identity: + raise UnauthorizedError("Missing identity", []) + if not identity.is_authenticated(): + raise UnauthorizedError("The resource requires authentication", []) + + def _get_requirements(self, policy: Policy, scope: Any) -> Iterable[Requirement]: + yield from self._get_instances(policy.requirements, scope) + + async def _handle_with_policy(self, policy: Policy, identity: Identity, scope: Any): + with AuthorizationContext( + identity, list(self._get_requirements(policy, scope)) + ) as context: + + for requirement in context.requirements: + if _is_async_handler(type(requirement)): # type: ignore + await requirement.handle(context) + else: + requirement.handle(context) # type: ignore + + if not context.has_succeeded: + raise UnauthorizedError( + context.forced_failure, context.pending_requirements + ) + + async def _handle_with_identity_getter( + self, policy_name: Optional[str], *args, **kwargs + ): + if self.identity_getter is None: + raise TypeError("Missing identity getter function.") + await self.authorize(policy_name, self.identity_getter(*args, **kwargs)) + + def __call__(self, policy: Optional[str] = None): + """ + Decorates a function to apply authorization logic on each call. + """ + + def decorator(fn): + @wraps(fn) + async def wrapper(*args, **kwargs): + await self._handle_with_identity_getter(policy, *args, **kwargs) + return await fn(*args, **kwargs) + + return wrapper + + return decorator diff --git a/guardpost/common.py b/neoteroi/auth/common.py similarity index 84% rename from guardpost/common.py rename to neoteroi/auth/common.py index e460011..e60abf0 100644 --- a/guardpost/common.py +++ b/neoteroi/auth/common.py @@ -2,12 +2,11 @@ from typing import Mapping as MappingType from typing import Sequence, Union -from .authorization import Policy -from .synchronous.authorization import AuthorizationContext, Requirement +from .authorization import AuthorizationContext, Policy, Requirement class AnonymousRequirement(Requirement): - """Requires an anonymous user, or service""" + """Requires an anonymous user, or service.""" def handle(self, context: AuthorizationContext): identity = context.identity @@ -17,14 +16,17 @@ def handle(self, context: AuthorizationContext): class AnonymousPolicy(Policy): - """Policy that requires an anonymous user, or service""" + """Policy that requires an anonymous user, or service.""" def __init__(self, name: str = "anonymous"): super().__init__(name, AnonymousRequirement()) class AuthenticatedRequirement(Requirement): - """Requires an authenticated user, or service""" + """ + Requires an authenticated user, or service. Meaning that an `identity` must be set + in the authorization context. + """ def handle(self, context: AuthorizationContext): identity = context.identity diff --git a/guardpost/errors.py b/neoteroi/auth/errors.py similarity index 100% rename from guardpost/errors.py rename to neoteroi/auth/errors.py diff --git a/guardpost/jwks/__init__.py b/neoteroi/auth/jwks/__init__.py similarity index 98% rename from guardpost/jwks/__init__.py rename to neoteroi/auth/jwks/__init__.py index b2316c0..82e4cf1 100644 --- a/guardpost/jwks/__init__.py +++ b/neoteroi/auth/jwks/__init__.py @@ -8,7 +8,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers -from guardpost.errors import UnsupportedFeatureError +from neoteroi.auth.errors import UnsupportedFeatureError def _raise_if_missing(value: dict, *keys: str) -> None: diff --git a/guardpost/jwks/caching.py b/neoteroi/auth/jwks/caching.py similarity index 100% rename from guardpost/jwks/caching.py rename to neoteroi/auth/jwks/caching.py diff --git a/guardpost/jwks/openid.py b/neoteroi/auth/jwks/openid.py similarity index 95% rename from guardpost/jwks/openid.py rename to neoteroi/auth/jwks/openid.py index d856930..d84a976 100644 --- a/guardpost/jwks/openid.py +++ b/neoteroi/auth/jwks/openid.py @@ -1,4 +1,4 @@ -from guardpost.utils import get_running_loop, read_json_data +from neoteroi.auth.utils import get_running_loop, read_json_data from . import JWKS, KeysProvider diff --git a/guardpost/jwks/urls.py b/neoteroi/auth/jwks/urls.py similarity index 92% rename from guardpost/jwks/urls.py rename to neoteroi/auth/jwks/urls.py index 310d499..fe9d3a6 100644 --- a/guardpost/jwks/urls.py +++ b/neoteroi/auth/jwks/urls.py @@ -1,4 +1,4 @@ -from guardpost.utils import get_running_loop, read_json_data +from neoteroi.auth.utils import get_running_loop, read_json_data from . import JWKS, KeysProvider diff --git a/guardpost/jwts/__init__.py b/neoteroi/auth/jwts/__init__.py similarity index 100% rename from guardpost/jwts/__init__.py rename to neoteroi/auth/jwts/__init__.py diff --git a/guardpost/py.typed b/neoteroi/auth/py.typed similarity index 100% rename from guardpost/py.typed rename to neoteroi/auth/py.typed diff --git a/guardpost/utils.py b/neoteroi/auth/utils.py similarity index 100% rename from guardpost/utils.py rename to neoteroi/auth/utils.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6498975 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "neoteroi-auth" +dynamic = ["version"] +authors = [ + { name = "Roberto Prevato", email = "roberto.prevato@gmail.com" }, +] +description = "Framework to handle authentication and authorization." +readme = "README.md" +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Operating System :: OS Independent", +] +keywords = ["authentication", "authorization", "identity", "claims", "strategy"] +dependencies = [ + "neoteroi-di<=2.0.0", +] + +[project.optional-dependencies] +jwt = [ + "PyJWT", + "cryptography", +] + +[tool.hatch.build.targets.sdist] +exclude = [ + "/.github", + "/docs", + "/examples", + "/deps", + "/styles", + "/tests", + "mkdocs-plugins.code-workspace", + "Makefile", + "CODE_OF_CONDUCT.md", + ".isort.cfg", + ".gitignore", + ".flake8", + "junit", + "neoteroi-auth.code-workspace", + "requirements.txt", + "examples-summary.py" +] + +[tool.hatch.version] +path = "neoteroi/auth/__about__.py" + +[project.urls] +"Homepage" = "https://github.com/Neoteroi/auth" +"Bug Tracker" = "https://github.com/Neoteroi/auth/issues" diff --git a/requirements.txt b/requirements.txt index 0c583e8..473054f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ isort cryptography black flask +neoteroi-di diff --git a/setup.py b/setup.py deleted file mode 100644 index 5daede4..0000000 --- a/setup.py +++ /dev/null @@ -1,51 +0,0 @@ -from setuptools import setup - - -def readme(): - with open("README.md") as f: - return f.read() - - -setup( - name="guardpost", - version="0.0.9", - description=( - "Basic framework to handle authentication and authorization " - "in any kind of Python application." - ), - long_description=readme(), - long_description_content_type="text/markdown", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Operating System :: OS Independent", - ], - url="https://github.com/Neoteroi/guardpost", - author="Roberto Prevato", - author_email="roberto.prevato@gmail.com", - keywords="authentication authorization identity claims strategy " - + "framework asyncio synchronous", - license="MIT", - packages=[ - "guardpost", - "guardpost.synchronous", - "guardpost.asynchronous", - "guardpost.jwks", - "guardpost.jwts", - ], - install_requires=[], - extras_require={ - "jwt": [ - "PyJWT~=2.3.0", - "cryptography~=35.0.0", - ] - }, - include_package_data=True, - zip_safe=False, -) diff --git a/tests/examples.py b/tests/examples.py index 74d033a..bec5101 100644 --- a/tests/examples.py +++ b/tests/examples.py @@ -1,5 +1,4 @@ -from guardpost.authorization import AuthorizationContext -from guardpost.synchronous.authorization import Requirement +from neoteroi.auth.authorization import AuthorizationContext, Requirement class Request: diff --git a/tests/serverfixtures.py b/tests/serverfixtures.py index ffbfb72..bcd21da 100644 --- a/tests/serverfixtures.py +++ b/tests/serverfixtures.py @@ -7,7 +7,7 @@ import pytest from flask import Flask -from guardpost.jwks import JWKS +from neoteroi.auth.jwks import JWKS SERVER_PORT = 44777 BASE_URL = f"http://127.0.0.1:{SERVER_PORT}" diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 21dd753..934be76 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,13 +1,17 @@ from typing import Any, Optional +from uuid import uuid4 import pytest +from neoteroi.di import Container from pytest import raises -from guardpost.asynchronous.authentication import ( +from neoteroi.auth.authentication import ( AuthenticationHandler, + AuthenticationSchemesNotFound, AuthenticationStrategy, + Identity, + User, ) -from guardpost.authentication import AuthenticationSchemesNotFound, Identity, User from tests.examples import Request @@ -52,7 +56,9 @@ def test_identity_dictionary_notation(): a = Identity({"oid": "bc5f60df-4c27-49c1-8466-acf32618a6d2"}) assert a["oid"] == "bc5f60df-4c27-49c1-8466-acf32618a6d2" - assert a["foo"] is None + + with raises(KeyError): + a["foo"] def test_identity_sub(): @@ -65,7 +71,9 @@ def test_user_identity_dictionary_notation(): a = User({"oid": "bc5f60df-4c27-49c1-8466-acf32618a6d2"}) assert a["oid"] == "bc5f60df-4c27-49c1-8466-acf32618a6d2" - assert a["foo"] is None + + with raises(KeyError): + a["foo"] def test_has_claim_value(): @@ -77,7 +85,7 @@ def test_has_claim_value(): def test_claims_default(): - a = Identity({}) + a = Identity() assert a.claims.get("oid") is None @@ -177,3 +185,54 @@ async def authenticate(self, context: Any) -> Optional[Identity]: assert Basic().scheme == "Basic" assert Foo().scheme == "Foo" + + +class Foo: + pass + + +class InjectedAuthenticationHandler(AuthenticationHandler): + service: Foo + + def authenticate(self, context) -> Optional[Identity]: + return None + + +@pytest.mark.asyncio +async def test_authentication_di(): + container = Container() + + container.register(Foo) + container.register(InjectedAuthenticationHandler) # TODO: auto register? + + auth = AuthenticationStrategy(InjectedAuthenticationHandler, container=container) + + result = await auth.authenticate("example") + assert result is None + + +@pytest.mark.asyncio +async def test_authenticate_set_identity_context_attribute_error_handling(): + """ + Tests that trying to set the identity on a context that does not support setting + attributes does not cause an exception. + """ + test_id = uuid4() + container = Container() + + class TestHandler(AuthenticationHandler): + def authenticate(self, context: Any) -> Optional[Identity]: + return Identity({"sub": test_id}) + + container.register(TestHandler) + + auth = AuthenticationStrategy(TestHandler, container=container) + + class A: + __slots__ = ("x",) + + context = A() + + result = await auth.authenticate(context) + assert isinstance(result, Identity) + assert result.sub == test_id diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 2367bd2..d560e9b 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -1,22 +1,27 @@ from typing import Sequence import pytest +from neoteroi.di import Container from pytest import raises -from guardpost.asynchronous.authorization import AsyncRequirement as Requirement -from guardpost.asynchronous.authorization import AuthorizationStrategy -from guardpost.authentication import User -from guardpost.authorization import ( +from neoteroi.auth.authentication import Identity, User +from neoteroi.auth.authorization import ( AuthorizationContext, + AuthorizationStrategy, Policy, PolicyNotFoundError, + Requirement, UnauthorizedError, ) -from guardpost.common import AuthenticatedRequirement, ClaimsRequirement +from neoteroi.auth.common import AuthenticatedRequirement, ClaimsRequirement from tests.examples import NoopRequirement -def empty_identity_getter(_): +def empty_identity_getter(*args, **kwargs): + return Identity() + + +def no_identity_getter(): return None @@ -155,8 +160,8 @@ def __init__(self, user): self.user = user -def request_identity_getter(args): - return args.get("request").user +def request_identity_getter(request): + return request.user @pytest.mark.asyncio @@ -288,6 +293,7 @@ async def test_claims_requirement_sequence(): @pytest.mark.asyncio async def test_auth_without_policy_no_identity(): auth: AuthorizationStrategy = get_strategy([]) + auth.identity_getter = no_identity_getter # type: ignore @auth() async def some_method(): @@ -326,7 +332,7 @@ async def some_method(): @pytest.mark.asyncio async def test_auth_without_policy_anonymous_identity(): - auth: AuthorizationStrategy = get_strategy([], lambda _: User({"oid": "001"})) + auth: AuthorizationStrategy = get_strategy([], lambda: User({"oid": "001"})) @auth() async def some_method(): @@ -340,3 +346,79 @@ def test_unauthorized_error_message(): ex = UnauthorizedError(None, None) assert str(ex) == "Unauthorized" + + +class Foo: + pass + + +class InjectedRequirement(Requirement): + service: Foo + + def handle(self, context): + assert isinstance(self.service, Foo) + context.succeed(self) + + +class ScopedTestRequirement1(Requirement): + service_1: Foo + service_2: Foo + + def handle(self, context): + assert isinstance(self.service_1, Foo) + assert self.service_1 is self.service_2 + context.succeed(self) + + +class ScopedTestRequirement2(Requirement): + foo: Foo + brother: ScopedTestRequirement1 + + def handle(self, context): + assert self.foo is self.brother.service_1 + context.succeed(self) + + +@pytest.mark.asyncio +async def test_authorization_di(): + container = Container() + + container.register(Foo) + container.register(InjectedRequirement) # TODO: auto register? + + auth = AuthorizationStrategy( + Policy("example", InjectedRequirement), container=container + ) + + identity = Identity() + assert await auth.authorize("example", identity) is None + + +@pytest.mark.asyncio +async def test_authorization_di_scoped(): + container = Container() + + container.add_scoped(Foo) + container.register(ScopedTestRequirement1) + container.register(ScopedTestRequirement2) + + auth = AuthorizationStrategy( + Policy("example", ScopedTestRequirement1, ScopedTestRequirement2), + container=container, + ) + + identity = Identity() + assert await auth.authorize("example", identity) is None + + +@pytest.mark.asyncio +async def test_auth_raises_for_missing_identity_getter(): + auth: AuthorizationStrategy = get_strategy([]) + auth.identity_getter = None + + @auth() + async def some_method(): + return True + + with raises(TypeError, match="Missing identity getter function."): + await some_method() diff --git a/tests/test_common.py b/tests/test_common.py index 345a4b4..066c0dd 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -1,35 +1,35 @@ from typing import Any, Optional +import pytest +from neoteroi.di import Container from pytest import raises -from guardpost.authentication import Identity -from guardpost.authorization import Policy, UnauthorizedError -from guardpost.common import AnonymousPolicy, AuthenticatedRequirement -from guardpost.synchronous.authentication import ( +from neoteroi.auth.abc import DINotConfiguredError +from neoteroi.auth.authentication import ( AuthenticationHandler, AuthenticationStrategy, + Identity, ) -from guardpost.synchronous.authorization import AuthorizationStrategy +from neoteroi.auth.authorization import AuthorizationStrategy, Policy, UnauthorizedError +from neoteroi.auth.common import AnonymousPolicy, AuthenticatedRequirement -def test_policy_without_requirements_always_succeeds(): +@pytest.mark.asyncio +async def test_policy_without_requirements_always_succeeds(): # a policy without requirements is a no-op policy that always succeeds, # even when there is no known identity strategy = AuthorizationStrategy(Policy("default")) - strategy.authorize("default", None) - - strategy.authorize("default", Identity({})) + await strategy.authorize("default", Identity()) assert True -def test_anonymous_policy(): +@pytest.mark.asyncio +async def test_anonymous_policy(): strategy = AuthorizationStrategy(default_policy=AnonymousPolicy()) - strategy.authorize(None, None) - - strategy.authorize(None, Identity({})) + await strategy.authorize(None, Identity()) assert True @@ -49,9 +49,10 @@ def test_policy_iadd_syntax_raises_for_non_requirements(): strategy = AuthorizationStrategy(default_policy=Policy("default")) with raises( - ValueError, match="Only requirements can be added using __iadd__ syntax" + ValueError, + match="Only instances, or types, of Requirement can be added to the policy.", ): - strategy.default_policy += object() + strategy.default_policy += object() # type: ignore def test_policy_add_method(): @@ -158,3 +159,19 @@ def test_unauthorized_error_supports_error_and_description(): assert error.scheme == "Bearer" assert error.error == "invalid token" assert error.error_description == "The access token has expired" + + +def test_strategy_set_container(): + strategy = AuthenticationStrategy() + strategy.container = Container() + + +def test_container_getter_raises_for_missing_container(): + strategy = AuthenticationStrategy() + + with raises(DINotConfiguredError): + strategy.container + + +def test_import_version(): + from neoteroi.auth.__about__ import __version__ # noqa diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..d0aa3b4 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,17 @@ +import glob +import importlib +import sys + +import pytest + +examples = [file for file in glob.glob("./examples/*.py")] + + +sys.path.append("./examples") + + +@pytest.mark.parametrize("file_path", examples) +def test_example(file_path: str): + module_name = file_path.replace("./examples/", "").replace(".py", "") + # assertions are in imported + importlib.import_module(module_name) diff --git a/tests/test_funchelper.py b/tests/test_funchelper.py deleted file mode 100644 index c13b81f..0000000 --- a/tests/test_funchelper.py +++ /dev/null @@ -1,27 +0,0 @@ -from guardpost.funchelper import args_to_dict_getter - - -def test_args_to_dict_getter(): - def method(a, b, c): - return - - getter = args_to_dict_getter(method) - - assert {"a": 1, "b": 2, "c": 3} == getter((1, 2, 3), {}) - - assert {"a": 1, "b": 2, "c": 3} == getter((1, 2), {"c": 3}) - - assert {"a": 1, "b": 6, "c": 3} == getter((1, 6), {"c": 3}) - - assert {"a": 2, "b": 5, "c": 3} == getter((), {"a": 2, "b": 5, "c": 3}) - - -def test_args(): - def my(request): - print(request) - - def some(*args, **kwargs): - - return my(*args, **kwargs) - - some(object()) diff --git a/tests/test_jwks.py b/tests/test_jwks.py index 31cb963..6cce119 100644 --- a/tests/test_jwks.py +++ b/tests/test_jwks.py @@ -1,7 +1,7 @@ import pytest -from guardpost.errors import UnsupportedFeatureError -from guardpost.jwks import JWK, JWKS, KeyType +from neoteroi.auth.errors import UnsupportedFeatureError +from neoteroi.auth.jwks import JWK, JWKS, KeyType def test_keytype_from_str(): diff --git a/tests/test_jwts.py b/tests/test_jwts.py index 09bb292..8d1e4d7 100644 --- a/tests/test_jwts.py +++ b/tests/test_jwts.py @@ -4,11 +4,11 @@ import jwt import pytest -from guardpost.jwks import InMemoryKeysProvider, KeysProvider -from guardpost.jwks.caching import CachingKeysProvider -from guardpost.jwks.openid import AuthorityKeysProvider -from guardpost.jwks.urls import URLKeysProvider -from guardpost.jwts import InvalidAccessToken, JWTValidator +from neoteroi.auth.jwks import InMemoryKeysProvider, KeysProvider +from neoteroi.auth.jwks.caching import CachingKeysProvider +from neoteroi.auth.jwks.openid import AuthorityKeysProvider +from neoteroi.auth.jwks.urls import URLKeysProvider +from neoteroi.auth.jwts import InvalidAccessToken, JWTValidator from .serverfixtures import * # noqa from .serverfixtures import BASE_URL, get_file_path, get_test_jwks diff --git a/tests/test_sync_authentication.py b/tests/test_sync_authentication.py deleted file mode 100644 index 47f922a..0000000 --- a/tests/test_sync_authentication.py +++ /dev/null @@ -1,35 +0,0 @@ -from pytest import raises - -from guardpost.authentication import User -from guardpost.synchronous.authentication import ( - AuthenticationHandler, - AuthenticationStrategy, -) -from tests.examples import Request - - -def test_authentication_strategy(): - class ExampleHandler(AuthenticationHandler): - def authenticate(self, context: Request): - # NB: imagine a web request with headers, and we authenticate the user - # by parsing and validating a JWT token - user = User({"id": context.headers["user"]}) - context.user = user - return user - - strategy = AuthenticationStrategy(ExampleHandler()) - - request = Request({"user": "xxx"}) - - strategy.authenticate(request) - - assert isinstance(request.user, User) - assert request.user["id"] == "xxx" - - -def test_strategy_throws_for_missing_context(): - - strategy = AuthenticationStrategy() - - with raises(ValueError): - strategy.authenticate(None) diff --git a/tests/test_sync_authorization.py b/tests/test_sync_authorization.py deleted file mode 100644 index cbfdc27..0000000 --- a/tests/test_sync_authorization.py +++ /dev/null @@ -1,134 +0,0 @@ -from typing import Sequence - -from pytest import raises - -from guardpost.authentication import User -from guardpost.authorization import Policy, PolicyNotFoundError -from guardpost.common import AuthenticatedRequirement -from guardpost.synchronous.authorization import ( - AuthorizationContext, - AuthorizationStrategy, - Requirement, - UnauthorizedError, -) -from tests.examples import NoopRequirement, Request - - -def empty_identity_getter(_): - return None - - -def get_strategy(policies: Sequence[Policy], identity_getter=None): - if identity_getter is None: - identity_getter = empty_identity_getter - return AuthorizationStrategy(*policies, identity_getter=identity_getter) - - -def request_identity_getter(args): - return args.get("request").user - - -def test_authorization_identity_getter(): - class UserNameRequirement(Requirement): - def __init__(self, expected_name: str): - self.expected_name = expected_name - - def handle(self, context: AuthorizationContext): - assert context.identity is not None - - if context.identity.has_claim_value("name", self.expected_name): - context.succeed(self) - - auth = get_strategy( - [Policy("user", UserNameRequirement("Tybek"))], request_identity_getter - ) - - @auth(policy="user") - def some_method(request: Request): - assert request is not None - return True - - value = some_method(Request(None, User({"name": "Tybek"}))) - - assert value is True - - -def test_policy_not_found_error_sync(): - auth = get_strategy([Policy("admin")]) - - @auth(policy="user") - def some_method(): - pass - - with raises(PolicyNotFoundError, match="Cannot find policy"): - some_method() - - -def test_policy_authorization_two_requirements_both_fail(): - class ExampleOne(Requirement): - def handle(self, context: AuthorizationContext): - pass - - class ExampleTwo(Requirement): - def handle(self, context: AuthorizationContext): - pass - - auth = get_strategy([Policy("user", ExampleOne(), ExampleTwo())]) - - @auth(policy="user") - def some_method(): - return True - - with raises( - UnauthorizedError, - match="The user is not authorized to perform the selected action. " - "Failed requirements: ExampleOne, ExampleTwo.", - ): - some_method() - - -def test_auth_without_policy_no_identity(): - auth: AuthorizationStrategy = get_strategy([]) - - @auth() - def some_method(): - return True - - with raises(UnauthorizedError, match="Missing identity"): - some_method() - - -def test_auth_using_default_policy_failing(): - auth: AuthorizationStrategy = get_strategy([]) - - auth.default_policy = Policy("authenticated", AuthenticatedRequirement()) - - @auth() - def some_method(): - return True - - with raises(UnauthorizedError): - some_method() - - -def test_auth_using_default_policy_succeeding(): - auth: AuthorizationStrategy = get_strategy([]) - - auth.default_policy = Policy("noop", NoopRequirement()) - - @auth() - def some_method(): - return True - - assert some_method() - - -def test_auth_without_policy_anonymous_identity(): - auth: AuthorizationStrategy = get_strategy([], lambda _: User({"oid": "001"})) - - @auth() - def some_method(): - return True - - with raises(UnauthorizedError, match="The resource requires authentication"): - some_method()