avatarTravis Luong

Summary

The web content provides a comprehensive guide on building a user authentication flow using Next.js for the frontend, FastAPI for the backend, and PostgreSQL as the database, along with integrating OAuth2 with Password and JWT tokens for security.

Abstract

The article is a continuation of a series on full-stack development with Next.js, FastAPI, and PostgreSQL. It focuses on implementing user authentication, detailing steps for setting up OAuth2 with password hashing, bearer token with JWT, and integrating PostgreSQL with SQLAlchemy and Alembic for database migrations. The tutorial emphasizes practical implementation, providing code snippets for user model creation, authentication functions, token generation, user retrieval, and handling login requests in Next.js. It also covers creating a login form, managing user sessions, and securing API endpoints. The guide concludes by instructing developers on how to create a user and test the authentication flow, encouraging them to build upon the tutorial by adding a sign-up page.

Opinions

  • The author suggests that using FastAPI's official security tutorial as a reference can be beneficial but may be too detailed for some use cases, hence the "shortcut" version provided in the article.
  • The article recommends developing Next.js applications on the host machine instead of inside Docker due to issues with hot reloading.
  • The author endorses the use of Tailwind CSS components for quickly building the login form interface, indicating a preference for their ease of use and adaptability.
  • The article implies that storing user sessions in localStorage is an acceptable practice for maintaining user authentication state on the client side.
  • The author provides a personal touch by mentioning that the content is originally published on their website, suggesting a sense of ownership and authority on the subject matter.

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.

Table of Contents

  1. Full Stack Next.js, FastAPI, PostgreSQL Tutorial
  2. How to Build a Full Stack Next.js, FastAPI, PostgreSQL Boilerplate Tutorial
  3. How to Deploy Next.js, FastAPI, and PostgreSQL with Shell Scripts
  4. How to Develop a Full Stack Next.js, FastAPI, PostgreSQL App Using Docker
  5. How to Build a User Authentication Flow with Next.js, FastAPI, and PostgreSQL
  6. How to Test an API with Pytest and Requests

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.

Originally published at https://www.travisluong.com on December 24, 2021.

Fastapi
Nextjs
Python
React
Web Development
Recommended from ReadMedium