Complete rewrite of the UI.

pull/330/head
Clement Michaud 2019-01-19 20:10:43 +01:00
parent 694840790b
commit 605002a333
56 changed files with 1188 additions and 504 deletions

View File

@ -3297,6 +3297,15 @@
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz",
"integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg=="
},
"connected-react-router": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.2.1.tgz",
"integrity": "sha512-7QFs0wPYvwrzA7NptHx0DgblNA/nVErX0TUjTiOCwXSaqj/1Ng+nEmEczrfdA8gw7kIzFIa08WJGMymdb7bAZA==",
"requires": {
"immutable": "^3.8.1",
"seamless-immutable": "^7.1.3"
}
},
"console-browserify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz",
@ -5957,13 +5966,11 @@
},
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"optional": true
"bundled": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -5976,18 +5983,15 @@
},
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"optional": true
"bundled": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"optional": true
"bundled": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"optional": true
"bundled": true
},
"core-util-is": {
"version": "1.0.2",
@ -6090,8 +6094,7 @@
},
"inherits": {
"version": "2.0.3",
"bundled": true,
"optional": true
"bundled": true
},
"ini": {
"version": "1.3.5",
@ -6101,7 +6104,6 @@
"is-fullwidth-code-point": {
"version": "1.0.0",
"bundled": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -6114,20 +6116,17 @@
"minimatch": {
"version": "3.0.4",
"bundled": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"minimist": {
"version": "0.0.8",
"bundled": true,
"optional": true
"bundled": true
},
"minipass": {
"version": "2.2.4",
"bundled": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.1",
"yallist": "^3.0.0"
@ -6144,7 +6143,6 @@
"mkdirp": {
"version": "0.5.1",
"bundled": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -6217,8 +6215,7 @@
},
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"optional": true
"bundled": true
},
"object-assign": {
"version": "4.1.1",
@ -6228,7 +6225,6 @@
"once": {
"version": "1.4.0",
"bundled": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -6334,7 +6330,6 @@
"string-width": {
"version": "1.0.2",
"bundled": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -7380,6 +7375,11 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-1.7.2.tgz",
"integrity": "sha512-4Urocwu9+XLDJw4Tc6ZCg7APVjjLInCFvO4TwGsAYV5zT6YYSor14dsZR0+0tHlDIN92cFUOq+i7fC00G5vTxA=="
},
"immutable": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz",
"integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM="
},
"import-cwd": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@ -15417,6 +15417,11 @@
}
}
},
"seamless-immutable": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/seamless-immutable/-/seamless-immutable-7.1.4.tgz",
"integrity": "sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A=="
},
"select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",

View File

@ -18,6 +18,7 @@
"@types/redux-thunk": "^2.1.0",
"await-to-js": "^2.1.1",
"classnames": "^2.2.6",
"connected-react-router": "^6.2.1",
"jss": "^9.8.7",
"node-sass": "^4.11.0",
"qrcode.react": "^0.9.2",

View File

