Building Your First API — Task Manager (Part 3)¶
🎯 What You’ll Learn¶
- How to extend your Task Manager with user accounts
- How to enforce per-user task ownership using JWT
- How to create admin-only endpoints with role checks
- How to apply rate limiting to signup/login endpoints to prevent brute-force attacks
🧱 Step 1: User Model¶
📄 models/user.py
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
email: str
hashed_password: str
is_active: bool = True
role: str = "user"
tasks: List["Task"] = Relationship(back_populates="user")
🧱 Step 2: Task Model with Ownership¶
📄 models/task.py
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional
from models.user import User
class Task(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
title: str
description: Optional[str] = None
completed: bool = False
user_id: Optional[int] = Field(default=None, foreign_key="user.id")
user: Optional[User] = Relationship(back_populates="tasks")
🔐 Step 3: Current User Dependency¶
📄 core/dependencies.py
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from sqlmodel import Session, select
from db import get_session
from core.jwt import decode_access_token
from models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def get_current_user(token: str = Depends(oauth2_scheme), session: Session = Depends(get_session)) -> User:
payload = decode_access_token(token)
if not payload or "sub" not in payload:
raise HTTPException(status_code=401, detail="Invalid token")
user = session.exec(select(User).where(User.username == payload["sub"])).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
def require_admin(user: User = Depends(get_current_user)):
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return user
⚡ Step 4: Rate Limiting in routers/auth.py¶
Instead of configuring rate limiting globally in main.py, we apply it directly to signup and login routes.
📄 routers/auth.py
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
from db import get_session
from models.user import User
from core.security import hash_password, verify_password
from core.jwt import create_access_token
# SlowAPI imports
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
router = APIRouter(prefix="/auth", tags=["Auth"])
# Create limiter instance for this router
limiter = Limiter(key_func=get_remote_address)
router.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@router.post("/signup", status_code=201)
@limiter.limit("3/minute") # limit signup attempts per IP
def signup(user: User, session: Session = Depends(get_session)):
existing = session.exec(select(User).where(User.username == user.username)).first()
if existing:
raise HTTPException(status_code=400, detail="Username already exists")
user.hashed_password = hash_password(user.hashed_password)
session.add(user)
session.commit()
session.refresh(user)
return {"message": "User created", "user_id": user.id}
@router.post("/login")
@limiter.limit("5/minute") # limit login attempts per IP
def login(form_data: OAuth2PasswordRequestForm = Depends(), session: Session = Depends(get_session)):
user = session.exec(select(User).where(User.username == form_data.username)).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token({"sub": user.username})
return {"access_token": token, "token_type": "bearer"}
🔍 Why this is better¶
- Rate limiting is scoped to auth routes (not global).
- Prevents brute-force attacks on login/signup.
- Keeps
main.pyclean — only routers handle their own limits.
🛡️ Step 5: Secure Task Endpoints¶
📄 routers/tasks.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from db import get_session
from models.task import Task
from models.user import User
from core.dependencies import get_current_user
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.post("/", status_code=201)
def create_task(task: Task, session: Session = Depends(get_session), user: User = Depends(get_current_user)):
task.user_id = user.id
session.add(task)
session.commit()
session.refresh(task)
return task
@router.get("/")
def get_tasks(session: Session = Depends(get_session), user: User = Depends(get_current_user)):
return session.exec(select(Task).where(Task.user_id == user.id)).all()
🛡️ Step 6: Admin-Only Endpoints¶
📄 routers/admin.py
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from db import get_session
from models.user import User
from core.dependencies import require_admin
router = APIRouter(prefix="/admin", tags=["Admin"])
@router.get("/users", dependencies=[Depends(require_admin)])
def list_users(session: Session = Depends(get_session)):
return session.exec(select(User)).all()
🧪 Try It in Swagger UI¶
- Try signing up more than 3 times in a minute → get rate limit error.
- Try logging in more than 5 times in a minute → get rate limit error.
- Regular users → only see their own tasks.
- Admin users → access
/admin/usersto see all users.
🧠 Recap¶
You now have a secure Task Manager API with:
| Feature | Implementation |
|---|---|
| User accounts | User model with roles |
| Task ownership | user_id foreign key in Task |
| Per-user access control | get_current_user dependency + filters |
| Admin-only endpoints | require_admin dependency |
| Rate limiting | Applied directly in routers/auth.py |
🧪 Practice Challenge¶
- Add a
PATCH /users/{id}/roleendpoint (admin-only) to promote/demote users. - Add a
GET /auth/meendpoint that returns the current user’s profile. - Add rate limiting to
/auth/me(e.g. 20 requests/minute) to prevent abuse.