Skip to content

Commit a29801e

Browse files
committed
Add endpoint for JWT refresh tokens
1 parent 63b9bc5 commit a29801e

File tree

5 files changed

+326
-0
lines changed

5 files changed

+326
-0
lines changed

changelog.d/3270.added.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add endpoint for JWT refresh tokens

python/nav/web/api/v1/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,5 @@
7373
name="prefix-usage-detail",
7474
),
7575
re_path(r'^', include(router.urls)),
76+
re_path(r'^refresh/$', views.JWTRefreshViewSet.as_view(), name='jwt-refresh'),
7677
]

python/nav/web/api/v1/views.py

+50
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,21 @@
4545
from oidc_auth.authentication import JSONWebTokenAuthentication
4646

4747
from nav.models import manage, event, cabling, rack, profiles
48+
from nav.models.api import JWTRefreshToken
4849
from nav.models.fields import INFINITY, UNRESOLVED
4950
from nav.web.servicecheckers import load_checker_classes
5051
from nav.util import auth_token, is_valid_cidr
5152

5253
from nav.buildconf import VERSION
5354
from nav.web.api.v1 import serializers, alert_serializers
5455
from nav.web.status2 import STATELESS_THRESHOLD
56+
from nav.web.jwtgen import (
57+
generate_access_token,
58+
generate_refresh_token,
59+
hash_token,
60+
decode_token,
61+
is_active,
62+
)
5563
from nav.macaddress import MacPrefix
5664
from .auth import (
5765
APIPermission,
@@ -1153,3 +1161,45 @@ class ModuleViewSet(NAVAPIMixin, viewsets.ReadOnlyModelViewSet):
11531161
'device__serial',
11541162
)
11551163
serializer_class = serializers.ModuleSerializer
1164+
1165+
1166+
class JWTRefreshViewSet(APIView):
1167+
"""
1168+
Accepts a valid refresh token.
1169+
Returns a new refresh token and an access token.
1170+
"""
1171+
1172+
def post(self, request):
1173+
incoming_token = request.data.get('refresh_token')
1174+
token_hash = hash_token(incoming_token)
1175+
try:
1176+
# If hash exists in the database, then we know it is a real token
1177+
db_token = JWTRefreshToken.objects.get(hash=token_hash)
1178+
except JWTRefreshToken.DoesNotExist:
1179+
return Response("Invalid token", status=status.HTTP_403_FORBIDDEN)
1180+
1181+
claims = decode_token(incoming_token)
1182+
if not is_active(claims['exp'], claims['nbf']):
1183+
return Response("Inactive token", status=status.HTTP_403_FORBIDDEN)
1184+
1185+
if db_token.revoked:
1186+
return Response(
1187+
"This token has been revoked", status=status.HTTP_403_FORBIDDEN
1188+
)
1189+
1190+
access_token = generate_access_token(claims)
1191+
refresh_token = generate_refresh_token(claims)
1192+
1193+
new_claims = decode_token(refresh_token)
1194+
new_hash = hash_token(refresh_token)
1195+
db_token.hash = new_hash
1196+
db_token.expires = datetime.fromtimestamp(new_claims['exp'])
1197+
db_token.activates = datetime.fromtimestamp(new_claims['nbf'])
1198+
db_token.last_used = datetime.now()
1199+
db_token.save()
1200+
1201+
response_data = {
1202+
'access_token': access_token,
1203+
'refresh_token': refresh_token,
1204+
}
1205+
return Response(response_data)

python/nav/web/jwtgen.py

+13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datetime import datetime, timedelta, timezone
22
from typing import Any, Optional
3+
import hashlib
34

45
import jwt
56

