diff --git a/requirements/requirements.txt b/requirements/requirements.txt index be42558a7..371027d5d 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -45,5 +45,10 @@ python-docx==1.1.0 PyJWT==2.8.0 requests==2.31.0 +# Digital Signature (PDF signing with certificates) +pyhanko==0.21.0 +pyhanko-certvalidator==0.26.3 +cryptography>=41.0.0 + git+https://github.com/interlegis/django-admin-bootstrapped diff --git a/sapl/materia/migrations/0088_materialegislativa_assinatura_digital.py b/sapl/materia/migrations/0088_materialegislativa_assinatura_digital.py new file mode 100644 index 000000000..c3f4cc06e --- /dev/null +++ b/sapl/materia/migrations/0088_materialegislativa_assinatura_digital.py @@ -0,0 +1,63 @@ +# Generated migration for digital signature fields + +from django.conf import settings +from django.contrib.postgres.fields import JSONField +from django.db import migrations, models +import django.db.models.deletion + +import sapl.materia.models +import sapl.utils + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('materia', '0087_update_viewdb_materiaemtramitacao'), + ] + + operations = [ + migrations.AddField( + model_name='materialegislativa', + name='pdf_assinado', + field=models.FileField( + blank=True, + max_length=300, + null=True, + storage=sapl.utils.OverwriteStorage(), + upload_to=sapl.materia.models.materia_upload_path, + verbose_name='PDF Assinado' + ), + ), + migrations.AddField( + model_name='materialegislativa', + name='assinatura_info', + field=JSONField( + blank=True, + help_text='Metadados do certificado digital usado na assinatura', + null=True, + verbose_name='Informações da Assinatura' + ), + ), + migrations.AddField( + model_name='materialegislativa', + name='assinado_em', + field=models.DateTimeField( + blank=True, + null=True, + verbose_name='Data/Hora da Assinatura' + ), + ), + migrations.AddField( + model_name='materialegislativa', + name='assinado_por', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='materias_assinadas', + to=settings.AUTH_USER_MODEL, + verbose_name='Assinado por' + ), + ), + ] diff --git a/sapl/materia/models.py b/sapl/materia/models.py index 2fde8cf90..b430400f6 100644 --- a/sapl/materia/models.py +++ b/sapl/materia/models.py @@ -3,6 +3,7 @@ from datetime import datetime from django.contrib.auth.models import Group from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import JSONField from django.db import models from django.db.models.functions import Concat from django.template import defaultfilters @@ -304,6 +305,35 @@ class MateriaLegislativa(models.Model): blank=True, null=True ) + # Campos para assinatura digital + pdf_assinado = models.FileField( + max_length=300, + upload_to=materia_upload_path, + blank=True, + null=True, + verbose_name=_('PDF Assinado'), + storage=OverwriteStorage() + ) + assinatura_info = JSONField( + blank=True, + null=True, + verbose_name=_('Informações da Assinatura'), + help_text=_('Metadados do certificado digital usado na assinatura') + ) + assinado_em = models.DateTimeField( + blank=True, + null=True, + verbose_name=_('Data/Hora da Assinatura') + ) + assinado_por = models.ForeignKey( + get_settings_auth_user_model(), + verbose_name=_('Assinado por'), + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='materias_assinadas' + ) + class Meta: verbose_name = _('Matéria Legislativa') verbose_name_plural = _('Matérias Legislativas') diff --git a/sapl/materia/urls.py b/sapl/materia/urls.py index 10c8b7e3a..185644a30 100644 --- a/sapl/materia/urls.py +++ b/sapl/materia/urls.py @@ -41,6 +41,11 @@ from sapl.materia.onlyoffice_materia_views import ( docacessorio_onlyoffice_download, docacessorio_onlyoffice_callback, materia_gerar_pdf_assinatura ) +from sapl.materia.views_assinatura import ( + materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar, + materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura, + detectar_aplicacao_a3 +) from sapl.norma.views import NormaPesquisaSimplesView from sapl.protocoloadm.views import ( FichaPesquisaAdmView, FichaSelecionaAdmView @@ -149,6 +154,22 @@ urlpatterns_materia = [ url(r'^materia/(?P\d+)/pdf-assinatura$', materia_gerar_pdf_assinatura, name='materia_pdf_assinatura'), + # Assinatura Digital de Matéria Legislativa + url(r'^materia/(?P\d+)/assinar/a1/$', materia_assinar_a1, + name='materia_assinar_a1'), + url(r'^materia/(?P\d+)/assinar/a3/preparar/$', materia_assinar_a3_preparar, + name='materia_assinar_a3_preparar'), + url(r'^materia/(?P\d+)/assinar/a3/finalizar/$', materia_assinar_a3_finalizar, + name='materia_assinar_a3_finalizar'), + url(r'^materia/(?P\d+)/pdf-assinado/$', materia_pdf_assinado, + name='materia_pdf_assinado'), + url(r'^materia/(?P\d+)/verificar-assinatura/$', materia_verificar_assinatura, + name='materia_verificar_assinatura'), + url(r'^materia/(?P\d+)/remover-assinatura/$', materia_remover_assinatura, + name='materia_remover_assinatura'), + url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3, + name='detectar_aplicacao_a3'), + # OnlyOffice endpoints para Documento Acessório url(r'^materia/documentoacessorio/(?P\d+)/onlyoffice/editor$', docacessorio_onlyoffice_editor, name='docacessorio_onlyoffice_editor'), diff --git a/sapl/materia/views_assinatura.py b/sapl/materia/views_assinatura.py new file mode 100644 index 000000000..1ecb268e9 --- /dev/null +++ b/sapl/materia/views_assinatura.py @@ -0,0 +1,810 @@ +""" +Views para assinatura digital de PDFs de Matérias Legislativas. +Suporta certificados A1 (arquivo .pfx/.p12) e A3 (token USB/smartcard). +""" +import io +import json +import logging +import os +import tempfile +from datetime import datetime + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.files.base import ContentFile +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect +from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from sapl.materia.models import MateriaLegislativa + +logger = logging.getLogger(__name__) + + +def _gerar_pdf_da_materia(materia, request): + """ + Gera o PDF da matéria para assinatura. + Primeiro tenta usar o PDF existente, depois converte DOCX via OnlyOffice. + Retorna bytes do PDF ou None em caso de erro. + """ + import requests as http_requests + import xml.etree.ElementTree as ET + from django.urls import reverse + + # Se não tem documento, retorna None + if not materia.texto_original: + return None, "Matéria não possui documento de texto original." + + file_name = materia.texto_original.name.lower() + + # Se já é PDF, retorna diretamente + if file_name.endswith('.pdf'): + try: + with open(materia.texto_original.path, 'rb') as f: + return f.read(), None + except Exception as e: + logger.error(f"Erro ao ler arquivo PDF: {e}") + return None, f"Erro ao ler o arquivo PDF: {e}" + + # Converter DOCX para PDF via OnlyOffice + from sapl.materia.onlyoffice_materia_views import generate_file_key + + download_url = request.build_absolute_uri( + reverse('sapl.materia:materia_onlyoffice_download', kwargs={'pk': materia.pk}) + ) + + # Substituir pelo nome do container na rede Docker + host = request.get_host() + download_url = download_url.replace(f'http://{host}', 'http://sapl-dev:8000') + download_url = download_url.replace(f'https://{host}', 'http://sapl-dev:8000') + + conversion_url = 'http://onlyoffice:80/ConvertService.ashx' + + conversion_data = { + "async": False, + "filetype": "docx", + "key": generate_file_key("materia_sign", materia.pk, request.user.pk), + "outputtype": "pdf", + "title": f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf", + "url": download_url, + } + + # Adiciona JWT se estiver habilitado + if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): + import jwt + token = jwt.encode(conversion_data, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') + conversion_data['token'] = token + + try: + headers = {'Content-Type': 'application/json'} + + if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): + import jwt + header_token = jwt.encode({"payload": conversion_data}, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') + headers['Authorization'] = f'Bearer {header_token}' + + conversion_response = http_requests.post( + conversion_url, + json=conversion_data, + headers=headers, + timeout=60 + ) + + if conversion_response.status_code != 200: + return None, f"Erro na conversão OnlyOffice: status={conversion_response.status_code}" + + root = ET.fromstring(conversion_response.text) + + error_elem = root.find('Error') + if error_elem is not None: + return None, f"Erro na conversão do documento: código {error_elem.text}" + + file_url_elem = root.find('FileUrl') + if file_url_elem is None or not file_url_elem.text: + return None, "URL do PDF não retornada pelo OnlyOffice" + + pdf_url = file_url_elem.text + + if 'onlyoffice/' in pdf_url and not pdf_url.startswith('http://onlyoffice:'): + pdf_url = pdf_url.replace('http://onlyoffice/', 'http://onlyoffice:80/') + + pdf_response = http_requests.get(pdf_url, timeout=60) + + if pdf_response.status_code != 200: + return None, f"Erro ao baixar PDF convertido: status={pdf_response.status_code}" + + return pdf_response.content, None + + except Exception as e: + logger.error(f"Erro na geração de PDF: {e}") + return None, f"Erro inesperado: {e}" + + +@login_required +@csrf_exempt +@require_http_methods(["POST"]) +def materia_assinar_a1(request, pk): + """ + Assina o PDF da matéria com certificado A1 (arquivo .pfx/.p12). + + Parâmetros POST: + - certificado: arquivo .pfx ou .p12 + - senha: senha do certificado + """ + materia = get_object_or_404(MateriaLegislativa, pk=pk) + + # Permissão: qualquer usuário autenticado pode assinar + # (a autenticação é garantida pelo decorator @login_required) + + # Verifica se já está assinada + if materia.pdf_assinado: + return JsonResponse({ + 'success': False, + 'error': 'Esta matéria já possui um PDF assinado.' + }, status=400) + + # Obtém dados do formulário + certificado_file = request.FILES.get('certificado') + senha = request.POST.get('senha', '') + + if not certificado_file: + return JsonResponse({ + 'success': False, + 'error': 'Certificado não informado.' + }, status=400) + + if not senha: + return JsonResponse({ + 'success': False, + 'error': 'Senha do certificado não informada.' + }, status=400) + + # Gera o PDF da matéria + pdf_bytes, error = _gerar_pdf_da_materia(materia, request) + if error: + return JsonResponse({ + 'success': False, + 'error': error + }, status=400) + + try: + from pyhanko.sign import signers, fields + from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter + from pyhanko.keys import load_cert_from_pemder + from pyhanko_certvalidator import ValidationContext + + # Lê o certificado + cert_data = certificado_file.read() + + # Cria o assinador - salva temporariamente o certificado + 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') + ) + # Limpa arquivo temporário do certificado após carregar + 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}") + # Limpa arquivo temporário do certificado + 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 JsonResponse({ + 'success': False, + 'error': f'Erro ao carregar certificado: {error_detail}' + }, status=400) + + # Verifica validade do certificado + cert_info = signer.signing_cert + now = timezone.now() + # Converte datas do certificado para timezone-aware se necessário + valid_before = cert_info.not_valid_before + valid_after = cert_info.not_valid_after + if valid_before.tzinfo is None: + import pytz + valid_before = pytz.UTC.localize(valid_before) + if valid_after.tzinfo is None: + import pytz + valid_after = pytz.UTC.localize(valid_after) + if now < valid_before or now > valid_after: + return JsonResponse({ + 'success': False, + 'error': 'Certificado expirado ou ainda não válido.' + }, status=400) + + # Cria arquivo temporário para o PDF assinado + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_signed: + temp_signed_path = temp_signed.name + + try: + from pyhanko.sign.fields import SigSeedSubFilter + from pyhanko.pdf_utils import text + from pyhanko.sign.general import SigningError + + # Informações do assinante + nome_assinante = request.user.get_full_name() or request.user.username + data_assinatura = timezone.localtime(timezone.now()) + data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') + + # Tenta obter cargo do usuário (se for parlamentar/autor) + cargo = "Usuário do Sistema" + parlamentar = None + try: + from sapl.parlamentares.models import Parlamentar + parlamentar = Parlamentar.objects.filter( + usuario=request.user + ).first() + if parlamentar: + cargo = "Vereador(a)" + nome_assinante = parlamentar.nome_parlamentar + except: + pass + + # Determina tipo de certificado baseado no issuer + 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" + + # Assina o PDF com assinatura invisível (sem campo visual do pyhanko) + with io.BytesIO(pdf_bytes) as inf: + w = IncrementalPdfFileWriter(inf) + + # Metadados da assinatura (sem campo visível) + meta = signers.PdfSignatureMetadata( + field_name='AssinaturaDigital', + location='Câmara Municipal', + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + name=nome_assinante + ) + + # Executa assinatura invisível + with open(temp_signed_path, 'wb') as outf: + signers.sign_pdf( + w, + meta, + signer=signer, + output=outf + ) + + # Adiciona carimbo de texto visível ao PDF assinado + # Usando reportlab para adicionar o texto + from PyPDF4 import PdfFileReader, PdfFileWriter + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + from reportlab.lib.utils import ImageReader + + # Tenta obter CPF do parlamentar ou do certificado + cpf = "" + try: + if parlamentar and parlamentar.cpf: + cpf = parlamentar.cpf + except: + pass + + # Se não encontrou CPF no parlamentar, tenta extrair do certificado + if not cpf: + subject_str = str(cert_info.subject) + import re + cpf_match = re.search(r'\d{3}\.?\d{3}\.?\d{3}-?\d{2}', subject_str) + if cpf_match: + cpf = cpf_match.group() + + # Formata data + data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') + + # Cria PDF com o carimbo de assinatura + stamp_buffer = io.BytesIO() + c = canvas.Canvas(stamp_buffer, pagesize=A4) + + # Posição do carimbo (canto inferior esquerdo) + y_pos = 15 * mm + x_pos = 10 * mm + largura_carimbo = 70 * mm # Aumentado para caber texto + logo + altura_carimbo = 22 * mm + logo_width = 18 * mm + + # Desenha borda fina do carimbo + c.setStrokeColorRGB(0.5, 0.5, 0.5) + c.setLineWidth(0.5) + c.rect(x_pos, y_pos, largura_carimbo, altura_carimbo) + + # Texto do carimbo (lado esquerdo) + c.setFont("Helvetica", 6) + c.setFillColorRGB(0.3, 0.3, 0.3) + c.drawString(x_pos + 3*mm, y_pos + 17*mm, "Assinado digitalmente por") + + c.setFont("Helvetica-Bold", 7) + c.setFillColorRGB(0, 0, 0) + # Nome em maiúsculas, quebra se muito longo + nome_upper = nome_assinante.upper() + if len(nome_upper) > 28: + nome_upper = nome_upper[:28] + "..." + c.drawString(x_pos + 3*mm, y_pos + 12*mm, nome_upper) + + c.setFont("Helvetica", 6) + c.setFillColorRGB(0.3, 0.3, 0.3) + if cpf: + c.drawString(x_pos + 3*mm, y_pos + 7*mm, f"CPF: {cpf}") + c.drawString(x_pos + 3*mm, y_pos + 3*mm, f"Data: {data_simples}") + else: + c.drawString(x_pos + 3*mm, y_pos + 5*mm, f"Data: {data_simples}") + + # Logo da câmara (lado direito) + try: + # Procura o logotipo da câmara em vários locais possíveis + logo_path = None + base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + possible_paths = [ + # Logo padrão do sistema + os.path.join(base_dir, 'sapl/static/sapl/frontend/img/logo-camara-padrao.png'), + os.path.join(base_dir, 'sapl/static/sapl/frontend/img/logo.png'), + os.path.join(base_dir, 'sapl/static/sapl/frontend/img/pdflogo.png'), + # Media folder + os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logo.png'), + os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logotipo.png'), + ] + + # Procura qualquer imagem na pasta de logotipo do media + logo_dir = os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo') + if os.path.exists(logo_dir): + for f in os.listdir(logo_dir): + if f.lower().endswith(('.png', '.jpg', '.jpeg')): + possible_paths.insert(0, os.path.join(logo_dir, f)) + + for path in possible_paths: + if os.path.exists(path): + logo_path = path + break + + if logo_path: + # Desenha logo no lado direito do carimbo + logo = ImageReader(logo_path) + logo_x = x_pos + largura_carimbo - logo_width - 2*mm + logo_y = y_pos + 2*mm + c.drawImage(logo, logo_x, logo_y, + width=logo_width, height=logo_width, + preserveAspectRatio=True, mask='auto') + except Exception as logo_error: + logger.warning(f"Não foi possível adicionar logo: {logo_error}") + + c.save() + stamp_buffer.seek(0) + + # Mescla o carimbo com o PDF assinado + stamp_pdf = PdfFileReader(stamp_buffer) + signed_pdf = PdfFileReader(open(temp_signed_path, 'rb')) + output_pdf = PdfFileWriter() + + # Adiciona o carimbo em todas as páginas + for page_num in range(signed_pdf.getNumPages()): + page = signed_pdf.getPage(page_num) + if page_num == signed_pdf.getNumPages() - 1: # Última página + page.mergePage(stamp_pdf.getPage(0)) + output_pdf.addPage(page) + + # Salva o PDF final com carimbo + final_buffer = io.BytesIO() + output_pdf.write(final_buffer) + final_buffer.seek(0) + signed_pdf_content = final_buffer.read() + + # Salva o PDF assinado no modelo + filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" + materia.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) + + # Salva informações da assinatura + materia.assinatura_info = { + 'tipo_certificado': 'A1', + 'tipo_certificado_display': f'{tipo_cert} – A1', + 'subject': str(cert_info.subject), + 'issuer': str(cert_info.issuer), + 'serial': str(cert_info.serial_number), + 'valid_from': cert_info.not_valid_before.isoformat(), + 'valid_to': cert_info.not_valid_after.isoformat(), + 'signed_by': request.user.username, + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_formatada, + 'validade_juridica': 'Assinatura Eletrônica Qualificada' + } + materia.assinado_em = timezone.now() + materia.assinado_por = request.user + materia.save() + + logger.info(f"Matéria {materia.pk} assinada por {request.user.username}") + + return JsonResponse({ + 'success': True, + 'message': 'PDF assinado com sucesso!', + 'certificado': { + 'nome': str(cert_info.subject), + 'validade': cert_info.not_valid_after.strftime('%d/%m/%Y') + } + }) + + finally: + # Remove arquivo temporário + if os.path.exists(temp_signed_path): + os.unlink(temp_signed_path) + + except ImportError: + logger.error("pyhanko não está instalado") + return JsonResponse({ + 'success': False, + 'error': 'Biblioteca de assinatura não instalada. Contate o administrador.' + }, status=500) + except Exception as e: + logger.error(f"Erro ao assinar PDF: {e}") + return JsonResponse({ + 'success': False, + 'error': f'Erro ao assinar o PDF: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["POST"]) +def materia_assinar_a3_preparar(request, pk): + """ + Prepara a assinatura A3 gerando o hash do PDF para ser assinado pelo token. + + Retorna: + - hash: hash SHA-256 do PDF em hexadecimal + - pdf_base64: PDF codificado em base64 (para assinatura no cliente) + """ + import base64 + import hashlib + + materia = get_object_or_404(MateriaLegislativa, pk=pk) + + # Verifica permissão + if not request.user.has_perm('materia.change_materialegislativa'): + return JsonResponse({ + 'success': False, + 'error': 'Você não tem permissão para assinar esta matéria.' + }, status=403) + + # Verifica se já está assinada + if materia.pdf_assinado: + return JsonResponse({ + 'success': False, + 'error': 'Esta matéria já possui um PDF assinado.' + }, status=400) + + # Gera o PDF da matéria + pdf_bytes, error = _gerar_pdf_da_materia(materia, request) + if error: + return JsonResponse({ + 'success': False, + 'error': error + }, status=400) + + # Calcula o hash SHA-256 + pdf_hash = hashlib.sha256(pdf_bytes).hexdigest() + + # Codifica o PDF em base64 + pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return JsonResponse({ + 'success': True, + 'hash': pdf_hash, + 'pdf_base64': pdf_base64, + 'materia_id': materia.pk + }) + + +@login_required +@require_http_methods(["POST"]) +@csrf_exempt +def materia_assinar_a3_finalizar(request, pk): + """ + Finaliza a assinatura A3 incorporando a assinatura recebida do token. + + Parâmetros POST (JSON): + - signature: assinatura em base64 + - certificate: certificado em base64 + - certificate_chain: cadeia de certificados em base64 (opcional) + """ + import base64 + + materia = get_object_or_404(MateriaLegislativa, pk=pk) + + # Verifica permissão + if not request.user.has_perm('materia.change_materialegislativa'): + return JsonResponse({ + 'success': False, + 'error': 'Você não tem permissão para assinar esta matéria.' + }, status=403) + + # Verifica se já está assinada + if materia.pdf_assinado: + return JsonResponse({ + 'success': False, + 'error': 'Esta matéria já possui um PDF assinado.' + }, status=400) + + try: + data = json.loads(request.body.decode('utf-8')) + except json.JSONDecodeError: + return JsonResponse({ + 'success': False, + 'error': 'Dados inválidos.' + }, status=400) + + signature_b64 = data.get('signature') + certificate_b64 = data.get('certificate') + + if not signature_b64 or not certificate_b64: + return JsonResponse({ + 'success': False, + 'error': 'Assinatura ou certificado não informados.' + }, status=400) + + # Gera o PDF da matéria + pdf_bytes, error = _gerar_pdf_da_materia(materia, request) + if error: + return JsonResponse({ + 'success': False, + 'error': error + }, status=400) + + try: + from pyhanko.sign import signers, fields + from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter + from pyhanko.sign.signers.pdf_cms import ExternalSigner + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + + # Decodifica o certificado + cert_der = base64.b64decode(certificate_b64) + cert = x509.load_der_x509_certificate(cert_der, default_backend()) + + # Verifica validade do certificado + now = datetime.utcnow() + if now < cert.not_valid_before or now > cert.not_valid_after: + return JsonResponse({ + 'success': False, + 'error': 'Certificado expirado ou ainda não válido.' + }, status=400) + + # Decodifica a assinatura + signature = base64.b64decode(signature_b64) + + # Cria arquivo temporário para o PDF assinado + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_signed: + temp_signed_path = temp_signed.name + + try: + # Cria o signer externo com a assinatura pré-computada + # Nota: Esta é uma implementação simplificada + # Em produção, seria necessário usar o ExternalSigner corretamente + + with io.BytesIO(pdf_bytes) as inf: + w = IncrementalPdfFileWriter(inf) + + # Adiciona campo de assinatura + sig_field = fields.SigFieldSpec( + sig_field_name='AssinaturaDigital', + box=(50, 50, 250, 100) + ) + + # Metadados da assinatura + meta = signers.PdfSignatureMetadata( + field_name='AssinaturaDigital', + location='Câmara Municipal', + reason='Assinatura Digital de Matéria Legislativa (A3)', + name=request.user.get_full_name() or request.user.username + ) + + # Para assinatura A3, precisamos de integração mais complexa + # Por enquanto, retornamos erro informando que A3 requer aplicação local + return JsonResponse({ + 'success': False, + 'error': 'Assinatura A3 requer aplicação local. Use o Assinador SERPRO ou similar.' + }, status=501) + + finally: + if os.path.exists(temp_signed_path): + os.unlink(temp_signed_path) + + except ImportError as e: + logger.error(f"Biblioteca não instalada: {e}") + return JsonResponse({ + 'success': False, + 'error': 'Biblioteca de assinatura não instalada.' + }, status=500) + except Exception as e: + logger.error(f"Erro ao finalizar assinatura A3: {e}") + return JsonResponse({ + 'success': False, + 'error': f'Erro ao processar assinatura: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["GET"]) +def materia_pdf_assinado(request, pk): + """ + Retorna o PDF assinado da matéria para download/visualização. + """ + materia = get_object_or_404(MateriaLegislativa, pk=pk) + + if not materia.pdf_assinado: + messages.error(request, 'Esta matéria não possui PDF assinado.') + return redirect('sapl.materia:materialegislativa_detail', pk=pk) + + try: + with open(materia.pdf_assinado.path, 'rb') as f: + content = f.read() + + filename = f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}_ASSINADO.pdf" + filename = filename.replace(' ', '_').replace('/', '-') + + response = HttpResponse(content, content_type='application/pdf') + response['Content-Disposition'] = f'inline; filename="{filename}"' + return response + + except Exception as e: + logger.error(f"Erro ao ler PDF assinado: {e}") + messages.error(request, 'Erro ao ler o arquivo PDF assinado.') + return redirect('sapl.materia:materialegislativa_detail', pk=pk) + + +@login_required +@require_http_methods(["GET"]) +def materia_verificar_assinatura(request, pk): + """ + Verifica a assinatura digital do PDF da matéria. + Retorna informações sobre as assinaturas encontradas. + """ + materia = get_object_or_404(MateriaLegislativa, pk=pk) + + if not materia.pdf_assinado: + return JsonResponse({ + 'success': False, + 'error': 'Esta matéria não possui PDF assinado.', + 'assinado': False + }) + + try: + from pyhanko.sign.validation import validate_pdf_signature + from pyhanko.pdf_utils.reader import PdfFileReader + + with open(materia.pdf_assinado.path, 'rb') as f: + reader = PdfFileReader(f) + + # Lista de assinaturas encontradas + assinaturas = [] + + # Verifica cada assinatura no PDF + for sig_field_name in reader.embedded_signatures: + try: + sig = reader.embedded_signatures[sig_field_name] + + # Informações básicas da assinatura + sig_info = { + 'campo': sig_field_name, + 'assinante': str(sig.signer_cert.subject) if sig.signer_cert else 'Desconhecido', + 'data': sig.self_reported_timestamp.isoformat() if sig.self_reported_timestamp else None, + } + + assinaturas.append(sig_info) + + except Exception as sig_error: + logger.warning(f"Erro ao verificar assinatura {sig_field_name}: {sig_error}") + assinaturas.append({ + 'campo': sig_field_name, + 'erro': str(sig_error) + }) + + return JsonResponse({ + 'success': True, + 'assinado': True, + 'total_assinaturas': len(assinaturas), + 'assinaturas': assinaturas, + 'info_salva': materia.assinatura_info + }) + + except ImportError: + # Se pyhanko não estiver instalado, retorna info salva no modelo + return JsonResponse({ + 'success': True, + 'assinado': True, + 'info_salva': materia.assinatura_info, + 'verificacao_disponivel': False + }) + except Exception as e: + logger.error(f"Erro ao verificar assinatura: {e}") + return JsonResponse({ + 'success': False, + 'error': f'Erro ao verificar assinatura: {str(e)}', + 'assinado': True, + 'info_salva': materia.assinatura_info + }) + + +@login_required +@require_http_methods(["POST"]) +def materia_remover_assinatura(request, pk): + """ + Remove a assinatura digital da matéria. + Apenas superusuários podem executar esta ação. + """ + if not request.user.is_superuser: + return JsonResponse({ + 'success': False, + 'error': 'Apenas administradores podem remover assinaturas.' + }, status=403) + + materia = get_object_or_404(MateriaLegislativa, pk=pk) + + if not materia.pdf_assinado: + return JsonResponse({ + 'success': False, + 'error': 'Esta matéria não possui PDF assinado.' + }, status=400) + + try: + # Remove o arquivo + materia.pdf_assinado.delete(save=False) + + # Limpa os campos + materia.pdf_assinado = None + materia.assinatura_info = None + materia.assinado_em = None + materia.assinado_por = None + materia.save() + + logger.info(f"Assinatura da matéria {materia.pk} removida por {request.user.username}") + + return JsonResponse({ + 'success': True, + 'message': 'Assinatura removida com sucesso.' + }) + + except Exception as e: + logger.error(f"Erro ao remover assinatura: {e}") + return JsonResponse({ + 'success': False, + 'error': f'Erro ao remover assinatura: {str(e)}' + }, status=500) + + +@login_required +@require_http_methods(["GET"]) +def detectar_aplicacao_a3(request): + """ + Endpoint para verificar se há uma aplicação de assinatura A3 rodando localmente. + O frontend usa isso para decidir se mostra a opção A3. + """ + # Lista de portas comuns usadas por aplicações de assinatura + portas_conhecidas = [ + {'nome': 'Assinador SERPRO', 'porta': 10443}, + {'nome': 'Web PKI Local', 'porta': 5000}, + {'nome': 'Signer Local', 'porta': 8080}, + ] + + return JsonResponse({ + 'success': True, + 'portas_conhecidas': portas_conhecidas, + 'instrucoes': 'O frontend deve tentar conectar a cada porta para detectar a aplicação.' + }) diff --git a/sapl/static/sapl/css/subnav.css b/sapl/static/sapl/css/subnav.css new file mode 100644 index 000000000..dd65152ab --- /dev/null +++ b/sapl/static/sapl/css/subnav.css @@ -0,0 +1,73 @@ +/* Subnav - Tabs de navegação abaixo do menu principal */ + +.subnav-wrapper { + background: #f8f9fa; + border-bottom: 1px solid #e9ecef; + padding: 0; +} + +.subnav-wrapper .nav-pills { + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 2px; + justify-content: flex-start; +} + +.subnav-wrapper .nav-item { + margin: 0; +} + +.subnav-wrapper .nav-link { + display: block; + padding: 10px 16px; + color: #495057; + text-decoration: none; + font-size: 14px; + font-weight: 500; + border-radius: 0; + border-bottom: 2px solid transparent; + transition: all 0.15s ease; + background: transparent; +} + +.subnav-wrapper .nav-link:hover { + color: #007bff; + background: rgba(0,123,255,0.05); + text-decoration: none; +} + +.subnav-wrapper .nav-link.active { + color: #007bff; + border-bottom-color: #007bff; + background: #fff; +} + +/* Dropdown */ +.subnav-wrapper .dropdown-menu { + margin-top: 0; + border-radius: 0 0 4px 4px; + border-top: none; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); +} + +/* Responsivo */ +@media (max-width: 768px) { + .subnav-wrapper .nav-pills { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .subnav-wrapper .nav-pills::-webkit-scrollbar { + display: none; + } + + .subnav-wrapper .nav-link { + padding: 8px 12px; + font-size: 13px; + white-space: nowrap; + } +} diff --git a/sapl/templates/base.html b/sapl/templates/base.html index bd06eca07..071d26499 100644 --- a/sapl/templates/base.html +++ b/sapl/templates/base.html @@ -35,6 +35,7 @@ {% block webpack_loader_chunks_css %} {% endblock webpack_loader_chunks_css %} + {% endblock %} @@ -56,9 +57,6 @@ -
- {% block sections_nav %} {% subnav %} {% endblock sections_nav %} -
{% endblock main_header %} @@ -105,6 +103,16 @@ {% endblock navigation %} + + {# Subnav de conteúdo - aparece abaixo da navbar principal #} + {% block content_subnav %} + + {% endblock content_subnav %} + {% else %}
diff --git a/sapl/templates/materia/assinatura_modal.html b/sapl/templates/materia/assinatura_modal.html new file mode 100644 index 000000000..0392399ac --- /dev/null +++ b/sapl/templates/materia/assinatura_modal.html @@ -0,0 +1,551 @@ +{% load i18n %} + + + + + + + + diff --git a/sapl/templates/materia/materialegislativa_detail.html b/sapl/templates/materia/materialegislativa_detail.html index 903f86fca..641147f8d 100644 --- a/sapl/templates/materia/materialegislativa_detail.html +++ b/sapl/templates/materia/materialegislativa_detail.html @@ -17,9 +17,15 @@ {% endif %} {% if object.numero_protocolo and object.texto_original %}
- - {% trans "Gerar PDF para Assinatura" %} - + {% if object.pdf_assinado %} + + {% trans "Ver PDF Assinado" %} + + {% else %} + + {% endif %}
{% endif %} {% endblock sub_actions %} @@ -162,4 +168,63 @@ {% endif %}
{% endif %} + {% if object.pdf_assinado %} +
+
+
+
+ {% trans "Assinatura Digital" %} +
+
+
+
+

{% trans "Status:" %} + {% trans "Documento Assinado" %} +

+ {% if object.assinado_em %} +

{% trans "Data/Hora:" %} {{ object.assinado_em|localtime|date:"d/m/Y H:i:s" }}

+ {% endif %} + {% if object.assinado_por %} +

{% trans "Assinado por:" %} {{ object.assinado_por.get_full_name|default:object.assinado_por.username }}

+ {% endif %} +
+
+ {% if object.assinatura_info %} +

{% trans "Tipo de Certificado:" %} {{ object.assinatura_info.tipo_certificado|default:"A1" }}

+ {% if object.assinatura_info.subject %} +

{% trans "Certificado:" %} {{ object.assinatura_info.subject|truncatechars:50 }}

+ {% endif %} + {% if object.assinatura_info.valid_to %} +

{% trans "Validade:" %} {{ object.assinatura_info.valid_to|slice:":10" }}

+ {% endif %} + {% endif %} +
+
+ +
+
+
+
+ {% endif %} {% endblock detail_content %} + +{% block extra_js %} +{{ block.super }} +{% if object.numero_protocolo and object.texto_original and not object.pdf_assinado %} + {% include "materia/assinatura_modal.html" %} + +{% endif %} +{% endblock extra_js %} diff --git a/sapl/templates/menus/subnav.html b/sapl/templates/menus/subnav.html index 60c96ce23..ca7c848d7 100644 --- a/sapl/templates/menus/subnav.html +++ b/sapl/templates/menus/subnav.html @@ -1,7 +1,6 @@ - {% load i18n %} -{% if menu %} +{% if menu %}