In the second post of our FastAPI learning series, we implemented a placeholder for the application’s own authentication process. In this post, we will complete this process by implementing persistent server-side HTTP sessions using the starsessions library and its Redis store store, as well as extending the OAuth2PasswordBearer class.

🐍 Index of the Complete Series.

107-feature-image.png
Python FastAPI: Implementing Persistent Stateful HTTP Sessions with Redis Session Middleware and Extending OAuth2PasswordBearer for OAuth2 Security

🚀 Please note, complete code for this post can be downloaded from GitHub with:

git clone -b v0.3.0 https://github.com/behai-nguyen/fastapi_learning.git

❶ We use the starsessions library and its Redis store to implement persistent server-side HTTP sessions.

The starsessions library stores UUID session IDs in browsers under a cookie named session. In Redis, these session IDs are prefixed with starsessions., for example, starsessions.4d6982465cb604f0fc8523c925957e56.

Requests must include the session cookie to be recognised as originating from authenticated sessions. Cookies can be problematic; we will discuss this in the relevant sections of the post.

Note that the starsessions Redis store example has been written for the Starlette framework. For FastAPI, we register middleware with FastAPI().add_middleware(...).

Please refer to the post Using the Redis Official Docker Image on Windows 10 and Ubuntu 22.10 kinetic for instructions on setting up the Redis server on both Windows 10 and Ubuntu 22.10 Kinetic.

On Windows 10, we use the Redis Insight desktop application to view the Docker container Redis databases on both Windows 10 and Ubuntu 22.10. Start the application; the local database should be listed under 127.0.0.1:6379. To connect to a Docker container Redis database on another machine, click the + Add Redis database button on the top left-hand corner, then specify the IP address and the Redis database port. I did not need to specify the username and password. The screenshot below shows both Docker container Redis databases on Windows 10 and Ubuntu 22.10:

107-01.png

In this post, we refactor the application significantly. We make changes to the routes and reorganise the project layout more logically.

❷ We group the implemented routes under two prefixes: /auth and /admin. We also add some new routes. Please refer to the list below:

  1. GET, http://0.0.0.0:port/admin/me: Originally /users/me.
  2. GET, http://0.0.0.0:port/auth/login: Originally /login.
  3. POST, http://0.0.0.0:port/auth/token: Originally /token.
  4. POST, http://0.0.0.0:port/auth/logout: This is a new route.
  5. GET, http://0.0.0.0:port/: This is a new route. It is the same as http://0.0.0.0:port/auth/login.

❸ We refactor the single-module application into appropriate layers: /models and /controllers. We also add integration tests for all routes. 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
│         ├── auth
│         │ └── login.html ★
│         └── base.html
└── tests ☆
    ├── conftest.py
    ├── __init__.py
    └── integration
        ├── test_admin_itgt.py
        └── test_auth_itgt.py

Despite appearing complicated, there is not a lot of new code. The code under /models is copied as-is from main.py. Most of the /controllers code is also copied from main.py; the endpoint method for /auth/logout and some minor private helper methods are new. These should be self-explanatory. We will discuss some of the key code refactorings in the following sections.

main.py: Most of the code has been moved out, as mentioned above.

● The key addition includes lines 28 to 30:

REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost")
app.add_middleware(SessionAutoloadMiddleware)
app.add_middleware(SessionMiddleware, store=RedisStore(REDIS_URL), cookie_https_only=False)

We use the starsessions library and its Redis store to implement persistent server-side HTTP sessions, as mentioned previously.

💥 Please note: cookie_https_only=False — since we are not using the HTTPS scheme, it is necessary to set cookie_https_only to False. This setting is unrelated to integration tests.

Cookies are a complex issue. We have discussed cookies in greater detail elsewhere. Please refer to this detailed discussion.

● Other refactorings in this module should be self-explanatory.

⓶ The next key refactoring is the endpoint handler method for the POST login path /auth/token, located in controllers/auth.py, please note lines 5, 6 and 10:

