diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..397c3be --- /dev/null +++ b/app/routers/auth.py @@ -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 diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..02141ee --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,30 @@ + + + + + + Entrar — Painel de Clientes + + + +
+

Painel de Clientes

+ {% if erro %}
{{ erro }}
{% endif %} + + + + + + + +
+ + diff --git a/tests/test_router_auth.py b/tests/test_router_auth.py new file mode 100644 index 0000000..19c6197 --- /dev/null +++ b/tests/test_router_auth.py @@ -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