Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add endpoint for JWT refresh tokens #3270

Open
wants to merge 4 commits into
base: jwt-refresh-token-model
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3270.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add endpoint for JWT refresh tokens
27 changes: 25 additions & 2 deletions doc/howto/using_the_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ doing your requests, make sure to add the header field to all requests.
on NAV installations that do not have SSL enabled, you are potentially giving
everyone access to the data.


.. _jwt-token:
JSON Web Tokens
------------------
JSON Web Tokens (JWTs) must be :doc:`configured <../reference/jwt>` before they can be used.
JSON Web Tokens (JWTs) must be :ref:`configured <jwt-configuration>` before they can be used.

Once configured, you can use tokens issued by your configured issuers in almost the same way
as :ref:`classic tokens <classic-token>`::
Expand All @@ -56,6 +56,29 @@ JWTs must include valid ``exp``, ``nbf``, ``iss`` and ``aud`` claims in order to
``iss`` and ``aud`` must match the :doc:`configuration <../reference/jwt>`, while ``exp`` must
be in the future and ``nbf`` must be in the past.


Locally issued JSON Web Tokens
------------------------------
Local JSON Web Tokens (JWTs) must be :ref:`configured <local-jwt-configuration>` before they can be used.

NAV will soon support generating JWT tokens for local use, as opposed to tokens generated externally.
You will be able to generate refresh tokens via the frontend which can be used to obtain access tokens
that can be used as described :ref:`above <jwt-token>`.

In order to create access tokens you must use the refresh token endpoint, which is available at ``/api/refresh/``.
The endpoint takes a JSON object with a single key, ``refresh_token``, which should contain the
token you wish to use::

curl -X POST -H "Content-type: application/json" http://localhost:80/api/refresh/ -d '{"refresh_token": "<refresh_token>"}'

The response will contain an access token and a new refresh token::

{"refresh_token": "<new_refresh_token>", "access_token": "<new_access_token>"}

Refresh tokens can only be used once, so the next time you need a new access token,
you should use the new refresh token provided in the response.


Browsing the API
================

Expand Down
30 changes: 30 additions & 0 deletions doc/reference/jwt.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,33 @@ by this issuer. This is how NAV maps an incoming token to the correct configurat
Likewise ``aud`` must match the ``aud`` claim of the tokens. This is a security measure to make sure a NAV instance
only accepts tokens meant for it. Otherwise the NAV instance would accept all JWT tokens generated by an issuer.
In other words, anyone using the same token issuer could generate tokens and gain access to your NAV instance.

.. _local-jwt-configuration:
Configuring local JWT token generation
--------------------------------------
Local JWT token generation is configured in the ``nav`` section of :file:`jwt.conf`.

You must configure three things: a private key, a public key, and the issuer name.
The keys must be a private/public RSA keypair in PEM format.
The issuer name is used for the ``iss`` and ``aud`` claims of generated tokens.
The issuer name can be whatever you want, but a natural choice is the domain name of your NAV instance (e.g. ``nav.example.com``).
An example configuration is shown below::

[nav]
# Absolute path to private key in PEM format
private_key=/path/to/jwtRS256.key
# Absolute path to public key in PEM format.
public_key=/path/to/jwtRS256.key.pub
# Used for the 'iss' and 'aud' claims of generated tokens.
name=nav

The keypair can be generated like so::

ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key
# Don't add passphrase
openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub

This keypair should ideally only be used for JWT token generation in one NAV instance.
If you are using the same keypair for multiple instances, you should use different issuer names for each instance.
That is important because the NAV token validation uses the pulbic key and the ``aud`` claim to decide if the token is valid,
so if someone else can generate tokens using the same keypair and the same ``aud`` claim they can also gain access to your NAV instance.
1 change: 1 addition & 0 deletions python/nav/web/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,5 @@
name="prefix-usage-detail",
),
re_path(r'^', include(router.urls)),
re_path(r'^refresh/$', views.JWTRefreshViewSet.as_view(), name='jwt-refresh'),
]
50 changes: 50 additions & 0 deletions python/nav/web/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,21 @@
from oidc_auth.authentication import JSONWebTokenAuthentication

