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:
parent
ea5ed1570b
commit
50b3fc513b
93
README.md
93
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
|
||||
```
|
||||
|
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/db/__init__.py
Normal file
0
app/db/__init__.py
Normal file
19
app/db/connection.py
Normal file
19
app/db/connection.py
Normal 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
0
app/models/__init__.py
Normal file
36
app/models/user.py
Normal file
36
app/models/user.py
Normal 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
0
app/routes/__init__.py
Normal file
70
app/routes/auth.py
Normal file
70
app/routes/auth.py
Normal 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
54
app/routes/users.py
Normal 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
0
app/schemas/__init__.py
Normal file
30
app/schemas/user.py
Normal file
30
app/schemas/user.py
Normal 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
0
app/services/__init__.py
Normal file
104
app/services/user_service.py
Normal file
104
app/services/user_service.py
Normal 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
0
app/utils/__init__.py
Normal file
39
app/utils/dependencies.py
Normal file
39
app/utils/dependencies.py
Normal 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
38
app/utils/security.py
Normal 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
47
main.py
Normal 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
9
requirements.txt
Normal 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
|
Loading…
x
Reference in New Issue
Block a user