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
+
+
+
+
+
+
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