Creating the User Authentication Model
Now that we have a working CRUD API, let's move on to more advanced topics. The first thing we need to tackle is figuring out who our users are and what they can do in the application. This breaks down into two main points:
- Authentication: Making sure users are who they say they are.
- Authorization: Deciding what actions users are allowed to take based on their identity.
Creating the user account model
To achieve that, we shall begin by having a database model for keeping information about user accounts. Our current project structure looks like.
├── requirements.txt
└── src
|-- ├── books
|-- │ ├── book_data.py
|-- │ ├── __init__.py
|-- │ ├── models.py
|-- │ ├── routes.py
|-- │ ├── schemas.py
|-- │ └── service.py
|-- ├── config.py
|-- ├── db
|-- │ ├── __init__.py
|-- │ └── main.py
`-- └── __init__.py
Inside the src
folder, we shall create a directory called auth
. This shall keep all source code associated with user account management. Inside it, we shall create a __init__.py
file to mark it as a Python package. Our updated directory structure is now:
├── README.md
├── requirements.txt
└── src
|-- ├── auth
|-- │ └── __init__.py
|-- ├── books
|-- │ ├── book_data.py
|-- │ ├── __init__.py
|-- │ ├── models.py
|-- │ ├── routes.py
|-- │ ├── schemas.py
|-- │ └── service.py
|-- ├── config.py
|-- ├── db
|-- │ ├── __init__.py
|-- │ └── main.py
`-- └── __init__.py
Let us now add a models.py
file in which we shall create the user account model.
from sqlmodel import SQLModel, Field, Column
import sqlalchemy.dialects.postgresql as pg
import uuid
class User(SQLModel, table=True):
__tablename__ = "user_accounts"
uid: uuid.UUID = Field(
sa_column=Column(
pg.UUID,
primary_key=True,
unique=True,
nullable=False,
default=uuid.uuid4,
info={"description": "Unique identifier for the user account"},
)
)
username: str
first_name: str = Field(nullable=True)
last_name: str = Field(nullable=True)
is_verified: bool = False
email: str
password_hash: str
created_at: datetime = Field(sa_column=Column(pg.TIMESTAMP, default=func.now))
def __repr__(self) -> str:
return f"<User {self.username}>"
Most of the code here was explained in Chapter 5, so I will only cover what is new.
- We created a database model called
User
with the table nameuser_accounts
. -
The primary fields added include
uuid
, along with other fields such asfirst_name
,last_name
,email
,password_hash
,created_at
, andis_verified
. -
The
is_verified
field is necessary as it allows us to verify user-provided email addresses, ensuring we only deal with valid email addresses.
Once we have created this database model, let's save it and ensure the table is reflected in our database.
Database Migrations with Alembic
Previously, we applied changes to the database using the lifespan event created in Chapter 5. This method allowed us to create the table each time our server started, which was helpful during development. However, in a production environment, we need a proper database migration system to migrate changes to our database schema without restarting our server.
To achieve this, we will use Alembic, a database migration tool for use with SQLAlchemy. Since we are using SQLModel, which is based on SQLAlchemy, Alembic will be very useful.
Let's begin by installing Alembic in our virtual environment using pip
:
To confirm that Alembic has been installed, run the following command:
Alembic is installed and the version is 1.13.1
. Now, let's initialize Alembic in our project with this command:
(env) $ alembic init -t async migrations
Creating directory '/home/jod35/Documents/fastapi-beyond-CRUD/migrations' ... done
Creating directory '/home/jod35/Documents/fastapi-beyond-CRUD/migrations/versions' ... done
Generating /home/jod35/Documents/fastapi-beyond-CRUD/migrations/script.py.mako ... done
Generating /home/jod35/Documents/fastapi-beyond-CRUD/migrations/env.py ... done
Generating /home/jod35/Documents/fastapi-beyond-CRUD/alembic.ini ... done
Generating /home/jod35/Documents/fastapi-beyond-CRUD/migrations/README ... done
Please edit configuration/connection/logging settings in '/home/jod35/Documents/fastapi-beyond-CRUD/alembic.ini' before proceeding.
The above command uses Alembic to create a migration environment. The migration environment, in this case, is the migrations
folder added to our project directory. This folder and the alembic.ini
file are generated by Alembic and form the migration environment.
We used the -t
option to specify the template for setting up the environment. We chose the async template because our project uses an async DBAPI.
Now, our folder structure looks like this:
├── alembic.ini
├── migrations
│ ├── env.py
│ ├── README
│ ├── script.py.mako
│ └── versions
├── README.md
├── requirements.txt
└── src
├── auth
│ ├── __init__.py
│ ├── models.py
├── books
│ ├── book_data.py
│ ├── __init__.py
│ ├── models.py
│ ├── routes.py
│ ├── schemas.py
│ └── service.py
├── config.py
├── db
│ ├── __init__.py
│ └── main.py
└── __init__.py
The migrations
directory contains the following:
versions/
: This folder will contain Python scripts created for each migration to track database changes.env.py
: This script serves as the entry point for Alembic. When you run Alembic commands likealembic init
,alembic revision
, oralembic upgrade
, this script executes the necessary actions.README
: This file contains a description of the migration environment we have set up.script.py.mako
: This is a template used by Alembic to create new Python migration scripts each time we create a new migration.
Sure, here is the rewritten text:
The alembic.ini
file contains configurations for Alembic that enable it to interact with our database and project.
Now that we understand our migration environment, let's set up SQLModel to work with Alembic. We start by editing migrations/env.py
:
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from sqlmodel import SQLModel
from src.config import Config
database_url = Config.DATABASE_URL
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
config.set_main_option("sqlalchemy.url", database_url)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = SQLModel.metadata
... # the rest of env.py
In this example, we import the User
model we created in /src/auth/models.py
. This is necessary because Alembic will automatically generate changes to the model. Additionally, we import the SQLModel
class to access the metadata
object, which Alembic uses to track changes to our database model using SQLModel. One other important change we have made is one to enable our sqlalchemy.url
be read from the DATABASE_URL
environment variable.
database_url = Config.DATABASE_URL
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
config.set_main_option("sqlalchemy.url", database_url)
Next, we edit the migrations/script.py.mako
file to include SQLModel:
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel # ADD THIS
...
Having made these changes, let's create our first database migration. Our database currently contains only the books
table. We'll create a migration to add the user_accounts
table:
(env) $ alembic revision --autogenerate -m "init"
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'user_accounts'
Generating /home/jod35/Documents/fastapi-beyond-CRUD/migrations/versions/8cf8276d5f3c_init.py ... done
The newly created migrations/versions/8cf8276d5f3c_init.py
file contains the following:
"""init
Revision ID: 8cf8276d5f3c
Revises:
Create Date: 2024-05-21 19:27:53.577277
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '8cf8276d5f3c'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user_accounts',
sa.Column('uid', sa.UUID(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('first_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('last_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_verified', sa.Boolean(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(), nullable=True),
sa.PrimaryKeyConstraint('uid'),
sa.UniqueConstraint('uid')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user_accounts')
# ### end Alembic commands ###
This code includes:
revision
: The unique identifier for this migration.down_revision
: The identifier of the previous migration, set toNone
for the initial migration.branch_labels
anddepends_on
: Optional fields, set toNone
.
The upgrade
function defines the changes to the database structure, creating the user_accounts
table with specified columns. The downgrade
function reverts these changes by dropping the user_accounts
table if the migration is undone.
To apply these changes to the database, use the following command:
(env) $ alembic upgrade head
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> 8cf8276d5f3c, init
This command creates the user_accounts
table in the database. To verify, list the tables in your current database:
For example:
(env) $ psql --username=jod35 --dbname=books
psql (14.11 (Ubuntu 14.11-0ubuntu0.22.04.1))
Type "help" for help.
books=# \dt
List of relations
Schema | Name | Type | Owner
--------+-----------------+-------+-------
public | alembic_version | table | jod35
public | books | table | jod35
public | user_accounts | table | jod35
(3 rows)
The alembic_version
and user_accounts
tables have been created. Let's examine their structures.
For alembic_version
:
books=# \d alembic_version
Table "public.alembic_version"
Column | Type | Collation | Nullable | Default
-------------+-----------------------+-----------+----------+---------
version_num | character varying(32) | | not null |
Indexes:
"alembic_version_pkc" PRIMARY KEY, btree (version_num)
This table includes one column, version_num
, which keeps records of the version numbers of changes made to the database structure.
For user_accounts
:
books=# \d user_accounts
Table "public.user_accounts"
Column | Type | Collation | Nullable | Default
---------------+-----------------------------+-----------+----------+---------
uid | uuid | | not null |
username | character varying | | not null |
first_name | character varying | | |
last_name | character varying | | |
is_verified | boolean | | not null |
email | character varying | | not null |
password_hash | character varying | | not null |
created_at | timestamp without time zone
```console
created_at | timestamp without time zone | | |
Indexes:
"user_accounts_pkey" PRIMARY KEY, btree (uid)
Ladies and gentlemen, we have successfully created the user_accounts
table from the User
model. Now that we have done this, let us look at some ways to implement authentication in FastAPI.
Conclusion
In this chapter, we have created a simple database model to enable us manage user accounts in our application. We have introduced Alembic, a database migration tool that runs on SQLAlchemy database models, allowing us to easily introduce changes to an existing database structure without haveing to delete data.