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
+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()