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:
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user