DEV Community

Cover image for Build a Perfect Blog with FastAPI: Add Authorization
Leapcell
Leapcell

Posted on

Build a Perfect Blog with FastAPI: Add Authorization

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
Enter fullscreen mode Exit fullscreen mode

Then, add it to your requirements.txt file:

# requirements.txt
fastapi
uvicorn[standard]
sqlmodel
psycopg2-binary
jinja2
python-dotenv
python-multipart
bcrypt
itsdangerous
Enter fullscreen mode Exit fullscreen mode

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.

ImageP1

The Redis detail page provides an online CLI where you can execute Redis commands directly.

ImageP2

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

ImageP3

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


Read on our blog

Related Posts:

Top comments (0)