From 9e2feefb2ee6b14c6ad08b0a829af740af5425b7 Mon Sep 17 00:00:00 2001 From: Guillaume Vincent Date: Wed, 29 May 2019 17:06:41 +0200 Subject: [PATCH] Support authentication Test at startup if /api/v1/ returns 401 status code. If yes redirect to login page, else continue. On login save credentials (username, password) in localStorage. On every request we set credentials in header if present. Fixes: https://github.com/ansible-community/ara-web/issues/1 Change-Id: I0f0b18b5590dec4ebfce32aa6519bb46fc8533f5 --- src/App.js | 15 +++- src/auth/PrivateRoute.js | 34 ++++++++ src/auth/authActions.js | 39 +++++++++ src/auth/authActions.test.js | 38 +++++++++ src/auth/authActionsTypes.js | 2 + src/auth/authReducer.js | 22 +++++ src/auth/authReducer.test.js | 24 ++++++ src/auth/localStorage.js | 15 ++++ src/auth/localStorage.test.js | 20 +++++ src/config/configActions.js | 4 +- src/containers.js | 1 + src/http.js | 12 +++ src/images/logo.svg | 1 + src/layout/{navigation => }/Header.js | 14 +++- src/layout/navigation/logo.svg | 1 - src/login/LoginContainer.js | 116 ++++++++++++++++++++++++++ src/playbooks/playbooksActions.js | 6 +- src/setupTests.js | 19 +++++ src/store.js | 4 +- 19 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 src/auth/PrivateRoute.js create mode 100644 src/auth/authActions.js create mode 100644 src/auth/authActions.test.js create mode 100644 src/auth/authActionsTypes.js create mode 100644 src/auth/authReducer.js create mode 100644 src/auth/authReducer.test.js create mode 100644 src/auth/localStorage.js create mode 100644 src/auth/localStorage.test.js create mode 100644 src/http.js create mode 100644 src/images/logo.svg rename src/layout/{navigation => }/Header.js (76%) delete mode 100644 src/layout/navigation/logo.svg create mode 100644 src/login/LoginContainer.js create mode 100644 src/setupTests.js diff --git a/src/App.js b/src/App.js index f62c227..d6c6430 100644 --- a/src/App.js +++ b/src/App.js @@ -6,8 +6,10 @@ import "@patternfly/patternfly/patternfly.css"; import "@patternfly/patternfly/patternfly-addons.css"; import store from "./store"; import { getConfig } from "./config/configActions"; +import { checkAuthentification } from "./auth/authActions"; import * as Containers from "./containers"; -import Header from "./layout/navigation/Header"; +import Header from "./layout/Header"; +import PrivateRoute from "./auth/PrivateRoute"; import Page from "./layout/Page"; class App extends Component { @@ -16,7 +18,11 @@ class App extends Component { }; componentDidMount() { - store.dispatch(getConfig()).then(() => this.setState({ isLoading: false })); + store + .dispatch(getConfig()) + .then(() => store.dispatch(checkAuthentification())) + .catch(error => console.error(error)) + .then(() => this.setState({ isLoading: false })); } render() { @@ -28,15 +34,16 @@ class App extends Component { }> - - + diff --git a/src/auth/PrivateRoute.js b/src/auth/PrivateRoute.js new file mode 100644 index 0000000..24ef17a --- /dev/null +++ b/src/auth/PrivateRoute.js @@ -0,0 +1,34 @@ +import React, { Component } from "react"; +import { connect } from "react-redux"; +import { Redirect, Route } from "react-router-dom"; + +class PrivateRoute extends Component { + render() { + const { isAuthenticated, component: Component, ...props } = this.props; + return ( + + isAuthenticated ? ( + + ) : ( + + ) + } + /> + ); + } +} + +function mapStateToProps(state) { + return { + isAuthenticated: state.auth.isAuthenticated + }; +} + +export default connect(mapStateToProps)(PrivateRoute); diff --git a/src/auth/authActions.js b/src/auth/authActions.js new file mode 100644 index 0000000..4c2e5d3 --- /dev/null +++ b/src/auth/authActions.js @@ -0,0 +1,39 @@ +import http from "../http"; +import * as types from "./authActionsTypes"; +import { setCredentials, removeCredentials } from "./localStorage"; + +export function logout() { + removeCredentials(); + return { + type: types.LOGOUT + }; +} + +export function checkAuthentification() { + return (dispatch, getState) => { + const state = getState(); + return http({ + method: "get", + url: `${state.config.apiURL}/api/v1/` + }) + .then(response => { + dispatch({ + type: types.LOGIN + }); + return response; + }) + .catch(error => { + if (error.response && error.response.status === 401) { + dispatch(logout()); + } + throw error; + }); + }; +} + +export function login(username, password) { + return dispatch => { + setCredentials({ username, password }); + return dispatch(checkAuthentification()); + }; +} diff --git a/src/auth/authActions.test.js b/src/auth/authActions.test.js new file mode 100644 index 0000000..14a7619 --- /dev/null +++ b/src/auth/authActions.test.js @@ -0,0 +1,38 @@ +import axios from "axios"; +import configureMockStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import axiosMockAdapter from "axios-mock-adapter"; + +import { checkAuthentification } from "./authActions"; +import * as types from "./authActionsTypes"; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +const axiosMock = new axiosMockAdapter(axios); + +it("checkAuthentification", () => { + axiosMock.onGet("https://api.example.org/api/v1/").reply(200, {}); + const expectedActions = [ + { + type: types.LOGIN + } + ]; + const store = mockStore({ config: { apiURL: "https://api.example.org" } }); + return store.dispatch(checkAuthentification()).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); +}); + +it("checkAuthentification unauthorized", () => { + axiosMock.onGet("https://api.example.org/api/v1/").reply(401, {}); + const expectedActions = [ + { + type: types.LOGOUT + } + ]; + const store = mockStore({ config: { apiURL: "https://api.example.org" } }); + return store.dispatch(checkAuthentification()).catch(() => { + expect(store.getActions()).toEqual(expectedActions); + }); +}); diff --git a/src/auth/authActionsTypes.js b/src/auth/authActionsTypes.js new file mode 100644 index 0000000..f413b89 --- /dev/null +++ b/src/auth/authActionsTypes.js @@ -0,0 +1,2 @@ +export const LOGIN = "LOGIN"; +export const LOGOUT = "LOGOUT"; diff --git a/src/auth/authReducer.js b/src/auth/authReducer.js new file mode 100644 index 0000000..299421d --- /dev/null +++ b/src/auth/authReducer.js @@ -0,0 +1,22 @@ +import * as types from "./authActionsTypes"; + +const initialState = { + isAuthenticated: true +}; + +export default function(state = initialState, action) { + switch (action.type) { + case types.LOGIN: + return { + ...state, + isAuthenticated: true + } + case types.LOGOUT: + return { + ...state, + isAuthenticated: false + } + default: + return state; + } +} diff --git a/src/auth/authReducer.test.js b/src/auth/authReducer.test.js new file mode 100644 index 0000000..0734a5a --- /dev/null +++ b/src/auth/authReducer.test.js @@ -0,0 +1,24 @@ +import reducer from "./authReducer"; +import * as types from "./authActionsTypes"; + +it("returns the initial state", () => { + expect(reducer(undefined, {})).toEqual({ isAuthenticated: true }); +}); + +it("LOGIN", () => { + const newState = reducer(undefined, { + type: types.LOGIN + }); + expect(newState).toEqual({ + isAuthenticated: true + }); +}); + +it("LOGOUT", () => { + const newState = reducer(undefined, { + type: types.LOGOUT + }); + expect(newState).toEqual({ + isAuthenticated: false + }); +}); diff --git a/src/auth/localStorage.js b/src/auth/localStorage.js new file mode 100644 index 0000000..20831b4 --- /dev/null +++ b/src/auth/localStorage.js @@ -0,0 +1,15 @@ +const TOKEN = "ARA"; + +export function getCredentials() { + const credentials = localStorage.getItem(TOKEN); + if (!credentials) return null; + return JSON.parse(credentials); +} + +export function setCredentials(credentials) { + localStorage.setItem(TOKEN, JSON.stringify(credentials)); +} + +export function removeCredentials() { + localStorage.removeItem(TOKEN); +} diff --git a/src/auth/localStorage.test.js b/src/auth/localStorage.test.js new file mode 100644 index 0000000..aa62762 --- /dev/null +++ b/src/auth/localStorage.test.js @@ -0,0 +1,20 @@ +import { + setCredentials, + getCredentials, + removeCredentials, +} from "./localStorage"; + +it("localStorage getCredentials", () => { + expect(getCredentials()).toBe(null); +}); + +it("localStorage setCredentials getCredentials removeCredentials", () => { + const credentials = { + username: "foo", + password: "bar" + }; + setCredentials(credentials); + expect(getCredentials()).toEqual(credentials); + removeCredentials(); + expect(getCredentials()).toBe(null); +}); diff --git a/src/config/configActions.js b/src/config/configActions.js index b4707e9..6683d9b 100644 --- a/src/config/configActions.js +++ b/src/config/configActions.js @@ -1,4 +1,4 @@ -import axios from "axios"; +import http from "../http"; import * as types from "./configActionsTypes"; export function setConfig(config) { @@ -10,7 +10,7 @@ export function setConfig(config) { export function getConfig() { return dispatch => { - return axios.get(`${process.env.PUBLIC_URL}/config.json`).then(response => { + return http.get(`${process.env.PUBLIC_URL}/config.json`).then(response => { const config = response.data; dispatch(setConfig(config)); return response; diff --git a/src/containers.js b/src/containers.js index 230e86b..9e5288e 100644 --- a/src/containers.js +++ b/src/containers.js @@ -1,4 +1,5 @@ export { default as Container404 } from "./layout/Container404"; export { default as LoadingContainer } from "./layout/LoadingContainer"; +export { default as LoginContainer } from "./login/LoginContainer"; export { default as PlaybooksContainer } from "./playbooks/PlaybooksContainer"; export { default as PlaybookContainer } from "./playbooks/PlaybookContainer"; diff --git a/src/http.js b/src/http.js new file mode 100644 index 0000000..5531a77 --- /dev/null +++ b/src/http.js @@ -0,0 +1,12 @@ +import axios from "axios"; +import { getCredentials } from "./auth/localStorage"; + +axios.interceptors.request.use(config => { + const credentials = getCredentials(); + if (credentials) { + config.auth = credentials + } + return config; +}); + +export default axios; diff --git a/src/images/logo.svg b/src/images/logo.svg new file mode 100644 index 0000000..7d11757 --- /dev/null +++ b/src/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/layout/navigation/Header.js b/src/layout/Header.js similarity index 76% rename from src/layout/navigation/Header.js rename to src/layout/Header.js index 6789bfd..059f292 100644 --- a/src/layout/navigation/Header.js +++ b/src/layout/Header.js @@ -1,4 +1,5 @@ import React, { Component } from "react"; +import { connect } from "react-redux"; import styled from "styled-components"; import { withRouter } from "react-router"; import { @@ -9,7 +10,7 @@ import { NavVariants, PageHeader } from "@patternfly/react-core"; -import logo from "./logo.svg"; +import logo from "../images/logo.svg"; const Logo = styled(Brand)` height: 45px; @@ -17,7 +18,8 @@ const Logo = styled(Brand)` class Header extends Component { render() { - const { location, history } = this.props; + const { location, history, isAuthenticated } = this.props; + if (!isAuthenticated) return null; const TopNav = (