Skip to content

Commit

Permalink
add excluded_paths to token auth (#227)
Browse files Browse the repository at this point in the history
* add excluded_paths to token auth

* make suggested changes

* move example to `literalinclude` and add images

* add params to docstring

---------

Co-authored-by: Daniel Townsend <dan@dantownsend.co.uk>
  • Loading branch information
sinisaos and dantownsend committed Apr 20, 2023
1 parent bcb1c72 commit 40d4316
Show file tree
Hide file tree
Showing 8 changed files with 222 additions and 11 deletions.
41 changes: 41 additions & 0 deletions docs/source/token_auth/examples/excluded_paths_example/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""
An example usage of excluded_paths.
"""

from fastapi import Depends, FastAPI
from fastapi.middleware import Middleware
from fastapi.security.api_key import APIKeyHeader
from starlette.middleware.authentication import AuthenticationMiddleware
from tables import Movie # An example Table

from piccolo_api.crud.endpoints import PiccoloCRUD
from piccolo_api.fastapi.endpoints import FastAPIKwargs, FastAPIWrapper
from piccolo_api.token_auth.middleware import (
SecretTokenAuthProvider,
TokenAuthBackend,
)

app = FastAPI(
dependencies=[Depends(APIKeyHeader(name="Authorization"))],
middleware=[
Middleware(
AuthenticationMiddleware,
backend=TokenAuthBackend(
SecretTokenAuthProvider(tokens=["abc123"]),
excluded_paths=["/docs", "/openapi.json"],
),
)
],
)


# This is a quick way of building FastAPI endpoiints using Piccolo, but isn't
# required:
FastAPIWrapper(
"/movies/",
fastapi_app=app,
piccolo_crud=PiccoloCRUD(Movie, read_only=False),
fastapi_kwargs=FastAPIKwargs(
all_routes={"tags": ["Movie"]},
),
)
23 changes: 23 additions & 0 deletions docs/source/token_auth/examples/excluded_paths_example/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import os
import sys

import uvicorn

sys.path.insert(
0,
os.path.dirname(
os.path.dirname(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
)
)
),
)


if __name__ == "__main__":
from app import app
from tables import Movie

Movie.create_table(if_not_exists=True).run_sync()
uvicorn.run(app, port=8081)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from piccolo.columns.column_types import Varchar
from piccolo.engine.sqlite import SQLiteEngine
from piccolo.table import Table

DB = SQLiteEngine()


class Movie(Table, db=DB):
title = Varchar()
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 61 additions & 8 deletions docs/source/token_auth/middleware.rst
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
Middleware
==========

The middleware builds upon Starlette's ``AuthenticationMiddleware``.
.. currentmodule:: piccolo_api.token_auth.middleware

The middleware builds upon Starlette's ``AuthenticationMiddleware`` (see the
`docs <https://www.starlette.io/authentication/>`_).

``TokenAuthBackend`` is used to extract the token from the request. If the token
:class:`TokenAuthBackend` is used to extract the token from the request. If the token
is present and correct, then the request is accepted and the corresponding user
is added to the scope, otherwise it is rejected.

``TokenAuthBackend`` can work with several different ``TokenAuthProvider``
``TokenAuthBackend`` can work with several different :class:`TokenAuthProvider`
subclasses. The following are provided by default, but custom ones can be
written by creating your own ``TokenAuthProvider`` subclasses.

SecretTokenAuthProvider
-----------------------
``SecretTokenAuthProvider``
---------------------------

This provider checks whether the token provided by the client matches a list of
predefined tokens.
Expand Down Expand Up @@ -41,8 +44,8 @@ apps. The client provides the token to be able to access the login endpoint,
after which they obtain a unique token, which is used to authenticate with
other endpoints.

PiccoloTokenAuthProvider
------------------------
``PiccoloTokenAuthProvider``
----------------------------

This provider checks a Piccolo database table for a corresponding token, and
retrieves a matching user ID. It is the default provider.
Expand All @@ -63,12 +66,62 @@ retrieves a matching user ID. It is the default provider.
You'll have to run the migrations for this to work correctly.

-------------------------------------------------------------------------------

``TokenAuthBackend``
--------------------

``excluded_paths``
~~~~~~~~~~~~~~~~~~

By default, the middleware protects all of the endpoints it is wrapping (i.e.
if a token isn't present in the header then the request is rejected).

However, we may want to exclude certain endpoints - for example, if there's a
Swagger docs endpoint, and allow access to them without a token. This is
possible using ``excluded_paths``.

Paths can be specified explicitly, or using wildcards:

.. code-block:: python
TokenAuthBackend(
PiccoloTokenAuthProvider(),
excluded_paths=["/docs", "/openapi.json", "/foo/*"],
)
.. note:: In the above example ``/foo/*`` matches ``/foo/``, ``/foo/a``, ``/foo/b``, ``/foo/b/1`` etc.

This is useful when using Swagger docs as they can be viewed in a browser,
but they are still token protected.

FastAPI example
***************

If we want to communicate with the API endpoints via the Swagger docs, we need to set `FastAPI APIKeyHeader <https://github.com/tiangolo/fastapi/blob/c81e136d75f5ac4252df740b35551cf2afb4c7f1/fastapi/security/api_key.py#L41>`_
as a dependency. After that we can authorise the user with a valid token as in
the example below.

.. literalinclude:: ./examples/excluded_paths_example/app.py

.. image:: ./images/authorize_button.png
.. image:: ./images/authorize_modal.png

The user can then use the Swagger docs to interact with the API.

.. note::
The full source code is available on GitHub. Find the source for this
documentation page, and look at the ``examples`` folder.


-------------------------------------------------------------------------------

Source
------

.. currentmodule:: piccolo_api.token_auth.middleware
.. autoclass:: TokenAuthBackend

.. autoclass:: TokenAuthProvider

.. autoclass:: PiccoloTokenAuthProvider

Expand Down
30 changes: 28 additions & 2 deletions piccolo_api/token_auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
)
from starlette.requests import HTTPConnection

from piccolo_api.shared.auth import User
from piccolo_api.shared.auth import UnauthenticatedUser, User
from piccolo_api.token_auth.tables import TokenAuth


Expand Down Expand Up @@ -84,9 +84,17 @@ class TokenAuthBackend(AuthenticationBackend):
def __init__(
self,
token_auth_provider: TokenAuthProvider = DEFAULT_PROVIDER,
excluded_paths: t.Optional[t.Sequence[str]] = None,
):
"""
:param token_auth_provider:
Used to verify that a token is correct.
:param excluded_paths:
These paths don't require a token.
"""
super().__init__()
self.token_auth_provider = token_auth_provider
self.excluded_paths = excluded_paths or []

def extract_token(self, header: str) -> str:
try:
Expand All @@ -99,8 +107,26 @@ def extract_token(self, header: str) -> str:
async def authenticate(
self, conn: HTTPConnection
) -> t.Optional[t.Tuple[AuthCredentials, BaseUser]]:

auth_header = conn.headers.get("Authorization", None)
conn_path = dict(conn)

for excluded_path in self.excluded_paths:
if excluded_path.endswith("*"):
if (
conn_path["raw_path"]
.decode("utf-8")
.startswith(excluded_path.rstrip("*"))
):
return (
AuthCredentials(scopes=[]),
UnauthenticatedUser(),
)
else:
if conn_path["path"] == excluded_path:
return (
AuthCredentials(scopes=[]),
UnauthenticatedUser(),
)

if not auth_header:
raise AuthenticationError("The Authorization header is missing.")
Expand Down
61 changes: 60 additions & 1 deletion tests/token_auth/test_middleware.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from unittest import TestCase

from fastapi import FastAPI
from piccolo.apps.user.tables import BaseUser
from piccolo.utils.sync import run_sync
from starlette.authentication import AuthenticationError
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.testclient import TestClient

from piccolo_api.token_auth.middleware import (
PiccoloTokenAuthProvider,
Expand All @@ -11,9 +14,38 @@
)
from piccolo_api.token_auth.tables import TokenAuth

fastapi_app = FastAPI(title="Test excluded paths")

fastapi_app_wildcard = FastAPI()


@fastapi_app_wildcard.get("/path/")
def home_root():
return "Root"


@fastapi_app_wildcard.get("/path/a/")
def sub_root():
return "Sub route"


APP_EXCLUDED_PATHS = AuthenticationMiddleware(
fastapi_app,
backend=TokenAuthBackend(
PiccoloTokenAuthProvider(), excluded_paths=["/docs"]
),
)

APP_EXCLUDED_PATHS_WILDCARD = AuthenticationMiddleware(
fastapi_app_wildcard,
backend=TokenAuthBackend(
PiccoloTokenAuthProvider(),
excluded_paths=["/path/*"],
),
)

class TestPiccoloToken(TestCase):

class TestPiccoloToken(TestCase):
credentials = {"username": "Bob", "password": "bob123"}

def setUp(self):
Expand Down Expand Up @@ -67,3 +99,30 @@ def test_extract_token_failure(self):
backend = TokenAuthBackend()
with self.assertRaises(AuthenticationError):
backend.extract_token("Bearer")


class TestExcludedPaths(TestCase):
def test_excluded_paths(self):
client = TestClient(APP_EXCLUDED_PATHS)

response = client.get("/docs")
self.assertEqual(response.status_code, 200)
self.assertIn(
b"<title>Test excluded paths - Swagger UI</title>",
response.content,
)

def test_excluded_paths_wildcard(self):
client = TestClient(APP_EXCLUDED_PATHS_WILDCARD)

response = client.get("/")
# Requires a authorization header
self.assertEqual(response.status_code, 400)
self.assertEqual(
response.content,
b"The Authorization header is missing.",
)

# Is an excluded path, so doesn't need a authorization header
response = client.get("/path/a/")
self.assertEqual(response.status_code, 200)

0 comments on commit 40d4316

Please sign in to comment.