-
Notifications
You must be signed in to change notification settings - Fork 27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
add excluded_paths
to JWTMiddleware
#226
Conversation
Codecov Report
❗ Your organization is not using the GitHub App Integration. As a result you may experience degraded service beginning May 15th. Please install the Github App Integration for your organization. Read more. @@ Coverage Diff @@
## master #226 +/- ##
==========================================
+ Coverage 92.55% 92.58% +0.03%
==========================================
Files 33 33
Lines 2027 2036 +9
==========================================
+ Hits 1876 1885 +9
Misses 151 151
|
piccolo_api/jwt_auth/middleware.py
Outdated
|
||
headers = dict(scope["headers"]) | ||
token = self.get_token(headers) | ||
if not token: | ||
error = JWTError.token_not_found.value | ||
if allow_unauthenticated: | ||
if allow_unauthenticated or visible_paths: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You need to be careful here because this is checking to the presence of visible_paths
, and not whether it matches the current path.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you think scope["path"] in visible_paths
is a better option?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think checking presence of visible_paths
(if is empty list is or not specified we got Token not found
error) enough because we can make a GET request only for Swagger docs from browser on routes /docs
and /openapi.json
where we have to authorize the user if we want to communicate with the endpoints. All other routes must have a token in the header.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you think scope["path"] in visible_paths is a better option?
Yes, something like that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I'll change that even though I think it might not be necessary (see previous comment) because if for example we pass a /bands
route to visible_paths
, we can't make a request to that route directly from the browser (we'll get Token not found
). Basically we can only pass the Swagger docs routes /docs
and /openapi.json
. Sorry if I'm wrong.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
async def __call__(self, scope, receive, send):
"""
Add the user_id to the scope if a JWT token is available, and the user
is recognised, otherwise raise a 403 HTTP error.
"""
allow_unauthenticated = self.allow_unauthenticated
if scope['path'] in self.visible_paths:
await self.asgi(scope, receive, send)
return
# then all of the existing code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd maybe change visible_paths
to something like excluded_paths
. Meaning it's excluded from the checks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah. All the time I tested with
auth_header = APIKeyHeader(name="Authorization")
private_app = FastAPI(dependencies=[Depends(auth_header)])
and with auth_header
as dependency to the whole private_app
only Swagger endpoints work in visible_paths
because the authorization header dependency expected header on every request (that's why the /bands
endpoint requested a token). I'm sorry.
I will make the changes you suggested. If that's not enough, feel free to change anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, interesting. I didn't consider the interaction with APIKeyHeader
.
It might be best to attach APIKeyHeader
to an APIRouter
rather than the app.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. We can do this at APIRouter
level or if we use FastAPIWrapper
we can do this
auth_header = APIKeyHeader(name="Authorization")
private_app = FastAPI()
FastAPIWrapper(
"/bands/",
fastapi_app=private_app,
piccolo_crud=PiccoloCRUD(Band, read_only=False),
fastapi_kwargs=FastAPIKwargs(
all_routes={"tags": ["Band"]},
post={"dependencies": [Depends(auth_header)]}, # protected route
put={"dependencies": [Depends(auth_header)]}, # protected route
patch={"dependencies": [Depends(auth_header)]}, # protected route
delete_single={
"dependencies": [Depends(auth_header)]
}, # protected route
),
)
But to be honest, I like it best at the application level, as we do in Piccolo Admin, because a protected (private) application should be private, and I think the best option is that only Swagger docs can be seen (for frontend developers as requested in issue) but in order to communicate with the endpoints, the user must be authorized which is provided with APIKeyHeader
.
But it depends on the user how they want to implement the protection, and with excluded_paths
we provide an option for that.
piccolo_api/jwt_auth/middleware.py
Outdated
@@ -130,6 +137,10 @@ async def __call__(self, scope, receive, send): | |||
""" | |||
allow_unauthenticated = self.allow_unauthenticated | |||
|
|||
if scope["path"] in self.excluded_paths: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can enhance this quite easily to allow *
. For example foo/*
.
for excluded_path in self.excluded_paths:
if excluded_path.endswith('*'):
if scope['path'].startswith(excluded_path.rstrip('*')):
await self.asgi(scope, receive, send)
return
else:
if scope['path'] == excluded_path:
await self.asgi(scope, receive, send)
return
It would be a really useful feature.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great idea. With this we can exclude the entire paths of the app, for example for private_app
we can exclude all paths like private/*
. I only need to change this line
if scope['path'].startswith(excluded_path.rstrip('*')):
to
if excluded_path.startswith(excluded_path.rstrip("*")):
to have an effect.
@@ -199,3 +205,13 @@ def test_token_without_user_id(self): | |||
response.json(), | |||
{"user_id": None, "jwt_error": JWTError.user_not_found.value}, | |||
) | |||
|
|||
def test_visible_paths(self): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should update this to test_excluded_paths
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I forgot to change that. Thanks.
piccolo_api/jwt_auth/middleware.py
Outdated
return | ||
for excluded_path in self.excluded_paths: | ||
if excluded_path.endswith("*"): | ||
if excluded_path.startswith(excluded_path.rstrip("*")): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't quite right - because it's comparing excluded_paths
with excluded_paths
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But
if scope['path'].startswith(excluded_path.rstrip('*')):
does nothing. If we have /foo/*
we need check to root_path
like this
if scope["root_path"].startswith(excluded_path.rstrip("/*")):
to take some effect. Sorry if I don't understand well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if scope['path'].startswith(excluded_path.rstrip('*')):
work only if we specified excluded_paths=["*"]
not excluded_paths=["/foo/*"]
. Did you think so?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the confusion is to do with how FastAPI splits the path between path
and root_path
.
For example, if I have the an APIRouter
mounted at /private
, and an endpoint mounted to that router at /blog
, then root_path
is /private
and path
is /blog
.
I think we need to combine them for it to work properly. So rather than just checking against path
it's something like urllib.parse.urljoin(request.scope['root_path'], request.scope['path'])
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's also raw_path
:
https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This discussion gives some more context django/asgiref#229
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
raw_path
did the trick. If we use raw_path
like this
if scope["raw_path"].decode("utf-8").startswith(excluded_path.rstrip("*")):
both excluded_paths=["*"]
and excluded_paths=["/foo/*"]
works.
@@ -39,7 +39,7 @@ def wildcard_route(): | |||
asgi=ECHO_APP, secret="SECRET", allow_unauthenticated=True | |||
) | |||
APP_EXCLUDED_PATHS = JWTMiddleware( | |||
asgi=fastapi_app, secret="SECRET", excluded_paths=["/docs", "/*"] | |||
asgi=fastapi_app, secret="SECRET", excluded_paths=["/docs", "*"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should remove "*"
as this lets everything through.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That will decrease the coverage. If we remove that, we need to add another FastAPI application to test the route with wildcards like this
fastapi_app_wildcard = FastAPI()
@fastapi_app_wildcard.get("/")
def wildcard_route():
return "Wildcard route test"
APP_EXCLUDED_PATHS_WILDCARD = JWTMiddleware(
asgi=fastapi_app_wildcard,
secret="SECRET",
excluded_paths=["*"],
)
def test_excluded_paths_wildcards(self):
client = TestClient(APP_EXCLUDED_PATHS_WILDCARD)
response = client.get("/")
self.assertEqual(response.status_code, 200)
self.assertIn(
b"Wildcard route test",
response.content,
)
Is that OK?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking something like this:
fastapi_app_wildcard = FastAPI()
@fastapi_app_wildcard.get("/")
def home_root():
return "Root"
@fastapi_app_wildcard.get("/path/a/")
def sub_root():
return "Sub route"
APP_EXCLUDED_PATHS_WILDCARD = JWTMiddleware(
asgi=fastapi_app_wildcard,
secret="SECRET",
excluded_paths=["/path/*"],
)
def test_excluded_paths_wildcards(self):
client = TestClient(APP_EXCLUDED_PATHS_WILDCARD)
response = client.get("/")
# Requires a token
self.assertEqual(response.status_code, 403)
# Is an excluded path, so doesn't need a token
response = client.get("/path/a/")
self.assertEqual(response.status_code, 200)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK. I will add your tests with one change
def test_excluded_paths_wildcards(self):
client = TestClient(APP_EXCLUDED_PATHS_WILDCARD)
with self.assertRaises(HTTPException):
response = client.get("/")
# Requires a token
self.assertEqual(response.status_code, 403)
# Is an excluded path, so doesn't need a token
response = client.get("/path/a/")
self.assertEqual(response.status_code, 200)
excluded_paths
to JWTMiddleware
@dantownsend What is the problem that this is not merged, since it is practically the same as TokenAuth |
@sinisaos I think it just slipped through the cracks. I can't remember if we tweaked the docs or something on the other PR. Will have a look later on. |
@dantownsend Great. Thanks |
Related to discussion.