Trabalho Unifecaf Controle de qualidade

Operação

Painel da linha

Critérios de aprovação

  • Peso entre 95 g e 105 g
  • Cor azul ou verde
  • Comprimento entre 10 cm e 20 cm

A peça entra em uma caixa somente se as três regras forem atendidas. Cada caixa fecha ao chegar em 10 peças.

Últimas inspeções

Peça Peso Cor Comprimento Resultado Caixa

Nenhuma peça encontrada com esse filtro.

Nenhuma caixa fechada ainda. A caixa fecha ao receber a 10ª peça aprovada.

Tecnologias

Tecnologias usadas

O Trabalho Unifecaf junta estas peças. Cada uma faz um trabalho só.

  • Python — é a linguagem do servidor. As regras de qualidade, o cadastro e o login estão escritos nela.
  • Flask — publica a página e a API. Recebe o pedido do navegador e devolve JSON ou o HTML.
  • Jinja — monta templates/index.html. O Flask chama render_template e entrega essa página na rota inicial.
  • MySQL remoto, via PyMySQL — guarda peças, caixas, usuários e eventos. O banco se chama trabalho5. A conexão usa SSL com certificado CA.
  • JWT, com PyJWT — assina o token de acesso e o de renovação. O programa confere a assinatura antes de aceitar o pedido.
  • bcrypt — transforma a senha em um hash. O texto da senha não fica no banco.
  • JavaScript no navegador — em static/js/app.js, troca as telas e chama a API. Não há script escrito dentro do HTML.
  • Nginx, na produção — recebe o HTTPS, redireciona o HTTP e encaminha o pedido ao Flask em 127.0.0.1:5000.

Funções do sistema

O caminho de uma peça

O operador informa identificador, peso, cor e comprimento. O sistema confere os três critérios. Se os três passam, a peça entra na caixa que está aberta. Quando essa caixa chega a 10 peças, ela fecha e a próxima aprovada abre outra.

Se o peso, a cor ou o comprimento falham, a peça fica reprovada. Todos os motivos são guardados, não só o primeiro. Essa peça não entra em caixa. Remover apaga só a peça escolhida. O relatório conta, no fim, quantas falharam em cada critério.

Cadastrar

O cadastro só segue com identificador, peso e comprimento numéricos e cor preenchida. Identificador repetido é recusado. A inspeção roda na hora, dentro de cadastrar_peca.

A caixa só é procurada quando a peça foi aprovada. Sem caixa aberta, o sistema abre a de número seguinte.

app/servicos.py

            caixa = None
            fechou = False
            if aprovada:
                caixa = _caixa_aberta(cur) or _abrir_caixa(cur)

Inspecionar

O peso precisa ficar entre 95 g e 105 g. A cor, sem diferenciar maiúsculas, precisa ser azul ou verde. O comprimento precisa ficar entre 10 cm e 20 cm. Cada falha entra na lista de motivos.

A peça só é aprovada se essa lista termina vazia. Por isso peso, cor e comprimento errados ficam os três registrados.

app/qualidade.py

PESO_MIN = 95.0
PESO_MAX = 105.0
COMPRIMENTO_MIN = 10.0
COMPRIMENTO_MAX = 20.0
CORES_PERMITIDAS = ("azul", "verde")
CAPACIDADE_CAIXA = 10

Este é o teste da cor. Peso e comprimento seguem o mesmo formato: se saem da faixa, um motivo é acrescentado.

app/qualidade.py

    if cor_normalizada not in CORES_PERMITIDAS:
        permitidas = " ou ".join(CORES_PERMITIDAS)
        motivos.append(
            {
                "criterio": "cor",
                "descricao": f"Cor '{cor}' não permitida. Aceitas: {permitidas}.",
            }
        )

No fim, lista vazia significa aprovada.

app/qualidade.py

    return len(motivos) == 0, motivos

Caixa de 10

A peça reprovada também é gravada, com os motivos. O campo da caixa fica vazio, porque não havia caixa para ela. Assim ela não ocupa vaga.

app/servicos.py

                    json.dumps(motivos, ensure_ascii=False) if motivos else None,
                    caixa["id"] if caixa else None,

Depois de guardar uma aprovada, o programa conta as peças daquela caixa. Se a conta chega à capacidade, que é 10, a caixa passa a fechada. A próxima aprovada abre outra.

app/servicos.py

                if ocupacao >= int(caixa["capacidade"]):
                    cur.execute(
                        """
                        UPDATE caixas
                        SET status = 'fechada', fechada_em = NOW()
                        WHERE id = %s
                        """,
                        (caixa["id"],),
                    )

