Python FastAPI: Complete Authentication Flow with OAuth2 Security
In the third post, we implemented persistent stateful HTTP sessions. In this post, we will complete the applicationβs authentication UI flow. For the existing /auth/token
and /admin/me
routes, we will add functionality to conditionally return either HTML or JSON. Based on this new functionality, we will implement two new routes: /api/login
and /api/me
. These routes will only return JSON, and their endpoint handlers will be the same as those of the aforementioned routes respectively.
π Index of the Complete Series.
Python FastAPI: Complete Authentication Flow with OAuth2 Security |
π Please note, complete code for this post can be downloaded from GitHub with:
git clone -b v0.4.0 https://github.com/behai-nguyen/fastapi_learning.git
Table of Contents
- βΆ New Functionality and Routes Summary
- β· Project Layout
- βΈ Implementation of Conditional HTML and JSON Response
- βΉ Completion of the Application Authentication UI Flow
- βΊ Implementation of a Production-Grade Password Hashing and Matching
- β» Integration Tests
- βΌ Preparation to Run the Example Application and UI Discussion
- β½ Concluding Remarks
βΆ Continuing from the introduction above, after the completion of this post,
our FastAPI
learning application will be capable of functioning as both an
application server
and an
API-like server
or a service
. Here is a summary of the available routes after
the completion of this post.
Existing routes, some with added functionality to conditionally return either HTML or JSON:
-
GET
,http://0.0.0.0:port/admin/me
: Returns the currently logged-in userβs information in either JSON or HTML format. This route is accessible only toauthenticated sessions
. -
GET
,http://0.0.0.0:port/auth/login
: Returns the application login page in HTML format. -
POST
,http://0.0.0.0:port/auth/token
: Authenticates users. The response can be in either JSON or HTML format. -
POST
,http://0.0.0.0:port/auth/logout
: Logs out the currently logged-in or authenticated user. Currently, this redirects to the applicationβs HTML login page. -
GET
,http://0.0.0.0:port/
: This is the same ashttp://0.0.0.0:port/auth/login
.
-
GET
,http://0.0.0.0:port/auth/home
: Returns the application home page in HTML format after a user has successfully logged in. This route is accessible only toauthenticated sessions
. -
GET
,http://0.0.0.0:port/api/me
: This is a duplicate ofhttp://0.0.0.0:port/admin/me
, but this route returns the currently logged-in userβs information in JSON only. -
POST
,http://0.0.0.0:port/api/login
: This is a duplicate ofhttp://0.0.0.0:port/auth/token
, but the response is in JSON only.
β· The layout of the project is listed below.
β Please note, those marked with β are updated, and those marked with β are new.
/home/behai/fastapi_learning/
.
βββ main.py β
βββ pyproject.toml β
βββ pytest.ini β
βββ README.md β
βββ src
β βββ fastapi_learning
β βββ common
β β βββ consts.py β
β βββ controllers
β β βββ admin.py β
β β βββ auth.py β
β β βββ __init__.py β
β βββ __init__.py β
β βββ models
β β βββ employees.py β
β βββ static
β β βββ styles.css
β βββ templates
β βββ admin β
β β βββ me.html
β βββ auth
β β βββ home.html β
β β βββ login.html β
β βββ base.html β
βββ tests
βββ conftest.py
βββ __init__.py
βββ integration
β βββ test_admin_itgt.py β
β βββ test_api_itgt.py β
β βββ test_auth_itgt.py β
βββ README.md β
βΈ We are implementing functionality to conditionally return either HTML or JSON. In the third post, the only UI we had was the HTML login page. All the responses were in JSON. Without a complete UI, itβs not very efficient to observe a complete authentication process.
We will refactor this behavior as follows: the default response will be HTML.
The response will be JSON only when the incoming request contains the header
x-expected-format
, and its value is set to application/json
.
The public helper function
json_req(...)
in the
/controllers
layer checks for a requested JSON response.
π Please note the following important change in the logic of the code. In the
third post, the default behavior of the
OAuth2PasswordBearer class was
to raise an HTTPException
on error, which resulted in a JSON response
being sent to the client. Similarly, the helper methods of the endpoint handlers
also raised HTTPException
. This is no longer appropriate,
as only the endpoint handlers can determine the correct response format.
To accommodate this, all helper methods of the endpoint handlers now return the
HTTPException
. Furthermore, the auto_error
of
OAuth2PasswordBearer
is turned off
to prevent it from raising the HTTPException
exceptions.
As a result, we need to
detect and handle its errors ourselves.
In this iteration of the code, to return an error JSON response,
the endpoint handlers also raise the returned HTTPException
.
This implementation might change in the future.
π₯ To request a JSON response from the existing /auth/token
and /admin/me
routes, users must explicitly set the value of
the x-expected-format
header to application/json
.
However, with the new routes /api/login
and /api/me
,
users do not have to set the x-expected-format
header.
Even though their respective endpoint handlers are the same, we register
the /api/*
routes with a
custom JsonAPIRoute
routing class.
Within this custom class, we intercept the incoming HTTP requests and add
our own custom header x-expected-format
:
41
42
json_header: Tuple[bytes] = FORMAT_HEADER.encode(), types_map['.json'].encode()
request.headers.__dict__["_list"].append(json_header)
In the
/controllers
modules
auth.py
and
admin.py
,
we create new router instances using
JsonAPIRoute
as follows:
api_router = APIRouter(route_class=JsonAPIRoute,
prefix="/api",
tags=["API"],
)
We decorate the new /api/*
routes using the new api_router
instances, respectively in each of the above two modules as follows:
@api_router.post("/login")
async def login_api(request: Request,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
return await login(request, form_data)
@api_router.get("/me")
async def read_users_me_api(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)]
):
return await read_users_me(request, current_user)
Finally, the application instance
incorporates the two new router
instances to activate the two new routes /api/login
and /api/me
.
βΉ We are completing the application authentication UI flow.
Before being served, a request must undergo
request authentication
.
The outcome of the authentication determines if the request gets served, redirected, or results in an error response.
The term βAuthentication UI flowβ refers to when the application is being used as an
application server
,
i.e., serving HTML pages, and how the application redirects requests to
appropriate HTML pages depending on the outcome of the
request authentication
process. Consider the following examples:
β΅ A request to the route /auth/home
while not logged in gets redirected
to the login page /auth/login?state=2
. The value of the query parameter
state
triggers an appropriate message to be displayed on the login page.
Please note, in this Rust series, I implement redirection using server-side per-request cookies. Cookies can be complicated, as discussed in this post. While a query parameter is not as elegant as cookies, it is simple to implement, and in this case, it is not a security-critical piece of information.
βΆ A request to the route /auth/login
while already logged in
redirects to /auth/home
.
β· While already logged in, a request to the route /admin/me
returns the logged-in userβs new information HTML page.
In the future, if we add new routes, they should follow the behaviour just described.
βΊ We have updated the password hashing and matching. In the last
revision of the models/employees.py
module, the
fake_users_db
constant had a bug: the value of the hashed_password
field was incorrect. This bug
has now been fixed.
In this post, we removed the existing
fake_hash_password(...)
method
and added a new production-grade
password_match(...)
method.
This new method uses the
argon2-cffi
library to dehash the database password and compare it to the plain submitted password.
This password_match(...)
method will work with the test database, as
mentioned toward the end of the
third post:
Also, we will use a proper database, the Oracle Corporation MySQL test database. And our database access layer will be the Database wrapper classes for SQLAlchemy. The main table in the above database is the
employees
table, which does not have the email and the password fields, we will have to manually add them as discussed in the following section of another post Update theemployees
table, adding new fieldspassword
.
Please note,
fake_users_db
has two test users listed below. Please use them to test the example:
-
Username
:behai_nguyen@hotmail.com
;password
:password
-
Username
:pranav.furedi.10198@gmail.com
;password
:password
β» We still only have
Integration Tests
,
which have been significantly upgraded:
β΅ Please refer to README.md for explanations on some implementation details.
βΆ For the two existing modules,
integration/test_auth_itgt.py
and
integration/test_admin_itgt.py
,
new tests were created to test new functionalities.
β· The new test module,
integration/test_api_itgt.py
,
contains some tests for the /api/*
routes.
This module is not very comprehensive, as it is just a duplicate of some of
the tests already covered in the previous two existing modules.
βΌ Letβs get the example application running and briefly describe how the UI works. Please note that our application UI and Swagger UI share the same persistent stateful HTTP session state. Regardless of which route we use to log in, once logged in, it should be recognised as such in both UIs.
β΅ We need to install the argon2-cffi package. Please run the below project editable install command:
βΆοΈWindows 10:</code> (venv) F:\fastapi_learning>venv\Scripts\pip.exe install -e .
βΆοΈUbuntu 22.10:</code> (venv) behai@hp-pavilion-15:~/fastapi_learning$ ./venv/bin/pip install -e .
βΆ The command to run the application:
βΆοΈWindows 10:</code> (venv) F:\fastapi_learning>venv\Scripts\uvicorn.exe main:app --host 0.0.0.0 --port 5000
βΆοΈUbuntu 22.10:</code> (venv) behai@hp-pavilion-15:~/fastapi_learning$ ./venv/bin/uvicorn main:app --host 0.0.0.0 --port 5000
β· The application UI. Open a web browser and navigate to the example URL http://192.168.0.16:5000/ or http://localhost:5000/. We should get the application login page as illustrated in these screenshots in the third post. Then use one of these test credentials to log in:
-
Username
:behai_nguyen@hotmail.com
;password
:password
-
Username
:pranav.furedi.10198@gmail.com
;password
:password
βΈ The Swagger UI URL is http://192.168.0.16:5000/docs or http://localhost:5000/docs. And it still shares the same persistent stateful HTTP session state as the application UI.
Log in using either UI, then accessing a route on the other UI that requires an
authenticated session
,
it should result in a successful response. We have discussed this behaviour in
this illustration in the third post.
βΉ Accessing the /api/*
routes.
The following screenshots illustrate accessing the /api/*
routes using
Postman.
β Accessing the login route /api/login
:
Submits the credentials using application/x-www-form-urlencoded
:
β Accessing the logged-in user information route /api/me
: We need to
set the Authorization
header with Bearer <access_token>
,
currently the access_token
is still just the username:
β½ During the development of the code for this post, I have learned some more useful features of FastAPI I hope you would enjoy these features as well. We still have more to explore in this series, though I am not certain what we will cover in the next post yet.
Thank you for reading. I hope you find the information in this post useful. Stay safe, as always.
βΏβΏβΏ
Feature image source:
- https://www.omgubuntu.co.uk/2022/09/ubuntu-2210-kinetic-kudu-default-wallpaper
- https://in.pinterest.com/pin/337277459600111737/
- https://fastapi.tiangolo.com/
- https://1000logos.net/download-image/