Skip to content

Commit

Permalink
Merge pull request #11316 from camptocamp/openid-connect-GSGGR-152
Browse files Browse the repository at this point in the history
Add OpenID connect support
  • Loading branch information
sbrunner authored Sep 3, 2024
2 parents 9f62ae6 + cbcde55 commit 699caf0
Show file tree
Hide file tree
Showing 21 changed files with 1,024 additions and 61 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
/geoportal/tests/testegg/production.ini
/geoportal/tests/tests.ini
node_modules/
# Files generated when we mount c2cgeoportal in the project, created with `make tests`
/geoportal/c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/demo.map
/geoportal/c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/mapserver.map
# Files needed to mount c2cgeoportal in the project, created with `make dev`
/geoportal/c2cgeoportal_geoportal/locale/
/admin/c2cgeoportal_admin/locale/
7 changes: 3 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ RUN --mount=type=cache,target=/var/lib/apt/lists \
--mount=type=cache,target=/var/cache,sharing=locked \
apt-get update \
&& apt-get upgrade --assume-yes \
&& DEBIAN_FRONTEND=noninteractive apt-get install --assume-yes --no-install-recommends python3-pip adduser \
&& DEBIAN_FRONTEND=noninteractive apt-get install --assume-yes --no-install-recommends adduser git python3-pip \
&& pip install --upgrade pip

# Used to convert the locked packages by poetry to pip requirements format
Expand All @@ -30,8 +30,8 @@ RUN --mount=type=cache,target=/root/.cache \

# Do the conversion
COPY poetry.lock pyproject.toml ./
RUN poetry export --output=requirements.txt \
&& poetry export --with=dev --output=requirements-dev.txt
RUN poetry export --without-hashes --output=requirements.txt \
&& poetry export --with=dev --without-hashes --output=requirements-dev.txt

