Skip to content

Commit ec2ead6

Browse files
committed
Add endpoint for JWT refresh tokens
1 parent c2568eb commit ec2ead6

File tree

5 files changed

+234
-0
lines changed

5 files changed

+234
-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

+40
Original file line numberDiff line numberDiff line change
@@ -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,36 @@ 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+
access_token = generate_access_token(db_token.data)
1183+
refresh_token = generate_refresh_token(db_token.data)
1184+
1185+
new_hash = hash_token(refresh_token)
1186+
new_data = decode_token(refresh_token)
1187+
db_token.hash = new_hash
1188+
db_token.data = new_data
1189+
db_token.save()
1190+
1191+
response_data = {
1192+
'access_token': access_token,
1193+
'refresh_token': refresh_token,
1194+
}
1195+
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,179 @@
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+
data = decode_token(token)
30+
# Set expiry date in the past
31+
data["exp"] = (datetime.now(tz=timezone.utc) - timedelta(hours=1)).timestamp()
32+
db_token = JWTRefreshToken(
33+
name="testtoken",
34+
hash=hash_token(token),
35+
data=data,
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+
db_token = JWTRefreshToken(
53+
name="testtoken",
54+
hash=hash_token(token),
55+
data=decode_token(token),
56+
)
57+
db_token.save()
58+
response = api_client.post(
59+
url,
60+
follow=True,
61+
data={
62+
'refresh_token': token,
63+
},
64+
)
65+
assert response.status_code == 200
66+
67+
68+
def test_valid_token_should_be_replaced_by_new_token_in_db(db, api_client, url):
69+
token = generate_refresh_token()
70+
token_hash = hash_token(token)
71+
db_token = JWTRefreshToken(
72+
name="testtoken",
73+
hash=token_hash,
74+
data=decode_token(token),
75+
)
76+
db_token.save()
77+
response = api_client.post(
78+
url,
79+
follow=True,
80+
data={
81+
'refresh_token': token,
82+
},
83+
)
84+
assert response.status_code == 200
85+
assert not JWTRefreshToken.objects.filter(hash=token_hash).exists()
86+
new_token = response.data.get("refresh_token")
87+
new_hash = hash_token(new_token)
88+
assert JWTRefreshToken.objects.filter(hash=new_hash).exists()
89+
90+
91+
def test_should_include_access_and_refresh_token_in_response(db, api_client, url):
92+
token = generate_refresh_token()
93+
db_token = JWTRefreshToken(
94+
name="testtoken",
95+
hash=hash_token(token),
96+
data=decode_token(token),
97+
)
98+
db_token.save()
99+
response = api_client.post(
100+
url,
101+
follow=True,
102+
data={
103+
'refresh_token': token,
104+
},
105+
)
106+
assert response.status_code == 200
107+
assert "access_token" in response.data
108+
assert "refresh_token" in response.data
109+
110+
111+
@pytest.fixture()
112+
def url():
113+
return reverse('api:1:jwt-refresh')
114+
115+
116+
@pytest.fixture(scope="module", autouse=True)
117+
def jwtconf_mock(private_key, nav_name) -> Generator[str, None, None]:
118+
"""Mocks the get_nave_name and get_nav_private_key functions for
119+
the JWTConf class
120+
"""
121+
with patch("nav.web.jwtgen.JWTConf") as _jwtconf_mock:
122+
instance = _jwtconf_mock.return_value
123+
instance.get_nav_name = Mock(return_value=nav_name)
124+
instance.get_nav_private_key = Mock(return_value=private_key)
125+
yield _jwtconf_mock
126+
127+
128+
@pytest.fixture(scope="module")
129+
def private_key() -> str:
130+
"""Yields a private key in PEM format"""
131+
key = """-----BEGIN PRIVATE KEY-----
132+
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCp+4AEZM4uYZKu
133+
/hrKzySMTFFx3/ncWo6XAFpADQHXLOwRB9Xh1/OwigHiqs/wHRAAmnrlkwCCQA8r
134+
xiHBAMjp5ApbkyggQz/DVijrpSba6Tiy1cyBTZC3cvOK2FpJzsakJLhIXD1HaULO
135+
ClyIJB/YrmHmQc8SL3Uzou5mMpdcBC2pzwmEW1cvQURpnvgrDF8V86GrQkjK6nIP
136+
IEeuW6kbD5lWFAPfLf1ohDWex3yxeSFyXNRApJhbF4HrKFemPkOi7acsky38UomQ
137+
jZgAMHPotJNkQvAHcnXHhg0FcWGdohv5bc/Ctt9GwZOzJxwyJLBBsSewbE310TZi
138+
3oLU1TmvAgMBAAECgf8zrhi95+gdMeKRpwV+TnxOK5CXjqvo0vTcnr7Runf/c9On
139+
WeUtRPr83E4LxuMcSGRqdTfoP0loUGb3EsYwZ+IDOnyWWvytfRoQdExSA2RM1PDo
140+
GRiUN4Dy8CrGNqvnb3agG99Ay3Ura6q5T20n9ykM4qKL3yDrO9fmWyMgRJbAOAYm
141+
xzf7H910mDZghXPpq8nzDky0JLNZcaqbxuPQ3+EI4p2dLNXbNqMPs8Y20JKLeOPs
142+
HikRM0zfhHEJSt5IPFQ54/CzscGHGeCleQINWTgvDLMcE5fJMvbLLZixV+YsBfAq
143+
e2JsSubS+9RI2ktMlSKaemr8yeoIpsXfAiJSHkECgYEA0NKU18xK+9w5IXfgNwI4
144+
peu2tWgwyZSp5R2pdLT7O1dJoLYRoAmcXNePB0VXNARqGxTNypJ9zmMawNmf3YRS
145+
BqG8aKz7qpATlx9OwYlk09fsS6MeVmaur8bHGHP6O+gt7Xg+zhiFPvU9P5LB+C0Z
146+
0d4grEmIxNhJCtJRQOThD8ECgYEA0GKRO9SJdnhw1b6LPLd+o/AX7IEzQDHwdtfi
147+
0h7hKHHGBlUMbIBwwjKmyKm6cSe0PYe96LqrVg+cVf84wbLZPAixhOjyplLznBzF
148+
LqOrfFPfI5lQVhslE1H1CdLlk9eyT96jDgmLAg8EGSMV8aLGj++Gi2l/isujHlWF
149+
BI4YpW8CgYEAsyKyhJzABmbYq5lGQmopZkxapCwJDiP1ypIzd+Z5TmKGytLlM8CK
150+
3iocjEQzlm/jBfBGyWv5eD8UCDOoLEMCiqXcFn+uNJb79zvoN6ZBVGl6TzhTIhNb
151+
73Y5/QQguZtnKrtoRSxLwcJnFE41D0zBRYOjy6gZJ6PSpPHeuiid2QECgYACuZc+
152+
mgvmIbMQCHrXo2qjiCs364SZDU4gr7gGmWLGXZ6CTLBp5tASqgjmTNnkSumfeFvy
153+
ZCaDbJbVxQ2f8s/GajKwEz/BDwqievnVH0zJxmr/kyyqw5Ybh5HVvA1GfqaVRssJ
154+
DvTjZQDft0a9Lyy7ix1OS2XgkcMjTWj840LNPwKBgDPXMBgL5h41jd7jCsXzPhyr
155+
V96RzQkPcKsoVvrCoNi8eoEYgRd9jwfiU12rlXv+fgVXrrfMoJBoYT6YtrxEJVdM
156+
RAjRpnE8PMqCUA8Rd7RFK9Vp5Uo8RxTNvk9yPvDv1+lHHV7lEltIk5PXuKPHIrc1
157+
nNUyhzvJs2Qba2L/huNC
158+
-----END PRIVATE KEY-----"""
159+
return key
160+
161+
162+
@pytest.fixture()
163+
def public_key() -> str:
164+
"""Yields a public key in PEM format"""
165+
key = """-----BEGIN PUBLIC KEY-----
166+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqfuABGTOLmGSrv4ays8k
167+
jExRcd/53FqOlwBaQA0B1yzsEQfV4dfzsIoB4qrP8B0QAJp65ZMAgkAPK8YhwQDI
168+
6eQKW5MoIEM/w1Yo66Um2uk4stXMgU2Qt3LzithaSc7GpCS4SFw9R2lCzgpciCQf
169+
2K5h5kHPEi91M6LuZjKXXAQtqc8JhFtXL0FEaZ74KwxfFfOhq0JIyupyDyBHrlup
170+
Gw+ZVhQD3y39aIQ1nsd8sXkhclzUQKSYWxeB6yhXpj5Dou2nLJMt/FKJkI2YADBz
171+
6LSTZELwB3J1x4YNBXFhnaIb+W3PwrbfRsGTsyccMiSwQbEnsGxN9dE2Yt6C1NU5
172+
rwIDAQAB
173+
-----END PUBLIC KEY-----"""
174+
return key
175+
176+
177+
@pytest.fixture(scope="module")
178+
def nav_name() -> str:
179+
return "nav"

0 commit comments

Comments
 (0)