# Configure User registration - password hashing
NOTE: From now, I have shifted from Python 3.9 to 3.8 due to this bug Segmentation Fault in record_repr at asyncpg/protocol/record/recordobj.c:462 (opens new window)
Okay, now it is time to restructure our project a bit and apply changes to store our User's data securely. Particularly, we need password hashing and salting and an endpoint to send the request.
Let's get started.
I would like to change our app/database.py
file and hard code some arguments there(those will be updated and will be read from .env as well in the future):
from .core.config import settings
db = Gino(
db: Gino = Gino(
dsn=settings.DATABASE_URI
dsn=settings.DATABASE_URI,
)
pool_min_size=3,
pool_max_size=20,
retry_limit=1,
retry_interval=1,
ssl=None,
)
Next I am going to change our Pydantic schemas in users/schemas.py
.
Changes are below, the rest of the file was left unchanged:
class UserCreate(CoreModel):
"""
Email, username, and password are required for registering a new user
"""
email: EmailStr
password: constr(min_length=7, max_length=100)
username: str
@validator("username", pre=True)
def username_is_valid(cls, username: str) -> str:
return validate_username(username)
class Config:
orm_mode = True
class UserInDB(DateTimeModelMixin, UserBase):
"""
Add in id, created_at, updated_at, and user's password and salt
"""
password: constr(min_length=7, max_length=100)
salt: str
class Config:
orm_mode = True
class UserPublic(DateTimeModelMixin, UserBase):
class Config:
orm_mode = True
# TODO: UserUpdate for profile update can be here
# TODO: UserPasswordUpdate for password update can be here
class UserPasswordUpdate(CoreModel):
"""
Users can change their password
"""
password: constr(min_length=7, max_length=100)
salt: str
class Config:
orm_mode = True
Basically we add orm_mode=True
and new schema called UserPasswordUpdate
.
This change will come handy when we are going to pass database models to our Pydantic schema.
Read more about orm_mode=True (opens new window).
We need some extra packages:
$ poetry add passlib[bcrypt]
Our final pyproject.toml
file will be:
[tool.poetry]
name = "backend"
version = "0.1.0"
description = ""
authors = ["Shahriyar Rzayev <rzayev.sehriyar@gmail.com>"]
[tool.poetry.dependencies]
python = "^3.7"
fastapi = "^0.64.0"
gino = {extras = ["pg", "starlette"], version = "^1.0.1"}
uvicorn = "^0.13.4"
gunicorn = "^20.1.0"
alembic = "^1.6.2"
psycopg2 = "^2.8.6"
passlib = {extras = ["bcrypt"], version = "^1.7.4"}
pydantic = {extras = ["dotenv"], version = "^1.8.2"}
[tool.poetry.dev-dependencies]
pytest = "^5.2"
pytest-cov = "^2.10.1"
requests = "^2.25.1"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
Great, now we need to create Authentication helper class and methods to create password hash and salt.
Create a file users/authentication.py
file and put:
import bcrypt
from passlib.context import CryptContext
from .schemas import UserPasswordUpdate
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class Authenticate:
def create_salt_and_hashed_password(self, *, plaintext_password: str) -> UserPasswordUpdate:
salt = self.generate_salt()
hashed_password = self.hash_password(password=plaintext_password, salt=salt)
return UserPasswordUpdate(salt=salt, password=hashed_password)
@staticmethod
def generate_salt() -> str:
return bcrypt.gensalt().decode()
@staticmethod
def hash_password(*, password: str, salt: str) -> str:
return pwd_context.hash(password + salt)
This code has been adopted from -> Auth users in FastAPI with JWT Tokens (opens new window).
Did you notice the UserPasswordUpdate
pydantic schema? This is basically the copy of UserInDB
schema.
Basically, we are returning back this schema with new generated salt and password for later usage in our create_user
function.
Next we need to instantiate this class in the users/__init__.py
file:
from .authentication import Authenticate
auth_service = Authenticate()
__all__ = ['auth_service', ]
Then go ahead to users/crud.py
file and add our actual user creation function:
from .schemas import UserPasswordUpdate, UserCreate, UserInDB
from backend.users import auth_service
from .models import User, db
from backend.app.core.config import settings
async def create_user(new_user: UserCreate) -> UserInDB:
# This is a UserPasswordUpdate
new_password = auth_service.create_salt_and_hashed_password(plaintext_password=new_user.password)
# Next we extend our UserCreate schema here
new_user_params = new_user.copy(update=new_password.dict())
# Updated and extended UserCreate schema was passed to UserInDB
new_user_updated = UserInDB(**new_user_params.dict())
# Just printing the result
print(new_user_updated)
# Here we are openning one time connection
async with db.with_bind(settings.DATABASE_URI) as engine:
# Database model User creation happens here
created_user = await User.create(**new_user_updated.dict())
# And now we nicely return from_orm with UserInDB
return UserInDB.from_orm(created_user)
Please follow comments in the code to get the idea what is going on there.
That's great but, we need an endpoint to use this crud operation. Let's update our api/controller.py
:
from fastapi import APIRouter
from ..schemas import UserCreate, UserInDB, UserPublic
router = APIRouter()
@router.post(
"/create",
tags=["user registration"],
description="Register the User",
response_model=UserPublic,
)
async def user_create(user: UserCreate) -> UserInDB:
from ..crud import create_user
return await create_user(user)
We are simply awaiting create_user and it should do the trick for us. But, please pay attention to our response_model it is UserPublic
- it means that the password, salt will be omitted in the response, but the function itself has the return type of UserInDB
. Nice and clean 😃
We have some other changes in order to finish this chapter and to test our app.
The updated version of app/main.py
:
import sys
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .core.config import settings
from .database import db
from backend.users.api.controller import router as user_router
sys.path.append('..')
app = FastAPI(title=settings.PROJECT_NAME)
db.init_app(app)
@app.on_event("startup")
async def startup():
print("app started")
@app.on_event("shutdown")
async def shutdown():
print("SHUTDOWN")
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(user_router, prefix='/users')
For migrations/env.py
update following lines:
sys.path.append('..')
from backend.app.core.config import settings
from backend.app.database import db
from backend.users.models import User
Now let's fire up our server and test it. First sending the request:
curl -X POST http://127.0.0.1:8000/users/create -d '{"email": "example@gmail.com", "password": "12345789", "username": "example"}' | jq
{
"email": "example@gmail.com",
"username": "example",
"email_verified": false,
"is_active": true,
"is_superuser": false,
"created_at": "2021-05-15T20:21:06.507097",
"updated_at": "2021-05-15T20:21:06.507117"
}
We can't see the hashed password and salt because of again UserPublic
schema.
But we can check if in database:
ecommerce=# select * from users;
id | username | email | email_verified | salt | password | is_active | is_superuser | created_at | updated_at
----+----------+-------------------+----------------+-------------------------------+--------------------------------------------------------------+-----------+--------------+----------------------------+----------------------------
22 | example | example@gmail.com | f | $2b$12$bu70L8D4nbNBftEvSc93gO | $2b$12$9OvltW2n.AqwXFhv5OyBh.x51.dWg7Vt..Gd6eAC6LUu5ziqNfF9G | t | f | 2021-05-15 20:21:06.507097 | 2021-05-15 20:21:06.507117
(1 row)
As you see we have hashed password and salt in place.
From the server output(do remember this print?):
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [28445] using statreload
INFO: Started server process [28447]
INFO: Waiting for application startup.
INFO: Application startup complete.
app started
email='example@gmail.com' username='example' email_verified=False is_active=True is_superuser=False created_at=datetime.datetime(2021, 5, 15, 20, 21, 6, 507097) updated_at=datetime.datetime(2021, 5, 15, 20, 21, 6, 507117) password='$2b$12$9OvltW2n.AqwXFhv5OyBh.x51.dWg7Vt..Gd6eAC6LUu5ziqNfF9G' salt='$2b$12$bu70L8D4nbNBftEvSc93gO'
INFO: 127.0.0.1:45054 - "POST /users/create HTTP/1.1" 200 OK
So basically we have registered the user with hashed and salted password properly.
The code changes for this episode -> episode-5 (opens new window)
The next thing is to have login functionality with JWT, so we are going to extend our Authentication system.