Skip to content

Commit

Permalink
Add JWT token auth provider based on djangorestframework-simplejwt (#36)
Browse files Browse the repository at this point in the history
* Add JWT token auth for DRF integration

* Add docs

* Working tests

* Update jwt token path

* Update demo with JWT auth
  • Loading branch information
barseghyanartur committed Jun 9, 2021
1 parent e71b91f commit 90c541f
Show file tree
Hide file tree
Showing 11 changed files with 251 additions and 5 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ node_modules
dist
coverage/
example/backend/db.sqlite3
.idea
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ const authProvider = tokenAuthProvider()
const dataProvider = drfProvider("/api", fetchJsonWithAuthToken);
```

#### jwtTokenAuthProvider

`jwtTokenAuthProvider` uses
[JSON Web Token Authentication](https://www.django-rest-framework.org/api-guide/authentication/#json-web-token-authentication)
to obtain token from django-rest-framework. User token is saved in `localStorage`.

`jwtTokenAuthProvider` accepts options as second argument with
`obtainAuthJWTTokenUrl` key. Default URL for obtaining a token is `/api/token/`.

`fetchJsonWithAuthJWTToken` overrides *httpClient* and adds authorization header
with previously saved user token to every request.

```javascrtipt
import drfProvider, { jwtTokenAuthProvider, fetchJsonWithAuthJWTToken } from 'ra-data-django-rest-framework';
const authProvider = jwtTokenAuthProvider()
const dataProvider = drfProvider("/api", fetchJsonWithAuthJWTToken);
```

## Example app

### Django application with django-rest-framework
Expand Down Expand Up @@ -125,6 +144,15 @@ You can now view example app in the browser: http://localhost:3000
Login with user `admin`, password is `password` or create new users in Django
admin dashboard or shell.

By default the ``rest_framework.authentication.TokenAuthentication`` will be
used. To use ``rest_framework_simplejwt.authentication.JWTAuthentication``, set
the value of the ``REACT_APP_USE_JWT_AUTH`` variable in the .env
file (example/client/.env) to true, as shown below:

```text
REACT_APP_USE_JWT_AUTH=true
```

## Contributing

This project was bootstrapped with [TSDX](https://github.com/jaredpalmer/tsdx).
Expand Down
3 changes: 2 additions & 1 deletion example/backend/backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*']


# Application definition
Expand Down Expand Up @@ -136,6 +136,7 @@
# SessionAuthentication is intentionally removed, see:
# https://github.com/encode/django-rest-framework/issues/6104'
'rest_framework.authentication.TokenAuthentication',
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
Expand Down
7 changes: 7 additions & 0 deletions example/backend/backend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@
from django.urls import include, path
from rest_framework.authtoken.views import ObtainAuthToken

from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)

urlpatterns = [
path("admin/", admin.site.urls),
# API base url
path("api/", include("backend.api_router")),
path("api-token-auth/", ObtainAuthToken.as_view()),
path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]
2 changes: 1 addition & 1 deletion example/backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Django==3.0.14
djangorestframework==3.11.2
djangorestframework-simplejwt==4.6
django-filter==2.1.0
jsonfield==3.1.0

1 change: 1 addition & 0 deletions example/client/.env
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
SKIP_PREFLIGHT_CHECK=true
REACT_APP_USE_JWT_AUTH=false
8 changes: 8 additions & 0 deletions example/client/src/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function parseBool(val) {
if ((typeof val === 'string' && (val.toLowerCase() === 'true' || val.toLowerCase() === 'yes')) || val === 1)
return true;
else if ((typeof val === 'string' && (val.toLowerCase() === 'false' || val.toLowerCase() === 'no')) || val === 0)
return false;

return null;
}
17 changes: 14 additions & 3 deletions example/client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,27 @@ import { Route } from 'react-router-dom';
import comments from './comments';
import CustomRouteLayout from './customRouteLayout';
import CustomRouteNoLayout from './customRouteNoLayout';
import drfProvider, { tokenAuthProvider, fetchJsonWithAuthToken } from 'ra-data-django-rest-framework';
import drfProvider, { tokenAuthProvider, fetchJsonWithAuthToken, jwtTokenAuthProvider, fetchJsonWithAuthJWTToken } from 'ra-data-django-rest-framework';
import i18nProvider from './i18nProvider';
import Layout from './Layout';
import posts from './posts';
import users from './users';
import tags from './tags';
import { parseBool } from "./helpers";

const authProvider = tokenAuthProvider()
let authProvider;
let dataProvider;
const useJWTAuth = parseBool(process.env.REACT_APP_USE_JWT_AUTH);

const dataProvider = drfProvider("/api", fetchJsonWithAuthToken);
if (useJWTAuth) {
console.log("Using rest_framework_simplejwt.authentication.JWTAuthentication");
authProvider = jwtTokenAuthProvider({obtainAuthTokenUrl: "/api/token/"});
dataProvider = drfProvider("/api", fetchJsonWithAuthJWTToken);
} else {
console.log("Using rest_framework.authentication.TokenAuthentication");
authProvider = tokenAuthProvider();
dataProvider = drfProvider("/api", fetchJsonWithAuthToken);
}

render(
<Admin
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export {
fetchJsonWithAuthToken,
} from './tokenAuthProvider';

export {
default as jwtTokenAuthProvider,
fetchJsonWithAuthJWTToken,
} from './jwtTokenAuthProvider';

const getPaginationQuery = (pagination: Pagination) => {
return {
page: pagination.page,
Expand Down
76 changes: 76 additions & 0 deletions src/jwtTokenAuthProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { AuthProvider, fetchUtils } from 'ra-core';

export interface Options {
obtainAuthTokenUrl?: string;
}

function jwtTokenAuthProvider(options: Options = {}): AuthProvider {
const opts = {
obtainAuthTokenUrl: '/api/token/',
...options,
};
return {
login: async ({ username, password }) => {
const request = new Request(opts.obtainAuthTokenUrl, {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: new Headers({ 'Content-Type': 'application/json' }),
});
const response = await fetch(request);
if (response.ok) {
const responseJSON = await response.json();
localStorage.setItem('access', responseJSON.access);
localStorage.setItem('refresh', responseJSON.refresh);
return;
}
if (response.headers.get('content-type') !== 'application/json') {
throw new Error(response.statusText);
}

const json = await response.json();
const error = json.non_field_errors;
throw new Error(error || response.statusText);
},
logout: () => {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
return Promise.resolve();
},
checkAuth: () =>
localStorage.getItem('access') ? Promise.resolve() : Promise.reject(),
checkError: error => {
const status = error.status;
if (status === 401 || status === 403) {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
return Promise.reject();
}
return Promise.resolve();
},
getPermissions: () => {
return Promise.resolve();
},
};
}

export function createOptionsFromJWTToken() {
const token = localStorage.getItem('access');
if (!token) {
return {};
}
return {
user: {
authenticated: true,
token: 'Bearer ' + token,
},
};
}

export function fetchJsonWithAuthJWTToken(url: string, options: object) {
return fetchUtils.fetchJson(
url,
Object.assign(createOptionsFromJWTToken(), options)
);
}

export default jwtTokenAuthProvider;
108 changes: 108 additions & 0 deletions test/jwtTokenAuthProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import 'cross-fetch/polyfill';
import fetchMock from 'fetch-mock-jest';

import jwtTokenAuthProvider, {
createOptionsFromJWTToken,
fetchJsonWithAuthJWTToken,
} from '../src/jwtTokenAuthProvider';

fetchMock.config.overwriteRoutes = true;

describe('login', () => {
const LOGIN_DATA = {
username: 'foo',
password: 'example',
};

it('should throw error with statusText for non json responses', async () => {
fetchMock.post('/api/token/', () => {
return 404;
});
await expect(jwtTokenAuthProvider().login(LOGIN_DATA)).rejects.toThrow(
'Not Found'
);
});

it('should throw error with non_field_errors', async () => {
const error = 'Unable to log in with provided credentials.';
fetchMock.post('/api/token/', {
body: {
non_field_errors: [error],
},
status: 400,
});
await expect(jwtTokenAuthProvider().login(LOGIN_DATA)).rejects.toThrow(error);
});

it('should set token when successfull', async () => {
const access = 'abcdef';
fetchMock.post('/api/token/', {
body: { access },
});
await jwtTokenAuthProvider().login(LOGIN_DATA);
expect(localStorage.getItem('access')).toBe(access);
});
});

describe('logout', () => {
it('should remove token', async () => {
localStorage.setItem('access', 'abcdef');
await jwtTokenAuthProvider().logout({});
});
});

describe('checkAuth', () => {
it('should return resolve when token exists', async () => {
localStorage.setItem('access', 'abcdef');
await expect(jwtTokenAuthProvider().checkAuth({})).resolves.toBeUndefined();
});
it('should return reject when token does not exists', async () => {
localStorage.clear();
await expect(jwtTokenAuthProvider().checkAuth({})).rejects.toBeUndefined();
});
});

describe('checkError', () => {
it('should remove token and reject for 401 or 403 error', async () => {
[401, 403].forEach(async status => {
await expect(
jwtTokenAuthProvider().checkError({ status })
).rejects.toBeUndefined();
});
});
it('should resolve on other errors', async () => {
await expect(
jwtTokenAuthProvider().checkError({ status: 500 })
).resolves.toBeUndefined();
});
});

describe('getPermissions', () => {
it.todo('missing implementation');
});

describe('createOptionsFromJWTToken', () => {
test('with token', () => {
localStorage.setItem('access', 'abcdef');
expect(createOptionsFromJWTToken()).toEqual({
user: {
authenticated: true,
token: 'Bearer abcdef',
},
});
});

test('without token', () => {
localStorage.clear();
expect(createOptionsFromJWTToken()).toEqual({});
});
});

describe('fetchJsonWithAuthJWTToken', function() {
fetchMock.patch('/', 200);
test('with options', () => {
fetchJsonWithAuthJWTToken('/', {
method: 'PATCH',
});
});
});

0 comments on commit 90c541f

Please sign in to comment.