Compare commits

..
1 Commits
Author SHA1 Message Date
wanderandClaude Opus 5 382567b929 Tela de login: credencial única, sem banco
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) <noreply@anthropic.com>
2026-09-05 02:44:04 -03:00
8 changed files with 180 additions and 7 deletions
+13
View File
@@ -18,6 +18,19 @@ GUNICORN_TIMEOUT=120
# debug | info | warning | error # debug | info | warning | error
GUNICORN_LOG_LEVEL=info 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) ───────────── # ── 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. # No Modo 1 (Application) não têm efeito: o volume é o Directory mount da UI.
+10 -1
View File
@@ -39,6 +39,13 @@ FROM python:3.13-slim
# TZ fuso do carimbo de exportação # TZ fuso do carimbo de exportação
# GUNICORN_TIMEOUT segundos por request (upload + análise da SD) # GUNICORN_TIMEOUT segundos por request (upload + análise da SD)
# GUNICORN_LOG_LEVEL debug | info | warning | error # 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 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
@@ -46,7 +53,9 @@ ENV PYTHONUNBUFFERED=1 \
PORT=5000 \ PORT=5000 \
TZ=America/Sao_Paulo \ TZ=America/Sao_Paulo \
GUNICORN_TIMEOUT=120 \ GUNICORN_TIMEOUT=120 \
GUNICORN_LOG_LEVEL=info GUNICORN_LOG_LEVEL=info \
APP_USUARIO=admin@iasis.com.br \
APP_SENHA=admin123
WORKDIR /app WORKDIR /app
+89 -1
View File
@@ -25,18 +25,28 @@ botão nenhum.
Estado em memória de processo, uma SD por vez: é ferramenta local de uso 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 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). 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 from __future__ import annotations
import hmac
import io import io
import os
import secrets
import sys import sys
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) 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 escrita_sd
import sessao as S 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} 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(): def canonico():
return carregar_canonico() return carregar_canonico()
+17 -3
View File
@@ -129,6 +129,11 @@ header h1 {
} }
.cabecalho { display: flex; justify-content: space-between; align-items: center; .cabecalho { display: flex; justify-content: space-between; align-items: center;
gap: 1rem; margin-bottom: 0.75rem; } 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 { #alternar-tema {
font-size: 1.05rem; line-height: 1; padding: 0.35rem 0.65rem; flex: none; font-size: 1.05rem; line-height: 1; padding: 0.35rem 0.65rem; flex: none;
border-radius: 999px; 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 { 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; label.radio { display: inline-flex; align-items: center; gap: 0.35rem; margin-right: 1rem;
color: var(--tinta); font-size: 0.82rem; font-weight: 400; } 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; display: block; width: 100%; margin-top: 0.3rem; padding: 0.5rem 0.6rem;
border: 1px solid var(--linha); border-radius: 7px; font: inherit; border: 1px solid var(--linha); border-radius: 7px; font: inherit;
font-size: 0.88rem; color: var(--tinta); background: var(--papel); font-size: 0.88rem; color: var(--tinta); background: var(--papel);
transition: border-color 0.15s ease, box-shadow 0.15s ease; 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); 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); border-color: var(--foco);
outline: 2px solid var(--foco); outline-offset: 1px; 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; } color: var(--fraco); font-weight: 600; }
table.entregaveis td.num { font-family: var(--fonte-mono); font-size: 0.78rem; table.entregaveis td.num { font-family: var(--fonte-mono); font-size: 0.78rem;
font-variant-numeric: tabular-nums; white-space: nowrap; } 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; }
+11 -2
View File
@@ -31,9 +31,17 @@
<header> <header>
<div class="cabecalho"> <div class="cabecalho">
<h1>Solicitação de Demanda — enviar · corrigir · exportar</h1> <h1>Solicitação de Demanda — enviar · corrigir · exportar</h1>
<button type="button" id="alternar-tema" class="discreto" title="Alternar tema" <div class="acoes-cabecalho">
aria-label="Alternar tema claro e escuro">◐</button> <button type="button" id="alternar-tema" class="discreto" title="Alternar tema"
aria-label="Alternar tema claro e escuro">◐</button>
{% if session.autenticado %}
<form method="post" action="{{ url_for('sair') }}">
<button type="submit" class="discreto" title="Encerrar a sessão">Sair</button>
</form>
{% endif %}
</div>
</div> </div>
{% block estados %}
<div class="estados"> <div class="estados">
{# Três etapas, porque são três as que existem: o arquivo chega, ou está {# 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 pronto ou tem pendência, e sai. "Com pendências" e "Pronta" ocupam a
@@ -46,6 +54,7 @@
{% endif %} {% endif %}
<span class="pastilha {{ 'atual' if estado_atual == 'exportada' }}">3 JSON exportado</span> <span class="pastilha {{ 'atual' if estado_atual == 'exportada' }}">3 JSON exportado</span>
</div> </div>
{% endblock %}
</header> </header>
<main id="topo"> <main id="topo">
+30
View File
@@ -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 %}
<section class="cartao entrada">
<h2>Entrar</h2>
<p>Acesso restrito. Informe o e-mail e a senha para usar a exportação da SD.</p>
<form method="post" action="{{ url_for('entrar') }}">
<label>E-mail
<input type="email" name="email" value="{{ email }}" placeholder="nome@dominio.com.br"
autocomplete="username" autofocus required>
</label>
<label>Senha
<input type="password" name="senha" autocomplete="current-password" required>
</label>
<div class="acoes">
<button type="submit" class="primario">Entrar</button>
</div>
</form>
<p class="nota">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.</p>
</section>
{% endblock %}
+6
View File
@@ -60,6 +60,12 @@ services:
TZ: ${TZ:-America/Sao_Paulo} TZ: ${TZ:-America/Sao_Paulo}
GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120} GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120}
GUNICORN_LOG_LEVEL: ${GUNICORN_LOG_LEVEL:-info} 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: volumes:
# OBRIGATÓRIO. O app lê clientes/ses-mg/contrato/itens.yaml a cada # 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 # análise (e contrato/linhas-os.yaml, se existir, como fallback da linha
+4
View File
@@ -43,6 +43,10 @@ import servidor # noqa: E402
FALHAS: list[str] = [] FALHAS: list[str] = []
CLIENTE = servidor.app.test_client() 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 # 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. # `atualizado_em` a cada edição, e um teste pinado ficaria vermelho à toa.
DATA_TABELA = servidor.canonico().linhas_os["atualizado_em"] DATA_TABELA = servidor.canonico().linhas_os["atualizado_em"]