OpenID Users from Keycloak don't work with the zopyx.usersascontent addon?

Hi @mtrebron

If I use the same layer "pas.plugins.oidc.interfaces.IPasPluginsOidcLayer" on my override as the following code:

  <browser:page
      for="pas.plugins.oidc.plugins.IOIDCPlugin"
      name="callback"
      class="eteaching.community.browser.view.CallbackView"
      layer="pas.plugins.oidc.interfaces.IPasPluginsOidcLayer"
      permission="zope2.View"
      />

It shows me a ZCML configuration error:

zope.configuration.config.ConfigurationConflictError: Conflicting configuration actions
  For: ('view', (<InterfaceClass pas.plugins.oidc.plugins.IOIDCPlugin>, <InterfaceClass pas.plugins.oidc.interfaces.IPasPluginsOidcLayer>), 'callback', <InterfaceClass zope.publisher.interfaces.browser.IBrowserRequest>)
    File "/home/macagua/projects/plone-6.0.7/src/my.package/src/my/package/browser/overrides.zcml", line 6.2-12.8
        <browser:page
            for="pas.plugins.oidc.plugins.IOIDCPlugin"
            name="callback"
            class="my.package.browser.view.CallbackView"
            layer="pas.plugins.oidc.interfaces.IPasPluginsOidcLayer"
            permission="zope2.View"
            />
    File "/home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/configure.zcml", line 31.2-37.8
        <browser:page
            for="..plugins.IOIDCPlugin"
            name="callback"
            class=".view.CallbackView"
            layer="pas.plugins.oidc.interfaces.IPasPluginsOidcLayer"
            permission="zope2.View"
            />

Yes, this is what @yurj meant with override. Use overrides.zcml to override the original view.

Edit, misunderstood your question:

Something like

<configure package="pas.plugins.oidc">
        <browser:page
            for="pas.plugins.oidc.plugins.IOIDCPlugin"
            name="callback"
            class="my.package.browser.view.CallbackView"
            layer="pas.plugins.oidc.interfaces.IPasPluginsOidcLayer"
            permission="zope2.View"
            />
</configure>

1 Like

Hi @dieter

I'll tell you how it is currently implemented.

On the my/package/interfaces.py file I included:

from pas.plugins.oidc.interfaces import IPasPluginsOidcLayer
from zope.publisher.interfaces.browser import IDefaultBrowserLayer

class IMarkerLayer(IDefaultBrowserLayer):
    """Marker interface that defines a browser layer."""

class ICustomPasPluginsOidcLayer(IPasPluginsOidcLayer):
    """Marker interface for requests indicating the
    custom 'callback' view is being used."""

On the my/package/profiles/default/browserlayer.xml file I included:

<?xml version="1.0" encoding="UTF-8"?>
<layers>
  <layer
      name="my.package"
      interface="my.package.interfaces.IMarkerLayer"
      />
  <layer
      name="my.package.oidc"
      interface="my.package.interfaces.ICustomPasPluginsOidcLayer"
      />
</layers>

On the my/package/profiles/uninstall/browserlayer.xml file I included:

<?xml version="1.0" encoding="UTF-8"?>
<layers>
  <layer
      name="my.package"
      remove="true"
      />
  <layer
      name="my.package.oidc"
      remove="true"
      />
</layers>

On the my/package/configure.zcml file I included:

<configure
    xmlns="http://namespaces.zope.org/zope"
    xmlns:genericsetup="http://namespaces.zope.org/genericsetup"
    xmlns:i18n="http://namespaces.zope.org/i18n"
    xmlns:plone="http://namespaces.plone.org/plone"
    i18n_domain="my.package">

  ...
  <include file="overrides.zcml" />
  ...
</configure>

On the my/package/overrides.zcml file I included:

<configure
    xmlns="http://namespaces.zope.org/zope">

    <includeOverrides package=".browser" file="overrides.zcml" />

</configure>

On the my/package/browser/overrides.zcml file I included:

<configure
    xmlns="http://namespaces.zope.org/zope"
    xmlns:browser="http://namespaces.zope.org/browser"
    i18n_domain="my.package">

  <browser:page
      for="pas.plugins.oidc.interfaces.IPasPluginsOidcLayer"
      name="callback"
      class="my.package.browser.view.CallbackView"
      layer="my.package.interfaces.ICustomPasPluginsOidcLayer"
      permission="zope2.View"
      />

