feat: login por sessao (troca HTTP Basic), UsuarioLogado, exigir_admin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
718e845651
commit
1b80290f6f
+52
-18
@@ -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
|
||||
|
||||
+70
-36
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user