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")