Remover

remover_peca apaga somente a peça escolhida. A caixa continua no histórico, mesmo vazia.

app/servicos.py

            cur.execute("DELETE FROM pecas WHERE id = %s", (identificador,))

Se a peça saiu de uma caixa já fechada e a quantidade ficou abaixo de 10, essa caixa reabre só quando não existe outra aberta. Se já houver caixa em enchimento, a fechada permanece fechada, para a linha não ter duas abertas ao mesmo tempo.

app/servicos.py

                if peca["caixa_status"] == "fechada" and ocupacao < capacidade:
                    aberta = _caixa_aberta(cur)
                    if aberta is None:
                        cur.execute(
                            """
                            UPDATE caixas
                            SET status = 'aberta', fechada_em = NULL
                            WHERE id = %s
                            """,
                            (peca["caixa_id"],),
                        )

Relatório

O relatório percorre as peças reprovadas. Cada critério que aparece nos motivos soma um. A mesma peça pode contar em peso, cor e comprimento.

app/servicos.py

    for peca in reprovadas:
        criterios = {m["criterio"] for m in (peca.get("motivos") or [])}
        for criterio in criterios:
            if criterio in contagem:
                contagem[criterio] += 1

Banco, sem apagar o que já existe

Na subida, init_db liga no MySQL remoto com SSL e certificado CA. Cria o banco e as tabelas só se ainda não existirem. Peças, caixas, usuários e eventos gravados permanecem.

app/db.py

            cur.execute(
                f"CREATE DATABASE IF NOT EXISTS `{nome}` "
                "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
            )

Login e sessão

Entrar com e-mail e senha

O operador envia e-mail e senha. O servidor confere o hash e, se estiver certo, devolve dois tokens. O de acesso dura 15 minutos e vem no JSON. O de renovação dura 7 dias e vai em cookie.

app/seguranca.py

ACCESS_SEGUNDOS = 15 * 60
REFRESH_DIAS = 7

A resposta leva o token curto no corpo e grava o longo no cookie.

app/auth.py

    resposta = jsonify({
        "message": "Login realizado com sucesso",
        "erro": None,
        "accessToken": acesso,
        "expiresIn": ACCESS_SEGUNDOS,
        "user": publico,
    })
    return _definir_refresh(resposta, refresh)

Token de acesso na memória

O JavaScript guarda o token de acesso na variável accessToken. Ela vive só na memória da aba. Cada chamada da API envia esse valor no cabeçalho Authorization: Bearer.

static/js/app.js

let accessToken = null;

Depois do login, o valor que veio no JSON entra nessa variável. Não é gravado em arquivo nem em armazenamento do navegador.

static/js/app.js

    accessToken = corpo.accessToken;

static/js/app.js

  if (accessToken) headers.Authorization = `Bearer ${accessToken}`;

Por que não fica no localStorage

O localStorage permanece no navegador e qualquer script da página consegue lê-lo. O token de acesso não é colocado lá. Ele fica só na variável, e some quando a aba fecha.

O token que dura 7 dias é o de renovação, e esse vai no cookie HttpOnly. O JavaScript não lê esse cookie. Assim um script da página não copia o token longo. O de acesso, mais curto, só existe enquanto a aba está aberta.

Renovação automática

Se uma chamada da API responde 401, o navegador pede um token novo em /api/auth/refresh, enviando o cookie. O servidor devolve outro access token, de novo só para a memória. Se a renovação falha, a tela de login volta.

