Skip to content

Commit

Permalink
Merge pull request #12 from alpacanetworks/11-add-jwt-authentication
Browse files Browse the repository at this point in the history
11 add jwt authentication
  • Loading branch information
eunyoung14 authored Oct 31, 2023
2 parents 816d1fc + 63d222d commit 387432c
Show file tree
Hide file tree
Showing 11 changed files with 508 additions and 1 deletion.
18 changes: 18 additions & 0 deletions alpacon/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
'rest_framework',
'crispy_forms',
'crispy_bootstrap5',
'rest_framework_simplejwt',
'django_filters',
'django.contrib.admin',
'django.contrib.auth',
Expand Down Expand Up @@ -198,6 +199,7 @@
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES':[
'rest_framework.authentication.SessionAuthentication',
'api.apiclient.auth.APIClientJWTAuthentication',
'api.apiclient.auth.APIClientAuthentication',
'api.apitoken.auth.APITokenAuthentication',
],
Expand All @@ -214,6 +216,22 @@
'TEST_REQUEST_DEFAULT_FORMAT': 'json',
}

SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(
minutes=int(os.getenv('ALPACON_ACCESS_TOKEN_LIFETIME_MINS', '1440'))
),
'REFRESH_TOKEN_LIFETIME': timedelta(
days=int(os.getenv('AlPACON_REFRESH_TOKEN_LIFETIME_DAYS', '14'))
),
'CLIENT_ID_FIELD' : 'id',
'CLIENT_ID_CLAIM': 'client_id',
'TOKEN_TYPE_CLAIM': 'token_type',
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
}

CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
CRISPY_TEMPLATE_PACK = 'bootstrap5'

Expand Down
40 changes: 40 additions & 0 deletions api/apiclient/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@

from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils.translation import gettext_lazy as _
from django.conf import settings

from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.tokens import Token
from rest_framework_simplejwt.exceptions import InvalidToken

from api.apiclient.models import APIClient, APIClientUser

Expand Down Expand Up @@ -51,3 +56,38 @@ def authenticate(self, request):
except Exception as e:
logger.exception(e)
return None

class APIClientJWTAuthentication(JWTAuthentication):
"""
An authentication plugin that authenticates requests through a JSON web token provided in a request header.
"""

client_model = APIClient

def authenticate(self, request: Request):
header = self.get_header(request)
if header is None:
return None

raw_token = self.get_raw_token(header)
if raw_token is None:
return None

validated_token = self.get_validated_token(raw_token)
request.client = self.get_client(validated_token)
return (APIClientUser(request.client), validated_token)

def get_client(self, validated_token: Token):
"""
Attempts to find and return a client using the given validated token.
"""

try:
client_id = validated_token[settings.SIMPLE_JWT['CLIENT_ID_CLAIM']]
except KeyError:
raise InvalidToken(_('Token contained no recognizable user identification'))
try:
client = self.client_model.objects.get(**{settings.SIMPLE_JWT['CLIENT_ID_FIELD']: client_id})
except self.client_model.DoesNotExist:
raise AuthenticationFailed(_('Client not found'), code='client_not_found')
return client
53 changes: 53 additions & 0 deletions api/apiclient/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from django.utils.translation import gettext_lazy as _

from rest_framework import serializers
from rest_framework.exceptions import ValidationError

from api.apiclient.models import APIClient
from api.apiclient.tokens import JWTRefreshToken


class JWTLoginSerializer(serializers.Serializer):
"""
After authenticating whether it is a valid API client using the ID and Key received through the request, the access token and refresh token are returned using the client_id.
"""

id = serializers.UUIDField(
label=_('ID'),
write_only=True,
)
key = serializers.CharField(
max_length=128,
label=_('key'),
style={'input_type': 'password'},
write_only=True,
)

def validate(self, data):
client_id = data['id']
client_key = data['key']
obj = APIClient.objects.get_valid_client(id=client_id, key=client_key)
if obj is not None:
refresh = JWTRefreshToken.for_client(client_id)
return {
'refresh': str(refresh),
'access': str(refresh.access_token),
}
else:
raise ValidationError(_('Login credentials are incorrect.'), 'credential-error')


class JWTRefreshSerializer(serializers.Serializer):
"""
After verifying whether the refresh token received in the request is a valid token, a new access token is returned.
"""

refresh = serializers.CharField()
access = serializers.CharField(read_only=True)
token_class = JWTRefreshToken

def validate(self, data):
refresh = self.token_class(data['refresh'])
return {
'access': str(refresh.access_token)
}
150 changes: 150 additions & 0 deletions api/apiclient/tests.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from datetime import timedelta
import uuid

from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.utils.crypto import get_random_string
from django.core.exceptions import ObjectDoesNotExist

from rest_framework.test import APITestCase
from rest_framework import status

from api.apiclient.models import APIClient
from api.apiclient.tokens import JWTRefreshToken


User = get_user_model()
Expand Down Expand Up @@ -223,3 +228,148 @@ def test_login_2(self):
self.client.login(id=self.client_id, key=self.client_key)
response = self.client.get(reverse('api:index'))
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

class JWTLoginTestCase(APITestCase):
"""
When logging in using ID and key, check the APIclient model and test whether the token is returned normally.
"""

def setUp(self):
self.client_id = uuid.uuid4()
self.client_key = get_random_string(16)

self.user = User.objects.create_user(username='testuser')
self.api_client = APIClient.objects.create_api_client(
owner=self.user,
id=self.client_id,
key=self.client_key,
)