@ -4,22 +4,28 @@ import './App.css';
import { Router, Route, Switch } from "react-router-dom";
import { routes } from './routes/index';
import { createBrowserHistory } from 'history';
import { createStore, applyMiddleware } from 'redux';
import { createStore, applyMiddleware, compose } from 'redux';
import reducer from './reducers';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import { routerMiddleware, ConnectedRouter } from 'connected-react-router';
const history = createBrowserHistory();
const store = createStore(
reducer,
applyMiddleware(thunk)
reducer(history),
compose(
applyMiddleware(
routerMiddleware(history),
thunk
)
)
);
class App extends Component {
render() {
return (
<Provider store={store}>
<Router history={history}>
<ConnectedRouter history={history}>
<div className="App">
<Switch>
{routes.map((r, key) => {
@ -27,7 +33,7 @@ class App extends Component {
})}
</Switch>
</div>
</Router>
</ConnectedRouter>
</Provider>
);
}

View File

@ -0,0 +1,34 @@
import { createStyles, Theme } from "@material-ui/core";
const styles = createStyles((theme: Theme) => ({
container: {
textAlign: 'center',
},
messageContainer: {
fontSize: theme.typography.fontSize,
marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit * 2,
color: 'green',
display: 'inline-block',
marginLeft: theme.spacing.unit * 2,
textAlign: 'left',
},
successContainer: {
verticalAlign: 'middle',
paddingTop: theme.spacing.unit * 2,
paddingBottom: theme.spacing.unit * 2,
marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit * 3,
border: '1px solid #8ae48a',
borderRadius: '100px',
},
successLogoContainer: {
display: 'inline-block',
},
logoutButtonContainer: {
marginTop: theme.spacing.unit * 2,
},
}));
export default styles;

View File

@ -3,7 +3,6 @@ import { createStyles, Theme } from "@material-ui/core";
const styles = createStyles((theme: Theme) => ({
fields: {
marginTop: theme.spacing.unit * 2,
marginBottom: theme.spacing.unit,
},
field: {
paddingBottom: theme.spacing.unit * 2,

View File

@ -9,7 +9,7 @@ const styles = createStyles((theme: Theme) => ({
},
messageContainer: {
color: 'white',
fontSize: theme.typography.fontSize * 0.9,
fontSize: theme.typography.fontSize,
padding: theme.spacing.unit * 2,
border: '1px solid red',
borderRadius: '5px',

View File

@ -29,7 +29,6 @@ const styles = createStyles((theme: Theme) => ({
paddingRight: theme.spacing.unit * 2,
border: '1px solid #e0e0e0',
borderRadius: '2px',
textAlign: 'justify',
},
methodName: {
fontSize: theme.typography.fontSize * 1.2,

View File

@ -18,7 +18,7 @@ const styles = createStyles((theme: Theme) => ({
title: {
fontSize: '1.4em',
fontWeight: 'bold',
borderBottom: '1px solid #c7c7c7',
borderBottom: '5px solid ' + theme.palette.primary.main,
display: 'inline-block',
paddingRight: '10px',
paddingBottom: '5px',

View File

@ -7,10 +7,27 @@ const styles = createStyles((theme: Theme) => ({
field: {
width: '100%',
},
button: {
buttonsContainer: {
marginTop: theme.spacing.unit * 2,
width: '100%',
}
},
buttonContainer: {
width: '50%',
display: 'inline-block',
boxSizing: 'border-box',
},
buttonConfirmContainer: {
paddingRight: theme.spacing.unit / 2,
},
buttonConfirm: {
width: '100%',
},
buttonCancelContainer: {
paddingLeft: theme.spacing.unit / 2,
},
buttonCancel: {
width: '100%',
},
}));
export default styles;

View File

@ -8,6 +8,20 @@ const styles = createStyles((theme: Theme) => ({
width: '100%',
marginBottom: theme.spacing.unit * 2,
},
buttonsContainer: {
width: '100%',
},
buttonContainer: {
width: '50%',
boxSizing: 'border-box',
display: 'inline-block',
},
buttonResetContainer: {
paddingRight: theme.spacing.unit / 2,
},
buttonCancelContainer: {
paddingLeft: theme.spacing.unit / 2,
},
button: {
width: '100%',
}

View File

@ -0,0 +1,19 @@
import { Dispatch } from "redux";
import * as AutheliaService from '../services/AutheliaService';
import { fetchStateFailure, fetchStateSuccess } from "../reducers/Portal/Authentication/actions";
import to from "await-to-js";
export default async function(dispatch: Dispatch) {
let err, res;
[err, res] = await to(AutheliaService.fetchState());
if (err) {
await dispatch(fetchStateFailure(err.message));
return;
}
if (!res) {
await dispatch(fetchStateFailure('No response'));
return
}
await dispatch(fetchStateSuccess(res));
return res;
}

View File

@ -0,0 +1,18 @@
import { Dispatch } from "redux";
import { logout, logoutFailure, logoutSuccess } from "../reducers/Portal/SecondFactor/actions";
import to from "await-to-js";
import * as AutheliaService from '../services/AutheliaService';
import fetchState from "./FetchStateBehavior";
export default async function(dispatch: Dispatch) {
await dispatch(logout());
let err, res;
[err, res] = await to(AutheliaService.postLogout());
if (err) {
await dispatch(logoutFailure(err.message));
return;
}
await dispatch(logoutSuccess());
await fetchState(dispatch);
}

View File

@ -0,0 +1,43 @@
import React, { Component } from "react";
import styles from '../../assets/jss/components/AlreadyAuthenticated/AlreadyAuthenticated';
import { WithStyles, withStyles, Button } from "@material-ui/core";
import CircleLoader, { Status } from "../CircleLoader/CircleLoader";
export interface OwnProps {
username: string;
}
export interface DispatchProps {
onLogoutClicked: () => void;
}
export type Props = OwnProps & DispatchProps & WithStyles;
class AlreadyAuthenticated extends Component<Props> {
render() {
const { classes } = this.props;
return (
<div className={classes.container}>
<div className={classes.successContainer}>
<CircleLoader status={Status.SUCCESSFUL} />
<span className={classes.messageContainer}>
<b>{this.props.username}</b><br/>
you are authenticated
</span>
</div>
<div>Close this tab or logout</div>
<div className={classes.logoutButtonContainer}>
<Button
onClick={this.props.onLogoutClicked}
variant="contained"
color="primary">
Logout
</Button>
</div>
</div>
)
}
}
export default withStyles(styles)(AlreadyAuthenticated);

View File

@ -7,40 +7,38 @@ import FormControlLabel from '@material-ui/core/FormControlLabel';
import Checkbox from '@material-ui/core/Checkbox';
import { Link } from "react-router-dom";
import { RouterProps, RouteProps } from "react-router";
import { WithStyles, withStyles } from "@material-ui/core";
import firstFactorViewStyles from '../../assets/jss/views/FirstFactorView/FirstFactorView';
import styles from '../../assets/jss/components/FirstFactorForm/FirstFactorForm';
import FormNotification from "../../components/FormNotification/FormNotification";
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import StateSynchronizer from "../../containers/components/StateSynchronizer/StateSynchronizer";
import RemoteState from "../../reducers/Portal/RemoteState";
export interface Props extends RouteProps, RouterProps, WithStyles {
export interface StateProps {
formDisabled: boolean;
error: string | null;
}
export interface DispatchProps {
onAuthenticationRequested(username: string, password: string): void;
}
export type Props = StateProps & DispatchProps & WithStyles;
interface State {
rememberMe: boolean;
username: string;
password: string;
loginButtonDisabled: boolean;
errorMessage: string | null;
remoteState: RemoteState | null;
rememberMe: boolean;
}
class FirstFactorView extends Component<Props, State> {
class FirstFactorForm extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
rememberMe: false,
username: '',
password: '',
loginButtonDisabled: false,
errorMessage: null,
remoteState: null,
rememberMe: false,
}
}
@ -68,13 +66,13 @@ class FirstFactorView extends Component<Props, State> {
}
}
private renderWithState() {
render() {
const { classes } = this.props;
return (
<div>
<FormNotification
show={this.state.errorMessage != null}>
{this.state.errorMessage || ''}
show={this.props.error != null}>
{this.props.error || ''}
</FormNotification>
<div className={classes.fields}>
<div className={classes.field}>
@ -83,6 +81,7 @@ class FirstFactorView extends Component<Props, State> {
variant="outlined"
id="username"
label="Username"
disabled={this.props.formDisabled}
onChange={this.onUsernameChanged}>
</TextField>
</div>
@ -93,6 +92,7 @@ class FirstFactorView extends Component<Props, State> {
variant="outlined"
label="Password"
type="password"
disabled={this.props.formDisabled}
onChange={this.onPasswordChanged}
onKeyPress={this.onPasswordKeyPressed}>
</TextField>
@ -104,7 +104,7 @@ class FirstFactorView extends Component<Props, State> {
onClick={this.onLoginClicked}
variant="contained"
color="primary"
disabled={this.state.loginButtonDisabled}>
disabled={this.props.formDisabled}>
Login
</Button>
</div>
@ -132,30 +132,11 @@ class FirstFactorView extends Component<Props, State> {
)
}
render() {
return (
<div>
<StateSynchronizer
onLoaded={(remoteState) => this.setState({remoteState})}/>
{this.state.remoteState ? this.renderWithState() : null}
</div>
)
}
private authenticate() {
this.setState({loginButtonDisabled: true});
this.props.onAuthenticationRequested(
this.state.username,
this.state.password);
this.setState({errorMessage: null});
}
onFailure = (error: string) => {
this.setState({
loginButtonDisabled: false,
errorMessage: 'An error occured. Your username/password are probably wrong.'
});
}
}
export default withStyles(firstFactorViewStyles)(FirstFactorView);
export default withStyles(styles)(FirstFactorForm);

View File

@ -1,38 +1,52 @@
import React, { Component } from 'react';
import React, { Component, KeyboardEvent, ChangeEvent } from 'react';
import { WithStyles, withStyles, Button, TextField } from '@material-ui/core';
import styles from '../../assets/jss/views/SecondFactorView/SecondFactorView';
import StateSynchronizer from '../../containers/components/StateSynchronizer/StateSynchronizer';
import { RouterProps, Redirect } from 'react-router';
import RemoteState from '../../reducers/Portal/RemoteState';
import AuthenticationLevel from '../../types/AuthenticationLevel';
import { WithState } from '../../components/StateSynchronizer/WithState';
import styles from '../../assets/jss/components/SecondFactorForm/SecondFactorForm';
import CircleLoader, { Status } from '../../components/CircleLoader/CircleLoader';
import FormNotification from '../FormNotification/FormNotification';
export interface Props extends WithStyles, RouterProps, WithState {
export interface OwnProps {
username: string;
redirection: string | null;
}
export interface StateProps {
securityKeySupported: boolean;
securityKeyVerified: boolean;
securityKeyError: string | null;
oneTimePasswordVerificationInProgress: boolean,
oneTimePasswordVerificationError: string | null;
}
export interface DispatchProps {
onInit: () => void;
onLogoutClicked: () => void;
onRegisterSecurityKeyClicked: () => void;
onRegisterOneTimePasswordClicked: () => void;
onStateLoaded: (state: RemoteState) => void;
};
onOneTimePasswordValidationRequested: (token: string) => void;
}
export type Props = OwnProps & StateProps & DispatchProps & WithStyles;
interface State {
remoteState: RemoteState | null;
oneTimePassword: string;
}
class SecondFactorView extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
remoteState: null,
oneTimePassword: '',
}
}
componentWillMount() {
this.props.onInit();
}
private renderU2f(n: number) {
const { classes } = this.props;
let u2fStatus = Status.LOADING;
@ -58,17 +72,37 @@ class SecondFactorView extends Component<Props, State> {
)
}
private onOneTimePasswordChanged = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({oneTimePassword: e.target.value});
}
private onTotpKeyPressed = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
this.onOneTimePasswordValidationRequested();
}
}
private onOneTimePasswordValidationRequested = () => {
if (this.props.oneTimePasswordVerificationInProgress) return;
this.props.onOneTimePasswordValidationRequested(this.state.oneTimePassword);
}
private renderTotp(n: number) {
const { classes } = this.props;
return (
<div className={classes.methodTotp} key='totp-method'>
<div className={classes.methodName}>Option {n} - One-Time Password</div>
<FormNotification show={this.props.oneTimePasswordVerificationError !== null}>
{this.props.oneTimePasswordVerificationError}
</FormNotification>
<TextField
className={classes.totpField}
name="password"
id="password"
name="totp-token"
id="totp-token"
variant="outlined"
label="One-Time Password">
label="One-Time Password"
onChange={this.onOneTimePasswordChanged}
onKeyPress={this.onTotpKeyPressed}>
</TextField>
<div className={classes.registerDeviceContainer}>
<a className={classes.registerDevice} href="#"
@ -79,7 +113,9 @@ class SecondFactorView extends Component<Props, State> {
<Button
className={classes.totpButton}
variant="contained"
color="primary">
color="primary"
onClick={this.onOneTimePasswordValidationRequested}
disabled={this.props.oneTimePasswordVerificationInProgress}>
OK
</Button>
</div>
@ -103,16 +139,12 @@ class SecondFactorView extends Component<Props, State> {
);
}
private renderWithState(state: RemoteState) {
if (state.authentication_level < AuthenticationLevel.ONE_FACTOR) {
return <Redirect to='/' key='redirect' />;
}
render() {
const { classes } = this.props;
return (
<div className={classes.container}>
<div className={classes.header}>
<div className={classes.hello}>Hello <b>{state.username}</b></div>
<div className={classes.hello}>Hello <b>{this.props.username}</b></div>
<div className={classes.logout}>
<a onClick={this.props.onLogoutClicked} href="#">Logout</a>
</div>
@ -123,21 +155,6 @@ class SecondFactorView extends Component<Props, State> {
</div>
)
}
onStateLoaded = (remoteState: RemoteState) => {
this.setState({remoteState});
this.props.onStateLoaded(remoteState);
}
render() {
return (
<div>
<StateSynchronizer
onLoaded={this.onStateLoaded}/>
{this.state.remoteState ? this.renderWithState(this.state.remoteState) : null}
</div>
)
}
}
export default withStyles(styles)(SecondFactorView);

View File

@ -1,28 +0,0 @@
import React, { Component } from "react";
import RemoteState from "../../reducers/Portal/RemoteState";
import { WithState } from "./WithState";
export type OnLoaded = (state: RemoteState) => void;
export type OnError = (err: Error) => void;
export interface Props extends WithState {
fetch: (onloaded: OnLoaded, onerror: OnError) => void;
onLoaded: OnLoaded;
onError?: OnError;
}
class StateSynchronizer extends Component<Props> {
componentWillMount() {
this.props.fetch(
(state) => this.props.onLoaded(state),
(err: Error) => {
if (this.props.onError) this.props.onError(err);
});
}
render() {
return null;
}
}
export default StateSynchronizer;

View File

@ -1,7 +0,0 @@
import RemoteState from '../../reducers/Portal/RemoteState';
export interface WithState {
state: RemoteState | null;
stateError: string | null;
stateLoading: boolean;
}

View File

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { RootState } from '../../../reducers';
import AlreadyAuthenticated, { DispatchProps } from '../../../components/AlreadyAuthenticated/AlreadyAuthenticated';
import LogoutBehavior from '../../../behaviors/LogoutBehavior';
const mapStateToProps = (state: RootState) => {
return {};
}
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
return {
onLogoutClicked: () => LogoutBehavior(dispatch),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(AlreadyAuthenticated);

View File

@ -0,0 +1,53 @@
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { authenticateFailure, authenticateSuccess, authenticate } from '../../../reducers/Portal/FirstFactor/actions';
import FirstFactorForm, { StateProps } from '../../../components/FirstFactorForm/FirstFactorForm';
import { RootState } from '../../../reducers';
import * as AutheliaService from '../../../services/AutheliaService';
import to from 'await-to-js';
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
const mapStateToProps = (state: RootState): StateProps => {
return {
error: state.firstFactor.error,
formDisabled: state.firstFactor.loading,
};
}
function onAuthenticationRequested(dispatch: Dispatch) {
return async (username: string, password: string) => {
let err, res;
// Validate first factor
dispatch(authenticate());
[err, res] = await to(AutheliaService.postFirstFactorAuth(username, password));
if (err) {
await dispatch(authenticateFailure(err.message));
return;
}
if (!res) {
await dispatch(authenticateFailure('No response'));
return;
}
const json = await res.json();
if ('error' in json) {
await dispatch(authenticateFailure(json['error']));
return;
}
dispatch(authenticateSuccess());
// fetch state
FetchStateBehavior(dispatch);
}
}
const mapDispatchToProps = (dispatch: Dispatch) => {
return {
onAuthenticationRequested: onAuthenticationRequested(dispatch),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(FirstFactorForm);

View File

@ -0,0 +1,114 @@
import { connect } from 'react-redux';
import { RootState } from '../../../reducers';
import { Dispatch } from 'redux';
import u2fApi from 'u2f-api';
import to from 'await-to-js';
import { securityKeySignSuccess, securityKeySign, securityKeySignFailure, setSecurityKeySupported, oneTimePasswordVerification, oneTimePasswordVerificationFailure, oneTimePasswordVerificationSuccess } from '../../../reducers/Portal/SecondFactor/actions';
import SecondFactorForm, { OwnProps, StateProps } from '../../../components/SecondFactorForm/SecondFactorForm';
import * as AutheliaService from '../../../services/AutheliaService';
import { push } from 'connected-react-router';
import fetchState from '../../../behaviors/FetchStateBehavior';
import LogoutBehavior from '../../../behaviors/LogoutBehavior';
const mapStateToProps = (state: RootState): StateProps => ({
securityKeySupported: state.secondFactor.securityKeySupported,
securityKeyVerified: state.secondFactor.securityKeySignSuccess || false,
securityKeyError: state.secondFactor.error,
oneTimePasswordVerificationInProgress: state.secondFactor.oneTimePasswordVerificationLoading,
oneTimePasswordVerificationError: state.secondFactor.oneTimePasswordVerificationError,
});
async function triggerSecurityKeySigning(dispatch: Dispatch) {
let err, result;
dispatch(securityKeySign());
[err, result] = await to(AutheliaService.requestSigning());
if (err) {
await dispatch(securityKeySignFailure(err.message));
throw err;
}
if (!result) {
await dispatch(securityKeySignFailure('No response'));
throw 'No response';
}
[err, result] = await to(u2fApi.sign(result, 60));
if (err) {
await dispatch(securityKeySignFailure(err.message));
throw err;
}
if (!result) {
await dispatch(securityKeySignFailure('No response'));
throw 'No response';
}
[err, result] = await to(AutheliaService.completeSecurityKeySigning(result));
if (err) {
await dispatch(securityKeySignFailure(err.message));
throw err;
}
await dispatch(securityKeySignSuccess());
}
function redirectOnSuccess(dispatch: Dispatch, ownProps: OwnProps, duration?: number) {
function redirect() {
if (ownProps.redirection) {
window.location.href = ownProps.redirection;
} else {
fetchState(dispatch);
}
}
if (duration) {
setTimeout(redirect, duration);
} else {
redirect();
}
}
const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => {
return {
onLogoutClicked: () => LogoutBehavior(dispatch),
onRegisterSecurityKeyClicked: async () => {
await AutheliaService.startU2FRegistrationIdentityProcess();
await dispatch(push('/confirmation-sent'));
},
onRegisterOneTimePasswordClicked: async () => {
await AutheliaService.startTOTPRegistrationIdentityProcess();
await dispatch(push('/confirmation-sent'));
},
onInit: async () => {
const isU2FSupported = await u2fApi.isSupported();
if (isU2FSupported) {
await dispatch(setSecurityKeySupported(true));
await triggerSecurityKeySigning(dispatch);
redirectOnSuccess(dispatch, ownProps, 1000);
}
},
onOneTimePasswordValidationRequested: async (token: string) => {
let err, res;
dispatch(oneTimePasswordVerification());
[err, res] = await to(AutheliaService.verifyTotpToken(token));
if (err) {
dispatch(oneTimePasswordVerificationFailure(err.message));
throw err;
}
if (!res) {
dispatch(oneTimePasswordVerificationFailure('No response'));
throw 'No response';
}
const body = await res.json();
if ('error' in body) {
dispatch(oneTimePasswordVerificationFailure(body['error']));
throw body['error'];
}
dispatch(oneTimePasswordVerificationSuccess());
redirectOnSuccess(dispatch, ownProps);
},
}
}
export default connect(mapStateToProps, mapDispatchToProps)(SecondFactorForm);

View File

@ -1,31 +0,0 @@
import { connect } from 'react-redux';
import StateSynchronizer, { OnLoaded, OnError } from '../../../components/StateSynchronizer/StateSynchronizer';
import { RootState } from '../../../reducers';
import { fetchStateSuccess, fetchState, fetchStateFailure } from '../../../reducers/Portal/FirstFactor/actions';
import RemoteState from '../../../reducers/Portal/RemoteState';
import { Dispatch } from 'redux';
const mapStateToProps = (state: RootState) => ({
state: state.firstFactor.remoteState,
stateError: state.firstFactor.remoteStateError,
stateLoading: state.firstFactor.remoteStateLoading,
});
const mapDispatchToProps = (dispatch: Dispatch) => {
return {
fetch: (onloaded: OnLoaded, onerror: OnError) => {
dispatch(fetchState());
fetch('/api/state').then(async (res) => {
const body = await res.json() as RemoteState;
await dispatch(fetchStateSuccess(body));
await onloaded(body);
})
.catch(async (err) => {
await dispatch(fetchStateFailure(err));
await onerror(err);
})
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(StateSynchronizer);

View File

@ -2,8 +2,6 @@ import { connect } from 'react-redux';
import PortalLayout from '../../../layouts/PortalLayout/PortalLayout';
import { RootState } from '../../../reducers';
const mapStateToProps = (state: RootState) => ({
authenticationLevel: (state.firstFactor.remoteState) ? state.firstFactor.remoteState.authentication_level : 0,
});
const mapStateToProps = (state: RootState) => ({});
export default connect(mapStateToProps)(PortalLayout);

View File

@ -0,0 +1,42 @@
import { connect } from 'react-redux';
import AuthenticationView, {StateProps, Stage, DispatchProps} from '../../../views/AuthenticationView/AuthenticationView';
import { RootState } from '../../../reducers';
import { Dispatch } from 'redux';
import AuthenticationLevel from '../../../types/AuthenticationLevel';
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
import { setRedirectionUrl } from '../../../reducers/Portal/Authentication/actions';
function authenticationLevelToStage(level: AuthenticationLevel): Stage {
switch (level) {
case AuthenticationLevel.NOT_AUTHENTICATED:
return Stage.FIRST_FACTOR;
case AuthenticationLevel.ONE_FACTOR:
return Stage.SECOND_FACTOR;
case AuthenticationLevel.TWO_FACTOR:
return Stage.ALREADY_AUTHENTICATED;
}
}
const mapStateToProps = (state: RootState): StateProps => {
const stage = (state.authentication.remoteState)
? authenticationLevelToStage(state.authentication.remoteState.authentication_level)
: Stage.FIRST_FACTOR;
return {
redirectionUrl: state.authentication.redirectionUrl,
remoteState: state.authentication.remoteState,
stage: stage,
};
}
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
return {
onInit: async (redirectionUrl?: string) => {
await FetchStateBehavior(dispatch);
if (redirectionUrl) {
await dispatch(setRedirectionUrl(redirectionUrl));
}
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(AuthenticationView);

View File

@ -1,57 +0,0 @@
import { connect } from 'react-redux';
import QueryString from 'query-string';
import FirstFactorView, { Props } from '../../../views/FirstFactorView/FirstFactorView';
import { Dispatch } from 'redux';
import { authenticateFailure, authenticateSuccess, authenticate } from '../../../reducers/Portal/FirstFactor/actions';
import { RootState } from '../../../reducers';
const mapStateToProps = (state: RootState) => ({});
function redirect2FA(props: Props) {
if (!props.location) {
props.history.push('/2fa');
return;
}
const params = QueryString.parse(props.location.search);
if ('rd' in params) {
const rd = params['rd'] as string;
props.history.push(`/2fa?rd=${rd}`);
return;
}
props.history.push('/2fa');
}
function onAuthenticationRequested(dispatch: Dispatch, ownProps: Props) {
return async (username: string, password: string) => {
dispatch(authenticate());
fetch('/api/firstfactor', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
password: password,
})
}).then(async (res) => {
const json = await res.json();
if ('error' in json) {
dispatch(authenticateFailure(json['error']));
return;
}
dispatch(authenticateSuccess());
redirect2FA(ownProps);
});
}
}
const mapDispatchToProps = (dispatch: Dispatch, ownProps: Props) => {
return {
onAuthenticationRequested: onAuthenticationRequested(dispatch, ownProps),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(FirstFactorView);

View File

@ -0,0 +1,31 @@
import { connect } from 'react-redux';
import { RootState } from '../../../reducers';
import { Dispatch } from 'redux';
import { push } from 'connected-react-router';
import * as AutheliaService from '../../../services/AutheliaService';
import ForgotPasswordView from '../../../views/ForgotPasswordView/ForgotPasswordView';
import { forgotPasswordRequest, forgotPasswordSuccess, forgotPasswordFailure } from '../../../reducers/Portal/ForgotPassword/actions';
const mapStateToProps = (state: RootState) => ({
disabled: state.forgotPassword.loading,
});
const mapDispatchToProps = (dispatch: Dispatch) => {
return {
onPasswordResetRequested: async (username: string) => {
try {
dispatch(forgotPasswordRequest());
await AutheliaService.initiatePasswordResetIdentityValidation(username);
dispatch(forgotPasswordSuccess());
await dispatch(push('/confirmation-sent'));
} catch (err) {
dispatch(forgotPasswordFailure(err.message));
}
},
onCancelClicked: async () => {
dispatch(push('/'));
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(ForgotPasswordView);

View File

@ -4,7 +4,7 @@ import { RootState } from '../../../reducers';
import { Dispatch } from 'redux';
import {to} from 'await-to-js';
import { generateTotpSecret, generateTotpSecretSuccess, generateTotpSecretFailure } from '../../../reducers/Portal/OneTimePasswordRegistration/actions';
import { Props } from '../../../views/OneTimePasswordRegistrationView/OneTimePasswordRegistrationView';
import { push } from 'connected-react-router';
const mapStateToProps = (state: RootState) => ({
error: state.oneTimePasswordRegistration.error,
@ -46,7 +46,7 @@ async function tryGenerateTotpSecret(dispatch: Dispatch, token: string) {
dispatch(generateTotpSecretSuccess(result));
}
const mapDispatchToProps = (dispatch: Dispatch, ownProps: Props) => {
const mapDispatchToProps = (dispatch: Dispatch) => {
let internalToken: string;
return {
onInit: async (token: string) => {
@ -57,10 +57,10 @@ const mapDispatchToProps = (dispatch: Dispatch, ownProps: Props) => {
await tryGenerateTotpSecret(dispatch, internalToken);
},
onCancelClicked: () => {
ownProps.history.push('/2fa');
dispatch(push('/'));
},
onLoginClicked: () => {
ownProps.history.push('/2fa');
dispatch(push('/'));
}
}
}

View File

@ -0,0 +1,27 @@
import { connect } from 'react-redux';
import { RootState } from '../../../reducers';
import { Dispatch } from 'redux';
import { push } from 'connected-react-router';
import * as AutheliaService from '../../../services/AutheliaService';
import ResetPasswordView, { StateProps } from '../../../views/ResetPasswordView/ResetPasswordView';
const mapStateToProps = (state: RootState): StateProps => ({
disabled: state.resetPassword.loading,
});
const mapDispatchToProps = (dispatch: Dispatch) => {
return {
onInit: async (token: string) => {
await AutheliaService.completePasswordResetIdentityValidation(token);
},
onPasswordResetRequested: async (newPassword: string) => {
await AutheliaService.resetPassword(newPassword);
await dispatch(push('/'));
},
onCancelClicked: async () => {
await dispatch(push('/'));
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(ResetPasswordView);

View File

@ -1,136 +0,0 @@
import { connect } from 'react-redux';
import QueryString from 'query-string';
import SecondFactorView, {Props} from '../../../views/SecondFactorView/SecondFactorView';
import { RootState } from '../../../reducers';
import { Dispatch } from 'redux';
import u2fApi, { SignResponse } from 'u2f-api';
import to from 'await-to-js';
import { logoutSuccess, logoutFailure, logout, securityKeySignSuccess, securityKeySign, securityKeySignFailure, setSecurityKeySupported } from '../../../reducers/Portal/SecondFactor/actions';
import AuthenticationLevel from '../../../types/AuthenticationLevel';
import RemoteState from '../../../reducers/Portal/RemoteState';
const mapStateToProps = (state: RootState) => ({
state: state.firstFactor.remoteState,
stateError: state.firstFactor.remoteStateError,
securityKeySupported: state.secondFactor.securityKeySupported,
securityKeyVerified: state.secondFactor.securityKeySignSuccess || false,
securityKeyError: state.secondFactor.error,
});
async function requestSigning() {
return fetch('/api/u2f/sign_request')
.then(async (res) => {
if (res.status !== 200) {
throw new Error('Status code ' + res.status);
}
return res.json();
});
}
async function completeSecurityKeySigning(response: u2fApi.SignResponse) {
return fetch('/api/u2f/sign', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(response),
})
.then(async (res) => {
if (res.status !== 200) {
throw new Error('Status code ' + res.status);
}
});
}
async function triggerSecurityKeySigning(dispatch: Dispatch, props: Props) {
let err, result;
dispatch(securityKeySign());
[err, result] = await to(requestSigning());
if (err) {
dispatch(securityKeySignFailure(err.message));
return;
}
[err, result] = await to(u2fApi.sign(result, 60));
if (err) {
dispatch(securityKeySignFailure(err.message));
return;
}
[err, result] = await to(completeSecurityKeySigning(result as SignResponse));
if (err) {
dispatch(securityKeySignFailure(err.message));
return;
}
dispatch(securityKeySignSuccess());
await redirectUponAuthentication(props);
}
async function redirectUponAuthentication(props: Props) {
const params = QueryString.parse(props.history.location.search);
if ('rd' in params) {
setTimeout(() => {
window.location.replace(params['rd'] as string);
}, 1500);
}
}
const mapDispatchToProps = (dispatch: Dispatch, ownProps: Props) => {
return {
onLogoutClicked: () => {
dispatch(logout());
fetch('/api/logout', {
method: 'POST',
})
.then(async (res) => {
if (res.status != 200) {
throw new Error('Status code ' + res.status);
}
await dispatch(logoutSuccess());
ownProps.history.push('/');
})
.catch(async (err: string) => {
console.error(err);
await dispatch(logoutFailure(err));
});
},
onRegisterSecurityKeyClicked: () => {
fetch('/api/secondfactor/u2f/identity/start', {
method: 'POST',
})
.then(async (res) => {
if (res.status != 200) {
throw new Error('Status code ' + res.status);
}
ownProps.history.push('/confirmation-sent');
})
.catch((err) => console.error(err));
},
onRegisterOneTimePasswordClicked: () => {
fetch('/api/secondfactor/totp/identity/start', {
method: 'POST',
})
.then(async (res) => {
if (res.status != 200) {
throw new Error('Status code ' + res.status);
}
ownProps.history.push('/confirmation-sent');
})
.catch((err) => console.error(err));
},
onStateLoaded: async (state: RemoteState) => {
if (state.authentication_level < AuthenticationLevel.ONE_FACTOR) {
ownProps.history.push('/');
return;
}
const isU2FSupported = await u2fApi.isSupported();
if (isU2FSupported) {
await dispatch(setSecurityKeySupported(true));
await triggerSecurityKeySigning(dispatch, ownProps);
}
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(SecondFactorView);

View File

@ -7,11 +7,8 @@ import { AUTHELIA_GITHUB_URL } from "../../constants";
import { WithStyles, withStyles } from "@material-ui/core";
import styles from '../../assets/jss/layouts/PortalLayout/PortalLayout';
import AuthenticationLevel from "../../types/AuthenticationLevel";
interface Props extends RouterProps, RouteProps, WithStyles {
authenticationLevel: AuthenticationLevel;
}
interface Props extends RouterProps, RouteProps, WithStyles {}
class PortalLayout extends Component<Props> {
private renderTitle() {

View File

@ -0,0 +1,25 @@
import { createAction } from 'typesafe-actions';
import {
FETCH_STATE_REQUEST,
FETCH_STATE_SUCCESS,
FETCH_STATE_FAILURE,
SET_REDIRECTION_URL,
} from "../../constants";
import RemoteState from '../../../views/AuthenticationView/RemoteState';
/* FETCH_STATE */
export const fetchState = createAction(FETCH_STATE_REQUEST);
export const fetchStateSuccess = createAction(FETCH_STATE_SUCCESS, resolve => {
return (state: RemoteState) => {
return resolve(state);
}
});
export const fetchStateFailure = createAction(FETCH_STATE_FAILURE, resolve => {
return (err: string) => {
return resolve(err);
}
});
export const setRedirectionUrl = createAction(SET_REDIRECTION_URL, resolve => {
return (url: string) => resolve(url);
})

View File

@ -0,0 +1,51 @@
import * as Actions from './actions';
import { ActionType, getType } from 'typesafe-actions';
import RemoteState from '../../../views/AuthenticationView/RemoteState';
export type Action = ActionType<typeof Actions>;
interface State {
redirectionUrl : string | null;
remoteState: RemoteState | null;
remoteStateLoading: boolean;
remoteStateError: string | null;
}
const initialState: State = {
redirectionUrl: null,
remoteState: null,
remoteStateLoading: false,
remoteStateError: null,
}
export default (state = initialState, action: Action): State => {
switch(action.type) {
case getType(Actions.fetchState):
return {
...state,
remoteState: null,
remoteStateError: null,
remoteStateLoading: true,
};
case getType(Actions.fetchStateSuccess):
return {
...state,
remoteState: action.payload,
remoteStateLoading: false,
};
case getType(Actions.fetchStateFailure):
return {
...state,
remoteStateError: action.payload,
remoteStateLoading: false,
};
case getType(Actions.setRedirectionUrl):
return {
...state,
redirectionUrl: action.payload,
}
}
return state;
}

View File

@ -2,25 +2,8 @@ import { createAction } from 'typesafe-actions';
import {
AUTHENTICATE_REQUEST,
AUTHENTICATE_SUCCESS,
AUTHENTICATE_FAILURE,
FETCH_STATE_REQUEST,
FETCH_STATE_SUCCESS,
FETCH_STATE_FAILURE,
AUTHENTICATE_FAILURE
} from "../../constants";
import RemoteState from '../RemoteState';
/* FETCH_STATE */
export const fetchState = createAction(FETCH_STATE_REQUEST);
export const fetchStateSuccess = createAction(FETCH_STATE_SUCCESS, resolve => {
return (state: RemoteState) => {
return resolve(state);
}
});
export const fetchStateFailure = createAction(FETCH_STATE_FAILURE, resolve => {
return (err: string) => {
return resolve(err);
}
})
/* AUTHENTICATE_REQUEST */
export const authenticate = createAction(AUTHENTICATE_REQUEST);

View File

@ -1,7 +1,6 @@
import * as Actions from './actions';
import { ActionType, getType, StateType } from 'typesafe-actions';
import RemoteState from '../RemoteState';
import { ActionType, getType } from 'typesafe-actions';
export type FirstFactorAction = ActionType<typeof Actions>;
@ -11,28 +10,19 @@ enum Result {
FAILURE,
}
interface State {
interface FirstFactorState {
lastResult: Result;
loading: boolean;
error: string | null;
remoteState: RemoteState | null;
remoteStateLoading: boolean;
remoteStateError: string | null;
}
const initialState: State = {
const firstFactorInitialState: FirstFactorState = {
lastResult: Result.NONE,
loading: false,
error: null,
remoteState: null,
remoteStateLoading: false,
remoteStateError: null,
}
export type PortalState = StateType<State>;
export default (state = initialState, action: FirstFactorAction) => {
export default (state = firstFactorInitialState, action: FirstFactorAction): FirstFactorState => {
switch(action.type) {
case getType(Actions.authenticate):
return {
@ -54,26 +44,6 @@ export default (state = initialState, action: FirstFactorAction) => {
loading: false,
error: action.payload,
};
case getType(Actions.fetchState):
return {
...state,
remoteState: null,
remoteStateError: null,
remoteStateLoading: true,
};
case getType(Actions.fetchStateSuccess):
return {
...state,
remoteState: action.payload,
remoteStateLoading: false,
};
case getType(Actions.fetchStateFailure):
return {
...state,
remoteStateError: action.payload,
remoteStateLoading: false,
};
}
return state;
}

View File

@ -0,0 +1,13 @@
import { createAction } from 'typesafe-actions';
import {
FORGOT_PASSWORD_REQUEST,
FORGOT_PASSWORD_SUCCESS,
FORGOT_PASSWORD_FAILURE
} from "../../constants";
/* AUTHENTICATE_REQUEST */
export const forgotPasswordRequest = createAction(FORGOT_PASSWORD_REQUEST);
export const forgotPasswordSuccess = createAction(FORGOT_PASSWORD_SUCCESS);
export const forgotPasswordFailure = createAction(FORGOT_PASSWORD_FAILURE, resolve => {
return (error: string) => resolve(error);
});

View File

@ -0,0 +1,44 @@
import * as Actions from './actions';
import { ActionType, getType } from 'typesafe-actions';
export type Action = ActionType<typeof Actions>;
interface State {
loading: boolean;
success: boolean | null;
error: string | null;
}
const initialState: State = {
loading: false,
success: null,
error: null,
}
export default (state = initialState, action: Action): State => {
switch(action.type) {
case getType(Actions.forgotPasswordRequest):
return {
...state,
loading: true,
error: null
};
case getType(Actions.forgotPasswordSuccess):
return {
...state,
success: true,
loading: false,
error: null,
};
case getType(Actions.forgotPasswordFailure):
return {
...state,
success: false,
loading: false,
error: action.payload,
};
}
return state;
}

View File

@ -4,28 +4,19 @@ import { Secret } from "../../../views/OneTimePasswordRegistrationView/Secret";
type OneTimePasswordRegistrationAction = ActionType<typeof Actions>
export interface State {
export interface OneTimePasswordRegistrationState {
loading: boolean;
error: string | null;
secret: Secret | null;
}
let initialState: State = {
let oneTimePasswordRegistrationInitialState: OneTimePasswordRegistrationState = {
loading: true,
error: null,
secret: null,
}
initialState = {
secret: {
base32_secret: 'PBSFWU2RM42HG3TNIRHUQMKSKVUW6NCNOBNFOLCFJZATS6CTI47A',
otpauth_url: 'PBSFWU2RM42HG3TNIRHUQMKSKVUW6NCNOBNFOLCFJZATS6CTI47A',
},
error: null,
loading: false,
}
export default (state = initialState, action: OneTimePasswordRegistrationAction) => {
export default (state = oneTimePasswordRegistrationInitialState, action: OneTimePasswordRegistrationAction): OneTimePasswordRegistrationState => {
switch(action.type) {
case getType(Actions.generateTotpSecret):
return {

View File

@ -0,0 +1,9 @@
import { createAction } from 'typesafe-actions';
import { RESET_PASSWORD_REQUEST, RESET_PASSWORD_SUCCESS, RESET_PASSWORD_FAILURE } from "../../constants";
/* AUTHENTICATE_REQUEST */
export const resetPasswordRequest = createAction(RESET_PASSWORD_REQUEST);
export const resetPasswordSuccess = createAction(RESET_PASSWORD_SUCCESS);
export const resetPasswordFailure = createAction(RESET_PASSWORD_FAILURE, resolve => {
return (error: string) => resolve(error);
});

View File

@ -0,0 +1,44 @@
import * as Actions from './actions';
import { ActionType, getType } from 'typesafe-actions';
export type Action = ActionType<typeof Actions>;
interface State {
loading: boolean;
success: boolean | null;
error: string | null;
}
const initialState: State = {
loading: false,
success: null,
error: null,
}
export default (state = initialState, action: Action): State => {
switch(action.type) {
case getType(Actions.resetPasswordRequest):
return {
...state,
loading: true,
error: null
};
case getType(Actions.resetPasswordSuccess):
return {
...state,
success: true,
loading: false,
error: null,
};
case getType(Actions.resetPasswordFailure):
return {
...state,
success: false,
loading: false,
error: action.payload,
};
}
return state;
}

View File

@ -6,7 +6,10 @@ import {
SECURITY_KEY_SIGN,
SECURITY_KEY_SIGN_SUCCESS,
SECURITY_KEY_SIGN_FAILURE,
SET_SECURITY_KEY_SUPPORTED
SET_SECURITY_KEY_SUPPORTED,
ONE_TIME_PASSWORD_VERIFICATION_REQUEST,
ONE_TIME_PASSWORD_VERIFICATION_SUCCESS,
ONE_TIME_PASSWORD_VERIFICATION_FAILURE
} from "../../constants";
export const setSecurityKeySupported = createAction(SET_SECURITY_KEY_SUPPORTED, resolve => {
@ -17,7 +20,14 @@ export const securityKeySign = createAction(SECURITY_KEY_SIGN);
export const securityKeySignSuccess = createAction(SECURITY_KEY_SIGN_SUCCESS);
export const securityKeySignFailure = createAction(SECURITY_KEY_SIGN_FAILURE, resolve => {
return (error: string) => resolve(error);
})
});
export const oneTimePasswordVerification = createAction(ONE_TIME_PASSWORD_VERIFICATION_REQUEST);
export const oneTimePasswordVerificationSuccess = createAction(ONE_TIME_PASSWORD_VERIFICATION_SUCCESS);
export const oneTimePasswordVerificationFailure = createAction(ONE_TIME_PASSWORD_VERIFICATION_FAILURE, resolve => {
return (err: string) => resolve(err);
});
export const logout = createAction(LOGOUT_REQUEST);
export const logoutSuccess = createAction(LOGOUT_SUCCESS);

View File

@ -4,7 +4,7 @@ import { ActionType, getType, StateType } from 'typesafe-actions';
export type SecondFactorAction = ActionType<typeof Actions>;
interface State {
interface SecondFactorState {
logoutLoading: boolean;
logoutSuccess: boolean | null;
error: string | null;
@ -12,9 +12,13 @@ interface State {
securityKeySupported: boolean;
securityKeySignLoading: boolean;
securityKeySignSuccess: boolean | null;
oneTimePasswordVerificationLoading: boolean,
oneTimePasswordVerificationSuccess: boolean | null,
oneTimePasswordVerificationError: string | null,
}
const initialState: State = {
const secondFactorInitialState: SecondFactorState = {
logoutLoading: false,
logoutSuccess: null,
error: null,
@ -22,11 +26,15 @@ const initialState: State = {
securityKeySupported: false,
securityKeySignLoading: false,
securityKeySignSuccess: null,
oneTimePasswordVerificationLoading: false,
oneTimePasswordVerificationError: null,
oneTimePasswordVerificationSuccess: null,
}
export type PortalState = StateType<State>;
export type PortalState = StateType<SecondFactorState>;
export default (state = initialState, action: SecondFactorAction): State => {
export default (state = secondFactorInitialState, action: SecondFactorAction): SecondFactorState => {
switch(action.type) {
case getType(Actions.logout):
return {
@ -47,6 +55,7 @@ export default (state = initialState, action: SecondFactorAction): State => {
logoutLoading: false,
error: action.payload,
}
case getType(Actions.securityKeySign):
return {
...state,
@ -65,11 +74,31 @@ export default (state = initialState, action: SecondFactorAction): State => {
securityKeySignLoading: false,
securityKeySignSuccess: false,
};
case getType(Actions.setSecurityKeySupported):
return {
...state,
securityKeySupported: action.payload,
};
case getType(Actions.oneTimePasswordVerification):
return {
...state,
oneTimePasswordVerificationLoading: true,
oneTimePasswordVerificationError: null,
}
case getType(Actions.oneTimePasswordVerificationSuccess):
return {
...state,
oneTimePasswordVerificationLoading: false,
oneTimePasswordVerificationSuccess: true,
}
case getType(Actions.oneTimePasswordVerificationFailure):
return {
...state,
oneTimePasswordVerificationLoading: false,
oneTimePasswordVerificationError: action.payload,
}
}
return state;
}

View File

@ -3,17 +3,17 @@ import * as Actions from './actions';
type SecurityKeyRegistrationAction = ActionType<typeof Actions>
export interface State {
export interface SecurityKeyRegistrationState {
error: string | null;
success: boolean | null;
}
let initialState: State = {
let securityKeyRegistrationInitialState: SecurityKeyRegistrationState = {
error: null,
success: null,
}
export default (state = initialState, action: SecurityKeyRegistrationAction): State => {
export default (state = securityKeyRegistrationInitialState, action: SecurityKeyRegistrationAction): SecurityKeyRegistrationState => {
switch(action.type) {
case getType(Actions.registerSecurityKey):
return {

View File

@ -4,10 +4,25 @@ import FirstFactorReducer from './FirstFactor/reducer';
import SecondFactorReducer from './SecondFactor/reducer';
import OneTimePasswordRegistrationReducer from './OneTimePasswordRegistration/reducer';
import SecurityKeyRegistrationReducer from './SecurityKeyRegistration/reducer';
import AuthenticationReducer from './Authentication/reducer';
import ForgotPasswordReducer from './ForgotPassword/reducer';
import ResetPasswordReducer from './ResetPassword/reducer';
export default combineReducers({
firstFactor: FirstFactorReducer,
secondFactor: SecondFactorReducer,
oneTimePasswordRegistration: OneTimePasswordRegistrationReducer,
securityKeyRegistration: SecurityKeyRegistrationReducer,
});
import { connectRouter } from 'connected-react-router'
import { History } from 'history';
function reducer(history: History) {
return combineReducers({
router: connectRouter(history),
authentication: AuthenticationReducer,
firstFactor: FirstFactorReducer,
secondFactor: SecondFactorReducer,
oneTimePasswordRegistration: OneTimePasswordRegistrationReducer,
securityKeyRegistration: SecurityKeyRegistrationReducer,
forgotPassword: ForgotPasswordReducer,
resetPassword: ResetPasswordReducer,
});
}
export default reducer;

View File

@ -3,6 +3,10 @@ export const FETCH_STATE_REQUEST = '@portal/fetch_state_request';
export const FETCH_STATE_SUCCESS = '@portal/fetch_state_success';
export const FETCH_STATE_FAILURE = '@portal/fetch_state_failure';
// AUTHENTICATION PROCESS
export const SET_REDIRECTION_URL = '@portal/authenticate/set_redirection_url';
export const AUTHENTICATE_REQUEST = '@portal/authenticate_request';
export const AUTHENTICATE_SUCCESS = '@portal/authenticate_success';
export const AUTHENTICATE_FAILURE = '@portal/authenticate_failure';
@ -14,6 +18,10 @@ export const SECURITY_KEY_SIGN = '@portal/second_factor/security_key_sign';
export const SECURITY_KEY_SIGN_SUCCESS = '@portal/second_factor/security_key_sign_success';
export const SECURITY_KEY_SIGN_FAILURE = '@portal/second_factor/security_key_sign_failure';
export const ONE_TIME_PASSWORD_VERIFICATION_REQUEST = '@portal/second_factor/one_time_password_verification_request';
export const ONE_TIME_PASSWORD_VERIFICATION_SUCCESS = '@portal/second_factor/one_time_password_verification_success';
export const ONE_TIME_PASSWORD_VERIFICATION_FAILURE = '@portal/second_factor/one_time_password_verification_failure';
export const LOGOUT_REQUEST = '@portal/logout_request';
export const LOGOUT_SUCCESS = '@portal/logout_success';
export const LOGOUT_FAILURE = '@portal/logout_failure';
@ -26,4 +34,14 @@ export const GENERATE_TOTP_SECRET_FAILURE = '@portal/generate_totp_secret_failur
// U2F REGISTRATION
export const REGISTER_SECURITY_KEY_REQUEST = '@portal/security_key_registration/register_request';
export const REGISTER_SECURITY_KEY_SUCCESS = '@portal/security_key_registration/register_success';
export const REGISTER_SECURITY_KEY_FAILURE = '@portal/security_key_registration/register_failed';
export const REGISTER_SECURITY_KEY_FAILURE = '@portal/security_key_registration/register_failed';
// FORGOT PASSWORD
export const FORGOT_PASSWORD_REQUEST = '@portal/forgot_password/forgot_password_request';
export const FORGOT_PASSWORD_SUCCESS = '@portal/forgot_password/forgot_password_success';
export const FORGOT_PASSWORD_FAILURE = '@portal/forgot_password/forgot_password_failure';
// FORGOT PASSWORD
export const RESET_PASSWORD_REQUEST = '@portal/forgot_password/reset_password_request';
export const RESET_PASSWORD_SUCCESS = '@portal/forgot_password/reset_password_success';
export const RESET_PASSWORD_FAILURE = '@portal/forgot_password/reset_password_failure';

View File

@ -1,6 +1,11 @@
import PortalReducer from './Portal';
import { StateType } from 'typesafe-actions';
export type RootState = StateType<typeof PortalReducer>;
function getReturnType<R> (f: (...args: any[]) => R): R {
return null!;
}
const t = getReturnType(PortalReducer)
export type RootState = StateType<typeof t>;
export default PortalReducer;

View File

@ -1,19 +1,14 @@
import FirstFactorView from "../containers/views/FirstFactorView/FirstFactorView";
import SecondFactorView from "../containers/views/SecondFactorView/SecondFactorView";
import ConfirmationSentView from "../views/ConfirmationSentView/ConfirmationSentView";
import OneTimePasswordRegistrationView from "../containers/views/OneTimePasswordRegistrationView/OneTimePasswordRegistrationView";
import SecurityKeyRegistrationView from "../containers/views/SecurityKeyRegistrationView/SecurityKeyRegistrationView";
import ForgotPasswordView from "../views/ForgotPasswordView/ForgotPasswordView";
import ResetPasswordView from "../views/ResetPasswordView/ResetPasswordView";
import ForgotPasswordView from "../containers/views/ForgotPasswordView/ForgotPasswordView";
import ResetPasswordView from "../containers/views/ResetPasswordView/ResetPasswordView";
import AuthenticationView from "../containers/views/AuthenticationView/AuthenticationView";
export const routes = [{
path: '/',
title: 'Login',
component: FirstFactorView,
}, {
path: '/2fa',
title: '2-factor',
component: SecondFactorView,
component: AuthenticationView,
}, {
path: '/confirmation-sent',
title: 'e-mail sent',

View File

@ -0,0 +1,117 @@
import RemoteState from "../views/AuthenticationView/RemoteState";
import u2fApi, { SignRequest } from "u2f-api";
async function fetchSafe(url: string, options?: RequestInit) {
return fetch(url, options)
.then(async (res) => {
if (res.status !== 200 && res.status !== 204) {
throw new Error('Status code ' + res.status);
}
return res;
});
}
/**
* Fetch current authentication state.
*/
export async function fetchState() {
return fetchSafe('/api/state')
.then(async (res) => {
const body = await res.json() as RemoteState;
return body;
});
}
export async function postFirstFactorAuth(username: string, password: string) {
return fetchSafe('/api/firstfactor', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
password: password,
})
});
}
export async function postLogout() {
return fetchSafe('/api/logout', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
})
}
export async function startU2FRegistrationIdentityProcess() {
return fetchSafe('/api/secondfactor/u2f/identity/start', {
method: 'POST',
});
}
export async function startTOTPRegistrationIdentityProcess() {
return fetchSafe('/api/secondfactor/totp/identity/start', {
method: 'POST',
});
}
export async function requestSigning() {
return fetchSafe('/api/u2f/sign_request')
.then(async (res) => {
const body = await res.json();
return body as SignRequest;
});
}
export async function completeSecurityKeySigning(response: u2fApi.SignResponse) {
return fetchSafe('/api/u2f/sign', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(response),
});
}
export async function verifyTotpToken(token: string) {
return fetchSafe('/api/totp', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({token}),
})
}
export async function initiatePasswordResetIdentityValidation(username: string) {
return fetchSafe('/api/password-reset/identity/start', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({username})
});
}
export async function completePasswordResetIdentityValidation(token: string) {
return fetch(`/api/password-reset/identity/finish?token=${token}`, {
method: 'POST',
});
}
export async function resetPassword(newPassword: string) {
return fetchSafe('/api/password-reset', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({password: newPassword})
});
}

View File

@ -0,0 +1,53 @@
import React, { Component } from "react";
import AlreadyAuthenticated from "../../containers/components/AlreadyAuthenticated/AlreadyAuthenticated";
import FirstFactorForm from "../../containers/components/FirstFactorForm/FirstFactorForm";
import SecondFactorForm from "../../containers/components/SecondFactorForm/SecondFactorForm";
import RemoteState from "./RemoteState";
import { RouterProps, Redirect } from "react-router";
import queryString from 'query-string';
export enum Stage {
FIRST_FACTOR,
SECOND_FACTOR,
ALREADY_AUTHENTICATED,
}
export interface StateProps {
stage: Stage;
remoteState: RemoteState | null;
redirectionUrl: string | null;
}
export interface DispatchProps {
onInit: (redirectionUrl?: string) => void;
}
export type Props = StateProps & DispatchProps & RouterProps;
class AuthenticationView extends Component<Props> {
componentDidMount() {
if (this.props.history.location) {
const params = queryString.parse(this.props.history.location.search);
if ('rd' in params) {
this.props.onInit(params['rd'] as string);
}
}
this.props.onInit();
}
render() {
if (!this.props.remoteState) return null;
if (this.props.stage === Stage.SECOND_FACTOR) {
return <SecondFactorForm
username={this.props.remoteState.username}
redirection={this.props.redirectionUrl} />;
} else if (this.props.stage === Stage.ALREADY_AUTHENTICATED) {
return <AlreadyAuthenticated
username={this.props.remoteState.username}/>;
}
return <FirstFactorForm />;
}
}
export default AuthenticationView;

View File

@ -1,31 +1,84 @@
import React, { Component } from "react";
import React, { Component, ChangeEvent, KeyboardEvent } from "react";
import { TextField, WithStyles, withStyles, Button } from "@material-ui/core";
import classnames from 'classnames';
import styles from '../../assets/jss/views/ForgotPasswordView/ForgotPasswordView';
import { RouterProps } from "react-router";
interface Props extends WithStyles, RouterProps {}
export interface StateProps {
disabled: boolean;
}
export interface DispatchProps {
onPasswordResetRequested: (username: string) => void;
onCancelClicked: () => void;
}
export type Props = StateProps & DispatchProps & WithStyles;
interface State {
username: string;
}
class ForgotPasswordView extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
username: '',
}
}
private onUsernameChanged = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({username: e.target.value});
}
private onKeyPressed = (e: KeyboardEvent) => {
if (e.key == 'Enter') {
this.onPasswordResetRequested();
}
}
private onPasswordResetRequested = () => {
if (this.state.username.length == 0) return;
this.props.onPasswordResetRequested(this.state.username);
}
class ForgotPasswordView extends Component<Props> {
render() {
const { classes } = this.props;
return (
<div>
<div>What's you e-mail address?</div>
<div>What's your username?</div>
<div className={classes.form}>
<TextField
className={classes.field}
variant="outlined"
id="email"
label="E-mail">
id="username"
label="Username"
onChange={this.onUsernameChanged}
onKeyPress={this.onKeyPressed}
value={this.state.username}
disabled={this.props.disabled}>
</TextField>
<Button
onClick={() => this.props.history.push('/confirmation-sent')}
variant="contained"
color="primary"
className={classes.button}>
Next
</Button>
<div className={classes.buttonsContainer}>
<div className={classnames(classes.buttonContainer, classes.buttonConfirmContainer)}>
<Button
onClick={this.onPasswordResetRequested}
variant="contained"
color="primary"
className={classes.buttonConfirm}
disabled={this.props.disabled}>
Next
</Button>
</div>
<div className={classnames(classes.buttonContainer, classes.buttonCancelContainer)}>
<Button
onClick={this.props.onCancelClicked}
variant="contained"
color="primary"
className={classes.buttonCancel}>
Cancel
</Button>
</div>
</div>
</div>
</div>
);

View File

@ -1,16 +1,86 @@
import React, { Component } from "react";
import React, { Component, KeyboardEvent, ChangeEvent } from "react";
import { TextField, Button, WithStyles, withStyles } from "@material-ui/core";
import { RouterProps } from "react-router";
import classnames from 'classnames';
import QueryString from 'query-string';
import styles from '../../assets/jss/views/ResetPasswordView/ResetPasswordView';
import FormNotification from "../../components/FormNotification/FormNotification";
interface Props extends RouterProps, WithStyles {};
export interface StateProps {
disabled: boolean;
}
export interface DispatchProps {
onInit: (token: string) => void;
onPasswordResetRequested: (password: string) => void;
onCancelClicked: () => void;
}
export type Props = StateProps & DispatchProps & RouterProps & WithStyles;
interface State {
password1: string;
password2: string;
error: string | null,
}
class ResetPasswordView extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
password1: '',
password2: '',
error: null,
}
}
componentWillMount() {
if (!this.props.history.location) {
console.error('There is no location to retrieve query params from...');
return;
}
const params = QueryString.parse(this.props.history.location.search);
if (!('token' in params)) {
console.error('Token parameter is expected and not provided');
return;
}
this.props.onInit(params['token'] as string);
}
private onPasswordResetRequested() {
if (this.state.password1 && this.state.password1 === this.state.password2) {
this.props.onPasswordResetRequested(this.state.password1);
} else {
this.setState({error: 'The passwords are different.'});
}
}
private onKeyPressed = (e: KeyboardEvent) => {
if (e.key == 'Enter') {
this.onPasswordResetRequested();
}
}
private onResetClicked = () => {
this.onPasswordResetRequested();
}
private onPassword1Changed = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({password1: e.target.value});
}
private onPassword2Changed = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({password2: e.target.value});
}
class ResetPasswordView extends Component<Props> {
render() {
const { classes } = this.props;
return (
<div>
<FormNotification show={this.state.error !== null}>
{this.state.error}
</FormNotification>
<div>Enter your new password</div>
<div className={classes.form}>
<TextField
@ -18,6 +88,9 @@ class ResetPasswordView extends Component<Props> {
variant="outlined"
type="password"
id="password1"
value={this.state.password1}
onChange={this.onPassword1Changed}
disabled={this.props.disabled}
label="New password">
</TextField>
<TextField
@ -25,15 +98,33 @@ class ResetPasswordView extends Component<Props> {
variant="outlined"
type="password"
id="password2"
value={this.state.password2}
onKeyPress={this.onKeyPressed}
onChange={this.onPassword2Changed}
disabled={this.props.disabled}
label="Confirm password">
</TextField>
<Button
onClick={() => this.props.history.push('/')}
variant="contained"
color="primary"
className={classes.button}>
Next
</Button>
<div className={classes.buttonsContainer}>
<div className={classnames(classes.buttonContainer, classes.buttonResetContainer)}>
<Button
onClick={this.onResetClicked}
variant="contained"
color="primary"
disabled={this.props.disabled}
className={classnames(classes.button, classes.buttonReset)}>
Reset
</Button>
</div>
<div className={classnames(classes.buttonContainer, classes.buttonCancelContainer)}>
<Button
onClick={this.props.onCancelClicked}
variant="contained"
color="primary"
className={classnames(classes.button, classes.buttonCancel)}>
Cancel
</Button>
</div>
</div>
</div>
</div>
)

View File

@ -94,8 +94,8 @@ export default function (vars: ServerVariables) {
})
.catch(Exceptions.LdapBindError, function (err: Error) {
vars.regulator.mark(username, false);
return ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.OPERATION_FAILED)(err);
return ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.AUTHENTICATION_FAILED)(err);
})
.catch(ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.OPERATION_FAILED));
.catch(ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.AUTHENTICATION_FAILED));
};
}

View File

@ -28,7 +28,7 @@ export default class PasswordResetHandler implements IdentityValidable {
preValidationInit(req: express.Request): BluebirdPromise<Identity> {
const that = this;
const userid: string =
objectPath.get<express.Request, string>(req, "query.userid");
objectPath.get<express.Request, string>(req, "body.username");
return BluebirdPromise.resolve()
.then(function () {
that.logger.debug(req, "User '%s' requested a password reset", userid);

View File

@ -1,10 +1,5 @@
import express = require("express");
import BluebirdPromise = require("bluebird");
import objectPath = require("object-path");
import exceptions = require("../../../Exceptions");
import Constants = require("./../constants");
const TEMPLATE_NAME = "password-reset-request";

View File

@ -34,7 +34,7 @@ export default function (vars: ServerVariables) {
return Bluebird.resolve();
})
.catch(ErrorReplies.replyWithError200(req, res, vars.logger,
UserMessages.OPERATION_FAILED));
UserMessages.AUTHENTICATION_TOTP_FAILED));
}
return handler;
}

View File

@ -49,7 +49,7 @@ export default function (vars: ServerVariables) {
return BluebirdPromise.resolve();
})
.catch(ErrorReplies.replyWithError200(req, res, vars.logger,
UserMessages.OPERATION_FAILED));
UserMessages.AUTHENTICATION_U2F_FAILED));
}
return handler;

View File

@ -187,7 +187,7 @@ export const RESET_PASSWORD_FORM_POST = "/api/password-reset";
*
* @apiDescription Serve a page that requires the username.
*/
export const RESET_PASSWORD_REQUEST_GET = "/password-reset/request";
export const RESET_PASSWORD_REQUEST_GET = "/api/password-reset/request";
@ -201,7 +201,7 @@ export const RESET_PASSWORD_REQUEST_GET = "/password-reset/request";
*
* @apiDescription Start password reset request.
*/
export const RESET_PASSWORD_IDENTITY_START_GET = "/password-reset/identity/start";
export const RESET_PASSWORD_IDENTITY_START_GET = "/api/password-reset/identity/start";
@ -215,7 +215,7 @@ export const RESET_PASSWORD_IDENTITY_START_GET = "/password-reset/identity/start
*
* @apiDescription Start password reset request.
*/
export const RESET_PASSWORD_IDENTITY_FINISH_GET = "/password-reset/identity/finish";
export const RESET_PASSWORD_IDENTITY_FINISH_GET = "/api/password-reset/identity/finish";