From 382567b929befec03a61b9726fb31e4ad66722e2 Mon Sep 17 00:00:00 2001 From: WanderMotta Date: Sat, 5 Sep 2026 02:44:04 -0300 Subject: [PATCH] =?UTF-8?q?Tela=20de=20login:=20credencial=20=C3=BAnica,?= =?UTF-8?q?=20sem=20banco?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toda rota passa por um before_request que redireciona para /login quem não tem sessão — inclusive os POSTs, então /exportar e /salvar-sd sem cookie não chegam ao handler. Ficam de fora só a própria entrada e /static (a tela de login precisa da folha de estilo). Credencial única em constante (admin@iasis.com.br / admin123), comparada com compare_digest nos dois campos e com a mesma mensagem para usuário errado e senha errada — dizer qual dos dois falhou entrega metade da credencial. Sem banco, sem cadastro e sem recuperação de senha: a app é ferramenta interna de uso individual, e isto existe para o upload e o writeback não ficarem abertos a quem alcançar a porta quando ela roda hospedada. SECRET_KEY não tem default assado na imagem — chave fixa numa imagem vale para toda cópia dela. Sem a variável, o servidor sorteia uma por processo e o login cai a cada restart, que é a mesma vida útil que a sessão da SD já tem; é seguro porque o gunicorn roda com -w 1. APP_USUARIO/APP_SENHA seguem a convenção do Dockerfile: default lá, compose só espelha. O Sair encerra a sessão do navegador sem descartar a SD em memória — é a mesma máquina, o mesmo processo. E o teste_e2e faz login uma vez no test client, senão todo request dele voltaria 302. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 13 ++++++ Dockerfile | 11 ++++- app/servidor.py | 90 +++++++++++++++++++++++++++++++++++++++- app/static/app.css | 20 +++++++-- app/templates/base.html | 13 +++++- app/templates/login.html | 30 ++++++++++++++ docker-compose.yml | 6 +++ scripts/teste_e2e.py | 4 ++ 8 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 app/templates/login.html diff --git a/.env.example b/.env.example index a09ca3d..de6ac7d 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,19 @@ GUNICORN_TIMEOUT=120 # debug | info | warning | error GUNICORN_LOG_LEVEL=info +# ── Porta de entrada (tela de login) ──────────────────────────────────────── + +# Credencial única da app. Não há banco nem cadastro: estes dois valores SÃO os +# usuários da aplicação. Os defaults do código são admin@iasis.com.br/admin123 — +# declare aqui só para trocar a credencial no deploy. +#APP_USUARIO=admin@iasis.com.br +#APP_SENHA=admin123 + +# Assina o cookie de sessão do login. Sem isto, cada start sorteia uma chave e +# o login cai a cada deploy/restart. Gere com: +# python -c "import secrets; print(secrets.token_hex(32))" +#SECRET_KEY= + # ── Lidas pelo COMPOSE ao montar volumes (só no Modo 2 / local) ───────────── # No Modo 1 (Application) não têm efeito: o volume é o Directory mount da UI. diff --git a/Dockerfile b/Dockerfile index 2ec7dab..2eb16d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,13 @@ FROM python:3.13-slim # TZ fuso do carimbo de exportação # GUNICORN_TIMEOUT segundos por request (upload + análise da SD) # GUNICORN_LOG_LEVEL debug | info | warning | error +# APP_USUARIO e-mail da credencial única da tela de login +# APP_SENHA senha dessa credencial +# SECRET_KEY assina o cookie de sessão — SEM default aqui de +# propósito: chave fixa assada na imagem é pior que +# nenhuma (vale para toda cópia dela). Sem a variável, o +# servidor.py sorteia uma por processo e o login cai a +# cada restart; cadastre no Coolify para o login durar. ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ LANG=C.UTF-8 \ @@ -46,7 +53,9 @@ ENV PYTHONUNBUFFERED=1 \ PORT=5000 \ TZ=America/Sao_Paulo \ GUNICORN_TIMEOUT=120 \ - GUNICORN_LOG_LEVEL=info + GUNICORN_LOG_LEVEL=info \ + APP_USUARIO=admin@iasis.com.br \ + APP_SENHA=admin123 WORKDIR /app diff --git a/app/servidor.py b/app/servidor.py index 0834dd2..5d4d9af 100644 --- a/app/servidor.py +++ b/app/servidor.py @@ -25,18 +25,28 @@ botão nenhum. Estado em memória de processo, uma SD por vez: é ferramenta local de uso individual. Reiniciar o servidor descarta a sessão — o SD.md de origem continua intacto no disco, e nada é gravado sem ação explícita (Salvar SD.md). + +Na frente de tudo isso há uma tela de login com credencial única, fixa na +configuração — sem banco, sem cadastro, sem recuperação de senha. Ela não +divide dados entre pessoas (a sessão da SD continua sendo uma só, do processo); +serve para que upload, writeback e download não fiquem abertos a quem alcançar +a porta quando a app roda hospedada. """ from __future__ import annotations +import hmac import io +import os +import secrets import sys from datetime import date from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -from flask import Flask, redirect, render_template, request, send_file, url_for +from flask import (Flask, redirect, render_template, request, send_file, session, + url_for) import escrita_sd import sessao as S @@ -59,6 +69,84 @@ app.jinja_env.globals["dias"] = lambda a, b: (date.fromisoformat(b) - date.fromi ATUAL: dict = {"sessao": None, "aviso": None, "erro": None} +# --------------------------------------------------------------------------- +# Porta de entrada +# --------------------------------------------------------------------------- + +# Credencial única, fixa no código. Não existe banco, cadastro nem recuperação +# de senha: a app é ferramenta interna de uso individual, e a autenticação aqui +# serve para não deixar o upload e o Salvar SD.md abertos a quem alcançar a +# porta. As variáveis de ambiente existem só para trocar a credencial no deploy +# sem editar código — os defaults abaixo são a credencial acordada. +USUARIO = os.environ.get("APP_USUARIO", "admin@iasis.com.br").strip().lower() +SENHA = os.environ.get("APP_SENHA", "admin123") + +# Assina o cookie de sessão. Sem SECRET_KEY no ambiente, sorteia uma por +# processo: reiniciar o servidor derruba o login — a mesma vida útil que a +# sessão da SD já tem (ver docstring), então nada se perde além do login. +# Vale porque o gunicorn roda com -w 1 (ver Dockerfile); com mais workers, +# cada um assinaria com uma chave diferente e SECRET_KEY passaria a ser +# obrigatória. +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) + +# Rotas que respondem sem login: a tela de entrada (`login`), o POST que a +# submete (`entrar` — endpoint com outro nome, e sem ele o próprio login bate +# no portão) e `static`, porque a tela de entrada precisa da folha de estilo. +LIVRES = {"login", "entrar", "static"} + + +def autenticado() -> bool: + return session.get("autenticado") is True + + +@app.before_request +def exigir_login(): + """O portão. Roda antes de qualquer rota — inclusive dos POSTs, então um + POST direto em /exportar ou /salvar-sd sem cookie não chega ao handler.""" + if request.endpoint in LIVRES or autenticado(): + return None + return redirect(url_for("login")) + + +def credencial_confere(email: str, senha: str) -> bool: + """compare_digest nos dois campos: comparação de tempo constante, e sempre + os dois — sair mais cedo no e-mail errado contaria qual dos dois falhou.""" + ok_email = hmac.compare_digest(email.strip().lower(), USUARIO) + ok_senha = hmac.compare_digest(senha, SENHA) + return ok_email and ok_senha + + +@app.get("/login") +def login(): + if autenticado(): + return redirect(url_for("inicio")) + return render_template("login.html") + + +@app.post("/login") +def entrar(): + email = request.form.get("email", "") + senha = request.form.get("senha", "") + if not credencial_confere(email, senha): + # Renderiza a própria tela com o erro, em vez de redirecionar: o + # redirect exigiria carregar a mensagem em estado global, e a app já + # tem uma sessão global só — duas abas se contaminariam. + # Mensagem única para e-mail errado e senha errada, de propósito. + return render_template("login.html", email=email, + erro="Usuário ou senha inválidos."), 401 + session.clear() + session["autenticado"] = True + return redirect(url_for("inicio")) + + +@app.post("/sair") +def sair(): + """Encerra a sessão do navegador. Não mexe na SD em memória: quem entrar + depois continua de onde parou — é a mesma máquina, o mesmo processo.""" + session.clear() + return redirect(url_for("login")) + + def canonico(): return carregar_canonico() diff --git a/app/static/app.css b/app/static/app.css index c39e2ae..b19b15e 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -129,6 +129,11 @@ header h1 { } .cabecalho { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 0.75rem; } +/* Tema e Sair juntos à direita do título; o form do Sair não pode empurrar + layout, por isso margem zero. */ +.acoes-cabecalho { display: flex; align-items: center; gap: 0.3rem; flex: none; } +.acoes-cabecalho form { margin: 0; } + #alternar-tema { font-size: 1.05rem; line-height: 1; padding: 0.35rem 0.65rem; flex: none; border-radius: 999px; @@ -239,16 +244,19 @@ ol.checklist li { margin-bottom: 0.2rem; } label { display: block; margin-bottom: 0.7rem; font-size: 0.82rem; color: var(--fraco); font-weight: 500; } label.radio { display: inline-flex; align-items: center; gap: 0.35rem; margin-right: 1rem; color: var(--tinta); font-size: 0.82rem; font-weight: 400; } -input[type=text], input:not([type]), select, textarea { +input[type=text], input[type=email], input[type=password], input:not([type]), +select, textarea { display: block; width: 100%; margin-top: 0.3rem; padding: 0.5rem 0.6rem; border: 1px solid var(--linha); border-radius: 7px; font: inherit; font-size: 0.88rem; color: var(--tinta); background: var(--papel); transition: border-color 0.15s ease, box-shadow 0.15s ease; } -input[type=text]:hover, input:not([type]):hover, select:hover, textarea:hover { +input[type=text]:hover, input[type=email]:hover, input[type=password]:hover, +input:not([type]):hover, select:hover, textarea:hover { border-color: var(--fraco); } -input[type=text]:focus, input:not([type]):focus, select:focus, textarea:focus { +input[type=text]:focus, input[type=email]:focus, input[type=password]:focus, +input:not([type]):focus, select:focus, textarea:focus { border-color: var(--foco); outline: 2px solid var(--foco); outline-offset: 1px; } @@ -356,3 +364,9 @@ table.entregaveis th { font-size: 0.7rem; text-transform: uppercase; letter-spac color: var(--fraco); font-weight: 600; } table.entregaveis td.num { font-family: var(--fonte-mono); font-size: 0.78rem; font-variant-numeric: tabular-nums; white-space: nowrap; } + +/* Tela de entrada. Um cartão estreito e centralizado: a largura útil do resto + da app deixaria dois campos soltos no meio de uma faixa vazia. */ +.cartao.entrada { max-width: 26rem; margin-left: auto; margin-right: auto; } +.cartao.entrada .acoes { margin-top: 1.1rem; } +.cartao.entrada button.primario { width: 100%; text-align: center; } diff --git a/app/templates/base.html b/app/templates/base.html index 6071ce6..4a4d2ed 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -31,9 +31,17 @@