@@ -64,3 +65,15 @@ def is_active(exp: float, nbf: float) -> bool:
6465
expires = datetime.fromtimestamp(exp, tz=timezone.utc)
6566
activates = datetime.fromtimestamp(nbf, tz=timezone.utc)
6667
return now >= activates and now < expires
68+
69+
70+
def hash_token(token: str) -> str:
71+
"""Hashes a token with SHA256"""
72+
hash_object = hashlib.sha256(token.encode('utf-8'))
73+
hex_dig = hash_object.hexdigest()
74+
return hex_dig
75+
76+
77+
def decode_token(token: str) -> dict[str, Any]:
78+
"""Decodes a token in JWT format and returns the data of the decoded token"""
79+
return jwt.decode(token, options={'verify_signature': False})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
from typing import Generator
2+
3+
import jwt
4+
import pytest
5+
from datetime import datetime, timedelta, timezone
6+
7+
from unittest.mock import Mock, patch
8+
9+
from django.urls import reverse
10+
from nav.models.api import JWTRefreshToken
11+
from nav.web.jwtgen import generate_refresh_token, hash_token, decode_token
12+
13+
14+
def test_token_not_in_database_should_be_rejected(db, api_client, url):
15+
token = generate_refresh_token()
16+
token_hash = hash_token(token)
17+
18+
assert not JWTRefreshToken.objects.filter(hash=token_hash).exists()
19+
response = api_client.post(
20+
url,
21+
follow=True,
22+
data={
23+
'refresh_token': token,
24+
},
25+
)
26+
assert response.status_code == 403
27+
28+
29+
def test_inactive_token_should_be_rejected(db, api_client, url, inactive_token):
30+
now = datetime.now()
31+
db_token = JWTRefreshToken(
32+
name="testtoken",
33+
hash=hash_token(inactive_token),
34+
expires=now - timedelta(hours=1),
35+
activates=now - timedelta(hours=2),
36+
)
37+
db_token.save()
38+
39+
response = api_client.post(
40+
url,
41+
follow=True,
42+
data={
43+
'refresh_token': inactive_token,
44+
},
45+
)
46+
47+
assert response.status_code == 403
48+
49+
50+
def test_valid_token_should_be_accepted(db, api_client, url, active_token):
51+
data = decode_token(active_token)
52+
db_token = JWTRefreshToken(
53+
name="testtoken",
54+
hash=hash_token(active_token),
55+
expires=datetime.fromtimestamp(data['exp']),
56+
activates=datetime.fromtimestamp(data['nbf']),
57+
)
58+
db_token.save()
59+
response = api_client.post(
60+
url,
61+
follow=True,
62+
data={
63+
'refresh_token': active_token,
64+
},
65+
)
66+
assert response.status_code == 200
67+
68+
69+
def test_valid_token_should_be_replaced_by_new_token_in_db(
70+
db, api_client, url, active_token
71+
):
72+
token_hash = hash_token(active_token)
73+
data = decode_token(active_token)
74+
db_token = JWTRefreshToken(
75+
name="testtoken",
76+
hash=token_hash,
77+
expires=datetime.fromtimestamp(data['exp']),
78+
activates=datetime.fromtimestamp(data['nbf']),
79+
)
80+
db_token.save()
81+
response = api_client.post(
82+
url,
83+
follow=True,
84+
data={
85+
'refresh_token': active_token,
86+
},
87+
)
88+
assert response.status_code == 200
89+
assert not JWTRefreshToken.objects.filter(hash=token_hash).exists()
90+
new_token = response.data.get("refresh_token")
91+
new_hash = hash_token(new_token)
92+
assert JWTRefreshToken.objects.filter(hash=new_hash).exists()
93+
94+
95+
def test_should_include_access_and_refresh_token_in_response(
96+
db, api_client, url, active_token
97+
):
98+
data = decode_token(active_token)
99+
db_token = JWTRefreshToken(
100+
name="testtoken",
101+
hash=hash_token(active_token),
102+
expires=datetime.fromtimestamp(data['exp']),
103+
activates=datetime.fromtimestamp(data['nbf']),
104+
)
105+
db_token.save()
106+
response = api_client.post(
107+
url,
108+
follow=True,
109+
data={
110+
'refresh_token': active_token,
111+
},
112+
)
113+
assert response.status_code == 200
114+
assert "access_token" in response.data
115+
assert "refresh_token" in response.data
116+
117+
118+
def test_revoked_token_should_be_rejected(db, api_client, url, active_token):
119+
data = decode_token(active_token)
120+
db_token = JWTRefreshToken(
121+
name="testtoken",
122+
hash=hash_token(active_token),
123+
expires=datetime.fromtimestamp(data['exp']),
124+
activates=datetime.fromtimestamp(data['nbf']),
125+
revoked=True,
126+
)
127+
db_token.save()
128+
response = api_client.post(
129+
url,
130+
follow=True,
131+
data={
132+
'refresh_token': active_token,
133+
},
134+
)
135+
assert response.status_code == 403
136+
137+
138+
def test_last_used_should_be_updated_after_token_is_used(
139+
db, api_client, url, active_token
140+
):
141+
token_hash = hash_token(active_token)
142+
data = decode_token(active_token)
143+
db_token = JWTRefreshToken(
144+
name="testtoken",
145+
hash=token_hash,
146+
expires=datetime.fromtimestamp(data['exp']),
147+
activates=datetime.fromtimestamp(data['nbf']),
148+
)
149+
db_token.save()
150+
assert db_token.last_used is None
151+
response = api_client.post(
152+
url,
153+
follow=True,
154+
data={
155+
'refresh_token': active_token,
156+
},
157+
)
158+
new_token = response.data.get("refresh_token")
159+
new_hash = hash_token(new_token)
160+
assert JWTRefreshToken.objects.get(hash=new_hash).last_used is not None
161+
162+
163+
@pytest.fixture()
164+
def inactive_token(nav_name, private_key) -> str:
165+
now = datetime.now(timezone.utc)
166+
claims = {
167+
'exp': (now - timedelta(hours=1)).timestamp(),
168+
'nbf': (now - timedelta(hours=2)).timestamp(),
169+
'iat': (now - timedelta(hours=2)).timestamp(),
170+
'aud': nav_name,
171+
'iss': nav_name,
172+
'token_type': 'refresh_token',
173+
}
174+
token = jwt.encode(claims, private_key, algorithm="RS256")
175+
return token
176+
177+
178+
@pytest.fixture()
179+
def active_token(nav_name, private_key) -> str:
180+
now = datetime.now(timezone.utc)
181+
claims = {
182+
'exp': (now + timedelta(hours=1)).timestamp(),
183+
'nbf': now.timestamp(),
184+
'iat': now.timestamp(),
185+
'aud': nav_name,
186+
'iss': nav_name,
187+
'token_type': 'refresh_token',
188+
}
189+
token = jwt.encode(claims, private_key, algorithm="RS256")
190+
return token
191+
192+
193+
@pytest.fixture()
194+
def url():
195+
return reverse('api:1:jwt-refresh')
196+
197+
198+
@pytest.fixture(scope="module", autouse=True)
199+
def jwtconf_mock(private_key, nav_name) -> Generator[str, None, None]:
200+
"""Mocks the get_nave_name and get_nav_private_key functions for
201+
the JWTConf class
202+
"""
203+
with patch("nav.web.jwtgen.JWTConf") as _jwtconf_mock:
204+
instance = _jwtconf_mock.return_value
205+
instance.get_nav_name = Mock(return_value=nav_name)
206+
instance.get_nav_private_key = Mock(return_value=private_key)
207+
yield _jwtconf_mock
208+
209+
210+
@pytest.fixture(scope="module")
211+
def private_key() -> str:
212+
"""Yields a private key in PEM format"""
213+
key = """-----BEGIN PRIVATE KEY-----
214+
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCp+4AEZM4uYZKu
215+
/hrKzySMTFFx3/ncWo6XAFpADQHXLOwRB9Xh1/OwigHiqs/wHRAAmnrlkwCCQA8r
216+
xiHBAMjp5ApbkyggQz/DVijrpSba6Tiy1cyBTZC3cvOK2FpJzsakJLhIXD1HaULO
217+
ClyIJB/YrmHmQc8SL3Uzou5mMpdcBC2pzwmEW1cvQURpnvgrDF8V86GrQkjK6nIP
218+
IEeuW6kbD5lWFAPfLf1ohDWex3yxeSFyXNRApJhbF4HrKFemPkOi7acsky38UomQ
219+
jZgAMHPotJNkQvAHcnXHhg0FcWGdohv5bc/Ctt9GwZOzJxwyJLBBsSewbE310TZi
220+
3oLU1TmvAgMBAAECgf8zrhi95+gdMeKRpwV+TnxOK5CXjqvo0vTcnr7Runf/c9On
221+
WeUtRPr83E4LxuMcSGRqdTfoP0loUGb3EsYwZ+IDOnyWWvytfRoQdExSA2RM1PDo
222+
GRiUN4Dy8CrGNqvnb3agG99Ay3Ura6q5T20n9ykM4qKL3yDrO9fmWyMgRJbAOAYm
223+
xzf7H910mDZghXPpq8nzDky0JLNZcaqbxuPQ3+EI4p2dLNXbNqMPs8Y20JKLeOPs
224+
HikRM0zfhHEJSt5IPFQ54/CzscGHGeCleQINWTgvDLMcE5fJMvbLLZixV+YsBfAq
225+
e2JsSubS+9RI2ktMlSKaemr8yeoIpsXfAiJSHkECgYEA0NKU18xK+9w5IXfgNwI4
226+
peu2tWgwyZSp5R2pdLT7O1dJoLYRoAmcXNePB0VXNARqGxTNypJ9zmMawNmf3YRS
227+
BqG8aKz7qpATlx9OwYlk09fsS6MeVmaur8bHGHP6O+gt7Xg+zhiFPvU9P5LB+C0Z
228+
0d4grEmIxNhJCtJRQOThD8ECgYEA0GKRO9SJdnhw1b6LPLd+o/AX7IEzQDHwdtfi
229+
0h7hKHHGBlUMbIBwwjKmyKm6cSe0PYe96LqrVg+cVf84wbLZPAixhOjyplLznBzF
230+
LqOrfFPfI5lQVhslE1H1CdLlk9eyT96jDgmLAg8EGSMV8aLGj++Gi2l/isujHlWF
231+
BI4YpW8CgYEAsyKyhJzABmbYq5lGQmopZkxapCwJDiP1ypIzd+Z5TmKGytLlM8CK
232+
3iocjEQzlm/jBfBGyWv5eD8UCDOoLEMCiqXcFn+uNJb79zvoN6ZBVGl6TzhTIhNb
233+
73Y5/QQguZtnKrtoRSxLwcJnFE41D0zBRYOjy6gZJ6PSpPHeuiid2QECgYACuZc+
234+
mgvmIbMQCHrXo2qjiCs364SZDU4gr7gGmWLGXZ6CTLBp5tASqgjmTNnkSumfeFvy
235+
ZCaDbJbVxQ2f8s/GajKwEz/BDwqievnVH0zJxmr/kyyqw5Ybh5HVvA1GfqaVRssJ
236+
DvTjZQDft0a9Lyy7ix1OS2XgkcMjTWj840LNPwKBgDPXMBgL5h41jd7jCsXzPhyr
237+
V96RzQkPcKsoVvrCoNi8eoEYgRd9jwfiU12rlXv+fgVXrrfMoJBoYT6YtrxEJVdM
238+
RAjRpnE8PMqCUA8Rd7RFK9Vp5Uo8RxTNvk9yPvDv1+lHHV7lEltIk5PXuKPHIrc1
239+
nNUyhzvJs2Qba2L/huNC
240+
-----END PRIVATE KEY-----"""
241+
return key
242+
243+
244+
@pytest.fixture()
245+
def public_key() -> str:
246+
"""Yields a public key in PEM format"""
247+
key = """-----BEGIN PUBLIC KEY-----
248+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqfuABGTOLmGSrv4ays8k
249+
jExRcd/53FqOlwBaQA0B1yzsEQfV4dfzsIoB4qrP8B0QAJp65ZMAgkAPK8YhwQDI
250+
6eQKW5MoIEM/w1Yo66Um2uk4stXMgU2Qt3LzithaSc7GpCS4SFw9R2lCzgpciCQf
251+
2K5h5kHPEi91M6LuZjKXXAQtqc8JhFtXL0FEaZ74KwxfFfOhq0JIyupyDyBHrlup
252+
Gw+ZVhQD3y39aIQ1nsd8sXkhclzUQKSYWxeB6yhXpj5Dou2nLJMt/FKJkI2YADBz
253+
6LSTZELwB3J1x4YNBXFhnaIb+W3PwrbfRsGTsyccMiSwQbEnsGxN9dE2Yt6C1NU5
254+
rwIDAQAB
255+
-----END PUBLIC KEY-----"""
256+
return key
257+
258+
259+
@pytest.fixture(scope="module")
260+
def nav_name() -> str:
261+
return "nav"

0 commit comments

Comments
 (0)