Skip to content

Commit 350595f

Browse files
committed
Add Bearer token (JWT) authentication via PAS plugin
1 parent 0139af6 commit 350595f

11 files changed

Lines changed: 552 additions & 18 deletions

File tree

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
install_requires=[
3939
"setuptools",
4040
"senaite.core",
41+
# PyJWT >=2.0.0 does not support Python 2.x anymore
42+
"pyjwt<2.0.0",
4143
],
4244
extras_require={
4345
"test": [

src/senaite/jsonapi/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,19 @@
1717
#
1818
# Copyright 2017-2025 by it's authors.
1919
# Some rights reserved, see README and LICENSE.
20+
21+
from senaite.jsonapi import PRODUCT_NAME
22+
23+
# Default JWT lifetime (60 minutes, in seconds)
24+
JWT_TOKENS_TIMEOUT = 60 * 60
25+
26+
# Name of the HttpOnly cookie used to carry the JWT token
27+
JWT_COOKIE_ID = "token"
28+
29+
# Fallback header used to carry the JWT token when neither the
30+
# Authorization Bearer header nor the cookie is set
31+
JWT_HEADER_ID = "X-JWT-Auth-Token"
32+
33+
# Annotation key used to store the per-user JWT signing secrets on the
34+
# portal (IAnnotations(portal)[KEY_STORAGE] is an OOBTree keyed by userid)
35+
KEY_STORAGE = "%s.pas.keystorage" % PRODUCT_NAME

src/senaite/jsonapi/configure.zcml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
11
<configure
22
xmlns="http://namespaces.zope.org/zope"
33
xmlns:browser="http://namespaces.zope.org/browser"
4+
xmlns:genericsetup="http://namespaces.zope.org/genericsetup"
45
xmlns:zcml="http://namespaces.zope.org/zcml"
56
i18n_domain="senaite.jsonapi">
67

78
<!-- Package Includes -->
89
<include package=".v1"/>
910

11+
<!-- GenericSetup profile -->
12+
<genericsetup:registerProfile
13+
name="default"
14+
title="SENAITE JSONAPI"
15+
description="Installs SENAITE JSONAPI's JWT PAS plugin"
16+
directory="profiles/default"
17+
provides="Products.GenericSetup.interfaces.EXTENSION"
18+
/>
19+
20+
<!-- Setup import step -->
21+
<genericsetup:importStep
22+
name="senaite.jsonapi.setuphandler"
23+
title="senaite.jsonapi setup handler"
24+
description="Installs SENAITE JSONAPI's JWT PAS Pugin"
25+
handler="senaite.jsonapi.setuphandlers.setup_handler">
26+
<depends name="plone-various"/>
27+
</genericsetup:importStep>
28+
1029
<!-- CATALOG
1130
Unified interface to one or more Portal Catalog tools.
1231
-->
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of SENAITE.JSONAPI.
4+
#
5+
# SENAITE.JSONAPI is free software: you can redistribute it and/or modify it
6+
# under the terms of the GNU General Public License as published by the Free
7+
# Software Foundation, version 2.
8+
#
9+
# This program is distributed in the hope that it will be useful, but WITHOUT
10+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
# details.
13+
#
14+
# You should have received a copy of the GNU General Public License along with
15+
# this program; if not, write to the Free Software Foundation, Inc., 51
16+
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17+
#
18+
# Copyright 2017-2026 by it's authors.
19+
# Some rights reserved, see README and LICENSE.

src/senaite/jsonapi/pas/plugin.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of SENAITE.JSONAPI.
4+
#
5+
# SENAITE.JSONAPI is free software: you can redistribute it and/or modify it
6+
# under the terms of the GNU General Public License as published by the Free
7+
# Software Foundation, version 2.
8+
#
9+
# This program is distributed in the hope that it will be useful, but WITHOUT
10+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
# details.
13+
#
14+
# You should have received a copy of the GNU General Public License along with
15+
# this program; if not, write to the Free Software Foundation, Inc., 51
16+
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17+
#
18+
# Copyright 2017-2026 by it's authors.
19+
# Some rights reserved, see README and LICENSE.
20+
21+
from calendar import timegm
22+
from datetime import timedelta
23+
24+
import jwt
25+
from AccessControl import ClassSecurityInfo
26+
from bika.lims import api
27+
from BTrees.OOBTree import OOBTree
28+
from plone.keyring.keyring import GenerateSecret
29+
from Products.PluggableAuthService.interfaces import plugins
30+
from Products.PluggableAuthService.plugins.BasePlugin import BasePlugin
31+
from senaite.core.api import dtime
32+
from senaite.jsonapi import PRODUCT_NAME
33+
from senaite.jsonapi.config import JWT_COOKIE_ID
34+
from senaite.jsonapi.config import JWT_HEADER_ID
35+
from senaite.jsonapi.config import JWT_TOKENS_TIMEOUT
36+
from senaite.jsonapi.config import KEY_STORAGE
37+
from zope.annotation.interfaces import IAnnotations
38+
from zope.interface import implementer
39+
40+
# The id of the plugin
41+
ID = "%s.pas.plugin.JWTAuthenticationPlugin" % PRODUCT_NAME
42+
43+
# Meta type name of the plugin
44+
META_TYPE = "%s: JWT Authentication Plugin" % PRODUCT_NAME
45+
46+
47+
@implementer(plugins.IAuthenticationPlugin, plugins.IExtractionPlugin)
48+
class JWTAuthenticationPlugin(BasePlugin):
49+
"""PAS plugin for authentication with JSON Web tokens (JWT).
50+
51+
JWT authentication is a stateless, token-based method where the SENAITE
52+
server issues a signed token to a client after a user logs in. The client
53+
then stores this token and includes it in subsequent requests to access
54+
protected resources, allowing the server to verify the user's identity
55+
without needing to store session data on the server-side.
56+
"""
57+
58+
id = ID
59+
title = META_TYPE
60+
meta_type = META_TYPE
61+
security = ClassSecurityInfo()
62+
63+
@security.private
64+
def extractCredentials(self, request): # noqa camelCase
65+
"""IExtractionPlugin implementation. Extracts the JSON Web Token (JWT)
66+
from the request, if any. Returns a dict made of {"token": <token>}
67+
:param request: the HTTPRequest object to extract credentials from
68+
:return: a dict with credentials
69+
"""
70+
token = get_jwt_token(request)
71+
if not token:
72+
return {}
73+
return {"token": token}
74+
75+
@security.private
76+
def authenticateCredentials(self, credentials): # noqa camelCase
77+
"""IAuthenticationPlugin implementation, maps credentials to a User ID.
78+
If credentials cannot be authenticated, return None
79+
:param credentials: dict with credentials
80+
:return: a tuple with the user id and login name or None
81+
"""
82+
# Ignore credentials that are not from our extractor
83+
extractor = credentials.get("extractor")
84+
if extractor != self.getId():
85+
return None
86+
87+
token = credentials.get("token")
88+
if not token:
89+
return None
90+
91+
# Peek the payload (no signature check) to learn the userid the
92+
# token claims to belong to
93+
userid = peek_userid(token)
94+
if not userid:
95+
return None
96+
97+
# Verify signature and expiration with that user's secret
98+
payload = decode_token(token, userid)
99+
if not payload:
100+
return None
101+
102+
# Defensive: ensure the verified payload still names the same user
103+
if api.to_utf8(payload.get("userid"), default=None) != userid:
104+
return None
105+
106+
# Make sure the user still exists
107+
user = api.get_user(userid)
108+
if not user:
109+
return None
110+
111+
return userid, userid
112+
113+
114+
def get_jwt_token(request):
115+
"""Extracts the JWT token from the request, in this order of preference:
116+
Authorization: Bearer header, "token" cookie, X-JWT-Auth-Token header.
117+
"""
118+
# Read from Authorization header
119+
auth = request._auth # noqa
120+
if auth and auth[:7].lower() == "bearer ":
121+
return auth.split()[-1]
122+
123+
# Read from cookie
124+
token = request.cookies.get(JWT_COOKIE_ID)
125+
if token:
126+
return token
127+
128+
# Read from header
129+
return request.getHeader(JWT_HEADER_ID)
130+
131+
132+
def get_keystorage():
133+
"""Returns the OOBTree where per-user JWT signing secrets are stored,
134+
creating it lazily on the portal's annotations on first access.
135+
"""
136+
annotation = IAnnotations(api.get_portal())
137+
storage = annotation.get(KEY_STORAGE)
138+
if storage is None:
139+
annotation[KEY_STORAGE] = OOBTree()
140+
return annotation[KEY_STORAGE]
141+
142+
143+
def signing_secret(userid):
144+
"""Returns the signing secret for the given userid, generating one
145+
on first call.
146+
"""
147+
userid = api.to_utf8(userid, default=None)
148+
if not userid:
149+
raise ValueError("Invalid userid: %r" % userid)
150+
151+
storage = get_keystorage()
152+
data = storage.get(userid)
153+
if not data:
154+
storage[userid] = {
155+
"key": GenerateSecret(),
156+
"created": timestamp(),
157+
}
158+
return storage[userid]["key"]
159+
160+
161+
def rotate_secret(userid):
162+
"""Discards the signing secret for the given userid, so a new one is
163+
generated on the next call to ``signing_secret``. All previously
164+
issued tokens for the user are invalidated.
165+
"""
166+
userid = api.to_utf8(userid, default=None)
167+
if not userid:
168+
return
169+
storage = get_keystorage()
170+
if userid in storage:
171+
del storage[userid]
172+
173+
174+
def timestamp(seconds=0, minutes=0, hours=0, days=0):
175+
"""Returns a Unix timestamp from GMT plus the given offset.
176+
"""
177+
delta = timedelta(seconds=seconds, minutes=minutes, hours=hours, days=days)
178+
utc = dtime.datetime.utcnow() + delta
179+
return timegm(utc.utctimetuple())
180+
181+
182+
def peek_userid(token):
183+
"""Returns the ``userid`` claim of the given token without verifying
184+
the signature, or None if the token cannot be decoded.
185+
"""
186+
token = api.to_utf8(token, default=None)
187+
if not token:
188+
return None
189+
try:
190+
payload = jwt.decode(token, verify=False)
191+
except (ValueError, TypeError, jwt.InvalidTokenError):
192+
return None
193+
return api.to_utf8(payload.get("userid"), default=None)
194+
195+
196+
def decode_token(token, userid):
197+
"""Decodes the given JWT, verifies the signature with ``userid``'s
198+
secret and checks the expiration. Returns the payload or None.
199+
"""
200+
token = api.to_utf8(token, default=None)
201+
if not token:
202+
return None
203+
try:
204+
secret = signing_secret(userid)
205+
return jwt.decode(token, secret, algorithms=["HS256"])
206+
except (ValueError, TypeError, jwt.InvalidTokenError):
207+
return None
208+
209+
210+
def create_token(userid, timeout=JWT_TOKENS_TIMEOUT, **kwargs):
211+
"""Creates a JSON Web Token (JWT) for the given userid.
212+
"""
213+
payload = {
214+
"userid": userid,
215+
"exp": timestamp(seconds=timeout),
216+
}
217+
payload.update(kwargs)
218+
219+
secret = signing_secret(userid)
220+
token = jwt.encode(payload, secret, algorithm="HS256")
221+
return api.safe_unicode(token)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0"?>
2+
<metadata>
3+
<version>1</version>
4+
</metadata>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Marker file checked by senaite.jsonapi.setuphandlers.setup_handler
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of SENAITE.JSONAPI.
4+
#
5+
# SENAITE.JSONAPI is free software: you can redistribute it and/or modify it
6+
# under the terms of the GNU General Public License as published by the Free
7+
# Software Foundation, version 2.
8+
#
9+
# This program is distributed in the hope that it will be useful, but WITHOUT
10+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
# details.
13+
#
14+
# You should have received a copy of the GNU General Public License along with
15+
# this program; if not, write to the Free Software Foundation, Inc., 51
16+
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17+
#
18+
# Copyright 2017-2025 by it's authors.
19+
# Some rights reserved, see README and LICENSE.
20+
21+
from Products.PlonePAS.setuphandlers import activatePluginInterfaces
22+
from senaite.jsonapi import logger
23+
from senaite.jsonapi import PRODUCT_NAME
24+
from senaite.jsonapi.pas.plugin import JWTAuthenticationPlugin
25+
26+
27+
def setup_handler(context):
28+
"""Generic setup handler for senaite.jsonapi
29+
"""
30+
install_file = "%s.txt" % PRODUCT_NAME
31+
if context.readDataFile(install_file) is None:
32+
return
33+
34+
logger.info("%s setup handler [BEGIN]" % PRODUCT_NAME.upper())
35+
portal = context.getSite()
36+
37+
setup_pas_plugin(portal)
38+
39+
logger.info("%s setup handler [DONE]" % PRODUCT_NAME.upper())
40+
41+
42+
def setup_pas_plugin(portal):
43+
"""Register the JWT PAS plugin in the site's acl_users so JWT-based
44+
authentication becomes active.
45+
"""
46+
plugin = JWTAuthenticationPlugin()
47+
plugin_id = plugin.getId()
48+
49+
logger.info("Setup %s plugin ..." % plugin_id)
50+
51+
pas = portal.acl_users
52+
if plugin_id not in pas.objectIds():
53+
# Add the plugin
54+
pas._setObject(plugin_id, plugin) # noqa
55+
56+
# Activate all supported interfaces for this plugin
57+
activatePluginInterfaces(pas, plugin_id)
58+
59+
# Make our plugin the first one for these interfaces, so the JWT
60+
# token takes precedence over other extractors/authenticators
61+
ifaces = ["IExtractionPlugin", "IAuthenticationPlugin"]
62+
plugin_registry = pas.plugins
63+
for iface_name in ifaces:
64+
iface = plugin_registry._getInterfaceFromName(iface_name) # noqa
65+
plugin_registry.movePluginsTop(iface, [plugin_id])
66+
67+
logger.info("Setup %s plugin [DONE]" % plugin_id)

src/senaite/jsonapi/tests/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ def setUpPloneSite(self, portal):
7878
# Apply Setup Profile (portal_quickinstaller)
7979
applyProfile(portal, "senaite.core:default")
8080

81+
# Install the JWT PAS plugin
82+
applyProfile(portal, "senaite.jsonapi:default")
83+
8184
# Add test users
8285
self.add_test_users(portal)
8386

0 commit comments

Comments
 (0)