Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Connexion à différents fournisseurs d'identités #3111

Closed
jacquesfize opened this issue Jul 3, 2024 · 9 comments · Fixed by #2976
Closed

Connexion à différents fournisseurs d'identités #3111

jacquesfize opened this issue Jul 3, 2024 · 9 comments · Fixed by #2976

Comments

@jacquesfize
Copy link
Contributor

jacquesfize commented Jul 3, 2024

Bonjour à tous,

Avec @TheoLechemia, nous travaillons sur l'ajout d'une fonctionnalité dans GeoNature permettant de se connecter à l'aide de différents fournisseurs d'identités. L'objectif est de permettre aux structures qui le souhaitent d'utiliser leurs propres fournisseurs d'identités pour se connecter à leur GeoNature. Par défaut, plusieurs protocoles de connexions sont intégrés dans le module UsersHub-authentification-module (v2.3):

  • OpenID et OpenIDConnect (compatible avec Google)
  • Le CAS de l'INPN (déjà présent dans les précédentes versions)
  • GeoNature

Fonctionnement

Avant/Après cette mise à jour

Avant.

Dans la version actuelle de GeoNature, il est possible de se connecter de deux manières :

  • via le système d'authentification propre à GeoNature
  • via le CAS de l'INPN.

Dans le cas par défaut, lors de la connexion, le frontend effectue une requête asynchrone vers la route /auth/login de l'API. L'API retourne les informations de l'utilisateur qui seront stockées dans le localStorage (gn_token, gn_current_user et gn_expires_at). Le token permet à l'API d'identifier l'utilisateur lors des ces requêtes sur cette dernière.

Dans le cas de l'INPN, l'utilisateur est automatiquement redirigé vers le portail de l'INPN lorsqu'il tente d'accéder à GeoNature. Une fois sur le portail, il doit saisir ses informations de connexion. Après une connexion réussie, une redirection est effectuée vers des routes spécifiques au CAS qui connecte l'utilisateur et renvoie son token d'identification au frontend.

Après.

Le fonctionnement initial du login est maintenu. La nouveauté réside dans la possibilité de se connecter à d'autres fournisseurs d'identités (FI) en s'appuyant sur des protocoles de connexions différents de GeoNature (OAuth, OAuth2, CAS, etc...). Côté Frontend, en cliquant pour se connecter à un FI, l'utilisateur sera rediriger vers l'API /auth/login/<id_provider> (id_provider correspond à l'idenfiant unique du FI). Cette API redirigera ensuite vers le portail de connexion de l'instance du fournisseur d'identités (Voir Figure ci-dessus).

google login page

Une fois la connexion réussie, le portail redirige vers la route de l'API auth/authorize/<id_provider> qui se charge de réconcilier les informations utilisateur fournies par le FI avec le schéma de la base de données de GeoNature.

Lors de la déconnexion, il est possible de se déconnecter du fournisseur d'identité ainsi que de GeoNature en utilisant la méthode revoke() définie par le provider.

N.B. Il est possible de se connecter à plusieurs fournisseurs d'identités autres que celui de base !

Schéma.

workflow

Comment utiliser un autre fournisseur d'identités (FI) ?

Comme expliqué précédemment, UsersHub-authenfication-module vient avec un ensemble de protocoles de connexion prédéfini.

Si le fournisseur d'identité utilise un des protocoles de connexions existant, il suffit de remplir la configuration comme dans l'exemple suivant :

[AUTHENTICATION]
    DEFAULT_RECONCILIATION_GROUP_ID = 2
[[AUTHENTICATION.PROVIDERS]]
    module="pypnusershub.auth.providers.default.DefaultConfiguration"
    id_provider="local_provider"
[[AUTHENTICATION.PROVIDERS]]
    module="pypnusershub.auth.providers.openid_provider.OpenIDProvider"
    id_provider = "keycloak"
    label = "KeyCloak"
    ISSUER = "http://<realmKeycloak>"
    CLIENT_ID = "secret"
    CLIENT_SECRET = "secret"
    group_mapping = {"/user"=1,"/admin"=2}
