Set up user authentication flow with FastAPI and SQLite

- Created user model with SQLAlchemy ORM
- Implemented authentication with JWT tokens (access and refresh tokens)
- Added password hashing with bcrypt
- Created API endpoints for registration, login, and user management
- Set up Alembic for database migrations
- Added health check endpoint
- Created role-based access control (standard users and superusers)
- Added comprehensive documentation
This commit is contained in:
Automated Action 2025-06-10 15:58:57 +00:00
parent 4786514e1b
commit aae9527254
34 changed files with 1421 additions and 2 deletions

109
README.md
View File

@ -1,3 +1,108 @@
# 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 with JWT tokens.
## Features
- User registration and management
- Authentication with JWT tokens (access and refresh tokens)
- Role-based access control (standard users and superusers)
- Password hashing with bcrypt
- SQLite database with SQLAlchemy ORM
- Alembic migrations
## Getting Started
### Prerequisites
- Python 3.10+
- pip (Python package manager)
### Installation
1. Clone the repository
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
Once the server is running, you can access the interactive API documentation at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## Environment Variables
The following environment variables can be set in a `.env` file:
| Variable | Description | Default Value |
|-----------------------------|--------------------------------------------------|-------------------------|
| SECRET_KEY | JWT secret key | Auto-generated |
| ACCESS_TOKEN_EXPIRE_MINUTES | Access token expiration time in minutes | 30 |
| REFRESH_TOKEN_EXPIRE_DAYS | Refresh token expiration time in days | 7 |
| SQLALCHEMY_DATABASE_URL | Database connection string | SQLite in /app/storage |
## Authentication Flow
1. **Registration**: Users can register via `POST /api/v1/register/`
2. **Login**: Users can obtain tokens via `POST /api/v1/auth/login`
3. **Access Protected Resources**: Include the access token in the Authorization header (`Bearer {token}`)
4. **Refresh Token**: When the access token expires, use `POST /api/v1/auth/refresh-token` to get a new one
## Project Structure
```
.
├── alembic.ini # Alembic configuration
├── app # Application package
│ ├── api # API endpoints
│ │ ├── deps.py # API dependencies
│ │ └── v1 # API version 1
│ │ ├── api.py # API router
│ │ └── endpoints # API endpoint modules
│ ├── core # Core modules
│ │ ├── config.py # Configuration settings
│ │ └── security.py # Security utilities
│ ├── crud # CRUD operations
│ │ └── user.py # User CRUD operations
│ ├── db # Database
│ │ ├── base.py # Base class
│ │ ├── base_class.py # Base class imports
│ │ ├── base_model.py # Base model
│ │ ├── init_db.py # Database initialization
│ │ └── session.py # Database session
│ ├── models # SQLAlchemy models
│ │ └── user.py # User model
│ └── schemas # Pydantic schemas
│ ├── token.py # Token schemas
│ └── user.py # User schemas
├── main.py # FastAPI application
├── migrations # Alembic migrations
│ ├── env.py # Alembic environment
│ ├── README # Alembic README
│ ├── script.py.mako # Migration script template
│ └── versions # Migration versions
├── pyproject.toml # Project configuration
└── requirements.txt # Python dependencies
```
## 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
# SQLite URL using absolute path
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

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

