Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): Sync classic/API PAS plugin cookies #1303

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions news/1303.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Logging in to or out of Plone classic or the API does the same in the other.
[rpatterson]
2 changes: 1 addition & 1 deletion plone-5.2.x-performance.cfg
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion plone-6.0.x.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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

[sources]
Expand Down
2 changes: 1 addition & 1 deletion site.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
extensions = mr.developer
extends = buildout.cfg
eggs += plone.restapi
auto-checkout = plone.restapi
auto-checkout += plone.restapi
parts = instance plonesite


Expand Down
32 changes: 32 additions & 0 deletions src/plone/restapi/pas/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
98 changes: 94 additions & 4 deletions src/plone/restapi/pas/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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)."""

Expand All @@ -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 = (
Expand All @@ -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
Expand Down Expand Up @@ -95,13 +110,21 @@ def extractCredentials(self, request):
return creds

creds = {}

# Prefer the Authorization Bearer header if present
auth = request._auth
if auth is None:
return
if auth[:7].lower() == "bearer ":
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):
Expand All @@ -127,6 +150,51 @@ def authenticateCredentials(self, credentials):

return (userid, userid)

@security.private
def updateCredentials(self, request, response, login, new_password):
rpatterson marked this conversation as resolved.
Show resolved Hide resolved
"""
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):
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
2 changes: 1 addition & 1 deletion src/plone/restapi/profiles/default/metadata.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0"?>
<metadata>
<version>0006</version>
<version>0007</version>
</metadata>
41 changes: 35 additions & 6 deletions src/plone/restapi/services/auth/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand All @@ -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:
Expand All @@ -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,
)
)

Expand Down Expand Up @@ -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
Expand Down
30 changes: 16 additions & 14 deletions src/plone/restapi/setuphandlers.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -31,19 +26,26 @@ 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",
Comment on lines +45 to +46
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these plugins really be enabled by default? Isn't this a specific use case? For those who don't care about integrated authentication, wouldn't that be an unnecessary burden (less performance)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these plugins really be enabled by default?

Yes. The purpose of these changes and this PR is to keep login/logout state in sync between plugins. Those interfaces are the ones that are responsible for that.

Isn't this a specific use case?

This is the common use case. A user who is accessing Plone from multiple interfaces, such as Plone Classic and Volto, and those interfaces use different PAS plugins, such as a cookie plugin and a JWT token plugin, that user has every reason to expect that logging out of one also logs them out of the other. This is the least surprising behavior and as such should be the default.

For those who don't care about integrated authentication

We can be grateful to PAS for making that a simple matter of deactivating the plugins for those interfaces in that specific use case. ;-)

wouldn't that be an unnecessary burden (less performance)?

Login/logout are rare events and as such not a significant impact on the user experience of performance. Both of these operations for both of these plugins are also very cheap and as such wouldn't impact performance anyways. Nor do I know of any plugin for which these operations are other than very cheap.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free to unresolve with details if this still is blocking merge!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well I'll mark it as unresolved so other people can see your points. But I don't think this is a blocker. I just wish other people would give their opinions too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optimization for speed without any measurements does not make sense. I personally doubt this will effect performance on a significant level. Proof me wrong.

],
)
if uf_parent is uf_parent.getPhysicalRoot():
break
uf_parent = aq_parent(uf_parent)


def post_install_default(context):
Expand Down
Loading