Build a Secure FastAPI Application with API Key Authentication, User Management, Jinja2 Templates, Material Design, and Automated Testing: Amazing Python Package Showcase (7)
FastAPI is a cutting-edge, high-performance web framework designed for building APIs in Python, utilizing standard Python type hints for enhanced efficiency. In this detailed, step-by-step guide, you’ll master the creation of a secure FastAPI application, complete with user management, API key authentication, and a dynamic web interface powered by Jinja2 templates and styled with Material Design principles.
Table of Contents
Prerequisites
- Basic knowledge of Python
- Familiarity with FastAPI
- Understanding of web development concepts
Project Setup
Before we begin, ensure you have Python installed. Then, create a virtual environment and install the required dependencies.
Step 1: Create a Virtual Environment
To get started with Mimesis, create an environment first (the same step at here) :
dynotes@P2021:~/projects/python/fastapi$ virtualenv fastapi
created virtual environment CPython3.10.12.final.0-64 in 934ms
creator CPython3Posix(dest=~/projects/python/fastapi/fastapi, clear=False, no_vcs_ignore=False, global=False)
seeder FromAppData(download=False, pip=bundle, setuptools=bundle, wheel=bundle, via=copy, app_data_dir=/home/dynotes/.local/share/virtualenv)
added seed packages: pip==24.1.2, setuptools==70.2.0, wheel==0.43.0
activators BashActivator,CShellActivator,FishActivator,NushellActivator,PowerShellActivator,PythonActivator
dynotes@WIN-P2021:~/projects/python/fastapi$ source ./fastapi/bin/activate
(fastapi) dynotes@P2021:~/projects/python/fastapi$
(fastapi) dynotes@WIN-P2021:~/projects/python/fastapi$ mkdir fastapi-jinja2
(fastapi) dynotes@WIN-P2021:~/projects/python/fastapi$ cd fastapi-jinja2
(fastapi) dynotes@WIN-P2021:~/projects/python/fastapi/fastapi-jinja2$
Step 2: Install Dependencies
Create the following requirements.txt
file:
fastapi
uvicorn
jinja2
databases
sqlalchemy
passlib
pyjwt
httpx
pytest
pytest-asyncio
python-jose
Then run it:
(fastapi) dynotes@P2021:~/projects/python/fastapi/fastapi-jinja2$ pip install -r requirements.txt
Step 3: Create the Project Structure
Set up the project directory and files:
mkdir fastapi-jinja2
cd fastapi-jinja2
mkdir templates
mkdir -p static/css
mkdir -p static/js
Please note: after the entire project is complete, the project directory should look like this:
fastapi-jinja2/
│
├── static/
│ ├── css/
│ │ └── materialize.min.css
│ └── js/
│ └── materialize.min.js
│
├── templates/
│ ├── base.html
│ ├── index.html
│ ├── register.html
│ ├── login.html
│ └── api_key_test.html
│
├── test.db
├── main.py
├── test_main.py
└── requirements.txt
Implement the Project
Download Materialize CSS and JS
Download the Materialize CSS and JS files from Materialize and place them in the static/css/
and static/js/
directories, respectively.
static/css/materialize.min.css
: This is the main CSS file that provides Material Design styling.static/js/materialize.min.js
: This is the JavaScript file required for interactive Material Design components.
Create the Base Template
Create a base.html
template that will be used as the base for all pages:
<!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="{{ url_for('static', path='/css/materialize.min.css') }}">
</head>
<body>
<nav>
<div class="nav-wrapper">
<a href="/" class="brand-logo">FastAPI App</a>
<ul id="nav-mobile" class="right">
<li><a href="/register">Register</a></li>
<li><a href="/login">Login</a></li>
</ul>
</div>
</nav>
<div class="container">
{% block content %}{% endblock %}
</div>
<script src="{{ url_for('static', path='/js/materialize.min.js') }}"></script>
</body>
</html>
Create the Pages
Index Page
Create an index.html
template:
{% extends "base.html" %}
{% block content %}
<h1>Welcome to the FastAPI Application</h1>
<p>This is a demo web application with Material Design style.</p>
{% endblock %}
Register Page
Create a register.html
template:
{% extends "base.html" %}
{% block content %}
<h1>Register</h1>
<form method="post" action="/register">
<div class="input-field">
<input id="email" type="email" name="email" required>
<label for="email">Email</label>
</div>
<div class="input-field">
<input id="password" type="password" name="password" required>
<label for="password">Password</label>
</div>
<button class="btn waves-effect waves-light" type="submit">Register</button>
</form>
{% endblock %}
Login Page
Create a login.html
template:
{% extends "base.html" %}
{% block content %}
<h1>Login</h1>
<form method="post" action="/token">
<div class="input-field">
<input id="email" type="email" name="username" required>
<label for="email">Email</label>
</div>
<div class="input-field">
<input id="password" type="password" name="password" required>
<label for="password">Password</label>
</div>
<button class="btn waves-effect waves-light" type="submit">Login</button>
</form>
{% endblock %}
API Key Test Page
Create an api_key_test.html
template:
{% extends "base.html" %}
{% block content %}
<h1>API Key Authentication Test</h1>
<p>Enter your API key to test access to a protected route:</p>
<form method="post" action="/api_key_test">
<div class="input-field">
<input id="api_key" type="text" name="api_key" required>
<label for="api_key">API Key</label>
</div>
<button class="btn waves-effect waves-light" type="submit">Test API Key</button>
</form>
{% if message %}
<p>{{ message }}</p>
{% endif %}
{% endblock %}
Set Up the FastAPI Application
Create the main.py
file and initialize the FastAPI application, configure user management, and set up API key authentication.
from fastapi import FastAPI, Request, Depends, HTTPException, Security, Form
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm, APIKeyQuery, APIKeyHeader
from sqlalchemy import Column, String, Integer, Boolean, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from passlib.context import CryptContext
from jose import JWTError, jwt
from datetime import datetime, timedelta
from typing import Optional
# Configuration
SECRET_KEY = "secret"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
API_KEY = "mysecretapikey"
# Database setup
DATABASE_URL = "sqlite:///./test.db"
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
# JWT Token handling
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_user(db: Session, email: str):
return db.query(User).filter(User.email == email).first()
def authenticate_user(db: Session, email: str, password: str):
user = get_user(db, email)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
# FastAPI app setup
app = FastAPI()
# Set up Jinja2 templates
templates = Jinja2Templates(directory="templates")
# Serve static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# OAuth2PasswordBearer to retrieve JWT tokens
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# Dependency to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Dependency to retrieve the current user
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=HTTPException,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = get_user(db, email=email)
if user is None:
raise credentials_exception
return user
# Dependency for API Key authentication
api_key_query = APIKeyQuery(name="access_token", auto_error=False)
api_key_header = APIKeyHeader(name="access_token", auto_error=False)
async def get_api_key(api_key_query: str = Security(api_key_query), api_key_header: str = Security(api_key_header)):
if api_key_query == API_KEY or api_key_header == API_KEY:
return api_key_query or api_key_header
raise HTTPException(
status_code=403, detail="Could not validate API key"
)
# Routes
@app.post("/token", response_model=dict)
async def login_for_access_token(db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()):
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=400,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(data={"sub": user.email}, expires_delta=access_token_expires)
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/")
async def home(request: Request):
return templates.TemplateResponse("index.html", {"request": request, "title": "Home"})
@app.get("/register")
async def register_get(request: Request):
return templates.TemplateResponse("register.html", {"request": request, "title": "Register"})
@app.post("/register")
async def register_post(request: Request, email: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)):
hashed_password = get_password_hash(password)
user = User(email=email, hashed_password=hashed_password)
db.add(user)
db.commit()
db.refresh(user)
return RedirectResponse(url="/login", status_code=303)
@app.get("/login")
async def login_get(request: Request):
return templates.TemplateResponse("login.html", {"request": request, "title": "Login"})
@app.get("/protected-route")
async def protected_route(api_key: API_KEY = Depends(get_api_key)):
return {"message": "You have access to this protected route."}
@app.get("/api_key_test")
async def api_key_test_get(request: Request):
return templates.TemplateResponse("api_key_test.html", {"request": request, "title": "API Key Test"})
@app.post("/api_key_test")
async def api_key_test_post(request: Request, api_key: str = Form(...)):
if api_key == API_KEY:
message = "API Key is valid! You have access to the protected route."
else:
message = "Invalid API Key. Access denied."
return templates.TemplateResponse("api_key_test.html", {"request": request, "title": "API Key Test", "message": message})
Run and Test the Application
Run the Application
Now that you have everything set up, you can run your FastAPI application using Uvicorn.
uvicorn main:app --reload
Test the Regular Pages
Open your web browser and navigate to http://127.0.0.1:8000/
. You should see the home page with the Material Design styling.
- Home Page: This is the landing page with a welcome message.
- Register Page: Accessible via the navigation menu; allows new user registration.
- Login Page: Accessible via the navigation menu; allows user login.
Test API Key Authentication
To test the API key authentication feature:
- Navigate to the “API Key Test” page via the navigation menu.
- Enter the API key (
mysecretapikey
) in the input field and submit the form. - The page will display a message indicating whether the API key is valid.
Using pytest
for Automatic Testing
To run the tests, create a new file called test_main.py
in the same folder of main.py
import pytest
from httpx import AsyncClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from main import app, get_db, Base, User, get_password_hash
DATABASE_URL = "sqlite:///./test.db"
# Create a new engine and session for the test database
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Override the get_db dependency to use the test database
Base.metadata.create_all(bind=engine)
def override_get_db():
try:
db = TestingSessionLocal()
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
# This fixture will automatically clear the database before each test
@pytest.fixture(scope="function", autouse=True)
def clear_database():
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
@pytest.mark.asyncio
async def test_register_user():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.post("/register", data={"email": "test@example.com", "password": "password123"})
assert response.status_code == 303 # Redirect to login after successful registration
@pytest.mark.asyncio
async def test_login_user():
# Register user first
db = TestingSessionLocal()
db.add(User(email="login_test@example.com", hashed_password=get_password_hash("password123")))
db.commit()
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.post("/token", data={"username": "login_test@example.com", "password": "password123"})
assert response.status_code == 200
assert "access_token" in response.json()
@pytest.mark.asyncio
async def test_login_user_invalid_credentials():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.post("/token", data={"username": "invalid@example.com", "password": "wrongpassword"})
assert response.status_code == 400 # Invalid credentials should return a 400 status code
@pytest.mark.asyncio
async def test_protected_route_with_valid_token():
async with AsyncClient(app=app, base_url="http://test") as ac:
headers = {"access_token": "mysecretapikey"}
response = await ac.get("/protected-route", headers=headers)
assert response.status_code == 200
assert response.json() == {"message": "You have access to this protected route."}
@pytest.mark.asyncio
async def test_protected_route_with_invalid_token():
async with AsyncClient(app=app, base_url="http://test") as ac:
headers = {"access_token": "notmysecretapikey"}
response = await ac.get("/protected-route", headers=headers)
assert response.status_code == 403 # Invalid token should return a 403 status code
@pytest.mark.asyncio
async def test_api_key_authentication():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.get("/protected-route", headers={"access_token": "mysecretapikey"})
assert response.status_code == 200
assert response.json() == {"message": "You have access to this protected route."}
@pytest.mark.asyncio
async def test_api_key_authentication_invalid_key():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.get("/protected-route", headers={"access_token": "invalidapikey"})
assert response.status_code == 403 # Invalid API key should return a 403 status code
Then, in the same virtual environment, run pytest
:
(fastapi) dynotes@P2021:~/projects/python/fastapi/fastapi-jinja2$ pytest
=============================================== test session starts ===============================================
platform linux -- Python 3.10.12, pytest-8.3.2, pluggy-1.5.0
rootdir: ~/projects/python/fastapi/fastapi-jinja2
plugins: anyio-4.4.0, asyncio-0.23.8
asyncio: mode=strict
collected 7 items
test_main.py ....... [100%]
================================================ warnings summary =================================================
test_main.py::test_register_user
test_main.py::test_login_user
test_main.py::test_login_user_invalid_credentials
test_main.py::test_protected_route_with_valid_token
test_main.py::test_protected_route_with_invalid_token
test_main.py::test_api_key_authentication
test_main.py::test_api_key_authentication_invalid_key
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================================== 7 passed, 8 warnings in 2.10s ==========================================
Summary
In this article, you learned how to build a secure web application using FastAPI, implementing essential features such as user management, API key authentication, and template rendering with Jinja2, all styled with Material Design.
The article began with setting up the project structure and configuring the development environment. You implemented a FastAPI application from scratch, manually handling user registration, password hashing, and JWT-based authentication for login. The API key authentication method was also integrated to secure specific routes.
The frontend of the application was created using Jinja2 templates and styled with Materialize CSS to follow Material Design principles, providing a clean and responsive user interface. You also learned how to set up routes for user registration, login, and protected content access.
To ensure the application’s functionality, the article guided you through writing automated tests using pytest
and httpx
. These tests covered user registration, login with valid and invalid credentials, and access control via API key and JWT tokens. Additionally, common errors were addressed, such as handling database integrity constraints and properly configuring authentication methods for protected routes.
Finally, the article concluded with practical advice on testing the /protected-route
endpoint, explaining how to use tools like Postman, Insomnia, browser developer tools, and curl
to validate both API key and JWT token authentication.
utuber
Hey,
great stuff. Do you mind placing the complete project on github?
Thanks