DEV Community

Cover image for Build Your Own Forum with FastAPI: Step 4 - User System
Leapcell
Leapcell

Posted on

Build Your Own Forum with FastAPI: Step 4 - User System

Cover

In the previous article, we used the Jinja2 template engine to separate the frontend HTML code from the backend Python logic, making the project structure clearer.

The current forum allows anyone to post anonymously, which is not the right way to run a community. A forum should be built around users: everyone has their own identity, their own posts, and replies.

Therefore, in this article, we will add a complete user system to the forum, including user registration, login, and logout functions.

Step 1: Install Dependencies

We need a library to handle password encryption. User passwords cannot be stored in plain text, which is extremely dangerous. We will use passlib and pbkdf2_sha256 algorithm.

Run the following command:

pip install "passlib[pbkdf2_sha256]"
Enter fullscreen mode Exit fullscreen mode

Step 2: Update the Database Model

We need a new table to store user information, and we need to associate the posts table with the users table to record the author of each post.

Open the models.py file and make the following changes:

models.py (Updated)

from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from database import Base

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True)
    hashed_password = Column(String)

    posts = relationship("Post", back_populates="owner")

class Post(Base):
    __tablename__ = "posts"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String, index=True)
    content = Column(String)
    owner_id = Column(Integer, ForeignKey("users.id"))

    owner = relationship("User", back_populates="posts")
Enter fullscreen mode Exit fullscreen mode

Two things were done here:

  • Create User model:
    • Defines the users table, containing id, unique username, and hashed_password fields.
  • Associate Post and User:
    • In the Post model, an owner_id field was added as a foreign key, pointing to the id of the users table.
    • Using SQLAlchemy's relationship, a bidirectional association was established between Post and User. Now we can access the author of a post through post.owner, and we can also access all of a user's posts through user.posts.

Before applying these models, you need to manually update your database. You need to create the users table and modify the posts table.

The corresponding SQL statements are as follows:

-- Create users table
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR UNIQUE,
    hashed_password VARCHAR
);

-- Modify posts table, add owner_id column and foreign key constraint
ALTER TABLE posts ADD COLUMN owner_id INTEGER;
ALTER TABLE posts ADD CONSTRAINT fk_owner_id FOREIGN KEY (owner_id) REFERENCES users (id);
Enter fullscreen mode Exit fullscreen mode

If your database was created using Leapcell,

ImageLc

you can execute these SQL statements directly in its web-based operation panel.

ImageDb

Step 3: Handle Passwords

We create a new file auth.py and write functions for password hashing and verification to handle passwords securely.

auth.py

from passlib.context import CryptContext

# 1. Create a CryptContext instance, specifying the encryption algorithm
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# 2. Function to verify password
def verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)

# 3. Function to generate password hash
def get_password_hash(password):
    return pwd_context.hash(password)
Enter fullscreen mode Exit fullscreen mode
  • verify_password: Compares the plain text password entered by the user with the hashed password stored in the database to see if they match.
  • get_password_hash: Converts the plain text password into a hash value so that it can be stored in the database.

Step 4: Create User Registration and Login Pages

Similar to posts.html, we create two new HTML files in the templates folder: register.html and login.html.

templates/register.html

<!DOCTYPE html>
<html>
  <head>
    <title>Register - My FastAPI Forum</title>
    <style>
      body {
        font-family: sans-serif;
        margin: 2em;
      }
      form {
        width: 300px;
        margin: 0 auto;
      }
      input {
        width: 100%;
        padding: 8px;
        margin-bottom: 10px;
        box-sizing: border-box;
      }
      button {
        padding: 10px 15px;
        background-color: #007bff;
        color: white;
        border: none;
        cursor: pointer;
        width: 100%;
      }
      .error {
        color: red;
      }
    </style>
  </head>
  <body>
    <h1>Register New User</h1>
    {% if error %}
    <p class="error">{{ error }}</p>
    {% endif %}
    <form method="post">
      <input type="text" name="username" placeholder="Username" required /><br />
      <input type="password" name="password" placeholder="Password" required /><br />
      <button type="submit">Register</button>
    </form>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

templates/login.html

