diff --git a/Makefile b/Makefile
index 0c40a7b17b..e570748c6d 100644
--- a/Makefile
+++ b/Makefile
@@ -36,7 +36,6 @@ update: ## Update Make and Buildout
wget -O requirements.txt https://raw.githubusercontent.com/kitconcept/buildout/5.2/requirements.txt
wget -O plone-5.2.x.cfg https://raw.githubusercontent.com/kitconcept/buildout/5.2/plone-5.2.x.cfg
wget -O ci.cfg https://raw.githubusercontent.com/kitconcept/buildout/5.2/ci.cfg
- wget -O versions.cfg https://raw.githubusercontent.com/kitconcept/buildout/5.2/versions.cfg
.installed.cfg: bin/buildout *.cfg
bin/buildout
diff --git a/base.cfg b/base.cfg
index 8f22ca3720..c54d23ed9b 100644
--- a/base.cfg
+++ b/base.cfg
@@ -35,6 +35,9 @@ allow-hosts =
[versions]
# Do not use a release of plone.restapi:
plone.restapi =
+# Fix Zope root `/acl_users` logout
+Products.PluggableAuthService = >=2.7.0
+Products.PlonePAS = >=7.0.0a3
[instance]
recipe = plone.recipe.zope2instance
diff --git a/news/1303.feature b/news/1303.feature
new file mode 100644
index 0000000000..7100014e67
--- /dev/null
+++ b/news/1303.feature
@@ -0,0 +1,2 @@
+Logging in to or out of Plone classic or the API does the same in the other.
+[rpatterson]
diff --git a/plone-5.2.x-performance.cfg b/plone-5.2.x-performance.cfg
index 8285f1ddf0..02d3217951 100644
--- a/plone-5.2.x-performance.cfg
+++ b/plone-5.2.x-performance.cfg
@@ -1,7 +1,7 @@
[buildout]
extends = plone-5.2.x.cfg
parts += instance plonesite
-auto-checkout = Products.ZCatalog
+auto-checkout += Products.ZCatalog
[instance]
recipe = plone.recipe.zope2instance
diff --git a/plone-6.0.x.cfg b/plone-6.0.x.cfg
index 6ab61a6ada..8493c97cf3 100644
--- a/plone-6.0.x.cfg
+++ b/plone-6.0.x.cfg
@@ -5,7 +5,7 @@ extends =
base.cfg
find-links = https://dist.plone.org/release/6.0.0a3/
versions=versions
-auto-checkout =
+auto-checkout +=
Products.CMFPlone
always-checkout = true
diff --git a/site.cfg b/site.cfg
index eb81698e9d..0114638d9c 100644
--- a/site.cfg
+++ b/site.cfg
@@ -2,7 +2,7 @@
extensions = mr.developer
extends = buildout.cfg
eggs += plone.restapi
-auto-checkout = plone.restapi
+auto-checkout += plone.restapi
parts = instance plonesite
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/pas/plugin.py b/src/plone/restapi/pas/plugin.py
index 643c77e35f..5fd961faef 100644
--- a/src/plone/restapi/pas/plugin.py
+++ b/src/plone/restapi/pas/plugin.py
@@ -2,6 +2,7 @@
from AccessControl.SecurityInfo import ClassSecurityInfo
from BTrees.OIBTree import OIBTree
from BTrees.OOBTree import OOBTree
+from DateTime import DateTime
from datetime import datetime
from datetime import timedelta
from plone.keyring.interfaces import IKeyManager
@@ -13,13 +14,18 @@
from Products.PluggableAuthService.interfaces.plugins import IAuthenticationPlugin
from Products.PluggableAuthService.interfaces.plugins import IChallengePlugin
from Products.PluggableAuthService.interfaces.plugins import IExtractionPlugin
+from Products.PluggableAuthService.interfaces.plugins import ICredentialsUpdatePlugin
+from Products.PluggableAuthService.interfaces.plugins import ICredentialsResetPlugin
from Products.PluggableAuthService.plugins.BasePlugin import BasePlugin
+from zope import component
from zope.component import getUtility
from zope.interface import implementer
import jwt
+import logging
import time
+logger = logging.getLogger(__name__)
manage_addJWTAuthenticationPlugin = PageTemplateFile(
"add_plugin", globals(), __name__="manage_addJWTAuthenticationPlugin"
@@ -39,7 +45,13 @@ def addJWTAuthenticationPlugin(self, id_, title=None, REQUEST=None):
)
-@implementer(IAuthenticationPlugin, IChallengePlugin, IExtractionPlugin)
+@implementer(
+ IAuthenticationPlugin,
+ IChallengePlugin,
+ IExtractionPlugin,
+ ICredentialsUpdatePlugin,
+ ICredentialsResetPlugin,
+)
class JWTAuthenticationPlugin(BasePlugin):
"""Plone PAS plugin for authentication with JSON web tokens (JWT)."""
@@ -51,6 +63,7 @@ class JWTAuthenticationPlugin(BasePlugin):
store_tokens = False
_secret = None
_tokens = None
+ cookie_name = "auth_token"
# ZMI tab for configuration page
manage_options = (
@@ -59,9 +72,11 @@ class JWTAuthenticationPlugin(BasePlugin):
security.declareProtected(ManagePortal, "manage_config")
manage_config = PageTemplateFile("config", globals(), __name__="manage_config")
- def __init__(self, id_, title=None):
+ def __init__(self, id_, title=None, cookie_name=None):
self._setId(id_)
self.title = title
+ if cookie_name:
+ self.cookie_name = cookie_name
# Initiate a challenge to the user to provide credentials.
@security.private
@@ -95,6 +110,8 @@ def extractCredentials(self, request):
return creds
creds = {}
+
+ # Prefer the Authorization Bearer header if present
auth = request._auth
if auth is None:
return
@@ -102,6 +119,12 @@ def extractCredentials(self, request):
creds["token"] = auth.split()[-1]
return creds
+ # Finally, use the cookie if present
+ cookie = request.get(self.cookie_name, "")
+ if cookie:
+ creds["token"] = cookie
+ return creds
+
# IAuthenticationPlugin implementation
@security.private
def authenticateCredentials(self, credentials):
@@ -127,6 +150,51 @@ def authenticateCredentials(self, credentials):
return (userid, userid)
+ @security.private
+ def updateCredentials(self, request, response, login, new_password):
+ """
+ Generate a new token for use both in the Bearer header and the cookie.
+ """
+ # Unfortunately PAS itself is confused as to whether this plugin method should
+ # get the immutable user ID or the mutable, user-facing user login/name. Real
+ # usage in the Plone code base also uses both. Do our best to guess which.
+ user_id = login
+ data = dict(fullname="")
+ user = self._getPAS().getUserById(login)
+ if user is None:
+ user = self._getPAS().getUser(login)
+ if user is not None:
+ user_id = user.getId()
+ data["fullname"] = user.getProperty("fullname")
+ payload, token = self.create_payload_token(user_id, data=data)
+ # Make available on the request for further use such as returning it in the JSON
+ # body of the response if the current request is for the REST API login view.
+ request[self.cookie_name] = token
+ # Make the token available to the client browser for use in UI code such as when
+ # the login happened through Plone Classic so that the the Volro React
+ # components can retrieve the token that way and use the Authorization Bearer
+ # header from then on.
+ cookie_kwargs = {}
+ if "exp" in payload:
+ # Match the token expiration date/time.
+ cookie_kwargs["expires"] = DateTime(payload["exp"]).toZone("GMT").rfc822()
+ response.setCookie(
+ self.cookie_name,
+ token,
+ path="/",
+ **cookie_kwargs,
+ )
+
+ @security.private
+ def resetCredentials(self, request, response):
+ """
+ Expire the token and remove the cookie.
+ """
+ if self.cookie_name in request:
+ if self.store_tokens:
+ self.delete_token(request[self.cookie_name])
+ response.expireCookie(self.cookie_name, path="/")
+
@security.protected(ManagePortal)
@postonly
def manage_updateConfig(self, REQUEST):
@@ -146,7 +214,15 @@ def manage_updateConfig(self, REQUEST):
def _decode_token(self, token, verify=True):
if self.use_keyring:
- manager = getUtility(IKeyManager)
+ manager = component.queryUtility(IKeyManager)
+ if manager is None:
+ logger.error(
+ "JWT token plugin configured to use IKeyManager "
+ "but no utility is registered: %r\n"
+ "Have you upgraded the `plone.restapi:default` profile?",
+ "/".join(self.getPhysicalPath()),
+ )
+ return
for secret in manager["_system"]:
if secret is None:
continue
@@ -184,7 +260,10 @@ def delete_token(self, token):
del self._tokens[userid][token]
return True
- def create_token(self, userid, timeout=None, data=None):
+ def create_payload_token(self, userid, timeout=None, data=None):
+ """
+ Create and return both a JWT payload and the signed token.
+ """
payload = {}
payload["sub"] = userid
if timeout is None:
@@ -201,4 +280,15 @@ def create_token(self, userid, timeout=None, data=None):
if userid not in self._tokens:
self._tokens[userid] = OIBTree()
self._tokens[userid][token] = int(time.time())
+ return payload, token
+
+ def create_token(self, userid, timeout=None, data=None):
+ """
+ Create a JWT payload and the signed token, return the token.
+ """
+ _, token = self.create_payload_token(
+ userid,
+ timeout=timeout,
+ data=data,
+ )
return token
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 47813440d5..e9014b8b3d 100644
--- a/src/plone/restapi/services/auth/login.py
+++ b/src/plone/restapi/services/auth/login.py
@@ -7,8 +7,11 @@
from zope.interface import alsoProvides
from zope import component
+import logging
import plone.protect.interfaces
+logger = logging.getLogger(__name__)
+
class Login(Service):
"""Handles login and returns a JSON web token (JWT)."""
@@ -28,8 +31,10 @@ def reply(self):
if "IDisableCSRFProtection" in dir(plone.protect.interfaces):
alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection)
- userid = data["login"]
- password = data["password"]
+ # Also add credentials to the request for other code that depends on it. In
+ # particular, the PAS cookie authentication plugin depends on `__ac_password`.
+ userid = self.request.form["__ac_name"] = data["login"]
+ password = self.request.form["__ac_password"] = data["password"]
uf = self._find_userfolder(userid)
if uf is not None:
@@ -43,10 +48,16 @@ def reply(self):
if plugin is None:
self.request.response.setStatus(501)
+ message = "JWT authentication plugin not installed"
+ logger.error(
+ "%s: %s",
+ message,
+ "/".join(uf.getPhysicalPath()),
+ )
return dict(
error=dict(
type="Login failed",
- message="JWT authentication plugin not installed.",
+ message=message,
)
)
@@ -75,9 +86,27 @@ def reply(self):
)
login_view._post_login()
- payload = {}
- payload["fullname"] = user.getProperty("fullname")
- return {"token": plugin.create_token(user.getId(), data=payload)}
+ 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 2755e39a5c..7b01de357f 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,19 +26,28 @@ def getNonInstallableProducts(self): # pragma: no cover
def install_pas_plugin(context):
- uf_parent = aq_inner(context)
- while True:
- uf = getToolByName(uf_parent, "acl_users")
- if IPluggableAuthService.providedBy(uf) and "jwt_auth" not in uf:
+ """
+ Install the JWT token PAS plugin in every PAS acl_users here and above.
+
+ 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:
plugin = JWTAuthenticationPlugin("jwt_auth")
uf._setObject(plugin.getId(), plugin)
plugin = uf["jwt_auth"]
plugin.manage_activateInterfaces(
- ["IAuthenticationPlugin", "IExtractionPlugin"]
+ [
+ "IAuthenticationPlugin",
+ "IExtractionPlugin",
+ "ICredentialsUpdatePlugin",
+ "ICredentialsResetPlugin",
+ ],
)
- if uf_parent is uf_parent.getPhysicalRoot():
- break
- uf_parent = aq_parent(uf_parent)
+ if not is_plone_site:
+ plugin.use_keyring = False
def post_install_default(context):
diff --git a/src/plone/restapi/testing.py b/src/plone/restapi/testing.py
index 303eed97cc..30197569ab 100644
--- a/src/plone/restapi/testing.py
+++ b/src/plone/restapi/testing.py
@@ -18,7 +18,7 @@
from plone.registry.interfaces import IRegistry
from plone.restapi.tests.dxtypes import INDEXES as DX_TYPES_INDEXES
from plone.restapi.tests.helpers import add_catalog_indexes
-from plone.testing import z2
+from plone.testing import zope
from plone.testing.layer import Layer
from plone.uuid.interfaces import IUUIDGenerator
from Products.CMFCore.utils import getToolByName
@@ -118,7 +118,7 @@ def setUpZope(self, app, configurationContext):
xmlconfig.file("testing.zcml", plone.restapi, context=configurationContext)
self.loadZCML(package=collective.MockMailHost)
- z2.installProduct(app, "plone.restapi")
+ zope.installProduct(app, "plone.restapi")
def setUpPloneSite(self, portal):
portal.acl_users.userFolderAddUser(
@@ -145,7 +145,7 @@ def setUpPloneSite(self, portal):
bases=(PLONE_RESTAPI_DX_FIXTURE,), name="PloneRestApiDXLayer:Integration"
)
PLONE_RESTAPI_DX_FUNCTIONAL_TESTING = FunctionalTesting(
- bases=(PLONE_RESTAPI_DX_FIXTURE, z2.ZSERVER_FIXTURE),
+ bases=(PLONE_RESTAPI_DX_FIXTURE, zope.WSGI_SERVER_FIXTURE),
name="PloneRestApiDXLayer:Functional",
)
@@ -175,7 +175,7 @@ def setUpZope(self, app, configurationContext):
xmlconfig.file("configure.zcml", plone.restapi, context=configurationContext)
xmlconfig.file("testing.zcml", plone.restapi, context=configurationContext)
- z2.installProduct(app, "plone.restapi")
+ zope.installProduct(app, "plone.restapi")
def setUpPloneSite(self, portal):
portal.acl_users.userFolderAddUser(
@@ -201,7 +201,7 @@ def setUpPloneSite(self, portal):
bases=(PLONE_RESTAPI_DX_PAM_FIXTURE,), name="PloneRestApiDXPAMLayer:Integration"
)
PLONE_RESTAPI_DX_PAM_FUNCTIONAL_TESTING = FunctionalTesting(
- bases=(PLONE_RESTAPI_DX_PAM_FIXTURE, z2.ZSERVER_FIXTURE),
+ bases=(PLONE_RESTAPI_DX_PAM_FIXTURE, zope.WSGI_SERVER_FIXTURE),
name="PloneRestApiDXPAMLayer:Functional",
)
@@ -216,7 +216,7 @@ def setUpZope(self, app, configurationContext):
xmlconfig.file("configure.zcml", plone.restapi, context=configurationContext)
xmlconfig.file("testing.zcml", plone.restapi, context=configurationContext)
- z2.installProduct(app, "plone.restapi")
+ zope.installProduct(app, "plone.restapi")
PLONE_RESTAPI_ITERATE_FIXTURE = PloneRestApiDXIterateLayer()
@@ -225,7 +225,7 @@ def setUpZope(self, app, configurationContext):
name="PloneRestApiDXIterateLayer:Integration",
)
PLONE_RESTAPI_ITERATE_FUNCTIONAL_TESTING = FunctionalTesting(
- bases=(PLONE_RESTAPI_ITERATE_FIXTURE, z2.ZSERVER_FIXTURE),
+ bases=(PLONE_RESTAPI_ITERATE_FIXTURE, zope.WSGI_SERVER_FIXTURE),
name="PloneRestApiDXIterateLayer:Functional",
)
@@ -243,7 +243,7 @@ def setUpPloneSite(self, portal):
bases=(PLONE_RESTAPI_BLOCKS_FIXTURE,), name="PloneRestApIBlocksLayer:Integration"
)
PLONE_RESTAPI_BLOCKS_FUNCTIONAL_TESTING = FunctionalTesting(
- bases=(PLONE_RESTAPI_BLOCKS_FIXTURE, z2.ZSERVER_FIXTURE),
+ bases=(PLONE_RESTAPI_BLOCKS_FIXTURE, zope.WSGI_SERVER_FIXTURE),
name="PloneRestApIBlocksLayer:Functional",
)
diff --git a/src/plone/restapi/tests/test_addons.py b/src/plone/restapi/tests/test_addons.py
index 78ab4fa2e9..b19a58a74a 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),
@@ -190,7 +193,7 @@ def _get_upgrade_info(self):
"available": True,
"hasProfile": True,
"installedVersion": "0002",
- "newVersion": "0006",
+ "newVersion": "0007",
"required": True,
},
_get_upgrade_info(self),
diff --git a/src/plone/restapi/tests/test_auth.py b/src/plone/restapi/tests/test_auth.py
index e619e0ab75..2db395dfca 100644
--- a/src/plone/restapi/tests/test_auth.py
+++ b/src/plone/restapi/tests/test_auth.py
@@ -86,7 +86,8 @@ def test_login_with_zope_user_fails_without_pas_plugin(self):
res = service.reply()
self.assertIn("error", res)
self.assertEqual(
- "JWT authentication plugin not installed.", res["error"]["message"]
+ "jwt authentication plugin not installed",
+ res["error"]["message"].lower(),
)
self.assertNotIn("token", res)
diff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py
index fe6d391de1..90f1b38a3f 100644
--- a/src/plone/restapi/tests/test_documentation.py
+++ b/src/plone/restapi/tests/test_documentation.py
@@ -27,7 +27,7 @@
from plone.restapi.testing import RelativeSession
from plone.restapi.tests.statictime import StaticTime
from plone.scale import storage
-from plone.testing.z2 import Browser
+from plone.testing.zope import Browser
from zope.component import createObject
from zope.component import getUtility
from zope.component.hooks import getSite
diff --git a/src/plone/restapi/tests/test_functional_auth.py b/src/plone/restapi/tests/test_functional_auth.py
index 59b8b31dec..2c1b87c823 100644
--- a/src/plone/restapi/tests/test_functional_auth.py
+++ b/src/plone/restapi/tests/test_functional_auth.py
@@ -1,3 +1,4 @@
+from plone.app import testing as pa_testing
from plone.app.testing import login
from plone.app.testing import setRoles
from plone.app.testing import SITE_OWNER_NAME
@@ -5,6 +6,9 @@
from plone.app.testing import TEST_USER_NAME
from plone.app.testing import TEST_USER_PASSWORD
from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
+from plone.testing import zope
+from Products.PluggableAuthService.interfaces import plugins as plugins_ifaces
+from Products.PluggableAuthService.plugins import CookieAuthHelper
import base64
import requests
@@ -17,14 +21,28 @@ class TestFunctionalAuth(unittest.TestCase):
layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING
def setUp(self):
+ """
+ Set initial test conditions and convenience attributes.
+ """
+ # Zope root references
+ self.app = self.layer["app"]
+ self.root_acl_users = self.app.acl_users
+
+ # Plone portal references
self.portal = self.layer["portal"]
self.portal_url = self.portal.absolute_url()
+
+ # User permissions and authentication
setRoles(self.portal, TEST_USER_ID, ["Manager"])
login(self.portal, SITE_OWNER_NAME)
+
+ # Create a page that can't be publicly accessed
self.private_document = self.portal[
self.portal.invokeFactory("Document", id="doc1", title="My Document")
]
self.private_document_url = self.private_document.absolute_url()
+
+ # This is a functional fixture, have to commit our changes
transaction.commit()
def test_login_without_credentials_fails(self):
@@ -60,34 +78,98 @@ def test_login_with_valid_credentials_returns_token(self):
headers={"Accept": "application/json"},
json={"login": TEST_USER_NAME, "password": TEST_USER_PASSWORD},
)
- self.assertEqual(200, response.status_code)
- self.assertIn("token", response.json())
+ self.assertEqual(
+ 200,
+ response.status_code,
+ "Wrong API login response status code",
+ )
+ self.assertIn(
+ "token",
+ response.json(),
+ "Authentication token missing from API response JSON",
+ )
- def test_api_login_grants_zmi(self):
+ def test_api_login_sets_classic_cookie(self):
"""
- Logging in via the API also grants access to the Zope root ZMI.
+ Logging in via the API also sets the Plone classic auth cookie.
"""
session = requests.Session()
self.addCleanup(session.close)
- login_resp = session.post(
+ session.post(
self.portal_url + "/@login",
headers={"Accept": "application/json"},
json={"login": SITE_OWNER_NAME, "password": TEST_USER_PASSWORD},
)
self.assertIn(
"__ac",
- login_resp.cookies,
+ session.cookies,
"Plone session cookie missing from API login POST response",
)
+
+ def test_classic_login_sets_api_token_cookie(self):
+ """
+ Logging in via Plone classic login form also sets cookie with the API token.
+
+ The cookie that Volto React components will recognize on first request and use
+ as the Authorization Bearer header for subsequent requests.
+ """
+ session = requests.Session()
+ self.addCleanup(session.close)
+ challenge_resp = session.get(self.private_document_url)
+ self.assertEqual(
+ challenge_resp.status_code,
+ 200,
+ "Wrong Plone login challenge status code",
+ )
+ self.assertTrue(
+ '
+
+
diff --git a/src/plone/restapi/upgrades/to0007.py b/src/plone/restapi/upgrades/to0007.py
new file mode 100644
index 0000000000..0c3af18424
--- /dev/null
+++ b/src/plone/restapi/upgrades/to0007.py
@@ -0,0 +1,45 @@
+"""
+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):
+ if not is_plone_site and jwt_plugin.use_keyring:
+ logger.info(
+ "Disabling keyring for plugin outside of Plone: %s",
+ "/".join(jwt_plugin.getPhysicalPath()),
+ )
+ jwt_plugin.use_keyring = False
+ 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)
diff --git a/versions.cfg b/versions.cfg
deleted file mode 100644
index 28a57f5575..0000000000
--- a/versions.cfg
+++ /dev/null
@@ -1,28 +0,0 @@
-[versions]
-# Buildout
-setuptools =
-zc.buildout =
-plone.restapi =
-
-# code analysis
-black = 20.8b1
-
-# Error: The requirement ('virtualenv>=20.0.35') is not allowed by your [versions] constraint (20.0.26)
-virtualenv = 20.0.35
-
-# Error: The requirement ('pep517>=0.9') is not allowed by your [versions] constraint (0.8.2)
-pep517 = 0.9.1
-
-# Error: The requirement ('importlib-metadata>=1') is not allowed by your [versions] constraint (0.23)
-importlib-metadata = 2.0.0
-
-# cryptography 3.4 requires a rust compiler installed on the system:
-# https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#34---2021-02-07
-cryptography = 3.3.2
-
-# cffi 1.14.3 fails on apple m1
-cffi = 1.14.4
-
-# requirement for json widget tests to pass
-plone.schema = 1.3.0
-plone.dexterity = 2.9.8
\ No newline at end of file