Browse Source

Merge 5391bac4b7 into 7079ca52d8

pull/3837/merge
miguel-salles 4 days ago
committed by GitHub
parent
commit
7eabf5d8ad
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      docker/config/env-sample
  2. 10
      docker/config/env_dockerfile
  3. 110
      docs/login-govbr.md
  4. 371
      docs/matriz-soft-delete.md
  5. 2
      requirements/requirements.txt
  6. 1
      requirements/test-requirements.txt
  7. 353
      sapl/base/govbr.py
  8. 57
      sapl/base/tests/test_login.py
  9. 11
      sapl/base/urls.py
  10. 133
      sapl/base/views.py
  11. 28
      sapl/settings.py
  12. 114
      sapl/templates/base/login.html

10
docker/config/env-sample

@ -7,3 +7,13 @@ EMAIL_HOST = ''
EMAIL_HOST_USER = '' EMAIL_HOST_USER = ''
EMAIL_SEND_USER = '' EMAIL_SEND_USER = ''
EMAIL_HOST_PASSWORD = '' 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

10
docker/config/env_dockerfile

@ -7,3 +7,13 @@ EMAIL_HOST = ''
EMAIL_HOST_USER = '' EMAIL_HOST_USER = ''
EMAIL_SEND_USER = '' EMAIL_SEND_USER = ''
EMAIL_HOST_PASSWORD = '' 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

110
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=<client-id-fornecido-pelo-govbr>
GOVBR_CLIENT_SECRET=<client-secret-fornecido-pelo-govbr>
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/

371
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/<pk>`, composicoes, participacoes, reunioes, documentos acessorios e `/comissao/<pk>/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/<pk>`, `/materia/<pk>/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/<pk>/acompanhar-materia/`, `/materia/<pk>/acompanhar-confirmar`, `/materia/<pk>/acompanhar-excluir`. | `materia_acompanhamentomateria`. | Nao expor acompanhamentos excluidos; cancelar acompanhamento deve usar soft delete com auditoria. |
| Materia - arquivos | `/materia/docacessorio/zip/<pk>`, `/materia/docacessorio/pdf/<pk>`. | Arquivos de documentos acessorios. | Publico nao deve baixar arquivos de registros excluidos. |
| Norma | `/norma/pesquisar`, `/norma/<pk>`, `/norma/<pk>/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/<pk>`, `/parlamentar/<pk>/materias`, `/parlamentar/<pk>/normas`, `/parlamentar/<pk>/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/<pk>`, `/docadm/texto_integral/<pk>`, 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/<pk>/acompanhar-documento/`, `/docadm/<pk>/acompanhar-confirmar`, `/docadm/<pk>/acompanhar-excluir`. | `protocoloadm_acompanhamentodocumento`. | Nao expor acompanhamentos excluidos; cancelar acompanhamento deve usar soft delete com auditoria. |
| Sessao plenaria | `/sessao/pesquisar-sessao`, `/sessao/<pk>`, `/sessao/<pk>/resumo`, `/sessao/<pk>/resumo_ata`, `/sessao/<pk>/expediente`, `/sessao/<pk>/presenca`, `/sessao/<pk>/presencaordemdia`, `/sessao/<pk>/mesa`, `/sessao/<pk>/ocorrencia_sessao`, `/sessao/<pk>/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/<pk>/pdf`, `/sessao/<pk>/votacao-nominal-transparencia/...`, `/sessao/<pk>/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/<pk>`, `/painel/<pk>/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/<pk>`, `/ta/<ta_id>/text`, `/ta/<ta_id>/text/vigencia/...`, `/ta/<ta_id>/publicacao`, `/ta/<ta_id>/publicacao/<pk>`. | 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/<pk>/sessao-plenaria`, `/relatorios/<pk>/resumo_ata`, `/relatorios/<pk>/sessao-plenaria-pdf`, `/relatorios/<pk>/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/<pk>`. | 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/<tipo_generico>/`, `/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/<pk>/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/<pk>/`. | 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/<pk>/proposicoes/`, `/api/parlamentares/parlamentar/search_parlamentares/`, `/api/parlamentares/legislatura/<pk>/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/<pk>/expedientes/`, `/api/sessao/sessaoplenaria/<pk>/ecidadania/`, `/api/sessao/sessaoplenaria/ecidadania/`, `/api/sessao-plenaria/`, `/api/sessao-plenaria/<pk>/`. | 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".

2
requirements/requirements.txt

@ -19,6 +19,8 @@ dj-database-url==0.5.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
pyyaml==6.0.1 pyyaml==6.0.1
pytz==2019.3 pytz==2019.3
PyJWT[crypto]==2.10.1
requests==2.32.5
python-magic==0.4.15 python-magic==0.4.15
unipath==1.1 unipath==1.1
Pillow==10.3.0 Pillow==10.3.0

1
requirements/test-requirements.txt

@ -7,4 +7,5 @@ model-bakery==1.5.0
pycodestyle==2.12.1 pycodestyle==2.12.1
pytest==8.3.3 pytest==8.3.3
pytest-cov==5.0.0 pytest-cov==5.0.0
pytest-django==4.5.2
WebTest==3.0.6 WebTest==3.0.6

353
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]

57
sapl/base/tests/test_login.py

@ -1,7 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from urllib.parse import parse_qs, urlparse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import override_settings
import pytest import pytest
from sapl.base.govbr import GOVBR_SESSION_KEY, resolve_user
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -26,6 +31,58 @@ def test_username_do_usuario_logado_aparece_na_barra(client, user):
assert '<a href="/logout/">Sair</a>' in str(response.content) assert '<a href="/logout/">Sair</a>' 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): # def test_nome_completo_do_usuario_logado_aparece_na_barra(client, user):
# # nome completo para o usuario # # nome completo para o usuario
# user.first_name = 'Joao' # user.first_name = 'Joao'

11
sapl/base/urls.py

@ -1,7 +1,6 @@
import os import os
from django.conf.urls import include, url from django.conf.urls import include, url
from django.contrib.auth import views
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.views.generic.base import RedirectView, TemplateView 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) RecuperarSenhaConfirmaView, RecuperarSenhaCompletoView, IndexView, UserCrud)
from sapl.settings import MEDIA_URL, LOGOUT_REDIRECT_URL from sapl.settings import MEDIA_URL, LOGOUT_REDIRECT_URL
from .apps import AppConfig from .apps import AppConfig
from .views import (LoginSapl, AlterarSenha, AppConfigCrud, CasaLegislativaCrud, from .views import (LoginSapl, LogoutSapl, GovBrLoginStartView, GovBrCallbackView,
AlterarSenha, AppConfigCrud, CasaLegislativaCrud,
HelpTopicView, LogotipoView, PesquisarAuditLogView, HelpTopicView, LogotipoView, PesquisarAuditLogView,
SaplSearchView, SaplSearchView,
ListarInconsistenciasView, ListarInconsistenciasView,
@ -118,7 +118,12 @@ urlpatterns = [
name='sistema'), name='sistema'),
url(r'^login/$', LoginSapl.as_view(), name='login'), 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'), {'next_page': LOGOUT_REDIRECT_URL}, name='logout'),
url(r'^sistema/search/', SaplSearchView(), name='haystack_search'), url(r'^sistema/search/', SaplSearchView(), name='haystack_search'),

133
sapl/base/views.py

@ -21,6 +21,7 @@ from django.template import TemplateDoesNotExist
from django.template.loader import get_template from django.template.loader import get_template
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.crypto import constant_time_compare
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode 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 ratelimit.decorators import ratelimit
from sapl import settings 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, from sapl.base.forms import (AutorForm, TipoAutorForm, RecuperarSenhaForm,
NovaSenhaForm, UserAdminForm, AuditLogFilterSet, NovaSenhaForm, UserAdminForm, AuditLogFilterSet,
LoginForm, SaplSearchForm) 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 .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm)
from .models import AppConfig, CasaLegislativa 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(): def get_casalegislativa():
return CasaLegislativa.objects.first() return CasaLegislativa.objects.first()
@ -76,6 +86,11 @@ class LoginSapl(views.LoginView):
template_name = 'base/login.html' template_name = 'base/login.html'
authentication_form = LoginForm 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): def form_valid(self, form):
"""Override do comportamento padrão para verificar senha fraca""" """Override do comportamento padrão para verificar senha fraca"""
username = form.cleaned_data.get('username') username = form.cleaned_data.get('username')
@ -92,6 +107,124 @@ class LoginSapl(views.LoginView):
return super().form_invalid(form) 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): class ConfirmarEmailView(TemplateView):
template_name = "email/confirma.html" template_name = "email/confirma.html"

28
sapl/settings.py

@ -43,6 +43,34 @@ ALLOWED_HOSTS = ['*']
LOGIN_REDIRECT_URL = '/' LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/login/?next=' 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' SAPL_VERSION = '3.1.165-RC2'
if DEBUG: if DEBUG:

114
sapl/templates/base/login.html

@ -1,5 +1,98 @@
{% extends "crud/detail.html" %} {% extends "crud/detail.html" %}
{% load i18n %} {% load i18n %}
{% block head_content %}{{ block.super }}
<!-- TODO: Hack. Atualizar vue-bootstrap libs so we can call this in base.html and get the glyphs -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<style>
.login-govbr .br-sign-in {
--button-radius: 100em;
--button-medium: 40px;
--button-size: var(--button-medium);
--color-dark: #ffffff;
--color-dark-rgb: 255, 255, 255;
--focus: #ffcd07;
--focus-offset: 4px;
--focus-style: solid;
--focus-width: 3px;
--font-size-scale-up-01: 16px;
--font-weight-semi-bold: 600;
--hover: 0.16;
--interactive-light: #1351b4;
--pressed: 0.24;
--spacing-scale-2x: 16px;
align-items: center;
background-color: transparent;
border: 0;
border-radius: var(--button-radius);
color: var(--interactive-light);
cursor: pointer;
display: inline-flex;
font-size: var(--font-size-scale-up-01);
font-weight: var(--font-weight-semi-bold);
height: var(--button-size);
justify-content: center;
letter-spacing: 0;
overflow: hidden;
padding: 0 var(--spacing-scale-2x);
position: relative;
text-align: center;
text-decoration: none;
vertical-align: middle;
white-space: nowrap;
width: auto;
}
.login-govbr .br-sign-in.block {
width: 100%;
}
.login-govbr .br-sign-in.primary {
--interactive-rgb: var(--color-dark-rgb);
background-color: var(--interactive-light);
color: var(--color-dark);
}
.login-govbr .br-sign-in:focus {
outline: none;
}
.login-govbr .br-sign-in:focus-visible {
outline-color: var(--focus);
outline-offset: var(--focus-offset);
outline-style: var(--focus-style);
outline-width: var(--focus-width);
}
.login-govbr .br-sign-in:hover {
background-image: linear-gradient(
rgba(var(--interactive-rgb), var(--hover)),
rgba(var(--interactive-rgb), var(--hover))
);
color: var(--color-dark);
text-decoration: none;
}
.login-govbr .br-sign-in:active {
background-image: linear-gradient(
rgba(var(--interactive-rgb), var(--pressed)),
rgba(var(--interactive-rgb), var(--pressed))
);
}
.login-govbr .br-sign-in__label {
margin-right: 0.35rem;
}
.login-govbr .br-sign-in__entity {
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
</style>
{% endblock head_content %}
{% block base_content %} {% block base_content %}
<!-- O bloco comentado é para ser implementado após as autorizacoes --> <!-- O bloco comentado é para ser implementado após as autorizacoes -->
@ -13,11 +106,6 @@
{% endif %} --> {% endif %} -->
<head>
<!-- TODO: Hack. Atualizar vue-bootstrap libs so we can call this in base.html and get the glyphs -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
</head>
<div class="container mb-3"> <div class="container mb-3">
<div class="row"> <div class="row">
<div class="col-lg-4 offset-lg-4 col-8 offset-2"> <div class="col-lg-4 offset-lg-4 col-8 offset-2">
@ -26,6 +114,22 @@
<h3 class=" font-weight-bolder ">Entrar</h3> <h3 class=" font-weight-bolder ">Entrar</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if govbr_login_enabled %}
<div class="login-govbr mb-3">
<a class="br-sign-in primary block"
type="primary"
density="middle"
label="Entrar com"
entity="GOV.BR"
href="{% url 'sapl.base:govbr_login' %}{% if next %}?next={{ next|urlencode }}{% endif %}"
aria-label="Entrar com GOV.BR"
title="Entrar com GOV.BR">
<span class="br-sign-in__label">Entrar com</span>
<span class="br-sign-in__entity">GOV.BR</span>
</a>
</div>
<hr>
{% endif %}
<form id="login-form" method="post" action="{% url 'sapl.base:login' %}"> <form id="login-form" method="post" action="{% url 'sapl.base:login' %}">
{% csrf_token %} {% csrf_token %}

Loading…
Cancel
Save