Implement user authentication service with FastAPI and SQLite

This commit is contained in:
Automated Action 2025-06-05 11:01:49 +00:00
parent 21ffddffb6
commit 96d90a5f04
30 changed files with 1172 additions and 2 deletions

150
README.md
View File

@ -1,3 +1,149 @@
# FastAPI Application # User Authentication Service
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A FastAPI service that provides user authentication functionality including registration, login, and user management.
## Features
- User registration and login
- JWT-based authentication
- Password hashing with bcrypt
- User profile management
- Role-based access control (superuser capabilities)
- Email verification flags
- SQLite database with SQLAlchemy and Alembic migrations
- Health check endpoint
## Requirements
- Python 3.8+
- FastAPI
- SQLAlchemy
- Alembic
- Uvicorn
- See `requirements.txt` for complete list
## Setup
### 1. Clone the repository
```bash
git clone <repository-url>
cd userauthenticationservice
```
### 2. Set up a virtual environment (optional but recommended)
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
### 3. Install dependencies
```bash
pip install -r requirements.txt
```
### 4. Set environment variables
Create a `.env` file in the project root with the following content:
```
SECRET_KEY=your-secret-key-here
ACCESS_TOKEN_EXPIRE_MINUTES=10080 # 7 days
```
### 5. Run migrations to create the database
```bash
alembic upgrade head
```
### 6. Start the application
```bash
uvicorn main:app --reload
```
The API will be available at http://localhost:8000
## API Endpoints
### Authentication
- `POST /api/v1/auth/register` - Register a new user
- `POST /api/v1/auth/login` - OAuth2 compatible token login (form)
- `POST /api/v1/auth/login/json` - JSON compatible token login
### Users
- `GET /api/v1/users/me` - Get current user information (requires authentication)
- `PUT /api/v1/users/me` - Update current user information (requires authentication)
- `GET /api/v1/users` - List all users (requires superuser)
- `GET /api/v1/users/{user_id}` - Get user by ID (requires authentication)
- `PUT /api/v1/users/{user_id}` - Update user by ID (requires superuser)
### Health Check
- `GET /health` - Check API health status
## API Documentation
Interactive API documentation is available at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| SECRET_KEY | JWT secret key | "CHANGE_THIS_TO_A_SECURE_SECRET_IN_PRODUCTION" |
| ALGORITHM | JWT algorithm | "HS256" |
| ACCESS_TOKEN_EXPIRE_MINUTES | Token expiration time in minutes | 10080 (7 days) |
| PROJECT_NAME | Project name | "User Authentication Service" |
## Project Structure
```
.
├── alembic.ini
├── app
│ ├── api
│ │ ├── deps.py # Dependency functions
│ │ ├── endpoints
│ │ │ ├── auth.py # Authentication endpoints
│ │ │ └── users.py # User management endpoints
│ │ └── api.py # API router
│ ├── core
│ │ ├── config.py # App configuration
│ │ ├── database.py # Database setup
│ │ ├── errors.py # Error handling
│ │ ├── health.py # Health check endpoint
│ │ └── security.py # Security utilities
│ ├── crud
│ │ └── user.py # User CRUD operations
│ ├── models
│ │ ├── health.py # Health check model
│ │ └── user.py # User database model
│ └── schemas
│ └── user.py # User Pydantic schemas
├── main.py # Application entry point
├── migrations # Alembic migrations
│ ├── env.py
│ ├── script.py.mako
│ └── versions
│ └── initial_migration.py # Initial database schema
└── requirements.txt # Dependencies
```
## Security Notes
For production deployment:
1. Replace the default SECRET_KEY with a secure random key
2. Consider setting up HTTPS
3. Review the default token expiration time
4. Implement rate limiting for auth endpoints
5. Add more robust logging
6. Consider additional security measures like IP-based blocking

111
alembic.ini Normal file
View File

@ -0,0 +1,111 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# 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.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# SQLite database URL
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 REVISION_SCRIPT_FILENAME
# 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

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

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

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