# Base, the biggest thing is to install the Python packages
FROM base-all AS base
Expand Down Expand Up @@ -69,7 +69,6 @@ RUN --mount=type=cache,target=/var/lib/apt/lists \
&& DEBIAN_FRONTEND=noninteractive apt-get install --assume-yes --no-install-recommends \
binutils gcc g++ \
&& PIP_NO_BINARY=fiona,rasterio GDAL_CONFIG=/usr/bin/gdal-config PROJ_DIR=/usr/local/ python3 -m pip install \
--use-deprecated=legacy-resolver \
--disable-pip-version-check --no-deps --requirement=/poetry/requirements.txt \
&& strip /usr/local/lib/python3.*/dist-packages/*/*.so \
&& apt-get auto-remove --assume-yes binutils gcc g++ \
Expand Down
6 changes: 6 additions & 0 deletions ci/dpkg-versions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,8 @@ camptocamp/geomapfish:latest:
ubuntu_22_04/gcc-12-base: 12.3.0-1ubuntu1~22.04
ubuntu_22_04/gettext: 0.21-4ubuntu4
ubuntu_22_04/gettext-base: 0.21-4ubuntu4
ubuntu_22_04/git: 1:2.34.1-1ubuntu1.11
ubuntu_22_04/git-man: 1:2.34.1-1ubuntu1.11
ubuntu_22_04/gnupg: 2.2.27-3ubuntu2.1
ubuntu_22_04/gnupg-l10n: 2.2.27-3ubuntu2.1
ubuntu_22_04/gnupg-utils: 2.2.27-3ubuntu2.1
Expand Down Expand Up @@ -1698,6 +1700,7 @@ camptocamp/geomapfish:latest:
ubuntu_22_04/libdrm-common: 2.4.113-2~ubuntu0.22.04.1
ubuntu_22_04/libdrm2: 2.4.113-2~ubuntu0.22.04.1
ubuntu_22_04/libepoxy0: 1.5.10-1
ubuntu_22_04/liberror-perl: 0.17029-1
ubuntu_22_04/libexpat1: 2.4.7-1ubuntu0.3
ubuntu_22_04/libexpat1-dev: 2.4.7-1ubuntu0.3
ubuntu_22_04/libext2fs2: 1.46.5-2ubuntu1.1
Expand Down Expand Up @@ -1951,6 +1954,8 @@ camptocamp/geomapfishapp-geoportal:latest:
ubuntu_22_04/gcc-12-base: 12.3.0-1ubuntu1~22.04
ubuntu_22_04/gettext: 0.21-4ubuntu4
ubuntu_22_04/gettext-base: 0.21-4ubuntu4
ubuntu_22_04/git: 1:2.34.1-1ubuntu1.11
ubuntu_22_04/git-man: 1:2.34.1-1ubuntu1.11
ubuntu_22_04/gnupg: 2.2.27-3ubuntu2.1
ubuntu_22_04/gnupg-l10n: 2.2.27-3ubuntu2.1
ubuntu_22_04/gnupg-utils: 2.2.27-3ubuntu2.1
Expand Down Expand Up @@ -2019,6 +2024,7 @@ camptocamp/geomapfishapp-geoportal:latest:
ubuntu_22_04/libdrm-common: 2.4.113-2~ubuntu0.22.04.1
ubuntu_22_04/libdrm2: 2.4.113-2~ubuntu0.22.04.1
ubuntu_22_04/libepoxy0: 1.5.10-1
ubuntu_22_04/liberror-perl: 0.17029-1
ubuntu_22_04/libexpat1: 2.4.7-1ubuntu0.3
ubuntu_22_04/libexpat1-dev: 2.4.7-1ubuntu0.3
ubuntu_22_04/libext2fs2: 1.46.5-2ubuntu1.1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Copyright (c) 2024, Camptocamp SA
# All rights reserved.

# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.

# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

# The views and conclusions contained in the software and documentation are those
# of the authors and should not be interpreted as representing official policies,
# either expressed or implied, of the FreeBSD Project.

"""
Add OpenID connect support.
Revision ID: aa41e9613256
Revises: 910b4ca53b68
Create Date: 2024-08-30 15:56:31.163378
"""

import sqlalchemy as sa
from alembic import op
from c2c.template.config import config
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "aa41e9613256"
down_revision = "910b4ca53b68"
branch_labels = None
depends_on = None


def upgrade() -> None:
"""Upgrade."""
staticschema = config["schema_static"]

# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f("ix_main_static_user_email"), "user", ["email"], unique=False, schema=staticschema)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade."""
staticschema = config["schema_static"]

# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_main_static_user_email"), table_name="user", schema=staticschema)
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions commons/c2cgeoportal_commons/models/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ class User(Base): # type: ignore
email: Mapped[str] = mapped_column(
Unicode,
nullable=False,
index=True,
info={
"colanderalchemy": {
"title": _("Email"),
Expand Down
1 change: 1 addition & 0 deletions doc/integrator/authentication.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Authentication
Supported standards
~~~~~~~~~~~~~~~~~~~

- `OpenID Connect`: as client, to be able to connect to an external OpenID Connect (OIDC) server.
- `TOTP`: for two-factor authentication (2FA), this can be used for example with Google Authenticator.
- `OAuth2` as server: An external application can use GeoMapFish as a single sign-on (SSO) for the
authentication, even if it was initially implemented to be able to connect from QGIS desktop on an
Expand Down
90 changes: 90 additions & 0 deletions doc/integrator/authentication_oidc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
OpenID Connect
~~~~~~~~~~~~~~

We can configure an OpenID connect service as an SSO (Single Sign-On) provider for our application. This allows users to log in to our application using their OpenID Connect credentials.

We use [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) with an Authorization Code Flow from [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html), with PKCE (Proof Key for Code Exchange, RFC 7636).

.. mermaid::

sequenceDiagram
actor User
participant Browser
participant Geoportal
participant IAM
Geoportal->>IAM: discovery endpoint

User->>+Browser: Login
Browser->>+Geoportal: Login
Geoportal->>-Browser: redirect
Browser->>+IAM: authorization endpoint
IAM->>-Browser: redirect
Browser->>+Geoportal: callback endpoint
Geoportal->>IAM: token endpoint
opt on using user info instead of jwt token
Geoportal->>IAM: userinfo endpoint
end
Geoportal->>-Browser: authentication data in cookie
Browser->>-User: Reload

Browser->>+Geoportal: any auth endpoint
opt on token expiry
Geoportal->>IAM: refresh token endpoint
end
Geoportal->>-Browser: response

~~~~~~~~~~~~~~~~~~~~~~~
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:

.. code:: yaml
vars:
authentication:
openid_connect:
enabled: true
url: <the service URL>
client_id: <the client application ID>
user_info_fields:
username: 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.

~~~~~~~~~~~~~~~~~~~~~~
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:

.. code:: yaml
vars:
authentication:
openid_connect:
enabled: true
url: <the service URL>
client_id: <the client application ID>
provide_roles: true
user_info_fields:
username: name # Default value
email: email # Default value
settings_role: settings_role
roles: roles
With that the user will not be in the database only the roles will be set in the GeoMapFish database.

~~~~~~~~~~~~~
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.

``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.
2 changes: 2 additions & 0 deletions doc/integrator/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Security

.. _integrator_authentication:
.. include:: authentication.rst
.. _integrator_authentication_oidc:
.. include:: authentication_oidc.rst
.. _integrator_authentication_oauth2:
.. include:: authentication_oauth2.rst
.. include:: https.rst
Expand Down
60 changes: 52 additions & 8 deletions geoportal/c2cgeoportal_geoportal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
# of the authors and should not be interpreted as representing official policies,
# either expressed or implied, of the FreeBSD Project.

import datetime
import importlib
import json
import logging
import os
import urllib.parse
Expand All @@ -36,6 +38,7 @@
import c2cwsgiutils
import c2cwsgiutils.db
import c2cwsgiutils.index
import dateutil.parser
import pyramid.config
import pyramid.renderers
import pyramid.request
Expand Down Expand Up @@ -313,6 +316,7 @@ 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

Expand All @@ -327,14 +331,52 @@ def get_user_from_request(
if username is None:
username = request.authenticated_userid
if username is not None:
# We know we will need the role object of the
# user so we use joined loading
request.user_ = (
DBSession.query(User)
.filter_by(username=username, deactivated=False)
.options(joinedload(User.roles))
.first()
)
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).exchange_refresh_token(
user_info["refresh_token"]
)
user_info = oidc.OidcRemember(request).remember(token_response)

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["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(email=user_info["email"]).first()
for user in DBSession.query(User).all():
_LOG.error(user.username)
else:
# We know we will need the role object of the
# user so we use joined loading
request.user_ = (
DBSession.query(User)
.filter_by(username=username, deactivated=False)
.options(joinedload(User.roles))
.first()
)

return cast(User, request.user_)

Expand Down Expand Up @@ -596,6 +638,8 @@ def handle(event: InvalidateCacheEvent) -> None:
add_cors_route(config, "/loginuser", "login")
config.add_route("loginuser", "/loginuser", request_method="GET")
config.add_route("testi18n", "/testi18n.html", request_method="GET")
config.add_route("oidc_login", "/oidc/login", request_method="GET")
config.add_route("oidc_callback", "/oidc/callback", request_method="GET")

config.add_renderer(".map", AssetRendererFactory)
config.add_renderer(".css", AssetRendererFactory)
Expand Down
6 changes: 3 additions & 3 deletions geoportal/c2cgeoportal_geoportal/lib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,19 @@ def get_typed(
elif type_["type"] == "float":
return float(value)
elif type_["type"] == "date":
date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0)) # type: ignore
date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0))
if date.time() != datetime.time(0, 0, 0):
errors.add(f"{prefix}The date attribute '{name}'='{value}' should not have any time")
else:
return datetime.date.strftime(date.date(), "%Y-%m-%d")
elif type_["type"] == "time":
date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0)) # type: ignore
date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0))
if date.date() != datetime.date(1, 1, 1):
errors.add(f"{prefix}The time attribute '{name}'='{value}' should not have any date")
else:
return datetime.time.strftime(date.time(), "%H:%M:%S")
elif type_["type"] == "datetime":
date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0)) # type: ignore
date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0))
return datetime.datetime.strftime(date, "%Y-%m-%dT%H:%M:%S")
elif type_["type"] == "url":
url = get_url2(f"{prefix}The attribute '{name}'", value, request, errors)
Expand Down
Loading

0 comments on commit 699caf0

Please sign in to comment.