Browse Source

Merge f278908074 into 7079ca52d8

pull/3837/merge
miguel-salles 2 days ago
committed by GitHub
parent
commit
f929bdbb16
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. 2
      requirements/requirements.txt
  5. 1
      requirements/test-requirements.txt
  6. 353
      sapl/base/govbr.py
  7. 57
      sapl/base/tests/test_login.py
  8. 11
      sapl/base/urls.py
  9. 133
      sapl/base/views.py
  10. 28
      sapl/settings.py
  11. 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/

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