Add user authentication system with login, signup, and JWT tokens

- Added user model and schema definitions
- Implemented JWT token authentication
- Created endpoints for user registration and login
- Added secure password hashing with bcrypt
- Set up SQLite database with SQLAlchemy
- Created Alembic migrations
- Added user management endpoints
- Included health check endpoint

generated with BackendIM... (backend.im)
This commit is contained in:
Automated Action 2025-05-11 22:51:17 +00:00
parent 7b7db2b703
commit 794d172f85
29 changed files with 767 additions and 2 deletions

View File

@ -1,3 +1,86 @@
# FastAPI Application # User Authentication Service
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A FastAPI-based authentication service that provides user management, JWT authentication, and secure password handling.
## Features
- User registration (signup) and login
- JWT token-based authentication
- Secure password hashing using bcrypt
- User profile management (view, update, delete)
- SQLite database with SQLAlchemy ORM
- Alembic migrations for database version control
- Health check endpoint
## Project Structure
```
├── alembic/ # Database migration files
├── app/ # Application code
│ ├── api/ # API endpoints
│ │ └── v1/ # API version 1
│ │ └── endpoints/ # API route handlers
│ ├── core/ # Core application code
│ ├── db/ # Database configuration and repositories
│ ├── middlewares/ # Middleware components
│ ├── models/ # SQLAlchemy ORM models
│ ├── schemas/ # Pydantic schema models
│ └── services/ # Business logic services
├── storage/ # Storage for SQLite database
└── main.py # FastAPI application entry point
```
## 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
- **Users**
- `GET /api/v1/users/` - List all users (requires authentication)
- `GET /api/v1/users/{user_id}` - Get a specific user (requires authentication)
- `PUT /api/v1/users/{user_id}` - Update a user (requires authentication)
- `DELETE /api/v1/users/{user_id}` - Delete a user (requires authentication)
- **Health Check**
- `GET /health` - Check service health status
## Getting Started
### Prerequisites
- Python 3.8+
- pip
### Installation
1. Clone the repository
2. Install dependencies:
```
pip install -r requirements.txt
```
3. Run database migrations:
```
alembic upgrade head
```
4. Start the server:
```
uvicorn main:app --reload
```
### Documentation
- Interactive API documentation is available at `/docs` when the server is running
- ReDoc documentation is available at `/redoc`
## Security
- Passwords are hashed using bcrypt
- Authentication is handled via JWT tokens
- CORS is enabled and configurable
- Environment variables can be used to configure secrets
## License
MIT

40
alembic.ini Normal file
View File