[[AUTHENTICATION.PROVIDERS]]
    module="pypnusershub.auth.providers.openid_provider.OpenIDConnectProvider"
    id_provider = "google"
    logo = "<i class='fa fa-google' aria-hidden='true'></i>"
    label = "Google"
    ISSUER = "https://accounts.google.com/"
    CLIENT_ID = "secret"
    CLIENT_SECRET = "secret"
[[AUTHENTICATION.PROVIDERS]]
    module="pypnusershub.auth.providers.usershub_provider.ExternalUsersHubAuthProvider"
    id_provider = "usershub"
    label ="Geonature Ecrins"
    login_url = "https://geonature.ecrins-parcnational.fr/api/auth/login"
    logout_url = "https://geonature.ecrins-parcnational.fr/api/auth/logout"

[... other providers instances definitions]

Cette configuration permet de se connecter à :

  • Google (OpenID)
  • Un KeyCloak (protocole OpenIDConnect)
  • Un autre GeoNature, ici l'instance de GeoNature du Parc National des Ecrins

Comment ça marche ?

Pour ajouter un fournisseur d'identité utilisant un protocole, if faut ajouter une section AUTHENTICATION.PROVIDERS dans la configuration.

[[AUTHENTICATION.PROVIDERS]]
    param1=value1
    param2=value2