def jwtlogin(self):
response = self.client.post(
reverse('api:apiclient:jwt:login'), {
'id': self.client_id,
'key': self.client_key,
}
)
self.assertEquals(response.status_code, status.HTTP_200_OK)
self.assertTrue('refresh' in response.data)
self.assertTrue('access' in response.data)
self.token = response.data['access']
self.client.credentials(
HTTP_AUTHORIZATION='Bearer %s' % self.token
)

def test_jwtlogin(self):
self.jwtlogin()
response = self.client.get(reverse('api:auth:is-authenticated'))
self.assertEquals(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data['authenticated'])

def test_unauthorized(self):
response = self.client.get(reverse('api:auth:is-authenticated'))
self.assertEquals(response.status_code, status.HTTP_401_UNAUTHORIZED)
self.assertFalse(response.data['authenticated'])

def test_jwtlogin_no_id_key(self):
response = self.client.post(
reverse('api:apiclient:jwt:login'), { }
)
self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_jwtlogin_no_id(self):
response = self.client.post(
reverse('api:apiclient:jwt:login'), {
'key': self.client_key,
}
)
self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_jwtlogin_no_key(self):
response = self.client.post(
reverse('api:apiclient:jwt:login'), {
'id': self.client_id,
}
)
self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_jwtlogin_invalid_id_and_key(self):
response = self.client.post(
reverse('api:apiclient:jwt:login'), {
'id': 'a'*128,
'key': 'b'*128,
}
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)


class JWTRefreshTestCase(APITestCase):
"""
When a refresh token is entered in the header, it verifies the refresh token and tests whether the new access token is returned normally.
"""

def test_jwtrefresh(self):
refresh = JWTRefreshToken()
refresh["test_claim"] = "test_client_id"

response = self.client.post(
reverse('api:apiclient:jwt:refresh'), {
"refresh": str(refresh)
}
)
self.assertEquals(response.status_code, status.HTTP_200_OK)
self.assertTrue('access' in response.data)

def test_it_should_return_401_if_token_invalid(self):
refresh = JWTRefreshToken()

refresh.set_exp(lifetime=-timedelta(seconds=1))

response = self.client.post(
reverse('api:apiclient:jwt:refresh'), {
"refresh": str(refresh)
}
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.data["code"], "token_not_valid")


class JWTSessionTestCase(APITestCase):
"""
When an expired access token is entered in the header, alpacon-server tests whether a 403 Forbidden error is generated.
"""

def setUp(self):
self.client_id = uuid.uuid4()
self.client_key = get_random_string(16)

self.user = User.objects.create_user(username='testuser')
self.api_client = APIClient.objects.create_api_client(
owner=self.user,
id=self.client_id,
key=self.client_key,
)

def test_session_connect(self):
refresh = JWTRefreshToken.for_client(self.client_id)
access = refresh.access_token
self.client.credentials(
HTTP_AUTHORIZATION='Bearer %s' % access
)

response = self.client.get(reverse('api:auth:is-authenticated'))
self.assertEquals(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data['authenticated'])

def test_session_no_connect(self):
refresh = JWTRefreshToken.for_client(self.client_id)
access = refresh.access_token

access.set_exp(lifetime=-timedelta(seconds=1))

self.client.credentials(
HTTP_AUTHORIZATION='Bearer %s' % access
)

response = self.client.get(reverse('api:auth:is-authenticated'))
self.assertEquals(response.status_code, status.HTTP_403_FORBIDDEN)
18 changes: 18 additions & 0 deletions api/apiclient/tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.conf import settings

from rest_framework_simplejwt.tokens import RefreshToken

class JWTRefreshToken(RefreshToken):
"""
Return a token object by putting the APIclient ID in the token claim, and create an access token using the refresh token.
"""

@classmethod
def for_client(cls, client_id):
"""
Returns an authorization token for the given client that will be provided after authenticating the user's credentials.
"""

token = cls()
token[settings.SIMPLE_JWT['CLIENT_ID_CLAIM']] = str(client_id)
return token
16 changes: 16 additions & 0 deletions api/apiclient/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.urls import include, path

from api.apiclient.views import JWTLoginView
from api.apiclient.views import JWTRefreshView

app_name = 'apiclient'


jwt_patterns = ([
path('login/', JWTLoginView.as_view(), name='login'),
path('refresh/', JWTRefreshView.as_view(), name='refresh'),
], 'jwt')

urlpatterns = [
path('jwt/', include(jwt_patterns)),
]
28 changes: 28 additions & 0 deletions api/apiclient/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import logging

from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.views import TokenRefreshView

from api.apiclient.serializers import JWTLoginSerializer
from api.apiclient.serializers import JWTRefreshSerializer


logger = logging.getLogger(__name__)


class JWTLoginView(TokenObtainPairView):
"""
Takes the Client ID and key and returns the access token and refresh token.
The two tokens are a pair of tokens that prove client authentication.
"""

serializer_class = JWTLoginSerializer


class JWTRefreshView(TokenRefreshView):
"""
Takes a refresh type JSON web token and returns an access type JSON web token if the refresh token is valid.
"""

serializer_class = JWTRefreshSerializer
1 change: 1 addition & 0 deletions api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
urlpatterns = [
path('', api_index, name='index'),
path('status/', status, name='status'),
path('apiclient/', include('api.apiclient.urls')),
] + list(map(
lambda app: path('%s/' % app, include('%s.api.urls' % app)),
settings.REST_API_APPS
Expand Down
Loading

0 comments on commit 387432c

Please sign in to comment.