Implement user authentication flow

- Setup project structure with FastAPI
- Create user models with SQLAlchemy
- Implement JWT authentication
- Create auth endpoints (register, login, me)
- Add health endpoint
- Generate Alembic migrations
- Update documentation

Generated with BackendIM... (backend.im)
This commit is contained in:
Automated Action 2025-05-12 17:00:50 +00:00
parent 2a4ea06c8c
commit 8575427c43
28 changed files with 905 additions and 2 deletions

103
README.md
View File

@ -1,3 +1,102 @@
# FastAPI Application # User Authentication Flow Service
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A FastAPI service that provides a complete user authentication flow with JWT tokens.
## Features
- User registration with email and username
- User login with JWT token generation
- Password hashing with bcrypt
- Authentication middleware and dependencies
- User data retrieval
- Health check endpoint
- SQLite database with SQLAlchemy ORM
## Project Structure
```
.
├── app/
│ ├── api/
│ │ ├── deps.py # Dependency injection
│ │ ├── endpoints/ # API endpoints
│ │ └── api.py # API router
│ ├── core/
│ │ ├── config.py # Application configuration
│ │ └── security.py # Security utilities
│ ├── db/
│ │ └── session.py # Database session
│ ├── models/
│ │ └── user.py # SQLAlchemy models
│ ├── schemas/
│ │ ├── auth.py # Auth-related schemas
│ │ ├── token.py # Token schemas
│ │ └── user.py # User schemas
│ └── services/
│ └── user.py # User service
├── migrations/ # Alembic migrations
├── storage/
│ └── db/ # SQLite database
├── alembic.ini # Alembic configuration
├── main.py # Application entry point
└── requirements.txt # Python dependencies
```
## Getting Started
### Prerequisites
- Python 3.8 or higher
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd userauthenticationflowservice
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run database migrations:
```bash
alembic upgrade head
```
4. Start the server:
```bash
uvicorn main:app --reload
```
The API will be available at http://localhost:8000.
API documentation is available at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## API Endpoints
### Authentication
- **POST /api/v1/auth/register** - Register a new user
- **POST /api/v1/auth/login** - Login and get access token
- **GET /api/v1/auth/me** - Get current user information (requires authentication)
### Health Check
- **GET /health** - Check application health status
## Environment Variables
The application uses environment variables for configuration. These can be set in a `.env` file:
- `SECRET_KEY` - Secret key for JWT token generation
- `ACCESS_TOKEN_EXPIRE_MINUTES` - Token expiration time in minutes
- `DEBUG` - Debug mode (true/false)
## License
This project is licensed under the MIT License.

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
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

0
app/__init__.py Normal file
View File

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

28
app/api/api.py Normal file
View File

@ -0,0 +1,28 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.endpoints import auth, health
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
description=settings.DESCRIPTION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
)
# Set CORS middleware
if settings.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix=settings.API_V1_STR, tags=["auth"])
app.include_router(health.router, tags=["health"])

105
app/api/deps.py Normal file
View File

