Quando uma infraestrutura geoespacial começa a ser utilizada por diferentes equipes de uma organização, chega um momento em que manter usuários e senhas diretamente no GeoNode deixa de ser uma boa estratégia.

O usuário já possui uma identidade corporativa. Ele já entra em outros sistemas com a mesma conta. Em muitos casos, seus grupos e perfis também já são gerenciados centralmente.

Nesse cenário, uma alternativa interessante é integrar o GeoNode a um provedor de identidade utilizando OpenID Connect (OIDC).

O objetivo deste artigo é mostrar um exemplo genérico de integração de um GeoNode 5 com um provedor OIDC, incluindo não apenas o login, mas também a utilização das claims retornadas pelo provedor para associar automaticamente o usuário aos grupos do GeoNode.

A arquitetura é aproximadamente esta:

O detalhe interessante é que o GeoNode não precisa saber como o Identity Provider (IdP) autenticou o usuário. Por trás do IdP pode existir LDAP, Active Directory, outro diretório corporativo ou até outro serviço de identidade. Para o GeoNode, isso é transparente.

OIDC, OAuth 2.0 e autenticação

É comum OAuth 2.0 e OpenID Connect aparecerem juntos, mas eles não representam exatamente a mesma coisa.

  • OAuth 2.0 foi criado principalmente para autorização.
  • OpenID Connect acrescenta uma camada de identidade e autenticação sobre o OAuth 2.0.

É por isso que em uma integração OIDC normalmente aparecem informações como: authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, issuer. Além de scopes como: openid, profile e email.

Para aplicações corporativas, frequentemente aparecem também scopes ou claims relacionadas a grupos e roles.

Ambiente utilizado neste exemplo

Este artigo considera uma instalação do GeoNode 5 baseada no geonode-project e executada com Docker Compose.

Os comandos docker compose devem ser executados no diretório raiz do projeto, onde está o arquivo docker-compose.yml.

Nos exemplos abaixo, utilizarei geonode_project como nome do pacote Django. Caso seu projeto utilize outro nome, substitua geonode_project pelo nome correspondente.

1. O Discovery Document

Uma das primeiras informações que devemos solicitar à equipe responsável pelo Identity Provider é a URL de discovery do OIDC. Normalmente ela segue este padrão:

https://idp.exemplo.org/.well-known/openid-configuration ou https://idp.exemplo.org/security/.well-known/openid-configuration

Ao acessar essa URL, recebemos um JSON semelhante a:

{
  "issuer": "https://idp.exemplo.org/",
  "authorization_endpoint": "https://idp.exemplo.org/connect/authorize",
  "token_endpoint": "https://idp.exemplo.org/connect/token",
  "userinfo_endpoint": "https://idp.exemplo.org/connect/userinfo",
  "jwks_uri": "https://idp.exemplo.org/.well-known/jwks",
  "scopes_supported": [
    "openid",
    "profile",
    "email",
    "roles"
  ]
}

Esse documento funciona praticamente como um mapa da integração.

2. Registrar o GeoNode como client

No lado do Identity Provider, o GeoNode precisa ser registrado como uma aplicação/client. De maneira simplificada, teremos algo como:

Client ID:
geonode

Client Secret:
**************

Redirect URI:
https://geo.exemplo.org/account/oidc/corp-idp/login/callback/

A URL de callback deve coincidir exatamente com a rota exposta pela versão do GeoNode/django-allauth utilizada. Confirme a URL do seu ambiente antes de registrá-la no Identity Provider.

O nome corp-idp neste artigo é apenas um exemplo. Esse identificador será utilizado posteriormente pelo django-allauth para identificar o provedor. Um cuidado importante: o Client Secret não deve ficar diretamente no código-fonte nem ser enviado ao Git. O ideal é mantê-lo em variável de ambiente:

OIDC_CLIENT_ID=geonode
OIDC_CLIENT_SECRET=xxxxxxxxxxxxxxxx
OIDC_DISCOVERY_URL=https://idp.exemplo.org/.well-known/openid-configuration

3. Criando settings específicos

Uma estratégia que considero interessante é não alterar diretamente o settings.py original do GeoNode. Podemos criar, por exemplo: src/geonode_project/corporate_settings.py com o seguinte conteúdo:

import os

from .settings import *


