From 605002a3338ba075ba5ba1bfafcda87784e7c998 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 19 Jan 2019 20:10:43 +0100 Subject: [PATCH] Complete rewrite of the UI. --- client-react/package-lock.json | 47 +++--- client-react/package.json | 1 + client-react/src/App.tsx | 16 ++- .../AlreadyAuthenticated.ts | 34 +++++ .../FirstFactorForm/FirstFactorForm.ts} | 1 - .../FormNotification/FormNotification.ts | 2 +- .../SecondFactorForm/SecondFactorForm.ts} | 1 - .../jss/layouts/PortalLayout/PortalLayout.ts | 2 +- .../ForgotPasswordView/ForgotPasswordView.ts | 21 ++- .../ResetPasswordView/ResetPasswordView.ts | 14 ++ .../src/behaviors/FetchStateBehavior.ts | 19 +++ client-react/src/behaviors/LogoutBehavior.ts | 18 +++ .../AlreadyAuthenticated.tsx | 43 ++++++ .../FirstFactorForm/FirstFactorForm.tsx} | 57 +++----- .../SecondFactorForm/SecondFactorForm.tsx} | 91 +++++++----- .../StateSynchronizer/StateSynchronizer.tsx | 28 ---- .../components/StateSynchronizer/WithState.ts | 7 - .../AlreadyAuthenticated.ts | 17 +++ .../FirstFactorForm/FirstFactorForm.ts | 53 +++++++ .../SecondFactorForm/SecondFactorForm.ts | 114 +++++++++++++++ .../StateSynchronizer/StateSynchronizer.ts | 31 ---- .../layouts/PortalLayout/PortalLayout.ts | 4 +- .../AuthenticationView/AuthenticationView.ts | 42 ++++++ .../views/FirstFactorView/FirstFactorView.ts | 57 -------- .../ForgotPasswordView/ForgotPasswordView.ts | 31 ++++ .../OneTimePasswordRegistrationView.ts | 8 +- .../ResetPasswordView/ResetPasswordView.ts | 27 ++++ .../SecondFactorView/SecondFactorView.ts | 136 ------------------ .../src/layouts/PortalLayout/PortalLayout.tsx | 5 +- .../reducers/Portal/Authentication/actions.ts | 25 ++++ .../reducers/Portal/Authentication/reducer.ts | 51 +++++++ .../reducers/Portal/FirstFactor/actions.ts | 19 +-- .../reducers/Portal/FirstFactor/reducer.ts | 38 +---- .../reducers/Portal/ForgotPassword/actions.ts | 13 ++ .../reducers/Portal/ForgotPassword/reducer.ts | 44 ++++++ .../OneTimePasswordRegistration/reducer.ts | 15 +- .../reducers/Portal/ResetPassword/actions.ts | 9 ++ .../reducers/Portal/ResetPassword/reducer.ts | 44 ++++++ .../reducers/Portal/SecondFactor/actions.ts | 14 +- .../reducers/Portal/SecondFactor/reducer.ts | 37 ++++- .../Portal/SecurityKeyRegistration/reducer.ts | 6 +- client-react/src/reducers/Portal/index.ts | 27 +++- client-react/src/reducers/constants.ts | 20 ++- client-react/src/reducers/index.ts | 7 +- client-react/src/routes/routes.ts | 13 +- client-react/src/services/AutheliaService.ts | 117 +++++++++++++++ .../AuthenticationView/AuthenticationView.tsx | 53 +++++++ .../AuthenticationView}/RemoteState.ts | 0 .../ForgotPasswordView/ForgotPasswordView.tsx | 81 +++++++++-- .../ResetPasswordView/ResetPasswordView.tsx | 111 ++++++++++++-- server/src/lib/routes/firstfactor/post.ts | 4 +- .../identity/PasswordResetHandler.ts | 2 +- .../lib/routes/password-reset/request/get.ts | 5 - .../lib/routes/secondfactor/totp/sign/post.ts | 2 +- .../lib/routes/secondfactor/u2f/sign/post.ts | 2 +- shared/api.ts | 6 +- 56 files changed, 1188 insertions(+), 504 deletions(-) create mode 100644 client-react/src/assets/jss/components/AlreadyAuthenticated/AlreadyAuthenticated.ts rename client-react/src/assets/jss/{views/FirstFactorView/FirstFactorView.ts => components/FirstFactorForm/FirstFactorForm.ts} (94%) rename client-react/src/assets/jss/{views/SecondFactorView/SecondFactorView.ts => components/SecondFactorForm/SecondFactorForm.ts} (98%) create mode 100644 client-react/src/behaviors/FetchStateBehavior.ts create mode 100644 client-react/src/behaviors/LogoutBehavior.ts create mode 100644 client-react/src/components/AlreadyAuthenticated/AlreadyAuthenticated.tsx rename client-react/src/{views/FirstFactorView/FirstFactorView.tsx => components/FirstFactorForm/FirstFactorForm.tsx} (71%) rename client-react/src/{views/SecondFactorView/SecondFactorView.tsx => components/SecondFactorForm/SecondFactorForm.tsx} (62%) delete mode 100644 client-react/src/components/StateSynchronizer/StateSynchronizer.tsx delete mode 100644 client-react/src/components/StateSynchronizer/WithState.ts create mode 100644 client-react/src/containers/components/AlreadyAuthenticated/AlreadyAuthenticated.ts create mode 100644 client-react/src/containers/components/FirstFactorForm/FirstFactorForm.ts create mode 100644 client-react/src/containers/components/SecondFactorForm/SecondFactorForm.ts delete mode 100644 client-react/src/containers/components/StateSynchronizer/StateSynchronizer.ts create mode 100644 client-react/src/containers/views/AuthenticationView/AuthenticationView.ts delete mode 100644 client-react/src/containers/views/FirstFactorView/FirstFactorView.ts create mode 100644 client-react/src/containers/views/ForgotPasswordView/ForgotPasswordView.ts create mode 100644 client-react/src/containers/views/ResetPasswordView/ResetPasswordView.ts delete mode 100644 client-react/src/containers/views/SecondFactorView/SecondFactorView.ts create mode 100644 client-react/src/reducers/Portal/Authentication/actions.ts create mode 100644 client-react/src/reducers/Portal/Authentication/reducer.ts create mode 100644 client-react/src/reducers/Portal/ForgotPassword/actions.ts create mode 100644 client-react/src/reducers/Portal/ForgotPassword/reducer.ts create mode 100644 client-react/src/reducers/Portal/ResetPassword/actions.ts create mode 100644 client-react/src/reducers/Portal/ResetPassword/reducer.ts create mode 100644 client-react/src/services/AutheliaService.ts create mode 100644 client-react/src/views/AuthenticationView/AuthenticationView.tsx rename client-react/src/{reducers/Portal => views/AuthenticationView}/RemoteState.ts (100%) diff --git a/client-react/package-lock.json b/client-react/package-lock.json index b56ad64d..c68e8385 100644 --- a/client-react/package-lock.json +++ b/client-react/package-lock.json @@ -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", diff --git a/client-react/package.json b/client-react/package.json index 60e321b1..adcb6267 100644 --- a/client-react/package.json +++ b/client-react/package.json @@ -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", diff --git a/client-react/src/App.tsx b/client-react/src/App.tsx index f38561c7..de95ce95 100644 --- a/client-react/src/App.tsx +++ b/client-react/src/App.tsx @@ -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 ( - +
{routes.map((r, key) => { @@ -27,7 +33,7 @@ class App extends Component { })}
-
+
); } diff --git a/client-react/src/assets/jss/components/AlreadyAuthenticated/AlreadyAuthenticated.ts b/client-react/src/assets/jss/components/AlreadyAuthenticated/AlreadyAuthenticated.ts new file mode 100644 index 00000000..b43200b8 --- /dev/null +++ b/client-react/src/assets/jss/components/AlreadyAuthenticated/AlreadyAuthenticated.ts @@ -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; \ No newline at end of file diff --git a/client-react/src/assets/jss/views/FirstFactorView/FirstFactorView.ts b/client-react/src/assets/jss/components/FirstFactorForm/FirstFactorForm.ts similarity index 94% rename from client-react/src/assets/jss/views/FirstFactorView/FirstFactorView.ts rename to client-react/src/assets/jss/components/FirstFactorForm/FirstFactorForm.ts index 0404c260..9e791e4f 100644 --- a/client-react/src/assets/jss/views/FirstFactorView/FirstFactorView.ts +++ b/client-react/src/assets/jss/components/FirstFactorForm/FirstFactorForm.ts @@ -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, diff --git a/client-react/src/assets/jss/components/FormNotification/FormNotification.ts b/client-react/src/assets/jss/components/FormNotification/FormNotification.ts index db8d549e..60844ec6 100644 --- a/client-react/src/assets/jss/components/FormNotification/FormNotification.ts +++ b/client-react/src/assets/jss/components/FormNotification/FormNotification.ts @@ -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', diff --git a/client-react/src/assets/jss/views/SecondFactorView/SecondFactorView.ts b/client-react/src/assets/jss/components/SecondFactorForm/SecondFactorForm.ts similarity index 98% rename from client-react/src/assets/jss/views/SecondFactorView/SecondFactorView.ts rename to client-react/src/assets/jss/components/SecondFactorForm/SecondFactorForm.ts index f90a33c1..2e4e7936 100644 --- a/client-react/src/assets/jss/views/SecondFactorView/SecondFactorView.ts +++ b/client-react/src/assets/jss/components/SecondFactorForm/SecondFactorForm.ts @@ -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, diff --git a/client-react/src/assets/jss/layouts/PortalLayout/PortalLayout.ts b/client-react/src/assets/jss/layouts/PortalLayout/PortalLayout.ts index dd22d98b..d6cd01b3 100644 --- a/client-react/src/assets/jss/layouts/PortalLayout/PortalLayout.ts +++ b/client-react/src/assets/jss/layouts/PortalLayout/PortalLayout.ts @@ -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', diff --git a/client-react/src/assets/jss/views/ForgotPasswordView/ForgotPasswordView.ts b/client-react/src/assets/jss/views/ForgotPasswordView/ForgotPasswordView.ts index 330c78da..fcb61330 100644 --- a/client-react/src/assets/jss/views/ForgotPasswordView/ForgotPasswordView.ts +++ b/client-react/src/assets/jss/views/ForgotPasswordView/ForgotPasswordView.ts @@ -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; \ No newline at end of file diff --git a/client-react/src/assets/jss/views/ResetPasswordView/ResetPasswordView.ts b/client-react/src/assets/jss/views/ResetPasswordView/ResetPasswordView.ts index a147fc88..4c3b7e8a 100644 --- a/client-react/src/assets/jss/views/ResetPasswordView/ResetPasswordView.ts +++ b/client-react/src/assets/jss/views/ResetPasswordView/ResetPasswordView.ts @@ -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%', } diff --git a/client-react/src/behaviors/FetchStateBehavior.ts b/client-react/src/behaviors/FetchStateBehavior.ts new file mode 100644 index 00000000..37db1e2c --- /dev/null +++ b/client-react/src/behaviors/FetchStateBehavior.ts @@ -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; +} \ No newline at end of file diff --git a/client-react/src/behaviors/LogoutBehavior.ts b/client-react/src/behaviors/LogoutBehavior.ts new file mode 100644 index 00000000..a6b7b50b --- /dev/null +++ b/client-react/src/behaviors/LogoutBehavior.ts @@ -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); +} \ No newline at end of file diff --git a/client-react/src/components/AlreadyAuthenticated/AlreadyAuthenticated.tsx b/client-react/src/components/AlreadyAuthenticated/AlreadyAuthenticated.tsx new file mode 100644 index 00000000..575b3c59 --- /dev/null +++ b/client-react/src/components/AlreadyAuthenticated/AlreadyAuthenticated.tsx @@ -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 { + render() { + const { classes } = this.props; + return ( +
+
+ + + {this.props.username}
+ you are authenticated +
+
+
Close this tab or logout
+
+ +
+
+ ) + } +} + +export default withStyles(styles)(AlreadyAuthenticated); \ No newline at end of file diff --git a/client-react/src/views/FirstFactorView/FirstFactorView.tsx b/client-react/src/components/FirstFactorForm/FirstFactorForm.tsx similarity index 71% rename from client-react/src/views/FirstFactorView/FirstFactorView.tsx rename to client-react/src/components/FirstFactorForm/FirstFactorForm.tsx index 2cccfea3..943083b1 100644 --- a/client-react/src/views/FirstFactorView/FirstFactorView.tsx +++ b/client-react/src/components/FirstFactorForm/FirstFactorForm.tsx @@ -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 { +class FirstFactorForm extends Component { 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 { } } - private renderWithState() { + render() { const { classes } = this.props; return (
- {this.state.errorMessage || ''} + show={this.props.error != null}> + {this.props.error || ''}
@@ -83,6 +81,7 @@ class FirstFactorView extends Component { variant="outlined" id="username" label="Username" + disabled={this.props.formDisabled} onChange={this.onUsernameChanged}>
@@ -93,6 +92,7 @@ class FirstFactorView extends Component { variant="outlined" label="Password" type="password" + disabled={this.props.formDisabled} onChange={this.onPasswordChanged} onKeyPress={this.onPasswordKeyPressed}> @@ -104,7 +104,7 @@ class FirstFactorView extends Component { onClick={this.onLoginClicked} variant="contained" color="primary" - disabled={this.state.loginButtonDisabled}> + disabled={this.props.formDisabled}> Login
@@ -132,30 +132,11 @@ class FirstFactorView extends Component { ) } - render() { - return ( -
- this.setState({remoteState})}/> - {this.state.remoteState ? this.renderWithState() : null} -
- ) - } - 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); \ No newline at end of file +export default withStyles(styles)(FirstFactorForm); \ No newline at end of file diff --git a/client-react/src/views/SecondFactorView/SecondFactorView.tsx b/client-react/src/components/SecondFactorForm/SecondFactorForm.tsx similarity index 62% rename from client-react/src/views/SecondFactorView/SecondFactorView.tsx rename to client-react/src/components/SecondFactorForm/SecondFactorForm.tsx index dcc021da..f1e8aac4 100644 --- a/client-react/src/views/SecondFactorView/SecondFactorView.tsx +++ b/client-react/src/components/SecondFactorForm/SecondFactorForm.tsx @@ -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 { 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 { ) } + private onOneTimePasswordChanged = (e: ChangeEvent) => { + 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 (
Option {n} - One-Time Password
+ + {this.props.oneTimePasswordVerificationError} + + label="One-Time Password" + onChange={this.onOneTimePasswordChanged} + onKeyPress={this.onTotpKeyPressed}> @@ -103,16 +139,12 @@ class SecondFactorView extends Component { ); } - private renderWithState(state: RemoteState) { - if (state.authentication_level < AuthenticationLevel.ONE_FACTOR) { - return ; - } - + render() { const { classes } = this.props; return (
-
Hello {state.username}
+
Hello {this.props.username}
@@ -123,21 +155,6 @@ class SecondFactorView extends Component {
) } - - onStateLoaded = (remoteState: RemoteState) => { - this.setState({remoteState}); - this.props.onStateLoaded(remoteState); - } - - render() { - return ( -
- - {this.state.remoteState ? this.renderWithState(this.state.remoteState) : null} -
- ) - } } export default withStyles(styles)(SecondFactorView); \ No newline at end of file diff --git a/client-react/src/components/StateSynchronizer/StateSynchronizer.tsx b/client-react/src/components/StateSynchronizer/StateSynchronizer.tsx deleted file mode 100644 index 46b7399b..00000000 --- a/client-react/src/components/StateSynchronizer/StateSynchronizer.tsx +++ /dev/null @@ -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 { - 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; \ No newline at end of file diff --git a/client-react/src/components/StateSynchronizer/WithState.ts b/client-react/src/components/StateSynchronizer/WithState.ts deleted file mode 100644 index 4f3e6ec7..00000000 --- a/client-react/src/components/StateSynchronizer/WithState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import RemoteState from '../../reducers/Portal/RemoteState'; - -export interface WithState { - state: RemoteState | null; - stateError: string | null; - stateLoading: boolean; -} \ No newline at end of file diff --git a/client-react/src/containers/components/AlreadyAuthenticated/AlreadyAuthenticated.ts b/client-react/src/containers/components/AlreadyAuthenticated/AlreadyAuthenticated.ts new file mode 100644 index 00000000..5029b5f4 --- /dev/null +++ b/client-react/src/containers/components/AlreadyAuthenticated/AlreadyAuthenticated.ts @@ -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); \ No newline at end of file diff --git a/client-react/src/containers/components/FirstFactorForm/FirstFactorForm.ts b/client-react/src/containers/components/FirstFactorForm/FirstFactorForm.ts new file mode 100644 index 00000000..40e30e52 --- /dev/null +++ b/client-react/src/containers/components/FirstFactorForm/FirstFactorForm.ts @@ -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); \ No newline at end of file diff --git a/client-react/src/containers/components/SecondFactorForm/SecondFactorForm.ts b/client-react/src/containers/components/SecondFactorForm/SecondFactorForm.ts new file mode 100644 index 00000000..98f2e060 --- /dev/null +++ b/client-react/src/containers/components/SecondFactorForm/SecondFactorForm.ts @@ -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); \ No newline at end of file diff --git a/client-react/src/containers/components/StateSynchronizer/StateSynchronizer.ts b/client-react/src/containers/components/StateSynchronizer/StateSynchronizer.ts deleted file mode 100644 index c525cfa7..00000000 --- a/client-react/src/containers/components/StateSynchronizer/StateSynchronizer.ts +++ /dev/null @@ -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); \ No newline at end of file diff --git a/client-react/src/containers/layouts/PortalLayout/PortalLayout.ts b/client-react/src/containers/layouts/PortalLayout/PortalLayout.ts index b426550d..f3c52147 100644 --- a/client-react/src/containers/layouts/PortalLayout/PortalLayout.ts +++ b/client-react/src/containers/layouts/PortalLayout/PortalLayout.ts @@ -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); \ No newline at end of file diff --git a/client-react/src/containers/views/AuthenticationView/AuthenticationView.ts b/client-react/src/containers/views/AuthenticationView/AuthenticationView.ts new file mode 100644 index 00000000..fe9fd47d --- /dev/null +++ b/client-react/src/containers/views/AuthenticationView/AuthenticationView.ts @@ -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); \ No newline at end of file diff --git a/client-react/src/containers/views/FirstFactorView/FirstFactorView.ts b/client-react/src/containers/views/FirstFactorView/FirstFactorView.ts deleted file mode 100644 index 1ab101d4..00000000 --- a/client-react/src/containers/views/FirstFactorView/FirstFactorView.ts +++ /dev/null @@ -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); \ No newline at end of file diff --git a/client-react/src/containers/views/ForgotPasswordView/ForgotPasswordView.ts b/client-react/src/containers/views/ForgotPasswordView/ForgotPasswordView.ts new file mode 100644 index 00000000..53fc7721 --- /dev/null +++ b/client-react/src/containers/views/ForgotPasswordView/ForgotPasswordView.ts @@ -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); \ No newline at end of file diff --git a/client-react/src/containers/views/OneTimePasswordRegistrationView/OneTimePasswordRegistrationView.ts b/client-react/src/containers/views/OneTimePasswordRegistrationView/OneTimePasswordRegistrationView.ts index dbc356a6..f724b345 100644 --- a/client-react/src/containers/views/OneTimePasswordRegistrationView/OneTimePasswordRegistrationView.ts +++ b/client-react/src/containers/views/OneTimePasswordRegistrationView/OneTimePasswordRegistrationView.ts @@ -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('/')); } } } diff --git a/client-react/src/containers/views/ResetPasswordView/ResetPasswordView.ts b/client-react/src/containers/views/ResetPasswordView/ResetPasswordView.ts new file mode 100644 index 00000000..60840c17 --- /dev/null +++ b/client-react/src/containers/views/ResetPasswordView/ResetPasswordView.ts @@ -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); \ No newline at end of file diff --git a/client-react/src/containers/views/SecondFactorView/SecondFactorView.ts b/client-react/src/containers/views/SecondFactorView/SecondFactorView.ts deleted file mode 100644 index 2364c99d..00000000 --- a/client-react/src/containers/views/SecondFactorView/SecondFactorView.ts +++ /dev/null @@ -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); \ No newline at end of file diff --git a/client-react/src/layouts/PortalLayout/PortalLayout.tsx b/client-react/src/layouts/PortalLayout/PortalLayout.tsx index 02922362..03aa3275 100644 --- a/client-react/src/layouts/PortalLayout/PortalLayout.tsx +++ b/client-react/src/layouts/PortalLayout/PortalLayout.tsx @@ -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 { private renderTitle() { diff --git a/client-react/src/reducers/Portal/Authentication/actions.ts b/client-react/src/reducers/Portal/Authentication/actions.ts new file mode 100644 index 00000000..6402f098 --- /dev/null +++ b/client-react/src/reducers/Portal/Authentication/actions.ts @@ -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); +}) \ No newline at end of file diff --git a/client-react/src/reducers/Portal/Authentication/reducer.ts b/client-react/src/reducers/Portal/Authentication/reducer.ts new file mode 100644 index 00000000..c5ef18c0 --- /dev/null +++ b/client-react/src/reducers/Portal/Authentication/reducer.ts @@ -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; + +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; +} \ No newline at end of file diff --git a/client-react/src/reducers/Portal/FirstFactor/actions.ts b/client-react/src/reducers/Portal/FirstFactor/actions.ts index 005389ba..cbbf2cdd 100644 --- a/client-react/src/reducers/Portal/FirstFactor/actions.ts +++ b/client-react/src/reducers/Portal/FirstFactor/actions.ts @@ -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); diff --git a/client-react/src/reducers/Portal/FirstFactor/reducer.ts b/client-react/src/reducers/Portal/FirstFactor/reducer.ts index 07ac6d60..f7aa685d 100644 --- a/client-react/src/reducers/Portal/FirstFactor/reducer.ts +++ b/client-react/src/reducers/Portal/FirstFactor/reducer.ts @@ -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; @@ -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; - -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; } \ No newline at end of file diff --git a/client-react/src/reducers/Portal/ForgotPassword/actions.ts b/client-react/src/reducers/Portal/ForgotPassword/actions.ts new file mode 100644 index 00000000..9182dfa9 --- /dev/null +++ b/client-react/src/reducers/Portal/ForgotPassword/actions.ts @@ -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); +}); diff --git a/client-react/src/reducers/Portal/ForgotPassword/reducer.ts b/client-react/src/reducers/Portal/ForgotPassword/reducer.ts new file mode 100644 index 00000000..d210eb63 --- /dev/null +++ b/client-react/src/reducers/Portal/ForgotPassword/reducer.ts @@ -0,0 +1,44 @@ + +import * as Actions from './actions'; +import { ActionType, getType } from 'typesafe-actions'; + +export type Action = ActionType; + + +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; +} \ No newline at end of file diff --git a/client-react/src/reducers/Portal/OneTimePasswordRegistration/reducer.ts b/client-react/src/reducers/Portal/OneTimePasswordRegistration/reducer.ts index 8d3d8a71..0ac99041 100644 --- a/client-react/src/reducers/Portal/OneTimePasswordRegistration/reducer.ts +++ b/client-react/src/reducers/Portal/OneTimePasswordRegistration/reducer.ts @@ -4,28 +4,19 @@ import { Secret } from "../../../views/OneTimePasswordRegistrationView/Secret"; type OneTimePasswordRegistrationAction = ActionType -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 { diff --git a/client-react/src/reducers/Portal/ResetPassword/actions.ts b/client-react/src/reducers/Portal/ResetPassword/actions.ts new file mode 100644 index 00000000..9220ec43 --- /dev/null +++ b/client-react/src/reducers/Portal/ResetPassword/actions.ts @@ -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); +}); diff --git a/client-react/src/reducers/Portal/ResetPassword/reducer.ts b/client-react/src/reducers/Portal/ResetPassword/reducer.ts new file mode 100644 index 00000000..8d415ac9 --- /dev/null +++ b/client-react/src/reducers/Portal/ResetPassword/reducer.ts @@ -0,0 +1,44 @@ + +import * as Actions from './actions'; +import { ActionType, getType } from 'typesafe-actions'; + +export type Action = ActionType; + + +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; +} \ No newline at end of file diff --git a/client-react/src/reducers/Portal/SecondFactor/actions.ts b/client-react/src/reducers/Portal/SecondFactor/actions.ts index befe92c5..c934824e 100644 --- a/client-react/src/reducers/Portal/SecondFactor/actions.ts +++ b/client-react/src/reducers/Portal/SecondFactor/actions.ts @@ -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); diff --git a/client-react/src/reducers/Portal/SecondFactor/reducer.ts b/client-react/src/reducers/Portal/SecondFactor/reducer.ts index e3d6f937..86a44612 100644 --- a/client-react/src/reducers/Portal/SecondFactor/reducer.ts +++ b/client-react/src/reducers/Portal/SecondFactor/reducer.ts @@ -4,7 +4,7 @@ import { ActionType, getType, StateType } from 'typesafe-actions'; export type SecondFactorAction = ActionType; -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; +export type PortalState = StateType; -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; } \ No newline at end of file diff --git a/client-react/src/reducers/Portal/SecurityKeyRegistration/reducer.ts b/client-react/src/reducers/Portal/SecurityKeyRegistration/reducer.ts index 41d80bdf..00ff3c53 100644 --- a/client-react/src/reducers/Portal/SecurityKeyRegistration/reducer.ts +++ b/client-react/src/reducers/Portal/SecurityKeyRegistration/reducer.ts @@ -3,17 +3,17 @@ import * as Actions from './actions'; type SecurityKeyRegistrationAction = ActionType -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 { diff --git a/client-react/src/reducers/Portal/index.ts b/client-react/src/reducers/Portal/index.ts index d8537b20..65265caa 100644 --- a/client-react/src/reducers/Portal/index.ts +++ b/client-react/src/reducers/Portal/index.ts @@ -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, -}); \ No newline at end of file +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; \ No newline at end of file diff --git a/client-react/src/reducers/constants.ts b/client-react/src/reducers/constants.ts index 23f8a931..4b9784d9 100644 --- a/client-react/src/reducers/constants.ts +++ b/client-react/src/reducers/constants.ts @@ -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'; \ No newline at end of file +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'; \ No newline at end of file diff --git a/client-react/src/reducers/index.ts b/client-react/src/reducers/index.ts index 41da5ac6..bdaa1bb5 100644 --- a/client-react/src/reducers/index.ts +++ b/client-react/src/reducers/index.ts @@ -1,6 +1,11 @@ import PortalReducer from './Portal'; import { StateType } from 'typesafe-actions'; -export type RootState = StateType; +function getReturnType (f: (...args: any[]) => R): R { + return null!; +} + +const t = getReturnType(PortalReducer) +export type RootState = StateType; export default PortalReducer; \ No newline at end of file diff --git a/client-react/src/routes/routes.ts b/client-react/src/routes/routes.ts index f7d54929..e0375e1a 100644 --- a/client-react/src/routes/routes.ts +++ b/client-react/src/routes/routes.ts @@ -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', diff --git a/client-react/src/services/AutheliaService.ts b/client-react/src/services/AutheliaService.ts new file mode 100644 index 00000000..5c4a962b --- /dev/null +++ b/client-react/src/services/AutheliaService.ts @@ -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}) + }); +} \ No newline at end of file diff --git a/client-react/src/views/AuthenticationView/AuthenticationView.tsx b/client-react/src/views/AuthenticationView/AuthenticationView.tsx new file mode 100644 index 00000000..90f8480c --- /dev/null +++ b/client-react/src/views/AuthenticationView/AuthenticationView.tsx @@ -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 { + 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 ; + } else if (this.props.stage === Stage.ALREADY_AUTHENTICATED) { + return ; + } + return ; + } +} + +export default AuthenticationView; \ No newline at end of file diff --git a/client-react/src/reducers/Portal/RemoteState.ts b/client-react/src/views/AuthenticationView/RemoteState.ts similarity index 100% rename from client-react/src/reducers/Portal/RemoteState.ts rename to client-react/src/views/AuthenticationView/RemoteState.ts diff --git a/client-react/src/views/ForgotPasswordView/ForgotPasswordView.tsx b/client-react/src/views/ForgotPasswordView/ForgotPasswordView.tsx index b7c00c30..77df0862 100644 --- a/client-react/src/views/ForgotPasswordView/ForgotPasswordView.tsx +++ b/client-react/src/views/ForgotPasswordView/ForgotPasswordView.tsx @@ -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 { + constructor(props: Props) { + super(props); + this.state = { + username: '', + } + } + + private onUsernameChanged = (e: ChangeEvent) => { + 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 { render() { const { classes } = this.props; return (
-
What's you e-mail address?
+
What's your username?
+ id="username" + label="Username" + onChange={this.onUsernameChanged} + onKeyPress={this.onKeyPressed} + value={this.state.username} + disabled={this.props.disabled}> - +
+
+ +
+
+ +
+
); diff --git a/client-react/src/views/ResetPasswordView/ResetPasswordView.tsx b/client-react/src/views/ResetPasswordView/ResetPasswordView.tsx index b1c08d36..148f83ae 100644 --- a/client-react/src/views/ResetPasswordView/ResetPasswordView.tsx +++ b/client-react/src/views/ResetPasswordView/ResetPasswordView.tsx @@ -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 { + 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) => { + this.setState({password1: e.target.value}); + } + + private onPassword2Changed = (e: ChangeEvent) => { + this.setState({password2: e.target.value}); + } -class ResetPasswordView extends Component { render() { const { classes } = this.props; return (
+ + {this.state.error} +
Enter your new password
{ variant="outlined" type="password" id="password1" + value={this.state.password1} + onChange={this.onPassword1Changed} + disabled={this.props.disabled} label="New password"> { variant="outlined" type="password" id="password2" + value={this.state.password2} + onKeyPress={this.onKeyPressed} + onChange={this.onPassword2Changed} + disabled={this.props.disabled} label="Confirm password"> - +
+
+ +
+
+ +
+
) diff --git a/server/src/lib/routes/firstfactor/post.ts b/server/src/lib/routes/firstfactor/post.ts index e1332071..1e5b9e6a 100644 --- a/server/src/lib/routes/firstfactor/post.ts +++ b/server/src/lib/routes/firstfactor/post.ts @@ -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)); }; } \ No newline at end of file diff --git a/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts b/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts index a5116a8b..55dc233f 100644 --- a/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts +++ b/server/src/lib/routes/password-reset/identity/PasswordResetHandler.ts @@ -28,7 +28,7 @@ export default class PasswordResetHandler implements IdentityValidable { preValidationInit(req: express.Request): BluebirdPromise { const that = this; const userid: string = - objectPath.get(req, "query.userid"); + objectPath.get(req, "body.username"); return BluebirdPromise.resolve() .then(function () { that.logger.debug(req, "User '%s' requested a password reset", userid); diff --git a/server/src/lib/routes/password-reset/request/get.ts b/server/src/lib/routes/password-reset/request/get.ts index 8f3ae2b4..d77a000f 100644 --- a/server/src/lib/routes/password-reset/request/get.ts +++ b/server/src/lib/routes/password-reset/request/get.ts @@ -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"; diff --git a/server/src/lib/routes/secondfactor/totp/sign/post.ts b/server/src/lib/routes/secondfactor/totp/sign/post.ts index 2da0cfad..0f379740 100644 --- a/server/src/lib/routes/secondfactor/totp/sign/post.ts +++ b/server/src/lib/routes/secondfactor/totp/sign/post.ts @@ -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; } diff --git a/server/src/lib/routes/secondfactor/u2f/sign/post.ts b/server/src/lib/routes/secondfactor/u2f/sign/post.ts index 7ee711c2..2f333c85 100644 --- a/server/src/lib/routes/secondfactor/u2f/sign/post.ts +++ b/server/src/lib/routes/secondfactor/u2f/sign/post.ts @@ -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; diff --git a/shared/api.ts b/shared/api.ts index 65be01e4..2844cdcb 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -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";