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