FastAPI & Cookies

October 27, 2024

A real-life example of how cookies are used in the FastAPI web framework.

python fastapi
0

FastAPI is a relatively new web framework, with high growth potential — according to surveys and reports that show how many big companies (like Uber and Netflix) are using it!

Cookies are small pieces of data stored on the client's browser by the web server. They play a crucial role in maintaining user sessions, tracking user preferences, and enhancing security.

Here we will learn how to use cookies in a good way in FastAPI!

Project Idea and Concept

Ʈhinga is a web application that lets users compare and rank various items. By presenting two items at a time, users can vote for their favorite, contributing to a dynamic ranking system. It serves as a fun way to gauge popularity and user preferences.

Technical Implementation

I don't mention the whole project code, just the sections that can be important are written...

Database and Related Things

Using enums like SessionStatus ensures clarity and consistency in the code, making it easier to manage different states of user sessions.

class SessionStatus(StrEnum):
    ACTIVE = auto()
    INACTIVE = auto()
    EXPIRED = auto()

For modeling, I like to use SQLAlchemy here, but SQLModel and Tortoise ORM are another alternatives...

class Session(Base):
    __tablename__ = "sessions"

    id = Column(Integer, primary_key=True, index=True)
    access_token = Column(
        String(32),
        default=lambda: uuid.uuid4().hex,
        unique=True,
        index=True,
    )
    client_fingerprint = Column(String(64), nullable=False)
    status = Column(
        Enum(enums.SessionStatus),
        default=enums.SessionStatus.ACTIVE,
    )
    expires_at = Column(
        DateTime,
        default=lambda: (
            datetime.now(timezone.utc) + timedelta(days=SESSION_EXPIRE_DAYS)
        ),
    )
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)

    user = relationship("User", back_populates="sessions")

This model defines the structure of the sessions table, including the session status managed by the SessionStatus enum.

Explanation about some fields of the sessions table:

  • access_token: A unique token generated for each session, defaults to a randomly generated UUID, and is both unique and indexed.
  • client_fingerprint: A unique identifier for the client's browser, which helps in preventing session hijacking by tracking the client's device and browser characteristics. While it is not foolproof, it adds an extra layer of security to mitigate session hijacking attempts.
  • expires_at: Specifies the expiration date and time of the session, calculated by adding a specified number of days (defined by SESSION_EXPIRE_DAYS) to the current UTC time. This ensures that sessions do not persist indefinitely.

Here are some CRUD operations:

def get_session_by_access_token(
    *,
    db: Session,
    access_token: str,
) -> Optional[models.Session]:
    return (
        db.query(models.Session)
        .filter(models.Session.access_token == access_token)
        .first()
    )


def create_session(
    *,
    db: Session,
    user_id: int,
    client_fingerprint: str,
) -> models.Session:
    db_session = models.Session(
        client_fingerprint=client_fingerprint,
        user_id=user_id,
    )
    db.add(db_session)
    db.commit()
    db.refresh(db_session)
    return db_session


def deactivate_session(*, db: Session, access_token: str) -> None:
    db_session = get_session_by_access_token(db=db, access_token=access_token)
    if db_session is not None:
        db_session.status = enums.SessionStatus.INACTIVE
        db.commit()


def verify_session(
    *,
    db: Session,
    access_token: str,
    client_fingerprint: str,
) -> Optional[models.Session]:
    db_session = get_session_by_access_token(db=db, access_token=access_token)
    if (
        db_session is None
        or db_session.status
        in (
            enums.SessionStatus.INACTIVE,
            enums.SessionStatus.EXPIRED,
        )
        or db_session.client_fingerprint != client_fingerprint
    ):
        return None
    elif db_session.expires_at.replace(tzinfo=timezone.utc) <= datetime.now(
        timezone.utc
    ):
        db_session.status = enums.SessionStatus.EXPIRED
        db.commit()

    return db_session