OIDC_CLIENT_ID = os.getenv(
    "OIDC_CLIENT_ID",
    "geonode",
)

OIDC_CLIENT_SECRET = os.getenv(
    "OIDC_CLIENT_SECRET",
    "",
)

OIDC_DISCOVERY_URL = os.getenv(
    "OIDC_DISCOVERY_URL",
    "",
)

E então definir no ambiente:

DJANGO_SETTINGS_MODULE=geonode_project.corporate_settings

Isso facilita bastante manutenção, deploy e rollback.

4. Habilitando o provider OpenID Connect

O GeoNode utiliza django-allauth para parte da integração com contas sociais/federadas. O código atual do projeto continua trazendo um GenericOpenIDConnectAdaptere mecanismos próprios para extrair atributos e sincronizar grupos. Podemos registrar o provider desta forma:

OIDC_PROVIDER_APP = (
    "allauth.socialaccount.providers.openid_connect"
)

if OIDC_PROVIDER_APP not in INSTALLED_APPS:
    INSTALLED_APPS += (OIDC_PROVIDER_APP,)


SOCIALACCOUNT_PROVIDERS = dict(
    SOCIALACCOUNT_PROVIDERS
)

SOCIALACCOUNT_PROVIDERS["openid_connect"] = {
    "OAUTH_PKCE_ENABLED": True,

    "SCOPE": [
        "openid",
        "profile",
        "email",
        "roles",
    ],

    "APPS": [
        {
            "provider_id": "corp-idp",

            "name": "Login Corporativo",

            "client_id": OIDC_CLIENT_ID,

            "secret": OIDC_CLIENT_SECRET,

            "settings": {
                "server_url": OIDC_DISCOVERY_URL,
            },
        }
    ],
}

O scope roles utilizado neste exemplo não é obrigatório no padrão OIDC. Os scopes e claims disponíveis dependem do Identity Provider. Alguns provedores retornam grupos/roles com outros scopes ou apenas pelo endpoint userinfo.

Também podemos permitir criação automática da conta local:

SOCIALACCOUNT_AUTO_SIGNUP = True

Durante homologação, particularmente prefiro manter o login local disponível:

SOCIALACCOUNT_ONLY = False

Assim, caso algo dê errado com o IdP, ainda existe uma forma administrativa de entrar no GeoNode.

5. Primeiro teste: autenticação apenas

Neste momento eu ainda não me preocuparia com grupos. O primeiro objetivo é provar apenas este fluxo:

Depois de reiniciar a aplicação:

docker compose up -d --build django celery

Valide:

docker compose exec django python manage.py check

Podemos também acompanhar o log:

docker compose logs -f --tail=200 django

Ao clicar em Login Corporativo, devemos ser redirecionados ao IdP e, após a autenticação, voltar para algo semelhante a: /account/oidc/corp-idp/login/callback/

Se o usuário for criado e entrar no GeoNode, a primeira etapa está concluída.

6. O que o Identity Provider enviou?

Depois da autenticação, uma das etapas mais importantes é verificar o conteúdo recebido. Um conjunto de claims pode ser parecido com:

{
  "sub": "5d823759-3dc0-4414-a936-2ef659829321",

  "name": "Fernando Quadro",

  "preferred_username": "fernando.quadro",

  "email": "fernando.quadro@exemplo.org",

  "given_name": "Fernando",

  "family_name": "Quadro",

  "role": [
    "gestores"
  ]
}

O campo mais importante do ponto de vista de identidade é normalmente o sub, ou Subject Identifier, que é o identificador estável daquele usuário dentro do provedor. Nome, username e até e-mail podem mudar. O sub não deveria.

7. Consultando as claims armazenadas pelo GeoNode

Podemos verificar exatamente o que ficou armazenado no SocialAccount. Por exemplo:

docker compose exec django python manage.py shell -c "
import json

from allauth.socialaccount.models import SocialAccount

for account in (
    SocialAccount.objects
    .filter(provider='corp-idp')
    .select_related('user')
):
    print('GeoNode:', account.user.username)
    print('UID/sub:', account.uid)

    print(
        json.dumps(
            account.extra_data,
            indent=2,
            ensure_ascii=False
        )
    )

    print()
"

