Implement FastAPI user authentication service with MongoDB

- Set up FastAPI application with MongoDB Motor driver
- Implemented user registration, login, and logout with HTTP-only cookies
- Added JWT token authentication and password hashing
- Created user management endpoints for username updates and password changes
- Structured application with proper separation of concerns (models, schemas, services, routes)
- Added CORS configuration and health endpoints
- Documented API endpoints and environment variables in README
This commit is contained in:
Automated Action 2025-06-20 14:19:13 +00:00
parent ea5ed1570b
commit 50b3fc513b
18 changed files with 537 additions and 2 deletions

View File

@ -1,3 +1,92 @@
# FastAPI Application
# User Authentication Service
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A FastAPI application with MongoDB using Motor for user authentication with HTTP-only cookies and CRUD operations.
## Features
- User registration and login with email/password
- HTTP-only cookie authentication
- Username updates
- Password changes
- MongoDB with Motor async driver
- JWT token-based session management
## Environment Variables
Create a `.env` file in the root directory with the following variables:
```bash
MONGODB_URL=mongodb://localhost:27017
SECRET_KEY=your-secret-key-here-change-in-production
```
## Installation
1. Install dependencies:
```bash
pip install -r requirements.txt
```
2. Start MongoDB (make sure MongoDB is running on your system)
3. Copy environment variables:
```bash
cp .env.example .env
```
4. Update the `.env` file with your actual values
## Running the Application
```bash
uvicorn main:app --reload
```
The application will be available at:
- API: http://localhost:8000
- Documentation: http://localhost:8000/docs
- Health check: http://localhost:8000/health
## API Endpoints
### Authentication
- `POST /api/auth/register` - Register a new user
- `POST /api/auth/login` - Login user (sets HTTP-only cookie)
- `POST /api/auth/logout` - Logout user (clears cookie)
- `GET /api/auth/me` - Get current user info
### User Management
- `PUT /api/users/username` - Update username
- `PUT /api/users/password` - Change password
## Project Structure
```
.
├── app/
│ ├── db/
│ │ ├── __init__.py
│ │ └── connection.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ └── users.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── services/
│ │ ├── __init__.py
│ │ └── user_service.py
│ ├── utils/
│ │ ├── __init__.py
│ │ ├── dependencies.py
│ │ └── security.py
│ └── __init__.py
├── main.py
├── requirements.txt
├── .env.example
└── README.md
```

0
app/__init__.py Normal file
View File

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

19
app/db/connection.py Normal file
View File

@ -0,0 +1,19 @@
import os
from motor.motor_asyncio import AsyncIOMotorClient
from typing import Optional
class Database:
client: Optional[AsyncIOMotorClient] = None
db = Database()
async def get_database():
return db.client.user_auth_db
async def connect_to_mongo():
mongodb_url = os.getenv("MONGODB_URL", "mongodb://localhost:27017")
db.client = AsyncIOMotorClient(mongodb_url)
async def close_mongo_connection():
if db.client:
db.client.close()

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

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

@ -0,0 +1,36 @@
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
from bson import ObjectId
class PyObjectId(ObjectId):
@classmethod
def __get_validators__(cls):
yield cls.validate
@classmethod
def validate(cls, v):
if not ObjectId.is_valid(v):
raise ValueError("Invalid objectid")
return ObjectId(v)
@classmethod
def __modify_schema__(cls, field_schema):
field_schema.update(type="string")
class User(BaseModel):
id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
email: EmailStr
username: str
hashed_password: str
is_active: bool = True
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class Config:
allow_population_by_field_name = True
arbitrary_types_allowed = True
json_encoders = {ObjectId: str}
class UserInDB(User):
pass

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

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

@ -0,0 +1,70 @@
from fastapi import APIRouter, HTTPException, status, Response, Depends
from fastapi.responses import JSONResponse
from datetime import timedelta
from app.schemas.user import UserCreate, UserLogin, UserResponse, Message
from app.services.user_service import user_service
from app.utils.security import create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
from app.utils.dependencies import get_current_active_user
from app.models.user import UserInDB
router = APIRouter()
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(user_data: UserCreate):
user = await user_service.create_user(user_data)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email or username already registered"
)
return UserResponse(
id=str(user.id),
email=user.email,
username=user.username,
is_active=user.is_active,
created_at=user.created_at,
updated_at=user.updated_at
)
@router.post("/login", response_model=Message)
async def login(user_credentials: UserLogin, response: Response):
user = await user_service.authenticate_user(user_credentials.email, user_credentials.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires
)
response.set_cookie(
key="access_token",
value=access_token,
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True,
secure=False, # Set to True in production with HTTPS
samesite="lax"
)
return {"message": "Login successful"}
@router.post("/logout", response_model=Message)
async def logout(response: Response, current_user: UserInDB = Depends(get_current_active_user)):
response.delete_cookie(key="access_token")
return {"message": "Logout successful"}
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: UserInDB = Depends(get_current_active_user)):
return UserResponse(
id=str(current_user.id),
email=current_user.email,
username=current_user.username,
is_active=current_user.is_active,
created_at=current_user.created_at,
updated_at=current_user.updated_at
)

54
app/routes/users.py Normal file
View File

