From 50b3fc513b3bde5d2bae128e541f0d638a7dab04 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Fri, 20 Jun 2025 14:19:13 +0000 Subject: [PATCH] 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 --- README.md | 93 ++++++++++++++++++++++++++++++- app/__init__.py | 0 app/db/__init__.py | 0 app/db/connection.py | 19 +++++++ app/models/__init__.py | 0 app/models/user.py | 36 ++++++++++++ app/routes/__init__.py | 0 app/routes/auth.py | 70 +++++++++++++++++++++++ app/routes/users.py | 54 ++++++++++++++++++ app/schemas/__init__.py | 0 app/schemas/user.py | 30 ++++++++++ app/services/__init__.py | 0 app/services/user_service.py | 104 +++++++++++++++++++++++++++++++++++ app/utils/__init__.py | 0 app/utils/dependencies.py | 39 +++++++++++++ app/utils/security.py | 38 +++++++++++++ main.py | 47 ++++++++++++++++ requirements.txt | 9 +++ 18 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 app/__init__.py create mode 100644 app/db/__init__.py create mode 100644 app/db/connection.py create mode 100644 app/models/__init__.py create mode 100644 app/models/user.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/users.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/user.py create mode 100644 app/services/__init__.py create mode 100644 app/services/user_service.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/dependencies.py create mode 100644 app/utils/security.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..0854c10 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/connection.py b/app/db/connection.py new file mode 100644 index 0000000..22b103b --- /dev/null +++ b/app/db/connection.py @@ -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() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..d0ed5fb --- /dev/null +++ b/app/models/user.py @@ -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 \ No newline at end of file diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..7b2a7de --- /dev/null +++ b/app/routes/auth.py @@ -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 + ) \ No newline at end of file diff --git a/app/routes/users.py b/app/routes/users.py new file mode 100644 index 0000000..cd09e01 --- /dev/null +++ b/app/routes/users.py @@ -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"} \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..3fb2203 --- /dev/null +++ b/app/schemas/user.py @@ -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 \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..9b8888c --- /dev/null +++ b/app/services/user_service.py @@ -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() \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/dependencies.py b/app/utils/dependencies.py new file mode 100644 index 0000000..ed3b6d5 --- /dev/null +++ b/app/utils/dependencies.py @@ -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 \ No newline at end of file diff --git a/app/utils/security.py b/app/utils/security.py new file mode 100644 index 0000000..98d5e25 --- /dev/null +++ b/app/utils/security.py @@ -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 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..857a851 --- /dev/null +++ b/main.py @@ -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"} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..19bddc0 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file