forked from City-of-Helsinki/parkkihubi
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
1 parent
1823f9e
commit dcd2bdc
Showing
12 changed files
with
574 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: ''}); | ||
} | ||
} |
Oops, something went wrong.