From dcd2bdcb751f998cf7fa443ef19dc66176ef90e9 Mon Sep 17 00:00:00 2001 From: Tuomas Suutari Date: Fri, 9 Feb 2018 11:35:20 +0200 Subject: [PATCH] Dashboard: Implement login to the Parkkihubi API Implement the JWT based Two Factor Authentication (as provided now by the Parkkihubi API application) to the Dashboard application. Session is kept open by refreshing the authentication token on each API request when it's older than 5 minutes. --- dashboard/src/actions.ts | 80 ++++++++++++++ dashboard/src/api/auth-manager.ts | 138 +++++++++++++++++++++++++ dashboard/src/api/index.ts | 7 ++ dashboard/src/api/types.ts | 8 ++ dashboard/src/components/LoginForm.tsx | 113 ++++++++++++++++++++ dashboard/src/containers/App.tsx | 33 +++++- dashboard/src/containers/Dashboard.tsx | 16 ++- dashboard/src/containers/LoginForm.ts | 28 +++++ dashboard/src/dispatchers.ts | 57 ++++++++++ dashboard/src/index.tsx | 8 +- dashboard/src/reducers.ts | 78 +++++++++++++- dashboard/src/types.ts | 15 +++ 12 files changed, 574 insertions(+), 7 deletions(-) create mode 100644 dashboard/src/api/auth-manager.ts create mode 100644 dashboard/src/components/LoginForm.tsx create mode 100644 dashboard/src/containers/LoginForm.ts diff --git a/dashboard/src/actions.ts b/dashboard/src/actions.ts index 58eb65ad..172b4cda 100644 --- a/dashboard/src/actions.ts +++ b/dashboard/src/actions.ts @@ -3,6 +3,77 @@ import { Moment } from 'moment'; import { RegionList, RegionStatsList } from './api/types'; import { MapViewport } from './components/types'; +interface CheckExistingLoginAction { + type: 'CHECK_EXISTING_LOGIN'; +} +export function checkExistingLogin(): CheckExistingLoginAction { + return {type: 'CHECK_EXISTING_LOGIN'}; +} + +interface ResolveExistingLoginCheckAction { + type: 'RESOLVE_EXISTING_LOGIN_CHECK'; +} +export function resolveExistingLoginCheck(): ResolveExistingLoginCheckAction { + return {type: 'RESOLVE_EXISTING_LOGIN_CHECK'}; +} + +interface RequestCodeTokenAction { + type: 'REQUEST_CODE_TOKEN'; +} +export function requestCodeToken(): RequestCodeTokenAction { + return {type: 'REQUEST_CODE_TOKEN'}; +} + +interface ReceiveCodeTokenAction { + type: 'RECEIVE_CODE_TOKEN'; + codeToken: string; +} +export function receiveCodeToken(codeToken: string): ReceiveCodeTokenAction { + return {type: 'RECEIVE_CODE_TOKEN', codeToken}; +} + +interface ReceiveCodeTokenFailureAction { + type: 'RECEIVE_CODE_TOKEN_FAILURE'; + reason: string; +} +export function receiveCodeTokenFailure( + reason: string +): ReceiveCodeTokenFailureAction { + return {type: 'RECEIVE_CODE_TOKEN_FAILURE', reason}; +} + +interface RequestAuthTokenAction { + type: 'REQUEST_AUTH_TOKEN'; +} +export function requestAuthToken(): RequestAuthTokenAction { + return {type: 'REQUEST_AUTH_TOKEN'}; +} + +interface ReceiveAuthTokenAction { + type: 'RECEIVE_AUTH_TOKEN'; + authToken: string; +} +export function receiveAuthToken(authToken: string): ReceiveAuthTokenAction { + return {type: 'RECEIVE_AUTH_TOKEN', authToken}; +} + +interface ReceiveAuthTokenFailureAction { + type: 'RECEIVE_AUTH_TOKEN_FAILURE'; + reason: string; +} +export function receiveAuthTokenFailure( + reason: string +): ReceiveAuthTokenFailureAction { + return {type: 'RECEIVE_AUTH_TOKEN_FAILURE', reason}; +} + +interface LogoutAction { + type: 'LOGOUT'; +} +export function logout(): LogoutAction { + return {type: 'LOGOUT'}; +} + interface SetMapViewportAction { type: 'SET_MAP_VIEWPORT'; viewport: MapViewport; @@ -57,6 +128,15 @@ export function receiveRegionInfo(data: RegionList): ReceiveRegionInfoAction { } export type Action = + CheckExistingLoginAction | + ResolveExistingLoginCheckAction | + RequestCodeTokenAction | + ReceiveCodeTokenAction | + ReceiveCodeTokenFailureAction | + RequestAuthTokenAction | + ReceiveAuthTokenAction | + ReceiveAuthTokenFailureAction | + LogoutAction | SetMapViewportAction | SetDataTimeAction | SetAutoUpdateAction | diff --git a/dashboard/src/api/auth-manager.ts b/dashboard/src/api/auth-manager.ts new file mode 100644 index 00000000..b661f99a --- /dev/null +++ b/dashboard/src/api/auth-manager.ts @@ -0,0 +1,138 @@ +import * as axios from 'axios'; + +import { Api } from './index'; +import { AuthToken, CodeToken } from './types'; + +class TokenStorage { + storeToken(token: string) { + if (!token) { + throw new Error('Cannot store empty token'); + } + localStorage.setItem('AUTH_TOKEN', token); + localStorage.setItem('AUTH_TOKEN_STORED_AT', Date.now().toString()); + } + + getToken(): string | null { + return localStorage.getItem('AUTH_TOKEN'); + } + + clearToken() { + localStorage.removeItem('AUTH_TOKEN'); + localStorage.removeItem('AUTH_TOKEN_STORED_AT'); + } + + getTokenAge(): number | null { + const timestampStr = localStorage.getItem('AUTH_TOKEN_STORED_AT'); + if (!this.getToken() || !timestampStr) { + return null; + } + return Date.now() - Number(timestampStr); + } +} + +const tokenStorage = new TokenStorage(); + +export default class AuthManager { + /** Maximum age of the token in seconds before it is refreshed. */ + public maxTokenAge: number = 5 * 60 * 1000; // 5 minutes + + private _api: Api; + private _axios: axios.AxiosInstance; + private _authInterceptorId: number | null = null; + private _tokenRefreshPromise: Promise | null = null; + + constructor(api: Api, axiosInstance: axios.AxiosInstance) { + this._api = api; + this._axios = axiosInstance; + } + + checkExistingLogin(): Promise { + return this.refreshLogin().then((authToken) => { + if (authToken) { + this._mountAxiosInterceptor(); + } + return authToken; + }); + } + + initiateLogin(username: string, password: string): Promise { + return this._axios.post( + this._api.endpoints.authCodeToken, {username, password}).then( + (response) => response.data); + } + + continueLogin( + codeToken: string, verificationCode: string + ): Promise { + const authTokenUrl = this._api.endpoints.authAuthToken; + const params = {code_token: codeToken, code: verificationCode}; + return this._axios.post(authTokenUrl, params).then( + (response) => { + tokenStorage.storeToken(response.data.token); + this._ejectAxiosInterceptor(); + this._mountAxiosInterceptor(); + return response.data; + }, + (error) => { + tokenStorage.clearToken(); + throw error; + }); + } + + logout() { + tokenStorage.clearToken(); + this._ejectAxiosInterceptor(); + } + + refreshLogin(): Promise { + if (tokenStorage.getToken()) { + return this._refreshToken(); + } + return Promise.resolve(null); + } + + private _mountAxiosInterceptor = () => { + this._authInterceptorId = ( + this._axios.interceptors.request.use(this._authRequestInterceptor)); + } + + private _ejectAxiosInterceptor = () => { + if (this._authInterceptorId !== null) { + this._axios.interceptors.request.eject(this._authInterceptorId); + this._authInterceptorId = null; + } + } + + private _authRequestInterceptor = (request: axios.AxiosRequestConfig) => { + if ((tokenStorage.getTokenAge() || 0) > this.maxTokenAge) { + this._refreshToken(); + } + return { + ...request, + headers: { + ...request.headers, + 'Authorization': `JWT ${tokenStorage.getToken()}`, + } + }; + } + + private _refreshToken: (() => Promise) = () => { + if (this._tokenRefreshPromise) { + return this._tokenRefreshPromise; + } + const refreshUrl = this._api.endpoints.authRefresh; + const promise = this._tokenRefreshPromise = this._axios.post( + refreshUrl, {token: tokenStorage.getToken()}).then( + (response) => { + this._tokenRefreshPromise = null; + tokenStorage.storeToken(response.data.token); + return response.data; + }, + (error) => { + this._tokenRefreshPromise = null; + tokenStorage.clearToken(); + throw error; + }); + return promise; + } +} diff --git a/dashboard/src/api/index.ts b/dashboard/src/api/index.ts index 03eda678..dacfa035 100644 --- a/dashboard/src/api/index.ts +++ b/dashboard/src/api/index.ts @@ -1,6 +1,7 @@ import * as axios from 'axios'; import { Moment } from 'moment'; +import AuthManager from './auth-manager'; import { RegionList, RegionStatsList } from './types'; interface SuccessCallback { @@ -13,14 +14,20 @@ interface ErrorHandler { export class Api { public endpoints = { + authCodeToken: '/auth/v1/get-code/', + authAuthToken: '/auth/v1/auth/', + authRefresh: '/auth/v1/refresh/', regions: '/monitoring/v1/region/', regionStats: '/monitoring/v1/region_statistics/', }; + public auth: AuthManager; + private axios: axios.AxiosInstance; constructor(baseUrl?: string) { this.axios = axios.default.create({baseURL: baseUrl}); + this.auth = new AuthManager(this, this.axios); } setBaseUrl(baseUrl: string) { diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts index 557d97cd..f330f695 100644 --- a/dashboard/src/api/types.ts +++ b/dashboard/src/api/types.ts @@ -1,5 +1,13 @@ import * as geojson from 'geojson'; +export interface AuthToken { + token: string; +} + +export interface CodeToken { + token: string; +} + ////////////////////////////////////////////////////////////////////// // Primitive types diff --git a/dashboard/src/components/LoginForm.tsx b/dashboard/src/components/LoginForm.tsx new file mode 100644 index 00000000..6d03dfd4 --- /dev/null +++ b/dashboard/src/components/LoginForm.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import { Alert, Button, Form, FormGroup, Label, Input } from 'reactstrap'; + +export interface Props { + phase?: 'login' | 'verification-code'; + loginErrorMessage?: string; + verificationCodeErrorMessage?: string; + onLogin?: (username: string, password: string) => void; + onVerificationCodeSubmitted?: (code: string) => void; +} + +export interface State { + username: string; + password: string; + verificationCode: string; +} + +const Field = ({name, text, ...inputProps}: { + name: string; + text: string; + [key: string]: {}; +}) => ( + + + + ); + +export default class LoginForm extends React.Component { + static defaultProps: Props = { + phase: 'login', + }; + + constructor(props: Props) { + super(props); + this.state = { + username: '', + password: '', + verificationCode: '', + }; + } + + render() { + return ( +
+ {(this.props.phase === 'login') ? (<> + + + {(this.props.loginErrorMessage) ? ( + {this.props.loginErrorMessage} + ) : null} + ) : (<> + + {(this.props.verificationCodeErrorMessage) ? ( + {this.props.verificationCodeErrorMessage} + ) : null} + )} + + ); + } + + private handleChange = (event: React.FormEvent) => { + const target = event.target as HTMLInputElement; + if (target.name === 'username') { + this.setState({username: target.value}); + } else if (target.name === 'password') { + this.setState({password: target.value}); + } else if (target.name === 'verificationCode') { + this.setState({verificationCode: target.value}); + } + } + + private handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const { props, state } = this; + if (props.phase === 'login' && props.onLogin) { + props.onLogin(state.username, state.password); + } else if (props.phase === 'verification-code' && + props.onVerificationCodeSubmitted) { + props.onVerificationCodeSubmitted(state.verificationCode); + } + this.setState({username: '', password: ''}); + } +} diff --git a/dashboard/src/containers/App.tsx b/dashboard/src/containers/App.tsx index 9bdf598e..bb90d40b 100644 --- a/dashboard/src/containers/App.tsx +++ b/dashboard/src/containers/App.tsx @@ -1,9 +1,38 @@ import * as React from 'react'; +import { connect } from 'react-redux'; + +import { RootState } from '../types'; import Dashboard from './Dashboard'; +import LoginForm from './LoginForm'; + +interface Props { + isLoading?: boolean; + showLoginForm?: boolean; +} -export default class App extends React.Component<{}> { +class App extends React.Component { render() { - return (); + if (this.props.isLoading) { + return null; + } else if (this.props.showLoginForm) { + return (); + } else { + return (); + } } } + +function mapStateToProps(state: RootState): Partial { + return { + isLoading: !state.auth.existingLoginChecked, + showLoginForm: !state.auth.loggedIn, + }; +} + +const mapDispatchToProps = null; + +const ConnectedApp = connect( + mapStateToProps, mapDispatchToProps)(App); + +export default ConnectedApp; diff --git a/dashboard/src/containers/Dashboard.tsx b/dashboard/src/containers/Dashboard.tsx index 7a87e40e..0d93ce47 100644 --- a/dashboard/src/containers/Dashboard.tsx +++ b/dashboard/src/containers/Dashboard.tsx @@ -3,7 +3,9 @@ import * as React from 'react'; import { connect } from 'react-redux'; import { Bar } from 'react-chartjs-2'; -import { Card, CardHeader, CardBody, Container, Row, Col } from 'reactstrap'; +import { + Button, Card, CardHeader, CardBody, + Container, Row, Col } from 'reactstrap'; import * as dispatchers from '../dispatchers'; import { RootState } from '../types'; @@ -33,6 +35,7 @@ const bar = { interface Props { autoUpdate: boolean; onUpdate: () => void; + onLogout: (event: React.MouseEvent<{}>) => void; } type TimerId = number; @@ -237,6 +240,16 @@ class Dashboard extends React.Component { + + + + + ); @@ -252,6 +265,7 @@ function mapStateToProps(state: RootState): Partial { function mapDispatchToProps(dispatch: Dispatch): Partial { return { onUpdate: () => dispatch(dispatchers.updateData()), + onLogout: (event: React.MouseEvent<{}>) => dispatch(dispatchers.logout()), }; } diff --git a/dashboard/src/containers/LoginForm.ts b/dashboard/src/containers/LoginForm.ts new file mode 100644 index 00000000..819bf267 --- /dev/null +++ b/dashboard/src/containers/LoginForm.ts @@ -0,0 +1,28 @@ +import { Dispatch } from 'redux'; +import { connect } from 'react-redux'; + +import LoginForm, { Props } from '../components/LoginForm'; +import * as dispatchers from '../dispatchers'; +import { RootState } from '../types'; + +function mapStateToProps(state: RootState): Partial { + const { auth } = state; + return { + phase: (auth.codeToken) ? 'verification-code' : 'login', + loginErrorMessage: auth.codeTokenFailure, + verificationCodeErrorMessage: auth.authTokenFailure, + }; +} + +function mapDispatchToProps(dispatch: Dispatch): Partial { + return { + onLogin: (username: string, password: string) => { + dispatch(dispatchers.initiateLogin(username, password)); + }, + onVerificationCodeSubmitted: (code: string) => { + dispatch(dispatchers.continueLogin(code)); + }, + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(LoginForm); diff --git a/dashboard/src/dispatchers.ts b/dashboard/src/dispatchers.ts index 2b3623db..b0da7012 100644 --- a/dashboard/src/dispatchers.ts +++ b/dashboard/src/dispatchers.ts @@ -8,6 +8,63 @@ import { RootState } from './types'; const updateInterval = 5 * 60 * 1000; // 5 minutes in ms +export function checkExistingLogin() { + return (dispatch: Dispatch) => { + dispatch(actions.checkExistingLogin()); + api.auth.checkExistingLogin().then( + (authToken) => { + if (authToken) { + dispatch(actions.receiveAuthToken(authToken.token)); + } + dispatch(actions.resolveExistingLoginCheck()); + }, + (error) => { + dispatch(actions.resolveExistingLoginCheck()); + throw error; + }); + }; +} + +export function initiateLogin(username: string, password: string) { + return (dispatch: Dispatch) => { + dispatch(actions.requestCodeToken()); + api.auth.initiateLogin(username, password).then( + (codeToken) => { + dispatch(actions.receiveCodeToken(codeToken.token)); + }, + (error) => { + dispatch(actions.receiveCodeTokenFailure( + `${error.response.statusText} ` + + `-- ${JSON.stringify(error.response.data)}`)); + }); + }; +} + +export function continueLogin(verificationCode: string) { + return (dispatch: Dispatch, getState: () => RootState) => { + dispatch(actions.requestAuthToken()); + const { codeToken } = getState().auth; + if (codeToken) { + api.auth.continueLogin(codeToken, verificationCode).then( + (authToken) => { + dispatch(actions.receiveAuthToken(authToken.token)); + }, + (error) => { + dispatch(actions.receiveAuthTokenFailure( + `${error.response.statusText} ` + + `-- ${JSON.stringify(error.response.data)}`)); + }); + } + }; +} + +export function logout() { + return (dispatch: Dispatch) => { + api.auth.logout(); + dispatch(actions.logout()); + }; +} + export function setMapViewport(viewport: MapViewport) { return (dispatch: Dispatch) => { dispatch(actions.setMapViewport(viewport)); diff --git a/dashboard/src/index.tsx b/dashboard/src/index.tsx index f513abf9..d6fb75d5 100644 --- a/dashboard/src/index.tsx +++ b/dashboard/src/index.tsx @@ -9,18 +9,19 @@ import thunkMiddleware from 'redux-thunk'; // Configure Moment.js to use Finnish locale import 'moment/locale/fi'; -import './index.css'; import api from './api'; import * as config from './config'; import App from './containers/App'; +import * as dispatchers from './dispatchers'; import rootReducer from './reducers'; import registerServiceWorker from './registerServiceWorker'; import 'bootstrap/dist/css/bootstrap.css'; import 'font-awesome/css/font-awesome.min.css'; +import 'leaflet/dist/leaflet.css'; import 'react-select/dist/react-select.css'; -import 'leaflet/dist/leaflet.css'; +import './index.css'; Leaflet.Icon.Default.imagePath = '//cdnjs.cloudflare.com/ajax/libs/leaflet/1.3.1/images/'; @@ -39,6 +40,9 @@ const store = Redux.createStore(rootReducer, composeEnhancers(storeEnhancer)); // Configure API base URL api.setBaseUrl(config.apiBaseUrl); +// Check if there is an existing login +store.dispatch(dispatchers.checkExistingLogin()); + ReactDOM.render( diff --git a/dashboard/src/reducers.ts b/dashboard/src/reducers.ts index ca2c59a2..0e283a4b 100644 --- a/dashboard/src/reducers.ts +++ b/dashboard/src/reducers.ts @@ -4,8 +4,81 @@ import { combineReducers } from 'redux'; import { Action } from './actions'; import * as conv from './converters'; import { - ParkingRegionMapState, RegionsMap, RegionUsageHistory, - RootState, ViewState } from './types'; + AuthenticationState, ParkingRegionMapState, RegionsMap, + RegionUsageHistory, RootState, ViewState } from './types'; + +// Auth state reducer //////////////////////////////////////////////// + +const initialAuthState: AuthenticationState = { + loggedIn: false, + existingLoginChecked: false, +}; + +function auth( + state: AuthenticationState = initialAuthState, + action: Action, +): AuthenticationState { + if (action.type === 'CHECK_EXISTING_LOGIN') { + return { + ...state, + existingLoginChecked: false, + }; + } else if (action.type === 'RESOLVE_EXISTING_LOGIN_CHECK') { + return { + ...state, + existingLoginChecked: true, + }; + } else if (action.type === 'REQUEST_CODE_TOKEN') { + return { + ...state, + codeToken: undefined, + codeTokenFailure: undefined, + codeTokenFetching: true, + loggedIn: false, + }; + } else if (action.type === 'RECEIVE_CODE_TOKEN') { + return { + ...state, + codeToken: action.codeToken, + codeTokenFetching: false, + }; + } else if (action.type === 'RECEIVE_CODE_TOKEN_FAILURE') { + return { + ...state, + codeTokenFailure: action.reason, + codeTokenFetching: false, + }; + } else if (action.type === 'REQUEST_AUTH_TOKEN') { + return { + ...state, + authToken: undefined, + authTokenFailure: undefined, + authTokenFetching: true, + }; + } else if (action.type === 'RECEIVE_AUTH_TOKEN') { + return { + ...state, + authToken: action.authToken, + authTokenFetching: false, + codeToken: undefined, + loggedIn: true, + }; + } else if (action.type === 'RECEIVE_AUTH_TOKEN_FAILURE') { + return { + ...state, + authTokenFailure: action.reason, + authTokenFetching: false, + }; + } else if (action.type === 'LOGOUT') { + return { + ...state, + codeToken: undefined, + authToken: undefined, + loggedIn: false, + }; + } + return state; +} // View state reducers /////////////////////////////////////////////// @@ -96,6 +169,7 @@ function mapByIdAndApply( const rootReducer: ((state: RootState, action: Action) => RootState) = combineReducers({ + auth, views, dataTime, autoUpdate, diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 2e51abf3..3dd92c10 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -1,6 +1,8 @@ import { Region, MapViewport } from './components/types'; export interface RootState { + auth: AuthenticationState; + views: ViewState; dataTime: number|null; // milliseconds @@ -13,6 +15,19 @@ export interface RootState { regionUsageHistory: RegionUsageHistory; } +export interface AuthenticationState { + existingLoginChecked?: boolean; + loggedIn?: boolean; + + codeToken?: string; + codeTokenFetching?: boolean; + codeTokenFailure?: string; + + authToken?: string; + authTokenFetching?: boolean; + authTokenFailure?: string; +} + export interface ViewState { parkingRegionMap: ParkingRegionMapState; }