Description
Is it possible to only show the documentation based on being logged in? Many times I create an API which should only expose it's documentation when somebody is logged in.
Sorry for the delay.
In short, yes *.
*Now goes the "it depends"...
How are you logging in users?
The simplest, less secure option I can imagine, is embeding your Swagger UI in your frontend, and show it selectively on if the user is authenticated or not.
FastAPI doesn't enforce a specific way to log in users, but you can use several approaches, e.g.:
spoiler: I think this wouldn't work for you.
The suggestion in the docs, using OAuth2 with the "password flow" (JWT tokens in HTTP headers), involves the following steps:
POST with form data, including username and password.access_token.Using that, it is common to hold that access token in memory in the client (in a JS variable) or in localStorage. And then, when the client (your JS code in the browser) requests a protected endpoint, it sends the access token in the HTTP header. But you have to code it explicitly.
Now, when you open the browser in the docs (Swagger UI), it doesn't automatically send any token from anywhere, because it would have to be added explicitly in code, taken from wherever you put it.
Another option would be to use Cookies. Because the browser sends those Cookies automatically. But in that case, the API and the Swagger UI must live in the same host (the same combination of http or https and domain), for the browser to actually send the Cookies.
But still, all that doesn't change the actual /openapi.json endpoint to be available. So, you might want to use Cookies to override it too.
Another simpler option would be to protect those endpoints with HTTP Basic Auth. So, the browser would show the default authentication pop-up and would remember the credentials used there for some time. But that would be more or less disconnected from your current authentication system.
Thanks for your elaborate answer! I will check-out the OAuth2 JWT token, Cookies and Basic Auth options and see what the best route is. Is there an easy / quick way to add a Depends / Security option to the documentation endpoints?
Yes, it is relatively easy to add dependencies to those endpoints. You can create your FastAPI removing the automatic docs endpoints: https://fastapi.tiangolo.com/tutorial/application-configuration/#docs-urls
And then you can create them by hand, using the utils from fastapi.openapi.docs, there's a get_swagger_ui_html and get_redoc_html functions. There you can create the endpoints/path operations as normally.
But the next problem (probably the main one) is how to authenticate the user and where, outside the docs endpoints.
Thanks for the input! I just managed to create a custom documentation endpoint, including a Dependency which checks for a valid user (based on your explanation of OAuth2 JWT tokens). What I have done to achieve this is:
When not logged in, I am unable to see the documentation, but when I am logged in and use the token with my requests, I am able to see the documentation (for example with Postman or a Firefox plugin).
As a next step I will try the cookie approach and see how this works. Thanks for your time / input and great framework, really love working with it!
Great! Thanks for sharing your discoveries here.
Ok, I have created a version which makes use of a cookie, with the following steps:
Create a /login endpoint which returns a token based on a valid username / password (based on your provided examples) and include a cookie with the token (key="Authorization"), based on the JSONResponse object of Starlette
Create a new class, OAuth2PasswordBearerCookie based on OAuth2PasswordBearer which also includes checking for a cookie with key="Authorization". When the header authorization header is None, the cookie Authorization token is used.
The same token check can be used as in the previous set-up. To logout I have created an endpoint /logout which triggers a delete cookie action
This setup works fine as well! I can imagine that step 2 could be implemented smarter, like using 2 dependencies (not to 'pollute' the Oauth2PasswordBearer class), which get combined in a single dependency where the token can be checked.
I am now going to check out the last option and hopefully come to a nice conclusion, @tiangolo do you have an example how to trigger a basic browser based username / password authentication window?
Great! Sounds very interesting.
About having the browser trigger the username/password, that's done with HTTP Basic Auth. The server sends a response with some specific headers.
There's part of it in FastAPI, but the headers are not implemented yet, as I was waiting to see if I needed to create a custom HTTPException. Now, since some versions ago, we have it. It has the option to add custom headers. Specifically for this use case. But I haven't updated the HTTP Basic Auth utils to use it yet.
Ah ok, thanks for your response!
I have created the third scenario with Basic Auth and this works as expected! I tweaked this set-up with the following steps:
Create a /login endpoint which validates username / password (based on BasicAuth, I created a custom class which works almost the same as the OAuth2PasswordBearer class) and include a cookie with the token (key="Authorization"), based on the JSONResponse object of Starlette
Use the OAuth2PasswordBearerCookie class based on OAuth2PasswordBearer which also includes checking for a cookie with key="Authorization". When the header authorization header is none or Basic, the cookie Authorization token is used.
The same token check can be used as in the previous set-up. To logout I have created an endpoint /logout which triggers a delete cookie action
@tiangolo let me know if you want me to share my code snippets!
Sounds great @nilsdebruin !
Yes, I would love to see the code snippets, as I'm about to update some of these security utils to facilitate these kinds of things :smile:
Cool, I will update this in a couple of hours!
I am an enthusiastic Python user and not a professional developer, just so you know 馃槂
Things to note: If you want to make use of the cookie method, make sure you have a redirection to a valid full domain. What I have done is creating a domain name like, localhost.mydomain.com on AWS Route53 which points to 127.0.0.1 . This way you can test this locally on your machine and it all works!
Imports:
import base64
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
from fastapi.encoders import jsonable_encoder
from starlette.exceptions import HTTPException
from starlette.responses import Response, JSONResponse, RedirectResponse
First the extended PasswordBearer Class:
class OAuth2PasswordBearerCookie(OAuth2):
def __init__ (
self,
tokenUrl: str,
scheme_name: str = None,
scopes: dict = None,
auto_error: bool = True,
):
if not scopes:
scopes = {}
flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes})
super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)
async def __call__ (self, request: Request) -> Optional[str]:
header_authorization: str = request.headers.get("Authorization")
cookie_authorization: str = request.cookies.get("Authorization")
header_scheme, header_param = get_authorization_scheme_param(header_authorization)
cookie_scheme, cookie_param = get_authorization_scheme_param(cookie_authorization)
if header_scheme.lower() == "bearer":
authorization = True
scheme = header_scheme
param = header_param
elif cookie_scheme.lower() == "bearer":
authorization = True
scheme = cookie_scheme
param = cookie_param
else:
authorization = False
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
)
else:
return None
return param
This is the BasicAuth class:
class BasicAuth(SecurityBase):
def __init__ (
self,
scheme_name: str = None,
auto_error: bool = True,
):
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
async def __call__ (self, request: Request) -> Optional[str]:
authorization: str = request.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "basic":
if self.auto_error:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Not authenticated"
)
else:
return None
return param
This code is an extended version of the provided login example, adding the set cookie feature:
@app.post("/login", response_model=Token)
async def route_login_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
user = authenticate(user_db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=400, detail="Incorrect email or password")
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"username": form_data.username}, expires_delta=access_token_expires
)
token = jsonable_encoder(access_token)
response = JSONResponse({"access_token": token, "token_type": "bearer"})
response.set_cookie("Authorization", value=f"Bearer {token}", domain="mydomain.com", httponly=True, max_age=120, expires=120) #, secure=True)
return response
This code can be used as a login endpoint with Basic Auth:
basic_auth = BasicAuth(auto_error=False)
@app.get("/login_basic")
async def login_basic(auth: BasicAuth = Depends(basic_auth)):
if not auth:
response = Response(headers={'WWW-Authenticate': 'Basic'}, status_code=401)
return response
try:
decoded = base64.b64decode(auth).decode("ascii")
username, _, password = decoded.partition(":")
user = authenticate(user_db, username, password)
if not user:
raise HTTPException(status_code=400, detail="Incorrect email or password")
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"username": username}, expires_delta=access_token_expires
)
token = jsonable_encoder(access_token)
response = RedirectResponse(url='/documentation')
response.set_cookie("Authorization", value=f"Bearer {token}", domain="mydomain.com", httponly=True,
max_age=1800, expires=1800)
return response
except:
response = Response(headers={'WWW-Authenticate': 'Basic'}, status_code=401)
return response
Logout endpoint to delete the cookie:
@app.get("/logout")
async def route_logout_and_remove_cookie():
response = RedirectResponse(url="/")
response.delete_cookie("Authorization", domain="mydomain.com")
return response
And these are the authenticated documentation endpoints:
@app.get("/openapi.json")
async def get_open_api_endpoint(current_user: User = Depends(get_current_active_user)):
return JSONResponse(get_openapi(title="FastAPI", version=1, routes=app.routes))
@app.get("/documentation")
async def get_documentation(current_user: User = Depends(get_current_active_user)):
return get_swagger_ui_html(openapi_url="/openapi.json", title="docs")
Let me know if you need more!
Hi Nils - this would make a great Blog article.
@wshayes nice suggestion!
Thanks for sharing it @nilsdebruin !
I am an enthusiastic Python user and not a professional developer, just so you know smiley
You are definitely doing more advanced stuff than many professional developers I know. So, I name you a professional Python developer. :man_student: :snake: :wink:
This is great, it's a very clean example with the minimum to fulfill the use case. It is totally worth a blog post.
Just a note, I will update the Basic auth utils to return the WWW-Authenticate header automatically, so you won't need to implement that yourself.
And another suggestion, although this probably depends a bit more on personal taste. I think you can create a dependency that requires a normal OAuth2PasswordBearer and a normal (not documented yet, sorry) fastapi.security.api_key.APIKeyCookie, creating both objects with auto_error=False, and then doing the validation inside the dependency, to ensure that one (but not necessarily both) are provided. But that would save you from having to write the whole class (although most of the logic in __call__ would still go).
But again, I think this is totally worth a blog post. If you write it, please share it in the chat too, so that others can see it if they need that functionality. :cake:
@tiangolo Thanks for the kind words! I am very busy at the moment, but I have written the first part of the article, and I am really enjoying writing it!
That's great @nilsdebruin ! When you complete it, please share it with us 馃榿
This is the article: https://medium.com/@nils_29588/fastapi-how-to-add-basic-and-cookie-authentication-a45c85ef47d3?source=friends_link&sk=1a22e815a11d9d2f6ae2ff7a32e0e5fa
I will close the issue, thanks for the support!
Awesome, thank you!
Most helpful comment
This is the article: https://medium.com/@nils_29588/fastapi-how-to-add-basic-and-cookie-authentication-a45c85ef47d3?source=friends_link&sk=1a22e815a11d9d2f6ae2ff7a32e0e5fa
I will close the issue, thanks for the support!