More specific access control headers to support cross origin Authorization

Pretty sign in page
Verify existing JWT on application mount
This commit is contained in:
Rick Watson 2019-05-19 17:51:57 +01:00
parent 04e852f7d9
commit 396d0333b6
9 changed files with 102 additions and 70 deletions

View File

@ -1 +1 @@
REACT_APP_ENDPOINT_ROOT=http://192.168.0.4/rest/ REACT_APP_ENDPOINT_ROOT=http://192.168.0.16/rest/

View File

@ -1,2 +1,2 @@
REACT_APP_ENDPOINT_ROOT=/rest/ REACT_APP_ENDPOINT_ROOT=/rest/
GENERATE_SOURCEMAP=false GENERATE_SOURCEMAP=false

View File

@ -29,11 +29,13 @@ export function fetchLoginRedirect() {
/** /**
* Wraps the normal fetch routene with one with provides the access token if present. * Wraps the normal fetch routene with one with provides the access token if present.
*/ */
export function secureFetch(url, params) { export function authorizedFetch(url, params) {
if (localStorage.getItem(ACCESS_TOKEN)) { const accessToken = localStorage.getItem(ACCESS_TOKEN);
if (accessToken) {
params = params || {}; params = params || {};
params.headers = params.headers || new Headers(); params.credentials = 'include';
params.headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN) params.headers = params.headers || {};
params.headers.Authorization = 'Bearer ' + accessToken;
} }
return fetch(url, params); return fetch(url, params);
} }
@ -41,9 +43,9 @@ export function secureFetch(url, params) {
/** /**
* Wraps the normal fetch routene which redirects on 401 response. * Wraps the normal fetch routene which redirects on 401 response.
*/ */
export function redirectingSecureFetch(url, params) { export function redirectingAuthorizedFetch(url, params) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
secureFetch(url, params).then(response => { authorizedFetch(url, params).then(response => {
if (response.status === 401) { if (response.status === 401) {
history.go("/"); history.go("/");
} else { } else {

View File

@ -1,10 +1,27 @@
import * as React from 'react'; import * as React from 'react';
import history from '../history' import history from '../history'
import { withNotifier } from '../components/SnackbarNotification'; import { withNotifier } from '../components/SnackbarNotification';
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../constants/Endpoints';
import { ACCESS_TOKEN } from './Authentication'; import { ACCESS_TOKEN, authorizedFetch } from './Authentication';
import { AuthenticationContext } from './Context'; import { AuthenticationContext } from './Context';
import jwtDecode from 'jwt-decode'; import jwtDecode from 'jwt-decode';
import CircularProgress from '@material-ui/core/CircularProgress';
import Typography from '@material-ui/core/Typography';
import { withStyles } from '@material-ui/core/styles';
const styles = theme => ({
loadingPanel: {
padding: theme.spacing.unit * 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
flexDirection: "column"
},
progress: {
margin: theme.spacing.unit * 4,
}
});
class AuthenticationWrapper extends React.Component { class AuthenticationWrapper extends React.Component {
@ -44,23 +61,30 @@ class AuthenticationWrapper extends React.Component {
} }
renderContentLoading() { renderContentLoading() {
const { classes } = this.props;
return ( return (
<div>THIS IS WHERE THE LOADING MESSAGE GOES</div> <div className={classes.loadingPanel}>
<CircularProgress className={classes.progress} size={100} />
<Typography variant="h4" >
Loading...
</Typography>
</div>
); );
} }
refresh() { refresh() {
var accessToken = localStorage.getItem(ACCESS_TOKEN); var accessToken = localStorage.getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
try { authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
this.setState({ initialized: true, context: { ...this.state.context, jwt: jwtDecode(accessToken) } }); .then(response => {
} catch (err) { const jwt = response.status === 200 ? jwtDecode(accessToken) : undefined;
localStorage.removeItem(ACCESS_TOKEN); this.setState({ initialized: true, context: { ...this.state.context, jwt } });
this.props.raiseNotification("Please log in again."); }).catch(error => {
history.push('/'); this.setState({ initialized: true, context: { ...this.state.context, jwt: undefined } });
} this.props.raiseNotification("Error verifying authorization: " + error.message);
});
} else { } else {
this.setState({ initialized: true }); this.setState({ initialized: true, context: { ...this.state.context, jwt: undefined } });
} }
} }
@ -69,8 +93,8 @@ class AuthenticationWrapper extends React.Component {
this.setState({ context: { ...this.state.context, jwt: jwtDecode(accessToken) } }); this.setState({ context: { ...this.state.context, jwt: jwtDecode(accessToken) } });
localStorage.setItem(ACCESS_TOKEN, accessToken); localStorage.setItem(ACCESS_TOKEN, accessToken);
} catch (err) { } catch (err) {
this.props.raiseNotification("JWT did not parse."); this.setState({ initialized: true, context: { ...this.state.context, jwt: undefined } });
history.push('/'); this.props.raiseNotification("Failed to parse JWT " + err.message);
} }
} }
@ -79,7 +103,7 @@ class AuthenticationWrapper extends React.Component {
this.setState({ this.setState({
context: { context: {
...this.state.context, ...this.state.context,
me: undefined jwt: undefined
} }
}); });
this.props.raiseNotification("You have signed out."); this.props.raiseNotification("You have signed out.");
@ -88,4 +112,4 @@ class AuthenticationWrapper extends React.Component {
} }
export default withNotifier(AuthenticationWrapper) export default withStyles(styles)(withNotifier(AuthenticationWrapper))

View File

@ -54,7 +54,7 @@ class SnackbarNotification extends React.Component {
open={this.state.open} open={this.state.open}
autoHideDuration={6000} autoHideDuration={6000}
onClose={this.handleClose} onClose={this.handleClose}
SnackbarContentProps={{ ContentProps={{
'aria-describedby': 'message-id', 'aria-describedby': 'message-id',
}} }}
message={<span id="message-id">{this.state.message}</span>} message={<span id="message-id">{this.state.message}</span>}

View File

@ -9,4 +9,5 @@ export const LIST_NETWORKS_ENDPOINT = ENDPOINT_ROOT + "listNetworks";
export const WIFI_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "wifiSettings"; export const WIFI_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "wifiSettings";
export const WIFI_STATUS_ENDPOINT = ENDPOINT_ROOT + "wifiStatus"; export const WIFI_STATUS_ENDPOINT = ENDPOINT_ROOT + "wifiStatus";
export const OTA_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "otaSettings"; export const OTA_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "otaSettings";
export const SIGN_IN_ENDPOINT = ENDPOINT_ROOT + "signIn"; export const SIGN_IN_ENDPOINT = ENDPOINT_ROOT + "signIn";
export const VERIFY_AUTHORIZATION_ENDPOINT = ENDPOINT_ROOT + "verifyAuthorization";

View File

@ -1,5 +1,4 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { withStyles } from '@material-ui/core/styles'; import { withStyles } from '@material-ui/core/styles';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import Paper from '@material-ui/core/Paper'; import Paper from '@material-ui/core/Paper';
@ -10,41 +9,42 @@ import ForwardIcon from '@material-ui/icons/Forward';
import { withNotifier } from '../components/SnackbarNotification'; import { withNotifier } from '../components/SnackbarNotification';
import { SIGN_IN_ENDPOINT } from '../constants/Endpoints'; import { SIGN_IN_ENDPOINT } from '../constants/Endpoints';
import { withAuthenticationContext } from '../authentication/Context'; import { withAuthenticationContext } from '../authentication/Context';
import PasswordValidator from '../components/PasswordValidator';
const styles = theme => ({ const styles = theme => {
loginPage: { return {
padding: theme.spacing.unit * 2, loginPage: {
height: "100vh", display: "flex",
display: "flex" height: "100vh",
}, margin: "auto",
loginPanel: { padding: theme.spacing.unit * 2,
margin: "auto", justifyContent: "center",
padding: theme.spacing.unit * 2, flexDirection: "column",
paddingTop: "200px", maxWidth: theme.breakpoints.values.sm
backgroundImage: 'url("/app/icon.png")', },
backgroundRepeat: "no-repeat", loginPanel: {
backgroundPosition: "50% " + theme.spacing.unit * 2 + "px", textAlign: "center",
backgroundSize: "auto 150px", padding: theme.spacing.unit * 2,
textAlign: "center" paddingTop: "200px",
}, backgroundImage: 'url("/app/icon.png")',
extendedIcon: { backgroundRepeat: "no-repeat",
marginRight: theme.spacing.unit, backgroundPosition: "50% " + theme.spacing.unit * 2 + "px",
}, backgroundSize: "auto 150px",
loadingSettings: { width: "100%"
margin: theme.spacing.unit, },
}, extendedIcon: {
loadingSettingsDetails: { marginRight: theme.spacing.unit,
margin: theme.spacing.unit * 4, },
textAlign: "center" textField: {
}, width: "100%"
textField: { },
width: "100%" button: {
}, marginRight: theme.spacing.unit * 2,
button: { marginTop: theme.spacing.unit * 2,
marginRight: theme.spacing.unit * 2, }
marginTop: theme.spacing.unit * 2,
} }
}); }
class LoginPage extends Component { class LoginPage extends Component {
@ -53,7 +53,7 @@ class LoginPage extends Component {
this.state = { this.state = {
username: '', username: '',
password: '', password: '',
fetched: false processing: false
}; };
} }
@ -64,7 +64,7 @@ class LoginPage extends Component {
onSubmit = () => { onSubmit = () => {
const { username, password } = this.state; const { username, password } = this.state;
const { authenticationContext } = this.props; const { authenticationContext } = this.props;
this.setState({ fetched: false }); this.setState({ processing: true });
fetch(SIGN_IN_ENDPOINT, { fetch(SIGN_IN_ENDPOINT, {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
@ -76,21 +76,22 @@ class LoginPage extends Component {
if (response.status === 200) { if (response.status === 200) {
return response.json(); return response.json();
} else if (response.status === 401) { } else if (response.status === 401) {
throw Error("Login details invalid!"); throw Error("Invalid login details.");
} else { } else {
throw Error("Invalid status code: " + response.status); throw Error("Invalid status code: " + response.status);
} }
}).then(json =>{ }).then(json => {
authenticationContext.signIn(json.access_token); authenticationContext.signIn(json.access_token);
this.setState({ processing: false });
}) })
.catch(error => { .catch(error => {
this.props.raiseNotification(error.message); this.props.raiseNotification(error.message);
this.setState({ fetched: true }); this.setState({ processing: false });
}); });
}; };
render() { render() {
const { username, password } = this.state; const { username, password, processing } = this.state;
const { classes } = this.props; const { classes } = this.props;
return ( return (
<div className={classes.loginPage}> <div className={classes.loginPage}>
@ -98,6 +99,7 @@ class LoginPage extends Component {
<Typography variant="h4">{APP_NAME}</Typography> <Typography variant="h4">{APP_NAME}</Typography>
<ValidatorForm onSubmit={this.onSubmit}> <ValidatorForm onSubmit={this.onSubmit}>
<TextValidator <TextValidator
disabled={processing}
validators={['required']} validators={['required']}
errorMessages={['Username is required']} errorMessages={['Username is required']}
name="username" name="username"
@ -107,7 +109,8 @@ class LoginPage extends Component {
onChange={this.handleValueChange('username')} onChange={this.handleValueChange('username')}
margin="normal" margin="normal"
/> />
<TextValidator <PasswordValidator
disabled={processing}
validators={['required']} validators={['required']}
errorMessages={['Password is required']} errorMessages={['Password is required']}
name="password" name="password"
@ -117,7 +120,7 @@ class LoginPage extends Component {
onChange={this.handleValueChange('password')} onChange={this.handleValueChange('password')}
margin="normal" margin="normal"
/> />
<Fab variant="extended" color="primary" className={classes.button} type="submit"> <Fab variant="extended" color="primary" className={classes.button} type="submit" disabled={processing}>
<ForwardIcon className={classes.extendedIcon} /> <ForwardIcon className={classes.extendedIcon} />
Sign In Sign In
</Fab> </Fab>

View File

@ -19,7 +19,8 @@ monitor_speed = 115200
build_flags= build_flags=
-D NO_GLOBAL_ARDUINOOTA -D NO_GLOBAL_ARDUINOOTA
; -D ENABLE_CORS -D ENABLE_CORS
-D CORS_ORIGIN=\"http://localhost:3000\"
lib_deps = lib_deps =
NtpClientLib@>=2.5.1,<3.0.0 NtpClientLib@>=2.5.1,<3.0.0
ArduinoJson@>=6.0.0,<7.0.0 ArduinoJson@>=6.0.0,<7.0.0

View File

@ -77,8 +77,9 @@ void setup() {
// Disable CORS if required // Disable CORS if required
#if defined(ENABLE_CORS) #if defined(ENABLE_CORS)
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", CORS_ORIGIN);
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "Accept, Content-Type, Authorization");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Credentials", "true");
#endif #endif
server.begin(); server.begin();