From ce6903a72b3e6758de6e553acbef03fbb6067478 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 28 May 2024 15:00:59 +0200 Subject: [PATCH 01/47] fix bad link to issue/pr in the 2.14.2 changelog --- docs/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f9b9a3c313..3a1cc81c85 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,10 +11,10 @@ CHANGELOG **🐛 Corrections** -- Correction d'erreur dans les fichiers de traductions du frontend (#3026) -- Correction de la fermeture des sessions SQLAlchemy lancĂ©es par Celery (#3050, #3065) +- Correction d'erreurs dans les fichiers de traductions du frontend (#3026) +- Correction de la fermeture des sessions SQLAlchemy lancĂ©es par Celery (#3050, #3062 ) - [CAS-INPN] Fix du systĂšme d'authentification au CAS de l'INPN (#2866) -- [Monitoring] Correction de la requĂȘte SQLAlchemy de rĂ©cupĂ©ration des aires de sites (#2954) +- [Monitoring] Correction de la requĂȘte SQLAlchemy de rĂ©cupĂ©ration des aires de sites (#2984) - [Occtax] Correction de la transformation de la valeur par dĂ©faut dans les champs additionnels d'Occtax (#2978, #3011, #3017) - [RefGeo] Correction du filtre `type_code` de la route `/geo/areas` (#3057, PnX-SI/RefGeo#26) From 911e000ca01bcbe63319384524a23836359bf408 Mon Sep 17 00:00:00 2001 From: VincentCauchois Date: Tue, 4 Jun 2024 12:12:47 +0200 Subject: [PATCH 02/47] fix(mtd): fix return of sync_af function --- backend/geonature/core/gn_meta/mtd/mtd_utils.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/geonature/core/gn_meta/mtd/mtd_utils.py b/backend/geonature/core/gn_meta/mtd/mtd_utils.py index 34b6795108..a52d373fc7 100644 --- a/backend/geonature/core/gn_meta/mtd/mtd_utils.py +++ b/backend/geonature/core/gn_meta/mtd/mtd_utils.py @@ -130,21 +130,20 @@ def sync_af(af): statement = ( update(TAcquisitionFramework) .where(TAcquisitionFramework.unique_acquisition_framework_id == af_uuid) - .values(af) - .returning(TAcquisitionFramework) + .values(**af) ) if not af_exists: statement = ( pg_insert(TAcquisitionFramework) .values(**af) .on_conflict_do_nothing(index_elements=["unique_acquisition_framework_id"]) - .returning(TAcquisitionFramework) ) DB.session.execute(statement) acquisition_framework = DB.session.scalars( select(TAcquisitionFramework).filter_by(unique_acquisition_framework_id=af_uuid) - ) + ).first() + return acquisition_framework From 3b2d232626ca82b7c4f07ac1be510a8c930e5d6d Mon Sep 17 00:00:00 2001 From: VincentCauchois Date: Tue, 4 Jun 2024 16:21:19 +0200 Subject: [PATCH 03/47] fix(mtd): fix typo in associate_actors function --- backend/geonature/core/gn_meta/mtd/mtd_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/geonature/core/gn_meta/mtd/mtd_utils.py b/backend/geonature/core/gn_meta/mtd/mtd_utils.py index a52d373fc7..e8bde8bf04 100644 --- a/backend/geonature/core/gn_meta/mtd/mtd_utils.py +++ b/backend/geonature/core/gn_meta/mtd/mtd_utils.py @@ -208,7 +208,7 @@ def associate_actors(actors, CorActor, pk_name, pk_value): # FIXME: prevent update of organism email from actor email ! Several actors may be associated to the same organism and still have different mails ! id_organism = add_or_update_organism( uuid=uuid_organism, - nom=actor["organism"] if actor["orgnanism"] else "", + nom=actor["organism"] if actor["organism"] else "", email=actor["email"], ) values = dict( From 475bc026bed27dd45250d2543288654b38baaa55 Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Thu, 6 Jun 2024 14:12:28 +0200 Subject: [PATCH 04/47] add missing code_nomenclature_type in serialization fix #3082 --- backend/geonature/core/gn_commons/models/additional_fields.py | 2 +- backend/geonature/core/gn_commons/schemas.py | 2 +- backend/geonature/tests/test_gn_commons.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/geonature/core/gn_commons/models/additional_fields.py b/backend/geonature/core/gn_commons/models/additional_fields.py index 0c0bb12349..d11e07c7b8 100644 --- a/backend/geonature/core/gn_commons/models/additional_fields.py +++ b/backend/geonature/core/gn_commons/models/additional_fields.py @@ -53,4 +53,4 @@ class TAdditionalFields(DB.Model): ) def __str__(self): - return f"{self.field_label} ({self.description})" + return self.field_label diff --git a/backend/geonature/core/gn_commons/schemas.py b/backend/geonature/core/gn_commons/schemas.py index ce408b5ada..588f69341c 100644 --- a/backend/geonature/core/gn_commons/schemas.py +++ b/backend/geonature/core/gn_commons/schemas.py @@ -101,7 +101,7 @@ class Meta: load_instance = True default_value = CastableField(allow_none=True) - + code_nomenclature_type = fields.Str(allow_none=True) modules = fields.Nested(ModuleSchema, many=True, dump_only=True) objects = fields.Nested(PermObjectSchema, many=True, dump_only=True) type_widget = fields.Nested(BibWidgetSchema, dump_only=True) diff --git a/backend/geonature/tests/test_gn_commons.py b/backend/geonature/tests/test_gn_commons.py index 2f2a658fd5..16e4a5f0e6 100644 --- a/backend/geonature/tests/test_gn_commons.py +++ b/backend/geonature/tests/test_gn_commons.py @@ -506,6 +506,7 @@ def test_get_additional_fields(self, datasets, additional_field): addi_one = data[0] assert "type_widget" in addi_one assert "bib_nomenclature_type" in addi_one + assert "code_nomenclature_type" in addi_one # test default value has been casted assert type(addi_one["default_value"]) is int From c64ec26145aae9d56aada546a48109bd1611355e Mon Sep 17 00:00:00 2001 From: amandine-sahl Date: Thu, 6 Jun 2024 10:58:59 +0200 Subject: [PATCH 05/47] =?UTF-8?q?FIX=20synthese=20query=20:=20filtre=20sta?= =?UTF-8?q?tut=20de=20protection=20des=20taxons=20-=20cas=20taxon=20avec?= =?UTF-8?q?=20plusieurs=20status=20du=20m=C3=AAme=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gn_synthese/utils/query_select_sqla.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/backend/geonature/core/gn_synthese/utils/query_select_sqla.py b/backend/geonature/core/gn_synthese/utils/query_select_sqla.py index b5aa956728..98b42c8b06 100644 --- a/backend/geonature/core/gn_synthese/utils/query_select_sqla.py +++ b/backend/geonature/core/gn_synthese/utils/query_select_sqla.py @@ -539,9 +539,9 @@ def build_bdc_status_pr_nb_lateral_join(self, protection_status_value, red_list_ - les statuts du type demandĂ© par l'utilisateur - les status s'appliquent bien sur la zone gĂ©ographique de la donnĂ©e (c-a-d le dĂ©partement) - IdĂ©e de façon Ă  limiter le nombre de sous reqĂȘtes, + IdĂ©e de façon Ă  limiter le nombre de sous requĂȘtes (le nombre de status demandĂ© ne dĂ©grade pas les performances), la liste des status selectionnĂ©s par l'utilisateur s'appliquant Ă  l'observation est - aggrĂ©gĂ©e de façon Ă  tester le nombre puis jointĂ© sur le dĂ©partement de la donnĂ©e + aggrĂ©gĂ©e de façon Ă  tester le nombre puis jointer sur le dĂ©partement de la donnĂ©e """ # Ajout de la table taxref si non ajoutĂ© self.add_join(Taxref, Taxref.cd_nom, self.model.cd_nom) @@ -559,11 +559,13 @@ def build_bdc_status_pr_nb_lateral_join(self, protection_status_value, red_list_ # Creation requĂȘte CTE : taxon, zone d'application dĂ©partementale des textes # pour les taxons rĂ©pondant aux critĂšres de selection - bdc_status_cte = ( + bdc_status_by_type_cte = ( select( TaxrefBdcStatutTaxon.cd_ref, - func.array_agg(bdc_statut_cor_text_area.c.id_area).label("ids_area"), + bdc_statut_cor_text_area.c.id_area, + TaxrefBdcStatutText.cd_type_statut, ) + .distinct() .select_from( TaxrefBdcStatutTaxon.__table__.join( TaxrefBdcStatutCorTextValues, @@ -601,15 +603,20 @@ def build_bdc_status_pr_nb_lateral_join(self, protection_status_value, red_list_ TaxrefBdcStatutText.cd_type_statut.in_(protection_status_value) ) - bdc_status_cte = bdc_status_cte.where(or_(*bdc_status_filters)) + bdc_status_by_type_cte = bdc_status_by_type_cte.where(or_(*bdc_status_filters)) + bdc_status_by_type_cte = bdc_status_by_type_cte.cte(name="status_by_type") # group by de façon Ă  ne selectionner que les taxons - # qui ont les textes selectionnĂ©s par l'utilisateurs - bdc_status_cte = bdc_status_cte.group_by(TaxrefBdcStatutTaxon.cd_ref).having( - func.count(distinct(TaxrefBdcStatutText.cd_type_statut)) + # qui ont l'ensemble des textes selectionnĂ©s par l'utilisateur + # c-a-d dont le nombre de cd_type_statut correspond au nombre demandĂ© + bdc_status_cte = select( + bdc_status_by_type_cte.c.cd_ref, + func.array_agg(bdc_status_by_type_cte.c.id_area).label("ids_area"), + ) + bdc_status_cte = bdc_status_cte.group_by(bdc_status_by_type_cte.c.cd_ref).having( + func.count(distinct(bdc_status_by_type_cte.c.cd_type_statut)) == (len(protection_status_value) + len(red_list_filters)) ) - bdc_status_cte = bdc_status_cte.cte(name="status") # Jointure sur le taxon From 4721f66c02bafaf9fb25e96030341f740cb21068 Mon Sep 17 00:00:00 2001 From: amandine-sahl Date: Thu, 6 Jun 2024 10:59:38 +0200 Subject: [PATCH 06/47] FIX synthese query : filtre attribut taxref non multiple --- .../geonature/core/gn_synthese/utils/query_select_sqla.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/geonature/core/gn_synthese/utils/query_select_sqla.py b/backend/geonature/core/gn_synthese/utils/query_select_sqla.py index 98b42c8b06..70f1402703 100644 --- a/backend/geonature/core/gn_synthese/utils/query_select_sqla.py +++ b/backend/geonature/core/gn_synthese/utils/query_select_sqla.py @@ -274,6 +274,11 @@ def filter_taxonomy(self): self.query = self.query.where(getattr(Taxref, colname).in_(value)) if colname.startswith("taxhub_attribut"): + # Test si la valeur n'est pas une liste transformation + # de value en liste pour utiliser le filtre IN + if not type(value) is list: + value = [value] + self.add_join(Taxref, Taxref.cd_nom, self.model.cd_nom) taxhub_id_attr = colname[16:] aliased_cor_taxon_attr[taxhub_id_attr] = aliased(CorTaxonAttribut) From 03809c82416118628e1e29722bfebdcf4578df2f Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Thu, 6 Jun 2024 16:49:31 +0200 Subject: [PATCH 07/47] Add tests on bdc filters --- .../geonature/core/gn_synthese/utils/query_select_sqla.py | 4 ++-- backend/geonature/tests/fixtures.py | 3 ++- backend/geonature/tests/test_synthese.py | 6 +++++- config/test_config.toml | 8 +++++++- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/backend/geonature/core/gn_synthese/utils/query_select_sqla.py b/backend/geonature/core/gn_synthese/utils/query_select_sqla.py index 70f1402703..732d0b5df2 100644 --- a/backend/geonature/core/gn_synthese/utils/query_select_sqla.py +++ b/backend/geonature/core/gn_synthese/utils/query_select_sqla.py @@ -318,7 +318,7 @@ def filter_taxonomy(self): protection_status_value += value if protection_status_value or red_list_filters: - self.build_bdc_status_pr_nb_lateral_join(protection_status_value, red_list_filters) + self.build_bdc_status_filters(protection_status_value, red_list_filters) # remove attributes taxhub from filters self.filters = { colname: value @@ -536,7 +536,7 @@ def filter_query_all_filters(self, user, permissions): self.apply_all_filters(user, permissions) return self.build_query() - def build_bdc_status_pr_nb_lateral_join(self, protection_status_value, red_list_filters): + def build_bdc_status_filters(self, protection_status_value, red_list_filters): """ Create subquery for bdc_status filters diff --git a/backend/geonature/tests/fixtures.py b/backend/geonature/tests/fixtures.py index 3b0bf8fdd9..428a93a143 100644 --- a/backend/geonature/tests/fixtures.py +++ b/backend/geonature/tests/fixtures.py @@ -537,7 +537,8 @@ def synthese_data(app, users, datasets, source, sources_modules): data = {} with db.session.begin_nested(): for name, cd_nom, point, ds, comment_description, source_m in [ - ("obs1", 713776, point1, datasets["own_dataset"], "obs1", sources_modules[0]), + # DonnnĂ©es de gypaĂšte : possĂšde des statuts de protection nationale + ("obs1", 2852, point1, datasets["own_dataset"], "obs1", sources_modules[0]), ("obs2", 212, point2, datasets["own_dataset"], "obs2", sources_modules[0]), ("obs3", 2497, point3, datasets["own_dataset"], "obs3", sources_modules[1]), ("p1_af1", 713776, point1, datasets["belong_af_1"], "p1_af1", sources_modules[1]), diff --git a/backend/geonature/tests/test_synthese.py b/backend/geonature/tests/test_synthese.py index a4b157cb6b..9df4cab520 100644 --- a/backend/geonature/tests/test_synthese.py +++ b/backend/geonature/tests/test_synthese.py @@ -333,6 +333,8 @@ def test_get_observations_for_web(self, app, users, synthese_data, taxon_attribu # test status protection filters = {"protections_protection_status": ["PN"]} r = self.client.get(url, json=filters) + # doit au moins contenir une donnĂ©e de gypaĂšte (protection nationale) + assert len(r.json["features"]) >= 1 assert r.status_code == 200 # test status protection and znieff filters = {"protections_protection_status": ["PN"], "znief_protection_status": True} @@ -1253,7 +1255,9 @@ def test_get_taxa_count_id_dataset(self, synthese_data, users, datasets, unexist response = self.client.get(url_for(url), query_string={"id_dataset": id_dataset}) response_empty = self.client.get(url_for(url), query_string={"id_dataset": unexisted_id}) - assert response.json == len(set(synt.cd_nom for synt in synthese_data.values())) + assert response.json == len( + set(synt.cd_nom for synt in synthese_data.values() if synt.id_dataset == id_dataset) + ) assert response_empty.json == 0 def test_get_observation_count(self, synthese_data, users): diff --git a/config/test_config.toml b/config/test_config.toml index 84f8e00c91..03432288eb 100644 --- a/config/test_config.toml +++ b/config/test_config.toml @@ -55,4 +55,10 @@ REF_LAYERS=[ ] [SYNTHESE] -AREA_AGGREGATION_TYPE = "M5" \ No newline at end of file +AREA_AGGREGATION_TYPE = "M5" +STATUS_FILTERS = [ + { "id" = "protections", "show" = true, "display_name" = "Taxons protĂ©gĂ©s", "status_types" = ["PN", "PR", "PD"] }, + { "id" = "regulations", "show" = true, "display_name" = "Taxons rĂ©glementĂ©s", "status_types" = ["REGLII", "REGL", "REGLSO"] }, + { "id" = "invasive", "show" = true, "display_name" = "EspĂšces envahissantes", "status_types" = ["REGLLUTTE"] }, + { "id" = "znief", "show" = true, "display_name" = "EspĂšces dĂ©terminantes ZNIEFF", "status_types" = ["ZDET"] }, +] \ No newline at end of file From 3e174e76e593ca88b0b5b56ce36e20b2a78e042c Mon Sep 17 00:00:00 2001 From: Pierre Narcisi Date: Tue, 11 Jun 2024 11:17:07 +0200 Subject: [PATCH 08/47] fix(synthse) actors display --- .../synthese-info-obs/synthese-info-obs.component.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.ts b/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.ts index df471f4040..70f65eff0d 100644 --- a/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.ts +++ b/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.ts @@ -117,6 +117,10 @@ export class SyntheseInfoObsComponent implements OnInit, OnChanges { this.selectedObs.date_min = date_min.toLocaleDateString('fr-FR'); const date_max = new Date(this.selectedObs.date_max); this.selectedObs.date_max = date_max.toLocaleDateString('fr-FR'); + for (let actor of this.selectedObs.dataset.cor_dataset_actor) { + if (actor.role) actor.display = actor.role.nom_complet; + else if (actor.organism) actor.display = actor.organism.nom_organisme; + } const areaDict = {}; // for each area type we want all the areas: we build an dict of array From f126bf4f673db99702565ab70c4992827e99ff73 Mon Sep 17 00:00:00 2001 From: Pierre Narcisi Date: Tue, 11 Jun 2024 15:26:02 +0200 Subject: [PATCH 09/47] fix(synthese) change variable name --- .../synthese-info-obs/synthese-info-obs.component.html | 2 +- .../synthese-info-obs/synthese-info-obs.component.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.html b/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.html index 83d91e6187..94ffedc4df 100644 --- a/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.html +++ b/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.html @@ -369,7 +369,7 @@

  • - {{ actor.display }} + {{ actor.display_name }}
diff --git a/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.ts b/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.ts index 70f65eff0d..4a4dc8fe53 100644 --- a/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.ts +++ b/frontend/src/app/shared/syntheseSharedModule/synthese-info-obs/synthese-info-obs.component.ts @@ -118,8 +118,8 @@ export class SyntheseInfoObsComponent implements OnInit, OnChanges { const date_max = new Date(this.selectedObs.date_max); this.selectedObs.date_max = date_max.toLocaleDateString('fr-FR'); for (let actor of this.selectedObs.dataset.cor_dataset_actor) { - if (actor.role) actor.display = actor.role.nom_complet; - else if (actor.organism) actor.display = actor.organism.nom_organisme; + if (actor.role) actor.display_name = actor.role.nom_complet; + else if (actor.organism) actor.display_name = actor.organism.nom_organisme; } const areaDict = {}; From f45aae111ae8cdb74278c451ed92d2120f693f06 Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Wed, 10 Jul 2024 17:35:57 +0200 Subject: [PATCH 10/47] relocalize material icon fonts --- frontend/angular.json | 1 + frontend/package-lock.json | 6 ++++++ frontend/package.json | 1 + frontend/src/index.html | 4 ---- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/angular.json b/frontend/angular.json index d4fa1496b7..9a3bc1024a 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -54,6 +54,7 @@ "node_modules/@swimlane/ngx-datatable/themes/material.css", "node_modules/@swimlane/ngx-datatable/assets/icons.css", "src/styles.scss", + "node_modules/material-design-icons/iconfont/material-icons.css", "node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css", "node_modules/@circlon/angular-tree-component/src/lib/angular-tree-component.css", "node_modules/leaflet.markercluster/dist/MarkerCluster.css", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9da0cc7bf6..c02183c51c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -45,6 +45,7 @@ "leaflet.locatecontrol": "^0.79.0", "leaflet.markercluster": "^1.5.3", "lodash": "^4.17.21", + "material-design-icons": "^3.0.1", "moment": "^2.29.4", "ng2-charts": "^4.1.1", "ng2-cookies": "^1.0.12", @@ -9334,6 +9335,11 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/material-design-icons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/material-design-icons/-/material-design-icons-3.0.1.tgz", + "integrity": "sha512-t19Z+QZBwSZulxptEu05kIm+UyfIdJY1JDwI+nx02j269m6W414whiQz9qfvQIiLrdx71RQv+T48nHhuQXOCIQ==" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0180308c46..93e6661a42 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,6 +50,7 @@ "leaflet.locatecontrol": "^0.79.0", "leaflet.markercluster": "^1.5.3", "lodash": "^4.17.21", + "material-design-icons": "^3.0.1", "moment": "^2.29.4", "ng2-charts": "^4.1.1", "ng2-cookies": "^1.0.12", diff --git a/frontend/src/index.html b/frontend/src/index.html index afc2338fe2..f9ca0bf15b 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -9,10 +9,6 @@ name="viewport" content="width=device-width, initial-scale=1" /> - Date: Fri, 29 Mar 2024 11:59:23 +0100 Subject: [PATCH 11/47] feat(custom login): include custom authentification process --- .../UsersHub-authentification-module | 2 +- backend/geonature/app.py | 1 - backend/geonature/custom.py | 160 ++++++++++++++++++ backend/geonature/utils/config.py | 14 +- 4 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 backend/geonature/custom.py diff --git a/backend/dependencies/UsersHub-authentification-module b/backend/dependencies/UsersHub-authentification-module index 41fcc43aee..4a0144a229 160000 --- a/backend/dependencies/UsersHub-authentification-module +++ b/backend/dependencies/UsersHub-authentification-module @@ -1 +1 @@ -Subproject commit 41fcc43aeef8abef7b2b09a890c166c9bbe2a50f +Subproject commit 4a0144a229014c8176a121eaf01d792f0fa568bb diff --git a/backend/geonature/app.py b/backend/geonature/app.py index 5314d28b68..831d4f0305 100755 --- a/backend/geonature/app.py +++ b/backend/geonature/app.py @@ -96,7 +96,6 @@ def create_app(with_external_mods=True): static_url_path=config["STATIC_URL"], template_folder="geonature/templates", ) - app.config.update(config) # Enable deprecation warnings in debug mode diff --git a/backend/geonature/custom.py b/backend/geonature/custom.py new file mode 100644 index 0000000000..640fbbda1a --- /dev/null +++ b/backend/geonature/custom.py @@ -0,0 +1,160 @@ +import datetime +import logging +from typing import Any, Union +import xmltodict + +from flask import Response, current_app, jsonify, make_response, redirect, render_template, request +from geonature.utils import utilsrequests +from geonature.utils.errors import GeonatureApiError +from pypnusershub.authentification import Authentification +from pypnusershub.db import db, models +from pypnusershub.db.tools import encode_token +from pypnusershub.routes import insert_or_update_organism, insert_or_update_role +from sqlalchemy import select + +log = logging.getLogger() + + +class CasAuthentificationError(GeonatureApiError): + pass + + +CAS_AUTHENTIFICATION = False +PUB_URL = "https://ginco2-preprod.mnhn.fr/" +CAS_PUBLIC_DD = 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", +) + + +def get_user_from_id_inpn_ws(id_user): + URL = f"https://inpn.mnhn.fr/authentication/rechercheParId/{id_user}" + try: + response = utilsrequests.get( + URL, + ( + CAS_USER_WS["ID"], + CAS_USER_WS["PASSWORD"], + ), + ) + assert response.status_code == 200 + return response.json() + except AssertionError: + log.error("Error with the inpn authentification service") + + +def insert_user_and_org(info_user): + 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_info = insert_or_update_role(user_info) + user = db.session.get(models.User, user_id) + # if not user.groups: + # if not current_app.config["CAS"]["USERS_CAN_SEE_ORGANISM_DATA"] or organism_id is None: + # # group socle 1 + # group_id = current_app.config["BDD"]["ID_USER_SOCLE_1"] + # else: + # # group socle 2 + # group_id = current_app.config["BDD"]["ID_USER_SOCLE_2"] + # group = db.session.get(models.User, group_id) + # user.groups.append(group) + return user + + +class AuthenficationCASINPN(Authentification): + + def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: + config_cas = current_app.config["CAS"] + params = request.args + if "ticket" in params: + base_url = current_app.config["API_ENDPOINT"] + "/auth/login" + url_validate = "{url}?ticket={ticket}&service={service}".format( + url=CAS_PUBLIC_DD["URL_VALIDATION"], + ticket=params["ticket"], + service=base_url, + ) + + response = utilsrequests.get(url_validate) + user = None + xml_dict = xmltodict.parse(response.content) + resp = xml_dict["cas:serviceResponse"] + if "cas:authenticationSuccess" in resp: + user = resp["cas:authenticationSuccess"]["cas:user"] + if user: + ws_user_url = "{url}/{user}/?verify=false".format(url=CAS_USER_WS["URL"], user=user) + try: + response = utilsrequests.get( + ws_user_url, + ( + CAS_USER_WS["ID"], + CAS_USER_WS["PASSWORD"], + ), + ) + assert response.status_code == 200 + except AssertionError: + log.error("Error with the inpn authentification service") + raise CasAuthentificationError( + "Error with the inpn authentification service", status_code=500 + ) + info_user = response.json() + user = insert_user_and_org(info_user) + 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 + else: + 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=CAS_PUBLIC_DD["URL_LOGOUT"], + url_geonature=current_app.config["URL_APPLICATION"], + ) + return jsonify({"message": "Authentification error"}, 500) + + def revoke(self) -> Any: + pass + + +authentification_class = AuthenficationCASINPN + +# Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privĂ©e) diff --git a/backend/geonature/utils/config.py b/backend/geonature/utils/config.py index f9ad8b6033..6104656eab 100644 --- a/backend/geonature/utils/config.py +++ b/backend/geonature/utils/config.py @@ -4,7 +4,7 @@ from flask import Config from flask.helpers import get_root_path -from marshmallow import EXCLUDE +from marshmallow import EXCLUDE, INCLUDE from marshmallow.exceptions import ValidationError from geonature.utils.config_schema import ( @@ -31,8 +31,8 @@ # Validate config try: - config_backend = GnPySchemaConf().load(config_toml, unknown=EXCLUDE) - config_frontend = GnGeneralSchemaConf().load(config_toml, unknown=EXCLUDE) + config_backend = GnPySchemaConf().load(config_toml, unknown=INCLUDE) + config_frontend = GnGeneralSchemaConf().load(config_toml, unknown=INCLUDE) except ValidationError as e: raise ConfigError(CONFIG_FILE, e.messages) @@ -42,6 +42,14 @@ "SEND_FILE_MAX_AGE_DEFAULT": 0, } + +try: + from geonature.custom import authentification_class + + config_programmatic["authentification_class"] = authentification_class +except ImportError: + pass + config = ChainMap({}, config_programmatic, config_backend, config_frontend, config_default) api_uri = urlsplit(config["API_ENDPOINT"]) From 8402a55c184ab37bf3f5362b1833a8f37d334db8 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 29 Mar 2024 12:00:23 +0100 Subject: [PATCH 12/47] fix(login,cas) : fix bad redirection and localstorage cleaning --- frontend/src/app/components/auth/auth.service.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/auth/auth.service.ts b/frontend/src/app/components/auth/auth.service.ts index 12f5a421df..853ac6e4cc 100644 --- a/frontend/src/app/components/auth/auth.service.ts +++ b/frontend/src/app/components/auth/auth.service.ts @@ -134,13 +134,12 @@ export class AuthService { logout() { this.cleanLocalStorage(); this.cruvedService.clearCruved(); - // call the logout route to delete the session this._http.get(`${this.config.API_ENDPOINT}/auth/logout`).subscribe(() => { location.reload(); }); - if (this.config.CAS_PUBLIC.CAS_AUTHENTIFICATION) { - document.location.href = `${this.config.CAS_PUBLIC.CAS_URL_LOGOUT}?service=${this.config.URL_APPLICATION}`; + if (this.config.CAS_AUTHENTIFICATION) { + document.location.href = `${this.config.CAS_PUBLIC_DD.URL_LOGOUT}?service='${this.config.URL_APPLICATION}'`; } else { this.router.navigate(['/login']); } From 0c7cea7fcc44e1fcf49daae003921a98af6665e7 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 29 Mar 2024 12:03:13 +0100 Subject: [PATCH 13/47] fix(login, casINPN) : change authgard and login component with new INPN cas login method --- .../src/app/modules/login/login/login.component.ts | 2 +- frontend/src/app/routing/auth-guard.service.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/modules/login/login/login.component.ts b/frontend/src/app/modules/login/login/login.component.ts index 691a6d410c..0cc2ca0bdc 100644 --- a/frontend/src/app/modules/login/login/login.component.ts +++ b/frontend/src/app/modules/login/login/login.component.ts @@ -48,7 +48,7 @@ export class LoginComponent implements OnInit { } ngOnInit() { - if (this.config.CAS_PUBLIC.CAS_AUTHENTIFICATION) { + if (this.config.CAS_AUTHENTIFICATION) { // if token not here here, redirection to CAS login page const url_redirection_cas = `${this.config.CAS_PUBLIC.CAS_URL_LOGIN}?service=${this.config.API_ENDPOINT}/gn_auth/login_cas`; if (!this._authService.isLoggedIn()) { diff --git a/frontend/src/app/routing/auth-guard.service.ts b/frontend/src/app/routing/auth-guard.service.ts index a5d3db3406..0d319413a2 100644 --- a/frontend/src/app/routing/auth-guard.service.ts +++ b/frontend/src/app/routing/auth-guard.service.ts @@ -10,6 +10,7 @@ import { AuthService } from '@geonature/components/auth/auth.service'; import { ModuleService } from '@geonature/services/module.service'; import { ConfigService } from '@geonature/services/config.service'; import { RoutingService } from './routing.service'; +import { HttpClient } from '@angular/common/http'; @Injectable() export class AuthGuard implements CanActivate, CanActivateChild { @@ -23,6 +24,7 @@ export class AuthGuard implements CanActivate, CanActivateChild { const moduleService = this._injector.get(ModuleService); const configService = this._injector.get(ConfigService); const routingService = this._injector.get(RoutingService); + const httpclient = this._injector.get(HttpClient) if (!authService.isLoggedIn()) { if ( @@ -45,6 +47,14 @@ export class AuthGuard implements CanActivate, CanActivateChild { return false; } } else { + if (configService.CAS_AUTHENTIFICATION) { + let data = await httpclient + .get(`${configService.API_ENDPOINT}/auth/get_current_user`) + .toPromise(); + data = { ...data }; + authService.manageUser(data); + return authService.isLoggedIn(); + } this._router.navigate(['/login'], { queryParams: { ...route.queryParams, ...{ route: state.url.split('?')[0] } }, }); From 5642b8ddc66652d1b525dffe11bf3694bdab1e8a Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 29 Mar 2024 16:11:21 +0100 Subject: [PATCH 14/47] feat(login, customprovider): use the flask extension template for custom login provider Co-authored-by: TheoLechemia --- backend/geonature/app.py | 4 +- backend/geonature/core/auth/auth_manager.py | 105 ++++++++++++++++++++ backend/geonature/custom.py | 3 +- backend/geonature/utils/config.py | 8 -- 4 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 backend/geonature/core/auth/auth_manager.py diff --git a/backend/geonature/app.py b/backend/geonature/app.py index 831d4f0305..56137f4aef 100755 --- a/backend/geonature/app.py +++ b/backend/geonature/app.py @@ -11,7 +11,6 @@ from importlib_metadata import entry_points else: from importlib.metadata import entry_points - from flask import Flask, g, request, current_app, send_from_directory from flask.json.provider import DefaultJSONProvider from flask_mail import Message @@ -33,6 +32,8 @@ from sqlalchemy.engine import RowProxy as Row from geonature.utils.config import config +from geonature.core.auth.auth_manager import auth_manager + from geonature.utils.env import MAIL, DB, db, MA, migrate, BACKEND_DIR from geonature.utils.logs import config_loggers from geonature.utils.module import iter_modules_dist @@ -97,6 +98,7 @@ def create_app(with_external_mods=True): template_folder="geonature/templates", ) app.config.update(config) + auth_manager.init_app(app) # Enable deprecation warnings in debug mode if app.debug and not sys.warnoptions: diff --git a/backend/geonature/core/auth/auth_manager.py b/backend/geonature/core/auth/auth_manager.py new file mode 100644 index 0000000000..8db23fdd6d --- /dev/null +++ b/backend/geonature/core/auth/auth_manager.py @@ -0,0 +1,105 @@ +from pypnusershub.authentification import Authentification, DefaultConfiguration + + +class AuthManager: + """ + Manages authentication providers. + """ + + def __init__(self) -> None: + """ + Initializes the AuthManager instance. + """ + self.provider_authentication_cls = {"default": DefaultConfiguration} + self.selected_provider = "default" + + 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, provider_name: str, provider_authentification: Authentification) -> None: + """ + Registers a new authentication provider. + + Parameters + ---------- + provider_name : str + The name of the provider. + provider : Authentification + The authentication provider class. + + Returns + ------- + None + + Raises + ------ + AssertionError + If the provider is not an instance of Authentification. + """ + if not issubclass(provider_authentification, Authentification): + raise AssertionError("Provider must be an instance of Authentification") + self.provider_authentication_cls[provider_name] = provider_authentification + + def init_app(self, app) -> None: + """ + Initializes the Flask application with the AuthManager. + + Parameters + ---------- + app : Flask + The Flask application instance. + + Returns + ------- + None + """ + app.auth_manager = self + + def get_current_provider(self) -> Authentification: + """ + Returns the current authentication provider. + + Returns + ------- + Authentification + The current authentication provider. + """ + return self.provider_authentication_cls[self.selected_provider]() + + def set_auth_provider( + self, provider_name: str, provider_authentification_cls: Authentification = None + ) -> None: + """ + Sets the authentication provider. + + Parameters + ---------- + provider_name : str + The name of the provider. + provider_cls : Authentification, optional + The authentication provider class, by default None. + + Returns + ------- + None + """ + if provider_authentification_cls and provider_name not in self: + self.add_provider(provider_name, provider_authentification_cls) + self.selected_provider = provider_name + return self.get_current_provider() + + +auth_manager = AuthManager() diff --git a/backend/geonature/custom.py b/backend/geonature/custom.py index 640fbbda1a..ec7b78e3ae 100644 --- a/backend/geonature/custom.py +++ b/backend/geonature/custom.py @@ -1,6 +1,7 @@ import datetime import logging from typing import Any, Union +from geonature.core.auth.auth_manager import auth_manager import xmltodict from flask import Response, current_app, jsonify, make_response, redirect, render_template, request @@ -155,6 +156,6 @@ def revoke(self) -> Any: pass -authentification_class = AuthenficationCASINPN +auth_manager.set_auth_provider("cas_inpn", AuthenficationCASINPN) # Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privĂ©e) diff --git a/backend/geonature/utils/config.py b/backend/geonature/utils/config.py index 6104656eab..7026b3b79d 100644 --- a/backend/geonature/utils/config.py +++ b/backend/geonature/utils/config.py @@ -42,14 +42,6 @@ "SEND_FILE_MAX_AGE_DEFAULT": 0, } - -try: - from geonature.custom import authentification_class - - config_programmatic["authentification_class"] = authentification_class -except ImportError: - pass - config = ChainMap({}, config_programmatic, config_backend, config_frontend, config_default) api_uri = urlsplit(config["API_ENDPOINT"]) From 9c5aed07089fb6f056e1e2e114449aeda51e5377 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 29 Mar 2024 18:13:10 +0100 Subject: [PATCH 15/47] feat(login, custom provider) : add config var + add new methods to authentification class --- backend/geonature/custom.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/backend/geonature/custom.py b/backend/geonature/custom.py index ec7b78e3ae..846614cf41 100644 --- a/backend/geonature/custom.py +++ b/backend/geonature/custom.py @@ -20,9 +20,14 @@ class CasAuthentificationError(GeonatureApiError): pass -CAS_AUTHENTIFICATION = False +AUTHENTIFICATION_CONFIG = { + "PROVIDER_NAME": "inpn", + "EXTERNAL_PROVIDER": True, +} + +CAS_AUTHENTIFICATION = True PUB_URL = "https://ginco2-preprod.mnhn.fr/" -CAS_PUBLIC_DD = dict( +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", @@ -34,6 +39,7 @@ class CasAuthentificationError(GeonatureApiError): ID="change_value", PASSWORD="change_value", ) +USERS_CAN_SEE_ORGANISM_DATA = False def get_user_from_id_inpn_ws(id_user): @@ -96,12 +102,11 @@ def insert_user_and_org(info_user): class AuthenficationCASINPN(Authentification): def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: - config_cas = current_app.config["CAS"] params = request.args if "ticket" in params: base_url = current_app.config["API_ENDPOINT"] + "/auth/login" url_validate = "{url}?ticket={ticket}&service={service}".format( - url=CAS_PUBLIC_DD["URL_VALIDATION"], + url=CAS_PUBLIC["URL_VALIDATION"], ticket=params["ticket"], service=base_url, ) @@ -147,7 +152,7 @@ def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: log.error("Erreur d'authentification liĂ© au CAS, voir log du CAS") return render_template( "cas_login_error.html", - cas_logout=CAS_PUBLIC_DD["URL_LOGOUT"], + cas_logout=CAS_PUBLIC["URL_LOGOUT"], url_geonature=current_app.config["URL_APPLICATION"], ) return jsonify({"message": "Authentification error"}, 500) @@ -155,7 +160,18 @@ def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: def revoke(self) -> Any: pass + def get_provider_url(self) -> str: + endpoint = current_app.config["API_ENDPOINT"] + base_url = CAS_PUBLIC["URL_LOGIN"] + return f"{base_url}?service={endpoint}/auth/login" + + def get_provider_revoke_url(self) -> str: + endpoint = current_app.config["URL_APPLICATION"] + base_url = CAS_PUBLIC["URL_LOGOUT"] + return f"{base_url}?service={endpoint}" + -auth_manager.set_auth_provider("cas_inpn", AuthenficationCASINPN) +if CAS_AUTHENTIFICATION: + auth_manager.set_auth_provider("cas_inpn", AuthenficationCASINPN) # Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privĂ©e) From 26f8852e1f3c057d89e593fdab78f2c9b9c215a6 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 29 Mar 2024 18:14:42 +0100 Subject: [PATCH 16/47] Update config schema + temporary fix on mtd sync --- .../geonature/core/gn_meta/mtd/__init__.py | 6 +- backend/geonature/core/gn_meta/routes.py | 2 +- backend/geonature/utils/config_schema.py | 55 +++++++------------ 3 files changed, 24 insertions(+), 39 deletions(-) diff --git a/backend/geonature/core/gn_meta/mtd/__init__.py b/backend/geonature/core/gn_meta/mtd/__init__.py index fb7b11ec05..929f3eaf27 100644 --- a/backend/geonature/core/gn_meta/mtd/__init__.py +++ b/backend/geonature/core/gn_meta/mtd/__init__.py @@ -146,9 +146,9 @@ def get_single_af(self, af_uuid): class INPNCAS: - base_url = config["CAS"]["CAS_USER_WS"]["BASE_URL"] - user = config["CAS"]["CAS_USER_WS"]["ID"] - password = config["CAS"]["CAS_USER_WS"]["PASSWORD"] + base_url = "" # FIXME config["CAS"]["CAS_USER_WS"]["BASE_URL"] + user = "" # FIXME config["CAS"]["CAS_USER_WS"]["BASE_URL"] + password = "" # FIXME config["CAS"]["CAS_USER_WS"]["PASSWORD"] id_search_path = "rechercheParId/{user_id}" @classmethod diff --git a/backend/geonature/core/gn_meta/routes.py b/backend/geonature/core/gn_meta/routes.py index 5bb91bbee0..f1bcab34f9 100644 --- a/backend/geonature/core/gn_meta/routes.py +++ b/backend/geonature/core/gn_meta/routes.py @@ -75,7 +75,7 @@ log = logging.getLogger() -if config["CAS_PUBLIC"]["CAS_AUTHENTIFICATION"]: +if config["MTD"]["ACTIVATED"] and config["AUTHENTIFICATION_CONFIG"]["PROVIDER_NAME"] == "inpn": @routes.before_request def synchronize_mtd(): diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index f066dee806..424dfe1fe4 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -40,32 +40,11 @@ def _check_email(self, value): validator(email) -class CasUserSchemaConf(Schema): - URL = fields.Url(load_default="https://inpn.mnhn.fr/authentication/information") - BASE_URL = fields.Url(load_default="https://inpn.mnhn.fr/authentication/") - ID = fields.String(load_default="mon_id") - PASSWORD = fields.String(load_default="mon_pass") - - -class CasFrontend(Schema): - CAS_AUTHENTIFICATION = fields.Boolean(load_default=False) - CAS_URL_LOGIN = fields.Url(load_default="https://preprod-inpn.mnhn.fr/auth/login") - CAS_URL_LOGOUT = fields.Url(load_default="https://preprod-inpn.mnhn.fr/auth/logout") - - -class CasSchemaConf(Schema): - CAS_URL_VALIDATION = fields.String( - load_default="https://preprod-inpn.mnhn.fr/auth/serviceValidate" - ) - CAS_USER_WS = fields.Nested(CasUserSchemaConf, load_default=CasUserSchemaConf().load({})) - USERS_CAN_SEE_ORGANISM_DATA = fields.Boolean(load_default=False) - # Quel modules seront associĂ©s au JDD rĂ©cupĂ©rĂ©s depuis MTD - - class MTDSchemaConf(Schema): JDD_MODULE_CODE_ASSOCIATION = fields.List(fields.String, load_default=["OCCTAX", "OCCHAB"]) ID_INSTANCE_FILTER = fields.Integer(load_default=None) SYNC_LOG_LEVEL = fields.String(load_default="INFO") + ACTIVATED = fields.Boolean(load_default=True, default=False) class BddConfig(Schema): @@ -180,6 +159,11 @@ class MetadataConfig(Schema): ) +class AuthentificationConfig(Schema): + EXTERNAL_PROVIDER = fields.Boolean(load_default=False) + PROVIDER_NAME = fields.String(load_default="default") + + # class a utiliser pour les paramĂštres que l'on ne veut pas passer au frontend @@ -203,7 +187,6 @@ class GnPySchemaConf(Schema): STATIC_FOLDER = fields.String(load_default="static") CUSTOM_STATIC_FOLDER = fields.String(load_default=ROOT_DIR / "custom") MEDIA_FOLDER = fields.String(load_default="media") - CAS = fields.Nested(CasSchemaConf, load_default=CasSchemaConf().load({})) MAIL_ON_ERROR = fields.Boolean(load_default=False) MAIL_CONFIG = fields.Nested(MailConfig, load_default=MailConfig().load({})) CELERY = fields.Nested(CeleryConfig, load_default=CeleryConfig().load({})) @@ -215,6 +198,9 @@ class GnPySchemaConf(Schema): SERVER = fields.Nested(ServerConfig, load_default=ServerConfig().load({})) MEDIAS = fields.Nested(MediasConfig, load_default=MediasConfig().load({})) ALEMBIC = fields.Nested(AlembicConfig, load_default=AlembicConfig().load({})) + AUTHENTIFICATION_CONFIG = fields.Nested( + AuthentificationConfig, load_default=AuthentificationConfig().load({}) + ) @post_load() def folders(self, data, **kwargs): @@ -557,7 +543,6 @@ class GnGeneralSchemaConf(Schema): XML_NAMESPACE = fields.String(load_default="{http://inpn.mnhn.fr/mtd}") MTD_API_ENDPOINT = fields.Url(load_default="https://preprod-inpn.mnhn.fr/mtd") DISABLED_MODULES = fields.List(fields.String(), load_default=[]) - CAS_PUBLIC = fields.Nested(CasFrontend, load_default=CasFrontend().load({})) RIGHTS = fields.Nested(RightsSchemaConf, load_default=RightsSchemaConf().load({})) FRONTEND = fields.Nested(GnFrontEndConf, load_default=GnFrontEndConf().load({})) SYNTHESE = fields.Nested(Synthese, load_default=Synthese().load({})) @@ -581,17 +566,17 @@ class GnGeneralSchemaConf(Schema): PROFILES_REFRESH_CRONTAB = fields.String(load_default="0 3 * * *") MEDIA_CLEAN_CRONTAB = fields.String(load_default="0 1 * * *") - @validates_schema - def validate_enable_sign_up(self, data, **kwargs): - # si CAS_PUBLIC = true and ENABLE_SIGN_UP = true - if data["CAS_PUBLIC"]["CAS_AUTHENTIFICATION"] and ( - data["ACCOUNT_MANAGEMENT"]["ENABLE_SIGN_UP"] - or data["ACCOUNT_MANAGEMENT"]["ENABLE_USER_MANAGEMENT"] - ): - raise ValidationError( - "CAS_PUBLIC et ENABLE_SIGN_UP ou ENABLE_USER_MANAGEMENT ne peuvent ĂȘtre activĂ©s ensemble", - "ENABLE_SIGN_UP, ENABLE_USER_MANAGEMENT", - ) + # @validates_schema + # def validate_enable_sign_up(self, data, **kwargs): + # # si CAS_PUBLIC = true and ENABLE_SIGN_UP = true + # if data["CAS_PUBLIC"]["CAS_AUTHENTIFICATION"] and ( + # data["ACCOUNT_MANAGEMENT"]["ENABLE_SIGN_UP"] + # or data["ACCOUNT_MANAGEMENT"]["ENABLE_USER_MANAGEMENT"] + # ): + # raise ValidationError( + # "CAS_PUBLIC et ENABLE_SIGN_UP ou ENABLE_USER_MANAGEMENT ne peuvent ĂȘtre activĂ©s ensemble", + # "ENABLE_SIGN_UP, ENABLE_USER_MANAGEMENT", + # ) @validates_schema def validate_account_autovalidation(self, data, **kwargs): From a40cde2d27f29b5c2418a8935819f7d8bed52a9a Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 29 Mar 2024 18:15:27 +0100 Subject: [PATCH 17/47] feat(login, custom provider): update frontend to work with new conf and routes change providers route + change custom.py --- backend/geonature/app.py | 4 +- backend/geonature/core/auth/auth_manager.py | 105 ------- backend/geonature/core/auth/providers.py | 51 ++++ backend/geonature/core/auth/routes.py | 122 +------- .../geonature/core/gn_monitoring/models.py | 274 +++++++++--------- .../geonature/core/gn_monitoring/routes.py | 177 +++++------ backend/geonature/custom.py | 207 +++++++++++-- backend/geonature/utils/config_schema.py | 6 +- .../src/app/components/auth/auth.service.ts | 33 ++- .../nav-home/nav-home.component.html | 2 +- .../modules/login/login/login.component.html | 11 +- .../modules/login/login/login.component.ts | 22 +- .../src/app/routing/auth-guard.service.ts | 4 +- 13 files changed, 519 insertions(+), 499 deletions(-) delete mode 100644 backend/geonature/core/auth/auth_manager.py create mode 100644 backend/geonature/core/auth/providers.py diff --git a/backend/geonature/app.py b/backend/geonature/app.py index 56137f4aef..1ad692c6bd 100755 --- a/backend/geonature/app.py +++ b/backend/geonature/app.py @@ -32,7 +32,6 @@ from sqlalchemy.engine import RowProxy as Row from geonature.utils.config import config -from geonature.core.auth.auth_manager import auth_manager from geonature.utils.env import MAIL, DB, db, MA, migrate, BACKEND_DIR from geonature.utils.logs import config_loggers @@ -46,6 +45,7 @@ AccessRightsExpiredError, ) from pypnusershub.db.models import Application +from pypnusershub.auth import auth_manager from pypnusershub.login_manager import login_manager @@ -99,6 +99,7 @@ def create_app(with_external_mods=True): ) app.config.update(config) auth_manager.init_app(app) + auth_manager.home_page = config["URL_APPLICATION"] # Enable deprecation warnings in debug mode if app.debug and not sys.warnoptions: @@ -191,7 +192,6 @@ def set_sentry_context(): ) for blueprint_path, url_prefix in [ - ("pypnusershub.routes:routes", "/auth"), ("pypn_habref_api.routes:routes", "/habref"), ("pypnusershub.routes_register:bp", "/pypn/register"), ("pypnnomenclature.routes:routes", "/nomenclatures"), diff --git a/backend/geonature/core/auth/auth_manager.py b/backend/geonature/core/auth/auth_manager.py deleted file mode 100644 index 8db23fdd6d..0000000000 --- a/backend/geonature/core/auth/auth_manager.py +++ /dev/null @@ -1,105 +0,0 @@ -from pypnusershub.authentification import Authentification, DefaultConfiguration - - -class AuthManager: - """ - Manages authentication providers. - """ - - def __init__(self) -> None: - """ - Initializes the AuthManager instance. - """ - self.provider_authentication_cls = {"default": DefaultConfiguration} - self.selected_provider = "default" - - 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, provider_name: str, provider_authentification: Authentification) -> None: - """ - Registers a new authentication provider. - - Parameters - ---------- - provider_name : str - The name of the provider. - provider : Authentification - The authentication provider class. - - Returns - ------- - None - - Raises - ------ - AssertionError - If the provider is not an instance of Authentification. - """ - if not issubclass(provider_authentification, Authentification): - raise AssertionError("Provider must be an instance of Authentification") - self.provider_authentication_cls[provider_name] = provider_authentification - - def init_app(self, app) -> None: - """ - Initializes the Flask application with the AuthManager. - - Parameters - ---------- - app : Flask - The Flask application instance. - - Returns - ------- - None - """ - app.auth_manager = self - - def get_current_provider(self) -> Authentification: - """ - Returns the current authentication provider. - - Returns - ------- - Authentification - The current authentication provider. - """ - return self.provider_authentication_cls[self.selected_provider]() - - def set_auth_provider( - self, provider_name: str, provider_authentification_cls: Authentification = None - ) -> None: - """ - Sets the authentication provider. - - Parameters - ---------- - provider_name : str - The name of the provider. - provider_cls : Authentification, optional - The authentication provider class, by default None. - - Returns - ------- - None - """ - if provider_authentification_cls and provider_name not in self: - self.add_provider(provider_name, provider_authentification_cls) - self.selected_provider = provider_name - return self.get_current_provider() - - -auth_manager = AuthManager() diff --git a/backend/geonature/core/auth/providers.py b/backend/geonature/core/auth/providers.py new file mode 100644 index 0000000000..23279eb54d --- /dev/null +++ b/backend/geonature/core/auth/providers.py @@ -0,0 +1,51 @@ +import requests +from flask import request +from werkzeug.exceptions import HTTPException +from sqlalchemy import select + +from geonature.utils.env import db +from pypnusershub.auth import Authentication +from pypnusershub.db.models import User + + +class ExternalGNAuthProvider(Authentication): + def __init__(self, base_url, id_group): + super().__init__("gn_ecrins") + self.base_url = base_url + self.id_group = id_group + + def authenticate(self): + params = request.json + print(self.base_url) + url = self.base_url + "/api/auth/login" + login_response = requests.post( + url, + json={"login": params.get("login"), "password": params.get("password")}, + ) + if login_response.status_code != 200: + raise HTTPException("Fail connect") + return self._get_or_create_user(login_response.json()["user"]) + + def _get_or_create_user(self, user): + db_user = db.session.execute( + select(User).where(User.identifiant == user["identifiant"]) + ).scalar_one_or_none() + group = db.session.get(User, self.id_group) + if not db_user: + new_user = User( + identifiant=user["identifiant"], + nom_role=user["nom_role"], + prenom_role=user["prenom_role"], + groups=[group], + ) + db.session.add(new_user) + db.session.commit() + return new_user + + return db_user + + def revoke(self): + pass + + def get_provider_url(self) -> str: + return "" diff --git a/backend/geonature/core/auth/routes.py b/backend/geonature/core/auth/routes.py index 58902cdd92..e12aa6330d 100644 --- a/backend/geonature/core/auth/routes.py +++ b/backend/geonature/core/auth/routes.py @@ -37,120 +37,14 @@ log = logging.getLogger() -@routes.route("/login_cas", methods=["GET", "POST"]) -def loginCas(): - """ - Login route with the INPN CAS - - .. :quickref: User; - """ - config_cas = current_app.config["CAS"] - params = request.args - if "ticket" in params: - base_url = current_app.config["API_ENDPOINT"] + "/gn_auth/login_cas" - url_validate = "{url}?ticket={ticket}&service={service}".format( - url=config_cas["CAS_URL_VALIDATION"], - ticket=params["ticket"], - service=base_url, - ) - - response = utilsrequests.get(url_validate) - data = None - xml_dict = xmltodict.parse(response.content) - resp = xml_dict["cas:serviceResponse"] - if "cas:authenticationSuccess" in resp: - data = resp["cas:authenticationSuccess"]["cas:user"] - if data: - ws_user_url = "{url}/{user}/?verify=false".format( - url=config_cas["CAS_USER_WS"]["URL"], user=data - ) - try: - response = utilsrequests.get( - ws_user_url, - ( - config_cas["CAS_USER_WS"]["ID"], - config_cas["CAS_USER_WS"]["PASSWORD"], - ), - ) - assert response.status_code == 200 - except AssertionError: - log.error("Error with the inpn authentification service") - raise CasAuthentificationError( - "Error with the inpn authentification service", status_code=500 - ) - info_user = response.json() - data = insert_user_and_org(info_user, update_user_organism=False) - db.session.commit() - - # creation de la Response - response = make_response(redirect(current_app.config["URL_APPLICATION"])) - cookie_exp = datetime.datetime.utcnow() - expiration = current_app.config["COOKIE_EXPIRATION"] - cookie_exp += datetime.timedelta(seconds=expiration) - data["id_application"] = ( - db.session.execute( - select(Application).filter_by( - code_application=current_app.config["CODE_APPLICATION"] - ) - ) - .scalar_one() - .id_application - ) - token = encode_token(data) - - token_exp = datetime.datetime.now(datetime.timezone.utc) - token_exp += datetime.timedelta(seconds=current_app.config["COOKIE_EXPIRATION"]) - - # User cookie - organism_id = info_user["codeOrganisme"] - if not organism_id: - organism_id = ( - db.session.execute( - select(Organisme).filter_by(nom_organisme="Autre"), - ) - .scalar_one() - .id_organisme, - ) - current_user = { - "user_login": data["identifiant"], - "id_role": data["id_role"], - "id_organisme": organism_id, - } - - # Log the user in - user = db.session.execute( - sa.select(models.User) - .where(models.User.identifiant == current_user["user_login"]) - .where(models.User.filter_by_app()) - ).scalar_one() - login_user(user) - - return response - else: - 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=current_app.config["CAS_PUBLIC"]["CAS_URL_LOGOUT"], - url_geonature=current_app.config["URL_APPLICATION"], - ) - return jsonify({"message": "Authentification error"}, 500) - - -@routes.route("/logout_cruved", methods=["GET"]) -@json_resp -def logout_cruved(): - """ - Route to logout with cruved - To avoid multiples server call, we store the cruved in the session - when the user logout we need clear the session to get the new cruved session - - .. :quickref: User; - """ - copy_session_key = copy(session) - for key in copy_session_key: - session.pop(key) - return "Logout", 200 +@routes.route("/providers", methods=["GET"]) +def get_providers(): + property_name = ["id_provider", "is_geonature", "logo", "label", "login_url"] + return [ + {getattr(provider, _property) for _property in property_name} + for _, provider in current_app.auth_manager.provider_authentication_cls.items() + ] + return list(current_app.auth_manager.provider_authentication_cls.keys()) def get_user_from_id_inpn_ws(id_user): diff --git a/backend/geonature/core/gn_monitoring/models.py b/backend/geonature/core/gn_monitoring/models.py index b764fa6a00..25f1f36406 100644 --- a/backend/geonature/core/gn_monitoring/models.py +++ b/backend/geonature/core/gn_monitoring/models.py @@ -21,140 +21,140 @@ from geonature.utils.env import DB -corVisitObserver = DB.Table( - "cor_visit_observer", - DB.Column( - "id_base_visit", - DB.Integer, - ForeignKey("gn_monitoring.t_base_visits.id_base_visit"), - primary_key=True, - ), - DB.Column( - "id_role", - DB.Integer, - ForeignKey("utilisateurs.t_roles.id_role"), - primary_key=True, - ), - schema="gn_monitoring", -) - - -corSiteModule = DB.Table( - "cor_site_module", - DB.Column( - "id_base_site", - DB.Integer, - ForeignKey("gn_monitoring.t_base_sites.id_base_site"), - primary_key=True, - ), - DB.Column( - "id_module", - DB.Integer, - ForeignKey("gn_commons.t_modules.id_module"), - primary_key=True, - ), - schema="gn_monitoring", -) - -corSiteArea = DB.Table( - "cor_site_area", - DB.Column( - "id_base_site", - DB.Integer, - ForeignKey("gn_monitoring.t_base_sites.id_base_site"), - primary_key=True, - ), - DB.Column("id_area", DB.Integer, ForeignKey(LAreas.id_area), primary_key=True), - schema="gn_monitoring", -) - - -@serializable -class TBaseVisits(DB.Model): - """ - Table de centralisation des visites liĂ©es Ă  un site - """ - - __tablename__ = "t_base_visits" - __table_args__ = {"schema": "gn_monitoring"} - id_base_visit = DB.Column(DB.Integer, primary_key=True) - id_base_site = DB.Column(DB.Integer, ForeignKey("gn_monitoring.t_base_sites.id_base_site")) - id_digitiser = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) - id_dataset = DB.Column(DB.Integer, ForeignKey("gn_meta.t_datasets.id_dataset")) - # Pour le moment non dĂ©fini comme une clĂ© Ă©trangĂšre - # pour les questions de perfs - # a voir en fonction des usage - id_module = DB.Column(DB.Integer) - - visit_date_min = DB.Column(DB.DateTime) - visit_date_max = DB.Column(DB.DateTime) - id_nomenclature_tech_collect_campanule = DB.Column(DB.Integer) - id_nomenclature_grp_typ = DB.Column(DB.Integer) - comments = DB.Column(DB.Unicode) - uuid_base_visit = DB.Column(UUID(as_uuid=True), default=select(func.uuid_generate_v4())) - - meta_create_date = DB.Column(DB.DateTime) - meta_update_date = DB.Column(DB.DateTime) - - digitiser = relationship( - User, primaryjoin=(User.id_role == id_digitiser), foreign_keys=[id_digitiser] - ) - - observers = DB.relationship( - User, - secondary=corVisitObserver, - primaryjoin=(corVisitObserver.c.id_base_visit == id_base_visit), - secondaryjoin=(corVisitObserver.c.id_role == User.id_role), - foreign_keys=[corVisitObserver.c.id_base_visit, corVisitObserver.c.id_role], - ) - - dataset = relationship( - TDatasets, - lazy="joined", - primaryjoin=(TDatasets.id_dataset == id_dataset), - foreign_keys=[id_dataset], - ) - - -@serializable -@geoserializable(geoCol="geom", idCol="id_base_site") -class TBaseSites(DB.Model): - """ - Table centralisant les donnĂ©es Ă©lĂ©mentaire des sites - """ - - __tablename__ = "t_base_sites" - __table_args__ = {"schema": "gn_monitoring"} - id_base_site = DB.Column(DB.Integer, primary_key=True) - id_inventor = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) - id_digitiser = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) - id_nomenclature_type_site = DB.Column(DB.Integer) - base_site_name = DB.Column(DB.Unicode) - base_site_description = DB.Column(DB.Unicode) - base_site_code = DB.Column(DB.Unicode) - first_use_date = DB.Column(DB.DateTime) - geom = DB.Column(Geometry("GEOMETRY", 4326)) - uuid_base_site = DB.Column(UUID(as_uuid=True), default=select(func.uuid_generate_v4())) - - meta_create_date = DB.Column(DB.DateTime) - meta_update_date = DB.Column(DB.DateTime) - altitude_min = DB.Column(DB.Integer) - altitude_max = DB.Column(DB.Integer) - digitiser = relationship( - User, primaryjoin=(User.id_role == id_digitiser), foreign_keys=[id_digitiser] - ) - inventor = relationship( - User, primaryjoin=(User.id_role == id_inventor), foreign_keys=[id_inventor] - ) - - t_base_visits = relationship("TBaseVisits", lazy="select", cascade="all,delete-orphan") - - modules = DB.relationship( - "TModules", - lazy="select", - enable_typechecks=False, - secondary=corSiteModule, - primaryjoin=(corSiteModule.c.id_base_site == id_base_site), - secondaryjoin=(corSiteModule.c.id_module == TModules.id_module), - foreign_keys=[corSiteModule.c.id_base_site, corSiteModule.c.id_module], - ) +# corVisitObserver = DB.Table( +# "cor_visit_observer", +# DB.Column( +# "id_base_visit", +# DB.Integer, +# ForeignKey("gn_monitoring.t_base_visits.id_base_visit"), +# primary_key=True, +# ), +# DB.Column( +# "id_role", +# DB.Integer, +# ForeignKey("utilisateurs.t_roles.id_role"), +# primary_key=True, +# ), +# schema="gn_monitoring", +# ) + + +# corSiteModule = DB.Table( +# "cor_site_module", +# DB.Column( +# "id_base_site", +# DB.Integer, +# ForeignKey("gn_monitoring.t_base_sites.id_base_site"), +# primary_key=True, +# ), +# DB.Column( +# "id_module", +# DB.Integer, +# ForeignKey("gn_commons.t_modules.id_module"), +# primary_key=True, +# ), +# schema="gn_monitoring", +# ) + +# corSiteArea = DB.Table( +# "cor_site_area", +# DB.Column( +# "id_base_site", +# DB.Integer, +# ForeignKey("gn_monitoring.t_base_sites.id_base_site"), +# primary_key=True, +# ), +# DB.Column("id_area", DB.Integer, ForeignKey(LAreas.id_area), primary_key=True), +# schema="gn_monitoring", +# ) + + +# @serializable +# class TBaseVisits(DB.Model): +# """ +# Table de centralisation des visites liĂ©es Ă  un site +# """ + +# __tablename__ = "t_base_visits" +# __table_args__ = {"schema": "gn_monitoring"} +# id_base_visit = DB.Column(DB.Integer, primary_key=True) +# id_base_site = DB.Column(DB.Integer, ForeignKey("gn_monitoring.t_base_sites.id_base_site")) +# id_digitiser = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) +# id_dataset = DB.Column(DB.Integer, ForeignKey("gn_meta.t_datasets.id_dataset")) +# # Pour le moment non dĂ©fini comme une clĂ© Ă©trangĂšre +# # pour les questions de perfs +# # a voir en fonction des usage +# id_module = DB.Column(DB.Integer) + +# visit_date_min = DB.Column(DB.DateTime) +# visit_date_max = DB.Column(DB.DateTime) +# id_nomenclature_tech_collect_campanule = DB.Column(DB.Integer) +# id_nomenclature_grp_typ = DB.Column(DB.Integer) +# comments = DB.Column(DB.Unicode) +# uuid_base_visit = DB.Column(UUID(as_uuid=True), default=select(func.uuid_generate_v4())) + +# meta_create_date = DB.Column(DB.DateTime) +# meta_update_date = DB.Column(DB.DateTime) + +# digitiser = relationship( +# User, primaryjoin=(User.id_role == id_digitiser), foreign_keys=[id_digitiser] +# ) + +# observers = DB.relationship( +# User, +# secondary=corVisitObserver, +# primaryjoin=(corVisitObserver.c.id_base_visit == id_base_visit), +# secondaryjoin=(corVisitObserver.c.id_role == User.id_role), +# foreign_keys=[corVisitObserver.c.id_base_visit, corVisitObserver.c.id_role], +# ) + +# dataset = relationship( +# TDatasets, +# lazy="joined", +# primaryjoin=(TDatasets.id_dataset == id_dataset), +# foreign_keys=[id_dataset], +# ) + + +# @serializable +# @geoserializable(geoCol="geom", idCol="id_base_site") +# class TBaseSites(DB.Model): +# """ +# Table centralisant les donnĂ©es Ă©lĂ©mentaire des sites +# """ + +# __tablename__ = "t_base_sites" +# __table_args__ = {"schema": "gn_monitoring"} +# id_base_site = DB.Column(DB.Integer, primary_key=True) +# id_inventor = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) +# id_digitiser = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) +# id_nomenclature_type_site = DB.Column(DB.Integer) +# base_site_name = DB.Column(DB.Unicode) +# base_site_description = DB.Column(DB.Unicode) +# base_site_code = DB.Column(DB.Unicode) +# first_use_date = DB.Column(DB.DateTime) +# geom = DB.Column(Geometry("GEOMETRY", 4326)) +# uuid_base_site = DB.Column(UUID(as_uuid=True), default=select(func.uuid_generate_v4())) + +# meta_create_date = DB.Column(DB.DateTime) +# meta_update_date = DB.Column(DB.DateTime) +# altitude_min = DB.Column(DB.Integer) +# altitude_max = DB.Column(DB.Integer) +# digitiser = relationship( +# User, primaryjoin=(User.id_role == id_digitiser), foreign_keys=[id_digitiser] +# ) +# inventor = relationship( +# User, primaryjoin=(User.id_role == id_inventor), foreign_keys=[id_inventor] +# ) + +# t_base_visits = relationship("TBaseVisits", lazy="select", cascade="all,delete-orphan") + +# modules = DB.relationship( +# "TModules", +# lazy="select", +# enable_typechecks=False, +# secondary=corSiteModule, +# primaryjoin=(corSiteModule.c.id_base_site == id_base_site), +# secondaryjoin=(corSiteModule.c.id_module == TModules.id_module), +# foreign_keys=[corSiteModule.c.id_base_site, corSiteModule.c.id_module], +# ) diff --git a/backend/geonature/core/gn_monitoring/routes.py b/backend/geonature/core/gn_monitoring/routes.py index eafe78c45d..b3b8d0372f 100644 --- a/backend/geonature/core/gn_monitoring/routes.py +++ b/backend/geonature/core/gn_monitoring/routes.py @@ -3,7 +3,8 @@ from geojson import FeatureCollection from geonature.utils.env import DB -from geonature.core.gn_monitoring.models import TBaseSites, corSiteArea, corSiteModule + +# from geonature.core.gn_monitoring.models import TBaseSites, corSiteArea, corSiteModule from utils_flask_sqla.response import json_resp from utils_flask_sqla_geo.generic import get_geojson_feature @@ -14,90 +15,90 @@ routes = Blueprint("gn_monitoring", __name__) -@routes.route("/siteslist", methods=["GET"]) -@json_resp -def get_list_sites(): - """ - Return the sites list for an application in a dict {id_base_site, nom site} - .. :quickref: Monitoring; - - :param id_base_site: id of base site - :param module_code: code of the module - :param id_module: id of the module - :param base_site_name: part of the name of the site - :param type: int - """ - query = select(TBaseSites) - parameters = request.args - - if parameters.get("module_code"): - query = query.where(TBaseSites.modules.any(module_code=parameters.get("module_code"))) - - if parameters.get("id_module"): - query = query.where(TBaseSites.modules.any(id_module=parameters.get("id_module"))) - - if parameters.get("id_base_site"): - query = query.where(TBaseSites.id_base_site == parameters.get("id_base_site")) - - if parameters.get("base_site_name"): - query = query.where( - TBaseSites.base_site_name.ilike("%{}%".format(parameters.get("base_site_name"))) - ) - - data = DB.session.scalars(query).all() - return [n.as_dict(fields=["id_base_site", "base_site_name"]) for n in data] - - -@routes.route("/siteslist/", methods=["GET"]) -@json_resp -def get_onelist_site(id_site): - """ - Get minimal information for a site {id_base_site, nom site} - .. :quickref: Monitoring; - - :param id_site: id of base site - :param type: int - """ - query = select( - TBaseSites.id_base_site, TBaseSites.base_site_name, TBaseSites.base_site_code - ).where(TBaseSites.id_base_site == id_site) - - data = DB.session.execute(query).scalar_one() - return {"id_base_site": data.id_base_site, "base_site_name": data.base_site_name} - - -@routes.route("/siteareas/", methods=["GET"]) -@json_resp -def get_site_areas(id_site): - """ - Get areas of a site from cor_site_area as geojson - - .. :quickref: Monitoring; - - :param id_module: int - :type id_module: int - :param id_area_type: - :type id_area_type: int - """ - params = request.args - - query = ( - select(corSiteArea, func.ST_Transform(LAreas.geom, 4326)) - .join(LAreas, LAreas.id_area == corSiteArea.c.id_area) - .where(corSiteArea.c.id_base_site == id_site) - ) - - if "id_area_type" in params: - query = query.where(LAreas.id_type == params["id_area_type"]) - if "id_module" in params: - query = query.join(corSiteModule, corSiteModule.c.id_base_site == id_site).where( - corSiteModule.c.id_module == params["id_module"] - ) - - data = DB.session.execute(query).all() - features = [] - for d in data: - feature = get_geojson_feature(d[2]) - feature["id"] = d[1] - features.append(feature) - return FeatureCollection(features) +# @routes.route("/siteslist", methods=["GET"]) +# @json_resp +# def get_list_sites(): +# """ +# Return the sites list for an application in a dict {id_base_site, nom site} +# .. :quickref: Monitoring; + +# :param id_base_site: id of base site +# :param module_code: code of the module +# :param id_module: id of the module +# :param base_site_name: part of the name of the site +# :param type: int +# """ +# query = select(TBaseSites) +# parameters = request.args + +# if parameters.get("module_code"): +# query = query.where(TBaseSites.modules.any(module_code=parameters.get("module_code"))) + +# if parameters.get("id_module"): +# query = query.where(TBaseSites.modules.any(id_module=parameters.get("id_module"))) + +# if parameters.get("id_base_site"): +# query = query.where(TBaseSites.id_base_site == parameters.get("id_base_site")) + +# if parameters.get("base_site_name"): +# query = query.where( +# TBaseSites.base_site_name.ilike("%{}%".format(parameters.get("base_site_name"))) +# ) + +# data = DB.session.scalars(query).all() +# return [n.as_dict(fields=["id_base_site", "base_site_name"]) for n in data] + + +# @routes.route("/siteslist/", methods=["GET"]) +# @json_resp +# def get_onelist_site(id_site): +# """ +# Get minimal information for a site {id_base_site, nom site} +# .. :quickref: Monitoring; + +# :param id_site: id of base site +# :param type: int +# """ +# query = select( +# TBaseSites.id_base_site, TBaseSites.base_site_name, TBaseSites.base_site_code +# ).where(TBaseSites.id_base_site == id_site) + +# data = DB.session.execute(query).scalar_one() +# return {"id_base_site": data.id_base_site, "base_site_name": data.base_site_name} + + +# @routes.route("/siteareas/", methods=["GET"]) +# @json_resp +# def get_site_areas(id_site): +# """ +# Get areas of a site from cor_site_area as geojson + +# .. :quickref: Monitoring; + +# :param id_module: int +# :type id_module: int +# :param id_area_type: +# :type id_area_type: int +# """ +# params = request.args + +# query = ( +# select(corSiteArea, func.ST_Transform(LAreas.geom, 4326)) +# .join(LAreas, LAreas.id_area == corSiteArea.c.id_area) +# .where(corSiteArea.c.id_base_site == id_site) +# ) + +# if "id_area_type" in params: +# query = query.where(LAreas.id_type == params["id_area_type"]) +# if "id_module" in params: +# query = query.join(corSiteModule, corSiteModule.c.id_base_site == id_site).where( +# corSiteModule.c.id_module == params["id_module"] +# ) + +# data = DB.session.scalars(query).all() +# features = [] +# for d in data: +# feature = get_geojson_feature(d[2]) +# feature["id"] = d[1] +# features.append(feature) +# return FeatureCollection(features) diff --git a/backend/geonature/custom.py b/backend/geonature/custom.py index 846614cf41..bb5bfb4e7e 100644 --- a/backend/geonature/custom.py +++ b/backend/geonature/custom.py @@ -1,13 +1,14 @@ import datetime import logging from typing import Any, Union -from geonature.core.auth.auth_manager import auth_manager + import xmltodict from flask import Response, current_app, jsonify, make_response, redirect, render_template, request from geonature.utils import utilsrequests from geonature.utils.errors import GeonatureApiError -from pypnusershub.authentification import Authentification +from geonature.core.auth.providers import ExternalGNAuthProvider +from pypnusershub.auth import auth_manager, Authentication from pypnusershub.db import db, models from pypnusershub.db.tools import encode_token from pypnusershub.routes import insert_or_update_organism, insert_or_update_role @@ -25,19 +26,165 @@ class CasAuthentificationError(GeonatureApiError): "EXTERNAL_PROVIDER": True, } -CAS_AUTHENTIFICATION = True -PUB_URL = "https://ginco2-preprod.mnhn.fr/" -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_AUTHENTIFICATION = True +# PUB_URL = "https://ginco2-preprod.mnhn.fr/" +# 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 + + +# def get_user_from_id_inpn_ws(id_user): +# URL = f"https://inpn.mnhn.fr/authentication/rechercheParId/{id_user}" +# try: +# response = utilsrequests.get( +# URL, +# ( +# CAS_USER_WS["ID"], +# CAS_USER_WS["PASSWORD"], +# ), +# ) +# assert response.status_code == 200 +# return response.json() +# except AssertionError: +# log.error("Error with the inpn authentification service") + + +# def insert_user_and_org(info_user): +# 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_info = insert_or_update_role(user_info) +# user = db.session.get(models.User, user_id) +# # if not user.groups: +# # if not current_app.config["CAS"]["USERS_CAN_SEE_ORGANISM_DATA"] or organism_id is None: +# # # group socle 1 +# # group_id = current_app.config["BDD"]["ID_USER_SOCLE_1"] +# # else: +# # # group socle 2 +# # group_id = current_app.config["BDD"]["ID_USER_SOCLE_2"] +# # group = db.session.get(models.User, group_id) +# # user.groups.append(group) +# return user + + +# class AuthenficationCASINPN(Authentification): + +# def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: +# params = request.args +# if "ticket" in params: +# base_url = current_app.config["API_ENDPOINT"] + "/auth/login" +# url_validate = "{url}?ticket={ticket}&service={service}".format( +# url=CAS_PUBLIC["URL_VALIDATION"], +# ticket=params["ticket"], +# service=base_url, +# ) + +# response = utilsrequests.get(url_validate) +# user = None +# xml_dict = xmltodict.parse(response.content) +# resp = xml_dict["cas:serviceResponse"] +# if "cas:authenticationSuccess" in resp: +# user = resp["cas:authenticationSuccess"]["cas:user"] +# if user: +# ws_user_url = "{url}/{user}/?verify=false".format(url=CAS_USER_WS["URL"], user=user) +# try: +# response = utilsrequests.get( +# ws_user_url, +# ( +# CAS_USER_WS["ID"], +# CAS_USER_WS["PASSWORD"], +# ), +# ) +# assert response.status_code == 200 +# except AssertionError: +# log.error("Error with the inpn authentification service") +# raise CasAuthentificationError( +# "Error with the inpn authentification service", status_code=500 +# ) +# info_user = response.json() +# user = insert_user_and_org(info_user) +# 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 +# else: +# 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=CAS_PUBLIC["URL_LOGOUT"], +# url_geonature=current_app.config["URL_APPLICATION"], +# ) +# return jsonify({"message": "Authentification error"}, 500) + +# def revoke(self) -> Any: +# pass + +# def get_provider_url(self) -> str: +# endpoint = current_app.config["API_ENDPOINT"] +# base_url = CAS_PUBLIC["URL_LOGIN"] +# return f"{base_url}?service={endpoint}/auth/login" + +# def get_provider_revoke_url(self) -> str: +# endpoint = current_app.config["URL_APPLICATION"] +# base_url = CAS_PUBLIC["URL_LOGOUT"] +# return f"{base_url}?service={endpoint}" + + +# if CAS_AUTHENTIFICATION: +# auth_manager.set_auth_provider("cas_inpn", AuthenficationCASINPN) + +auth_manager.add_provider( + ExternalGNAuthProvider(base_url="https://geonature.ecrins-parcnational.fr", id_group=2), ) +from .custom_bis import user_cs, pw CAS_USER_WS = dict( URL="https://inpn.mnhn.fr/authentication/information", BASE_URL="https://inpn.mnhn.fr/authentication/", - ID="change_value", - PASSWORD="change_value", + ID=user_cs, + PASSWORD=pw, ) USERS_CAN_SEE_ORGANISM_DATA = False @@ -99,7 +246,28 @@ def insert_user_and_org(info_user): return user -class AuthenficationCASINPN(Authentification): +class AuthenficationCASINPN(Authentication): + + def __init__(self) -> None: + + def login(): + gn_api = current_app.config["API_ENDPOINT"] + base_url = CAS_PUBLIC["URL_LOGIN"] + return f"{CAS_PUBLIC['URL_LOGOUT']}?service={auth_manager.home_page}" + + def logout(): + gn_api = current_app.config["API_ENDPOINT"] + base_url = CAS_PUBLIC["URL_LOGIN"] + return f"{base_url}?service={gn_api}/auth/login" + + super().__init__( + "cas_inpn", + login_url=property(login), + logout_url=property(logout), + is_external=True, + is_uh=False, + label="Cas INPN", + ) def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: params = request.args @@ -160,18 +328,13 @@ def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: def revoke(self) -> Any: pass - def get_provider_url(self) -> str: - endpoint = current_app.config["API_ENDPOINT"] - base_url = CAS_PUBLIC["URL_LOGIN"] - return f"{base_url}?service={endpoint}/auth/login" - - def get_provider_revoke_url(self) -> str: - endpoint = current_app.config["URL_APPLICATION"] - base_url = CAS_PUBLIC["URL_LOGOUT"] - return f"{base_url}?service={endpoint}" - if CAS_AUTHENTIFICATION: - auth_manager.set_auth_provider("cas_inpn", AuthenficationCASINPN) + auth_manager.add_provider(AuthenficationCASINPN()) + +# auth_manager.add_provider( +# "gn_ecrins", +# ExternalGNAuthProvider(base_url="https://geonature.ecrins-parcnational.fr", id_group=2), +# ) # Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privĂ©e) diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index 424dfe1fe4..88d612ae1c 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -198,9 +198,6 @@ class GnPySchemaConf(Schema): SERVER = fields.Nested(ServerConfig, load_default=ServerConfig().load({})) MEDIAS = fields.Nested(MediasConfig, load_default=MediasConfig().load({})) ALEMBIC = fields.Nested(AlembicConfig, load_default=AlembicConfig().load({})) - AUTHENTIFICATION_CONFIG = fields.Nested( - AuthentificationConfig, load_default=AuthentificationConfig().load({}) - ) @post_load() def folders(self, data, **kwargs): @@ -565,6 +562,9 @@ class GnGeneralSchemaConf(Schema): NOTIFICATIONS_ENABLED = fields.Boolean(load_default=True) PROFILES_REFRESH_CRONTAB = fields.String(load_default="0 3 * * *") MEDIA_CLEAN_CRONTAB = fields.String(load_default="0 1 * * *") + AUTHENTIFICATION_CONFIG = fields.Nested( + AuthentificationConfig, load_default=AuthentificationConfig().load({}) + ) # @validates_schema # def validate_enable_sign_up(self, data, **kwargs): diff --git a/frontend/src/app/components/auth/auth.service.ts b/frontend/src/app/components/auth/auth.service.ts index 853ac6e4cc..da867886c5 100644 --- a/frontend/src/app/components/auth/auth.service.ts +++ b/frontend/src/app/components/auth/auth.service.ts @@ -41,10 +41,26 @@ export class AuthService { public config: ConfigService ) {} + /** + * Retrieves the URL for the external authentication provider. + * + * @return {Observable} The URL of the external authentication provider. + */ + getLoginExternalProviderUrl() { + // Constructs the URL for the external authentication provider using the API endpoint from the configuration. + const url = `${this.config.API_ENDPOINT}/auth/external_provider_url`; + + // Sends an HTTP GET request to the constructed URL and returns the result. + return this._http.get(url); + } setCurrentUser(user) { localStorage.setItem(this.prefix + 'current_user', JSON.stringify(user)); } + getAuthProviders(): Observable> { + return this._http.get>(`${this.config.API_ENDPOINT}/gn_auth/providers`); + } + getCurrentUser() { let currentUser = localStorage.getItem(this.prefix + 'current_user'); return JSON.parse(currentUser); @@ -77,13 +93,8 @@ export class AuthService { localStorage.setItem(this.prefix + 'expires_at', authResult.expires); } - signinUser(user: any) { - const options = { - login: user.username, - password: user.password, - }; - - return this._http.post(`${this.config.API_ENDPOINT}/auth/login`, options); + signinUser(form: any) { + return this._http.post(`${this.config.API_ENDPOINT}/auth/login/gn_ecrins`, form); } signinPublicUser(): Observable { @@ -138,8 +149,12 @@ export class AuthService { location.reload(); }); - if (this.config.CAS_AUTHENTIFICATION) { - document.location.href = `${this.config.CAS_PUBLIC_DD.URL_LOGOUT}?service='${this.config.URL_APPLICATION}'`; + if (this.config.AUTHENTIFICATION_CONFIG.EXTERNAL_PROVIDER) { + this._http + .get(`${this.config.API_ENDPOINT}/auth/external_provider_revoke_url`) + .subscribe((url) => { + document.location.href = url; + }); } else { this.router.navigate(['/login']); } diff --git a/frontend/src/app/components/nav-home/nav-home.component.html b/frontend/src/app/components/nav-home/nav-home.component.html index ef6eca94f3..84b8a934cd 100644 --- a/frontend/src/app/components/nav-home/nav-home.component.html +++ b/frontend/src/app/components/nav-home/nav-home.component.html @@ -53,7 +53,7 @@

{{ config.appName }}

> -
+
Se connecter +
+ {{ authProviders | json }} +
+
diff --git a/frontend/src/app/modules/login/login/login.component.ts b/frontend/src/app/modules/login/login/login.component.ts index 0cc2ca0bdc..70a7889208 100644 --- a/frontend/src/app/modules/login/login/login.component.ts +++ b/frontend/src/app/modules/login/login/login.component.ts @@ -19,7 +19,6 @@ export class LoginComponent implements OnInit { enable_sign_up: boolean = false; enable_user_management: boolean = false; external_links: [] = []; - public casLogin: boolean; public disableSubmit = false; public enablePublicAccess = null; identifiant: UntypedFormGroup; @@ -27,9 +26,10 @@ export class LoginComponent implements OnInit { form: UntypedFormGroup; login_or_pass_recovery: boolean = false; public APP_NAME = null; + public authProviders: Array; constructor( - private _authService: AuthService, + public _authService: AuthService, //FIXME : change to private (html must be modified) private _commonService: CommonService, public config: ConfigService, private moduleService: ModuleService, @@ -40,7 +40,6 @@ export class LoginComponent implements OnInit { ) { this.enablePublicAccess = this.config.PUBLIC_ACCESS_USERNAME; this.APP_NAME = this.config.appName; - this.casLogin = this.config.CAS_PUBLIC.CAS_AUTHENTIFICATION; this.enable_sign_up = this.config['ACCOUNT_MANAGEMENT']['ENABLE_SIGN_UP'] || false; this.enable_user_management = this.config['ACCOUNT_MANAGEMENT']['ENABLE_USER_MANAGEMENT'] || false; @@ -48,19 +47,20 @@ export class LoginComponent implements OnInit { } ngOnInit() { - if (this.config.CAS_AUTHENTIFICATION) { - // if token not here here, redirection to CAS login page - const url_redirection_cas = `${this.config.CAS_PUBLIC.CAS_URL_LOGIN}?service=${this.config.API_ENDPOINT}/gn_auth/login_cas`; - if (!this._authService.isLoggedIn()) { - document.location.href = url_redirection_cas; - } + if (this.config.AUTHENTIFICATION_CONFIG.EXTERNAL_PROVIDER) { + this._authService.getLoginExternalProviderUrl().subscribe((url) => { + document.location.href = url; + }); } + this._authService.getAuthProviders().subscribe((providers) => { + this.authProviders = providers; + }); } - async register(user) { + async register(form) { this._authService.enableLoader(); const data = await this._authService - .signinUser(user) + .signinUser(form) .toPromise() .catch(() => { this._authService.handleLoginError(); diff --git a/frontend/src/app/routing/auth-guard.service.ts b/frontend/src/app/routing/auth-guard.service.ts index 0d319413a2..6bc7deeb52 100644 --- a/frontend/src/app/routing/auth-guard.service.ts +++ b/frontend/src/app/routing/auth-guard.service.ts @@ -24,7 +24,7 @@ export class AuthGuard implements CanActivate, CanActivateChild { const moduleService = this._injector.get(ModuleService); const configService = this._injector.get(ConfigService); const routingService = this._injector.get(RoutingService); - const httpclient = this._injector.get(HttpClient) + const httpclient = this._injector.get(HttpClient); if (!authService.isLoggedIn()) { if ( @@ -47,7 +47,7 @@ export class AuthGuard implements CanActivate, CanActivateChild { return false; } } else { - if (configService.CAS_AUTHENTIFICATION) { + if (configService.AUTHENTIFICATION_CONFIG.EXTERNAL_PROVIDER) { let data = await httpclient .get(`${configService.API_ENDPOINT}/auth/get_current_user`) .toPromise(); From 5172f6d3d7f348761d2fa1cb5e924d3f3ec70c2e Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 2 Apr 2024 16:17:24 +0200 Subject: [PATCH 18/47] add button login component --- backend/geonature/core/auth/providers.py | 3 - backend/geonature/core/auth/routes.py | 10 - .../geonature/core/gn_monitoring/routes.py | 174 +++++++++--------- .../src/app/components/auth/auth.service.ts | 17 +- .../modules/login/login/login.component.html | 20 +- .../modules/login/login/login.component.ts | 13 +- frontend/src/app/modules/login/providers.ts | 8 + 7 files changed, 122 insertions(+), 123 deletions(-) create mode 100644 frontend/src/app/modules/login/providers.ts diff --git a/backend/geonature/core/auth/providers.py b/backend/geonature/core/auth/providers.py index 23279eb54d..023f50f00a 100644 --- a/backend/geonature/core/auth/providers.py +++ b/backend/geonature/core/auth/providers.py @@ -46,6 +46,3 @@ def _get_or_create_user(self, user): def revoke(self): pass - - def get_provider_url(self) -> str: - return "" diff --git a/backend/geonature/core/auth/routes.py b/backend/geonature/core/auth/routes.py index e12aa6330d..8e11fdda8e 100644 --- a/backend/geonature/core/auth/routes.py +++ b/backend/geonature/core/auth/routes.py @@ -37,16 +37,6 @@ log = logging.getLogger() -@routes.route("/providers", methods=["GET"]) -def get_providers(): - property_name = ["id_provider", "is_geonature", "logo", "label", "login_url"] - return [ - {getattr(provider, _property) for _property in property_name} - for _, provider in current_app.auth_manager.provider_authentication_cls.items() - ] - return list(current_app.auth_manager.provider_authentication_cls.keys()) - - def get_user_from_id_inpn_ws(id_user): URL = f"https://inpn.mnhn.fr/authentication/rechercheParId/{id_user}" config_cas = current_app.config["CAS"] diff --git a/backend/geonature/core/gn_monitoring/routes.py b/backend/geonature/core/gn_monitoring/routes.py index b3b8d0372f..bb7b47e493 100644 --- a/backend/geonature/core/gn_monitoring/routes.py +++ b/backend/geonature/core/gn_monitoring/routes.py @@ -15,90 +15,90 @@ routes = Blueprint("gn_monitoring", __name__) -# @routes.route("/siteslist", methods=["GET"]) -# @json_resp -# def get_list_sites(): -# """ -# Return the sites list for an application in a dict {id_base_site, nom site} -# .. :quickref: Monitoring; - -# :param id_base_site: id of base site -# :param module_code: code of the module -# :param id_module: id of the module -# :param base_site_name: part of the name of the site -# :param type: int -# """ -# query = select(TBaseSites) -# parameters = request.args - -# if parameters.get("module_code"): -# query = query.where(TBaseSites.modules.any(module_code=parameters.get("module_code"))) - -# if parameters.get("id_module"): -# query = query.where(TBaseSites.modules.any(id_module=parameters.get("id_module"))) - -# if parameters.get("id_base_site"): -# query = query.where(TBaseSites.id_base_site == parameters.get("id_base_site")) - -# if parameters.get("base_site_name"): -# query = query.where( -# TBaseSites.base_site_name.ilike("%{}%".format(parameters.get("base_site_name"))) -# ) - -# data = DB.session.scalars(query).all() -# return [n.as_dict(fields=["id_base_site", "base_site_name"]) for n in data] - - -# @routes.route("/siteslist/", methods=["GET"]) -# @json_resp -# def get_onelist_site(id_site): -# """ -# Get minimal information for a site {id_base_site, nom site} -# .. :quickref: Monitoring; - -# :param id_site: id of base site -# :param type: int -# """ -# query = select( -# TBaseSites.id_base_site, TBaseSites.base_site_name, TBaseSites.base_site_code -# ).where(TBaseSites.id_base_site == id_site) - -# data = DB.session.execute(query).scalar_one() -# return {"id_base_site": data.id_base_site, "base_site_name": data.base_site_name} - - -# @routes.route("/siteareas/", methods=["GET"]) -# @json_resp -# def get_site_areas(id_site): -# """ -# Get areas of a site from cor_site_area as geojson - -# .. :quickref: Monitoring; - -# :param id_module: int -# :type id_module: int -# :param id_area_type: -# :type id_area_type: int -# """ -# params = request.args - -# query = ( -# select(corSiteArea, func.ST_Transform(LAreas.geom, 4326)) -# .join(LAreas, LAreas.id_area == corSiteArea.c.id_area) -# .where(corSiteArea.c.id_base_site == id_site) -# ) - -# if "id_area_type" in params: -# query = query.where(LAreas.id_type == params["id_area_type"]) -# if "id_module" in params: -# query = query.join(corSiteModule, corSiteModule.c.id_base_site == id_site).where( -# corSiteModule.c.id_module == params["id_module"] -# ) - -# data = DB.session.scalars(query).all() -# features = [] -# for d in data: -# feature = get_geojson_feature(d[2]) -# feature["id"] = d[1] -# features.append(feature) -# return FeatureCollection(features) +@routes.route("/siteslist", methods=["GET"]) +@json_resp +def get_list_sites(): + """ + Return the sites list for an application in a dict {id_base_site, nom site} + .. :quickref: Monitoring; + + :param id_base_site: id of base site + :param module_code: code of the module + :param id_module: id of the module + :param base_site_name: part of the name of the site + :param type: int + """ + query = select(TBaseSites) + parameters = request.args + + if parameters.get("module_code"): + query = query.where(TBaseSites.modules.any(module_code=parameters.get("module_code"))) + + if parameters.get("id_module"): + query = query.where(TBaseSites.modules.any(id_module=parameters.get("id_module"))) + + if parameters.get("id_base_site"): + query = query.where(TBaseSites.id_base_site == parameters.get("id_base_site")) + + if parameters.get("base_site_name"): + query = query.where( + TBaseSites.base_site_name.ilike("%{}%".format(parameters.get("base_site_name"))) + ) + + data = DB.session.scalars(query).all() + return [n.as_dict(fields=["id_base_site", "base_site_name"]) for n in data] + + +@routes.route("/siteslist/", methods=["GET"]) +@json_resp +def get_onelist_site(id_site): + """ + Get minimal information for a site {id_base_site, nom site} + .. :quickref: Monitoring; + + :param id_site: id of base site + :param type: int + """ + query = select( + TBaseSites.id_base_site, TBaseSites.base_site_name, TBaseSites.base_site_code + ).where(TBaseSites.id_base_site == id_site) + + data = DB.session.execute(query).scalar_one() + return {"id_base_site": data.id_base_site, "base_site_name": data.base_site_name} + + +@routes.route("/siteareas/", methods=["GET"]) +@json_resp +def get_site_areas(id_site): + """ + Get areas of a site from cor_site_area as geojson + + .. :quickref: Monitoring; + + :param id_module: int + :type id_module: int + :param id_area_type: + :type id_area_type: int + """ + params = request.args + + query = ( + select(corSiteArea, func.ST_Transform(LAreas.geom, 4326)) + .join(LAreas, LAreas.id_area == corSiteArea.c.id_area) + .where(corSiteArea.c.id_base_site == id_site) + ) + + if "id_area_type" in params: + query = query.where(LAreas.id_type == params["id_area_type"]) + if "id_module" in params: + query = query.join(corSiteModule, corSiteModule.c.id_base_site == id_site).where( + corSiteModule.c.id_module == params["id_module"] + ) + + data = DB.session.scalars(query).all() + features = [] + for d in data: + feature = get_geojson_feature(d[2]) + feature["id"] = d[1] + features.append(feature) + return FeatureCollection(features) diff --git a/frontend/src/app/components/auth/auth.service.ts b/frontend/src/app/components/auth/auth.service.ts index da867886c5..d1c1cab51a 100644 --- a/frontend/src/app/components/auth/auth.service.ts +++ b/frontend/src/app/components/auth/auth.service.ts @@ -12,6 +12,7 @@ import { CruvedStoreService } from '@geonature_common/service/cruved-store.servi import { ModuleService } from '../../services/module.service'; import { RoutingService } from '@geonature/routing/routing.service'; import { ConfigService } from '@geonature/services/config.service'; +import { Provider } from '@geonature/modules/login/providers'; export interface User { user_login: string; @@ -41,24 +42,12 @@ export class AuthService { public config: ConfigService ) {} - /** - * Retrieves the URL for the external authentication provider. - * - * @return {Observable} The URL of the external authentication provider. - */ - getLoginExternalProviderUrl() { - // Constructs the URL for the external authentication provider using the API endpoint from the configuration. - const url = `${this.config.API_ENDPOINT}/auth/external_provider_url`; - - // Sends an HTTP GET request to the constructed URL and returns the result. - return this._http.get(url); - } setCurrentUser(user) { localStorage.setItem(this.prefix + 'current_user', JSON.stringify(user)); } - getAuthProviders(): Observable> { - return this._http.get>(`${this.config.API_ENDPOINT}/gn_auth/providers`); + getAuthProviders(): Observable> { + return this._http.get>(`${this.config.API_ENDPOINT}/auth/providers`); } getCurrentUser() { diff --git a/frontend/src/app/modules/login/login/login.component.html b/frontend/src/app/modules/login/login/login.component.html index 38bb37f3db..e006df3fa6 100644 --- a/frontend/src/app/modules/login/login/login.component.html +++ b/frontend/src/app/modules/login/login/login.component.html @@ -74,9 +74,23 @@ > Se connecter -
- {{ authProviders | json }} -
+
+ +
+

Login with other GeoNature

+ + Se connecter sur {{provider.logo}}{{provider.label}} + +
+
+

Login with other providers

+ + Se connecter sur {{provider.logo}}{{provider.label}} + +
+
+ +
diff --git a/frontend/src/app/modules/login/login/login.component.ts b/frontend/src/app/modules/login/login/login.component.ts index 70a7889208..341c0d07a8 100644 --- a/frontend/src/app/modules/login/login/login.component.ts +++ b/frontend/src/app/modules/login/login/login.component.ts @@ -9,6 +9,7 @@ import { ConfigService } from '@geonature/services/config.service'; import { ModuleService } from '@geonature/services/module.service'; import { ActivatedRoute, Router } from '@angular/router'; import { RoutingService } from '@geonature/routing/routing.service'; +import { Provider } from '../providers'; @Component({ selector: 'pnx-login', @@ -26,7 +27,7 @@ export class LoginComponent implements OnInit { form: UntypedFormGroup; login_or_pass_recovery: boolean = false; public APP_NAME = null; - public authProviders: Array; + public authProviders: Array; constructor( public _authService: AuthService, //FIXME : change to private (html must be modified) @@ -47,11 +48,11 @@ export class LoginComponent implements OnInit { } ngOnInit() { - if (this.config.AUTHENTIFICATION_CONFIG.EXTERNAL_PROVIDER) { - this._authService.getLoginExternalProviderUrl().subscribe((url) => { - document.location.href = url; - }); - } + // if (this.config.AUTHENTIFICATION_CONFIG.EXTERNAL_PROVIDER) { + // this._authService.getLoginExternalProviderUrl().subscribe((url) => { + // document.location.href = url; + // }); + // } this._authService.getAuthProviders().subscribe((providers) => { this.authProviders = providers; }); diff --git a/frontend/src/app/modules/login/providers.ts b/frontend/src/app/modules/login/providers.ts new file mode 100644 index 0000000000..59378986f3 --- /dev/null +++ b/frontend/src/app/modules/login/providers.ts @@ -0,0 +1,8 @@ +export interface Provider{ + id_provider:string; + is_uh: boolean; + label: string; + login_url:string; + logout_url:string; + logo:string; +} \ No newline at end of file From d8e224c07490ff16a750308731a96ad311fc2c2e Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 5 Apr 2024 17:05:49 +0200 Subject: [PATCH 19/47] add new providers + refact INPN login process --- config/providers/cas_inpn_provider.py | 167 ++++++++++++++++++++++++++ config/providers/github_provider.py | 69 +++++++++++ config/providers/google_provider.py | 71 +++++++++++ 3 files changed, 307 insertions(+) create mode 100644 config/providers/cas_inpn_provider.py create mode 100644 config/providers/github_provider.py create mode 100644 config/providers/google_provider.py diff --git a/config/providers/cas_inpn_provider.py b/config/providers/cas_inpn_provider.py new file mode 100644 index 0000000000..201624dfce --- /dev/null +++ b/config/providers/cas_inpn_provider.py @@ -0,0 +1,167 @@ +import logging +from typing import Any, Union + +import xmltodict +from flask import Response, current_app, make_response, redirect, render_template, request +from geonature.utils import utilsrequests +from geonature.utils.errors import GeonatureApiError +from pypnusershub.auth import Authentication +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 +PUB_URL = "https://ginco2-preprod.mnhn.fr/" +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): + id_provider = "cas_inpn" + label = "CAS INPN" + is_uh = False + logo = """pets""" + + @property + def login_url(self): + gn_api = f"{current_app.config['API_ENDPOINT']}/auth/authorize/cas_inpn" + return f"{CAS_PUBLIC['URL_LOGIN']}?service={gn_api}" + + @property + def logout_url(self): + return f"{CAS_PUBLIC['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"{CAS_PUBLIC['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=CAS_PUBLIC["URL_LOGOUT"], + url_geonature=current_app.config["URL_APPLICATION"], + ) + + ws_user_url = f"{CAS_USER_WS['URL']}/{user}/?verify=false" + response = utilsrequests.get( + ws_user_url, + ( + CAS_USER_WS["ID"], + CAS_USER_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, + "provider": id_provider, + } + user_info = insert_or_update_role(user_info) + user = db.session.get(models.User, user_id) + 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 + + +# Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privée) + + +AUTHENTICATION_CLASS = [ + AuthenficationCASINPN, +] diff --git a/config/providers/github_provider.py b/config/providers/github_provider.py new file mode 100644 index 0000000000..4b600a9572 --- /dev/null +++ b/config/providers/github_provider.py @@ -0,0 +1,69 @@ +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) + + +AUTHENTICATION_CLASS = [ + GitHubAuthProvider, +] diff --git a/config/providers/google_provider.py b/config/providers/google_provider.py new file mode 100644 index 0000000000..179a096e0a --- /dev/null +++ b/config/providers/google_provider.py @@ -0,0 +1,71 @@ +import os +from typing import Any, Union + +from authlib.integrations.flask_client import OAuth +from flask import ( + Response, + current_app, + jsonify, + make_response, + redirect, + render_template, + request, + url_for, +) +from geonature.core.auth.providers import ExternalGNAuthProvider +from geonature.utils.config import config +from pypnusershub.auth import Authentication +from pypnusershub.db import models, db +from pypnusershub.db.models import User +from pypnusershub.routes import insert_or_update_role +import sqlalchemy as sa + +current_app.config["GOOGLE_CLIENT_ID"] = "" + +current_app.config["GOOGLE_CLIENT_SECRET"] = "" + +oauth = OAuth(current_app) +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): + 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, + "provider": "google", + } + 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) + + +AUTHENTICATION_CLASS = [ + GoogleAuthProvider, +] From 02aceaf7a4be3846230df9576bfce0092756b850 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 5 Apr 2024 17:06:47 +0200 Subject: [PATCH 20/47] feat(custom authentication) : change frontend to work with the new provider attribute in the User model --- .../geonature/core/gn_monitoring/models.py | 274 +++++++++--------- .../geonature/core/gn_monitoring/routes.py | 2 +- .../src/app/components/auth/auth.service.ts | 19 +- .../modules/login/login/login.component.html | 25 +- .../modules/login/login/login.component.ts | 11 + 5 files changed, 174 insertions(+), 157 deletions(-) diff --git a/backend/geonature/core/gn_monitoring/models.py b/backend/geonature/core/gn_monitoring/models.py index 25f1f36406..b764fa6a00 100644 --- a/backend/geonature/core/gn_monitoring/models.py +++ b/backend/geonature/core/gn_monitoring/models.py @@ -21,140 +21,140 @@ from geonature.utils.env import DB -# corVisitObserver = DB.Table( -# "cor_visit_observer", -# DB.Column( -# "id_base_visit", -# DB.Integer, -# ForeignKey("gn_monitoring.t_base_visits.id_base_visit"), -# primary_key=True, -# ), -# DB.Column( -# "id_role", -# DB.Integer, -# ForeignKey("utilisateurs.t_roles.id_role"), -# primary_key=True, -# ), -# schema="gn_monitoring", -# ) - - -# corSiteModule = DB.Table( -# "cor_site_module", -# DB.Column( -# "id_base_site", -# DB.Integer, -# ForeignKey("gn_monitoring.t_base_sites.id_base_site"), -# primary_key=True, -# ), -# DB.Column( -# "id_module", -# DB.Integer, -# ForeignKey("gn_commons.t_modules.id_module"), -# primary_key=True, -# ), -# schema="gn_monitoring", -# ) - -# corSiteArea = DB.Table( -# "cor_site_area", -# DB.Column( -# "id_base_site", -# DB.Integer, -# ForeignKey("gn_monitoring.t_base_sites.id_base_site"), -# primary_key=True, -# ), -# DB.Column("id_area", DB.Integer, ForeignKey(LAreas.id_area), primary_key=True), -# schema="gn_monitoring", -# ) - - -# @serializable -# class TBaseVisits(DB.Model): -# """ -# Table de centralisation des visites liées à un site -# """ - -# __tablename__ = "t_base_visits" -# __table_args__ = {"schema": "gn_monitoring"} -# id_base_visit = DB.Column(DB.Integer, primary_key=True) -# id_base_site = DB.Column(DB.Integer, ForeignKey("gn_monitoring.t_base_sites.id_base_site")) -# id_digitiser = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) -# id_dataset = DB.Column(DB.Integer, ForeignKey("gn_meta.t_datasets.id_dataset")) -# # Pour le moment non défini comme une clé étrangÚre -# # pour les questions de perfs -# # a voir en fonction des usage -# id_module = DB.Column(DB.Integer) - -# visit_date_min = DB.Column(DB.DateTime) -# visit_date_max = DB.Column(DB.DateTime) -# id_nomenclature_tech_collect_campanule = DB.Column(DB.Integer) -# id_nomenclature_grp_typ = DB.Column(DB.Integer) -# comments = DB.Column(DB.Unicode) -# uuid_base_visit = DB.Column(UUID(as_uuid=True), default=select(func.uuid_generate_v4())) - -# meta_create_date = DB.Column(DB.DateTime) -# meta_update_date = DB.Column(DB.DateTime) - -# digitiser = relationship( -# User, primaryjoin=(User.id_role == id_digitiser), foreign_keys=[id_digitiser] -# ) - -# observers = DB.relationship( -# User, -# secondary=corVisitObserver, -# primaryjoin=(corVisitObserver.c.id_base_visit == id_base_visit), -# secondaryjoin=(corVisitObserver.c.id_role == User.id_role), -# foreign_keys=[corVisitObserver.c.id_base_visit, corVisitObserver.c.id_role], -# ) - -# dataset = relationship( -# TDatasets, -# lazy="joined", -# primaryjoin=(TDatasets.id_dataset == id_dataset), -# foreign_keys=[id_dataset], -# ) - - -# @serializable -# @geoserializable(geoCol="geom", idCol="id_base_site") -# class TBaseSites(DB.Model): -# """ -# Table centralisant les données élémentaire des sites -# """ - -# __tablename__ = "t_base_sites" -# __table_args__ = {"schema": "gn_monitoring"} -# id_base_site = DB.Column(DB.Integer, primary_key=True) -# id_inventor = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) -# id_digitiser = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) -# id_nomenclature_type_site = DB.Column(DB.Integer) -# base_site_name = DB.Column(DB.Unicode) -# base_site_description = DB.Column(DB.Unicode) -# base_site_code = DB.Column(DB.Unicode) -# first_use_date = DB.Column(DB.DateTime) -# geom = DB.Column(Geometry("GEOMETRY", 4326)) -# uuid_base_site = DB.Column(UUID(as_uuid=True), default=select(func.uuid_generate_v4())) - -# meta_create_date = DB.Column(DB.DateTime) -# meta_update_date = DB.Column(DB.DateTime) -# altitude_min = DB.Column(DB.Integer) -# altitude_max = DB.Column(DB.Integer) -# digitiser = relationship( -# User, primaryjoin=(User.id_role == id_digitiser), foreign_keys=[id_digitiser] -# ) -# inventor = relationship( -# User, primaryjoin=(User.id_role == id_inventor), foreign_keys=[id_inventor] -# ) - -# t_base_visits = relationship("TBaseVisits", lazy="select", cascade="all,delete-orphan") - -# modules = DB.relationship( -# "TModules", -# lazy="select", -# enable_typechecks=False, -# secondary=corSiteModule, -# primaryjoin=(corSiteModule.c.id_base_site == id_base_site), -# secondaryjoin=(corSiteModule.c.id_module == TModules.id_module), -# foreign_keys=[corSiteModule.c.id_base_site, corSiteModule.c.id_module], -# ) +corVisitObserver = DB.Table( + "cor_visit_observer", + DB.Column( + "id_base_visit", + DB.Integer, + ForeignKey("gn_monitoring.t_base_visits.id_base_visit"), + primary_key=True, + ), + DB.Column( + "id_role", + DB.Integer, + ForeignKey("utilisateurs.t_roles.id_role"), + primary_key=True, + ), + schema="gn_monitoring", +) + + +corSiteModule = DB.Table( + "cor_site_module", + DB.Column( + "id_base_site", + DB.Integer, + ForeignKey("gn_monitoring.t_base_sites.id_base_site"), + primary_key=True, + ), + DB.Column( + "id_module", + DB.Integer, + ForeignKey("gn_commons.t_modules.id_module"), + primary_key=True, + ), + schema="gn_monitoring", +) + +corSiteArea = DB.Table( + "cor_site_area", + DB.Column( + "id_base_site", + DB.Integer, + ForeignKey("gn_monitoring.t_base_sites.id_base_site"), + primary_key=True, + ), + DB.Column("id_area", DB.Integer, ForeignKey(LAreas.id_area), primary_key=True), + schema="gn_monitoring", +) + + +@serializable +class TBaseVisits(DB.Model): + """ + Table de centralisation des visites liées à un site + """ + + __tablename__ = "t_base_visits" + __table_args__ = {"schema": "gn_monitoring"} + id_base_visit = DB.Column(DB.Integer, primary_key=True) + id_base_site = DB.Column(DB.Integer, ForeignKey("gn_monitoring.t_base_sites.id_base_site")) + id_digitiser = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) + id_dataset = DB.Column(DB.Integer, ForeignKey("gn_meta.t_datasets.id_dataset")) + # Pour le moment non défini comme une clé étrangÚre + # pour les questions de perfs + # a voir en fonction des usage + id_module = DB.Column(DB.Integer) + + visit_date_min = DB.Column(DB.DateTime) + visit_date_max = DB.Column(DB.DateTime) + id_nomenclature_tech_collect_campanule = DB.Column(DB.Integer) + id_nomenclature_grp_typ = DB.Column(DB.Integer) + comments = DB.Column(DB.Unicode) + uuid_base_visit = DB.Column(UUID(as_uuid=True), default=select(func.uuid_generate_v4())) + + meta_create_date = DB.Column(DB.DateTime) + meta_update_date = DB.Column(DB.DateTime) + + digitiser = relationship( + User, primaryjoin=(User.id_role == id_digitiser), foreign_keys=[id_digitiser] + ) + + observers = DB.relationship( + User, + secondary=corVisitObserver, + primaryjoin=(corVisitObserver.c.id_base_visit == id_base_visit), + secondaryjoin=(corVisitObserver.c.id_role == User.id_role), + foreign_keys=[corVisitObserver.c.id_base_visit, corVisitObserver.c.id_role], + ) + + dataset = relationship( + TDatasets, + lazy="joined", + primaryjoin=(TDatasets.id_dataset == id_dataset), + foreign_keys=[id_dataset], + ) + + +@serializable +@geoserializable(geoCol="geom", idCol="id_base_site") +class TBaseSites(DB.Model): + """ + Table centralisant les données élémentaire des sites + """ + + __tablename__ = "t_base_sites" + __table_args__ = {"schema": "gn_monitoring"} + id_base_site = DB.Column(DB.Integer, primary_key=True) + id_inventor = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) + id_digitiser = DB.Column(DB.Integer, ForeignKey("utilisateurs.t_roles.id_role")) + id_nomenclature_type_site = DB.Column(DB.Integer) + base_site_name = DB.Column(DB.Unicode) + base_site_description = DB.Column(DB.Unicode) + base_site_code = DB.Column(DB.Unicode) + first_use_date = DB.Column(DB.DateTime) + geom = DB.Column(Geometry("GEOMETRY", 4326)) + uuid_base_site = DB.Column(UUID(as_uuid=True), default=select(func.uuid_generate_v4())) + + meta_create_date = DB.Column(DB.DateTime) + meta_update_date = DB.Column(DB.DateTime) + altitude_min = DB.Column(DB.Integer) + altitude_max = DB.Column(DB.Integer) + digitiser = relationship( + User, primaryjoin=(User.id_role == id_digitiser), foreign_keys=[id_digitiser] + ) + inventor = relationship( + User, primaryjoin=(User.id_role == id_inventor), foreign_keys=[id_inventor] + ) + + t_base_visits = relationship("TBaseVisits", lazy="select", cascade="all,delete-orphan") + + modules = DB.relationship( + "TModules", + lazy="select", + enable_typechecks=False, + secondary=corSiteModule, + primaryjoin=(corSiteModule.c.id_base_site == id_base_site), + secondaryjoin=(corSiteModule.c.id_module == TModules.id_module), + foreign_keys=[corSiteModule.c.id_base_site, corSiteModule.c.id_module], + ) diff --git a/backend/geonature/core/gn_monitoring/routes.py b/backend/geonature/core/gn_monitoring/routes.py index bb7b47e493..a5a21d0684 100644 --- a/backend/geonature/core/gn_monitoring/routes.py +++ b/backend/geonature/core/gn_monitoring/routes.py @@ -4,7 +4,7 @@ from geonature.utils.env import DB -# from geonature.core.gn_monitoring.models import TBaseSites, corSiteArea, corSiteModule +from geonature.core.gn_monitoring.models import TBaseSites, corSiteArea, corSiteModule from utils_flask_sqla.response import json_resp from utils_flask_sqla_geo.generic import get_geojson_feature diff --git a/frontend/src/app/components/auth/auth.service.ts b/frontend/src/app/components/auth/auth.service.ts index d1c1cab51a..ff15b34527 100644 --- a/frontend/src/app/components/auth/auth.service.ts +++ b/frontend/src/app/components/auth/auth.service.ts @@ -72,6 +72,7 @@ export class AuthService { nom_role: data.user.nom_role, nom_complet: data.user.nom_role + ' ' + data.user.prenom_role, id_organisme: data.user.id_organisme, + provider: data.user.provider }; this.setCurrentUser(userForFront); this.loginError = false; @@ -132,21 +133,15 @@ export class AuthService { } logout() { + const provider = this.getCurrentUser().provider; this.cleanLocalStorage(); this.cruvedService.clearCruved(); - this._http.get(`${this.config.API_ENDPOINT}/auth/logout`).subscribe(() => { - location.reload(); - }); - - if (this.config.AUTHENTIFICATION_CONFIG.EXTERNAL_PROVIDER) { - this._http - .get(`${this.config.API_ENDPOINT}/auth/external_provider_revoke_url`) - .subscribe((url) => { - document.location.href = url; - }); - } else { - this.router.navigate(['/login']); + let logout_url = `${this.config.API_ENDPOINT}/auth/logout` + if (provider !="default") { + logout_url = `${logout_url}/${provider}` + // this.router.navigate(['/login']); } + location.href = logout_url } private cleanLocalStorage() { diff --git a/frontend/src/app/modules/login/login/login.component.html b/frontend/src/app/modules/login/login/login.component.html index e006df3fa6..8cfc924b7d 100644 --- a/frontend/src/app/modules/login/login/login.component.html +++ b/frontend/src/app/modules/login/login/login.component.html @@ -74,23 +74,34 @@ > Se connecter -
+
- -
diff --git a/frontend/src/app/modules/login/login/login.component.ts b/frontend/src/app/modules/login/login/login.component.ts index 341c0d07a8..8f78ebac4f 100644 --- a/frontend/src/app/modules/login/login/login.component.ts +++ b/frontend/src/app/modules/login/login/login.component.ts @@ -114,4 +114,15 @@ export class LoginComponent implements OnInit { } } } + + + /** + * Returns the login URL for a given provider. + * + * @param {string} provider_id - The ID of the provider. + * @return {string} The login URL for the provider. + */ + getProviderLoginUrl(provider_id: string): string { + return `${this.config.API_ENDPOINT}/auth/login/${provider_id}`; + } } From 5541751ac708551379499ce9e8d958094d3e1799 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 16 Apr 2024 10:18:29 +0200 Subject: [PATCH 21/47] feat(login, custom-login) : change config --- backend/geonature/utils/config_schema.py | 12 +++++++----- config/default_config.toml.example | 4 ++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index 88d612ae1c..6d0e63ca2f 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -159,9 +159,11 @@ class MetadataConfig(Schema): ) -class AuthentificationConfig(Schema): - EXTERNAL_PROVIDER = fields.Boolean(load_default=False) - PROVIDER_NAME = fields.String(load_default="default") +class AuthenticationConfig(Schema): + AUTHENTICATION_CLASSES = fields.List( + fields.String(), load_default=[] + ) # MAYBE add default auth in this list ? (for people to disable the default login) + DISPLAY_DEFAULT_LOGIN_FORM = fields.Boolean(load_default=True) # class a utiliser pour les paramÚtres que l'on ne veut pas passer au frontend @@ -562,8 +564,8 @@ class GnGeneralSchemaConf(Schema): NOTIFICATIONS_ENABLED = fields.Boolean(load_default=True) PROFILES_REFRESH_CRONTAB = fields.String(load_default="0 3 * * *") MEDIA_CLEAN_CRONTAB = fields.String(load_default="0 1 * * *") - AUTHENTIFICATION_CONFIG = fields.Nested( - AuthentificationConfig, load_default=AuthentificationConfig().load({}) + AUTHENTICATION = fields.Nested( + AuthenticationConfig, load_default=AuthentificationConfig().load({}) ) # @validates_schema diff --git a/config/default_config.toml.example b/config/default_config.toml.example index f9042684b9..1fa90cb3ea 100644 --- a/config/default_config.toml.example +++ b/config/default_config.toml.example @@ -619,3 +619,7 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *" TITLE = "Bienvenue dans GeoNature" INTRODUCTION = "Texte d'introduction, configurable pour le modifier réguliÚrement ou le masquer" FOOTER = "" + +[AUTHENTICATION] + AUTHENTICATION_CLASSES=["pypnusershub.auth.providers.default.DefaultConfiguration"] + DISPLAY_DEFAULT_LOGIN_FORM = true \ No newline at end of file From 3bbd4ab045a9d6f2e59af355982c7ad859e9625d Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 16 Apr 2024 10:20:53 +0200 Subject: [PATCH 22/47] feat(login, custom-login) : moved provider to uh-am --- backend/geonature/app.py | 2 + backend/geonature/core/auth/providers.py | 48 ----- backend/geonature/core/gn_meta/routes.py | 2 +- backend/geonature/utils/config.py | 29 ++- backend/geonature/utils/config_schema.py | 20 ++- config/providers/cas_inpn_provider.py | 167 ------------------ config/providers/github_provider.py | 69 -------- config/providers/google_provider.py | 71 -------- .../src/app/components/auth/auth.service.ts | 9 +- .../nav-home/nav-home.component.html | 3 +- .../src/app/routing/auth-guard.service.ts | 20 +-- 11 files changed, 57 insertions(+), 383 deletions(-) delete mode 100644 backend/geonature/core/auth/providers.py delete mode 100644 config/providers/cas_inpn_provider.py delete mode 100644 config/providers/github_provider.py delete mode 100644 config/providers/google_provider.py diff --git a/backend/geonature/app.py b/backend/geonature/app.py index 1ad692c6bd..0457947649 100755 --- a/backend/geonature/app.py +++ b/backend/geonature/app.py @@ -131,6 +131,8 @@ def create_app(with_external_mods=True): migrate.init_app(app, DB, directory=BACKEND_DIR / "geonature" / "migrations") MA.init_app(app) CORS(app, supports_credentials=True) + auth_manager.init_app(app) + auth_manager.home_page = config["URL_APPLICATION"] if "CELERY" in app.config: from geonature.utils.celery import celery_app diff --git a/backend/geonature/core/auth/providers.py b/backend/geonature/core/auth/providers.py deleted file mode 100644 index 023f50f00a..0000000000 --- a/backend/geonature/core/auth/providers.py +++ /dev/null @@ -1,48 +0,0 @@ -import requests -from flask import request -from werkzeug.exceptions import HTTPException -from sqlalchemy import select - -from geonature.utils.env import db -from pypnusershub.auth import Authentication -from pypnusershub.db.models import User - - -class ExternalGNAuthProvider(Authentication): - def __init__(self, base_url, id_group): - super().__init__("gn_ecrins") - self.base_url = base_url - self.id_group = id_group - - def authenticate(self): - params = request.json - print(self.base_url) - url = self.base_url + "/api/auth/login" - login_response = requests.post( - url, - json={"login": params.get("login"), "password": params.get("password")}, - ) - if login_response.status_code != 200: - raise HTTPException("Fail connect") - return self._get_or_create_user(login_response.json()["user"]) - - def _get_or_create_user(self, user): - db_user = db.session.execute( - select(User).where(User.identifiant == user["identifiant"]) - ).scalar_one_or_none() - group = db.session.get(User, self.id_group) - if not db_user: - new_user = User( - identifiant=user["identifiant"], - nom_role=user["nom_role"], - prenom_role=user["prenom_role"], - groups=[group], - ) - db.session.add(new_user) - db.session.commit() - return new_user - - return db_user - - def revoke(self): - pass diff --git a/backend/geonature/core/gn_meta/routes.py b/backend/geonature/core/gn_meta/routes.py index f1bcab34f9..afeadae7a6 100644 --- a/backend/geonature/core/gn_meta/routes.py +++ b/backend/geonature/core/gn_meta/routes.py @@ -75,7 +75,7 @@ log = logging.getLogger() -if config["MTD"]["ACTIVATED"] and config["AUTHENTIFICATION_CONFIG"]["PROVIDER_NAME"] == "inpn": +if config["MTD"]["ACTIVATED"]: @routes.before_request def synchronize_mtd(): diff --git a/backend/geonature/utils/config.py b/backend/geonature/utils/config.py index 7026b3b79d..b69c0b238c 100644 --- a/backend/geonature/utils/config.py +++ b/backend/geonature/utils/config.py @@ -1,10 +1,12 @@ import os +import importlib + from collections import ChainMap from urllib.parse import urlsplit from flask import Config from flask.helpers import get_root_path -from marshmallow import EXCLUDE, INCLUDE +from marshmallow import EXCLUDE, INCLUDE, Schema, fields from marshmallow.exceptions import ValidationError from geonature.utils.config_schema import ( @@ -19,6 +21,22 @@ __all__ = ["config", "config_frontend"] +def validate_provider_config(config, config_toml): + if not "AUTHENTICATION" in config_toml: + return + for path_provider in config_toml["AUTHENTICATION"]["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) + schema_unique_provider = class_.configuration_schema() + config["AUTHENTICATION"][class_.name] = schema_unique_provider(many=True).load( + config_toml["AUTHENTICATION"][class_.name], unknown=EXCLUDE + ) + + # Load config from GEONATURE_* env vars and from GEONATURE_SETTINGS python module (if any) config_programmatic = Config(get_root_path("geonature")) config_programmatic.from_prefixed_env(prefix="GEONATURE") @@ -26,13 +44,14 @@ config_programmatic.from_object(os.environ["GEONATURE_SETTINGS"]) # Load toml file and override with env & py config + config_toml = load_toml(CONFIG_FILE) if CONFIG_FILE else {} config_toml.update(config_programmatic) # Validate config try: - config_backend = GnPySchemaConf().load(config_toml, unknown=INCLUDE) - config_frontend = GnGeneralSchemaConf().load(config_toml, unknown=INCLUDE) + config_backend = GnPySchemaConf().load(config_toml, unknown=EXCLUDE) + config_frontend = GnGeneralSchemaConf().load(config_toml, unknown=EXCLUDE) except ValidationError as e: raise ConfigError(CONFIG_FILE, e.messages) @@ -44,6 +63,10 @@ config = ChainMap({}, config_programmatic, config_backend, config_frontend, config_default) +validate_provider_config(config, config_toml) + +print("EHHHHHHHHHHHHHHHHHHHHHH", config["AUTHENTICATION"]) + api_uri = urlsplit(config["API_ENDPOINT"]) if "APPLICATION_ROOT" not in config: config["APPLICATION_ROOT"] = api_uri.path or "/" diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index 6d0e63ca2f..46935b5867 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -6,7 +6,15 @@ from warnings import warn -from marshmallow import Schema, fields, validates_schema, ValidationError, post_load, pre_load +from marshmallow import ( + INCLUDE, + Schema, + fields, + validates_schema, + ValidationError, + post_load, + pre_load, +) from marshmallow.validate import OneOf, Regexp, Email, Length from geonature.core.gn_synthese.synthese_config import ( @@ -18,6 +26,8 @@ from geonature.utils.utilsmails import clean_recipients from geonature.utils.utilstoml import load_and_validate_toml +from pypnusershub.auth.authentication import ProviderConfigurationSchema + class EmailStrOrListOfEmailStrField(fields.Field): def _deserialize(self, value, attr, data, **kwargs): @@ -160,10 +170,14 @@ class MetadataConfig(Schema): class AuthenticationConfig(Schema): - AUTHENTICATION_CLASSES = fields.List( + PROVIDERS = fields.List( fields.String(), load_default=[] ) # MAYBE add default auth in this list ? (for people to disable the default login) + DISPLAY_DEFAULT_LOGIN_FORM = fields.Boolean(load_default=True) + PROVIDERS_CONFIG = fields.Dict( + load_default={}, + ) # class a utiliser pour les paramÚtres que l'on ne veut pas passer au frontend @@ -565,7 +579,7 @@ class GnGeneralSchemaConf(Schema): PROFILES_REFRESH_CRONTAB = fields.String(load_default="0 3 * * *") MEDIA_CLEAN_CRONTAB = fields.String(load_default="0 1 * * *") AUTHENTICATION = fields.Nested( - AuthenticationConfig, load_default=AuthentificationConfig().load({}) + AuthenticationConfig, load_default=AuthenticationConfig().load({}), unknown=INCLUDE ) # @validates_schema diff --git a/config/providers/cas_inpn_provider.py b/config/providers/cas_inpn_provider.py deleted file mode 100644 index 201624dfce..0000000000 --- a/config/providers/cas_inpn_provider.py +++ /dev/null @@ -1,167 +0,0 @@ -import logging -from typing import Any, Union - -import xmltodict -from flask import Response, current_app, make_response, redirect, render_template, request -from geonature.utils import utilsrequests -from geonature.utils.errors import GeonatureApiError -from pypnusershub.auth import Authentication -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 -PUB_URL = "https://ginco2-preprod.mnhn.fr/" -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): - id_provider = "cas_inpn" - label = "CAS INPN" - is_uh = False - logo = """pets""" - - @property - def login_url(self): - gn_api = f"{current_app.config['API_ENDPOINT']}/auth/authorize/cas_inpn" - return f"{CAS_PUBLIC['URL_LOGIN']}?service={gn_api}" - - @property - def logout_url(self): - return f"{CAS_PUBLIC['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"{CAS_PUBLIC['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=CAS_PUBLIC["URL_LOGOUT"], - url_geonature=current_app.config["URL_APPLICATION"], - ) - - ws_user_url = f"{CAS_USER_WS['URL']}/{user}/?verify=false" - response = utilsrequests.get( - ws_user_url, - ( - CAS_USER_WS["ID"], - CAS_USER_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, - "provider": id_provider, - } - user_info = insert_or_update_role(user_info) - user = db.session.get(models.User, user_id) - 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 - - -# Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privée) - - -AUTHENTICATION_CLASS = [ - AuthenficationCASINPN, -] diff --git a/config/providers/github_provider.py b/config/providers/github_provider.py deleted file mode 100644 index 4b600a9572..0000000000 --- a/config/providers/github_provider.py +++ /dev/null @@ -1,69 +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 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) - - -AUTHENTICATION_CLASS = [ - GitHubAuthProvider, -] diff --git a/config/providers/google_provider.py b/config/providers/google_provider.py deleted file mode 100644 index 179a096e0a..0000000000 --- a/config/providers/google_provider.py +++ /dev/null @@ -1,71 +0,0 @@ -import os -from typing import Any, Union - -from authlib.integrations.flask_client import OAuth -from flask import ( - Response, - current_app, - jsonify, - make_response, - redirect, - render_template, - request, - url_for, -) -from geonature.core.auth.providers import ExternalGNAuthProvider -from geonature.utils.config import config -from pypnusershub.auth import Authentication -from pypnusershub.db import models, db -from pypnusershub.db.models import User -from pypnusershub.routes import insert_or_update_role -import sqlalchemy as sa - -current_app.config["GOOGLE_CLIENT_ID"] = "" - -current_app.config["GOOGLE_CLIENT_SECRET"] = "" - -oauth = OAuth(current_app) -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): - 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, - "provider": "google", - } - 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) - - -AUTHENTICATION_CLASS = [ - GoogleAuthProvider, -] diff --git a/frontend/src/app/components/auth/auth.service.ts b/frontend/src/app/components/auth/auth.service.ts index ff15b34527..d0d33d5b8c 100644 --- a/frontend/src/app/components/auth/auth.service.ts +++ b/frontend/src/app/components/auth/auth.service.ts @@ -72,7 +72,6 @@ export class AuthService { nom_role: data.user.nom_role, nom_complet: data.user.nom_role + ' ' + data.user.prenom_role, id_organisme: data.user.id_organisme, - provider: data.user.provider }; this.setCurrentUser(userForFront); this.loginError = false; @@ -136,12 +135,8 @@ export class AuthService { const provider = this.getCurrentUser().provider; this.cleanLocalStorage(); this.cruvedService.clearCruved(); - let logout_url = `${this.config.API_ENDPOINT}/auth/logout` - if (provider !="default") { - logout_url = `${logout_url}/${provider}` - // this.router.navigate(['/login']); - } - location.href = logout_url + let logout_url = `${this.config.API_ENDPOINT}/auth/logout`; + location.href = logout_url; } private cleanLocalStorage() { diff --git a/frontend/src/app/components/nav-home/nav-home.component.html b/frontend/src/app/components/nav-home/nav-home.component.html index 84b8a934cd..a95decd801 100644 --- a/frontend/src/app/components/nav-home/nav-home.component.html +++ b/frontend/src/app/components/nav-home/nav-home.component.html @@ -53,8 +53,7 @@

{{ config.appName }}

> diff --git a/frontend/src/app/routing/auth-guard.service.ts b/frontend/src/app/routing/auth-guard.service.ts index 6bc7deeb52..a205c7527c 100644 --- a/frontend/src/app/routing/auth-guard.service.ts +++ b/frontend/src/app/routing/auth-guard.service.ts @@ -47,18 +47,14 @@ export class AuthGuard implements CanActivate, CanActivateChild { return false; } } else { - if (configService.AUTHENTIFICATION_CONFIG.EXTERNAL_PROVIDER) { - let data = await httpclient - .get(`${configService.API_ENDPOINT}/auth/get_current_user`) - .toPromise(); - data = { ...data }; - authService.manageUser(data); - return authService.isLoggedIn(); - } - this._router.navigate(['/login'], { - queryParams: { ...route.queryParams, ...{ route: state.url.split('?')[0] } }, - }); - return false; + let data = await httpclient + .get(`${configService.API_ENDPOINT}/auth/get_current_user`) + .toPromise(); + data = { ...data }; + authService.manageUser(data); + const modules = await moduleService.loadModules().toPromise(); + routingService.loadRoutes(modules, route._routerState.url); + return authService.isLoggedIn(); } } else if (moduleService.shouldLoadModules) { const modules = await moduleService.loadModules().toPromise(); From 0ce20208a1a2610c1ba628aad22c946ddc98f619 Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Wed, 15 May 2024 17:12:06 +0200 Subject: [PATCH 23/47] add ID_GROUP_RECONCILIATION + conf example --- backend/geonature/utils/config.py | 2 -- backend/geonature/utils/config_schema.py | 1 + config/default_config.toml.example | 22 ++++++++++++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/geonature/utils/config.py b/backend/geonature/utils/config.py index b69c0b238c..771f90abc7 100644 --- a/backend/geonature/utils/config.py +++ b/backend/geonature/utils/config.py @@ -65,8 +65,6 @@ def validate_provider_config(config, config_toml): validate_provider_config(config, config_toml) -print("EHHHHHHHHHHHHHHHHHHHHHH", config["AUTHENTICATION"]) - api_uri = urlsplit(config["API_ENDPOINT"]) if "APPLICATION_ROOT" not in config: config["APPLICATION_ROOT"] = api_uri.path or "/" diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index 46935b5867..1c114eb4d3 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -174,6 +174,7 @@ class AuthenticationConfig(Schema): fields.String(), load_default=[] ) # MAYBE add default auth in this list ? (for people to disable the default login) + ID_GROUP_RECONCILIATION = fields.Integer() DISPLAY_DEFAULT_LOGIN_FORM = fields.Boolean(load_default=True) PROVIDERS_CONFIG = fields.Dict( load_default={}, diff --git a/config/default_config.toml.example b/config/default_config.toml.example index 1fa90cb3ea..d679d8a1cf 100644 --- a/config/default_config.toml.example +++ b/config/default_config.toml.example @@ -621,5 +621,23 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *" FOOTER = "" [AUTHENTICATION] - AUTHENTICATION_CLASSES=["pypnusershub.auth.providers.default.DefaultConfiguration"] - DISPLAY_DEFAULT_LOGIN_FORM = true \ No newline at end of file + AUTHENTICATION_CLASSES=["pypnusershub.auth.providers.openid_provider.OpenIDProvider"] + DISPLAY_DEFAULT_LOGIN_FORM = true + + # identifiant du groupe dans lequel les utilisateurs externes vont ĂȘtre ajoutĂ©s + ID_GROUP_RECONCILIATION = 2 + + [[AUTHENTICATION.OPENID_PROVIDER_CONFIG]] + id_provider = "keycloak" + label = "KeyCloak" + ISSUER = "http://MY_URL" + CLIENT_ID = "local-gn" + CLIENT_SECRET = "561vc5s4v6s5f4v65ds4v564fd" + + [[AUTHENTICATION.OPENID_PROVIDER_CONFIG]] + id_provider = "google" + logo = "" + label = "Google" + ISSUER = "https://accounts.google.com/" + CLIENT_ID = "MY_CLIENT_ID" + CLIENT_SECRET = "MY_CLIENT_KEY" \ No newline at end of file From 4aa2d7b2a5030993c2e2e274b45fa36309bda798 Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Wed, 15 May 2024 18:11:58 +0200 Subject: [PATCH 24/47] ui --- .../modules/login/login/login.component.html | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/modules/login/login/login.component.html b/frontend/src/app/modules/login/login/login.component.html index 8cfc924b7d..f9ea5c2694 100644 --- a/frontend/src/app/modules/login/login/login.component.html +++ b/frontend/src/app/modules/login/login/login.component.html @@ -77,27 +77,14 @@
-

Login with other GeoNature

+

Se connecter avec :

- Se connecter sur {{ provider.logo }}{{ provider.label }} - - -
- From 71035807ea9027e06ae13392883990b65a36ddbb Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Mon, 3 Jun 2024 17:26:57 +0200 Subject: [PATCH 25/47] external uh login --- backend/geonature/utils/config_schema.py | 2 +- .../src/app/modules/login/login.module.ts | 3 +- .../login/login/external-login-dialog.html | 42 +++++++++++++ .../login/login/external-login-dialog.ts | 41 +++++++++++++ .../modules/login/login/login.component.html | 60 +++++++++++-------- .../modules/login/login/login.component.scss | 10 +++- .../modules/login/login/login.component.ts | 22 ++++++- 7 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 frontend/src/app/modules/login/login/external-login-dialog.html create mode 100644 frontend/src/app/modules/login/login/external-login-dialog.ts diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index 1c114eb4d3..de4b2bdabc 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -174,7 +174,7 @@ class AuthenticationConfig(Schema): fields.String(), load_default=[] ) # MAYBE add default auth in this list ? (for people to disable the default login) - ID_GROUP_RECONCILIATION = fields.Integer() + DEFAULT_RECONCILIATION_GROUP_ID = fields.Integer() DISPLAY_DEFAULT_LOGIN_FORM = fields.Boolean(load_default=True) PROVIDERS_CONFIG = fields.Dict( load_default={}, diff --git a/frontend/src/app/modules/login/login.module.ts b/frontend/src/app/modules/login/login.module.ts index 047c038cc5..acd273a227 100644 --- a/frontend/src/app/modules/login/login.module.ts +++ b/frontend/src/app/modules/login/login.module.ts @@ -10,10 +10,11 @@ import { SignUpComponent } from './sign-up/sign-up.component'; import { routes } from './login.routes'; import { SignUpGuard, UserManagementGuard } from './routes-guard.service'; +import { LoginDialog } from './login/external-login-dialog'; @NgModule({ imports: [CommonModule, GN2CommonModule, RouterModule.forChild(routes)], - declarations: [LoginComponent, NewPasswordComponent, SignUpComponent], + declarations: [LoginComponent, NewPasswordComponent, SignUpComponent, LoginDialog], providers: [SignUpGuard, UserManagementGuard], }) export class LoginModule {} diff --git a/frontend/src/app/modules/login/login/external-login-dialog.html b/frontend/src/app/modules/login/login/external-login-dialog.html new file mode 100644 index 0000000000..8f11437105 --- /dev/null +++ b/frontend/src/app/modules/login/login/external-login-dialog.html @@ -0,0 +1,42 @@ +
+

Se connecter au {{data.provider.label}}

+ +
+
+
+ + +
+
+ + +
+ +
+
+
diff --git a/frontend/src/app/modules/login/login/external-login-dialog.ts b/frontend/src/app/modules/login/login/external-login-dialog.ts new file mode 100644 index 0000000000..d3793616ae --- /dev/null +++ b/frontend/src/app/modules/login/login/external-login-dialog.ts @@ -0,0 +1,41 @@ +import { Component, Inject, Output, EventEmitter } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { HttpClient } from '@angular/common/http'; +import { AuthService } from '@geonature/components/auth/auth.service'; +import { ConfigService } from '@geonature/services/config.service'; +import { CommonService } from '@geonature_common/service/common.service'; + +export interface DialogData { + provider: any; +} + +@Component({ + selector: 'login-dialog', + templateUrl: 'external-login-dialog.html', + styleUrls: ['./login.component.scss'], +}) +export class LoginDialog { + @Output() userLogged = new EventEmitter(); + constructor( + public dialogRef: MatDialogRef, + private _http: HttpClient, + private _authService: AuthService, + @Inject(MAT_DIALOG_DATA) public data: DialogData, + public config: ConfigService, + private _commonService: CommonService + ) {} + + async externalLogin(form) { + this._http + .post(this.config.API_ENDPOINT + '/auth/login/' + this.data.provider.id_provider, form) + .subscribe( + (data) => { + this.userLogged.emit(data); + }, + (error) => { + console.log(error); + this._commonService.regularToaster('error', error.error.description); + } + ); + } +} diff --git a/frontend/src/app/modules/login/login/login.component.html b/frontend/src/app/modules/login/login/login.component.html index f9ea5c2694..f57e08354f 100644 --- a/frontend/src/app/modules/login/login/login.component.html +++ b/frontend/src/app/modules/login/login/login.component.html @@ -74,42 +74,52 @@ > Se connecter -
- -
-

Se connecter avec :

- + +
+ +

Se connecter avec :

+
+
+ + {{ provider.label }} - +
- +
+
-
+
-
- +
+ - + diff --git a/frontend/src/app/modules/login/login/login.component.scss b/frontend/src/app/modules/login/login/login.component.scss index 4f76fc4d50..515137f81d 100644 --- a/frontend/src/app/modules/login/login/login.component.scss +++ b/frontend/src/app/modules/login/login/login.component.scss @@ -7,6 +7,10 @@ background-color: #1d21249e; } +mat-dialog-content { + z-index: 50; +} + .panel-heading { background-color: transparent; color: white; @@ -29,12 +33,16 @@ box-shadow: 0.5px -0.5px 0.5px 0.2rem rgba(98, 255, 0, 0.15) !important; } +#externalLoginForm { + margin-top: 10%; +} + .addon { color: #55585a; position: absolute; top: 10px; left: 6px; - z-index: 9999; + z-index: 10; font-size: 18px; } diff --git a/frontend/src/app/modules/login/login/login.component.ts b/frontend/src/app/modules/login/login/login.component.ts index 8f78ebac4f..8607d40733 100644 --- a/frontend/src/app/modules/login/login/login.component.ts +++ b/frontend/src/app/modules/login/login/login.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; import { CommonService } from '@geonature_common/service/common.service'; @@ -10,6 +11,7 @@ import { ModuleService } from '@geonature/services/module.service'; import { ActivatedRoute, Router } from '@angular/router'; import { RoutingService } from '@geonature/routing/routing.service'; import { Provider } from '../providers'; +import { LoginDialog } from './external-login-dialog'; @Component({ selector: 'pnx-login', @@ -37,7 +39,7 @@ export class LoginComponent implements OnInit { private router: Router, private route: ActivatedRoute, private _routingService: RoutingService, - private _cookie: CookieService + public dialog: MatDialog ) { this.enablePublicAccess = this.config.PUBLIC_ACCESS_USERNAME; this.APP_NAME = this.config.appName; @@ -115,7 +117,6 @@ export class LoginComponent implements OnInit { } } - /** * Returns the login URL for a given provider. * @@ -125,4 +126,21 @@ export class LoginComponent implements OnInit { getProviderLoginUrl(provider_id: string): string { return `${this.config.API_ENDPOINT}/auth/login/${provider_id}`; } + + openDialog(provider) { + const dialogRef = this.dialog.open(LoginDialog, { + height: '30%', + width: '30%', + position: { top: '10%' }, + data: { + provider: provider, + }, + }); + // dialogRef.updateSize('100%', '100%'); + const componentInstance: LoginDialog = dialogRef.componentInstance; + componentInstance.userLogged.subscribe((data) => { + this.handleRegister(data); + dialogRef.close(); + }); + } } From f75a34595ddaf0e4c9f02c08e45a3a6d5c73922b Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Mon, 1 Jul 2024 11:18:33 +0200 Subject: [PATCH 26/47] feat(authentication, login form) : Now one provider can be use as default + default login form can be hidden --- backend/geonature/utils/config_schema.py | 1 + config/default_config.toml.example | 1 + .../modules/login/login/login.component.html | 94 +++++++++---------- .../modules/login/login/login.component.ts | 8 +- 4 files changed, 52 insertions(+), 52 deletions(-) diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index de4b2bdabc..54763ef552 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -176,6 +176,7 @@ class AuthenticationConfig(Schema): DEFAULT_RECONCILIATION_GROUP_ID = fields.Integer() DISPLAY_DEFAULT_LOGIN_FORM = fields.Boolean(load_default=True) + ONLY_PROVIDER = fields.String(load_default=None) PROVIDERS_CONFIG = fields.Dict( load_default={}, ) diff --git a/config/default_config.toml.example b/config/default_config.toml.example index d679d8a1cf..0580e2ec9d 100644 --- a/config/default_config.toml.example +++ b/config/default_config.toml.example @@ -623,6 +623,7 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *" [AUTHENTICATION] AUTHENTICATION_CLASSES=["pypnusershub.auth.providers.openid_provider.OpenIDProvider"] DISPLAY_DEFAULT_LOGIN_FORM = true + ONLY_PROVIDER = 'keycloak' # identifiant du groupe dans lequel les utilisateurs externes vont ĂȘtre ajoutĂ©s ID_GROUP_RECONCILIATION = 2 diff --git a/frontend/src/app/modules/login/login/login.component.html b/frontend/src/app/modules/login/login/login.component.html index f57e08354f..df134b2a35 100644 --- a/frontend/src/app/modules/login/login/login.component.html +++ b/frontend/src/app/modules/login/login/login.component.html @@ -1,4 +1,5 @@
-
- -
- - -
-
- - -
-
+ - {{ 'ForgotPasswordOrLogin' | translate }} -
+
+ + +
+
+ + +
+
+ {{ 'ForgotPasswordOrLogin' | translate }} +
+ + +
+
+
- - -

Se connecter avec :

diff --git a/frontend/src/app/modules/login/login/login.component.ts b/frontend/src/app/modules/login/login/login.component.ts index 8607d40733..48ccfa9de0 100644 --- a/frontend/src/app/modules/login/login/login.component.ts +++ b/frontend/src/app/modules/login/login/login.component.ts @@ -50,14 +50,12 @@ export class LoginComponent implements OnInit { } ngOnInit() { - // if (this.config.AUTHENTIFICATION_CONFIG.EXTERNAL_PROVIDER) { - // this._authService.getLoginExternalProviderUrl().subscribe((url) => { - // document.location.href = url; - // }); - // } this._authService.getAuthProviders().subscribe((providers) => { this.authProviders = providers; }); + if (this.config.AUTHENTICATION.ONLY_PROVIDER) { + window.location.href = this.getProviderLoginUrl(this.config.AUTHENTICATION.ONLY_PROVIDER); + } } async register(form) { From d079fd99155a62bb1437cebaaceba96355b5c0a0 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Mon, 1 Jul 2024 12:11:10 +0200 Subject: [PATCH 27/47] feat(authentication, user edition) user not logged in with local provider cannot access edit user panel --- backend/config_sync_mtd.py | 28 +++++++++++++++++++ .../geonature/core/gn_meta/mtd/__init__.py | 17 +++++------ .../geonature/core/gn_meta/mtd/mtd_utils.py | 4 +-- backend/geonature/core/gn_meta/routes.py | 18 +----------- .../src/app/components/auth/auth.service.ts | 16 ++++++++++- .../nav-home/nav-home.component.html | 2 +- .../components/nav-home/nav-home.component.ts | 2 ++ .../src/app/modules/login/login.module.ts | 4 +-- .../app/modules/login/routes-guard.service.ts | 17 +++++++++++ frontend/src/app/userModule/user.module.ts | 5 ++-- 10 files changed, 79 insertions(+), 34 deletions(-) create mode 100644 backend/config_sync_mtd.py diff --git a/backend/config_sync_mtd.py b/backend/config_sync_mtd.py new file mode 100644 index 0000000000..7ccc750ae7 --- /dev/null +++ b/backend/config_sync_mtd.py @@ -0,0 +1,28 @@ +from geonature.core.gn_meta.mtd import ( + INPNCAS, + sync_af_and_ds as mtd_sync_af_and_ds, + sync_af_and_ds_by_user, +) +from geonature.core.gn_meta.routes import routes +from flask import request, g +import logging + +INPNCAS.base_url = "https://inpn.mnhn.fr/authentication/" +INPNCAS.user = "user_change" +INPNCAS.password = "pass_change" +INPNCAS.id_instance_filter = 6 +INPNCAS.mtd_api_endpoint = "https://preprod-inpn.mnhn.fr/mtd" +INPNCAS.activated = True + +log = logging.getLogger() + + +@routes.before_request +def synchronize_mtd(): + from flask_login import current_user + + if request.endpoint in ["gn_meta.get_datasets", "gn_meta.get_acquisition_frameworks_list"]: + try: + sync_af_and_ds_by_user(id_role=current_user.id_role) + except Exception as e: + log.exception("Error while get JDD via MTD") diff --git a/backend/geonature/core/gn_meta/mtd/__init__.py b/backend/geonature/core/gn_meta/mtd/__init__.py index 929f3eaf27..e996645768 100644 --- a/backend/geonature/core/gn_meta/mtd/__init__.py +++ b/backend/geonature/core/gn_meta/mtd/__init__.py @@ -16,6 +16,7 @@ from pypnnomenclature.models import TNomenclatures from pypnusershub.db.models import User +from pypnusershub.auth.providers.cas_inpn_provider import * from sqlalchemy import func, select from .mtd_utils import associate_actors, sync_af, sync_ds @@ -146,10 +147,13 @@ def get_single_af(self, af_uuid): class INPNCAS: - base_url = "" # FIXME config["CAS"]["CAS_USER_WS"]["BASE_URL"] - user = "" # FIXME config["CAS"]["CAS_USER_WS"]["BASE_URL"] - password = "" # FIXME config["CAS"]["CAS_USER_WS"]["PASSWORD"] + base_url = "" + user = "" + password = "" + id_instance_filter = None id_search_path = "rechercheParId/{user_id}" + mtd_api_endpoint = "" + activated = False @classmethod def _get_user_json(cls, user_id): @@ -238,7 +242,7 @@ def sync_af_and_ds(): Method to trigger global MTD sync. """ logger.info("MTD - SYNC GLOBAL : START") - mtd_api = MTDInstanceApi(config["MTD_API_ENDPOINT"], config["MTD"]["ID_INSTANCE_FILTER"]) + mtd_api = MTDInstanceApi(INPNCAS.mtd_api_endpoint, INPNCAS.id_instance_filter) af_list = mtd_api.get_af_list() @@ -260,10 +264,7 @@ def sync_af_and_ds_by_user(id_role, id_af=None): logger.info("MTD - SYNC USER : START") - # Create an instance of MTDInstanceApi - mtd_api = MTDInstanceApi( - config["MTD_API_ENDPOINT"], config["MTD"]["ID_INSTANCE_FILTER"], id_role - ) + mtd_api = MTDInstanceApi(INPNCAS.mtd_api_endpoint, INPNCAS.id_instance_filter, id_role) # Get the list of datasets (ds) for the user # NOTE: `mtd_api.get_ds_user_list()` tested and timed to about 7 seconds on the PROD instance 'GINCO Occtax' with id_role = 13829 > a user with a lot of metadata to be retrieved from 'INPN Métadonnées' to 'GINCO Occtax' diff --git a/backend/geonature/core/gn_meta/mtd/mtd_utils.py b/backend/geonature/core/gn_meta/mtd/mtd_utils.py index e8bde8bf04..a117f761f6 100644 --- a/backend/geonature/core/gn_meta/mtd/mtd_utils.py +++ b/backend/geonature/core/gn_meta/mtd/mtd_utils.py @@ -241,8 +241,6 @@ def associate_dataset_modules(dataset): """ dataset.modules.extend( DB.session.scalars( - select(TModules).where( - TModules.module_code.in_(current_app.config["MTD"]["JDD_MODULE_CODE_ASSOCIATION"]) - ) + select(TModules).where(TModules.module_code.in_(["OCCTAX", "OCCHAB", "IMPORT"])) ).all() ) diff --git a/backend/geonature/core/gn_meta/routes.py b/backend/geonature/core/gn_meta/routes.py index afeadae7a6..2db09b663f 100644 --- a/backend/geonature/core/gn_meta/routes.py +++ b/backend/geonature/core/gn_meta/routes.py @@ -61,6 +61,7 @@ import geonature.utils.utilsmails as mail from geonature.utils.errors import GeonatureApiError from .mtd import sync_af_and_ds as mtd_sync_af_and_ds +from geonature.core.gn_meta.mtd import INPNCAS from ref_geo.models import LAreas @@ -75,23 +76,6 @@ log = logging.getLogger() -if config["MTD"]["ACTIVATED"]: - - @routes.before_request - def synchronize_mtd(): - if request.endpoint in ["gn_meta.get_datasets", "gn_meta.get_acquisition_frameworks_list"]: - from flask_login import current_user - - if current_user.is_authenticated: - params = request.json if request.is_json else request.args - try: - list_id_af = params.get("id_acquisition_frameworks", []) - for id_af in list_id_af: - sync_af_and_ds_by_user(id_role=current_user.id_role, id_af=id_af) - except Exception as e: - log.exception(f"Error while get JDD via MTD: {e}") - - @routes.route("/datasets", methods=["GET", "POST"]) @login_required def get_datasets(): diff --git a/frontend/src/app/components/auth/auth.service.ts b/frontend/src/app/components/auth/auth.service.ts index d0d33d5b8c..be1ee00efd 100644 --- a/frontend/src/app/components/auth/auth.service.ts +++ b/frontend/src/app/components/auth/auth.service.ts @@ -21,6 +21,7 @@ export interface User { prenom_role?: string; nom_role?: string; nom_complet?: string; + providers?: string[]; } @Injectable() @@ -40,8 +41,15 @@ export class AuthService { private _routingService: RoutingService, private moduleService: ModuleService, public config: ConfigService - ) {} + ) { + this.refreshCurrentUserData(); + } + refreshCurrentUserData() { + if (!this.currentUser) { + this.currentUser = this.getCurrentUser(); + } + } setCurrentUser(user) { localStorage.setItem(this.prefix + 'current_user', JSON.stringify(user)); } @@ -72,6 +80,7 @@ export class AuthService { nom_role: data.user.nom_role, nom_complet: data.user.nom_role + ' ' + data.user.prenom_role, id_organisme: data.user.id_organisme, + providers: data.user.providers.map((provider) => provider.name), }; this.setCurrentUser(userForFront); this.loginError = false; @@ -163,4 +172,9 @@ export class AuthService { disableLoader() { this.isLoading = false; } + + canBeLoggedWithLocalProvider(): boolean { + this.refreshCurrentUserData(); + return this.currentUser.providers.includes('local_provider'); + } } diff --git a/frontend/src/app/components/nav-home/nav-home.component.html b/frontend/src/app/components/nav-home/nav-home.component.html index a95decd801..f824de2be6 100644 --- a/frontend/src/app/components/nav-home/nav-home.component.html +++ b/frontend/src/app/components/nav-home/nav-home.component.html @@ -53,7 +53,7 @@

{{ config.appName }}

> diff --git a/frontend/src/app/components/nav-home/nav-home.component.ts b/frontend/src/app/components/nav-home/nav-home.component.ts index b7f80c2d35..7c4077ea68 100644 --- a/frontend/src/app/components/nav-home/nav-home.component.ts +++ b/frontend/src/app/components/nav-home/nav-home.component.ts @@ -24,6 +24,7 @@ export class NavHomeComponent implements OnInit, OnDestroy { public locale: string; public moduleUrl: string; public notificationNumber: number; + public useLocalProvider: boolean; // Indicate if the user is logged in using a non local provider @ViewChild('sidenav', { static: true }) public sidenav: MatSidenav; @@ -50,6 +51,7 @@ export class NavHomeComponent implements OnInit, OnDestroy { // Put the user name in navbar this.currentUser = this.authService.getCurrentUser(); + this.useLocalProvider = this.authService.canBeLoggedWithLocalProvider(); } private extractLocaleFromUrl() { diff --git a/frontend/src/app/modules/login/login.module.ts b/frontend/src/app/modules/login/login.module.ts index acd273a227..8a9f46e61b 100644 --- a/frontend/src/app/modules/login/login.module.ts +++ b/frontend/src/app/modules/login/login.module.ts @@ -9,12 +9,12 @@ import { NewPasswordComponent } from './new-password/new-password.component'; import { SignUpComponent } from './sign-up/sign-up.component'; import { routes } from './login.routes'; -import { SignUpGuard, UserManagementGuard } from './routes-guard.service'; +import { SignUpGuard, UserEditGuard, UserManagementGuard } from './routes-guard.service'; import { LoginDialog } from './login/external-login-dialog'; @NgModule({ imports: [CommonModule, GN2CommonModule, RouterModule.forChild(routes)], declarations: [LoginComponent, NewPasswordComponent, SignUpComponent, LoginDialog], - providers: [SignUpGuard, UserManagementGuard], + providers: [SignUpGuard, UserManagementGuard, UserEditGuard], }) export class LoginModule {} diff --git a/frontend/src/app/modules/login/routes-guard.service.ts b/frontend/src/app/modules/login/routes-guard.service.ts index 68c056b331..9b27ff3546 100644 --- a/frontend/src/app/modules/login/routes-guard.service.ts +++ b/frontend/src/app/modules/login/routes-guard.service.ts @@ -30,6 +30,23 @@ export class SignUpGuard implements CanActivate { } } +@Injectable() +export class UserEditGuard implements CanActivate { + constructor( + private _router: Router, + private _authService: AuthService + ) {} + + canActivate() { + if (!this._authService.canBeLoggedWithLocalProvider()) { + this._router.navigate(['/']); + return false; + } + + return true; + } +} + @Injectable() export class UserManagementGuard implements CanActivate { constructor( diff --git a/frontend/src/app/userModule/user.module.ts b/frontend/src/app/userModule/user.module.ts index d47b94915e..975f7ccd50 100644 --- a/frontend/src/app/userModule/user.module.ts +++ b/frontend/src/app/userModule/user.module.ts @@ -7,10 +7,11 @@ import { UserComponent } from './user.component'; import { PasswordComponent } from './password/password.component'; //Services import { RoleFormService, UserDataService } from './services'; +import { UserEditGuard } from '@geonature/modules/login/routes-guard.service'; const routes: Routes = [ - { path: '', component: UserComponent }, - { path: 'password', component: PasswordComponent }, + { path: '', component: UserComponent, canActivate: [UserEditGuard] }, + { path: 'password', component: PasswordComponent, canActivate: [UserEditGuard] }, ]; @NgModule({ From c419970d4ccd3e08e6102ed87ed516ab2381f506 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Tue, 2 Jul 2024 08:50:00 +0200 Subject: [PATCH 28/47] make mtd sync work --- backend/config_sync_mtd.py | 28 -- .../UsersHub-authentification-module | 2 +- backend/geonature/app.py | 12 +- backend/geonature/core/auth/__init__.py | 0 backend/geonature/core/auth/routes.py | 105 ------ .../core/auth/templates/cas_login_error.html | 33 -- .../geonature/core/gn_meta/mtd/__init__.py | 302 ---------------- .../geonature/core/gn_meta/mtd/mtd_utils.py | 246 ------------- .../core/gn_meta/mtd/mtd_webservice.py | 52 --- .../geonature/core/gn_meta/mtd/xml_parser.py | 210 ----------- backend/geonature/core/gn_meta/routes.py | 26 -- .../geonature/core/gn_monitoring/routes.py | 12 +- backend/geonature/custom.py | 340 ------------------ backend/geonature/tests/test_mtd.py | 58 --- backend/geonature/utils/config.py | 33 +- backend/geonature/utils/config_schema.py | 31 -- config/default_config.toml.example | 32 +- .../src/app/components/auth/auth.service.ts | 5 +- .../modules/login/login/login.component.ts | 13 +- frontend/src/app/modules/login/providers.ts | 16 +- .../app/modules/login/routes-guard.service.ts | 2 +- 21 files changed, 50 insertions(+), 1508 deletions(-) delete mode 100644 backend/config_sync_mtd.py delete mode 100644 backend/geonature/core/auth/__init__.py delete mode 100644 backend/geonature/core/auth/routes.py delete mode 100644 backend/geonature/core/auth/templates/cas_login_error.html delete mode 100644 backend/geonature/core/gn_meta/mtd/__init__.py delete mode 100644 backend/geonature/core/gn_meta/mtd/mtd_utils.py delete mode 100644 backend/geonature/core/gn_meta/mtd/mtd_webservice.py delete mode 100644 backend/geonature/core/gn_meta/mtd/xml_parser.py delete mode 100644 backend/geonature/custom.py delete mode 100644 backend/geonature/tests/test_mtd.py diff --git a/backend/config_sync_mtd.py b/backend/config_sync_mtd.py deleted file mode 100644 index 7ccc750ae7..0000000000 --- a/backend/config_sync_mtd.py +++ /dev/null @@ -1,28 +0,0 @@ -from geonature.core.gn_meta.mtd import ( - INPNCAS, - sync_af_and_ds as mtd_sync_af_and_ds, - sync_af_and_ds_by_user, -) -from geonature.core.gn_meta.routes import routes -from flask import request, g -import logging - -INPNCAS.base_url = "https://inpn.mnhn.fr/authentication/" -INPNCAS.user = "user_change" -INPNCAS.password = "pass_change" -INPNCAS.id_instance_filter = 6 -INPNCAS.mtd_api_endpoint = "https://preprod-inpn.mnhn.fr/mtd" -INPNCAS.activated = True - -log = logging.getLogger() - - -@routes.before_request -def synchronize_mtd(): - from flask_login import current_user - - if request.endpoint in ["gn_meta.get_datasets", "gn_meta.get_acquisition_frameworks_list"]: - try: - sync_af_and_ds_by_user(id_role=current_user.id_role) - except Exception as e: - log.exception("Error while get JDD via MTD") diff --git a/backend/dependencies/UsersHub-authentification-module b/backend/dependencies/UsersHub-authentification-module index 4a0144a229..62f24e9875 160000 --- a/backend/dependencies/UsersHub-authentification-module +++ b/backend/dependencies/UsersHub-authentification-module @@ -1 +1 @@ -Subproject commit 4a0144a229014c8176a121eaf01d792f0fa568bb +Subproject commit 62f24e9875d4a3b3b0f62da9aa011e6d5bc3c390 diff --git a/backend/geonature/app.py b/backend/geonature/app.py index 0457947649..ae3cfe5bae 100755 --- a/backend/geonature/app.py +++ b/backend/geonature/app.py @@ -21,10 +21,7 @@ from werkzeug.middleware.shared_data import SharedDataMiddleware from werkzeug.middleware.dispatcher import DispatcherMiddleware from werkzeug.wrappers import Response -from psycopg2.errors import UndefinedTable import sqlalchemy as sa -from sqlalchemy.exc import OperationalError, ProgrammingError -from sqlalchemy.orm.exc import NoResultFound if version.parse(sa.__version__) >= version.parse("1.4"): from sqlalchemy.engine import Row @@ -39,11 +36,7 @@ from geonature.core.admin.admin import admin from geonature.middlewares import SchemeFix, RequestID -from pypnusershub.db.tools import ( - user_from_token, - UnreadableAccessRightsError, - AccessRightsExpiredError, -) + from pypnusershub.db.models import Application from pypnusershub.auth import auth_manager from pypnusershub.login_manager import login_manager @@ -98,8 +91,6 @@ def create_app(with_external_mods=True): template_folder="geonature/templates", ) app.config.update(config) - auth_manager.init_app(app) - auth_manager.home_page = config["URL_APPLICATION"] # Enable deprecation warnings in debug mode if app.debug and not sys.warnoptions: @@ -203,7 +194,6 @@ def set_sentry_context(): ("geonature.core.users.routes:routes", "/users"), ("geonature.core.gn_synthese.routes:routes", "/synthese"), ("geonature.core.gn_meta.routes:routes", "/meta"), - ("geonature.core.auth.routes:routes", "/gn_auth"), ("geonature.core.gn_monitoring.routes:routes", "/gn_monitoring"), ("geonature.core.gn_profiles.routes:routes", "/gn_profiles"), ("geonature.core.sensitivity.routes:routes", None), diff --git a/backend/geonature/core/auth/__init__.py b/backend/geonature/core/auth/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/geonature/core/auth/routes.py b/backend/geonature/core/auth/routes.py deleted file mode 100644 index 8e11fdda8e..0000000000 --- a/backend/geonature/core/auth/routes.py +++ /dev/null @@ -1,105 +0,0 @@ -""" - Module d'identificiation provisoire pour test du CAS INPN -""" - -import datetime -import xmltodict -import logging -from copy import copy - - -from flask import ( - Blueprint, - request, - make_response, - redirect, - current_app, - jsonify, - render_template, - session, - Response, -) -from flask_login import login_user -import sqlalchemy as sa -from sqlalchemy import select -from utils_flask_sqla.response import json_resp - -from pypnusershub.db import models -from pypnusershub.db.models import User, Organisme, Application -from pypnusershub.db.tools import encode_token -from pypnusershub.routes import insert_or_update_organism, insert_or_update_role -from geonature.utils import utilsrequests -from geonature.utils.errors import CasAuthentificationError -from geonature.utils.env import db - - -routes = Blueprint("gn_auth", __name__, template_folder="templates") -log = logging.getLogger() - - -def get_user_from_id_inpn_ws(id_user): - URL = f"https://inpn.mnhn.fr/authentication/rechercheParId/{id_user}" - config_cas = current_app.config["CAS"] - try: - response = utilsrequests.get( - URL, - ( - config_cas["CAS_USER_WS"]["ID"], - config_cas["CAS_USER_WS"]["PASSWORD"], - ), - ) - assert response.status_code == 200 - return response.json() - except AssertionError: - log.error("Error with the inpn authentification service") - - -def insert_user_and_org(info_user, update_user_organism: bool = True): - organism_id = info_user["codeOrganisme"] - organism_name = info_user.get("libelleLongOrganisme", "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) - - # Retrieve user information from `info_user` - 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, - } - - # If not updating user organism and user already exists, retrieve existing user organism information rather than information from `info_user` - existing_user = User.query.get(user_id) - if not update_user_organism and existing_user: - user_info["id_organisme"] = existing_user.id_organisme - - # Insert or update user - user_info = insert_or_update_role(user_info) - - # Associate user to a default group if the user is not associated to any group - user = existing_user or db.session.get(User, user_id) - if not user.groups: - if current_app.config["CAS"]["USERS_CAN_SEE_ORGANISM_DATA"] and organism_id: - # group socle 2 - for a user associated to an organism if users can see data from their organism - group_id = current_app.config["BDD"]["ID_USER_SOCLE_2"] - else: - # group socle 1 - group_id = current_app.config["BDD"]["ID_USER_SOCLE_1"] - group = db.session.get(User, group_id) - user.groups.append(group) - - return user_info diff --git a/backend/geonature/core/auth/templates/cas_login_error.html b/backend/geonature/core/auth/templates/cas_login_error.html deleted file mode 100644 index ab90a5a933..0000000000 --- a/backend/geonature/core/auth/templates/cas_login_error.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - -

Echec de l'authentification

-

Deconnectez-vous du service INPN avant de retenter - une connexion Ă  GeoNature

-

Deconnexion

-

Retour vers GeoNature

- - diff --git a/backend/geonature/core/gn_meta/mtd/__init__.py b/backend/geonature/core/gn_meta/mtd/__init__.py deleted file mode 100644 index e996645768..0000000000 --- a/backend/geonature/core/gn_meta/mtd/__init__.py +++ /dev/null @@ -1,302 +0,0 @@ -import logging -import time -from urllib.parse import urljoin - -from lxml import etree -import requests - -from geonature.core.auth.routes import insert_user_and_org -from geonature.core.gn_meta.models import ( - CorAcquisitionFrameworkActor, - CorDatasetActor, - TAcquisitionFramework, -) -from geonature.utils.config import config -from geonature.utils.env import db - -from pypnnomenclature.models import TNomenclatures -from pypnusershub.db.models import User -from pypnusershub.auth.providers.cas_inpn_provider import * -from sqlalchemy import func, select - -from .mtd_utils import associate_actors, sync_af, sync_ds -from .xml_parser import parse_acquisition_framework, parse_acquisition_framwork_xml, parse_jdd_xml - -# create logger -logger = logging.getLogger("MTD_SYNC") -# config logger -logger.setLevel(config["MTD"]["SYNC_LOG_LEVEL"]) -handler = logging.StreamHandler() -formatter = logging.Formatter("%(asctime)s | %(levelname)s : %(message)s", "%Y-%m-%d %H:%M:%S") -handler.setFormatter(formatter) -logger.addHandler(handler) -# avoid logging output dupplication -logger.propagate = False - - -class MTDInstanceApi: - af_path = "/mtd/cadre/export/xml/GetRecordsByInstanceId?id={ID_INSTANCE}" - ds_path = "/mtd/cadre/jdd/export/xml/GetRecordsByInstanceId?id={ID_INSTANCE}" - ds_user_path = "/mtd/cadre/jdd/export/xml/GetRecordsByUserId?id={ID_ROLE}" - af_user_path = "/mtd/cadre/export/xml/GetRecordsByUserId?id={ID_ROLE}" - single_af_path = "/mtd/cadre/export/xml/GetRecordById?id={ID_AF}" # NOTE: `ID_AF` is actually an UUID and not an ID from the point of view of geonature database. - - # https://inpn.mnhn.fr/mtd/cadre/jdd/export/xml/GetRecordsByUserId?id=41542" - def __init__(self, api_endpoint, instance_id, id_role=None): - self.api_endpoint = api_endpoint - self.instance_id = instance_id - self.id_role = id_role - - def _get_xml_by_url(self, url): - logger.debug("MTD - REQUEST : %s" % url) - response = requests.get(url) - response.raise_for_status() - return response.content - - def _get_xml(self, path): - url = urljoin(self.api_endpoint, path) - url = url.format(ID_INSTANCE=self.instance_id) - return self._get_xml_by_url(url) - - def _get_af_xml(self): - return self._get_xml(self.af_path) - - def get_af_list(self): - xml = self._get_af_xml() - _xml_parser = etree.XMLParser(ns_clean=True, recover=True, encoding="utf-8") - root = etree.fromstring(xml, parser=_xml_parser) - af_iter = root.iterfind(".//{http://inpn.mnhn.fr/mtd}CadreAcquisition") - af_list = [] - for af in af_iter: - af_list.append(parse_acquisition_framework(af)) - return af_list - - def _get_ds_xml(self): - return self._get_xml(self.ds_path) - - def get_ds_list(self): - xml = self._get_ds_xml() - return parse_jdd_xml(xml) - - def get_ds_user_list(self): - """ - Retrieve the list of of datasets (ds) for the user. - - Returns - ------- - list - A list of datasets (ds) for the user. - """ - url = urljoin(self.api_endpoint, self.ds_user_path) - url = url.format(ID_ROLE=self.id_role) - try: - xml = self._get_xml_by_url(url) - except requests.HTTPError as http_error: - error_code = http_error.response.status_code - warning_message = f"""[HTTPError : {error_code}] for URL "{url}".""" - if error_code == 404: - warning_message = f"""{warning_message} > Probably no dataset found for the user with ID '{self.id_role}'""" - logger.warning(warning_message) - return [] - ds_list = parse_jdd_xml(xml) - return ds_list - - def get_list_af_for_user(self): - """ - Retrieve a list of acquisition frameworks (af) for the user. - - Returns - ------- - list - A list of acquisition frameworks for the user. - """ - url = urljoin(self.api_endpoint, self.af_user_path).format(ID_ROLE=self.id_role) - try: - xml = self._get_xml_by_url(url) - except requests.HTTPError as http_error: - error_code = http_error.response.status_code - warning_message = f"""[HTTPError : {error_code}] for URL "{url}".""" - if error_code == 404: - warning_message = f"""{warning_message} > Probably no acquisition framework found for the user with ID '{self.id_role}'""" - logger.warning(warning_message) - return [] - _xml_parser = etree.XMLParser(ns_clean=True, recover=True, encoding="utf-8") - root = etree.fromstring(xml, parser=_xml_parser) - af_iter = root.findall(".//{http://inpn.mnhn.fr/mtd}CadreAcquisition") - af_list = [parse_acquisition_framework(af) for af in af_iter] - return af_list - - def get_single_af(self, af_uuid): - """ - Return a single acquistion framework based on its uuid. - - Parameters - ---------- - af_uuid : str - uuid of the acquisition framework - - Returns - ------- - dict - acquisition framework data - """ - url = urljoin(self.api_endpoint, self.single_af_path) - url = url.format(ID_AF=af_uuid) - xml = self._get_xml_by_url(url) - return parse_acquisition_framwork_xml(xml) - - -class INPNCAS: - base_url = "" - user = "" - password = "" - id_instance_filter = None - id_search_path = "rechercheParId/{user_id}" - mtd_api_endpoint = "" - activated = False - - @classmethod - def _get_user_json(cls, user_id): - url = urljoin(cls.base_url, cls.id_search_path) - url = url.format(user_id=user_id) - response = requests.get(url, auth=(cls.user, cls.password)) - return response.json() - - @classmethod - def get_user(cls, user_id): - return cls._get_user_json(user_id) - - -def add_unexisting_digitizer(id_digitizer): - """ - Method to trigger global MTD sync. - - :param id_digitizer: as id role from meta info - """ - if ( - not db.session.scalar( - select(func.count("*")).select_from(User).filter_by(id_role=id_digitizer) - ) - > 0 - ): - # not fast - need perf optimization on user call - user = INPNCAS.get_user(id_digitizer) - # to avoid to create org - if user.get("codeOrganisme"): - user["codeOrganisme"] = None - # insert or update user - insert_user_and_org(user) - - -def process_af_and_ds(af_list, ds_list, id_role=None): - """ - Synchro AF, Synchro DS - - :param af_list: list af - :param ds_list: list ds - :param id_role: use role id pass on user authent only - """ - cas_api = INPNCAS() - # read nomenclatures from DB to avoid errors if GN nomenclature is not the same - list_cd_nomenclature = db.session.scalars( - select(TNomenclatures.cd_nomenclature).distinct() - ).all() - user_add_total_time = 0 - logger.debug("MTD - PROCESS AF LIST") - for af in af_list: - actors = af.pop("actors") - with db.session.begin_nested(): - start_add_user_time = time.time() - add_unexisting_digitizer(af["id_digitizer"] if not id_role else id_role) - user_add_total_time += time.time() - start_add_user_time - af = sync_af(af) - associate_actors( - actors, - CorAcquisitionFrameworkActor, - "id_acquisition_framework", - af.id_acquisition_framework, - ) - # TODO: remove actors removed from MTD - db.session.commit() - logger.debug("MTD - PROCESS DS LIST") - for ds in ds_list: - actors = ds.pop("actors") - # CREATE DIGITIZER - with db.session.begin_nested(): - start_add_user_time = time.time() - if not id_role: - add_unexisting_digitizer(ds["id_digitizer"]) - else: - add_unexisting_digitizer(id_role) - user_add_total_time += time.time() - start_add_user_time - ds = sync_ds(ds, list_cd_nomenclature) - if ds is not None: - associate_actors(actors, CorDatasetActor, "id_dataset", ds.id_dataset) - - user_add_total_time = round(user_add_total_time, 2) - db.session.commit() - - -def sync_af_and_ds(): - """ - Method to trigger global MTD sync. - """ - logger.info("MTD - SYNC GLOBAL : START") - mtd_api = MTDInstanceApi(INPNCAS.mtd_api_endpoint, INPNCAS.id_instance_filter) - - af_list = mtd_api.get_af_list() - - ds_list = mtd_api.get_ds_list() - - # synchro a partir des listes - process_af_and_ds(af_list, ds_list) - logger.info("MTD - SYNC GLOBAL : FINISH") - - -def sync_af_and_ds_by_user(id_role, id_af=None): - """ - Method to trigger MTD sync on user authentication. - - Args: - id_role (int): The ID of the role (group or user). - id_af (str, optional): The ID of the AF (Acquisition Framework). Defaults to None. - """ - - logger.info("MTD - SYNC USER : START") - - mtd_api = MTDInstanceApi(INPNCAS.mtd_api_endpoint, INPNCAS.id_instance_filter, id_role) - - # Get the list of datasets (ds) for the user - # NOTE: `mtd_api.get_ds_user_list()` tested and timed to about 7 seconds on the PROD instance 'GINCO Occtax' with id_role = 13829 > a user with a lot of metadata to be retrieved from 'INPN MĂ©tadonnĂ©es' to 'GINCO Occtax' - ds_list = mtd_api.get_ds_user_list() - - if not id_af: - # Get the unique UUIDs of the acquisition frameworks for the user - set_user_af_uuids = {ds["uuid_acquisition_framework"] for ds in ds_list} - user_af_uuids = list(set_user_af_uuids) - - # TODO - voir avec INPN pourquoi les AF par user ne sont pas dans l'appel global des AF - # Ce code ne fonctionne pas pour cette raison -> AF manquants - # af_list = mtd_api.get_af_list() - # af_list = [af for af in af_list if af["unique_acquisition_framework_id"] in user_af_uuids] - - # Get the list of acquisition frameworks for the user - # call INPN API for each AF to retrieve info - af_list = mtd_api.get_list_af_for_user() - else: - # TODO: handle case where the AF ; corresponding to the provided `id_af` ; does not exist yet in the database - # this case should not happend from a user action because the only case where `id_af` is provided is for when the user click to unroll an AF in the module Metadata, in which case the AF already exists in the database. - # It would still be better to handle case where the AF does not exist in the database, and to first retrieve the AF from 'INPN MĂ©tadonnĂ©es' in this case - uuid_af = TAcquisitionFramework.query.get(id_af).unique_acquisition_framework_id - uuid_af = str(uuid_af).upper() - - # Get the acquisition framework for the specified UUID, thus a list of one element - af_list = [mtd_api.get_single_af(uuid_af)] - - # Filter the datasets based on the specified UUID - ds_list = [ds for ds in ds_list if ds["uuid_acquisition_framework"] == uuid_af] - - # Process the acquisition frameworks and datasets - process_af_and_ds(af_list, ds_list, id_role) - - logger.info("MTD - SYNC USER : FINISH") diff --git a/backend/geonature/core/gn_meta/mtd/mtd_utils.py b/backend/geonature/core/gn_meta/mtd/mtd_utils.py deleted file mode 100644 index a117f761f6..0000000000 --- a/backend/geonature/core/gn_meta/mtd/mtd_utils.py +++ /dev/null @@ -1,246 +0,0 @@ -import logging -import json -from copy import copy -from flask import current_app - -from sqlalchemy import select, exists -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.sql import func, update - -from sqlalchemy.dialects.postgresql import insert as pg_insert - -from geonature.utils.env import DB -from geonature.core.gn_meta.models import ( - TDatasets, - CorDatasetActor, - TAcquisitionFramework, - CorAcquisitionFrameworkActor, -) -from geonature.core.gn_commons.models import TModules -from pypnusershub.db.models import Organisme as BibOrganismes, User -from geonature.core.users import routes as users -from geonature.core.auth.routes import insert_user_and_org, get_user_from_id_inpn_ws - -from .xml_parser import parse_acquisition_framwork_xml, parse_jdd_xml -from .mtd_webservice import get_jdd_by_user_id, get_acquisition_framework, get_jdd_by_uuid - -NOMENCLATURE_MAPPING = { - "cd_nomenclature_data_type": "DATA_TYP", - "cd_nomenclature_dataset_objectif": "JDD_OBJECTIFS", - "cd_nomenclature_data_origin": "DS_PUBLIQUE", - "cd_nomenclature_source_status": "STATUT_SOURCE", -} - -# get the root logger -log = logging.getLogger() - - -def sync_ds(ds, cd_nomenclatures): - """ - Will create or update a given DS according to UUID. - Only process DS if dataset's cd_nomenclatures exists in ref_normenclatures.t_nomenclatures. - - :param ds: DS infos - :param cd_nomenclatures: cd_nomenclature from ref_normenclatures.t_nomenclatures - """ - if not ds["cd_nomenclature_data_origin"]: - ds["cd_nomenclature_data_origin"] = "NSP" - - # FIXME: the following temporary fix was added due to possible differences in referential of nomenclatures values between INPN and GeoNature - # should be fixed by ensuring that the two referentials are identical, at least for instances that integrates with INPN and thus rely on MTD synchronization from INPN MĂ©tadonnĂ©es: GINCO and DEPOBIO instances. - if ds["cd_nomenclature_data_origin"] not in cd_nomenclatures: - return - - # CONTROL AF - af_uuid = ds.pop("uuid_acquisition_framework") - af = ( - DB.session.execute( - select(TAcquisitionFramework).filter_by(unique_acquisition_framework_id=af_uuid) - ) - .unique() - .scalar_one_or_none() - ) - - if af is None: - log.warning(f"AF with UUID '{af_uuid}' not found in database.") - return - - ds["id_acquisition_framework"] = af.id_acquisition_framework - ds = { - field.replace("cd_nomenclature", "id_nomenclature"): ( - func.ref_nomenclatures.get_id_nomenclature(NOMENCLATURE_MAPPING[field], value) - if field.startswith("cd_nomenclature") - else value - ) - for field, value in ds.items() - if value is not None - } - - ds_exists = DB.session.scalar( - exists() - .where( - TDatasets.unique_dataset_id == ds["unique_dataset_id"], - ) - .select() - ) - - statement = ( - pg_insert(TDatasets) - .values(**ds) - .on_conflict_do_nothing(index_elements=["unique_dataset_id"]) - ) - if ds_exists: - statement = ( - update(TDatasets) - .where(TDatasets.unique_dataset_id == ds["unique_dataset_id"]) - .values(**ds) - ) - DB.session.execute(statement) - - dataset = DB.session.scalars( - select(TDatasets).filter_by(unique_dataset_id=ds["unique_dataset_id"]) - ).first() - - # Associate dataset to the modules if new dataset - if not ds_exists: - associate_dataset_modules(dataset) - - return dataset - - -def sync_af(af): - """Will update a given AF (Acquisition Framework) if already exists in database according to UUID, else update the AF. - - Parameters - ---------- - af : dict - AF infos. - - Returns - ------- - TAcquisitionFramework - The updated or inserted acquisition framework. - """ - af_uuid = af["unique_acquisition_framework_id"] - af_exists = DB.session.scalar( - exists().where(TAcquisitionFramework.unique_acquisition_framework_id == af_uuid).select() - ) - - # Update statement if AF already exists in DB else insert statement - statement = ( - update(TAcquisitionFramework) - .where(TAcquisitionFramework.unique_acquisition_framework_id == af_uuid) - .values(**af) - ) - if not af_exists: - statement = ( - pg_insert(TAcquisitionFramework) - .values(**af) - .on_conflict_do_nothing(index_elements=["unique_acquisition_framework_id"]) - ) - DB.session.execute(statement) - - acquisition_framework = DB.session.scalars( - select(TAcquisitionFramework).filter_by(unique_acquisition_framework_id=af_uuid) - ).first() - - return acquisition_framework - - -def add_or_update_organism(uuid, nom, email): - """ - Create or update organism if UUID not exists in DB. - - :param uuid: uniq organism uuid - :param nom: org name - :param email: org email - """ - # Test if actor already exists to avoid nextVal increase - org_exist = DB.session.scalar(exists().where(BibOrganismes.uuid_organisme == uuid).select()) - - if org_exist: - statement = ( - update(BibOrganismes) - .where(BibOrganismes.uuid_organisme == uuid) - .values( - dict( - nom_organisme=nom, - email_organisme=email, - ) - ) - .returning(BibOrganismes.id_organisme) - ) - else: - statement = ( - pg_insert(BibOrganismes) - .values( - uuid_organisme=uuid, - nom_organisme=nom, - email_organisme=email, - ) - .on_conflict_do_nothing(index_elements=["uuid_organisme"]) - .returning(BibOrganismes.id_organisme) - ) - return DB.session.execute(statement).scalar() - - -def associate_actors(actors, CorActor, pk_name, pk_value): - """ - Associate actor and DS or AF according to CorActor value. - - Parameters - ---------- - actors : list - list of actors - CorActor : db.Model - table model - pk_name : str - pk attribute name - pk_value : str - pk value - """ - for actor in actors: - id_organism = None - uuid_organism = actor["uuid_organism"] - if uuid_organism: - with DB.session.begin_nested(): - # create or update organisme - # FIXME: prevent update of organism email from actor email ! Several actors may be associated to the same organism and still have different mails ! - id_organism = add_or_update_organism( - uuid=uuid_organism, - nom=actor["organism"] if actor["organism"] else "", - email=actor["email"], - ) - values = dict( - id_nomenclature_actor_role=func.ref_nomenclatures.get_id_nomenclature( - "ROLE_ACTEUR", actor["actor_role"] - ), - **{pk_name: pk_value}, - ) - if not id_organism: - values["id_role"] = DB.session.scalar( - select(User.id_role).filter_by(email=actor["email"]) - ) - else: - values["id_organism"] = id_organism - statement = ( - pg_insert(CorActor) - .values(**values) - .on_conflict_do_nothing( - index_elements=[pk_name, "id_organism", "id_nomenclature_actor_role"], - ) - ) - DB.session.execute(statement) - - -def associate_dataset_modules(dataset): - """ - Associate a dataset to modules specified in [MTD][JDD_MODULE_CODE_ASSOCIATION] parameter (geonature config) - - :param dataset: dataset (SQLAlchemy model object) - """ - dataset.modules.extend( - DB.session.scalars( - select(TModules).where(TModules.module_code.in_(["OCCTAX", "OCCHAB", "IMPORT"])) - ).all() - ) diff --git a/backend/geonature/core/gn_meta/mtd/mtd_webservice.py b/backend/geonature/core/gn_meta/mtd/mtd_webservice.py deleted file mode 100644 index 78d90976cf..0000000000 --- a/backend/geonature/core/gn_meta/mtd/mtd_webservice.py +++ /dev/null @@ -1,52 +0,0 @@ -from geonature.utils import utilsrequests -from geonature.utils.errors import GeonatureApiError -from geonature.utils.config import config - -api_endpoint = config["MTD_API_ENDPOINT"] - - -def get_acquisition_framework(uuid_af): - """ - Fetch a AF from the MTD WS with the uuid of the AD - - Parameters: - - uuid_af (str): the uuid of the AF - Returns: - byte: the xml of the AF as byte - """ - url = "{}/cadre/export/xml/GetRecordById?id={}" - try: - r = utilsrequests.get(url.format(api_endpoint, uuid_af)) - except AssertionError: - raise GeonatureApiError( - message="Error with the MTD Web Service while getting Acquisition Framwork" - ) - return r.content - - -def get_jdd_by_user_id(id_user): - """fetch the jdd(s) created by a user from the MTD web service - Parameters: - - id (int): id_user from CAS - Return: - byte: a XML as byte - """ - url = "{}/cadre/jdd/export/xml/GetRecordsByUserId?id={}" - try: - r = utilsrequests.get(url.format(api_endpoint, str(id_user))) - assert r.status_code == 200 - except AssertionError: - raise GeonatureApiError( - message="Error with the MTD Web Service (JDD), status_code: {}".format(r.status_code) - ) - return r.content - - -def get_jdd_by_uuid(uuid): - ds_URL = f"{api_endpoint}/cadre/jdd/export/xml/GetRecordById?id={uuid.upper()}" - try: - r = utilsrequests.get(ds_URL) - assert r.status_code == 200 - except AssertionError: - print(f"NO JDD FOUND FOR UUID {uuid}") - return r.content diff --git a/backend/geonature/core/gn_meta/mtd/xml_parser.py b/backend/geonature/core/gn_meta/mtd/xml_parser.py deleted file mode 100644 index efcf98d6be..0000000000 --- a/backend/geonature/core/gn_meta/mtd/xml_parser.py +++ /dev/null @@ -1,210 +0,0 @@ -import datetime -import json - -from flask import current_app -from lxml import etree as ET - -from geonature.utils.config import config -from geonature.core.gn_meta.models import TAcquisitionFramework - - -namespace = config["XML_NAMESPACE"] - -_xml_parser = ET.XMLParser(ns_clean=True, recover=True, encoding="utf-8") - - -def get_tag_content(parent, tag_name, default_value=None): - """ - Return the content of a xml tag - Check if the node exist or return a default value - Params: - parent (etree Element): the parent where find the tag - tag_name (str): the name of the tag - default_value (any): the default value f the tag doesn't exist - Return - any: the tag content or the default value - """ - tag = parent.find(namespace + tag_name) - if tag is not None: - if tag.text and len(tag.text) > 0: - return tag.text - return default_value - - -def parse_actors_xml(actors): - """ - Parse the parameters of the Actor provided as an XML node in the input variable "actors" - Param: - actors (etree Element): Node of an actor type containing from one to multiple actors - Returns: - dict: A dictionnary of the actors informations - """ - actor_list = [] - if actors is not None: - for actor_node in actors: - name = get_tag_content(actor_node, "nomPrenom") - actor_role = get_tag_content(actor_node, "roleActeur") - uuid_organism = get_tag_content(actor_node, "idOrganisme") - organism = get_tag_content(actor_node, "organisme") - email = get_tag_content(actor_node, "mail") - - actor_list.append( - { - "name": name, - "uuid_organism": uuid_organism, - "organism": organism, - "actor_role": actor_role, - "email": email, - } - ) - - return actor_list - - -def parse_acquisition_framwork_xml(xml): - """ - Parse an xml of AF from a string - Return: - dict: a dict of the parsed xml - """ - root = ET.fromstring(xml, parser=_xml_parser) - ca = root.find(".//" + namespace + "CadreAcquisition") - return parse_acquisition_framework(ca) - - -def parse_acquisition_framework(ca): - # We extract all the required informations from the different tags of the XML file - ca_uuid = get_tag_content(ca, "identifiantCadre") - ca_name_max_length = TAcquisitionFramework.acquisition_framework_name.property.columns[ - 0 - ].type.length - ca_name = get_tag_content(ca, "libelle")[: ca_name_max_length - 1] - ca_desc = get_tag_content(ca, "description", default_value="") - date_info = ca.find(namespace + "ReferenceTemporelle") - ca_create_date = get_tag_content(ca, "dateCreationMtd", default_value=datetime.datetime.now()) - ca_update_date = get_tag_content(ca, "dateMiseAJourMtd") - ca_start_date = get_tag_content( - date_info, "dateLancement", default_value=datetime.datetime.now() - ) - ca_end_date = get_tag_content(date_info, "dateCloture") - ca_id_digitizer = None - attributs_additionnels_node = ca.find(namespace + "attributsAdditionnels") - - # We extract the ID of the user to assign it the JDD as an id_digitizer - for attr in attributs_additionnels_node: - if get_tag_content(attr, "nomAttribut") == "ID_CREATEUR": - ca_id_digitizer = get_tag_content(attr, "valeurAttribut") - - # We search for all the Contact nodes : - # - Main contact in acteurPrincipal node - # - Funder in acteurAutre node - # - Project owner in acteurAutre node - # - Project manager in acteurAutre node - list_contact_tags = ["acteurPrincipal", "acteurAutre"] - all_actors = [] - for contact_tag in list_contact_tags: - if get_tag_content(ca, contact_tag) is not None: - for actor_node in ca.findall(namespace + contact_tag): - actor = parse_actors_xml(actor_node) - all_actors = all_actors + actor - - return { - "unique_acquisition_framework_id": ca_uuid, - "acquisition_framework_name": ca_name, - "acquisition_framework_desc": ca_desc, - "acquisition_framework_start_date": ca_start_date, - "acquisition_framework_end_date": ca_end_date, - "meta_create_date": ca_create_date, - "meta_update_date": ca_update_date, - "id_digitizer": ca_id_digitizer, - "actors": all_actors, - } - - -def parse_jdd_xml(xml): - """ - Parse an xml of datasets from a string - Return: - list: a list of dict of the JDD in the xml - """ - - root = ET.fromstring(xml, parser=_xml_parser) - jdd_list = [] - for jdd in root.findall(".//" + namespace + "JeuDeDonnees"): - # We extract all the required informations from the different tags of the XML file - jdd_uuid = get_tag_content(jdd, "identifiantJdd") - ca_uuid = get_tag_content(jdd, "identifiantCadre") - dataset_name = get_tag_content(jdd, "libelle") - dataset_shortname = get_tag_content(jdd, "libelleCourt", default_value="") - dataset_desc = get_tag_content(jdd, "description", default_value="") - terrestrial_domain = get_tag_content(jdd, "domaineTerrestre", default_value=False) - marine_domain = get_tag_content(jdd, "domaineMarin", default_value=False) - data_type = get_tag_content(jdd, "typeDonnees") - collect_data_type = get_tag_content(jdd, "typeDonneesCollectees") - create_date = get_tag_content(jdd, "dateCreation", default_value=datetime.datetime.now()) - update_date = get_tag_content(jdd, "dateRevision") - attributs_additionnels_node = jdd.find(namespace + "attributsAdditionnels") - - # We extract the ID of the user to assign it the JDD as an id_digitizer - id_digitizer = None - id_instance = None - code_statut_donnees_source = None - for attr in attributs_additionnels_node: - if get_tag_content(attr, "nomAttribut") == "ID_CREATEUR": - id_digitizer = get_tag_content(attr, "valeurAttribut") - - if get_tag_content(attr, "nomAttribut") == "ID_INSTANCE": - id_instance = get_tag_content(attr, "valeurAttribut") - - if get_tag_content(attr, "nomAttribut") == "CODE_STATUT_DONNEES_SOURCE": - code_statut_donnees_source = get_tag_content(attr, "valeurAttribut") - - # We search for all the Contact nodes : - # - Main contact in pointContactPF node - # - JDD provider in pointContactJdd node - # - JDD builder in pointContactJdd node - # - Database contact in contactBaseProduction node - list_contact_tags = ["pointContactPF", "pointContactJdd", "contactBaseProduction"] - all_actors = [] - for contact_tag in list_contact_tags: - if contact_tag == "contactBaseProduction": - contact_node = jdd.find(namespace + "BaseProduction") - else: - contact_node = jdd - if get_tag_content(contact_node, contact_tag) is not None: - for actor_node in contact_node.findall(namespace + contact_tag): - actor = parse_actors_xml(actor_node) - all_actors = all_actors + actor - - keywords = None - - # We build the JDD data from all the variables collected from the XML file - current_jdd = { - "unique_dataset_id": jdd_uuid, - "uuid_acquisition_framework": ca_uuid, - "dataset_name": dataset_name if len(dataset_name) < 256 else f"{dataset_name[:253]}...", - "dataset_shortname": dataset_shortname, - "dataset_desc": ( - dataset_desc - if len(dataset_name) < 256 - else f"Nom complet du jeu de donnĂ©es dans MTD : {dataset_name}\n {dataset_desc}" - ), - "keywords": keywords, - "terrestrial_domain": json.loads(terrestrial_domain), - "marine_domain": json.loads(marine_domain), - "cd_nomenclature_data_type": data_type, - "id_digitizer": id_digitizer, - "cd_nomenclature_data_origin": code_statut_donnees_source, - "actors": all_actors, - "meta_create_date": create_date, - "meta_update_date": update_date, - } - - # filter with id_instance - if current_app.config["MTD"]["ID_INSTANCE_FILTER"]: - if id_instance and id_instance == str(current_app.config["MTD"]["ID_INSTANCE_FILTER"]): - jdd_list.append(current_jdd) - else: - jdd_list.append(current_jdd) - - return jdd_list diff --git a/backend/geonature/core/gn_meta/routes.py b/backend/geonature/core/gn_meta/routes.py index 2db09b663f..e405ece17f 100644 --- a/backend/geonature/core/gn_meta/routes.py +++ b/backend/geonature/core/gn_meta/routes.py @@ -31,7 +31,6 @@ ) from geonature.core.gn_permissions.decorators import login_required -from .mtd import sync_af_and_ds as mtd_sync_af_and_ds, sync_af_and_ds_by_user from ref_geo.models import LAreas from pypnnomenclature.models import TNomenclatures @@ -56,12 +55,8 @@ from geonature.core.gn_permissions import decorators as permissions from geonature.core.gn_permissions.tools import get_scopes_by_action from geonature.core.gn_permissions.models import TObjects -from geonature.core.gn_meta.mtd import mtd_utils import geonature.utils.filemanager as fm import geonature.utils.utilsmails as mail -from geonature.utils.errors import GeonatureApiError -from .mtd import sync_af_and_ds as mtd_sync_af_and_ds -from geonature.core.gn_meta.mtd import INPNCAS from ref_geo.models import LAreas @@ -1048,24 +1043,3 @@ def publish_acquisition_framework(af_id): publish_acquisition_framework_mail(af) return af.as_dict() - - -@routes.cli.command() -@click.option("--id-role", nargs=1, required=False, default=None, help="ID of an user") -@click.option( - "--id-af", nargs=1, required=False, default=None, help="ID of an acquisition framework" -) -def mtd_sync(id_role, id_af): - """ - \b - Triggers : - - global sync for instance - - a sync for a given user only (if id_role is provided) - - a sync for a given AF (Acquisition Framework) only (if id_af is provided). NOTE: the AF should in this case already exist in the database, and only datasets associated to this AF will be retrieved - - NOTE: if both id_role and id_af are provided, only the datasets possibly associated to both the AF and the user will be retrieved. - """ - if id_role: - return sync_af_and_ds_by_user(id_role, id_af) - else: - return mtd_sync_af_and_ds() diff --git a/backend/geonature/core/gn_monitoring/routes.py b/backend/geonature/core/gn_monitoring/routes.py index a5a21d0684..3eaac25be8 100644 --- a/backend/geonature/core/gn_monitoring/routes.py +++ b/backend/geonature/core/gn_monitoring/routes.py @@ -1,16 +1,12 @@ from flask import Blueprint, request -from sqlalchemy.sql import func from geojson import FeatureCollection - -from geonature.utils.env import DB - from geonature.core.gn_monitoring.models import TBaseSites, corSiteArea, corSiteModule - -from utils_flask_sqla.response import json_resp -from utils_flask_sqla_geo.generic import get_geojson_feature +from geonature.utils.env import DB from ref_geo.models import LAreas from sqlalchemy import select - +from sqlalchemy.sql import func +from utils_flask_sqla.response import json_resp +from utils_flask_sqla_geo.generic import get_geojson_feature routes = Blueprint("gn_monitoring", __name__) diff --git a/backend/geonature/custom.py b/backend/geonature/custom.py deleted file mode 100644 index bb5bfb4e7e..0000000000 --- a/backend/geonature/custom.py +++ /dev/null @@ -1,340 +0,0 @@ -import datetime -import logging -from typing import Any, Union - -import xmltodict - -from flask import Response, current_app, jsonify, make_response, redirect, render_template, request -from geonature.utils import utilsrequests -from geonature.utils.errors import GeonatureApiError -from geonature.core.auth.providers import ExternalGNAuthProvider -from pypnusershub.auth import auth_manager, Authentication -from pypnusershub.db import db, models -from pypnusershub.db.tools import encode_token -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 -# PUB_URL = "https://ginco2-preprod.mnhn.fr/" -# 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 - - -# def get_user_from_id_inpn_ws(id_user): -# URL = f"https://inpn.mnhn.fr/authentication/rechercheParId/{id_user}" -# try: -# response = utilsrequests.get( -# URL, -# ( -# CAS_USER_WS["ID"], -# CAS_USER_WS["PASSWORD"], -# ), -# ) -# assert response.status_code == 200 -# return response.json() -# except AssertionError: -# log.error("Error with the inpn authentification service") - - -# def insert_user_and_org(info_user): -# 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_info = insert_or_update_role(user_info) -# user = db.session.get(models.User, user_id) -# # if not user.groups: -# # if not current_app.config["CAS"]["USERS_CAN_SEE_ORGANISM_DATA"] or organism_id is None: -# # # group socle 1 -# # group_id = current_app.config["BDD"]["ID_USER_SOCLE_1"] -# # else: -# # # group socle 2 -# # group_id = current_app.config["BDD"]["ID_USER_SOCLE_2"] -# # group = db.session.get(models.User, group_id) -# # user.groups.append(group) -# return user - - -# class AuthenficationCASINPN(Authentification): - -# def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: -# params = request.args -# if "ticket" in params: -# base_url = current_app.config["API_ENDPOINT"] + "/auth/login" -# url_validate = "{url}?ticket={ticket}&service={service}".format( -# url=CAS_PUBLIC["URL_VALIDATION"], -# ticket=params["ticket"], -# service=base_url, -# ) - -# response = utilsrequests.get(url_validate) -# user = None -# xml_dict = xmltodict.parse(response.content) -# resp = xml_dict["cas:serviceResponse"] -# if "cas:authenticationSuccess" in resp: -# user = resp["cas:authenticationSuccess"]["cas:user"] -# if user: -# ws_user_url = "{url}/{user}/?verify=false".format(url=CAS_USER_WS["URL"], user=user) -# try: -# response = utilsrequests.get( -# ws_user_url, -# ( -# CAS_USER_WS["ID"], -# CAS_USER_WS["PASSWORD"], -# ), -# ) -# assert response.status_code == 200 -# except AssertionError: -# log.error("Error with the inpn authentification service") -# raise CasAuthentificationError( -# "Error with the inpn authentification service", status_code=500 -# ) -# info_user = response.json() -# user = insert_user_and_org(info_user) -# 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 -# else: -# 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=CAS_PUBLIC["URL_LOGOUT"], -# url_geonature=current_app.config["URL_APPLICATION"], -# ) -# return jsonify({"message": "Authentification error"}, 500) - -# def revoke(self) -> Any: -# pass - -# def get_provider_url(self) -> str: -# endpoint = current_app.config["API_ENDPOINT"] -# base_url = CAS_PUBLIC["URL_LOGIN"] -# return f"{base_url}?service={endpoint}/auth/login" - -# def get_provider_revoke_url(self) -> str: -# endpoint = current_app.config["URL_APPLICATION"] -# base_url = CAS_PUBLIC["URL_LOGOUT"] -# return f"{base_url}?service={endpoint}" - - -# if CAS_AUTHENTIFICATION: -# auth_manager.set_auth_provider("cas_inpn", AuthenficationCASINPN) - -auth_manager.add_provider( - ExternalGNAuthProvider(base_url="https://geonature.ecrins-parcnational.fr", id_group=2), -) -from .custom_bis import user_cs, pw - -CAS_USER_WS = dict( - URL="https://inpn.mnhn.fr/authentication/information", - BASE_URL="https://inpn.mnhn.fr/authentication/", - ID=user_cs, - PASSWORD=pw, -) -USERS_CAN_SEE_ORGANISM_DATA = False - - -def get_user_from_id_inpn_ws(id_user): - URL = f"https://inpn.mnhn.fr/authentication/rechercheParId/{id_user}" - try: - response = utilsrequests.get( - URL, - ( - CAS_USER_WS["ID"], - CAS_USER_WS["PASSWORD"], - ), - ) - assert response.status_code == 200 - return response.json() - except AssertionError: - log.error("Error with the inpn authentification service") - - -def insert_user_and_org(info_user): - 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_info = insert_or_update_role(user_info) - user = db.session.get(models.User, user_id) - # if not user.groups: - # if not current_app.config["CAS"]["USERS_CAN_SEE_ORGANISM_DATA"] or organism_id is None: - # # group socle 1 - # group_id = current_app.config["BDD"]["ID_USER_SOCLE_1"] - # else: - # # group socle 2 - # group_id = current_app.config["BDD"]["ID_USER_SOCLE_2"] - # group = db.session.get(models.User, group_id) - # user.groups.append(group) - return user - - -class AuthenficationCASINPN(Authentication): - - def __init__(self) -> None: - - def login(): - gn_api = current_app.config["API_ENDPOINT"] - base_url = CAS_PUBLIC["URL_LOGIN"] - return f"{CAS_PUBLIC['URL_LOGOUT']}?service={auth_manager.home_page}" - - def logout(): - gn_api = current_app.config["API_ENDPOINT"] - base_url = CAS_PUBLIC["URL_LOGIN"] - return f"{base_url}?service={gn_api}/auth/login" - - super().__init__( - "cas_inpn", - login_url=property(login), - logout_url=property(logout), - is_external=True, - is_uh=False, - label="Cas INPN", - ) - - def authenticate(self, *args, **kwargs) -> Union[Response, models.User]: - params = request.args - if "ticket" in params: - base_url = current_app.config["API_ENDPOINT"] + "/auth/login" - url_validate = "{url}?ticket={ticket}&service={service}".format( - url=CAS_PUBLIC["URL_VALIDATION"], - ticket=params["ticket"], - service=base_url, - ) - - response = utilsrequests.get(url_validate) - user = None - xml_dict = xmltodict.parse(response.content) - resp = xml_dict["cas:serviceResponse"] - if "cas:authenticationSuccess" in resp: - user = resp["cas:authenticationSuccess"]["cas:user"] - if user: - ws_user_url = "{url}/{user}/?verify=false".format(url=CAS_USER_WS["URL"], user=user) - try: - response = utilsrequests.get( - ws_user_url, - ( - CAS_USER_WS["ID"], - CAS_USER_WS["PASSWORD"], - ), - ) - assert response.status_code == 200 - except AssertionError: - log.error("Error with the inpn authentification service") - raise CasAuthentificationError( - "Error with the inpn authentification service", status_code=500 - ) - info_user = response.json() - user = insert_user_and_org(info_user) - 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 - else: - 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=CAS_PUBLIC["URL_LOGOUT"], - url_geonature=current_app.config["URL_APPLICATION"], - ) - return jsonify({"message": "Authentification error"}, 500) - - def revoke(self) -> Any: - pass - - -if CAS_AUTHENTIFICATION: - auth_manager.add_provider(AuthenficationCASINPN()) - -# auth_manager.add_provider( -# "gn_ecrins", -# ExternalGNAuthProvider(base_url="https://geonature.ecrins-parcnational.fr", id_group=2), -# ) - -# Accueil : https://ginco2-preprod.mnhn.fr/ (URL publique) + http://ginco2-preprod.patnat.mnhn.fr/ (URL privĂ©e) diff --git a/backend/geonature/tests/test_mtd.py b/backend/geonature/tests/test_mtd.py deleted file mode 100644 index c0930803ab..0000000000 --- a/backend/geonature/tests/test_mtd.py +++ /dev/null @@ -1,58 +0,0 @@ -import pytest - -from sqlalchemy import select, exists - -from geonature.core.gn_meta.mtd import sync_af_and_ds_by_user, MTDInstanceApi -from pypnusershub.db.models import Organisme as BibOrganismes -from geonature.core.gn_meta.models import TAcquisitionFramework -from geonature.utils.config import config - -from geonature.utils.env import db - - -@pytest.fixture(scope="function") -def instances(): - instances = { - "af": MTDInstanceApi( - "https://inpn.mnhn.fr", - "26", - ), - "dataset": MTDInstanceApi( - "https://inpn.mnhn.fr", - "26", - ), - } - return instances - - -@pytest.mark.usefixtures("client_class", "temporary_transaction", "instances") -class TestMTD: - @pytest.mark.skip(reason="FIX with monkeypatch") - def test_get_xml(self, instances): - xml = instances["af"]._get_xml(MTDInstanceApi.af_path) - xml = instances["dataset"]._get_xml(MTDInstanceApi.ds_path) - - @pytest.mark.skip(reason="must fix CI on http request") # FIXME - def test_mtd(self, instances): - # mtd_api = MTDInstanceApi(config["MTD_API_ENDPOINT"], config["MTD"]["ID_INSTANCE_FILTER"]) - config["MTD_API_ENDPOINT"] = instances["af"].api_endpoint - config["MTD"]["ID_INSTANCE_FILTER"] = instances["af"].instance_id - af_list = instances["af"].get_af_list() - af = af_list[0] - if not af: - return - af_digitizer_id = af["id_digitizer"] - af_actors = af["actors"] - org_uuid = af_actors[0]["uuid_organism"] - if af_digitizer_id: - assert af_digitizer_id == "922" - - sync_af_and_ds_by_user(af_digitizer_id) - jdds = db.session.scalars( - select(TAcquisitionFramework).filter_by(id_digitizer=af_digitizer_id) - ).all() - # TODO Need Fix when INPN protocol is known - assert len(jdds) >= 1 - assert db.session.scalar( - exists().where(BibOrganismes.uuid_organisme == org_uuid).select() - ) diff --git a/backend/geonature/utils/config.py b/backend/geonature/utils/config.py index 771f90abc7..dd9fdb77f1 100644 --- a/backend/geonature/utils/config.py +++ b/backend/geonature/utils/config.py @@ -1,27 +1,38 @@ -import os import importlib - +import os from collections import ChainMap from urllib.parse import urlsplit from flask import Config from flask.helpers import get_root_path -from marshmallow import EXCLUDE, INCLUDE, Schema, fields -from marshmallow.exceptions import ValidationError - -from geonature.utils.config_schema import ( - GnGeneralSchemaConf, - GnPySchemaConf, -) -from geonature.utils.utilstoml import load_toml +from geonature.utils.config_schema import GnGeneralSchemaConf, GnPySchemaConf from geonature.utils.env import CONFIG_FILE from geonature.utils.errors import ConfigError - +from geonature.utils.utilstoml import load_toml +from marshmallow import EXCLUDE +from marshmallow.exceptions import ValidationError __all__ = ["config", "config_frontend"] def validate_provider_config(config, config_toml): + """ + Validate the authentication providers configuration. + + Parameters + ---------- + config : dict + The Flask application configuration. + config_toml : dict + The TOML configuration. + + + Raises + ------ + ValidationError + If the authentication providers configuration is invalid. + + """ if not "AUTHENTICATION" in config_toml: return for path_provider in config_toml["AUTHENTICATION"]["PROVIDERS"]: diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index 54763ef552..443642e64f 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -24,9 +24,6 @@ from geonature.utils.env import GEONATURE_VERSION, BACKEND_DIR, ROOT_DIR from geonature.utils.module import iter_modules_dist, get_module_config from geonature.utils.utilsmails import clean_recipients -from geonature.utils.utilstoml import load_and_validate_toml - -from pypnusershub.auth.authentication import ProviderConfigurationSchema class EmailStrOrListOfEmailStrField(fields.Field): @@ -50,18 +47,6 @@ def _check_email(self, value): validator(email) -class MTDSchemaConf(Schema): - JDD_MODULE_CODE_ASSOCIATION = fields.List(fields.String, load_default=["OCCTAX", "OCCHAB"]) - ID_INSTANCE_FILTER = fields.Integer(load_default=None) - SYNC_LOG_LEVEL = fields.String(load_default="INFO") - ACTIVATED = fields.Boolean(load_default=True, default=False) - - -class BddConfig(Schema): - ID_USER_SOCLE_1 = fields.Integer(load_default=7) - ID_USER_SOCLE_2 = fields.Integer(load_default=6) - - class RightsSchemaConf(Schema): NOTHING = fields.Integer(load_default=0) MY_DATA = fields.Integer(load_default=1) @@ -555,8 +540,6 @@ class GnGeneralSchemaConf(Schema): API_ENDPOINT = fields.Url(required=True) API_TAXHUB = fields.Url(required=True) CODE_APPLICATION = fields.String(load_default="GN") - XML_NAMESPACE = fields.String(load_default="{http://inpn.mnhn.fr/mtd}") - MTD_API_ENDPOINT = fields.Url(load_default="https://preprod-inpn.mnhn.fr/mtd") DISABLED_MODULES = fields.List(fields.String(), load_default=[]) RIGHTS = fields.Nested(RightsSchemaConf, load_default=RightsSchemaConf().load({})) FRONTEND = fields.Nested(GnFrontEndConf, load_default=GnFrontEndConf().load({})) @@ -564,14 +547,12 @@ class GnGeneralSchemaConf(Schema): MAPCONFIG = fields.Nested(MapConfig, load_default=MapConfig().load({})) # Ajoute la surchouche 'taxonomique' sur l'API nomenclature ENABLE_NOMENCLATURE_TAXONOMIC_FILTERS = fields.Boolean(load_default=True) - BDD = fields.Nested(BddConfig, load_default=BddConfig().load({})) URL_USERSHUB = fields.Url(required=False) ACCOUNT_MANAGEMENT = fields.Nested(AccountManagement, load_default=AccountManagement().load({})) MEDIAS = fields.Nested(MediasConfig, load_default=MediasConfig().load({})) STATIC_URL = fields.String(load_default="/static") MEDIA_URL = fields.String(load_default="/media") METADATA = fields.Nested(MetadataConfig, load_default=MetadataConfig().load({})) - MTD = fields.Nested(MTDSchemaConf, load_default=MTDSchemaConf().load({})) NB_MAX_DATA_SENSITIVITY_REPORT = fields.Integer(load_default=1000000) ADDITIONAL_FIELDS = fields.Nested(AdditionalFields, load_default=AdditionalFields().load({})) PUBLIC_ACCESS_USERNAME = fields.String(load_default="") @@ -584,18 +565,6 @@ class GnGeneralSchemaConf(Schema): AuthenticationConfig, load_default=AuthenticationConfig().load({}), unknown=INCLUDE ) - # @validates_schema - # def validate_enable_sign_up(self, data, **kwargs): - # # si CAS_PUBLIC = true and ENABLE_SIGN_UP = true - # if data["CAS_PUBLIC"]["CAS_AUTHENTIFICATION"] and ( - # data["ACCOUNT_MANAGEMENT"]["ENABLE_SIGN_UP"] - # or data["ACCOUNT_MANAGEMENT"]["ENABLE_USER_MANAGEMENT"] - # ): - # raise ValidationError( - # "CAS_PUBLIC et ENABLE_SIGN_UP ou ENABLE_USER_MANAGEMENT ne peuvent ĂȘtre activĂ©s ensemble", - # "ENABLE_SIGN_UP, ENABLE_USER_MANAGEMENT", - # ) - @validates_schema def validate_account_autovalidation(self, data, **kwargs): account_config = data["ACCOUNT_MANAGEMENT"] diff --git a/config/default_config.toml.example b/config/default_config.toml.example index 0580e2ec9d..50d93c17cd 100644 --- a/config/default_config.toml.example +++ b/config/default_config.toml.example @@ -59,10 +59,6 @@ COOKIE_AUTORENEW = true # Capturer toutes les exceptions (=true) ou pas (=false) TRAP_ALL_EXCEPTIONS = false -# MTD (pour la connexion au webservice de mĂ©tadonnĂ©es de l'INPN) -XML_NAMESPACE = "{http://inpn.mnhn.fr/mtd}" -MTD_API_ENDPOINT = "https://preprod-inpn.mnhn.fr/mtd" - # Configuration de l'accĂšs sans authentication. Renseigner l’identifiant de l’utilisateur public. PUBLIC_ACCESS_USERNAME = "" @@ -107,29 +103,6 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *" # aprĂšs les virgules sont ignorĂ©s. ERROR_MAIL_TO = ["PrĂ©nom NOM ", "email2@email.com"] -# CAS authentification (Optionnel, remplace l'authentification locale via UsersHub) -[CAS_PUBLIC] - CAS_AUTHENTIFICATION = false - CAS_URL_LOGIN = "https://preprod-inpn.mnhn.fr/auth/login" - CAS_URL_LOGOUT = "https://preprod-inpn.mnhn.fr/auth/logout" - -[CAS] - CAS_URL_VALIDATION = "https://preprod-inpn.mnhn.fr/auth/serviceValidate" - -[CAS.CAS_USER_WS] - URL = "https://inpn2.mnhn.fr/authentication/information" - BASE_URL = "https://inpn2.mnhn.fr/authentication/" - ID = "mon_id" - PASSWORD = "mon_pass" - -# Connexion avec le WS MTD -[MTD] - # Modules auxquels les JDD sont automatiquement associĂ©s - JDD_MODULE_CODE_ASSOCIATION = ["OCCTAX", "OCCHAB"] - # Filter les JDD par id_instance - # ID_INSTANCE_FILTER = "null" - SYNC_LOG_LEVEL = "INFO" - [BDD] id_area_type_municipality = 25 ID_USER_SOCLE_1 = 8 @@ -627,13 +600,14 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *" # identifiant du groupe dans lequel les utilisateurs externes vont ĂȘtre ajoutĂ©s ID_GROUP_RECONCILIATION = 2 + group_mapping [[AUTHENTICATION.OPENID_PROVIDER_CONFIG]] id_provider = "keycloak" label = "KeyCloak" ISSUER = "http://MY_URL" - CLIENT_ID = "local-gn" - CLIENT_SECRET = "561vc5s4v6s5f4v65ds4v564fd" + CLIENT_ID = "ddd" + CLIENT_SECRET = "dddd" [[AUTHENTICATION.OPENID_PROVIDER_CONFIG]] id_provider = "google" diff --git a/frontend/src/app/components/auth/auth.service.ts b/frontend/src/app/components/auth/auth.service.ts index be1ee00efd..097a708d4e 100644 --- a/frontend/src/app/components/auth/auth.service.ts +++ b/frontend/src/app/components/auth/auth.service.ts @@ -5,8 +5,6 @@ import { HttpClient } from '@angular/common/http'; import { CookieService } from 'ng2-cookies'; import 'rxjs/add/operator/delay'; -import { forkJoin } from 'rxjs'; -import { tap } from 'rxjs/operators'; import * as moment from 'moment'; import { CruvedStoreService } from '@geonature_common/service/cruved-store.service'; import { ModuleService } from '../../services/module.service'; @@ -92,7 +90,7 @@ export class AuthService { } signinUser(form: any) { - return this._http.post(`${this.config.API_ENDPOINT}/auth/login/gn_ecrins`, form); + return this._http.post(`${this.config.API_ENDPOINT}/auth/login/local_provider`, form); } signinPublicUser(): Observable { @@ -141,7 +139,6 @@ export class AuthService { } logout() { - const provider = this.getCurrentUser().provider; this.cleanLocalStorage(); this.cruvedService.clearCruved(); let logout_url = `${this.config.API_ENDPOINT}/auth/logout`; diff --git a/frontend/src/app/modules/login/login/login.component.ts b/frontend/src/app/modules/login/login/login.component.ts index 48ccfa9de0..afecf8ff46 100644 --- a/frontend/src/app/modules/login/login/login.component.ts +++ b/frontend/src/app/modules/login/login/login.component.ts @@ -50,12 +50,12 @@ export class LoginComponent implements OnInit { } ngOnInit() { - this._authService.getAuthProviders().subscribe((providers) => { - this.authProviders = providers; - }); if (this.config.AUTHENTICATION.ONLY_PROVIDER) { window.location.href = this.getProviderLoginUrl(this.config.AUTHENTICATION.ONLY_PROVIDER); } + this._authService.getAuthProviders().subscribe((providers) => { + this.authProviders = providers; + }); } async register(form) { @@ -125,6 +125,11 @@ export class LoginComponent implements OnInit { return `${this.config.API_ENDPOINT}/auth/login/${provider_id}`; } + /** + * Opens a dialog to connect to a specified provider (with is_uh activated) . + * + * @param {Provider} provider - The provider for which the dialog is opened. + */ openDialog(provider) { const dialogRef = this.dialog.open(LoginDialog, { height: '30%', @@ -134,7 +139,7 @@ export class LoginComponent implements OnInit { provider: provider, }, }); - // dialogRef.updateSize('100%', '100%'); + const componentInstance: LoginDialog = dialogRef.componentInstance; componentInstance.userLogged.subscribe((data) => { this.handleRegister(data); diff --git a/frontend/src/app/modules/login/providers.ts b/frontend/src/app/modules/login/providers.ts index 59378986f3..e98171cf63 100644 --- a/frontend/src/app/modules/login/providers.ts +++ b/frontend/src/app/modules/login/providers.ts @@ -1,8 +1,8 @@ -export interface Provider{ - id_provider:string; - is_uh: boolean; - label: string; - login_url:string; - logout_url:string; - logo:string; -} \ No newline at end of file +export interface Provider { + id_provider: string; + is_uh: boolean; + label: string; + login_url: string; + logout_url: string; + logo: string; +} diff --git a/frontend/src/app/modules/login/routes-guard.service.ts b/frontend/src/app/modules/login/routes-guard.service.ts index 9b27ff3546..6816303cfe 100644 --- a/frontend/src/app/modules/login/routes-guard.service.ts +++ b/frontend/src/app/modules/login/routes-guard.service.ts @@ -111,7 +111,7 @@ export class UserCasGuard implements CanActivate, CanActivateChild { async canActivate(): Promise { let res: boolean = false; - if (this._configService.CAS_PUBLIC.CAS_AUTHENTIFICATION) { + if (this._configService?.CAS_PUBLIC?.CAS_AUTHENTIFICATION) { let data = await this._httpclient .get(`${this._configService.API_ENDPOINT}/auth/get_current_user`) .toPromise(); From 511b926a804c66e37d4637e6dd0262b542f0d525 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 12 Jul 2024 14:39:01 +0200 Subject: [PATCH 29/47] feat(authentication) : replace is_uh by is_external + drop `Authentication.configuration_schema` method (validation schema is declared and used in `configure` --- .../UsersHub-authentification-module | 2 +- backend/geonature/utils/config.py | 34 ------------------- backend/geonature/utils/config_schema.py | 11 +++--- .../src/app/components/auth/auth.service.ts | 2 +- .../modules/login/login/login.component.html | 2 +- .../modules/login/login/login.component.ts | 2 +- frontend/src/app/modules/login/providers.ts | 2 +- 7 files changed, 12 insertions(+), 43 deletions(-) diff --git a/backend/dependencies/UsersHub-authentification-module b/backend/dependencies/UsersHub-authentification-module index 62f24e9875..8b4b3daea6 160000 --- a/backend/dependencies/UsersHub-authentification-module +++ b/backend/dependencies/UsersHub-authentification-module @@ -1 +1 @@ -Subproject commit 62f24e9875d4a3b3b0f62da9aa011e6d5bc3c390 +Subproject commit 8b4b3daea6de1bc96915e4f6eecee7739f69bf32 diff --git a/backend/geonature/utils/config.py b/backend/geonature/utils/config.py index dd9fdb77f1..34acffb504 100644 --- a/backend/geonature/utils/config.py +++ b/backend/geonature/utils/config.py @@ -15,39 +15,6 @@ __all__ = ["config", "config_frontend"] -def validate_provider_config(config, config_toml): - """ - Validate the authentication providers configuration. - - Parameters - ---------- - config : dict - The Flask application configuration. - config_toml : dict - The TOML configuration. - - - Raises - ------ - ValidationError - If the authentication providers configuration is invalid. - - """ - if not "AUTHENTICATION" in config_toml: - return - for path_provider in config_toml["AUTHENTICATION"]["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) - schema_unique_provider = class_.configuration_schema() - config["AUTHENTICATION"][class_.name] = schema_unique_provider(many=True).load( - config_toml["AUTHENTICATION"][class_.name], unknown=EXCLUDE - ) - - # Load config from GEONATURE_* env vars and from GEONATURE_SETTINGS python module (if any) config_programmatic = Config(get_root_path("geonature")) config_programmatic.from_prefixed_env(prefix="GEONATURE") @@ -74,7 +41,6 @@ def validate_provider_config(config, config_toml): config = ChainMap({}, config_programmatic, config_backend, config_frontend, config_default) -validate_provider_config(config, config_toml) api_uri = urlsplit(config["API_ENDPOINT"]) if "APPLICATION_ROOT" not in config: diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index 443642e64f..469d52b65a 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -24,6 +24,7 @@ from geonature.utils.env import GEONATURE_VERSION, BACKEND_DIR, ROOT_DIR from geonature.utils.module import iter_modules_dist, get_module_config from geonature.utils.utilsmails import clean_recipients +from pypnusershub.auth.authentication import ProviderConfigurationSchema class EmailStrOrListOfEmailStrField(fields.Field): @@ -156,15 +157,17 @@ class MetadataConfig(Schema): class AuthenticationConfig(Schema): PROVIDERS = fields.List( - fields.String(), load_default=[] + fields.Dict(), load_default=[] ) # MAYBE add default auth in this list ? (for people to disable the default login) DEFAULT_RECONCILIATION_GROUP_ID = fields.Integer() DISPLAY_DEFAULT_LOGIN_FORM = fields.Boolean(load_default=True) ONLY_PROVIDER = fields.String(load_default=None) - PROVIDERS_CONFIG = fields.Dict( - load_default={}, - ) + + @validates_schema + def validate_provider(self, data, **kwargs): + for provider in data["PROVIDERS"]: + ProviderConfigurationSchema().load(provider, unknown=INCLUDE) # class a utiliser pour les paramĂštres que l'on ne veut pas passer au frontend diff --git a/frontend/src/app/components/auth/auth.service.ts b/frontend/src/app/components/auth/auth.service.ts index 097a708d4e..fd9bdd6ae1 100644 --- a/frontend/src/app/components/auth/auth.service.ts +++ b/frontend/src/app/components/auth/auth.service.ts @@ -90,7 +90,7 @@ export class AuthService { } signinUser(form: any) { - return this._http.post(`${this.config.API_ENDPOINT}/auth/login/local_provider`, form); + return this._http.post(`${this.config.API_ENDPOINT}/auth/login`, form); } signinPublicUser(): Observable { diff --git a/frontend/src/app/modules/login/login/login.component.html b/frontend/src/app/modules/login/login/login.component.html index df134b2a35..8cde6eeaae 100644 --- a/frontend/src/app/modules/login/login/login.component.html +++ b/frontend/src/app/modules/login/login/login.component.html @@ -81,7 +81,7 @@
- +
- +

Se connecter avec :

diff --git a/frontend/src/app/modules/login/login/login.component.ts b/frontend/src/app/modules/login/login/login.component.ts index 4190202b35..e8719b7f46 100644 --- a/frontend/src/app/modules/login/login/login.component.ts +++ b/frontend/src/app/modules/login/login/login.component.ts @@ -30,7 +30,8 @@ export class LoginComponent implements OnInit { login_or_pass_recovery: boolean = false; public APP_NAME = null; public authProviders: Array; - + public localProviderEnabled: boolean = true; + public isOtherProviders: boolean = false; constructor( public _authService: AuthService, //FIXME : change to private (html must be modified) private _commonService: CommonService, @@ -55,6 +56,19 @@ export class LoginComponent implements OnInit { } this._authService.getAuthProviders().subscribe((providers) => { this.authProviders = providers; + this.isOtherProviders = this.authProviders.length > 1; + // If local provider is not available in the configuration, disable it + if (!this.authProviders.find((p) => p.id_provider === 'local_provider')) { + this.localProviderEnabled = false; + } + // Local provider should not be display in the other providers buttons + this.authProviders = this.authProviders.filter((p) => p.id_provider !== 'local_provider'); + + // If one provider is declared (except the local one) + if (this.authProviders.length === 1 && !this.localProviderEnabled) { + const provider = this.authProviders[0]; + window.location.href = this.getProviderLoginUrl(provider.id_provider); + } }); } From 295a3a352b4d1637a51a3d043f9d42c6927c5887 Mon Sep 17 00:00:00 2001 From: jacquesfize Date: Fri, 12 Jul 2024 17:03:42 +0200 Subject: [PATCH 32/47] change use of is_external --- backend/dependencies/UsersHub-authentification-module | 2 +- frontend/src/app/modules/login/login/login.component.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/dependencies/UsersHub-authentification-module b/backend/dependencies/UsersHub-authentification-module index d90e0f13f0..fded771cdd 160000 --- a/backend/dependencies/UsersHub-authentification-module +++ b/backend/dependencies/UsersHub-authentification-module @@ -1 +1 @@ -Subproject commit d90e0f13f026f0ac9f5cfa7f73c7199b87dc74a1 +Subproject commit fded771cdd45575bef1aa554b49a948a809f5337 diff --git a/frontend/src/app/modules/login/login/login.component.html b/frontend/src/app/modules/login/login/login.component.html index 364b4b4164..41374ccaf7 100644 --- a/frontend/src/app/modules/login/login/login.component.html +++ b/frontend/src/app/modules/login/login/login.component.html @@ -81,7 +81,7 @@
- - - - - {{ label }} - - From 55c1f668e2561109d82f9f696e738c94e1aed1a6 Mon Sep 17 00:00:00 2001 From: TheoLechemia Date: Mon, 29 Jul 2024 10:07:58 +0200 Subject: [PATCH 47/47] Fix missing bottom padding on admin left menu --- backend/geonature/templates/layout.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/geonature/templates/layout.html b/backend/geonature/templates/layout.html index c16398ac43..4454b1ffea 100644 --- a/backend/geonature/templates/layout.html +++ b/backend/geonature/templates/layout.html @@ -74,8 +74,7 @@
-