From 1b80290f6f83453cb6a24aa574f5f20e9ba4b2c2 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Thu, 13 Aug 2026 20:33:30 -0300 Subject: [PATCH] feat: login por sessao (troca HTTP Basic), UsuarioLogado, exigir_admin Co-Authored-By: Claude Sonnet 5 --- app/auth.py | 70 ++++++++++++++++++++++-------- tests/test_auth.py | 106 ++++++++++++++++++++++++++++++--------------- 2 files changed, 122 insertions(+), 54 deletions(-) diff --git a/app/auth.py b/app/auth.py index d8dc3e2..e46c009 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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 bootstrap, útil para o primeiro acesso (cadastrar o primeiro funcionário em /funcionarios) e como plano B se a tabela de 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 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 secrets +from dataclasses import dataclass -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBasic, HTTPBasicCredentials +from fastapi import Depends, HTTPException, Request, status +from . import session from .db import funcionarios as db_funcionarios 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", "") senha_admin = os.environ.get("ADMIN_PASSWORD", "") if usuario_admin and senha_admin: - usuario_ok = secrets.compare_digest(credenciais.username, usuario_admin) - senha_ok = secrets.compare_digest(credenciais.password, senha_admin) + usuario_ok = secrets.compare_digest(usuario, usuario_admin) + senha_ok = secrets.compare_digest(senha, senha_admin) 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) - if funcionario and funcionario["ATIVO"] and verificar_senha(credenciais.password, funcionario["SENHA_HASH"]): - return funcionario["NOME"] + funcionario = db_funcionarios.obter_por_login(usuario) + if funcionario and funcionario["ATIVO"] and verificar_senha(senha, funcionario["SENHA_HASH"]): + return UsuarioLogado(login=funcionario["LOGIN"], nome=funcionario["NOME"], perfil=funcionario["PERFIL"]) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Usuário ou senha inválidos.", - headers={"WWW-Authenticate": "Basic"}, - ) + return None + + +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 diff --git a/tests/test_auth.py b/tests/test_auth.py index 2b56d44..f2386cb 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,10 +1,9 @@ -import base64 - import pytest -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, HTTPException 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.connection import get_connection @@ -12,42 +11,79 @@ app_teste = FastAPI() @app_teste.get("/protegido") -def rota_protegida(usuario: str = Depends(exigir_login)): - return {"usuario": usuario} +def rota_protegida(usuario: UsuarioLogado = Depends(exigir_login)): + 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: - token = base64.b64encode(f"{usuario}:{senha}".encode()).decode() - return {"Authorization": f"Basic {token}"} +@app_teste.exception_handler(NaoAutenticado) +def _handler_nao_autenticado(request, exc): + 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") assert resposta.status_code == 401 -def test_credenciais_erradas_retorna_401(): - resposta = client.get("/protegido", headers=_basic("admin", "senha_errada")) +def test_cookie_invalido_retorna_401(): + resposta = client.get("/protegido", cookies={session.SESSION_COOKIE_NAME: "lixo"}) assert resposta.status_code == 401 -def test_credenciais_corretas_retorna_200(): - # ADMIN_USER/ADMIN_PASSWORD vêm de tests/conftest.py ("admin"/"teste123") - resposta = client.get("/protegido", headers=_basic("admin", "teste123")) +def test_cookie_valido_retorna_dados_do_usuario(): + resposta = client.get("/protegido", cookies=_cookie_sessao("admin", "Admin", "ADMIN")) 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): - # Sem ADMIN_USER/ADMIN_PASSWORD configurados, o painel não pode ficar - # aberto para uma requisição com usuário/senha vazios (Basic Og==). +def test_usuario_logado_str_retorna_nome(): + usuario = UsuarioLogado(login="fulano", nome="Fulano da Silva", perfil="PADRAO") + 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_PASSWORD", "") - resposta = client.get("/protegido", headers=_basic("", "")) - assert resposta.status_code == 401 + assert autenticar_credenciais("", "") is None def _limpar_login(login: str) -> None: @@ -63,29 +99,27 @@ def _limpar_login(login: str) -> None: def funcionario_teste(): login = "teste.login.auth" _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 _limpar_login(login) -def test_funcionario_ativo_com_senha_correta_autentica(funcionario_teste): - resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaForte123")) - assert resposta.status_code == 200 - assert resposta.json() == {"usuario": "Fulano de Teste"} +def test_autenticar_credenciais_funcionario_ativo(funcionario_teste): + usuario = autenticar_credenciais(funcionario_teste, "senhaForte123") + assert usuario is not None + assert usuario.nome == "Fulano de Teste" + assert usuario.perfil == "PADRAO" -def test_funcionario_com_senha_errada_nao_autentica(funcionario_teste): - resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaErrada")) - assert resposta.status_code == 401 +def test_autenticar_credenciais_funcionario_senha_errada(funcionario_teste): + assert autenticar_credenciais(funcionario_teste, "senhaErrada") is None -def test_funcionario_inativo_nao_autentica(funcionario_teste): +def test_autenticar_credenciais_funcionario_inativo(funcionario_teste): funcionario = db_funcionarios.obter_por_login(funcionario_teste) db_funcionarios.alternar_ativo(funcionario["ID"]) # desativa - resposta = client.get("/protegido", headers=_basic(funcionario_teste, "senhaForte123")) - assert resposta.status_code == 401 + assert autenticar_credenciais(funcionario_teste, "senhaForte123") is None -def test_login_inexistente_nao_autentica(): - resposta = client.get("/protegido", headers=_basic("nao-existe-esse-login", "qualquer")) - assert resposta.status_code == 401 +def test_autenticar_credenciais_login_inexistente(): + assert autenticar_credenciais("nao-existe-esse-login", "qualquer") is None