@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = sqlite:///app/storage/db/db.sqlite
[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

79
alembic/env.py Normal file
View File

@ -0,0 +1,79 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from app.models import Base
from app.db.database import SQLALCHEMY_DATABASE_URL
# 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)
# Set the SQLAlchemy URL
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
# 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"},
)
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:
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
alembic/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 @@
"""Initial migration
Revision ID: 001
Revises:
Create Date: 2025-05-11
"""
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() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False, primary_key=True),
sa.Column('email', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True, onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

0
app/__init__.py Normal file
View File

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

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

View File

View File

@ -0,0 +1,37 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.schemas.user import UserCreate, User, Token
from app.services.auth_service import auth_service
router = APIRouter()
@router.post("/register", response_model=User, status_code=status.HTTP_201_CREATED)
def register(user_in: UserCreate, db: Session = Depends(get_db)):
"""
Register a new user.
"""
return auth_service.register_user(db, user_in)
@router.post("/login", response_model=Token)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = auth_service.authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
return auth_service.create_token(user)
@router.get("/me", response_model=User)
def read_users_me(current_user: User = Depends(auth_service.get_current_user)):
"""
Get current user information.
"""
return current_user

View File

@ -0,0 +1,104 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.db.database import get_db
from app.db.repositories import user_repository
from app.schemas.user import User, UserUpdate
from app.services.auth_service import auth_service
from app.models.user import User as UserModel
router = APIRouter()
@router.get("/", response_model=List[User])
def read_users(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: UserModel = Depends(auth_service.get_current_user)
):
"""
Retrieve users. Only authenticated users can see the list.
"""
users = user_repository.list(db, skip=skip, limit=limit)
return users
@router.get("/{user_id}", response_model=User)
def read_user(
user_id: int,
db: Session = Depends(get_db),
current_user: UserModel = Depends(auth_service.get_current_user)
):
"""
Get a specific user by id.
"""
db_user = user_repository.get_by_id(db, user_id)
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
return db_user
@router.put("/{user_id}", response_model=User)
def update_user(
user_id: int,
user_in: UserUpdate,
db: Session = Depends(get_db),
current_user: UserModel = Depends(auth_service.get_current_user)
):
"""
Update a user. Users can only update their own information.
"""
# Only allow users to update their own information
if current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
db_user = user_repository.get_by_id(db, user_id)
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
# Check for email uniqueness if changing email
if user_in.email and user_in.email != db_user.email:
existing_user = user_repository.get_by_email(db, user_in.email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Check for username uniqueness if changing username
if user_in.username and user_in.username != db_user.username:
existing_user = user_repository.get_by_username(db, user_in.username)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
user_data = user_in.model_dump(exclude_unset=True)
updated_user = user_repository.update(db, db_user, user_data)
return updated_user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: UserModel = Depends(auth_service.get_current_user)
):
"""
Delete a user. Users can only delete their own account.
"""
# Only allow users to delete their own account
if current_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
db_user = user_repository.get_by_id(db, user_id)
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
user_repository.delete(db, db_user)
return None

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

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

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

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

@ -0,0 +1,21 @@
from pydantic_settings import BaseSettings
import secrets
from typing import Optional
class Settings(BaseSettings):
# API settings
API_V1_STR: str = "/api/v1"
# Security settings
SECRET_KEY: str = secrets.token_urlsafe(32)
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# CORS settings
BACKEND_CORS_ORIGINS: list[str] = ["*"]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()

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

@ -0,0 +1,25 @@
from datetime import datetime, timedelta
from typing import Any, Union, Optional
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
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=ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)

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

27
app/db/database.py Normal file
View File

@ -0,0 +1,27 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from pathlib import Path
# Create DB directory if it doesn't exist
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()
# Dependency to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@ -0,0 +1,5 @@
from app.db.repositories.user_repository import UserRepository
user_repository = UserRepository()
__all__ = ["user_repository"]

View File

@ -0,0 +1,35 @@
from sqlalchemy.orm import Session
from app.models.user import User
from app.schemas.user import UserCreate
from typing import Optional
class UserRepository:
def get_by_id(self, db: Session, user_id: int) -> Optional[User]:
return db.query(User).filter(User.id == user_id).first()
def get_by_email(self, db: Session, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()
def get_by_username(self, db: Session, username: str) -> Optional[User]:
return db.query(User).filter(User.username == username).first()
def create(self, db: Session, user_data: dict) -> User:
user = User(**user_data)
db.add(user)
db.commit()
db.refresh(user)
return user
def update(self, db: Session, user: User, user_data: dict) -> User:
for key, value in user_data.items():
setattr(user, key, value)
db.commit()
db.refresh(user)
return user
def delete(self, db: Session, user: User) -> None:
db.delete(user)
db.commit()
def list(self, db: Session, skip: int = 0, limit: int = 100):
return db.query(User).offset(skip).limit(limit).all()

View File

@ -0,0 +1,3 @@
from app.middlewares.auth import AuthMiddleware
__all__ = ["AuthMiddleware"]

51
app/middlewares/auth.py Normal file
View File

@ -0,0 +1,51 @@
from fastapi import Request, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
from app.core.config import settings
from app.core.security import ALGORITHM
# Define a simple middleware class for global JWT verification (if needed)
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
# Skip auth for certain paths
if self._should_skip_auth(request.url.path):
return await call_next(request)
# Get the Authorization header
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
# If no auth header, proceed without user context (endpoints will handle auth as needed)
return await call_next(request)
# Extract the token
token = auth_header.replace("Bearer ", "")
try:
# Verify token and get payload
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload.get("sub")
# Attach user ID to request state for use in endpoint handlers
request.state.user_id = user_id
except JWTError:
# If token is invalid, proceed without user context
pass
# Continue processing the request
return await call_next(request)
def _should_skip_auth(self, path: str) -> bool:
"""Paths that don't require authentication check."""
skip_paths = [
"/docs",
"/redoc",
"/openapi.json",
f"{settings.API_V1_STR}/auth/login",
f"{settings.API_V1_STR}/auth/register",
"/health",
]
return any(path.startswith(skip_path) for skip_path in skip_paths)

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

@ -0,0 +1,3 @@
from app.models.user import User
__all__ = ["User"]

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

@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.db.database 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)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

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

