diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..18f12fa --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DATABASE_URL="postgresql://postgres:postgres@db:5432/gameverse_catalog" + +# JWT Config +JWT_ISSUER="https://auth.local" +JWT_AUDIENCE="internal-apis" +JWT_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA...\n-----END PUBLIC KEY-----" diff --git a/app/api/v1/endpoints/games.py b/app/api/v1/endpoints/games.py index 65d750f..4573eae 100644 --- a/app/api/v1/endpoints/games.py +++ b/app/api/v1/endpoints/games.py @@ -8,6 +8,7 @@ import unicodedata from app.schemas.game import GameCreate, GameUpdate, GameResponse, StandardResponse from app.models.game import Game from app.db.database import get_db +from app.core.security import get_current_user, UserAuth router = APIRouter() @@ -38,7 +39,7 @@ def read_games(skip: int = 0, limit: int = 100, genre: Optional[str] = None, pla return {"success": True, "message": "Lista de jogos", "data": data} @router.post("/", response_model=StandardResponse, status_code=status.HTTP_201_CREATED) -def create_game(game: GameCreate, db: Session = Depends(get_db)): +def create_game(game: GameCreate, db: Session = Depends(get_db), current_user: UserAuth = Depends(get_current_user)): slug = generate_slug(game.title) # Verifica se já existe um jogo com esse slug @@ -71,7 +72,7 @@ def read_game(id_ou_slug: str, db: Session = Depends(get_db)): return {"success": True, "message": "Detalhes do jogo", "data": data} @router.patch("/{id}", response_model=StandardResponse) -def update_game(id: int, game: GameUpdate, db: Session = Depends(get_db)): +def update_game(id: int, game: GameUpdate, db: Session = Depends(get_db), current_user: UserAuth = Depends(get_current_user)): db_game = db.query(Game).filter(Game.id == id).first() if not db_game: @@ -98,7 +99,7 @@ def update_game(id: int, game: GameUpdate, db: Session = Depends(get_db)): return {"success": True, "message": "Jogo atualizado com sucesso", "data": data} @router.delete("/{id_ou_slug}", response_model=StandardResponse) -def delete_game(id_ou_slug: str, db: Session = Depends(get_db)): +def delete_game(id_ou_slug: str, db: Session = Depends(get_db), current_user: UserAuth = Depends(get_current_user)): if id_ou_slug.isdigit(): db_game = db.query(Game).filter(Game.id == int(id_ou_slug)).first() else: diff --git a/app/core/config.py b/app/core/config.py index ed560f8..68d6db0 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -4,6 +4,11 @@ class Settings(BaseSettings): PROJECT_NAME: str = "Microsserviço de Catálogo de Jogos" API_V1_STR: str = "/api/v1" DATABASE_URL: str + + # Configurações de JWT + JWT_ISSUER: str + JWT_AUDIENCE: str + JWT_PUBLIC_KEY_PEM: str class Config: env_file = ".env" diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..4200c8a --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,54 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import jwt +from pydantic import BaseModel +from app.core.config import settings + +security = HTTPBearer() + +class UserAuth(BaseModel): + id: str + token: str + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserAuth: + token = credentials.credentials + + try: + # A chave pública pode vir com '\n' escapado do .env + public_key = settings.JWT_PUBLIC_KEY_PEM.replace('\\n', '\n') + + payload = jwt.decode( + token, + public_key, + algorithms=["RS256"], + issuer=settings.JWT_ISSUER, + audience=settings.JWT_AUDIENCE + ) + + sub = payload.get("sub") + if not sub or not str(sub).strip(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token claims", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return UserAuth(id=str(sub), token=token) + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token expired", + headers={"WWW-Authenticate": "Bearer"}, + ) + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid access token", + headers={"WWW-Authenticate": "Bearer"}, + ) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/jwtimplementation.md b/jwtimplementation.md new file mode 100644 index 0000000..73e6740 --- /dev/null +++ b/jwtimplementation.md @@ -0,0 +1,140 @@ +# Guia didatico: validar JWT nos servicos consumidores (sem JWKS) + +Este guia mostra a forma mais simples de integrar os servicos: + +1. Auth assina o token com `JWT_PRIVATE_KEY_PEM` +2. cada servico consumidor valida localmente com `JWT_PUBLIC_KEY_PEM` + +## 1) Configuracao em cada servico consumidor + +Cada servico precisa ter no `.env`: + +```env +JWT_ISSUER="https://auth.local" +JWT_AUDIENCE="internal-apis" +JWT_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" +``` + +Importante: + +- o `JWT_PUBLIC_KEY_PEM` deve ser exatamente a chave publica do Auth +- mantenha os `\n` literais no `.env` + +## 2) O que validar + +Para toda rota protegida: + +1. ler `Authorization` +2. extrair `Bearer ` +3. validar assinatura RS256 com `JWT_PUBLIC_KEY_PEM` +4. validar `iss`, `aud` e `exp` +5. validar se `sub` existe +6. montar `request.auth = { id: sub, token }` + +Se algo falhar, retornar `401`. + +## 3) Exemplo pratico (Node/Express + jose) + +```ts +import express from "express"; +import { importSPKI, jwtVerify } from "jose"; + +const app = express(); + +const ISSUER = process.env.JWT_ISSUER!; +const AUDIENCE = process.env.JWT_AUDIENCE!; +const PUBLIC_KEY_PEM = process.env.JWT_PUBLIC_KEY_PEM!.replace(/\\n/g, "\n"); + +let publicKeyPromise: ReturnType | null = null; + +function getPublicKey() { + if (!publicKeyPromise) { + publicKeyPromise = importSPKI(PUBLIC_KEY_PEM, "RS256"); + } + + return publicKeyPromise; +} + +function authMiddleware() { + return async (req, res, next) => { + try { + const authHeader = req.header("authorization"); + + if (!authHeader) { + return res + .status(401) + .json({ message: "Missing Authorization header" }); + } + + const [scheme, token] = authHeader.split(" "); + + if (!scheme || !token || scheme.toLowerCase() !== "bearer") { + return res + .status(401) + .json({ message: "Authorization must be Bearer token" }); + } + + const publicKey = await getPublicKey(); + + const { payload } = await jwtVerify(token, publicKey, { + issuer: ISSUER, + audience: AUDIENCE, + algorithms: ["RS256"], + }); + + if (typeof payload.sub !== "string" || !payload.sub.trim()) { + return res.status(401).json({ message: "Invalid token claims" }); + } + + req.auth = { + id: payload.sub, + token, + }; + + return next(); + } catch { + return res + .status(401) + .json({ message: "Invalid or expired access token" }); + } + }; +} + +app.get("/profile/me", authMiddleware(), (req, res) => { + return res.json({ userId: req.auth.id }); +}); +``` + +## 4) Propagacao entre servicos (A -> B) + +Quando API A chamar API B em nome do usuario: + +- repasse o mesmo `Authorization: Bearer ` +- API B valida esse token com `JWT_PUBLIC_KEY_PEM` +- API B usa `sub` como identidade confiavel + +```ts +const authHeader = req.header("authorization"); + +const response = await fetch("http://courses-service/courses/me", { + headers: { Authorization: authHeader! }, +}); +``` + +## 5) Checklist rapido + +- todos os servicos com o mesmo `JWT_PUBLIC_KEY_PEM` +- middleware valida assinatura + `iss` + `aud` + `exp` +- `sub` obrigatorio +- retorna `401` para token ausente/invalido/expirado +- usa `sub` como ID confiavel + +## 6) Trade-off dessa abordagem + +Vantagem: + +- muito simples para implementar e explicar + +Desvantagem: + +- quando trocar a chave publica, precisa atualizar o `.env` de todos os servicos consumidores diff --git a/requirements.txt b/requirements.txt index 06e3828..df64dd5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ sqlalchemy>=2.0.25 psycopg2-binary>=2.9.9 alembic>=1.13.1 pytest>=8.0.0 +PyJWT>=2.8.0 +cryptography>=42.0.0