@ -0,0 +1,128 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import TOKEN_TYPE_ACCESS
from app.crud.user import get_user
from app.db.session import get_db
from app.models.user import User
from app.schemas.token import TokenPayload
# Define OAuth2 scheme for token authentication
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> User:
"""
Get the current user from the provided JWT token.
Args:
db: Database session
token: JWT token from Authorization header
Returns:
Current user
Raises:
HTTPException: If token is invalid or user not found
"""
try:
# Decode the JWT token
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
token_data = TokenPayload(**payload)
# Check if token is expired
if token_data.exp is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token is missing expiration claim",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if token is the correct type (access token)
if token_data.type != TOKEN_TYPE_ACCESS:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"},
)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Get the user from the database
user = get_user(db, user_id=int(token_data.sub))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get the current active user.
Args:
current_user: Current user from get_current_user
Returns:
Current active user
Raises:
HTTPException: If user is inactive
"""
if not current_user.is_active:
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 the current active superuser.
Args:
current_user: Current user from get_current_active_user
Returns:
Current active superuser
Raises:
HTTPException: If user is not a superuser
"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges",
)
return current_user

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

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

@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, register, users
# Create main API router
api_router = APIRouter()
# Include routers for different endpoints
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(register.router, prefix="/register", tags=["Registration"])

View File

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

View File

@ -0,0 +1,158 @@
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_user
from app.core.config import settings
from app.core.security import (
create_access_token,
create_refresh_token,
TOKEN_TYPE_REFRESH
)
from app.crud.user import authenticate_user, get_user
from app.db.session import get_db
from app.schemas.token import Token, RefreshToken
from app.schemas.user import User
from jose import jwt, JWTError
from pydantic import ValidationError
router = APIRouter()
@router.post("/login", response_model=Token)
async def login_access_token(
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
Args:
db: Database session
form_data: Form containing username (can be email or username) and password
Returns:
Access token
"""
# Try to authenticate with username
user = authenticate_user(db, username=form_data.username, password=form_data.password)
# If username authentication fails, try with email
if not user:
user = authenticate_user(db, email=form_data.username, password=form_data.password)
# If both attempts fail, raise an exception
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username/email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token with the default expiration time
access_token = create_access_token(subject=user.id)
# Create refresh token with the default expiration time
refresh_token = create_refresh_token(subject=user.id)
# Return tokens
return {
"access_token": access_token,
"token_type": "bearer",
"refresh_token": refresh_token
}
@router.post("/refresh-token", response_model=Token)
async def refresh_token(
db: Session = Depends(get_db),
refresh_token_in: RefreshToken = Body(...)
) -> Any:
"""
Refresh access token using a refresh token.
Args:
db: Database session
refresh_token_in: Refresh token
Returns:
New access token and refresh token
"""
try:
# Decode the refresh token
payload = jwt.decode(
refresh_token_in.refresh_token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM]
)
# Validate token payload
if payload.get("type") != TOKEN_TYPE_REFRESH:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"},
)
# Get user ID from token
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
# Get user from database
user = get_user(db, user_id=int(user_id))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
# Create new access token
access_token = create_access_token(subject=user.id)
# Create new refresh token
new_refresh_token = create_refresh_token(subject=user.id)
# Return new tokens
return {
"access_token": access_token,
"token_type": "bearer",
"refresh_token": new_refresh_token
}
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.get("/me", response_model=User)
async def read_users_me(
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get current user information.
Args:
current_user: Current user from authentication
Returns:
Current user information
"""
return current_user

View File

@ -0,0 +1,51 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.crud.user import create_user, get_user_by_email, get_user_by_username
from app.db.session import get_db
from app.schemas.user import User, UserCreate
router = APIRouter()
@router.post("/", response_model=User)
def register_user(
*,
db: Session = Depends(get_db),
user_in: UserCreate,
) -> Any:
"""
Register a new user.
Args:
db: Database session
user_in: User data for registration
Returns:
Registered 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 user (non-superuser by default)
user_in_data = user_in.dict()
user_in_data["is_superuser"] = False
user = create_user(db, obj_in=UserCreate(**user_in_data))
return user

View File

@ -0,0 +1,217 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_current_active_superuser, get_current_active_user
from app.crud.user import (
create_user,
get_user,
get_user_by_email,
get_user_by_username,
get_users,
update_user,
)
from app.db.session import get_db
from app.models.user import User as UserModel
from app.schemas.user import User, UserCreate, UserUpdate
router = APIRouter()
@router.get("/", response_model=List[User])
def read_users(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: UserModel = Depends(get_current_active_superuser),
) -> Any:
"""
Retrieve users.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum number of records to return
current_user: Current user (must be superuser)
Returns:
List of users
"""
users = get_users(db, skip=skip, limit=limit)
return users
@router.post("/", response_model=User)
def create_new_user(
*,
db: Session = Depends(get_db),
user_in: UserCreate,
current_user: UserModel = Depends(get_current_active_superuser),
) -> Any:
"""
Create new user (superuser only).
Args:
db: Database session
user_in: User data
current_user: Current user (must be superuser)
Returns:
Created 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 user
user = create_user(db, obj_in=user_in)
return user
@router.put("/me", response_model=User)
def update_user_me(
*,
db: Session = Depends(get_db),
user_in: UserUpdate,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Update own user.
Args:
db: Database session
user_in: User data to update
current_user: Current user
Returns:
Updated user
"""
# Check if trying to update email to one that already exists
if user_in.email is not None and user_in.email != current_user.email:
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 trying to update username to one that already exists
if user_in.username is not None and user_in.username != current_user.username:
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",
)
# Update user
user = update_user(db, db_obj=current_user, obj_in=user_in)
return user
@router.get("/me", response_model=User)
def read_user_me(
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get current user.
Args:
current_user: Current user
Returns:
Current user
"""
return current_user
@router.get("/{user_id}", response_model=User)
def read_user_by_id(
user_id: int,
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db),
) -> Any:
"""
Get a specific user by id.
Args:
user_id: User ID
current_user: Current user
db: Database session
Returns:
User with specified ID
"""
user = get_user(db, user_id=user_id)
if user == current_user:
return user
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges"
)
return user
@router.put("/{user_id}", response_model=User)
def update_user_by_id(
*,
db: Session = Depends(get_db),
user_id: int,
user_in: UserUpdate,
current_user: UserModel = Depends(get_current_active_superuser),
) -> Any:
"""
Update a user (superuser only).
Args:
db: Database session
user_id: User ID
user_in: User data to update
current_user: Current user (must be superuser)
Returns:
Updated user
"""
user = get_user(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="The user with this ID does not exist",
)
# Check if trying to update email to one that already exists
if user_in.email is not None and user_in.email != user.email:
user_with_email = get_user_by_email(db, email=user_in.email)
if user_with_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists",
)
# Check if trying to update username to one that already exists
if user_in.username is not None and user_in.username != user.username:
user_with_username = get_user_by_username(db, username=user_in.username)
if user_with_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this username already exists",
)
# Update user
user = update_user(db, db_obj=user, obj_in=user_in)
return 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

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

@ -0,0 +1,46 @@
import secrets
from pathlib import Path
from typing import List
from pydantic import AnyHttpUrl, validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Base configuration
API_V1_STR: str = "/api/v1"
PROJECT_NAME: str = "User Authentication Service"
PROJECT_DESCRIPTION: str = "FastAPI User Authentication Service with JWT"
PROJECT_VERSION: str = "0.1.0"
# Security configuration
SECRET_KEY: str = secrets.token_urlsafe(32)
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
ALGORITHM: str = "HS256"
# Database configuration
DB_DIR: Path = Path("/app") / "storage" / "db"
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
# CORS configuration
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: str | List[str]) -> List[AnyHttpUrl]:
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)
class Config:
case_sensitive = True
env_file = ".env"
# Create settings instance
settings = Settings()
# Ensure DB directory exists
settings.DB_DIR.mkdir(parents=True, exist_ok=True)

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

@ -0,0 +1,108 @@
from datetime import datetime, timedelta
from typing import Any, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Token types
TOKEN_TYPE_ACCESS = "access"
TOKEN_TYPE_REFRESH = "refresh"
def create_access_token(
subject: Union[str, Any], expires_delta: timedelta = None
) -> str:
"""
Create a JWT access token for a given subject.
Args:
subject: Subject of the token (typically user ID)
expires_delta: Optional expiration time delta
Returns:
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),
"type": TOKEN_TYPE_ACCESS
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def create_refresh_token(
subject: Union[str, Any], expires_delta: timedelta = None
) -> str:
"""
Create a JWT refresh token for a given subject.
Args:
subject: Subject of the token (typically user ID)
expires_delta: Optional expiration time delta
Returns:
Encoded JWT token
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode = {
"exp": expire,
"sub": str(subject),
"type": TOKEN_TYPE_REFRESH
}
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 plain password against a hashed password.
Args:
plain_password: Plain password
hashed_password: Hashed password
Returns:
True if passwords match, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password.
Args:
password: Plain password
Returns:
Hashed password
"""
return pwd_context.hash(password)

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

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