Esse comando é extremamente útil durante uma homologação. Antes de escrever qualquer lógica de grupos, vale confirmar:

  • sub
  • email
  • preferred_username
  • given_name
  • family_name
  • role / roles
  • groups

E principalmente os tipos desses atributos. Uma role pode chegar como: “role”: “gestores” ou “role”: [“gestores”]

Essas diferenças parecem pequenas, mas fazem bastante diferença no código.

8. GeoNode já possui mecanismo para sincronizar grupos

Aqui existe uma parte interessante do GeoNode que nem sempre é muito conhecida. O projeto possui um mecanismo de Profile Extractors. A própria classe base explica que extractors customizados podem ser registrados através de: SOCIALACCOUNT_PROFILE_EXTRACTORS

Existe também um OpenIDExtractor. Entre outras coisas, ele possui:

def extract_groups(self, data):
    return data.get("groups", "")

def extract_roles(self, data):
    return data.get("roles", "")

Perceba um detalhe. Ele procura roles no plural. Mas nosso Identity Provider fictício retorna role no singular. É exatamente aí que um pequeno extractor customizado pode ser útil.

9. Criando um extractor específico

Vamos criar esse extractor na pasta src/geonode_project/corporate_auth.

Primeiro o arquivo profileextractors.py:

import logging

from geonode.people.profileextractors import (
    OpenIDExtractor,
)


logger = logging.getLogger(__name__)


class CorporateOpenIDExtractor(OpenIDExtractor):

    ROLE_MAPPING = {
        "publico": "publico",
        "analistas": "analistas",
        "gestores": "gestores",

        # Compatibilidade com outra nomenclatura
        "role_publico": "publico",
        "role_analistas": "analistas",
        "role_gestores": "gestores",
    }

    def extract_roles(self, data):

        roles = data.get("role", [])

        if isinstance(roles, str):
            roles = [roles]

        mapped_roles = [
            self.ROLE_MAPPING[role]
            for role in roles
            if role in self.ROLE_MAPPING
        ]

        unknown_roles = [
            role
            for role in roles
            if role not in self.ROLE_MAPPING
        ]

        if unknown_roles:
            logger.warning(
                "OIDC retornou roles não mapeadas: %s",
                unknown_roles,
            )

        return mapped_roles

Perceba que esse código não implementa autenticação. Ele também não implementa a associação ao grupo. Sua única responsabilidade é:

claim do IdP
↓
normalização
↓
slug conhecido pelo GeoNode

10. Registrando o extractor

Vamos registrar o extractor no arquivo src/geonode_project/corporate_settings.py:

SOCIALACCOUNT_PROFILE_EXTRACTORS = dict(
    SOCIALACCOUNT_PROFILE_EXTRACTORS
)

SOCIALACCOUNT_PROFILE_EXTRACTORS[
    "corp-idp"
] = (
    "geonode_project."
    "geonode.corporate_auth."
    "profileextractors."
    "CorporateOpenIDExtractor"
)

Para testar se funcionou, você pode usar o comando abaixo:

docker compose exec django python manage.py shell -c "
from geonode.people.adapters import get_data_extractor

extractor = get_data_extractor('corp-idp')

print(
    extractor.__class__.__module__
    + '.'
    + extractor.__class__.__name__
)
"

O resultado esperado é: geonode_project.corporate_auth.profileextractors.CorporateOpenIDExtractor

Por último, vamos validar o adapter:

docker compose exec django python manage.py shell -c "
from django.conf import settings
from geonode.people.adapters import get_data_extractor

print(
    'SETTINGS:',
    settings.SETTINGS_MODULE
)

print(
    'ADAPTER:',
    settings.SOCIALACCOUNT_ADAPTER
)

print(
    'EXTRACTOR:',
    get_data_extractor('corp-idp')
)
"

O resultado esperado é:

SETTINGS:
geonode_project.corporate_settings

ADAPTER:
geonode.people.adapters.GenericOpenIDConnectAdapter

EXTRACTOR:
<...CorporateOpenIDExtractor object...>

11. Criando os grupos no GeoNode

No GeoNode, acesse Grupos → Criar um Novo Grupo e crie Público, Analistas e Gestores, garantindo que os slugs sejam respectivamente publico, analistas e gestores.

