Python FastAPI: Bug Fixing the Logout Process and Redis Session Cleanup
While experimenting with some CLI clients for the server implemented in this Python FastAPI learning series, I found two similar bugs in the server: both were related to Redis session entries not being cleaned up.
The first bug involves some temporary redirection entries that do not get removed after the requests are completed. The second, more significant bug, is that the logout process does not clean up the session entry if the incoming request has only the access token and no session cookies.
We address both of these bugs in this post, with most of the focus on the second one.
π Index of the Complete Series.
Python FastAPI: Bug Fixing the Logout Process and Redis Session Cleanup |
The code requires Python 3.12.4. Please refer to the following discussion on how to upgrade to Python 3.12.4.
π Please note, complete code for this post can be downloaded from GitHub with:
git clone -b v0.14.0 https://github.com/behai-nguyen/fastapi_learning.git
βΆ Problem Description and Proposed Solution
The above problems are in the current version, which is v0.13.0
, available for cloning with the following command:
git clone -b v0.13.0 https://github.com/behai-nguyen/fastapi_learning.git
There are two problems:
β΅ Temporary entries that store redirection data between requests do not get removed from Redis storage. This was simple to resolveβjust remove the entries after retrieval. We will not go into this any further.
βΆ Logout does not work with the access token, leaving the Redis session entry behind after logging out.
I understood this issue as early as in the third post:
Subsequent requests from this same
authenticated session
should include thesession
UUID cookie as discussed. The session middleware then uses this UUID to load the actual session content from the Redis store, making theaccess_token
available in the incoming requestβsrequest.session
property.
Logout request must include the session
UUID cookie.
When used as an
application server
,
browsers automatically include this cookie. When used as an
API-like server
or a service
,
we must manually include this cookie in the request.
Due to a massive oversight, I forgot this issue until it was too late.
Letβs illustrate the problem with the following script:
import urllib3
COOKIE: str = None
ACCESS_TOKEN: str = None
def login(http: urllib3.PoolManager, username: str, password: str):
global COOKIE
global ACCESS_TOKEN
# Assuming always succeeds.
resp = http.request(
"POST",
"https://localhost:5000/api/login",
fields={"username": username, "password": password}
)
status = resp.json()
"""
An example of server response cookie resp.headers['Set-Cookie']:
session=4441b57be81e7f8ec37b40652b0a8039; path=/; httponly; samesite=lax
while resp.info().get_all('Set-Cookie') is a list:
['session=4441b57be81e7f8ec37b40652b0a8039; path=/; httponly; samesite=lax']
"""
COOKIE = resp.headers['Set-Cookie']
ACCESS_TOKEN = status['data']['access_token']
def logout_01(http: urllib3.PoolManager, response_cookie: str):
"""
This logout request deletes the Redis session entry.
"""
http.request(
"POST",
"https://localhost:5000/auth/logout",
headers={
"Cookie": response_cookie,
"x-expected-format": "application/json",
}
)
def logout_02(http: urllib3.PoolManager, access_token: str):
"""
This logout request DOES NOT delete the Redis session entry.
"""
http.request(
"POST",
"https://localhost:5000/auth/logout",
headers={
"Authorization": f"Bearer {access_token}",
"x-expected-format": "application/json",
}
)
urllib3.disable_warnings()
http = urllib3.PoolManager(cert_reqs='CERT_NONE', assert_hostname=False)
login(http, 'behai_nguyen@hotmail.com', 'password')
print(f"COOKIE: {COOKIE}")
print(f"ACCESS_TOKEN: {ACCESS_TOKEN}")
logout_01(http, COOKIE)
# logout_02(http, ACCESS_TOKEN)
For more information on urllib3 and SSL/HTTPS, please see the following article: The Python urllib3 HTTP Library and SSL/HTTPS for localhost.
Letβs run the above script as follows:
-
Call
login(...)
, thenlogout_01(http, COOKIE)
: Redis Insight shows that the session entry gets removed, leaving nothing behind.
π₯ At the the server-side, the cookie identifies the access token. This makes it unnecessary for the client to remember the access token; they only need to remember the cookie. -
Call
login(...)
, thenlogout_02(http, ACCESS_TOKEN)
: The session entry did not get removed. In addition, an additional entry with a differentsession
UUID was created. This entry stores the redirection data, which also gets fixed in this post: delete the entry when the data gets retrieved.
β· The Proposed Solution for the Access Token Problem
π The proposed solution is to include the session UUID in the access token payload.
All other endpoints that expect the access token will work as they currently do.
The only endpoint that needs refactoring is the logout endpoint: If the request includes
the session
UUID, then use it to manage the Redis session entry.
Otherwise, decode the access token, ignoring expiration and any other errors,
then use the session UUID included in the access token payload to manage the Redis session entry.
This proposed solution also ensures that, when used as an
application server
,
browser cookies work as they normally do.
π₯ Please note ahead that this proposed solution raises some more issues, which I havenβt found elegant solutions to. I settled for some workarounds, which are discussed in a a later section.
β· No new files were added. The current structure of the project is outlined below.
β Please note, those marked with β are updated, and those marked with β are new.
/home/behai/fastapi_learning/
.
βββ cert
βΒ Β βββ cert.pem
βΒ Β βββ key.pem
βββ .env
βββ logger_config.yaml
βββ pyproject.toml
βββ pytest.ini β
βββ README.md β
βββ src
βΒ Β βββ fastapi_learning
βΒ Β βββ businesses
βΒ Β βΒ Β βββ app_business.py
βΒ Β βΒ Β βββ base_business.py
βΒ Β βΒ Β βββ base_validation.py
βΒ Β βΒ Β βββ employees_mgr.py
βΒ Β βΒ Β βββ employees_validation.py
βΒ Β βββ common
βΒ Β βΒ Β βββ consts.py
βΒ Β βΒ Β βββ jwt_utils.py β
βΒ Β βΒ Β βββ queue_logging.py
βΒ Β βΒ Β βββ scope_utils.py
βΒ Β βββ controllers
βΒ Β βΒ Β βββ admin.py β
βΒ Β βΒ Β βββ auth.py β
βΒ Β βΒ Β βββ employees_admin.py β
βΒ Β βΒ Β βββ __init__.py β
βΒ Β βΒ Β βββ required_login.py β
βΒ Β βββ __init__.py β
βΒ Β βββ models
βΒ Β βΒ Β βββ employees.py
βΒ Β βββ static
βΒ Β βΒ Β βββ js
βΒ Β βΒ Β βββ application.js
βΒ Β βββ templates
βΒ Β βββ admin
βΒ Β βΒ Β βββ me.html
βΒ Β βββ auth
βΒ Β βΒ Β βββ home.html
βΒ Β βΒ Β βββ login.html
βΒ Β βββ base.html
βΒ Β βββ emp
βΒ Β βββ insert.html
βΒ Β βββ search.html
βΒ Β βββ search_result.html
βΒ Β βββ update.html
βββ tests
βββ business
βΒ Β βββ test_employees_mgr.py
βΒ Β βββ test_employees_validation.py
βΒ Β βββ test_scope_utils.py
βββ conftest.py
βββ __init__.py
βββ integration
βΒ Β βββ test_admin_itgt.py
βΒ Β βββ test_api_itgt.py
βΒ Β βββ test_auth_itgt.py
βΒ Β βββ test_employees_itgt.py
βΒ Β βββ test_expired_jwt.py
βΒ Β βββ test_scope_permission_itgt.py
βΒ Β βββ test_scope_ui_itgt.py
βββ README.md
βββ unit
βββ test_employees.py
β΅ Accessing the session
UUID to Include in the Access Token Payload
I have looked through the
starsessions library documentation and code,
and I could not find a way to generate the session
UUID
in advance of access token creation.
In the current version (v0.13.0
), after calling
set_access_token(request, access_token)
,
the session
UUID is still not accessible, at least from my understanding.
It is first available after redirecting to
/auth/token
,
i.e., the
home_page(...)
endpoint handler method.
As we also redirect to the home_page(...)
under different scenarios,
we cannot create the access token there. Instead, we redirect to a βprivateβ endpoint,
create and set the access token in this endpoint handler method, and then redirect to the
/auth/token
endpoint as before.
βΆ Refactoring the login process
Within the login(...)
endpoint handler method,
these lines are the refactored code.
The FIXME
markers indicate workaround code for which I hope to find a better solution later on. Please note the following:
303
304
305
306
307
#
# FIXME: HACK! This causes the 'session' cookie created, so that
# async def __internal(request: Request): can access the session Id.
#
request.session['x-email'] = op_status.data[0]['email']
Without setting an entry in request.session
, the session
UUID
cookie would not be created.
322
323
324
325
326
327
response.set_cookie(key=RESPONSE_FORMAT, value=response_format, httponly=True)
response.set_cookie(key='x-email', value=op_status.data[0]['email'], httponly=True)
response.set_cookie(key='x-emp-no', value=str(op_status.data[0]['emp_no']), httponly=True)
response.set_cookie(key='x-scopes', value='^'.join(op_status.data.scopes), httponly=True)
return response
We need these pieces of information in the
__internal(...)
endpoint handler method.
In this method, we extract the information from the cookies to create the access token and set it to the session storage. We remove all temporary entries set in cookies and session storage. Then, finally, we redirect to the home page endpoint handler method as before.
I donβt particularly like this workaround, but for the time being, this is the best I could do. I have tried several other approaches and have not succeeded in getting them to work.
β· Rewrote and Bug-Fixed the
logout(...)
Endpoint Handler Method
Again, please note the FIXME
markers. Please note the following:
β Added the parameter token: Annotated[str, Depends(oauth2_scheme)]
: This
ensures that this endpoint method gets the access token.
β First, it looks into the request cookies to extract the session
UUID.
If there are no cookies, it extracts the session
UUID from the access token.
β Then it uses Redis directly to remove the session entry:
366
367
368
response.set_cookie(key=RESPONSE_FORMAT, value=res # Does this session Id exist, currently?
auth_session = session_id in redis_server.scan_iter(session_id)
redis_server.delete(session_id)
The
redis_server
instance is:
41
42
redis_server = redis.Redis(host=os.environ.get("REDIS_URL", "redis://localhost").split('//')[1],
decode_responses=True)
This is a bit drastic. I do not like it. But I do not know of another alternative.
I have written middleware to inject starsessions
βs
RedisStore
into the request and use the RedisStore
instance to access Redis.
However, in the latest version, they removed the exists(...)
method, which I need.
These changes take care of cleaning up the Redis session storage when logging out using access tokens.
βΈ Redecorate Endpoint Methods
I have just learned that endpoint methods can be decorated with multiple different path decorators. π
In the admin.py
module, we removed the method
read_users_me_api(...)
,
and decorated the method
read_users_me(...)
with
@api_router.get("/me")
.
Similarly, in the auth.py
module, we removed the method
login_api(...)
,
and moved the decorator @api_router.post("/login")
to the
login(...)
method.
Finally, we created a new /api/logout
route with the following decorator
@api_router.post("/logout", response_class=HTMLResponse)
on the
logout(...)
endpoint handler method.
All of the 84 tests remain unchanged, and they all pass with this new implementation. I intentionally did not write any new tests; I just ensured the existing tests work as they are. I might write some more later. Importantly, after running all tests, the Redis server should show no entries.
βΊ Test the Refactored Code with a CLI Test Script
Letβs see how the illustrative script
previously demonstrated would work with the refactored code. But first, recall
from the login process code refactoring that
we redirect with some cookies set. The
urllib3
library does not have cookies enabled, and the server will not handle cookies correctly.
We need to enable cookies ourselves, which I have not been able to figure out how to do.
The
Requests
library, which uses urllib3
, handles cookies out of the box.
So we need to modify the original script
a little: The two logout methods remain unchanged, and the login(...)
method
uses the Requests
library. Please note the header setting
headers={'x-referer': 'desktopclient'}
, which is used in the
__internal(...)
endpoint handler method
as discussed.
import urllib3
import requests
COOKIE: str = None
ACCESS_TOKEN: str = None
def login(http: requests.Session, username: str, password: str):
global COOKIE
global ACCESS_TOKEN
# Assuming always succeeds.
resp = http.post(
"https://localhost:5000/api/login",
headers={'x-referer': 'desktopclient'},
data={"username": username, "password": password},
verify=False
)
status = resp.json()
COOKIE = f"session={resp.cookies.get('session')}; path=/; httponly; samesite=lax"
ACCESS_TOKEN = status['data']['access_token']
def logout_01(http: urllib3.PoolManager, response_cookie: str):
"""
This logout request deletes the Redis session entry.
"""
http.request(
"POST",
"https://localhost:5000/auth/logout",
headers={
"Cookie": response_cookie,
"x-expected-format": "application/json",
}
)
def logout_02(http: urllib3.PoolManager, access_token: str):
"""
This logout request DOES NOT delete the Redis session entry.
"""
http.request(
"POST",
"https://localhost:5000/auth/logout",
headers={
"Authorization": f"Bearer {access_token}",
"x-expected-format": "application/json",
}
)
# Suppress only the single warning from urllib3.
urllib3.disable_warnings(category=urllib3.exceptions.InsecureRequestWarning)
session = requests.Session()
urllib3.disable_warnings()
http = urllib3.PoolManager(cert_reqs='CERT_NONE', assert_hostname=False)
login(session, 'behai_nguyen@hotmail.com', 'password')
print(f"COOKIE: {COOKIE}")
print(f"ACCESS_TOKEN: {ACCESS_TOKEN}")
logout_01(http, COOKIE)
# logout_02(http, ACCESS_TOKEN)
Please run it as previously described, but this time, both of the logout methods should remove all entries from the Redis server storage.
β» I am not sure how much I have understood
starsessions,
even though I have spent time looking at its code. Ideally, users should be able to dictate when to create the session
UUID and thus the session entry. So far, this does not seem to be the case with this library.
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/2024/03/ubuntu-24-04-wallpaper
- https://in.pinterest.com/pin/337277459600111737/
- https://www.python.org/downloads/release/python-3124/
- https://fastapi.tiangolo.com/
- https://1000logos.net/download-image/
- https://www.logo.wine/logo/MySQL
- https://icon-icons.com/download/170836/PNG/512/
- https://www.stickpng.com/img/icons-logos-emojis/tech-companies/mariadb-full-logo