static/js/app.js

  if (resposta.status === 401 && repetir && !url.includes("/api/auth/")) {
    const renovou = await renovarSessao();

static/js/app.js

    renovando = fetch("/api/auth/refresh", { method: "POST", credentials: "include" })

Medidas de segurança

Senha com bcrypt

A senha digitada não é gravada. O que vai para a coluna senha_hash é o resultado do bcrypt, com custo 12. Conferir a senha compara o que foi digitado com esse hash.

app/seguranca.py

def hash_senha(senha: str) -> str:
    return bcrypt.hashpw(senha.encode(), bcrypt.gensalt(rounds=12)).decode()

Comparação em tempo constante

Se o e-mail não existe, ou se o valor no banco não é um hash bcrypt, o programa ainda assim roda a comparação contra um hash fixo. A resposta não volta mais rápido nesse caso, e por isso não revela se a conta está cadastrada. E-mail desconhecido e senha errada recebem a mesma mensagem: credenciais inválidas.

app/seguranca.py

def conferir_senha(senha: str, senha_hash: str | None) -> bool:
    material = (senha_hash or "").encode()
    if not material.startswith(b"$2"):
        material = _DUMMY
    return bcrypt.checkpw(senha.encode(), material)

Bloqueio após 5 falhas

Cada senha errada de uma conta que existe soma uma falha. Na quinta, o usuário fica bloqueado por 15 minutos. Enquanto esse prazo não passa, o login responde que houve tentativas demais.

app/auth.py

                    if falhas >= 5:
                        cur.execute(
                            """
                            UPDATE usuarios
                            SET falhas_login = %s, bloqueado_ate = DATE_ADD(NOW(), INTERVAL 15 MINUTE)
                            WHERE id = %s
                            """,
                            (falhas, usuario["id"]),
                        )

Limite de tentativas

Além do bloqueio da conta, o mesmo e-mail só pode tentar o login 30 vezes em 15 minutos. Esse contador fica na memória do processo e é verificado antes da senha. Acima do limite, a resposta é 429.

app/seguranca.py

JANELA_LOGIN = 15 * 60
MAX_LOGIN = 30

app/seguranca.py

    if len(recentes) >= MAX_LOGIN:
        raise ErroOperacao(
            "Muitas tentativas de login. Tente novamente em alguns minutos.",
            429,
            "RATE_LIMIT_EXCEEDED",
        )

Cookie HttpOnly, SameSite e Secure

O cookie refreshToken não pode ser lido pelo JavaScript (HttpOnly). SameSite estrito evita que outro site envie esse cookie. Secure liga quando COOKIE_SECURE é verdadeiro ou, se essa opção não estiver definida, quando o ambiente é produção. O caminho do cookie é só /api/auth.

app/seguranca.py

def opcoes_cookie() -> dict:
    return {
        "httponly": True,
        "secure": cookie_seguro(),
        "samesite": "Strict",
        "max_age": REFRESH_DIAS * 24 * 60 * 60,
        "path": "/api/auth",
    }

Rotação do refresh

Cada renovação emite outro token e substitui o que está gravado no usuário. O cookie antigo deixa de coincidir com o banco, então não serve de novo.

app/auth.py

            acesso, novo = emitir_tokens(usuario["id"])
            cur.execute("UPDATE usuarios SET refresh_token = %s WHERE id = %s", (novo, usuario["id"]))

O token de renovação também carrega um resumo do navegador e do IP. Se essa origem muda, a sessão é encerrada.

Cabeçalhos de segurança

Toda resposta leva estes cabeçalhos. A política de conteúdo só aceita script da própria origem. O frame é negado, o navegador não tenta adivinhar o tipo do arquivo, a referência enviada é restrita e câmera, microfone e localização ficam desligados. O HSTS, que obriga HTTPS, só sai quando o ambiente é produção.

app/seguranca.py

    resposta.headers["X-Content-Type-Options"] = "nosniff"
    resposta.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    resposta.headers["X-Frame-Options"] = "DENY"
    resposta.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
    resposta.headers["Content-Security-Policy"] = (
        "default-src 'self'; "
        "script-src 'self'; "
        "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
        "font-src 'self' https://fonts.gstatic.com; "
        "img-src 'self' data:; "
        "connect-src 'self'; "
        "frame-ancestors 'none'; "
        "base-uri 'self'; "
        "form-action 'self'"
    )
    if producao():
        resposta.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"

Auditoria

Login certo, login inválido, bloqueio, conta inativa, sessão suspeita, saída e troca de senha geram uma linha em eventos_seguranca, com tipo, e-mail, IP e um detalhe curto.

app/auth.py

                INSERT INTO eventos_seguranca (tipo, email, ip, detalhe)
                VALUES (%s, %s, %s, %s)

Troca de senha

A senha nova precisa ter pelo menos 8 caracteres, com letra maiúscula, letra minúscula e número. Ao salvar, o hash muda e o refresh é apagado: a sessão longa deixa de valer e é preciso entrar de novo.

A senha de avaliação trabalho123 não cumpre essa política, porque não tem maiúscula. Ela entra na conta semeada; uma senha nova, trocada na tela, precisa ser forte.

app/seguranca.py

    if len(senha) < 8 or not any(c.isupper() for c in senha) or not any(c.islower() for c in senha) or not any(c.isdigit() for c in senha):

app/auth.py

            cur.execute(
                "UPDATE usuarios SET senha_hash = %s, refresh_token = NULL WHERE id = %s",
                (hash_senha(nova), usuario["id"]),
            )