</configure>
  • Next, I restart the Zope instance
  • Login as admin user
  • Next, Go to the Site Setup > Add-ons:
    • Click on Uninstall button at my.package add-on
    • Click on Install button at my.package add-on
  • Next, when I login via the /acl_users/oidc/login URL and now it loads my custom callback view.
2024-03-26 12:15:29,352 INFO    [pas.plugins.oidc.plugins:359][waitress-2] Challenge. Came from http://localhost:7080/Plone/acl_users/oidc/callback
> /home/macagua/projects/plone-6.0.7/src/my.package/src/my/package/browser/view.py(24)__call__()
     23
---> 24         response = self.request.environ["QUERY_STRING"]
     25         session = Session(

ipdb>

This way I can load the callback view override.

@yurj @dieter @mtrebron @erral Thanks for your advice, it helped me understand how to implement the override to the view class.

Next, I can try to fire the event IUserInitialLoginInEvent.

1 Like

Here are the updates about firing the event IUserInitialLoginInEvent task:

For debugging reasons, I am raising the IUserInitialLoginInEvent() event inside the CallbackView view before implementing it in my override view, so:

In the src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py file I included:

from Products.PlonePAS.interfaces.events import IUserInitialLoginInEvent
from zope.event import notify

In the same src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py file but inside the view CallbackView I included:

            # userinfo in an instance of OpenIDSchema or ErrorResponse
            # It could also be dict, if there is no userinfo_endpoint
            if userinfo and isinstance(userinfo, (OpenIDSchema, dict)):
                self.context.rememberIdentity(userinfo)
                self.request.response.setHeader(
                    "Cache-Control", "no-cache, must-revalidate"
                )
                self.request.response.redirect(self.return_url(session=session))
                import ipdb ; ipdb.set_trace()
                try:
                    event = IUserInitialLoginInEvent()
                    notify(event)
                except Exception as e:
                    logger.error(e)
                return
            else:
                logger.error(
                    "authentication failed invalid response %s %s", resp, userinfo
                )
                raise Unauthorized()

In the my/package/subscribers.zcml file I included:

<configure
    xmlns="http://namespaces.zope.org/zope">

  <!-- Event handler for creating a 'PloneUser' object after in the Initial Login -->
  <subscriber
    for="Products.PlonePAS.interfaces.events.IUserInitialLoginInEvent"
    handler=".handlers.user_logged_in_first"
    />

  <subscriber
    for="Products.PluggableAuthService.interfaces.events.IPrincipalCreatedEvent"
    handler=".handlers.user_logged_in_first"
    />
</configure>

In the my/package/handlers.py file I included:

"""Event handlers"""
from my.package.interfaces import IMarkerLayer
from plone import api

def user_logged_in_first(event):
    """Event handler to create 'PloneUser' object when a user is logged in the first time.

    Args:
        event (class): Subclass of event. The event object
    """

    request = event.object.REQUEST
    portal = api.portal.get()

So, when I try to login with a Keycloak external user using the Keycloak login form aka /acl_users/oidc/login to use the IUserInitialLoginInEvent() event, it shows the following error:

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(294)__call__()
    293                 self.request.response.redirect(self.return_url(session=session))
--> 294                 import ipdb ; ipdb.set_trace()
    295                 try:

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(295)__call__()
    294                 import ipdb ; ipdb.set_trace()
--> 295                 try:
    296                     event = IUserInitialLoginInEvent()

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(297)__call__()
    295                 try:
--> 296                     event = IUserInitialLoginInEvent()
    297                     notify(event)

ipdb> n
TypeError: function missing required argument 'obj' (pos 1)
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(297)__call__()
    295                 try:
--> 296                     event = IUserInitialLoginInEvent()
    297                     notify(event)

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(299)__call__()
    297                     notify(event)
--> 298                 except Exception as e:
    299                     logger.error(e)

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(300)__call__()
    298                 except Exception as e:
--> 299                     logger.error(e)
    300                 return

ipdb> n
2024-04-01 21:29:54,506 ERROR   [pas.plugins.oidc.browser.view:38][waitress-0] function missing required argument 'obj' (pos 1)
> /home/macagua/projects/plone-6.0.7/eggs/cp311/Products.PDBDebugMode-2.0-py3.11-linux-x86_64.egg/Products/PDBDebugMode/pdblogging.py(59)error()
     57             set_trace()
     58
---> 59     return result

ipdb> n
--Return--

So, if I try to pass self.context as argument to IUserInitialLoginInEvent(), show the following error:

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(294)__call__()
    293                 self.request.response.redirect(self.return_url(session=session))
--> 294                 import ipdb ; ipdb.set_trace()
    295                 try:

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(295)__call__()
    294                 import ipdb ; ipdb.set_trace()
--> 295                 try:
    296                     event = IUserInitialLoginInEvent(self.context)

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(297)__call__()
    295                 try:
--> 296                     event = IUserInitialLoginInEvent(self.context)
    297                     notify(event)

ipdb> n
TypeError: ('Could not adapt', <OIDCPlugin at /eteaching/acl_users/oidc>, <InterfaceClass Products.PlonePAS.interfaces.events.IUserInitialLoginInEvent>)
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(297)__call__()
    295                 try:
--> 296                     event = IUserInitialLoginInEvent(self.context)
    297                     notify(event)

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(299)__call__()
    297                     notify(event)
--> 298                 except Exception as e:
    299                     logger.error(e)

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py(300)__call__()
    298                 except Exception as e:
--> 299                     logger.error(e)
    300                 return

ipdb> n
2024-04-02 04:53:25,571 ERROR   [pas.plugins.oidc.browser.view:38][waitress-3] ('Could not adapt', <OIDCPlugin at /eteaching/acl_users/oidc>, <InterfaceClass Products.PlonePAS.interfaces.events.IUserInitialLoginInEvent>)
n> /home/macagua/projects/plone-6.0.7/eggs/cp311/Products.PDBDebugMode-2.0-py3.11-linux-x86_64.egg/Products/PDBDebugMode/pdblogging.py(59)error()
     57             set_trace()
     58
---> 59     return result

ipdb> n

So, what is the correct parameter that IUserInitialLoginInEvent() event needs to fire the event and invoke my user_logged_in_first function?

Any idea or help is welcome guys!

Wild guess, you have to pass the member object? See:

1 Like

@mtrebron If I understand the principal is the user logged in to the system, right?

Maybe, it can be done, like as the following code:

...
from Products.PlonePAS.interfaces.events import IUserInitialLoginInEvent
from zope.event import notify
from plone import api
...
user_current = api.user.get_current()
event = IUserInitialLoginInEvent(user_current)
notify(event)

How do you see the above code?

Could be a chicken-egg situation...

At which stage of the process are you getting the user from plone.api? Another wild guess: the callback is executed after the user's successful log in, maybe it will work.

Have you checked what actually happens?

1 Like

@mtrebron you are right!

ipdb> api.user.get_current()
<SpecialUser 'Anonymous User'>
ipdb> event = IUserInitialLoginInEvent(user_current)
*** TypeError: ('Could not adapt', <SpecialUser 'Anonymous User'>, <InterfaceClass Products.PlonePAS.interfaces.events.IUserInitialLoginInEvent>)
ipdb>`

At CallBack view, until the return syntax, in the following line: pas.plugins.oidc/src/pas/plugins/oidc/browser/view.py at 1.x · collective/pas.plugins.oidc (github.com)

Here the full source code

from hashlib import sha256
from oic import rndstr
from oic.oic.message import AccessTokenResponse
from oic.oic.message import AuthorizationResponse
from oic.oic.message import EndSessionRequest
from oic.oic.message import IdToken
from oic.oic.message import OpenIDSchema
from pas.plugins.oidc.utils import SINGLE_OPTIONAL_BOOLEAN_AS_STRING
from plone import api
from Products.CMFCore.utils import getToolByName
from Products.Five.browser import BrowserView
from Products.PlonePAS.interfaces.events import IUserInitialLoginInEvent
from zExceptions import Unauthorized
from zope.event import notify
from pas.plugins.oidc.plugins import OAuth2ConnectionException
from pas.plugins.oidc import _

import base64
import json
import logging

try:
    # Python 3
    from urllib.parse import quote
except ImportError:
    # Python 2
    from urllib import quote


logger = logging.getLogger(__name__)


class Session(object):
    session_cookie_name = "__ac_session"
    _session = {}

    def __init__(self, request, use_session_data_manager=False):
        self.request = request
        self.use_session_data_manager = use_session_data_manager
        if self.use_session_data_manager:
            sdm = api.portal.get_tool("session_data_manager")
            self._session = sdm.getSessionData(create=True)
        else:
            data = self.request.cookies.get(self.session_cookie_name) or {}
            if data:
                data = json.loads(base64.b64decode(data))
            self._session = data

    def set(self, name, value):
        if self.use_session_data_manager:
            self._session.set(name, value)
        else:
            if self.get(name) != value:
                self._session[name] = value
                self.request.response.setCookie(
                    self.session_cookie_name,
                    base64.b64encode(json.dumps(self._session).encode("utf-8")),
                )

    def get(self, name):
        # if self.use_session_data_manager:
        return self._session.get(name)

    def __repr__(self):
        return repr(self._session)


class RequireLoginView(BrowserView):
    """Our version of the require-login view from Plone.

    Our challenge plugin redirects here.
    Note that the plugin has no way of knowing if you are authenticated:
    its code is called before this is known.
    I think.
    """

    def __call__(self):
        if api.user.is_anonymous():
            # context is our PAS plugin
            url = self.context.absolute_url() + "/login"
            came_from = self.request.get('came_from', None)
            if came_from:
                url += "?came_from={}".format(quote(came_from))
        else:
            url = api.portal.get().absolute_url()
            url += "/insufficient-privileges"

        self.request.response.redirect(url)


class LoginView(BrowserView):
    def __call__(self):
        session = Session(
            self.request,
            use_session_data_manager=self.context.getProperty("use_session_data_manager"),
        )
        # state is used to keep track of responses to outstanding requests (state).
        # nonce is a string value used to associate a Client session with an ID Token, and to mitigate replay attacks.
        session.set("state", rndstr())
        session.set("nonce", rndstr())
        came_from = self.request.get("came_from")
        if came_from:
            session.set("came_from", came_from)

        try:
            client = self.context.get_oauth2_client()
        except OAuth2ConnectionException:
            portal_url = api.portal.get_tool("portal_url")
            if came_from and portal_url.isURLInPortal(came_from):
                self.request.response.redirect(came_from)
            else:
                self.request.response.redirect(api.portal.get().absolute_url())

        # https://pyoidc.readthedocs.io/en/latest/examples/rp.html#authorization-code-flow
        args = {
            "client_id": self.context.getProperty("client_id"),
            "response_type": "code",
            "scope": self.context.get_scopes(),
            "state": session.get("state"),
            "nonce": session.get("nonce"),
            "redirect_uri": self.context.get_redirect_uris(),
        }

        if self.context.getProperty("use_pkce"):
            # Build a random string of 43 to 128 characters
            # and send it in the request as a base64-encoded urlsafe string of the sha256 hash of that string
            session.set("verifier", rndstr(128))
            args["code_challenge"] = self.get_code_challenge(
                session.get("verifier")
            )
            args["code_challenge_method"] = "S256"

        try:
            auth_req = client.construct_AuthorizationRequest(request_args=args)
            login_url = auth_req.request(client.authorization_endpoint)
        except Exception as e:
            logger.error(e)
            api.portal.show_message(
                _(
                    "There was an error during the login process. Please try"
                    " again."
                )
            )
            portal_url = api.portal.get_tool("portal_url")
            if came_from and portal_url.isURLInPortal(came_from):
                self.request.response.redirect(came_from)
            else:
                self.request.response.redirect(api.portal.get().absolute_url())

            return

        self.request.response.setHeader(
            "Cache-Control", "no-cache, must-revalidate"
        )
        self.request.response.redirect(login_url)
        return

    def get_code_challenge(self, value):
        """build a sha256 hash of the base64 encoded value of value
        be careful: this should be url-safe base64 and we should also remove the trailing '='
        See https://www.stefaanlippens.net/oauth-code-flow-pkce.html#PKCE-code-verifier-and-challenge
        """
        hash_code = sha256(value.encode("utf-8")).digest()
        return (
            base64.urlsafe_b64encode(hash_code)
            .decode("utf-8")
            .replace("=", "")
        )


class LogoutView(BrowserView):
    def __call__(self):
        try:
            client = self.context.get_oauth2_client()
        except OAuth2ConnectionException:
            return ""

        # session = Session(
        #   self.request,
        #   use_session_data_manager=self.context.getProperty("use_session_data_manager")
        # )
        # state is used to keep track of responses to outstanding requests (state).
        # https://github.com/keycloak/keycloak-documentation/blob/master/securing_apps/topics/oidc/java/logout.adoc
        # session.set('end_session_state', rndstr())

        redirect_uri = api.portal.get().absolute_url()

        # Volto frontend mapping exception
        if redirect_uri.endswith('/api'):
            redirect_uri = redirect_uri[:-4]

        if self.context.getProperty("use_deprecated_redirect_uri_for_logout"):
            args = {
                "redirect_uri": redirect_uri,
                }
        else:
            args = {
                "post_logout_redirect_uri": redirect_uri,
                "client_id": self.context.getProperty("client_id"),
                }

        pas = getToolByName(self.context, "acl_users")
        auth_cookie_name = pas.credentials_cookie_auth.cookie_name

        # end_req = client.construct_EndSessionRequest(request_args=args)
        end_req = EndSessionRequest(**args)
        logout_url = end_req.request(client.end_session_endpoint)
        self.request.response.setHeader("Cache-Control", "no-cache, must-revalidate")
        # TODO: change path with portal_path
        self.request.response.expireCookie(auth_cookie_name, path="/")
        self.request.response.expireCookie("auth_token", path="/")
        self.request.response.redirect(logout_url)
        return


class CallbackView(BrowserView):
    def __call__(self):
        response = self.request.environ["QUERY_STRING"]
        session = Session(
            self.request,
            use_session_data_manager=self.context.getProperty("use_session_data_manager"),
        )
        client = self.context.get_oauth2_client()
        aresp = client.parse_response(
            AuthorizationResponse, info=response, sformat="urlencoded"
        )
        if aresp["state"] != session.get("state"):
            logger.error("invalid OAuth2 state response:%s != session:%s",
                         aresp.get("state"), session.get("state"))
            # TODO: need to double check before removing the comment below
            # raise ValueError("invalid OAuth2 state")

        args = {
            "code": aresp["code"],
            "redirect_uri": self.context.get_redirect_uris(),
        }

        if self.context.getProperty("use_pkce"):
            args["code_verifier"] = session.get("verifier")

        if self.context.getProperty("use_modified_openid_schema"):
            IdToken.c_param.update(
                {
                    "email_verified": SINGLE_OPTIONAL_BOOLEAN_AS_STRING,
                    "phone_number_verified": SINGLE_OPTIONAL_BOOLEAN_AS_STRING,
                }
            )

        # The response you get back is an instance of an AccessTokenResponse
        # or again possibly an ErrorResponse instance.
        resp = client.do_access_token_request(
            state=aresp["state"],
            request_args=args,
            authn_method="client_secret_basic",
        )

        if isinstance(resp, AccessTokenResponse):
            # If it's an AccessTokenResponse the information in the response will be stored in the
            # client instance with state as the key for future use.
            if client.userinfo_endpoint:
                # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo

                # XXX: Not completely sure if this is even needed
                #      We do not have a OpenID connect provider with userinfo endpoint
                #      enabled and with the weird treatment of boolean values, so we cannot test this
                # if self.context.getProperty("use_modified_openid_schema"):
                #     userinfo = client.do_user_info_request(state=aresp["state"], user_info_schema=CustomOpenIDNonBooleanSchema)
                # else:
                #     userinfo = client.do_user_info_request(state=aresp["state"])

                userinfo = client.do_user_info_request(state=aresp["state"])
            else:
                userinfo = resp.to_dict().get("id_token", {})

            # userinfo in an instance of OpenIDSchema or ErrorResponse
            # It could also be dict, if there is no userinfo_endpoint
            if userinfo and isinstance(userinfo, (OpenIDSchema, dict)):
                self.context.rememberIdentity(userinfo)
                self.request.response.setHeader(
                    "Cache-Control", "no-cache, must-revalidate"
                )
                self.request.response.redirect(self.return_url(session=session))
                import ipdb ; ipdb.set_trace()
                try:
                    user_current = api.user.get_current()
                    event = IUserInitialLoginInEvent(user_current)
                    notify(event)
                except Exception as e:
                    logger.error(e)
                return
            else:
                logger.error(
                    "authentication failed invalid response %s %s", resp, userinfo
                )
                raise Unauthorized()
        else:
            logger.error("authentication failed %s", resp)
            raise Unauthorized()

    def return_url(self, session=None):
        came_from = self.request.get("came_from")
        if not came_from and session:
            came_from = session.get("came_from")

        portal_url = api.portal.get_tool("portal_url")
        if not (came_from and portal_url.isURLInPortal(came_from)):
            came_from = api.portal.get().absolute_url()

        # Volto frontend mapping exception
        if came_from.endswith('/api'):
            came_from = came_from[:-4]

        return came_from

Do you know how to do this 'in code'? (I have done this earlier by going to /manage_main portal_actions user log in, but it would be nice how have it 'on install' )

1 Like

@espenmn Sure, here are the source codes and zcml configurations:

In the my/package/configure.zcml file I included:

<configure
    xmlns="http://namespaces.zope.org/zope"
    xmlns:genericsetup="http://namespaces.zope.org/genericsetup"
    xmlns:i18n="http://namespaces.zope.org/i18n"
    xmlns:plone="http://namespaces.plone.org/plone"
    i18n_domain="my.package">

  ...

  <!-- -*- extra stuff goes here -*- -->

  ...
  <include package=".viewlets" />
  ...

</configure>

In the my/package/viewlets/configure.zcml file I included:

<configure
    xmlns="http://namespaces.zope.org/zope"
    xmlns:browser="http://namespaces.zope.org/browser"
    xmlns:plone="http://namespaces.plone.org/plone"
    i18n_domain="my.package">

    ...
	<!-- plone.portalheader -->

	<browser:viewlet
		name="portalheader.personalbar"
		manager="plone.app.layout.viewlets.interfaces.IPortalHeader"
		layer="my.package.interfaces.IMarkerLayer"
		template="templates/portalheader.personalbar.pt"
		class=".portalheader.PersonalBar"
		permission="zope2.View"
		/>
    ...

</configure>

In the my/package/viewlets/portalheader.py file I included:

from plone import api
from plone.app.layout.viewlets import ViewletBase


class PersonalBar(ViewletBase):
    """ Personal bar on top """

    def current_user(self):
        if api.user.is_anonymous():
            return None
        else:
            user = api.user.get_current()
            return user.id

    def is_manager(self):
        if not api.user.is_anonymous():
            user = api.user.get_current()
            return api.user.has_permission('my.package: Manage site',
                                           username=user.id)
        return False

    def self_reg_enabled(self):
        return api.portal.get_registry_record('plone.enable_self_reg')

In the my/package/viewlets/templates/portalheader.personalbar.pt file I included:


<ul
	class="portalheader-personalbar"
	tal:define="current_user view/current_user; is_manager view/is_manager; self_reg view/self_reg_enabled">
	<li id="anon-personalbar">
		<a
			tal:attributes="
			href string:${context/portal_url}/global_search;
			aria-label string:Search">
			<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">
  			<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
			</svg>
		</a>
	</li>
	<li
		id="anon-personalbar">
		<a
			tal:condition="current_user"
			tal:attributes="
			href string:${context/portal_url}/users/${current_user};
			aria-label string:Account Settings">
			<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="bi bi-person-gear" viewBox="0 0 16 16">
  			<path d="M11 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm.256 7a4.474 4.474 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10c.26 0 .507.009.74.025.226-.341.496-.65.804-.918C9.077 9.038 8.564 9 8 9c-5 0-6 3-6 4s1 1 1 1h5.256Zm3.63-4.54c.18-.613 1.048-.613 1.229 0l.043.148a.64.64 0 0 0 .921.382l.136-.074c.561-.306 1.175.308.87.869l-.075.136a.64.64 0 0 0 .382.92l.149.045c.612.18.612 1.048 0 1.229l-.15.043a.64.64 0 0 0-.38.921l.074.136c.305.561-.309 1.175-.87.87l-.136-.075a.64.64 0 0 0-.92.382l-.045.149c-.18.612-1.048.612-1.229 0l-.043-.15a.64.64 0 0 0-.921-.38l-.136.074c-.561.305-1.175-.309-.87-.87l.075-.136a.64.64 0 0 0-.382-.92l-.148-.045c-.613-.18-.613-1.048 0-1.229l.148-.043a.64.64 0 0 0 .382-.921l-.074-.136c-.306-.561.308-1.175.869-.87l.136.075a.64.64 0 0 0 .92-.382l.045-.148ZM14 12.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0Z"/>
			</svg>
		</a>
		<a
			tal:condition="not: current_user"
			tal:attributes="
			href string:${context/portal_url}/login;
			aria-label string:Log in">
			<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="bi bi-person" viewBox="0 0 16 16">
  			<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4Zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10Z"/>
			</svg>
		</a>
	</li>
	<li
		tal:condition="python:not current_user and self_reg"
		id="anon-personalbar">
		<a
			tal:attributes="
			href string:${context/portal_url}/@@register;
			aria-label string:Register">
			<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="bi bi-person-add" viewBox="0 0 16 16">
			  <path d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0Zm-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM8 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/>
			  <path d="M8.256 14a4.474 4.474 0 0 1-.229-1.004H3c.001-.246.154-.986.832-1.664C4.484 10.68 5.711 10 8 10c.26 0 .507.009.74.025.226-.341.496-.65.804-.918C9.077 9.038 8.564 9 8 9c-5 0-6 3-6 4s1 1 1 1h5.256Z"/>
			</svg>
		</a>
	</li>
	<li id="anon-personalbar" tal:condition="is_manager">
		<a
			tal:attributes="
			href string:${context/portal_url}/@@overview-controlpanel;
			aria-label string:Settings">
			<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="bi bi-gear" viewBox="0 0 16 16">
			  <path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
			  <path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
			</svg>
		</a>
	</li>
	<li id="anon-personalbar">
		<a
			tal:condition="current_user"
			tal:attributes="
			href string:${context/portal_url}/logout;
			aria-label string:Log out">
			<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="bi bi-box-arrow-right" viewBox="0 0 16 16">
			  <path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
			  <path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
			</svg>
		</a>
	</li>

</ul>

I hope it helps you!!!

Thanks.
Did you (also) try to do it 'without a theme' (I was hoping for a way of disabling the modal popup by changing the modal setting)

1 Like

@espenmn I had thought about using JavaScript to disable that Modal behavior.

As I understand they use the Mockup Modal reference.

Using a profile (axtions.xml) seems the way to do it.
I leave it here for reference

  <?xml version="1.0" encoding="utf-8"?>
  <object name="portal_actions" meta_type="Plone Actions Tool"
     xmlns:i18n="http://xml.zope.org/namespaces/i18n">
  <object name="user" meta_type="CMF Action Category">
  

  <object name="login" meta_type="CMF Action" i18n:domain="plone">
     <property name="title" i18n:translate="">Log in</property>
     <property name="description" i18n:translate=""></property>
     <property
        name="url_expr">python:f&quot;{plone_portal_state.navigation_root_url()}/login&quot;</property>
     <property name="link_target"></property>
     <property name="icon_expr">string:plone-login</property>
     <property name="available_expr">python:member is None</property>
     <property name="permissions">
     <element value="View"/>
     </property>
     <property name="visible">True</property>
     <property name="modal" type="text"></property>
  </object>

  
  </object>
  

  </object>
1 Like

Another test I made was install the collective.fingerpointing · PyPI addon it reports the login and logout for the Plone user that login using the /login URL as the follow:

2024-04-04 06:51:43,405 INFO    [collective.fingerpointing:75][waitress-3] user=anapoleo ip=127.0.0.1 action=login
2024-04-04 06:51:45,515 INFO    [collective.fingerpointing:75][waitress-3] user=anapoleo ip=127.0.0.1 action=logout

But if an external user from Keycloak login using the /acl_users/oidc/login URL doesn't report anything.

This addon validates the IUserLoggedInEvent event collective.fingerpointing/src/collective/fingerpointing/subscribers/pas_logger.py at master · collective/collective.fingerpointing (github.com).

Maybe it's missing functionality from the pas.plugins.oidc plugin?

IUserInitialLoginInEvent event

The IUserInitialLoginInEvent event needs as arguments a principal object, so I test with the following sintaxis:

principal = api.user.get_current()
principal = api.user.get(username=userinfo["sub"])
portal_mtool = api.portal.get_tool("portal_membership")
principal = portal_mtool.getAuthenticatedMember()

In all the above cases, the principal object returns the value <SpecialUser 'Anonymous User'>

If I understand, one of the very first things you have to do on any event subscriber is to check if I am in the right context.

It seems that I am not placing my code in the correct place of user authentication flow, to manually launch my code.

I remind you that I am doing this in the callback view of the plugin pas.plugins.oidc.

My code is just before executing the return syntax, I thought it was the right place, but obviously I am wrong.

Could you guide me in which part of the authentication workflow of the pas.plugins.oidc plugin where I should place my source code that launches the IUserInitialLoginInEvent event?

Here my updates:

I already managed to launch the event correctly from my callback override view, with the following source code

In the my/package/subscribers.zcml file I included:

<configure
    xmlns="http://namespaces.zope.org/zope">

  <!-- Event handler for creating a 'PloneUser' object after in the Initial Login -->
  <subscriber
    for="Products.PlonePAS.interfaces.events.IUserInitialLoginInEvent"
    handler=".handlers.user_logged_in_first"
    />

</configure>

In the my/package/browser/view.py file I included:

....
from Products.PlonePAS.events import UserInitialLoginInEvent
....
from pas.plugins.oidc.browser.view import CallbackView
....
from zope.event import notify
....

class CallbackView(CallbackView):

    def __call__(self):
        ...
        # Create the event instance
        event = UserInitialLoginInEvent(self.request)
        # Notify the system
        notify(event)
        ...

The main problem is that I was using the IUserInitialLoginInEvent interface class instead of the UserInitialLoginInEvent event class.

The only thing I'm missing is being able to access the correct user object context to access the authenticated user information to perform the tasks within my handler script.

This is the CallbackView in your package?

I have lost track of the full sequence of changes you are making.

My approach would be to -either- subclass the CallBackView Browserview of the PAS plugin, or to fire an event on completion of Products.PlonePAS.interfaces.events.IUserInitialLoginInEvent

Does your user_logged_in_first handler ever get executed?

EDIT after re-reading your very first post. It makes sense that the zopyx package does not create your usercontent when a user does not exist in /acl_users/source_users. I would suppose you need to tell zopyx.usersascontent to consider your external user source.

Maybe look at pas.plugins.ldap for inspiration, i have an instance where this package is being used, and can see those user having profiles. As far as I can tell, they are treated as regular Plone members.

EDIT2 Also, there is /portal_membership/manage_mapRoles - see the bottom of that page. I am not knowledgeable enough to assess what this would do / and or what settings it should have, to create your member areas.

1 Like

My task is when the user logs in for the first time an event is fired to create a PloneUser object included in the package zopyx.userascontent, this package includes an adapter class for this function, but it does not work for an external user from Keycloak who logs in with the URL /acl_users/oidc/login.

So here I was recommended to try overriding the callback view of the package pas.plugins.oidc, that's why I have my own callback class in my plugin ``my. Package .

I understand you; we have discussed several things here xD

I have created an override of the BrowserView class callback and it works, that is, the override goes to my class callback and not to the class callback from the package pas.plugins.odic.

And within my custom BrowserView class callback I invoke using the notify method the event class Products.PlonePAS.events.UserInitialLoginInEvent like the previous source code.

Yes, the user_logged_in_first handler gets executed!

As to getting the member, here is how the zopyx package does it:

I would poke around a bit to see if and when that IRedirectAfterLogin interface comes into play.

1 Like

That's why I created a handler event as a subscriber for external users.

I will review this package

I will review this suggestion

With my source code

                    principal = api.user.get_current()
                    # Create the event instance
                    event = UserInitialLoginInEvent(principal)
                    # Notify the system
                    notify(event)

On a breakpoint on ipdb session, look like the following:

ipdb> principal
<SpecialUser 'Anonymous User'>
ipdb> type(principal)
<class 'Acquisition.ImplicitAcquisitionWrapper'>
ipdb> dir(principal)
['__', '__allow_access_to_unprotected_subobjects__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__implemented__', '__init__', '__init_subclass__', '__le__', '__len__', '__lt__', '__module__', '__ne__', '__new__', '__of__', '__providedBy__', '__provides__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setstate__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_check_context', '_getPassword', 'allowed', 'authenticate', 'domains', 'getDomains', 'getId', 'getRoles', 'getRolesInContext', 'getUserName', 'has_permission', 'has_role', 'name', 'roles', 'zmi_icon']
ipdb> api.user.get_current().getId()
ipdb> api.user.get_current().getUserName()
'Anonymous User'
ipdb> l
--> 109                     event = UserInitialLoginInEvent(principal)
    110                     # Notify the system
    111                     notify(event)
    112                 except Exception as e:
    113                     LOG.error(e)
    114                 return

ipdb> n
> /home/macagua/projects/plone-6.0.7/src/my.package/src/my/package/browser/view.py(111)__call__()
    110                     # Notify the system
--> 111                     notify(event)
    112                 except Exception as e:

ipdb> n
2024-04-05 11:20:23,326 INFO    [plone.protect:32][waitress-1] auto rotating keyring _forms
2024-04-05 11:20:23,332 INFO    [plone.protect:32][waitress-1] auto rotating keyring _anon
2024-04-05 11:20:23,341 INFO    [plone.protect:32][waitress-1] auto rotating keyring _forms
2024-04-05 11:20:23,348 INFO    [plone.protect:32][waitress-1] auto rotating keyring _anon
2024-04-05 11:20:23,366 INFO    [collective.fingerpointing:75][waitress-1] user=- ip=127.0.0.1 action=login
> /home/macagua/projects/plone-6.0.7/src/my.package/src/my/package/handlers.py(70)user_logged_in_first()
     69     # request = getattr(portal, "REQUEST", None)
---> 70     request = event.object.REQUEST
     71     portal = api.portal.get()

ipdb> event.principal
<SpecialUser 'Anonymous User'>
ipdb> event.principal.getUserName()
'Anonymous User'
ipdb>

As you can see, obtaining the user both from my callback BrowserView and in my handler script gives me the value Anonymous User.

1 Like