from nav.models import manage, event, cabling, rack, profiles
from nav.models.api import JWTRefreshToken
from nav.models.fields import INFINITY, UNRESOLVED
from nav.web.servicecheckers import load_checker_classes
from nav.util import auth_token, is_valid_cidr

from nav.buildconf import VERSION
from nav.web.api.v1 import serializers, alert_serializers
from nav.web.status2 import STATELESS_THRESHOLD
from nav.web.jwtgen import (
decode_token,
generate_access_token,
generate_refresh_token,
hash_token,
is_active,
)
from nav.macaddress import MacPrefix
from .auth import (
APIPermission,
Expand Down Expand Up @@ -1153,3 +1161,45 @@ class ModuleViewSet(NAVAPIMixin, viewsets.ReadOnlyModelViewSet):
'device__serial',
)
serializer_class = serializers.ModuleSerializer


class JWTRefreshViewSet(APIView):
"""
Accepts a valid refresh token.
Returns a new refresh token and an access token.
"""

def post(self, request):
incoming_token = request.data.get('refresh_token')
token_hash = hash_token(incoming_token)
try:
# If hash exists in the database, then we know it is a real token
db_token = JWTRefreshToken.objects.get(hash=token_hash)
except JWTRefreshToken.DoesNotExist:
return Response("Invalid token", status=status.HTTP_403_FORBIDDEN)

claims = decode_token(incoming_token)
if not is_active(claims['exp'], claims['nbf']):
return Response("Inactive token", status=status.HTTP_403_FORBIDDEN)

if db_token.revoked:
return Response(
"This token has been revoked", status=status.HTTP_403_FORBIDDEN
)

access_token = generate_access_token(claims)
refresh_token = generate_refresh_token(claims)

new_claims = decode_token(refresh_token)
new_hash = hash_token(refresh_token)
db_token.hash = new_hash
db_token.expires = datetime.fromtimestamp(new_claims['exp'])
db_token.activates = datetime.fromtimestamp(new_claims['nbf'])
db_token.last_used = datetime.now()
db_token.save()

response_data = {
'access_token': access_token,
'refresh_token': refresh_token,
}
return Response(response_data)
13 changes: 13 additions & 0 deletions python/nav/web/jwtgen.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import hashlib

import jwt

Expand Down Expand Up @@ -64,3 +65,15 @@ def is_active(exp: float, nbf: float) -> bool:
expires = datetime.fromtimestamp(exp, tz=timezone.utc)
activates = datetime.fromtimestamp(nbf, tz=timezone.utc)
return now >= activates and now < expires


def hash_token(token: str) -> str:
"""Hashes a token with SHA256"""
hash_object = hashlib.sha256(token.encode('utf-8'))
hex_dig = hash_object.hexdigest()
return hex_dig


def decode_token(token: str) -> dict[str, Any]:
"""Decodes a token in JWT format and returns the data of the decoded token"""
return jwt.decode(token, options={'verify_signature': False})
49 changes: 49 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,52 @@ def gunicorn():
yield base_url

gunicorn.terminate()


