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.

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.

You Might Also Like

Leave a Reply