Implement user authentication service with JWT tokens

This commit is contained in:
Automated Action 2025-06-05 07:32:18 +00:00
parent b0a0b6d19d
commit 5fede46dcb
30 changed files with 936 additions and 2 deletions

146
README.md
View File

@ -1,3 +1,145 @@
# FastAPI Application # User Authentication Service
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A FastAPI service for user authentication using JWT tokens with SQLite database.
## Features
- User registration and login
- JWT token-based authentication
- Password hashing with bcrypt
- Protected routes with authentication
- SQLite database with SQLAlchemy ORM
- Alembic migrations
## Getting Started
### Prerequisites
- Python 3.9+
- pip
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd userauthenticationservice
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Set up environment variables:
Create a `.env` file in the project root directory and add the following variables:
```
SECRET_KEY=your-secret-key-here
ACCESS_TOKEN_EXPIRE_MINUTES=30
```
### Database Setup
Run the database migrations:
```bash
alembic upgrade head
```
### Running the Application
Start the FastAPI server:
```bash
uvicorn main:app --reload
```
The API will be available at http://localhost:8000
## API Documentation
Once the server is running, you can access the interactive API documentation at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
### Authentication Flow
1. **Register a new user:**
- Endpoint: `POST /api/v1/users/`
- Body:
```json
{
"email": "user@example.com",
"username": "username",
"full_name": "User Name",
"password": "password123",
"password_confirm": "password123"
}
```
2. **Login to get an access token:**
- Endpoint: `POST /api/v1/auth/token`
- Form data:
```
username: user@example.com
password: password123
```
- Response:
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
```
3. **Access protected endpoints:**
- Add the header: `Authorization: Bearer <access_token>`
- Example protected endpoint: `GET /api/v1/users/me`
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| SECRET_KEY | Secret key for JWT token generation | CHANGEME_SECRET_KEY_CHANGEME |
| ACCESS_TOKEN_EXPIRE_MINUTES | Token expiration time in minutes | 30 |
| BACKEND_CORS_ORIGINS | CORS allowed origins | ["*"] |
## Project Structure
```
.
├── alembic.ini
├── app
│ ├── api
│ │ └── v1
│ │ ├── api.py
│ │ └── endpoints
│ │ ├── auth.py
│ │ ├── protected.py
│ │ └── users.py
│ ├── core
│ │ └── config.py
│ ├── db
│ │ ├── init_db.py
│ │ └── session.py
│ ├── models
│ │ └── user.py
│ ├── schemas
│ │ ├── auth.py
│ │ └── user.py
│ └── services
│ ├── auth.py
│ ├── security.py
│ └── user.py
├── main.py
├── migrations
│ ├── env.py
│ ├── script.py.mako
│ └── versions
│ └── 001_create_users_table.py
└── requirements.txt
```

85
alembic.ini Normal file
View File

@ -0,0 +1,85 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Absolute path to SQLite database
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks=black
# black.type=console_scripts
# black.entrypoint=black
# black.options=-l 79
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

1
app/api/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

1
app/api/v1/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

8
app/api/v1/api.py Normal file
View File

@ -0,0 +1,8 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, protected
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(protected.router, prefix="/protected", tags=["protected"])

View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

View File

@ -0,0 +1,46 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.services.auth import authenticate_user
from app.services.security import create_access_token
from app.schemas.auth import Token
router = APIRouter()
@router.post("/token", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = authenticate_user(db, email=form_data.username, password=form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
# Create access token
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
subject=user.id, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

View File

@ -0,0 +1,22 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends
from app.models.user import User
from app.services.auth import get_current_active_user
router = APIRouter()
@router.get("/", response_model=Dict[str, Any])
def get_protected_data(
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Protected endpoint example that requires authentication.
"""
return {
"message": "This is protected data",
"user_id": current_user.id,
"email": current_user.email,
}

View File

@ -0,0 +1,43 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserRead
from app.services.auth import get_current_active_user
from app.services.user import create_user, get_user_by_email
router = APIRouter()
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def register_user(
user_in: UserCreate,
db: Session = Depends(get_db),
) -> Any:
"""
Register a new user.
"""
# Check if user exists
user = get_user_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists",
)
# Create new user
user = create_user(db, user_in)
return user
@router.get("/me", response_model=UserRead)
def read_user_me(
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get current user.
"""
return current_user

1
app/core/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

44
app/core/config.py Normal file
View File

@ -0,0 +1,44 @@
from pathlib import Path
from typing import Any, List
from pydantic import validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
PROJECT_NAME: str = "User Authentication Service"
PROJECT_DESCRIPTION: str = "A FastAPI service for user authentication"
PROJECT_VERSION: str = "0.1.0"
# CORS
BACKEND_CORS_ORIGINS: List[str] = ["*"]
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Any) -> List[str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
# Database
DB_DIR: Path = Path("/app") / "storage" / "db"
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
# JWT
SECRET_KEY: str = "CHANGEME_SECRET_KEY_CHANGEME"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore"
)
settings = Settings()
# Ensure the database directory exists
settings.DB_DIR.mkdir(parents=True, exist_ok=True)

1
app/db/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

8
app/db/init_db.py Normal file
View File

@ -0,0 +1,8 @@
from sqlalchemy.orm import Session
def init_db(db: Session) -> None:
"""Initialize the database with initial data."""
# This will be implemented after creating the user model and service
pass

22
app/db/session.py Normal file
View File

@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(
settings.SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} # Needed for SQLite
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Dependency for getting a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()

1
app/models/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

18
app/models/user.py Normal file
View File

@ -0,0 +1,18 @@
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.sql import func
from app.db.session import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

1
app/schemas/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

13
app/schemas/auth.py Normal file
View File

@ -0,0 +1,13 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenPayload(BaseModel):
sub: Optional[int] = None
exp: Optional[int] = None

48
app/schemas/user.py Normal file
View File

@ -0,0 +1,48 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, validator
class UserBase(BaseModel):
email: EmailStr
username: str
full_name: Optional[str] = None
is_active: bool = True
class UserCreate(UserBase):
password: str = Field(..., min_length=8)
password_confirm: str
@validator("password_confirm")
def passwords_match(cls, v, values, **kwargs):
if "password" in values and v != values["password"]:
raise ValueError("Passwords do not match")
return v
class UserUpdate(UserBase):
password: Optional[str] = None
is_active: Optional[bool] = None
is_superuser: Optional[bool] = None
class UserInDBBase(UserBase):
id: int
created_at: datetime
updated_at: datetime
is_superuser: bool
class Config:
from_attributes = True
class UserRead(UserInDBBase):
"""Return schema for user without sensitive data"""
pass
class UserInDB(UserInDBBase):
"""Database schema for user including sensitive data"""
hashed_password: str

1
app/services/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

70
app/services/auth.py Normal file
View File

@ -0,0 +1,70 @@
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.models.user import User
from app.schemas.auth import TokenPayload
from app.services.user import get_user, is_active_user
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="/api/v1/auth/token"
)
def authenticate_user(db: Session, *, email: str, password: str) -> Optional[User]:
"""
Authenticate a user by email and password.
"""
from app.services.user import get_user_by_email, verify_password
user = get_user_by_email(db, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> User:
"""
Get the current user from the token.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
token_data = TokenPayload(sub=int(user_id))
except JWTError:
raise credentials_exception
user = get_user(db, user_id=token_data.sub)
if user is None:
raise credentials_exception
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get the current active user.
"""
if not is_active_user(current_user):
raise HTTPException(status_code=400, detail="Inactive user")
return current_user

38
app/services/security.py Normal file
View File

@ -0,0 +1,38 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from passlib.context import CryptContext
from jose import jwt
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against a hash.
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password for storing.
"""
return pwd_context.hash(password)

126
app/services/user.py Normal file
View File

@ -0,0 +1,126 @@
from typing import Any, Dict, Optional, Union
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
from app.services.security import get_password_hash, verify_password
def get_user(db: Session, user_id: int) -> Optional[User]:
"""
Get a user by ID.
"""
return db.query(User).filter(User.id == user_id).first()
def get_user_by_email(db: Session, email: str) -> Optional[User]:
"""
Get a user by email.
"""
return db.query(User).filter(User.email == email).first()
def get_user_by_username(db: Session, username: str) -> Optional[User]:
"""
Get a user by username.
"""
return db.query(User).filter(User.username == username).first()
def get_users(
db: Session, skip: int = 0, limit: int = 100
) -> list[User]:
"""
Get multiple users.
"""
return db.query(User).offset(skip).limit(limit).all()
def create_user(db: Session, user_in: UserCreate) -> User:
"""
Create a new user.
"""
# Check if user with this email already exists
user = get_user_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists",
)
# Check if user with this username already exists
user = get_user_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this username already exists",
)
# Create new user
hashed_password = get_password_hash(user_in.password)
db_user = User(
email=user_in.email,
username=user_in.username,
hashed_password=hashed_password,
full_name=user_in.full_name,
is_active=user_in.is_active,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def update_user(
db: Session, *, db_user: User, user_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
"""
Update a user.
"""
if isinstance(user_in, dict):
update_data = user_in
else:
update_data = user_in.model_dump(exclude_unset=True)
# If password is being updated, hash it
if update_data.get("password"):
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
# Update user
for field, value in update_data.items():
setattr(db_user, field, value)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def authenticate_user(db: Session, *, email: str, password: str) -> Optional[User]:
"""
Authenticate a user.
"""
user = get_user_by_email(db, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active_user(user: User) -> bool:
"""
Check if user is active.
"""
return user.is_active
def is_superuser(user: User) -> bool:
"""
Check if user is superuser.
"""
return user.is_superuser

29
main.py Normal file
View File

@ -0,0 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.api import api_router
from app.core.config import settings
app = FastAPI(
title=settings.PROJECT_NAME,
description=settings.PROJECT_DESCRIPTION,
version=settings.PROJECT_VERSION,
openapi_url="/openapi.json",
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router)
@app.get("/health", tags=["Health"])
def health_check():
"""Health check endpoint to confirm the service is running."""
return {"status": "ok"}

1
migrations/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

83
migrations/env.py Normal file
View File

@ -0,0 +1,83 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from app.db.session import Base
from app.models.user import User # noqa: F401 - Import all models here for Alembic to detect
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# 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
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True, # Important for SQLite
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
is_sqlite = connection.dialect.name == "sqlite"
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite, # Important for SQLite
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,43 @@
"""Create users table
Revision ID: 001
Revises:
Create Date: 2023-11-15
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import func
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('is_superuser', sa.Boolean(), nullable=False, default=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=func.now(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
def downgrade() -> None:
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')

12
requirements.txt Normal file
View File

@ -0,0 +1,12 @@
fastapi>=0.104.1
uvicorn>=0.24.0,<0.25.0
sqlalchemy>=2.0.23,<3.0.0
alembic>=1.12.1,<2.0.0
pydantic>=2.4.2,<3.0.0
pydantic-settings>=2.0.3,<3.0.0
passlib>=1.7.4,<2.0.0
python-jose>=3.3.0,<4.0.0
python-multipart>=0.0.6,<0.1.0
bcrypt>=4.0.1,<5.0.0
email-validator>=2.1.0.post1,<3.0.0
ruff>=0.1.3,<0.2.0