Skip to content

Commit 206b8f3

Browse files
committed
Add endpoint for JWT refresh tokens
1 parent f118619 commit 206b8f3

File tree

5 files changed

+243
-1
lines changed

5 files changed

+243
-1
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

+43-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
# pylint: disable=R0903, R0901, R0904
1717
"""Views for the NAV API"""
1818

19-
from datetime import datetime, timedelta
19+
from datetime import datetime, timedelta, timezone
2020
import logging
2121

2222
from IPy import IP
@@ -45,13 +45,20 @@
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+
)
5562
from nav.macaddress import MacPrefix
5663
from .auth import (
5764
APIPermission,
@@ -1153,3 +1160,38 @@ class ModuleViewSet(NAVAPIMixin, viewsets.ReadOnlyModelViewSet):
11531160
'device__serial',
11541161
)
11551162
serializer_class = serializers.ModuleSerializer
1163+
1164+
1165+
class JWTRefreshViewSet(APIView):
1166+
"""
1167+
Accepts a valid refresh token.
1168+
Returns a new refresh token and an access token.
1169+
"""
1170+
1171+
def post(self, request):
1172+
incoming_token = request.data.get('refresh_token')
1173+
token_hash = hash_token(incoming_token)
1174+
try:
1175+
# If hash exists in the database, then we know it is a real token
1176+
db_token = JWTRefreshToken.objects.get(hash=token_hash)
1177+
except JWTRefreshToken.DoesNotExist:
1178+
return Response("Invalid token", status=status.HTTP_403_FORBIDDEN)
1179+
if not db_token.is_active():
1180+
return Response("Inactive token", status=status.HTTP_403_FORBIDDEN)
1181+
1182+
claims = decode_token(incoming_token)
1183+
access_token = generate_access_token(claims)
1184+
refresh_token = generate_refresh_token(claims)
1185+
1186+
new_claims = decode_token(refresh_token)
1187+
new_hash = hash_token(refresh_token)
1188+
db_token.hash = new_hash
1189+
db_token.expires = datetime.fromtimestamp(new_claims['exp'], tz=timezone.utc)
1190+
db_token.activates = datetime.fromtimestamp(new_claims['nbf'], tz=timezone.utc)
1191+
db_token.save()
1192+
1193+
response_data = {
1194+
'access_token': access_token,
1195+
'refresh_token': refresh_token,
1196+
}
1197+
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