@ -0,0 +1,54 @@
from fastapi import APIRouter, HTTPException, status, Depends
from app.schemas.user import UserUpdate, PasswordChange, UserResponse, Message
from app.services.user_service import user_service
from app.utils.dependencies import get_current_active_user
from app.models.user import UserInDB
router = APIRouter()
@router.put("/username", response_model=UserResponse)
async def update_username(
user_update: UserUpdate,
current_user: UserInDB = Depends(get_current_active_user)
):
if not user_update.username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username is required"
)
updated_user = await user_service.update_username(str(current_user.id), user_update.username)
if not updated_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
return UserResponse(
id=str(updated_user.id),
email=updated_user.email,
username=updated_user.username,
is_active=updated_user.is_active,
created_at=updated_user.created_at,
updated_at=updated_user.updated_at
)
@router.put("/password", response_model=Message)
async def change_password(
password_change: PasswordChange,
current_user: UserInDB = Depends(get_current_active_user)
):
success = await user_service.change_password(
str(current_user.id),
password_change.current_password,
password_change.new_password
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect"
)
return {"message": "Password updated successfully"}

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

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

@ -0,0 +1,30 @@
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
class UserCreate(BaseModel):
email: EmailStr
username: str
password: str = Field(..., min_length=8)
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: str
email: EmailStr
username: str
is_active: bool
created_at: datetime
updated_at: datetime
class UserUpdate(BaseModel):
username: Optional[str] = None
class PasswordChange(BaseModel):
current_password: str
new_password: str = Field(..., min_length=8)
class Message(BaseModel):
message: str

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

View File

@ -0,0 +1,104 @@
from typing import Optional
from datetime import datetime
from bson import ObjectId
from pymongo.errors import DuplicateKeyError
from app.db.connection import get_database
from app.models.user import User, UserInDB
from app.schemas.user import UserCreate, UserUpdate
from app.utils.security import get_password_hash, verify_password
class UserService:
def __init__(self):
self.collection_name = "users"
async def get_collection(self):
db = await get_database()
return db[self.collection_name]
async def create_user(self, user_data: UserCreate) -> Optional[UserInDB]:
try:
collection = await self.get_collection()
existing_user = await collection.find_one({"email": user_data.email})
if existing_user:
return None
existing_username = await collection.find_one({"username": user_data.username})
if existing_username:
return None
hashed_password = get_password_hash(user_data.password)
user_dict = {
"email": user_data.email,
"username": user_data.username,
"hashed_password": hashed_password,
"is_active": True,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
result = await collection.insert_one(user_dict)
user_dict["_id"] = result.inserted_id
return UserInDB(**user_dict)
except DuplicateKeyError:
return None
async def get_user_by_email(self, email: str) -> Optional[UserInDB]:
collection = await self.get_collection()
user_data = await collection.find_one({"email": email})
if user_data:
return UserInDB(**user_data)
return None
async def get_user_by_id(self, user_id: str) -> Optional[UserInDB]:
collection = await self.get_collection()
user_data = await collection.find_one({"_id": ObjectId(user_id)})
if user_data:
return UserInDB(**user_data)
return None
async def authenticate_user(self, email: str, password: str) -> Optional[UserInDB]:
user = await self.get_user_by_email(email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
async def update_username(self, user_id: str, new_username: str) -> Optional[UserInDB]:
collection = await self.get_collection()
existing_username = await collection.find_one({"username": new_username, "_id": {"$ne": ObjectId(user_id)}})
if existing_username:
return None
result = await collection.update_one(
{"_id": ObjectId(user_id)},
{"$set": {"username": new_username, "updated_at": datetime.utcnow()}}
)
if result.modified_count:
return await self.get_user_by_id(user_id)
return None
async def change_password(self, user_id: str, current_password: str, new_password: str) -> bool:
user = await self.get_user_by_id(user_id)
if not user:
return False
if not verify_password(current_password, user.hashed_password):
return False
collection = await self.get_collection()
new_hashed_password = get_password_hash(new_password)
result = await collection.update_one(
{"_id": ObjectId(user_id)},
{"$set": {"hashed_password": new_hashed_password, "updated_at": datetime.utcnow()}}
)
return result.modified_count > 0
user_service = UserService()

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

39
app/utils/dependencies.py Normal file
View File

@ -0,0 +1,39 @@
from fastapi import Depends, HTTPException, status, Request
from typing import Optional
from app.services.user_service import user_service
from app.utils.security import verify_token
from app.models.user import UserInDB
async def get_current_user(request: Request) -> UserInDB:
token = request.cookies.get("access_token")
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated"
)
email = verify_token(token)
if email is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
user = await user_service.get_user_by_email(email)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
return user
async def get_current_active_user(current_user: UserInDB = Depends(get_current_user)) -> UserInDB:
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user

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

@ -0,0 +1,38 @@
import os
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
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)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_token(token: str) -> Optional[str]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
return None
return email
except JWTError:
return None

47
main.py Normal file
View File

@ -0,0 +1,47 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import os
from dotenv import load_dotenv
from app.routes import auth, users
from app.db.connection import connect_to_mongo, close_mongo_connection
load_dotenv()
app = FastAPI(
title="User Authentication Service",
description="A simple CRUD app with user authentication using HTTP-only cookies",
version="1.0.0"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/auth", tags=["authentication"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
@app.on_event("startup")
async def startup_db_client():
await connect_to_mongo()
@app.on_event("shutdown")
async def shutdown_db_client():
await close_mongo_connection()
@app.get("/")
async def root():
return {
"title": "User Authentication Service",
"documentation": "/docs",
"health": "/health"
}
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "User Authentication Service"}

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
fastapi==0.104.1
uvicorn==0.24.0
motor==3.3.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
email-validator==2.1.0
pydantic[email]==2.5.0
python-dotenv==1.0.0