@pytest.fixture(scope="session")
def rsa_private_key() -> str:
"""Yields a private key in PEM format"""
key = """-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCp+4AEZM4uYZKu
/hrKzySMTFFx3/ncWo6XAFpADQHXLOwRB9Xh1/OwigHiqs/wHRAAmnrlkwCCQA8r
xiHBAMjp5ApbkyggQz/DVijrpSba6Tiy1cyBTZC3cvOK2FpJzsakJLhIXD1HaULO
ClyIJB/YrmHmQc8SL3Uzou5mMpdcBC2pzwmEW1cvQURpnvgrDF8V86GrQkjK6nIP
IEeuW6kbD5lWFAPfLf1ohDWex3yxeSFyXNRApJhbF4HrKFemPkOi7acsky38UomQ
jZgAMHPotJNkQvAHcnXHhg0FcWGdohv5bc/Ctt9GwZOzJxwyJLBBsSewbE310TZi
3oLU1TmvAgMBAAECgf8zrhi95+gdMeKRpwV+TnxOK5CXjqvo0vTcnr7Runf/c9On
WeUtRPr83E4LxuMcSGRqdTfoP0loUGb3EsYwZ+IDOnyWWvytfRoQdExSA2RM1PDo
GRiUN4Dy8CrGNqvnb3agG99Ay3Ura6q5T20n9ykM4qKL3yDrO9fmWyMgRJbAOAYm
xzf7H910mDZghXPpq8nzDky0JLNZcaqbxuPQ3+EI4p2dLNXbNqMPs8Y20JKLeOPs
HikRM0zfhHEJSt5IPFQ54/CzscGHGeCleQINWTgvDLMcE5fJMvbLLZixV+YsBfAq
e2JsSubS+9RI2ktMlSKaemr8yeoIpsXfAiJSHkECgYEA0NKU18xK+9w5IXfgNwI4
peu2tWgwyZSp5R2pdLT7O1dJoLYRoAmcXNePB0VXNARqGxTNypJ9zmMawNmf3YRS
BqG8aKz7qpATlx9OwYlk09fsS6MeVmaur8bHGHP6O+gt7Xg+zhiFPvU9P5LB+C0Z
0d4grEmIxNhJCtJRQOThD8ECgYEA0GKRO9SJdnhw1b6LPLd+o/AX7IEzQDHwdtfi
0h7hKHHGBlUMbIBwwjKmyKm6cSe0PYe96LqrVg+cVf84wbLZPAixhOjyplLznBzF
LqOrfFPfI5lQVhslE1H1CdLlk9eyT96jDgmLAg8EGSMV8aLGj++Gi2l/isujHlWF
BI4YpW8CgYEAsyKyhJzABmbYq5lGQmopZkxapCwJDiP1ypIzd+Z5TmKGytLlM8CK
3iocjEQzlm/jBfBGyWv5eD8UCDOoLEMCiqXcFn+uNJb79zvoN6ZBVGl6TzhTIhNb
73Y5/QQguZtnKrtoRSxLwcJnFE41D0zBRYOjy6gZJ6PSpPHeuiid2QECgYACuZc+
mgvmIbMQCHrXo2qjiCs364SZDU4gr7gGmWLGXZ6CTLBp5tASqgjmTNnkSumfeFvy
ZCaDbJbVxQ2f8s/GajKwEz/BDwqievnVH0zJxmr/kyyqw5Ybh5HVvA1GfqaVRssJ
DvTjZQDft0a9Lyy7ix1OS2XgkcMjTWj840LNPwKBgDPXMBgL5h41jd7jCsXzPhyr
V96RzQkPcKsoVvrCoNi8eoEYgRd9jwfiU12rlXv+fgVXrrfMoJBoYT6YtrxEJVdM
RAjRpnE8PMqCUA8Rd7RFK9Vp5Uo8RxTNvk9yPvDv1+lHHV7lEltIk5PXuKPHIrc1
nNUyhzvJs2Qba2L/huNC
-----END PRIVATE KEY-----"""
return key


@pytest.fixture(scope="session")
def rsa_public_key() -> str:
"""Yields a public key in PEM format"""
key = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqfuABGTOLmGSrv4ays8k
jExRcd/53FqOlwBaQA0B1yzsEQfV4dfzsIoB4qrP8B0QAJp65ZMAgkAPK8YhwQDI
6eQKW5MoIEM/w1Yo66Um2uk4stXMgU2Qt3LzithaSc7GpCS4SFw9R2lCzgpciCQf
2K5h5kHPEi91M6LuZjKXXAQtqc8JhFtXL0FEaZ74KwxfFfOhq0JIyupyDyBHrlup
Gw+ZVhQD3y39aIQ1nsd8sXkhclzUQKSYWxeB6yhXpj5Dou2nLJMt/FKJkI2YADBz
6LSTZELwB3J1x4YNBXFhnaIb+W3PwrbfRsGTsyccMiSwQbEnsGxN9dE2Yt6C1NU5
rwIDAQAB
-----END PUBLIC KEY-----"""
return key
Loading
Loading