Skip to content

Commit ff76695

Browse files
authored
update pyjwt and move pyramid-jwtauth -> pyramid_jwt
the jwt secret will now be read from docker-compose.yml (with fallback for local dev) to avoid having it in the repo code
2 parents 85d3673 + a1a0f91 commit ff76695

9 files changed

Lines changed: 188 additions & 17 deletions

File tree

c2corg_api/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from c2corg_api.models import DBSession, Base
1616
from c2corg_api.search import configure_es_from_config, get_queue_config
1717

18+
from pyramid.authorization import ACLAuthorizationPolicy
1819
from pyramid.settings import asbool
1920

2021
log = logging.getLogger(__name__)
@@ -51,7 +52,19 @@ def main(global_config, **settings):
5152
bypass_auth = asbool(settings["noauthorization"])
5253

5354
if not bypass_auth:
54-
config.include("pyramid_jwtauth")
55+
from c2corg_api.security.roles import groupfinder
56+
from c2corg_api.security.jwt_policy import \
57+
IntegerSubJWTAuthenticationPolicy
58+
policy = IntegerSubJWTAuthenticationPolicy(
59+
private_key=settings['jwt.private_key'],
60+
algorithm='HS256',
61+
auth_type='JWT',
62+
callback=groupfinder,
63+
)
64+
config.set_authentication_policy(policy)
65+
config.set_authorization_policy(ACLAuthorizationPolicy())
66+
config.add_request_method(
67+
policy.get_claims, 'jwt_claims', reify=True)
5568
# Intercept request handling to validate token against the database
5669
config.add_tween(
5770
"c2corg_api.tweens.jwt_database_validation."

c2corg_api/security/jwt_policy.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import re
2+
3+
from pyramid_jwt import JWTAuthenticationPolicy
4+
5+
# Matches token="<value>" in the Authorization header params
6+
_TOKEN_RE = re.compile(r'token="([^"]+)"')
7+
8+
9+
class IntegerSubJWTAuthenticationPolicy(JWTAuthenticationPolicy):
10+
"""Custom JWT authentication policy that handles the legacy
11+
``JWT token="<token>"`` header format used by the frontend,
12+
and converts the ``sub`` claim back to an integer so that the
13+
rest of the application can keep comparing
14+
``request.authenticated_userid`` with integer user ids.
15+
16+
PyJWT >= 2.9 enforces that ``sub`` must be a string (per the JWT
17+
spec), but this application has historically stored integer user ids
18+
in the ``sub`` claim. We now encode ``sub`` as a string (see
19+
:func:`c2corg_api.security.roles.create_claims`) and convert it
20+
back here.
21+
"""
22+
23+
def get_claims(self, request):
24+
"""Extract claims from the request, supporting the legacy
25+
``JWT token="<value>"`` authorization header format.
26+
"""
27+
try:
28+
if request.authorization is None:
29+
return {}
30+
except ValueError:
31+
return {}
32+
(auth_type, params) = request.authorization
33+
if auth_type != self.auth_type:
34+
return {}
35+
# Support legacy format: JWT token="<actual_token>"
36+
match = _TOKEN_RE.search(params)
37+
token = match.group(1) if match else params
38+
if not token:
39+
return {}
40+
return self.jwt_decode(request, token)
41+
42+
def unauthenticated_userid(self, request):
43+
sub = request.jwt_claims.get('sub')
44+
if sub is not None:
45+
try:
46+
return int(sub)
47+
except (TypeError, ValueError):
48+
return sub
49+
return None

c2corg_api/security/roles.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from pyramid.security import Authenticated
33
from pyramid.interfaces import IAuthenticationPolicy
44

5+
import jwt as pyjwt
56
from c2corg_api.models import DBSession
67
from c2corg_api.models.user import User
78
from c2corg_api.models.token import Token
@@ -19,9 +20,12 @@
1920

2021

2122
def extract_token(request):
22-
# Extract XXX from 'JWT token="XXX"'
23-
splitted = request.authorization[1].split('"')
24-
return splitted[1] if len(splitted) >= 2 else None
23+
# Extract token from 'JWT token="XXX"' or 'JWT XXX'
24+
params = request.authorization[1]
25+
if 'token="' in params:
26+
splitted = params.split('"')
27+
return splitted[1] if len(splitted) >= 2 else None
28+
return params
2529

2630

2731
def groupfinder(userid, request):
@@ -70,7 +74,7 @@ def remove_token(token):
7074

7175
def create_claims(user, exp):
7276
return {
73-
'sub': user.id,
77+
'sub': str(user.id),
7478
'username': user.username,
7579
'exp': int((exp - datetime.datetime(1970, 1, 1)).total_seconds())
7680
}
@@ -96,7 +100,8 @@ def log_validated_user_i_know_what_i_do(user, request):
96100
now = datetime.datetime.utcnow()
97101
exp = now + datetime.timedelta(days=CONST_EXPIRE_AFTER_DAYS)
98102
claims = create_claims(user, exp)
99-
token = policy.encode_jwt(request, claims=claims).decode('utf-8')
103+
token = pyjwt.encode(
104+
claims, key=policy.private_key, algorithm=policy.algorithm)
100105
return add_or_retrieve_token(token, exp, user.id)
101106

102107

@@ -108,7 +113,8 @@ def renew_token(user, request):
108113
now = datetime.datetime.utcnow()
109114
exp = now + datetime.timedelta(days=CONST_EXPIRE_AFTER_DAYS)
110115
claims = create_claims(user, exp)
111-
token_value = policy.encode_jwt(request, claims=claims).decode('utf-8')
116+
token_value = pyjwt.encode(
117+
claims, key=policy.private_key, algorithm=policy.algorithm)
112118
return add_or_retrieve_token(token_value, exp, user.id)
113119

114120
return None

c2corg_api/tests/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,15 +164,14 @@ def _add_global_test_data(session):
164164

165165
session.flush()
166166

167-
key = settings['jwtauth.master_secret']
167+
key = settings['jwt.private_key']
168168
algorithm = 'HS256'
169169
now = datetime.datetime.utcnow()
170170
exp = now + datetime.timedelta(weeks=10)
171171

172172
for user in users:
173173
claims = create_claims(user, exp)
174-
token = jwt.encode(claims, key=key, algorithm=algorithm). \
175-
decode('utf-8')
174+
token = jwt.encode(claims, key=key, algorithm=algorithm)
176175
add_or_retrieve_token(token, exp, user.id)
177176
global_userids[user.username] = user.id
178177
global_tokens[user.username] = token
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import jwt
2+
import unittest
3+
4+
from webob import Request
5+
6+
from c2corg_api.security.jwt_policy import IntegerSubJWTAuthenticationPolicy
7+
from c2corg_api.security.roles import extract_token
8+
9+
10+
class TestIntegerSubJWTAuthenticationPolicy(unittest.TestCase):
11+
12+
def setUp(self):
13+
self.secret = 'a_long_enough_secret_key_for_hs256_testing'
14+
self.policy = IntegerSubJWTAuthenticationPolicy(
15+
private_key=self.secret,
16+
algorithm='HS256',
17+
auth_type='JWT',
18+
)
19+
self.claims = {
20+
'sub': '12345',
21+
'username': 'testuser',
22+
}
23+
self.token = jwt.encode(
24+
self.claims, self.secret, algorithm='HS256')
25+
26+
def _make_request(self, authorization_header=None):
27+
environ = {'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}
28+
if authorization_header:
29+
environ['HTTP_AUTHORIZATION'] = authorization_header
30+
request = Request(environ)
31+
# Attach jwt_claims for unauthenticated_userid
32+
request.jwt_claims = self.policy.get_claims(request)
33+
return request
34+
35+
def test_get_claims_legacy_format(self):
36+
"""JWT token="<token>" format used by the frontend."""
37+
request = self._make_request(
38+
'JWT token="' + self.token + '"')
39+
claims = self.policy.get_claims(request)
40+
self.assertEqual(claims['sub'], '12345')
41+
self.assertEqual(claims['username'], 'testuser')
42+
43+
def test_get_claims_standard_format(self):
44+
"""JWT <token> format (standard Bearer-style)."""
45+
request = self._make_request('JWT ' + self.token)
46+
claims = self.policy.get_claims(request)
47+
self.assertEqual(claims['sub'], '12345')
48+
self.assertEqual(claims['username'], 'testuser')
49+
50+
def test_get_claims_no_authorization(self):
51+
request = self._make_request()
52+
claims = self.policy.get_claims(request)
53+
self.assertEqual(claims, {})
54+
55+
def test_get_claims_wrong_auth_type(self):
56+
request = self._make_request('Bearer ' + self.token)
57+
claims = self.policy.get_claims(request)
58+
self.assertEqual(claims, {})
59+
60+
def test_get_claims_invalid_token(self):
61+
request = self._make_request('JWT not-a-valid-token')
62+
claims = self.policy.get_claims(request)
63+
self.assertEqual(claims, {})
64+
65+
def test_unauthenticated_userid_returns_int(self):
66+
"""sub claim is a string but unauthenticated_userid returns int."""
67+
request = self._make_request(
68+
'JWT token="' + self.token + '"')
69+
userid = self.policy.unauthenticated_userid(request)
70+
self.assertIsInstance(userid, int)
71+
self.assertEqual(userid, 12345)
72+
73+
def test_unauthenticated_userid_standard_format(self):
74+
request = self._make_request('JWT ' + self.token)
75+
userid = self.policy.unauthenticated_userid(request)
76+
self.assertIsInstance(userid, int)
77+
self.assertEqual(userid, 12345)
78+
79+
def test_unauthenticated_userid_no_auth(self):
80+
request = self._make_request()
81+
request.jwt_claims = {}
82+
userid = self.policy.unauthenticated_userid(request)
83+
self.assertIsNone(userid)
84+
85+
86+
class TestExtractToken(unittest.TestCase):
87+
88+
def _make_request(self, authorization_header):
89+
environ = {
90+
'REQUEST_METHOD': 'GET',
91+
'PATH_INFO': '/',
92+
'HTTP_AUTHORIZATION': authorization_header,
93+
}
94+
return Request(environ)
95+
96+
def test_extract_token_legacy_format(self):
97+
request = self._make_request('JWT token="my.jwt.token"')
98+
self.assertEqual(extract_token(request), 'my.jwt.token')
99+
100+
def test_extract_token_standard_format(self):
101+
request = self._make_request('JWT my.jwt.token')
102+
self.assertEqual(extract_token(request), 'my.jwt.token')

common.ini.in

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,8 @@ feed.admin_user_account = {feed_admin_user_account}
6060

6161
logging.level = {logging_level}
6262

63-
jwtauth.find_groups = c2corg_api.security.roles:groupfinder
64-
65-
# FIXME: do not save the secret key on github
66-
jwtauth.master_secret = The master key
63+
# JWT signing key – MUST be at least 32 bytes for HS256
64+
jwt.private_key = {jwt_secret_key}
6765

6866
# FIXME: do not save the forum key on github
6967
discourse.url = {discourse_url}

config/env.default

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ discourse_sso_secret="d836444a9e4084d5b224a60c208dce14"
8585
discourse_api_key="SET_API_KEY"
8686
image_backend_secret_key="test"
8787

88+
# JWT signing key – MUST be at least 32 bytes for HS256.
89+
# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
90+
jwt_secret_key="CHANGE_ME_TO_A_REAL_SECRET______"
91+
8892
skip_captcha_validation=false
8993
recaptcha_secret_key="6LfWUwoUAAAAABDo4_HRfru4HfLxOKmcqBRBObGj"
9094

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ kombu==5.6.2
1313
Markdown==3.7
1414
phpserialize==1.3.0 # phpserialize is only required during the migration
1515
psycopg2-binary==2.9.11
16-
pyjwt==1.7.1
16+
pyjwt==2.12.1
1717
pymdown-extensions==10.21.2
1818
pyproj==3.6.1
19-
pyramid-jwtauth==0.1.3
19+
pyramid_jwt==1.6.1
2020
pyramid==1.10.8
2121
pyramid_debugtoolbar==4.12.1
2222
pyramid_mailer==0.15.1

test.ini.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ pyramid.includes =
66
sqlalchemy.url = postgresql://{tests_db_user}:{tests_db_password}@{tests_db_host}:{tests_db_port}/{tests_db_name}
77
noauthorization = False
88
debug_authorization = True
9-
jwtauth.master_secret = The master key
9+
jwt.private_key = {jwt_secret_key}
1010
elasticsearch.host = {tests_elasticsearch_host}
1111
elasticsearch.port = {tests_elasticsearch_port}
1212
elasticsearch.index = {tests_elasticsearch_index}

0 commit comments

Comments
 (0)