@@ -49,3 +50,15 @@ def _generate_token(
4950
new_token, JWTConf().get_nav_private_key(), algorithm="RS256"
5051
)
5152
return encoded_token
53+
54+
55+
def hash_token(token: str) -> str:
56+
"""Hashes a token with SHA256"""
57+
hash_object = hashlib.sha256(token.encode('utf-8'))
58+
hex_dig = hash_object.hexdigest()
59+
return hex_dig
60+
61+
62+
def decode_token(token: str) -> dict[str, Any]:
63+
"""Decodes a token in JWT format and returns the data of the decoded token"""
64+
return jwt.decode(token, options={'verify_signature': False})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
from typing import Generator
2+
import pytest
3+
from datetime import datetime, timezone, timedelta
4+
5+
from unittest.mock import Mock, patch
6+
7+
from django.urls import reverse
8+
from nav.models.api import JWTRefreshToken
9+
from nav.web.jwtgen import generate_refresh_token, hash_token, decode_token
10+
11+
12+
def test_token_not_in_database_should_be_rejected(db, api_client, url):
13+
token = generate_refresh_token()
14+
token_hash = hash_token(token)
15+
16+
assert not JWTRefreshToken.objects.filter(hash=token_hash).exists()
17+
response = api_client.post(
18+
url,
19+
follow=True,
20+
data={
21+
'refresh_token': token,
22+
},
23+
)
24+
assert response.status_code == 403
25+
26+
27+
def test_inactive_token_should_be_rejected(db, api_client, url):
28+
token = generate_refresh_token()
29+
# Set expiry date in the past
30+
now = datetime.now(tz=timezone.utc)
31+
db_token = JWTRefreshToken(
32+
name="testtoken",
33+
hash=hash_token(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': token,
44+
},
45+
)
46+
47+
assert response.status_code == 403
48+
49+
50+
def test_valid_token_should_be_accepted(db, api_client, url):
51+
token = generate_refresh_token()
52+
data = decode_token(token)
53+
db_token = JWTRefreshToken(
54+
name="testtoken",
55+
hash=hash_token(token),
56+
expires=datetime.fromtimestamp(data['exp'], tz=timezone.utc),
57+
activates=datetime.fromtimestamp(data['nbf'], tz=timezone.utc),
58+
)
59+
db_token.save()
60+
response = api_client.post(
61+
url,
62+
follow=True,
63+
data={
64+
'refresh_token': token,
65+
},
66+
)
67+
assert response.status_code == 200
68+
69+
70+
def test_valid_token_should_be_replaced_by_new_token_in_db(db, api_client, url):
71+
token = generate_refresh_token()
72+
token_hash = hash_token(token)
73+
data = decode_token(token)
74+
db_token = JWTRefreshToken(
75+
name="testtoken",
76+
hash=token_hash,
77+
expires=datetime.fromtimestamp(data['exp'], tz=timezone.utc),
78+
activates=datetime.fromtimestamp(data['nbf'], tz=timezone.utc),
79+
)
80+
db_token.save()
81+
response = api_client.post(
82+
url,
83+
follow=True,
84+
data={
85+
'refresh_token': 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(db, api_client, url):
96+
token = generate_refresh_token()
97+
data = decode_token(token)
98+
db_token = JWTRefreshToken(
99+
name="testtoken",
100+
hash=hash_token(token),
101+
expires=datetime.fromtimestamp(data['exp'], tz=timezone.utc),
102+
activates=datetime.fromtimestamp(data['nbf'], tz=timezone.utc),
103+
)
104+
db_token.save()
105+
response = api_client.post(
106+
url,
107+
follow=True,
108+
data={
109+
'refresh_token': token,
110+
},
111+
)
112+
assert response.status_code == 200
113+
assert "access_token" in response.data
114+
assert "refresh_token" in response.data
115+
116+
117+
@pytest.fixture()
118+
def url():
119+
return reverse('api:1:jwt-refresh')
120+
121+
122+
@pytest.fixture(scope="module", autouse=True)
123+
def jwtconf_mock(private_key, nav_name) -> Generator[str, None, None]:
124+
"""Mocks the get_nave_name and get_nav_private_key functions for
125+
the JWTConf class
126+
"""
127+
with patch("nav.web.jwtgen.JWTConf") as _jwtconf_mock:
128+
instance = _jwtconf_mock.return_value
129+
instance.get_nav_name = Mock(return_value=nav_name)
130+
instance.get_nav_private_key = Mock(return_value=private_key)
131+
yield _jwtconf_mock
132+
133+
134+
@pytest.fixture(scope="module")
135+
def private_key() -> str:
136+
"""Yields a private key in PEM format"""
137+
key = """-----BEGIN PRIVATE KEY-----
138+
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCp+4AEZM4uYZKu
139+
/hrKzySMTFFx3/ncWo6XAFpADQHXLOwRB9Xh1/OwigHiqs/wHRAAmnrlkwCCQA8r
140+
xiHBAMjp5ApbkyggQz/DVijrpSba6Tiy1cyBTZC3cvOK2FpJzsakJLhIXD1HaULO
141+
ClyIJB/YrmHmQc8SL3Uzou5mMpdcBC2pzwmEW1cvQURpnvgrDF8V86GrQkjK6nIP
142+
IEeuW6kbD5lWFAPfLf1ohDWex3yxeSFyXNRApJhbF4HrKFemPkOi7acsky38UomQ
143+
jZgAMHPotJNkQvAHcnXHhg0FcWGdohv5bc/Ctt9GwZOzJxwyJLBBsSewbE310TZi
144+
3oLU1TmvAgMBAAECgf8zrhi95+gdMeKRpwV+TnxOK5CXjqvo0vTcnr7Runf/c9On
145+
WeUtRPr83E4LxuMcSGRqdTfoP0loUGb3EsYwZ+IDOnyWWvytfRoQdExSA2RM1PDo
146+
GRiUN4Dy8CrGNqvnb3agG99Ay3Ura6q5T20n9ykM4qKL3yDrO9fmWyMgRJbAOAYm
147+
xzf7H910mDZghXPpq8nzDky0JLNZcaqbxuPQ3+EI4p2dLNXbNqMPs8Y20JKLeOPs
148+
HikRM0zfhHEJSt5IPFQ54/CzscGHGeCleQINWTgvDLMcE5fJMvbLLZixV+YsBfAq
149+
e2JsSubS+9RI2ktMlSKaemr8yeoIpsXfAiJSHkECgYEA0NKU18xK+9w5IXfgNwI4
150+
peu2tWgwyZSp5R2pdLT7O1dJoLYRoAmcXNePB0VXNARqGxTNypJ9zmMawNmf3YRS
151+
BqG8aKz7qpATlx9OwYlk09fsS6MeVmaur8bHGHP6O+gt7Xg+zhiFPvU9P5LB+C0Z
152+
0d4grEmIxNhJCtJRQOThD8ECgYEA0GKRO9SJdnhw1b6LPLd+o/AX7IEzQDHwdtfi
153+
0h7hKHHGBlUMbIBwwjKmyKm6cSe0PYe96LqrVg+cVf84wbLZPAixhOjyplLznBzF
154+
LqOrfFPfI5lQVhslE1H1CdLlk9eyT96jDgmLAg8EGSMV8aLGj++Gi2l/isujHlWF
155+
BI4YpW8CgYEAsyKyhJzABmbYq5lGQmopZkxapCwJDiP1ypIzd+Z5TmKGytLlM8CK
156+
3iocjEQzlm/jBfBGyWv5eD8UCDOoLEMCiqXcFn+uNJb79zvoN6ZBVGl6TzhTIhNb
157+
73Y5/QQguZtnKrtoRSxLwcJnFE41D0zBRYOjy6gZJ6PSpPHeuiid2QECgYACuZc+
158+
mgvmIbMQCHrXo2qjiCs364SZDU4gr7gGmWLGXZ6CTLBp5tASqgjmTNnkSumfeFvy
159+
ZCaDbJbVxQ2f8s/GajKwEz/BDwqievnVH0zJxmr/kyyqw5Ybh5HVvA1GfqaVRssJ
160+
DvTjZQDft0a9Lyy7ix1OS2XgkcMjTWj840LNPwKBgDPXMBgL5h41jd7jCsXzPhyr
161+
V96RzQkPcKsoVvrCoNi8eoEYgRd9jwfiU12rlXv+fgVXrrfMoJBoYT6YtrxEJVdM
162+
RAjRpnE8PMqCUA8Rd7RFK9Vp5Uo8RxTNvk9yPvDv1+lHHV7lEltIk5PXuKPHIrc1
163+
nNUyhzvJs2Qba2L/huNC
164+
-----END PRIVATE KEY-----"""
165+
return key
166+
167+
168+
@pytest.fixture()
169+
def public_key() -> str:
170+
"""Yields a public key in PEM format"""
171+
key = """-----BEGIN PUBLIC KEY-----
172+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqfuABGTOLmGSrv4ays8k
173+
jExRcd/53FqOlwBaQA0B1yzsEQfV4dfzsIoB4qrP8B0QAJp65ZMAgkAPK8YhwQDI
174+
6eQKW5MoIEM/w1Yo66Um2uk4stXMgU2Qt3LzithaSc7GpCS4SFw9R2lCzgpciCQf
175+
2K5h5kHPEi91M6LuZjKXXAQtqc8JhFtXL0FEaZ74KwxfFfOhq0JIyupyDyBHrlup
176+
Gw+ZVhQD3y39aIQ1nsd8sXkhclzUQKSYWxeB6yhXpj5Dou2nLJMt/FKJkI2YADBz
177+
6LSTZELwB3J1x4YNBXFhnaIb+W3PwrbfRsGTsyccMiSwQbEnsGxN9dE2Yt6C1NU5
178+
rwIDAQAB
179+
-----END PUBLIC KEY-----"""
180+
return key
181+
182+
183+
@pytest.fixture(scope="module")
184+
def nav_name() -> str:
185+
return "nav"

0 commit comments

Comments
 (0)