Build Your Own Forum with FastAPI: Step 4 - User System
Min-jun Kim
Dev Intern · Leapcell

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]"
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")
Two things were done here:
- Create
Usermodel:- Defines the
userstable, containingid, uniqueusername, andhashed_passwordfields.
- Defines the
- Associate
PostandUser:- In the
Postmodel, anowner_idfield was added as a foreign key, pointing to theidof theuserstable. - Using SQLAlchemy's
relationship, a bidirectional association was established betweenPostandUser. Now we can access the author of a post throughpost.owner, and we can also access all of a user's posts throughuser.posts.
- In the
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);
If your database was created using Leapcell,
you can execute these SQL statements directly in its web-based operation panel.

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)
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>
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>
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
This file mainly made these changes:
- Added the
get_current_userfunction: This function will read theforum_usercookie in the request to identify the current user. In subsequent routes, we can directly get the logged-in user's information throughDepends(get_current_user). - 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 namedforum_userin the response and set the user's username as the value. This is a simple session implementation. - Logout (
/logout): Clears theforum_usercookie and redirects back to the homepage.
- Register (
- Route protection: The
create_postroute now depends onget_current_user. If the user is not logged in, it will redirect to the login page. When posting, theowner_idof the post will be automatically set to the ID of the currently logged-in user. - View update: Routes such as
/postswill 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>
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
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.

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.

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

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.


