From d0388a018f1e0bc2e9b6266def9dd25c94618f84 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 29 Mar 2024 11:40:19 +0100 Subject: [PATCH 01/28] feat (login, custom provider) : add authentification class for custom login --- src/pypnusershub/authentification.py | 122 +++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/pypnusershub/authentification.py diff --git a/src/pypnusershub/authentification.py b/src/pypnusershub/authentification.py new file mode 100644 index 0000000..642c1f5 --- /dev/null +++ b/src/pypnusershub/authentification.py @@ -0,0 +1,122 @@ +from typing import Any, Union +import json +import logging + +import datetime +from flask import ( + request, + Response, + current_app, +) +from markupsafe import escape + +from sqlalchemy.orm import exc +import sqlalchemy as sa +from werkzeug.exceptions import BadRequest + +from pypnusershub.utils import get_current_app_id +from pypnusershub.db import models, db +from pypnusershub.db.tools import ( + encode_token, +) +from pypnusershub.schemas import OrganismeSchema, UserSchema + +log = logging.getLogger(__name__) + + +class Authentification: + """ + Abstract class for authentication implementations. + """ + + def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: + """ + Authenticate a user with the provided parameters. + + Parameters + ---------- + *args : Any + Positional arguments to be passed to the implementation. + **kwargs : Any + Keyword arguments to be passed to the implementation. + + Raises + ------ + NotImplementedError + This method must be implemented by subclasses. + + Returns + ------- + Union[Response, models.User] + The result of the authentication process, which can be either a Response object or a User object. + """ + raise NotImplementedError() + + def revoke(self) -> Any: + """ + Revoke current authentication. + + Raises + ------ + NotImplementedError + This method must be implemented by subclasses. + + Returns + ------- + Any + Revocation result depending on the implementation. + """ + raise NotImplementedError() + + +class DefaultConfiguration(Authentification): + + def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: + user_data = request.json + try: + username, password = user_data.get("login"), user_data.get("password") + id_app = user_data.get("id_application", get_current_app_id()) + + if id_app is None or username is None or password is None: + msg = json.dumps( + "One of the following parameter is required ['id_application', 'login', 'password']" + ) + return Response(msg, status=400) + app = db.session.get(models.Application, id_app) + if not app: + raise BadRequest(f"No app for id {id_app}") + user = db.session.execute( + sa.select(models.User) + .where(models.User.identifiant == username) + .where(models.User.filter_by_app()) + ).scalar_one() + user_dict = UserSchema( + exclude=["remarques"], only=["+max_level_profil"] + ).dump(user) + except exc.NoResultFound as e: + msg = json.dumps( + { + "type": "login", + "msg": ( + 'No user found with the username "{login}" for ' + 'the application with id "{id_app}"' + ).format(login=escape(username), id_app=id_app), + } + ) + log.info(msg) + status_code = current_app.config.get("BAD_LOGIN_STATUS_CODE", 490) + return Response(msg, status=status_code) + + if not user.check_password(user_data["password"]): + msg = json.dumps({"type": "password", "msg": "Mot de passe invalide"}) + log.info(msg) + status_code = current_app.config.get("BAD_LOGIN_STATUS_CODE", 490) + return Response(msg, status=status_code) + # Génération d'un token + token = encode_token(user_dict) + token_exp = datetime.datetime.now(datetime.timezone.utc) + token_exp += datetime.timedelta(seconds=current_app.config["COOKIE_EXPIRATION"]) + return user + + def revoke(self) -> Any: + pass From 1b9c52852c87e103dd91905141825f08ac9bffd2 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 29 Mar 2024 11:41:31 +0100 Subject: [PATCH 02/28] feat (login, custom provider) : include authentification class in the main login process Co-authored-by: TheoLechemia --- src/pypnusershub/auth/__init__.py | 7 + src/pypnusershub/auth/auth_manager.py | 111 +++++++ src/pypnusershub/auth/authentication.py | 154 ++++++++++ src/pypnusershub/auth/providers/__init__.py | 1 + .../auth/providers/cas_inpn_provider.py | 197 ++++++++++++ src/pypnusershub/auth/providers/default.py | 48 +++ .../auth/providers/github_provider.py | 66 ++++ .../auth/providers/google_provider.py | 73 +++++ .../auth/providers/openid_provider.py | 113 +++++++ .../auth/providers/usershub_provider.py | 44 +++ src/pypnusershub/authentification.py | 122 -------- src/pypnusershub/db/models.py | 37 ++- ...viders_table_and_cor_tables_with_troles.py | 62 ++++ src/pypnusershub/routes.py | 281 +++++++++++++----- src/pypnusershub/schemas.py | 10 +- 15 files changed, 1123 insertions(+), 203 deletions(-) create mode 100644 src/pypnusershub/auth/__init__.py create mode 100644 src/pypnusershub/auth/auth_manager.py create mode 100644 src/pypnusershub/auth/authentication.py create mode 100644 src/pypnusershub/auth/providers/__init__.py create mode 100644 src/pypnusershub/auth/providers/cas_inpn_provider.py create mode 100644 src/pypnusershub/auth/providers/default.py create mode 100644 src/pypnusershub/auth/providers/github_provider.py create mode 100644 src/pypnusershub/auth/providers/google_provider.py create mode 100644 src/pypnusershub/auth/providers/openid_provider.py create mode 100644 src/pypnusershub/auth/providers/usershub_provider.py delete mode 100644 src/pypnusershub/authentification.py create mode 100644 src/pypnusershub/migrations/versions/b7c98935d9e8_add_providers_table_and_cor_tables_with_troles.py diff --git a/src/pypnusershub/auth/__init__.py b/src/pypnusershub/auth/__init__.py new file mode 100644 index 0000000..ed670e6 --- /dev/null +++ b/src/pypnusershub/auth/__init__.py @@ -0,0 +1,7 @@ +from authlib.integrations.flask_client import OAuth + +from .auth_manager import * +from .authentication import * + + +oauth = OAuth() diff --git a/src/pypnusershub/auth/auth_manager.py b/src/pypnusershub/auth/auth_manager.py new file mode 100644 index 0000000..19a47a9 --- /dev/null +++ b/src/pypnusershub/auth/auth_manager.py @@ -0,0 +1,111 @@ +from .authentication import Authentication +from .providers import DefaultConfiguration +from pypnusershub.db.models import Provider +import importlib +import sqlalchemy as sa +from pypnusershub.env import db + + +class AuthManager: + """ + Manages authentication providers. + """ + + def __init__(self) -> None: + """ + Initializes the AuthManager instance. + """ + self.provider_authentication_cls = {"local_provider": DefaultConfiguration()} + + def __contains__(self, item) -> bool: + """ + Checks if a provider is registered. + + Parameters + ---------- + item : str + The provider name. + + Returns + ------- + bool + True if the provider is registered, False otherwise. + """ + return item in self.provider_authentication_cls + + def add_provider( + self, id_provider: str, provider_authentification: Authentication + ) -> None: + """ + Registers a new authentication provider instance. + + Parameters + ---------- + id_instance : str + identifier of the new provider instance + provider : Authentification + The authentication provider class. + + + Raises + ------ + AssertionError + If the provider is not an instance of Authentification. + """ + + assert id_provider not in self.provider_authentication_cls + query = sa.exists(Provider).where(Provider.name == id_provider).select() + if not db.session.scalar(query): + db.session.add( + Provider(name=id_provider, url=provider_authentification.login_url) + ) + db.session.commit() + if not isinstance(provider_authentification, Authentication): + raise AssertionError("Provider must be an instance of Authentication") + self.provider_authentication_cls[id_provider] = provider_authentification + + def init_app(self, app, prefix: str = "/auth") -> None: + """ + Initializes the Flask application with the AuthManager. + + Parameters + ---------- + app : Flask + The Flask application instance. + + Returns + ------- + None + """ + from pypnusershub.routes import routes + + app.auth_manager = self + + app.register_blueprint(routes, url_prefix=prefix) + + for path_provider in app.config["AUTHENTICATION"].get("PROVIDERS", []): + import_path, class_name = ( + ".".join(path_provider.split(".")[:-1]), + path_provider.split(".")[-1], + ) + module = importlib.import_module(import_path) + class_ = getattr(module, class_name) + for config in app.config["AUTHENTICATION"][class_.name]: + with app.app_context(): + instance_provider: Authentication = class_() + instance_provider.configure(configuration=config) + self.add_provider(instance_provider.id_provider, instance_provider) + + def get_provider(self, instance_name: str) -> Authentication: + """ + Returns the current authentication provider. + + Returns + ------- + Authentification + The current authentication provider. + """ + return self.provider_authentication_cls[instance_name] + + +auth_manager = AuthManager() diff --git a/src/pypnusershub/auth/authentication.py b/src/pypnusershub/auth/authentication.py new file mode 100644 index 0000000..cc9e81b --- /dev/null +++ b/src/pypnusershub/auth/authentication.py @@ -0,0 +1,154 @@ +from typing import Any, Union +import logging + +from pypnusershub.db import models + +from marshmallow import Schema, fields + +log = logging.getLogger(__name__) + + +class ProviderConfigurationSchema(Schema): + id_provider = fields.Str(required=True) + group_mapping = fields.Dict(keys=fields.Str(), values=fields.Integer()) + logo = fields.String() + label = fields.String() + + +class Authentication: + """ + Abstract class for authentication implementations. + """ + + @property + def name(self) -> str: + """ + Name of the authentication provider. + Use for config key + + Returns + ------- + str + The name of the authentication provider. + """ + raise NotImplementedError() + + """ + Identifier of the instance of the authentication provider (str). + Is override by provider config if provided + """ + id_provider = None + + """ + Label of the authentication provider. + Use in frontend + """ + label = "" + + """ + Group mapping between source_group and destination_group. Must be in the following format: + [{"grp_src":"admin","grp_dst":"Grp_admin"},...] + """ + group_mapping = [] + + """ + External login URL. + Must be define if the authentication provider is external + Not mandatory for OpenID Providers + """ + login_url = "" + + """ + External logout URL. + Must be define if the authentication provider is external + Not mandatory for OpenID Providers + """ + logout_url = "" + + """ + Logo of the authentication provider (str) + URL or html of the logo image + """ + logo = "" + + @property + def is_uh(self) -> bool: + """ + Return whether the authentication is an 'usershub-auth-module' authentication. + + Returns + ------- + bool + """ + return True + + def authenticate(self, *args, **kwargs) -> models.User: + """ + Authenticate a user with the provided parameters. + + Parameters + ---------- + *args : Any + Positional arguments to be passed to the implementation. + **kwargs : Any + Keyword arguments to be passed to the implementation. + + Raises + ------ + NotImplementedError + This method must be implemented by subclasses. + + Returns + ------- + Union[Response, models.User] + The result of the authentication process, which can be either a Response object or a User object. + """ + raise NotImplementedError() + + def authorize(self) -> Any: + """ + Authorize the current user. + + This function is meant to be called after a successful authentication (`/login`) + in order to complete the authorization process. It will reconcile the data recovered + from the login provider and the database. It will return a User object + or raise an exception if the authorization process fails. + + Returns + ------- + Any + A redirect response or an exception. + + Raises + ------ + NotImplementedError + This method must be implemented by subclasses. + """ + raise NotImplementedError() + + def revoke(self) -> Any: + """ + Revoke current authentication. + + Raises + ------ + NotImplementedError + This method must be implemented by subclasses. + + Returns + ------- + Any + Revocation result depending on the implementation. + """ + log.warn("Revoke is not implemented.") + pass + + def configure(self, configuration: Union[dict, Any] = {}): + self.id_provider = configuration["id_provider"] + for field in ["label", "logo", "login_url", "logout_url", "group_mapping"]: + if field in configuration: + setattr(self, field, configuration[field]) + + @staticmethod + def configuration_schema() -> ProviderConfigurationSchema: + return ProviderConfigurationSchema diff --git a/src/pypnusershub/auth/providers/__init__.py b/src/pypnusershub/auth/providers/__init__.py new file mode 100644 index 0000000..84cc9da --- /dev/null +++ b/src/pypnusershub/auth/providers/__init__.py @@ -0,0 +1 @@ +from .default import DefaultConfiguration diff --git a/src/pypnusershub/auth/providers/cas_inpn_provider.py b/src/pypnusershub/auth/providers/cas_inpn_provider.py new file mode 100644 index 0000000..fa218e4 --- /dev/null +++ b/src/pypnusershub/auth/providers/cas_inpn_provider.py @@ -0,0 +1,197 @@ +import logging +from typing import Any, Optional, Tuple, Union + +import xmltodict +from flask import ( + Response, + current_app, + make_response, + redirect, + render_template, + request, +) +from marshmallow import fields +from geonature.utils import utilsrequests +from geonature.utils.errors import GeonatureApiError +from pypnusershub.auth import Authentication, ProviderConfigurationSchema +from pypnusershub.db import db, models +from pypnusershub.routes import insert_or_update_organism, insert_or_update_role +from sqlalchemy import select + +log = logging.getLogger() + + +class CasAuthentificationError(GeonatureApiError): + pass + + +AUTHENTIFICATION_CONFIG = { + "PROVIDER_NAME": "inpn", + "EXTERNAL_PROVIDER": True, +} + +CAS_AUTHENTIFICATION = True +CAS_PUBLIC = dict( + URL_LOGIN="https://inpn.mnhn.fr/auth/login", + URL_LOGOUT="https://inpn.mnhn.fr/auth/logout", + URL_VALIDATION="https://inpn.mnhn.fr/auth/serviceValidate", +) + +CAS_USER_WS = dict( + URL="https://inpn.mnhn.fr/authentication/information", + BASE_URL="https://inpn.mnhn.fr/authentication/", + ID="change_value", + PASSWORD="change_value", +) +USERS_CAN_SEE_ORGANISM_DATA = False + +ID_USER_SOCLE_1 = 1 +ID_USER_SOCLE_2 = 2 + + +class AuthenficationCASINPN(Authentication): + name = "CAS_INPN_PROVIDER" + label = "INPN" + is_uh = False + logo = "" + + @property + def login_url(self): + gn_api = f"{current_app.config['API_ENDPOINT']}/auth/authorize/cas_inpn" + return f"{self.URL_LOGIN}?service={gn_api}" + + @property + def logout_url(self): + return f"{self.URL_LOGOUT}?service={current_app.config['URL_APPLICATION']}" + + def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: + return redirect(self.login_url) + + def authorize(self): + user = None + + if not "ticket" in request.args: + return redirect(self.login_url) + + ticket = request.args["ticket"] + base_url = ( + f"{current_app.config['API_ENDPOINT']}/auth/authorize/{self.id_provider}" + ) + url_validate = f"{self.URL_VALIDATION}?ticket={ticket}&service={base_url}" + + response = utilsrequests.get(url_validate) + xml_dict = xmltodict.parse(response.content) + + if "cas:authenticationSuccess" in xml_dict["cas:serviceResponse"]: + user = xml_dict["cas:serviceResponse"]["cas:authenticationSuccess"][ + "cas:user" + ] + + if not user: + log.info("Erreur d'authentification lié au CAS, voir log du CAS") + log.error("Erreur d'authentification lié au CAS, voir log du CAS") + return render_template( + "cas_login_error.html", + cas_logout=self.URL_LOGOUT, + url_geonature=current_app.config["URL_APPLICATION"], + ) + + ws_user_url = f"{self.URL_INFO}/{user}/?verify=false" + response = utilsrequests.get( + ws_user_url, + ( + self.WS_ID, + self.WS_PASSWORD, + ), + ) + + if response.status_code != 200: + raise CasAuthentificationError( + "Error with the inpn authentification service", status_code=500 + ) + + info_user = response.json() + user = self.insert_user_and_org(info_user, self.id_provider) + db.session.commit() + organism_id = info_user["codeOrganisme"] + if not organism_id: + organism_id = ( + db.session.execute( + select(models.Organisme).filter_by(nom_organisme="Autre"), + ) + .scalar_one() + .id_organisme, + ) + # user.id_organisme = organism_id + return user + + def revoke(self) -> Any: + return redirect(self.logout_url) + + def insert_user_and_org(self, info_user, id_provider): + organism_id = info_user["codeOrganisme"] + if info_user["libelleLongOrganisme"] is not None: + organism_name = info_user["libelleLongOrganisme"] + else: + organism_name = "Autre" + + user_login = info_user["login"] + user_id = info_user["id"] + try: + assert user_id is not None and user_login is not None + except AssertionError: + log.error("'CAS ERROR: no ID or LOGIN provided'") + raise CasAuthentificationError( + "CAS ERROR: no ID or LOGIN provided", status_code=500 + ) + # Reconciliation avec base GeoNature + if organism_id: + organism = {"id_organisme": organism_id, "nom_organisme": organism_name} + insert_or_update_organism(organism) + user_info = { + "id_role": user_id, + "identifiant": user_login, + "nom_role": info_user["nom"], + "prenom_role": info_user["prenom"], + "id_organisme": organism_id, + "email": info_user["email"], + "active": True, + } + user = insert_or_update_role( + models.User(**user_info), provider_name=self.id_provider + ) + if not user.groups: + if not USERS_CAN_SEE_ORGANISM_DATA or organism_id is None: + # group socle 1 + group_id = ID_USER_SOCLE_1 + else: + # group socle 2 + group_id = ID_USER_SOCLE_2 + group = db.session.get(models.User, group_id) + user.groups.append(group) + return user + + @staticmethod + def configuration_schema() -> Optional[Tuple[str, ProviderConfigurationSchema]]: + class CASINPNConfiguration(ProviderConfigurationSchema): + URL_LOGIN = fields.String(load_default="https://inpn.mnhn.fr/auth/login") + URL_LOGOUT = fields.String(load_default="https://inpn.mnhn.fr/auth/logout") + URL_VALIDATION = fields.String( + load_default="https://inpn.mnhn.fr/auth/serviceValidate" + ) + URL_AUTHORIZE = fields.String( + load_default="https://inpn.mnhn.fr/authentication/" + ) + URL_INFO = fields.String( + load_default="https://inpn.mnhn.fr/authentication/information", + ) + WS_ID = fields.String(required=True) + WS_PASSWORD = fields.String(required=True) + + return CASINPNConfiguration + + def configure(self, configuration: Union[dict, Any]): + super().configure(configuration) + print(configuration) + for key in configuration: + setattr(self, key, configuration[key]) diff --git a/src/pypnusershub/auth/providers/default.py b/src/pypnusershub/auth/providers/default.py new file mode 100644 index 0000000..9d2a2f7 --- /dev/null +++ b/src/pypnusershub/auth/providers/default.py @@ -0,0 +1,48 @@ +import json +from typing import Any, Union + +import sqlalchemy as sa +from flask import Response, request +from pypnusershub.db import db, models +from pypnusershub.utils import get_current_app_id +from sqlalchemy.orm import exc +from werkzeug.exceptions import BadRequest, Unauthorized + +from ..authentication import Authentication + + +class DefaultConfiguration(Authentication): + login_url = "" + logout_url = "" + id_provider = "local_provider" + + def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: + user_data = request.json + try: + username, password = user_data.get("login"), user_data.get("password") + id_app = user_data.get("id_application", get_current_app_id()) + + if id_app is None or username is None or password is None: + msg = json.dumps( + "One of the following parameter is required ['id_application', 'login', 'password']" + ) + return Response(msg, status=400) + app = db.session.get(models.Application, id_app) + if not app: + raise BadRequest(f"No app for id {id_app}") + user = db.session.execute( + sa.select(models.User) + .where(models.User.identifiant == username) + .where(models.User.filter_by_app()) + ).scalar_one() + except exc.NoResultFound as e: + raise Unauthorized( + 'No user found with the username "{login}" for the application with id "{id_app}"' + ) + + if not user.check_password(user_data["password"]): + raise Unauthorized("Invalid password") + return user + + def revoke(self) -> Any: + pass diff --git a/src/pypnusershub/auth/providers/github_provider.py b/src/pypnusershub/auth/providers/github_provider.py new file mode 100644 index 0000000..fd0aa58 --- /dev/null +++ b/src/pypnusershub/auth/providers/github_provider.py @@ -0,0 +1,66 @@ +from typing import Union + +from authlib.integrations.flask_client import OAuth +from flask import ( + Response, + current_app, + url_for, +) +from pypnusershub.auth import Authentication +from pypnusershub.db import models, db +from pypnusershub.routes import insert_or_update_role + + +oauth = OAuth(current_app) +oauth.register( + name="github", + client_id="", + client_secret="", + access_token_url="https://github.com/login/oauth/access_token", + access_token_params=None, + authorize_url="https://github.com/login/oauth/authorize", + authorize_params=None, + api_base_url="https://api.github.com/", + client_kwargs={"scope": "user:email"}, +) + + +class GitHubAuthProvider(Authentication): + id_provider = "github" + label = "GitHub" + is_uh = False + login_url = "http://127.0.0.1:8000/auth/login/github" + logout_url = "" + logo = '' + + def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: + redirect_uri = url_for( + "auth.authorize", provider=self.id_provider, _external=True + ) + return oauth.github.authorize_redirect(redirect_uri) + + def authorize(self): + token = oauth.github.authorize_access_token() + resp = oauth.github.get("user", token=token) + resp.raise_for_status() + user_info = resp.json() + prenom = user_info["name"].split(" ")[0] + nom = " ".join(user_info["name"].split(" ")[1:]) + new_user = { + "identifiant": f"{user_info['login'].lower()}", + "email": user_info["email"], + "prenom_role": prenom, + "nom_role": nom, + "active": True, + "provider": "github", + } + user_info = insert_or_update_role(new_user) + user = db.session.get(models.User, user_info["id_role"]) + if not user.groups: + group = db.session.get(models.User, 2) # ADMIN for test + user.groups.append(group) + db.session.commit() + return user + + +# Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privée) diff --git a/src/pypnusershub/auth/providers/google_provider.py b/src/pypnusershub/auth/providers/google_provider.py new file mode 100644 index 0000000..be356c3 --- /dev/null +++ b/src/pypnusershub/auth/providers/google_provider.py @@ -0,0 +1,73 @@ +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 + + +# TODO : à enlever : fonctionne avec OPENID_PROVIDER + +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_uh = False + login_url = "" + logout_url = "" + logo = '' + + 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(User(**new_user), provider_name=self.id_provider) + + return user + + @staticmethod + def configuration_schema() -> Optional[Tuple[str, ProviderConfigurationSchema]]: + class GoogleProviderConfiguration(ProviderConfigurationSchema): + GOOGLE_CLIENT_ID = fields.String(load_default="") + GOOGLE_CLIENT_SECRET = fields.String(load_default="") + + return GoogleProviderConfiguration + + def configure(self, configuration: Union[dict, Any]): + super().configure(configuration) + current_app.config["GOOGLE_CLIENT_ID"] = configuration["GOOGLE_CLIENT_ID"] + current_app.config["GOOGLE_CLIENT_SECRET"] = configuration[ + "GOOGLE_CLIENT_SECRET" + ] + + +# Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privée) diff --git a/src/pypnusershub/auth/providers/openid_provider.py b/src/pypnusershub/auth/providers/openid_provider.py new file mode 100644 index 0000000..6c19c23 --- /dev/null +++ b/src/pypnusershub/auth/providers/openid_provider.py @@ -0,0 +1,113 @@ +import requests + +from authlib.integrations.flask_client import OAuth +from marshmallow import Schema, fields +from typing import Any, Optional, Tuple, Union +from flask import Response, current_app, url_for, session +from werkzeug.exceptions import Unauthorized + +from pypnusershub.auth import Authentication, ProviderConfigurationSchema, oauth +from pypnusershub.db import models, db +from pypnusershub.routes import insert_or_update_role +from pypnusershub.auth.auth_manager import auth_manager + + +class OpenIDProvider(Authentication): + name = "OPENID_PROVIDER_CONFIG" + logo = '' + is_uh = False + login_url = "" + logout_url = "" + """ + Name of the fields in the OpenID token that contains the groups info + """ + group_claim_name = "groups" + + def __init__(self): + super().__init__() + for provider in current_app.config["AUTHENTICATION"][self.name]: + oauth.register( + name=provider["id_provider"], + client_id=provider["CLIENT_ID"], + client_secret=provider["CLIENT_SECRET"], + server_metadata_url=f'{provider["ISSUER"]}/.well-known/openid-configuration', + client_kwargs={ + "scope": "openid email profile", + "issuer": provider["ISSUER"], + }, + ) + + def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: + redirect_uri = url_for( + "auth.authorize", provider=self.id_provider, _external=True + ) + oauth_provider = getattr(oauth, self.id_provider) + return oauth_provider.authorize_redirect(redirect_uri) + + def authorize(self): + oauth_provider = getattr(oauth, self.id_provider) + token = oauth_provider.authorize_access_token() + session["openid_token_resp"] = 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, + } + kwargs = ( + dict(group_keys=user_info[self.group_claim_name]) + if self.group_claim_name in user_info + else {} + ) + user = insert_or_update_role( + models.User(**new_user), provider_instance=self, **kwargs + ) + db.session.commit() + return user + + def revoke(self): + if not "openid_token_resp" in session: + raise Unauthorized() + token_response = session["openid_token_resp"] + oauth_provider = getattr(oauth, self.id_provider) + metadata = oauth_provider.load_server_metadata() + requests.post( + metadata["revocation_endpoint"], + data={ + "token": token_response["access_token"], + }, + ) + session.pop("openid_token_resp") + + @staticmethod + def configuration_schema() -> Optional[Tuple[str, ProviderConfigurationSchema]]: + class OpenIDProviderConfiguration(ProviderConfigurationSchema): + ISSUER = fields.String(required=True) + CLIENT_ID = fields.String(required=True) + CLIENT_SECRET = fields.String(required=True) + group_claim_name = fields.String(load_default="groups") + + return OpenIDProviderConfiguration + + +class OpenIDConnectProvider(OpenIDProvider): + name = "OPENID_CONNECT_PROVIDER_CONFIG" + + def revoke(self): + + if not "openid_token_resp" in session: + raise Unauthorized() + token_response = session["openid_token_resp"] + oauth_provider = getattr(oauth, self.id_provider) + metadata = oauth_provider.load_server_metadata() + requests.post( + metadata["end_session_endpoint"], + data={ + "client_id": oauth_provider.client_id, + "client_secret": oauth_provider.client_secret, + "refresh_token": token_response.get("refresh_token", ""), + }, + ) + session.pop("openid_token_resp") diff --git a/src/pypnusershub/auth/providers/usershub_provider.py b/src/pypnusershub/auth/providers/usershub_provider.py new file mode 100644 index 0000000..2312768 --- /dev/null +++ b/src/pypnusershub/auth/providers/usershub_provider.py @@ -0,0 +1,44 @@ +import requests +from typing import Any, Optional, Tuple, Union + +from marshmallow import Schema, fields + +from flask import request, Response, url_for, current_app, redirect +from werkzeug.exceptions import Unauthorized +from sqlalchemy import select + +from geonature.utils.env import db +from pypnusershub.auth import Authentication, ProviderConfigurationSchema +from pypnusershub.db.models import User +from pypnusershub.routes import insert_or_update_role + + +class ExternalUsersHubAuthProvider(Authentication): + name = "EXTERNAL_USERSHUB_PROVIDER_CONFIG" + logo = '' + + def authenticate(self): + params = request.json + login_response = requests.post( + self.login_url, + json={"login": params.get("login"), "password": params.get("password")}, + ) + if login_response.status_code != 200: + raise Unauthorized(f"Connexion impossible à {self.label} ") + user_resp = login_response.json()["user"] + user = User( + uuid_role=user_resp.get("uuid_role"), + identifiant=user_resp["identifiant"], + email=user_resp["email"], + nom_role=user_resp["nom_role"], + prenom_role=user_resp["prenom_role"], + ) + return insert_or_update_role(user, provider_instance=self) + + @staticmethod + def configuration_schema() -> Optional[Tuple[str, ProviderConfigurationSchema]]: + class ExternalGNConfiguration(ProviderConfigurationSchema): + login_url = fields.String(required=True) + logout_url = fields.String(required=True) + + return ExternalGNConfiguration diff --git a/src/pypnusershub/authentification.py b/src/pypnusershub/authentification.py deleted file mode 100644 index 642c1f5..0000000 --- a/src/pypnusershub/authentification.py +++ /dev/null @@ -1,122 +0,0 @@ -from typing import Any, Union -import json -import logging - -import datetime -from flask import ( - request, - Response, - current_app, -) -from markupsafe import escape - -from sqlalchemy.orm import exc -import sqlalchemy as sa -from werkzeug.exceptions import BadRequest - -from pypnusershub.utils import get_current_app_id -from pypnusershub.db import models, db -from pypnusershub.db.tools import ( - encode_token, -) -from pypnusershub.schemas import OrganismeSchema, UserSchema - -log = logging.getLogger(__name__) - - -class Authentification: - """ - Abstract class for authentication implementations. - """ - - def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: - """ - Authenticate a user with the provided parameters. - - Parameters - ---------- - *args : Any - Positional arguments to be passed to the implementation. - **kwargs : Any - Keyword arguments to be passed to the implementation. - - Raises - ------ - NotImplementedError - This method must be implemented by subclasses. - - Returns - ------- - Union[Response, models.User] - The result of the authentication process, which can be either a Response object or a User object. - """ - raise NotImplementedError() - - def revoke(self) -> Any: - """ - Revoke current authentication. - - Raises - ------ - NotImplementedError - This method must be implemented by subclasses. - - Returns - ------- - Any - Revocation result depending on the implementation. - """ - raise NotImplementedError() - - -class DefaultConfiguration(Authentification): - - def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: - user_data = request.json - try: - username, password = user_data.get("login"), user_data.get("password") - id_app = user_data.get("id_application", get_current_app_id()) - - if id_app is None or username is None or password is None: - msg = json.dumps( - "One of the following parameter is required ['id_application', 'login', 'password']" - ) - return Response(msg, status=400) - app = db.session.get(models.Application, id_app) - if not app: - raise BadRequest(f"No app for id {id_app}") - user = db.session.execute( - sa.select(models.User) - .where(models.User.identifiant == username) - .where(models.User.filter_by_app()) - ).scalar_one() - user_dict = UserSchema( - exclude=["remarques"], only=["+max_level_profil"] - ).dump(user) - except exc.NoResultFound as e: - msg = json.dumps( - { - "type": "login", - "msg": ( - 'No user found with the username "{login}" for ' - 'the application with id "{id_app}"' - ).format(login=escape(username), id_app=id_app), - } - ) - log.info(msg) - status_code = current_app.config.get("BAD_LOGIN_STATUS_CODE", 490) - return Response(msg, status=status_code) - - if not user.check_password(user_data["password"]): - msg = json.dumps({"type": "password", "msg": "Mot de passe invalide"}) - log.info(msg) - status_code = current_app.config.get("BAD_LOGIN_STATUS_CODE", 490) - return Response(msg, status=status_code) - # Génération d'un token - token = encode_token(user_dict) - token_exp = datetime.datetime.now(datetime.timezone.utc) - token_exp += datetime.timedelta(seconds=current_app.config["COOKIE_EXPIRATION"]) - return user - - def revoke(self) -> Any: - pass diff --git a/src/pypnusershub/db/models.py b/src/pypnusershub/db/models.py index c3aafbd..15cadf2 100644 --- a/src/pypnusershub/db/models.py +++ b/src/pypnusershub/db/models.py @@ -34,6 +34,8 @@ from sqlalchemy import Sequence, func, ForeignKey, or_ from sqlalchemy.sql import select, func from sqlalchemy.dialects.postgresql import UUID, JSONB, array +from sqlalchemy.schema import FetchedValue + from pypnusershub.db.tools import NoPasswordError, DifferentPasswordError from pypnusershub.env import db @@ -85,6 +87,23 @@ def fn_check_password(self, pwd): extend_existing=True, ) +cor_role_provider = db.Table( + "cor_role_provider", + db.Column( + "id_role", + db.Integer, + ForeignKey("utilisateurs.t_roles.id_role"), + primary_key=True, + ), + db.Column( + "id_provider", + db.Integer, + ForeignKey("utilisateurs.t_providers.id_provider"), + primary_key=True, + ), + schema="utilisateurs", +) + class UserQuery(Query): def filter_by_app(self, code_app=None): @@ -113,6 +132,7 @@ class User(db.Model, UserMixin): __table_args__ = {"schema": "utilisateurs"} query_class = UserQuery + uuid_role = db.Column(UUID, server_default=FetchedValue()) groupe = db.Column(db.Boolean, default=False) id_role = db.Column( db.Integer, @@ -142,6 +162,7 @@ class User(db.Model, UserMixin): secondaryjoin="User.id_role == utilisateurs.cor_roles.c.id_role_groupe", backref=backref("members", cascade_backrefs=False), ) + providers = db.relationship("Provider", secondary=cor_role_provider) @property def max_level_profil(self): @@ -209,8 +230,8 @@ def is_public(self): def __repr__(self): return "".format(self.identifiant, self.id_role) - def __str__(self): - return self.identifiant or self.nom_complet + # def __str__(self): + # return self.identifiant or self.nom_complet @qfilter def filter_by_app(cls, code_app=None, **kwargs): @@ -473,3 +494,15 @@ class UserList(db.Model): desc_liste = db.Column(db.Unicode) users = db.relationship(User, secondary=cor_role_liste) + + +class Provider(db.Model): + __tablename__ = "t_providers" + __table_args__ = {"schema": "utilisateurs"} + id_provider = db.Column( + db.Integer, + nullable=False, + primary_key=True, + ) + name = db.Column(db.Unicode, nullable=False) + url = db.Column(db.Unicode, nullable=False) diff --git a/src/pypnusershub/migrations/versions/b7c98935d9e8_add_providers_table_and_cor_tables_with_troles.py b/src/pypnusershub/migrations/versions/b7c98935d9e8_add_providers_table_and_cor_tables_with_troles.py new file mode 100644 index 0000000..9b2f1cf --- /dev/null +++ b/src/pypnusershub/migrations/versions/b7c98935d9e8_add_providers_table_and_cor_tables_with_troles.py @@ -0,0 +1,62 @@ +"""add provider table and correspondances table with t_roles + +Revision ID: b7c98935d9e8 +Revises: f9d3b95946cd +Create Date: 2024-04-04 10:35:44.745906 + +""" + +from alembic import op +import sqlalchemy as sa + +from pypnusershub.db.models import Provider + + +# revision identifiers, used by Alembic. +revision = "b7c98935d9e8" +down_revision = "f9d3b95946cd" +branch_labels = None +depends_on = None + + +def upgrade(): + + op.create_table( + "t_providers", + sa.Column("id_provider", sa.Integer, primary_key=True), + sa.Column( + "name", sa.Unicode, nullable=False, comment="Nom de l'instance du provider" + ), + sa.Column( + "url", + sa.Unicode, + nullable=True, + comment="L'url du fournisseur d'authentification", + ), + schema="utilisateurs", + ) + op.create_table( + "cor_role_provider", + sa.Column( + "id_role", + sa.Integer, + sa.ForeignKey("utilisateurs.t_roles.id_role"), + nullable=False, + primary_key=True, + ), + sa.Column( + "id_provider", + sa.Integer, + sa.ForeignKey("utilisateurs.t_providers.id_provider"), + nullable=False, + primary_key=True, + ), + schema="utilisateurs", + comment="Table de correpondance entre t_roles et t_providers", + ) + op.execute(sa.insert(Provider).values(name="local_provider")) + + +def downgrade(): + op.drop_table("cor_role_provider", schema="utilisateurs") + op.drop_table("t_providers", schema="utilisateurs") diff --git a/src/pypnusershub/routes.py b/src/pypnusershub/routes.py index 8c776b2..8ef7a8c 100755 --- a/src/pypnusershub/routes.py +++ b/src/pypnusershub/routes.py @@ -1,40 +1,39 @@ # coding: utf8 -from __future__ import unicode_literals, print_function, absolute_import, division - +from __future__ import absolute_import, division, print_function, unicode_literals +from typing import List """ routes relatives aux application, utilisateurs et à l'authentification """ +import datetime import json import logging import datetime from flask_login import login_required, login_user, logout_user, current_user +import sqlalchemy as sa from flask import ( Blueprint, - request, Response, current_app, - redirect, g, - make_response, jsonify, + make_response, + redirect, + request, + session, ) +from flask_login import current_user, login_required, login_user, logout_user from markupsafe import escape - -from sqlalchemy.orm import exc -import sqlalchemy as sa -from werkzeug.exceptions import BadRequest, Forbidden - -from pypnusershub.utils import get_current_app_id -from pypnusershub.db import models, db -from pypnusershub.db.tools import ( - encode_token, -) +from pypnusershub.auth import oauth +from pypnusershub.db import db, models +from pypnusershub.db.tools import encode_token from pypnusershub.schemas import OrganismeSchema, UserSchema - +from pypnusershub.utils import get_current_app_id +from sqlalchemy.orm import exc +from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized log = logging.getLogger(__name__) # This module was originally designed as a submodule of designed @@ -77,6 +76,7 @@ def register(self, app, *args, **kwargs): ) parent = super(ConfigurableBlueprint, self) parent.register(app, *args, **kwargs) + oauth.init_app(app) @app.before_request def load_current_user(): @@ -89,17 +89,60 @@ def load_current_user(): from pypnusershub.decorators import check_auth +@routes.route("/providers", methods=["GET"]) +def get_providers(): + from itertools import chain + + property_name = [ + # "id_provider", + "is_uh", + "logo", + "label", + "login_url", + "logout_url", + ] + print(current_app.auth_manager.provider_authentication_cls) + return jsonify( + [ + dict( + chain.from_iterable( + d.items() + for d in ( + { + _property: getattr(provider, _property) + for _property in property_name + }, + {"id_provider": id_provider}, + ) + ) + ) + for id_provider, provider in current_app.auth_manager.provider_authentication_cls.items() + if not provider.id_provider == "local_provider" + ] + ) + + @routes.route("/get_current_user") @login_required def get_user_data(): - user_dict = UserSchema(exclude=["remarques"], only=["+max_level_profil"]).dump( - g.current_user - ) + """ + Retrieves the data of the currently authenticated user. + + This route is protected and requires the user to be logged in. It retrieves the user data + from the `g.current_user` object and serializes it using the `UserSchema` class. The serialized user data + is then added to the response JSON along with a JWT token and the expiration time of the token. + + Returns + ------- + dict + A dictionary containing the user data, token, and expiration time. + """ + user_dict = UserSchema( + exclude=["remarques"], only=["+max_level_profil", "+providers"] + ).dump(g.current_user) token_exp = datetime.datetime.now(datetime.timezone.utc) - token_exp += datetime.timedelta( - seconds=current_app.config.get("COOKIE_EXPIRATION", 3600) - ) + token_exp += datetime.timedelta(seconds=current_app.config["COOKIE_EXPIRATION"]) data = { "user": user_dict, "token": encode_token(g.current_user.as_dict()).decode(), @@ -109,58 +152,47 @@ def get_user_data(): return jsonify(data) -@routes.route("/login", methods=["POST"]) -def login(): - user_data = request.json - try: - login = user_data.get("login") - password = user_data.get("password") - id_app = user_data.get("id_application", get_current_app_id()) - if id_app is None or login is None or password is None: - msg = json.dumps( - "One of the following parameter is required ['id_application', 'login', 'password']" - ) - return Response(msg, status=400) - app = db.session.get(models.Application, id_app) - if not app: - raise BadRequest(f"No app for id {id_app}") - user = db.session.execute( - sa.select(models.User) - .where(models.User.identifiant == login) - .where(models.User.filter_by_app()) - ).scalar_one() - user_dict = UserSchema(exclude=["remarques"], only=["+max_level_profil"]).dump( - user - ) - except exc.NoResultFound as e: - msg = json.dumps( +@routes.route("/login/", methods=["POST", "GET"]) +def login(provider="local_provider"): + """ + Authenticates the user and returns their data and a JWT token. + + This route is called by the client to authenticate the user. It uses the + `authentification_class` configured in the Flask app to authenticate the user. + If the authentication is successful, it returns a JSON response containing + the serialized user data, a JWT token, and the expiration time of the token. + If the authentication fails, it returns the result of the authentication. + + Returns + ------- + - If the authentication is successful, it returns a JSON response containing: + - `user`: The serialized user data. + - `expires`: The expiration time of the token. + - `token`: The JWT token. + - If the authentication fails, it returns the result of the authentication. + """ + auth_provider = current_app.auth_manager.get_provider(provider) + session["current_provider"] = provider + auth_result = auth_provider.authenticate() + if isinstance(auth_result, Response): + return auth_result + if isinstance(auth_result, models.User): + login_user(auth_result) + user_dict = UserSchema( + exclude=["remarques"], only=["+max_level_profil", "+providers"] + ).dump(auth_result) + token = encode_token(user_dict) + token_exp = datetime.datetime.now(datetime.timezone.utc) + token_exp += datetime.timedelta(seconds=current_app.config["COOKIE_EXPIRATION"]) + + return jsonify( { - "type": "login", - "msg": ( - 'No user found with the username "{login}" for ' - 'the application with id "{id_app}"' - ).format(login=escape(login), id_app=id_app), + "user": user_dict, + "expires": token_exp.isoformat(), + "token": token.decode(), } ) - log.info(msg) - status_code = current_app.config.get("BAD_LOGIN_STATUS_CODE", 490) - return Response(msg, status=status_code) - - if not user.check_password(user_data["password"]): - msg = json.dumps({"type": "password", "msg": "Mot de passe invalide"}) - log.info(msg) - status_code = current_app.config.get("BAD_LOGIN_STATUS_CODE", 490) - return Response(msg, status=status_code) - login_user(user, remember=True) - # Génération d'un token - token = encode_token(user_dict) - token_exp = datetime.datetime.now(datetime.timezone.utc) - token_exp += datetime.timedelta( - seconds=current_app.config["REMEMBER_COOKIE_DURATION"] - ) - return jsonify( - {"user": user_dict, "expires": token_exp.isoformat(), "token": token.decode()} - ) + return redirect(current_app.config["URL_APPLICATION"]) @routes.route("/public_login", methods=["POST"]) @@ -191,15 +223,34 @@ def public_login(): @routes.route("/logout", methods=["GET", "POST"]) def logout(): + if not "current_provider" in session: + raise Unauthorized("No provider in session") + auth_provider = current_app.auth_manager.get_provider(session["current_provider"]) + logout_user() + resp = auth_provider.revoke() + if isinstance(resp, Response): + return resp + params = request.args if "redirect" in params: resp = redirect(params["redirect"], code=302) else: - resp = make_response() - logout_user() + resp = redirect(current_app.config["URL_APPLICATION"]) + return resp +@routes.route("/authorize/", methods=["GET", "POST"]) +def authorize(provider="local_provider"): + auth_provider = current_app.auth_manager.get_provider(provider) + authorize_result = auth_provider.authorize() + if isinstance(authorize_result, models.User): + login_user(authorize_result) + + # if auth_provider.is_external: + return redirect(current_app.config["URL_APPLICATION"]) + + def insert_or_update_organism(organism): """ Insert a organism @@ -211,11 +262,85 @@ def insert_or_update_organism(organism): return organism_schema.dump(organism) -def insert_or_update_role(data): +def insert_or_update_role( + user: models.User, + provider_instance: models.Provider, + reconciliate_attr="email", + group_keys: List[int] = [], +) -> models.User: """ Insert or update a role (also add groups if provided) + + Parameters + ---------- + user: models.User + User to insert or update + provider_instance: models.Provider + Provider instance used to create/log the user + reconciliate_attr: str, default="email" + Attribute used to reconciliate existing users + group_keys: List[int], default=[] + List of group keys to compare with existing groups defined in the group_mapping properties of the provider + + Returns + ------- + models.User + The updated or created user + + Raises + ------ + Exception + If no group mapping indicated for the provider and DEFAULT_RECONCILIATION_GROUP_ID + is not set + KeyError + If Group {group_name} was not found in the mapping """ - user_schema = UserSchema(only=["groups"]) - user = user_schema.load(data) - db.session.add(user) - return user_schema.dump(user) + assert hasattr(user, reconciliate_attr) + + user_exists = db.session.execute( + sa.select(models.User).where( + getattr(models.User, reconciliate_attr) == getattr(user, reconciliate_attr), + ) + ).scalar_one_or_none() + provider = db.session.execute( + sa.select(models.Provider).where( + models.Provider.name == provider_instance.id_provider + ) + ).scalar_one() + if user_exists: + if not provider in user_exists.providers: + user_exists.providers.append(provider) + db.session.commit() + return user_exists + else: + group_id = "" + # No group mapping indicated + if not (provider_instance.group_mapping and group_keys): + if not "DEFAULT_RECONCILIATION_GROUP_ID" in current_app.config.get( + "AUTHENTICATION", {} + ): + raise Exception( + f"If no group mapping indicated for the provider {provider.id_provider}, DEFAULT_RECONCILIATION_GROUP_ID must be set !" + ) + group_id = current_app.config["AUTHENTICATION"][ + "DEFAULT_RECONCILIATION_GROUP_ID" + ] + group = db.session.get(models.User, group_id) + if group: + user.groups.append(group) + # Group Mapping indicated + else: + for key in group_keys: + if not key in provider_instance.group_mapping: + raise KeyError("Group {group_name} was not found in the mapping !") + group_id = provider_instance.group_mapping[key] + + group = db.session.get(models.User, group_id) + + if group: + user.groups.append(group) + + user.providers.append(provider) + db.session.add(user) + db.session.commit() + return user diff --git a/src/pypnusershub/schemas.py b/src/pypnusershub/schemas.py index 665e317..f6bfee0 100644 --- a/src/pypnusershub/schemas.py +++ b/src/pypnusershub/schemas.py @@ -3,7 +3,7 @@ from utils_flask_sqla.schema import SmartRelationshipsMixin from pypnusershub.env import ma, db -from pypnusershub.db.models import User, Organisme +from pypnusershub.db.models import User, Organisme, Provider class OrganismeSchema(SmartRelationshipsMixin, ma.SQLAlchemyAutoSchema): @@ -13,6 +13,13 @@ class Meta: sqla_session = db.session +class ProviderSchema(SmartRelationshipsMixin, ma.SQLAlchemyAutoSchema): + class Meta: + model = Provider + load_instance = True + sqla_session = db.session + + class UserSchema(SmartRelationshipsMixin, ma.SQLAlchemyAutoSchema): class Meta: model = User @@ -25,6 +32,7 @@ class Meta: nom_complet = fields.String() groups = fields.Nested(lambda: UserSchema, many=True) organisme = fields.Nested(OrganismeSchema) + providers = fields.Nested(ProviderSchema, many=True) # TODO: remove this and fix usage of the schema @pre_load From 86ed433abdd0990f7681e2979d2bffce53630d9f Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Wed, 3 Jul 2024 09:32:25 +0200 Subject: [PATCH 03/28] add remember option to login_user() call --- src/pypnusershub/routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pypnusershub/routes.py b/src/pypnusershub/routes.py index 8ef7a8c..042531b 100755 --- a/src/pypnusershub/routes.py +++ b/src/pypnusershub/routes.py @@ -177,7 +177,7 @@ def login(provider="local_provider"): if isinstance(auth_result, Response): return auth_result if isinstance(auth_result, models.User): - login_user(auth_result) + login_user(auth_result, remember=True) user_dict = UserSchema( exclude=["remarques"], only=["+max_level_profil", "+providers"] ).dump(auth_result) @@ -245,7 +245,7 @@ def authorize(provider="local_provider"): auth_provider = current_app.auth_manager.get_provider(provider) authorize_result = auth_provider.authorize() if isinstance(authorize_result, models.User): - login_user(authorize_result) + login_user(authorize_result, remember=True) # if auth_provider.is_external: return redirect(current_app.config["URL_APPLICATION"]) From 3d08a99bd50f2249e7f8ac9a874349115b0f6c18 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 5 Jul 2024 09:46:08 +0200 Subject: [PATCH 04/28] clean code --- src/pypnusershub/auth/auth_manager.py | 13 ++-- src/pypnusershub/auth/authentication.py | 25 ++++++- .../auth/providers/cas_inpn_provider.py | 49 +++---------- .../auth/providers/github_provider.py | 16 +--- .../auth/providers/google_provider.py | 73 ------------------- .../auth/providers/openid_provider.py | 31 +++++--- .../auth/providers/usershub_provider.py | 18 ++--- src/pypnusershub/db/models.py | 36 +++------ src/pypnusershub/routes.py | 16 +--- 9 files changed, 85 insertions(+), 192 deletions(-) delete mode 100644 src/pypnusershub/auth/providers/google_provider.py diff --git a/src/pypnusershub/auth/auth_manager.py b/src/pypnusershub/auth/auth_manager.py index 19a47a9..0445057 100644 --- a/src/pypnusershub/auth/auth_manager.py +++ b/src/pypnusershub/auth/auth_manager.py @@ -1,10 +1,12 @@ -from .authentication import Authentication -from .providers import DefaultConfiguration -from pypnusershub.db.models import Provider import importlib + import sqlalchemy as sa +from pypnusershub.db.models import Provider from pypnusershub.env import db +from .authentication import Authentication +from .providers import DefaultConfiguration + class AuthManager: """ @@ -66,16 +68,13 @@ def add_provider( def init_app(self, app, prefix: str = "/auth") -> None: """ - Initializes the Flask application with the AuthManager. + Initializes the Flask application with the AuthManager. In addtion, it registers the authentification module blueprint. Parameters ---------- app : Flask The Flask application instance. - Returns - ------- - None """ from pypnusershub.routes import routes diff --git a/src/pypnusershub/auth/authentication.py b/src/pypnusershub/auth/authentication.py index cc9e81b..3c386b0 100644 --- a/src/pypnusershub/auth/authentication.py +++ b/src/pypnusershub/auth/authentication.py @@ -1,9 +1,8 @@ -from typing import Any, Union import logging - -from pypnusershub.db import models +from typing import Any, Union from marshmallow import Schema, fields +from pypnusershub.db import models log = logging.getLogger(__name__) @@ -143,7 +142,17 @@ def revoke(self) -> Any: log.warn("Revoke is not implemented.") pass - def configure(self, configuration: Union[dict, Any] = {}): + def configure(self, configuration: Union[dict, Any] = {}) -> None: + """ + Configure the authentication provider based on data in the configuration file. + + Parameters + ---------- + configuration : Union[dict, Any], optional + The configuration parameters. + Default is an empty dictionary. + + """ self.id_provider = configuration["id_provider"] for field in ["label", "logo", "login_url", "logout_url", "group_mapping"]: if field in configuration: @@ -151,4 +160,12 @@ def configure(self, configuration: Union[dict, Any] = {}): @staticmethod def configuration_schema() -> ProviderConfigurationSchema: + """ + Returns the marshmallow schema used to configure this authentication provider. + + Returns + ------- + ProviderConfigurationSchema + The schema used to configure this authentication provider. + """ return ProviderConfigurationSchema diff --git a/src/pypnusershub/auth/providers/cas_inpn_provider.py b/src/pypnusershub/auth/providers/cas_inpn_provider.py index fa218e4..c26c230 100644 --- a/src/pypnusershub/auth/providers/cas_inpn_provider.py +++ b/src/pypnusershub/auth/providers/cas_inpn_provider.py @@ -2,17 +2,10 @@ from typing import Any, Optional, Tuple, Union import xmltodict -from flask import ( - Response, - current_app, - make_response, - redirect, - render_template, - request, -) -from marshmallow import fields +from flask import Response, current_app, redirect, render_template, request from geonature.utils import utilsrequests from geonature.utils.errors import GeonatureApiError +from marshmallow import fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema from pypnusershub.db import db, models from pypnusershub.routes import insert_or_update_organism, insert_or_update_role @@ -25,30 +18,6 @@ class CasAuthentificationError(GeonatureApiError): pass -AUTHENTIFICATION_CONFIG = { - "PROVIDER_NAME": "inpn", - "EXTERNAL_PROVIDER": True, -} - -CAS_AUTHENTIFICATION = True -CAS_PUBLIC = dict( - URL_LOGIN="https://inpn.mnhn.fr/auth/login", - URL_LOGOUT="https://inpn.mnhn.fr/auth/logout", - URL_VALIDATION="https://inpn.mnhn.fr/auth/serviceValidate", -) - -CAS_USER_WS = dict( - URL="https://inpn.mnhn.fr/authentication/information", - BASE_URL="https://inpn.mnhn.fr/authentication/", - ID="change_value", - PASSWORD="change_value", -) -USERS_CAN_SEE_ORGANISM_DATA = False - -ID_USER_SOCLE_1 = 1 -ID_USER_SOCLE_2 = 2 - - class AuthenficationCASINPN(Authentication): name = "CAS_INPN_PROVIDER" label = "INPN" @@ -157,16 +126,14 @@ def insert_user_and_org(self, info_user, id_provider): "email": info_user["email"], "active": True, } - user = insert_or_update_role( - models.User(**user_info), provider_name=self.id_provider - ) + user = insert_or_update_role(models.User(**user_info), provider_instance=self) if not user.groups: - if not USERS_CAN_SEE_ORGANISM_DATA or organism_id is None: + if not self.USERS_CAN_SEE_ORGANISM_DATA or organism_id is None: # group socle 1 - group_id = ID_USER_SOCLE_1 + group_id = self.ID_USER_SOCLE_1 else: # group socle 2 - group_id = ID_USER_SOCLE_2 + group_id = self.ID_USER_SOCLE_2 group = db.session.get(models.User, group_id) user.groups.append(group) return user @@ -187,11 +154,13 @@ class CASINPNConfiguration(ProviderConfigurationSchema): ) WS_ID = fields.String(required=True) WS_PASSWORD = fields.String(required=True) + USERS_CAN_SEE_ORGANISM_DATA = fields.Boolean(load_default=False) + ID_USER_SOCLE_1 = fields.Integer(load_default=7) + ID_USER_SOCLE_2 = fields.Integer(load_default=6) return CASINPNConfiguration def configure(self, configuration: Union[dict, Any]): super().configure(configuration) - print(configuration) for key in configuration: setattr(self, key, configuration[key]) diff --git a/src/pypnusershub/auth/providers/github_provider.py b/src/pypnusershub/auth/providers/github_provider.py index fd0aa58..214f03f 100644 --- a/src/pypnusershub/auth/providers/github_provider.py +++ b/src/pypnusershub/auth/providers/github_provider.py @@ -1,16 +1,11 @@ from typing import Union from authlib.integrations.flask_client import OAuth -from flask import ( - Response, - current_app, - url_for, -) +from flask import Response, current_app, url_for from pypnusershub.auth import Authentication -from pypnusershub.db import models, db +from pypnusershub.db import db, models from pypnusershub.routes import insert_or_update_role - oauth = OAuth(current_app) oauth.register( name="github", @@ -32,6 +27,7 @@ class GitHubAuthProvider(Authentication): login_url = "http://127.0.0.1:8000/auth/login/github" logout_url = "" logo = '' + name = "GITHUB_PROVIDER_CONFIG" def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: redirect_uri = url_for( @@ -52,15 +48,11 @@ def authorize(self): "prenom_role": prenom, "nom_role": nom, "active": True, - "provider": "github", } - user_info = insert_or_update_role(new_user) + user_info = insert_or_update_role(new_user, self) user = db.session.get(models.User, user_info["id_role"]) if not user.groups: group = db.session.get(models.User, 2) # ADMIN for test user.groups.append(group) db.session.commit() return user - - -# Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privée) diff --git a/src/pypnusershub/auth/providers/google_provider.py b/src/pypnusershub/auth/providers/google_provider.py deleted file mode 100644 index be356c3..0000000 --- a/src/pypnusershub/auth/providers/google_provider.py +++ /dev/null @@ -1,73 +0,0 @@ -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 - - -# TODO : à enlever : fonctionne avec OPENID_PROVIDER - -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_uh = False - login_url = "" - logout_url = "" - logo = '' - - 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(User(**new_user), provider_name=self.id_provider) - - return user - - @staticmethod - def configuration_schema() -> Optional[Tuple[str, ProviderConfigurationSchema]]: - class GoogleProviderConfiguration(ProviderConfigurationSchema): - GOOGLE_CLIENT_ID = fields.String(load_default="") - GOOGLE_CLIENT_SECRET = fields.String(load_default="") - - return GoogleProviderConfiguration - - def configure(self, configuration: Union[dict, Any]): - super().configure(configuration) - current_app.config["GOOGLE_CLIENT_ID"] = configuration["GOOGLE_CLIENT_ID"] - current_app.config["GOOGLE_CLIENT_SECRET"] = configuration[ - "GOOGLE_CLIENT_SECRET" - ] - - -# Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privée) diff --git a/src/pypnusershub/auth/providers/openid_provider.py b/src/pypnusershub/auth/providers/openid_provider.py index 6c19c23..caa4b6a 100644 --- a/src/pypnusershub/auth/providers/openid_provider.py +++ b/src/pypnusershub/auth/providers/openid_provider.py @@ -1,23 +1,25 @@ -import requests - -from authlib.integrations.flask_client import OAuth -from marshmallow import Schema, fields -from typing import Any, Optional, Tuple, Union -from flask import Response, current_app, url_for, session -from werkzeug.exceptions import Unauthorized +from typing import Optional, Tuple, Union +import requests +from flask import Response, current_app, session, url_for +from marshmallow import fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema, oauth -from pypnusershub.db import models, db +from pypnusershub.db import db, models from pypnusershub.routes import insert_or_update_role -from pypnusershub.auth.auth_manager import auth_manager +from werkzeug.exceptions import Unauthorized class OpenIDProvider(Authentication): + """ + OpenID provider authentication class. + + This class handle the authentication process with an OpenID provider. + + """ + name = "OPENID_PROVIDER_CONFIG" logo = '' is_uh = False - login_url = "" - logout_url = "" """ Name of the fields in the OpenID token that contains the groups info """ @@ -93,6 +95,13 @@ class OpenIDProviderConfiguration(ProviderConfigurationSchema): class OpenIDConnectProvider(OpenIDProvider): + """ + OpenID Connect provider authentication class. + + This class handle the authentication process with an OpenID Connect provider. + + """ + name = "OPENID_CONNECT_PROVIDER_CONFIG" def revoke(self): diff --git a/src/pypnusershub/auth/providers/usershub_provider.py b/src/pypnusershub/auth/providers/usershub_provider.py index 2312768..f3d71e2 100644 --- a/src/pypnusershub/auth/providers/usershub_provider.py +++ b/src/pypnusershub/auth/providers/usershub_provider.py @@ -1,19 +1,19 @@ -import requests -from typing import Any, Optional, Tuple, Union - -from marshmallow import Schema, fields - -from flask import request, Response, url_for, current_app, redirect -from werkzeug.exceptions import Unauthorized -from sqlalchemy import select +from typing import Optional, Tuple -from geonature.utils.env import db +import requests +from flask import request +from marshmallow import fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema from pypnusershub.db.models import User from pypnusershub.routes import insert_or_update_role +from werkzeug.exceptions import Unauthorized class ExternalUsersHubAuthProvider(Authentication): + """ + Authentication provider for Flask application using UsersHub-authentification-module. + """ + name = "EXTERNAL_USERSHUB_PROVIDER_CONFIG" logo = '' diff --git a/src/pypnusershub/db/models.py b/src/pypnusershub/db/models.py index 15cadf2..456fded 100644 --- a/src/pypnusershub/db/models.py +++ b/src/pypnusershub/db/models.py @@ -1,6 +1,6 @@ # coding: utf8 -from __future__ import unicode_literals, print_function, absolute_import, division +from __future__ import absolute_import, division, print_function, unicode_literals from utils_flask_sqla.models import qfilter @@ -9,38 +9,29 @@ """ import hashlib + import bcrypt +import flask_sqlalchemy from bcrypt import checkpw -from os import environ -from importlib import import_module from packaging import version -from flask_sqlalchemy import SQLAlchemy -import flask_sqlalchemy - - if version.parse(flask_sqlalchemy.__version__) >= version.parse("3"): from flask_sqlalchemy.query import Query else: from flask_sqlalchemy import BaseQuery as Query - from flask import current_app from flask_login import UserMixin - +from pypnusershub.db.tools import DifferentPasswordError, NoPasswordError +from pypnusershub.env import db +from pypnusershub.utils import get_current_app_id +from sqlalchemy import ForeignKey, func, or_ +from sqlalchemy.dialects.postgresql import JSONB, UUID, array from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import relationship, backref +from sqlalchemy.orm import backref, relationship from sqlalchemy.orm.session import object_session -from sqlalchemy import Sequence, func, ForeignKey, or_ -from sqlalchemy.sql import select, func -from sqlalchemy.dialects.postgresql import UUID, JSONB, array from sqlalchemy.schema import FetchedValue - - -from pypnusershub.db.tools import NoPasswordError, DifferentPasswordError -from pypnusershub.env import db -from pypnusershub.utils import get_current_app_id - +from sqlalchemy.sql import func, select from utils_flask_sqla.serializers import serializable @@ -230,8 +221,8 @@ def is_public(self): def __repr__(self): return "".format(self.identifiant, self.id_role) - # def __str__(self): - # return self.identifiant or self.nom_complet + def __str__(self): + return self.identifiant or self.nom_complet @qfilter def filter_by_app(cls, code_app=None, **kwargs): @@ -422,9 +413,6 @@ class AppUser(db.Model): _password = db.Column("pass", db.Unicode) _password_plus = db.Column("pass_plus", db.Unicode) id_droit_max = db.Column(db.Integer, primary_key=True) - # user = db.relationship('User', backref='relations', lazy='joined') - # application = db.relationship('Application', - # backref='relations', lazy='joined') @property def password(self): diff --git a/src/pypnusershub/routes.py b/src/pypnusershub/routes.py index 042531b..3238498 100755 --- a/src/pypnusershub/routes.py +++ b/src/pypnusershub/routes.py @@ -1,18 +1,14 @@ # coding: utf8 - -from __future__ import absolute_import, division, print_function, unicode_literals -from typing import List - """ routes relatives aux application, utilisateurs et à l'authentification """ +from __future__ import absolute_import, division, print_function, unicode_literals + import datetime -import json import logging +from typing import List -import datetime -from flask_login import login_required, login_user, logout_user, current_user import sqlalchemy as sa from flask import ( Blueprint, @@ -20,7 +16,6 @@ current_app, g, jsonify, - make_response, redirect, request, session, @@ -31,9 +26,7 @@ from pypnusershub.db import db, models from pypnusershub.db.tools import encode_token from pypnusershub.schemas import OrganismeSchema, UserSchema -from pypnusershub.utils import get_current_app_id -from sqlalchemy.orm import exc -from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized +from werkzeug.exceptions import Forbidden, Unauthorized log = logging.getLogger(__name__) # This module was originally designed as a submodule of designed @@ -94,7 +87,6 @@ def get_providers(): from itertools import chain property_name = [ - # "id_provider", "is_uh", "logo", "label", From 0bf90403da29a594da35ec6d5f3be3f2570b01cd Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 5 Jul 2024 09:51:19 +0200 Subject: [PATCH 05/28] add default value for auth.login route --- src/pypnusershub/routes.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pypnusershub/routes.py b/src/pypnusershub/routes.py index 3238498..7358745 100755 --- a/src/pypnusershub/routes.py +++ b/src/pypnusershub/routes.py @@ -145,7 +145,10 @@ def get_user_data(): @routes.route("/login/", methods=["POST", "GET"]) -def login(provider="local_provider"): +@routes.route( + "/login", methods=["POST", "GET"], defaults={"provider": "local_provider"} +) +def login(provider): """ Authenticates the user and returns their data and a JWT token. From 116fd8ba43a9e62c30db20d53db0221019ffb758 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 5 Jul 2024 09:55:34 +0200 Subject: [PATCH 06/28] add missing auth manager in the conftest --- src/pypnusershub/routes.py | 21 ++++++++-------- src/pypnusershub/tests/conftest.py | 6 +++-- src/pypnusershub/tests/test_utilisateurs.py | 28 ++++++++++++++++----- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/pypnusershub/routes.py b/src/pypnusershub/routes.py index 7358745..946e1e7 100755 --- a/src/pypnusershub/routes.py +++ b/src/pypnusershub/routes.py @@ -93,7 +93,6 @@ def get_providers(): "login_url", "logout_url", ] - print(current_app.auth_manager.provider_authentication_cls) return jsonify( [ dict( @@ -303,6 +302,7 @@ def insert_or_update_role( ) ).scalar_one() if user_exists: + if not provider in user_exists.providers: user_exists.providers.append(provider) db.session.commit() @@ -311,18 +311,17 @@ def insert_or_update_role( group_id = "" # No group mapping indicated if not (provider_instance.group_mapping and group_keys): - if not "DEFAULT_RECONCILIATION_GROUP_ID" in current_app.config.get( + + if "DEFAULT_RECONCILIATION_GROUP_ID" in current_app.config.get( "AUTHENTICATION", {} ): - raise Exception( - f"If no group mapping indicated for the provider {provider.id_provider}, DEFAULT_RECONCILIATION_GROUP_ID must be set !" - ) - group_id = current_app.config["AUTHENTICATION"][ - "DEFAULT_RECONCILIATION_GROUP_ID" - ] - group = db.session.get(models.User, group_id) - if group: - user.groups.append(group) + + group_id = current_app.config["AUTHENTICATION"][ + "DEFAULT_RECONCILIATION_GROUP_ID" + ] + group = db.session.get(models.User, group_id) + if group: + user.groups.append(group) # Group Mapping indicated else: for key in group_keys: diff --git a/src/pypnusershub/tests/conftest.py b/src/pypnusershub/tests/conftest.py index 6fa5887..f6b474c 100644 --- a/src/pypnusershub/tests/conftest.py +++ b/src/pypnusershub/tests/conftest.py @@ -6,6 +6,7 @@ from pypnusershub.env import db, ma from pypnusershub.login_manager import login_manager +from pypnusershub.auth.auth_manager import auth_manager @pytest.fixture(scope="session", autouse=True) @@ -13,15 +14,16 @@ def app(): app = Flask("pypnusershub") from pypnusershub.routes import routes - app.register_blueprint(routes) app.testing = True app.test_client_class = JSONClient - + app.config["AUTHENTICATION"] = {} app.config.from_envvar("USERSHUB_AUTH_MODULE_SETTINGS") app.testing = True db.init_app(app) ma.init_app(app) + auth_manager.init_app(app) login_manager.init_app(app) + with app.app_context(): transaction = db.session.begin_nested() # execute tests in a savepoint yield app diff --git a/src/pypnusershub/tests/test_utilisateurs.py b/src/pypnusershub/tests/test_utilisateurs.py index 2c71043..da77fff 100644 --- a/src/pypnusershub/tests/test_utilisateurs.py +++ b/src/pypnusershub/tests/test_utilisateurs.py @@ -11,14 +11,21 @@ from sqlalchemy import select +from pypnusershub.auth.auth_manager import auth_manager + + +@pytest.fixture +def provider_instance(): + return auth_manager.get_provider("local_provider") + @pytest.mark.usefixtures("client_class", "temporary_transaction") class TestUtilisateurs: - def test_insert_user(self, organism, group_and_users): + def test_insert_user(self, app, organism, group_and_users, provider_instance): user_schema = UserSchema(exclude=["nom_complet", "max_level_profil"]) group = group_and_users["group1"] - user = { + user_dict = { "id_role": 99999, "identifiant": "test.user", "nom_role": "test", @@ -26,11 +33,13 @@ def test_insert_user(self, organism, group_and_users): "id_organisme": organism.id_organisme, "email": "test@test.fr", "active": True, - "groups": [user_schema.dump(group)], + "groups": [group], } - insert_or_update_role(user) - user["identifiant"] = "update" - insert_or_update_role(user) + user_ = User(**user_dict) + user_ = insert_or_update_role(user_, provider_instance) + user_.identifiant = "update" + db.session.commit() + created_user = db.session.get(User, 99999) user_schema = UserSchema(only=["groups"]) created_user_as_dict = user_schema.dump(created_user) @@ -38,6 +47,13 @@ def test_insert_user(self, organism, group_and_users): assert created_user_as_dict["id_role"] == 99999 assert len(created_user_as_dict["groups"]) == 1 + app.config["AUTHENTICATION"]["DEFAULT_RECONCILIATION_GROUP_ID"] = 2 + user_dict["id_role"] = 99998 + user_dict["email"] = "test@test2.fr" + user_ = User(**user_dict) + user_ = insert_or_update_role(user_, provider_instance) + assert len(user_.groups) == 2 + def test_insert_organisme(self): organism = { "nom_organisme": "test", From 576002ec896c450ef6a86727f9a3c2b9e87f8c28 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 12 Jul 2024 14:37:29 +0200 Subject: [PATCH 07/28] feat(authentication) : replace is_uh by is_external + drop `Authentication.configuration_schema` method + take in account the change of the authentication class location in the configuration --- src/pypnusershub/auth/auth_manager.py | 13 +++--- src/pypnusershub/auth/authentication.py | 40 +++++++++++-------- .../auth/providers/cas_inpn_provider.py | 17 ++++---- .../auth/providers/github_provider.py | 2 +- .../auth/providers/openid_provider.py | 26 ++++++++---- .../auth/providers/usershub_provider.py | 18 ++++++--- src/pypnusershub/routes.py | 2 +- 7 files changed, 74 insertions(+), 44 deletions(-) diff --git a/src/pypnusershub/auth/auth_manager.py b/src/pypnusershub/auth/auth_manager.py index 0445057..6acd03a 100644 --- a/src/pypnusershub/auth/auth_manager.py +++ b/src/pypnusershub/auth/auth_manager.py @@ -82,18 +82,19 @@ def init_app(self, app, prefix: str = "/auth") -> None: app.register_blueprint(routes, url_prefix=prefix) - for path_provider in app.config["AUTHENTICATION"].get("PROVIDERS", []): + for provider_config in app.config["AUTHENTICATION"].get("PROVIDERS", []): + path_provider = provider_config.get("module") import_path, class_name = ( ".".join(path_provider.split(".")[:-1]), path_provider.split(".")[-1], ) module = importlib.import_module(import_path) class_ = getattr(module, class_name) - for config in app.config["AUTHENTICATION"][class_.name]: - with app.app_context(): - instance_provider: Authentication = class_() - instance_provider.configure(configuration=config) - self.add_provider(instance_provider.id_provider, instance_provider) + + with app.app_context(): + instance_provider: Authentication = class_() + instance_provider.configure(configuration=provider_config) + self.add_provider(instance_provider.id_provider, instance_provider) def get_provider(self, instance_name: str) -> Authentication: """ diff --git a/src/pypnusershub/auth/authentication.py b/src/pypnusershub/auth/authentication.py index 3c386b0..d801066 100644 --- a/src/pypnusershub/auth/authentication.py +++ b/src/pypnusershub/auth/authentication.py @@ -1,37 +1,45 @@ import logging from typing import Any, Union -from marshmallow import Schema, fields +from marshmallow import Schema, ValidationError, fields, validates_schema from pypnusershub.db import models log = logging.getLogger(__name__) class ProviderConfigurationSchema(Schema): + module = fields.Str(required=True) id_provider = fields.Str(required=True) group_mapping = fields.Dict(keys=fields.Str(), values=fields.Integer()) logo = fields.String() label = fields.String() + @validates_schema + def check_if_module_exists(self, data, **kwargs): + import importlib + + path_provider = data["module"] + import_path, class_name = ( + ".".join(path_provider.split(".")[:-1]), + path_provider.split(".")[-1], + ) + try: + importlib.import_module(import_path) + except ModuleNotFoundError: + raise ValidationError(f"Module {import_path} not found") + try: + getattr(importlib.import_module(import_path), class_name) + except AttributeError: + raise ValidationError( + f"Class {class_name} not found in module {import_path}" + ) + class Authentication: """ Abstract class for authentication implementations. """ - @property - def name(self) -> str: - """ - Name of the authentication provider. - Use for config key - - Returns - ------- - str - The name of the authentication provider. - """ - raise NotImplementedError() - """ Identifier of the instance of the authentication provider (str). Is override by provider config if provided @@ -71,9 +79,9 @@ def name(self) -> str: logo = "" @property - def is_uh(self) -> bool: + def is_external(self) -> bool: """ - Return whether the authentication is an 'usershub-auth-module' authentication. + Return whether the authentication is performed by the identity provider. Returns ------- diff --git a/src/pypnusershub/auth/providers/cas_inpn_provider.py b/src/pypnusershub/auth/providers/cas_inpn_provider.py index c26c230..70c5545 100644 --- a/src/pypnusershub/auth/providers/cas_inpn_provider.py +++ b/src/pypnusershub/auth/providers/cas_inpn_provider.py @@ -5,7 +5,7 @@ from flask import Response, current_app, redirect, render_template, request from geonature.utils import utilsrequests from geonature.utils.errors import GeonatureApiError -from marshmallow import fields +from marshmallow import EXCLUDE, ValidationError, fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema from pypnusershub.db import db, models from pypnusershub.routes import insert_or_update_organism, insert_or_update_role @@ -21,7 +21,7 @@ class CasAuthentificationError(GeonatureApiError): class AuthenficationCASINPN(Authentication): name = "CAS_INPN_PROVIDER" label = "INPN" - is_uh = False + is_external = False logo = "" @property @@ -138,8 +138,9 @@ def insert_user_and_org(self, info_user, id_provider): user.groups.append(group) return user - @staticmethod - def configuration_schema() -> Optional[Tuple[str, ProviderConfigurationSchema]]: + def configure(self, configuration: Union[dict, Any]): + super().configure(configuration) + class CASINPNConfiguration(ProviderConfigurationSchema): URL_LOGIN = fields.String(load_default="https://inpn.mnhn.fr/auth/login") URL_LOGOUT = fields.String(load_default="https://inpn.mnhn.fr/auth/logout") @@ -158,9 +159,9 @@ class CASINPNConfiguration(ProviderConfigurationSchema): ID_USER_SOCLE_1 = fields.Integer(load_default=7) ID_USER_SOCLE_2 = fields.Integer(load_default=6) - return CASINPNConfiguration - - def configure(self, configuration: Union[dict, Any]): - super().configure(configuration) + try: + configuration = CASINPNConfiguration().load(configuration, unknown=EXCLUDE) + except ValidationError as e: + raise ValidationError(f"Error in CAS INPN configuration {str(e)}") for key in configuration: setattr(self, key, configuration[key]) diff --git a/src/pypnusershub/auth/providers/github_provider.py b/src/pypnusershub/auth/providers/github_provider.py index 214f03f..d719d33 100644 --- a/src/pypnusershub/auth/providers/github_provider.py +++ b/src/pypnusershub/auth/providers/github_provider.py @@ -23,7 +23,7 @@ class GitHubAuthProvider(Authentication): id_provider = "github" label = "GitHub" - is_uh = False + is_external = False login_url = "http://127.0.0.1:8000/auth/login/github" logout_url = "" logo = '' diff --git a/src/pypnusershub/auth/providers/openid_provider.py b/src/pypnusershub/auth/providers/openid_provider.py index caa4b6a..6d89027 100644 --- a/src/pypnusershub/auth/providers/openid_provider.py +++ b/src/pypnusershub/auth/providers/openid_provider.py @@ -1,8 +1,8 @@ -from typing import Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union import requests from flask import Response, current_app, session, url_for -from marshmallow import fields +from marshmallow import EXCLUDE, ValidationError, fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema, oauth from pypnusershub.db import db, models from pypnusershub.routes import insert_or_update_role @@ -19,7 +19,7 @@ class OpenIDProvider(Authentication): name = "OPENID_PROVIDER_CONFIG" logo = '' - is_uh = False + is_ecternal = False """ Name of the fields in the OpenID token that contains the groups info """ @@ -27,7 +27,12 @@ class OpenIDProvider(Authentication): def __init__(self): super().__init__() - for provider in current_app.config["AUTHENTICATION"][self.name]: + for provider in current_app.config["AUTHENTICATION"]["PROVIDERS"]: + if not ( + provider["module"].endswith("OpenIDProvider") + or provider["module"].endswith("OpenIDConnectProvider") + ): + continue oauth.register( name=provider["id_provider"], client_id=provider["CLIENT_ID"], @@ -83,15 +88,22 @@ def revoke(self): ) session.pop("openid_token_resp") - @staticmethod - def configuration_schema() -> Optional[Tuple[str, ProviderConfigurationSchema]]: + def configure(self, configuration: dict | Any) -> None: + + super().configure(configuration) + class OpenIDProviderConfiguration(ProviderConfigurationSchema): ISSUER = fields.String(required=True) CLIENT_ID = fields.String(required=True) CLIENT_SECRET = fields.String(required=True) group_claim_name = fields.String(load_default="groups") - return OpenIDProviderConfiguration + try: + OpenIDProviderConfiguration().load(configuration, unknown=EXCLUDE) + except ValidationError as e: + raise ValidationError( + f"Error while loading OpenID provider configuration: {e}" + ) class OpenIDConnectProvider(OpenIDProvider): diff --git a/src/pypnusershub/auth/providers/usershub_provider.py b/src/pypnusershub/auth/providers/usershub_provider.py index f3d71e2..7965dae 100644 --- a/src/pypnusershub/auth/providers/usershub_provider.py +++ b/src/pypnusershub/auth/providers/usershub_provider.py @@ -1,8 +1,8 @@ -from typing import Optional, Tuple +from typing import Any, Optional, Tuple import requests from flask import request -from marshmallow import fields +from marshmallow import EXCLUDE, ValidationError, fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema from pypnusershub.db.models import User from pypnusershub.routes import insert_or_update_role @@ -35,10 +35,18 @@ def authenticate(self): ) return insert_or_update_role(user, provider_instance=self) - @staticmethod - def configuration_schema() -> Optional[Tuple[str, ProviderConfigurationSchema]]: + def configure(self, configuration: dict | Any) -> None: + class ExternalGNConfiguration(ProviderConfigurationSchema): login_url = fields.String(required=True) logout_url = fields.String(required=True) - return ExternalGNConfiguration + try: + configuration = ExternalGNConfiguration().load( + configuration, unknown=EXCLUDE + ) + except ValidationError as e: + raise ValidationError( + f"Error while loading OpenID provider configuration: {e}" + ) + super().configure(configuration) diff --git a/src/pypnusershub/routes.py b/src/pypnusershub/routes.py index 946e1e7..9c1b2e8 100755 --- a/src/pypnusershub/routes.py +++ b/src/pypnusershub/routes.py @@ -87,7 +87,7 @@ def get_providers(): from itertools import chain property_name = [ - "is_uh", + "is_external", "logo", "label", "login_url", From 9cacb804e057627299a8d1cb1e903520d3921500 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 12 Jul 2024 14:39:30 +0200 Subject: [PATCH 08/28] remove the configuration_schema method --- src/pypnusershub/auth/authentication.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/pypnusershub/auth/authentication.py b/src/pypnusershub/auth/authentication.py index d801066..200d467 100644 --- a/src/pypnusershub/auth/authentication.py +++ b/src/pypnusershub/auth/authentication.py @@ -165,15 +165,3 @@ def configure(self, configuration: Union[dict, Any] = {}) -> None: for field in ["label", "logo", "login_url", "logout_url", "group_mapping"]: if field in configuration: setattr(self, field, configuration[field]) - - @staticmethod - def configuration_schema() -> ProviderConfigurationSchema: - """ - Returns the marshmallow schema used to configure this authentication provider. - - Returns - ------- - ProviderConfigurationSchema - The schema used to configure this authentication provider. - """ - return ProviderConfigurationSchema From 51141594b58afe05679c0444efb35a235d5aef2e Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 12 Jul 2024 14:52:17 +0200 Subject: [PATCH 09/28] feat(authentication): insert_or_update_role take user info as a dict (rollback) --- src/pypnusershub/auth/auth_manager.py | 3 +- src/pypnusershub/auth/authentication.py | 4 +- .../auth/providers/cas_inpn_provider.py | 2 +- .../auth/providers/openid_provider.py | 6 +-- .../auth/providers/usershub_provider.py | 4 +- src/pypnusershub/routes.py | 37 ++++++++++--------- src/pypnusershub/tests/test_utilisateurs.py | 13 +++---- 7 files changed, 33 insertions(+), 36 deletions(-) diff --git a/src/pypnusershub/auth/auth_manager.py b/src/pypnusershub/auth/auth_manager.py index 6acd03a..c6e9c98 100644 --- a/src/pypnusershub/auth/auth_manager.py +++ b/src/pypnusershub/auth/auth_manager.py @@ -5,7 +5,6 @@ from pypnusershub.env import db from .authentication import Authentication -from .providers import DefaultConfiguration class AuthManager: @@ -17,7 +16,7 @@ def __init__(self) -> None: """ Initializes the AuthManager instance. """ - self.provider_authentication_cls = {"local_provider": DefaultConfiguration()} + self.provider_authentication_cls = {} def __contains__(self, item) -> bool: """ diff --git a/src/pypnusershub/auth/authentication.py b/src/pypnusershub/auth/authentication.py index 200d467..2c32af9 100644 --- a/src/pypnusershub/auth/authentication.py +++ b/src/pypnusershub/auth/authentication.py @@ -54,9 +54,9 @@ class Authentication: """ Group mapping between source_group and destination_group. Must be in the following format: - [{"grp_src":"admin","grp_dst":"Grp_admin"},...] + {"grp_src":"grp_dst",...} """ - group_mapping = [] + group_mapping = {} """ External login URL. diff --git a/src/pypnusershub/auth/providers/cas_inpn_provider.py b/src/pypnusershub/auth/providers/cas_inpn_provider.py index 70c5545..45b49a4 100644 --- a/src/pypnusershub/auth/providers/cas_inpn_provider.py +++ b/src/pypnusershub/auth/providers/cas_inpn_provider.py @@ -126,7 +126,7 @@ def insert_user_and_org(self, info_user, id_provider): "email": info_user["email"], "active": True, } - user = insert_or_update_role(models.User(**user_info), provider_instance=self) + user = insert_or_update_role(user_info, provider_instance=self) if not user.groups: if not self.USERS_CAN_SEE_ORGANISM_DATA or organism_id is None: # group socle 1 diff --git a/src/pypnusershub/auth/providers/openid_provider.py b/src/pypnusershub/auth/providers/openid_provider.py index 6d89027..8996399 100644 --- a/src/pypnusershub/auth/providers/openid_provider.py +++ b/src/pypnusershub/auth/providers/openid_provider.py @@ -19,7 +19,7 @@ class OpenIDProvider(Authentication): name = "OPENID_PROVIDER_CONFIG" logo = '' - is_ecternal = False + is_external = False """ Name of the fields in the OpenID token that contains the groups info """ @@ -68,9 +68,7 @@ def authorize(self): if self.group_claim_name in user_info else {} ) - user = insert_or_update_role( - models.User(**new_user), provider_instance=self, **kwargs - ) + user = insert_or_update_role(new_user, provider_instance=self, **kwargs) db.session.commit() return user diff --git a/src/pypnusershub/auth/providers/usershub_provider.py b/src/pypnusershub/auth/providers/usershub_provider.py index 7965dae..9a280fe 100644 --- a/src/pypnusershub/auth/providers/usershub_provider.py +++ b/src/pypnusershub/auth/providers/usershub_provider.py @@ -26,14 +26,14 @@ def authenticate(self): if login_response.status_code != 200: raise Unauthorized(f"Connexion impossible à {self.label} ") user_resp = login_response.json()["user"] - user = User( + user_dict = dict( uuid_role=user_resp.get("uuid_role"), identifiant=user_resp["identifiant"], email=user_resp["email"], nom_role=user_resp["nom_role"], prenom_role=user_resp["prenom_role"], ) - return insert_or_update_role(user, provider_instance=self) + return insert_or_update_role(user_dict, provider_instance=self) def configure(self, configuration: dict | Any) -> None: diff --git a/src/pypnusershub/routes.py b/src/pypnusershub/routes.py index 9c1b2e8..6b28404 100755 --- a/src/pypnusershub/routes.py +++ b/src/pypnusershub/routes.py @@ -108,7 +108,6 @@ def get_providers(): ) ) for id_provider, provider in current_app.auth_manager.provider_authentication_cls.items() - if not provider.id_provider == "local_provider" ] ) @@ -257,10 +256,10 @@ def insert_or_update_organism(organism): def insert_or_update_role( - user: models.User, + user_dict: dict, provider_instance: models.Provider, reconciliate_attr="email", - group_keys: List[int] = [], + source_groups: List[int] = [], ) -> models.User: """ Insert or update a role (also add groups if provided) @@ -273,8 +272,8 @@ def insert_or_update_role( Provider instance used to create/log the user reconciliate_attr: str, default="email" Attribute used to reconciliate existing users - group_keys: List[int], default=[] - List of group keys to compare with existing groups defined in the group_mapping properties of the provider + source_groups: List[str], default=[] + List of group names to compare with existing groups defined in the group_mapping properties of the provider Returns ------- @@ -289,11 +288,11 @@ def insert_or_update_role( KeyError If Group {group_name} was not found in the mapping """ - assert hasattr(user, reconciliate_attr) + assert reconciliate_attr in user_dict user_exists = db.session.execute( sa.select(models.User).where( - getattr(models.User, reconciliate_attr) == getattr(user, reconciliate_attr), + getattr(models.User, reconciliate_attr) == user_dict[reconciliate_attr], ) ).scalar_one_or_none() provider = db.session.execute( @@ -305,12 +304,16 @@ def insert_or_update_role( if not provider in user_exists.providers: user_exists.providers.append(provider) - db.session.commit() + + for attr_key, attr_value in user_dict.items(): + setattr(user_exists, attr_key, attr_value) + db.session.commit() return user_exists else: + user_ = models.User(**user_dict) group_id = "" # No group mapping indicated - if not (provider_instance.group_mapping and group_keys): + if not (provider_instance.group_mapping and source_groups): if "DEFAULT_RECONCILIATION_GROUP_ID" in current_app.config.get( "AUTHENTICATION", {} @@ -321,20 +324,20 @@ def insert_or_update_role( ] group = db.session.get(models.User, group_id) if group: - user.groups.append(group) + user_.groups.append(group) # Group Mapping indicated else: - for key in group_keys: - if not key in provider_instance.group_mapping: + for group_source_name in source_groups: + if not group_source_name in provider_instance.group_mapping: raise KeyError("Group {group_name} was not found in the mapping !") - group_id = provider_instance.group_mapping[key] + group_id = provider_instance.group_mapping[group_source_name] group = db.session.get(models.User, group_id) if group: - user.groups.append(group) + user_.groups.append(group) - user.providers.append(provider) - db.session.add(user) + user_.providers.append(provider) + db.session.add(user_) db.session.commit() - return user + return user_ diff --git a/src/pypnusershub/tests/test_utilisateurs.py b/src/pypnusershub/tests/test_utilisateurs.py index da77fff..52a78c9 100644 --- a/src/pypnusershub/tests/test_utilisateurs.py +++ b/src/pypnusershub/tests/test_utilisateurs.py @@ -35,11 +35,9 @@ def test_insert_user(self, app, organism, group_and_users, provider_instance): "active": True, "groups": [group], } - user_ = User(**user_dict) - user_ = insert_or_update_role(user_, provider_instance) - user_.identifiant = "update" - db.session.commit() - + insert_or_update_role(user_dict, provider_instance) + user_dict["identifiant"] = "update" + insert_or_update_role(user_dict, provider_instance) created_user = db.session.get(User, 99999) user_schema = UserSchema(only=["groups"]) created_user_as_dict = user_schema.dump(created_user) @@ -50,9 +48,8 @@ def test_insert_user(self, app, organism, group_and_users, provider_instance): app.config["AUTHENTICATION"]["DEFAULT_RECONCILIATION_GROUP_ID"] = 2 user_dict["id_role"] = 99998 user_dict["email"] = "test@test2.fr" - user_ = User(**user_dict) - user_ = insert_or_update_role(user_, provider_instance) - assert len(user_.groups) == 2 + user_ = insert_or_update_role(user_dict, provider_instance) + assert len(db.session.get(User, 99998).groups) == 2 def test_insert_organisme(self): organism = { From f8952a825667e5f3adbd9484887a809a732572ef Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 12 Jul 2024 16:12:45 +0200 Subject: [PATCH 10/28] add missing local provider in conftest --- src/pypnusershub/tests/conftest.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pypnusershub/tests/conftest.py b/src/pypnusershub/tests/conftest.py index f6b474c..ff681f6 100644 --- a/src/pypnusershub/tests/conftest.py +++ b/src/pypnusershub/tests/conftest.py @@ -1,3 +1,4 @@ +from pypnusershub.auth.providers.default import DefaultConfiguration import pytest from flask import Flask @@ -16,7 +17,14 @@ def app(): app.testing = True app.test_client_class = JSONClient - app.config["AUTHENTICATION"] = {} + app.config["AUTHENTICATION"] = { + "PROVIDERS": [ + dict( + module="pypnusershub.auth.providers.default.DefaultConfiguration", + id_provider="local_provider", + ) + ] + } app.config.from_envvar("USERSHUB_AUTH_MODULE_SETTINGS") app.testing = True db.init_app(app) From e3a9f3db05ae99f4fc525de03510a3756c093ec8 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 12 Jul 2024 16:44:08 +0200 Subject: [PATCH 11/28] fix: do not add provider if t_providers not in the db --- src/pypnusershub/auth/auth_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pypnusershub/auth/auth_manager.py b/src/pypnusershub/auth/auth_manager.py index c6e9c98..a0d1010 100644 --- a/src/pypnusershub/auth/auth_manager.py +++ b/src/pypnusershub/auth/auth_manager.py @@ -53,7 +53,8 @@ def add_provider( AssertionError If the provider is not an instance of Authentification. """ - + if not db.engine.has_table(Provider.__tablename__, schema="utilisateurs"): + return assert id_provider not in self.provider_authentication_cls query = sa.exists(Provider).where(Provider.name == id_provider).select() if not db.session.scalar(query): From 2c728699ab7abc308a933aae4326eb47ec68af05 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 12 Jul 2024 17:03:24 +0200 Subject: [PATCH 12/28] change use of is_external --- src/pypnusershub/auth/providers/cas_inpn_provider.py | 2 +- src/pypnusershub/auth/providers/github_provider.py | 2 +- src/pypnusershub/auth/providers/openid_provider.py | 2 +- src/pypnusershub/auth/providers/usershub_provider.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pypnusershub/auth/providers/cas_inpn_provider.py b/src/pypnusershub/auth/providers/cas_inpn_provider.py index 45b49a4..3e0ad28 100644 --- a/src/pypnusershub/auth/providers/cas_inpn_provider.py +++ b/src/pypnusershub/auth/providers/cas_inpn_provider.py @@ -21,7 +21,7 @@ class CasAuthentificationError(GeonatureApiError): class AuthenficationCASINPN(Authentication): name = "CAS_INPN_PROVIDER" label = "INPN" - is_external = False + is_external = True logo = "" @property diff --git a/src/pypnusershub/auth/providers/github_provider.py b/src/pypnusershub/auth/providers/github_provider.py index d719d33..6acaaf8 100644 --- a/src/pypnusershub/auth/providers/github_provider.py +++ b/src/pypnusershub/auth/providers/github_provider.py @@ -23,7 +23,7 @@ class GitHubAuthProvider(Authentication): id_provider = "github" label = "GitHub" - is_external = False + is_external = True login_url = "http://127.0.0.1:8000/auth/login/github" logout_url = "" logo = '' diff --git a/src/pypnusershub/auth/providers/openid_provider.py b/src/pypnusershub/auth/providers/openid_provider.py index 8996399..946e348 100644 --- a/src/pypnusershub/auth/providers/openid_provider.py +++ b/src/pypnusershub/auth/providers/openid_provider.py @@ -19,7 +19,7 @@ class OpenIDProvider(Authentication): name = "OPENID_PROVIDER_CONFIG" logo = '' - is_external = False + is_external = True """ Name of the fields in the OpenID token that contains the groups info """ diff --git a/src/pypnusershub/auth/providers/usershub_provider.py b/src/pypnusershub/auth/providers/usershub_provider.py index 9a280fe..8064953 100644 --- a/src/pypnusershub/auth/providers/usershub_provider.py +++ b/src/pypnusershub/auth/providers/usershub_provider.py @@ -16,6 +16,7 @@ class ExternalUsersHubAuthProvider(Authentication): name = "EXTERNAL_USERSHUB_PROVIDER_CONFIG" logo = '' + is_external = False def authenticate(self): params = request.json From a52071c2036a1cedd96cc9ad696a011325bd4b0e Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Mon, 15 Jul 2024 09:40:01 +0200 Subject: [PATCH 13/28] remove hard coded config key in auth_manager init_app method --- src/pypnusershub/auth/auth_manager.py | 20 ++++++++++++++++++-- src/pypnusershub/tests/conftest.py | 4 +++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/pypnusershub/auth/auth_manager.py b/src/pypnusershub/auth/auth_manager.py index a0d1010..953afec 100644 --- a/src/pypnusershub/auth/auth_manager.py +++ b/src/pypnusershub/auth/auth_manager.py @@ -7,6 +7,20 @@ from .authentication import Authentication +from typing import TypedDict + +ProviderType = TypedDict( + "Provider", + { + "id_provider": str, + "module": str, + "label": str, + "group_mapping": dict, + "logo": str, + }, +) + + class AuthManager: """ Manages authentication providers. @@ -66,7 +80,9 @@ def add_provider( raise AssertionError("Provider must be an instance of Authentication") self.provider_authentication_cls[id_provider] = provider_authentification - def init_app(self, app, prefix: str = "/auth") -> None: + def init_app( + self, app, prefix: str = "/auth", providers_declaration: list[ProviderType] = [] + ) -> None: """ Initializes the Flask application with the AuthManager. In addtion, it registers the authentification module blueprint. @@ -82,7 +98,7 @@ def init_app(self, app, prefix: str = "/auth") -> None: app.register_blueprint(routes, url_prefix=prefix) - for provider_config in app.config["AUTHENTICATION"].get("PROVIDERS", []): + for provider_config in providers_declaration: path_provider = provider_config.get("module") import_path, class_name = ( ".".join(path_provider.split(".")[:-1]), diff --git a/src/pypnusershub/tests/conftest.py b/src/pypnusershub/tests/conftest.py index ff681f6..2482800 100644 --- a/src/pypnusershub/tests/conftest.py +++ b/src/pypnusershub/tests/conftest.py @@ -29,7 +29,9 @@ def app(): app.testing = True db.init_app(app) ma.init_app(app) - auth_manager.init_app(app) + auth_manager.init_app( + app, providers_declaration=app.config["AUTHENTICATION"]["PROVIDERS"] + ) login_manager.init_app(app) with app.app_context(): From 120e3b2d814877596c44652911abfd42a905e89a Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Mon, 15 Jul 2024 10:24:16 +0200 Subject: [PATCH 14/28] fix uuid integrity test in userhub provider --- src/pypnusershub/auth/providers/usershub_provider.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pypnusershub/auth/providers/usershub_provider.py b/src/pypnusershub/auth/providers/usershub_provider.py index 8064953..51b88af 100644 --- a/src/pypnusershub/auth/providers/usershub_provider.py +++ b/src/pypnusershub/auth/providers/usershub_provider.py @@ -28,12 +28,13 @@ def authenticate(self): raise Unauthorized(f"Connexion impossible à {self.label} ") user_resp = login_response.json()["user"] user_dict = dict( - uuid_role=user_resp.get("uuid_role"), identifiant=user_resp["identifiant"], email=user_resp["email"], nom_role=user_resp["nom_role"], prenom_role=user_resp["prenom_role"], ) + if "uuid_role" in user_dict: + user_dict["uuid_role"] = user_resp.get("uuid_role") return insert_or_update_role(user_dict, provider_instance=self) def configure(self, configuration: dict | Any) -> None: From 19d2ca861e09aecedfdb1d0e31c2ccceef6697b3 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Mon, 15 Jul 2024 13:46:36 +0200 Subject: [PATCH 15/28] update Readme.md --- README.md | 245 +++++++++++++++++++++++++++--------------------------- 1 file changed, 124 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 4d8b119..906db72 100644 --- a/README.md +++ b/README.md @@ -19,142 +19,83 @@ Il est possible de surcoucher les vues de redirection ainsi que les callbacks re ## Routes -- login : +- `login/` : - parametres : login, password, id_application - return : token ## Fonction de décoration -- check_auth +- `@check_auth` - parametres : level = niveau de droit - utilise le token passé en cookie de la requête ## Exemple d'usage -Pour disposer des routes de login/logout dans votre application Flask, ajoutez dans votre fichier de lancement de l'application (`server.py` par exemple) : +Pour disposer des routes de connexions/deconnexions avec le protocole de connexion par défaut, le code minimal d'une application Flask est le suivant: -``` - from pypnusershub.routes import routes - app.register_blueprint(routes, url_prefix='/auth') -``` +```python +from flask import Flask +from pypnusershub.auth import auth_manager -Pour protéger une route : +app = Flask(__name__) # Instantiate a Flask application +app.config["URL_APPLICATION"] = "/" # Home page of your application +providers_config = # Declare identity providers used to log into your app + [ + # Default identity provider (comes with UH-AM) + { + "module" : "pypnusershub.auth.providers.default.DefaultConfiguration", + "id_provider":"local_provider" + }, + # you can add other identity providers that works with OpenID protocol (and many others !) + ] +auth_manager.init_app(app,providers_config) +if __name__ == "__main__": + app.run(host="0.0.0.0",port=5200) ``` - #Import de la librairie - from pypnusershub.routes import routes as fnauth - #Ajout d'un test d'authentification avec niveau de droit +Pour protéger une route, utiliser le décorateur `check_auth(niveau_profil)`: + +```python + # Import the decorator from + from pypnusershub.decorators import check_auth + @adresses.route('/', methods=['POST', 'PUT']) - @fnauth.check_auth(4) + @check_auth(4) # Decorate the Flask route def insertUpdate_bibtaxons(id_taxon=None): - ... + pass ``` -## Utilisation de l'API - -### Routes définies dans UsersHub - -* create_tmp_user : - * in : {données sur l'utilisateur} - * return : {token} - * Création d'un utilisateur temporaire en base -* valid_temp_user : - * in : {token, application_id} - * return : {role} - * Création utilisateur en base dans la table t_role et ajout d'un profil avec code 1 pour une l’application donnée -* create_cor_role_token: - * in : {email} - * return : {role} - * Génère un token pour utilisateur ayant l’email indiqué et stoque le token dans cor_role_token -* change_password - * in: {token, password, password_confirmation} - * return : {role} - * Mise à jour du mot de passe de l’utilisateur et suppression du token en base -* change_application_right - * in : {id_application, id_profil, id_role} - * return : {id_role, id_profil, id_application, role} - * Modifie le profil de l’utilisateur pour l’application -* update_user - * in : {id_role, données utilisateur} - * return : {role} - * Mise à jour d'un rôle - -### Méthodes définies dans le module - - * connect_admin : décorateur pour la connexion d’un utilisateur type admin a une appli ici usershub. Paramètres à renseigner dans config.py - * post_usershub : - * route générique pour appeler les route usershub en tant qu'administrateur de l'appli en cours - * lance l’action spécifié - * si une post request est définie pour l’action exécute la fonction +Pour utiliser les routes de UsersHub, ajouter les paramètres suivants dans la configuration de l'application : -### Configuration - -Paramètres à rajouter dans le fichier de configuration (`config.py`) +- `URL_USERSHUB` : Url de votre UsersHub +- `ADMIN_APPLICATION_LOGIN` , `ADMIN_APPLICATION_PASSWORD`, `ADMIN_APPLICATION_MAIL` : identifiant de l'administrateur de votre UsersHub -Les paramètre concernant la gestion du cookie sont gérés par flask-admin : https://flask-login.readthedocs.io/en/latest/#cookie-settings - -`REDIRECT_ON_FORBIDDEN` : paramètre de redirection utilisé par le décorateur `check_auth` lorsque les droits d'accès à une ressource/page sont insuffisants (par défaut lève une erreur 403) - -``` -URL_USERSHUB="http://usershub-url.ext" +```python +app.config["URL_USERSHUB"]="http://usershub-url.ext" # Administrateur de mon application -ADMIN_APPLICATION_LOGIN="admin-monapplication" -ADMIN_APPLICATION_PASSWORD="monpassword" -ADMIN_APPLICATION_MAIL="admin-monapplication@mail.ext" +app.config["ADMIN_APPLICATION_LOGIN"]="admin-monapplication" +app.config["ADMIN_APPLICATION_PASSWORD"]="monpassword" +app.config["ADMIN_APPLICATION_MAIL"]="admin-monapplication@mail.ext" ``` -### Appel des routes - -Pour disposer des routes dans votre application Flask, ajoutez dans votre fichier de lancement de l'application (`server.py` par exemple) : - -``` -from pypnusershub import routes_register -app.register_blueprint(routes_register.bp, url_prefix='/pypn/register') -``` - -### Configuration des actions post request - -Rajouter le paramètre `after_USERSHUB_request` à la configuration. Ce paramètre est un tableau qui définit, pour chaque action, un ensemble d'opérations à réaliser ensuite. Comme par exemple envoyer un email. - -``` -function_dict = { - 'create_cor_role_token': create_cor_role_token, - 'create_temp_user': create_temp_user, - 'valid_temp_user': valid_temp_user, - 'change_application_right': change_application_right -} -``` - -Chaque fonction prend un paramètre en argument qui correspond aux données retournées par la route de UsersHub. - ## Installation -Cloner le repository ou télécharger une archive, puis dans le dossier : +### Pré-requis -``` -python setup.py install -``` +- Python 3.9 ou ultérieur +- Paquets systèmes suivant: `sudo apt install python3-dev build-essential postgresql-server-dev` -Le driver PostgreSQL Python, "psycopg2", peut avoir besoin d'être compilé. Si -à l'installation vous obtenez un message d'erreur décrivant un fichier de -header manquant (xxxx.h), comme par exemple : +### Installer `UsersHub-authentification-module` -``` -fatal error: Python.h: Aucun fichier ou dossier de ce type -``` - -Alors il faudra installer au préalable les headers de votre version de Python, -votre version de PostgreSQL et un compilateur. - -Par exemple, sur Ubuntu avec Python 3.5 et PostgreSQL 9.5 : +Cloner le repository ou télécharger une archive, puis dans le dossier : ``` -sudo apt install python3.5-dev build-essential postgresql-server-dev-9.5 +pip install . ``` -Il faut ensuite configurer la base de données en étant super-utilisateur. +### Configuration de la base de données La manière la plus courante pour se connecter à la base de données en ayant les droits super-utilisateur est de se logger avec l'utilisateur 'postgres'. Par exemple sous Ubuntu : @@ -162,12 +103,15 @@ La manière la plus courante pour se connecter à la base de données en ayant l sudo su postgres ``` +**Création de la base de données** Assurez-vous d'avoir au moins créé une base de données. Par exemple sous Ubuntu : -``` +```sh createdb ma_db +psql -d ma_db -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' ``` +**Création d'un utilisateur** Il faut ensuite créer un utilisateur. Par exemple : ``` @@ -176,11 +120,17 @@ createuser -P parcnational Puis donner les droits à cet utilisateur sur la base de données : +```sh +psql ``` -$ psql -postgres=# GRANT ALL PRIVILEGES ON DATABASE ma_db TO parcnational; + +Puis, entrez la requête suivante: + +```sql +GRANT ALL PRIVILEGES ON DATABASE ma_db TO parcnational; ``` +**Accédez à votre base de données** SQLAlchemy vous permettra de vous connecter à la base de données avec une URL de type : @@ -188,36 +138,89 @@ de type : postgresql://nom_utilisateur:mot_de_passe@host:port/db_name ``` -Par exemple : +**Création des tables et schémas nécessaires** + +UsersHub-authentification-module s'appuit sur un schéma PostgreSQL nommée `utilisateurs`. Pour créer ce dernier et l'ensemble des tables nécessaires, on utilise `alembic`. Alembic est une librairie python de versionnage de base de données. Chaque modification sur la base de données est décrite par un révision (e.g. `/src/pypnusershub/migrations/versions/fa35dfe5ff27_create_utilisateurs_schema.py`). Cette dernière indique quelles sont les actions sur la base de données à effectuer pour passer à la révision suivante (fonction `upgrade()`) mais aussi pour revenir à la précédente (fonction `downgrade()`). + +Dans un premier temps, indiquer la nouvelle url de connexion à votre BDD dans la variable `sqlalchemy.url` dans le fichier `alembic.ini`. +```ini +sqlalchemy.url = postgresql://parcnational:@localhost:5432/db_name ``` -postgresql://parcnational:secret@127.0.0.1:5432/ma_db + +Une fois modifié, lancer la commande suivante pour remplir la base de données: + +```sh +alembic upgrade utilisateurs@head ``` -Il vous faudra créer un schema nommé `utilisateurs` qui contient toutes les tables nécessaires. +### (Optionnel) Interface de gestion utilisateurs -Utilisez le SQL maintenu dans le dépôt de UsersHub : https://github.com/PnX-SI/UsersHub/blob/master/data/usershub.sql +Si vous souhaitez une interface permettant de modifier les données utilisateurs décritent dans `UsersHub-authentification-module`, il est conseillé d'utiliser [UsersHub](https://github.com/PnX-SI/UsersHub). -Pour l'éxécuter, il faut avoir ajouter l'extension UUID à votre base de données. Pour le faire en ligne de commande en tant que super-utilisateur de PotsgreSQL : ``sudo -n -u postgres -s psql -d $db_name -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";'``. +## Utilisation de l'API -**Attention**, les commandes qui suivent sont obsolètes, car le script SQL local a été supprimé du dépôt pour utiliser celui de UsersHub. +### Routes définies par UsersHub-authentification module -Ce module contient le SQL pour le faire dans le fichier `db/schema.sql`. Néanmoins une commande vous permet de le faire automatiquement : +Les routes suivantes sont implémentés dans `UsersHub-authentification-module` : -``` -python -m pypnusershub init_schema url_de_la_db -``` +| Route URI | Action | Paramètres | Retourne | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------------- | +| `/providers` | Retourne l'ensemble des fournisseurs d'identités activés | NA | | +| `/get_current_user` | Retourne les informations de l'utilisateur connecté | NA | {user,expires,token} | +| `/login/` | Connecte un utilisateur avec le provider | Optionnel({user,password}) | {user,expires,token} ou redirect | +| `/public_login` | Connecte l'utilisateur permettant l'accès public à votre application | NA | {user,expires,token} | +| `/logout` | Déconnecte l'utilisateur courant | NA | redirect | +| `/authorize` | Connecte un utilisateur à l'aide des infos retournées par le fournisseurs d'identités (Si redirection vers un portail de connexion par /login) | {data} | redirect | -Exemple : +### Routes définies dans UsersHub -``` -python -m pypnusershub init_schema postgresql://parcnational:secret@127.0.0.1:5432/ma_db +Les routes utilisées dans le `UsersHub-authentification-module` proviennent du module `UsersHub`. Les routes sont les suivantes : + +| Route URI | Action | Paramètres | Retourne | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------ | +| `/create_tmp_user` | Création d'un utilisateur temporaire en base | {données sur l'utilisateur} | {token} | +| `/valid_temp_user` | Création utilisateur en base dans la table t_role et ajout d'un profil avec code 1 pour une l’application donnée | {token, application_id} | {role} | +| `/create_cor_role_token` | Génère un token pour utilisateur ayant l’email indiqué et stoque le token dans cor_role_token | {email} | {role} | +| `/change_password` | Mise à jour du mot de passe de l’utilisateur et suppression du token en base | {token, password, password_confirmation} | {role} | +| `/change_application_right` | Modifie le profil de l’utilisateur pour l’application | {id_application, id_profil, id_role} | {id_role, id_profil, id_application, role} | +| `/update_user` | Mise à jour d'un rôle | {id_role, données utilisateur} | {role} | + +### Méthodes définies dans le module + +- `connect_admin()` : décorateur pour la connexion d’un utilisateur type admin a une appli ici usershub. Paramètres à renseigner dans config.py +- `post_usershub()` : + - route générique pour appeler les route usershub en tant qu'administrateur de l'appli en cours + +### Configuration + +Paramètres à rajouter dans le fichier de configuration (`config.py`) + +Les paramètre concernant la gestion du cookie sont gérés par flask-admin : https://flask-login.readthedocs.io/en/latest/#cookie-settings + +`REDIRECT_ON_FORBIDDEN` : paramètre de redirection utilisé par le décorateur `check_auth` lorsque les droits d'accès à une ressource/page sont insuffisants (par défaut lève une erreur 403) + +### Changement du prefix d'accès aux routes de UsersHub-authentification-modules + +Par défaut, les routes sont acesibles depuis le prefix `/auth/`. Si vous voulez changez cela, il suffit de modifier le paramètre `prefix` de l'appel de la méthode `AuthManager.init_app()`: + +```python +auth_manager.init_app(app, prefix="/authentification", providers_declaration=providers_config) ``` -`python -m pypnusershub` permet aussi de supprimer le schema (`delete_schema`), remettre à zéro (`reset_schema`) et charger des données de test (`load_fixtures`). Pour plus d'informations : +### Configuration des actions post request + +Rajouter le paramètre `after_USERSHUB_request` à la configuration. Ce paramètre est un tableau qui définit, pour chaque action, un ensemble d'opérations à réaliser ensuite. Comme par exemple envoyer un email. ``` -python -m pypnusershub --help +function_dict = { + 'create_cor_role_token': create_cor_role_token, + 'create_temp_user': create_temp_user, + 'valid_temp_user': valid_temp_user, + 'change_application_right': change_application_right +} ``` -Please note that you can only load the fixtures once, as they have UNIQUE constraints. +Chaque fonction prend un paramètre en argument qui correspond aux données retournées par la route de UsersHub. + +## Connexion à l'aide de fournisseurs d'identités extérieurs From 7b4078d9a6822d2c3bb7fc857b7ed724b81523ab Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Mon, 15 Jul 2024 10:38:10 +0200 Subject: [PATCH 16/28] fix GN call --- .../auth/providers/cas_inpn_provider.py | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/pypnusershub/auth/providers/cas_inpn_provider.py b/src/pypnusershub/auth/providers/cas_inpn_provider.py index 3e0ad28..ff8bd5b 100644 --- a/src/pypnusershub/auth/providers/cas_inpn_provider.py +++ b/src/pypnusershub/auth/providers/cas_inpn_provider.py @@ -1,23 +1,20 @@ import logging from typing import Any, Optional, Tuple, Union +import requests import xmltodict from flask import Response, current_app, redirect, render_template, request -from geonature.utils import utilsrequests -from geonature.utils.errors import GeonatureApiError from marshmallow import EXCLUDE, ValidationError, fields +from marshmallow import fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema from pypnusershub.db import db, models from pypnusershub.routes import insert_or_update_organism, insert_or_update_role from sqlalchemy import select +from werkzeug.exceptions import InternalServerError log = logging.getLogger() -class CasAuthentificationError(GeonatureApiError): - pass - - class AuthenficationCASINPN(Authentication): name = "CAS_INPN_PROVIDER" label = "INPN" @@ -48,7 +45,7 @@ def authorize(self): ) url_validate = f"{self.URL_VALIDATION}?ticket={ticket}&service={base_url}" - response = utilsrequests.get(url_validate) + response = requests.get(url_validate) xml_dict = xmltodict.parse(response.content) if "cas:authenticationSuccess" in xml_dict["cas:serviceResponse"]: @@ -66,7 +63,7 @@ def authorize(self): ) ws_user_url = f"{self.URL_INFO}/{user}/?verify=false" - response = utilsrequests.get( + response = requests.get( ws_user_url, ( self.WS_ID, @@ -75,9 +72,7 @@ def authorize(self): ) if response.status_code != 200: - raise CasAuthentificationError( - "Error with the inpn authentification service", status_code=500 - ) + raise InternalServerError("Error with the inpn authentification service") info_user = response.json() user = self.insert_user_and_org(info_user, self.id_provider) @@ -110,8 +105,8 @@ def insert_user_and_org(self, info_user, id_provider): assert user_id is not None and user_login is not None except AssertionError: log.error("'CAS ERROR: no ID or LOGIN provided'") - raise CasAuthentificationError( - "CAS ERROR: no ID or LOGIN provided", status_code=500 + raise InternalServerError( + "CAS ERROR: no ID or LOGIN provided", ) # Reconciliation avec base GeoNature if organism_id: From 282b4d1c8ea838bf50e788c3cae24c0a73157805 Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Mon, 15 Jul 2024 10:41:09 +0200 Subject: [PATCH 17/28] update test_settings.py --- .gitignore | 2 ++ src/pypnusershub/test_settings.py | 38 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/.gitignore b/.gitignore index fdce011..4856489 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # editor .vscode/ +settings_test.py + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/src/pypnusershub/test_settings.py b/src/pypnusershub/test_settings.py index 3fe16c3..385cb4f 100644 --- a/src/pypnusershub/test_settings.py +++ b/src/pypnusershub/test_settings.py @@ -3,3 +3,41 @@ PASS_METHOD = "hash" SECRET_KEY = "151VD61V6DF1V6F" COOKIE_EXPIRATION = 3600 + +AUTHENTICATION = { + "PROVIDERS": [ + { + "module": "pypnusershub.auth.providers.default.DefaultConfiguration", + "id_provider": "local_provider", + }, + { + "module": "pypnusershub.auth.providers.cas_inpn_provider.AuthenficationCASINPN", + "id_provider": "cas_inpn", + "WS_ID": "bidule", + "WS_PASSWORD": "bidule", + }, + { + "module": "pypnusershub.auth.providers.openid_provider.OpenIDProvider", + "id_provider": "keycloak", + "label": "bidule", + "ISSUER": "bidule", + "CLIENT_ID": "bidule", + "CLIENT_SECRET": "bidule", + }, + { + "module": "pypnusershub.auth.providers.openid_provider.OpenIDConnectProvider", + "id_provider": "bis", + "label": "bidule", + "ISSUER": "bidule", + "CLIENT_ID": "bidule", + "CLIENT_SECRET": "bidule", + }, + { + "module": "pypnusershub.auth.providers.usershub_provider.ExternalUsersHubAuthProvider", + "id_provider": "bis", + "label": "bidule", + "login_url": "bidule", + "logout_url": "bidule", + }, + ] +} From 855ea09bde501a92550f4ce8c05ae618ac3be7a3 Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Mon, 15 Jul 2024 17:20:23 +0200 Subject: [PATCH 18/28] test and refactor --- src/pypnusershub/auth/auth_manager.py | 13 +---- .../auth/providers/cas_inpn_provider.py | 22 +++---- .../auth/providers/github_provider.py | 58 ------------------- .../auth/providers/openid_provider.py | 30 ++++------ src/pypnusershub/routes.py | 20 +++++-- src/pypnusershub/test_settings.py | 3 +- src/pypnusershub/tests/conftest.py | 8 --- src/pypnusershub/tests/test_authentication.py | 41 +++++++++++++ 8 files changed, 83 insertions(+), 112 deletions(-) delete mode 100644 src/pypnusershub/auth/providers/github_provider.py create mode 100644 src/pypnusershub/tests/test_authentication.py diff --git a/src/pypnusershub/auth/auth_manager.py b/src/pypnusershub/auth/auth_manager.py index 953afec..9887bdf 100644 --- a/src/pypnusershub/auth/auth_manager.py +++ b/src/pypnusershub/auth/auth_manager.py @@ -67,15 +67,10 @@ def add_provider( AssertionError If the provider is not an instance of Authentification. """ - if not db.engine.has_table(Provider.__tablename__, schema="utilisateurs"): - return - assert id_provider not in self.provider_authentication_cls - query = sa.exists(Provider).where(Provider.name == id_provider).select() - if not db.session.scalar(query): - db.session.add( - Provider(name=id_provider, url=provider_authentification.login_url) + if id_provider in self.provider_authentication_cls: + raise Exception( + f"Id provider {id_provider} already exist, please check your authentication config" ) - db.session.commit() if not isinstance(provider_authentification, Authentication): raise AssertionError("Provider must be an instance of Authentication") self.provider_authentication_cls[id_provider] = provider_authentification @@ -95,9 +90,7 @@ def init_app( from pypnusershub.routes import routes app.auth_manager = self - app.register_blueprint(routes, url_prefix=prefix) - for provider_config in providers_declaration: path_provider = provider_config.get("module") import_path, class_name = ( diff --git a/src/pypnusershub/auth/providers/cas_inpn_provider.py b/src/pypnusershub/auth/providers/cas_inpn_provider.py index ff8bd5b..b69aaa8 100644 --- a/src/pypnusershub/auth/providers/cas_inpn_provider.py +++ b/src/pypnusershub/auth/providers/cas_inpn_provider.py @@ -3,7 +3,7 @@ import requests import xmltodict -from flask import Response, current_app, redirect, render_template, request +from flask import Response, current_app, redirect, render_template, request, url_for from marshmallow import EXCLUDE, ValidationError, fields from marshmallow import fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema @@ -21,23 +21,25 @@ class AuthenficationCASINPN(Authentication): is_external = True logo = "" - @property - def login_url(self): - gn_api = f"{current_app.config['API_ENDPOINT']}/auth/authorize/cas_inpn" - return f"{self.URL_LOGIN}?service={gn_api}" - @property def logout_url(self): return f"{self.URL_LOGOUT}?service={current_app.config['URL_APPLICATION']}" def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: - return redirect(self.login_url) + redirect_uri = url_for( + "auth.authorize", provider=self.id_provider, _external=True + ) + + return redirect(f"{self.URL_LOGIN}?service={redirect_uri}") def authorize(self): user = None + redirect_uri = url_for( + "auth.authorize", provider=self.id_provider, _external=True + ) if not "ticket" in request.args: - return redirect(self.login_url) + return redirect(f"{self.URL_LOGIN}?service={redirect_uri}") ticket = request.args["ticket"] base_url = ( @@ -75,7 +77,7 @@ def authorize(self): raise InternalServerError("Error with the inpn authentification service") info_user = response.json() - user = self.insert_user_and_org(info_user, self.id_provider) + user = self.insert_user_and_org(info_user) db.session.commit() organism_id = info_user["codeOrganisme"] if not organism_id: @@ -92,7 +94,7 @@ def authorize(self): def revoke(self) -> Any: return redirect(self.logout_url) - def insert_user_and_org(self, info_user, id_provider): + def insert_user_and_org(self, info_user): organism_id = info_user["codeOrganisme"] if info_user["libelleLongOrganisme"] is not None: organism_name = info_user["libelleLongOrganisme"] diff --git a/src/pypnusershub/auth/providers/github_provider.py b/src/pypnusershub/auth/providers/github_provider.py deleted file mode 100644 index 6acaaf8..0000000 --- a/src/pypnusershub/auth/providers/github_provider.py +++ /dev/null @@ -1,58 +0,0 @@ -from typing import Union - -from authlib.integrations.flask_client import OAuth -from flask import Response, current_app, url_for -from pypnusershub.auth import Authentication -from pypnusershub.db import db, models -from pypnusershub.routes import insert_or_update_role - -oauth = OAuth(current_app) -oauth.register( - name="github", - client_id="", - client_secret="", - access_token_url="https://github.com/login/oauth/access_token", - access_token_params=None, - authorize_url="https://github.com/login/oauth/authorize", - authorize_params=None, - api_base_url="https://api.github.com/", - client_kwargs={"scope": "user:email"}, -) - - -class GitHubAuthProvider(Authentication): - id_provider = "github" - label = "GitHub" - is_external = True - login_url = "http://127.0.0.1:8000/auth/login/github" - logout_url = "" - logo = '' - name = "GITHUB_PROVIDER_CONFIG" - - def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: - redirect_uri = url_for( - "auth.authorize", provider=self.id_provider, _external=True - ) - return oauth.github.authorize_redirect(redirect_uri) - - def authorize(self): - token = oauth.github.authorize_access_token() - resp = oauth.github.get("user", token=token) - resp.raise_for_status() - user_info = resp.json() - prenom = user_info["name"].split(" ")[0] - nom = " ".join(user_info["name"].split(" ")[1:]) - new_user = { - "identifiant": f"{user_info['login'].lower()}", - "email": user_info["email"], - "prenom_role": prenom, - "nom_role": nom, - "active": True, - } - user_info = insert_or_update_role(new_user, self) - user = db.session.get(models.User, user_info["id_role"]) - if not user.groups: - group = db.session.get(models.User, 2) # ADMIN for test - user.groups.append(group) - db.session.commit() - return user diff --git a/src/pypnusershub/auth/providers/openid_provider.py b/src/pypnusershub/auth/providers/openid_provider.py index 946e348..58f570d 100644 --- a/src/pypnusershub/auth/providers/openid_provider.py +++ b/src/pypnusershub/auth/providers/openid_provider.py @@ -25,25 +25,6 @@ class OpenIDProvider(Authentication): """ group_claim_name = "groups" - def __init__(self): - super().__init__() - for provider in current_app.config["AUTHENTICATION"]["PROVIDERS"]: - if not ( - provider["module"].endswith("OpenIDProvider") - or provider["module"].endswith("OpenIDConnectProvider") - ): - continue - oauth.register( - name=provider["id_provider"], - client_id=provider["CLIENT_ID"], - client_secret=provider["CLIENT_SECRET"], - server_metadata_url=f'{provider["ISSUER"]}/.well-known/openid-configuration', - client_kwargs={ - "scope": "openid email profile", - "issuer": provider["ISSUER"], - }, - ) - def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: redirect_uri = url_for( "auth.authorize", provider=self.id_provider, _external=True @@ -90,6 +71,17 @@ def configure(self, configuration: dict | Any) -> None: super().configure(configuration) + oauth.register( + name=configuration["id_provider"], + client_id=configuration["CLIENT_ID"], + client_secret=configuration["CLIENT_SECRET"], + server_metadata_url=f'{configuration["ISSUER"]}/.well-known/openid-configuration', + client_kwargs={ + "scope": "openid email profile", + "issuer": configuration["ISSUER"], + }, + ) + class OpenIDProviderConfiguration(ProviderConfigurationSchema): ISSUER = fields.String(required=True) CLIENT_ID = fields.String(required=True) diff --git a/src/pypnusershub/routes.py b/src/pypnusershub/routes.py index 6b28404..d63b770 100755 --- a/src/pypnusershub/routes.py +++ b/src/pypnusershub/routes.py @@ -26,6 +26,7 @@ from pypnusershub.db import db, models from pypnusershub.db.tools import encode_token from pypnusershub.schemas import OrganismeSchema, UserSchema +from pypnusershub.auth.authentication import Authentication from werkzeug.exceptions import Forbidden, Unauthorized log = logging.getLogger(__name__) @@ -185,7 +186,6 @@ def login(provider): "token": token.decode(), } ) - return redirect(current_app.config["URL_APPLICATION"]) @routes.route("/public_login", methods=["POST"]) @@ -257,7 +257,7 @@ def insert_or_update_organism(organism): def insert_or_update_role( user_dict: dict, - provider_instance: models.Provider, + provider_instance: Authentication, reconciliate_attr="email", source_groups: List[int] = [], ) -> models.User: @@ -268,8 +268,8 @@ def insert_or_update_role( ---------- user: models.User User to insert or update - provider_instance: models.Provider - Provider instance used to create/log the user + provider_instance: pypnusershub.auth.Authentication + the autentication instance use for connexion reconciliate_attr: str, default="email" Attribute used to reconciliate existing users source_groups: List[str], default=[] @@ -288,6 +288,7 @@ def insert_or_update_role( KeyError If Group {group_name} was not found in the mapping """ + assert reconciliate_attr in user_dict user_exists = db.session.execute( @@ -295,13 +296,20 @@ def insert_or_update_role( getattr(models.User, reconciliate_attr) == user_dict[reconciliate_attr], ) ).scalar_one_or_none() + provider = db.session.execute( sa.select(models.Provider).where( models.Provider.name == provider_instance.id_provider ) - ).scalar_one() - if user_exists: + ).scalar_one_or_none() + if not provider: + provider = models.Provider( + name=provider_instance.id_provider, url=provider_instance.login_url + ) + db.session.add() + db.session.commit() + if user_exists: if not provider in user_exists.providers: user_exists.providers.append(provider) diff --git a/src/pypnusershub/test_settings.py b/src/pypnusershub/test_settings.py index 385cb4f..b1067b9 100644 --- a/src/pypnusershub/test_settings.py +++ b/src/pypnusershub/test_settings.py @@ -3,6 +3,7 @@ PASS_METHOD = "hash" SECRET_KEY = "151VD61V6DF1V6F" COOKIE_EXPIRATION = 3600 +URL_APPLICATION = "/" AUTHENTICATION = { "PROVIDERS": [ @@ -34,7 +35,7 @@ }, { "module": "pypnusershub.auth.providers.usershub_provider.ExternalUsersHubAuthProvider", - "id_provider": "bis", + "id_provider": "ter", "label": "bidule", "login_url": "bidule", "logout_url": "bidule", diff --git a/src/pypnusershub/tests/conftest.py b/src/pypnusershub/tests/conftest.py index 2482800..d09d889 100644 --- a/src/pypnusershub/tests/conftest.py +++ b/src/pypnusershub/tests/conftest.py @@ -17,14 +17,6 @@ def app(): app.testing = True app.test_client_class = JSONClient - app.config["AUTHENTICATION"] = { - "PROVIDERS": [ - dict( - module="pypnusershub.auth.providers.default.DefaultConfiguration", - id_provider="local_provider", - ) - ] - } app.config.from_envvar("USERSHUB_AUTH_MODULE_SETTINGS") app.testing = True db.init_app(app) diff --git a/src/pypnusershub/tests/test_authentication.py b/src/pypnusershub/tests/test_authentication.py new file mode 100644 index 0000000..1f58491 --- /dev/null +++ b/src/pypnusershub/tests/test_authentication.py @@ -0,0 +1,41 @@ +import pytest + +from flask import Flask + +from pypnusershub.auth.auth_manager import AuthManager +from pypnusershub.auth.providers.openid_provider import OpenIDProvider + + +class TestAuthManager: + def test_init(self): + auth_manager = AuthManager() + assert hasattr(auth_manager, "provider_authentication_cls") + assert type(auth_manager.provider_authentication_cls) is dict + + def test_add_provider(self, app): + auth_manager = AuthManager() + with app.app_context(): + auth_manager.add_provider("test", OpenIDProvider()) + assert "test" in auth_manager + + with pytest.raises(Exception): + auth_manager.add_provider("test", OpenIDProvider()) + + def test_init_app(self): + app = Flask(__name__) + + providers = [ + { + "module": "pypnusershub.auth.providers.openid_provider.OpenIDConnectProvider", + "id_provider": "bis", + "label": "bidule", + "ISSUER": "bidule", + "CLIENT_ID": "bidule", + "CLIENT_SECRET": "bidule", + } + ] + auth_manager = AuthManager() + auth_manager.init_app(app, "/authent", providers) + + assert isinstance(app.auth_manager, AuthManager) + assert "bis" in auth_manager From c8433a69386a4fc5f21b56fec3fdc0e42babbaaa Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 16 Jul 2024 10:16:19 +0200 Subject: [PATCH 19/28] finish README --- README.md | 430 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 368 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 906db72..f8eaae2 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,10 @@ -# UsersHub-authentification-module +# UsersHub-authentification-module [![pytest](https://github.com/PnX-SI/UsersHub-authentification-module/actions/workflows/pytest.yml/badge.svg)](https://github.com/PnX-SI/UsersHub-authentification-module/actions/workflows/pytest.yml)[![codecov](https://codecov.io/gh/PnX-SI/UsersHub-authentification-module/branch/master/graph/badge.svg?token=O57GQEH494)](https://codecov.io/gh/PnX-SI/UsersHub-authentification-module) -[![pytest](https://github.com/PnX-SI/UsersHub-authentification-module/actions/workflows/pytest.yml/badge.svg)](https://github.com/PnX-SI/UsersHub-authentification-module/actions/workflows/pytest.yml) -[![codecov](https://codecov.io/gh/PnX-SI/UsersHub-authentification-module/branch/master/graph/badge.svg?token=O57GQEH494)](https://codecov.io/gh/PnX-SI/UsersHub-authentification-module) +UsersHub-authentification-module est une extension [Flask](https://flask.palletsprojects.com/) intégrant un processus d'authentification. Sa spécificité repose sur son modèle de données répondant aux besoins de l'application [UsersHub](https://github.com/PnX-SI/UsersHub/) et [GeoNature](https://github.com/PnX-SI/GeoNature/). -Module Flask (Python) permettant de gérer l'authentification suivant le modèle de [UsersHub](https://github.com/PnX-SI/UsersHub/). +## Exemple d'utilisation -Prévu pour être utilisé comme un submodule git. - -Nécessite le schéma `utilisateurs` de UsersHub dans la BDD de l'application l'utilisant. Pour cela installez UsersHub dans la même BDD ou uniquement son schéma : https://github.com/PnX-SI/UsersHub/blob/master/data/usershub.sql - -Par défaut le sous-module utilise le mot de passe "pass_plus" (méthode de hashage bcrypt) pour s'authentifier. Si vous souhaitez utiliser le champ "pass" (en md5), il faut passer le paramètre `PASS_METHOD = 'md5'` à la configuration Flask de l'application parent qui utilise le sous-module. - -## Flask Login - -Le sous-module utilise la librairie Flask-Login pour la gestion de l'authentification (https://flask-login.readthedocs.io/en/latest/). -Cette librairie offre une méthode standard de gestion de l'authentification via un cookie de session. Le sous-module ajoute la possibilité de s'authentifier via un JWT si notre application utilise une API. -Il est possible de surcoucher les vues de redirection ainsi que les callbacks renvoyés suite à une erreur 401. Voir https://flask-login.readthedocs.io/en/latest/#customizing-the-login-process. - -## Routes - -- `login/` : - - parametres : login, password, id_application - - return : token - -## Fonction de décoration - -- `@check_auth` - - parametres : level = niveau de droit - - utilise le token passé en cookie de la requête - -## Exemple d'usage - -Pour disposer des routes de connexions/deconnexions avec le protocole de connexion par défaut, le code minimal d'une application Flask est le suivant: +Pour disposer des routes de connexion/déconnexion avec le protocole de connexion par défaut, le code minimal d'une application Flask est le suivant: ```python from flask import Flask @@ -48,10 +21,10 @@ providers_config = # Declare identity providers used to log into your app }, # you can add other identity providers that works with OpenID protocol (and many others !) ] -auth_manager.init_app(app,providers_config) +auth_manager.init_app(app,providers_declaration=providers_config) if __name__ == "__main__": - app.run(host="0.0.0.0",port=5200) + app.run(host="0.0.0.0",port=5000) ``` Pour protéger une route, utiliser le décorateur `check_auth(niveau_profil)`: @@ -84,12 +57,13 @@ app.config["ADMIN_APPLICATION_MAIL"]="admin-monapplication@mail.ext" ### Pré-requis -- Python 3.9 ou ultérieur -- Paquets systèmes suivant: `sudo apt install python3-dev build-essential postgresql-server-dev` +- Python 3.9 ou (ou version supérieure) +- PostgreSQL 11.x (ou version supérieure) +- Paquets systèmes suivants: `sudo apt install python3-dev build-essential postgresql-server-dev` ### Installer `UsersHub-authentification-module` -Cloner le repository ou télécharger une archive, puis dans le dossier : +Cloner le dépôt (ou télécharger une archive), dirigez vous dans le dossier et lancez la commande : ``` pip install . @@ -97,14 +71,14 @@ pip install . ### Configuration de la base de données -La manière la plus courante pour se connecter à la base de données en ayant les droits super-utilisateur est de se logger avec l'utilisateur 'postgres'. Par exemple sous Ubuntu : +La manière la plus courante pour se connecter à la base de données en ayant les droits super-utilisateur est de connecter avec l'utilisateur 'postgres'. ``` sudo su postgres ``` **Création de la base de données** -Assurez-vous d'avoir au moins créé une base de données. Par exemple sous Ubuntu : +Assurez-vous d'avoir au moins créé une base de données et d'avoir l'extension `uuid-ossp` activée. ```sh createdb ma_db @@ -112,27 +86,29 @@ psql -d ma_db -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' ``` **Création d'un utilisateur** -Il faut ensuite créer un utilisateur. Par exemple : +Pour créer un utilisateur, utiliser la commande `createuser`. Par exemple : ``` createuser -P parcnational ``` -Puis donner les droits à cet utilisateur sur la base de données : +Donner les droits à cet utilisateur sur la base de données. Lancez la ligne de commande PostgreSQL: ```sh psql ``` -Puis, entrez la requête suivante: +Entrez la requête suivante: ```sql GRANT ALL PRIVILEGES ON DATABASE ma_db TO parcnational; ``` +Quittez la ligne de commande en utilisant le raccourci `Ctrl+D` ou en entrant la commande `\q`. + **Accédez à votre base de données** -SQLAlchemy vous permettra de vous connecter à la base de données avec une URL -de type : +[SQLAlchemy](https://www.sqlalchemy.org/) se connectera à la base de données à l'aide d'une URL +du type : ``` postgresql://nom_utilisateur:mot_de_passe@host:port/db_name @@ -140,9 +116,12 @@ postgresql://nom_utilisateur:mot_de_passe@host:port/db_name **Création des tables et schémas nécessaires** -UsersHub-authentification-module s'appuit sur un schéma PostgreSQL nommée `utilisateurs`. Pour créer ce dernier et l'ensemble des tables nécessaires, on utilise `alembic`. Alembic est une librairie python de versionnage de base de données. Chaque modification sur la base de données est décrite par un révision (e.g. `/src/pypnusershub/migrations/versions/fa35dfe5ff27_create_utilisateurs_schema.py`). Cette dernière indique quelles sont les actions sur la base de données à effectuer pour passer à la révision suivante (fonction `upgrade()`) mais aussi pour revenir à la précédente (fonction `downgrade()`). +`UsersHub-authentification-module` s'appuit sur un schéma PostgreSQL nommé `utilisateurs`. Pour créer ce dernier et l'ensemble des tables nécessaires, on utilise `alembic`. Alembic est une librairie Python de versionnage de base de données. Chaque modification sur la base de données est décrite par une révision (e.g. `/src/pypnusershub/migrations/versions/fa35dfe5ff27_create_utilisateurs_schema.py`). Cette dernière indique quelles sont les actions sur la base de données à effectuer pour: -Dans un premier temps, indiquer la nouvelle url de connexion à votre BDD dans la variable `sqlalchemy.url` dans le fichier `alembic.ini`. +- passer à la révision suivante -> méthode `upgrade()` +- mais aussi pour revenir à la précédente -> méthode `downgrade()`. + +Avant de lancer la création du schéma, indiquer la nouvelle url de connexion à votre BDD dans la variable `sqlalchemy.url` dans le fichier `alembic.ini`. ```ini sqlalchemy.url = postgresql://parcnational:@localhost:5432/db_name @@ -154,9 +133,261 @@ Une fois modifié, lancer la commande suivante pour remplir la base de données: alembic upgrade utilisateurs@head ``` -### (Optionnel) Interface de gestion utilisateurs +**N.B.** Si vous utilisez `alembic` dans votre projet, il est possible d'accéder aux révisions de `UsersHub-authenfication-module` à l'aide de la variable [`alembic.script_location`](https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file). + +### Remplissage des tables pour son application + +Pour utiliser votre application avec les `UsersHub-authentification-module`, il est nécessaire : + +- D'avoir (au moins) un utilisateur dans la table `t_roles` +- Enregister votre application dans `t_applications` +- Avoir au moins un profil de permissions dans `t_profils` +- Associer les profils crées à votre application dans `cor_profil_for_app` +- Enfin, pour associer votre utilisateur au couple --profil, app--, remplisser la table `cor_role_app_profil` (Nécessaire pour utiliser le décorateur `pypnusershub.decorators.check_auth`) + +Ci-dessous, un exemple minimal d'une migration _alembic_ pour utiliser `UsersHub-authentification-module` pour votre application + +```python +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "id_revision" +down_revision = "id_revision_precedente" +branch_labels = "branch_votre_application" +depends_on = None + + +def upgrade(): + op.execute( + """ + INSERT INTO utilisateurs.t_roles (id_role, nom_role, desc_role) + VALUES (1, 'Grp_admin', 'Role qui fait des trucs'); + + + INSERT INTO utilisateurs.t_applications (id_application,code_application, nom_application,desc_application, id_parent) + VALUES (250,'APP','votre_application','Application qui fait des trucs', NULL); + + INSERT INTO utilisateurs.t_profils (id_profil, code_profil, nom_profil, desc_profil) + VALUES (15, 15, 'votre_profil', 'Profil qui fait des trucs'); + + INSERT INTO utilisateurs.cor_profil_for_app (id_profil, id_application) + VALUES (1, 250); + + INSERT INTO utilisateurs.cor_role_app_profil (id_role, id_application, id_profil,is_default_group_for_app) + VALUES (1, 250, 15, true); + + """ + ) + + +def downgrade(): + op.execute( + """ + DELETE FROM utilisateurs.cor_role_app_profil cor + USING + utilisateurs.t_roles r, + utilisateurs.t_applications a, + utilisateurs.t_profils p + WHERE + cor.id_role = r.id_role + AND cor.id_application = a.id_application + AND cor.id_profil = p.id_profil + AND r.nom_role = 'Grp_admin' + AND a.code_application = 'APP' + AND p.code_profil = 15 + """ + ) + + op.execute( + """ + DELETE FROM utilisateurs.cor_profil_app + WHERE id_profil = 15 + AND id_application = 250; + """ + ) + + op.execute( + """ + DELETE FROM utilisateurs.t_profils + WHERE code_profil = 15; + """ + ) + + op.execute( + """ + DELETE FROM utilisateurs.t_applications + WHERE code_application = 'APP'; + """ + ) + +``` + +> [!TIP] +> Si vous souhaitez une interface permettant de modifier les données utilisateurs décritent dans `UsersHub-authentification-module`, il est conseillé d'utiliser [UsersHub](https://github.com/PnX-SI/UsersHub). + +## Schéma de la base de données + +> [!IMPORTANT] +> +> #### Concepts essentiels +> +> **Profil**: Concept associé à un nom et à un niveau de permission. +> **Provider**: Concept associé à celui de fournisseurs d'identités. Service (Google, INPN, ORCID,...) qui permet de s’identifier et qui utilise un protocole de connexion (e.g. _OAuth_) +> **Listes**: Groupe d'utilisateurs + +### Structure de la base + +Le diagramme ci-dessous décrit les différentes tables du schéma `utilisateurs`, leurs attributs et leur relations. + +```mermaid +classDiagram +class t_roles { + -id_role: int + -uuid_role + -identifiant: str + -nom_role: str + -prenom_role: str + -pass: str + -pass_plus: str + -groupe: bool + -id_organisme: int + -remarques: str + -champs_addi: dict + -date_insert: datetime + -date_update: datetime + -active: bool +} +class temp_users{ + -id_temp_user: int + -token_role: str + -identifiant: str + -nom_role: str + -prenom_role: str + -password: str + -pass_md5: str + -groupe: bool + -id_organisme: int + -remarques: str + -champs_addi: dict + -date_insert: datetime + -date_update: datetime +} + +class bib_organismes { + -id_organisme: int + -uuid_organisme: UUID + -nom_organisme: str + -adresse_organisme: str + -cp_organisme: str + -ville_organisme: str + -tel_organisme: str + -fax_organisme: str + -email_organisme: str + -url_organisme: str + -url_logo: str + -id_parent: int + -additional_data: dict +} +class t_profils { + -id_profil: int + -code_profil: str + -nom_profil: str + -desc_profil: str +} +class t_listes{ + -id_liste:int + -code_list:str(20) + -nom_liste:str(50) + -desc_liste:str +} +class t_applications { + -id_application: int + -code_application: str(20) + -nom_application: str(50) + -desc_application: str + -id_parent: int +} + +class t_providers { + -id_provider:int + -name:str + -url:str +} + +class cor_role_liste{ + -id_role:int + -id_liste:int +} + +class cor_roles{ + -id_role_groupe:int + -id_role_utilisateur:int +} +class cor_profil_for_app{ + -id_profil + -id_application +} +class cor_role_profil_app { + -id_role: int + -id_profil: int + -id_application: int +} + +class cor_role_token{ + -id_role:int + -token:str +} + +class cor_role_provider{ + -id_provider:int + -id_role:int +} + + +t_roles --* t_roles + +t_roles --* bib_organismes +temp_users --* bib_organismes +bib_organismes *-- bib_organismes + +cor_role_profil_app *-- t_applications +cor_role_profil_app *-- t_profils +cor_role_profil_app *-- t_roles + +cor_roles *-- t_roles + +cor_role_token *-- t_roles + +cor_role_liste *-- t_roles +cor_role_liste *-- t_listes -Si vous souhaitez une interface permettant de modifier les données utilisateurs décritent dans `UsersHub-authentification-module`, il est conseillé d'utiliser [UsersHub](https://github.com/PnX-SI/UsersHub). +cor_role_provider *-- t_roles +cor_role_provider *-- t_providers + +cor_role_token *-- t_roles + +cor_profil_for_app *-- t_profils +cor_profil_for_app *-- t_applications + +``` + +**Fonctions des tables** + +| Nom table | Description | +| ------------------- | -------------------------------------------------------------------------------------------- | +| bib_organismes | Contient les organismes | +| t_roles | Contient les utilisateurs | +| t_profils | Permet de définir les profils de permissions | +| t_providers | Contient les fournisseurs d'identités dans l'applications | +| t_applications | Liste les applications qui utilisent UsersHub-authentification-module | +| temp_users | Permet de créer des utilisateurs temporaires (en attente de validation par l'administrateur) | +| cor_profil_for_app | Permet d'attribuer et limiter les profils disponibles pour chacune des applications | +| cor_role_app_profil | Cette table permet d'associer des utilisateurs à des profils par application | +| cor_role_list | Cette table permet d'associer des utilisateurs à des listes d'utilisateurs | +| cor_role_provider | Cette table permet d'associer des utilisateurs à des fournisseurs d'identités | +| cor_role_token | Permet d'associer des utilisateurs à des tokens | +| cor_roles | Permet d'associer des utilisateurs entre eux (groupes et utilisateurs) | ## Utilisation de l'API @@ -188,13 +419,13 @@ Les routes utilisées dans le `UsersHub-authentification-module` proviennent du ### Méthodes définies dans le module -- `connect_admin()` : décorateur pour la connexion d’un utilisateur type admin a une appli ici usershub. Paramètres à renseigner dans config.py -- `post_usershub()` : - - route générique pour appeler les route usershub en tant qu'administrateur de l'appli en cours +- `connect_admin()` : décorateur pour la connexion d’un utilisateur type admin a une appli ici usershub. Paramètres à renseigner dans la configuration. +- `post_usershub()` : route générique pour appeler les route usershub en tant qu'administrateur de l'appli en cours. +- `insert_or_update_role` : méthode pour insérer ou mettre à jour un utilisateur. -### Configuration +### Configuration de Flask-login -Paramètres à rajouter dans le fichier de configuration (`config.py`) +Paramètres à rajouter dans la configuration ( attribut `config` de l'objet `Flask`) de votre application. Les paramètre concernant la gestion du cookie sont gérés par flask-admin : https://flask-login.readthedocs.io/en/latest/#cookie-settings @@ -208,19 +439,94 @@ Par défaut, les routes sont acesibles depuis le prefix `/auth/`. Si vous voulez auth_manager.init_app(app, prefix="/authentification", providers_declaration=providers_config) ``` -### Configuration des actions post request +## Connexion à l'aide de fournisseurs d'identités extérieurs + +Depuis la 3.0, il est possible d'ajouter la possibilité de se connecter à des fournisseurs d'identités externes utilisant d'autres protocoles de connexions que `UsersHub-authentification-module` : OpenID, OpenIDConnect, CAS INPN, etc ... + +### Utiliser les protocoles de connexions existant + +Lors de l'appel de `AuthManager.init_app`, il faut indiquer les configurations des différents fournisseurs d'identités sur lesquels on souhaite se connecter dans le paramètre `providers_declaration`. -Rajouter le paramètre `after_USERSHUB_request` à la configuration. Ce paramètre est un tableau qui définit, pour chaque action, un ensemble d'opérations à réaliser ensuite. Comme par exemple envoyer un email. +Pour chaque configuration, on doit déclarer : + +- le chemin vers la classe Python `module` déclarant le protocole de connexion +- son identifiant unique `id_provider` +- et les variables de configurations propre au protocole de connexion + +```python +from flask import Flask +from pypnusershub.auth import auth_manager +app = Flask(__name__) # Instantiate a Flask application +app.config["URL_APPLICATION"] = "/" # Home page of your application +providers_config = # Declare identity providers used to log into your app + [ + # Default identity provider (comes with UH-AM) + { + "module" : "pypnusershub.auth.providers.default.DefaultConfiguration", + "id_provider":"local_provider" + }, + # Other identity provider + { + "module": "pypnusershub.auth.providers.openid_provider.OpenIDProvider", + "id_provider":"open_id_1", + "ISSUER":"http://", + "CLIENT_ID":"secret", + "CLIENT_SECRET":"secret" + } + + # you can add other identity providers that works with OpenID protocol (and many others !) + ] +auth_manager.init_app(app,providers_declaration=providers_config) + +if __name__ == "__main__": + app.run(host="0.0.0.0",port=5200) ``` -function_dict = { - 'create_cor_role_token': create_cor_role_token, - 'create_temp_user': create_temp_user, - 'valid_temp_user': valid_temp_user, - 'change_application_right': change_application_right -} + +Pour lancer la connexion sur un provider en particulier, il suffit d'appeler la route `/login/`. + +### Ajouter son propre protocole de connexion + +L'ensemble des protocoles de connexion dans `UsersHub-authentification-module` n'est pas exhaustif et peut dans certains cas ne pas convenir à votre contexte. Pas de panique ! Il est possible de déclarer son propre protocole à l'aide de la classe `Authentication` comme dans l'exemple ci-dessous : + +```python +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 # go through an external connection portal + + def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: + pass # return a User or a Flask redirection to the login portal of the provider + + def authorize(self): + pass # must return a User + + def revoke(self): + pass # if specific action have to be made when logout + + def configure(self, configuration: Union[dict, Any]): + pass # Indique la configuration d'un fournisseur d'identités ``` -Chaque fonction prend un paramètre en argument qui correspond aux données retournées par la route de UsersHub. +Un **protocole de connexion** est défini par 5 méthodes et plusieurs attributs. -## Connexion à l'aide de fournisseurs d'identités extérieurs +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. From eec8dddfbc5325a6fa3a28baa3672099f0209971 Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Tue, 16 Jul 2024 12:35:53 +0200 Subject: [PATCH 20/28] test and refacto --- src/pypnusershub/auth/authentication.py | 93 ++++++++++++- src/pypnusershub/auth/providers/__init__.py | 2 +- .../auth/providers/cas_inpn_provider.py | 4 +- src/pypnusershub/auth/providers/default.py | 14 +- .../auth/providers/openid_provider.py | 16 ++- .../auth/providers/usershub_provider.py | 3 +- src/pypnusershub/routes.py | 126 +----------------- src/pypnusershub/schemas.py | 16 +++ src/pypnusershub/test_settings.py | 4 +- src/pypnusershub/tests/conftest.py | 1 - src/pypnusershub/tests/fixtures.py | 14 ++ src/pypnusershub/tests/test_authentication.py | 19 +-- src/pypnusershub/tests/test_utilisateurs.py | 53 +++++++- 13 files changed, 211 insertions(+), 154 deletions(-) diff --git a/src/pypnusershub/auth/authentication.py b/src/pypnusershub/auth/authentication.py index 2c32af9..5c7c0f0 100644 --- a/src/pypnusershub/auth/authentication.py +++ b/src/pypnusershub/auth/authentication.py @@ -1,8 +1,13 @@ import logging -from typing import Any, Union +from typing import Any, Union, List +import sqlalchemy as sa + +from flask import current_app from marshmallow import Schema, ValidationError, fields, validates_schema from pypnusershub.db import models +from pypnusershub.db import db, models + log = logging.getLogger(__name__) @@ -165,3 +170,89 @@ def configure(self, configuration: Union[dict, Any] = {}) -> None: for field in ["label", "logo", "login_url", "logout_url", "group_mapping"]: if field in configuration: setattr(self, field, configuration[field]) + + def insert_or_update_role( + self, + user_dict: dict, + reconciliate_attr="email", + source_groups: List[int] = [], + ) -> models.User: + """ + Insert or update a role (also add groups if provided) + + Parameters + ---------- + user: models.User + User to insert or update + reconciliate_attr: str, default="email" + Attribute used to reconciliate existing users + source_groups: List[str], default=[] + List of group names to compare with existing groups defined in the group_mapping properties of the provider + + Returns + ------- + models.User + The updated or created user + + Raises + ------ + Exception + If no group mapping indicated for the provider and DEFAULT_RECONCILIATION_GROUP_ID + is not set + KeyError + If Group {group_name} was not found in the mapping + """ + + assert reconciliate_attr in user_dict + + user_exists = db.session.execute( + sa.select(models.User).where( + getattr(models.User, reconciliate_attr) == user_dict[reconciliate_attr], + ) + ).scalar_one_or_none() + + provider = db.session.execute( + sa.select(models.Provider).where(models.Provider.name == self.id_provider) + ).scalar_one_or_none() + if not provider: + provider = models.Provider(name=self.id_provider, url=self.login_url) + db.session.add() + db.session.commit() + + if user_exists: + if not provider in user_exists.providers: + user_exists.providers.append(provider) + + for attr_key, attr_value in user_dict.items(): + setattr(user_exists, attr_key, attr_value) + db.session.commit() + return user_exists + else: + user_ = models.User(**user_dict) + group_id = "" + # No group mapping indicated + if not (self.group_mapping and source_groups): + + if "DEFAULT_RECONCILIATION_GROUP_ID" in current_app.config.get( + "AUTHENTICATION", {} + ): + + group_id = current_app.config["AUTHENTICATION"][ + "DEFAULT_RECONCILIATION_GROUP_ID" + ] + group = db.session.get(models.User, group_id) + if group: + user_.groups.append(group) + # Group Mapping indicated + else: + for group_source_name in source_groups: + group_id = self.group_mapping.get(group_source_name, None) + if group_id: + group = db.session.get(models.User, group_id) + if group and not group in user_.groups: + user_.groups.append(group) + + user_.providers.append(provider) + db.session.add(user_) + db.session.commit() + return user_ diff --git a/src/pypnusershub/auth/providers/__init__.py b/src/pypnusershub/auth/providers/__init__.py index 84cc9da..05ba067 100644 --- a/src/pypnusershub/auth/providers/__init__.py +++ b/src/pypnusershub/auth/providers/__init__.py @@ -1 +1 @@ -from .default import DefaultConfiguration +from .default import LocalProvider diff --git a/src/pypnusershub/auth/providers/cas_inpn_provider.py b/src/pypnusershub/auth/providers/cas_inpn_provider.py index b69aaa8..405e5a7 100644 --- a/src/pypnusershub/auth/providers/cas_inpn_provider.py +++ b/src/pypnusershub/auth/providers/cas_inpn_provider.py @@ -8,7 +8,7 @@ from marshmallow import fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema from pypnusershub.db import db, models -from pypnusershub.routes import insert_or_update_organism, insert_or_update_role +from pypnusershub.routes import insert_or_update_organism from sqlalchemy import select from werkzeug.exceptions import InternalServerError @@ -123,7 +123,7 @@ def insert_user_and_org(self, info_user): "email": info_user["email"], "active": True, } - user = insert_or_update_role(user_info, provider_instance=self) + user = self.insert_or_update_role(user_info) if not user.groups: if not self.USERS_CAN_SEE_ORGANISM_DATA or organism_id is None: # group socle 1 diff --git a/src/pypnusershub/auth/providers/default.py b/src/pypnusershub/auth/providers/default.py index 9d2a2f7..ac8357d 100644 --- a/src/pypnusershub/auth/providers/default.py +++ b/src/pypnusershub/auth/providers/default.py @@ -11,7 +11,7 @@ from ..authentication import Authentication -class DefaultConfiguration(Authentication): +class LocalProvider(Authentication): login_url = "" logout_url = "" id_provider = "local_provider" @@ -35,6 +35,7 @@ def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: .where(models.User.identifiant == username) .where(models.User.filter_by_app()) ).scalar_one() + except exc.NoResultFound as e: raise Unauthorized( 'No user found with the username "{login}" for the application with id "{id_app}"' @@ -42,6 +43,17 @@ def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: if not user.check_password(user_data["password"]): raise Unauthorized("Invalid password") + + provider = db.session.execute( + sa.select(models.Provider).where(models.Provider.name == self.id_provider) + ).scalar_one_or_none() + if not provider: + provider = models.Provider(name=self.id_provider, url=self.login_url) + db.session.add() + db.session.commit() + if not provider in user.providers: + user.providers.append(provider) + db.session.commit() return user def revoke(self) -> Any: diff --git a/src/pypnusershub/auth/providers/openid_provider.py b/src/pypnusershub/auth/providers/openid_provider.py index 58f570d..ee39821 100644 --- a/src/pypnusershub/auth/providers/openid_provider.py +++ b/src/pypnusershub/auth/providers/openid_provider.py @@ -5,7 +5,6 @@ from marshmallow import EXCLUDE, ValidationError, fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema, oauth from pypnusershub.db import db, models -from pypnusershub.routes import insert_or_update_role from werkzeug.exceptions import Unauthorized @@ -44,12 +43,12 @@ def authorize(self): "nom_role": user_info["family_name"], "active": True, } - kwargs = ( - dict(group_keys=user_info[self.group_claim_name]) + source_groups = ( + user_info[self.group_claim_name] if self.group_claim_name in user_info - else {} + else [] ) - user = insert_or_update_role(new_user, provider_instance=self, **kwargs) + user = self.insert_or_update_role(new_user, source_groups=source_groups) db.session.commit() return user @@ -67,7 +66,7 @@ def revoke(self): ) session.pop("openid_token_resp") - def configure(self, configuration: dict | Any) -> None: + def configure(self, configuration: Union[dict, Any]) -> None: super().configure(configuration) @@ -89,11 +88,14 @@ class OpenIDProviderConfiguration(ProviderConfigurationSchema): group_claim_name = fields.String(load_default="groups") try: - OpenIDProviderConfiguration().load(configuration, unknown=EXCLUDE) + configuration = OpenIDProviderConfiguration().load( + configuration, unknown=EXCLUDE + ) except ValidationError as e: raise ValidationError( f"Error while loading OpenID provider configuration: {e}" ) + self.group_claim_name = configuration["group_claim_name"] class OpenIDConnectProvider(OpenIDProvider): diff --git a/src/pypnusershub/auth/providers/usershub_provider.py b/src/pypnusershub/auth/providers/usershub_provider.py index 51b88af..80842b6 100644 --- a/src/pypnusershub/auth/providers/usershub_provider.py +++ b/src/pypnusershub/auth/providers/usershub_provider.py @@ -5,7 +5,6 @@ from marshmallow import EXCLUDE, ValidationError, fields from pypnusershub.auth import Authentication, ProviderConfigurationSchema from pypnusershub.db.models import User -from pypnusershub.routes import insert_or_update_role from werkzeug.exceptions import Unauthorized @@ -35,7 +34,7 @@ def authenticate(self): ) if "uuid_role" in user_dict: user_dict["uuid_role"] = user_resp.get("uuid_role") - return insert_or_update_role(user_dict, provider_instance=self) + return self.insert_or_update_role(user_dict) def configure(self, configuration: dict | Any) -> None: diff --git a/src/pypnusershub/routes.py b/src/pypnusershub/routes.py index d63b770..7d80b37 100755 --- a/src/pypnusershub/routes.py +++ b/src/pypnusershub/routes.py @@ -128,19 +128,11 @@ def get_user_data(): dict A dictionary containing the user data, token, and expiration time. """ - user_dict = UserSchema( + user_dict_with_token = UserSchema( exclude=["remarques"], only=["+max_level_profil", "+providers"] - ).dump(g.current_user) + ).dump_with_token(g.current_user) - token_exp = datetime.datetime.now(datetime.timezone.utc) - token_exp += datetime.timedelta(seconds=current_app.config["COOKIE_EXPIRATION"]) - data = { - "user": user_dict, - "token": encode_token(g.current_user.as_dict()).decode(), - "expires": token_exp.isoformat(), - } - - return jsonify(data) + return jsonify(user_dict_with_token) @routes.route("/login/", methods=["POST", "GET"]) @@ -172,20 +164,10 @@ def login(provider): return auth_result if isinstance(auth_result, models.User): login_user(auth_result, remember=True) - user_dict = UserSchema( + user_dict_with_token = UserSchema( exclude=["remarques"], only=["+max_level_profil", "+providers"] - ).dump(auth_result) - token = encode_token(user_dict) - token_exp = datetime.datetime.now(datetime.timezone.utc) - token_exp += datetime.timedelta(seconds=current_app.config["COOKIE_EXPIRATION"]) - - return jsonify( - { - "user": user_dict, - "expires": token_exp.isoformat(), - "token": token.decode(), - } - ) + ).dump_with_token(auth_result) + return jsonify(user_dict_with_token) @routes.route("/public_login", methods=["POST"]) @@ -253,99 +235,3 @@ def insert_or_update_organism(organism): organism = organism_schema.load(organism) db.session.add(organism) return organism_schema.dump(organism) - - -def insert_or_update_role( - user_dict: dict, - provider_instance: Authentication, - reconciliate_attr="email", - source_groups: List[int] = [], -) -> models.User: - """ - Insert or update a role (also add groups if provided) - - Parameters - ---------- - user: models.User - User to insert or update - provider_instance: pypnusershub.auth.Authentication - the autentication instance use for connexion - reconciliate_attr: str, default="email" - Attribute used to reconciliate existing users - source_groups: List[str], default=[] - List of group names to compare with existing groups defined in the group_mapping properties of the provider - - Returns - ------- - models.User - The updated or created user - - Raises - ------ - Exception - If no group mapping indicated for the provider and DEFAULT_RECONCILIATION_GROUP_ID - is not set - KeyError - If Group {group_name} was not found in the mapping - """ - - assert reconciliate_attr in user_dict - - user_exists = db.session.execute( - sa.select(models.User).where( - getattr(models.User, reconciliate_attr) == user_dict[reconciliate_attr], - ) - ).scalar_one_or_none() - - provider = db.session.execute( - sa.select(models.Provider).where( - models.Provider.name == provider_instance.id_provider - ) - ).scalar_one_or_none() - if not provider: - provider = models.Provider( - name=provider_instance.id_provider, url=provider_instance.login_url - ) - db.session.add() - db.session.commit() - - if user_exists: - if not provider in user_exists.providers: - user_exists.providers.append(provider) - - for attr_key, attr_value in user_dict.items(): - setattr(user_exists, attr_key, attr_value) - db.session.commit() - return user_exists - else: - user_ = models.User(**user_dict) - group_id = "" - # No group mapping indicated - if not (provider_instance.group_mapping and source_groups): - - if "DEFAULT_RECONCILIATION_GROUP_ID" in current_app.config.get( - "AUTHENTICATION", {} - ): - - group_id = current_app.config["AUTHENTICATION"][ - "DEFAULT_RECONCILIATION_GROUP_ID" - ] - group = db.session.get(models.User, group_id) - if group: - user_.groups.append(group) - # Group Mapping indicated - else: - for group_source_name in source_groups: - if not group_source_name in provider_instance.group_mapping: - raise KeyError("Group {group_name} was not found in the mapping !") - group_id = provider_instance.group_mapping[group_source_name] - - group = db.session.get(models.User, group_id) - - if group: - user_.groups.append(group) - - user_.providers.append(provider) - db.session.add(user_) - db.session.commit() - return user_ diff --git a/src/pypnusershub/schemas.py b/src/pypnusershub/schemas.py index f6bfee0..45f48d0 100644 --- a/src/pypnusershub/schemas.py +++ b/src/pypnusershub/schemas.py @@ -1,9 +1,15 @@ +import datetime + +from typing import Any + +from flask import current_app from marshmallow import pre_load, fields from utils_flask_sqla.schema import SmartRelationshipsMixin from pypnusershub.env import ma, db from pypnusershub.db.models import User, Organisme, Provider +from pypnusershub.db.tools import encode_token class OrganismeSchema(SmartRelationshipsMixin, ma.SQLAlchemyAutoSchema): @@ -40,3 +46,13 @@ def make_observer(self, data, **kwargs): if isinstance(data, int): return dict({"id_role": data}) return data + + def dump_with_token(self, obj): + user_dict = self.dump(obj) + token_exp = datetime.datetime.now(datetime.timezone.utc) + token_exp += datetime.timedelta(seconds=current_app.config["COOKIE_EXPIRATION"]) + return { + "user": user_dict, + "token": encode_token(user_dict).decode(), + "expires": token_exp.isoformat(), + } diff --git a/src/pypnusershub/test_settings.py b/src/pypnusershub/test_settings.py index b1067b9..b1e3b23 100644 --- a/src/pypnusershub/test_settings.py +++ b/src/pypnusershub/test_settings.py @@ -8,7 +8,7 @@ AUTHENTICATION = { "PROVIDERS": [ { - "module": "pypnusershub.auth.providers.default.DefaultConfiguration", + "module": "pypnusershub.auth.providers.default.LocalProvider", "id_provider": "local_provider", }, { @@ -32,6 +32,8 @@ "ISSUER": "bidule", "CLIENT_ID": "bidule", "CLIENT_SECRET": "bidule", + "group_claim_name": "provided_groups", + "group_mapping": {"group1": 1, "group2": 2}, }, { "module": "pypnusershub.auth.providers.usershub_provider.ExternalUsersHubAuthProvider", diff --git a/src/pypnusershub/tests/conftest.py b/src/pypnusershub/tests/conftest.py index d09d889..1b17570 100644 --- a/src/pypnusershub/tests/conftest.py +++ b/src/pypnusershub/tests/conftest.py @@ -1,4 +1,3 @@ -from pypnusershub.auth.providers.default import DefaultConfiguration import pytest from flask import Flask diff --git a/src/pypnusershub/tests/fixtures.py b/src/pypnusershub/tests/fixtures.py index 32298ff..01fe677 100644 --- a/src/pypnusershub/tests/fixtures.py +++ b/src/pypnusershub/tests/fixtures.py @@ -95,3 +95,17 @@ def group_and_users(app, applications, profils): "user1": user1, "user_no_group": user_no_group, } + + +@pytest.fixture() +def provider_config(): + return { + "module": "pypnusershub.auth.providers.openid_provider.OpenIDConnectProvider", + "id_provider": "bis", + "label": "bidule", + "ISSUER": "bidule", + "CLIENT_ID": "bidule", + "CLIENT_SECRET": "bidule", + "group_claim_name": "provided_groups", + "group_mapping": {"group1": 1, "group2": 2}, + } diff --git a/src/pypnusershub/tests/test_authentication.py b/src/pypnusershub/tests/test_authentication.py index 1f58491..1765e72 100644 --- a/src/pypnusershub/tests/test_authentication.py +++ b/src/pypnusershub/tests/test_authentication.py @@ -4,6 +4,7 @@ from pypnusershub.auth.auth_manager import AuthManager from pypnusershub.auth.providers.openid_provider import OpenIDProvider +from pypnusershub.tests.fixtures import * class TestAuthManager: @@ -21,21 +22,15 @@ def test_add_provider(self, app): with pytest.raises(Exception): auth_manager.add_provider("test", OpenIDProvider()) - def test_init_app(self): + def test_init_app(self, provider_config): app = Flask(__name__) - providers = [ - { - "module": "pypnusershub.auth.providers.openid_provider.OpenIDConnectProvider", - "id_provider": "bis", - "label": "bidule", - "ISSUER": "bidule", - "CLIENT_ID": "bidule", - "CLIENT_SECRET": "bidule", - } - ] auth_manager = AuthManager() - auth_manager.init_app(app, "/authent", providers) + auth_manager.init_app(app, "/authent", [provider_config]) assert isinstance(app.auth_manager, AuthManager) assert "bis" in auth_manager + + provider = auth_manager.get_provider("bis") + assert provider.group_claim_name == "provided_groups" + assert provider.group_mapping == {"group1": 1, "group2": 2} diff --git a/src/pypnusershub/tests/test_utilisateurs.py b/src/pypnusershub/tests/test_utilisateurs.py index 52a78c9..b541c8b 100644 --- a/src/pypnusershub/tests/test_utilisateurs.py +++ b/src/pypnusershub/tests/test_utilisateurs.py @@ -5,17 +5,18 @@ from pypnusershub.db.models import Organisme, User -from pypnusershub.routes import insert_or_update_organism, insert_or_update_role +from pypnusershub.routes import insert_or_update_organism from pypnusershub.schemas import OrganismeSchema, UserSchema from pypnusershub.tests.fixtures import * +from pypnusershub.tests.utils import set_logged_user from sqlalchemy import select -from pypnusershub.auth.auth_manager import auth_manager +from pypnusershub.auth.auth_manager import auth_manager, Authentication @pytest.fixture -def provider_instance(): +def provider_instance() -> Authentication: return auth_manager.get_provider("local_provider") @@ -35,9 +36,9 @@ def test_insert_user(self, app, organism, group_and_users, provider_instance): "active": True, "groups": [group], } - insert_or_update_role(user_dict, provider_instance) + provider_instance.insert_or_update_role(user_dict) user_dict["identifiant"] = "update" - insert_or_update_role(user_dict, provider_instance) + provider_instance.insert_or_update_role(user_dict) created_user = db.session.get(User, 99999) user_schema = UserSchema(only=["groups"]) created_user_as_dict = user_schema.dump(created_user) @@ -48,9 +49,36 @@ def test_insert_user(self, app, organism, group_and_users, provider_instance): app.config["AUTHENTICATION"]["DEFAULT_RECONCILIATION_GROUP_ID"] = 2 user_dict["id_role"] = 99998 user_dict["email"] = "test@test2.fr" - user_ = insert_or_update_role(user_dict, provider_instance) + user_ = provider_instance.insert_or_update_role(user_dict) assert len(db.session.get(User, 99998).groups) == 2 + def test_insert_or_update_role_grp_reconcialiation( + self, provider_instance, group_and_users + ): + # test modification du local provider pour lui ajouter un group mapping + # et un group_claim_name + # provider_instance.configure(provider_config) + provider_instance.group_claim_name = "provided_groups" + provider_instance.group_mapping = { + "group1": group_and_users["group1"].id_role, + "group2": group_and_users["group1"].id_role, + } + + user_to_reconcialite = { + "id_role": 999998, + "identifiant": "test2.user", + "nom_role": "test2", + "prenom_role": "test2", + "email": "test3@test.fr", + } + + user = provider_instance.insert_or_update_role( + user_dict=user_to_reconcialite, source_groups=["group1", "group2"] + ) + + user_group_id = map(lambda g: g.id_role, user.groups) + assert set(user_group_id) == {group_and_users["group1"].id_role} + def test_insert_organisme(self): organism = { "nom_organisme": "test", @@ -102,3 +130,16 @@ def test_login(self, group_and_users): datetime_expires = datetime.fromisoformat(expires) # the token expiration must be tz aware to avoid issue in date comparison assert datetime_expires.tzinfo is not None + + def test_get_user_data(self, group_and_users): + set_logged_user(self.client, group_and_users["user1"]) + + response = self.client.get(url_for("auth.get_user_data")) + assert response.status_code == 200 + data = response.json + assert "token" in data + assert "expires" in data + assert "user" in data + + assert "max_level_profil" in data["user"] + assert "providers" in data["user"] From 37cb11281f921652da211549bee7dece0c297928 Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Tue, 16 Jul 2024 12:58:40 +0200 Subject: [PATCH 21/28] add xmltodict in requirements --- requirements-common.in | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-common.in b/requirements-common.in index ee74055..5c745ad 100644 --- a/requirements-common.in +++ b/requirements-common.in @@ -9,4 +9,5 @@ sqlalchemy>=1.4,<2 flask-marshmallow marshmallow-sqlalchemy alembic +xmltodict # for flask login From 8e10a76f8aa57ad9f45f9c8e376ccf5961e3eed1 Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Tue, 16 Jul 2024 13:02:10 +0200 Subject: [PATCH 22/28] fix typing error --- src/pypnusershub/auth/providers/usershub_provider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pypnusershub/auth/providers/usershub_provider.py b/src/pypnusershub/auth/providers/usershub_provider.py index 80842b6..e24f769 100644 --- a/src/pypnusershub/auth/providers/usershub_provider.py +++ b/src/pypnusershub/auth/providers/usershub_provider.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Tuple +from typing import Any, Optional, Tuple, Union import requests from flask import request @@ -36,7 +36,7 @@ def authenticate(self): user_dict["uuid_role"] = user_resp.get("uuid_role") return self.insert_or_update_role(user_dict) - def configure(self, configuration: dict | Any) -> None: + def configure(self, configuration: Union[dict, Any]) -> None: class ExternalGNConfiguration(ProviderConfigurationSchema): login_url = fields.String(required=True) From 3e03dd96c74280a4e01cef1aeacc682809286571 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 16 Jul 2024 13:58:24 +0200 Subject: [PATCH 23/28] doc(readme) : include parameters for each provider --- README.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f8eaae2..5e3312c 100644 --- a/README.md +++ b/README.md @@ -463,7 +463,7 @@ providers_config = # Declare identity providers used to log into your app [ # Default identity provider (comes with UH-AM) { - "module" : "pypnusershub.auth.providers.default.DefaultConfiguration", + "module" : "pypnusershub.auth.providers.default.LocalProvider", "id_provider":"local_provider" }, # Other identity provider @@ -485,6 +485,29 @@ if __name__ == "__main__": Pour lancer la connexion sur un provider en particulier, il suffit d'appeler la route `/login/`. +### Paramètres de configurations des protocoles de connexions inclus + +**OpenID et OpenIDConnect**. + +- `group_claim_name` (string) : nom du champs retournée par le fournisseur d'identités dans lequel se trouve la liste de groupes auquel l'utilisateur appartient (par défaut : "groups"). +- `ISSUER` (string) : URL du fournisseur d'identités +- `CLIENT_ID` (string) : Identifiant publique de l'application auprès du fournisseur d'identités. +- `CLIENT_SECRET` (string) : Clé secrete connue uniquement par l'application et le fournisseur d'identités. + +**UsersHub-authentification-module** + +- `login_url` : URL absolue vers la route `/auth/login` de l'application Flask. +- `logout_url` : URL absolue vers la route `/auth/login` de l'application Flask. + +**CAS INPN** + +- `URL_LOGIN` et `URL_LOGOUT` (string) : route de déconnexion de l'API de l'INPN (par défaut https://inpn.mnhn.fr/auth/login et https://inpn.mnhn.fr/auth/logout) +- `URL_VALIDATION` (string) : URL qui permet de valider le token renvoyer après l'authentification de l'utilisateur sur le portail de l'INPN (par défaut: https://inpn.mnhn.fr/auth/serviceValidate"). +- `URL_INFO` (string) : A l'aide des infos retournées par `URL_VALIDATION`, permet de récupérer les informations d'un utilisateurs (par défaut: https://inpn.mnhn.fr/authentication/information). +- `WS_ID` et `WS_PASSWORD` (string): identifiant et mot de passe permettant d'accéder au service accessible sur `URL_INFO`. +- `USERS_CAN_SEE_ORGANISM_DATA` (boolean): indique si l'utilisateur connecté peut voir les données de son organisme (par défaut: false). +- `ID_USER_SOCLE_1` et `ID_USER_SOCLE_2` : `ID_USER_SOCLE_1` indique le groupe dans l'instance GeoNature qui permet à l'utilisateur de voir les données de son organisme. Dans le cas contraire, il est associé au groupe indiqué dans `ID_USER_SOCLE_2`. + ### Ajouter son propre protocole de connexion L'ensemble des protocoles de connexion dans `UsersHub-authentification-module` n'est pas exhaustif et peut dans certains cas ne pas convenir à votre contexte. Pas de panique ! Il est possible de déclarer son propre protocole à l'aide de la classe `Authentication` comme dans l'exemple ci-dessous : From 7e70acad57081759d18e2a3d75d760d429a8bdee Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 16 Jul 2024 15:49:08 +0200 Subject: [PATCH 24/28] add some documentation --- README.md | 425 ++++++++++++++++++------------------ src/pypnusershub/schemas.py | 16 ++ 2 files changed, 224 insertions(+), 217 deletions(-) diff --git a/README.md b/README.md index 5e3312c..89c4473 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,17 @@ # UsersHub-authentification-module [![pytest](https://github.com/PnX-SI/UsersHub-authentification-module/actions/workflows/pytest.yml/badge.svg)](https://github.com/PnX-SI/UsersHub-authentification-module/actions/workflows/pytest.yml)[![codecov](https://codecov.io/gh/PnX-SI/UsersHub-authentification-module/branch/master/graph/badge.svg?token=O57GQEH494)](https://codecov.io/gh/PnX-SI/UsersHub-authentification-module) -UsersHub-authentification-module est une extension [Flask](https://flask.palletsprojects.com/) intégrant un processus d'authentification. Sa spécificité repose sur son modèle de données répondant aux besoins de l'application [UsersHub](https://github.com/PnX-SI/UsersHub/) et [GeoNature](https://github.com/PnX-SI/GeoNature/). +UsersHub-authentification-module est une extension [Flask](https://flask.palletsprojects.com/) permettant d'intégrer un système d'authentification et de gestion de sessions utilisateurs à son application. L'extension fournit un mécanisme d'authentification basé sur une base de données locale et permet également de se connecter via des mécanismes d'authentification externe standard (OAuth, OIDC). +La gestion des session est gérée par l'extension [Flask-Login](https://flask-login.readthedocs.io/en/latest/). -## Exemple d'utilisation +## Get Started + +### Installation + +```sh +pip install pypnusershub +``` + +### Application Flask minimale Pour disposer des routes de connexion/déconnexion avec le protocole de connexion par défaut, le code minimal d'une application Flask est le suivant: @@ -27,30 +36,28 @@ if __name__ == "__main__": app.run(host="0.0.0.0",port=5000) ``` -Pour protéger une route, utiliser le décorateur `check_auth(niveau_profil)`: +### Protèger une route + +Pour protéger une route en utilisant les profils de permissions, utilisez le décorateur `check_auth(niveau_profil)`: ```python - # Import the decorator from - from pypnusershub.decorators import check_auth +from pypnusershub.decorators import check_auth - @adresses.route('/', methods=['POST', 'PUT']) - @check_auth(4) # Decorate the Flask route - def insertUpdate_bibtaxons(id_taxon=None): - pass +@adresses.route('/', methods=['POST', 'PUT']) +@check_auth(4) # Decorate the Flask route +def insertUpdate_bibtaxons(id_taxon=None): + pass ``` -Pour utiliser les routes de UsersHub, ajouter les paramètres suivants dans la configuration de l'application : - -- `URL_USERSHUB` : Url de votre UsersHub -- `ADMIN_APPLICATION_LOGIN` , `ADMIN_APPLICATION_PASSWORD`, `ADMIN_APPLICATION_MAIL` : identifiant de l'administrateur de votre UsersHub +Si vous voulez limitez l'accès à une route uniquement aux utilisateurs connectés (qu'importe leur profils), utilisez le décorateur `login_required` de Flask-login ```python -app.config["URL_USERSHUB"]="http://usershub-url.ext" +from flask_login import login_required -# Administrateur de mon application -app.config["ADMIN_APPLICATION_LOGIN"]="admin-monapplication" -app.config["ADMIN_APPLICATION_PASSWORD"]="monpassword" -app.config["ADMIN_APPLICATION_MAIL"]="admin-monapplication@mail.ext" +@adresses.route('/', methods=['POST', 'PUT']) +@login_required +def insertUpdate_bibtaxons(id_taxon=None): + pass ``` ## Installation @@ -59,7 +66,7 @@ app.config["ADMIN_APPLICATION_MAIL"]="admin-monapplication@mail.ext" - Python 3.9 ou (ou version supérieure) - PostgreSQL 11.x (ou version supérieure) -- Paquets systèmes suivants: `sudo apt install python3-dev build-essential postgresql-server-dev` +- Paquets systèmes suivants: `python3-dev`,`build-essential`, `postgresql-server-dev` ### Installer `UsersHub-authentification-module` @@ -69,50 +76,42 @@ Cloner le dépôt (ou télécharger une archive), dirigez vous dans le dossier e pip install . ``` -### Configuration de la base de données - -La manière la plus courante pour se connecter à la base de données en ayant les droits super-utilisateur est de connecter avec l'utilisateur 'postgres'. +### Configuration de l'extension -``` -sudo su postgres -``` +#### Configuration Flask -**Création de la base de données** -Assurez-vous d'avoir au moins créé une base de données et d'avoir l'extension `uuid-ossp` activée. +Indiquer la route de la _homepage_ de votre application dans la variable `URL_APPLICATION` -```sh -createdb ma_db -psql -d ma_db -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' -``` +Pour manipuler la base de données, nous utilisons l'extension `flask-sqlalchemy`. Si votre application déclare déjà l'object `flask_sqlalchemy.SQLAlchemy`, déclarer le chemin python vers celui-ci dans `FLASK_SQLALCHEMY_DB`. -**Création d'un utilisateur** -Pour créer un utilisateur, utiliser la commande `createuser`. Par exemple : +#### Configuration de Flask-login -``` -createuser -P parcnational -``` +Paramètres à rajouter dans la configuration ( attribut `config` de l'objet `Flask`) de votre application. -Donner les droits à cet utilisateur sur la base de données. Lancez la ligne de commande PostgreSQL: +Les paramètre concernant la gestion du cookie sont gérés par flask-admin : https://flask-login.readthedocs.io/en/latest/#cookie-settings -```sh -psql -``` +`REDIRECT_ON_FORBIDDEN` : paramètre de redirection utilisé par le décorateur `check_auth` lorsque les droits d'accès à une ressource/page sont insuffisants (par défaut lève une erreur 403) -Entrez la requête suivante: +#### Lien avec UsersHub -```sql -GRANT ALL PRIVILEGES ON DATABASE ma_db TO parcnational; -``` +Pour utiliser les routes de UsersHub, ajouter les paramètres suivants dans la configuration de l'application : -Quittez la ligne de commande en utilisant le raccourci `Ctrl+D` ou en entrant la commande `\q`. +- `URL_USERSHUB` : Url de votre UsersHub +- `ADMIN_APPLICATION_LOGIN` , `ADMIN_APPLICATION_PASSWORD`, `ADMIN_APPLICATION_MAIL` : identifiant de l'administrateur de votre UsersHub -**Accédez à votre base de données** -[SQLAlchemy](https://www.sqlalchemy.org/) se connectera à la base de données à l'aide d'une URL -du type : +```python +app.config["URL_USERSHUB"]="http://usershub-url.ext" +# Administrateur de mon application +app.config["ADMIN_APPLICATION_LOGIN"]="admin-monapplication" +app.config["ADMIN_APPLICATION_PASSWORD"]="monpassword" +app.config["ADMIN_APPLICATION_MAIL"]="admin-monapplication@mail.ext" ``` -postgresql://nom_utilisateur:mot_de_passe@host:port/db_name -``` + +> [!TIP] +> Si vous souhaitez une interface permettant de modifier les données utilisateurs décritent dans `UsersHub-authentification-module`, il est conseillé d'utiliser [UsersHub](https://github.com/PnX-SI/UsersHub). + +#### Configuration de la base de données **Création des tables et schémas nécessaires** @@ -135,7 +134,7 @@ alembic upgrade utilisateurs@head **N.B.** Si vous utilisez `alembic` dans votre projet, il est possible d'accéder aux révisions de `UsersHub-authenfication-module` à l'aide de la variable [`alembic.script_location`](https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file). -### Remplissage des tables pour son application +**Remplissage des tables pour son application** Pour utiliser votre application avec les `UsersHub-authentification-module`, il est nécessaire : @@ -223,8 +222,165 @@ def downgrade(): ``` +## Utilisation de l'API + +### Ajout des routes + +Les routes déclarées par le _blueprint_ de `UsersHub-authentification-module` sont automatiquement ajoutés dans le blueprint de votre application lors de l'appel de la méthode `init_app()` de l'object `AuthManager`. + +### Routes définies par UsersHub-authentification module + +Les routes suivantes sont implémentés dans `UsersHub-authentification-module`: + +| Route URI | Action | Paramètres | Retourne | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------------- | +| `/providers` | Retourne l'ensemble des fournisseurs d'identités activés | NA | | +| `/get_current_user` | Retourne les informations de l'utilisateur connecté | NA | {user,expires,token} | +| `/login/` | Connecte un utilisateur avec le provider | Optionnel({user,password}) | {user,expires,token} ou redirect | +| `/public_login` | Connecte l'utilisateur permettant l'accès public à votre application | NA | {user,expires,token} | +| `/logout` | Déconnecte l'utilisateur courant | NA | redirect | +| `/authorize` | Connecte un utilisateur à l'aide des infos retournées par le fournisseurs d'identités (Si redirection vers un portail de connexion par /login) | {data} | redirect | + > [!TIP] -> Si vous souhaitez une interface permettant de modifier les données utilisateurs décritent dans `UsersHub-authentification-module`, il est conseillé d'utiliser [UsersHub](https://github.com/PnX-SI/UsersHub). +> Certaines routes utilisées accessibles depuis `UsersHub-authentification-module` proviennent du module `UsersHub`. Les routes sont les suivantes : +> +> | Route URI | Action | Paramètres | Retourne | +> | --------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------ | +> | `/create_tmp_user` | Création d'un utilisateur temporaire en base | {données sur l'utilisateur} | {token} | +> | `/valid_temp_user` | Création utilisateur en base dans la table t_role et ajout d'un profil avec code 1 pour une l’application donnée | {token, application_id} | {role} | +> | `/create_cor_role_token` | Génère un token pour utilisateur ayant l’email indiqué et stoque le token dans cor_role_token | {email} | {role} | +> | `/change_password` | Mise à jour du mot de passe de l’utilisateur et suppression du token en base | {token, password, password_confirmation} | {role} | +> | `/change_application_right` | Modifie le profil de l’utilisateur pour l’application | {id_application, id_profil, id_role} | {id_role, id_profil, id_application, role} | +> | `/update_user` | Mise à jour d'un rôle | {id_role, données utilisateur} | {role} | + +### Méthodes définies dans le module + +- `connect_admin()` : décorateur pour la connexion d’un utilisateur type admin a une appli ici usershub. Paramètres à renseigner dans la configuration. +- `post_usershub()` : route générique pour appeler les route usershub en tant qu'administrateur de l'appli en cours. +- `insert_or_update_role` : méthode pour insérer ou mettre à jour un utilisateur. + +### Changement du prefix d'accès aux routes de UsersHub-authentification-module + +Par défaut, les routes sont accessibles depuis le préfixe `/auth/`. Si vous voulez changer cela, il suffit de modifier le paramètre `prefix` de l'appel de la méthode `AuthManager.init_app()` : + +```python +auth_manager.init_app(app, prefix="/authentification", providers_declaration=providers_config) +``` + +## Connexion à l'aide de fournisseurs d'identités extérieurs + +Depuis la version 3.0, il est possible d'ajouter la possibilité de se connecter à des fournisseurs d'identités externes utilisant d'autres protocoles de connexion : OpenID, OpenID Connect, CAS (INPN), etc. ... + +### Utiliser les protocoles de connexions existant + +Lors de l'appel de `AuthManager.init_app`, il faut indiquer les configurations des différents fournisseurs d'identités sur lesquels on souhaite se connecter dans le paramètre `providers_declaration`. + +Pour chaque configuration, on doit déclarer : + +- le chemin vers la classe Python `module` déclarant le protocole de connexion +- son identifiant unique `id_provider` +- et les variables de configurations propre au protocole de connexion + +```python +from flask import Flask +from pypnusershub.auth import auth_manager + +app = Flask(__name__) # Instantiate a Flask application +app.config["URL_APPLICATION"] = "/" # Home page of your application +providers_config = # Declare identity providers used to log into your app + [ + # Default identity provider (comes with UH-AM) + { + "module" : "pypnusershub.auth.providers.default.LocalProvider", + "id_provider":"local_provider" + }, + # Other identity provider + { + "module": "pypnusershub.auth.providers.openid_provider.OpenIDProvider", + "id_provider":"open_id_1", + "ISSUER":"http://", + "CLIENT_ID":"secret", + "CLIENT_SECRET":"secret" + } + + # you can add other identity providers that works with OpenID protocol (and many others !) + ] +auth_manager.init_app(app,providers_declaration=providers_config) + +if __name__ == "__main__": + app.run(host="0.0.0.0",port=5200) +``` + +Pour lancer la connexion sur un provider en particulier, il suffit d'appeler la route `/login/`. + +### Paramètres de configurations des protocoles de connexions inclus + +**OpenID et OpenIDConnect**. + +- `group_claim_name` (string) : nom du champs retournée par le fournisseur d'identités dans lequel se trouve la liste de groupes auquel l'utilisateur appartient (par défaut : "groups"). +- `ISSUER` (string) : URL du fournisseur d'identités +- `CLIENT_ID` (string) : Identifiant publique de l'application auprès du fournisseur d'identités. +- `CLIENT_SECRET` (string) : Clé secrete connue uniquement par l'application et le fournisseur d'identités. + +**UsersHub-authentification-module** + +- `login_url` : URL absolue vers la route `/auth/login` de l'application Flask. +- `logout_url` : URL absolue vers la route `/auth/login` de l'application Flask. + +**CAS INPN** + +- `URL_LOGIN` et `URL_LOGOUT` (string) : route de déconnexion de l'API de l'INPN (par défaut https://inpn.mnhn.fr/auth/login et https://inpn.mnhn.fr/auth/logout) +- `URL_VALIDATION` (string) : URL qui permet de valider le token renvoyer après l'authentification de l'utilisateur sur le portail de l'INPN (par défaut: https://inpn.mnhn.fr/auth/serviceValidate"). +- `URL_INFO` (string) : A l'aide des infos retournées par `URL_VALIDATION`, permet de récupérer les informations d'un utilisateurs (par défaut: https://inpn.mnhn.fr/authentication/information). +- `WS_ID` et `WS_PASSWORD` (string): identifiant et mot de passe permettant d'accéder au service accessible sur `URL_INFO`. +- `USERS_CAN_SEE_ORGANISM_DATA` (boolean): indique si l'utilisateur connecté peut voir les données de son organisme (par défaut: false). +- `ID_USER_SOCLE_1` et `ID_USER_SOCLE_2` : `ID_USER_SOCLE_1` indique le groupe dans l'instance GeoNature qui permet à l'utilisateur de voir les données de son organisme. Dans le cas contraire, il est associé au groupe indiqué dans `ID_USER_SOCLE_2`. + +### Ajouter son propre protocole de connexion + +L'ensemble des protocoles de connexion dans `UsersHub-authentification-module` n'est pas exhaustif et peut dans certains cas ne pas convenir à votre contexte. Pas de panique ! Il est possible de déclarer son propre protocole à l'aide de la classe `Authentication` comme dans l'exemple ci-dessous : + +```python +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 # go through an external connection portal + + def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: + pass # return a User or a Flask redirection to the login portal of the provider + + def authorize(self): + pass # must return a User + + def revoke(self): + pass # if specific action have to be made when logout + + def configure(self, configuration: Union[dict, Any]): + pass # Indique la configuration d'un fournisseur d'identités +``` + +Un **protocole de connexion** est défini par 5 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. ## Schéma de la base de données @@ -388,168 +544,3 @@ cor_profil_for_app *-- t_applications | cor_role_provider | Cette table permet d'associer des utilisateurs à des fournisseurs d'identités | | cor_role_token | Permet d'associer des utilisateurs à des tokens | | cor_roles | Permet d'associer des utilisateurs entre eux (groupes et utilisateurs) | - -## Utilisation de l'API - -### Routes définies par UsersHub-authentification module - -Les routes suivantes sont implémentés dans `UsersHub-authentification-module` : - -| Route URI | Action | Paramètres | Retourne | -| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------------- | -| `/providers` | Retourne l'ensemble des fournisseurs d'identités activés | NA | | -| `/get_current_user` | Retourne les informations de l'utilisateur connecté | NA | {user,expires,token} | -| `/login/` | Connecte un utilisateur avec le provider | Optionnel({user,password}) | {user,expires,token} ou redirect | -| `/public_login` | Connecte l'utilisateur permettant l'accès public à votre application | NA | {user,expires,token} | -| `/logout` | Déconnecte l'utilisateur courant | NA | redirect | -| `/authorize` | Connecte un utilisateur à l'aide des infos retournées par le fournisseurs d'identités (Si redirection vers un portail de connexion par /login) | {data} | redirect | - -### Routes définies dans UsersHub - -Les routes utilisées dans le `UsersHub-authentification-module` proviennent du module `UsersHub`. Les routes sont les suivantes : - -| Route URI | Action | Paramètres | Retourne | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------ | -| `/create_tmp_user` | Création d'un utilisateur temporaire en base | {données sur l'utilisateur} | {token} | -| `/valid_temp_user` | Création utilisateur en base dans la table t_role et ajout d'un profil avec code 1 pour une l’application donnée | {token, application_id} | {role} | -| `/create_cor_role_token` | Génère un token pour utilisateur ayant l’email indiqué et stoque le token dans cor_role_token | {email} | {role} | -| `/change_password` | Mise à jour du mot de passe de l’utilisateur et suppression du token en base | {token, password, password_confirmation} | {role} | -| `/change_application_right` | Modifie le profil de l’utilisateur pour l’application | {id_application, id_profil, id_role} | {id_role, id_profil, id_application, role} | -| `/update_user` | Mise à jour d'un rôle | {id_role, données utilisateur} | {role} | - -### Méthodes définies dans le module - -- `connect_admin()` : décorateur pour la connexion d’un utilisateur type admin a une appli ici usershub. Paramètres à renseigner dans la configuration. -- `post_usershub()` : route générique pour appeler les route usershub en tant qu'administrateur de l'appli en cours. -- `insert_or_update_role` : méthode pour insérer ou mettre à jour un utilisateur. - -### Configuration de Flask-login - -Paramètres à rajouter dans la configuration ( attribut `config` de l'objet `Flask`) de votre application. - -Les paramètre concernant la gestion du cookie sont gérés par flask-admin : https://flask-login.readthedocs.io/en/latest/#cookie-settings - -`REDIRECT_ON_FORBIDDEN` : paramètre de redirection utilisé par le décorateur `check_auth` lorsque les droits d'accès à une ressource/page sont insuffisants (par défaut lève une erreur 403) - -### Changement du prefix d'accès aux routes de UsersHub-authentification-modules - -Par défaut, les routes sont acesibles depuis le prefix `/auth/`. Si vous voulez changez cela, il suffit de modifier le paramètre `prefix` de l'appel de la méthode `AuthManager.init_app()`: - -```python -auth_manager.init_app(app, prefix="/authentification", providers_declaration=providers_config) -``` - -## Connexion à l'aide de fournisseurs d'identités extérieurs - -Depuis la 3.0, il est possible d'ajouter la possibilité de se connecter à des fournisseurs d'identités externes utilisant d'autres protocoles de connexions que `UsersHub-authentification-module` : OpenID, OpenIDConnect, CAS INPN, etc ... - -### Utiliser les protocoles de connexions existant - -Lors de l'appel de `AuthManager.init_app`, il faut indiquer les configurations des différents fournisseurs d'identités sur lesquels on souhaite se connecter dans le paramètre `providers_declaration`. - -Pour chaque configuration, on doit déclarer : - -- le chemin vers la classe Python `module` déclarant le protocole de connexion -- son identifiant unique `id_provider` -- et les variables de configurations propre au protocole de connexion - -```python -from flask import Flask -from pypnusershub.auth import auth_manager - -app = Flask(__name__) # Instantiate a Flask application -app.config["URL_APPLICATION"] = "/" # Home page of your application -providers_config = # Declare identity providers used to log into your app - [ - # Default identity provider (comes with UH-AM) - { - "module" : "pypnusershub.auth.providers.default.LocalProvider", - "id_provider":"local_provider" - }, - # Other identity provider - { - "module": "pypnusershub.auth.providers.openid_provider.OpenIDProvider", - "id_provider":"open_id_1", - "ISSUER":"http://", - "CLIENT_ID":"secret", - "CLIENT_SECRET":"secret" - } - - # you can add other identity providers that works with OpenID protocol (and many others !) - ] -auth_manager.init_app(app,providers_declaration=providers_config) - -if __name__ == "__main__": - app.run(host="0.0.0.0",port=5200) -``` - -Pour lancer la connexion sur un provider en particulier, il suffit d'appeler la route `/login/`. - -### Paramètres de configurations des protocoles de connexions inclus - -**OpenID et OpenIDConnect**. - -- `group_claim_name` (string) : nom du champs retournée par le fournisseur d'identités dans lequel se trouve la liste de groupes auquel l'utilisateur appartient (par défaut : "groups"). -- `ISSUER` (string) : URL du fournisseur d'identités -- `CLIENT_ID` (string) : Identifiant publique de l'application auprès du fournisseur d'identités. -- `CLIENT_SECRET` (string) : Clé secrete connue uniquement par l'application et le fournisseur d'identités. - -**UsersHub-authentification-module** - -- `login_url` : URL absolue vers la route `/auth/login` de l'application Flask. -- `logout_url` : URL absolue vers la route `/auth/login` de l'application Flask. - -**CAS INPN** - -- `URL_LOGIN` et `URL_LOGOUT` (string) : route de déconnexion de l'API de l'INPN (par défaut https://inpn.mnhn.fr/auth/login et https://inpn.mnhn.fr/auth/logout) -- `URL_VALIDATION` (string) : URL qui permet de valider le token renvoyer après l'authentification de l'utilisateur sur le portail de l'INPN (par défaut: https://inpn.mnhn.fr/auth/serviceValidate"). -- `URL_INFO` (string) : A l'aide des infos retournées par `URL_VALIDATION`, permet de récupérer les informations d'un utilisateurs (par défaut: https://inpn.mnhn.fr/authentication/information). -- `WS_ID` et `WS_PASSWORD` (string): identifiant et mot de passe permettant d'accéder au service accessible sur `URL_INFO`. -- `USERS_CAN_SEE_ORGANISM_DATA` (boolean): indique si l'utilisateur connecté peut voir les données de son organisme (par défaut: false). -- `ID_USER_SOCLE_1` et `ID_USER_SOCLE_2` : `ID_USER_SOCLE_1` indique le groupe dans l'instance GeoNature qui permet à l'utilisateur de voir les données de son organisme. Dans le cas contraire, il est associé au groupe indiqué dans `ID_USER_SOCLE_2`. - -### Ajouter son propre protocole de connexion - -L'ensemble des protocoles de connexion dans `UsersHub-authentification-module` n'est pas exhaustif et peut dans certains cas ne pas convenir à votre contexte. Pas de panique ! Il est possible de déclarer son propre protocole à l'aide de la classe `Authentication` comme dans l'exemple ci-dessous : - -```python -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 # go through an external connection portal - - def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: - pass # return a User or a Flask redirection to the login portal of the provider - - def authorize(self): - pass # must return a User - - def revoke(self): - pass # if specific action have to be made when logout - - def configure(self, configuration: Union[dict, Any]): - pass # Indique la configuration d'un fournisseur d'identités -``` - -Un **protocole de connexion** est défini par 5 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. diff --git a/src/pypnusershub/schemas.py b/src/pypnusershub/schemas.py index 45f48d0..ddf2ef9 100644 --- a/src/pypnusershub/schemas.py +++ b/src/pypnusershub/schemas.py @@ -48,6 +48,22 @@ def make_observer(self, data, **kwargs): return data def dump_with_token(self, obj): + """ + Dumps user information with a JWT token and its expiration date. + + Parameters + ---------- + obj : User + The user object to dump. + + Returns + ------- + dict + A dictionary with the user information and the token. The token is + encoded using the user's information and the secret key from the + current Flask application configuration. The dictionary also + contains the expiration date of the token. + """ user_dict = self.dump(obj) token_exp = datetime.datetime.now(datetime.timezone.utc) token_exp += datetime.timedelta(seconds=current_app.config["COOKIE_EXPIRATION"]) From 1eac7426d105f5ef93f238feb5aa5264a2743d3e Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 16 Jul 2024 15:49:48 +0200 Subject: [PATCH 25/28] move login_manager initialisation into auth_manager init_app method --- src/pypnusershub/auth/auth_manager.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/pypnusershub/auth/auth_manager.py b/src/pypnusershub/auth/auth_manager.py index 9887bdf..1a675ee 100644 --- a/src/pypnusershub/auth/auth_manager.py +++ b/src/pypnusershub/auth/auth_manager.py @@ -5,6 +5,7 @@ from pypnusershub.env import db from .authentication import Authentication +from pypnusershub.login_manager import login_manager from typing import TypedDict @@ -61,30 +62,33 @@ def add_provider( provider : Authentification The authentication provider class. - Raises ------ AssertionError If the provider is not an instance of Authentification. """ + if not isinstance(provider_authentification, Authentication): + raise AssertionError("Provider must be an instance of Authentication") if id_provider in self.provider_authentication_cls: raise Exception( f"Id provider {id_provider} already exist, please check your authentication config" ) - if not isinstance(provider_authentification, Authentication): - raise AssertionError("Provider must be an instance of Authentication") self.provider_authentication_cls[id_provider] = provider_authentification def init_app( self, app, prefix: str = "/auth", providers_declaration: list[ProviderType] = [] ) -> None: """ - Initializes the Flask application with the AuthManager. In addtion, it registers the authentification module blueprint. + Initializes the Flask application with the AuthManager. In addition, it registers the authentication module blueprint. Parameters ---------- app : Flask The Flask application instance. + prefix : str, optional + The URL prefix for the authentication module blueprint. + providers_declaration : list[ProviderType], optional + List of provider declarations to be used by the AuthManager. """ from pypnusershub.routes import routes @@ -104,6 +108,7 @@ def init_app( instance_provider: Authentication = class_() instance_provider.configure(configuration=provider_config) self.add_provider(instance_provider.id_provider, instance_provider) + login_manager.init_app(app) def get_provider(self, instance_name: str) -> Authentication: """ From 87fadd24af83e90ff06372376edc1d7dc76d72de Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 16 Jul 2024 15:50:17 +0200 Subject: [PATCH 26/28] cleaning code and debug cas inpn --- .../auth/providers/cas_inpn_provider.py | 13 ++----------- src/pypnusershub/auth/providers/openid_provider.py | 3 --- .../auth/providers/usershub_provider.py | 1 - 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/pypnusershub/auth/providers/cas_inpn_provider.py b/src/pypnusershub/auth/providers/cas_inpn_provider.py index 405e5a7..799b116 100644 --- a/src/pypnusershub/auth/providers/cas_inpn_provider.py +++ b/src/pypnusershub/auth/providers/cas_inpn_provider.py @@ -16,7 +16,6 @@ class AuthenficationCASINPN(Authentication): - name = "CAS_INPN_PROVIDER" label = "INPN" is_external = True logo = "" @@ -54,20 +53,15 @@ def authorize(self): user = xml_dict["cas:serviceResponse"]["cas:authenticationSuccess"][ "cas:user" ] - if not user: log.info("Erreur d'authentification lié au CAS, voir log du CAS") log.error("Erreur d'authentification lié au CAS, voir log du CAS") - return render_template( - "cas_login_error.html", - cas_logout=self.URL_LOGOUT, - url_geonature=current_app.config["URL_APPLICATION"], - ) + return redirect(self.logout_url) ws_user_url = f"{self.URL_INFO}/{user}/?verify=false" response = requests.get( ws_user_url, - ( + auth=( self.WS_ID, self.WS_PASSWORD, ), @@ -144,9 +138,6 @@ class CASINPNConfiguration(ProviderConfigurationSchema): URL_VALIDATION = fields.String( load_default="https://inpn.mnhn.fr/auth/serviceValidate" ) - URL_AUTHORIZE = fields.String( - load_default="https://inpn.mnhn.fr/authentication/" - ) URL_INFO = fields.String( load_default="https://inpn.mnhn.fr/authentication/information", ) diff --git a/src/pypnusershub/auth/providers/openid_provider.py b/src/pypnusershub/auth/providers/openid_provider.py index ee39821..4567dd8 100644 --- a/src/pypnusershub/auth/providers/openid_provider.py +++ b/src/pypnusershub/auth/providers/openid_provider.py @@ -16,7 +16,6 @@ class OpenIDProvider(Authentication): """ - name = "OPENID_PROVIDER_CONFIG" logo = '' is_external = True """ @@ -106,8 +105,6 @@ class OpenIDConnectProvider(OpenIDProvider): """ - name = "OPENID_CONNECT_PROVIDER_CONFIG" - def revoke(self): if not "openid_token_resp" in session: diff --git a/src/pypnusershub/auth/providers/usershub_provider.py b/src/pypnusershub/auth/providers/usershub_provider.py index e24f769..b4f35c8 100644 --- a/src/pypnusershub/auth/providers/usershub_provider.py +++ b/src/pypnusershub/auth/providers/usershub_provider.py @@ -13,7 +13,6 @@ class ExternalUsersHubAuthProvider(Authentication): Authentication provider for Flask application using UsersHub-authentification-module. """ - name = "EXTERNAL_USERSHUB_PROVIDER_CONFIG" logo = '' is_external = False From 7e13ccaa26f46ae6cac1633689c12c7edcc67fdf Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Tue, 16 Jul 2024 16:01:27 +0200 Subject: [PATCH 27/28] add missing provider --- src/pypnusershub/auth/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pypnusershub/auth/authentication.py b/src/pypnusershub/auth/authentication.py index 5c7c0f0..c574797 100644 --- a/src/pypnusershub/auth/authentication.py +++ b/src/pypnusershub/auth/authentication.py @@ -216,7 +216,7 @@ def insert_or_update_role( ).scalar_one_or_none() if not provider: provider = models.Provider(name=self.id_provider, url=self.login_url) - db.session.add() + db.session.add(provider) db.session.commit() if user_exists: From 5acccecb1ede78168c9e0dfd8cb2223654f3235c Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 16 Jul 2024 16:09:15 +0200 Subject: [PATCH 28/28] update readme --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 89c4473..bcb9b57 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,10 @@ pip install . Indiquer la route de la _homepage_ de votre application dans la variable `URL_APPLICATION` -Pour manipuler la base de données, nous utilisons l'extension `flask-sqlalchemy`. Si votre application déclare déjà l'object `flask_sqlalchemy.SQLAlchemy`, déclarer le chemin python vers celui-ci dans `FLASK_SQLALCHEMY_DB`. +Pour manipuler la base de données, nous utilisons l'extension `flask-sqlalchemy`. Si votre application déclare déjà un objet `flask_sqlalchemy.SQLAlchemy`, déclarer le chemin python vers celui-ci dans la variable de configuration `FLASK_SQLALCHEMY_DB`. +````python +os.environ["FLASK_SQLALCHEMY_DB"] = "unmodule.unsousmodule.nomvariable" #### Configuration de Flask-login Paramètres à rajouter dans la configuration ( attribut `config` de l'objet `Flask`) de votre application. @@ -106,7 +108,7 @@ app.config["URL_USERSHUB"]="http://usershub-url.ext" app.config["ADMIN_APPLICATION_LOGIN"]="admin-monapplication" app.config["ADMIN_APPLICATION_PASSWORD"]="monpassword" app.config["ADMIN_APPLICATION_MAIL"]="admin-monapplication@mail.ext" -``` +```` > [!TIP] > Si vous souhaitez une interface permettant de modifier les données utilisateurs décritent dans `UsersHub-authentification-module`, il est conseillé d'utiliser [UsersHub](https://github.com/PnX-SI/UsersHub).