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. 116
      sapl/templates/base/login.html

10
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

10
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

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

1
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

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 -*-
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 '<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):
# # nome completo para o usuario
# user.first_name = 'Joao'

11
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'),

133
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"

28
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:

116
sapl/templates/base/login.html

@ -1,5 +1,98 @@
{% extends "crud/detail.html" %}
{% 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 %}
<!-- O bloco comentado é para ser implementado após as autorizacoes -->
@ -13,11 +106,6 @@
{% 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="row">
<div class="col-lg-4 offset-lg-4 col-8 offset-2">
@ -26,6 +114,22 @@
<h3 class=" font-weight-bolder ">Entrar</h3>
</div>
<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' %}">
{% csrf_token %}
@ -113,4 +217,4 @@
});
});
</script>
{% endblock %}
{% endblock %}

Loading…
Cancel
Save