diff --git a/docker/config/env-sample b/docker/config/env-sample index 1763499cc..493c8e994 100644 --- a/docker/config/env-sample +++ b/docker/config/env-sample @@ -7,3 +7,13 @@ EMAIL_HOST = '' EMAIL_HOST_USER = '' EMAIL_SEND_USER = '' EMAIL_HOST_PASSWORD = '' + +# Login Único gov.br +GOVBR_LOGIN_ENABLED = False +GOVBR_SSO_BASE_URL = https://sso.staging.acesso.gov.br +GOVBR_CLIENT_ID = '' +GOVBR_CLIENT_SECRET = '' +GOVBR_REDIRECT_URI = https://sapl.indaiatuba.tec.br/auth/govbr/callback/ +GOVBR_POST_LOGOUT_REDIRECT_URI = https://sapl.indaiatuba.tec.br +GOVBR_USER_LOOKUP_FIELDS = username +GOVBR_AUTO_CREATE_USERS = False diff --git a/docker/config/env_dockerfile b/docker/config/env_dockerfile index 4b564ee6c..3323bde9d 100644 --- a/docker/config/env_dockerfile +++ b/docker/config/env_dockerfile @@ -7,3 +7,13 @@ EMAIL_HOST = '' EMAIL_HOST_USER = '' EMAIL_SEND_USER = '' EMAIL_HOST_PASSWORD = '' + +# Login Único gov.br +GOVBR_LOGIN_ENABLED = False +GOVBR_SSO_BASE_URL = https://sso.staging.acesso.gov.br +GOVBR_CLIENT_ID = '' +GOVBR_CLIENT_SECRET = '' +GOVBR_REDIRECT_URI = https://sapl.indaiatuba.tec.br/auth/govbr/callback/ +GOVBR_POST_LOGOUT_REDIRECT_URI = https://sapl.indaiatuba.tec.br +GOVBR_USER_LOOKUP_FIELDS = username +GOVBR_AUTO_CREATE_USERS = False diff --git a/docs/login-govbr.md b/docs/login-govbr.md new file mode 100644 index 000000000..a9d2501e1 --- /dev/null +++ b/docs/login-govbr.md @@ -0,0 +1,110 @@ +# Login gov.br no SAPL + +Este documento registra a integração do SAPL com o Login Único gov.br por +OpenID Connect. Ele serve como referência para revisar, publicar no GitHub da +equipe e depois espelhar a alteração no repositório de produção +`Camara-Indaiatuba/SAPL.git`. + +## Escopo da alteração + +- adiciona o cliente OIDC gov.br em `sapl/base/govbr.py`; +- expõe as rotas `/login/govbr/`, `/auth/govbr/callback/` e a rota legada + `/login/govbr/callback/`; +- exibe o botão "Entrar com GOV.BR" na tela de login apenas quando a integração + está habilitada; +- valida `state`, `nonce`, assinatura RS256 via JWK, `audience` e `issuer`; +- resolve o usuário local pelo CPF retornado pelo gov.br, procurando por + `username` por padrão e opcionalmente por e-mail verificado; +- permite criação automática de usuário quando `GOVBR_AUTO_CREATE_USERS=True`; +- redireciona o logout para o encerramento da sessão gov.br quando a sessão foi + autenticada por esse provedor; +- adiciona `requests` e `PyJWT[crypto]` como dependências de runtime; +- adiciona testes para renderização do botão, início do fluxo OIDC e resolução + do usuário por CPF. + +## Variáveis de ambiente + +As variáveis abaixo ficam em `sapl/settings.py` e podem ser configuradas no +`.env`, Docker Compose ou ambiente de produção. + +```env +GOVBR_LOGIN_ENABLED=True +GOVBR_SSO_BASE_URL=https://sso.staging.acesso.gov.br +GOVBR_CLIENT_ID= +GOVBR_CLIENT_SECRET= +GOVBR_SCOPE=openid email profile govbr_confiabilidades govbr_confiabilidades_idtoken +GOVBR_REDIRECT_URI=https://sapl.indaiatuba.tec.br/auth/govbr/callback/ +GOVBR_POST_LOGOUT_REDIRECT_URI=https://sapl.indaiatuba.tec.br +GOVBR_USER_LOOKUP_FIELDS=username +GOVBR_AUTO_CREATE_USERS=False +``` + +Variáveis avançadas, úteis se o gov.br entregar URLs específicas junto com a +credencial: + +```env +GOVBR_ISSUER= +GOVBR_AUTHORIZE_URL= +GOVBR_TOKEN_URL= +GOVBR_JWK_URL= +GOVBR_LOGOUT_URL= +GOVBR_REQUEST_TIMEOUT=10 +GOVBR_JWT_LEEWAY=30 +GOVBR_STATE_MAX_AGE=600 +``` + +Para homologação, o valor padrão de `GOVBR_SSO_BASE_URL` aponta para +`https://sso.staging.acesso.gov.br`. Em produção, confirmar a URL final no +cadastro da credencial gov.br antes de ativar a integração. + +## Cadastro da aplicação no gov.br + +Solicitar credenciais de teste/homologação e produção no Serviço de Integração +aos Produtos do Ecossistema da Identidade Digital GOV.BR. + +Cadastrar pelo menos estas URLs na credencial: + +```text +Redirect URI: https://sapl.indaiatuba.tec.br/auth/govbr/callback/ +URL de logout: https://sapl.indaiatuba.tec.br +``` + +O roteiro técnico gov.br exige HTTPS para o fluxo de autenticação e recomenda +domínio oficial de governo para credenciais de produção. + +## Vínculo com usuários locais + +Por padrão, o SAPL procura uma conta local cujo `username` seja o CPF retornado +pelo gov.br, aceitando CPF apenas com dígitos ou no formato `000.000.000-00`. + +Para procurar também por e-mail verificado: + +```env +GOVBR_USER_LOOKUP_FIELDS=username,email +``` + +Use `GOVBR_AUTO_CREATE_USERS=True` somente se a criação automática já estiver +aprovada pela regra operacional da Câmara. Usuários criados automaticamente ficam +sem senha local utilizável e usam o CPF como `username`. + +## Checklist para produção + +1. Abrir uma branch dedicada a partir de `3.1.x`. +2. Incluir somente os arquivos do escopo gov.br no commit. +3. Configurar as variáveis no ambiente de produção, sem versionar segredo. +4. Garantir que os usuários locais que usarão gov.br tenham `username` igual ao + CPF, ou ajustar `GOVBR_USER_LOOKUP_FIELDS`. +5. Executar migrações existentes do SAPL, se houver pendência do ambiente. +6. Executar `python manage.py check`. +7. Executar `pytest sapl/base/tests/test_login.py -q`. +8. Validar manualmente a tela `/login/`, o redirecionamento para gov.br e o + retorno em `/auth/govbr/callback/`. +9. Publicar a branch no GitHub da equipe. +10. Abrir PR para `Camara-Indaiatuba/SAPL.git`, base `3.1.x`, descrevendo + configuração, impacto e validações. + +## Referências + +- Roteiro técnico gov.br: https://acesso.gov.br/roteiro-tecnico/iniciarintegracao.html +- Solicitação de credencial gov.br: https://acesso.gov.br/roteiro-tecnico/solicitacaocredencialprocesso.html +- pytest-django 4.5.2: https://pypi.org/project/pytest-django/4.5.2/ diff --git a/docs/matriz-soft-delete.md b/docs/matriz-soft-delete.md new file mode 100644 index 000000000..5ae29565a --- /dev/null +++ b/docs/matriz-soft-delete.md @@ -0,0 +1,371 @@ +# Matriz de Impacto - Soft Delete + +Branch analisada: `codex/login-govbr` + +Esta matriz classifica as tabelas do SAPL para a mudança de hard delete para exclusao logica. + +## Decisoes Aprovadas + +- Campos padrao nas tabelas SAPL em escopo: + - `excluido` + - `excluido_em` + - `excluido_por` + - `motivo_exclusao` +- Auditoria central: + - `base_auditoriaexclusaologica` + - `dados_registro` + - `motivo_restauracao` +- Todo soft delete exige `motivo_exclusao`. +- Toda restauracao exige `motivo_restauracao`. +- Restauracao sera permitida apenas ao grupo `Administrador do Sistema`. +- O grupo `Administrador do Sistema` deve ter todas as permissoes. +- Usuarios comuns poderao visualizar registros que eles mesmos excluiram apenas pelo futuro fluxo "Meus excluidos". +- Usuarios Gov.br poderao reativar o proprio usuario quando a conta estiver desativada por exclusao logica. +- Retencao permanente: nao ha purge/hard delete operacional previsto. +- Tabelas Django/terceiros devem usar `is_active` quando aplicavel; tabelas tecnicas permanecem sem soft delete. +- A exclusao de `base_appconfig`, `base_casalegislativa` e `painel_*` sera bloqueada. +- Para relacoes M2M automaticas, a auditoria central do servico de exclusao e suficiente. +- A tabela `parlamentares_mandato` permanece no escopo dos trabalhos. +- Nenhum endpoint atual devera mostrar registros excluidos pelo proprio usuario; inclusive `Proposicao` so exibira excluidos no futuro fluxo "Meus excluidos". + +## Legenda + +| Classificacao | Significado | +| --- | --- | +| Soft delete | Adicionar campos `excluido*`, ocultar nas consultas comuns, auditar exclusao e restauracao. | +| is_active | Usar flag propria de ativacao/desativacao, com auditoria central. | +| Manter tecnico | Manter sem soft delete; tabela tecnica, cache, migracao, sessao, token, permissao, view ou tabela com exclusao bloqueada. | + +## Fluxos Transversais Impactados + +| Fluxo | Impacto | +| --- | --- | +| CRUD generico | `CrudDeleteView` deve trocar delete fisico por soft delete com motivo obrigatorio. | +| APIs DRF | Querysets devem ocultar `excluido=True`, exceto endpoints administrativos. | +| Auditoria | `base_auditlog` hoje registra `D` via `post_delete`; soft delete deve registrar `D` manualmente. | +| Solr/Haystack | Indices devem remover/ocultar objetos excluidos logicamente e reindexar em restauracao. | +| Arquivos anexos | Overrides de `delete()` que removem arquivos fisicos devem ser ajustados para preservar arquivos no soft delete. | +| Permissoes | Criar grupo `Administrador do Sistema` e permissoes de ver/restaurar excluidos. | +| Gov.br | `auth_user.is_active=False` por exclusao logica deve permitir reativacao via login Gov.br. | +| Unicidade | Chaves unicas de tabelas com soft delete devem virar indices unicos parciais para registros nao excluidos. | +| Views SQL | Views devem filtrar tabelas base com `excluido=False`. | + +## Fluxos Por Modulo + +| Modulo | Fluxos impactados | +| --- | --- | +| `audiencia` | Cadastro de audiencias, anexos, arquivos de pauta/ata/anexo, consultas publicas. | +| `base` | Autores, operadores de autor, tipo de autor, auditoria, usuarios, configuracoes. | +| `comissoes` | Comissoes, composicoes, participacoes, reunioes, pautas, documentos acessorios. | +| `compilacao` | Texto articulado, dispositivos, publicacoes, notas, vides, owners e regras de ordenacao. | +| `lexml` | Provedor/publicador LexML e configuracoes de integracao. | +| `materia` | Materias, proposicoes, tramitacoes, autoria, anexadas, documentos, relatorias, numeracao, view de materia em tramitacao. | +| `norma` | Normas juridicas, autoria, anexos, legislacao citada, relacoes, estatisticas e view de estatisticas. | +| `painel` | Estado operacional de painel e cronometro. | +| `parlamentares` | Parlamentares, mandatos, filiacao, dependentes, mesa, frente, bloco, votantes. | +| `protocoloadm` | Protocolos, documentos administrativos, tramitacoes, anexados, vinculos e acompanhamentos. | +| `sessao` | Sessoes plenarias, expediente, ordem do dia, presencas, votacoes, justificativas, leituras, correspondencias. | +| Django/terceiros | Usuarios, grupos, permissoes, sessoes, tokens, thumbnails, feature flags. | + +## Matriz Tabela Por Tabela + +### Audiencia + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `audiencia_anexoaudienciapublica` | Soft delete | Anexos de audiencia; preservar arquivo fisico e auditar restauracao. | +| `audiencia_audienciapublica` | Soft delete | Cadastro principal de audiencia; possui arquivos removidos hoje no `delete()`. | +| `audiencia_tipoaudienciapublica` | Soft delete | Tabela auxiliar mantida por usuario. | + +### Auth, Tokens e Django + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `auth_group` | Manter tecnico | Grupos e permissoes; criar `Administrador do Sistema`; nao usar soft delete. | +| `auth_group_permissions` | Manter tecnico | Relacao tecnica de permissoes. | +| `auth_permission` | Manter tecnico | Permissoes geradas por Django/SAPL. | +| `auth_user` | is_active | Usar `is_active=False` com auditoria central; permitir reativacao Gov.br quando desativado por exclusao logica. | +| `auth_user_groups` | Manter tecnico | Relacao tecnica de grupos de usuario. | +| `auth_user_user_permissions` | Manter tecnico | Relacao tecnica de permissoes diretas. | +| `authtoken_token` | Manter tecnico | Token operacional; hard delete em rotacao deve continuar. | +| `django_admin_log` | Manter tecnico | Log tecnico/administrativo imutavel. | +| `django_content_type` | Manter tecnico | Metadados do Django. | +| `django_migrations` | Manter tecnico | Controle de migrations. | +| `django_session` | Manter tecnico | Sessao temporaria. | + +### Base + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `base_appconfig` | Manter tecnico | Exclusao bloqueada; configuracao singleton/global. | +| `base_auditlog` | Manter tecnico | Auditoria existente; deve receber evento `D` manual em soft delete. | +| `base_autor` | Soft delete | Autor e vinculos genericos; afeta Materia, Norma, Protocolo, Parlamentares, Comissoes. | +| `base_casalegislativa` | Manter tecnico | Exclusao bloqueada; configuracao institucional. | +| `base_metadata` | Manter tecnico | Metadados tecnicos/signaturas; preservar junto ao objeto. | +| `base_operadorautor` | Soft delete | Associacao usuario/autor; hoje pode ser removida ao editar usuario. | +| `base_tipoautor` | Soft delete | Tabela auxiliar; parte e gerada pelo sistema, mas administravel. | + +### Comissoes + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `comissoes_cargocomissao` | Soft delete | Tabela auxiliar. | +| `comissoes_comissao` | Soft delete | Cadastro principal; campo `ativa` e de dominio, nao substitui `excluido`. | +| `comissoes_composicao` | Soft delete | Periodos/composicoes; impacta participacoes. | +| `comissoes_documentoacessorio` | Soft delete | Documento de reuniao; preservar arquivo fisico. | +| `comissoes_participacao` | Soft delete | Integrantes/cargos em composicao. | +| `comissoes_periodo` | Soft delete | Tabela auxiliar de periodo. | +| `comissoes_reuniao` | Soft delete | Reunioes, pauta e anexos; possui arquivos removidos hoje no `delete()`. | +| `comissoes_tipocomissao` | Soft delete | Tabela auxiliar. | + +### Compilacao + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `compilacao_dispositivo` | Soft delete | Estrutura de texto articulado; muitos deletes reposicionam arvore/ordem. | +| `compilacao_nota` | Soft delete | Notas de dispositivos. | +| `compilacao_perfilestruturaltextoarticulado` | Soft delete | Configuracao estrutural administravel. | +| `compilacao_publicacao` | Soft delete | Publicacoes de texto articulado. | +| `compilacao_textoarticulado` | Soft delete | Documento principal do texto articulado. | +| `compilacao_textoarticulado_owners` | Soft delete | Relacao de proprietarios; auditoria central do servico de exclusao basta para esta relacao M2M. | +| `compilacao_tipodispositivo` | Soft delete | Tabela auxiliar estrutural. | +| `compilacao_tipodispositivorelationship` | Soft delete | Relacoes pai/filho permitidas; hoje valida unicidade. | +| `compilacao_tiponota` | Soft delete | Tabela auxiliar. | +| `compilacao_tipopublicacao` | Soft delete | Tabela auxiliar. | +| `compilacao_tipotextoarticulado` | Soft delete | Tabela auxiliar vinculada a content type. | +| `compilacao_tipotextoarticulado_perfis` | Soft delete | M2M auxiliar; auditoria central do servico de exclusao basta. | +| `compilacao_tipovide` | Soft delete | Tabela auxiliar. | +| `compilacao_veiculopublicacao` | Soft delete | Tabela auxiliar. | +| `compilacao_vide` | Soft delete | Referencias/vide entre dispositivos. | + +### Easy Thumbnails + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `easy_thumbnails_source` | Manter tecnico | Cache/derivacao de imagem. | +| `easy_thumbnails_thumbnail` | Manter tecnico | Cache/derivacao de imagem. | +| `easy_thumbnails_thumbnaildimensions` | Manter tecnico | Cache/derivacao de imagem. | + +### LexML + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `lexml_lexmlprovedor` | Soft delete | Configuracao de provedor LexML. | +| `lexml_lexmlpublicador` | Soft delete | Configuracao de publicador LexML. | + +### Materia + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `materia_acompanhamentomateria` | Soft delete | Acompanhamento por email/usuario. | +| `materia_anexada` | Soft delete | Relacao entre materias; impacta listagens de anexadas. | +| `materia_assuntomateria` | Soft delete | Tabela auxiliar. | +| `materia_autoria` | Soft delete | Autoria de materia; unicidade autor/materia deve considerar nao excluidos. | +| `materia_configetiquetamaterialegislativa` | Soft delete | Configuracao administravel de etiquetas. | +| `materia_despachoinicial` | Soft delete | Despachos/comissoes iniciais. | +| `materia_documentoacessorio` | Soft delete | Documento acessorio; preservar arquivo fisico e Solr. | +| `materia_historicoproposicao` | Soft delete | Historico de proposicao; em regra nao deve ser apagado fisicamente. | +| `materia_materiaassunto` | Soft delete | Relacao materia/assunto. | +| `materia_materialegislativa` | Soft delete | Cadastro principal; unicidade tipo/numero/ano deve virar indice parcial. | +| `materia_materiaemtramitacao` | Manter tecnico | View SQL; deve filtrar `materia_materialegislativa` e `materia_tramitacao` nao excluidas. | +| `materia_numeracao` | Soft delete | Numeracao complementar por tipo/ano. | +| `materia_orgao` | Soft delete | Tabela auxiliar/unidade. | +| `materia_origem` | Soft delete | Tabela auxiliar. | +| `materia_parecer` | Soft delete | Parecer de relatoria/materia. | +| `materia_pautareuniao` | Soft delete | Relacao materia/reuniao de comissao. | +| `materia_proposicao` | Soft delete | Proposicao; regras atuais bloqueiam exclusao apos envio/recebimento. | +| `materia_regimetramitacao` | Soft delete | Tabela auxiliar. | +| `materia_relatoria` | Soft delete | Relatoria de materia. | +| `materia_statustramitacao` | Soft delete | Tabela auxiliar; exclusao impacta tramitacoes. | +| `materia_tipodocumento` | Soft delete | Tabela auxiliar. | +| `materia_tipofimrelatoria` | Soft delete | Tabela auxiliar. | +| `materia_tipomaterialegislativa` | Soft delete | Tabela auxiliar; impacta numeracao e materias. | +| `materia_tipoproposicao` | Soft delete | Tabela auxiliar vinculada a content type. | +| `materia_tipoproposicao_perfis` | Soft delete | M2M auxiliar; auditoria central do servico de exclusao basta. | +| `materia_tramitacao` | Soft delete | Tramitação; deletes atuais recalculam `em_tramitacao`; restauracao deve recalcular tambem. | +| `materia_unidadetramitacao` | Soft delete | Unidade de tramitacao; impacta Materia, Protocolo, Sessao. | + +### Norma + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `norma_anexonormajuridica` | Soft delete | Anexos de norma; preservar arquivo fisico. | +| `norma_assuntonorma` | Soft delete | Tabela auxiliar. | +| `norma_autorianorma` | Soft delete | Autoria de norma; unicidade autor/norma deve considerar nao excluidos. | +| `norma_legislacaocitada` | Soft delete | Relacao materia/norma citada. | +| `norma_normaestatisticas` | Manter tecnico | Estatisticas de acesso; nao representa exclusao de dado de usuario. | +| `norma_normajuridica` | Soft delete | Cadastro principal; preservar texto integral e Solr. | +| `norma_normajuridica_assuntos` | Soft delete | M2M de assuntos; auditoria central do servico de exclusao basta. | +| `norma_normarelacionada` | Soft delete | Relacao entre normas. | +| `norma_tiponormajuridica` | Soft delete | Tabela auxiliar. | +| `norma_tipovinculonormajuridica` | Soft delete | Tabela auxiliar. | +| `norma_viewnormasestatisticas` | Manter tecnico | View SQL; deve ocultar normas excluidas. | + +### Painel + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `painel_cronometro` | Manter tecnico | Exclusao bloqueada; estado operacional/temporario do painel. | +| `painel_painel` | Manter tecnico | Exclusao bloqueada; estado operacional do painel. | + +### Parlamentares + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `parlamentares_bloco` | Soft delete | Cadastro de bloco; possui autores vinculados. | +| `parlamentares_bloco_partidos` | Soft delete | M2M bloco/partido; auditoria central do servico de exclusao basta. | +| `parlamentares_blococargo` | Soft delete | Cargo em bloco. | +| `parlamentares_blocomembro` | Soft delete | Membros de bloco. | +| `parlamentares_cargomesa` | Soft delete | Tabela auxiliar. | +| `parlamentares_coligacao` | Soft delete | Coligacao eleitoral. | +| `parlamentares_composicaocoligacao` | Soft delete | Relacao coligacao/partido. | +| `parlamentares_composicaomesa` | Soft delete | Composicao de mesa diretora. | +| `parlamentares_dependente` | Soft delete | Dependentes; preservar historico. | +| `parlamentares_filiacao` | Soft delete | Filiacoes partidarias. | +| `parlamentares_frente` | Soft delete | Frente parlamentar; possui autores vinculados. | +| `parlamentares_frentecargo` | Soft delete | Cargo em frente. | +| `parlamentares_frenteparlamentar` | Soft delete | Membros/cargos de frente. | +| `parlamentares_legislatura` | Soft delete | Legislatura; tabela estrutural de alto impacto. | +| `parlamentares_mandato` | Soft delete | Mandatos; historico parlamentar. | +| `parlamentares_mesadiretora` | Soft delete | Mesa diretora. | +| `parlamentares_nivelinstrucao` | Soft delete | Tabela auxiliar. | +| `parlamentares_parlamentar` | Soft delete | Cadastro principal; campo `ativo` e de dominio, nao substitui `excluido`. Preservar fotografia. | +| `parlamentares_partido` | Soft delete | Partidos; preservar logotipo. | +| `parlamentares_sessaolegislativa` | Soft delete | Sessao legislativa. | +| `parlamentares_situacaomilitar` | Soft delete | Tabela auxiliar. | +| `parlamentares_tipoafastamento` | Soft delete | Tabela auxiliar. | +| `parlamentares_tipodependente` | Soft delete | Tabela auxiliar. | +| `parlamentares_votante` | Soft delete | Vinculo parlamentar/usuario votante; hoje pode ser removido ao editar usuario. | + +### Protocolo Administrativo + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `protocoloadm_acompanhamentodocumento` | Soft delete | Acompanhamento por email/usuario. | +| `protocoloadm_anexado` | Soft delete | Relacao de documentos anexados. | +| `protocoloadm_documentoacessorioadministrativo` | Soft delete | Documento acessorio; preservar arquivo fisico. | +| `protocoloadm_documentoadministrativo` | Soft delete | Cadastro principal; preservar texto integral e respeitar restricao. | +| `protocoloadm_protocolo` | Soft delete | Protocolo; `anulado` e regra de negocio, nao substitui `excluido`. Unicidade numero/ano deve virar parcial. | +| `protocoloadm_statustramitacaoadministrativo` | Soft delete | Tabela auxiliar. | +| `protocoloadm_tipodocumentoadministrativo` | Soft delete | Tabela auxiliar. | +| `protocoloadm_tramitacaoadministrativo` | Soft delete | Tramitacao; deletes atuais recalculam status; restauracao deve recalcular tambem. | +| `protocoloadm_vinculodocadminmateria` | Soft delete | Vinculo documento/materia; unicidade deve considerar nao excluidos. | + +### Sessao + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `sessao_bancada` | Soft delete | Bancadas por legislatura/partido. | +| `sessao_cargobancada` | Soft delete | Cargo de bancada. | +| `sessao_consideracoesfinais` | Soft delete | Consideracoes finais da sessao. | +| `sessao_correspondencia` | Soft delete | Correspondencias vinculadas a documento administrativo. | +| `sessao_expedientemateria` | Soft delete | Materias do expediente; impacta leitura/votacao/retirada. | +| `sessao_expedientesessao` | Soft delete | Expediente textual; possui delete customizado. | +| `sessao_integrantemesa` | Soft delete | Integrantes da mesa da sessao. | +| `sessao_justificativaausencia` | Soft delete | Justificativas; preservar anexo. | +| `sessao_justificativaausencia_materias_da_ordem_do_dia` | Soft delete | M2M justificativa/ordem; auditoria central do servico de exclusao basta. | +| `sessao_justificativaausencia_materias_do_expediente` | Soft delete | M2M justificativa/expediente; auditoria central do servico de exclusao basta. | +| `sessao_ocorrenciasessao` | Soft delete | Ocorrencias da sessao. | +| `sessao_orador` | Soft delete | Oradores. | +| `sessao_oradorexpediente` | Soft delete | Oradores do expediente. | +| `sessao_oradorordemdia` | Soft delete | Oradores da ordem do dia. | +| `sessao_ordemdia` | Soft delete | Ordem do dia; impacta votacao/leitura/retirada. | +| `sessao_presencaordemdia` | Soft delete | Presencas da ordem do dia. | +| `sessao_registroleitura` | Soft delete | Registro de leitura. | +| `sessao_registrovotacao` | Soft delete | Registro de votacao; varios deletes operacionais hoje recriam votacao. | +| `sessao_resumoordenacao` | Soft delete | Configuracao/ordenacao de resumo da sessao. | +| `sessao_retiradapauta` | Soft delete | Retiradas de pauta. | +| `sessao_sessaoplenaria` | Soft delete | Cadastro principal; preservar pauta, ata e anexos. | +| `sessao_sessaoplenariapresenca` | Soft delete | Lista de presenca da sessao. | +| `sessao_tipoexpediente` | Soft delete | Tabela auxiliar. | +| `sessao_tipojustificativa` | Soft delete | Tabela auxiliar. | +| `sessao_tiporesultadovotacao` | Soft delete | Tabela auxiliar. | +| `sessao_tiporetiradapauta` | Soft delete | Tabela auxiliar. | +| `sessao_tiposessaoplenaria` | Soft delete | Tabela auxiliar. | +| `sessao_votoparlamentar` | Soft delete | Voto parlamentar; preservar trilha de auditoria. | + +### Waffle + +| Tabela | Classificacao | Fluxos/observacoes | +| --- | --- | --- | +| `waffle_flag` | Manter tecnico | Feature flag de terceiro. | +| `waffle_flag_groups` | Manter tecnico | Relacao tecnica de feature flag. | +| `waffle_flag_users` | Manter tecnico | Relacao tecnica de feature flag. | +| `waffle_sample` | Manter tecnico | Feature flag de terceiro. | +| `waffle_switch` | Manter tecnico | Feature flag de terceiro. | + +## Pontos De Alto Risco + +| Ponto | Risco | Acao antes de implementar | +| --- | --- | --- | +| Views SQL | Podem mostrar excluidos se nao forem reescritas. | Alterar views e criar testes de regressao. | +| M2M automaticas | Django nao tem model explicito para campos `excluido*`. | Auditar pelo servico central de exclusao; nao converter para `through` explicito nesta etapa. | +| Unicidade | Registros excluidos bloqueiam novo cadastro com mesma chave. | Converter constraints para indices parciais no PostgreSQL. | +| Arquivos | Overrides de `delete()` apagam arquivos fisicos. | Separar `soft_delete()` de `hard_delete_permanente()`; hard delete nao usado em operacao normal. | +| Gov.br | `auth_user.is_active=False` pode significar bloqueio ou exclusao logica. | Auditoria deve distinguir motivo e permitir reativacao apenas de exclusao logica. | +| Votacoes e tramitacoes | Deletes atuais recalculam estado operacional. | Soft delete/restauracao devem recalcular estado derivado. | +| Solr | Resultado de busca pode exibir excluidos. | Ajustar index querysets e executar reindexacao apos migracao. | + +## Endpoints Publicos e APIs Para Confirmacao + +Regra sugerida para confirmacao: + +- Consultas anonimas/publicas: nao devem expor registros com `excluido=True`. +- Usuarios autenticados comuns: podem ver registros que eles mesmos excluiram apenas no futuro fluxo "Meus excluidos". +- `Administrador do Sistema`: pode ver e restaurar todos os excluidos por telas/endpoints administrativos especificos. +- APIs devem aplicar a mesma regra das telas: list/detail publicos filtram excluidos; rotas administrativas ou "meus excluidos" usam permissao explicita. + +### Paginas Publicas Web + +| Modulo | Endpoints publicos candidatos | Dados impactados | Confirmacao sugerida | +| --- | --- | --- | --- | +| Base | `/`, `/sistema/pesquisa-textual`, `/sistema/estatisticas`, `/login/govbr/`, `/auth/govbr/callback/`, `/email/validate/...`, `/recuperar-senha/...` | Home, busca textual, estatisticas, login, validacao/recuperacao de conta. | Home/busca/estatisticas nao devem expor excluidos. Gov.br deve permitir reativacao apenas do proprio usuario excluido logicamente. | +| Audiencia | `/audiencia/`, detalhes de audiencia e anexos gerados por `AudienciaCrud` e `AnexoAudienciaPublicaCrud`. | `audiencia_audienciapublica`, `audiencia_anexoaudienciapublica`. | Publico nao ve excluidos. | +| Comissoes | `/comissao/`, `/comissao/`, composicoes, participacoes, reunioes, documentos acessorios e `/comissao//materias-em-tramitacao`. | Comissao, composicao, participacao, reuniao, documentos e materias em tramitacao. | Publico nao ve excluidos; se uma reuniao/composicao for excluida, ocultar tambem filhos na consulta publica. | +| Materia | `/materia/pesquisar-materia`, `/materia/`, `/materia//ta`, autoria, anexadas, despachos, assuntos, numeracao, legislacao citada, tramitacao, relatoria e documentos acessorios. | Materias e relacoes publicas do processo legislativo. | Publico nao ve excluidos. Confirmar se autores/operadores autenticados poderao ver seus proprios excluidos em uma tela separada. | +| Materia - acompanhamento | `/materia//acompanhar-materia/`, `/materia//acompanhar-confirmar`, `/materia//acompanhar-excluir`. | `materia_acompanhamentomateria`. | Nao expor acompanhamentos excluidos; cancelar acompanhamento deve usar soft delete com auditoria. | +| Materia - arquivos | `/materia/docacessorio/zip/`, `/materia/docacessorio/pdf/`. | Arquivos de documentos acessorios. | Publico nao deve baixar arquivos de registros excluidos. | +| Norma | `/norma/pesquisar`, `/norma/`, `/norma//ta`, anexos e autoria de norma. | Normas juridicas, anexos, autoria e texto articulado. | Publico nao ve excluidos; se norma for excluida, ocultar anexos/autorias/publicacoes associadas. | +| Parlamentares | `/parlamentar/`, `/parlamentar/`, `/parlamentar//materias`, `/parlamentar//normas`, `/parlamentar//frentes/`, filiacao, dependentes, participacoes, relatorias, proposicoes e `MandatoCrud`. | Parlamentares e historico politico, incluindo `parlamentares_mandato`. | Publico nao ve excluidos. `parlamentares_mandato` permanece no escopo. | +| Mesa diretora | `/mesa-diretora/`, `/mesa-diretora/altera-field-mesa-public-view/`. | Mesa diretora e composicao. | Publico nao ve excluidos. Rotas de alteracao continuam protegidas quando modificarem dados. | +| Protocolo administrativo | `/docadm/pesq-doc-adm`, `/docadm/`, `/docadm/texto_integral/`, anexados, tramitacoes, documentos acessorios e vinculos, quando documentos administrativos estiverem ostensivos. | Documentos administrativos e anexos. | Publico nao ve excluidos; respeitar `restrito=True` e configuracao ostensivo/restritivo. | +| Protocolo - acompanhamento | `/docadm//acompanhar-documento/`, `/docadm//acompanhar-confirmar`, `/docadm//acompanhar-excluir`. | `protocoloadm_acompanhamentodocumento`. | Nao expor acompanhamentos excluidos; cancelar acompanhamento deve usar soft delete com auditoria. | +| Sessao plenaria | `/sessao/pesquisar-sessao`, `/sessao/`, `/sessao//resumo`, `/sessao//resumo_ata`, `/sessao//expediente`, `/sessao//presenca`, `/sessao//presencaordemdia`, `/sessao//mesa`, `/sessao//ocorrencia_sessao`, `/sessao//consideracoes_finais`. | Sessao, presencas, mesa, expediente, ocorrencias e consideracoes finais. | Publico nao ve excluidos; resumos devem filtrar filhos excluidos. | +| Pauta e votacoes | `/sessao/pauta-sessao`, `/sessao/pauta-sessao/pesquisar-pauta`, `/sessao/pauta-sessao//pdf`, `/sessao//votacao-nominal-transparencia/...`, `/sessao//votacao-simbolica-transparencia/...`. | Pauta, ordem do dia, expediente, registros de votacao e votos. | Publico nao ve excluidos; se votacao for excluida logicamente, nao aparecer em transparencia publica. | +| Painel | `/painel-principal/`, `/painel//dados`, `/painel/mensagem`, `/painel/parlamentar`, `/painel/votacao`, `/painel/verifica-painel`, `/painel/cronometro`. | Estado operacional de painel/cronometro. | Exclusao bloqueada para `painel_*`; endpoints nao precisam expor excluidos. | +| Compilacao/texto articulado | `/ta/`, `/ta/`, `/ta//text`, `/ta//text/vigencia/...`, `/ta//publicacao`, `/ta//publicacao/`. | Texto articulado, dispositivos, publicacoes, notas e vides. | Publico so ve conteudo com permissao de visualizacao; nao expor excluidos. | +| LexML | `/sistema/lexml/provedor/`, `/sistema/lexml/request_search/...`, `/sistema/lexml/oai`. | Integracao LexML. | Confirmar se provedor/publicador seguem como tecnico; consultas LexML nao devem expor excluidos SAPL. | +| Relatorios publicos/operacionais | `/relatorios/materia`, `/relatorios/ordem-dia`, `/relatorios//sessao-plenaria`, `/relatorios//resumo_ata`, `/relatorios//sessao-plenaria-pdf`, `/relatorios//materia-tramitacao`, demais relatorios em `/sistema/relatorios/...`. | Relatorios de materia, norma, sessao, audiencia, reuniao, documentos e votacoes. | Aplicar a mesma regra do fluxo de origem; relatorios publicos nao exibem excluidos. | + +### APIs Publicas ou Semi-Publicas + +| API | Endpoints candidatos | Dados impactados | Confirmacao sugerida | +| --- | --- | --- | --- | +| API schema/docs | `/api/schema/`, `/api/schema/swagger-ui/`, `/api/schema/redoc/`. | Documentacao OpenAPI. | Sem dados de negocio; nao se aplica. | +| Auth/token | `/api/auth/token`, `/api/recriar-token/`. | Token de autenticacao. | Nao expor excluidos; recriar token segue admin. | +| Health/version | `/version/`, `/health/`, `/ready/`. | Estado tecnico. | Sem dados de negocio; nao se aplica. | +| API Audiencia | `/api/audiencia/tipoaudienciapublica/`, `/api/audiencia/audienciapublica/`, `/api/audiencia/anexoaudienciapublica/`. | Audiencias e anexos. | GET publico nao ve excluidos. | +| API Base | `/api/base/casalegislativa/`, `/api/base/tipoautor/`, `/api/base/autor/`, `/api/base/autor/possiveis/`, `/api/base/autor/provaveis/`, `/api/base/autor//`, `/api/contenttypes/contenttype/`. | Casa legislativa, autores, tipos e contenttypes. | `base_casalegislativa` tem exclusao bloqueada; autores excluidos nao aparecem publicamente. | +| API Comissoes | `/api/comissoes/cargocomissao/`, `/api/comissoes/tipocomissao/`, `/api/comissoes/periodo/`, `/api/comissoes/comissao/`, `/api/comissoes/composicao/`, `/api/comissoes/participacao/`, `/api/comissoes/reuniao/`, `/api/comissoes/documentoacessorio/`, `/api/comissoes/comissao//materiaemtramitacao/`. | Comissoes, composicoes, participacoes, reunioes, documentos e materias em tramitacao. | GET publico nao ve excluidos. | +| API Compilacao | `/api/compilacao/tipotextoarticulado/`, `/api/compilacao/tiponota/`, `/api/compilacao/tipovide/`, `/api/compilacao/tipopublicacao/`, `/api/compilacao/veiculopublicacao/`, `/api/compilacao/tipodispositivo/`, `/api/compilacao/dispositivo/`, `/api/compilacao/publicacao/`, `/api/compilacao/vide/`, `/api/compilacao/nota/`. | Tipos, dispositivos, publicacoes, vides e notas. | GET publico nao ve excluidos; texto privado continua protegido por regra de permissao. | +| API Materia | `/api/materia/materialegislativa/`, `/api/materia/anexada/`, `/api/materia/autoria/`, `/api/materia/despachoinicial/`, `/api/materia/documentoacessorio/`, `/api/materia/materiaassunto/`, `/api/materia/assuntomateria/`, `/api/materia/numeracao/`, `/api/materia/tramitacao/`, `/api/materia/materiaemtramitacao/`, `/api/materia/relatoria/`, `/api/materia/pautareuniao/`, auxiliares de materia. | Materias e relacoes publicas. | GET publico nao ve excluidos. | +| API Proposicao | `/api/materia/proposicao/`, `/api/materia/proposicao//`. | Proposicoes. | GET publico por permissao customizada, mas queryset diferencia anonimo, dono e operador. O endpoint atual nao deve expor excluidos; proposicoes excluidas pelo dono aparecerao somente em "Meus excluidos". | +| API Norma | `/api/norma/normajuridica/`, `/api/norma/normarelacionada/`, `/api/norma/anexonormajuridica/`, `/api/norma/autorianorma/`, `/api/norma/legislacaocitada/`, `/api/norma/assuntonorma/`, `/api/norma/tiponormajuridica/`, `/api/norma/tipovinculonormajuridica/`, `/api/norma/normaestatisticas/`. | Normas, relacoes, anexos, autoria e estatisticas. | GET publico nao ve excluidos. | +| API Parlamentares | `/api/parlamentares/parlamentar/`, `/api/parlamentares/parlamentar//proposicoes/`, `/api/parlamentares/parlamentar/search_parlamentares/`, `/api/parlamentares/legislatura//parlamentares/`, `/api/parlamentares/mandato/`, filiacao, dependente, mesa, frente, bloco, votante e auxiliares. | Parlamentares e historico, incluindo `parlamentares_mandato`. | GET publico nao ve excluidos. Nenhum endpoint adicional autorizado a mostrar excluidos do proprio usuario. | +| API Protocolo administrativo | `/api/protocoloadm/documentoadministrativo/`, `/api/protocoloadm/documentoacessorioadministrativo/`, `/api/protocoloadm/tramitacaoadministrativo/`, `/api/protocoloadm/anexado/`. | Documentos administrativos e relacoes. | GET pode ser publico quando documentos administrativos estiverem ostensivos; sempre ocultar excluidos e respeitar `restrito=True`. | +| API Sessao | `/api/sessao/sessaoplenaria/`, `/api/sessao/sessaoplenaria/years/`, `/api/sessao/sessaoplenaria//expedientes/`, `/api/sessao/sessaoplenaria//ecidadania/`, `/api/sessao/sessaoplenaria/ecidadania/`, `/api/sessao-plenaria/`, `/api/sessao-plenaria//`. | Sessao plenaria, e-Cidadania e expediente. | GET publico nao ve excluidos; endpoint legado deve receber o mesmo filtro. | +| API Sessao - itens | `/api/sessao/expedientemateria/`, `/api/sessao/ordemdia/`, `/api/sessao/registrovotacao/`, `/api/sessao/votoparlamentar/`, `/api/sessao/retiradapauta/`, `/api/sessao/registroleitura/`, `/api/sessao/presencaordemdia/`, `/api/sessao/sessaoplenariapresenca/`, `/api/sessao/correspondencia/`, demais auxiliares. | Itens da sessao, votacoes, presencas e correspondencias. | GET publico nao ve excluidos; correspondencias respeitam restricao do documento administrativo. | +| API Painel | `/api/painel/painel/`, `/api/painel/cronometro/`. | Estado operacional do painel. | Exclusao bloqueada para `painel_*`; nao expor excluidos. | + +### Endpoints Novos Necessarios + +| Endpoint/fluxo | Objetivo | Quem acessa | +| --- | --- | --- | +| Tela/API de excluidos por modulo | Listar registros `excluido=True` para auditoria e restauracao. | `Administrador do Sistema`. | +| Acao de restauracao | Restaurar registro com `motivo_restauracao` obrigatorio. | `Administrador do Sistema`. | +| "Meus excluidos" | Permitir ao usuario comum visualizar registros que ele mesmo excluiu. | Usuario autenticado, limitado por `excluido_por`. | +| Reativacao Gov.br | Reativar `auth_user.is_active=False` quando a auditoria indicar exclusao logica do proprio usuario. | Proprio usuario Gov.br. | + +## Informacoes Ainda Necessarias + +- Definir o desenho funcional e a URL do futuro fluxo "Meus excluidos". diff --git a/requirements/requirements.txt b/requirements/requirements.txt index ca05c71b7..e5f3c8185 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -19,6 +19,8 @@ dj-database-url==0.5.0 psycopg2-binary==2.9.9 pyyaml==6.0.1 pytz==2019.3 +PyJWT[crypto]==2.10.1 +requests==2.32.5 python-magic==0.4.15 unipath==1.1 Pillow==10.3.0 diff --git a/requirements/test-requirements.txt b/requirements/test-requirements.txt index 2da40408f..5fc477d5c 100644 --- a/requirements/test-requirements.txt +++ b/requirements/test-requirements.txt @@ -7,4 +7,5 @@ model-bakery==1.5.0 pycodestyle==2.12.1 pytest==8.3.3 pytest-cov==5.0.0 +pytest-django==4.5.2 WebTest==3.0.6 diff --git a/sapl/base/govbr.py b/sapl/base/govbr.py new file mode 100644 index 000000000..3a1809e62 --- /dev/null +++ b/sapl/base/govbr.py @@ -0,0 +1,353 @@ +import base64 +import hashlib +import json +import logging +import secrets +import time +from urllib.parse import urlencode + +import requests +from django.conf import settings +from django.contrib.auth import get_user_model +from django.utils.crypto import constant_time_compare + + +logger = logging.getLogger(__name__) + +GOVBR_SESSION_KEY = 'govbr_oidc' +GOVBR_AUTH_SESSION_KEY = 'govbr_authenticated' + + +class GovBrAuthError(Exception): + pass + + +def govbr_login_enabled(): + return bool(getattr(settings, 'GOVBR_LOGIN_ENABLED', False)) + + +def build_absolute_uri(request, configured_uri, view_name): + if configured_uri: + return configured_uri + + from django.urls import reverse + + return request.build_absolute_uri(reverse(view_name)) + + +def is_truthy(value): + if isinstance(value, bool): + return value + return str(value).lower() in ('1', 'true', 't', 'yes', 'y', 'sim') + + +def clean_cpf(value): + cpf = ''.join(ch for ch in str(value or '') if ch.isdigit()) + if len(cpf) == 11: + return cpf + return '' + + +def cpf_variants(cpf): + if not cpf: + return [] + return [ + cpf, + '{}.{}.{}-{}'.format(cpf[:3], cpf[3:6], cpf[6:9], cpf[9:]), + ] + + +def get_lookup_fields(): + fields = getattr(settings, 'GOVBR_USER_LOOKUP_FIELDS', ('username',)) + if isinstance(fields, str): + fields = fields.replace(';', ',').split(',') + return tuple(field.strip() for field in fields if field.strip()) + + +def _base64url(value): + return base64.urlsafe_b64encode(value).rstrip(b'=').decode('ascii') + + +def new_code_verifier(): + return _base64url(secrets.token_bytes(64)) + + +def code_challenge_from_verifier(code_verifier): + digest = hashlib.sha256(code_verifier.encode('ascii')).digest() + return _base64url(digest) + + +class GovBrClient: + + def __init__(self, http_session=None): + self.http = http_session or requests.Session() + + @property + def base_url(self): + return getattr(settings, 'GOVBR_SSO_BASE_URL', '').rstrip('/') + + @property + def issuer(self): + configured_issuer = getattr(settings, 'GOVBR_ISSUER', '') + return configured_issuer or '{}/'.format(self.base_url) + + @property + def authorize_url(self): + return ( + getattr(settings, 'GOVBR_AUTHORIZE_URL', '') or + '{}/authorize'.format(self.base_url) + ) + + @property + def token_url(self): + return ( + getattr(settings, 'GOVBR_TOKEN_URL', '') or + '{}/token'.format(self.base_url) + ) + + @property + def jwk_url(self): + return ( + getattr(settings, 'GOVBR_JWK_URL', '') or + '{}/jwk'.format(self.base_url) + ) + + @property + def logout_url(self): + return ( + getattr(settings, 'GOVBR_LOGOUT_URL', '') or + '{}/logout'.format(self.base_url) + ) + + @property + def timeout(self): + return getattr(settings, 'GOVBR_REQUEST_TIMEOUT', 10) + + def authorization_url(self, redirect_uri, state, nonce, code_verifier): + params = { + 'response_type': 'code', + 'client_id': settings.GOVBR_CLIENT_ID, + 'scope': settings.GOVBR_SCOPE, + 'redirect_uri': redirect_uri, + 'nonce': nonce, + 'state': state, + 'code_challenge': code_challenge_from_verifier(code_verifier), + 'code_challenge_method': 'S256', + } + return '{}?{}'.format(self.authorize_url, urlencode(params)) + + def exchange_code(self, code, redirect_uri, code_verifier): + data = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirect_uri, + 'code_verifier': code_verifier, + } + try: + response = self.http.post( + self.token_url, + auth=(settings.GOVBR_CLIENT_ID, settings.GOVBR_CLIENT_SECRET), + data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + timeout=self.timeout, + ) + response.raise_for_status() + except requests.RequestException as exc: + logger.exception('Erro ao trocar code do gov.br por tokens.') + raise GovBrAuthError( + 'Não foi possível concluir a autenticação no gov.br.') from exc + + try: + return response.json() + except ValueError as exc: + raise GovBrAuthError('Resposta inválida recebida do gov.br.') from exc + + def validate_tokens(self, token_response, nonce): + access_token = token_response.get('access_token') + id_token = token_response.get('id_token') + + if not access_token or not id_token: + raise GovBrAuthError( + 'Resposta do gov.br não retornou os tokens esperados.') + + access_claims = self.decode_token(access_token) + id_claims = self.decode_token(id_token) + + if not constant_time_compare(str(id_claims.get('nonce') or ''), nonce): + raise GovBrAuthError('Nonce inválido no retorno do gov.br.') + + return id_claims, access_claims + + def decode_token(self, token): + try: + import jwt + from jwt.algorithms import RSAAlgorithm + except ImportError as exc: + raise GovBrAuthError( + 'Biblioteca PyJWT não instalada para validar tokens gov.br.') from exc + + try: + header = jwt.get_unverified_header(token) + except jwt.InvalidTokenError as exc: + raise GovBrAuthError('Token gov.br inválido.') from exc + + if header.get('alg') != 'RS256': + raise GovBrAuthError('Algoritmo de assinatura gov.br não suportado.') + + key = self._find_jwk(header.get('kid')) + public_key = RSAAlgorithm.from_jwk(json.dumps(key)) + + try: + return jwt.decode( + token, + public_key, + algorithms=['RS256'], + audience=settings.GOVBR_CLIENT_ID, + issuer=self.issuer, + leeway=getattr(settings, 'GOVBR_JWT_LEEWAY', 30), + options={'require': ['exp', 'iat', 'iss', 'aud', 'sub']}, + ) + except jwt.InvalidTokenError as exc: + raise GovBrAuthError('Falha na validação do token gov.br.') from exc + + def _find_jwk(self, kid): + try: + response = self.http.get(self.jwk_url, timeout=self.timeout) + response.raise_for_status() + jwks = response.json() + except (requests.RequestException, ValueError) as exc: + logger.exception('Erro ao obter JWK do gov.br.') + raise GovBrAuthError('Não foi possível validar a assinatura do gov.br.') from exc + + for key in jwks.get('keys', []): + if key.get('kid') == kid: + return key + + raise GovBrAuthError( + 'Chave pública gov.br não encontrada para o token recebido.') + + def logout_redirect_url(self, post_logout_redirect_uri): + return '{}?{}'.format( + self.logout_url, + urlencode({'post_logout_redirect_uri': post_logout_redirect_uri}), + ) + + +def start_authorization(request, redirect_uri, next_url=''): + state = secrets.token_urlsafe(32) + nonce = secrets.token_urlsafe(32) + code_verifier = new_code_verifier() + + request.session[GOVBR_SESSION_KEY] = { + 'state': state, + 'nonce': nonce, + 'code_verifier': code_verifier, + 'next': next_url, + 'created_at': int(time.time()), + } + + return GovBrClient().authorization_url( + redirect_uri=redirect_uri, + state=state, + nonce=nonce, + code_verifier=code_verifier, + ) + + +def pop_authorization_state(request): + oidc_state = request.session.pop(GOVBR_SESSION_KEY, None) + if not oidc_state: + raise GovBrAuthError('Sessão de autenticação gov.br não encontrada.') + + max_age = getattr(settings, 'GOVBR_STATE_MAX_AGE', 600) + created_at = int(oidc_state.get('created_at') or 0) + if created_at + max_age < int(time.time()): + raise GovBrAuthError('Sessão de autenticação gov.br expirada.') + + return oidc_state + + +def resolve_user(id_claims, access_claims): + cpf = clean_cpf( + id_claims.get('preferred_username') or + id_claims.get('sub') or + access_claims.get('preferred_username') or + access_claims.get('sub') + ) + if not cpf: + raise GovBrAuthError('O retorno do gov.br não trouxe um CPF válido.') + + user = find_existing_user(cpf, id_claims) + if user is None and getattr(settings, 'GOVBR_AUTO_CREATE_USERS', False): + user = create_user_from_claims(cpf, id_claims) + + if user is None: + raise GovBrAuthError( + 'Não foi encontrada conta local vinculada ao CPF autenticado no gov.br.' + ) + + if not user.is_active: + raise GovBrAuthError('A conta local vinculada ao gov.br está inativa.') + + return user, cpf + + +def find_existing_user(cpf, id_claims): + User = get_user_model() + email = id_claims.get('email') + email_verified = is_truthy(id_claims.get('email_verified')) + + for field in get_lookup_fields(): + if field == 'username': + user = first_or_error( + User.objects.filter(username__in=cpf_variants(cpf)), + 'CPF informado pelo gov.br', + ) + if user: + return user + elif field == 'email' and email and email_verified: + user = first_or_error( + User.objects.filter(email__iexact=email), + 'e-mail verificado informado pelo gov.br', + ) + if user: + return user + + return None + + +def first_or_error(queryset, description): + users = list(queryset[:2]) + if len(users) > 1: + raise GovBrAuthError( + 'Mais de uma conta local corresponde ao {}.'.format(description) + ) + return users[0] if users else None + + +def create_user_from_claims(cpf, id_claims): + User = get_user_model() + if User.objects.filter(username__in=cpf_variants(cpf)).exists(): + raise GovBrAuthError( + 'Já existe conta local com o CPF informado pelo gov.br.') + + name = (id_claims.get('social_name') or id_claims.get('name') or '').strip() + name_parts = name.split() + first_name = name_parts[0] if name_parts else '' + last_name = ' '.join(name_parts[1:]) if len(name_parts) > 1 else '' + + user = User(username=cpf) + user.first_name = limit_user_field(User, 'first_name', first_name) + user.last_name = limit_user_field(User, 'last_name', last_name) + + if id_claims.get('email') and is_truthy(id_claims.get('email_verified')): + user.email = limit_user_field(User, 'email', id_claims['email']) + + user.set_unusable_password() + user.save() + return user + + +def limit_user_field(User, field_name, value): + max_length = User._meta.get_field(field_name).max_length + return (value or '')[:max_length] diff --git a/sapl/base/tests/test_login.py b/sapl/base/tests/test_login.py index 6c6a75cb8..23b77bd1e 100755 --- a/sapl/base/tests/test_login.py +++ b/sapl/base/tests/test_login.py @@ -1,7 +1,12 @@ # -*- coding: utf-8 -*- +from urllib.parse import parse_qs, urlparse + from django.contrib.auth import get_user_model +from django.test import override_settings import pytest +from sapl.base.govbr import GOVBR_SESSION_KEY, resolve_user + pytestmark = pytest.mark.django_db @@ -26,6 +31,58 @@ def test_username_do_usuario_logado_aparece_na_barra(client, user): assert 'Sair' in str(response.content) +@override_settings(GOVBR_LOGIN_ENABLED=False) +def test_botao_govbr_nao_aparece_por_padrao(client): + response = client.get('/login/') + + assert 'Entrar com GOV.BR' not in str(response.content) + + +@override_settings(GOVBR_LOGIN_ENABLED=True) +def test_botao_govbr_aparece_quando_habilitado(client): + response = client.get('/login/') + + assert 'Entrar com GOV.BR' in str(response.content) + + +@override_settings( + GOVBR_LOGIN_ENABLED=True, + GOVBR_CLIENT_ID='cliente-sapl', + GOVBR_CLIENT_SECRET='segredo', + GOVBR_SSO_BASE_URL='https://sso.staging.acesso.gov.br', + GOVBR_SCOPE='openid email profile govbr_confiabilidades govbr_confiabilidades_idtoken', + GOVBR_REDIRECT_URI='https://sapl.indaiatuba.tec.br/auth/govbr/callback/') +def test_inicio_login_govbr_redireciona_para_authorize(client): + response = client.get('/login/govbr/?next=/sistema/') + redirect = urlparse(response['Location']) + params = parse_qs(redirect.query) + + assert response.status_code == 302 + assert redirect.scheme == 'https' + assert redirect.netloc == 'sso.staging.acesso.gov.br' + assert redirect.path == '/authorize' + assert params['response_type'] == ['code'] + assert params['client_id'] == ['cliente-sapl'] + assert params['redirect_uri'] == [ + 'https://sapl.indaiatuba.tec.br/auth/govbr/callback/'] + assert params['code_challenge_method'] == ['S256'] + assert params['state'] == [client.session[GOVBR_SESSION_KEY]['state']] + assert client.session[GOVBR_SESSION_KEY]['next'] == '/sistema/' + + +@override_settings(GOVBR_USER_LOOKUP_FIELDS='username') +def test_resolve_usuario_govbr_por_cpf_no_username(user): + user.username = '12345678900' + user.save() + + usuario, cpf = resolve_user( + {'sub': '12345678900', 'preferred_username': '12345678900'}, + {'sub': '12345678900'}) + + assert usuario == user + assert cpf == '12345678900' + + # def test_nome_completo_do_usuario_logado_aparece_na_barra(client, user): # # nome completo para o usuario # user.first_name = 'Joao' diff --git a/sapl/base/urls.py b/sapl/base/urls.py index 6733a25ec..053ec3e50 100644 --- a/sapl/base/urls.py +++ b/sapl/base/urls.py @@ -1,7 +1,6 @@ import os from django.conf.urls import include, url -from django.contrib.auth import views from django.contrib.auth.decorators import permission_required from django.views.generic.base import RedirectView, TemplateView @@ -10,7 +9,8 @@ from sapl.base.views import (AutorCrud, ConfirmarEmailView, TipoAutorCrud, get_e RecuperarSenhaConfirmaView, RecuperarSenhaCompletoView, IndexView, UserCrud) from sapl.settings import MEDIA_URL, LOGOUT_REDIRECT_URL from .apps import AppConfig -from .views import (LoginSapl, AlterarSenha, AppConfigCrud, CasaLegislativaCrud, +from .views import (LoginSapl, LogoutSapl, GovBrLoginStartView, GovBrCallbackView, + AlterarSenha, AppConfigCrud, CasaLegislativaCrud, HelpTopicView, LogotipoView, PesquisarAuditLogView, SaplSearchView, ListarInconsistenciasView, @@ -118,7 +118,12 @@ urlpatterns = [ name='sistema'), url(r'^login/$', LoginSapl.as_view(), name='login'), - url(r'^logout/$', views.LogoutView.as_view(), + url(r'^login/govbr/$', GovBrLoginStartView.as_view(), name='govbr_login'), + url(r'^auth/govbr/callback/$', + GovBrCallbackView.as_view(), name='govbr_callback'), + url(r'^login/govbr/callback/$', + GovBrCallbackView.as_view(), name='govbr_callback_legacy'), + url(r'^logout/$', LogoutSapl.as_view(), {'next_page': LOGOUT_REDIRECT_URL}, name='logout'), url(r'^sistema/search/', SaplSearchView(), name='haystack_search'), diff --git a/sapl/base/views.py b/sapl/base/views.py index 2b53aa57c..edefebc4f 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -21,6 +21,7 @@ from django.template import TemplateDoesNotExist from django.template.loader import get_template from django.urls import reverse, reverse_lazy from django.utils import timezone +from django.utils.crypto import constant_time_compare from django.utils.decorators import method_decorator from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode @@ -33,6 +34,10 @@ from haystack.views import SearchView from ratelimit.decorators import ratelimit from sapl import settings +from sapl.base.govbr import ( + GOVBR_AUTH_SESSION_KEY, GovBrAuthError, GovBrClient, + build_absolute_uri, govbr_login_enabled, pop_authorization_state, + resolve_user, start_authorization) from sapl.base.forms import (AutorForm, TipoAutorForm, RecuperarSenhaForm, NovaSenhaForm, UserAdminForm, AuditLogFilterSet, LoginForm, SaplSearchForm) @@ -55,6 +60,11 @@ from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_serv from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm) from .models import AppConfig, CasaLegislativa +try: + from django.utils.http import url_has_allowed_host_and_scheme +except ImportError: + from django.utils.http import is_safe_url as url_has_allowed_host_and_scheme + def get_casalegislativa(): return CasaLegislativa.objects.first() @@ -76,6 +86,11 @@ class LoginSapl(views.LoginView): template_name = 'base/login.html' authentication_form = LoginForm + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['govbr_login_enabled'] = govbr_login_enabled() + return context + def form_valid(self, form): """Override do comportamento padrão para verificar senha fraca""" username = form.cleaned_data.get('username') @@ -92,6 +107,124 @@ class LoginSapl(views.LoginView): return super().form_invalid(form) +class GovBrLoginStartView(RedirectView): + permanent = False + + def get_redirect_url(self, *args, **kwargs): + if not govbr_login_enabled(): + messages.error(self.request, _('Login gov.br não está habilitado.')) + return reverse('sapl.base:login') + + if not settings.GOVBR_CLIENT_ID or not settings.GOVBR_CLIENT_SECRET: + messages.error(self.request, _('Credenciais gov.br não configuradas.')) + return reverse('sapl.base:login') + + next_url = safe_next_url( + self.request, + self.request.GET.get('next') or settings.LOGIN_REDIRECT_URL) + redirect_uri = build_absolute_uri( + self.request, + settings.GOVBR_REDIRECT_URI, + 'sapl.base:govbr_callback') + + try: + return start_authorization(self.request, redirect_uri, next_url) + except Exception: + logging.exception('Erro ao iniciar login gov.br.') + messages.error( + self.request, + _('Não foi possível iniciar o login gov.br.')) + return reverse('sapl.base:login') + + +class GovBrCallbackView(RedirectView): + permanent = False + + def get_redirect_url(self, *args, **kwargs): + login_url = reverse('sapl.base:login') + + if not govbr_login_enabled(): + messages.error(self.request, _('Login gov.br não está habilitado.')) + return login_url + + if self.request.GET.get('error'): + messages.error( + self.request, + _('Autenticação gov.br cancelada ou não autorizada.')) + return login_url + + try: + oidc_state = pop_authorization_state(self.request) + returned_state = self.request.GET.get('state') or '' + if not constant_time_compare(returned_state, oidc_state['state']): + raise GovBrAuthError('State inválido no retorno do gov.br.') + + code = self.request.GET.get('code') + if not code: + raise GovBrAuthError( + 'Retorno do gov.br não trouxe o código de autenticação.') + + redirect_uri = build_absolute_uri( + self.request, + settings.GOVBR_REDIRECT_URI, + 'sapl.base:govbr_callback') + client = GovBrClient() + token_response = client.exchange_code( + code, + redirect_uri, + oidc_state['code_verifier']) + id_claims, access_claims = client.validate_tokens( + token_response, + oidc_state['nonce']) + user, cpf = resolve_user(id_claims, access_claims) + + login( + self.request, + user, + backend='django.contrib.auth.backends.ModelBackend') + self.request.session[GOVBR_AUTH_SESSION_KEY] = True + self.request.session['govbr_cpf'] = cpf + + return oidc_state.get('next') or settings.LOGIN_REDIRECT_URL + except GovBrAuthError as exc: + messages.error(self.request, _(str(exc))) + return login_url + except Exception: + logging.exception('Erro no callback do login gov.br.') + messages.error( + self.request, + _('Não foi possível concluir o login gov.br.')) + return login_url + + +class LogoutSapl(views.LogoutView): + + def dispatch(self, request, *args, **kwargs): + should_logout_govbr = govbr_login_enabled() and request.session.get( + GOVBR_AUTH_SESSION_KEY, False) + response = super().dispatch(request, *args, **kwargs) + + if should_logout_govbr: + post_logout_redirect_uri = settings.GOVBR_POST_LOGOUT_REDIRECT_URI + if not post_logout_redirect_uri: + post_logout_redirect_uri = request.build_absolute_uri( + reverse('sapl.base:login')) + logout_url = GovBrClient().logout_redirect_url( + post_logout_redirect_uri) + return redirect(logout_url) + + return response + + +def safe_next_url(request, next_url): + if next_url and url_has_allowed_host_and_scheme( + url=next_url, + allowed_hosts={request.get_host()}, + require_https=request.is_secure()): + return next_url + return settings.LOGIN_REDIRECT_URL + + class ConfirmarEmailView(TemplateView): template_name = "email/confirma.html" diff --git a/sapl/settings.py b/sapl/settings.py index 4be58ab0c..cfb16141b 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -43,6 +43,34 @@ ALLOWED_HOSTS = ['*'] LOGIN_REDIRECT_URL = '/' LOGIN_URL = '/login/?next=' +# Integração com Login Único gov.br (OpenID Connect) +GOVBR_LOGIN_ENABLED = config('GOVBR_LOGIN_ENABLED', default=False, cast=bool) +GOVBR_SSO_BASE_URL = config( + 'GOVBR_SSO_BASE_URL', + default='https://sso.staging.acesso.gov.br') +GOVBR_ISSUER = config('GOVBR_ISSUER', default='') +GOVBR_AUTHORIZE_URL = config('GOVBR_AUTHORIZE_URL', default='') +GOVBR_TOKEN_URL = config('GOVBR_TOKEN_URL', default='') +GOVBR_JWK_URL = config('GOVBR_JWK_URL', default='') +GOVBR_LOGOUT_URL = config('GOVBR_LOGOUT_URL', default='') +GOVBR_CLIENT_ID = config('GOVBR_CLIENT_ID', default='') +GOVBR_CLIENT_SECRET = config('GOVBR_CLIENT_SECRET', default='') +GOVBR_SCOPE = config( + 'GOVBR_SCOPE', + default='openid email profile govbr_confiabilidades govbr_confiabilidades_idtoken') +GOVBR_REDIRECT_URI = config('GOVBR_REDIRECT_URI', default='') +GOVBR_POST_LOGOUT_REDIRECT_URI = config( + 'GOVBR_POST_LOGOUT_REDIRECT_URI', default='') +GOVBR_USER_LOOKUP_FIELDS = config( + 'GOVBR_USER_LOOKUP_FIELDS', default='username') +GOVBR_AUTO_CREATE_USERS = config( + 'GOVBR_AUTO_CREATE_USERS', default=False, cast=bool) +GOVBR_REQUEST_TIMEOUT = config( + 'GOVBR_REQUEST_TIMEOUT', default=10, cast=int) +GOVBR_JWT_LEEWAY = config('GOVBR_JWT_LEEWAY', default=30, cast=int) +GOVBR_STATE_MAX_AGE = config( + 'GOVBR_STATE_MAX_AGE', default=600, cast=int) + SAPL_VERSION = '3.1.165-RC2' if DEBUG: diff --git a/sapl/templates/base/login.html b/sapl/templates/base/login.html index 11f2bfcec..4f083d650 100644 --- a/sapl/templates/base/login.html +++ b/sapl/templates/base/login.html @@ -1,5 +1,98 @@ {% extends "crud/detail.html" %} {% load i18n %} + +{% block head_content %}{{ block.super }} + + + +{% endblock head_content %} + {% block base_content %} @@ -13,11 +106,6 @@ {% endif %} --> - - - - -
@@ -26,6 +114,22 @@

Entrar

+ {% if govbr_login_enabled %} + +
+ {% endif %}
{% csrf_token %} @@ -113,4 +217,4 @@ }); }); -{% endblock %} \ No newline at end of file +{% endblock %}