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>
This commit is contained in:
2026-09-05 02:44:04 -03:00
co-authored by Claude Opus 5
parent 62ec768d0c
commit 382567b929
8 changed files with 180 additions and 7 deletions
+13
View File
@@ -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.
+10 -1
View File
@@ -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
+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
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()
+17 -3
View File
@@ -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; }
+9
View File
@@ -31,9 +31,17 @@
<header>
<div class="cabecalho">
<h1>Solicitação de Demanda — enviar · corrigir · exportar</h1>
<div class="acoes-cabecalho">
<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>
{% block estados %}
<div class="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 %}
<span class="pastilha {{ 'atual' if estado_atual == 'exportada' }}">3 JSON exportado</span>
</div>
{% endblock %}
</header>
<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}
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
+4
View File
@@ -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"]