Files
GTI_GERENCIADOR_FISCAL/app/routers/auth.py
T
GustavoandClaude Sonnet 5 d7a08ddd8d security: rate limit no login, cookies Secure condicional, security headers e deps com CVE
- app/rate_limit.py: bloqueia por IP após 5 tentativas de senha erradas
  em 5 minutos em POST /login (rota pré-autenticação, hoje sem nenhuma
  proteção contra força bruta).
- Cookie de sessão (routers/auth.py) e cookie CSRF (main.py) passam a
  sair com Secure quando a requisição chega em HTTPS (request.url.scheme)
  — hoje é sempre False porque produção ainda serve HTTP puro, mas o
  cookie passa a ficar protegido automaticamente assim que um TLS for
  colocado na frente, sem precisar editar isso de novo.
- main.py: novo middleware de security headers (X-Content-Type-Options,
  X-Frame-Options: DENY, Referrer-Policy, Content-Security-Policy,
  Strict-Transport-Security condicional a HTTPS).
- requirements.txt: fastapi 0.115.0 -> 0.115.6 (starlette >=0.40.0,
  corrige DoS por memória não limitada ao parsear multipart/form-data —
  GHSA-f96h-pmfr-66vw), python-multipart 0.0.9 -> 0.0.20 (várias
  vulnerabilidades de DoS corrigidas), jinja2 3.1.4 -> 3.1.6 (escapes de
  sandbox). httpx/uvicorn/PyMySQL já estavam em versões sem CVE
  conhecida — conferido via osv.dev.
- Dockerfile: container não roda mais como root.

Suíte completa (181 testes) passando, incluindo testes novos para o
rate limiter (unitários + integração via POST /login) e smoke test
manual confirmando headers, fluxo de login com CSRF e o bloqueio por
força bruta.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 11:17:41 -03:00

99 lines
3.8 KiB
Python

"""
Rotas de login/logoff. O painel não usa mais HTTP Basic Auth (ver
app/auth.py) — quem não está logado é redirecionado para GET /login pelo
exception handler de NaoAutenticado (registrado em app/main.py).
O cookie de sessão só sai com o atributo Secure quando a requisição já
chegou em HTTPS (request.url.scheme) — hoje a produção ainda serve só
HTTP puro (sem proxy reverso com TLS na frente), então forçar Secure
sempre quebraria o login (o navegador nunca devolveria o cookie). Assim
que um TLS for colocado na frente, o cookie passa a sair Secure
automaticamente, sem precisar mexer aqui de novo. Se um proxy reverso
for adicionado, ele precisa repassar o esquema original (cabeçalho
X-Forwarded-Proto + ProxyHeadersMiddleware/--proxy-headers do uvicorn),
senão request.url.scheme sempre lê "http" mesmo com o navegador em HTTPS.
"""
from typing import Optional
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import rate_limit, session
from ..auth import UsuarioLogado, autenticar_credenciais, exigir_login
from ..csrf import exigir_csrf, token_do_contexto
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _origem(request: Request) -> str:
"""IP de quem fez a requisição, usado como chave do limitador de
tentativas (app/rate_limit.py). "desconhecido" só ocorre em contexto
de teste sem client configurado — nesse caso todas as tentativas sem
IP caem na mesma chave, o que é aceitável (ambiente de teste, não
produção)."""
return request.client.host if request.client else "desconhecido"
def _proximo_seguro(next_: Optional[str]) -> str:
"""Só aceita um path interno (começa com "/", sem "//") — nunca uma URL
externa, para não virar um open redirect via POST /login?next=..."""
if next_ and next_.startswith("/") and not next_.startswith(("//", "/\\")):
return next_
return "/clientes"
@router.get("/login", response_class=HTMLResponse)
def form_login(request: Request, next: Optional[str] = None):
return templates.TemplateResponse(
request, "login.html", {"csrf_token": token_do_contexto(request), "next": next or ""}
)
@router.post("/login", dependencies=[Depends(exigir_csrf)])
def login(
request: Request,
usuario: str = Form(...),
senha: str = Form(...),
next: Optional[str] = Form(None),
):
origem = _origem(request)
if rate_limit.bloqueado(origem):
return templates.TemplateResponse(
request,
"login.html",
{
"csrf_token": token_do_contexto(request), "next": next or "",
"erro": "Muitas tentativas de login. Aguarde alguns minutos e tente novamente.",
},
status_code=429,
)
usuario_logado = autenticar_credenciais(usuario, senha)
if usuario_logado is None:
rate_limit.registrar_falha(origem)
return templates.TemplateResponse(
request,
"login.html",
{"csrf_token": token_do_contexto(request), "next": next or "", "erro": "Usuário ou senha inválidos."},
)
rate_limit.limpar(origem)
destino = _proximo_seguro(next)
resposta = RedirectResponse(url=destino, status_code=303)
token = session.criar_token(usuario_logado.login, usuario_logado.nome, usuario_logado.perfil)
resposta.set_cookie(
session.SESSION_COOKIE_NAME, token, httponly=True, samesite="lax",
secure=request.url.scheme == "https",
)
return resposta
@router.post("/logout", dependencies=[Depends(exigir_csrf)])
def logout(usuario: UsuarioLogado = Depends(exigir_login)):
resposta = RedirectResponse(url="/login", status_code=303)
resposta.delete_cookie(session.SESSION_COOKIE_NAME)
return resposta