Solicitação de Demanda — enviar · corrigir · exportar

- +
+ + {% if session.autenticado %} +
+ +
+ {% endif %} +
+ {% block estados %}
{# Três etapas, porque são três as que existem: o arquivo chega, ou está pronto ou tem pendência, e sai. "Com pendências" e "Pronta" ocupam a @@ -46,6 +54,7 @@ {% endif %} 3 JSON exportado
+ {% endblock %}
diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..3115129 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block titulo %}Entrar{% endblock %} + +{# A tela de login fica fora do fluxo enviar → corrigir → exportar: as três + etapas do cabeçalho não descrevem nada aqui, então o bloco esvazia. #} +{% block estados %}{% endblock %} + +{% block conteudo %} +
+

Entrar

+

Acesso restrito. Informe o e-mail e a senha para usar a exportação da SD.

+ +
+ + +
+ +
+
+ +

Credencial única, definida na configuração da aplicação. Não há + cadastro de usuário nem recuperação de senha — se a senha se perdeu, ela se + troca no ambiente de quem hospeda a app.

+
+{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index 5c7f4a7..d828f9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,12 @@ services: TZ: ${TZ:-America/Sao_Paulo} GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120} GUNICORN_LOG_LEVEL: ${GUNICORN_LOG_LEVEL:-info} + # Credencial única da tela de login (não há banco nem cadastro). + APP_USUARIO: ${APP_USUARIO:-admin@iasis.com.br} + APP_SENHA: ${APP_SENHA:-admin123} + # Vazio de propósito: sem valor, o servidor sorteia a chave por + # processo e o login cai a cada restart. Defina no .env para durar. + SECRET_KEY: ${SECRET_KEY:-} volumes: # OBRIGATÓRIO. O app lê clientes/ses-mg/contrato/itens.yaml a cada # análise (e contrato/linhas-os.yaml, se existir, como fallback da linha diff --git a/scripts/teste_e2e.py b/scripts/teste_e2e.py index 8048843..17fee1a 100644 --- a/scripts/teste_e2e.py +++ b/scripts/teste_e2e.py @@ -43,6 +43,10 @@ import servidor # noqa: E402 FALHAS: list[str] = [] CLIENTE = servidor.app.test_client() +# O portão de login vale para todas as rotas (servidor.exigir_login); sem esta +# entrada, todo request abaixo voltaria 302 para /login e nenhum teste chegaria +# ao handler que quer exercitar. +CLIENTE.post("/login", data={"email": servidor.USUARIO, "senha": servidor.SENHA}) # Lida da tabela, não fixada: o cabeçalho de linhas-os.yaml manda atualizar # `atualizado_em` a cada edição, e um teste pinado ficaria vermelho à toa. DATA_TABELA = servidor.canonico().linhas_os["atualizado_em"]