Skip to content

Commit f679975

Browse files
baijumclaude
andcommitted
feat: add auth infrastructure, DB sessions, and S3 helpers
Backport battle-tested patterns from WIT into the app template so new apps start with JWT auth (register/login/me), User model, database session management, S3 storage helpers, CORS middleware, and a proper test infrastructure with SQLite fixtures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 07df8ae commit f679975

15 files changed

Lines changed: 410 additions & 5 deletions

File tree

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ A template repository for bootstrapping new applications on the [Towlion platfor
1414
app/ # FastAPI backend
1515
main.py # Application entry point
1616
Dockerfile # Backend container image
17-
models.py # SQLAlchemy models
17+
models.py # SQLAlchemy models (User)
18+
database.py # DB session management
19+
auth.py # JWT + bcrypt helpers
20+
deps.py # FastAPI dependencies (get_current_user)
21+
schemas.py # Pydantic request/response models
22+
storage.py # S3/MinIO file helpers
23+
routers/
24+
auth.py # Register, login, profile endpoints
1825
alembic/ # Database migrations
1926
deploy/
2027
docker-compose.yml # App containers (multi-app mode)
@@ -39,6 +46,35 @@ uvicorn app.main:app --reload --port 8000
3946
curl http://localhost:8000/health
4047
```
4148

49+
## Authentication
50+
51+
The template includes JWT-based authentication out of the box:
52+
53+
| Endpoint | Method | Description |
54+
|---|---|---|
55+
| `/api/auth/register` | POST | Create account (email, display_name, password) |
56+
| `/api/auth/login` | POST | Get token (email, password) |
57+
| `/api/auth/me` | GET | Current user profile (requires token) |
58+
59+
Use the `Authorization: Bearer <token>` header for authenticated requests.
60+
61+
To protect your own endpoints, add the `get_current_user` dependency:
62+
63+
```python
64+
from fastapi import Depends
65+
from app.deps import get_current_user
66+
from app.models import User
67+
68+
@app.get("/api/my-endpoint")
69+
def my_endpoint(user: User = Depends(get_current_user)):
70+
return {"user_id": user.id}
71+
```
72+
73+
**Environment variables:**
74+
- `JWT_SECRET` — Secret key for signing tokens (required in production)
75+
- `JWT_EXPIRY_HOURS` — Token lifetime, default `72`
76+
- `CORS_ORIGINS` — Comma-separated allowed origins, default `http://localhost:3000`
77+
4278
## Environment Variables
4379

4480
Copy `deploy/env.template` to `deploy/.env` and fill in your values. See the [platform spec](https://github.com/towlion/platform/blob/main/docs/spec.md) for details on required and optional variables.

app/alembic/versions/0001_users.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""create users table
2+
3+
Revision ID: 0001
4+
Revises:
5+
Create Date: 2026-03-17
6+
"""
7+
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
revision = "0001"
12+
down_revision = None
13+
branch_labels = None
14+
depends_on = None
15+
16+
17+
def upgrade():
18+
op.create_table(
19+
"users",
20+
sa.Column("id", sa.Integer(), primary_key=True),
21+
sa.Column("email", sa.String(255), unique=True, nullable=False),
22+
sa.Column("display_name", sa.String(255), nullable=False),
23+
sa.Column("password_hash", sa.String(255), nullable=False),
24+
sa.Column(
25+
"created_at",
26+
sa.DateTime(timezone=True),
27+
server_default=sa.func.now(),
28+
),
29+
)
30+
31+
32+
def downgrade():
33+
op.drop_table("users")

app/auth.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import os
2+
from datetime import datetime, timedelta, timezone
3+
4+
import bcrypt
5+
import jwt
6+
7+
JWT_SECRET = os.getenv("JWT_SECRET", "dev-secret-change-me")
8+
JWT_ALGORITHM = "HS256"
9+
JWT_EXPIRY_HOURS = int(os.getenv("JWT_EXPIRY_HOURS", "72"))
10+
11+
12+
def hash_password(password: str) -> str:
13+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
14+
15+
16+
def verify_password(plain: str, hashed: str) -> bool:
17+
return bcrypt.checkpw(plain.encode(), hashed.encode())
18+
19+
20+
def create_token(user_id: int) -> str:
21+
payload = {
22+
"sub": str(user_id),
23+
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRY_HOURS),
24+
}
25+
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
26+
27+
28+
def decode_token(token: str) -> int | None:
29+
try:
30+
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
31+
return int(payload["sub"])
32+
except (jwt.PyJWTError, KeyError, ValueError):
33+
return None

app/database.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import os
2+
from collections.abc import Generator
3+
4+
from sqlalchemy import create_engine
5+
from sqlalchemy.orm import Session, sessionmaker
6+
7+
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://localhost:5432/myapp")
8+
9+
engine = create_engine(DATABASE_URL)
10+
SessionLocal = sessionmaker(bind=engine)
11+
12+
13+
def get_db() -> Generator[Session, None, None]:
14+
db = SessionLocal()
15+
try:
16+
yield db
17+
finally:
18+
db.close()

app/deps.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from fastapi import Depends, HTTPException, status
2+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3+
from sqlalchemy.orm import Session
4+
5+
from app.auth import decode_token
6+
from app.database import get_db
7+
from app.models import User
8+
9+
security = HTTPBearer()
10+
11+
12+
def get_current_user(
13+
credentials: HTTPAuthorizationCredentials = Depends(security),
14+
db: Session = Depends(get_db),
15+
) -> User:
16+
user_id = decode_token(credentials.credentials)
17+
if user_id is None:
18+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
19+
user = db.get(User, user_id)
20+
if user is None:
21+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
22+
return user

app/main.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import logging
2+
import os
23
import sys
34
import time
45

56
from fastapi import FastAPI, Request
7+
from fastapi.middleware.cors import CORSMiddleware
68
from fastapi.responses import JSONResponse
79
from pythonjsonlogger import jsonlogger
810
from slowapi import Limiter, _rate_limit_exceeded_handler
911
from slowapi.errors import RateLimitExceeded
1012
from slowapi.util import get_remote_address
1113

14+
from app.routers import auth
15+
1216
# Configure structured JSON logging
1317
handler = logging.StreamHandler(sys.stdout)
1418
handler.setFormatter(
@@ -27,6 +31,19 @@
2731
app.state.limiter = limiter
2832
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
2933

34+
# CORS
35+
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
36+
app.add_middleware(
37+
CORSMiddleware,
38+
allow_origins=cors_origins,
39+
allow_credentials=True,
40+
allow_methods=["*"],
41+
allow_headers=["*"],
42+
)
43+
44+
# Routers
45+
app.include_router(auth.router, prefix="/api")
46+
3047

3148
@app.middleware("http")
3249
async def log_requests(request: Request, call_next):

app/models.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1-
from sqlalchemy.orm import DeclarativeBase
1+
import datetime
2+
3+
from sqlalchemy import DateTime, String, func
4+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
25

36

47
class Base(DeclarativeBase):
58
pass
69

710

8-
# Add your SQLAlchemy models here
11+
class User(Base):
12+
__tablename__ = "users"
13+
14+
id: Mapped[int] = mapped_column(primary_key=True)
15+
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
16+
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
17+
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
18+
created_at: Mapped[datetime.datetime] = mapped_column(
19+
DateTime(timezone=True), server_default=func.now()
20+
)

app/routers/auth.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from fastapi import APIRouter, Depends, HTTPException, status
2+
from sqlalchemy.orm import Session
3+
4+
from app.auth import create_token, hash_password, verify_password
5+
from app.database import get_db
6+
from app.deps import get_current_user
7+
from app.models import User
8+
from app.schemas import LoginRequest, RegisterRequest, TokenResponse, UserResponse
9+
10+
router = APIRouter(prefix="/auth", tags=["auth"])
11+
12+
13+
@router.post("/register", response_model=TokenResponse, status_code=201)
14+
def register(body: RegisterRequest, db: Session = Depends(get_db)):
15+
existing = db.query(User).filter_by(email=body.email).first()
16+
if existing:
17+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
18+
user = User(
19+
email=body.email,
20+
display_name=body.display_name,
21+
password_hash=hash_password(body.password),
22+
)
23+
db.add(user)
24+
db.commit()
25+
db.refresh(user)
26+
return TokenResponse(access_token=create_token(user.id))
27+
28+
29+
@router.post("/login", response_model=TokenResponse)
30+
def login(body: LoginRequest, db: Session = Depends(get_db)):
31+
user = db.query(User).filter_by(email=body.email).first()
32+
if not user or not verify_password(body.password, user.password_hash):
33+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
34+
return TokenResponse(access_token=create_token(user.id))
35+
36+
37+
@router.get("/me", response_model=UserResponse)
38+
def me(user: User = Depends(get_current_user)):
39+
return user

app/schemas.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from datetime import datetime
2+
3+
from pydantic import BaseModel, EmailStr, Field
4+
5+
6+
class RegisterRequest(BaseModel):
7+
email: EmailStr = Field(examples=["user@example.com"])
8+
display_name: str = Field(examples=["Jane Doe"])
9+
password: str = Field(min_length=8)
10+
11+
12+
class LoginRequest(BaseModel):
13+
email: EmailStr = Field(examples=["user@example.com"])
14+
password: str
15+
16+
17+
class TokenResponse(BaseModel):
18+
access_token: str
19+
token_type: str = "bearer"
20+
21+
22+
class UserResponse(BaseModel):
23+
id: int
24+
email: str
25+
display_name: str
26+
created_at: datetime
27+
28+
model_config = {"from_attributes": True}

0 commit comments

Comments
 (0)