Skip to content

Commit

Permalink
OpenID connect
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
sbrunner committed Oct 28, 2024
1 parent bdd3602 commit 23a5183
Show file tree
Hide file tree
Showing 11 changed files with 290 additions and 103 deletions.
11 changes: 9 additions & 2 deletions admin/c2cgeoportal_admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
]

Expand Down
14 changes: 10 additions & 4 deletions admin/c2cgeoportal_admin/views/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
# either expressed or implied, of the FreeBSD Project.


import os
from functools import partial

from c2cgeoform.schema import GeoFormSchemaNode
Expand Down Expand Up @@ -57,18 +58,23 @@

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]):
"""The admin user view."""

_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 "",
Expand Down
1 change: 1 addition & 0 deletions admin/tests/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
66 changes: 44 additions & 22 deletions commons/c2cgeoportal_commons/models/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}
)
},
)

Expand Down Expand Up @@ -232,33 +242,45 @@ 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}
)
},
)

deactivated: Mapped[bool] = mapped_column(
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}
)
},
)

Expand Down
94 changes: 80 additions & 14 deletions doc/integrator/authentication_oidc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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: <the service URL>
client_id: <the client application 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.
Expand All @@ -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: <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
Expand All @@ -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
Expand All @@ -102,16 +133,17 @@ 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:

.. 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]:
def get_user_from_remember(request: Request, remember_object: OidcRememberObject, update_create_user: bool) -> Union[User, DynamicUsed]:
Configure the hooks in the project initialization:

Expand All @@ -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 ``<zitadel_base_url>/oauth/v2/authorize``.
* Set ``Token URL`` to ``<zitadel_base_url>/oauth/v2/token``.
* Set ``Client ID`` to ``<client_id>``.
* 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.
Loading

0 comments on commit 23a5183

Please sign in to comment.