Python FastAPI: Implementing Persistent Stateful HTTP Sessions with Redis Session Middleware and Extending OAuth2PasswordBearer for OAuth2 Security
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.
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:
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:
-
GET
,http://0.0.0.0:port/admin/me
: Originally/users/me
. -
GET
,http://0.0.0.0:port/auth/login
: Originally/login
. -
POST
,http://0.0.0.0:port/auth/token
: Originally/token
. -
POST
,http://0.0.0.0:port/auth/logout
: This is a new route. -
GET
,http://0.0.0.0:port/
: This is a new route. It is the same ashttp://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.
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:
- 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/