feat: rotas de login/logoff (app/routers/auth.py)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-08-13 20:38:43 -03:00
co-authored by Claude Sonnet 5
parent 1b80290f6f
commit c4079b3e47
3 changed files with 177 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
"""
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).
"""
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 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 _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),
):
usuario_logado = autenticar_credenciais(usuario, senha)
if usuario_logado is None:
return templates.TemplateResponse(
request,
"login.html",
{"csrf_token": token_do_contexto(request), "next": next or "", "erro": "Usuário ou senha inválidos."},
)
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")
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
+30
View File
@@ -0,0 +1,30 @@
<!-- app/templates/login.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Entrar — Painel de Clientes</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; background: #17171a; color: #f4f4f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
form { width: 320px; padding: 24px; background: #232326; border-radius: 10px; }
h1 { font-size: 1.2rem; margin-top: 0; }
label { display: block; margin-top: 10px; font-size: 0.85rem; color: #a1a1aa; }
input { width: 100%; padding: 8px; border-radius: 6px; border: 1px solid #3a3a3e; background: #17171a; color: #f4f4f5; box-sizing: border-box; }
button { margin-top: 16px; width: 100%; padding: 10px 16px; border: none; border-radius: 6px; background: #F5821F; color: #17171a; font-weight: 700; cursor: pointer; }
.mensagem.danger { padding: 10px; border-radius: 6px; margin-bottom: 12px; background: #7f1d1d; }
</style>
</head>
<body>
<form method="post" action="/login">
<h1>Painel de Clientes</h1>
{% if erro %}<div class="mensagem danger">{{ erro }}</div>{% endif %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="next" value="{{ next }}">
<label for="usuario">Usuário</label>
<input type="text" id="usuario" name="usuario" required autofocus>
<label for="senha">Senha</label>
<input type="password" id="senha" name="senha" required>
<button type="submit">Entrar</button>
</form>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from app import session
from app.auth import NaoAutenticado, UsuarioLogado, exigir_login
from app.routers import auth as router_auth
app_teste = FastAPI()
app_teste.include_router(router_auth.router)
@app_teste.exception_handler(NaoAutenticado)
def _handler(request, exc):
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"/login?next={exc.caminho_original}", status_code=303)
@app_teste.get("/rota-protegida-teste")
def _rota_protegida(usuario: UsuarioLogado = Depends(exigir_login)):
return {"ok": True}
client = TestClient(app_teste, follow_redirects=False)
CSRF_TESTE = "teste-csrf-token"
client.cookies.set("csrf_token", CSRF_TESTE)
def test_get_login_mostra_formulario():
resposta = client.get("/login")
assert resposta.status_code == 200
assert "senha" in resposta.text.lower()
def test_post_login_credenciais_corretas_grava_cookie_e_redireciona():
resposta = client.post(
"/login", data={"usuario": "admin", "senha": "teste123", "next": "/clientes", "csrf_token": CSRF_TESTE}
)
assert resposta.status_code == 303
assert resposta.headers["location"] == "/clientes"
assert session.SESSION_COOKIE_NAME in resposta.cookies
def test_post_login_sem_next_redireciona_para_clientes():
resposta = client.post("/login", data={"usuario": "admin", "senha": "teste123", "csrf_token": CSRF_TESTE})
assert resposta.status_code == 303
assert resposta.headers["location"] == "/clientes"
def test_post_login_next_externo_e_ignorado_por_seguranca():
resposta = client.post(
"/login",
data={"usuario": "admin", "senha": "teste123", "next": "https://site-malicioso.com", "csrf_token": CSRF_TESTE},
)
assert resposta.status_code == 303
assert resposta.headers["location"] == "/clientes"
def test_post_login_credenciais_erradas_nao_grava_cookie():
resposta = client.post(
"/login", data={"usuario": "admin", "senha": "errada", "csrf_token": CSRF_TESTE}
)
assert resposta.status_code == 200
assert "inválid" in resposta.text.lower()
assert session.SESSION_COOKIE_NAME not in resposta.cookies
def test_get_rota_protegida_sem_login_redireciona_para_login():
# Testes anteriores deixaram um painel_session válido no cookie jar do
# client (persistente, como um navegador real) — limpa antes de simular
# a requisição de um visitante não autenticado.
client.cookies.delete(session.SESSION_COOKIE_NAME)
resposta = client.get("/rota-protegida-teste")
assert resposta.status_code == 303
assert resposta.headers["location"] == "/login?next=/rota-protegida-teste"
def test_post_logout_apaga_cookie_e_redireciona():
cookies = {session.SESSION_COOKIE_NAME: session.criar_token("admin", "Admin", "ADMIN")}
resposta = client.post("/logout", data={"csrf_token": CSRF_TESTE}, cookies=cookies)
assert resposta.status_code == 303
assert resposta.headers["location"] == "/login"
# cookie apagado: o Set-Cookie da resposta zera o valor
set_cookie = resposta.headers.get("set-cookie", "")
assert session.SESSION_COOKIE_NAME in set_cookie