@ -0,0 +1,70 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.crud import user as crud_user
from app.models.user import User
from app.schemas.user import TokenPayload
# Create OAuth2 password bearer scheme
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
async def get_current_user(
db: AsyncSession = Depends(get_db),
token: str = Depends(oauth2_scheme),
) -> User:
"""
Validate access token and return current user.
"""
try:
# Decode JWT token
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
token_data = TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Get user from database
user = await crud_user.get_by_id(db, user_id=token_data.sub)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get current active user.
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user"
)
return current_user
async def get_current_active_superuser(
current_user: User = Depends(get_current_active_user),
) -> User:
"""
Get current active superuser.
"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return current_user

View File

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

@ -0,0 +1,98 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import create_access_token
from app.crud import user as crud_user
from app.schemas.user import Token, User, UserCreate, UserLogin
router = APIRouter()
@router.post("/auth/register", response_model=User, status_code=status.HTTP_201_CREATED)
async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)) -> Any:
"""
Register a new user.
"""
# Check if user with this email already exists
user = await crud_user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Create new user
user = await crud_user.create(db, obj_in=user_in)
return user
@router.post("/auth/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = await crud_user.authenticate(
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"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user"
)
# Update last login timestamp
await crud_user.update_last_login(db, user=user)
# Create access token
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.post("/auth/login/json", response_model=Token)
async def login_json(login_data: UserLogin, db: AsyncSession = Depends(get_db)) -> Any:
"""
JSON compatible login, get an access token for future requests.
"""
user = await crud_user.authenticate(
db, email=login_data.email, password=login_data.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user"
)
# Update last login timestamp
await crud_user.update_last_login(db, user=user)
# Create access token
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",
}

View File

@ -0,0 +1,93 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import user as crud_user
from app.schemas.user import User, UserUpdate
from app.api.deps import get_current_active_user, get_current_active_superuser
router = APIRouter()
@router.get("/users/me", response_model=User)
async def read_user_me(
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get current user information.
"""
return current_user
@router.put("/users/me", response_model=User)
async def update_user_me(
user_in: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Update own user information.
"""
user = await crud_user.update(db, db_obj=current_user, obj_in=user_in)
return user
@router.get("/users", response_model=List[User])
async def read_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_superuser),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Retrieve users. Only available to superusers.
"""
users = await crud_user.get_users(db, skip=skip, limit=limit)
return users
@router.get("/users/{user_id}", response_model=User)
async def read_user_by_id(
user_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Get a specific user by id.
"""
user = await crud_user.get_by_id(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="Not enough permissions to access this resource",
)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return user
@router.put("/users/{user_id}", response_model=User)
async def update_user(
user_id: str,
user_in: UserUpdate,
current_user: User = Depends(get_current_active_superuser),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Update a user. Only available to superusers.
"""
user = await crud_user.get_by_id(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user = await crud_user.update(db, db_obj=user, obj_in=user_in)
return user

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

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

@ -0,0 +1,47 @@
from pathlib import Path
from typing import List, Optional, Union
from pydantic import AnyHttpUrl, EmailStr, validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Base Settings
PROJECT_NAME: str = "User Authentication Service"
API_V1_STR: str = "/api/v1"
# Database Settings
DB_DIR: Path = Path("/app") / "storage" / "db"
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
# JWT Settings
SECRET_KEY: str = "CHANGE_THIS_TO_A_SECURE_SECRET_IN_PRODUCTION"
ALGORITHM: str = "HS256"
# 60 minutes * 24 hours * 7 days = 7 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7
# CORS Settings
BACKEND_CORS_ORIGINS: List[Union[str, 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)
# Email Settings
EMAILS_FROM_EMAIL: Optional[EmailStr] = None
EMAILS_FROM_NAME: Optional[str] = None
class Config:
case_sensitive = True
env_file = ".env"
# Create settings object
settings = Settings()
# Ensure DB directory exists
settings.DB_DIR.mkdir(parents=True, exist_ok=True)

54
app/core/database.py Normal file
View File

@ -0,0 +1,54 @@
from pathlib import Path
from typing import AsyncGenerator
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# Ensure DB directory exists
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
# Create SQLite URL (with URI flag for foreign key support)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
# Create engine for synchronous operations (like migrations)
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
# For async operations, we need to use a different driver
ASYNC_SQLALCHEMY_DATABASE_URL = f"sqlite+aiosqlite:///{DB_DIR}/db.sqlite"
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
# Create session factories
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
AsyncSessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=async_engine,
class_=AsyncSession,
)
# Create a base class for declarative models
Base = declarative_base()
# Dependency function for getting a database session
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency function that yields an async database session.
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

48
app/core/errors.py Normal file
View File

@ -0,0 +1,48 @@
from typing import Any, Dict, Optional
from fastapi import HTTPException, status
class AuthError(HTTPException):
"""Authentication error."""
def __init__(
self,
detail: str = "Authentication error",
headers: Optional[Dict[str, Any]] = None,
):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers=headers or {"WWW-Authenticate": "Bearer"},
)
class ForbiddenError(HTTPException):
"""Permission denied error."""
def __init__(self, detail: str = "Permission denied"):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=detail,
)
class NotFoundError(HTTPException):
"""Resource not found error."""
def __init__(self, detail: str = "Resource not found"):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
)
class BadRequestError(HTTPException):
"""Bad request error."""
def __init__(self, detail: str = "Bad request"):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail,
)

15
app/core/health.py Normal file
View File

@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.health import HealthCheck
health_router = APIRouter()
@health_router.get("/health", response_model=HealthCheck, tags=["health"])
async def health(db: AsyncSession = Depends(get_db)):
"""
Health check endpoint to verify API is running properly.
"""
return {"status": "ok", "database": "connected", "api_version": "v1"}

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

