Skip to content

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.py clean — 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

  1. Try signing up more than 3 times in a minute → get rate limit error.
  2. Try logging in more than 5 times in a minute → get rate limit error.
  3. Regular users → only see their own tasks.
  4. Admin users → access /admin/users to 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

  1. Add a PATCH /users/{id}/role endpoint (admin-only) to promote/demote users.
  2. Add a GET /auth/me endpoint that returns the current user’s profile.
  3. Add rate limiting to /auth/me (e.g. 20 requests/minute) to prevent abuse.