├── .github
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── ISSUE_TEMPLATE
│ ├── Bug_report.md
│ ├── Custom.md
│ └── Feature_request.md
├── pull_request_template.md
└── repository-open-graph.png
├── .travis.yml
├── LICENSE
├── Makefile
├── README.md
├── demo
├── Makefile
├── README.md
├── admin
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ ├── icon-48x48.png
│ │ ├── index.html
│ │ └── robots.txt
│ └── src
│ │ ├── App.js
│ │ ├── App.test.js
│ │ ├── authProvider.js
│ │ ├── dataProvider.js
│ │ ├── inMemoryJWT.js
│ │ ├── index.js
│ │ ├── setupTests.js
│ │ └── users
│ │ └── index.js
├── back
│ ├── cli
│ │ └── create-user.js
│ ├── knexfile.js
│ ├── migrations
│ │ └── 20200512072137_init-db.js
│ ├── package-lock.json
│ ├── package.json
│ └── src
│ │ ├── config.js
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ ├── toolbox
│ │ ├── authentication
│ │ │ ├── jwtMiddleware.js
│ │ │ ├── refreshTokenRepository.js
│ │ │ └── router.js
│ │ ├── dbConnexion.js
│ │ ├── middleware
│ │ │ └── db.js
│ │ └── rest-list
│ │ │ ├── filters-helpers.js
│ │ │ ├── filters-helpers.spec.js
│ │ │ ├── index.js
│ │ │ ├── pagination-helpers.js
│ │ │ ├── pagination-helpers.spec.js
│ │ │ ├── query-parameters-helpers.js
│ │ │ ├── query-parameters-helpers.spec.js
│ │ │ ├── sort-helpers.js
│ │ │ └── sort-helpers.spec.js
│ │ └── user-account
│ │ ├── repository.js
│ │ ├── router.js
│ │ ├── user.js
│ │ └── user.spec.js
├── demo.env
└── docker-compose.yml
├── doc
├── blogPostFr.md
├── jwtSessionFirstTry.gif
├── jwtSessionSecondTry.gif
├── raInMemoryJwtRefresh.gif
├── raInMemoryJwtTwoTabs.gif
└── refreshToken.gif
├── package.json
└── src
└── index.js
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at foss@marmelab.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | So you want to contribute to THIS_PROJECT? Awesome! Thank you in advance for your contribution. Here are a few guidelines that will help you along the way.
4 |
5 | ## What should I know before I get started?
6 |
7 | ## Code of Conduct
8 |
9 | This project and everyone participating in it is governed by the [Marmelab Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [foss@marmelab.com](mailto:foss@marmelab.com).
10 |
11 | ## Asking Questions
12 |
13 | Here, indicate the best way to ask questions: via a github issue, on [StackOverflow](https://stackoverflow.com/) ...
14 |
15 | ## Opening an Issue
16 |
17 | If you think you have found a bug, or have a new feature idea, please start by making sure it hasn't already been [reported or fixed](https://github.com/marmelab/THIS_PROJECT/issues?q=is%3Aissue+is%3Aclosed). You can search through existing issues and PRs to see if someone has reported one similar to yours.
18 |
19 | Next, create a new issue that briefly explains the problem, and provides a bit of background as to the circumstances that triggered it, and steps to reproduce it.
20 |
21 | ### Issue Guidelines
22 |
23 | Please use a succinct description. "doesn't work" doesn't help others find similar issues.
24 |
25 | Please don't group multiple topics into one issue, but instead each should be its own issue.
26 |
27 | And please don't just '+1' an issue. It spams the maintainers and doesn't help move the issue forward.
28 |
29 | ## Submitting a Pull Request
30 |
31 | THIS_PROJECT is a community project, so pull requests are always welcome, but before working on a large change, it is best to open an issue first to discuss it with the maintainers. In that case, prefix it with "[RFC]" (Request for Comments)
32 |
33 | When in doubt, keep your pull requests small. To give a PR the best chance of getting accepted, don't bundle more than one feature or bug fix per pull request. It's always best to create two smaller PRs than one big one.
34 |
35 | The core team prefix their PRs width "[WIP]" (Work in Progress) or "[RFR]" (ready for Review), don't hesitate to do the same to explain how far you are from completion.
36 |
37 | When adding new features or modifying existing, please attempt to include tests to confirm the new behaviour.
38 |
39 | ### Coding style
40 |
41 | You must follow the coding style of the existing files.
42 |
43 | ### Tests
44 |
45 | ```bash
46 | make test
47 | ```
48 |
49 | ## License
50 |
51 | By contributing your code to the marmelab/this-project GitHub repository, you agree to license your contribution under the THIS_PROJECT_LICENSE license.
52 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/Bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F41B Bug report"
3 | about: Something isn't working as expected. Please tell us!
4 |
5 | ---
6 |
7 |
8 |
9 | **What you were expecting:**
10 |
11 |
12 | **What happened instead:**
13 |
14 |
15 | **Steps to reproduce:**
16 |
17 |
18 | **Related code:**
19 |
24 |
25 | ```
26 | insert short code snippets here
27 | ```
28 |
29 | **Other information:**
30 |
31 |
32 | **Environment**
33 |
34 | * Last version that did not exhibit the issue (if applicable):
35 | * Browser:
36 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/Custom.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F4AC Support Question"
3 | about: If you have a "How to" question, please check out CONTRIBUTION.md!
4 |
5 | ---
6 |
7 | We primarily use GitHub as an issue tracker; for usage and support questions, please use the correct way to do it as indicated in the contribution guide Thanks! 😁.
8 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/Feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F680 Feature request"
3 | about: "I have a suggestion (and may want to implement it \U0001F642)!"
4 |
5 | ---
6 |
7 | **Is your feature request related to a problem? Please describe.**
8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
9 |
10 | **Describe the solution you'd like**
11 | A clear and concise description of what you want to happen.
12 |
13 | **Describe alternatives you've considered**
14 | A clear and concise description of any alternative solutions or features you've considered.
15 |
16 | **Additional context**
17 | Add any other context or screenshots about the feature request here.
18 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 |
4 |
5 |
6 | ## Related Issue
7 |
8 |
9 |
10 |
11 |
12 |
13 | ## ToDo list:
14 |
15 |
16 |
17 |
18 | - [ ] step 1.
19 | - [ ] step 2.
20 |
--------------------------------------------------------------------------------
/.github/repository-open-graph.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marmelab/ra-in-memory-jwt/6a30cf8c75717fdc4b5c04bf0dc50238b9367210/.github/repository-open-graph.png
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: minimal
2 | before_script:
3 | - make install
4 | script:
5 | - make test
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) [year] [fullname]
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: default install start test
2 |
3 | .DEFAULT_GOAL := help
4 |
5 | help:
6 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
7 |
8 | install: ## Install project's dependencies
9 | @echo "Install project deps"
10 |
11 | start: ## Start project
12 | @echo "Start the project"
13 |
14 | test: ## Launch the project's tests
15 | @echo "Launch the tests"
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ra-in-memory-jwt
2 |
3 |     
4 |
5 | Probably by routine or by *Stack Overflow syndrome*, we often use a [JSON Web Token(JWT)](https://tools.ietf.org/html/rfc7519) to manage this authentication between our frontend apps and their API. For convenience, we store this token in the browser's [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). But this is not a good practice, as Randall Degges explains in his article ["Please Stop Using Local Storage"](https://dev.to/rdegges/please-stop-using-local-storage-1i04). For the most curious, here is an example of how ["Stealing JWTs in localStorage via XSS"](https://medium.com/redteam/stealing-jwts-in-localstorage-via-xss-6048d91378a0).
6 |
7 | But then, how to use a JWT to manage authentication in a more secure way? `ra-in-memory-jwt` is an implementation of a solution proposed by the [Hasura](https://hasura.io) team in their article [The Ultimate Guide to handling JWTs on frontend clients](https://hasura.io/blog/best-practices-of-using-jwt-with-graphql/).
8 |
9 | You can find a detailed explanation of this implementation on the blog post [Handling JWT in Admin Apps the Right Way](https://marmelab.com/blog/2020/07/02/manage-your-jwt-react-admin-authentication-in-memory.html).
10 |
11 | ## Installation
12 |
13 | ### From npm
14 |
15 | ```bash
16 | npm install ra-in-memory-jwt
17 | ```
18 |
19 | ### From scratch
20 |
21 | The use of `ra-in-memory-jwt` is strongly linked to your API. Rather than using the npm package and the configuration options (see next part), you will probably save time to recreate the `innMemoryJWT.js` file from the [original file](https://github.com/marmelab/ra-in-memory-jwt/blob/master/src/index.js). And it will be one less dependency for your project!
22 |
23 | ## Configuration
24 |
25 | `ra-in-memory-jwt` must know the API endpoints to refresh the JWT. The default value is `/refresh-token`, but you can change it with the `setRefreshTokenEndpoint` method:
26 |
27 | ```javascript
28 | inMemoryJWT.setRefreshTokenEndpoint('http://localhost:8001/another/refresh-token-endpoint');
29 | ```
30 |
31 | ## Contributing
32 |
33 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
34 |
35 | To learn more about the contributions to this project, consult the [contribution guide](/.github/CONTRIBUTING.md).
36 |
37 | ## Maintainer
38 |
39 | [](https://github.com/alexisjanvier)
40 | [Alexis Janvier](https://github.com/alexisjanvier)
41 |
42 | ## License
43 |
44 | ra-in-memory-jwt is licensed under the [MIT License](LICENSE), courtesy of [Marmelab](http://marmelab.com).
45 |
--------------------------------------------------------------------------------
/demo/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: install start stop log
2 |
3 | export CURRENT_UID ?= $(id -u):$(id -g)
4 | export NODE_ENV ?= development
5 |
6 | DC_DEMO := docker-compose -p ra-inmemoryjwt-demo
7 |
8 | help: ## Display available commands
9 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
10 |
11 | # =====================================================================
12 | # Initialization ======================================================
13 | # =====================================================================
14 |
15 | install: ## Install all js deps
16 | @${DC_DEMO} run --rm --no-deps back ash -ci 'npm install'
17 | @${DC_DEMO} run --rm --no-deps admin ash -ci 'npm install'
18 |
19 | start: ## Start all service in containers
20 | ${DC_DEMO} up -d
21 |
22 | stop: ## Stop all containers
23 | ${DC_DEMO} down
24 |
25 | connect-admin: ## Connectback container
26 | ${DC_DEMO} exec admin ash
27 |
28 | connect-back: ## Connectback container
29 | ${DC_DEMO} exec back ash
30 |
31 | logs: ## Display all logs
32 | ${DC_DEMO} logs -f
33 |
34 | init: ## Init db with demo user
35 | $(DC_DEMO) exec back ash -ci 'npm run migrate:latest'
36 | $(DC_DEMO) exec back ash -ci 'USERNAME=myFirstUser PASSWORD=n33dToB3+Str0ng node ./cli/create-user.js'
37 |
--------------------------------------------------------------------------------
/demo/README.md:
--------------------------------------------------------------------------------
1 | # ra-in-memory-jwt Demo
2 |
3 | ## Prerequisites
4 |
5 | The demo of `ra-in-memory-jwt` is expected to start in [Docker](https://www.docker.com) et [Docker-Compose](https://docs.docker.com/compose/).
6 |
7 | ## Installation
8 |
9 | ```bash
10 | $ make install
11 | $ make init
12 | $ make start
13 | ```
14 |
15 | The default user is `myFirstUser/n33dToB3+Str0ng`.
--------------------------------------------------------------------------------
/demo/admin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "raInMemoryKWT-demo-admin",
3 | "version": "0.1.0",
4 | "private": true,
5 | "description": "Admin part of the ra-in-memory-jwt demo",
6 | "dependencies": {
7 | "lodash.omit": "4.5.0",
8 | "prop-type": "0.0.1",
9 | "query-string": "6.13.1",
10 | "react": "16.13.1",
11 | "react-admin": "3.7.1",
12 | "react-dom": "16.13.1",
13 | "react-scripts": "3.4.1"
14 | },
15 | "scripts": {
16 | "start": "react-scripts start",
17 | "build": "react-scripts build",
18 | "test": "react-scripts test",
19 | "eject": "react-scripts eject"
20 | },
21 | "eslintConfig": {
22 | "extends": "react-app"
23 | },
24 | "browserslist": {
25 | "production": [
26 | ">0.2%",
27 | "not dead",
28 | "not op_mini all"
29 | ],
30 | "development": [
31 | "last 1 chrome version",
32 | "last 1 firefox version",
33 | "last 1 safari version"
34 | ]
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/demo/admin/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marmelab/ra-in-memory-jwt/6a30cf8c75717fdc4b5c04bf0dc50238b9367210/demo/admin/public/favicon.ico
--------------------------------------------------------------------------------
/demo/admin/public/icon-48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marmelab/ra-in-memory-jwt/6a30cf8c75717fdc4b5c04bf0dc50238b9367210/demo/admin/public/icon-48x48.png
--------------------------------------------------------------------------------
/demo/admin/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | ra-in-memory-jwt demo
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/demo/admin/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow: *
4 |
--------------------------------------------------------------------------------
/demo/admin/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Admin, Resource } from 'react-admin';
3 |
4 | import myDataProvider from './dataProvider';
5 | import authProvider from './authProvider';
6 | import usersConfiguration from './users';
7 |
8 | const dataProvider = myDataProvider('http://localhost:8001/api');
9 | const App = () => (
10 |
11 |
12 |
13 | );
14 |
15 |
16 | export default App;
17 |
--------------------------------------------------------------------------------
/demo/admin/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | const { getByText } = render();
7 | const linkElement = getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/demo/admin/src/authProvider.js:
--------------------------------------------------------------------------------
1 | import inMemoryJWT from './inMemoryJWT';
2 |
3 | const authProvider = {
4 | login: ({ username, password }) => {
5 | const request = new Request('http://localhost:8001/authenticate', {
6 | method: 'POST',
7 | body: JSON.stringify({ username, password }),
8 | headers: new Headers({ 'Content-Type': 'application/json' }),
9 | credentials: 'include',
10 | });
11 | inMemoryJWT.setRefreshTokenEndpoint('http://localhost:8001/refresh-token');
12 | return fetch(request)
13 | .then((response) => {
14 | if (response.status < 200 || response.status >= 300) {
15 | throw new Error(response.statusText);
16 | }
17 | return response.json();
18 | })
19 | .then(({ token, tokenExpiry }) => {
20 | return inMemoryJWT.setToken(token, tokenExpiry);
21 | });
22 | },
23 |
24 | logout: () => {
25 | const request = new Request('http://localhost:8001/logout', {
26 | method: 'GET',
27 | headers: new Headers({ 'Content-Type': 'application/json' }),
28 | credentials: 'include',
29 | });
30 | inMemoryJWT.ereaseToken();
31 |
32 | return fetch(request).then(() => '/login');
33 | },
34 |
35 | checkAuth: () => {
36 | return inMemoryJWT.waitForTokenRefresh().then(() => {
37 | return inMemoryJWT.getToken() ? Promise.resolve() : Promise.reject();
38 | });
39 | },
40 |
41 | checkError: (error) => {
42 | const status = error.status;
43 | if (status === 401 || status === 403) {
44 | inMemoryJWT.ereaseToken();
45 | return Promise.reject();
46 | }
47 | return Promise.resolve();
48 | },
49 |
50 | getPermissions: () => {
51 | return inMemoryJWT.waitForTokenRefresh().then(() => {
52 | return inMemoryJWT.getToken() ? Promise.resolve() : Promise.reject();
53 | });
54 | },
55 | };
56 |
57 | export default authProvider;
58 |
--------------------------------------------------------------------------------
/demo/admin/src/dataProvider.js:
--------------------------------------------------------------------------------
1 | import { stringify } from 'query-string';
2 | import { fetchUtils } from 'ra-core';
3 |
4 | import inMemoryJWT from './inMemoryJWT';
5 |
6 | const getXTotalCountHeaderValue = (headers) => {
7 | if (!headers.has('x-total-count')) {
8 | throw new Error(
9 | 'The X-Total-Count header is missing in the HTTP Response.'
10 | );
11 | }
12 |
13 | return parseInt(headers.get('x-total-count'), 10);
14 | };
15 |
16 | const formatFilters = (filters) => {
17 | return Object.keys(filters).reduce((acc, filterKey) => {
18 | const [name, operator = 'eq'] = filterKey.split(':');
19 | return {
20 | ...acc,
21 | [name]: `${filters[filterKey]}:${operator}`,
22 | };
23 | }, {});
24 | };
25 |
26 | export default (apiUrl) => {
27 | const httpClient = (url) => {
28 | const options = {
29 | headers: new Headers({ Accept: 'application/json' }),
30 | };
31 | const token = inMemoryJWT.getToken();
32 |
33 | if (token) {
34 | options.headers.set('Authorization', `Bearer ${token}`);
35 | return fetchUtils.fetchJson(url, options);
36 | } else {
37 | inMemoryJWT.setRefreshTokenEndpoint('http://localhost:8001/refresh-token');
38 | return inMemoryJWT.getRefreshedToken().then((gotFreshToken) => {
39 | if (gotFreshToken) {
40 | options.headers.set('Authorization', `Bearer ${inMemoryJWT.getToken()}`);
41 | };
42 | return fetchUtils.fetchJson(url, options);
43 | });
44 | }
45 | };
46 |
47 | return {
48 | getList: (resource, params) => {
49 | const { page: currentPage, perPage } = params.pagination;
50 | const { field, order } = params.sort;
51 | const filters = params.filter;
52 | const query = {
53 | sortBy: field,
54 | orderBy: order,
55 | currentPage,
56 | perPage,
57 | ...formatFilters(filters),
58 | };
59 | const url = `${apiUrl}/${resource}?${stringify(query)}`;
60 |
61 | return httpClient(url).then(({ headers, json }) => {
62 | return {
63 | data: json,
64 | total: getXTotalCountHeaderValue(headers),
65 | };
66 | });
67 | },
68 |
69 | getOne: (resource, params) =>
70 | httpClient(`${apiUrl}/${resource}/${params.id}`).then(({ json }) => ({
71 | data: json,
72 | })),
73 |
74 | getMany: (resource, params) => {
75 | const filters = params.filter;
76 | const query = {
77 | sortBy: 'id',
78 | currentPage: 1,
79 | perPage: 100,
80 | ...formatFilters(filters),
81 | };
82 | const url = `${apiUrl}/${resource}?${stringify(query)}`;
83 |
84 | return httpClient(url).then(({ headers, json }) => {
85 | return {
86 | data: json,
87 | total: getXTotalCountHeaderValue(headers),
88 | };
89 | });
90 | },
91 |
92 | getManyReference: (resource, params) => {
93 | const filters = params.filter;
94 | const query = {
95 | sortBy: 'id',
96 | currentPage: 1,
97 | perPage: 100,
98 | ...formatFilters(filters),
99 | };
100 | const url = `${apiUrl}/${resource}?${stringify(query)}`;
101 |
102 | return httpClient(url).then(({ headers, json }) => {
103 | return {
104 | data: json,
105 | total: getXTotalCountHeaderValue(headers),
106 | };
107 | });
108 | },
109 |
110 | update: () => Promise.reject(),
111 | updateMany: () => Promise.reject(),
112 | create: () => Promise.reject(),
113 | delete: () => Promise.reject(),
114 | deleteMany: () => Promise.reject(),
115 | };
116 | };
117 |
--------------------------------------------------------------------------------
/demo/admin/src/inMemoryJWT.js:
--------------------------------------------------------------------------------
1 | const inMemoryJWTManager = () => {
2 | let inMemoryJWT = null;
3 | let isRefreshing = null;
4 | let logoutEventName = 'ra-logout';
5 | let refreshEndpoint = '/refresh-token';
6 | let refreshTimeOutId;
7 |
8 | const setLogoutEventName = name => logoutEventName = name;
9 | const setRefreshTokenEndpoint = endpoint => refreshEndpoint = endpoint;
10 |
11 | // This countdown feature is used to renew the JWT before it's no longer valid
12 | // in a way that is transparent to the user.
13 | const refreshToken = (delay) => {
14 | refreshTimeOutId = window.setTimeout(
15 | getRefreshedToken,
16 | delay * 1000 - 5000
17 | ); // Validity period of the token in seconds, minus 5 seconds
18 | };
19 |
20 | const abordRefreshToken = () => {
21 | if (refreshTimeOutId) {
22 | window.clearTimeout(refreshTimeOutId);
23 | }
24 | };
25 |
26 | const waitForTokenRefresh = () => {
27 | if (!isRefreshing) {
28 | return Promise.resolve();
29 | }
30 | return isRefreshing.then(() => {
31 | isRefreshing = null;
32 | return true;
33 | });
34 | }
35 |
36 | // The method make a call to the refresh-token endpoint
37 | // If there is a valid cookie, the endpoint will set a fresh jwt in memory.
38 | const getRefreshedToken = () => {
39 | const request = new Request(refreshEndpoint, {
40 | method: 'GET',
41 | headers: new Headers({ 'Content-Type': 'application/json' }),
42 | credentials: 'include',
43 | });
44 |
45 | isRefreshing = fetch(request)
46 | .then((response) => {
47 | if (response.status !== 200) {
48 | ereaseToken();
49 | global.console.log(
50 | 'Token renewal failure'
51 | );
52 | return { token: null };
53 | }
54 | return response.json();
55 | })
56 | .then(({ token, tokenExpiry }) => {
57 | if (token) {
58 | setToken(token, tokenExpiry);
59 | return true;
60 | }
61 | ereaseToken();
62 | return false;
63 | });
64 |
65 | return isRefreshing;
66 | };
67 |
68 |
69 | const getToken = () => inMemoryJWT;
70 |
71 | const setToken = (token, delay) => {
72 | inMemoryJWT = token;
73 | refreshToken(delay);
74 | return true;
75 | };
76 |
77 | const ereaseToken = () => {
78 | inMemoryJWT = null;
79 | abordRefreshToken();
80 | window.localStorage.setItem(logoutEventName, Date.now());
81 | return true;
82 | }
83 |
84 | // This listener will allow to disconnect a session of ra started in another tab
85 | window.addEventListener('storage', (event) => {
86 | if (event.key === logoutEventName) {
87 | inMemoryJWT = null;
88 | }
89 | });
90 |
91 | return {
92 | ereaseToken,
93 | getRefreshedToken,
94 | getToken,
95 | setLogoutEventName,
96 | setRefreshTokenEndpoint,
97 | setToken,
98 | waitForTokenRefresh,
99 | }
100 | };
101 |
102 | export default inMemoryJWTManager();
103 |
--------------------------------------------------------------------------------
/demo/admin/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | ReactDOM.render(
6 |
7 |
8 | ,
9 | document.getElementById('root')
10 | );
11 |
--------------------------------------------------------------------------------
/demo/admin/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/demo/admin/src/users/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Datagrid, TextField, DateField, ShowButton, ShowGuesser, List } from 'react-admin';
3 | import UserIcon from '@material-ui/icons/People';
4 |
5 | export const UserList = props => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | );
13 | };
14 |
15 | export default {
16 | icon: UserIcon,
17 | list: UserList,
18 | options: { label: 'Users' },
19 | show: ShowGuesser,
20 | };
21 |
--------------------------------------------------------------------------------
/demo/back/cli/create-user.js:
--------------------------------------------------------------------------------
1 | const knex = require('knex');
2 | const signale = require('signale');
3 |
4 | const knexConfig = require('../knexfile');
5 | const {
6 | hashPassword,
7 | isValidPassword,
8 | isValidUsername,
9 | } = require('../src/user-account/user');
10 |
11 | const pg = knex(knexConfig);
12 |
13 | const createUser = async () => {
14 | signale.info("Création d'un utilisateur");
15 | const { USERNAME: username, PASSWORD: password } = process.env;
16 | if (!username) {
17 | throw new Error(
18 | "Vous devez déclarer une variable d'environnement USERNAME avec un username valide pour pouvoir créer un utilisateur"
19 | );
20 | }
21 | const usernameValidation = isValidUsername(username);
22 | if (!usernameValidation.isValid) {
23 | throw new Error(usernameValidation.error);
24 | }
25 |
26 | if (!password) {
27 | throw new Error(
28 | "Vous devez déclarer une variable d'environnement PASSWORD avec un mot de passe de plus de 10 caractères pour pouvoir créer un utilisateur"
29 | );
30 | }
31 | const passwordValidation = isValidPassword(password);
32 | if (!passwordValidation.isValid) {
33 | throw new Error(passwordValidation.error);
34 | }
35 | const hashedPassword = await hashPassword(password);
36 | signale.info(`On va créer un utilisateur ${username}`);
37 | await pg('user_account').insert({
38 | username,
39 | password: hashedPassword,
40 | });
41 |
42 | return true;
43 | };
44 |
45 | createUser()
46 | .then(() => {
47 | signale.info('Le nouvel utilisateur a bien été créé.');
48 | process.exit(0);
49 | })
50 | .catch((error) => {
51 | signale.error(
52 | "Erreur lors de la création de l'utilisateur : ",
53 | error.message
54 | );
55 | process.exit(1);
56 | });
57 |
--------------------------------------------------------------------------------
/demo/back/knexfile.js:
--------------------------------------------------------------------------------
1 | const config = require('./src/config');
2 | const knexStringcase = require('knex-stringcase');
3 | const { attachPaginateRestList } = require('./src/toolbox/rest-list');
4 |
5 | attachPaginateRestList();
6 | const knexConfig = {
7 | client: 'pg',
8 | connection: config.db,
9 | migrations: {
10 | tableName: 'migrations',
11 | },
12 | pool: { min: 1, max: 7 },
13 | };
14 |
15 | module.exports = knexStringcase(knexConfig);
--------------------------------------------------------------------------------
/demo/back/migrations/20200512072137_init-db.js:
--------------------------------------------------------------------------------
1 | exports.up = async (knex) => {
2 | await knex.raw(`CREATE extension IF NOT EXISTS "uuid-ossp"`);
3 | await knex.schema.createTable('user_account', function (table) {
4 | table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
5 | table.string('username', 50).notNullable();
6 | table.string('password', 300).notNullable();
7 | table.dateTime('created_at').defaultTo(knex.fn.now());
8 | table.unique('username');
9 | });
10 | return knex.schema.createTable('refresh_token', function (table) {
11 | table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
12 | table.uuid('user_id');
13 | table
14 | .foreign('user_id')
15 | .references('user_account.id')
16 | .onDelete('CASCADE');
17 | table.boolean('remember_me').defaultTo(false);
18 | table.dateTime('created_at').defaultTo(knex.fn.now());
19 | table.integer('validity_timestamp').unsigned().notNullable();
20 | table.unique('user_id');
21 | });
22 | };
23 |
24 | exports.down = function () {
25 | return knex.schema
26 | .dropTable('user_account')
27 | .dropTable('refresh_token');
28 | };
29 |
--------------------------------------------------------------------------------
/demo/back/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "raInMemoryKWT-demo-back",
3 | "version": "0.1.0",
4 | "private": true,
5 | "description": "Backend part of the ra-in-memory-jwt demo",
6 | "scripts": {
7 | "start": "nodemon --watch src src/index.js --ext js,json,yml",
8 | "migrate:latest": "knex migrate:latest",
9 | "migrate:rollback": "knex migrate:rollback",
10 | "migrate:up": "knex migrate:up",
11 | "migrate:down": "knex migrate:down",
12 | "migrate:list": "knex migrate:list",
13 | "migrate:create": "knex migrate:make"
14 | },
15 | "dependencies": {
16 | "bcrypt": "5.0.0",
17 | "convict": "6.2.4",
18 | "jsonwebtoken": "8.5.1",
19 | "knex": "0.21.2",
20 | "knex-stringcase": "1.4.1",
21 | "koa": "2.13.0",
22 | "koa-bodyparser": "4.3.0",
23 | "koa-json-error": "3.1.2",
24 | "koa-router": "9.1.0",
25 | "koa-static": "5.0.0",
26 | "koa2-cors": "2.0.6",
27 | "owasp-password-strength-test": "1.3.0",
28 | "pg": "8.3.0",
29 | "signale": "1.4.0"
30 | },
31 | "devDependencies": {
32 | "nodemon": "2.0.4"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/demo/back/src/config.js:
--------------------------------------------------------------------------------
1 | const convict = require('convict');
2 |
3 | const config = convict({
4 | env: {
5 | doc: 'Application environment.',
6 | format: ['production', 'development', 'test'],
7 | default: '',
8 | env: 'NODE_ENV',
9 | },
10 | db: {
11 | host: {
12 | doc: 'Database host name/IP',
13 | format: '*',
14 | default: 'postgres',
15 | env: 'POSTGRES_HOST',
16 | },
17 | port: {
18 | doc: 'Database port',
19 | format: 'port',
20 | default: 5432,
21 | env: 'POSTGRES_PORT',
22 | },
23 | database: {
24 | doc: 'Database name',
25 | format: String,
26 | default: '',
27 | env: 'POSTGRES_DB',
28 | },
29 | user: {
30 | doc: 'Database user',
31 | format: String,
32 | default: '',
33 | env: 'POSTGRES_USER',
34 | },
35 | password: {
36 | doc: 'Database password',
37 | format: String,
38 | default: '',
39 | env: 'POSTGRES_PASSWORD',
40 | },
41 | },
42 | security: {
43 | bcryptSaltRounds: {
44 | doc: 'the cost of processing the salt used during password hashing',
45 | format: 'integer',
46 | default: 10,
47 | env: 'PASSWORD_SALT_ROUNDS',
48 | },
49 | jwt: {
50 | secretkey: {
51 | doc: 'the key used to sign the token with HMAC SHA256',
52 | format: String,
53 | default: 'thisIsTheDefaultJWTSecretKey',
54 | env: 'JWT_SECRET_KET',
55 | },
56 | expiration: {
57 | doc: 'duration in seconds of the token lifetime',
58 | format: 'integer',
59 | default: 600, // 10 min - Token life time must be short !
60 | env: 'JWT_EXPIRATION',
61 | },
62 | },
63 | refreshToken: {
64 | name: {
65 | doc: 'the name of the refresh token',
66 | format: String,
67 | default: 'jobBoardRefreshToken',
68 | env: 'REFRESH_TOKEN_NAME',
69 | },
70 | expiration: {
71 | doc: 'The token lifetime duration in seconds',
72 | format: 'integer',
73 | default: 3600, // 1 hour
74 | env: 'REFRESH_TOKEN_NAME',
75 | },
76 | rememberExpiration: {
77 | doc:
78 | 'The token lifetime if user ask to remember her/him in seconds',
79 | format: 'integer',
80 | default: 1296000, // 15 days
81 | env: 'REFRESH_TOKEN_NAME',
82 | },
83 | },
84 | signedCookie: {
85 | key1: {
86 | doc: 'First key used for signed cookies',
87 | format: String,
88 | default: 'The Torture Never Stops',
89 | env: 'SIGNED_COOKIE_KEY_1',
90 | },
91 | key2: {
92 | doc: 'Second key used for signed cookies',
93 | format: String,
94 | default: 'Watermelon in Easter Hay',
95 | env: 'SIGNED_COOKIE_KEY_2',
96 | },
97 | },
98 | },
99 | });
100 |
101 | config.validate({ allowed: 'strict' });
102 |
103 | module.exports = config.getProperties();
104 |
--------------------------------------------------------------------------------
/demo/back/src/index.js:
--------------------------------------------------------------------------------
1 | const Koa = require('koa');
2 | const cors = require('koa2-cors');
3 | const Router = require('koa-router');
4 | const error = require('koa-json-error');
5 | const bodyParser = require('koa-bodyparser');
6 |
7 | const dbMiddleware = require('./toolbox/middleware/db');
8 | const jwtMiddleware = require('./toolbox/authentication/jwtMiddleware');
9 | const authenticationRouter = require('./toolbox/authentication/router');
10 | const config = require('./config');
11 | const userRouter = require('./user-account/router');
12 |
13 | const app = new Koa();
14 |
15 | // Add keys for signed cookies
16 | app.keys = [
17 | config.security.signedCookie.key1,
18 | config.security.signedCookie.key2,
19 | ];
20 |
21 | // See https://github.com/zadzbw/koa2-cors for configuration
22 | app.use(
23 | cors({
24 | origin: 'http://localhost:8002',
25 | allowHeaders: ['Origin, Content-Type, Accept, Authorization'],
26 | exposeHeaders: ['X-Total-Count', 'Link'],
27 | credentials: true,
28 | })
29 | );
30 |
31 | const router = new Router();
32 | const env = process.env.NODE_ENV;
33 |
34 | /**
35 | * This method is used to format message return by the global error middleware
36 | *
37 | * @param {object} error - the catched error
38 | * @return {object} the content of the json error return
39 | */
40 | const formatError = (error) => {
41 | return {
42 | status: error.status,
43 | message: error.message,
44 | };
45 | };
46 |
47 | app.use(jwtMiddleware);
48 | app.use(bodyParser());
49 | app.use(error(formatError));
50 |
51 | router.get('/api', (ctx) => {
52 | ctx.body = { message: 'ra-in-memory-jwt API' };
53 | });
54 | app.use(router.routes()).use(router.allowedMethods());
55 | app.use(dbMiddleware);
56 | app.use(authenticationRouter.routes()).use(
57 | authenticationRouter.allowedMethods()
58 | );
59 | app.use(userRouter.routes()).use(userRouter.allowedMethods());
60 |
61 | app.listen(3001, () => global.console.log('API started on port 3001'));
62 |
--------------------------------------------------------------------------------
/demo/back/src/index.spec.js:
--------------------------------------------------------------------------------
1 | describe('mise en place des tests', () => {
2 | it('devrait être toujours vrai', () => {
3 | expect.assertions(1);
4 | expect('ccc').toStrictEqual('ccc');
5 | });
6 | });
7 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/authentication/jwtMiddleware.js:
--------------------------------------------------------------------------------
1 | const jwt = require('jsonwebtoken');
2 |
3 | const config = require('../../config');
4 |
5 | const jwtMiddleware = async (ctx, next) => {
6 | const authorization = ctx.request.headers.authorization || '';
7 | const [prefix, token] = authorization.split(' ');
8 | if (prefix === 'Bearer') {
9 | try {
10 | const decoded = jwt.verify(token, config.security.jwt.secretkey);
11 | ctx.state.jwt = decoded;
12 | } catch (error) {
13 | ctx.state.jwt = null;
14 | }
15 | }
16 |
17 | await next();
18 | };
19 |
20 | module.exports = jwtMiddleware;
21 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/authentication/refreshTokenRepository.js:
--------------------------------------------------------------------------------
1 | const { getDbClient } = require('../dbConnexion');
2 |
3 | const tableName = 'refresh_token';
4 |
5 | /**
6 | * Return a new refresh token for a specific user
7 | *
8 | * @param {object} data - The token data for token creation
9 | * @returns {Promise} - the refresh token
10 | */
11 | const createOneForUser = async (data) => {
12 | const client = getDbClient();
13 | return client(tableName)
14 | .insert(data)
15 | .returning('*')
16 | .catch((error) => ({ error }));
17 | };
18 |
19 | /**
20 | * Return a refresh token if exist
21 | *
22 | * @param {string} id - The token id
23 | * @returns {Promise} - the refresh token
24 | */
25 | const getOne = async (id) => {
26 | const client = getDbClient();
27 | return client(tableName)
28 | .first('*')
29 | .where({ id })
30 | .catch((error) => ({ error }));
31 | };
32 |
33 | /**
34 | * Return a user refresh token if exist
35 | *
36 | * @param {string} userId - The user id that owned the token
37 | * @returns {Promise} - the refresh token
38 | */
39 | const getOneByUserId = async (userId) => {
40 | const client = getDbClient();
41 | return client(tableName)
42 | .first('*')
43 | .where({ userId })
44 | .catch((error) => ({ error }));
45 | };
46 |
47 | /**
48 | * Delete refresh token if exist
49 | *
50 | * @param {string} id - The id's token to delete
51 | * @returns {Promise} - the deleted token id
52 | */
53 | const deleteOne = async (id) => {
54 | const client = getDbClient();
55 | return client(tableName)
56 | .where({ id })
57 | .del()
58 | .then((nbDeletion) => {
59 | return nbDeletion ? { id } : {};
60 | })
61 | .catch((error) => ({ error }));
62 | };
63 |
64 | module.exports = {
65 | createOneForUser,
66 | deleteOne,
67 | getOne,
68 | getOneByUserId,
69 | };
70 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/authentication/router.js:
--------------------------------------------------------------------------------
1 | const Router = require('koa-router');
2 | const bcrypt = require('bcrypt');
3 | const jwt = require('jsonwebtoken');
4 |
5 | const { getOneByUsername, getOne } = require('../../user-account/repository');
6 | const {
7 | createOneForUser: createRefreshToken,
8 | deleteOne: deleteRefreshToken,
9 | getOneByUserId: getExistingRefreshToken,
10 | getOne: getExistingRefreshTokenById,
11 | } = require('./refreshTokenRepository');
12 | const config = require('../../config');
13 |
14 | const router = new Router();
15 |
16 | router.post('/authenticate', async (ctx) => {
17 | const { username, password, rememberMe = false } = ctx.request.body;
18 |
19 | const user = await getOneByUsername(username);
20 |
21 | if (!user || user.error) {
22 | ctx.throw(401, user ? user.error : 'Invalid credentials.');
23 | return;
24 | }
25 |
26 | if (!bcrypt.compareSync(password, user.password)) {
27 | ctx.throw(401, 'Invalid credentials.');
28 | return;
29 | }
30 |
31 | // We check that there is not already a refresh token for this user.
32 | // If this is the case - which can happen when the same user logs on to two tabs or two browsers
33 | // we need to keep it to keep this refresh-token valid.
34 | // else, we'll create a new one.
35 | let refreshTokenId;
36 | const existingRefreshToken = await getExistingRefreshToken(user.id);
37 | const currentTimestamp = Math.floor(Date.now() / 1000);
38 | if (
39 | existingRefreshToken &&
40 | !existingRefreshToken.error &&
41 | existingRefreshToken.validityTimestamp > currentTimestamp
42 | ) {
43 | refreshTokenId = existingRefreshToken.id;
44 | } else {
45 | // If there was already a refresh token for the user
46 | // but that this one was no longer valid
47 | // we erase it so we can create a new one.
48 | if (existingRefreshToken && existingRefreshToken.id) {
49 | await deleteRefreshToken(existingRefreshToken.id);
50 | }
51 | const newTokenData = {
52 | userId: user.id,
53 | rememberMe,
54 | validity_timestamp: rememberMe
55 | ? currentTimestamp +
56 | config.security.refreshToken.rememberExpiration
57 | : currentTimestamp + config.security.refreshToken.expiration,
58 | };
59 | const newRefreshToken = await createRefreshToken(newTokenData);
60 |
61 | if (!newRefreshToken || newRefreshToken.error) {
62 | ctx.throw(
63 | newRefreshToken.error
64 | ? newRefreshToken.error.message
65 | : 'Error during refresh token creation'
66 | );
67 | return;
68 | }
69 |
70 | refreshTokenId = newRefreshToken[0].id;
71 | }
72 |
73 | const delay = rememberMe
74 | ? config.security.refreshToken.rememberExpiration * 1000
75 | : config.security.refreshToken.expiration * 1000;
76 | const tokenExpires = new Date(new Date().getTime() + delay);
77 | const cookieOptions = {
78 | expires: tokenExpires,
79 | httpOnly: true,
80 | overwrite: true,
81 | secure: false,
82 | signed: true,
83 | };
84 | ctx.cookies.set(
85 | config.security.refreshToken.name,
86 | refreshTokenId,
87 | cookieOptions
88 | );
89 |
90 | const token = jwt.sign({ username }, config.security.jwt.secretkey, {
91 | expiresIn: config.security.jwt.expiration,
92 | });
93 |
94 | ctx.body = {
95 | token: token,
96 | tokenExpiry: config.security.jwt.expiration,
97 | username: user.username,
98 | };
99 | });
100 |
101 | router.get('/refresh-token', async (ctx) => {
102 | const refreshTokenId = ctx.cookies.get(config.security.refreshToken.name, {
103 | signed: true,
104 | });
105 |
106 | const dbToken = await getExistingRefreshTokenById(refreshTokenId);
107 |
108 | if (!dbToken.id || dbToken.error) {
109 | ctx.throw(400, `The refresh token is not valid.`);
110 | return;
111 | }
112 |
113 | const currentTimestamp = Math.floor(Date.now() / 1000);
114 | if (dbToken.validityTimestamp <= currentTimestamp) {
115 | await deleteRefreshToken(refreshTokenId);
116 |
117 | ctx.throw(400, `The refresh token is expired.`);
118 | return;
119 | }
120 |
121 | const user = await getOne(dbToken.userId);
122 |
123 | if (!user || user.error) {
124 | ctx.throw(401, user.error || 'Invalid credentials.');
125 | return;
126 | }
127 |
128 | const token = jwt.sign(
129 | { username: user.username },
130 | config.security.jwt.secretkey,
131 | {
132 | expiresIn: config.security.jwt.expiration,
133 | }
134 | );
135 |
136 | ctx.body = {
137 | token: token,
138 | tokenExpiry: config.security.jwt.expiration,
139 | username: user.username,
140 | };
141 | });
142 |
143 | router.get('/logout', async (ctx) => {
144 | const refreshTokenId = ctx.cookies.get(config.security.refreshToken.name, {
145 | signed: true,
146 | });
147 |
148 | await deleteRefreshToken(refreshTokenId);
149 |
150 | const cookieOptions = {
151 | expires: new Date(new Date().getTime() - 1),
152 | };
153 | ctx.cookies.set(config.security.refreshToken.name, null, cookieOptions);
154 |
155 | ctx.body = { message: 'logout' };
156 | });
157 |
158 | module.exports = router;
159 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/dbConnexion.js:
--------------------------------------------------------------------------------
1 | const knex = require('knex');
2 | const knexfile = require('../../knexfile');
3 |
4 | let dbClient;
5 | module.exports = {
6 | getDbClient: function () {
7 | if (dbClient) return dbClient;
8 | dbClient = knex(knexfile);
9 | return dbClient;
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/middleware/db.js:
--------------------------------------------------------------------------------
1 | const knex = require('knex');
2 | const knexfile = require('../../../knexfile');
3 |
4 | const db = knex(knexfile);
5 |
6 | const dbConnexionMiddleware = async (ctx, next) => {
7 | ctx.db = db;
8 | await next();
9 | };
10 |
11 | module.exports = dbConnexionMiddleware;
12 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/rest-list/filters-helpers.js:
--------------------------------------------------------------------------------
1 | /**
2 | | operator | relevant for | explanation |
3 | | -------- | ----------------------------- | --------------------------- |
4 | | eq | string, number, date, boolean | Is equal to |
5 | | neq | string, number, date, boolean | Is not equal to |
6 | | gt | number, date | Is greater than |
7 | | gte | number, date | Is greater than or equal to |
8 | | lt | number, date | Is less than |
9 | | lte | number, date | Is less than or equal to |
10 | | in | string, number, date | Is in |
11 | | nin | string, number, date | Is not in |
12 | | %l% | string | Is %LIKE% |
13 | | %l | string | Is %LIKE |
14 | | l% | string | Is LIKE% |
15 | */
16 | const FILTER_OPERATOR_EQ = 'eq';
17 | const FILTER_OPERATOR_NEQ = 'neq';
18 | const FILTER_OPERATOR_GT = 'gt';
19 | const FILTER_OPERATOR_GTE = 'gte';
20 | const FILTER_OPERATOR_LT = 'lt';
21 | const FILTER_OPERATOR_LTE = 'lte';
22 | const FILTER_OPERATOR_IN = 'in';
23 | const FILTER_OPERATOR_NIN = 'nin';
24 | const FILTER_OPERATOR_PLP = '%l%';
25 | const FILTER_OPERATOR_PL = '%l';
26 | const FILTER_OPERATOR_LP = 'l%';
27 | const filterOperators = [
28 | FILTER_OPERATOR_EQ,
29 | FILTER_OPERATOR_NEQ,
30 | FILTER_OPERATOR_GT,
31 | FILTER_OPERATOR_GTE,
32 | FILTER_OPERATOR_LT,
33 | FILTER_OPERATOR_LTE,
34 | FILTER_OPERATOR_IN,
35 | FILTER_OPERATOR_NIN,
36 | FILTER_OPERATOR_LP,
37 | FILTER_OPERATOR_PL,
38 | FILTER_OPERATOR_PLP,
39 | ];
40 |
41 | /**
42 | * @typedef {Object} FilterParameters
43 | * @property {string} name - The name of the property to which the filter applies
44 | * @property {(string|number|boolean)} value - The value of the filter
45 | * @property {string} operator - The filter operator : eq, neq, lt, ...
46 | */
47 |
48 | /**
49 | * Method to clean the filters sent in query parameters
50 | *
51 | * @param {Object} filters from query parameters of type { foo: 'bar:eq', ... }
52 | * @param {Array} filterableFields the fields allowed to be used as a filter
53 | * @returns {Array.} An array of filter parameters
54 | */
55 | const filtersSanitizer = (filters, filterableFields) => {
56 | if (!filters || typeof filters !== 'object') {
57 | return [];
58 | }
59 |
60 | let sanitizedFilters = Object.keys(filters)
61 | .map((filterKey) => {
62 | let unparsedValue = filters[filterKey];
63 |
64 | if (unparsedValue === null) {
65 | return { name: filterKey, value: null, operator: 'eq' };
66 | }
67 |
68 | if (
69 | unparsedValue === undefined ||
70 | unparsedValue.trim().length == 0 ||
71 | !filterableFields.includes(filterKey)
72 | ) {
73 | return null;
74 | }
75 |
76 | let [value, operator] = unparsedValue.split(':');
77 |
78 | return {
79 | name: filterKey,
80 | value: value,
81 | operator:
82 | !operator || !filterOperators.includes(operator)
83 | ? FILTER_OPERATOR_EQ
84 | : operator,
85 | };
86 | })
87 | .filter((filter) => filter !== null);
88 |
89 | return sanitizedFilters;
90 | };
91 |
92 | module.exports = {
93 | FILTER_OPERATOR_EQ,
94 | FILTER_OPERATOR_GT,
95 | FILTER_OPERATOR_GTE,
96 | FILTER_OPERATOR_IN,
97 | FILTER_OPERATOR_LP,
98 | FILTER_OPERATOR_LT,
99 | FILTER_OPERATOR_LTE,
100 | FILTER_OPERATOR_NEQ,
101 | FILTER_OPERATOR_NIN,
102 | FILTER_OPERATOR_PL,
103 | FILTER_OPERATOR_PLP,
104 | filterOperators,
105 | filtersSanitizer,
106 | };
107 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/rest-list/filters-helpers.spec.js:
--------------------------------------------------------------------------------
1 | const { filtersSanitizer } = require('./filters-helpers');
2 |
3 | describe('Filters Helpers', () => {
4 | describe('filtersSanitizer', () => {
5 | it('should return an empty array if query filters are not set', () => {
6 | const defaultFilterableFields = ['foo', 'bar'];
7 | expect(
8 | filtersSanitizer(undefined, defaultFilterableFields)
9 | ).toEqual([]);
10 | });
11 |
12 | it('should remove all unfiltrable fields from query parameters', () => {
13 | const defaultFilterableFields = ['foo', 'bar'];
14 | expect(
15 | filtersSanitizer(
16 | { unfiltrable: 'yes' },
17 | defaultFilterableFields
18 | )
19 | ).toEqual([]);
20 | });
21 |
22 | it('should return valid filter from query parameters, with default operator eq', () => {
23 | const defaultFilterableFields = ['foo', 'bar'];
24 | expect(
25 | filtersSanitizer({ foo: 'yes' }, defaultFilterableFields)
26 | ).toEqual([{ name: 'foo', value: 'yes', operator: 'eq' }]);
27 | });
28 |
29 | it('should return valid filter from query parameters, with properly parsed operator', () => {
30 | const defaultFilterableFields = ['foo', 'bar'];
31 | expect(
32 | filtersSanitizer(
33 | { foo: '2020-05-02:gte' },
34 | defaultFilterableFields
35 | )
36 | ).toEqual([{ name: 'foo', value: '2020-05-02', operator: 'gte' }]);
37 | });
38 |
39 | it('should take several query parameters', () => {
40 | const defaultFilterableFields = ['foo', 'bar'];
41 | expect(
42 | filtersSanitizer(
43 | { foo: 'yes', bar: 'no' },
44 | defaultFilterableFields
45 | )
46 | ).toEqual([
47 | { name: 'foo', value: 'yes', operator: 'eq' },
48 | { name: 'bar', value: 'no', operator: 'eq' },
49 | ]);
50 | });
51 |
52 | it('should return valid filter and remove unfiltrable from query parameters', () => {
53 | const defaultFilterableFields = ['foo', 'bar'];
54 | expect(
55 | filtersSanitizer(
56 | { bar: 'yes', unfiltrable: 'yes' },
57 | defaultFilterableFields
58 | )
59 | ).toEqual([{ name: 'bar', value: 'yes', operator: 'eq' }]);
60 | });
61 |
62 | it('should remove empty valid filters from query parameters', () => {
63 | const defaultFilterableFields = ['foo', 'bar'];
64 | expect(
65 | filtersSanitizer(
66 | { foo: 'yes', bar: ' ' },
67 | defaultFilterableFields
68 | )
69 | ).toEqual([{ name: 'foo', value: 'yes', operator: 'eq' }]);
70 | });
71 |
72 | it('should not remove null valid filters from query parameters', () => {
73 | const defaultFilterableFields = ['foo', 'bar'];
74 | expect(
75 | filtersSanitizer(
76 | { foo: 'yes', bar: null },
77 | defaultFilterableFields
78 | )
79 | ).toEqual([
80 | { name: 'foo', value: 'yes', operator: 'eq' },
81 | { name: 'bar', value: null, operator: 'eq' },
82 | ]);
83 | });
84 |
85 | it('should remove undefined valid filters from query parameters', () => {
86 | const defaultFilterableFields = ['foo', 'bar'];
87 | expect(
88 | filtersSanitizer(
89 | { foo: 'yes', bar: undefined },
90 | defaultFilterableFields
91 | )
92 | ).toEqual([{ name: 'foo', value: 'yes', operator: 'eq' }]);
93 | });
94 | });
95 | });
96 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/rest-list/index.js:
--------------------------------------------------------------------------------
1 | const Knex = require('knex');
2 | const signale = require('signale');
3 |
4 | const {
5 | FILTER_OPERATOR_EQ,
6 | FILTER_OPERATOR_GT,
7 | FILTER_OPERATOR_GTE,
8 | FILTER_OPERATOR_IN,
9 | FILTER_OPERATOR_LP,
10 | FILTER_OPERATOR_LT,
11 | FILTER_OPERATOR_LTE,
12 | FILTER_OPERATOR_NEQ,
13 | FILTER_OPERATOR_NIN,
14 | FILTER_OPERATOR_PL,
15 | FILTER_OPERATOR_PLP,
16 | filtersSanitizer,
17 | } = require('./filters-helpers');
18 | const { paginationSanitizer } = require('./pagination-helpers');
19 | const { formatQueryParameters } = require('./query-parameters-helpers');
20 | const { sortSanitizer } = require('./sort-helpers');
21 |
22 | module.exports.attachPaginateRestList = function attachPaginateRestList() {
23 | Knex.QueryBuilder.extend('paginateRestList', function paginate({
24 | queryParameters = '',
25 | authorizedFilters = ['id'],
26 | authorizedSort = ['id'],
27 | debug = false,
28 | }) {
29 | const isFromStart = false;
30 | const isLengthAware = true;
31 | const {
32 | pagination: rawPagination,
33 | sort,
34 | filters,
35 | } = formatQueryParameters(queryParameters);
36 | const { perPage, currentPage } = paginationSanitizer(rawPagination);
37 | const { sortBy, orderBy } = sortSanitizer(sort, authorizedSort);
38 | const filtersParameters = filtersSanitizer(filters, authorizedFilters);
39 |
40 | if (debug) {
41 | signale.debug('queryParameters', queryParameters);
42 | signale.debug(
43 | 'Formated query parameters',
44 | formatQueryParameters(queryParameters)
45 | );
46 | signale.debug('perPage', perPage);
47 | signale.debug('currentPage', currentPage);
48 | signale.debug('sortBy', sortBy);
49 | signale.debug('orderBy', orderBy);
50 | signale.debug('filters', filtersParameters);
51 | }
52 |
53 | // Filter stuff
54 | if (filtersParameters && filtersParameters.length) {
55 | filtersParameters.map((filter) => {
56 | switch (filter.operator) {
57 | case FILTER_OPERATOR_EQ:
58 | this.andWhere(filter.name, '=', filter.value);
59 | break;
60 | case FILTER_OPERATOR_NEQ:
61 | this.andWhere(filter.name, '!=', filter.value);
62 | break;
63 | case FILTER_OPERATOR_LT:
64 | this.andWhere(filter.name, '<', filter.value);
65 | break;
66 | case FILTER_OPERATOR_LTE:
67 | this.andWhere(filter.name, '<=', filter.value);
68 | break;
69 | case FILTER_OPERATOR_GT:
70 | this.andWhere(filter.name, '>', filter.value);
71 | break;
72 | case FILTER_OPERATOR_GTE:
73 | this.andWhere(filter.name, '>=', filter.value);
74 | break;
75 | case FILTER_OPERATOR_PLP:
76 | this.andWhere(
77 | filter.name,
78 | 'ILIKE',
79 | `%${filter.value}%`
80 | );
81 | break;
82 | case FILTER_OPERATOR_PL:
83 | this.andWhere(filter.name, 'ILIKE', `%${filter.value}`);
84 | break;
85 | case FILTER_OPERATOR_LP:
86 | this.andWhere(filter.name, 'ILIKE', `${filter.value}%`);
87 | break;
88 | case FILTER_OPERATOR_IN:
89 | this.whereIn(filter.name, filter.value);
90 | break;
91 | case FILTER_OPERATOR_NIN:
92 | this.whereNotIn(filter.name, filter.value);
93 | break;
94 | default:
95 | debug &&
96 | signale.log(
97 | `The filter operator ${filter.operator} is not managed`
98 | );
99 | }
100 | });
101 | }
102 |
103 | // Sort stuff
104 | this.orderBy(sortBy, orderBy);
105 |
106 | // Pagination stuff
107 | const shouldFetchTotals =
108 | isLengthAware || currentPage === 1 || isFromStart;
109 | let pagination = {};
110 | let countQuery = null;
111 |
112 | const offset = isFromStart ? 0 : (currentPage - 1) * perPage;
113 | const limit = isFromStart ? perPage * currentPage : perPage;
114 |
115 | if (shouldFetchTotals) {
116 | countQuery = new this.constructor(this.client)
117 | .count('* as total')
118 | .from(
119 | this.clone().offset(0).clearOrder().as('__count__query__')
120 | )
121 | .first()
122 | .debug(this._debug);
123 | }
124 |
125 | // This will paginate the data itself
126 | this.offset(offset).limit(limit);
127 |
128 | return this.client.transaction(async (trx) => {
129 | const result = await this.transacting(trx);
130 |
131 | if (shouldFetchTotals) {
132 | const { total } = await countQuery.transacting(trx);
133 |
134 | pagination = {
135 | total: +total,
136 | lastPage: Math.ceil(total / perPage),
137 | };
138 | }
139 |
140 | // Add pagination data to paginator object
141 | pagination = {
142 | ...pagination,
143 | perPage,
144 | currentPage,
145 | from: offset,
146 | to: offset + result.length,
147 | };
148 |
149 | return { data: result, pagination };
150 | });
151 | });
152 | };
153 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/rest-list/pagination-helpers.js:
--------------------------------------------------------------------------------
1 | const querystring = require('querystring');
2 |
3 | /**
4 | * @typedef {Object} PaginationParameters
5 | * @property {number} perPage - The number of objects requested per page
6 | * @property {number} currentPage - The requested page number
7 | */
8 |
9 | /**
10 | * Function to clean the pagination sent in query parameters
11 | *
12 | * @param {PaginationParameters} pagination - pagination object from query parameters
13 | * @returns {PaginationParameters} Ready-to-use filters for the sql query
14 | */
15 | const paginationSanitizer = ({ perPage, currentPage }) => {
16 | const convertedPagination = {
17 | perPage: parseInt(perPage, 10) || 10,
18 | currentPage: parseInt(currentPage, 10) || 1,
19 | };
20 |
21 | return {
22 | perPage:
23 | convertedPagination.perPage < 1 ? 10 : convertedPagination.perPage,
24 | currentPage:
25 | convertedPagination.currentPage < 1
26 | ? 1
27 | : convertedPagination.currentPage,
28 | };
29 | };
30 |
31 | /**
32 | * Function to return a single pagination information
33 | *
34 | * @param {object}
35 | * @returns {String}
36 | * @example ; rel="self"
37 | */
38 | const linkHeaderItem = ({ resourceURI, currentPage, perPage, rel }) => {
39 | const params = {
40 | currentPage,
41 | perPage,
42 | };
43 | return `<${resourceURI}?${querystring.stringify(params)}>; rel="${rel}"`;
44 | };
45 |
46 | /**
47 | * Function to return a fill pagination information with
48 | * first, prev, self, next and last relations.
49 | *
50 | * @param {object}
51 | * @returns {String}
52 | */
53 | const formatPaginationToLinkHeader = ({ resourceURI, pagination = {} }) => {
54 | const { currentPage, perPage, lastPage } = pagination;
55 |
56 | if (!resourceURI || !currentPage || !perPage || !lastPage) {
57 | return null;
58 | }
59 |
60 | const prevPage =
61 | currentPage - 1 <= lastPage && currentPage - 1 > 0
62 | ? currentPage - 1
63 | : currentPage;
64 | const nextPage =
65 | currentPage + 1 <= lastPage ? currentPage + 1 : currentPage;
66 |
67 | let items = [
68 | { resourceURI, currentPage: 1, perPage, rel: 'first' },
69 | {
70 | resourceURI,
71 | currentPage: prevPage,
72 | perPage,
73 | rel: 'prev',
74 | },
75 | { resourceURI, currentPage, perPage, rel: 'self' },
76 | {
77 | resourceURI,
78 | currentPage: nextPage,
79 | perPage,
80 | rel: 'next',
81 | },
82 | {
83 | resourceURI,
84 | currentPage: lastPage,
85 | perPage,
86 | rel: 'last',
87 | },
88 | ];
89 |
90 | return items.map((item) => linkHeaderItem(item)).join(',');
91 | };
92 |
93 | module.exports = {
94 | paginationSanitizer,
95 | formatPaginationToLinkHeader,
96 | };
97 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/rest-list/pagination-helpers.spec.js:
--------------------------------------------------------------------------------
1 | const {
2 | paginationSanitizer,
3 | formatPaginationToLinkHeader,
4 | } = require('./pagination-helpers');
5 |
6 | describe('Pagination Helpers', () => {
7 | describe('paginationSanitizer', () => {
8 | it('should return string pagination params as integer if it possible', () => {
9 | expect(
10 | paginationSanitizer({ perPage: '12', currentPage: '2' })
11 | ).toEqual({ perPage: 12, currentPage: 2 });
12 | });
13 |
14 | it('should return default pagination if pagination array is empty', () => {
15 | expect(paginationSanitizer({})).toEqual({
16 | perPage: 10,
17 | currentPage: 1,
18 | });
19 | });
20 |
21 | it('should return default pagination if one of pagination params could not be cast as integer', () => {
22 | expect(
23 | paginationSanitizer({ perPage: 'douze', currentPage: '2' })
24 | ).toEqual({ perPage: 10, currentPage: 2 });
25 | expect(
26 | paginationSanitizer({ perPage: '12', currentPage: 'deux' })
27 | ).toEqual({ perPage: 12, currentPage: 1 });
28 | expect(
29 | paginationSanitizer({ perPage: {}, currentPage: '2' })
30 | ).toEqual({ perPage: 10, currentPage: 2 });
31 | expect(
32 | paginationSanitizer({ perPage: null, currentPage: '2' })
33 | ).toEqual({ perPage: 10, currentPage: 2 });
34 | });
35 |
36 | it('should remove the supernumerary parameters of the pagination array', () => {
37 | expect(
38 | paginationSanitizer({
39 | perPage: 22,
40 | currentPage: 3,
41 | notPage: 'foo',
42 | isPage: 'bar',
43 | })
44 | ).toEqual({ perPage: 22, currentPage: 3 });
45 | });
46 |
47 | it('should not accept negative value for perPage params', () => {
48 | expect(
49 | paginationSanitizer({
50 | perPage: -6,
51 | currentPage: 3,
52 | })
53 | ).toEqual({ perPage: 10, currentPage: 3 });
54 | });
55 |
56 | it('should not accept negative value for currentPage params', () => {
57 | expect(
58 | paginationSanitizer({
59 | perPage: 6,
60 | currentPage: -2,
61 | })
62 | ).toEqual({ perPage: 6, currentPage: 1 });
63 | });
64 | });
65 | describe('formatPaginationToLinkHeader', () => {
66 | it('should contain all pagination elements', () => {
67 | expect(
68 | formatPaginationToLinkHeader({
69 | resourceURI: '/api/resources',
70 | pagination: {
71 | currentPage: 3,
72 | perPage: 10,
73 | lastPage: 5,
74 | },
75 | })
76 | ).toEqual(
77 | [
78 | '; rel="first"',
79 | '; rel="prev"',
80 | '; rel="self"',
81 | '; rel="next"',
82 | '; rel="last"',
83 | ].join(',')
84 | );
85 | });
86 |
87 | it('should have same first, prev and self elements', () => {
88 | expect(
89 | formatPaginationToLinkHeader({
90 | resourceURI: '/api/resources',
91 | pagination: {
92 | currentPage: 1,
93 | perPage: 10,
94 | lastPage: 3,
95 | },
96 | })
97 | ).toEqual(
98 | [
99 | '; rel="first"',
100 | '; rel="prev"',
101 | '; rel="self"',
102 | '; rel="next"',
103 | '; rel="last"',
104 | ].join(',')
105 | );
106 | });
107 |
108 | it('should have same self, next and last elements', () => {
109 | expect(
110 | formatPaginationToLinkHeader({
111 | resourceURI: '/api/resources',
112 | pagination: {
113 | currentPage: 3,
114 | perPage: 10,
115 | lastPage: 3,
116 | },
117 | })
118 | ).toEqual(
119 | [
120 | '; rel="first"',
121 | '; rel="prev"',
122 | '; rel="self"',
123 | '; rel="next"',
124 | '; rel="last"',
125 | ].join(',')
126 | );
127 | });
128 |
129 | it('should have same first, prev, self, next and last elements', () => {
130 | expect(
131 | formatPaginationToLinkHeader({
132 | resourceURI: '/api/resources',
133 | pagination: {
134 | currentPage: 1,
135 | perPage: 10,
136 | lastPage: 1,
137 | },
138 | })
139 | ).toEqual(
140 | [
141 | '; rel="first"',
142 | '; rel="prev"',
143 | '; rel="self"',
144 | '; rel="next"',
145 | '; rel="last"',
146 | ].join(',')
147 | );
148 | });
149 |
150 | it('should contain return null if any element is missing', () => {
151 | expect(
152 | formatPaginationToLinkHeader({
153 | resourceURI: '/api/resources',
154 | pagination: {
155 | currentPage: 3,
156 | perPage: 10,
157 | },
158 | })
159 | ).toBeNull();
160 | expect(
161 | formatPaginationToLinkHeader({
162 | resourceURI: '/api/resources',
163 | pagination: {
164 | currentPage: 3,
165 | lastPage: 5,
166 | },
167 | })
168 | ).toBeNull();
169 | expect(
170 | formatPaginationToLinkHeader({
171 | pagination: {
172 | currentPage: 3,
173 | lastPage: 5,
174 | },
175 | })
176 | ).toBeNull();
177 | expect(
178 | formatPaginationToLinkHeader({
179 | resourceURI: '/api/resources',
180 | })
181 | ).toBeNull();
182 | });
183 | });
184 | });
185 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/rest-list/query-parameters-helpers.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {Object} SortParameters
3 | * @property {string} sortBy - The name of the property to which the sort applies
4 | * @property {string} orderBy - The sort order. Could be 'ASC' or 'DESC'
5 | */
6 |
7 | /**
8 | * @typedef {Object} PaginationParameters
9 | * @property {number} perPage - The number of objects requested per page
10 | * @property {number} currentPage - The requested page number
11 | */
12 |
13 | /**
14 | * @typedef {Object} FormatedQueryParameters
15 | * @property {(SortParameters || null)} sort - The sort parameters
16 | * @property {PaginationParameters} pagination - The pagination parameters
17 | * @property {Object} filters - The filters with name as key and value:operator as value
18 | */
19 |
20 | /**
21 | * Format the parameters from the query
22 | *
23 | * @module rest-list
24 | * @param {Object} query - The query parameters
25 | * @returns {FormatedQueryParameters} The extracted parameters, ready for sanitizing
26 | */
27 | const formatQueryParameters = ({
28 | sortBy,
29 | orderBy,
30 | currentPage,
31 | perPage,
32 | ...filters
33 | } = {}) => {
34 | return {
35 | sort: sortBy ? { sortBy, orderBy: orderBy || 'ASC' } : null,
36 | pagination: {
37 | currentPage: currentPage || 1,
38 | perPage: perPage || 10,
39 | },
40 | filters,
41 | };
42 | };
43 |
44 | module.exports = {
45 | formatQueryParameters,
46 | };
47 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/rest-list/query-parameters-helpers.spec.js:
--------------------------------------------------------------------------------
1 | const { formatQueryParameters } = require('./query-parameters-helpers');
2 |
3 | describe('Query Parameters Helpers', () => {
4 | describe('formatQueryParameters', () => {
5 | it('should return an empty object of filters, null sort and default pagination without query params', () => {
6 | expect(formatQueryParameters()).toEqual({
7 | filters: {},
8 | sort: null,
9 | pagination: { currentPage: 1, perPage: 10 },
10 | });
11 | });
12 |
13 | it('should split query params into filters, sort and pagination props', () => {
14 | expect(
15 | formatQueryParameters({
16 | sortBy: 'name',
17 | orderBy: 'DESC',
18 | perPage: 10,
19 | currentPage: 3,
20 | age: 'gte:40',
21 | })
22 | ).toEqual({
23 | filters: { age: 'gte:40' },
24 | sort: { sortBy: 'name', orderBy: 'DESC' },
25 | pagination: { currentPage: 3, perPage: 10 },
26 | });
27 | });
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/rest-list/sort-helpers.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {Object} SortParameters
3 | * @property {string} sortBy - The name of the property to which the sort applies
4 | * @property {string} orderBy - The sort order. Could be 'ASC' or 'DESC'
5 | */
6 |
7 | /**
8 | * Method to clean the sort sent in query parameters
9 | *
10 | * @param {SortParameters} sort - sort from query parameters
11 | * @param {Array} sortableFields the fields allowed to be used as a sort
12 | * @returns {Array} Ready-to-use filters for the sql query
13 | */
14 | const sortSanitizer = (sort, sortableFields) => {
15 | if (!sort) {
16 | return { sortBy: sortableFields[0], orderBy: 'ASC' };
17 | }
18 | const { sortBy, orderBy } = sort;
19 | if (
20 | orderBy === undefined ||
21 | sortBy === undefined ||
22 | !sortableFields.includes(sortBy)
23 | ) {
24 | return { sortBy: sortableFields[0], orderBy: 'ASC' };
25 | }
26 |
27 | if (!['ASC', 'DESC'].includes(orderBy)) {
28 | return { sortBy, orderBy: 'ASC' };
29 | }
30 |
31 | return { sortBy, orderBy };
32 | };
33 |
34 | module.exports = {
35 | sortSanitizer,
36 | };
37 |
--------------------------------------------------------------------------------
/demo/back/src/toolbox/rest-list/sort-helpers.spec.js:
--------------------------------------------------------------------------------
1 | const { sortSanitizer } = require('./sort-helpers');
2 |
3 | describe('Sort Helpers', () => {
4 | describe('sortSanitizer', () => {
5 | it('should return the first sortable field ASC if sortBy is not set', () => {
6 | const defaultSortableFields = ['foo', 'bar'];
7 | expect(
8 | sortSanitizer(
9 | { sortBy: undefined, orderBy: 'DESC' },
10 | defaultSortableFields
11 | )
12 | ).toEqual({ sortBy: 'foo', orderBy: 'ASC' });
13 | });
14 |
15 | it('should return the first sortable field ASC if orderBy is not set', () => {
16 | const defaultSortableFields = ['foo', 'bar'];
17 | expect(
18 | sortSanitizer(
19 | { sortBy: 'bar', orderBy: undefined },
20 | defaultSortableFields
21 | )
22 | ).toEqual({ sortBy: 'foo', orderBy: 'ASC' });
23 | });
24 |
25 | it('should return the first sortable field ASC if query sort is not a sortable field', () => {
26 | const defaultSortableFields = ['foo', 'bar'];
27 | expect(
28 | sortSanitizer(
29 | { sortBy: 'notSortable', orderBy: 'DESC' },
30 | defaultSortableFields
31 | )
32 | ).toEqual({ sortBy: 'foo', orderBy: 'ASC' });
33 | });
34 |
35 | it('should replace the sort order with ASC if the query param sort order is not valid', () => {
36 | const defaultSortableFields = ['foo', 'bar'];
37 | expect(
38 | sortSanitizer(
39 | { sortBy: 'bar', orderBy: 'horizontal' },
40 | defaultSortableFields
41 | )
42 | ).toEqual({ sortBy: 'bar', orderBy: 'ASC' });
43 | });
44 |
45 | it('should remove the supernumerary parameters of the sort object', () => {
46 | const defaultSortableFields = ['foo', 'bar'];
47 | expect(
48 | sortSanitizer(
49 | { sortBy: 'bar', orderBy: 'DESC', nonsense: 'this' },
50 | defaultSortableFields
51 | )
52 | ).toEqual({ sortBy: 'bar', orderBy: 'DESC' });
53 | });
54 |
55 | it('should return a well formated sort from query parameter', () => {
56 | const defaultSortableFields = ['foo', 'bar'];
57 | expect(
58 | sortSanitizer(
59 | { sortBy: 'bar', orderBy: 'DESC' },
60 | defaultSortableFields
61 | )
62 | ).toEqual({ sortBy: 'bar', orderBy: 'DESC' });
63 | });
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/demo/back/src/user-account/repository.js:
--------------------------------------------------------------------------------
1 | const { getDbClient } = require('../toolbox/dbConnexion');
2 |
3 | const tableName = `user_account`;
4 | const authorizedFilters = ['username', 'createdAt'];
5 | const authorizedSort = ['id', 'username', 'createdAt'];
6 |
7 | /**
8 | * Return paginated and filtered list of users
9 | *
10 | * @param {object} queryParameters - An object of query parameters from Koa
11 | * @returns {Promise} - paginated object with paginated users list and pagination
12 | */
13 | const getPaginatedList = async (queryParameters) => {
14 | const client = getDbClient();
15 | return client(tableName)
16 | .select('id', 'username', 'createdAt')
17 | .paginateRestList({
18 | queryParameters,
19 | authorizedFilters,
20 | authorizedSort,
21 | })
22 | .then(({ data, pagination }) => ({
23 | users: data,
24 | pagination,
25 | }))
26 | .catch((error) => ({ error }));
27 | };
28 |
29 | /**
30 | * Return a user account if exist
31 | *
32 | * @param {string} username - The searched username
33 | * @returns {Promise} - the user
34 | */
35 | const getOneByUsername = async (username) => {
36 | const client = getDbClient();
37 | return client(tableName)
38 | .first('*')
39 | .where({ username })
40 | .catch((error) => ({ error }));
41 | };
42 |
43 | /**
44 | * Return a user account if exist
45 | *
46 | * @param {string} id - The user id
47 | * @returns {Promise} - the user
48 | */
49 | const getOne = async (id) => {
50 | const client = getDbClient();
51 | return client(tableName)
52 | .first('*')
53 | .where({ id })
54 | .catch((error) => ({ error }));
55 | };
56 |
57 | module.exports = {
58 | getPaginatedList,
59 | getOne,
60 | getOneByUsername,
61 | };
62 |
--------------------------------------------------------------------------------
/demo/back/src/user-account/router.js:
--------------------------------------------------------------------------------
1 | const Router = require('koa-router');
2 |
3 | const {
4 | getOne,
5 | getPaginatedList,
6 | } = require('./repository');
7 | const {
8 | formatPaginationToLinkHeader,
9 | } = require('../toolbox/rest-list/pagination-helpers');
10 |
11 | const router = new Router({
12 | prefix: '/api/users',
13 | });
14 |
15 | router.use(async (ctx, next) => {
16 | if (
17 | !ctx.state.jwt
18 | ) {
19 | ctx.throw(401, "You don't have the authorization to make this query");
20 |
21 | return;
22 | }
23 |
24 | await next();
25 | });
26 |
27 | router.get('/', async (ctx) => {
28 | const { users, pagination, error } = await getPaginatedList(ctx.query);
29 |
30 | if (error) {
31 | const explainedError = new Error(error.message);
32 | explainedError.status = 500;
33 |
34 | throw explainedError;
35 | }
36 |
37 | const linkHeaderValue = formatPaginationToLinkHeader({
38 | resourceURI: '/api/users',
39 | pagination,
40 | });
41 |
42 | ctx.set('X-Total-Count', pagination.total);
43 | if (linkHeaderValue) {
44 | ctx.set('Link', linkHeaderValue);
45 | }
46 | ctx.body = users;
47 | });
48 |
49 | router.get('/:userId', async (ctx) => {
50 | const user = await getOne(ctx.params.userId);
51 |
52 | if (!user.id) {
53 | const explainedError = new Error(
54 | `The user of id ${ctx.params.organizationId} does not exist.`
55 | );
56 | explainedError.status = 404;
57 |
58 | throw explainedError;
59 | }
60 |
61 | if (user.error) {
62 | const explainedError = new Error(user.error.message);
63 | explainedError.status = 400;
64 |
65 | throw explainedError;
66 | }
67 |
68 | ctx.body = user;
69 | });
70 |
71 | module.exports = router;
72 |
--------------------------------------------------------------------------------
/demo/back/src/user-account/user.js:
--------------------------------------------------------------------------------
1 | const owasp = require('owasp-password-strength-test');
2 | const bcrypt = require('bcrypt');
3 |
4 | const config = require('../config');
5 |
6 | owasp.config({
7 | allowPassphrases: true,
8 | maxLength: 128,
9 | minLength: 10,
10 | minPhraseLength: 20,
11 | minOptionalTestsToPass: 4,
12 | });
13 |
14 | /**
15 | * Method to check if a string is a valid username
16 | *
17 | * @param {string} username
18 | * @returns {object} an object with a boolean key isValid and an optionnal key error to describe error
19 | */
20 | const isValidUsername = (username) => {
21 | const usernameRegex = /^[a-zA-Z0-9-]{3,20}$/;
22 |
23 | const isValid = usernameRegex.test(String(username));
24 | const error =
25 | 'Un username ne doit contenir que des lettres en majuscule ou minuscule et des nombres entiers ou des -, et doit contenir entre 3 et 20 caractères';
26 |
27 | return isValid ? { isValid } : { isValid, error };
28 | };
29 |
30 | /**
31 | * Method to check if a string is a valid password
32 | *
33 | * @param {string} password
34 | * @returns {object} an object with a boolean key isValid and an optionnal key error to describe error
35 | */
36 | const isValidPassword = (password) => {
37 | const passwordTest = owasp.test(password);
38 | let error = null;
39 | if (passwordTest.requiredTestErrors.length) {
40 | error = `Le mot de passe n'est pas valide : ${passwordTest.requiredTestErrors.join(
41 | ', '
42 | )}`;
43 | }
44 | if (!error && !passwordTest.strong) {
45 | error = `Le mot de passe n'est pas assez complexe : ${passwordTest.optionalTestErrors.join(
46 | ', '
47 | )}`;
48 | }
49 |
50 | return error ? { isValid: false, error } : { isValid: true };
51 | };
52 |
53 | /**
54 | * Method hash a plain text password with bcrypt
55 | *
56 | * @param {string} plainTextPassword
57 | * @returns {Promise} A promise that will return the hashed password ready for secure storage
58 | */
59 | const hashPassword = (plainTextPassword) =>
60 | bcrypt.hash(plainTextPassword, config.security.bcryptSaltRounds);
61 |
62 | module.exports = {
63 | hashPassword,
64 | isValidPassword,
65 | isValidUsername,
66 | };
67 |
--------------------------------------------------------------------------------
/demo/back/src/user-account/user.spec.js:
--------------------------------------------------------------------------------
1 | const { isValidUsername, isValidPassword } = require('./user');
2 |
3 | describe('User methods', () => {
4 | describe('isValidUsername', () => {
5 | it.each([
6 | ['us'],
7 | ['usernameusernameusern'],
8 | ['user name'],
9 | ['user_name'],
10 | ['user&name'],
11 | ['user/name'],
12 | ])('%s should not be a valid username', (username) => {
13 | const testUsername = isValidUsername(username);
14 | expect(testUsername.isValid).toBeFalsy();
15 | });
16 |
17 | it.each([
18 | ['username'],
19 | ['user-name'],
20 | ['iser0name'],
21 | ['UserName'],
22 | ['userName-24'],
23 | ])('%s should be a valid username', (username) => {
24 | const testUsername = isValidUsername(username);
25 | expect(testUsername.isValid).toBeTruthy();
26 | });
27 | });
28 |
29 | describe('isValidPassword', () => {
30 | it.each([['password'], ['P===°hjkN']])(
31 | '%s should not be a valid password',
32 | (password) => {
33 | const testPassword = isValidPassword(password);
34 | expect(testPassword.isValid).toBeFalsy();
35 | expect(testPassword.error).toContain(
36 | "Le mot de passe n'est pas valide"
37 | );
38 | }
39 | );
40 |
41 | it.each([['azertyazerty']])(
42 | '%s should not be a enough complex password',
43 | (password) => {
44 | const testPassword = isValidPassword(password);
45 | expect(testPassword.isValid).toBeFalsy();
46 | expect(testPassword.error).toContain(
47 | "Le mot de passe n'est pas assez complexe"
48 | );
49 | }
50 | );
51 |
52 | it.each([['n33dToB3=Str0ng']])(
53 | '%s should not be a valid password',
54 | (password) => {
55 | const testPassword = isValidPassword(password);
56 | expect(testPassword.isValid).toBeTruthy();
57 | }
58 | );
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/demo/demo.env:
--------------------------------------------------------------------------------
1 | NODE_ENV=development
2 |
3 | POSTGRES_USER=ra-demo-user
4 | POSTGRES_DB=ra-in-memory-jwt
5 | POSTGRES_PASSWORD=ra-demo-pw
6 | POSTGRES_HOST=postgres
--------------------------------------------------------------------------------
/demo/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.5"
2 |
3 | services:
4 | admin:
5 | image: node:13-alpine
6 | volumes:
7 | - ./admin:/admin
8 | working_dir: "/admin"
9 | user: ${CURRENT_UID}
10 | ports:
11 | - 8002:3000
12 | command: "npm run start"
13 | depends_on:
14 | - back
15 | environment:
16 | - NODE_ENV=development
17 | - HOST=0.0.0.0
18 | - BROWSER=none
19 | - CI=true
20 |
21 | back:
22 | image: node:13-alpine
23 | volumes:
24 | - ./back:/back
25 | working_dir: "/back"
26 | user: ${CURRENT_UID}
27 | ports:
28 | - 8001:3001
29 | depends_on:
30 | - postgres
31 | command: "npm run start"
32 | env_file:
33 | - ./demo.env
34 |
35 | postgres:
36 | image: postgres:12.2
37 | volumes:
38 | - raInMemoryJWTDemo-pgData:/var/lib/postgresql/data
39 | ports:
40 | - 5432:5432
41 | env_file:
42 | - ./demo.env
43 |
44 | volumes:
45 | raInMemoryJWTDemo-pgData:
46 |
--------------------------------------------------------------------------------
/doc/blogPostFr.md:
--------------------------------------------------------------------------------
1 | Afin de sécuriser au mieux l'authentification de React-admin, voyons comment stocker un Json Web Token en mémoire plutôt que dans le localStorage du navigateur.
2 |
3 | React-admin s'appuie sur un très efficace [authProvider](https://marmelab.com/react-admin/Authentication.html) pour gérer l'authentification. Et sans doute par habitude ou mimétisme, on utilise souvent sur un [JSON Web Token(JWT)](https://tools.ietf.org/html/rfc7519) pour transmettre cette authentification entre React-admin et l'API, JWT que l'on stocke ensuite par commodité dans le [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) du navigateur. Mais ce n'est pourtant pas une bonne pratique, comme l'explique par exemple Randall Degges dans son article ["Please Stop Using Local Storage"](https://dev.to/rdegges/please-stop-using-local-storage-1i04). Et pour le plus curieux, voici par exemple comment ["Stealing JWTs in localStorage via XSS"](https://medium.com/redteam/stealing-jwts-in-localstorage-via-xss-6048d91378a0).
4 |
5 | Mais alors comment utiliser un JWT pour gérer son authentification React-admin de manière plus sécurisée ? Ce post de blog va illustrer une implémentation du principe proposé par l'équipe d'[Hasura](https://hasura.io) dans leur article [The Ultimate Guide to handling JWTs on frontend clients](https://hasura.io/blog/best-practices-of-using-jwt-with-graphql/). Ce principe consiste "tout simplement" à stocker ce jeton en mémoire.
6 |
7 | Ce qui n'est pas si simple en fait !
8 |
9 | ## Première mise en place
10 |
11 | Partons du postulat que l'on a une API possédant une route d'authentification qui en cas de succès retournera un JWT. Voici un exemple d'une telle implémentation avec [Koa](https://koajs.com/) et [node-jsonwebtoken](https://github.com/auth0/node-jsonwebtoken#readme) :
12 |
13 | ```javascript
14 | router.post('/authenticate', async (ctx) => {
15 | const { username, password } = ctx.request.body;
16 |
17 | const user = await getOneByUsername(username);
18 |
19 | if (!user || user.error) {
20 | ctx.throw(401, user ? user.error : 'Invalid credentials.');
21 | return;
22 | }
23 |
24 | if (!bcrypt.compareSync(password, user.password)) {
25 | ctx.throw(401, 'Invalid credentials.');
26 | return;
27 | }
28 |
29 | const token = jwt.sign({ username }, config.security.jwt.secretkey, {
30 | expiresIn: config.security.jwt.expiration,
31 | });
32 |
33 | ctx.body = {
34 | token: token,
35 | tokenExpiry: config.security.jwt.expiration,
36 | };
37 | });
38 |
39 | ```
40 |
41 | > Ici le code est minimal, vous pourrez trouver un exemple plus complet dans le code de la démo sur le dépôt de [ra-in-memory-jwt](https://github.com/marmelab/ra-in-memory-jwt/tree/master/demo).
42 |
43 | Et voici une première version de `ra-in-memory-jwt` qui va nous servir à stocker le jeton obtenu lors de l'authentification, en mémoire, et non pas dans le `localStorage` :
44 |
45 | ```javascript
46 | // inMemoryJwt.js
47 | const inMemoryJWTManager = () => {
48 | let inMemoryJWT = null;
49 |
50 | const getToken = () => inMemoryJWT;
51 |
52 | const setToken = (token) => {
53 | inMemoryJWT = token;
54 | return true;
55 | };
56 |
57 | const ereaseToken = () => {
58 | inMemoryJWT = null;
59 | return true;
60 | }
61 |
62 | return {
63 | ereaseToken,
64 | getToken,
65 | setToken,
66 | }
67 | };
68 |
69 | export default inMemoryJWTManager();
70 | ```
71 |
72 | On profite donc d'une [closure](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures) pour instancier la variable `inMemoryJWT` qui portera en mémoire notre JWT, puisque l'on execute la fonction inMemoryJWTManager lors de l'export.
73 |
74 | Voyons maintenant comment utiliser ce `inMemoryJWTManager` dans une application React-admin basique. Tout d'abord, nous déclarons une application `App` :
75 |
76 | ```javascript
77 | // App.js
78 | import React from 'react';
79 | import { Admin, Resource } from 'react-admin';
80 |
81 | import myDataProvider from './dataProvider';
82 | import authProvider from './authProvider';
83 | import usersConfiguration from './users';
84 |
85 | const dataProvider = myDataProvider('http://localhost:8001/api');
86 | const App = () => (
87 |
88 |
89 |
90 | );
91 |
92 | export default App;
93 | ```
94 |
95 | Ensuite, il faut configurer le fournisseur d'authentification, le `authProvider` :
96 |
97 | ```javascript
98 | // in authProvider.js
99 | import inMemoryJWT from 'ra-in-memory-jwt';
100 |
101 | const authProvider = {
102 | login: ({ username, password }) => {
103 | const request = new Request('http://localhost:8001/authenticate', {
104 | method: 'POST',
105 | body: JSON.stringify({ username, password }),
106 | headers: new Headers({ 'Content-Type': 'application/json' })
107 | });
108 | return fetch(request)
109 | .then((response) => {
110 | if (response.status < 200 || response.status >= 300) {
111 | throw new Error(response.statusText);
112 | }
113 | return response.json();
114 | })
115 | .then(({ token }) => inMemoryJWT.setToken(token));
116 | },
117 | logout: () => {
118 | inMemoryJWT.ereaseToken();
119 | return Promise.resolve();
120 | },
121 |
122 | checkAuth: () => {
123 | return inMemoryJWT.getToken() ? Promise.resolve() : Promise.reject();
124 | },
125 |
126 | checkError: (error) => {
127 | const status = error.status;
128 | if (status === 401 || status === 403) {
129 | inMemoryJWT.ereaseToken();
130 | return Promise.reject();
131 | }
132 | return Promise.resolve();
133 | },
134 |
135 | getPermissions: () => {
136 | return inMemoryJWT.getToken() ? Promise.resolve() : Promise.reject();
137 | },
138 | };
139 |
140 | export default authProvider;
141 | ```
142 |
143 | Puis il faut configurer le fournisseur de données, le `dataProvider`. En effet, c'est ce `dataProvider` qui se chargera de transmettre le JWT à l'API, via un en-tête http `Authorization` :
144 |
145 | ```javascript
146 | // in dataProvider.js
147 | import { fetchUtils } from 'ra-core';
148 | import inMemoryJWT from 'ra-in-memory-jwt';
149 |
150 | export default (apiUrl) => {
151 | const httpClient = (url) => {
152 | const options = {
153 | headers: new Headers({ Accept: 'application/json' }),
154 | };
155 | const token = inMemoryJWT.getToken();
156 | if (token) {
157 | options.headers.set('Authorization', `Bearer ${token}`);
158 | }
159 |
160 | return fetchUtils.fetchJson(url, options);
161 | };
162 |
163 | return {
164 | getList: (resource, params) => {
165 | const url = `${apiUrl}/${resource}`;
166 | return httpClient(url).then(({ headers, json }) => {
167 | return {
168 | data: json,
169 | total: headers.get('x-total-count'),
170 | };
171 | });
172 | },
173 | getOne: (resource, params) =>
174 | httpClient(`${apiUrl}/${resource}/${params.id}`).then(({ json }) => ({
175 | data: json,
176 | })),
177 | getMany: () => Promise.reject(),
178 | getManyReference: () => Promise.reject(),
179 | update: () => Promise.reject(),
180 | updateMany: () => Promise.reject(),
181 | create: () => Promise.reject(),
182 | delete: () => Promise.reject(),
183 | deleteMany: () => Promise.reject(),
184 | };
185 | };
186 | ```
187 |
188 | Notre application est d'hors et déjà fonctionnelle et sécurisée. Le JWT n'est en effet plus visible dans le `localStorage` du navigateur. Mais l'expériences utilisateur n'est pas extraordinaire !
189 |
190 | Par exemple, lorsque l'on recharge la page :
191 |
192 | 
193 |
194 | Ou bien lorsque l'on se déconnecte d'un onglet alors que l'on est aussi connecté sur un second :
195 |
196 | 
197 |
198 | ## Le problème des onglets
199 |
200 | Lorsque le JWT est stocké dans le `localStorage`, deux sessions de React-admin lancées dans deux onglets du navigateur vont pouvoir se partager ce JWT. Et lorsque l'on se déconnecte, la suppression du JWT dans le `localStorage` va donc impacter les deux onglets.
201 |
202 | Ce n'est plus la cas lorsque le JWT est stocké en mémoire, ou chaque instance de React-admin va gérer le stockage du JWT indépendamment l'une de l'autre.
203 |
204 | La solution proposer dans l'article [The Ultimate Guide to handling JWTs on frontend clients](https://hasura.io/blog/best-practices-of-using-jwt-with-graphql/) est assez maligne, et passe par ... le `localStorage` :)
205 |
206 | ```javascript
207 | // inMemoryJwt.js
208 | const inMemoryJWTManager = () => {
209 | let inMemoryJWT = null;
210 |
211 | // This listener will allow to disconnect a session of ra started in another tab
212 | window.addEventListener('storage', (event) => {
213 | if (event.key === 'ra-logout') {
214 | inMemoryJWT = null;
215 | }
216 | });
217 |
218 | const getToken = () => inMemoryJWT;
219 |
220 | const setToken = (token) => {
221 | inMemoryJWT = token;
222 | return true;
223 | };
224 |
225 | const ereaseToken = () => {
226 | inMemoryJWT = null;
227 | window.localStorage.setItem('ra-logout', Date.now());
228 | return true;
229 | }
230 |
231 | return {
232 | ereaseToken,
233 | getToken,
234 | setToken,
235 | }
236 | };
237 |
238 | export default inMemoryJWTManager();
239 | ```
240 |
241 | Ainsi, lorsque l'utilisateur se déconnecte depuis un onglet, il génère un évènement sur la clé `ra-logout` du `localStorage`, évènement écouté par toutes les instances de `inMemoryJWT`.
242 |
243 | ## Gérer une session sur un durée de vie plus longue que celle du token
244 |
245 | Un principe important lors de la sécurisation des JWT est d'avoir des tokens ayant une durée de vie limitée. Disons 5 min. Ainsi, si malgré tout nos effort ce jeton est volé, il ne sera pas valide très longtemps.
246 |
247 | Donc, même si l'utilisateur ne recharge pas sa page, la session cessera lorsque le token ne sera plus valide. Ce qui implique des sessions utilisateurs très courtes.
248 |
249 | Comment étendre cette durée de session ? Et bien avec un cookie ! Mais ici, nous utiliserons un cookie très sécurisé ([httpOnly](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Secure_and_HttpOnly_cookies), [SameSite](https://web.dev/samesite-cookies-explained/), etc ...) qui va nous permettre d'obtenir un nouveau JWT avant que le JWT courant ne soit périmé !
250 |
251 | Cela va tout d'abord induire du code supplémentaire coté back pour :
252 |
253 | 1. Recevoir en plus du token sa durée de vie lors de la connexion. Nous pourrions le faire en décodant le token coté front, mais cela implique manipulation inutile !
254 | 2. De poser un cookie `refresh-token` lors de l'authentification
255 |
256 | En effet, ce token va nous permettre d'interroger une nouvelle route à mettre en place côté API : la route `/refresh-token`. Lorsque le front va interroger cette route, et dans le cas ou le `refresh-token` est valide, cette route va renvoyer un nouveau token qui pourra remplacer en mémoire le token périmé.
257 |
258 | Je ne détaille pas ici le détail de l'implémentation côté API, mais vous pourrez trouver un exemple plus complet dans le code de la démo sur le dépôt de [ra-in-memory-jwt](https://github.com/marmelab/ra-in-memory-jwt/tree/master/demo)
259 |
260 | Par contre, voyons comment cela va fonctionner du côté de l'application React-admin.
261 |
262 | ```javascript
263 | const inMemoryJWTManager = () => {
264 | let logoutEventName = 'ra-logout';
265 | let refreshEndpoint = '/refresh-token';
266 | let inMemoryJWT = null;
267 | let refreshTimeOutId;
268 |
269 | // This listener will allow to disconnect a session of ra started in another tab
270 | window.addEventListener('storage', (event) => {
271 | if (event.key === logoutEventName) {
272 | inMemoryJWT = null;
273 | }
274 | });
275 |
276 | const setRefreshTokenEndpoint = endpoint => refreshEndpoint = endpoint;
277 |
278 | // This countdown feature is used to renew the JWT in a way that is transparent to the user.
279 | // before it's no longer valid
280 | const refreshToken = (delay) => {
281 | refreshTimeOutId = window.setTimeout(
282 | getRefreshedToken,
283 | delay * 1000 - 5000
284 | ); // Validity period of the token in seconds, minus 5 seconds
285 | };
286 |
287 | const abordRefreshToken = () => {
288 | if (refreshTimeOutId) {
289 | window.clearTimeout(refreshTimeOutId);
290 | }
291 | };
292 |
293 | // The method make a call to the refresh-token endpoint
294 | // If there is a valid cookie, the endpoint will return a fresh jwt.
295 | const getRefreshedToken = () => {
296 | const request = new Request(refreshEndpoint, {
297 | method: 'GET',
298 | headers: new Headers({ 'Content-Type': 'application/json' }),
299 | credentials: 'include',
300 | });
301 | return fetch(request)
302 | .then((response) => {
303 | if (response.status !== 200) {
304 | ereaseToken();
305 | global.console.log(
306 | 'Failed to renew the jwt from the refresh token.'
307 | );
308 | return { token: null };
309 | }
310 | return response.json();
311 | })
312 | .then(({ token, tokenExpiry }) => {
313 | if (token) {
314 | setToken(token, tokenExpiry);
315 | return true;
316 | }
317 |
318 | return false;
319 | });
320 | };
321 |
322 |
323 | const getToken = () => inMemoryJWT;
324 |
325 | const setToken = (token, delay) => {
326 | inMemoryJWT = token;
327 | refreshToken(delay);
328 | return true;
329 | };
330 |
331 | const ereaseToken = () => {
332 | inMemoryJWT = null;
333 | abordRefreshToken();
334 | window.localStorage.setItem(logoutEventName, Date.now());
335 | return true;
336 | }
337 |
338 | const setLogoutEventName = name => logoutEventName = name;
339 |
340 | return {
341 | ereaseToken,
342 | getToken,
343 | setLogoutEventName,
344 | setRefreshTokenEndpoint,
345 | setToken,
346 | }
347 | };
348 |
349 | export default inMemoryJWTManager();
350 | ```
351 |
352 | ```javascript
353 | //in authProvider.js
354 | //...
355 | const authProvider = {
356 | login: ({ username, password }) => {
357 | const request = new Request('http://localhost:8001/authenticate', {
358 | method: 'POST',
359 | body: JSON.stringify({ username, password }),
360 | headers: new Headers({ 'Content-Type': 'application/json' }),
361 | credentials: 'include',
362 | });
363 | inMemoryJWT.setRefreshTokenEndpoint('http://localhost:8001/refresh-token');
364 | return fetch(request)
365 | .then((response) => {
366 | if (response.status < 200 || response.status >= 300) {
367 | throw new Error(response.statusText);
368 | }
369 | return response.json();
370 | })
371 | .then(({ token, tokenExpiry }) => inMemoryJWT.setToken(token, tokenExpiry));
372 | },
373 | //...
374 | ```
375 |
376 | L'idée est assez simple : on récupère la durée de vie en même temps que le token et on lance un compte-à-rebours (timeout) sur la fonction qui va appeler la route `/refresh-token` 5 secondes avant le péremption du token. Cette route fonctionnera durant toute la durée de vie du cookie créé lors de l'authentification. C'est donc ce cookie qui déterminera la durée d'une session de connexion.
377 |
378 | 
379 |
380 | ## La session
381 |
382 | Le mécanisme que l'on vient de voir permet d'avoir une session authentifiée plus longue que la durée de vie du JWT. Mais il ne permet pas de maintenir une session, par exemple si l'on rafraichie la page !
383 |
384 | Pour parvenir à ce résultat, il devrait suffire de faire un appel à la route `/refresh-token` lorsque l'on test les droits de l'utilisateur (le `checkAuth` de l'`authProvider`):
385 |
386 | ```javascript
387 | //in authProvider.js
388 | //...
389 | checkAuth: () => {
390 | console.log('checkAuth');
391 | if (!inMemoryJWT.getToken()) {
392 | inMemoryJWT.setRefreshTokenEndpoint('http://localhost:8001/refresh-token');
393 | return inMemoryJWT.getRefreshedToken().then(tokenHasBeenRefreshed => {
394 | return tokenHasBeenRefreshed ? Promise.resolve() : Promise.reject();
395 | });
396 | } else {
397 | return Promise.resolve();
398 | }
399 | },
400 | ```
401 |
402 | Cette solution fonctionne. Mais elle n'est pas pour autant très satisfaisante :
403 |
404 | 
405 |
406 | En effet, React-admin, pour des raisons d'[optimistic rendering](https://medium.com/@whosale/optimistic-and-pessimistic-ui-rendering-approaches-bc49d1298cc0), va lancer l'appel à la vue courante (dans notre exemple, une vue liste) **avant** le retour de la promesse de `checkAuth`.
407 | Du coup, cet appel se fera sans le JWT, avec un retour en `403`, entrainant une redirection vers la page d'authentification par la méthode `checkError` de l'`authProvider`. Par contre, la page d'authentification va elle "profiter" du retour du JWT, et va donc ... rediriger vers la vue d'origine.
408 |
409 | Plusieurs solution sont possibles pour résoudre ce problème en fonction de votre besoin.
410 |
411 | En effet, on pourrait imaginer que certaine route n'aient pas besoin de jeton JWT, comme les listes. C'est la cas si vous utilisez React-admin pour afficher les vues de consultation (le `list` et le `show`) publiquement.
412 |
413 | Mais pour notre exemple, on considère que ce n'est pas le cas et que toute les routes nécessitent une authentification. Toutes les routes ayant besoin d'un token pour fonctionner, nous allons donc implémenter l'appel à la route `refresh-token` directement au niveau du client http :
414 |
415 | ```javascript
416 | // in dataProvider
417 |
418 | const httpClient = (url) => {
419 | const options = {
420 | headers: new Headers({ Accept: 'application/json' }),
421 | };
422 | const token = inMemoryJWT.getToken();
423 |
424 | if (token) {
425 | options.headers.set('Authorization', `Bearer ${token}`);
426 | return fetchUtils.fetchJson(url, options);
427 | } else {
428 | inMemoryJWT.setRefreshTokenEndpoint('http://localhost:8001/refresh-token');
429 | return inMemoryJWT.getRefreshedToken().then((gotFreshToken) => {
430 | if (gotFreshToken) {
431 | options.headers.set('Authorization', `Bearer ${inMemoryJWT.getToken()}`);
432 | };
433 | return fetchUtils.fetchJson(url, options);
434 | });
435 | }
436 | };
437 |
438 | ```
439 |
440 | Cela résout notre premier problème d'appel à la liste qui renvoyait une `403`. Mais cela provoque un second problème : le `getPermissions` n'ayant pas de token, cela va aussi provoquer une déconnexion et une redirection vers l'authentification.
441 |
442 | En fait, on a globalement un problème de concurrence entre les méthodes dépendantes du JWT, toutes ces méthodes pouvant avoir besoin de lancer un appel asynchrone à la route de rafraichissement du jeton !
443 |
444 | La solution va consister à demander à ces méthodes du `authProvider` d'attendre la fin d'une éventuelle requête vers la route `/refresh-token` lancée par le `dataProvider` avant de retourner leur réponse.
445 |
446 | Et pour cela, on implémente une méthode `waitForTokenRefresh` au niveau du `inMemoryJWTManager`.
447 |
448 | Cette méthode retourne une promesse résolue si aucun appel vers le `refresh-token` n'est en cours. Si un appel est en cours, elle attend la résolution de cet appel avant de renvoyer la résolution de la promesse.
449 |
450 | ```javascript
451 | // in inMemoryJWTManager
452 |
453 | const inMemoryJWTManager = () => {
454 | ...
455 | let isRefreshing = null;
456 |
457 | ...
458 |
459 | const waitForTokenRefresh = () => {
460 | if (!isRefreshing) {
461 | return Promise.resolve();
462 | }
463 | return isRefreshing.then(() => {
464 | isRefreshing = null;
465 | return true;
466 | });
467 | }
468 |
469 | const getRefreshedToken = () => {
470 | const request = new Request(refreshEndpoint, {
471 | method: 'GET',
472 | headers: new Headers({ 'Content-Type': 'application/json' }),
473 | credentials: 'include',
474 | });
475 |
476 | isRefreshing = fetch(request)
477 | .then((response) => {
478 | if (response.status !== 200) {
479 | ereaseToken();
480 | global.console.log(
481 | 'Token renewal failure'
482 | );
483 | return { token: null };
484 | }
485 | return response.json();
486 | })
487 | .then(({ token, tokenExpiry }) => {
488 | if (token) {
489 | setToken(token, tokenExpiry);
490 | return true;
491 | }
492 | ereaseToken();
493 | return false;
494 | });
495 |
496 | return isRefreshing;
497 | };
498 |
499 | ...
500 | };
501 | ```
502 |
503 | La méthode `waitForTokenRefresh` est donc implémentée au sein de l'`authProvider` :
504 |
505 | ```javascript
506 | // in authProvider.js
507 | ...
508 |
509 | checkAuth: () => {
510 | return inMemoryJWT.waitForTokenRefresh().then(() => {
511 | return inMemoryJWT.getToken() ? Promise.resolve() : Promise.reject();
512 | });
513 | },
514 |
515 | ...
516 |
517 | getPermissions: () => {
518 | return inMemoryJWT.waitForTokenRefresh().then(() => {
519 | return inMemoryJWT.getToken() ? Promise.resolve() : Promise.reject();
520 | });
521 | },
522 | ```
523 |
524 | 
525 |
526 | ## La déconnexion
527 |
528 | Le dernier point à adresser touche à la déconnexion. En effet, avec notre nouveau mécanisme, la déconnexion marche pour le session courante. Mais dès que l'on recharge la page, nous somme de nouveau connecté ! La seule solution consiste à appeler une **route de déconnexion** de l'API, car seule l'API pourra invalider le cookie de rafraichissement !.
529 |
530 | ```javascript
531 | // in authProvider.js
532 |
533 | logout: () => {
534 | const request = new Request('http://localhost:8001/logout', {
535 | method: 'GET',
536 | headers: new Headers({ 'Content-Type': 'application/json' }),
537 | credentials: 'include',
538 | });
539 | inMemoryJWT.ereaseToken();
540 |
541 | return fetch(request).then(() => '/login');
542 | },
543 | ```
544 |
545 | ## Conclusion
546 |
547 | Le code décrit dans ce post permet de gérer une authentification React-admin basée sur l'utilisation d'un Json Web Token de manière bien sécurisée, el le stockant en mémoire, sans être au détriment du confort de l'utilisateur.
548 |
549 | Est-ce que pour autant `ra-in-memory-jwt` est amené à devenir une partie incontournable de l'écosystème React-admin ?
550 |
551 | J'aimerais bien car j'avoue que cela flatterait un peu mon égo. Mais je suis persuadé que ce n'est pas souhaitable !
552 |
553 | En effet, son utilisation apporte beaucoup de complexité. Une complexité que je soupçonne d'être inutile dans bien des cas ! Il faut se poser la bonne question : **à quel besoin répond l'utilisation d'un Json Web Token pour gérer l'authentification de mon application React-admin (ou non basée sur React-admin d'ailleurs) ?**
554 |
555 | Si vous interagissez par exemple avec un gros système de micro-services qui utilisera ce JWT pour partager l'authentification de l'utilisateur entre les différents services, alors oui, il est possible que vous ayez intérêt à vous pencher sur `ra-in-memory-jwt`.
556 |
557 | Par contre, si votre application React-admin interagit avec une API plus monolithique, il y a de fortes chances pour que votre authentification n'est pas besoin de plus qu'un bon cookie. Pour peu que vous appliquiez les mêmes bonnes pratiques de sécurité que celles utilisées par le cookie décrit dans ce post gérant la route de `refresh-token`.
558 |
--------------------------------------------------------------------------------
/doc/jwtSessionFirstTry.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marmelab/ra-in-memory-jwt/6a30cf8c75717fdc4b5c04bf0dc50238b9367210/doc/jwtSessionFirstTry.gif
--------------------------------------------------------------------------------
/doc/jwtSessionSecondTry.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marmelab/ra-in-memory-jwt/6a30cf8c75717fdc4b5c04bf0dc50238b9367210/doc/jwtSessionSecondTry.gif
--------------------------------------------------------------------------------
/doc/raInMemoryJwtRefresh.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marmelab/ra-in-memory-jwt/6a30cf8c75717fdc4b5c04bf0dc50238b9367210/doc/raInMemoryJwtRefresh.gif
--------------------------------------------------------------------------------
/doc/raInMemoryJwtTwoTabs.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marmelab/ra-in-memory-jwt/6a30cf8c75717fdc4b5c04bf0dc50238b9367210/doc/raInMemoryJwtTwoTabs.gif
--------------------------------------------------------------------------------
/doc/refreshToken.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marmelab/ra-in-memory-jwt/6a30cf8c75717fdc4b5c04bf0dc50238b9367210/doc/refreshToken.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ra-in-memory-jwt",
3 | "version": "1.0.0",
4 | "description": "Manage React-admin authentication with jwt in memory, not in local storage",
5 | "main": "src/index.js",
6 | "files": [
7 | "src"
8 | ],
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/marmelab/ra-in-memory-jwt.git"
12 | },
13 | "keywords": [
14 | "react-admin",
15 | "jwt",
16 | "authentication"
17 | ],
18 | "author": "Alexis Janvier ",
19 | "license": "MIT",
20 | "bugs": {
21 | "url": "https://github.com/marmelab/ra-in-memory-jwt/issues"
22 | },
23 | "homepage": "https://github.com/marmelab/ra-in-memory-jwt#readme"
24 | }
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | const inMemoryJWTManager = () => {
2 | let inMemoryJWT = null;
3 | let isRefreshing = null;
4 | let logoutEventName = 'ra-logout';
5 | let refreshEndpoint = '/refresh-token';
6 | let refreshTimeOutId;
7 |
8 | const setLogoutEventName = name => logoutEventName = name;
9 | const setRefreshTokenEndpoint = endpoint => refreshEndpoint = endpoint;
10 |
11 | // This countdown feature is used to renew the JWT before it's no longer valid
12 | // in a way that is transparent to the user.
13 | const refreshToken = (delay) => {
14 | refreshTimeOutId = window.setTimeout(
15 | getRefreshedToken,
16 | delay * 1000 - 5000
17 | ); // Validity period of the token in seconds, minus 5 seconds
18 | };
19 |
20 | const abordRefreshToken = () => {
21 | if (refreshTimeOutId) {
22 | window.clearTimeout(refreshTimeOutId);
23 | }
24 | };
25 |
26 | const waitForTokenRefresh = () => {
27 | if (!isRefreshing) {
28 | return Promise.resolve();
29 | }
30 | return isRefreshing.then(() => {
31 | isRefreshing = null;
32 | return true;
33 | });
34 | }
35 |
36 | // The method make a call to the refresh-token endpoint
37 | // If there is a valid cookie, the endpoint will set a fresh jwt in memory.
38 | const getRefreshedToken = () => {
39 | const request = new Request(refreshEndpoint, {
40 | method: 'GET',
41 | headers: new Headers({ 'Content-Type': 'application/json' }),
42 | credentials: 'include',
43 | });
44 |
45 | isRefreshing = fetch(request)
46 | .then((response) => {
47 | if (response.status !== 200) {
48 | ereaseToken();
49 | global.console.log(
50 | 'Token renewal failure'
51 | );
52 | return { token: null };
53 | }
54 | return response.json();
55 | })
56 | .then(({ token, tokenExpiry }) => {
57 | if (token) {
58 | setToken(token, tokenExpiry);
59 | return true;
60 | }
61 | ereaseToken();
62 | return false;
63 | });
64 |
65 | return isRefreshing;
66 | };
67 |
68 |
69 | const getToken = () => inMemoryJWT;
70 |
71 | const setToken = (token, delay) => {
72 | inMemoryJWT = token;
73 | refreshToken(delay);
74 | return true;
75 | };
76 |
77 | const ereaseToken = () => {
78 | inMemoryJWT = null;
79 | abordRefreshToken();
80 | window.localStorage.setItem(logoutEventName, Date.now());
81 | return true;
82 | }
83 |
84 | // This listener will allow to disconnect a session of ra started in another tab
85 | window.addEventListener('storage', (event) => {
86 | if (event.key === logoutEventName) {
87 | inMemoryJWT = null;
88 | }
89 | });
90 |
91 | return {
92 | ereaseToken,
93 | getRefreshedToken,
94 | getToken,
95 | setLogoutEventName,
96 | setRefreshTokenEndpoint,
97 | setToken,
98 | waitForTokenRefresh,
99 | }
100 | };
101 |
102 | export default inMemoryJWTManager();
103 |
--------------------------------------------------------------------------------