From bdd360271e7c18ccbca3339b80c312e426c8bcaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Brunner?= Date: Thu, 19 Sep 2024 10:22:08 +0200 Subject: [PATCH 1/2] OpenID Connect: Add hook to be able to customize role creation --- doc/integrator/authentication_oidc.rst | 31 +++++ geoportal/c2cgeoportal_geoportal/__init__.py | 33 +---- geoportal/c2cgeoportal_geoportal/lib/oidc.py | 130 ++++++++++++++---- .../c2cgeoportal_geoportal/views/login.py | 22 +-- geoportal/tests/functional/test_oidc.py | 12 +- 5 files changed, 153 insertions(+), 75 deletions(-) diff --git a/doc/integrator/authentication_oidc.rst b/doc/integrator/authentication_oidc.rst index 4e9ed707cd..9fb84813ef 100644 --- a/doc/integrator/authentication_oidc.rst +++ b/doc/integrator/authentication_oidc.rst @@ -89,3 +89,34 @@ Other options ``scopes``: The list of scopes to request, default is [``openid``, ``profile``, ``email``]. ``query_user_info``: If ``true``, the user info will be requested instead if using the ``id_token``, default is false. + +~~~~~ +Hooks +~~~~~ + +If you want to redefine the user creation process, you can use the hooks ``get_remember_from_user_info`` +and ``get_user_from_remember``. + +``get_remember_from_user_info``: This hook is called during the user is authentication. +The argument are the pyramid ``request``, the received ``user_info``, and the ``remember_object`` dictionary +to be filled and will be stored in the cookie. + +``get_user_from_remember``: This hook is called during the user is certification. +The argument are the pyramid ``request``, the received ``remember_object``, and the ``create_user`` boolean. +The return value is the user object ``User`` or ``DynamicUsed``. + +Full signatures: + +.. code:: python + + def get_remember_from_user_info(request: Request, user_info: Dict[str, Any], remember_object: OidcRememberObject) -> None: + + def get_user_from_remember(request: Request, remember_object: OidcRememberObject, create_user: bool) -> Union[User, DynamicUsed]: + +Configure the hooks in the project initialization: + +.. code:: python + + def includeme(config): + config.add_request_method(get_remember_from_user_info, name="get_remember_from_user_info") + config.add_request_method(get_user_from_remember, name="get_user_from_remember") diff --git a/geoportal/c2cgeoportal_geoportal/__init__.py b/geoportal/c2cgeoportal_geoportal/__init__.py index 1e502de20d..b0ee2331a1 100644 --- a/geoportal/c2cgeoportal_geoportal/__init__.py +++ b/geoportal/c2cgeoportal_geoportal/__init__.py @@ -49,7 +49,6 @@ import sqlalchemy.orm import zope.event.classhandler from c2cgeoform import translator -from c2cwsgiutils.broadcast import decorator from c2cwsgiutils.health_check import HealthCheck from c2cwsgiutils.prometheus import MemoryMapCollector from deform import Form @@ -57,15 +56,15 @@ from papyrus.renderers import GeoJSON from prometheus_client.core import REGISTRY from pyramid.config import Configurator -from pyramid.httpexceptions import HTTPBadRequest, HTTPException +from pyramid.httpexceptions import HTTPException from pyramid.path import AssetResolver from pyramid_mako import add_mako_renderer -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import joinedload import c2cgeoportal_commons.models import c2cgeoportal_geoportal.views from c2cgeoportal_commons.models import InvalidateCacheEvent -from c2cgeoportal_geoportal.lib import C2CPregenerator, caching, check_collector, checker +from c2cgeoportal_geoportal.lib import C2CPregenerator, caching, check_collector, checker, oidc from c2cgeoportal_geoportal.lib.cacheversion import version_cache_buster from c2cgeoportal_geoportal.lib.common_headers import Cache, set_common_headers from c2cgeoportal_geoportal.lib.i18n import available_locale_names @@ -368,7 +367,6 @@ def get_user_from_request( """ from c2cgeoportal_commons.models import DBSession # pylint: disable=import-outside-toplevel from c2cgeoportal_commons.models.static import User # pylint: disable=import-outside-toplevel - from c2cgeoportal_geoportal.lib import oidc # pylint: disable=import-outside-toplevel assert DBSession is not None @@ -398,29 +396,7 @@ def get_user_from_request( ) user_info = oidc.OidcRemember(request).remember(token_response, request.host) - if openid_connect_config.get("provide_roles", False) is True: - from c2cgeoportal_commons.models.main import ( # pylint: disable=import-outside-toplevel - Role, - ) - - request.user_ = oidc.DynamicUser( - username=user_info["sub"], - display_name=user_info["username"], - email=user_info["email"], - settings_role=( - DBSession.query(Role).filter_by(name=user_info["settings_role"]).first() - if user_info.get("settings_role") is not None - else None - ), - roles=[ - DBSession.query(Role).filter_by(name=role).one() - for role in user_info.get("roles", []) - ], - ) - else: - request.user_ = DBSession.query(User).filter_by(username=user_info["sub"]).first() - for user in DBSession.query(User).all(): - _LOG.error(user.username) + request.user_ = request.get_user_from_reminder(user_info) else: # We know we will need the role object of the # user so we use joined loading @@ -569,6 +545,7 @@ def includeme(config: pyramid.config.Configurator) -> None: config.include("pyramid_mako") config.include("c2cwsgiutils.pyramid.includeme") + config.include(oidc.includeme) health_check = HealthCheck(config) config.registry["health_check"] = health_check diff --git a/geoportal/c2cgeoportal_geoportal/lib/oidc.py b/geoportal/c2cgeoportal_geoportal/lib/oidc.py index 072030c482..62d1eb9e0c 100644 --- a/geoportal/c2cgeoportal_geoportal/lib/oidc.py +++ b/geoportal/c2cgeoportal_geoportal/lib/oidc.py @@ -26,9 +26,10 @@ # either expressed or implied, of the FreeBSD Project. import datetime +import dis import json import logging -from typing import NamedTuple, TypedDict +from typing import TYPE_CHECKING, Any, NamedTuple, Optional, TypedDict, Union import pyramid.request import pyramid.response @@ -37,9 +38,11 @@ from pyramid.httpexceptions import HTTPBadRequest, HTTPInternalServerError, HTTPUnauthorized from pyramid.security import remember -from c2cgeoportal_commons.models import main from c2cgeoportal_geoportal.lib.caching import get_region +if TYPE_CHECKING: + from c2cgeoportal_commons.models import main, static + _LOG = logging.getLogger(__name__) _CACHE_REGION_OBJ = get_region("obj") @@ -53,8 +56,8 @@ class DynamicUser(NamedTuple): username: str display_name: str email: str - settings_role: main.Role | None - roles: list[main.Role] + settings_role: Optional["main.Role"] + roles: list["main.Role"] @_CACHE_REGION_OBJ.cache_on_arguments() @@ -70,7 +73,6 @@ def get_oidc_client(request: pyramid.request.Request, host: str) -> simple_openi if openid_connect.get("enabled", False) is not True: raise HTTPBadRequest("OpenID Connect not enabled") - _LOG.info(openid_connect) return simple_openid_connect.client.OpenidClient.from_issuer_url( url=openid_connect["url"], authentication_redirect_uri=request.route_url("oidc_callback"), @@ -96,6 +98,95 @@ class OidcRememberObject(TypedDict): roles: list[str] +def get_remember_from_user_info( + request: pyramid.request.Request, user_info: dict[str, Any], remember_object: OidcRememberObject +) -> None: + """ + Fill the remember object from the user info. + + The remember object will be stored in a cookie to remember the user. + + :param user_info: The user info from the ID token or from the user info view according to the `query_user_info` configuration. + :param remember_object: The object to fill, by default with the `username`, `email`, `settings_role` and `roles`, + the corresponding field from `user_info` can be configured in `user_info_fields`. + :param settings: The OpenID Connect configuration. + """ + settings_fields = ( + request.registry.settings.get("authentication", {}) + .get("openid_connect", {}) + .get("user_info_fields", {}) + ) + + for field_, default_field in ( + ("username", "sub"), + ("display_name", "name"), + ("email", "email"), + ("settings_role", None), + ("roles", None), + ): + user_info_field = settings_fields.get(field_, default_field) + if user_info_field is not None: + if user_info_field not in user_info: + _LOG.error( + "Field '%s' not found in user info, available: %s.", + user_info_field, + ", ".join(user_info.keys()), + ) + raise HTTPInternalServerError(f"Field '{user_info_field}' not found in user info.") + remember_object[field_] = user_info[user_info_field] # type: ignore[literal-required] + + +def get_user_from_remember( + request: pyramid.request.Request, remember_object: OidcRememberObject, create_user: bool = False +) -> Union["static.User", DynamicUser] | None: + """ + Create a user from the remember object filled from `get_remember_from_user_info`. + + :param remember_object: The object to fill, by default with the `username`, `email`, `settings_role` and `roles`. + :param settings: The OpenID Connect configuration. + :param create_user: If the user should be created if it does not exist. + """ + from c2cgeoportal_commons import models # pylint: disable=import-outside-toplevel + from c2cgeoportal_commons.models import main, static # pylint: disable=import-outside-toplevel + + assert models.DBSession is not None + + user: static.User | DynamicUser | None + username = remember_object["username"] + assert username is not None + email = remember_object["email"] + assert email is not None + display_name = remember_object["display_name"] or email + + + provide_roles = ( + request.registry.settings.get("authentication", {}) + .get("openid_connect", {}) + .get("provide_roles", False) + ) + if provide_roles is False: + user = models.DBSession.query(static.User).filter_by(email=email).one_or_none() + if user is None and create_user is True: + user = static.User(username=username, email=email, display_name=display_name) + models.DBSession.add(user) + else: + user = DynamicUser( + username=username, + display_name=display_name, + email=email, + settings_role=( + models.DBSession.query(main.Role).filter_by(name=remember_object["settings_role"]).first() + if remember_object.get("settings_role") is not None + else None + ), + roles=[ + models.DBSession.query(main.Role).filter_by(name=role).one() + for role in remember_object.get("roles", []) + ], + ) + return user + + class OidcRemember: """ Build the abject that we want to remember in the cookie. @@ -151,7 +242,6 @@ def remember( "settings_role": None, "roles": [], } - settings_fields = openid_connect.get("user_info_fields", {}) client = get_oidc_client(self.request, self.request.host) if openid_connect.get("query_user_info", False) is True: @@ -175,25 +265,15 @@ def remember( ), ) - for field_, default_field in ( - ("username", "sub"), - ("display_name", "name"), - ("email", "email"), - ("settings_role", None), - ("roles", None), - ): - user_info_field = settings_fields.get(field_, default_field) - if user_info_field is not None: - user_info_dict = user_info.dict() - if user_info_field not in user_info_dict: - _LOG.error( - "Field '%s' not found in user info, available: %s.", - user_info_field, - ", ".join(user_info_dict.keys()), - ) - raise HTTPInternalServerError(f"Field '{user_info_field}' not found in user info.") - remember_object[field_] = user_info_dict[user_info_field] # type: ignore[literal-required] - + self.request.get_remember_from_user_info(user_info.dict(), remember_object) self.request.response.headers.extend(remember(self.request, json.dumps(remember_object))) return remember_object + + +def includeme(config: pyramid.config.Configurator) -> None: + """ + Pyramid includeme function. + """ + config.add_request_method(get_remember_from_user_info, name="get_remember_from_user_info") + config.add_request_method(get_user_from_remember, name="get_user_from_remember") diff --git a/geoportal/c2cgeoportal_geoportal/views/login.py b/geoportal/c2cgeoportal_geoportal/views/login.py index 9486eba9cc..569661ba58 100644 --- a/geoportal/c2cgeoportal_geoportal/views/login.py +++ b/geoportal/c2cgeoportal_geoportal/views/login.py @@ -643,27 +643,7 @@ def oidc_callback(self) -> pyramid.response.Response: remember_object = oidc.OidcRemember(self.request).remember(token_response, self.request.host) - user: static.User | oidc.DynamicUser | None - if self.authentication_settings.get("openid_connect", {}).get("provide_roles", False) is False: - user = models.DBSession.query(static.User).filter_by(email=remember_object["email"]).one_or_none() - if user is None: - user = static.User(username=remember_object["username"], email=remember_object["email"]) - models.DBSession.add(user) - else: - user = oidc.DynamicUser( - username=remember_object["sub"], - display_name=remember_object["username"], - email=remember_object["email"], - settings_role=( - models.DBSession.query(main.Role).filter_by(name=remember_object["settings_role"]).first() - if remember_object.get("settings_role") is not None - else None - ), - roles=[ - models.DBSession.query(main.Role).filter_by(name=role).one() - for role in remember_object.get("roles", []) - ], - ) + user: static.User | oidc.DynamicUser | None = self.request.get_user_from_remember(remember_object) assert user is not None self.request.user_ = user diff --git a/geoportal/tests/functional/test_oidc.py b/geoportal/tests/functional/test_oidc.py index accfb1291f..21e7e8d688 100644 --- a/geoportal/tests/functional/test_oidc.py +++ b/geoportal/tests/functional/test_oidc.py @@ -1,12 +1,12 @@ import base64 import re +import types import urllib.parse from http.client import responses from unittest import TestCase import jwt import responses -from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from pyramid import testing from tests.functional import cleanup_db, create_dummy_request @@ -14,6 +14,8 @@ from tests.functional import setup_db from tests.functional import teardown_common as teardown_module # noqa, pylint: disable=unused-import +from c2cgeoportal_geoportal.lib import oidc + _OIDC_CONFIGURATION = { "issuer": "https://sso.example.com", "authorization_endpoint": "https://sso.example.com/authorize", @@ -41,6 +43,11 @@ } +def includeme(request): + request.get_remember_from_user_info = types.MethodType(oidc.get_remember_from_user_info, request) + request.get_user_from_remember = types.MethodType(oidc.get_user_from_remember, request) + + class TestLogin(TestCase): def setUp(self): setup_db() @@ -66,6 +73,7 @@ def test_login(self): }, params={"came_from": "/came_from"}, ) + includeme(request) responses.get("https://sso.example.com/.well-known/openid-configuration", json=_OIDC_CONFIGURATION) responses.get("https://sso.example.com/jwks", json=_OIDC_KEYS) @@ -106,6 +114,7 @@ def test_callback(self): "authentication": { "openid_connect": { "enabled": True, + "provide_roles": True, "url": "https://sso.example.com", "client_id": "client_id_123", } @@ -118,6 +127,7 @@ def test_callback(self): "code_challenge": "code_challenge", }, ) + includeme(request) responses.get("https://sso.example.com/.well-known/openid-configuration", json=_OIDC_CONFIGURATION) responses.get("https://sso.example.com/jwks", json=_OIDC_KEYS) responses.post( From 23a51832618e4ea1231a22cf66330b37e769a82b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Brunner?= Date: Mon, 14 Oct 2024 18:06:14 +0200 Subject: [PATCH 2/2] OpenID connect - Make it working with QGIS desktop (Support Bearer token). - Fix login from admin interface - Hide unwanted files in the admin interface. - Add match_field and update_fields configuration. - Some fixies. --- admin/c2cgeoportal_admin/routes.py | 11 ++- admin/c2cgeoportal_admin/views/users.py | 14 ++- admin/tests/test_user.py | 1 + commons/c2cgeoportal_commons/models/static.py | 66 ++++++++----- doc/integrator/authentication_oidc.rst | 94 ++++++++++++++++--- geoportal/c2cgeoportal_geoportal/__init__.py | 59 ++++++++---- .../lib/authentication.py | 10 +- geoportal/c2cgeoportal_geoportal/lib/oidc.py | 49 +++++++--- .../geoportal/CONST_config-schema.yaml | 26 ++++- .../geoportal/CONST_vars.yaml | 5 + .../c2cgeoportal_geoportal/views/login.py | 58 +++++++----- 11 files changed, 290 insertions(+), 103 deletions(-) diff --git a/admin/c2cgeoportal_admin/routes.py b/admin/c2cgeoportal_admin/routes.py index 3e25ea2d63..4f4fe8f60e 100644 --- a/admin/c2cgeoportal_admin/routes.py +++ b/admin/c2cgeoportal_admin/routes.py @@ -73,6 +73,13 @@ def includeme(config): User, ) + authentication_configuration = config.registry.settings.get("authentication", {}) + oidc_configuration = authentication_configuration.get("openid_connect", {}) + oidc_enabled = oidc_configuration.get("enabled", False) + oidc_provide_roles = oidc_configuration.get("provide_roles", False) + oauth2_configuration = authentication_configuration.get("oauth2", {}) + oauth2_enabled = oauth2_configuration.get("enabled", not oidc_enabled) + visible_routes = [ ("themes", Theme), ("layer_groups", LayerGroup), @@ -82,11 +89,11 @@ def includeme(config): ("layers_cog", LayerCOG), ("ogc_servers", OGCServer), ("restriction_areas", RestrictionArea), - ("users", User), + *([("users", User)] if not oidc_enabled or not oidc_provide_roles else []), ("roles", Role), ("functionalities", Functionality), ("interfaces", Interface), - ("oauth2_clients", OAuth2Client), + *([("oauth2_clients", OAuth2Client)] if oauth2_enabled else []), ("logs", Log), ] diff --git a/admin/c2cgeoportal_admin/views/users.py b/admin/c2cgeoportal_admin/views/users.py index 9a6cc2316f..a706bf88c0 100644 --- a/admin/c2cgeoportal_admin/views/users.py +++ b/admin/c2cgeoportal_admin/views/users.py @@ -26,6 +26,7 @@ # either expressed or implied, of the FreeBSD Project. +import os from functools import partial from c2cgeoform.schema import GeoFormSchemaNode @@ -57,6 +58,8 @@ settings_role = aliased(Role) +_OPENID_CONNECT_ENABLED = os.environ.get("OPENID_CONNECT_ENABLED", "false").lower() in ("true", "yes", "1") + @view_defaults(match_param="table=users") class UserViews(LoggedViews[User]): @@ -64,11 +67,14 @@ class UserViews(LoggedViews[User]): _list_fields = [ _list_field("id"), - _list_field("username"), + *([_list_field("username")] if not _OPENID_CONNECT_ENABLED else []), + _list_field("display_name"), _list_field("email"), - _list_field("last_login"), - _list_field("expire_on"), - _list_field("deactivated"), + *( + [_list_field("last_login"), _list_field("expire_on"), _list_field("deactivated")] + if not _OPENID_CONNECT_ENABLED + else [] + ), _list_field( "settings_role", renderer=lambda user: user.settings_role.name if user.settings_role else "", diff --git a/admin/tests/test_user.py b/admin/tests/test_user.py index 41b7b2e5b6..956a7660ac 100644 --- a/admin/tests/test_user.py +++ b/admin/tests/test_user.py @@ -60,6 +60,7 @@ def test_index_rendering(self, test_app): ("actions", "", "false"), ("id", "id", "true"), ("username", "Username"), + ("display_name", "Display name", "true"), ("email", "Email"), ("last_login", "Last login"), ("expire_on", "Expiration date"), diff --git a/commons/c2cgeoportal_commons/models/static.py b/commons/c2cgeoportal_commons/models/static.py index ba7be48961..90adc4d66b 100644 --- a/commons/c2cgeoportal_commons/models/static.py +++ b/commons/c2cgeoportal_commons/models/static.py @@ -28,6 +28,7 @@ import crypt import logging +import os from datetime import datetime from hashlib import sha1 from hmac import compare_digest as compare_hash @@ -67,6 +68,7 @@ def __init__(self, *args: Any, **kwargs: Any): _LOG = logging.getLogger(__name__) +_OPENID_CONNECT_ENABLED = os.environ.get("OPENID_CONNECT_ENABLED", "false").lower() in ("true", "yes", "1") _schema: str = config["schema_static"] or "static" @@ -136,10 +138,14 @@ class User(Base): # type: ignore unique=True, nullable=False, info={ - "colanderalchemy": { - "title": _("Username"), - "description": _("Name used for authentication (must be unique)."), - } + "colanderalchemy": ( + { + "title": _("Username"), + "description": _("Name used for authentication (must be unique)."), + } + if not _OPENID_CONNECT_ENABLED + else {"widget": HiddenWidget()} + ) }, ) display_name: Mapped[str] = mapped_column( @@ -176,10 +182,14 @@ class User(Base): # type: ignore Boolean, default=False, info={ - "colanderalchemy": { - "title": _("The user changed his password"), - "description": _("Indicates if user has changed his password."), - } + "colanderalchemy": ( + { + "title": _("The user changed his password"), + "description": _("Indicates if user has changed his password."), + } + if not _OPENID_CONNECT_ENABLED + else {"exclude": True} + ) }, ) @@ -232,22 +242,30 @@ class User(Base): # type: ignore DateTime(timezone=True), nullable=True, info={ - "colanderalchemy": { - "title": _("Last login"), - "description": _("Date of the user's last login."), - "missing": drop, - "widget": DateTimeInputWidget(readonly=True), - } + "colanderalchemy": ( + { + "title": _("Last login"), + "description": _("Date of the user's last login."), + "missing": drop, + "widget": DateTimeInputWidget(readonly=True), + } + if not _OPENID_CONNECT_ENABLED + else {"exclude": True} + ) }, ) expire_on: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), info={ - "colanderalchemy": { - "title": _("Expiration date"), - "description": _("After this date the user will not be able to login anymore."), - } + "colanderalchemy": ( + { + "title": _("Expiration date"), + "description": _("After this date the user will not be able to login anymore."), + } + if not _OPENID_CONNECT_ENABLED + else {"exclude": True} + ) }, ) @@ -255,10 +273,14 @@ class User(Base): # type: ignore Boolean, default=False, info={ - "colanderalchemy": { - "title": _("Deactivated"), - "description": _("Deactivate a user without removing it completely."), - } + "colanderalchemy": ( + { + "title": _("Deactivated"), + "description": _("Deactivate a user without removing it completely."), + } + if not _OPENID_CONNECT_ENABLED + else {"exclude": True} + ) }, ) diff --git a/doc/integrator/authentication_oidc.rst b/doc/integrator/authentication_oidc.rst index 9fb84813ef..1c1deeb669 100644 --- a/doc/integrator/authentication_oidc.rst +++ b/doc/integrator/authentication_oidc.rst @@ -37,20 +37,17 @@ We use [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-di Authentication provider ~~~~~~~~~~~~~~~~~~~~~~~ -If we want to use OpenID Connect as an authentication provider, we need to set the following configuration in our ``vars.yaml`` file: +If we want to use OpenID Connect as an authentication provider, we need to set the environment variable +``OPENID_CONNECT_ENABLED`` to ``true``, then we need to set the following configuration in our +``vars.yaml`` file: .. code:: yaml vars: authentication: openid_connect: - enabled: true url: client_id: - user_info_fields: - username: sub # Default value - display_name: name # Default value - email: email # Default value With that the user will be create in the database at the first login, and the access right will be set in the GeoMapFish database. The user correspondence will be done on the email field. @@ -59,20 +56,19 @@ The user correspondence will be done on the email field. Authorization provider ~~~~~~~~~~~~~~~~~~~~~~ -If we want to use OpenID Connect as an authorization provider, we need to set the following configuration in our ``vars.yaml`` file: +If we want to use OpenID Connect as an authorization provider, we need to set the environment variable +``OPENID_CONNECT_ENABLED`` to ``true``, then we need to set the following configuration in our +``vars.yaml`` file: .. code:: yaml vars: authentication: openid_connect: - enabled: true url: client_id: provide_roles: true user_info_fields: - username: name # Default value - email: email # Default value settings_role: settings_role roles: roles @@ -84,11 +80,46 @@ Other options ``client_secret``: The secret of the client. -``trusted_audiences``: The list of trusted audiences, if the token audience is not in this list, the token will be rejected. +``trusted_audiences``: The list of trusted audiences, if the audience provided by the id-token is not in + this list, the ``ID token`` will be rejected. ``scopes``: The list of scopes to request, default is [``openid``, ``profile``, ``email``]. -``query_user_info``: If ``true``, the user info will be requested instead if using the ``id_token``, default is false. +``query_user_info``: If ``true``, the OpenID Connect provider user info endpoint will be requested to + provide the user info instead of using the information provided in the ``ID token``, + default is ``false``. + +``create_user``: If ``true``, a user will be create in the geomapfish database if not exists, + default is ``false``. + +``match_field``: The field to use to match the user in the database, can be ``username`` (default) or ``email``. + +``update_fields``: The fields to update in the database, default is: ``[]``, allowed values are + ``username``, ``display_name`` and ``email``. + +``user_info_fields:`` The mapping between the user info fields and the user fields in the GeoMapFish database, + the key is the GeoMpaFish user field and the value is the field of the user info provided by the + OpenID Connect provider, default is: + + .. code:: yaml + + username: sub + display_name: name + email: email + +~~~~~~~~~~~~~~~~~~~~ +Example with Zitadel +~~~~~~~~~~~~~~~~~~~~ + +.. code:: yaml + + vars: + authentication: + openid_connect: + url: https://sso.example.com + client_id: '***' + query_user_info: true + create_user: true ~~~~~ Hooks @@ -102,8 +133,9 @@ The argument are the pyramid ``request``, the received ``user_info``, and the `` to be filled and will be stored in the cookie. ``get_user_from_remember``: This hook is called during the user is certification. -The argument are the pyramid ``request``, the received ``remember_object``, and the ``create_user`` boolean. +The argument are the pyramid ``request``, the received ``remember_object``, and the ``update_create_user`` boolean. The return value is the user object ``User`` or ``DynamicUsed``. +The ``update_create_user`` will be ``True`` only when we are in the callback endpoint. Full signatures: @@ -111,7 +143,7 @@ Full signatures: def get_remember_from_user_info(request: Request, user_info: Dict[str, Any], remember_object: OidcRememberObject) -> None: - def get_user_from_remember(request: Request, remember_object: OidcRememberObject, create_user: bool) -> Union[User, DynamicUsed]: + def get_user_from_remember(request: Request, remember_object: OidcRememberObject, update_create_user: bool) -> Union[User, DynamicUsed]: Configure the hooks in the project initialization: @@ -120,3 +152,37 @@ Configure the hooks in the project initialization: def includeme(config): config.add_request_method(get_remember_from_user_info, name="get_remember_from_user_info") config.add_request_method(get_user_from_remember, name="get_user_from_remember") + +~~~~~~~~~~~~~~~~~ +QGIS with Zitadel +~~~~~~~~~~~~~~~~~ + +In Zitadel you should have a PKCS application with the following settings: +Redirect URI: ``http://127.0.0.1:7070/``. + +On QGIS: + +* Add an ``Authentication``. +* Set a ``Name``. +* Set ``Authentication`` to ``OAuth2``. +* Set ``Grant flow`` to ``PKCE authentication code``. +* Set ``Request URL`` to ``/oauth/v2/authorize``. +* Set ``Token URL`` to ``/oauth/v2/token``. +* Set ``Client ID`` to ````. +* Set ``Scope`` to the ``openid profile email``. + +~~~~~~~~~~~~~~ +Implementation +~~~~~~~~~~~~~~ + +When we implement OpenID Connect, we have to possibilities: +- Implement it in the backend. +- Implement it in the frontend, and give a token to the backend that allows to be authenticated on an other service. + +In c2cgeoportal we have implemented booth method. + +The backend implementation is used by ngeo an the admin interface, where se store the user information +(including the access and refresh token) in an encrypted JSON as a cookie. + +The frontend implementation is used by application like QGIS desktop, on every call we have to call the +user info endpoint to get the user information. diff --git a/geoportal/c2cgeoportal_geoportal/__init__.py b/geoportal/c2cgeoportal_geoportal/__init__.py index b0ee2331a1..15cca5b7ad 100644 --- a/geoportal/c2cgeoportal_geoportal/__init__.py +++ b/geoportal/c2cgeoportal_geoportal/__init__.py @@ -45,6 +45,7 @@ import pyramid.request import pyramid.response import pyramid.security +import simple_openid_connect.data import sqlalchemy import sqlalchemy.orm import zope.event.classhandler @@ -378,25 +379,47 @@ def get_user_from_request( if not hasattr(request, "user_"): request.user_ = None - if username is None: - username = request.authenticated_userid - if username is not None: - openid_connect_config = settings.get("authentication", {}).get("openid_connect", {}) - if openid_connect_config.get("enabled", False): - user_info = json.loads(username) - access_token_expires = dateutil.parser.isoparse(user_info["access_token_expires"]) - if access_token_expires < datetime.datetime.now(): - if user_info["refresh_token_expires"] is None: - return None - refresh_token_expires = dateutil.parser.isoparse(user_info["refresh_token_expires"]) - if refresh_token_expires < datetime.datetime.now(): - return None - token_response = oidc.get_oidc_client(request, request.host).exchange_refresh_token( - user_info["refresh_token"] + user_info_remember: dict[str, Any] | None = None + openid_connect_configuration = settings.get("authentication", {}).get("openid_connect", {}) + openid_connect_enabled = openid_connect_configuration.get("enabled", False) + if ( + openid_connect_enabled + and "Authorization" in request.headers + and request.headers["Authorization"].startswith("Bearer ") + ): + token = request.headers["Authorization"][7:] + client = oidc.get_oidc_client(request, request.host) + user_info = client.fetch_userinfo(token) + user_info_remember = {} + request.get_remember_from_user_info(user_info.dict(), user_info_remember) + elif username is None: + username = request.unauthenticated_userid + if username is not None or user_info_remember is not None: + if openid_connect_enabled: + if user_info_remember is None: + assert username is not None + user_info_remember = json.loads(username) + del username + if "access_token_expires" in user_info_remember: + access_token_expires = dateutil.parser.isoparse( + user_info_remember["access_token_expires"] ) - user_info = oidc.OidcRemember(request).remember(token_response, request.host) - - request.user_ = request.get_user_from_reminder(user_info) + if access_token_expires < datetime.datetime.now(): + if user_info_remember["refresh_token_expires"] is None: + return None + refresh_token_expires = dateutil.parser.isoparse( + user_info_remember["refresh_token_expires"] + ) + if refresh_token_expires < datetime.datetime.now(): + return None + token_response = oidc.get_oidc_client( + request, request.host + ).exchange_refresh_token(user_info_remember["refresh_token"]) + user_info_remember = oidc.OidcRemember(request).remember( + token_response, request.host + ) + + request.user_ = request.get_user_from_remember(user_info_remember) else: # We know we will need the role object of the # user so we use joined loading diff --git a/geoportal/c2cgeoportal_geoportal/lib/authentication.py b/geoportal/c2cgeoportal_geoportal/lib/authentication.py index 72fb7dcba6..77ab37a34c 100644 --- a/geoportal/c2cgeoportal_geoportal/lib/authentication.py +++ b/geoportal/c2cgeoportal_geoportal/lib/authentication.py @@ -203,14 +203,20 @@ def create_authentication(settings: dict[str, Any]) -> MultiAuthenticationPolicy ) ) - policies.append(OAuth2AuthenticationPolicy()) + authentication_config = settings.get("authentication", {}) + openid_connect_config = authentication_config.get("openid_connect", {}) + oauth2_config = authentication_config.get("oauth2", {}) + if oauth2_config.get("enabled", not openid_connect_config.get("enabled", False)): + policies.append(OAuth2AuthenticationPolicy()) if basicauth: - if settings["authentication"].get("two_factor", False): + if authentication_config.get("two_factor", False): _LOG.warning( "Basic auth and two factor auth should not be enable together, " "you should use OAuth2 instead of Basic auth" ) + if openid_connect_config.get("enabled", False): + _LOG.warning("Basic auth and OpenID Connect should not be enable together") basic_authentication_policy = BasicAuthAuthenticationPolicy(c2cgeoportal_check) policies.append(basic_authentication_policy) diff --git a/geoportal/c2cgeoportal_geoportal/lib/oidc.py b/geoportal/c2cgeoportal_geoportal/lib/oidc.py index 62d1eb9e0c..ee2ad8a589 100644 --- a/geoportal/c2cgeoportal_geoportal/lib/oidc.py +++ b/geoportal/c2cgeoportal_geoportal/lib/oidc.py @@ -26,7 +26,6 @@ # either expressed or implied, of the FreeBSD Project. import datetime -import dis import json import logging from typing import TYPE_CHECKING, Any, NamedTuple, Optional, TypedDict, Union @@ -119,7 +118,7 @@ def get_remember_from_user_info( for field_, default_field in ( ("username", "sub"), - ("display_name", "name"), + ("display_name", "name"), ("email", "email"), ("settings_role", None), ("roles", None), @@ -137,15 +136,18 @@ def get_remember_from_user_info( def get_user_from_remember( - request: pyramid.request.Request, remember_object: OidcRememberObject, create_user: bool = False + request: pyramid.request.Request, remember_object: OidcRememberObject, update_create_user: bool = False ) -> Union["static.User", DynamicUser] | None: """ Create a user from the remember object filled from `get_remember_from_user_info`. :param remember_object: The object to fill, by default with the `username`, `email`, `settings_role` and `roles`. :param settings: The OpenID Connect configuration. - :param create_user: If the user should be created if it does not exist. + :param update_create_user: If the user should be updated or created if it does not exist. """ + + # Those imports are here to avoid initializing the models module before the database schema are + # correctly initialized. from c2cgeoportal_commons import models # pylint: disable=import-outside-toplevel from c2cgeoportal_commons.models import main, static # pylint: disable=import-outside-toplevel @@ -158,17 +160,38 @@ def get_user_from_remember( assert email is not None display_name = remember_object["display_name"] or email - - provide_roles = ( - request.registry.settings.get("authentication", {}) - .get("openid_connect", {}) - .get("provide_roles", False) + openid_connect_configuration = request.registry.settings.get("authentication", {}).get( + "openid_connect", {} ) + provide_roles = openid_connect_configuration.get("provide_roles", False) if provide_roles is False: - user = models.DBSession.query(static.User).filter_by(email=email).one_or_none() - if user is None and create_user is True: - user = static.User(username=username, email=email, display_name=display_name) - models.DBSession.add(user) + user_query = models.DBSession.query(static.User) + match_field = openid_connect_configuration.get("match_field", "username") + if match_field == "username": + user_query = user_query.filter_by(username=username) + elif match_field == "email": + user_query = user_query.filter_by(email=email) + else: + raise HTTPInternalServerError( + f"Unknown match_field: '{match_field}', allowed values are 'username' and 'email'" + ) + user = user_query.one_or_none() + if update_create_user is True: + if user is not None: + for field in openid_connect_configuration.get("update_fields", []): + if field == "username": + user.username = username + elif field == "display_name": + user.display_name = display_name + elif field == "email": + user.email = email + else: + raise HTTPInternalServerError( + f"Unknown update_field: '{field}', allowed values are 'username', 'display_name' and 'email'" + ) + elif openid_connect_configuration.get("create_user", False) is True: + user = static.User(username=username, email=email, display_name=display_name) + models.DBSession.add(user) else: user = DynamicUser( username=username, diff --git a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml index c823b44168..df925e8ff0 100644 --- a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml +++ b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml @@ -207,6 +207,12 @@ mapping: oauth2_token_expire_minutes: type: scalar required: false + oauth2: + type: map + required: false + mapping: + enabled: + type: bool allowed_hosts: type: seq sequence: @@ -216,8 +222,8 @@ mapping: required: false mapping: enabled: - type: bool - default: false + type: scalar + # default: false url: type: str required: false @@ -241,6 +247,22 @@ mapping: query_user_info: type: bool default: false + create_user: + type: bool + default: false + match_field: + type: str + enum: + - username + - email + update_fields: + type: seq + sequence: + - type: str + enum: + - username + - display_name + - email user_info_fields: type: map mapping: diff --git a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml index e801a4ca04..04ac052ca7 100644 --- a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml +++ b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml @@ -43,6 +43,8 @@ vars: oauth2_token_expire_minutes: 60 oauth2_authorization_expire_minutes: 10 max_consecutive_failures: 10 + openid_connect: + enabled: '{OPENID_CONNECT_ENABLED}' intranet: networks: [] @@ -1389,6 +1391,8 @@ runtime_environment: default: '' - name: SENTRY_CLIENT_ENVIRONMENT default: '' + - name: OPENID_CONNECT_ENABLED + default: 'false' runtime_postprocess: - expression: int({}) @@ -1469,6 +1473,7 @@ runtime_postprocess: - expression: str({}).lower() in ("true", "yes", "1") vars: - authentication.two_factor + - authentication.openid_connect.enabled - getitfixed.enabled - layers.geometry_validation - smtp.ssl diff --git a/geoportal/c2cgeoportal_geoportal/views/login.py b/geoportal/c2cgeoportal_geoportal/views/login.py index 569661ba58..2d812a3c94 100644 --- a/geoportal/c2cgeoportal_geoportal/views/login.py +++ b/geoportal/c2cgeoportal_geoportal/views/login.py @@ -52,7 +52,7 @@ from c2cgeoportal_commons import models from c2cgeoportal_commons.lib.email_ import send_email_config -from c2cgeoportal_commons.models import main, static +from c2cgeoportal_commons.models import static from c2cgeoportal_geoportal import is_allowed_url, is_valid_referrer from c2cgeoportal_geoportal.lib import get_setting, is_intranet, oauth2, oidc from c2cgeoportal_geoportal.lib.common_headers import Cache, set_common_headers @@ -95,7 +95,7 @@ def loginform403(self) -> dict[str, Any] | pyramid.response.Response: if self.authentication_settings.get("openid_connect", {}).get("enabled", False): return HTTPFound( location=self.request.route_url( - "login", + "oidc_login", _query={"came_from": f"{self.request.path}?{urllib.parse.urlencode(self.request.GET)}"}, ) ) @@ -293,10 +293,11 @@ def _oauth2_login(self, user: static.User) -> pyramid.response.Response: def logout(self) -> pyramid.response.Response: if self.authentication_settings.get("openid_connect", {}).get("enabled", False): client = oidc.get_oidc_client(self.request, self.request.host) - user_info = json.loads(self.request.authenticated_userid) - client.revoke_token(user_info["access_token"]) - if user_info.get("refresh_token") is not None: - client.revoke_token(user_info["refresh_token"]) + if hasattr(client, "revoke_token"): + user_info = json.loads(self.request.authenticated_userid) + client.revoke_token(user_info["access_token"]) + if user_info.get("refresh_token") is not None: + client.revoke_token(user_info["refresh_token"]) headers = forget(self.request) @@ -643,9 +644,11 @@ def oidc_callback(self) -> pyramid.response.Response: remember_object = oidc.OidcRemember(self.request).remember(token_response, self.request.host) - user: static.User | oidc.DynamicUser | None = self.request.get_user_from_remember(remember_object) - assert user is not None - self.request.user_ = user + user: static.User | oidc.DynamicUser | None = self.request.get_user_from_remember( + remember_object, update_create_user=True + ) + if user is not None: + self.request.user_ = user if "came_from" in self.request.cookies: came_from = self.request.cookies["came_from"] @@ -653,21 +656,24 @@ def oidc_callback(self) -> pyramid.response.Response: return HTTPFound(location=came_from, headers=self.request.response.headers) - return set_common_headers( - self.request, - "login", - Cache.PRIVATE_NO, - response=Response( - # TODO respect the user interface... - json.dumps( - { - "username": user.display_name, - "email": user.email, - "is_intranet": is_intranet(self.request), - "functionalities": self._functionality(), - "roles": [{"name": r.name, "id": r.id} for r in user.roles], - } + if user is not None: + return set_common_headers( + self.request, + "login", + Cache.PRIVATE_NO, + response=Response( + # TODO respect the user interface... + json.dumps( + { + "username": user.display_name, + "email": user.email, + "is_intranet": is_intranet(self.request), + "functionalities": self._functionality(), + "roles": [{"name": r.name, "id": r.id} for r in user.roles], + } + ), + headers=(("Content-Type", "text/json"),), ), - headers=(("Content-Type", "text/json"),), - ), - ) + ) + else: + return HTTPUnauthorized("See server logs for details")