├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── AdminPage │ ├── AdminPage.jsx │ └── index.js ├── App │ ├── App.jsx │ └── index.js ├── HomePage │ ├── HomePage.jsx │ └── index.js ├── LoginPage │ ├── LoginPage.jsx │ └── index.js ├── _components │ ├── PrivateRoute.jsx │ └── index.js ├── _helpers │ ├── auth-header.js │ ├── fake-backend.js │ ├── handle-response.js │ ├── history.js │ ├── index.js │ └── role.js ├── _services │ ├── authentication.service.js │ ├── index.js │ └── user.service.js ├── index.html └── index.jsx └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | "env", 5 | "stage-0" 6 | ] 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | typings 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jason Watmore 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-role-based-authorization-example 2 | 3 | React - Role Based Authorization Tutorial & Example 4 | 5 | To see a demo and further details go to http://jasonwatmore.com/post/2019/02/01/react-role-based-authorization-tutorial-with-example -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-role-based-authorization-example", 3 | "version": "1.0.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/cornflourblue/react-role-based-authorization-example.git" 7 | }, 8 | "license": "MIT", 9 | "scripts": { 10 | "start": "webpack-dev-server --open" 11 | }, 12 | "dependencies": { 13 | "formik": "^1.4.2", 14 | "history": "^4.7.2", 15 | "react": "^16.0.0", 16 | "react-dom": "^16.0.0", 17 | "react-router-dom": "^4.1.2", 18 | "rxjs": "^6.3.3", 19 | "yup": "^0.26.10" 20 | }, 21 | "devDependencies": { 22 | "babel-core": "^6.26.0", 23 | "babel-loader": "^7.1.5", 24 | "babel-preset-env": "^1.6.1", 25 | "babel-preset-react": "^6.16.0", 26 | "babel-preset-stage-0": "^6.24.1", 27 | "html-webpack-plugin": "^3.2.0", 28 | "path": "^0.12.7", 29 | "webpack": "^4.15.0", 30 | "webpack-cli": "^3.0.8", 31 | "webpack-dev-server": "^3.1.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/AdminPage/AdminPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { userService } from '@/_services'; 4 | 5 | class AdminPage extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | users: null 11 | }; 12 | } 13 | 14 | componentDidMount() { 15 | userService.getAll().then(users => this.setState({ users })); 16 | } 17 | 18 | render() { 19 | const { users } = this.state; 20 | return ( 21 |
22 |

Admin

23 |

This page can only be accessed by administrators.