98
app/crud/user.py Normal file
View File

@ -0,0 +1,98 @@
from typing import Any, Dict, Optional, Union
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_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 with pagination."""
return db.query(User).offset(skip).limit(limit).all()
def create_user(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),
full_name=obj_in.full_name,
is_active=obj_in.is_active,
is_superuser=obj_in.is_superuser,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update_user(
db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
"""Update a user."""
if isinstance(obj_in, dict):
update_data = obj_in
else:
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 in update_data:
if hasattr(db_obj, field):
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def authenticate_user(
db: Session, *, email: str = None, username: str = None, password: str
) -> Optional[User]:
"""Authenticate a user by email/username and password."""
if email:
user = get_user_by_email(db, email=email)
elif username:
user = get_user_by_username(db, username=username)
else:
return None
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

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

4
app/db/base.py Normal file
View File

@ -0,0 +1,4 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

3
app/db/base_class.py Normal file
View File

@ -0,0 +1,3 @@
# Import all the models, so that Base has them before being imported by Alembic
from app.db.base import Base # noqa
from app.models.user import User # noqa

15
app/db/base_model.py Normal file
View File

@ -0,0 +1,15 @@
from sqlalchemy import Column, DateTime, Integer, func
from sqlalchemy.ext.declarative import declared_attr
class BaseModel:
"""Base model class for all SQLAlchemy models"""
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
@declared_attr
def __tablename__(cls):
"""Automatically generates table name from class name"""
return cls.__name__.lower()

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

@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
from app.crud.user import create_user, get_user_by_email
from app.schemas.user import UserCreate
def init_db(db: Session) -> None:
"""Initialize the database with seed data if needed"""
# Create a superuser if no users exist
user = get_user_by_email(db, email="admin@example.com")
if not user:
user_in = UserCreate(
email="admin@example.com",
username="admin",
password="admin123", # This should be a secure password in production
full_name="Administrator",
is_superuser=True
)
create_user(db, obj_in=user_in)

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

@ -0,0 +1,29 @@
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Ensure the database directory exists
db_dir = Path("/app") / "storage" / "db"
db_dir.mkdir(parents=True, exist_ok=True)
# Create SQLAlchemy engine
engine = create_engine(
settings.SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} # Needed for SQLite
)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Dependency to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,2 @@
# Import models here for easy access
from app.models.user import User # noqa

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

@ -0,0 +1,15 @@
from sqlalchemy import Boolean, Column, String
from app.db.base import Base
from app.db.base_model import BaseModel
class User(Base, BaseModel):
"""User model for authentication"""
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, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)

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

@ -0,0 +1,3 @@
# Import schemas here for easy access
from app.schemas.user import User, UserCreate, UserUpdate # noqa
from app.schemas.token import Token, TokenPayload, RefreshToken # noqa

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

@ -0,0 +1,19 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
refresh_token: str
class TokenPayload(BaseModel):
sub: Optional[str] = None # subject (typically user ID)
exp: Optional[int] = None # expiration time
type: Optional[str] = None # token type (access or refresh)
class RefreshToken(BaseModel):
refresh_token: str

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

@ -0,0 +1,46 @@
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, validator
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = None
is_active: Optional[bool] = True
is_superuser: bool = False
full_name: Optional[str] = None
# Properties to receive on user creation
class UserCreate(UserBase):
email: EmailStr
username: str
password: str = Field(..., min_length=8)
@validator("username")
def username_alphanumeric(cls, v):
if not v.isalnum():
raise ValueError("Username must be alphanumeric")
return v
# Properties to receive on user update
class UserUpdate(UserBase):
password: Optional[str] = None
# Properties to return to client
class User(UserBase):
id: int
class Config:
orm_mode = True
# Properties properties stored in JWT token
class UserInDB(User):
hashed_password: str
class Config:
orm_mode = True

67
main.py Normal file
View File

@ -0,0 +1,67 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
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,
)
# Set up CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_STR)
# Define custom OpenAPI schema
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=settings.PROJECT_NAME,
version=settings.PROJECT_VERSION,
description=settings.PROJECT_DESCRIPTION,
routes=app.routes,
)
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
@app.get("/", tags=["Root"])
async def root():
"""
Root endpoint providing basic service information.
"""
return {
"title": settings.PROJECT_NAME,
"documentation": "/docs",
"health_check": "/health"
}
@app.get("/health", tags=["Health"])
async def health():
"""
Health check endpoint to verify service status.
"""
return {
"status": "healthy",
"version": settings.PROJECT_VERSION
}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration with SQLite.

86
migrations/env.py Normal file
View File

@ -0,0 +1,86 @@
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
# Add the parent directory to sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Import the Base object from our app
from app.db.base_class import Base
# 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
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:
is_sqlite = connection.dialect.name == 'sqlite'
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite # Key configuration 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():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,48 @@
"""Initial migration
Revision ID: c5d2eb0d8a87
Revises:
Create Date: 2023-11-30 10:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'c5d2eb0d8a87'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create user table
op.create_table(
'user',
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(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
sa.PrimaryKeyConstraint('id')
)
# Create indexes
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True)
def downgrade():
# Drop indexes
op.drop_index(op.f('ix_user_username'), table_name='user')
op.drop_index(op.f('ix_user_id'), table_name='user')
op.drop_index(op.f('ix_user_email'), table_name='user')
# Drop table
op.drop_table('user')

3
pyproject.toml Normal file
View File

@ -0,0 +1,3 @@
[tool.ruff]
line-length = 120
target-version = "py310"

19
requirements.txt Normal file
View File

@ -0,0 +1,19 @@
# FastAPI and ASGI server
fastapi>=0.104.0
uvicorn>=0.23.2
pydantic>=2.4.2
pydantic-settings>=2.0.3
email-validator>=2.1.0
# Database
sqlalchemy>=2.0.23
alembic>=1.12.1
# Authentication
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
# Utils
python-dotenv>=1.0.0
ruff>=0.1.3