Skip to content

Commit 7dae866

Browse files
authored
282 Add excluded_paths to SessionsAuthBackend (#283)
* add `excluded_paths` to `SessionsAuthBackend` * try fixing mypy test * fix typo
1 parent 9cc0966 commit 7dae866

File tree

6 files changed

+170
-26
lines changed

6 files changed

+170
-26
lines changed

docs/source/session_auth/middleware.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ follows:
8888
8989
-------------------------------------------------------------------------------
9090

91+
``excluded_paths``
92+
------------------
93+
94+
This works identically to token auth - see :ref:`excluded_paths`.
95+
96+
-------------------------------------------------------------------------------
97+
9198
Source
9299
------
93100

docs/source/token_auth/middleware.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ You'll have to run the migrations for this to work correctly.
7171
``TokenAuthBackend``
7272
--------------------
7373

74+
.. _excluded_paths:
75+
7476
``excluded_paths``
7577
~~~~~~~~~~~~~~~~~~
7678

piccolo_api/session_auth/middleware.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from piccolo_api.session_auth.tables import SessionsBase
1616
from piccolo_api.shared.auth import UnauthenticatedUser, User
17+
from piccolo_api.shared.auth.excluded_paths import check_excluded_paths
1718

1819

1920
class SessionsAuthBackend(AuthenticationBackend):
@@ -31,6 +32,7 @@ def __init__(
3132
active_only: bool = True,
3233
increase_expiry: t.Optional[timedelta] = None,
3334
allow_unauthenticated: bool = False,
35+
excluded_paths: t.Optional[t.Sequence[str]] = None,
3436
):
3537
"""
3638
:param auth_table:
@@ -43,22 +45,26 @@ def __init__(
4345
The name of the session cookie. Override this if it clashes with
4446
other cookies in your application.
4547
:param admin_only:
46-
If True, users which aren't admins will be rejected.
48+
If ``True``, users which aren't admins will be rejected.
4749
:param superuser_only:
48-
If True, users which aren't superusers will be rejected.
50+
If ``True``, users which aren't superusers will be rejected.
4951
:param active_only:
50-
If True, users which aren't active will be rejected.
52+
If ``True``, users which aren't active will be rejected.
5153
:param increase_expiry:
5254
If set, the session expiry will be increased by this amount on each
5355
request, if it's close to expiry. This allows sessions to have a
5456
short expiry date, whilst also providing a good user experience.
5557
:param allow_unauthenticated:
56-
If True, when a matching user session can't be found, the request
58+
If ``True``, when a matching user session can't be found, the request
5759
still continues, but an unauthenticated user is added to the scope.
5860
It's then up to the application's endpoints to check if a user is
5961
authenticated or not using ``request.user.is_authenticated``. If
60-
False, the request is automatically rejected if a user session
62+
``False``, the request is automatically rejected if a user session
6163
can't be found.
64+
:param excluded_paths:
65+
These paths don't require a session cookie - useful if you want to
66+
exclude a few URLs, such as docs.
67+
6268
""" # noqa: E501
6369
super().__init__()
6470
self.auth_table = auth_table
@@ -69,7 +75,9 @@ def __init__(
6975
self.active_only = active_only
7076
self.increase_expiry = increase_expiry
7177
self.allow_unauthenticated = allow_unauthenticated
78+
self.excluded_paths = excluded_paths or []
7279

80+
@check_excluded_paths
7381
async def authenticate(
7482
self, conn: HTTPConnection
7583
) -> t.Optional[t.Tuple[AuthCredentials, BaseUser]]:
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
import functools
4+
import typing as t
5+
6+
from starlette.authentication import AuthCredentials, AuthenticationBackend
7+
from starlette.requests import HTTPConnection
8+
9+
from piccolo_api.shared.auth import UnauthenticatedUser
10+
11+
12+
def check_excluded_paths(authenticate_func: t.Callable):
13+
14+
@functools.wraps(authenticate_func)
15+
async def authenticate(self: AuthenticationBackend, conn: HTTPConnection):
16+
conn_path = dict(conn)
17+
18+
excluded_paths = getattr(self, "excluded_paths", None)
19+
20+
if excluded_paths is None:
21+
raise ValueError("excluded_paths isn't defined")
22+
23+
for excluded_path in excluded_paths:
24+
if excluded_path.endswith("*"):
25+
if (
26+
conn_path["raw_path"]
27+
.decode("utf-8")
28+
.startswith(excluded_path.rstrip("*"))
29+
):
30+
return (
31+
AuthCredentials(scopes=[]),
32+
UnauthenticatedUser(),
33+
)
34+
else:
35+
if conn_path["path"] == excluded_path:
36+
return (
37+
AuthCredentials(scopes=[]),
38+
UnauthenticatedUser(),
39+
)
40+
41+
return await authenticate_func(self=self, conn=conn)
42+
43+
return authenticate

piccolo_api/token_auth/middleware.py

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
)
1414
from starlette.requests import HTTPConnection
1515

16-
from piccolo_api.shared.auth import UnauthenticatedUser, User
16+
from piccolo_api.shared.auth import User
17+
from piccolo_api.shared.auth.excluded_paths import check_excluded_paths
1718
from piccolo_api.token_auth.tables import TokenAuth
1819

1920

@@ -90,7 +91,9 @@ def __init__(
9091
:param token_auth_provider:
9192
Used to verify that a token is correct.
9293
:param excluded_paths:
93-
These paths don't require a token.
94+
These paths don't require a token - useful if you want to
95+
exclude a few URLs, such as docs.
96+
9497
"""
9598
super().__init__()
9699
self.token_auth_provider = token_auth_provider
@@ -104,29 +107,11 @@ def extract_token(self, header: str) -> str:
104107

105108
return token
106109

110+
@check_excluded_paths
107111
async def authenticate(
108112
self, conn: HTTPConnection
109113
) -> t.Optional[t.Tuple[AuthCredentials, BaseUser]]:
110114
auth_header = conn.headers.get("Authorization", None)
111-
conn_path = dict(conn)
112-
113-
for excluded_path in self.excluded_paths:
114-
if excluded_path.endswith("*"):
115-
if (
116-
conn_path["raw_path"]
117-
.decode("utf-8")
118-
.startswith(excluded_path.rstrip("*"))
119-
):
120-
return (
121-
AuthCredentials(scopes=[]),
122-
UnauthenticatedUser(),
123-
)
124-
else:
125-
if conn_path["path"] == excluded_path:
126-
return (
127-
AuthCredentials(scopes=[]),
128-
UnauthenticatedUser(),
129-
)
130115

131116
if not auth_header:
132117
raise AuthenticationError("The Authorization header is missing.")

tests/session_auth/test_session.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,105 @@ def test_wrong_cookie_value(self):
707707
)
708708

709709

710+
###############################################################################
711+
712+
EXCLUDED_PATHS_APP = Router(
713+
routes=[
714+
Route("/", EchoEndpoint),
715+
Route(
716+
"/foo/",
717+
EchoEndpoint,
718+
),
719+
Route(
720+
"/foo/1/",
721+
EchoEndpoint,
722+
),
723+
Route(
724+
"/bar/",
725+
EchoEndpoint,
726+
),
727+
Route(
728+
"/bar/1/",
729+
EchoEndpoint,
730+
),
731+
]
732+
)
733+
734+
735+
class TestExcludedPaths(SessionTestCase):
736+
"""
737+
Make sure that if `excluded_paths` is set, then the middleware allows the
738+
request to continue without a cookie.
739+
"""
740+
741+
def create_user_and_session(self):
742+
user = BaseUser(
743+
**self.credentials, active=True, admin=True, superuser=True
744+
)
745+
user.save().run_sync()
746+
SessionsBase.create_session_sync(user_id=user.id)
747+
748+
def setUp(self):
749+
super().setUp()
750+
751+
# Add a session to the database to make it more realistic.
752+
self.create_user_and_session()
753+
754+
def test_excluded_paths(self):
755+
"""
756+
Make sure that only the `excluded_paths` are accessible
757+
"""
758+
app = AuthenticationMiddleware(
759+
EXCLUDED_PATHS_APP,
760+
SessionsAuthBackend(
761+
allow_unauthenticated=False,
762+
excluded_paths=["/foo/"],
763+
),
764+
)
765+
client = TestClient(app)
766+
767+
for path in ("/", "/foo/1/", "/bar/", "/bar/1/"):
768+
response = client.get(path)
769+
self.assertEqual(response.status_code, 400)
770+
self.assertEqual(response.content, b"No session cookie found.")
771+
772+
response = client.get("/foo/")
773+
assert response.status_code == 200
774+
self.assertDictEqual(
775+
response.json(),
776+
{"is_unauthenticated_user": True, "is_authenticated": False},
777+
)
778+
779+
def test_excluded_paths_wildcard(self):
780+
"""
781+
Make sure that wildcard paths work correctly.
782+
"""
783+
app = AuthenticationMiddleware(
784+
EXCLUDED_PATHS_APP,
785+
SessionsAuthBackend(
786+
allow_unauthenticated=False,
787+
excluded_paths=["/foo/*"],
788+
),
789+
)
790+
client = TestClient(app)
791+
792+
for path in ("/", "/bar/", "/bar/1/"):
793+
response = client.get(path)
794+
self.assertEqual(response.status_code, 400)
795+
self.assertEqual(response.content, b"No session cookie found.")
796+
797+
for path in ("/foo/", "/foo/1/"):
798+
response = client.get(path)
799+
self.assertEqual(response.status_code, 200)
800+
self.assertDictEqual(
801+
response.json(),
802+
{"is_unauthenticated_user": True, "is_authenticated": False},
803+
)
804+
805+
806+
###############################################################################
807+
808+
710809
class TestHooks(SessionTestCase):
711810
def test_hooks(self):
712811
# TODO Replace these with mocks ...

0 commit comments

Comments
 (0)