<!DOCTYPE html>
<html>
  <head>
    <title>Login - My FastAPI Forum</title>
    <style>
      body {
        font-family: sans-serif;
        margin: 2em;
      }
      form {
        width: 300px;
        margin: 0 auto;
      }
      input {
        width: 100%;
        padding: 8px;
        margin-bottom: 10px;
        box-sizing: border-box;
      }
      button {
        padding: 10px 15px;
        background-color: #007bff;
        color: white;
        border: none;
        cursor: pointer;
        width: 100%;
      }
      .error {
        color: red;
      }
    </style>
  </head>
  <body>
    <h1>User Login</h1>
    {% if error %}
    <p class="error">{{ error }}</p>
    {% endif %}
    <form method="post">
      <input type="text" name="username" placeholder="Username" required /><br />
      <input type="password" name="password" placeholder="Password" required /><br />
      <button type="submit">Login</button>
    </form>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 5: Implement Authentication-Related API Routes

Now, we will refactor main.py to add registration, login, logout, and current user state management functions. This is a relatively large update.

main.py (Final complete version)

from fastapi import FastAPI, Form, Depends, Request, Response, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy.orm import selectinload
from typing import Optional

import models
from database import get_db
from auth import get_password_hash, verify_password

app = FastAPI()

templates = Jinja2Templates(directory="templates")

# --- User status dependency ---
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> Optional[models.User]:
    username = request.cookies.get("forum_user")
    if not username:
        return None
    result = await db.execute(select(models.User).where(models.User.username == username))
    return result.scalar_one_or_none()

# --- Routes ---

@app.get("/", response_class=RedirectResponse)
def read_root():
    return RedirectResponse(url="/posts", status_code=status.HTTP_302_FOUND)

@app.get("/posts", response_class=HTMLResponse)
async def view_posts(request: Request, db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user)):
    # Use selectinload to preload the owner relationship, avoiding the N+1 query problem
    result = await db.execute(
        select(models.Post).options(selectinload(models.Post.owner)).order_by(desc(models.Post.id))
    )
    posts = result.scalars().all()
    return templates.TemplateResponse("posts.html", {"request": request, "posts": posts, "current_user": current_user})

@app.post("/api/posts")
async def create_post(
    title: str = Form(...),
    content: str = Form(...),
    db: AsyncSession = Depends(get_db),
    current_user: Optional[models.User] = Depends(get_current_user)
):
    if not current_user:
        return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)

    new_post = models.Post(title=title, content=content, owner_id=current_user.id)
    db.add(new_post)
    await db.commit()
    await db.refresh(new_post)
    return RedirectResponse(url="/posts", status_code=status.HTTP_303_SEE_OTHER)

@app.get("/register", response_class=HTMLResponse)
async def get_registration_form(request: Request):
    return templates.TemplateResponse("register.html", {"request": request})

@app.post("/register")
async def register_user(
    request: Request,
    username: str = Form(...),
    password: str = Form(...),
    db: AsyncSession = Depends(get_db)
):
    result = await db.execute(select(models.User).where(models.User.username == username))
    if result.scalar_one_or_none():
        return templates.TemplateResponse("register.html", {"request": request, "error": "Username already exists"})

    hashed_password = get_password_hash(password)
    new_user = models.User(username=username, hashed_password=hashed_password)
    db.add(new_user)
    await db.commit()

    return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)

@app.get("/login", response_class=HTMLResponse)
async def get_login_form(request: Request):
    return templates.TemplateResponse("login.html", {"request": request})

@app.post("/login")
async def login_user(
    response: Response,
    request: Request,
    username: str = Form(...),
    password: str = Form(...),
    db: AsyncSession = Depends(get_db)
):
    result = await db.execute(select(models.User).where(models.User.username == username))
    user = result.scalar_one_or_none()

    if not user or not verify_password(password, user.hashed_password):
        return templates.TemplateResponse("login.html", {"request": request, "error": "Incorrect username or password"})

    # Use a cookie to implement a simple session
    response = RedirectResponse(url="/posts", status_code=status.HTTP_303_SEE_OTHER)
    response.set_cookie(key="forum_user", value=user.username, httponly=True)
    return response