@ -0,0 +1,66 @@
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
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Token related functions
def create_access_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
"""
Create a JWT access token.
Args:
subject: The subject to encode in the JWT (typically user ID)
expires_delta: Optional timedelta for token expiration
Returns:
JWT token as string
"""
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
# Password related functions
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a plain password against a hashed password.
Args:
plain_password: The plain text password
hashed_password: The hashed password to compare against
Returns:
True if the password matches, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Generate a password hash from a plain password.
Args:
password: The plain text password to hash
Returns:
Hashed password string
"""
return pwd_context.hash(password)

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

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

@ -0,0 +1,113 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_password_hash, verify_password
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
async def get_by_id(db: AsyncSession, user_id: str) -> Optional[User]:
"""
Get a user by ID.
"""
result = await db.execute(select(User).filter(User.id == user_id))
return result.scalars().first()
async def get_by_email(db: AsyncSession, email: str) -> Optional[User]:
"""
Get a user by email.
"""
result = await db.execute(select(User).filter(User.email == email))
return result.scalars().first()
async def get_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> List[User]:
"""
Get a list of users with pagination.
"""
result = await db.execute(select(User).offset(skip).limit(limit))
return result.scalars().all()
async def create(db: AsyncSession, *, obj_in: UserCreate) -> User:
"""
Create a new user.
"""
db_obj = User(
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
is_active=True,
is_superuser=False,
email_verified=False,
)
db.add(db_obj)
await db.commit()
await db.refresh(db_obj)
return db_obj
async def update(
db: AsyncSession, *, 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 "password" in update_data and update_data["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)
await db.commit()
await db.refresh(db_obj)
return db_obj
async def remove(db: AsyncSession, *, id: str) -> Optional[User]:
"""
Delete a user.
"""
user = await get_by_id(db, id)
if user:
await db.delete(user)
await db.commit()
return user
async def authenticate(
db: AsyncSession, *, email: str, password: str
) -> Optional[User]:
"""
Authenticate a user by email and password.
"""
user = await get_by_email(db, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
async def update_last_login(db: AsyncSession, *, user: User) -> User:
"""
Update the last login timestamp for a user.
"""
user.last_login = datetime.utcnow()
db.add(user)
await db.commit()
await db.refresh(user)
return user

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

11
app/models/health.py Normal file
View File

@ -0,0 +1,11 @@
from pydantic import BaseModel
class HealthCheck(BaseModel):
"""
Health check response model.
"""
status: str
database: str
api_version: str

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

@ -0,0 +1,36 @@
import uuid
from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy.sql import func
from app.core.database import Base
class User(Base):
"""
User model for authentication and user management.
"""
__tablename__ = "users"
# Primary key
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# Auth fields
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
# User details
full_name = Column(String, nullable=True)
# Status fields
is_active = Column(Boolean, default=True, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)
email_verified = Column(Boolean, default=False, nullable=False)
# Timestamps
created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
last_login = Column(DateTime(timezone=True), nullable=True)

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

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

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

@ -0,0 +1,59 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, Field
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
is_active: Optional[bool] = True
is_superuser: Optional[bool] = False
full_name: Optional[str] = None
# Properties to receive on user creation
class UserCreate(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8)
full_name: Optional[str] = None
# Properties to receive on user update
class UserUpdate(UserBase):
password: Optional[str] = Field(None, min_length=8)
# Properties to return to client
class User(UserBase):
id: str
email_verified: bool
created_at: datetime
updated_at: Optional[datetime] = None
last_login: Optional[datetime] = None
class Config:
from_attributes = True
# Properties to return to admin users
class UserAdminRead(User):
pass
# Properties for user login
class UserLogin(BaseModel):
email: EmailStr
password: str
# Token response schema
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
# Token payload schema
class TokenPayload(BaseModel):
sub: str
exp: datetime

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

48
main.py Normal file
View File

@ -0,0 +1,48 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from app.api.api import api_router
from app.core.config import settings
from app.core.health import health_router
app = FastAPI(
title=settings.PROJECT_NAME,
description="User Authentication Service API",
version="0.1.0",
openapi_url="/openapi.json",
)
# Set up CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(health_router)
app.include_router(api_router)
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
)
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

0
migrations/__init__.py Normal file
View File

62
migrations/env.py Normal file
View File

@ -0,0 +1,62 @@
from logging.config import fileConfig
from alembic import context
from app.core.database import Base
# Import all models so they are registered with SQLAlchemy
import app.models.user # noqa
from sqlalchemy import engine_from_config, pool
# This is the Alembic Config object
config = context.config
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Add your model's MetaData here
target_metadata = Base.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
# For SQLite, use batch mode for table alterations
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode."""
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,
# For SQLite, use batch mode for table alterations
render_as_batch=is_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,51 @@
"""Initial migration
Revision ID: 001
Revises:
Create Date: 2023-10-01
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create users table
op.create_table(
"users",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("email", 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("email_verified", sa.Boolean(), nullable=False, default=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_login", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# Create indexes
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
def downgrade():
# Drop indexes
op.drop_index(op.f("ix_users_email"), table_name="users")
# Drop tables
op.drop_table("users")

11
requirements.txt Normal file
View File

@ -0,0 +1,11 @@
fastapi>=0.103.1
uvicorn>=0.23.2
sqlalchemy>=2.0.20
alembic>=1.12.0
pydantic>=2.3.0
pydantic-settings>=2.0.3
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
email-validator>=2.0.0
ruff>=0.0.290