Skip to content

Commit

Permalink
feat: add authentication provider tokenAuthProvider
Browse files Browse the repository at this point in the history
* feat: add authentication provider tokenAuthProvider

* doc: update tokenAuthProvider docs
  • Loading branch information
bmihelac committed Jun 21, 2020
1 parent f951437 commit 794830d
Show file tree
Hide file tree
Showing 10 changed files with 282 additions and 11 deletions.
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ra-data-django-rest-framework

[react-admin](https://marmelab.com/react-admin/) data provider for [Django REST
[react-admin](https://marmelab.com/react-admin/) data and authentication provider for [Django REST
framework](https://www.django-rest-framework.org/).

[![Stable Release](https://img.shields.io/npm/v/ra-data-django-rest-framework)](https://npm.im/ra-data-django-rest-framework)
Expand Down Expand Up @@ -32,6 +32,7 @@ const dataProvider = drfProvider("/api");
* Sorting
* Pagination
* Filtering
* Authentication

### Sorting

Expand Down Expand Up @@ -63,6 +64,27 @@ ra-data-django-rest-framework supports:
* [Generic Filtering](https://www.django-rest-framework.org/api-guide/filtering/#generic-filtering)
* [DjangoFilterBackend](https://www.django-rest-framework.org/api-guide/filtering/#djangofilterbackend)

### Authentication

#### tokenAuthProvider

`tokenAuthProvider` uses
[TokenAuthentication](https://www.django-rest-framework.org/api-guide/authentication/#tokenauthentication)
to obtain token from django-rest-framework. User token is saved in `localStorage`.

`tokenAuthProvider` accepts options as second argument with
`obtainAuthTokenUrl` key. Default URL for obtaining a token is `/api-token-auth/`.

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

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

## Example app

### Django application with django-rest-framework
Expand All @@ -84,8 +106,6 @@ Run server:
./manage.py runserver
```

django-rest-framework browsable api is available on http://localhost:8000/api/

### React-admin demo application

```bash
Expand All @@ -96,6 +116,8 @@ yarn start
```

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.

## Contributing

Expand Down
9 changes: 9 additions & 0 deletions example/backend/backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
'django.contrib.staticfiles',
"django_filters",
"rest_framework",
"rest_framework.authtoken",
"exampleapp",
]

Expand Down Expand Up @@ -131,4 +132,12 @@
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
# SessionAuthentication is intentionally removed, see:
# https://github.com/encode/django-rest-framework/issues/6104'
'rest_framework.authentication.TokenAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
]
}
6 changes: 4 additions & 2 deletions example/backend/backend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.urls import include, path
from rest_framework.authtoken.views import ObtainAuthToken

urlpatterns = [
path('admin/', admin.site.urls),
path("admin/", admin.site.urls),
# API base url
path("api/", include("backend.api_router")),
path("api-token-auth/", ObtainAuthToken.as_view()),
]
2 changes: 1 addition & 1 deletion example/backend/exampleapp/fixtures/initial.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions example/client/src/index.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
/* eslint react/jsx-key: off */
import React from 'react';
import { Admin, Resource } from 'react-admin'; // eslint-disable-line import/no-unresolved
import { Admin, Resource} from 'react-admin'; // eslint-disable-line import/no-unresolved
import { render } from 'react-dom';
import { Route } from 'react-router-dom';

import authProvider from './authProvider';
import comments from './comments';
import CustomRouteLayout from './customRouteLayout';
import CustomRouteNoLayout from './customRouteNoLayout';
import drfProvider from 'ra-data-django-rest-framework';
import drfProvider, { tokenAuthProvider, fetchJsonWithAuthToken } 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';

const authProvider = tokenAuthProvider()

const dataProvider = drfProvider("/api");
const dataProvider = drfProvider("/api", fetchJsonWithAuthToken);

render(
<Admin
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
"author": "Bojan Mihelac",
"module": "dist/ra-data-django-rest-framework.esm.js",
"devDependencies": {
"cross-fetch": "^3.0.5",
"fetch-mock-jest": "^1.3.0",
"husky": "^4.2.5",
"react": "^16.9.0",
"react-admin": "3.6.0",
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
DataProvider,
} from 'ra-core';

export {
default as tokenAuthProvider,
fetchJsonWithAuthToken,
} from './tokenAuthProvider';

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

export interface Options {
obtainAuthTokenUrl?: string;
}

function tokenAuthProvider(options: Options = {}): AuthProvider {
const opts = {
obtainAuthTokenUrl: '/api-token-auth/',
...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) {
localStorage.setItem('token', (await response.json()).token);
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('token');
return Promise.resolve();
},
checkAuth: () =>
localStorage.getItem('token') ? Promise.resolve() : Promise.reject(),
checkError: error => {
const status = error.status;
if (status === 401 || status === 403) {
localStorage.removeItem('token');
return Promise.reject();
}
return Promise.resolve();
},
getPermissions: () => {
return Promise.resolve();
},
};
}

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

export function fetchJsonWithAuthToken(url: string) {
return fetchUtils.fetchJson(url, createOptionsFromToken());
}

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

import tokenAuthProvider, {
createOptionsFromToken,
} from '../src/tokenAuthProvider';

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-auth/', () => {
return 404;
});
await expect(tokenAuthProvider().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-auth/', {
body: {
non_field_errors: [error],
},
status: 400,
});
await expect(tokenAuthProvider().login(LOGIN_DATA)).rejects.toThrow(error);
});

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

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

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

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

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

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

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

0 comments on commit 794830d

Please sign in to comment.