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
|
# 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
@@ -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
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div class="acoes-cabecalho">
|
||||||
<button type="button" id="alternar-tema" class="discreto" title="Alternar tema"
|
<button type="button" id="alternar-tema" class="discreto" title="Alternar tema"
|
||||||
aria-label="Alternar tema claro e escuro">◐</button>
|
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">
|
||||||
|
|||||||
@@ -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}
|
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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user