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

fix(auth): Unify Zope root ZMI w/ API log in/out #1304

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d55c7ae
test(deprecation): Fix all warnings from our code
rpatterson Dec 26, 2021
6f4b1b5
test(auth): Cleanup redundant assertions
rpatterson Dec 25, 2021
2668766
test(auth): Fix cookie presence checks
rpatterson Dec 26, 2021
9422195
fix(auth): Set both classic and API login cookies
rpatterson Dec 24, 2021
d9ee713
fix(auth): API token cookie match token expiration
rpatterson Dec 27, 2021
c941bf2
fix(auth): Logout all sessions when any logout
rpatterson Dec 26, 2021
1228b58
fix(auth): API login for Zope root acl_users user
rpatterson Dec 26, 2021
68e9cdb
fix(log): Log error conditions in the server logs
rpatterson Jan 4, 2022
75aa812
fix(auth): Install to arbitrary ZODB OFS hierarchy
rpatterson Dec 28, 2021
895eeed
build(checkouts): Fix override base auto-checkout
rpatterson Dec 28, 2021
03dd65a
fix(auth): Missing JWT plugin activation upgrade
rpatterson Jan 4, 2022
c043475
Merge remote-tracking branch 'origin/master' into HEAD
rpatterson Feb 23, 2022
85670ea
fix(auth): Workaround broken zope root JWT config
rpatterson Feb 23, 2022
f0dba92
Merge remote-tracking branch 'origin/master' into fix-unify-auth-logout
rpatterson Mar 9, 2022
e5f5ca1
Merge remote-tracking branch 'origin/master' into fix-unify-auth-logout
rpatterson Apr 13, 2022
fdc03e0
build(versions): Cleanup unused buildout config
rpatterson Feb 3, 2022
ea0af68
fix(auth): Fix broken PAS plugin config at root
rpatterson Dec 28, 2021
2afb7a4
test(auth): Zope root cookie login form auth
rpatterson Dec 28, 2021
054fe37
build(checkouts): PlonePAS PR merged and released
rpatterson Mar 9, 2022
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
1 change: 0 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions base.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
always-checkout = true

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):
"""
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
32 changes: 18 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,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):
Expand Down
Loading