feat: login por sessao (troca HTTP Basic), UsuarioLogado, exigir_admin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-08-13 20:33:30 -03:00
co-authored by Claude Sonnet 5
parent 718e845651
commit 1b80290f6f
2 changed files with 122 additions and 54 deletions
+52 -18
View File
@@ -1,44 +1,78 @@
""" """
Login HTTP Basic. Duas fontes de credencial, checadas nessa ordem: Login por sessão. Duas fontes de credencial, checadas nessa ordem em
autenticar_credenciais() (chamada tanto por POST /login quanto,
indiretamente, por quem valida o cookie de sessão):
1. ADMIN_USER/ADMIN_PASSWORD (variáveis de ambiente) — conta única de 1. ADMIN_USER/ADMIN_PASSWORD (variáveis de ambiente) — conta única de
bootstrap, útil para o primeiro acesso (cadastrar o primeiro bootstrap, útil para o primeiro acesso (cadastrar o primeiro
funcionário em /funcionarios) e como plano B se a tabela de funcionário em /funcionarios) e como plano B se a tabela de
funcionários ficar vazia/inacessível. Continua exigindo as duas funcionários ficar vazia/inacessível. Continua exigindo as duas
variáveis preenchidas (nunca autentica credencial vazia). variáveis preenchidas (nunca autentica credencial vazia). Sempre
perfil ADMIN, não depende do banco.
2. FUNCIONARIO_PAINEL (app/db/funcionarios.py) — conta individual por 2. FUNCIONARIO_PAINEL (app/db/funcionarios.py) — conta individual por
funcionário, com senha em hash (app/senha.py). Só autentica se ATIVO=1. funcionário, com senha em hash (app/senha.py). Só autentica se ATIVO=1.
Perfil vem da coluna PERFIL ("ADMIN" ou "PADRAO").
Sem sessão/cookie — o próprio navegador guarda a credencial Basic. A credencial em si só é usada em POST /login (app/routers/auth.py), que
troca por um cookie de sessão assinado (app/session.py) — as rotas do
painel não recebem usuário/senha a cada requisição, só o cookie.
""" """
import os import os
import secrets import secrets
from dataclasses import dataclass
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from . import session
from .db import funcionarios as db_funcionarios from .db import funcionarios as db_funcionarios
from .senha import verificar_senha from .senha import verificar_senha
_security = HTTPBasic()
@dataclass
class UsuarioLogado:
login: str
nome: str
perfil: str # "ADMIN" ou "PADRAO"
def __str__(self) -> str:
return self.nome
@property
def eh_admin(self) -> bool:
return self.perfil == "ADMIN"
def exigir_login(credenciais: HTTPBasicCredentials = Depends(_security)) -> str: class NaoAutenticado(Exception):
def __init__(self, caminho_original: str = "/"):
self.caminho_original = caminho_original
def autenticar_credenciais(usuario: str, senha: str) -> UsuarioLogado | None:
usuario_admin = os.environ.get("ADMIN_USER", "") usuario_admin = os.environ.get("ADMIN_USER", "")
senha_admin = os.environ.get("ADMIN_PASSWORD", "") senha_admin = os.environ.get("ADMIN_PASSWORD", "")
if usuario_admin and senha_admin: if usuario_admin and senha_admin:
usuario_ok = secrets.compare_digest(credenciais.username, usuario_admin) usuario_ok = secrets.compare_digest(usuario, usuario_admin)
senha_ok = secrets.compare_digest(credenciais.password, senha_admin) senha_ok = secrets.compare_digest(senha, senha_admin)
if usuario_ok and senha_ok: if usuario_ok and senha_ok:
return credenciais.username return UsuarioLogado(login=usuario_admin, nome=usuario_admin, perfil="ADMIN")
funcionario = db_funcionarios.obter_por_login(credenciais.username) funcionario = db_funcionarios.obter_por_login(usuario)
if funcionario and funcionario["ATIVO"] and verificar_senha(credenciais.password, funcionario["SENHA_HASH"]): if funcionario and funcionario["ATIVO"] and verificar_senha(senha, funcionario["SENHA_HASH"]):
return funcionario["NOME"] return UsuarioLogado(login=funcionario["LOGIN"], nome=funcionario["NOME"], perfil=funcionario["PERFIL"])
raise HTTPException( return None
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuário ou senha inválidos.",
headers={"WWW-Authenticate": "Basic"}, def exigir_login(request: Request) -> UsuarioLogado:
) token = request.cookies.get(session.SESSION_COOKIE_NAME)
dados = session.ler_token(token)
if dados is None:
raise NaoAutenticado(caminho_original=request.url.path)
return UsuarioLogado(login=dados["login"], nome=dados["nome"], perfil=dados["perfil"])
def exigir_admin(usuario: UsuarioLogado = Depends(exigir_login)) -> UsuarioLogado:
if not usuario.eh_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Só administradores acessam esta página.")
return usuario
+70 -36
View File
@@ -1,10 +1,9 @@
import base64
import pytest import pytest
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.auth import exigir_login from app import session
from app.auth import NaoAutenticado, UsuarioLogado, autenticar_credenciais, exigir_admin, exigir_login
from app.db import funcionarios as db_funcionarios from app.db import funcionarios as db_funcionarios
from app.db.connection import get_connection from app.db.connection import get_connection
@@ -12,42 +11,79 @@ app_teste = FastAPI()
@app_teste.get("/protegido") @app_teste.get("/protegido")
def rota_protegida(usuario: str = Depends(exigir_login)): def rota_protegida(usuario: UsuarioLogado = Depends(exigir_login)):
return {"usuario": usuario} return {"usuario": str(usuario), "perfil": usuario.perfil}
client = TestClient(app_teste) @app_teste.get("/so-admin")
def rota_admin(usuario: UsuarioLogado = Depends(exigir_admin)):
return {"usuario": str(usuario)}
def _basic(usuario: str, senha: str) -> dict: @app_teste.exception_handler(NaoAutenticado)
token = base64.b64encode(f"{usuario}:{senha}".encode()).decode() def _handler_nao_autenticado(request, exc):
return {"Authorization": f"Basic {token}"} raise HTTPException(status_code=401, detail="não autenticado")
def test_sem_credenciais_retorna_401(): client = TestClient(app_teste, follow_redirects=False)
def _cookie_sessao(login: str, nome: str, perfil: str) -> dict:
return {session.SESSION_COOKIE_NAME: session.criar_token(login, nome, perfil)}
def test_sem_cookie_retorna_401():
resposta = client.get("/protegido") resposta = client.get("/protegido")
assert resposta.status_code == 401 assert resposta.status_code == 401
def test_credenciais_erradas_retorna_401(): def test_cookie_invalido_retorna_401():
resposta = client.get("/protegido", headers=_basic("admin", "senha_errada")) resposta = client.get("/protegido", cookies={session.SESSION_COOKIE_NAME: "lixo"})
assert resposta.status_code == 401 assert resposta.status_code == 401
def test_credenciais_corretas_retorna_200(): def test_cookie_valido_retorna_dados_do_usuario():
# ADMIN_USER/ADMIN_PASSWORD vêm de tests/conftest.py ("admin"/"teste123") resposta = client.get("/protegido", cookies=_cookie_sessao("admin", "Admin", "ADMIN"))
resposta = client.get("/protegido", headers=_basic("admin", "teste123"))
assert resposta.status_code == 200 assert resposta.status_code == 200
assert resposta.json() == {"usuario": "admin"} assert resposta.json() == {"usuario": "Admin", "perfil": "ADMIN"}
def test_admin_user_e_senha_vazios_nao_autentica_com_credenciais_vazias(monkeypatch): def test_usuario_logado_str_retorna_nome():
# Sem ADMIN_USER/ADMIN_PASSWORD configurados, o painel não pode ficar usuario = UsuarioLogado(login="fulano", nome="Fulano da Silva", perfil="PADRAO")
# aberto para uma requisição com usuário/senha vazios (Basic Og==). assert str(usuario) == "Fulano da Silva"
def test_usuario_logado_eh_admin():
assert UsuarioLogado(login="a", nome="A", perfil="ADMIN").eh_admin is True
assert UsuarioLogado(login="a", nome="A", perfil="PADRAO").eh_admin is False
def test_exigir_admin_aceita_admin():
resposta = client.get("/so-admin", cookies=_cookie_sessao("admin", "Admin", "ADMIN"))
assert resposta.status_code == 200
def test_exigir_admin_rejeita_padrao():
resposta = client.get("/so-admin", cookies=_cookie_sessao("fulano", "Fulano", "PADRAO"))
assert resposta.status_code == 403
def test_autenticar_credenciais_admin_env():
# ADMIN_USER/ADMIN_PASSWORD vêm de tests/conftest.py ("admin"/"teste123")
usuario = autenticar_credenciais("admin", "teste123")
assert usuario is not None
assert usuario.perfil == "ADMIN"
assert usuario.login == "admin"
def test_autenticar_credenciais_admin_env_senha_errada():
assert autenticar_credenciais("admin", "senha_errada") is None
def test_autenticar_credenciais_admin_env_vazio_nao_autentica_vazio(monkeypatch):
monkeypatch.setenv("ADMIN_USER", "") monkeypatch.setenv("ADMIN_USER", "")
monkeypatch.setenv("ADMIN_PASSWORD", "") monkeypatch.setenv("ADMIN_PASSWORD", "")
resposta = client.get("/protegido", headers=_basic("", "")) assert autenticar_credenciais("", "") is None
assert resposta.status_code == 401
def _limpar_login(login: str) -> None: def _limpar_login(login: str) -> None:
@@ -63,29 +99,27 @@ def _limpar_login(login: str) -> None:
def funcionario_teste(): def funcionario_teste():
login = "teste.login.auth" login = "teste.login.auth"
_limpar_login(login) _limpar_login(login)
funcionario_id = db_funcionarios.criar_funcionario("Fulano de Teste", login, "senhaForte123") funcionario_id = db_funcionarios.criar_funcionario("Fulano de Teste", login, "senhaForte123", "PADRAO")
yield login yield login
_limpar_login(login) _limpar_login(login)
def test_funcionario_ativo_com_senha_correta_autentica(funcionario_teste): def test_autenticar_credenciais_funcionario_ativo(funcionario_teste):
resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaForte123")) usuario = autenticar_credenciais(funcionario_teste, "senhaForte123")
assert resposta.status_code == 200 assert usuario is not None
assert resposta.json() == {"usuario": "Fulano de Teste"} assert usuario.nome == "Fulano de Teste"
assert usuario.perfil == "PADRAO"
def test_funcionario_com_senha_errada_nao_autentica(funcionario_teste): def test_autenticar_credenciais_funcionario_senha_errada(funcionario_teste):
resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaErrada")) assert autenticar_credenciais(funcionario_teste, "senhaErrada") is None
assert resposta.status_code == 401
def test_funcionario_inativo_nao_autentica(funcionario_teste): def test_autenticar_credenciais_funcionario_inativo(funcionario_teste):
funcionario = db_funcionarios.obter_por_login(funcionario_teste) funcionario = db_funcionarios.obter_por_login(funcionario_teste)
db_funcionarios.alternar_ativo(funcionario["ID"]) # desativa db_funcionarios.alternar_ativo(funcionario["ID"]) # desativa
resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaForte123")) assert autenticar_credenciais(funcionario_teste, "senhaForte123") is None
assert resposta.status_code == 401
def test_login_inexistente_nao_autentica(): def test_autenticar_credenciais_login_inexistente():
resposta = client.get("/protegido", headers=_basic("nao-existe-esse-login", "qualquer")) assert autenticar_credenciais("nao-existe-esse-login", "qualquer") is None
assert resposta.status_code == 401