Agora criamos os GroupProfile: Público, Analistas e Gestores com os seguintes slugs: publico, analistas e gestores. Esse detalhe é importante. O mecanismo de associação procura o grupo pelo slug, não pelo título visual.

Então: role = gestores

Precisa resular em: slug = gestores

12. O que acontece no login?

O GeoNode possui uma rotina interna para sincronizar grupos provenientes da conta social. O código atual busca o extractor correspondente ao provider, extrai groups ou roles, localiza o GroupProfile pelo slug e executa join(user).

O GenericOpenIDConnectAdapter chama essa sincronização tanto na criação da conta quanto em logins posteriores. GitHub O fluxo fica:

É uma solução interessante porque aproveita a infraestrutura que já existe no GeoNode em vez de criar um segundo sistema paralelo de autorização.

13. Sincronização em logins seguintes

Essa parte é especialmente importante em ambientes corporativos. Imagine:

Na segunda-feira o usuário “fernando.quadro” foi colocado na role analistas. O usuário acessa o GeoNode e é inserido no grupo Analistas. Posterior a essa data o administrador alterar o perfil desse usuário para a role gestores.

No próximo login, a associação pode ser sincronizada novamente. Na versão atual do código do GeoNode, inclusive, existem estratégias explícitas para sincronização dos grupos sociais, e o adapter continua executando essa lógica no pre_social_login.

Isso permite tratar o provedor corporativo como fonte da verdade da associação de grupos.

⚠ Atenção

Na estratégia nativa apresentada, durante o login social o GeoNode pode remover os
GroupProfile atualmente associados ao usuário e recriar as associações com base nos grupos ou roles recebidos do Identity Provider.

Portanto, esta abordagem é apropriada quando o Identity Provider é realmente
a fonte de verdade dos grupos corporativos
. Se a mesma conta também precisar
pertencer a grupos do GeoNode administrados localmente e que não são enviados pelo IdP, a estratégia de sincronização deverá ser customizada.

14. E administradores?

Eu separaria duas coisas: grupo funcional e privilégio administrativo do GeoNode. Um grupo chamado Gestores não significa necessariamente que esse usuário tem privilégios de Staff e/ou Superusuário.

Você pode criar uma role especial chamada admin, com isso é possível implementar uma pequena lógica adicional, por exemplo usando um signal do django-allauth, para controlar explicitamente:

user.is_staff = True
user.is_superuser = True

Essa separação evita transformar um grupo corporativo em sinônimo de superusuário da aplicação.

O tratamento de administradores é uma política adicional e não faz parte da configuração mínima necessária para autenticação OIDC e sincronização de grupos deste artigo.

15. Verificando usuários e grupos

Depois da implementação acima, é interessante realizar alguns testes de auditoria rápida:

docker compose exec django python manage.py shell -c "
from allauth.socialaccount.models import (
    SocialAccount,
)

for account in (
    SocialAccount.objects
    .filter(provider='corp-idp')
    .select_related('user')
):
    user = account.user
    data = account.extra_data or {}

    print(
        user.username,
        '| role(s):', data.get('role') or data.get('roles'),
        '| grupos:',
        [
            (g.title, g.slug)
            for g in user.group_list_all()
        ],
        '| staff:', user.is_staff,
        '| superuser:', user.is_superuser,
    )
"

A saída será algo semelhante a:

fernando.quadro
| role(s): ['gestores']
| grupos: [('Gestores', 'gestores')]
| staff: False
| superuser: False

Isso permite verificar em uma única consulta claim recebida, role, grupo no GeoNode e status administrativo.

16. Conclusão

Integrar o GeoNode a um Identity Provider via OpenID Connect não precisa significar criar uma grande camada customizada de autenticação. Boa parte da infraestrutura já existe no próprio GeoNode e no django-allauth. O ponto mais importante é entender o contrato entre os dois sistemas:

  • Qual provider?
  • Qual callback?
  • Quais scopes?
  • Quais claims?
  • Qual identificador do usuário?
  • Como chegam grupos e roles?
  • Qual slug existe no GeoNode?

A partir daí, um pequeno extractor pode ser suficiente para adaptar as claims corporativas à estrutura de grupos utilizada pelo GeoNode.

O resultado é uma arquitetura em que a identidade fica centralizada no serviço corporativo e o GeoNode continua responsável pelo que faz melhor: controlar o acesso aos recursos geoespaciais.