In the previous article, we successfully built a user registration system and basic login validation logic for our FastAPI blog. Users can create accounts, and the application can verify their usernames and passwords.
However, the current login is just a one-time validation; the server doesn't "remember" the user's login state. Every time the page is refreshed or a new page is visited, the user reverts to being an unauthenticated guest.
In this article, we will use middleware to implement true user login state management for our blog. We will learn how to protect pages and features that require a login and dynamically update the interface based on the user's login status.
Configuring Sessions
To handle session management in FastAPI, we will use Starlette's SessionMiddleware
. Starlette is the ASGI framework that FastAPI is built on, and SessionMiddleware
is its official, standard tool for handling sessions.
First, install the itsdangerous
library. SessionMiddleware
relies on it to cryptographically sign session data, ensuring its security.
pip install itsdangerous
Then, add it to your requirements.txt
file:
# requirements.txt
fastapi
uvicorn[standard]
sqlmodel
psycopg2-binary
jinja2
python-dotenv
python-multipart
bcrypt
itsdangerous
Using Redis to Store Sessions
By default, SessionMiddleware
encrypts session data and stores it in a client-side cookie. This approach is simple and requires no backend storage, but it has the disadvantage of a limited cookie size (typically 4KB), making it unsuitable for storing large amounts of data.
For better scalability and security, we will use Redis, a high-performance in-memory database, to persist sessions on the server side. This ensures that the login state can be preserved even if the user closes the browser or the server restarts.
What if you don't have Redis?
You can create a Redis instance on Leapcell. Leapcell provides most of the tools you need for a backend application!
Click the "Create Redis" button in the interface to create a new Redis instance.
The Redis detail page provides an online CLI where you can execute Redis commands directly.
If you don't have a Redis service available for now, SessionMiddleware
will default to using signed cookies. For the purposes of this tutorial, the functionality will not be affected.
Install the Redis-related dependency:
pip install redis
Now, open the main.py
file to import and configure SessionMiddleware
.
# main.py
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware # Import the middleware
from dotenv import load_dotenv
from database import create_db_and_tables
from routers import posts, users, auth
# Load environment variables
load_dotenv()
@asynccontextmanager
async def lifespan(app: FastAPI):
print("Creating tables..")
create_db_and_tables()
yield
app = FastAPI(lifespan=lifespan)
# Read the secret key from environment variables
# Be sure to replace 'your-secret-key' with a truly secure random string. You can generate one using `openssl rand -hex 32`
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key")
# Add SessionMiddleware
app.add_middleware(
SessionMiddleware,
secret_key=SECRET_KEY,
session_cookie="session_id", # The name of the session ID stored in the cookie
max_age=60 * 60 * 24 * 7 # Session expires after 7 days
)
# Mount the static files directory
app.mount("/static", StaticFiles(directory="public"), name="static")
# Include routers
app.include_router(posts.router)
app.include_router(users.router)
app.include_router(auth.router)
Note: For security, the secret_key
should be a complex, randomly generated string. Like the database URL, it should be managed through environment variables rather than being hardcoded.
Once configured, SessionMiddleware
will automatically handle each request, parsing session data from the request's cookie and attaching it to the request.session
object for our use.
Implementing Real Login and Logout Routes
Next, let's update routers/auth.py
to handle the actual login and logout logic.
# routers/auth.py
from fastapi import APIRouter, Request, Depends, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlmodel import Session
from database import get_session
import auth_service
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/auth/login", response_class=HTMLResponse)
def show_login_form(request: Request):
return templates.TemplateResponse("login.html", {"request": request, "title": "Login"})
@router.post("/auth/login")
def login(
request: Request, # Inject the Request object to access the session
username: str = Form(...),
password: str = Form(...),
session: Session = Depends(get_session)
):
user = auth_service.validate_user(username, password, session)
if not user:
raise HTTPException(status_code=401, detail="Incorrect username or password")
# Validation successful, store user info in the session
# SessionMiddleware will automatically handle the subsequent encryption and cookie setting
request.session["user"] = {"username": user.username, "id": str(user.id)}
return RedirectResponse(url="/posts", status_code=302)
@router.get("/auth/logout")
def logout(request: Request):
# Clear the session
request.session.clear()
return RedirectResponse(url="/", status_code=302)
In the login
function, after a user is successfully validated, we store a dictionary containing basic user information in request.session["user"]
. SessionMiddleware
will automatically encrypt and sign this session data and set a cookie in the browser containing it. The browser will then automatically include this cookie in all subsequent requests, allowing the server to recognize the user's login state.
In the logout
function, we call request.session.clear()
, which clears the session data, effectively logging the user out.
Protecting Routes and Updating the UI
Now that we have a login mechanism, the final step is to use it to protect our "Create Post" feature and display different UI elements based on the login state.
Creating an Authentication Dependency
In FastAPI, the most elegant way to protect routes is by using Dependency Injection. We will create a dependency function to check if a user is logged in.
In the project's root directory, create a new file named auth_dependencies.py
:
# auth_dependencies.py
from fastapi import Request, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
def login_required(request: Request):
"""
A dependency to check if the user is logged in.
If not logged in, redirect to the login page.
"""
if not request.session.get("user"):
# You can also choose to raise an HTTPException
# raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND)
return request.session.get("user")
def get_user_from_session(request: Request) -> dict | None:
"""
Gets user information from the session (if it exists).
This dependency does not enforce a login, it's just for conveniently getting user info in templates.
"""
return request.session.get("user")
The logic of the first function, login_required
, is simple: if user
does not exist in request.session
, it redirects the user to the login page. If it does exist, it returns the user information, allowing the route function to use it directly.
Applying the Dependency
Open routers/posts.py
and apply the login_required
dependency to the routes that need protection.
# routers/posts.py
import uuid
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
from database import get_session
from models import Post
from auth_dependencies import login_required # Import the dependency
router = APIRouter()
templates = Jinja2Templates(directory="templates")
# ... other routes ...
# Apply the dependency to protect this route
@router.get("/posts/new", response_class=HTMLResponse)
def new_post_form(request: Request, user: dict = Depends(login_required)):
return templates.TemplateResponse("new-post.html", {"request": request, "title": "New Post", "user": user})
# Apply the dependency to protect this route
@router.post("/posts", response_class=HTMLResponse)
def create_post(
title: str = Form(...),
content: str = Form(...),
session: Session = Depends(get_session),
user: dict = Depends(login_required) # Ensure only logged-in users can create posts
):
new_post = Post(title=title, content=content)
session.add(new_post)
session.commit()
return RedirectResponse(url="/posts", status_code=302)
# ... other routes ...
Now, if an unauthenticated user tries to access /posts/new
, they will be automatically redirected to the login page.
Updating the Frontend UI
Finally, let's update the UI to show different buttons based on the user's login status. We will use the get_user_from_session
dependency to retrieve user information and pass it to the templates.
Modify templates/_header.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ title }}</title>
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<header>
<h1><a href="/">My Blog</a></h1>
<nav>
{% if user %}
<span class="welcome-msg">Welcome, {{ user.username }}</span>
<a href="/posts/new" class="new-post-btn">New Post</a>
<a href="/auth/logout" class="nav-link">Logout</a>
{% else %}
<a href="/users/register" class="nav-link">Register</a>
<a href="/auth/login" class="nav-link">Login</a>
{% endif %}
</nav>
</header>
<main>
For the template above to work correctly, we need to update all the routes that render pages to pass the user
information to the view.
In routers/posts.py
, modify all methods that render views:
# routers/posts.py
# ... imports ...
from auth_dependencies import get_user_from_session, login_required # Import the new dependencies
# ...
@router.get("/", response_class=HTMLResponse)
def root():
return RedirectResponse(url="/posts", status_code=302)
@router.get("/posts", response_class=HTMLResponse)
def get_all_posts(
request: Request,
session: Session = Depends(get_session),
user: dict | None = Depends(get_user_from_session) # Get session user info
):
statement = select(Post).order_by(Post.createdAt.desc())
posts = session.exec(statement).all()
# Pass the user to the template
return templates.TemplateResponse("index.html", {"request": request, "posts": posts, "title": "Home", "user": user})
# ... new_post_form route was updated above ...
@router.get("/posts/{post_id}", response_class=HTMLResponse)
def get_post_by_id(
request: Request,
post_id: uuid.UUID,
session: Session = Depends(get_session),
user: dict | None = Depends(get_user_from_session) # Get session user info
):
post = session.get(Post, post_id)
# Pass the user to the template
return templates.TemplateResponse("post.html", {"request": request, "post": post, "title": post.title, "user": user})
Similarly, we also need to update the template-rendering routes in routers/users.py
and routers/auth.py
by adding user: dict | None = Depends(get_user_from_session)
and passing the user
to the template.
Running and Testing
Now, restart your application:
uvicorn main:app --reload
Visit http://localhost:8000
. You should see "Login" and "Register" buttons in the top-right corner.
Try to access http://localhost:8000/posts/new
. You will be automatically redirected to the login page.
Now, register an account and log in. After a successful login, you will be redirected to the homepage, and you will see "Welcome, [Your Username]", "New Post", and "Logout" buttons in the top-right corner.
At this point, you can click "New Post" to create a new article. If you log out and try to access /posts/new
again, you will be redirected once more.
With that, we have added a complete user authentication system to our blog. You no longer have to worry about your friends messing around with your blog!
Follow us on X: @LeapcellHQ
Related Posts:
Top comments (0)