24 |
25 | All users from secure (admin only) api end point: 26 | {users && 27 | 32 | } 33 |
34 |
35 | ); 36 | } 37 | } 38 | 39 | export { AdminPage }; -------------------------------------------------------------------------------- /src/AdminPage/index.js: -------------------------------------------------------------------------------- 1 | export * from './AdminPage'; -------------------------------------------------------------------------------- /src/App/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router, Route, Link } from 'react-router-dom'; 3 | 4 | import { history, Role } from '@/_helpers'; 5 | import { authenticationService } from '@/_services'; 6 | import { PrivateRoute } from '@/_components'; 7 | import { HomePage } from '@/HomePage'; 8 | import { AdminPage } from '@/AdminPage'; 9 | import { LoginPage } from '@/LoginPage'; 10 | 11 | class App extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | 15 | this.state = { 16 | currentUser: null, 17 | isAdmin: false 18 | }; 19 | } 20 | 21 | componentDidMount() { 22 | authenticationService.currentUser.subscribe(x => this.setState({ 23 | currentUser: x, 24 | isAdmin: x && x.role === Role.Admin 25 | })); 26 | } 27 | 28 | logout() { 29 | authenticationService.logout(); 30 | history.push('/login'); 31 | } 32 | 33 | render() { 34 | const { currentUser, isAdmin } = this.state; 35 | return ( 36 | 37 |
38 | {currentUser && 39 | 46 | } 47 |
48 |
49 |
50 |
51 | 52 | 53 | 54 |
55 |
56 |
57 |
58 |
59 |
60 | ); 61 | } 62 | } 63 | 64 | export { App }; -------------------------------------------------------------------------------- /src/App/index.js: -------------------------------------------------------------------------------- 1 | export * from './App'; -------------------------------------------------------------------------------- /src/HomePage/HomePage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { userService, authenticationService } from '@/_services'; 4 | 5 | class HomePage extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | currentUser: authenticationService.currentUserValue, 11 | userFromApi: null 12 | }; 13 | } 14 | 15 | componentDidMount() { 16 | const { currentUser } = this.state; 17 | userService.getById(currentUser.id).then(userFromApi => this.setState({ userFromApi })); 18 | } 19 | 20 | render() { 21 | const { currentUser, userFromApi } = this.state; 22 | return ( 23 |
24 |

Home

25 |

You're logged in with React & JWT!!

26 |

Your role is: {currentUser.role}.

27 |

This page can be accessed by all authenticated users.

28 |
29 | Current user from secure api end point: 30 | {userFromApi && 31 | 34 | } 35 |
36 |
37 | ); 38 | } 39 | } 40 | 41 | export { HomePage }; -------------------------------------------------------------------------------- /src/HomePage/index.js: -------------------------------------------------------------------------------- 1 | export * from './HomePage'; -------------------------------------------------------------------------------- /src/LoginPage/LoginPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Formik, Field, Form, ErrorMessage } from 'formik'; 3 | import * as Yup from 'yup'; 4 | 5 | import { authenticationService } from '@/_services'; 6 | 7 | class LoginPage extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | // redirect to home if already logged in 12 | if (authenticationService.currentUserValue) { 13 | this.props.history.push('/'); 14 | } 15 | } 16 | 17 | render() { 18 | return ( 19 |
20 |
21 | Normal User - U: user P: user
22 | Administrator - U: admin P: admin 23 |
24 |

Login

