""" 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 from .clientes import POR_PAGINA_COOKIE 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) resposta.delete_cookie(POR_PAGINA_COOKIE) return resposta