Skip to content

Commit

Permalink
fix(auth): Set both classic and API login cookies
Browse files Browse the repository at this point in the history
Use the appropriate PAS interface for setting the cookies used by both classic Plone and
Volto's React component.  Move the API token generation out of the login API endpoint
view and into the PAS plugin where it belongs.  Then it will be per the PAS plugin
configuration to set the cookie.  The plugin then also makes the token available to the
login API endpoint view via the request so it can then also return the token in the JSON
response.
  • Loading branch information
rpatterson committed Feb 14, 2022
1 parent 2668766 commit 9422195
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 35 deletions.
47 changes: 45 additions & 2 deletions src/plone/restapi/pas/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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.plugins.BasePlugin import BasePlugin
from zope.component import getUtility
from zope.interface import implementer
Expand All @@ -39,7 +40,12 @@ def addJWTAuthenticationPlugin(self, id_, title=None, REQUEST=None):
)


@implementer(IAuthenticationPlugin, IChallengePlugin, IExtractionPlugin)
@implementer(
IAuthenticationPlugin,
IChallengePlugin,
IExtractionPlugin,
ICredentialsUpdatePlugin,
)
class JWTAuthenticationPlugin(BasePlugin):
"""Plone PAS plugin for authentication with JSON web tokens (JWT)."""

Expand All @@ -51,6 +57,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 +66,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 +104,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 +144,32 @@ 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")
token = self.create_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.
response.setCookie(self.cookie_name, token, path="/")

@security.protected(ManagePortal)
@postonly
def manage_updateConfig(self, REQUEST):
Expand Down
4 changes: 1 addition & 3 deletions src/plone/restapi/services/auth/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,7 @@ def reply(self):
)
login_view._post_login()

payload = {}
payload["fullname"] = user.getProperty("fullname")
return {"token": plugin.create_token(user.getId(), data=payload)}
return {"token": self.request[plugin.cookie_name]}

def _find_userfolder(self, userid):
"""Try to find a user folder that contains a user with the given
Expand Down
6 changes: 5 additions & 1 deletion src/plone/restapi/setuphandlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ def install_pas_plugin(context):
uf._setObject(plugin.getId(), plugin)
plugin = uf["jwt_auth"]
plugin.manage_activateInterfaces(
["IAuthenticationPlugin", "IExtractionPlugin"]
[
"IAuthenticationPlugin",
"IExtractionPlugin",
"ICredentialsUpdatePlugin",
],
)
if uf_parent is uf_parent.getPhysicalRoot():
break
Expand Down
99 changes: 70 additions & 29 deletions src/plone/restapi/tests/test_functional_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ def test_login_with_valid_credentials_returns_token(self):
"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)
Expand All @@ -88,6 +88,72 @@ def test_api_login_grants_zmi(self):
"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(
'<input id="__ac_password" name="__ac_password"' in challenge_resp.text,
"Plone login challenge response content missing password field",
)
login_resp = session.post(
self.portal_url + "/login",
data={
"__ac_name": SITE_OWNER_NAME,
"__ac_password": TEST_USER_PASSWORD,
"came_from": "/".join(self.private_document.getPhysicalPath()),
"buttons.login": "Log in",
},
)
self.assertEqual(
login_resp.status_code,
200,
"Wrong Plone login response status code",
)
self.assertEqual(
login_resp.url,
self.private_document_url,
"Plone login response didn't redirect to original URL",
)

self.assertTrue(
login_resp.history,
"Plone classic login form response missing redirect history",
)
self.assertIn(
"__ac",
session.cookies,
"Plone session cookie missing from Plone classic login form response",
)
self.assertIn(
"auth_token",
session.cookies,
"API token cookie missing from Plone classic login form response",
)

def test_api_login_grants_zmi(self):
"""
Logging in via the API also grants access to the Zope root ZMI.
"""
session = requests.Session()
self.addCleanup(session.close)
session.post(
self.portal_url + "/@login",
headers={"Accept": "application/json"},
json={"login": SITE_OWNER_NAME, "password": TEST_USER_PASSWORD},
)

zmi_resp = session.get(
self.layer["app"].absolute_url() + "/manage_workspace",
)
Expand All @@ -106,7 +172,7 @@ def test_api_login_grants_zmi(self):
"Wrong ZMI view response content",
)

def test_zmi_login_grants_api(self):
def test_root_zmi_login_grants_api(self):
"""
Logging in via the Zope root ZMI also grants access to the API.
"""
Expand Down Expand Up @@ -162,17 +228,7 @@ def test_cookie_login_grants_api(self):
"""
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(
'<input id="__ac_password" name="__ac_password"' in challenge_resp.text,
"Plone login challenge response content missing password field",
)
login_resp = session.post(
session.post(
self.portal_url + "/login",
data={
"__ac_name": SITE_OWNER_NAME,
Expand All @@ -181,21 +237,6 @@ def test_cookie_login_grants_api(self):
"buttons.login": "Log in",
},
)
self.assertIn(
"__ac",
session.cookies,
"Plone session cookie missing form login POST response",
)
self.assertEqual(
login_resp.status_code,
200,
"Wrong Plone login response status code",
)
self.assertEqual(
login_resp.url,
self.private_document_url,
"Plone login response didn't redirect to original URL",
)

api_resp = session.get(
self.private_document_url,
Expand Down

0 comments on commit 9422195

Please sign in to comment.