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.

110-feature-image.png
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

❢ 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:

  1. 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 to authenticated sessions.
  2. GET, http://0.0.0.0:port/auth/login: Returns the application login page in HTML format.
  3. POST, http://0.0.0.0:port/auth/token: Authenticates users. The response can be in either JSON or HTML format.
  4. 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.
  5. GET, http://0.0.0.0:port/: This is the same as http://0.0.0.0:port/auth/login.

New routes:

  1. 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 to authenticated sessions.
  2. GET, http://0.0.0.0:port/api/me: This is a duplicate of http://0.0.0.0:port/admin/me, but this route returns the currently logged-in user’s information in JSON only.
  3. POST, http://0.0.0.0:port/api/login: This is a duplicate of http://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 the employees table, adding new fields email and password.

Please note, fake_users_db has two test users listed below. Please use them to test the example:

  1. Username: behai_nguyen@hotmail.com; password: password
  2. 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:

  1. Username: behai_nguyen@hotmail.com; password: password
  2. 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:

110-01.png

● 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:

110-02.png

❽ 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:

🐍 Index of the Complete Series.