Skip to content

Commit

Permalink
Dashboard: Implement login to the Parkkihubi API
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
suutari-ai committed Feb 14, 2018
1 parent 1823f9e commit dcd2bdc
Show file tree
Hide file tree
Showing 12 changed files with 574 additions and 7 deletions.
80 changes: 80 additions & 0 deletions dashboard/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 |
Expand Down
138 changes: 138 additions & 0 deletions dashboard/src/api/auth-manager.ts
Original file line number Diff line number Diff line change
@@ -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<AuthToken> | null = null;

constructor(api: Api, axiosInstance: axios.AxiosInstance) {
this._api = api;
this._axios = axiosInstance;
}

checkExistingLogin(): Promise<AuthToken|null> {
return this.refreshLogin().then((authToken) => {
if (authToken) {
this._mountAxiosInterceptor();
}
return authToken;
});
}

initiateLogin(username: string, password: string): Promise<CodeToken> {
return this._axios.post<CodeToken>(
this._api.endpoints.authCodeToken, {username, password}).then(
(response) => response.data);
}

continueLogin(
codeToken: string, verificationCode: string
): Promise<AuthToken> {
const authTokenUrl = this._api.endpoints.authAuthToken;
const params = {code_token: codeToken, code: verificationCode};
return this._axios.post<AuthToken>(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<AuthToken|null> {
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<AuthToken>) = () => {
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;
}
}
7 changes: 7 additions & 0 deletions dashboard/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import * as geojson from 'geojson';

export interface AuthToken {
token: string;
}

export interface CodeToken {
token: string;
}

//////////////////////////////////////////////////////////////////////
// Primitive types

Expand Down
113 changes: 113 additions & 0 deletions dashboard/src/components/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -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]: {};
}) => (
<FormGroup>
<Label for={name}>{text}</Label>
<Input
type={(name !== 'password') ? 'text' : 'password'}
name={name}
id={name}
{...inputProps}
/>
</FormGroup>);

export default class LoginForm extends React.Component<Props, State> {
static defaultProps: Props = {
phase: 'login',
};

constructor(props: Props) {
super(props);
this.state = {
username: '',
password: '',
verificationCode: '',
};
}

render() {
return (
<Form onSubmit={this.handleSubmit}>
{(this.props.phase === 'login') ? (<>
<Field
name="username"
text="Käyttäjätunnus"
value={this.state.username}
onChange={this.handleChange}
/>
<Field
name="password"
text="Salasana"
value={this.state.password}
onChange={this.handleChange}
/>
{(this.props.loginErrorMessage) ? (
<Alert color="danger">{this.props.loginErrorMessage}</Alert>
) : null}
</>) : (<>
<Field
name="verificationCode"
text="Varmennuskoodi"
value={this.state.verificationCode}
onChange={this.handleChange}
/>
{(this.props.verificationCodeErrorMessage) ? (
<Alert color="danger">{this.props.verificationCodeErrorMessage}</Alert>
) : null}
</>)}
<Button
type="submit"
onClick={this.handleSubmit}
color="primary"
>
{(this.props.phase === 'login') ? (<>
<i className="fa fa-step-forward"/>{' '}Seuraava
</>) : (<>
<i className="fa fa-sign-in"/>{' '}Kirjaudu
</>)}
</Button>
</Form>);
}

private handleChange = (event: React.FormEvent<HTMLInputElement>) => {
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<HTMLElement>) => {
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: ''});
}
}
Loading

0 comments on commit dcd2bdc

Please sign in to comment.