From f278908074f41764ab0133591fb587fe7a71e320 Mon Sep 17 00:00:00 2001 From: miguel-salles Date: Mon, 18 May 2026 12:50:52 +0000 Subject: [PATCH] Adiciona login gov.br ao SAPL --- docker/config/env-sample | 10 + docker/config/env_dockerfile | 10 + docs/login-govbr.md | 110 +++++++++ requirements/requirements.txt | 2 + requirements/test-requirements.txt | 1 + sapl/base/govbr.py | 353 +++++++++++++++++++++++++++++ sapl/base/tests/test_login.py | 57 +++++ sapl/base/urls.py | 11 +- sapl/base/views.py | 133 +++++++++++ sapl/settings.py | 28 +++ sapl/templates/base/login.html | 116 +++++++++- 11 files changed, 822 insertions(+), 9 deletions(-) create mode 100644 docs/login-govbr.md create mode 100644 sapl/base/govbr.py diff --git a/docker/config/env-sample b/docker/config/env-sample index 1763499cc..493c8e994 100644 --- a/docker/config/env-sample +++ b/docker/config/env-sample @@ -7,3 +7,13 @@ EMAIL_HOST = '' EMAIL_HOST_USER = '' EMAIL_SEND_USER = '' EMAIL_HOST_PASSWORD = '' + +# Login Único gov.br +GOVBR_LOGIN_ENABLED = False +GOVBR_SSO_BASE_URL = https://sso.staging.acesso.gov.br +GOVBR_CLIENT_ID = '' +GOVBR_CLIENT_SECRET = '' +GOVBR_REDIRECT_URI = https://sapl.indaiatuba.tec.br/auth/govbr/callback/ +GOVBR_POST_LOGOUT_REDIRECT_URI = https://sapl.indaiatuba.tec.br +GOVBR_USER_LOOKUP_FIELDS = username +GOVBR_AUTO_CREATE_USERS = False diff --git a/docker/config/env_dockerfile b/docker/config/env_dockerfile index 4b564ee6c..3323bde9d 100644 --- a/docker/config/env_dockerfile +++ b/docker/config/env_dockerfile @@ -7,3 +7,13 @@ EMAIL_HOST = '' EMAIL_HOST_USER = '' EMAIL_SEND_USER = '' EMAIL_HOST_PASSWORD = '' + +# Login Único gov.br +GOVBR_LOGIN_ENABLED = False +GOVBR_SSO_BASE_URL = https://sso.staging.acesso.gov.br +GOVBR_CLIENT_ID = '' +GOVBR_CLIENT_SECRET = '' +GOVBR_REDIRECT_URI = https://sapl.indaiatuba.tec.br/auth/govbr/callback/ +GOVBR_POST_LOGOUT_REDIRECT_URI = https://sapl.indaiatuba.tec.br +GOVBR_USER_LOOKUP_FIELDS = username +GOVBR_AUTO_CREATE_USERS = False diff --git a/docs/login-govbr.md b/docs/login-govbr.md new file mode 100644 index 000000000..a9d2501e1 --- /dev/null +++ b/docs/login-govbr.md @@ -0,0 +1,110 @@ +# Login gov.br no SAPL + +Este documento registra a integração do SAPL com o Login Único gov.br por +OpenID Connect. Ele serve como referência para revisar, publicar no GitHub da +equipe e depois espelhar a alteração no repositório de produção +`Camara-Indaiatuba/SAPL.git`. + +## Escopo da alteração + +- adiciona o cliente OIDC gov.br em `sapl/base/govbr.py`; +- expõe as rotas `/login/govbr/`, `/auth/govbr/callback/` e a rota legada + `/login/govbr/callback/`; +- exibe o botão "Entrar com GOV.BR" na tela de login apenas quando a integração + está habilitada; +- valida `state`, `nonce`, assinatura RS256 via JWK, `audience` e `issuer`; +- resolve o usuário local pelo CPF retornado pelo gov.br, procurando por + `username` por padrão e opcionalmente por e-mail verificado; +- permite criação automática de usuário quando `GOVBR_AUTO_CREATE_USERS=True`; +- redireciona o logout para o encerramento da sessão gov.br quando a sessão foi + autenticada por esse provedor; +- adiciona `requests` e `PyJWT[crypto]` como dependências de runtime; +- adiciona testes para renderização do botão, início do fluxo OIDC e resolução + do usuário por CPF. + +## Variáveis de ambiente + +As variáveis abaixo ficam em `sapl/settings.py` e podem ser configuradas no +`.env`, Docker Compose ou ambiente de produção. + +```env +GOVBR_LOGIN_ENABLED=True +GOVBR_SSO_BASE_URL=https://sso.staging.acesso.gov.br +GOVBR_CLIENT_ID= +GOVBR_CLIENT_SECRET= +GOVBR_SCOPE=openid email profile govbr_confiabilidades govbr_confiabilidades_idtoken +GOVBR_REDIRECT_URI=https://sapl.indaiatuba.tec.br/auth/govbr/callback/ +GOVBR_POST_LOGOUT_REDIRECT_URI=https://sapl.indaiatuba.tec.br +GOVBR_USER_LOOKUP_FIELDS=username +GOVBR_AUTO_CREATE_USERS=False +``` + +Variáveis avançadas, úteis se o gov.br entregar URLs específicas junto com a +credencial: + +```env +GOVBR_ISSUER= +GOVBR_AUTHORIZE_URL= +GOVBR_TOKEN_URL= +GOVBR_JWK_URL= +GOVBR_LOGOUT_URL= +GOVBR_REQUEST_TIMEOUT=10 +GOVBR_JWT_LEEWAY=30 +GOVBR_STATE_MAX_AGE=600 +``` + +Para homologação, o valor padrão de `GOVBR_SSO_BASE_URL` aponta para +`https://sso.staging.acesso.gov.br`. Em produção, confirmar a URL final no +cadastro da credencial gov.br antes de ativar a integração. + +## Cadastro da aplicação no gov.br + +Solicitar credenciais de teste/homologação e produção no Serviço de Integração +aos Produtos do Ecossistema da Identidade Digital GOV.BR. + +Cadastrar pelo menos estas URLs na credencial: + +```text +Redirect URI: https://sapl.indaiatuba.tec.br/auth/govbr/callback/ +URL de logout: https://sapl.indaiatuba.tec.br +``` + +O roteiro técnico gov.br exige HTTPS para o fluxo de autenticação e recomenda +domínio oficial de governo para credenciais de produção. + +## Vínculo com usuários locais + +Por padrão, o SAPL procura uma conta local cujo `username` seja o CPF retornado +pelo gov.br, aceitando CPF apenas com dígitos ou no formato `000.000.000-00`. + +Para procurar também por e-mail verificado: + +```env +GOVBR_USER_LOOKUP_FIELDS=username,email +``` + +Use `GOVBR_AUTO_CREATE_USERS=True` somente se a criação automática já estiver +aprovada pela regra operacional da Câmara. Usuários criados automaticamente ficam +sem senha local utilizável e usam o CPF como `username`. + +## Checklist para produção + +1. Abrir uma branch dedicada a partir de `3.1.x`. +2. Incluir somente os arquivos do escopo gov.br no commit. +3. Configurar as variáveis no ambiente de produção, sem versionar segredo. +4. Garantir que os usuários locais que usarão gov.br tenham `username` igual ao + CPF, ou ajustar `GOVBR_USER_LOOKUP_FIELDS`. +5. Executar migrações existentes do SAPL, se houver pendência do ambiente. +6. Executar `python manage.py check`. +7. Executar `pytest sapl/base/tests/test_login.py -q`. +8. Validar manualmente a tela `/login/`, o redirecionamento para gov.br e o + retorno em `/auth/govbr/callback/`. +9. Publicar a branch no GitHub da equipe. +10. Abrir PR para `Camara-Indaiatuba/SAPL.git`, base `3.1.x`, descrevendo + configuração, impacto e validações. + +## Referências + +- Roteiro técnico gov.br: https://acesso.gov.br/roteiro-tecnico/iniciarintegracao.html +- Solicitação de credencial gov.br: https://acesso.gov.br/roteiro-tecnico/solicitacaocredencialprocesso.html +- pytest-django 4.5.2: https://pypi.org/project/pytest-django/4.5.2/ diff --git a/requirements/requirements.txt b/requirements/requirements.txt index ca05c71b7..e5f3c8185 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -19,6 +19,8 @@ dj-database-url==0.5.0 psycopg2-binary==2.9.9 pyyaml==6.0.1 pytz==2019.3 +PyJWT[crypto]==2.10.1 +requests==2.32.5 python-magic==0.4.15 unipath==1.1 Pillow==10.3.0 diff --git a/requirements/test-requirements.txt b/requirements/test-requirements.txt index 2da40408f..5fc477d5c 100644 --- a/requirements/test-requirements.txt +++ b/requirements/test-requirements.txt @@ -7,4 +7,5 @@ model-bakery==1.5.0 pycodestyle==2.12.1 pytest==8.3.3 pytest-cov==5.0.0 +pytest-django==4.5.2 WebTest==3.0.6 diff --git a/sapl/base/govbr.py b/sapl/base/govbr.py new file mode 100644 index 000000000..3a1809e62 --- /dev/null +++ b/sapl/base/govbr.py @@ -0,0 +1,353 @@ +import base64 +import hashlib +import json +import logging +import secrets +import time +from urllib.parse import urlencode + +import requests +from django.conf import settings +from django.contrib.auth import get_user_model +from django.utils.crypto import constant_time_compare + + +logger = logging.getLogger(__name__) + +GOVBR_SESSION_KEY = 'govbr_oidc' +GOVBR_AUTH_SESSION_KEY = 'govbr_authenticated' + + +class GovBrAuthError(Exception): + pass + + +def govbr_login_enabled(): + return bool(getattr(settings, 'GOVBR_LOGIN_ENABLED', False)) + + +def build_absolute_uri(request, configured_uri, view_name): + if configured_uri: + return configured_uri + + from django.urls import reverse + + return request.build_absolute_uri(reverse(view_name)) + + +def is_truthy(value): + if isinstance(value, bool): + return value + return str(value).lower() in ('1', 'true', 't', 'yes', 'y', 'sim') + + +def clean_cpf(value): + cpf = ''.join(ch for ch in str(value or '') if ch.isdigit()) + if len(cpf) == 11: + return cpf + return '' + + +def cpf_variants(cpf): + if not cpf: + return [] + return [ + cpf, + '{}.{}.{}-{}'.format(cpf[:3], cpf[3:6], cpf[6:9], cpf[9:]), + ] + + +def get_lookup_fields(): + fields = getattr(settings, 'GOVBR_USER_LOOKUP_FIELDS', ('username',)) + if isinstance(fields, str): + fields = fields.replace(';', ',').split(',') + return tuple(field.strip() for field in fields if field.strip()) + + +def _base64url(value): + return base64.urlsafe_b64encode(value).rstrip(b'=').decode('ascii') + + +def new_code_verifier(): + return _base64url(secrets.token_bytes(64)) + + +def code_challenge_from_verifier(code_verifier): + digest = hashlib.sha256(code_verifier.encode('ascii')).digest() + return _base64url(digest) + + +class GovBrClient: + + def __init__(self, http_session=None): + self.http = http_session or requests.Session() + + @property + def base_url(self): + return getattr(settings, 'GOVBR_SSO_BASE_URL', '').rstrip('/') + + @property + def issuer(self): + configured_issuer = getattr(settings, 'GOVBR_ISSUER', '') + return configured_issuer or '{}/'.format(self.base_url) + + @property + def authorize_url(self): + return ( + getattr(settings, 'GOVBR_AUTHORIZE_URL', '') or + '{}/authorize'.format(self.base_url) + ) + + @property + def token_url(self): + return ( + getattr(settings, 'GOVBR_TOKEN_URL', '') or + '{}/token'.format(self.base_url) + ) + + @property + def jwk_url(self): + return ( + getattr(settings, 'GOVBR_JWK_URL', '') or + '{}/jwk'.format(self.base_url) + ) + + @property + def logout_url(self): + return ( + getattr(settings, 'GOVBR_LOGOUT_URL', '') or + '{}/logout'.format(self.base_url) + ) + + @property + def timeout(self): + return getattr(settings, 'GOVBR_REQUEST_TIMEOUT', 10) + + def authorization_url(self, redirect_uri, state, nonce, code_verifier): + params = { + 'response_type': 'code', + 'client_id': settings.GOVBR_CLIENT_ID, + 'scope': settings.GOVBR_SCOPE, + 'redirect_uri': redirect_uri, + 'nonce': nonce, + 'state': state, + 'code_challenge': code_challenge_from_verifier(code_verifier), + 'code_challenge_method': 'S256', + } + return '{}?{}'.format(self.authorize_url, urlencode(params)) + + def exchange_code(self, code, redirect_uri, code_verifier): + data = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirect_uri, + 'code_verifier': code_verifier, + } + try: + response = self.http.post( + self.token_url, + auth=(settings.GOVBR_CLIENT_ID, settings.GOVBR_CLIENT_SECRET), + data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + timeout=self.timeout, + ) + response.raise_for_status() + except requests.RequestException as exc: + logger.exception('Erro ao trocar code do gov.br por tokens.') + raise GovBrAuthError( + 'Não foi possível concluir a autenticação no gov.br.') from exc + + try: + return response.json() + except ValueError as exc: + raise GovBrAuthError('Resposta inválida recebida do gov.br.') from exc + + def validate_tokens(self, token_response, nonce): + access_token = token_response.get('access_token') + id_token = token_response.get('id_token') + + if not access_token or not id_token: + raise GovBrAuthError( + 'Resposta do gov.br não retornou os tokens esperados.') + + access_claims = self.decode_token(access_token) + id_claims = self.decode_token(id_token) + + if not constant_time_compare(str(id_claims.get('nonce') or ''), nonce): + raise GovBrAuthError('Nonce inválido no retorno do gov.br.') + + return id_claims, access_claims + + def decode_token(self, token): + try: + import jwt + from jwt.algorithms import RSAAlgorithm + except ImportError as exc: + raise GovBrAuthError( + 'Biblioteca PyJWT não instalada para validar tokens gov.br.') from exc + + try: + header = jwt.get_unverified_header(token) + except jwt.InvalidTokenError as exc: + raise GovBrAuthError('Token gov.br inválido.') from exc + + if header.get('alg') != 'RS256': + raise GovBrAuthError('Algoritmo de assinatura gov.br não suportado.') + + key = self._find_jwk(header.get('kid')) + public_key = RSAAlgorithm.from_jwk(json.dumps(key)) + + try: + return jwt.decode( + token, + public_key, + algorithms=['RS256'], + audience=settings.GOVBR_CLIENT_ID, + issuer=self.issuer, + leeway=getattr(settings, 'GOVBR_JWT_LEEWAY', 30), + options={'require': ['exp', 'iat', 'iss', 'aud', 'sub']}, + ) + except jwt.InvalidTokenError as exc: + raise GovBrAuthError('Falha na validação do token gov.br.') from exc + + def _find_jwk(self, kid): + try: + response = self.http.get(self.jwk_url, timeout=self.timeout) + response.raise_for_status() + jwks = response.json() + except (requests.RequestException, ValueError) as exc: + logger.exception('Erro ao obter JWK do gov.br.') + raise GovBrAuthError('Não foi possível validar a assinatura do gov.br.') from exc + + for key in jwks.get('keys', []): + if key.get('kid') == kid: + return key + + raise GovBrAuthError( + 'Chave pública gov.br não encontrada para o token recebido.') + + def logout_redirect_url(self, post_logout_redirect_uri): + return '{}?{}'.format( + self.logout_url, + urlencode({'post_logout_redirect_uri': post_logout_redirect_uri}), + ) + + +def start_authorization(request, redirect_uri, next_url=''): + state = secrets.token_urlsafe(32) + nonce = secrets.token_urlsafe(32) + code_verifier = new_code_verifier() + + request.session[GOVBR_SESSION_KEY] = { + 'state': state, + 'nonce': nonce, + 'code_verifier': code_verifier, + 'next': next_url, + 'created_at': int(time.time()), + } + + return GovBrClient().authorization_url( + redirect_uri=redirect_uri, + state=state, + nonce=nonce, + code_verifier=code_verifier, + ) + + +def pop_authorization_state(request): + oidc_state = request.session.pop(GOVBR_SESSION_KEY, None) + if not oidc_state: + raise GovBrAuthError('Sessão de autenticação gov.br não encontrada.') + + max_age = getattr(settings, 'GOVBR_STATE_MAX_AGE', 600) + created_at = int(oidc_state.get('created_at') or 0) + if created_at + max_age < int(time.time()): + raise GovBrAuthError('Sessão de autenticação gov.br expirada.') + + return oidc_state + + +def resolve_user(id_claims, access_claims): + cpf = clean_cpf( + id_claims.get('preferred_username') or + id_claims.get('sub') or + access_claims.get('preferred_username') or + access_claims.get('sub') + ) + if not cpf: + raise GovBrAuthError('O retorno do gov.br não trouxe um CPF válido.') + + user = find_existing_user(cpf, id_claims) + if user is None and getattr(settings, 'GOVBR_AUTO_CREATE_USERS', False): + user = create_user_from_claims(cpf, id_claims) + + if user is None: + raise GovBrAuthError( + 'Não foi encontrada conta local vinculada ao CPF autenticado no gov.br.' + ) + + if not user.is_active: + raise GovBrAuthError('A conta local vinculada ao gov.br está inativa.') + + return user, cpf + + +def find_existing_user(cpf, id_claims): + User = get_user_model() + email = id_claims.get('email') + email_verified = is_truthy(id_claims.get('email_verified')) + + for field in get_lookup_fields(): + if field == 'username': + user = first_or_error( + User.objects.filter(username__in=cpf_variants(cpf)), + 'CPF informado pelo gov.br', + ) + if user: + return user + elif field == 'email' and email and email_verified: + user = first_or_error( + User.objects.filter(email__iexact=email), + 'e-mail verificado informado pelo gov.br', + ) + if user: + return user + + return None + + +def first_or_error(queryset, description): + users = list(queryset[:2]) + if len(users) > 1: + raise GovBrAuthError( + 'Mais de uma conta local corresponde ao {}.'.format(description) + ) + return users[0] if users else None + + +def create_user_from_claims(cpf, id_claims): + User = get_user_model() + if User.objects.filter(username__in=cpf_variants(cpf)).exists(): + raise GovBrAuthError( + 'Já existe conta local com o CPF informado pelo gov.br.') + + name = (id_claims.get('social_name') or id_claims.get('name') or '').strip() + name_parts = name.split() + first_name = name_parts[0] if name_parts else '' + last_name = ' '.join(name_parts[1:]) if len(name_parts) > 1 else '' + + user = User(username=cpf) + user.first_name = limit_user_field(User, 'first_name', first_name) + user.last_name = limit_user_field(User, 'last_name', last_name) + + if id_claims.get('email') and is_truthy(id_claims.get('email_verified')): + user.email = limit_user_field(User, 'email', id_claims['email']) + + user.set_unusable_password() + user.save() + return user + + +def limit_user_field(User, field_name, value): + max_length = User._meta.get_field(field_name).max_length + return (value or '')[:max_length] diff --git a/sapl/base/tests/test_login.py b/sapl/base/tests/test_login.py index 6c6a75cb8..23b77bd1e 100755 --- a/sapl/base/tests/test_login.py +++ b/sapl/base/tests/test_login.py @@ -1,7 +1,12 @@ # -*- coding: utf-8 -*- +from urllib.parse import parse_qs, urlparse + from django.contrib.auth import get_user_model +from django.test import override_settings import pytest +from sapl.base.govbr import GOVBR_SESSION_KEY, resolve_user + pytestmark = pytest.mark.django_db @@ -26,6 +31,58 @@ def test_username_do_usuario_logado_aparece_na_barra(client, user): assert 'Sair' in str(response.content) +@override_settings(GOVBR_LOGIN_ENABLED=False) +def test_botao_govbr_nao_aparece_por_padrao(client): + response = client.get('/login/') + + assert 'Entrar com GOV.BR' not in str(response.content) + + +@override_settings(GOVBR_LOGIN_ENABLED=True) +def test_botao_govbr_aparece_quando_habilitado(client): + response = client.get('/login/') + + assert 'Entrar com GOV.BR' in str(response.content) + + +@override_settings( + GOVBR_LOGIN_ENABLED=True, + GOVBR_CLIENT_ID='cliente-sapl', + GOVBR_CLIENT_SECRET='segredo', + GOVBR_SSO_BASE_URL='https://sso.staging.acesso.gov.br', + GOVBR_SCOPE='openid email profile govbr_confiabilidades govbr_confiabilidades_idtoken', + GOVBR_REDIRECT_URI='https://sapl.indaiatuba.tec.br/auth/govbr/callback/') +def test_inicio_login_govbr_redireciona_para_authorize(client): + response = client.get('/login/govbr/?next=/sistema/') + redirect = urlparse(response['Location']) + params = parse_qs(redirect.query) + + assert response.status_code == 302 + assert redirect.scheme == 'https' + assert redirect.netloc == 'sso.staging.acesso.gov.br' + assert redirect.path == '/authorize' + assert params['response_type'] == ['code'] + assert params['client_id'] == ['cliente-sapl'] + assert params['redirect_uri'] == [ + 'https://sapl.indaiatuba.tec.br/auth/govbr/callback/'] + assert params['code_challenge_method'] == ['S256'] + assert params['state'] == [client.session[GOVBR_SESSION_KEY]['state']] + assert client.session[GOVBR_SESSION_KEY]['next'] == '/sistema/' + + +@override_settings(GOVBR_USER_LOOKUP_FIELDS='username') +def test_resolve_usuario_govbr_por_cpf_no_username(user): + user.username = '12345678900' + user.save() + + usuario, cpf = resolve_user( + {'sub': '12345678900', 'preferred_username': '12345678900'}, + {'sub': '12345678900'}) + + assert usuario == user + assert cpf == '12345678900' + + # def test_nome_completo_do_usuario_logado_aparece_na_barra(client, user): # # nome completo para o usuario # user.first_name = 'Joao' diff --git a/sapl/base/urls.py b/sapl/base/urls.py index 6733a25ec..053ec3e50 100644 --- a/sapl/base/urls.py +++ b/sapl/base/urls.py @@ -1,7 +1,6 @@ import os from django.conf.urls import include, url -from django.contrib.auth import views from django.contrib.auth.decorators import permission_required from django.views.generic.base import RedirectView, TemplateView @@ -10,7 +9,8 @@ from sapl.base.views import (AutorCrud, ConfirmarEmailView, TipoAutorCrud, get_e RecuperarSenhaConfirmaView, RecuperarSenhaCompletoView, IndexView, UserCrud) from sapl.settings import MEDIA_URL, LOGOUT_REDIRECT_URL from .apps import AppConfig -from .views import (LoginSapl, AlterarSenha, AppConfigCrud, CasaLegislativaCrud, +from .views import (LoginSapl, LogoutSapl, GovBrLoginStartView, GovBrCallbackView, + AlterarSenha, AppConfigCrud, CasaLegislativaCrud, HelpTopicView, LogotipoView, PesquisarAuditLogView, SaplSearchView, ListarInconsistenciasView, @@ -118,7 +118,12 @@ urlpatterns = [ name='sistema'), url(r'^login/$', LoginSapl.as_view(), name='login'), - url(r'^logout/$', views.LogoutView.as_view(), + url(r'^login/govbr/$', GovBrLoginStartView.as_view(), name='govbr_login'), + url(r'^auth/govbr/callback/$', + GovBrCallbackView.as_view(), name='govbr_callback'), + url(r'^login/govbr/callback/$', + GovBrCallbackView.as_view(), name='govbr_callback_legacy'), + url(r'^logout/$', LogoutSapl.as_view(), {'next_page': LOGOUT_REDIRECT_URL}, name='logout'), url(r'^sistema/search/', SaplSearchView(), name='haystack_search'), diff --git a/sapl/base/views.py b/sapl/base/views.py index 2b53aa57c..edefebc4f 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -21,6 +21,7 @@ from django.template import TemplateDoesNotExist from django.template.loader import get_template from django.urls import reverse, reverse_lazy from django.utils import timezone +from django.utils.crypto import constant_time_compare from django.utils.decorators import method_decorator from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode @@ -33,6 +34,10 @@ from haystack.views import SearchView from ratelimit.decorators import ratelimit from sapl import settings +from sapl.base.govbr import ( + GOVBR_AUTH_SESSION_KEY, GovBrAuthError, GovBrClient, + build_absolute_uri, govbr_login_enabled, pop_authorization_state, + resolve_user, start_authorization) from sapl.base.forms import (AutorForm, TipoAutorForm, RecuperarSenhaForm, NovaSenhaForm, UserAdminForm, AuditLogFilterSet, LoginForm, SaplSearchForm) @@ -55,6 +60,11 @@ from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_serv from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm) from .models import AppConfig, CasaLegislativa +try: + from django.utils.http import url_has_allowed_host_and_scheme +except ImportError: + from django.utils.http import is_safe_url as url_has_allowed_host_and_scheme + def get_casalegislativa(): return CasaLegislativa.objects.first() @@ -76,6 +86,11 @@ class LoginSapl(views.LoginView): template_name = 'base/login.html' authentication_form = LoginForm + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['govbr_login_enabled'] = govbr_login_enabled() + return context + def form_valid(self, form): """Override do comportamento padrão para verificar senha fraca""" username = form.cleaned_data.get('username') @@ -92,6 +107,124 @@ class LoginSapl(views.LoginView): return super().form_invalid(form) +class GovBrLoginStartView(RedirectView): + permanent = False + + def get_redirect_url(self, *args, **kwargs): + if not govbr_login_enabled(): + messages.error(self.request, _('Login gov.br não está habilitado.')) + return reverse('sapl.base:login') + + if not settings.GOVBR_CLIENT_ID or not settings.GOVBR_CLIENT_SECRET: + messages.error(self.request, _('Credenciais gov.br não configuradas.')) + return reverse('sapl.base:login') + + next_url = safe_next_url( + self.request, + self.request.GET.get('next') or settings.LOGIN_REDIRECT_URL) + redirect_uri = build_absolute_uri( + self.request, + settings.GOVBR_REDIRECT_URI, + 'sapl.base:govbr_callback') + + try: + return start_authorization(self.request, redirect_uri, next_url) + except Exception: + logging.exception('Erro ao iniciar login gov.br.') + messages.error( + self.request, + _('Não foi possível iniciar o login gov.br.')) + return reverse('sapl.base:login') + + +class GovBrCallbackView(RedirectView): + permanent = False + + def get_redirect_url(self, *args, **kwargs): + login_url = reverse('sapl.base:login') + + if not govbr_login_enabled(): + messages.error(self.request, _('Login gov.br não está habilitado.')) + return login_url + + if self.request.GET.get('error'): + messages.error( + self.request, + _('Autenticação gov.br cancelada ou não autorizada.')) + return login_url + + try: + oidc_state = pop_authorization_state(self.request) + returned_state = self.request.GET.get('state') or '' + if not constant_time_compare(returned_state, oidc_state['state']): + raise GovBrAuthError('State inválido no retorno do gov.br.') + + code = self.request.GET.get('code') + if not code: + raise GovBrAuthError( + 'Retorno do gov.br não trouxe o código de autenticação.') + + redirect_uri = build_absolute_uri( + self.request, + settings.GOVBR_REDIRECT_URI, + 'sapl.base:govbr_callback') + client = GovBrClient() + token_response = client.exchange_code( + code, + redirect_uri, + oidc_state['code_verifier']) + id_claims, access_claims = client.validate_tokens( + token_response, + oidc_state['nonce']) + user, cpf = resolve_user(id_claims, access_claims) + + login( + self.request, + user, + backend='django.contrib.auth.backends.ModelBackend') + self.request.session[GOVBR_AUTH_SESSION_KEY] = True + self.request.session['govbr_cpf'] = cpf + + return oidc_state.get('next') or settings.LOGIN_REDIRECT_URL + except GovBrAuthError as exc: + messages.error(self.request, _(str(exc))) + return login_url + except Exception: + logging.exception('Erro no callback do login gov.br.') + messages.error( + self.request, + _('Não foi possível concluir o login gov.br.')) + return login_url + + +class LogoutSapl(views.LogoutView): + + def dispatch(self, request, *args, **kwargs): + should_logout_govbr = govbr_login_enabled() and request.session.get( + GOVBR_AUTH_SESSION_KEY, False) + response = super().dispatch(request, *args, **kwargs) + + if should_logout_govbr: + post_logout_redirect_uri = settings.GOVBR_POST_LOGOUT_REDIRECT_URI + if not post_logout_redirect_uri: + post_logout_redirect_uri = request.build_absolute_uri( + reverse('sapl.base:login')) + logout_url = GovBrClient().logout_redirect_url( + post_logout_redirect_uri) + return redirect(logout_url) + + return response + + +def safe_next_url(request, next_url): + if next_url and url_has_allowed_host_and_scheme( + url=next_url, + allowed_hosts={request.get_host()}, + require_https=request.is_secure()): + return next_url + return settings.LOGIN_REDIRECT_URL + + class ConfirmarEmailView(TemplateView): template_name = "email/confirma.html" diff --git a/sapl/settings.py b/sapl/settings.py index 4be58ab0c..cfb16141b 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -43,6 +43,34 @@ ALLOWED_HOSTS = ['*'] LOGIN_REDIRECT_URL = '/' LOGIN_URL = '/login/?next=' +# Integração com Login Único gov.br (OpenID Connect) +GOVBR_LOGIN_ENABLED = config('GOVBR_LOGIN_ENABLED', default=False, cast=bool) +GOVBR_SSO_BASE_URL = config( + 'GOVBR_SSO_BASE_URL', + default='https://sso.staging.acesso.gov.br') +GOVBR_ISSUER = config('GOVBR_ISSUER', default='') +GOVBR_AUTHORIZE_URL = config('GOVBR_AUTHORIZE_URL', default='') +GOVBR_TOKEN_URL = config('GOVBR_TOKEN_URL', default='') +GOVBR_JWK_URL = config('GOVBR_JWK_URL', default='') +GOVBR_LOGOUT_URL = config('GOVBR_LOGOUT_URL', default='') +GOVBR_CLIENT_ID = config('GOVBR_CLIENT_ID', default='') +GOVBR_CLIENT_SECRET = config('GOVBR_CLIENT_SECRET', default='') +GOVBR_SCOPE = config( + 'GOVBR_SCOPE', + default='openid email profile govbr_confiabilidades govbr_confiabilidades_idtoken') +GOVBR_REDIRECT_URI = config('GOVBR_REDIRECT_URI', default='') +GOVBR_POST_LOGOUT_REDIRECT_URI = config( + 'GOVBR_POST_LOGOUT_REDIRECT_URI', default='') +GOVBR_USER_LOOKUP_FIELDS = config( + 'GOVBR_USER_LOOKUP_FIELDS', default='username') +GOVBR_AUTO_CREATE_USERS = config( + 'GOVBR_AUTO_CREATE_USERS', default=False, cast=bool) +GOVBR_REQUEST_TIMEOUT = config( + 'GOVBR_REQUEST_TIMEOUT', default=10, cast=int) +GOVBR_JWT_LEEWAY = config('GOVBR_JWT_LEEWAY', default=30, cast=int) +GOVBR_STATE_MAX_AGE = config( + 'GOVBR_STATE_MAX_AGE', default=600, cast=int) + SAPL_VERSION = '3.1.165-RC2' if DEBUG: diff --git a/sapl/templates/base/login.html b/sapl/templates/base/login.html index 11f2bfcec..4f083d650 100644 --- a/sapl/templates/base/login.html +++ b/sapl/templates/base/login.html @@ -1,5 +1,98 @@ {% extends "crud/detail.html" %} {% load i18n %} + +{% block head_content %}{{ block.super }} + + + +{% endblock head_content %} + {% block base_content %} @@ -13,11 +106,6 @@ {% endif %} --> - - - - -
@@ -26,6 +114,22 @@

Entrar

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