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
This commit is contained in:
Guillaume Vincent 2019-05-29 17:06:41 +02:00
parent 41c073b8fd
commit 9e2feefb2e
19 changed files with 373 additions and 14 deletions

View File

@ -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 {
<Page header={<Header />}>
<Switch>
<Redirect from="/" exact to="/playbooks" />
<Route
<PrivateRoute
path="/playbooks"
exact
component={Containers.PlaybooksContainer}
/>
<Route
<PrivateRoute
path="/playbooks/:id"
component={Containers.PlaybookContainer}
/>
<Route path="/login" component={Containers.LoginContainer} />
<Route component={Containers.Container404} />
</Switch>
</Page>

34
src/auth/PrivateRoute.js Normal file
View File

@ -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 (
<Route
{...props}
render={props =>
isAuthenticated ? (
<Component {...props} />
) : (
<Redirect
to={{
pathname: "/login",
state: { from: props.location }
}}
/>
)
}
/>
);
}
}
function mapStateToProps(state) {
return {
isAuthenticated: state.auth.isAuthenticated
};
}
export default connect(mapStateToProps)(PrivateRoute);

39
src/auth/authActions.js Normal file
View File

@ -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());
};
}

View File

@ -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);
});
});

View File

@ -0,0 +1,2 @@
export const LOGIN = "LOGIN";
export const LOGOUT = "LOGOUT";

22
src/auth/authReducer.js Normal file
View File

@ -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;
}
}

View File

@ -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
});
});

15
src/auth/localStorage.js Normal file
View File

@ -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);
}

View File

@ -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);
});

View File

@ -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;

View File

@ -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";

12
src/http.js Normal file
View File

@ -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;

1
src/images/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -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 = (
<Nav onSelect={this.onNavSelect} aria-label="Nav">
<NavList variant={NavVariants.horizontal}>
@ -49,4 +51,10 @@ class Header extends Component {
}
}
export default withRouter(Header);
function mapStateToProps(state) {
return {
isAuthenticated: state.auth.isAuthenticated
};
}
export default connect(mapStateToProps)(withRouter(Header));

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.9 KiB

116
src/login/LoginContainer.js Normal file
View File

@ -0,0 +1,116 @@
import React, { Component } from "react";
import { connect } from "react-redux";
import {
LoginFooterItem,
LoginForm,
LoginPage,
ListItem
} from "@patternfly/react-core";
import { Redirect } from "react-router-dom";
import logo from "../images/logo.svg";
import { login } from "../auth/authActions";
export class LoginContainer extends Component {
state = {
showHelperText: false,
helperText: "",
username: "",
isValidUsername: true,
password: "",
isValidPassword: true,
redirectToReferrer: false
};
handleUsernameChange = username => {
this.setState({ username });
};
handlePasswordChange = password => {
this.setState({ password });
};
onLoginButtonClick = event => {
event.preventDefault();
const { username, password } = this.state;
const { login } = this.props;
login(username, password)
.then(() => {
this.setState({ redirectToReferrer: true });
})
.catch(() => {
this.setState({
showHelperText: true,
isValidUsername: false,
isValidPassword: false,
helperText: "Invalid username or password"
});
});
};
render() {
const {
username,
isValidUsername,
password,
isValidPassword,
showHelperText,
helperText,
redirectToReferrer
} = this.state;
const { location, isAuthenticated } = this.props;
const { from } = location.state || { from: { pathname: "/" } };
if (redirectToReferrer || isAuthenticated) return <Redirect to={from} />;
const loginForm = (
<LoginForm
showHelperText={showHelperText}
helperText={helperText}
usernameLabel="Username"
usernameValue={username}
isValidUsername={isValidUsername}
onChangeUsername={this.handleUsernameChange}
passwordLabel="Password"
passwordValue={password}
isValidPassword={isValidPassword}
onChangePassword={this.handlePasswordChange}
onLoginButtonClick={this.onLoginButtonClick}
/>
);
return (
<LoginPage
footerListVariants="inline"
brandImgSrc={logo}
brandImgAlt="Ara"
footerListItems={
<ListItem>
<LoginFooterItem href="https://ara.readthedocs.io/en/feature-1.0/">
Documentation
</LoginFooterItem>
</ListItem>
}
textContent="The ARA API server you are connecting to, requires authentication. Please specify your credentials to proceed."
loginTitle="Log in to your account"
>
{loginForm}
</LoginPage>
);
}
}
function mapStateToProps(state) {
return {
isAuthenticated: state.auth.isAuthenticated
};
}
function mapDispatchToProps(dispatch) {
return {
login: (username, password) => dispatch(login(username, password))
};
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(LoginContainer);

View File

@ -1,10 +1,10 @@
import axios from "axios";
import http from "../http";
import * as types from "./playbooksActionsTypes";
export function getPlaybooks() {
return (dispatch, getState) => {
const { apiURL } = getState().config;
return axios.get(`${apiURL}/api/v1/playbooks`).then(response => {
return http.get(`${apiURL}/api/v1/playbooks`).then(response => {
dispatch({
type: types.FETCH_PLAYBOOKS,
playbooks: response.data.results
@ -17,6 +17,6 @@ export function getPlaybooks() {
export function getPlaybook(playbook) {
return (dispatch, getState) => {
const { apiURL } = getState().config;
return axios.get(`${apiURL}/api/v1/playbooks/${playbook.id}`);
return http.get(`${apiURL}/api/v1/playbooks/${playbook.id}`);
};
}

19
src/setupTests.js Normal file
View File

@ -0,0 +1,19 @@
const localStorageMock = (function() {
let store = {};
return {
getItem: function(key) {
return store[key] || null;
},
setItem: function(key, value) {
store[key] = value.toString();
},
removeItem: function(key) {
delete store[key];
},
clear: function() {
store = {};
}
};
})();
global.localStorage = localStorageMock;

View File

@ -2,11 +2,13 @@ import { createStore, applyMiddleware, combineReducers } from "redux";
import thunk from "redux-thunk";
import configReducer from "./config/configReducer";
import playbooksReducer from "./playbooks/playbooksReducer";
import authReducer from "./auth/authReducer";
const store = createStore(
combineReducers({
config: configReducer,
playbooks: playbooksReducer
playbooks: playbooksReducer,
auth: authReducer
}),
applyMiddleware(thunk)
);