├── .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 | ![GitHub top language](https://img.shields.io/github/languages/top/marmelab/ra-in-memory-jwt.svg) ![GitHub contributors](https://img.shields.io/github/contributors/marmelab/ra-in-memory-jwt.svg) ![ra-in-memory-jwt.svg](https://img.shields.io/github/license/marmelab/ra-in-memory-jwt.svg) ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg) ![npm](https://img.shields.io/npm/v/ra-in-memory-jwt) 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 | [![alexisjanvier](https://avatars1.githubusercontent.com/u/547706?s=96&v=4)](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 | ![Lorsque l'on recharge la page](raInMemoryJwtRefresh.gif) 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 | ![Connexion deux onglets](raInMemoryJwtTwoTabs.gif) 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 | ![Rafraichissement du jeton](refreshToken.gif) 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 | ![Maintient d'une session, premier essai](./jwtSessionFirstTry.gif) 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 | ![Maintient d'une session, second essai](./jwtSessionSecondTry.gif) 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 | --------------------------------------------------------------------------------