25 | { 35 | setStatus(); 36 | authenticationService.login(username, password) 37 | .then( 38 | user => { 39 | const { from } = this.props.location.state || { from: { pathname: "/" } }; 40 | this.props.history.push(from); 41 | }, 42 | error => { 43 | setSubmitting(false); 44 | setStatus(error); 45 | } 46 | ); 47 | }} 48 | render={({ errors, status, touched, isSubmitting }) => ( 49 |
50 |
51 | 52 | 53 | 54 |
55 |
56 | 57 | 58 | 59 |
60 |
61 | 62 | {isSubmitting && 63 | 64 | } 65 |
66 | {status && 67 |
{status}
68 | } 69 |
70 | )} 71 | /> 72 |
73 | ) 74 | } 75 | } 76 | 77 | export { LoginPage }; -------------------------------------------------------------------------------- /src/LoginPage/index.js: -------------------------------------------------------------------------------- 1 | export * from './LoginPage'; -------------------------------------------------------------------------------- /src/_components/PrivateRoute.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Redirect } from 'react-router-dom'; 3 | 4 | import { authenticationService } from '@/_services'; 5 | 6 | export const PrivateRoute = ({ component: Component, roles, ...rest }) => ( 7 | { 8 | const currentUser = authenticationService.currentUserValue; 9 | if (!currentUser) { 10 | // not logged in so redirect to login page with the return url 11 | return 12 | } 13 | 14 | // check if route is restricted by role 15 | if (roles && roles.indexOf(currentUser.role) === -1) { 16 | // role not authorised so redirect to home page 17 | return 18 | } 19 | 20 | // authorised so return component 21 | return 22 | }} /> 23 | ) -------------------------------------------------------------------------------- /src/_components/index.js: -------------------------------------------------------------------------------- 1 | export * from './PrivateRoute'; 2 | -------------------------------------------------------------------------------- /src/_helpers/auth-header.js: -------------------------------------------------------------------------------- 1 | import { authenticationService } from '@/_services'; 2 | 3 | export function authHeader() { 4 | // return authorization header with jwt token 5 | const currentUser = authenticationService.currentUserValue; 6 | if (currentUser && currentUser.token) { 7 | return { Authorization: `Bearer ${currentUser.token}` }; 8 | } else { 9 | return {}; 10 | } 11 | } -------------------------------------------------------------------------------- /src/_helpers/fake-backend.js: -------------------------------------------------------------------------------- 1 | import { Role } from './' 2 | 3 | export function configureFakeBackend() { 4 | let users = [ 5 | { id: 1, username: 'admin', password: 'admin', firstName: 'Admin', lastName: 'User', role: Role.Admin }, 6 | { id: 2, username: 'user', password: 'user', firstName: 'Normal', lastName: 'User', role: Role.User } 7 | ]; 8 | let realFetch = window.fetch; 9 | window.fetch = function (url, opts) { 10 | const authHeader = opts.headers['Authorization']; 11 | const isLoggedIn = authHeader && authHeader.startsWith('Bearer fake-jwt-token'); 12 | const roleString = isLoggedIn && authHeader.split('.')[1]; 13 | const role = roleString ? Role[roleString] : null; 14 | 15 | return new Promise((resolve, reject) => { 16 | // wrap in timeout to simulate server api call 17 | setTimeout(() => { 18 | // authenticate - public 19 | if (url.endsWith('/users/authenticate') && opts.method === 'POST') { 20 | const params = JSON.parse(opts.body); 21 | const user = users.find(x => x.username === params.username && x.password === params.password); 22 | if (!user) return error('Username or password is incorrect'); 23 | return ok({ 24 | id: user.id, 25 | username: user.username, 26 | firstName: user.firstName, 27 | lastName: user.lastName, 28 | role: user.role, 29 | token: `fake-jwt-token.${user.role}` 30 | }); 31 | } 32 | 33 | // get user by id - admin or user (user can only access their own record) 34 | if (url.match(/\/users\/\d+$/) && opts.method === 'GET') { 35 | if (!isLoggedIn) return unauthorised(); 36 | 37 | // get id from request url 38 | let urlParts = url.split('/'); 39 | let id = parseInt(urlParts[urlParts.length - 1]); 40 | 41 | // only allow normal users access to their own record 42 | const currentUser = users.find(x => x.role === role); 43 | if (id !== currentUser.id && role !== Role.Admin) return unauthorised(); 44 | 45 | const user = users.find(x => x.id === id); 46 | return ok(user); 47 | } 48 | 49 | // get all users - admin only 50 | if (url.endsWith('/users') && opts.method === 'GET') { 51 | if (role !== Role.Admin) return unauthorised(); 52 | return ok(users); 53 | } 54 | 55 | // pass through any requests not handled above 56 | realFetch(url, opts).then(response => resolve(response)); 57 | 58 | // private helper functions 59 | 60 | function ok(body) { 61 | resolve({ ok: true, text: () => Promise.resolve(JSON.stringify(body)) }) 62 | } 63 | 64 | function unauthorised() { 65 | resolve({ status: 401, text: () => Promise.resolve(JSON.stringify({ message: 'Unauthorised' })) }) 66 | } 67 | 68 | function error(message) { 69 | resolve({ status: 400, text: () => Promise.resolve(JSON.stringify({ message })) }) 70 | } 71 | }, 500); 72 | }); 73 | } 74 | } -------------------------------------------------------------------------------- /src/_helpers/handle-response.js: -------------------------------------------------------------------------------- 1 | import { authenticationService } from '@/_services'; 2 | 3 | export function handleResponse(response) { 4 | return response.text().then(text => { 5 | const data = text && JSON.parse(text); 6 | if (!response.ok) { 7 | if ([401, 403].indexOf(response.status) !== -1) { 8 | // auto logout if 401 Unauthorized or 403 Forbidden response returned from api 9 | authenticationService.logout(); 10 | location.reload(true); 11 | } 12 | 13 | const error = (data && data.message) || response.statusText; 14 | return Promise.reject(error); 15 | } 16 | 17 | return data; 18 | }); 19 | } -------------------------------------------------------------------------------- /src/_helpers/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | 3 | export const history = createBrowserHistory(); -------------------------------------------------------------------------------- /src/_helpers/index.js: -------------------------------------------------------------------------------- 1 | export * from './auth-header'; 2 | export * from './fake-backend'; 3 | export * from './handle-response'; 4 | export * from './history'; 5 | export * from './role'; -------------------------------------------------------------------------------- /src/_helpers/role.js: -------------------------------------------------------------------------------- 1 | export const Role = { 2 | Admin: 'Admin', 3 | User: 'User' 4 | } -------------------------------------------------------------------------------- /src/_services/authentication.service.js: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs'; 2 | 3 | import config from 'config'; 4 | import { handleResponse } from '@/_helpers'; 5 | 6 | const currentUserSubject = new BehaviorSubject(JSON.parse(localStorage.getItem('currentUser'))); 7 | 8 | export const authenticationService = { 9 | login, 10 | logout, 11 | currentUser: currentUserSubject.asObservable(), 12 | get currentUserValue () { return currentUserSubject.value } 13 | }; 14 | 15 | function login(username, password) { 16 | const requestOptions = { 17 | method: 'POST', 18 | headers: { 'Content-Type': 'application/json' }, 19 | body: JSON.stringify({ username, password }) 20 | }; 21 | 22 | return fetch(`${config.apiUrl}/users/authenticate`, requestOptions) 23 | .then(handleResponse) 24 | .then(user => { 25 | // store user details and jwt token in local storage to keep user logged in between page refreshes 26 | localStorage.setItem('currentUser', JSON.stringify(user)); 27 | currentUserSubject.next(user); 28 | 29 | return user; 30 | }); 31 | } 32 | 33 | function logout() { 34 | // remove user from local storage to log user out 35 | localStorage.removeItem('currentUser'); 36 | currentUserSubject.next(null); 37 | } 38 | -------------------------------------------------------------------------------- /src/_services/index.js: -------------------------------------------------------------------------------- 1 | export * from './authentication.service'; 2 | export * from './user.service'; 3 | -------------------------------------------------------------------------------- /src/_services/user.service.js: -------------------------------------------------------------------------------- 1 | import config from 'config'; 2 | import { authHeader, handleResponse } from '@/_helpers'; 3 | 4 | export const userService = { 5 | getAll, 6 | getById 7 | }; 8 | 9 | function getAll() { 10 | const requestOptions = { method: 'GET', headers: authHeader() }; 11 | return fetch(`${config.apiUrl}/users`, requestOptions).then(handleResponse); 12 | } 13 | 14 | function getById(id) { 15 | const requestOptions = { method: 'GET', headers: authHeader() }; 16 | return fetch(`${config.apiUrl}/users/${id}`, requestOptions).then(handleResponse); 17 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React - Role Based Authorization Tutorial & Example 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 |
16 | 17 | 18 | 26 | 27 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | 4 | import { App } from './App'; 5 | 6 | // setup fake backend 7 | import { configureFakeBackend } from './_helpers'; 8 | configureFakeBackend(); 9 | 10 | render( 11 | , 12 | document.getElementById('app') 13 | ); -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | mode: 'development', 6 | resolve: { 7 | extensions: ['.js', '.jsx'] 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.jsx?$/, 13 | loader: 'babel-loader' 14 | } 15 | ] 16 | }, 17 | resolve: { 18 | extensions: ['.js', '.jsx'], 19 | alias: { 20 | '@': path.resolve(__dirname, 'src/'), 21 | } 22 | }, 23 | plugins: [new HtmlWebpackPlugin({ 24 | template: './src/index.html' 25 | })], 26 | devServer: { 27 | historyApiFallback: true 28 | }, 29 | externals: { 30 | // global app config object 31 | config: JSON.stringify({ 32 | apiUrl: 'http://localhost:4000' 33 | }) 34 | } 35 | } --------------------------------------------------------------------------------