feat: rotas de login/logoff (app/routers/auth.py)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1b80290f6f
commit
c4079b3e47
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user