1
2
3
4
5
6
7
8
9
10
11
12
@router.post("/token")
async def login(request: Request,
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
    
    if __is_logged_in(request): 
        return {"message": LOGGED_IN_SESSION_MSG}

    ...
    
    request.session["access_token"] = user.username

    return {"access_token": user.username, "token_type": "bearer"}

● After a valid login, we store the access_token in the persistent server-side HTTP session Redis store.

Subsequent requests from this same authenticated session should include the session UUID cookie as discussed. The session middleware then uses this UUID to load the actual session content from the Redis store, making the access_token available in the incoming request's request.session property.

● As long as we have a valid access_token in the persistent server-side HTTP session Redis store, any subsequent login request will simply return a JSON response {"message": "Already logged in"}. This same behavior applies to the GET login page path /auth/login, whose endpoint handler method is the login_form(...) method in controllers/auth.py.

This simple returned JSON serves as a placeholder in this revision of the code. It will be refactored further as we progress.

⓷ The final key refactoring is in the module fastapi_learning/__init__.py, with the following content:

class OAuth2PasswordBearerRedis(OAuth2PasswordBearer):
    async def __call__(self, request: Request) -> Optional[str]:

        ret_value = request.session.get("access_token")

        if ret_value != None:
            return ret_value
    
        return await super().__call__(request)
    
oauth2_scheme = OAuth2PasswordBearerRedis(tokenUrl="/auth/token")

Recall that in the previous version, it was a one-liner:

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

We effectively extend OAuth2PasswordBearer to first look for the access_token in the incoming request.session. If it is not there, we fall back to the default behavior, which checks the request Authorization header.

We understand that at this point, the appropriate session content in the Redis database has been loaded as described. Otherwise, the call to request.session.get("access_token") would not work as expected.

Integration Tests.

We are using pytest and FastAPI TestClient. To install the development dependencies onto the active virtual environment, please use the command:

pip install -e .[dev]

💥 Key point to remember: It is our responsibility to pass the session cookie from a successful login response to the subsequent requests; otherwise, the tests will not have access to the Redis session store. Here is what the code looks like. Please note lines 4 and 5:

1
2
3
4
5
6
7
login_response = test_client.post('/auth/token', ...)

# Set session (Id) for next request.
session_cookie = login_response.cookies.get('session')
test_client.cookies = {'session': session_cookie}

response = test_client.post('/auth/logout')

I struggled with setting the cookie correctly and sought help from various posts, please refer to this post and this post, but they were not very helpful. Finally, ChatGPT came to the rescue. Interestingly, ChatGPT did not get it right the first time on cookie setting, but after giving feedback, the second revision of the test code was correct.

We will not discuss the actual integration tests in detail; they should be self-explanatory. The tests simply check the responses from each route based on whether the HTTP session is authenticated or not.

❺ Let's take a look at how the UI works. We want to verify that regardless of which route we use to log in, once logged in, it should be recognised as such in both our application UI and Swagger UI.

⓵ Login using the application login page:

Note in the screenshots that the session cookie is created and stored in the browser, and in Redis, the session UUID is prefixed with starsessions., as discussed previously. The access_token is stored in the Redis database.

⓶ Using the same browser as in illustration ⓵, go to Swagger UI and submit a GET request to /admin/me:

We get the expected response. Please recall that this did not work in the previous post.

⓷ Using the same browser as in illustration ⓵, logout from Swagger UI:

Please note that the browser cookie is removed from the browser, and the session UUID is removed from Redis. In the last Redis screenshot, the data on the right-hand pane is residual data from illustration ⓵.

⓸ From Swagger UI, login using the POST request to path /auth/token:

We can see that it functions the same as when we login via the application login page, it should be so, since the endpoint method for both routes is the same.

The above UI illustrations are not all possible UI combinations. We can try, for example, login using the Swagger UI Authorize button, then accessing other routes. They should all work the same for both the Swagger UI and the application UI.

We conclude this post here. In the next iterations, we will possibly complete the UI, having a home page, etc. 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.

We will also have a PostgreSQL version of the above MySQL database. And our future example application will work with both databases, all we have to do is setting the database connection string appropriately in an external .env file.

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.