I prefer to define my CRUD functions using keyword-only arguments, which enhances readability and clarity — this is the first project where I have implemented this approach. :)

Note: Some ORM libraries allow you to define helper functions directly within the class to manage table fields, enhancing the encapsulation and organization of your database logic.

Working With Cookies

Look at environment variables and configuration constants first:

DEBUG_ENABLED = os.environ["DEBUG_ENABLED"] == "1"

ALLOWED_ORIGINS = re.split(r"[,;]\s?", os.environ["ALLOWED_ORIGINS"])

COOKIE_SECURE_MODE = not DEBUG_ENABLED
COOKIE_NO_JS_ACCESS = os.environ["COOKIE_NO_JS_ACCESS"] == "1"
COOKIE_SAMESITE_POLICY = "lax" if DEBUG_ENABLED else "none"

Related dependency functions:

async def get_access_token(request: Request) -> str:
    access_token = request.cookies.get("access_token")
    if access_token is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated.",
        )
    return access_token


async def get_current_user(
    request: Request,
    access_token: str = Depends(get_access_token),
    db: Session = Depends(get_db),
) -> models.User:
    client_fingerprint = utils.generate_client_fingerprint(request)
    db_session = crud.verify_session(
        db=db,
        access_token=access_token,
        client_fingerprint=client_fingerprint,
    )
    if db_session is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated.",
        )

    db_user = crud.get_user_by_id(db=db, user_id=db_session.user_id)
    if db_user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="User not found.",
        )
    return db_user

Authentication endpoints:

router = APIRouter()


@router.post("/login/", response_model=schemas.User)
async def login(
    request: Request,
    response: Response,
    username: str = Body(...),
    password: str = Body(...),
    db: Session = Depends(get_db),
):
    db_user = crud.get_user_by_username(db=db, username=username)
    if db_user is None or not utils.verify_password(
        password, db_user.hashed_password
    ):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password.",
        )

    client_fingerprint = utils.generate_client_fingerprint(request)
    db_session = crud.create_session(
        db=db,
        user_id=db_user.id,
        client_fingerprint=client_fingerprint,
    )
    response.set_cookie(
        key="access_token",
        value=db_session.access_token,
        httponly=COOKIE_NO_JS_ACCESS,
        secure=COOKIE_SECURE_MODE,
        samesite=COOKIE_SAMESITE_POLICY,
    )
    return db_user


@router.post("/logout/")
async def logout(
    response: Response,
    access_token: str = Depends(get_access_token),
    db: Session = Depends(get_db),
):
    crud.deactivate_session(db=db, access_token=access_token)
    response.delete_cookie(key="access_token")
    return {"message": "Successfully logged out."}


@router.get("/users/me/", response_model=schemas.User)
async def read_users_me(
    current_user: models.User = Depends(get_current_user),
):
    return current_user

Cookie security attributes:

  • httponly: This flag ensures that the cookie is inaccessible to JavaScript, reducing the risk of XSS attacks.
  • secure: This flag ensures that the cookie is transmitted over HTTPS, which is crucial for securing sensitive data.
  • samesite: This attribute helps protect against CSRF attacks. The lax value allows the cookie to be sent with requests initiated by third-party websites, but only if the request is a GET request.

To handle cookies correctly, especially in a cross-origin scenario, you need to configure CORS properly:

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_methods=["*"],
    allow_headers=["*"],
    allow_credentials=True,
)

By following these practices, you can securely and effectively manage cookies in your FastAPI application.

Screenshots

Our website index page:

Screenshots of the login and registration forms, as well as the user profile dashboard:

The user interface of the comparison and voting game:

Top 20 things leaderboard based on votes:

Final Words

You can now contribute to the project by visiting our repositories: back-end and front-end. The project is open to contributions and feedback! ;)

Useful links:


Comments on this post

Be the first to comment!

Leave a comment

Comments can only be deleted by the author of the post...