diff --git a/sapl/materia/assinatura_api_client.py b/sapl/materia/assinatura_api_client.py index 7a3a7b536..ccddb8344 100644 --- a/sapl/materia/assinatura_api_client.py +++ b/sapl/materia/assinatura_api_client.py @@ -14,13 +14,47 @@ Contrato real da API (descoberto via /openapi.json): Resposta 200: Content-Type: application/pdf → bytes do PDF assinado Resposta 4xx/5xx: JSON com campo "detail" +A partir da fase C3 (AB#1473) o /sign também sabe COMPOR a página de autenticação +(código, URL de verificação, QR e os blocos da grade) — capacidade que antes só o +SAPL tinha. Quem quer delegar a composição usa +`assinar_pdf_com_pagina_autenticacao()`, que manda o bloco de parâmetros do +`auth_page` e devolve os cabeçalhos de resposta junto com o PDF. + Outros endpoints: POST /validate-pfx – valida certificado PFX POST /validate – valida assinaturas de um PDF - POST /sign/batch – assina múltiplos PDFs em lote + POST /sign/batch – carimba em lote (ver a regra abaixo) GET / – health check + +Duas composições convivem aqui — qual usar +------------------------------------------ +Quem **compõe** a página de autenticação (código, URL de verificação, QR, blocos +da grade) decide qual função chamar: + +- **Composição no microserviço** — `assinar_pdf_com_pagina_autenticacao()`, um + documento por vez. É o caminho da C3 e o padrão para assinatura individual de + matéria e de documento acessório: o SAPL manda os dados da casa legislativa e + recebe o PDF já composto, o código no `X-Codigo-Autenticacao` e os metadados do + certificado via `/validate-pfx`. +- **Composição no SAPL** — `assinar_pdf_lote_via_api()`, para as telas "Assinar + Despachos em Lote". O `/sign/batch` **não compõe**: seus parâmetros valem para + o lote inteiro, e a composição é por documento. Então o chamador compõe cada + PDF localmente e manda coordenadas explícitas; o batch só carimba. + +As duas produzem o mesmo artefato — a grade e o algoritmo do código são os +mesmos dos dois lados (sha256[:16].upper(), congelado). O que muda é onde o +desenho acontece. + +Mandar para `assinar_pdf_lote_via_api()` um PDF **não composto** produz documento +sem página de autenticação e sem código verificável — é o erro que a condição de +validade no docstring daquela função existe para evitar. + +Antes de mudar esse desenho (por exemplo, fazer o lote delegar), fale com o +arquiteto: a decisão da C3 (AB#1473) é que o microserviço é o centro evolutivo +das assinaturas, e um lote delegado exige `auth_page` por item, do lado de lá. """ +import json import logging import requests @@ -56,6 +90,178 @@ def _url(endpoint): return f'{base}/{endpoint}' +class ResultadoAssinatura: + """ + Resposta do /sign quando a composição da página é delegada ao microserviço. + + Além do PDF, o microserviço devolve em cabeçalhos o que só ele sabe depois de + compor: o código impresso na página (que o cidadão digita na verificação + pública) e qual bloco da grade esta assinatura ocupou. + """ + + __slots__ = ('pdf', 'codigo_autenticacao', 'signature_index', + 'auth_page_aplicada', 'auth_page_suportado') + + def __init__(self, pdf, codigo_autenticacao, signature_index, + auth_page_aplicada, auth_page_suportado): + self.pdf = pdf + self.codigo_autenticacao = codigo_autenticacao + self.signature_index = signature_index + #: True quando ESTA chamada anexou a página de autenticação + self.auth_page_aplicada = auth_page_aplicada + #: False quando o microserviço não devolveu os cabeçalhos do auth_page — + #: ou seja, é uma versão anterior à C2 e ignorou os parâmetros enviados. + self.auth_page_suportado = auth_page_suportado + + +def _post_sign(files, data): + """ + POST /sign com o tratamento de erro comum aos dois modos de uso. + + Retorna o `requests.Response` de sucesso; lança AssinaturaAPIError caso + contrário. O corpo da resposta é o PDF assinado. + """ + timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120) + + try: + response = requests.post( + _url('sign'), + files=files, + data=data, + headers=_montar_headers(), + timeout=timeout, + ) + except requests.exceptions.ConnectionError as exc: + logger.error(f'[assinatura-api] Falha de conexao: {exc}') + raise AssinaturaAPIError( + 'Nao foi possivel conectar ao microservico de assinatura. ' + 'Verifique se o servico esta disponivel.' + ) + except requests.exceptions.Timeout: + raise AssinaturaAPIError( + f'Timeout ao aguardar resposta do microservico de assinatura ' + f'(limite: {timeout}s).' + ) + except requests.exceptions.RequestException as exc: + logger.error(f'[assinatura-api] Erro inesperado: {exc}') + raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}') + + if not response.ok: + try: + detail = response.json() + msg = detail.get('detail') or detail.get('error') or str(detail) + except Exception: + msg = response.text[:300] or f'HTTP {response.status_code}' + logger.error(f'[assinatura-api] Erro HTTP {response.status_code}: {msg}') + raise AssinaturaAPIError(msg, status_code=response.status_code) + + # A API retorna o PDF assinado diretamente como application/pdf + content_type = response.headers.get('Content-Type', '') + if 'pdf' not in content_type and len(response.content) < 100: + raise AssinaturaAPIError( + f'Resposta inesperada do microservico (Content-Type: {content_type}).' + ) + + return response + + +def assinar_pdf_com_pagina_autenticacao( + pdf_bytes, *, certificado_bytes, senha, + verification_url_base, assinaturas, + casa_legislativa=None, signer_name=None, signer_role=None, + codigo_autenticacao=None, brasao_bytes=None, brasao_filename='brasao.png', + reason=None, location=None): + """ + Assina delegando ao microserviço a composição da página de autenticação. + + Diferença para `assinar_pdf_via_api`: aqui o SAPL **não** monta a página nem + calcula a posição do bloco — manda os dados da casa legislativa e o + microserviço compõe (fase C3 da convergência dos assinadores, AB#1473). + Por isso nenhum `signature_*` é enviado: quem conhece a grade é o + microserviço, e mandar coordenada junto faz o `auth_page` e a posição + explícita disputarem o mesmo campo. + + Parâmetros + ---------- + verification_url_base : str — URL pública de verificação SEM o `?codigo=` + (o microserviço acrescenta) + assinaturas : list[dict] — blocos a desenhar + ([{nome_assinante, cargo, data_assinatura}]) + casa_legislativa : str — nome impresso no cabeçalho da página + signer_name / signer_role — nome e cargo exibidos (no legislativo, o nome + parlamentar do vereador, não o CN do certificado) + codigo_autenticacao : str — código já emitido. OBRIGATÓRIO da segunda + assinatura em diante: o PDF mudou ao ser + assinado e o hash de agora não reproduz o que + está impresso na página. Na primeira, deixe + vazio — o microserviço deriva do próprio PDF. + brasao_bytes : bytes — brasão da câmara. Mande em TODAS as assinaturas + da mesma matéria: ele desloca a grade em 3 mm e + omiti-lo depois desalinharia os blocos seguintes. + + Retorna: ResultadoAssinatura. + Lança: AssinaturaAPIError em caso de erro. + """ + if not _api_configurada(): + raise AssinaturaAPIError( + 'Microserviço de assinatura não configurado (ASSINATURA_API_URL vazio).' + ) + + files = { + 'pdf': ('documento.pdf', pdf_bytes, 'application/pdf'), + 'pfx': ('certificado.pfx', certificado_bytes, 'application/octet-stream'), + } + if brasao_bytes: + files['brasao'] = (brasao_filename, brasao_bytes, 'application/octet-stream') + + data = { + 'pfx_password': senha, + 'auth_page': 'auto', + 'verification_url_base': verification_url_base, + 'assinaturas': json.dumps(assinaturas or [], ensure_ascii=False), + } + if casa_legislativa: + data['casa_legislativa'] = casa_legislativa + if signer_name: + data['signer_name'] = signer_name + if signer_role: + data['signer_role'] = signer_role + if codigo_autenticacao: + data['codigo_autenticacao'] = codigo_autenticacao + if reason: + data['reason'] = reason + if location: + data['location'] = location + + response = _post_sign(files, data) + + headers = response.headers + # A ausência do cabeçalho é o sinal de que o microserviço IGNOROU o auth_page + # (versão anterior à C2). Quem chama precisa saber: sem a composição, o PDF + # sai sem página de autenticação e sem código verificável. + auth_page_suportado = 'X-Auth-Page-Applied' in headers + + try: + signature_index = int(headers.get('X-Signature-Index', '')) + except (TypeError, ValueError): + signature_index = None + + resultado = ResultadoAssinatura( + pdf=response.content, + codigo_autenticacao=(headers.get('X-Codigo-Autenticacao') or '').strip(), + signature_index=signature_index, + auth_page_aplicada=(headers.get('X-Auth-Page-Applied', '').strip().lower() + == 'true'), + auth_page_suportado=auth_page_suportado, + ) + logger.info( + '[assinatura-api] PDF assinado com pagina de autenticacao composta pelo ' + f'microservico (bloco={resultado.signature_index}, ' + f'pagina_anexada={resultado.auth_page_aplicada}).' + ) + return resultado + + def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha, reason=None, location=None, signature_page=None, @@ -64,6 +270,10 @@ def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha, """ Envia o PDF ao pyHanko Sign Service e retorna o PDF assinado como bytes. + Modo "posição explícita": o PDF já chega composto e o chamador diz onde o + carimbo vai. Para delegar a composição da página de autenticação ao + microserviço, use `assinar_pdf_com_pagina_autenticacao`. + Parâmetros ---------- pdf_bytes : bytes — PDF a assinar @@ -103,46 +313,7 @@ def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha, if signature_height is not None: data['signature_height'] = str(signature_height) - timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120) - - try: - response = requests.post( - _url('sign'), - files=files, - data=data, - headers=_montar_headers(), - timeout=timeout, - ) - except requests.exceptions.ConnectionError as exc: - logger.error(f'[assinatura-api] Falha de conexao: {exc}') - raise AssinaturaAPIError( - 'Nao foi possivel conectar ao microservico de assinatura. ' - 'Verifique se o servico esta disponivel.' - ) - except requests.exceptions.Timeout: - raise AssinaturaAPIError( - f'Timeout ao aguardar resposta do microservico de assinatura ' - f'(limite: {timeout}s).' - ) - except requests.exceptions.RequestException as exc: - logger.error(f'[assinatura-api] Erro inesperado: {exc}') - raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}') - - if not response.ok: - try: - detail = response.json() - msg = detail.get('detail') or detail.get('error') or str(detail) - except Exception: - msg = response.text[:300] or f'HTTP {response.status_code}' - logger.error(f'[assinatura-api] Erro HTTP {response.status_code}: {msg}') - raise AssinaturaAPIError(msg, status_code=response.status_code) - - # A API retorna o PDF assinado diretamente como application/pdf - content_type = response.headers.get('Content-Type', '') - if 'pdf' not in content_type and len(response.content) < 100: - raise AssinaturaAPIError( - f'Resposta inesperada do microservico (Content-Type: {content_type}).' - ) + response = _post_sign(files, data) logger.info('[assinatura-api] PDF assinado com sucesso pelo microservico.') return response.content @@ -152,8 +323,25 @@ def assinar_pdf_lote_via_api(itens, *, certificado_bytes, senha, reason=None, location=None, download_workers=8): """ - Assina múltiplos PDFs em uma única chamada POST /sign/batch e baixa os - resultados em paralelo via download_url do S3. + Assina em lote PDFs **que já chegam compostos e posicionados**, via POST + /sign/batch, e baixa os resultados em paralelo pelo download_url do S3. + + Condição de validade — leia antes de usar + ----------------------------------------- + O `/sign/batch` **não compõe** a página de autenticação: ele recebe um único + conjunto de parâmetros para o lote inteiro, e a composição é por documento + (a URL de verificação carrega o id da matéria, o código sai do hash daquele + PDF). Aqui o batch é **só carimbo**. + + Por isso o chamador precisa, para cada item: compor a página localmente + (`_compor_pagina_auth_localmente` e companhia, em views_assinatura), gerar o + código, montar a URL de verificação com o pk e calcular o bloco da grade — + e então mandar as coordenadas explícitas. É o que as telas "Assinar + Despachos em Lote" fazem (`docacessorio_assinar_lote`, `materia_assinar_lote`). + + Mandar um PDF **não composto** para cá produz documento sem página de + autenticação e sem código verificável. Se o que você quer é delegar a + composição, o caminho é `assinar_pdf_com_pagina_autenticacao` (um a um). Parâmetros ---------- diff --git a/sapl/materia/views_assinatura.py b/sapl/materia/views_assinatura.py index b0ea55c7c..b8dc8394e 100644 --- a/sapl/materia/views_assinatura.py +++ b/sapl/materia/views_assinatura.py @@ -85,8 +85,198 @@ def _extrair_posicao_custom(post_data): 'sig_height': float(sig_height), 'sig_page': int(sig_page), } - except (ValueError, TypeError): + except (ValueError, TypeError) as exc: + # Descartar a posição em silêncio faz a assinatura cair no bloco automático + # da grade em vez de onde foi pedido — diferença de layout que ninguém + # consegue explicar depois olhando só o PDF. + logger.warning( + f'Posicao customizada da assinatura ignorada (valores invalidos): {exc}' + ) + return None + + +class ErroAssinaturaUsuario(Exception): + """ + Erro que o operador entende e consegue corrigir sozinho. + + Senha errada, arquivo que não é um .pfx, certificado vencido: nada disso é + falha do sistema, e responder 500 com o texto cru do erro só assusta quem + está assinando. Separado do Exception genérico para as views devolverem 400 + com a mensagem — que é o que o caminho local já fazia em `_validar_certificado`. + """ + + +def _erro_de_assinatura(exc): + """ + Converte um AssinaturaAPIError na exceção certa para o chamador levantar. + + HTTP 400 do microserviço é recusa de validação (certificado fora da validade, + senha incorreta, parâmetro faltando) — problema do usuário. Qualquer outra + coisa (conexão, timeout, 5xx) é falha de infraestrutura e continua sendo erro + genérico. + """ + if getattr(exc, 'status_code', None) == 400: + return ErroAssinaturaUsuario(str(exc)) + return Exception(str(exc)) + + +def _metadados_certificado_via_api(certificado_bytes, senha): + """ + Lê os metadados do certificado no microserviço (POST /validate-pfx). + + Existe por dois motivos: + + 1. Preencher `subject`, `issuer`, `serial`, `valid_from` e `valid_to` no + `assinatura_info`. Quando o SAPL delega a assinatura, ele não abre o PFX — + e sem esta chamada esses campos ficavam string vazia, deixando emissor, + série e validade em branco em tudo que lê o `assinatura_info`. + 2. Recusar certificado fora da validade antes de assinar, com a mesma + mensagem do caminho local (`_validar_certificado`). + + A senha é usada só nesta requisição e não é persistida em lugar nenhum. + + Retorna dict com as chaves já no formato do `assinatura_info` (vazio se o + microserviço não respondeu). Lança ErroAssinaturaUsuario para PFX recusado. + """ + from sapl.materia.assinatura_api_client import ( + AssinaturaAPIError, validar_pfx_via_api, + ) + + try: + info = validar_pfx_via_api(certificado_bytes, senha) or {} + except AssinaturaAPIError as exc: + if getattr(exc, 'status_code', None) == 400: + # PFX ilegível ou senha incorreta: o /sign recusaria do mesmo jeito, + # e aqui a mensagem chega antes de enviar o documento. + raise ErroAssinaturaUsuario(str(exc)) + # Indisponibilidade do /validate-pfx não pode impedir a assinatura: o + # /sign também recusa certificado vencido. Só perdemos os metadados. + logger.warning( + f'[assinatura-api] Nao foi possivel ler os metadados do certificado ' + f'(/validate-pfx): {exc}. A assinatura segue sem eles.' + ) + return {} + + if info.get('is_valid_now') is False: + validade = ' a '.join( + v for v in (info.get('not_valid_before'), info.get('not_valid_after')) if v + ) + raise ErroAssinaturaUsuario( + 'Certificado expirado ou ainda não válido.' + + (f' Validade: {validade}.' if validade else '') + ) + + serial = info.get('serial_number') + return { + 'subject': info.get('subject') or '', + 'issuer': info.get('issuer') or '', + 'serial': '' if serial is None else str(serial), + 'valid_from': info.get('not_valid_before') or '', + 'valid_to': info.get('not_valid_after') or '', + } + + +def _nome_e_cargo_do_assinante(request): + """ + Nome e cargo que vão IMPRESSOS no documento, via Autor.operadores → Parlamentar. + + Fonte única para os dois backends. Existia em duas cópias — uma aqui e outra + inline no ramo da API — que hoje coincidiam por acaso: mudar o critério em uma + faria o mesmo vereador sair com nomes diferentes conforme o backend da casa. + + Quem assina é o vereador, então o nome impresso é o dele. O operador (assessor) + fica registrado à parte, em `signed_by`. + """ + nome_assinante = request.user.get_full_name() or request.user.username + cargo = 'Usuário do Sistema' + + try: + from sapl.base.models import Autor + from sapl.parlamentares.models import Parlamentar + + autor = Autor.objects.filter(operadores=request.user).first() + if autor: + # Tipo do autor vira cargo (ex.: "Parlamentar" → "Vereador(a)") + tipo_descricao = autor.tipo.descricao if autor.tipo else '' + if tipo_descricao == 'Parlamentar': + cargo = 'Vereador(a)' + elif tipo_descricao: + cargo = tipo_descricao + + if isinstance(autor.autor_related, Parlamentar): + parlamentar = autor.autor_related + tipo_nome = AppConfig.attr('assinatura_nome') + nome_assinante = ( + parlamentar.nome_completo if tipo_nome == 'C' + else parlamentar.nome_parlamentar + ) + except Exception as exc: + # Cair no username do operador significa imprimir no PDF um nome que não é + # o do vereador. Não derruba a assinatura, mas não pode passar em silêncio. + logger.warning( + f'Nao foi possivel resolver o parlamentar de {request.user.username}: ' + f'{exc}. A assinatura sai com "{nome_assinante}".' + ) + + return nome_assinante, cargo + + +def _tipo_certificado_pelo_emissor(issuer): + """Rotula o certificado a partir do emissor (mesma regra nos dois backends).""" + issuer_str = str(issuer or '').upper() + if 'ICP-BRASIL' in issuer_str or 'ICP BRASIL' in issuer_str: + return 'ICP-Brasil' + return 'Certificado Digital' + + +def _ler_brasao(): + """ + Bytes do logotipo da casa, ou None. + + Precisa ir em TODAS as assinaturas da mesma matéria: o brasão desloca a grade + de blocos em 3 mm, então mandá-lo só na primeira desalinharia os blocos + seguintes com o que já está desenhado na página. + """ + caminho = _encontrar_logo() + if not caminho: return None + try: + with open(caminho, 'rb') as f: + return f.read() + except OSError as exc: + logger.warning(f'Nao foi possivel ler o brasao em {caminho}: {exc}') + return None + + +def _compor_pagina_auth_localmente(pdf_bytes, request, tipo_doc, pk_doc, blocos): + """ + Anexa a página de autenticação ao PDF aqui mesmo (caminho pré-C3). + + Continua em uso pelo backend local (pyhanko no Django) e pelo caso de posição + explícita, em que o microserviço não pode compor. Retorna (pdf, codigo). + """ + from PyPDF4 import PdfFileReader, PdfFileWriter + + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + + codigo = _gerar_codigo_autenticacao(pdf_bytes) + url_verificacao = _construir_url_verificacao(request, tipo_doc, pk_doc, codigo) + auth_page_bytes = _gerar_pagina_autenticacao( + blocos, codigo, url_verificacao, page_width, page_height + ) + + auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output_pdf = PdfFileWriter() + for page_num in range(original_pdf.getNumPages()): + output_pdf.addPage(original_pdf.getPage(page_num)) + output_pdf.addPage(auth_page_pdf.getPage(0)) + + buf = io.BytesIO() + output_pdf.write(buf) + return buf.getvalue(), codigo def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc, @@ -94,22 +284,38 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc, certificado_bytes=None, senha=None, assinatura_a3_bytes=None, cert_chain_bytes=None, tipo_cert_input='a1', - posicao_custom=None): + posicao_custom=None, + hash_doc=''): """ - Orquestra a assinatura digital completa de um PDF: - - Monta a página de autenticação (1ª assinatura) ou assina incrementalmente - (assinaturas subsequentes). - - Usa a API externa quando ASSINATURA_API_URL estiver configurado; - caso contrário usa pyhanko localmente. + Orquestra a assinatura digital completa de um PDF. + + Dois backends, escolhidos por ASSINATURA_API_URL: + + - **API externa (padrão quando configurada)**: desde a fase C3 (AB#1473) quem + compõe a página de autenticação é o microserviço. O SAPL manda os dados da + casa legislativa (URL de verificação, nome, brasão, bloco do assinante) e + recebe de volta o PDF já composto e assinado, mais o código impresso. Não + manda posição: a grade é do microserviço, e é isso que faz SAPL e app do AMU + produzirem o mesmo artefato sem reimplementar o desenho. + - **pyhanko local (ASSINATURA_API_URL vazio)**: compõe a página aqui e assina + no próprio Django, como sempre fez. posicao_custom : dict opcional com chaves sig_left, sig_bottom, sig_width, sig_height (em pontos PDF) e sig_page (1-based). - Quando fornecido, substitui o cálculo automático de grid. + Quando fornecido, substitui o cálculo automático de grid — + e, na API externa, mantém a composição local, porque + coordenada explícita e composição no microserviço disputam + o mesmo campo. + hash_doc : código de autenticação já emitido para o documento. Vai para + o carimbo visual no backend local e, na API externa, é o + `codigo_autenticacao` devolvido ao microserviço da 2ª + assinatura em diante. Retorna (signed_pdf_bytes, nova_assinatura_dict, codigo_autenticacao_ou_None). - codigo_autenticacao_ou_None é não-None apenas na 1ª assinatura. - Lança Exception em caso de erro. + Lança ErroAssinaturaUsuario quando o problema é do operador (certificado + vencido, senha incorreta) e Exception nos demais casos. """ from PyPDF4 import PdfFileReader, PdfFileWriter import base64 as _base64 @@ -117,122 +323,124 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc, ja_tem_pdf_assinado = bool(assinaturas_existentes) # ── Informações do assinante ────────────────────────────────────────────── - # Para API externa: não há signer local; tentamos extrair info do certificado - # de forma leve (apenas para metadados visuais). + # Na API externa o SAPL não abre o PFX: quem assina é o microserviço. O nome + # que vai no PDF é o do parlamentar (relação User → Autor), e os dados do + # certificado vêm do /validate-pfx. if _usar_api_externa(): - from sapl.materia.assinatura_api_client import assinar_pdf_via_api, AssinaturaAPIError + from sapl.materia.assinatura_api_client import ( + AssinaturaAPIError, + assinar_pdf_com_pagina_autenticacao, + assinar_pdf_via_api, + ) - # Obter nome/cargo via relação User → Autor (sem pyhanko) - nome_assinante = request.user.get_full_name() or request.user.username - cargo = 'Usuário do Sistema' + # O tipo do certificado só é conhecido de verdade pelo /validate-pfx (C3); + # este é o rótulo de partida, sobrescrito quando os metadados chegam. tipo_cert_display = 'Certificado Digital' - try: - from sapl.base.models import Autor - from sapl.parlamentares.models import Parlamentar - autor = Autor.objects.filter(operadores=request.user).first() - if autor: - tipo_desc = autor.tipo.descricao if autor.tipo else '' - if tipo_desc == 'Parlamentar': - cargo = 'Vereador(a)' - elif tipo_desc: - cargo = tipo_desc - if isinstance(autor.autor_related, Parlamentar): - parl = autor.autor_related - tipo_nome = AppConfig.attr('assinatura_nome') - nome_assinante = parl.nome_completo if tipo_nome == 'C' else parl.nome_parlamentar - except Exception: - pass + # Nome/cargo pela MESMA função do backend local: o vereador não pode sair + # com nome diferente conforme a casa usa microserviço ou pyhanko. + nome_assinante, cargo = _nome_e_cargo_do_assinante(request) data_assinatura = timezone.localtime(timezone.now()) data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') codigo_retorno = None - if not ja_tem_pdf_assinado: - # 1ª assinatura: montar PDF com página de autenticação ANTES de enviar - original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) - last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) - page_width = float(last_page.mediaBox.getWidth()) - page_height = float(last_page.mediaBox.getHeight()) - - codigo = _gerar_codigo_autenticacao(pdf_bytes) - url_verificacao = _construir_url_verificacao(request, tipo_doc, pk_doc, codigo) - - nova_assinatura_info_visual = { - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_simples, - } - auth_page_bytes = _gerar_pagina_autenticacao( - [nova_assinatura_info_visual], codigo, url_verificacao, - page_width, page_height - ) - - auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PdfFileWriter() - for page_num in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(page_num)) - output_pdf.addPage(auth_page_pdf.getPage(0)) + # Metadados do certificado: o /validate-pfx responde ANTES de assinar quem + # emitiu, qual a série e até quando vale. Sem esta chamada esses campos iam + # vazios para o assinatura_info — e o SAPL exibia "Válido até:" em branco. + # Ela também é a checagem de validade que o caminho local faz em + # `_validar_certificado`: certificado vencido não chega a ser enviado. + cert_api = _metadados_certificado_via_api(certificado_bytes, senha) - buf_combinado = io.BytesIO() - output_pdf.write(buf_combinado) - pdf_para_assinar = buf_combinado.getvalue() - codigo_retorno = codigo - else: - pdf_para_assinar = pdf_bytes # já inclui página de autenticação + # O bloco desta assinatura — o único que a página precisa desenhar agora. + bloco_atual = { + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + } - # Calcula a posição do campo de assinatura na última página (página de autenticação) - # para que a assinatura visual fique no bloco correto do grid. - # Se posicao_custom foi fornecido pelo usuário, usa ele diretamente. if posicao_custom: - sig_page = posicao_custom.get('sig_page') - sig_left = posicao_custom.get('sig_left') - sig_bottom = posicao_custom.get('sig_bottom') - sig_width = posicao_custom.get('sig_width') - sig_height = posicao_custom.get('sig_height') + # Posição explícita pedida pelo chamador. Quando o microserviço compõe a + # página, é ELE quem decide a posição do bloco (a grade é dele), então + # `auth_page` e coordenada explícita não convivem: pedir os dois faria a + # coordenada do usuário ser silenciosamente descartada. Neste caso — e só + # nele — a composição continua sendo feita aqui, como antes da C3. + if not ja_tem_pdf_assinado: + pdf_para_assinar, codigo_retorno = _compor_pagina_auth_localmente( + pdf_bytes, request, tipo_doc, pk_doc, [bloco_atual] + ) + else: + pdf_para_assinar = pdf_bytes # já inclui página de autenticação + + try: + pdf_assinado_bytes = assinar_pdf_via_api( + pdf_para_assinar, + certificado_bytes=certificado_bytes, + senha=senha, + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + location=_obter_nome_casa_legislativa(), + signature_page=posicao_custom.get('sig_page'), + signature_left=posicao_custom.get('sig_left'), + signature_bottom=posicao_custom.get('sig_bottom'), + signature_width=posicao_custom.get('sig_width'), + signature_height=posicao_custom.get('sig_height'), + ) + except AssinaturaAPIError as exc: + raise _erro_de_assinatura(exc) else: - n_assinatura = len(assinaturas_existentes) + # Caminho normal (C3): o microserviço compõe a página de autenticação — + # código, URL de verificação, QR e os blocos — e escolhe o bloco desta + # assinatura contando as que já existem no PDF. O SAPL só manda o que é + # da casa legislativa. Nenhum `signature_*` vai junto, de propósito. try: - temp_pypdf = PdfFileReader(io.BytesIO(pdf_para_assinar)) - auth_pg = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1) - auth_w = float(auth_pg.mediaBox.getWidth()) - auth_h = float(auth_pg.mediaBox.getHeight()) - sig_x1, sig_y1, sig_x2, sig_y2 = _posicao_bloco_assinatura( - n_assinatura, auth_w, auth_h + resultado = assinar_pdf_com_pagina_autenticacao( + pdf_bytes, + certificado_bytes=certificado_bytes, + senha=senha, + verification_url_base=_construir_url_verificacao_base( + request, tipo_doc, pk_doc + ), + assinaturas=[bloco_atual], + casa_legislativa=_obter_nome_casa_legislativa(), + signer_name=nome_assinante, + signer_role=cargo, + # Da 2ª assinatura em diante o código já está impresso na página: + # o PDF mudou ao ser assinado e o hash de agora não o reproduz. + codigo_autenticacao=(hash_doc or '') if ja_tem_pdf_assinado else None, + brasao_bytes=_ler_brasao(), + brasao_filename=os.path.basename(_encontrar_logo() or 'brasao.png'), + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + location=_obter_nome_casa_legislativa(), + ) + except AssinaturaAPIError as exc: + raise _erro_de_assinatura(exc) + + if not resultado.auth_page_suportado: + # Sem os cabeçalhos do auth_page o microserviço ignorou a composição: + # o PDF sairia sem página de autenticação e sem código verificável. + # Falhar aqui é melhor do que gravar um documento inverificável. + raise Exception( + 'O microserviço de assinatura não compôs a página de ' + 'autenticação (resposta sem X-Auth-Page-Applied). Atualize o ' + 'microserviço para a versão com suporte a auth_page.' ) - sig_page = temp_pypdf.getNumPages() # 1-based, última página - sig_left = sig_x1 - sig_bottom = sig_y1 - sig_width = sig_x2 - sig_x1 - sig_height = sig_y2 - sig_y1 - except Exception: - sig_page = sig_left = sig_bottom = sig_width = sig_height = None - try: - pdf_assinado_bytes = assinar_pdf_via_api( - pdf_para_assinar, - certificado_bytes=certificado_bytes, - senha=senha, - reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', - location='Câmara Municipal', - signature_page=sig_page, - signature_left=sig_left, - signature_bottom=sig_bottom, - signature_width=sig_width, - signature_height=sig_height, - ) - except AssinaturaAPIError as exc: - raise Exception(str(exc)) + pdf_assinado_bytes = resultado.pdf + if resultado.auth_page_aplicada: + codigo_retorno = resultado.codigo_autenticacao nova_assinatura = { 'tipo_certificado': tipo_cert_input.upper(), - 'tipo_certificado_display': f'Certificado Digital – {tipo_cert_input.upper()}', - 'subject': '', - 'issuer': '', - 'serial': '', - 'valid_from': '', - 'valid_to': '', + 'tipo_certificado_display': ( + f'{_tipo_certificado_pelo_emissor(cert_api.get("issuer", ""))} – ' + f'{tipo_cert_input.upper()}' + ), + 'subject': cert_api.get('subject', ''), + 'issuer': cert_api.get('issuer', ''), + 'serial': cert_api.get('serial', ''), + 'valid_from': cert_api.get('valid_from', ''), + 'valid_to': cert_api.get('valid_to', ''), 'signed_by': request.user.username, 'nome_assinante': nome_assinante, 'cargo': cargo, @@ -261,17 +469,26 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc, passphrase=senha.encode('utf-8') ) except Exception as cert_error: - raise Exception(f'Erro ao carregar certificado: {cert_error}') + logger.warning(f'Falha ao abrir o PFX: {cert_error}') + raise ErroAssinaturaUsuario(_mensagem_de_erro_do_pfx(cert_error)) finally: if os.path.exists(tmp_cert_path): os.unlink(tmp_cert_path) + if signer is None: + raise ErroAssinaturaUsuario( + 'Não foi possível carregar o certificado. Verifique se o arquivo ' + '.pfx/.p12 é válido e contém uma chave de assinatura.' + ) + cert_info = signer.signing_cert error_response = _validar_certificado(cert_info) if error_response: import json as _json data = _json.loads(error_response.content) - raise Exception(data.get('error', 'Certificado inválido.')) + # Certificado vencido é problema do operador, não falha do sistema: 400 + # com a mensagem, igual ao que o microserviço devolve no outro backend. + raise ErroAssinaturaUsuario(data.get('error', 'Certificado inválido.')) # Criar objeto fake para _obter_info_assinante class _CertWrapper: @@ -324,7 +541,7 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc, meta = signers.PdfSignatureMetadata( field_name=sig_field_name, - location='Câmara Municipal', + location=_obter_nome_casa_legislativa(), reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', name=nome_assinante ) @@ -343,46 +560,21 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc, else: # Primeira assinatura: página de autenticação + pyhanko - original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) - last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) - page_width = float(last_page.mediaBox.getWidth()) - page_height = float(last_page.mediaBox.getHeight()) - - codigo = _gerar_codigo_autenticacao(pdf_bytes) - url_verificacao = _construir_url_verificacao(request, tipo_doc, pk_doc, codigo) - nova_assinatura_info_visual = { - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_simples, - } - auth_page_bytes = _gerar_pagina_autenticacao( - [nova_assinatura_info_visual], codigo, url_verificacao, - page_width, page_height + stamped_bytes, codigo = _compor_pagina_auth_localmente( + pdf_bytes, request, tipo_doc, pk_doc, + [{ + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + }], ) - auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PdfFileWriter() - for page_num in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(page_num)) - output_pdf.addPage(auth_page_pdf.getPage(0)) - - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_f: - tmp_path = tmp_f.name - output_pdf.write(tmp_f) - - try: - with open(tmp_path, 'rb') as f: - stamped_bytes = f.read() - finally: - if os.path.exists(tmp_path): - os.unlink(tmp_path) - signed_buffer = io.BytesIO() with io.BytesIO(stamped_bytes) as inf: w = IncrementalPdfFileWriter(inf) meta = signers.PdfSignatureMetadata( field_name='AssinaturaDigital', - location='Câmara Municipal', + location=_obter_nome_casa_legislativa(), reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', name=nome_assinante ) @@ -437,17 +629,24 @@ def _gerar_qrcode_image(url): return buf -def _construir_url_verificacao(request, tipo, pk, codigo): - """Monta URL pública de verificação.""" +def _construir_url_verificacao_base(request, tipo, pk): + """ + URL pública de verificação SEM o `?codigo=`. + + É o que vai para o microserviço: só o SAPL sabe a URL pública da casa, mas o + código quem gera é quem compõe a página — então a montagem final é lá. + """ if tipo == 'materia': url_name = 'sapl.materia:materia_verificar_documento' else: url_name = 'sapl.materia:docacessorio_verificar_documento' - base_url = request.build_absolute_uri( - reverse(url_name, kwargs={'pk': pk}) - ) - return f'{base_url}?codigo={codigo}' + return request.build_absolute_uri(reverse(url_name, kwargs={'pk': pk})) + + +def _construir_url_verificacao(request, tipo, pk, codigo): + """Monta URL pública de verificação.""" + return f'{_construir_url_verificacao_base(request, tipo, pk)}?codigo={codigo}' def _obter_nome_casa_legislativa(): @@ -736,89 +935,30 @@ def _criar_stamp_style(nome_assinante, cargo, hash_doc=''): def _obter_info_assinante(request, cert_info): """ Obtém informações do assinante (nome, cargo, tipo_cert). - Busca via Autor.operadores → Parlamentar (GenericFK). Retorna (nome_assinante, cargo, tipo_cert). """ - nome_assinante = request.user.get_full_name() or request.user.username - cargo = "Usuário do Sistema" + nome_assinante, cargo = _nome_e_cargo_do_assinante(request) + return nome_assinante, cargo, _tipo_certificado_pelo_emissor(cert_info.issuer) - try: - from sapl.base.models import Autor - from sapl.parlamentares.models import Parlamentar - autor = Autor.objects.filter(operadores=request.user).first() - if autor: - # Usa tipo do autor como cargo (ex: "Parlamentar" → "Vereador(a)") - tipo_descricao = autor.tipo.descricao if autor.tipo else '' - if tipo_descricao == 'Parlamentar': - cargo = "Vereador(a)" - elif tipo_descricao: - cargo = tipo_descricao - - # Se o autor está vinculado a um Parlamentar, usa o nome dele - if isinstance(autor.autor_related, Parlamentar): - parlamentar = autor.autor_related - tipo_nome = AppConfig.attr('assinatura_nome') - if tipo_nome == 'C': - nome_assinante = parlamentar.nome_completo - else: - nome_assinante = parlamentar.nome_parlamentar - except Exception: - pass - - issuer_str = str(cert_info.issuer).upper() - if 'ICP-BRASIL' in issuer_str or 'ICP BRASIL' in issuer_str: - tipo_cert = "ICP-Brasil" - else: - tipo_cert = "Certificado Digital" - - return nome_assinante, cargo, tipo_cert - - -def _carregar_certificado(certificado_file, senha): - """ - Carrega certificado PKCS12 de um arquivo. - Retorna (signer, error_response) — se error_response não é None, retornar direto. +def _mensagem_de_erro_do_pfx(exc): """ - from pyhanko.sign import signers - - cert_data = certificado_file.read() - - import tempfile as tmp_module - with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: - tmp_cert.write(cert_data) - tmp_cert_path = tmp_cert.name - - try: - signer = signers.SimpleSigner.load_pkcs12( - pfx_file=tmp_cert_path, - passphrase=senha.encode('utf-8') - ) - if os.path.exists(tmp_cert_path): - os.unlink(tmp_cert_path) - except Exception as cert_error: - logger.error(f"Erro ao carregar certificado: {cert_error}") - if os.path.exists(tmp_cert_path): - os.unlink(tmp_cert_path) - error_msg = str(cert_error) - if 'password' in error_msg.lower() or 'mac' in error_msg.lower(): - error_detail = 'Senha incorreta.' - elif 'decode' in error_msg.lower() or 'parse' in error_msg.lower(): - error_detail = 'Arquivo não é um certificado válido (.pfx/.p12).' - else: - error_detail = f'Detalhes: {error_msg}' - return None, JsonResponse({ - 'success': False, - 'error': f'Erro ao carregar certificado: {error_detail}' - }, status=400) + Traduz a falha do pyhanko ao abrir o PKCS12 para algo que o operador resolve. - if signer is None: - return None, JsonResponse({ - 'success': False, - 'error': 'Não foi possível carregar o certificado. Verifique se o arquivo .pfx/.p12 é válido e contém uma chave de assinatura.' - }, status=400) - - return signer, None + Sem isto, senha errada vira `Exception` genérica e a view responde 500 com o + texto cru da biblioteca — quem está assinando não tem como saber que só errou + a senha. É a mesma tradução que o caminho via microserviço faz ao converter o + HTTP 400 em `ErroAssinaturaUsuario`; os dois backends precisam falar igual. + """ + texto = str(exc) + minusculo = texto.lower() + if 'password' in minusculo or 'mac' in minusculo: + detalhe = 'Senha incorreta.' + elif 'decode' in minusculo or 'parse' in minusculo: + detalhe = 'Arquivo não é um certificado válido (.pfx/.p12).' + else: + detalhe = f'Detalhes: {texto}' + return f'Erro ao carregar certificado: {detalhe}' def _validar_certificado(cert_info): @@ -1093,6 +1233,14 @@ def materia_assinar_a1(request, pk): 'success': False, 'error': 'Biblioteca de assinatura não instalada. Contate o administrador.' }, status=500) + except ErroAssinaturaUsuario as e: + # Certificado vencido, senha incorreta: o operador resolve sozinho, então + # a resposta é 400 com a mensagem — não 500 com o erro cru. + logger.warning(f"Assinatura da matéria {pk} recusada: {e}") + return JsonResponse({ + 'success': False, + 'error': str(e) + }, status=400) except Exception as e: logger.error(f"Erro ao assinar PDF da matéria {pk}: {e}") return JsonResponse({'success': False, 'error': str(e)}, status=500) @@ -1272,7 +1420,7 @@ def materia_assinar_a3_finalizar(request, pk): # Metadados da assinatura meta = signers.PdfSignatureMetadata( field_name='AssinaturaDigital', - location='Câmara Municipal', + location=_obter_nome_casa_legislativa(), reason='Assinatura Digital de Matéria Legislativa (A3)', name=request.user.get_full_name() or request.user.username ) @@ -1694,6 +1842,14 @@ def docacessorio_assinar_a1(request, pk): 'success': False, 'error': 'Biblioteca de assinatura não instalada. Contate o administrador.' }, status=500) + except ErroAssinaturaUsuario as e: + # Certificado vencido, senha incorreta: o operador resolve sozinho, então + # a resposta é 400 com a mensagem — não 500 com o erro cru. + logger.warning(f"Assinatura do doc acessório {pk} recusada: {e}") + return JsonResponse({ + 'success': False, + 'error': str(e) + }, status=400) except Exception as e: logger.error(f"Erro ao assinar PDF do doc acessório {pk}: {e}") return JsonResponse({'success': False, 'error': str(e)}, status=500) @@ -2153,33 +2309,18 @@ def materia_assinar_lote(request): ja_tem = bool(assinaturas_existentes) if not ja_tem: - original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) - last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) - page_width = float(last_page.mediaBox.getWidth()) - page_height = float(last_page.mediaBox.getHeight()) - - codigo = _gerar_codigo_autenticacao(pdf_bytes) - url_verificacao = _construir_url_verificacao(request, 'materia', pk, codigo) - codigos_map[pk] = codigo - - nova_assinatura_info_visual = { - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_simples, - } - auth_page_bytes = _gerar_pagina_autenticacao( - [nova_assinatura_info_visual], codigo, url_verificacao, - page_width, page_height + # Composição local, documento por documento — é o que torna o + # /sign/batch utilizável: ele só carimba. Mesmo helper do caminho + # individual local, para não existir um terceiro compositor. + pdf_para_assinar, codigo = _compor_pagina_auth_localmente( + pdf_bytes, request, 'materia', pk, + [{ + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + }], ) - - auth_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PdfFileWriter() - for i in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(i)) - output_pdf.addPage(auth_pdf.getPage(0)) - buf = io.BytesIO() - output_pdf.write(buf) - pdf_para_assinar = buf.getvalue() + codigos_map[pk] = codigo else: pdf_para_assinar = pdf_bytes @@ -2496,23 +2637,17 @@ def docacessorio_assinar_lote(request): ja_tem = bool(assinaturas_existentes) if not ja_tem: - original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) - last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) - page_width = float(last_page.mediaBox.getWidth()) - page_height = float(last_page.mediaBox.getHeight()) - codigo = _gerar_codigo_autenticacao(pdf_bytes) - url_verificacao = _construir_url_verificacao(request, 'docacessorio', pk, codigo) + # Mesma composição local do lote de matéria e do caminho + # individual local — um compositor só (ver _compor_pagina_auth_localmente). + pdf_para_assinar, codigo = _compor_pagina_auth_localmente( + pdf_bytes, request, 'docacessorio', pk, + [{ + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + }], + ) codigos_map[pk] = codigo - nova_assinatura_info_visual = {'nome_assinante': nome_assinante, 'cargo': cargo, 'data_assinatura': data_simples} - auth_page_bytes = _gerar_pagina_autenticacao([nova_assinatura_info_visual], codigo, url_verificacao, page_width, page_height) - auth_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PdfFileWriter() - for i in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(i)) - output_pdf.addPage(auth_pdf.getPage(0)) - buf = io.BytesIO() - output_pdf.write(buf) - pdf_para_assinar = buf.getvalue() else: pdf_para_assinar = pdf_bytes