diff --git a/src/plone/restapi/pas/__init__.py b/src/plone/restapi/pas/__init__.py index e69de29bb2..d761a9744b 100644 --- a/src/plone/restapi/pas/__init__.py +++ b/src/plone/restapi/pas/__init__.py @@ -0,0 +1,32 @@ +""" +A JWT token authentication plugin for PluggableAuthService. +""" + +from Products.CMFCore.utils import getToolByName +from Products.CMFPlone import interfaces as plone_ifaces +from Products import PluggableAuthService # noqa, Ensure PAS patch in place +from Products.PluggableAuthService.interfaces import authservice as authservice_ifaces + +import Acquisition + + +def iter_ancestor_pas(context): + """ + Walk up the ZODB OFS returning Pluggableauthservice `./acl_users/` for each level. + """ + uf_parent = Acquisition.aq_inner(context) + while True: + is_plone_site = plone_ifaces.IPloneSiteRoot.providedBy(uf_parent) + uf = getToolByName(uf_parent, "acl_users", default=None) + + # Skip ancestor contexts to which we don't/can't apply + if uf is None or not authservice_ifaces.IPluggableAuthService.providedBy(uf): + uf_parent = Acquisition.aq_parent(uf_parent) + continue + + yield uf, is_plone_site + + # Go up one more level + if uf_parent is uf_parent.getPhysicalRoot(): + break + uf_parent = Acquisition.aq_parent(uf_parent) diff --git a/src/plone/restapi/profiles/default/metadata.xml b/src/plone/restapi/profiles/default/metadata.xml index 81970e34ed..1e4872e498 100644 --- a/src/plone/restapi/profiles/default/metadata.xml +++ b/src/plone/restapi/profiles/default/metadata.xml @@ -1,4 +1,4 @@ - 0006 + 0007 diff --git a/src/plone/restapi/services/auth/login.py b/src/plone/restapi/services/auth/login.py index 21fa9fa6b5..e9014b8b3d 100644 --- a/src/plone/restapi/services/auth/login.py +++ b/src/plone/restapi/services/auth/login.py @@ -86,7 +86,27 @@ def reply(self): ) login_view._post_login() - return {"token": self.request[plugin.cookie_name]} + response = {} + if plugin.cookie_name in self.request: + response["token"] = self.request[plugin.cookie_name] + else: + self.request.response.setStatus(501) + message = ( + "JWT authentication token not created, plugin probably not activated " + "for `ICredentialsUpdatePlugin`" + ) + logger.error( + "%s: %s", + message, + "/".join(plugin.getPhysicalPath()), + ) + return dict( + error=dict( + type="Login failed", + message=message, + ) + ) + return response def _find_userfolder(self, userid): """Try to find a user folder that contains a user with the given diff --git a/src/plone/restapi/setuphandlers.py b/src/plone/restapi/setuphandlers.py index 1bc174d40c..a1a3948172 100644 --- a/src/plone/restapi/setuphandlers.py +++ b/src/plone/restapi/setuphandlers.py @@ -1,11 +1,6 @@ -from Acquisition import aq_inner -from Acquisition import aq_parent +from plone.restapi import pas from plone.restapi.pas.plugin import JWTAuthenticationPlugin -from Products.CMFCore.utils import getToolByName from Products.CMFPlone.interfaces import INonInstallable -from Products.PluggableAuthService.interfaces.authservice import ( - IPluggableAuthService, -) # noqa: E501 from zope.component.hooks import getSite from zope.interface import implementer @@ -31,14 +26,12 @@ def getNonInstallableProducts(self): # pragma: no cover def install_pas_plugin(context): - uf_parent = aq_inner(context) - while True: - uf = getToolByName(uf_parent, "acl_users", default=None) + """ + Install the JWT token PAS plugin in every PAS acl_users here and above. - # Skip ancestor contexts to which we don't/can't apply - if uf is None or not IPluggableAuthService.providedBy(uf): - uf_parent = aq_parent(uf_parent) - continue + Usually this means it is installed into Plone and into the Zope root. + """ + for uf, is_plone_site in pas.iter_ancestor_pas(context): # Add the API token plugin if not already installed at this level if "jwt_auth" not in uf: @@ -54,11 +47,6 @@ def install_pas_plugin(context): ], ) - # Go up one more level - if uf_parent is uf_parent.getPhysicalRoot(): - break - uf_parent = aq_parent(uf_parent) - def post_install_default(context): """Post install of default profile""" diff --git a/src/plone/restapi/tests/test_addons.py b/src/plone/restapi/tests/test_addons.py index 9fd449e7ee..36b5e40f40 100644 --- a/src/plone/restapi/tests/test_addons.py +++ b/src/plone/restapi/tests/test_addons.py @@ -116,12 +116,15 @@ def _get_upgrade_info(self): # Set need upgrade state self.ps.setLastVersionForProfile("plone.restapi:default", "0002") transaction.commit() + # FIXME: At least the `newVersion` should be extracted from + # `./profiles/default/metadata.xml` so that this test isn't constantly changing + # for unrelated code changes. self.assertEqual( { "available": True, "hasProfile": True, "installedVersion": "0002", - "newVersion": "0006", + "newVersion": "0007", "required": True, }, _get_upgrade_info(self), @@ -135,8 +138,8 @@ def _get_upgrade_info(self): { "available": False, "hasProfile": True, - "installedVersion": "0006", - "newVersion": "0006", + "installedVersion": "0007", + "newVersion": "0007", "required": False, }, _get_upgrade_info(self), diff --git a/src/plone/restapi/upgrades/configure.zcml b/src/plone/restapi/upgrades/configure.zcml index 66bb67b0d7..88b9b86425 100644 --- a/src/plone/restapi/upgrades/configure.zcml +++ b/src/plone/restapi/upgrades/configure.zcml @@ -69,4 +69,15 @@ handler="plone.restapi.upgrades.to0006.rename_iface_to_name_in_blocks_behavior" /> + + diff --git a/src/plone/restapi/upgrades/to0007.py b/src/plone/restapi/upgrades/to0007.py new file mode 100644 index 0000000000..3f8103e40a --- /dev/null +++ b/src/plone/restapi/upgrades/to0007.py @@ -0,0 +1,39 @@ +""" +GenericSetup profile upgrades from version 0006 to 0007. +""" + +from plone.restapi import pas +from plone.restapi.pas import plugin +from Products.CMFCore.utils import getToolByName +from Products.PluggableAuthService.interfaces import plugins as plugins_ifaces + +import logging + +logger = logging.getLogger(__name__) + + +def enable_new_pas_plugin_interfaces(context): + """ + Enable new PAS plugin interfaces. + + After correcting/completing the PAS plugin interfaces, those interfaces need to be + enabled for existing functionality to continue working. + """ + portal = getToolByName(context, "portal_url").getPortalObject() + for uf, is_plone_site in pas.iter_ancestor_pas(portal): + for jwt_plugin in uf.objectValues(plugin.JWTAuthenticationPlugin.meta_type): + for new_iface in ( + plugins_ifaces.ICredentialsUpdatePlugin, + plugins_ifaces.ICredentialsResetPlugin, + ): + active_plugin_ids = [ + active_plugin_id for active_plugin_id, _ in + uf.plugins.listPlugins(new_iface) + ] + if jwt_plugin.id not in active_plugin_ids: + logger.info( + "Activating PAS interface %s: %s", + new_iface.__name__, + "/".join(jwt_plugin.getPhysicalPath()) + ) + uf.plugins.activatePlugin(new_iface, jwt_plugin.id)