Dans chaque section décrivant un fournisseur d'identité, il faut déclaré :

  • le chemin vers la classe Python module déclarant le protocole de connexion
  • son identifiant unique id_provider (dans cette instance de GeoNature)
  • (optionel) le logo et le label qui seront affichés sur la page de login.
  • login_url, logout_url si le provider en a besoin
  • (optionel)groupe_mapping pour la réconciliation entre les groupes du fournisseurs d'identité et celui dans GeoNature.
  • Autre variables de configuration propre au protocole de connexion (clé d'API, etc..)

Une fois la configuration mise à jour, vous devriez voir l'interface suivante.

image

Déclaration d'un protocole de connexion

La "brique" permettant de faire la connexion et la réconciliation (i.e synchronisation des données du fournisseurs et celle présente en local) sur différents fournisseurs d'identités.

Chaque protocole de connexion ou provider est défini par une classe comme celle-ci :

from typing import Any, Optional, Tuple, Union

from authlib.integrations.flask_client import OAuth
from flask import (
    Response,
    current_app,
    url_for,
)
from marshmallow import Schema, fields

from pypnusershub.auth import Authentication, ProviderConfigurationSchema, oauth
from pypnusershub.db import models, db
from pypnusershub.db.models import User
from pypnusershub.routes import insert_or_update_role
import sqlalchemy as sa

CONF_URL = "https://accounts.google.com/.well-known/openid-configuration"
oauth.register(
    name="google",
    server_metadata_url=CONF_URL,
    client_kwargs={"scope": "openid email profile"},
)

class GoogleAuthProvider(Authentication):
    name = "GOOGLE_PROVIDER_CONFIG"
    id_provider = "google"
    label = "Google"
    is_external = False
    group_claim_name = "groups"
    logo = '<i class="fa fa-google"></i>'

    def authenticate(self, *args, **kwargs) -> Union[Response, models.User]:
        redirect_uri = url_for(
            "auth.authorize", provider=self.id_provider, _external=True
        )
        return oauth.google.authorize_redirect(redirect_uri)

    def authorize(self):
        token = oauth.google.authorize_access_token()
        user_info = token["userinfo"]
        new_user = {
            "identifiant": f"{user_info['given_name'].lower()}{user_info['family_name'].lower()}",
            "email": user_info["email"],
            "prenom_role": user_info["given_name"],
            "nom_role": user_info["family_name"],
            "active": True,
        }
        return insert_or_update_role(new_user, provider_instance=self)

    def configure(self, configuration: Union[dict, Any]):
        super().configure(configuration)
        class GoogleProviderConfiguration(ProviderConfigurationSchema):
            GOOGLE_CLIENT_ID = fields.String(load_default="")
            GOOGLE_CLIENT_SECRET = fields.String(load_default="")
        configuration = GoogleProviderConfiguration().load(configuration)

        current_app.config["GOOGLE_CLIENT_ID"] = configuration["GOOGLE_CLIENT_ID"]
        current_app.config["GOOGLE_CLIENT_SECRET"] = configuration[
            "GOOGLE_CLIENT_SECRET"
        ]

Un protocole de connexion est défini par 4 méthodes et plusieurs attributs.

Les attributs sont les suivants

  • L'attribut id_provider indique l'identifiant de l'instance du provider.
  • Les attributs logo et label sont destinés à l'interface utilisateur.
  • L'attribut is_external spécifie si le provider permet de se connecter à une autre application Flask utilisant UsersHub-authentification-module ou à un fournisseur d'identité qui requiert une redirection vers une page de login.
  • L'attribut login_url et logout_url, si le protocole de connexion nécessite une redirection
  • L'attribut group_mapping contient le mapping entre les groupes du fournisseurs d'identités et celui de votre instance de GeoNature.

Les méthodes sont les suivantes :

  • authenticate: Lancée sur la route /auth/login, elle récupère les informations du formulaire de login et retourne un objet User. Si le protocole de connexion doit rediriger l'utilisateur vers un portail, alors authenticate retourne une flask.Response qui redirige vers ce dernier.
  • authorize: Cette méthode est lancée par la route /auth/authorize qui récupère les informations renvoyés par le fournisseur d'identités après la connexions sur le portail.
  • configure(self, configuration: Union[dict, Any]): Permet de récupérer et d'utiliser les variables présentes dans le fichier de configuration. Il est possible aussi de valider les résultats à l'aide d'un schéma marshmallow
  • revoke(): Permet de spécifier un fonctionnement spécifique lors de la déconnexion d'un utilisateur.

Ajouter son propre provider

Si les protocoles de connexions fournis dans le module d'authentification ne répondent pas à vos besoins, vous pouvez créer les vôtres !

Pour ce faire, il suffit de créer une classe qui hérite de Authentication et qui implémente les méthodes suivantes :

  • authenticate()
  • configure() (pour indiquer comment les variables de configuration sont utilisées pour configurer le provider)

et les attributs suivants :

  • is_external

D'autres méthodes et attributs sont disponibles, voir la classe Authentication.

from marshmallow import Schema, fields
from typing import Any, Optional, Tuple, Union

from pypnusershub.auth import Authentication, ProviderConfigurationSchema
from pypnusershub.db import models, db
from flask import Response

class NEW_PROVIDER(Authentication):
    is_external = True # si redirection vers un portail de connexion externe

    def authenticate(self, *args, **kwargs) -> Union[Response, models.User]:
        pass # doit retourner un utilisateur (User) ou rediriger (flask.Redirect) vers le portail de connexion du fournisseur d'identités

    def authorize(self):
        # appeler par /auth/authorize si redirection d'un portail de connexion externe
        pass # doit retourner un utilisateur

    def revoke(self):
        pass # si une action spécifique doit être faite lors de la déconnexion

    def configure(self, configuration: Union[dict, Any]):
        class SchemaConf(ProviderConfigurationSchema):
            VAR = fields.String(required=True)
        configuration = SchemaConf().load(configuration) # Si besoin d'un processus de validation
        ...# Configuration du fournisseur d'identités

Comme les autres protocoles de connexions, il suffit d'indiquer le chemin vers votre classe Python et sa configuration pour le fournisseur d'identité utilisant ce dernier !

@maximetoma
Copy link
Contributor

Peut-on envisager l'authentification avec Microsoft 365 ? Je ne sais pas du tout si c'est possible en revanche....

@jacquesfize
Copy link
Contributor Author

Ça semble possible: https://learn.microsoft.com/fr-fr/entra/identity-platform/v2-app-types#web-apps

Leur plateforme utilise le protocole OAuth2. Avec @TheoLechemia, on a implémenté un provider pour ce type de protocole.

@camillemonchicourt
Copy link
Member

Présentation du sujet lors du Cotech du 9 juillet 2024 : https://geonature.fr/documents/comite-technique/2024-07-09-COTECH-AUTHENTIFICATION.pdf

@camillemonchicourt
Copy link
Member

Voir les évolutions liées dans le sous-module d'authentification : PnX-SI/UsersHub-authentification-module#93

@camillemonchicourt
Copy link
Member

Intégré dans la 2.15

@LaurentPOUL1
Copy link

Geonature 2.15.2

J'ai mis ne place l'authentification avec un compte google, ça fonctionne bien mais j'avais un problème si je voulais conserver l'authentification locale, il ne trouvait pas la classe ci-dessous

AUTHENTICATION:
               - {'_schema': ['Class DefaultConfiguration not found in module pypnusershub.auth.providers.default']}
[2025-01-18 11:27:00 +0000] [842902] [INFO] Worker exiting (pid: 842902)

Dans geonature.config.toml j'ai remplacé DefaultConfiguration par LocalProvider et ça fonctionne.

[[AUTHENTICATION.PROVIDERS]]
        #module="pypnusershub.auth.providers.default.DefaultConfiguration"
        module="pypnusershub.auth.providers.default.LocalProvider"
		id_provider="local_provider"

Par contre la déconnexion depuis un compte google ne fonctionne pas : effet de bord?

[33ea1dd1-e06f-4d91-8e6b-05c5c52564fa] Exception on /auth/logout [GET]
Traceback (most recent call last):
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask/app.py", line 1511, in wsgi_app
    response = self.full_dispatch_request()
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask/app.py", line 919, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask_cors/extension.py", line 194, in wrapped_function
    return cors_after_request(app.make_response(f(*args, **kwargs)))
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask/app.py", line 809, in handle_user_exception
    return self.ensure_sync(handler)(e)  # type: ignore[no-any-return]
  File "/home/geonatureadmin/geonature/backend/geonature/core/errors.py", line 93, in handle_exception
    raise e
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask/app.py", line 917, in full_dispatch_request
    rv = self.dispatch_request()
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask/app.py", line 902, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)  # type: ignore[no-any-return]
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/pypnusershub/routes.py", line 199, in logout
    resp = auth_provider.revoke()
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/pypnusershub/auth/providers/openid_provider.py", line 116, in revoke
    metadata["end_session_endpoint"],
KeyError: 'end_session_endpoint'
[2025-01-18 15:30:31 +0000] [980472] [ERROR] Exception on /auth/logout [GET]
Traceback (most recent call last):
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask/app.py", line 1511, in wsgi_app
    response = self.full_dispatch_request()
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask/app.py", line 919, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask_cors/extension.py", line 194, in wrapped_function
    return cors_after_request(app.make_response(f(*args, **kwargs)))
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask/app.py", line 809, in handle_user_exception
    return self.ensure_sync(handler)(e)  # type: ignore[no-any-return]
  File "/home/geonatureadmin/geonature/backend/geonature/core/errors.py", line 93, in handle_exception
    raise e
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask/app.py", line 917, in full_dispatch_request
    rv = self.dispatch_request()
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/flask/app.py", line 902, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)  # type: ignore[no-any-return]
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/pypnusershub/routes.py", line 199, in logout
    resp = auth_provider.revoke()
  File "/home/geonatureadmin/geonature/backend/venv/lib/python3.10/site-packages/pypnusershub/auth/providers/openid_provider.py", line 116, in revoke
    metadata["end_session_endpoint"],
KeyError: 'end_session_endpoint'

Merci pour votre aide

@jacquesfize
Copy link
Contributor Author

Bonjour @LaurentPOUL1,

En effet, un oubli de modification dans le fichier default_config.toml.example de notre part 🙏

Pour la connexion à Google, il faut utiliser le pypnusershub.auth.providers.openid_provider.OpenIDProvider. Le lien pour déconnecter l'application de Google est accessible avec la variable nommée revocation_endpoint (voir https://accounts.google.com/.well-known/openid-configuration). Contrairement au provider OpenIDConnectProvider qui récupère cette url avec la variable end_session_endpoint.

@LaurentPOUL1
Copy link

Merci, effectivement Google a sa propre adresse de déconnexion, cependant je ne sais pas comment l'indiquer à geonature : logout_url ne semble pas disponible pour OpenIDConnectProvider. Bon j'avoue que je ne suis pas certain de bien tout comprendre :-)

@jacquesfize
Copy link
Contributor Author

jacquesfize commented Jan 22, 2025

Dans le cas de l'OpenIDConnectProvider et OpenIDConnect pas besoin d'indiquer le logout_url et login_url :)

60d0403

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants