FastAPI by A Python Beginner (5)

Authentication & Authorization (II)

Leo Lin (leocantthinkofaname)
7 min readOct 13, 2023

This is a multiparter journey. I don’t know where it’s going to ends. You’re always welcome if you’re interested in this topic or want to join this step-by-step journey.

FastAPI by A Python Beginner

5 stories
Photo by Nik Owens on Unsplash

Last time I implemented a basic HTTP authorization. Everything seems to work, but there’s still lack of something.

While the authorization system works, but my app is supposed to be used by real person. The basic authorization doesn't seem to be a good fit for this case.

The problem is all requests need Authorization: Basic USERNAME:PASSWORD header is not practical for real user. Because I don’t want my user to type their username and password every time. So, I have to do something to keep the information in the browser. But it’s never a good idea to store user’s password on the client side.

And that’s why we have API key. A short-lived, series of gibberish that doesn’t contain any sensitive information, and can be revoked anytime if we needed. I can store the API key in browsers’ local storage, or as a piece of cookie without worrying my user’s credential being compromised.

Like the last time, instead catching the trend and using the latest technologies. I’d like to take the old-fashioned way first. Session cookies.

FastAPI’s documentation has been great for almost everything I’ve done so far. But there’s almost zero about how to use session cookies in the documentation…

But I do know FastAPI is built on top of Starlette. So, maybe I can find something there.

Before that, I’ll need two endpoints for my user “john” to log in and log out. This should be easy.

app
├── __init__.py
├── main.py
├── dependencies.py
├── routers
├── __init__.py
├── tracks.py
├── users.py
├── login.py
├── logout.py
dockerfile
docker-compose.yml
requirements.txt

Now I have something like this. I add two routes, login.py and logout.py, and create a new file called dependencies.py to store something that is not specifically for one module.

Then wire them up in the main.py

app.include_router(tracks.router, prefix="/tracks", tags=["Tracks"], responses=fallback)
app.include_router(users.router, prefix="/users", tags=["Users"], responses=fallback)
app.include_router(login.router, prefix="/login", tags=["Login"], responses=fallback)
app.include_router(logout.router, prefix="/logout", tags=["Logout"], responses=fallback)

Now. Sessions… Something never been mentioned in FastAPI documentation (at least not the way I want).

So, I went to the Starlette documentation and found something called SessionMiddleware.

Adds signed cookie-based HTTP sessions. Session information is readable but not modifiable.
Access or modify the session data using the request.session dictionary interface.

Looks just like what I need.

First, I have to figure out how to use middleware. There’s one method called add_middleware in FastAPI instance, my app, let’s do this.

app.add_middleware(
SessionMiddleware,
same_site="strict",
session_cookie=MY_SESSION_ID,
secret_key="mysecret",
)

And the add_middleware accepts multiple arguments. The first is the actual middleware I want to use, and the rest are options for the middleware.

If someone want to use this approach, you should probably set https_only on for security and store your secret_key somewhere else, and better use some random generated value, instead of something silly like mysecret.

According to the Starlette documentation, I should be able to access the request.session now.

But the middleware also requires one more dependency, itsdangerous. Sounds like something I should not use… Let’s see what it does.

Sometimes you want to send some data to untrusted environments, then get it back later. To do this safely, the data must be signed to detect changes.

Given a key only you know, you can cryptographically sign your data and hand it over to someone else. When you get the data back you can ensure that nobody tampered with it.

The receiver can see the data, but they can not modify it unless they also have your key. So if you keep the key secret and complex, you will be fine.

OK, so I guess the SessionMiddleware use it to sign the value inside the cookie? I’m not really sure about it. But looks like it’s a good stuff, and a must have. Let me put it in my requirement.txt and rebuild the image.

fastapi==0.103.1
pydantic==2.3.0
uvicorn==0.23.2
itsdangerous==2.1.2

Once the dependency is included, run docker-compose up -d --build to rebuild the image and restart the container.

Now I can start working on my /login route. The /login route will require user to submit a form that contains their username and password. To read the form data, we need to Annotated the params as Form().

from fastapi import ..., Form

But we still need an extra dependency to make it functional. The python-multipart. Let me put it in my requirement.txt file.

fastapi==0.103.1
pydantic==2.3.0
uvicorn==0.23.2
python-multipart==0.0.6
itsdangerous==2.1.2

Now I have to rebuild again… Maybe I should come-up a better way to do this… Probably next time.

After the container restarted, let’s start working on the /login now.

import secrets
from typing import Annotated
from fastapi import APIRouter, HTTPException, status, Form
from fastapi.security import APIKeyCookie
from fastapi.requests import Request
from dependencies import user, MY_SESSION_ID

router = APIRouter()

security = APIKeyCookie(name=MY_SESSION_ID)


@router.post("")
async def login(
request: Request,
username: Annotated[str, Form()],
password: Annotated[str, Form()],
):
is_correct_user = secrets.compare_digest(username, user["username"])
is_correct_password = secrets.compare_digest(password, user["password"])

if not is_correct_user or not is_correct_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
request.session.update({"username": username})
return {"success": True}

For this route, I’ll need three params. The request, username and password. The username and password are very self-explanatory, they are required for user to login and are part of the form data. The request is because I need it to access the request.session, so I can modify its value. In this case, I put the username in the user’s session dictionary. And lastly just return a nice success message that my frontend… Which is me again… Can tell the request has successfully done.

Now I’m able to login my user, it’s time to rewrite my /users/me route.

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import APIKeyCookie
from dependencies import MY_SESSION_ID
from fastapi.requests import Request

router = APIRouter()

security = APIKeyCookie(name=MY_SESSION_ID)


@router.get("/me", dependencies=[Depends(security)])
async def get_current_user(
request: Request,
):
if not request.session:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
return request.session

Much cleaner!

So, first things first. I have to change my security strategy from HTTPBasic to APIKeyCookie, and give it the same name as what I set in the SessionMiddleware.

And inside the get_current_user function, I only need to check if the session cookie exists. If the there’s no session inside the request, just throw a 401 error. If the request includes a valid session cookie, I’ll tell them what’s their username just like they don’t remember it.

I feel like I’ve been doing a lot. Let’s see if this flow works as what I expected.

My /login route requires a form as the “Request body” stated, and it consist with username and password fields, both string and required. Great!

When I click the “Execute” button, it returns {"success": true} and with the following respone headers…

HTTP/1.1 200 OK
date: Fri, 13 Oct 2023 11:50:02 GMT
server: uvicorn
content-length: 16
content-type: application/json
set-cookie: my_session_id=eyJ1c2VybmFtZSI6ICJqb2huIn0=.ZSku6g.G50HfhgGbz-_Wi9qgGrXEbVlXeg; path=/; Max-Age=1209600; httponly; samesite=strict

The cookie is also properly set!

Move on to the users/me route and try it if I can get “john” back… And it does!

There’s a tiny lock icon on the top-right, indicates this is a protected route. And the execution gets the response correctly. Nice!

The last step is I need a way for “john” to log out my app. After all, I can’t just ask them remove the session cookie from the dev tool.

To make the user log out is fairly simple, just clear it!

from fastapi import APIRouter
from fastapi.security import APIKeyCookie
from fastapi.requests import Request

router = APIRouter()


@router.post("")
async def logout(
request: Request,
):
request.session.clear()
return {"success": True}

Just like the /login, I also return a success message just for being a frontend friendly backend developer.

Let’s try it out!

HTTP/1.1 200 OK
date: Fri, 13 Oct 2023 11:59:57 GMT
server: uvicorn
content-length: 16
content-type: application/json
set-cookie: my_session_id=null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; httponly; samesite=strict

And it successfully revokes my cookie. If I go to /users/me and request for the user’s data again, it now return {"details": "Not authenticated"}. Hooray!

There’s a lot of ways to do one thing that is conceptually identical. Last time I tried the HTTPBasic approach. Which works alright, but I don’t really like it since it’s not very “user friendly”. Personally, I think this session cookie approach is better for real user. And it’s still widely used by a lot of websites we’re using right now.

But in the authentication world, there’s more to come! There’s regular API token that is suitable for both real user and machine. And there’s JSON web token (JWT) that is common in stateless, multiparty services etc…

Next time, I would like to implement the OAuth2, a more popular way to do authentication and authorization nowadays. Just to catch up the trend.

That’s all for it today. If you aren’t able to find how to do session cookie on the FastAPI doc, I hope this post would help.

All the source code can be found on my GitHub.

--

--

Leo Lin (leocantthinkofaname)
Leo Lin (leocantthinkofaname)

No responses yet