How to Build a User Authentication Flow with Next.js, FastAPI, and PostgreSQL
This is a continuation of previous articles on how to build, deploy, and dockerize a Next.js, FastAPI, and PostgreSQL boilerplate.
The tutorial branch:
https://github.com/travisluong/nfp-boilerplate/tree/tutorial-4-user-authentication
The completed project:
https://github.com/travisluong/nfp-boilerplate
This tutorial builds off of the previous tutorials. For a more detailed explanation on building out user authentication flow, see the official FastAPI documentation on security.
The steps contained in this tutorial are a “shortcut” version of the official tutorial, skipping much of the explanatory steps in the documentation.
Install dependencies
$ pip install python-multipart "python-jose[cryptography]" "passlib[bcrypt]"
$ pip freeze > requirements.txt
OAuth2 with Password (and hashing), Bearer with JWT tokens
Create a nfp-backend/routers/users.py
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Depends, APIRouter, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
from ..database import users, database
# to get a string like this run:
# openssl rand -hex 32
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
class User(BaseModel):
username: str
email: Optional[str] = None
full_name: Optional[str] = None
disabled: Optional[bool] = None
class UserInDB(User):
hashed_password: str
class UserIn(User):
password: str
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
router = APIRouter()
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)
async def get_user(username: str):
query = users.select().where(users.c.username == username)
user = await database.fetch_one(query)
return UserInDB(username=user["username"], hashed_password=user["hashed_password"])
async def authenticate_user(username: str, password: str):
user = await get_user(username)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
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
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = await get_user(username=token_data.username)
if user is None:
raise credentials_exception
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
@router.post("/token", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
user = await authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
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.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/users/me/", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
return current_user
@router.get("/users/me/items/")
async def read_own_items(current_user: User = Depends(get_current_active_user)):
return [{"item_id": "Foo", "owner": current_user.username}]
@router.post("/users/", response_model=User)
async def sign_up(user: UserIn):
hashed_password = get_password_hash(user.password)
query = users.insert().values(
username=user.username, hashed_password=hashed_password
)
last_record_id = await database.execute(query)
return {"username": user.username, "id": last_record_id}
This code was modeled after the example from the FastAPI documentation.
We searched and replaced all instances of @app
with @router
. And FastAPI
with APIRouter
. The purpose of this is to allow putting all of the auth code in its own file.
We also replaced the calls to the fake in-memory database with real database calls.
In main.py
, import the router:
from routers import users
Add paste this just under app = FastAPI()
.
app.include_routers(users.router)
Create the database.py
file.
import os
import databases
import sqlalchemy
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
database = databases.Database(DATABASE_URL)
metadata = sqlalchemy.MetaData()
notes = sqlalchemy.Table(
"notes",
metadata,
sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
sqlalchemy.Column("text", sqlalchemy.String),
sqlalchemy.Column("completed", sqlalchemy.Boolean),
)
users = sqlalchemy.Table(
"users",
metadata,
sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True),
sqlalchemy.Column("username", sqlalchemy.String),
sqlalchemy.Column("hashed_password", sqlalchemy.String)
)
engine = sqlalchemy.create_engine(
DATABASE_URL
)
This contains the database configuration and sqlalchemy mappings. As your app grows, you may want to split out table mappings.
PostgreSQL, SQLAlchemy, Alembic Integration
In nfp-backend
directory, run:
$ alembic revision -m "create users table"
Open up the file that was just created. Fill in the upgrade and downgrade methods:
"""create users table
Revision ID: 7bb035cc0f48
Revises: df0d975d6fc2
Create Date: 2021-12-19 00:05:48.045380
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7bb035cc0f48'
down_revision = 'df0d975d6fc2'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("username", sa.String, unique=True),
sa.Column("password", sa.String)
)
def downgrade():
op.drop_table("users")
If you’re using the dockerized version of the boilerplate, open a terminal into the backend container:
$ docker exec -it nfp-boilerplate-backend-1 bash
Run the migrations:
$ alembic upgrade head
Next.js Login Form
Note that I’ve commented out the frontend service in docker-compose.yml
in the tutorial repo. I found the hot reloading for Next.js doesn’t work too well inside docker, so I recommend developing Next.js apps on the host machine instead.
Create a login.js
in pages
.
import { useState } from 'react';
import { useRouter } from 'next/router';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
function handleUsernameChange(e) {
setUsername(e.target.value);
}
function handlePasswordChange(e) {
setPassword(e.target.value);
}
async function handleSubmit(e) {
e.preventDefault();
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/token`, {
method: 'POST',
body: formData
});
if (res.status == 200) {
const json = await res.json();
localStorage.setItem('token', json.access_token);
router.push("admin");
} else {
alert('Login failed.')
}
}
return (
<>
<div className="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<img
className="mx-auto h-12 w-auto"
src="https://tailwindui.com/img/logos/workflow-mark-indigo-600.svg"
alt="Workflow"
/>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Sign in to your account</h2>
</div>
<form className="mt-8 space-y-6" action="#" method="POST" onSubmit={handleSubmit}>
<input type="hidden" name="remember" defaultValue="true" />
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
Username
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Username"
value={username}
onChange={handleUsernameChange}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={password}
onChange={handlePasswordChange}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
<div className="text-sm">
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
Forgot your password?
</a>
</div>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span className="absolute left-0 inset-y-0 flex items-center pl-3">
</span>
Sign in
</button>
</div>
</form>
</div>
</div>
</>
)
}
Note that the above code was modeled after a tailwindcss component from the tailwind component examples. There are many components that you can copy and paste. Be sure to select the React sample.
Next, create an admin.js
in pages
.
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
export default function Admin() {
const [user, setUser] = useState(null);
const router = useRouter();
useEffect(() => {
const token = localStorage.getItem('token');
async function fetchUser() {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/users/me/`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (res.status == 200) {
const json = await res.json();
setUser(json);
} else {
router.push('login');
}
}
fetchUser();
}, []);
return (
<div>
<h1>Admin</h1>
{user && (
<p>{user.username}</p>
)}
</div>
)
}
Create a user by making a post request to the /users/
endpoint.
$ curl -X POST localhost:8000/users/ -d '{"username": "foo", "password": "password"}' -H 'Content-Type: application/json'
This curl command can also be used on the server to create the first user.
Conclusion
Congratulations. In this tutorial, you learned how to set up a basic username and password authentication flow with Next.js, FastAPI, and PostgreSQL. You should be able to log in by going to the /login
route. As a next step, try building out a Sign Up page.