@app.get("/logout")
async def logout_user(response: Response):
    response = RedirectResponse(url="/posts", status_code=status.HTTP_303_SEE_OTHER)
    response.delete_cookie(key="forum_user")
    return response
Enter fullscreen mode Exit fullscreen mode

This file mainly made these changes:

  1. Added the get_current_user function: This function will read the forum_user cookie in the request to identify the current user. In subsequent routes, we can directly get the logged-in user's information through Depends(get_current_user).
  2. Added routes related to user registration and login
    • Register (/register): The GET request displays the registration form, and the POST request handles the form submission. It will check if the username already exists, then hash the password and store it in the database.
    • Login (/login): The GET request displays the login form. The POST request will verify the username and password. If successful, it will set a cookie named forum_user in the response and set the user's username as the value. This is a simple session implementation.
    • Logout (/logout): Clears the forum_user cookie and redirects back to the homepage.
  3. Route protection: The create_post route now depends on get_current_user. If the user is not logged in, it will redirect to the login page. When posting, the owner_id of the post will be automatically set to the ID of the currently logged-in user.
  4. View update: Routes such as /posts will now get the current user's information and pass it to the template so that the login status can be displayed on the page.

Step 6: Update the Homepage Template to Display User Status

Finally, we need to modify templates/posts.html so that it can display different content based on the user's login status.

templates/posts.html (Updated)

<!DOCTYPE html>
<html>
  <head>
    <title>My FastAPI Forum</title>
    <style>
      body {
        font-family: sans-serif;
        margin: 2em;
      }
      input,
      textarea {
        width: 100%;
        padding: 8px;
        margin-bottom: 10px;
        box-sizing: border-box;
      }
      button {
        padding: 10px 15px;
        background-color: #007bff;
        color: white;
        border: none;
        cursor: pointer;
      }
      header {
        display: flex;
        justify-content: space-between;
        align-items: center;
      }
    </style>
  </head>
  <body>
    <header>
      <h1>Welcome to My Forum</h1>

      <div class="auth-links">
        {% if current_user %}
        <span>Welcome, {{ current_user.username }}!</span>
        <a href="/logout">Logout</a>
        {% else %}
        <a href="/login">Login</a> |
        <a href="/register">Register</a>
        {% endif %}
      </div>
    </header>

    {% if current_user %}
    <h2>Create a New Post</h2>
    <form action="/api/posts" method="post">
      <input type="text" name="title" placeholder="Post Title" required /><br />
      <textarea name="content" rows="4" placeholder="Post Content" required></textarea><br />
      <button type="submit">Post</button>
    </form>
    {% else %}
    <p><a href="/login">Login</a> to create a new post.</p>
    {% endif %}

    <hr />
    <h2>Post List</h2>

    {% for post in posts %}
    <div style="border: 1px solid #ccc; padding: 10px; margin-bottom: 10px;">
      <h3>{{ post.title }}</h3>
      <p>{{ post.content }}</p>
      <small>Author: {{ post.owner.username if post.owner else 'Unknown' }}</small>
    </div>
    {% endfor %}
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The template mainly made these changes:

  • The top navigation will use {% if current_user %} to determine the login status. If the user is logged in, it will display a welcome message and a "Logout" link; otherwise, it will display "Login" and "Register" links.
  • The form for creating a new post is restricted so that only logged-in users can see it.
  • At the bottom of each post, the author's username is displayed through {{ post.owner.username }}.

Run and Verify

It's time to see the results! Restart your uvicorn server:

uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

Visit http://127.0.0.1:8000. You will see "Login" and "Register" links in the upper right corner of the homepage, and there is no entry for creating a post on the page.

ImageP1

Try to register a new user, and then log in. After logging in, you will see the post form appear, and your username will be displayed at the top of the page.

ImageP2

Post a post, and its author will be correctly displayed as your username.

ImageP3

Summary

Through this article, we have built a user system for the forum. Everyone can now register, log in, and publish their own posts.

After the posts have their own users, you can consider the next thing: What if the user wants to modify the content of the posts they have published?

In the next article, we will implement a new feature based on the current user system: allowing users to edit the posts they have already created.


Follow us on X: @LeapcellHQ


Read on our blog

Related Posts:

Top comments (0)