@ -0,0 +1,3 @@
from app.schemas.user import UserBase, UserCreate, UserLogin, UserUpdate, User, Token, TokenData
__all__ = ["UserBase", "UserCreate", "UserLogin", "UserUpdate", "User", "Token", "TokenData"]

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

@ -0,0 +1,34 @@
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
email: EmailStr
username: str = Field(..., min_length=3, max_length=50)
class UserCreate(UserBase):
password: str = Field(..., min_length=8)
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = Field(None, min_length=3, max_length=50)
class User(UserBase):
id: int
is_active: bool
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
user_id: Optional[int] = None

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

@ -0,0 +1,3 @@
from app.services.auth_service import auth_service
__all__ = ["auth_service"]

View File

@ -0,0 +1,79 @@
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from datetime import timedelta
from jose import JWTError, jwt
from fastapi.security import OAuth2PasswordBearer
from app.db.database import get_db
from app.db.repositories import user_repository
from app.models.user import User
from app.schemas.user import UserCreate, UserLogin, TokenData
from app.core.security import verify_password, get_password_hash, create_access_token, ALGORITHM
from app.core.config import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
class AuthService:
def authenticate_user(self, db: Session, email: str, password: str) -> Optional[User]:
user = user_repository.get_by_email(db, email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def register_user(self, db: Session, user_in: UserCreate) -> User:
# Check if email already exists
db_user = user_repository.get_by_email(db, user_in.email)
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Check if username already exists
db_user = user_repository.get_by_username(db, user_in.username)
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
# Create new user
hashed_password = get_password_hash(user_in.password)
user_data = user_in.model_dump(exclude={"password"})
user_data["hashed_password"] = hashed_password
return user_repository.create(db, user_data)
def create_token(self, user: User) -> dict:
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"}
def get_current_user(self, db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)) -> User:
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=[ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
token_data = TokenData(user_id=int(user_id))
except JWTError:
raise credentials_exception
user = user_repository.get_by_id(db, token_data.user_id)
if user is None:
raise credentials_exception
return user
auth_service = AuthService()

31
main.py Normal file
View File

@ -0,0 +1,31 @@
from fastapi import FastAPI
from app.api.v1.routes import router as v1_router
from app.middlewares.auth import AuthMiddleware
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(
title="User Authentication Service",
description="A service for user authentication with JWT tokens",
version="0.1.0",
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify the allowed origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API routes
app.include_router(v1_router, prefix="/api/v1")
# Health check endpoint
@app.get("/health", tags=["Health"])
async def health_check():
return {"status": "healthy"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

13
requirements.txt Normal file
View File

@ -0,0 +1,13 @@
fastapi==0.103.1
uvicorn==0.23.2
sqlalchemy==2.0.20
pydantic==2.3.0
pydantic-settings==2.0.3
python-jose==3.3.0
passlib==1.7.4
bcrypt==4.0.1
python-multipart==0.0.6
email-validator==2.0.0
alembic==1.12.0
python-dotenv==1.0.0
pathlib==1.0.1