feat: protecao CSRF (double-submit cookie) e paginacao da lista de clientes
CSRF: novo app/csrf.py + middleware em app/main.py garantem um cookie csrf_token por navegador; todo formulario que muda estado (criar/editar/ excluir cliente, adicionar/editar/excluir contato) embute esse valor como campo oculto, e Depends(exigir_csrf) confere no POST que o valor enviado bate com o cookie antes de deixar a rota executar. Verificado ponta a ponta com o app real rodando (curl): token errado -> 403, token correto -> 303. Paginacao: db_clientes.listar_clientes ganha offset, nova contar_clientes(); GET /clientes aceita ?pagina=N (100 clientes por pagina), template mostra Anterior/Proxima e o total. 53/53 testes passando (8 novos).
This commit is contained in:
+44
-12
@@ -1,8 +1,9 @@
|
||||
# app/routers/clientes.py
|
||||
"""
|
||||
Rotas de gestão de CLIENTE: listar/buscar, criar, editar, ver detalhe
|
||||
(contatos + status de Clientes_Chaves), excluir.
|
||||
Rotas de gestão de CLIENTE: listar/buscar (paginado), criar, editar, ver
|
||||
detalhe (contatos + status de Clientes_Chaves), excluir.
|
||||
"""
|
||||
import math
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
@@ -12,6 +13,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from ..auth import exigir_login
|
||||
from ..csrf import exigir_csrf, token_do_contexto
|
||||
from ..db import chaves as db_chaves
|
||||
from ..db import clientes as db_clientes
|
||||
from ..db import contatos as db_contatos
|
||||
@@ -20,6 +22,8 @@ from ..normalize import normalizar_documento, normalizar_telefone, validar_dv_cp
|
||||
router = APIRouter(prefix="/clientes")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
TAMANHO_PAGINA = 100
|
||||
|
||||
|
||||
def _redirect_com_mensagem(url: str, mensagem: str, tipo: str = "success") -> RedirectResponse:
|
||||
separador = "&" if "?" in url else "?"
|
||||
@@ -48,15 +52,25 @@ def _normalizar_documento_e_telefones(dados: dict) -> None:
|
||||
def listar(
|
||||
request: Request,
|
||||
busca: Optional[str] = None,
|
||||
pagina: int = 1,
|
||||
mensagem: Optional[str] = None,
|
||||
mensagem_tipo: Optional[str] = None,
|
||||
usuario: str = Depends(exigir_login),
|
||||
):
|
||||
clientes = db_clientes.listar_clientes(busca=busca or None)
|
||||
pagina = max(pagina, 1)
|
||||
total = db_clientes.contar_clientes(busca=busca or None)
|
||||
total_paginas = max(math.ceil(total / TAMANHO_PAGINA), 1)
|
||||
pagina = min(pagina, total_paginas)
|
||||
offset = (pagina - 1) * TAMANHO_PAGINA
|
||||
|
||||
clientes = db_clientes.listar_clientes(busca=busca or None, limite=TAMANHO_PAGINA, offset=offset)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"clientes_lista.html",
|
||||
{"clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo},
|
||||
{
|
||||
"clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo,
|
||||
"pagina": pagina, "total_paginas": total_paginas, "total": total,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -65,11 +79,14 @@ def form_novo(request: Request, usuario: str = Depends(exigir_login)):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"cliente_form.html",
|
||||
{"cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE},
|
||||
{
|
||||
"cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE,
|
||||
"csrf_token": token_do_contexto(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/novo")
|
||||
@router.post("/novo", dependencies=[Depends(exigir_csrf)])
|
||||
async def criar(request: Request, usuario: str = Depends(exigir_login)):
|
||||
form = dict((await request.form()))
|
||||
dados = _ler_form_cliente(form)
|
||||
@@ -85,6 +102,7 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)):
|
||||
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
|
||||
"aviso_codigo_duplicado": True,
|
||||
"confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv,
|
||||
"csrf_token": token_do_contexto(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -102,6 +120,7 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)):
|
||||
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
|
||||
"aviso_dv_invalido": True,
|
||||
"confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv,
|
||||
"csrf_token": token_do_contexto(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -111,7 +130,10 @@ async def criar(request: Request, usuario: str = Depends(exigir_login)):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"cliente_form.html",
|
||||
{"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc)},
|
||||
{
|
||||
"cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc),
|
||||
"csrf_token": token_do_contexto(request),
|
||||
},
|
||||
)
|
||||
return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.")
|
||||
|
||||
@@ -136,6 +158,7 @@ def detalhe(
|
||||
{
|
||||
"cliente": cliente, "contatos": contatos, "status_chaves": status,
|
||||
"mensagem": mensagem, "mensagem_tipo": mensagem_tipo,
|
||||
"csrf_token": token_do_contexto(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -148,11 +171,14 @@ def form_editar(request: Request, cliente_id: int, usuario: str = Depends(exigir
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"cliente_form.html",
|
||||
{"cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE},
|
||||
{
|
||||
"cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE,
|
||||
"csrf_token": token_do_contexto(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{cliente_id}/editar")
|
||||
@router.post("/{cliente_id}/editar", dependencies=[Depends(exigir_csrf)])
|
||||
async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)):
|
||||
form = dict((await request.form()))
|
||||
dados = _ler_form_cliente(form)
|
||||
@@ -163,7 +189,10 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"cliente_form.html",
|
||||
{"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True},
|
||||
{
|
||||
"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
|
||||
"aviso_codigo_duplicado": True, "csrf_token": token_do_contexto(request),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -172,12 +201,15 @@ async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigi
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"cliente_form.html",
|
||||
{"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc)},
|
||||
{
|
||||
"cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE,
|
||||
"erro_gravacao": str(exc), "csrf_token": token_do_contexto(request),
|
||||
},
|
||||
)
|
||||
return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.")
|
||||
|
||||
|
||||
@router.post("/{cliente_id}/excluir")
|
||||
@router.post("/{cliente_id}/excluir", dependencies=[Depends(exigir_csrf)])
|
||||
def excluir(cliente_id: int, usuario: str = Depends(exigir_login)):
|
||||
try:
|
||||
db_clientes.excluir_cliente(cliente_id)
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Form
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from ..auth import exigir_login
|
||||
from ..csrf import exigir_csrf
|
||||
from ..db import clientes as db_clientes
|
||||
from ..db import contatos as db_contatos
|
||||
from ..normalize import normalizar_telefone
|
||||
@@ -21,7 +22,7 @@ def _redirect_com_mensagem(cliente_id: int, mensagem: str, tipo: str = "success"
|
||||
return RedirectResponse(f"/clientes/{cliente_id}?{query}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{cliente_id}/contatos")
|
||||
@router.post("/{cliente_id}/contatos", dependencies=[Depends(exigir_csrf)])
|
||||
def adicionar(
|
||||
cliente_id: int,
|
||||
nome: str = Form(""),
|
||||
@@ -43,7 +44,7 @@ def adicionar(
|
||||
return _redirect_com_mensagem(cliente_id, "Contato adicionado.")
|
||||
|
||||
|
||||
@router.post("/{cliente_id}/contatos/{contato_id}/editar")
|
||||
@router.post("/{cliente_id}/contatos/{contato_id}/editar", dependencies=[Depends(exigir_csrf)])
|
||||
def editar(
|
||||
cliente_id: int,
|
||||
contato_id: int,
|
||||
@@ -63,7 +64,7 @@ def editar(
|
||||
return _redirect_com_mensagem(cliente_id, "Contato atualizado.")
|
||||
|
||||
|
||||
@router.post("/{cliente_id}/contatos/{contato_id}/excluir")
|
||||
@router.post("/{cliente_id}/contatos/{contato_id}/excluir", dependencies=[Depends(exigir_csrf)])
|
||||
def excluir(cliente_id: int, contato_id: int, usuario: str = Depends(exigir_login)):
|
||||
db_contatos.excluir_contato(contato_id, cliente_id)
|
||||
return _redirect_com_mensagem(cliente_id, "Contato excluído.")
|
||||
|
||||
Reference in New Issue
Block a user