@ -0,0 +1,105 @@
from typing import Generator, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import (
create_access_token,
get_password_hash,
verify_password,
)
from app.db.session import get_db
from app.models.user import User
from app.schemas.token import TokenPayload
from app.services import user as user_service
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(reusable_oauth2)
) -> User:
"""
Get the current user based on JWT token.
Args:
db: Database session
token: JWT token from OAuth2 dependency
Returns:
User: Current user model
Raises:
HTTPException: If token is invalid or user doesn't exist
"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=["HS256"]
)
token_data = TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = user_service.get_by_id(db, id=token_data.sub)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get current active user (inactive users cannot use the system).
Args:
current_user: Current user from get_current_user dependency
Returns:
User: Current active user
Raises:
HTTPException: If user is inactive
"""
if not user_service.is_active(current_user):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user
def get_current_active_superuser(
current_user: User = Depends(get_current_active_user),
) -> User:
"""
Get current active superuser.
Args:
current_user: Current active user from get_current_active_user dependency
Returns:
User: Current active superuser
Raises:
HTTPException: If user is not a superuser
"""
if not user_service.is_superuser(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user

0
app/api/deps/__init__.py Normal file
View File

View File

89
app/api/endpoints/auth.py Normal file
View File

@ -0,0 +1,89 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.api.deps import get_current_active_user
from app.core.config import settings
from app.core.security import create_access_token
from app.db.session import get_db
from app.models.user import User
from app.schemas.auth import TokenResponse
from app.schemas.user import User as UserSchema, UserCreate
from app.services import user as user_service
router = APIRouter()
@router.post("/auth/register", response_model=UserSchema)
def register(
*,
db: Session = Depends(get_db),
user_in: UserCreate,
) -> Any:
"""
Register a new user.
"""
# Check if user with this email already exists
user = user_service.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Check if user with this username already exists
user = user_service.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken",
)
# Create new user
user = user_service.create(db, obj_in=user_in)
return user
@router.post("/auth/login", response_model=TokenResponse)
def login(
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = user_service.authenticate(
db, username=form_data.username, password=form_data.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user_service.is_active(user):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}
@router.get("/auth/me", response_model=UserSchema)
def read_current_user(
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get current user information.
"""
return current_user

View File

@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
router = APIRouter()
@router.get("/health")
def health_check(db: Session = Depends(get_db)):
"""
Health check endpoint.
Returns:
dict: Status indicating the health of the application and its dependencies
"""
# Check database connection
try:
# Execute a simple query to check DB connection
db.execute("SELECT 1")
db_status = "healthy"
except Exception as e:
db_status = f"unhealthy: {str(e)}"
return {
"status": "up",
"database": db_status,
}

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

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

@ -0,0 +1,44 @@
import secrets
from typing import Any, Dict, List, Optional, Union
from pydantic import AnyHttpUrl, EmailStr, HttpUrl, validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
PROJECT_NAME: str = "User Authentication Flow Service"
DESCRIPTION: str = "A service for user authentication flow"
VERSION: str = "0.1.0"
API_V1_STR: str = "/api/v1"
# Secret key for JWT
SECRET_KEY: str = secrets.token_urlsafe(32)
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
# CORS settings
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], 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)
# Debug mode
DEBUG: bool = True
# Database settings
DB_PATH: str = "/app/storage/db/db.sqlite"
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_PATH}"
# First superuser
FIRST_SUPERUSER: EmailStr = "admin@example.com"
FIRST_SUPERUSER_PASSWORD: str = "admin"
class Config:
case_sensitive = True
settings = Settings()

61
app/core/security.py Normal file
View File

@ -0,0 +1,61 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from jose import jwt
from passlib.context import CryptContext
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 for the given subject (user id).
Args:
subject: The subject (user id) for whom the token is being created.
expires_delta: Optional timedelta for token expiration. If not provided, uses settings.
Returns:
str: Encoded JWT 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="HS256")
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a plaintext password against its hash.
Args:
plain_password: The plaintext password
hashed_password: The hashed password to check against
Returns:
bool: True if the password matches, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password with bcrypt.
Args:
password: The plaintext password to hash
Returns:
str: The hashed password
"""
return pwd_context.hash(password)

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

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

@ -0,0 +1,28 @@
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Ensure db directory exists
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

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

@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
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)
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

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

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

@ -0,0 +1,17 @@
from typing import Optional
from pydantic import BaseModel, EmailStr
class Login(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class RefreshToken(BaseModel):
refresh_token: str

12
app/schemas/token.py Normal file
View File

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

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

@ -0,0 +1,46 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, Field
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = None
is_active: Optional[bool] = True
is_superuser: bool = False
first_name: Optional[str] = None
last_name: Optional[str] = None
# Properties to receive via API on creation
class UserCreate(UserBase):
email: EmailStr
username: str
password: str
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
# Properties shared by models stored in DB
class UserInDBBase(UserBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
# Additional properties to return via API
class User(UserInDBBase):
pass
# Additional properties stored in DB
class UserInDB(UserInDBBase):
hashed_password: str

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

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

@ -0,0 +1,75 @@
from typing import Optional
from sqlalchemy.orm import Session
from app.core.security import get_password_hash, verify_password
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
def get_by_email(db: Session, email: str) -> Optional[User]:
"""Get a user by email."""
return db.query(User).filter(User.email == email).first()
def get_by_username(db: Session, username: str) -> Optional[User]:
"""Get a user by username."""
return db.query(User).filter(User.username == username).first()
def get_by_id(db: Session, id: int) -> Optional[User]:
"""Get a user by ID."""
return db.query(User).filter(User.id == id).first()
def create(db: Session, obj_in: UserCreate) -> User:
"""Create a new user."""
db_obj = User(
email=obj_in.email,
username=obj_in.username,
hashed_password=get_password_hash(obj_in.password),
first_name=obj_in.first_name,
last_name=obj_in.last_name,
is_superuser=obj_in.is_superuser,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(db: Session, db_obj: User, obj_in: UserUpdate) -> User:
"""Update user properties."""
update_data = obj_in.dict(exclude_unset=True)
if update_data.get("password"):
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
for field, value in update_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def authenticate(db: Session, username: str, password: str) -> Optional[User]:
"""Authenticate a user by username and password."""
user = get_by_username(db, username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(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

11
main.py Normal file
View File

@ -0,0 +1,11 @@
import uvicorn
from app.core.config import settings
from app.api.api import app
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG
)

78
migrations/env.py Normal file
View File

@ -0,0 +1,78 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# 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.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from app.db.session import Base
from app.models.user import User # Import all models here for Alembic to detect
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():
"""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"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""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:
context.configure(
connection=connection, target_metadata=target_metadata
)
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():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,44 @@
"""initial migration
Revision ID: 20250512
Revises:
Create Date: 2025-05-12
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20250512'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create users table
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('first_name', sa.String(), nullable=True),
sa.Column('last_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('is_superuser', sa.Boolean(), default=False),
sa.Column('created_at', sa.DateTime(), default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), default=sa.func.now(), onupdate=sa.func.now()),
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():
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')

10
requirements.txt Normal file
View File

@ -0,0 +1,10 @@
fastapi==0.104.1
uvicorn==0.23.2
pydantic==2.4.2
sqlalchemy==2.0.22
passlib==1.7.4
python-jose==3.3.0
python-multipart==0.0.6
alembic==1.12.1
bcrypt==4.0.1
email-validator==2.0.0