From d9e487c99f360b9e535c38846be7688de8b7dfdf Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 23 Mar 2019 15:44:46 +0100 Subject: [PATCH] Display only one 2FA option. Displaying only one option at 2FA stage will allow to add more options like DUO push or OAuth. The user can switch to other option and in this case the option is remembered so that next time, the user will see the same option. The latest option is considered as the prefered option by Authelia. --- .../SecondFactorForm.module.scss | 37 +-- .../SecondFactorTOTP.module.scss | 20 ++ .../SecondFactorU2F.module.scss | 21 ++ .../src/behaviors/FetchPrefered2faMethod.ts | 13 + client/src/behaviors/FetchStateBehavior.ts | 2 +- client/src/behaviors/LogoutBehavior.ts | 2 +- client/src/behaviors/SetPrefered2faMethod.ts | 14 + .../SecondFactorForm/SecondFactorForm.tsx | 161 +++-------- .../SecondFactorTOTP/SecondFactorTOTP.tsx | 89 ++++++ .../SecondFactorU2F/SecondFactorU2F.tsx | 52 ++++ .../FirstFactorForm/FirstFactorForm.ts | 2 +- .../SecondFactorForm/SecondFactorForm.ts | 157 ++-------- .../SecondFactorTOTP/SecondFactorTOTP.ts | 79 ++++++ .../SecondFactorU2F/SecondFactorU2F.ts | 107 +++++++ .../AuthenticationView/AuthenticationView.ts | 2 +- .../ForgotPasswordView/ForgotPasswordView.ts | 2 +- .../ResetPasswordView/ResetPasswordView.ts | 2 +- .../reducers/Portal/SecondFactor/actions.ts | 28 +- .../reducers/Portal/SecondFactor/reducer.ts | 64 +++++ client/src/reducers/constants.ts | 9 + client/src/services/AutheliaService.ts | 267 ++++++++++-------- client/src/types/Method2FA.ts | 4 + .../views/AuthenticationView/RemoteState.ts | 1 + .../nginx/backend/html/secure/index.html | 13 + .../secondfactor/preferences/Get.spec.ts | 36 +++ .../routes/secondfactor/preferences/Get.ts | 18 ++ .../secondfactor/preferences/Post.spec.ts | 55 ++++ .../routes/secondfactor/preferences/Post.ts | 23 ++ .../lib/storage/CollectionFactoryStub.spec.ts | 1 - server/src/lib/storage/IUserDataStore.d.ts | 4 + server/src/lib/storage/UserDataStore.spec.ts | 27 +- server/src/lib/storage/UserDataStore.ts | 68 ++--- .../src/lib/storage/UserDataStoreStub.spec.ts | 17 +- server/src/lib/web_server/RestApi.ts | 12 +- shared/Method2FA.ts | 3 + shared/RedirectionMessage.ts | 4 - shared/api.ts | 22 +- test/helpers/LoginAs.ts | 2 +- .../assertions/VerifyIsOneTimePasswordView.ts | 6 + .../assertions/VerifyIsSecurityKeyView.ts | 6 + .../VerifyIsUseAnotherMethodView.ts | 6 + test/helpers/behaviors/ClickOnButton.ts | 8 + test/helpers/behaviors/LoginOneFactor.ts | 6 +- test/helpers/context/AutheliaServer.ts | 4 +- .../context/AutheliaServerWithHotReload.ts | 4 +- .../suites/acl-full-bypass/users_database.yml | 2 +- test/suites/basic/config.yml | 3 + test/suites/basic/environment.ts | 2 +- .../basic/scenarii/Prefered2faMethod.ts | 51 ++++ test/suites/basic/test.ts | 2 + test/suites/basic/users_database.yml | 2 +- test/suites/short-timeouts/users_database.yml | 2 +- 52 files changed, 1077 insertions(+), 467 deletions(-) create mode 100644 client/src/assets/scss/components/SecondFactorTOTP/SecondFactorTOTP.module.scss create mode 100644 client/src/assets/scss/components/SecondFactorU2F/SecondFactorU2F.module.scss create mode 100644 client/src/behaviors/FetchPrefered2faMethod.ts create mode 100644 client/src/behaviors/SetPrefered2faMethod.ts create mode 100644 client/src/components/SecondFactorTOTP/SecondFactorTOTP.tsx create mode 100644 client/src/components/SecondFactorU2F/SecondFactorU2F.tsx create mode 100644 client/src/containers/components/SecondFactorTOTP/SecondFactorTOTP.ts create mode 100644 client/src/containers/components/SecondFactorU2F/SecondFactorU2F.ts create mode 100644 client/src/types/Method2FA.ts create mode 100644 example/compose/nginx/backend/html/secure/index.html create mode 100644 server/src/lib/routes/secondfactor/preferences/Get.spec.ts create mode 100644 server/src/lib/routes/secondfactor/preferences/Get.ts create mode 100644 server/src/lib/routes/secondfactor/preferences/Post.spec.ts create mode 100644 server/src/lib/routes/secondfactor/preferences/Post.ts create mode 100644 shared/Method2FA.ts delete mode 100644 shared/RedirectionMessage.ts create mode 100644 test/helpers/assertions/VerifyIsOneTimePasswordView.ts create mode 100644 test/helpers/assertions/VerifyIsSecurityKeyView.ts create mode 100644 test/helpers/assertions/VerifyIsUseAnotherMethodView.ts create mode 100644 test/helpers/behaviors/ClickOnButton.ts create mode 100644 test/suites/basic/scenarii/Prefered2faMethod.ts diff --git a/client/src/assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss b/client/src/assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss index 631cecddd..cdfad95ea 100644 --- a/client/src/assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss +++ b/client/src/assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss @@ -26,6 +26,7 @@ padding-bottom: ($theme-spacing) * 2; padding-left: ($theme-spacing) * 2; padding-right: ($theme-spacing) * 2; + margin: (($theme-spacing) * 2) 0px; border: 1px solid #e0e0e0; border-radius: 2px; } @@ -36,40 +37,16 @@ margin-bottom: ($theme-spacing); } -.methodU2f { - border-bottom: 1px solid #e0e0e0; - padding: ($theme-spacing); -} - -.methodTotp { - padding: ($theme-spacing); - padding-top: ($theme-spacing) * 2; -} - -.image { - width: '120px'; -} - -.imageContainer { +.anotherMethodLink { text-align: center; - margin-top: ($theme-spacing) * 2; - margin-bottom: ($theme-spacing) * 2; + font-size: (0.8em) } -.registerDeviceContainer { - text-align: right; - font-size: 0.7em; -} - -.totpField { - margin-top: ($theme-spacing) * 2; - width: 100%; -} - -.totpButton { - margin-top: ($theme-spacing); +.buttonsContainer { + text-align: center; + margin: ($theme-spacing) 0; button { - width: 100%; + margin: ($theme-spacing) 0; } } \ No newline at end of file diff --git a/client/src/assets/scss/components/SecondFactorTOTP/SecondFactorTOTP.module.scss b/client/src/assets/scss/components/SecondFactorTOTP/SecondFactorTOTP.module.scss new file mode 100644 index 000000000..29cdb5cdc --- /dev/null +++ b/client/src/assets/scss/components/SecondFactorTOTP/SecondFactorTOTP.module.scss @@ -0,0 +1,20 @@ +@import '../../variables.scss'; + + +.totpField { + margin-top: ($theme-spacing) * 2; + width: 100%; +} + +.totpButton { + margin-top: ($theme-spacing); + + button { + width: 100%; + } +} + +.registerDeviceContainer { + text-align: right; + font-size: 0.7em; +} \ No newline at end of file diff --git a/client/src/assets/scss/components/SecondFactorU2F/SecondFactorU2F.module.scss b/client/src/assets/scss/components/SecondFactorU2F/SecondFactorU2F.module.scss new file mode 100644 index 000000000..1d8127b75 --- /dev/null +++ b/client/src/assets/scss/components/SecondFactorU2F/SecondFactorU2F.module.scss @@ -0,0 +1,21 @@ +@import '../../variables.scss'; + + +.methodU2f { + padding: ($theme-spacing); +} + +.image { + width: '120px'; +} + +.imageContainer { + text-align: center; + margin-top: ($theme-spacing) * 2; + margin-bottom: ($theme-spacing) * 2; +} + +.registerDeviceContainer { + text-align: right; + font-size: 0.7em; +} \ No newline at end of file diff --git a/client/src/behaviors/FetchPrefered2faMethod.ts b/client/src/behaviors/FetchPrefered2faMethod.ts new file mode 100644 index 000000000..ada449f3e --- /dev/null +++ b/client/src/behaviors/FetchPrefered2faMethod.ts @@ -0,0 +1,13 @@ +import { Dispatch } from "redux"; +import { getPreferedMethod, getPreferedMethodSuccess, getPreferedMethodFailure } from "../reducers/Portal/SecondFactor/actions"; +import AutheliaService from "../services/AutheliaService"; + +export default async function(dispatch: Dispatch) { + dispatch(getPreferedMethod()); + try { + const method = await AutheliaService.fetchPrefered2faMethod(); + dispatch(getPreferedMethodSuccess(method)); + } catch (err) { + dispatch(getPreferedMethodFailure(err.message)) + } +} \ No newline at end of file diff --git a/client/src/behaviors/FetchStateBehavior.ts b/client/src/behaviors/FetchStateBehavior.ts index 37db1e2c7..2da2e9eab 100644 --- a/client/src/behaviors/FetchStateBehavior.ts +++ b/client/src/behaviors/FetchStateBehavior.ts @@ -1,7 +1,7 @@ import { Dispatch } from "redux"; -import * as AutheliaService from '../services/AutheliaService'; import { fetchStateFailure, fetchStateSuccess } from "../reducers/Portal/Authentication/actions"; import to from "await-to-js"; +import AutheliaService from "../services/AutheliaService"; export default async function(dispatch: Dispatch) { let err, res; diff --git a/client/src/behaviors/LogoutBehavior.ts b/client/src/behaviors/LogoutBehavior.ts index a6b7b50b3..082bba6b5 100644 --- a/client/src/behaviors/LogoutBehavior.ts +++ b/client/src/behaviors/LogoutBehavior.ts @@ -1,8 +1,8 @@ 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"; +import AutheliaService from "../services/AutheliaService"; export default async function(dispatch: Dispatch) { await dispatch(logout()); diff --git a/client/src/behaviors/SetPrefered2faMethod.ts b/client/src/behaviors/SetPrefered2faMethod.ts new file mode 100644 index 000000000..82d974300 --- /dev/null +++ b/client/src/behaviors/SetPrefered2faMethod.ts @@ -0,0 +1,14 @@ +import { Dispatch } from "redux"; +import { setPreferedMethod, setPreferedMethodSuccess, setPreferedMethodFailure } from "../reducers/Portal/SecondFactor/actions"; +import AutheliaService from "../services/AutheliaService"; +import Method2FA from "../types/Method2FA"; + +export default async function(dispatch: Dispatch, method: Method2FA) { + dispatch(setPreferedMethod()); + try { + await AutheliaService.setPrefered2faMethod(method); + dispatch(setPreferedMethodSuccess()); + } catch (err) { + dispatch(setPreferedMethodFailure(err.message)) + } +} \ No newline at end of file diff --git a/client/src/components/SecondFactorForm/SecondFactorForm.tsx b/client/src/components/SecondFactorForm/SecondFactorForm.tsx index a2ac3b3ea..fef245ce6 100644 --- a/client/src/components/SecondFactorForm/SecondFactorForm.tsx +++ b/client/src/components/SecondFactorForm/SecondFactorForm.tsx @@ -1,12 +1,10 @@ -import React, { Component, KeyboardEvent, FormEvent } from 'react'; -import classnames from 'classnames'; - -import TextField, { Input } from '@material/react-text-field'; -import Button from '@material/react-button'; - +import React, { Component } from 'react'; import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss'; -import CircleLoader, { Status } from '../../components/CircleLoader/CircleLoader'; -import Notification from '../Notification/Notification'; +import Method2FA from '../../types/Method2FA'; +import SecondFactorTOTP from '../../containers/components/SecondFactorTOTP/SecondFactorTOTP'; +import SecondFactorU2F from '../../containers/components/SecondFactorU2F/SecondFactorU2F'; +import { Button } from '@material/react-button'; +import classnames from 'classnames'; export interface OwnProps { username: string; @@ -14,130 +12,62 @@ export interface OwnProps { } export interface StateProps { - securityKeySupported: boolean; - securityKeyVerified: boolean; - securityKeyError: string | null; - - oneTimePasswordVerificationInProgress: boolean, - oneTimePasswordVerificationError: string | null; + method: Method2FA | null; + useAnotherMethod: boolean; } export interface DispatchProps { onInit: () => void; onLogoutClicked: () => void; - onRegisterSecurityKeyClicked: () => void; - onRegisterOneTimePasswordClicked: () => void; - - onOneTimePasswordValidationRequested: (token: string) => void; + onOneTimePasswordMethodClicked: () => void; + onSecurityKeyMethodClicked: () => void; + onUseAnotherMethodClicked: () => void; } export type Props = OwnProps & StateProps & DispatchProps; -interface State { - oneTimePassword: string; -} - -class SecondFactorView extends Component { - constructor(props: Props) { - super(props); - this.state = { - oneTimePassword: '', - } - } - - componentWillMount() { +class SecondFactorForm extends Component { + componentDidMount() { this.props.onInit(); } - private renderU2f(n: number) { - let u2fStatus = Status.LOADING; - if (this.props.securityKeyVerified) { - u2fStatus = Status.SUCCESSFUL; - } else if (this.props.securityKeyError) { - u2fStatus = Status.FAILURE; + private renderMethod() { + let method: Method2FA = (this.props.method) ? this.props.method : 'totp' + let methodComponent, title: string; + if (method == 'u2f') { + title = "Security Key"; + methodComponent = (); + } else { + title = "One-Time Password" + methodComponent = (); } + return ( -
-
Option {n} - Security Key
-
Insert your security key into a USB port and touch the gold disk.
-
- -
-
- - Register device - +
+
{title}
+ {methodComponent} +
+ ); + } + + private renderUseAnotherMethod() { + return ( +
+
Choose a method
+
+ +
- ) + ); } - private onOneTimePasswordChanged = (e: FormEvent) => { - this.setState({oneTimePassword: (e.target as HTMLInputElement).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) { + private renderUseAnotherMethodLink() { return ( -
-
Option {n} - One-Time Password
- - {this.props.oneTimePasswordVerificationError} - - - - - -
- -
-
- ) - } - - private renderMode() { - const methods = []; - let n = 1; - if (this.props.securityKeySupported) { - methods.push(this.renderU2f(n)); - n++; - } - methods.push(this.renderTotp(n)); - - return ( -
- {methods} + ); } @@ -152,11 +82,12 @@ class SecondFactorView extends Component {
- {this.renderMode()} + {(this.props.useAnotherMethod) ? this.renderUseAnotherMethod() : this.renderMethod()}
+ {(this.props.useAnotherMethod) ? null : this.renderUseAnotherMethodLink()}
) } } -export default SecondFactorView; \ No newline at end of file +export default SecondFactorForm; \ No newline at end of file diff --git a/client/src/components/SecondFactorTOTP/SecondFactorTOTP.tsx b/client/src/components/SecondFactorTOTP/SecondFactorTOTP.tsx new file mode 100644 index 000000000..a64d3473f --- /dev/null +++ b/client/src/components/SecondFactorTOTP/SecondFactorTOTP.tsx @@ -0,0 +1,89 @@ +import React, { FormEvent, KeyboardEvent } from 'react'; +import classnames from 'classnames'; + +import TextField, { Input } from '@material/react-text-field'; +import Button from '@material/react-button'; +import Notification from '../Notification/Notification'; + +import styles from '../../assets/scss/components/SecondFactorTOTP/SecondFactorTOTP.module.scss'; + +export interface OwnProps { + redirectionUrl: string | null; +} + +export interface StateProps { + oneTimePasswordVerificationInProgress: boolean, + oneTimePasswordVerificationError: string | null; +} + +export interface DispatchProps { + onRegisterOneTimePasswordClicked: () => void; + onOneTimePasswordValidationRequested: (token: string) => void; +} + +type Props = OwnProps & StateProps & DispatchProps; + +interface State { + oneTimePassword: string; +} + +export default class SecondFactorTOTP extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + oneTimePassword: '', + } + } + + private onOneTimePasswordChanged = (e: FormEvent) => { + this.setState({oneTimePassword: (e.target as HTMLInputElement).value}); + } + + private onTotpKeyPressed = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + this.onOneTimePasswordValidationRequested(); + } + } + + private onOneTimePasswordValidationRequested = () => { + if (this.props.oneTimePasswordVerificationInProgress) return; + this.props.onOneTimePasswordValidationRequested(this.state.oneTimePassword); + } + + render() { + return ( +
+ + {this.props.oneTimePasswordVerificationError} + + + + + +
+ +
+
+ ) + } +} \ No newline at end of file diff --git a/client/src/components/SecondFactorU2F/SecondFactorU2F.tsx b/client/src/components/SecondFactorU2F/SecondFactorU2F.tsx new file mode 100644 index 000000000..d58899d0f --- /dev/null +++ b/client/src/components/SecondFactorU2F/SecondFactorU2F.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import classnames from 'classnames'; +import CircleLoader, { Status } from '../../components/CircleLoader/CircleLoader'; +import styles from '../../assets/scss/components/SecondFactorU2F/SecondFactorU2F.module.scss'; + +export interface OwnProps { + redirectionUrl: string | null; +} + +export interface StateProps { + securityKeyVerified: boolean; + securityKeyError: string | null; +} + +export interface DispatchProps { + onInit: () => void; + onRegisterSecurityKeyClicked: () => void; +} + +export type Props = StateProps & DispatchProps; + +interface State {} + +export default class SecondFactorU2F extends React.Component { + componentWillMount() { + this.props.onInit(); + } + + render() { + let u2fStatus = Status.LOADING; + if (this.props.securityKeyVerified) { + u2fStatus = Status.SUCCESSFUL; + } else if (this.props.securityKeyError) { + u2fStatus = Status.FAILURE; + } + return ( +
+
Insert your security key into a USB port and touch the gold disk.
+
+ +
+ +
+ ) + } +} \ No newline at end of file diff --git a/client/src/containers/components/FirstFactorForm/FirstFactorForm.ts b/client/src/containers/components/FirstFactorForm/FirstFactorForm.ts index 97a3eafda..e63b934e9 100644 --- a/client/src/containers/components/FirstFactorForm/FirstFactorForm.ts +++ b/client/src/containers/components/FirstFactorForm/FirstFactorForm.ts @@ -3,9 +3,9 @@ import { Dispatch } from 'redux'; import { authenticateFailure, authenticateSuccess, authenticate } from '../../../reducers/Portal/FirstFactor/actions'; import FirstFactorForm, { StateProps, OwnProps } 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'; +import AutheliaService from '../../../services/AutheliaService'; const mapStateToProps = (state: RootState): StateProps => { return { diff --git a/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts b/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts index 0040a513f..66b1e5ea0 100644 --- a/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts +++ b/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts @@ -1,138 +1,37 @@ 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 SecondFactorForm from '../../../components/SecondFactorForm/SecondFactorForm'; import LogoutBehavior from '../../../behaviors/LogoutBehavior'; +import { RootState } from '../../../reducers'; +import { StateProps, DispatchProps } from '../../../components/SecondFactorForm/SecondFactorForm'; +import FetchPrefered2faMethod from '../../../behaviors/FetchPrefered2faMethod'; +import SetPrefered2faMethod from '../../../behaviors/SetPrefered2faMethod'; +import { getPreferedMethodSuccess, setUseAnotherMethod } from '../../../reducers/Portal/SecondFactor/actions'; +import Method2FA from '../../../types/Method2FA'; -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, redirectionUrl: string | null) { - 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, redirectionUrl)); - if (err) { - await dispatch(securityKeySignFailure(err.message)); - throw err; - } - - try { - await redirectIfPossible(dispatch, result as Response); - dispatch(securityKeySignSuccess()); - await handleSuccess(dispatch, 1000); - } catch (err) { - dispatch(securityKeySignFailure(err.message)); - } -} - -async function redirectIfPossible(dispatch: Dispatch, res: Response) { - if (res.status === 204) return; - - const body = await res.json(); - if ('error' in body) { - throw new Error(body['error']); - } - - if ('redirect' in body) { - window.location.href = body['redirect']; - return; - } - return; -} - -async function handleSuccess(dispatch: Dispatch, duration?: number) { - async function handle() { - await fetchState(dispatch); - } - - if (duration) { - setTimeout(handle, duration); - } else { - await handle(); - } -} - -const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => { +const mapStateToProps = (state: RootState): StateProps => { 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, ownProps.redirectionUrl); - } - }, - onOneTimePasswordValidationRequested: async (token: string) => { - let err, res; - dispatch(oneTimePasswordVerification()); - [err, res] = await to(AutheliaService.verifyTotpToken(token, ownProps.redirectionUrl)); - if (err) { - dispatch(oneTimePasswordVerificationFailure(err.message)); - throw err; - } - if (!res) { - dispatch(oneTimePasswordVerificationFailure('No response')); - throw 'No response'; - } + method: state.secondFactor.preferedMethod, + useAnotherMethod: state.secondFactor.userAnotherMethod, + } +} - try { - await redirectIfPossible(dispatch, res); - dispatch(oneTimePasswordVerificationSuccess()); - await handleSuccess(dispatch); - } catch (err) { - dispatch(oneTimePasswordVerificationFailure(err.message)); - } - }, +async function storeMethod(dispatch: Dispatch, method: Method2FA) { + // display the new option + dispatch(getPreferedMethodSuccess(method)); + dispatch(setUseAnotherMethod(false)); + + // And save the method for next time. + await SetPrefered2faMethod(dispatch, method); +} + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { + return { + onInit: () => FetchPrefered2faMethod(dispatch), + onLogoutClicked: () => LogoutBehavior(dispatch), + onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'), + onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'), + onUseAnotherMethodClicked: () => dispatch(setUseAnotherMethod(true)), } } diff --git a/client/src/containers/components/SecondFactorTOTP/SecondFactorTOTP.ts b/client/src/containers/components/SecondFactorTOTP/SecondFactorTOTP.ts new file mode 100644 index 000000000..1f4a04041 --- /dev/null +++ b/client/src/containers/components/SecondFactorTOTP/SecondFactorTOTP.ts @@ -0,0 +1,79 @@ +import { connect } from 'react-redux'; +import SecondFactorTOTP, { StateProps, OwnProps } from "../../../components/SecondFactorTOTP/SecondFactorTOTP"; +import { RootState } from '../../../reducers'; +import { Dispatch } from 'redux'; +import { + oneTimePasswordVerification, + oneTimePasswordVerificationFailure, + oneTimePasswordVerificationSuccess +} from '../../../reducers/Portal/SecondFactor/actions'; +import to from 'await-to-js'; +import AutheliaService from '../../../services/AutheliaService'; +import { push } from 'connected-react-router'; +import FetchStateBehavior from '../../../behaviors/FetchStateBehavior'; + + +const mapStateToProps = (state: RootState): StateProps => ({ + oneTimePasswordVerificationInProgress: state.secondFactor.oneTimePasswordVerificationLoading, + oneTimePasswordVerificationError: state.secondFactor.oneTimePasswordVerificationError, +}); + +async function redirectIfPossible(dispatch: Dispatch, res: Response) { + if (res.status === 204) return; + + const body = await res.json(); + if ('error' in body) { + throw new Error(body['error']); + } + + if ('redirect' in body) { + window.location.href = body['redirect']; + return; + } + return; +} + +async function handleSuccess(dispatch: Dispatch, duration?: number) { + async function handle() { + await FetchStateBehavior(dispatch); + } + + if (duration) { + setTimeout(handle, duration); + } else { + await handle(); + } +} + +const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => { + return { + onOneTimePasswordValidationRequested: async (token: string) => { + let err, res; + dispatch(oneTimePasswordVerification()); + [err, res] = await to(AutheliaService.verifyTotpToken(token, ownProps.redirectionUrl)); + if (err) { + dispatch(oneTimePasswordVerificationFailure(err.message)); + throw err; + } + if (!res) { + dispatch(oneTimePasswordVerificationFailure('No response')); + throw 'No response'; + } + + try { + await redirectIfPossible(dispatch, res); + dispatch(oneTimePasswordVerificationSuccess()); + await handleSuccess(dispatch); + } catch (err) { + dispatch(oneTimePasswordVerificationFailure(err.message)); + } + }, + onRegisterOneTimePasswordClicked: async () => { + await AutheliaService.startTOTPRegistrationIdentityProcess(); + await dispatch(push('/confirmation-sent')); + }, + } +} + + +export default connect(mapStateToProps, mapDispatchToProps)(SecondFactorTOTP); \ No newline at end of file diff --git a/client/src/containers/components/SecondFactorU2F/SecondFactorU2F.ts b/client/src/containers/components/SecondFactorU2F/SecondFactorU2F.ts new file mode 100644 index 000000000..bbf645f9f --- /dev/null +++ b/client/src/containers/components/SecondFactorU2F/SecondFactorU2F.ts @@ -0,0 +1,107 @@ +import { connect } from 'react-redux'; +import { RootState } from '../../../reducers'; +import { Dispatch } from 'redux'; +import SecondFactorU2F, { StateProps, OwnProps } from '../../../components/SecondFactorU2F/SecondFactorU2F'; +import AutheliaService from '../../../services/AutheliaService'; +import { push } from 'connected-react-router'; +import u2fApi from 'u2f-api'; +import to from 'await-to-js'; +import { + securityKeySignSuccess, + securityKeySign, + securityKeySignFailure, + setSecurityKeySupported +} from '../../../reducers/Portal/SecondFactor/actions'; +import FetchStateBehavior from '../../../behaviors/FetchStateBehavior'; + + +const mapStateToProps = (state: RootState): StateProps => ({ + securityKeyVerified: state.secondFactor.securityKeySignSuccess || false, + securityKeyError: state.secondFactor.error, +}); + +async function triggerSecurityKeySigning(dispatch: Dispatch, redirectionUrl: string | null) { + 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, redirectionUrl)); + if (err) { + await dispatch(securityKeySignFailure(err.message)); + throw err; + } + + try { + await redirectIfPossible(dispatch, result as Response); + dispatch(securityKeySignSuccess()); + await handleSuccess(dispatch, 1000); + } catch (err) { + dispatch(securityKeySignFailure(err.message)); + } +} + +async function redirectIfPossible(dispatch: Dispatch, res: Response) { + if (res.status === 204) return; + + const body = await res.json(); + if ('error' in body) { + throw new Error(body['error']); + } + + if ('redirect' in body) { + window.location.href = body['redirect']; + return; + } + return; +} + +async function handleSuccess(dispatch: Dispatch, duration?: number) { + async function handle() { + await FetchStateBehavior(dispatch); + } + + if (duration) { + setTimeout(handle, duration); + } else { + await handle(); + } +} + +const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => { + return { + onRegisterSecurityKeyClicked: async () => { + await AutheliaService.startU2FRegistrationIdentityProcess(); + await dispatch(push('/confirmation-sent')); + }, + onInit: async () => { + const isU2FSupported = await u2fApi.isSupported(); + if (isU2FSupported) { + await dispatch(setSecurityKeySupported(true)); + await triggerSecurityKeySigning(dispatch, ownProps.redirectionUrl); + } + }, + } +} + + +export default connect(mapStateToProps, mapDispatchToProps)(SecondFactorU2F); \ No newline at end of file diff --git a/client/src/containers/views/AuthenticationView/AuthenticationView.ts b/client/src/containers/views/AuthenticationView/AuthenticationView.ts index 472037194..46921865c 100644 --- a/client/src/containers/views/AuthenticationView/AuthenticationView.ts +++ b/client/src/containers/views/AuthenticationView/AuthenticationView.ts @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import QueryString from 'query-string'; -import AuthenticationView, {StateProps, Stage, DispatchProps, OwnProps} from '../../../views/AuthenticationView/AuthenticationView'; +import AuthenticationView, {StateProps, Stage, OwnProps} from '../../../views/AuthenticationView/AuthenticationView'; import { RootState } from '../../../reducers'; import { Dispatch } from 'redux'; import AuthenticationLevel from '../../../types/AuthenticationLevel'; diff --git a/client/src/containers/views/ForgotPasswordView/ForgotPasswordView.ts b/client/src/containers/views/ForgotPasswordView/ForgotPasswordView.ts index 53fc7721a..b27cb7354 100644 --- a/client/src/containers/views/ForgotPasswordView/ForgotPasswordView.ts +++ b/client/src/containers/views/ForgotPasswordView/ForgotPasswordView.ts @@ -2,9 +2,9 @@ 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'; +import AutheliaService from '../../../services/AutheliaService'; const mapStateToProps = (state: RootState) => ({ disabled: state.forgotPassword.loading, diff --git a/client/src/containers/views/ResetPasswordView/ResetPasswordView.ts b/client/src/containers/views/ResetPasswordView/ResetPasswordView.ts index 60840c171..4053ebbbc 100644 --- a/client/src/containers/views/ResetPasswordView/ResetPasswordView.ts +++ b/client/src/containers/views/ResetPasswordView/ResetPasswordView.ts @@ -2,8 +2,8 @@ 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'; +import AutheliaService from '../../../services/AutheliaService'; const mapStateToProps = (state: RootState): StateProps => ({ disabled: state.resetPassword.loading, diff --git a/client/src/reducers/Portal/SecondFactor/actions.ts b/client/src/reducers/Portal/SecondFactor/actions.ts index c934824ef..eaeae9cf7 100644 --- a/client/src/reducers/Portal/SecondFactor/actions.ts +++ b/client/src/reducers/Portal/SecondFactor/actions.ts @@ -9,11 +9,37 @@ import { SET_SECURITY_KEY_SUPPORTED, ONE_TIME_PASSWORD_VERIFICATION_REQUEST, ONE_TIME_PASSWORD_VERIFICATION_SUCCESS, - ONE_TIME_PASSWORD_VERIFICATION_FAILURE + ONE_TIME_PASSWORD_VERIFICATION_FAILURE, + GET_PREFERED_METHOD, + GET_PREFERED_METHOD_SUCCESS, + GET_PREFERED_METHOD_FAILURE, + SET_PREFERED_METHOD, + SET_PREFERED_METHOD_FAILURE, + SET_PREFERED_METHOD_SUCCESS, + SET_USE_ANOTHER_METHOD } from "../../constants"; +import Method2FA from "../../../types/Method2FA"; export const setSecurityKeySupported = createAction(SET_SECURITY_KEY_SUPPORTED, resolve => { return (supported: boolean) => resolve(supported); +}); + +export const setUseAnotherMethod = createAction(SET_USE_ANOTHER_METHOD, resolve => { + return (useAnotherMethod: boolean) => resolve(useAnotherMethod); +}); + +export const getPreferedMethod = createAction(GET_PREFERED_METHOD); +export const getPreferedMethodSuccess = createAction(GET_PREFERED_METHOD_SUCCESS, resolve => { + return (method: Method2FA) => resolve(method); +}); +export const getPreferedMethodFailure = createAction(GET_PREFERED_METHOD_FAILURE, resolve => { + return (err: string) => resolve(err); +}); + +export const setPreferedMethod = createAction(SET_PREFERED_METHOD); +export const setPreferedMethodSuccess = createAction(SET_PREFERED_METHOD_SUCCESS); +export const setPreferedMethodFailure = createAction(SET_PREFERED_METHOD_FAILURE, resolve => { + return (err: string) => resolve(err); }) export const securityKeySign = createAction(SECURITY_KEY_SIGN); diff --git a/client/src/reducers/Portal/SecondFactor/reducer.ts b/client/src/reducers/Portal/SecondFactor/reducer.ts index 86a446122..e12608e4f 100644 --- a/client/src/reducers/Portal/SecondFactor/reducer.ts +++ b/client/src/reducers/Portal/SecondFactor/reducer.ts @@ -1,6 +1,7 @@ import * as Actions from './actions'; import { ActionType, getType, StateType } from 'typesafe-actions'; +import Method2FA from '../../../types/Method2FA'; export type SecondFactorAction = ActionType; @@ -9,6 +10,16 @@ interface SecondFactorState { logoutSuccess: boolean | null; error: string | null; + userAnotherMethod: boolean; + + preferedMethodLoading: boolean; + preferedMethodError: string | null; + preferedMethod: Method2FA | null; + + setPreferedMethodLoading: boolean; + setPreferedMethodError: string | null; + setPreferedMethodSuccess: boolean | null; + securityKeySupported: boolean; securityKeySignLoading: boolean; securityKeySignSuccess: boolean | null; @@ -23,6 +34,16 @@ const secondFactorInitialState: SecondFactorState = { logoutSuccess: null, error: null, + userAnotherMethod: false, + + preferedMethod: null, + preferedMethodError: null, + preferedMethodLoading: false, + + setPreferedMethodLoading: false, + setPreferedMethodError: null, + setPreferedMethodSuccess: null, + securityKeySupported: false, securityKeySignLoading: false, securityKeySignSuccess: null, @@ -99,6 +120,49 @@ export default (state = secondFactorInitialState, action: SecondFactorAction): S oneTimePasswordVerificationLoading: false, oneTimePasswordVerificationError: action.payload, } + case getType(Actions.getPreferedMethod): + return { + ...state, + preferedMethodLoading: true, + preferedMethod: null, + preferedMethodError: null, + } + case getType(Actions.getPreferedMethodSuccess): + return { + ...state, + preferedMethodLoading: false, + preferedMethod: action.payload, + } + case getType(Actions.getPreferedMethodFailure): + return { + ...state, + preferedMethodLoading: false, + preferedMethodError: action.payload, + } + case getType(Actions.setPreferedMethod): + return { + ...state, + setPreferedMethodLoading: true, + setPreferedMethodSuccess: null, + preferedMethodError: null, + } + case getType(Actions.getPreferedMethodSuccess): + return { + ...state, + setPreferedMethodLoading: false, + setPreferedMethodSuccess: true, + } + case getType(Actions.getPreferedMethodFailure): + return { + ...state, + setPreferedMethodLoading: false, + setPreferedMethodError: action.payload, + } + case getType(Actions.setUseAnotherMethod): + return { + ...state, + userAnotherMethod: action.payload, + } } return state; } \ No newline at end of file diff --git a/client/src/reducers/constants.ts b/client/src/reducers/constants.ts index 232e13ce7..abbd5f055 100644 --- a/client/src/reducers/constants.ts +++ b/client/src/reducers/constants.ts @@ -10,6 +10,15 @@ export const AUTHENTICATE_FAILURE = '@portal/authenticate_failure'; // SECOND FACTOR PAGE export const SET_SECURITY_KEY_SUPPORTED = '@portal/second_factor/set_security_key_supported'; +export const SET_USE_ANOTHER_METHOD = '@portal/second_factor/set_use_another_method'; + +export const GET_PREFERED_METHOD = '@portal/second_factor/get_prefered_method'; +export const GET_PREFERED_METHOD_SUCCESS = '@portal/second_factor/get_prefered_method_success'; +export const GET_PREFERED_METHOD_FAILURE = '@portal/second_factor/get_prefered_method_failure'; + +export const SET_PREFERED_METHOD = '@portal/second_factor/set_prefered_method'; +export const SET_PREFERED_METHOD_SUCCESS = '@portal/second_factor/set_prefered_method_success'; +export const SET_PREFERED_METHOD_FAILURE = '@portal/second_factor/set_prefered_method_failure'; export const SECURITY_KEY_SIGN = '@portal/second_factor/security_key_sign'; export const SECURITY_KEY_SIGN_SUCCESS = '@portal/second_factor/security_key_sign_success'; diff --git a/client/src/services/AutheliaService.ts b/client/src/services/AutheliaService.ts index 860d56d1b..a0c5d1f55 100644 --- a/client/src/services/AutheliaService.ts +++ b/client/src/services/AutheliaService.ts @@ -1,138 +1,161 @@ import RemoteState from "../views/AuthenticationView/RemoteState"; import u2fApi, { SignRequest } from "u2f-api"; +import Method2FA from "../types/Method2FA"; -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, - rememberMe: boolean, redirectionUrl: string | null) { - - const headers: Record = { - 'Accept': 'application/json', - 'Content-Type': 'application/json', +class AutheliaService { + static async fetchSafe(url: string, options?: RequestInit): Promise { + const res = await fetch(url, options); + if (res.status !== 200 && res.status !== 204) { + throw new Error('Status code ' + res.status); + } + return res; } - if (redirectionUrl) { - headers['X-Target-Url'] = redirectionUrl; + static async fetchSafeJson(url: string, options?: RequestInit): Promise { + const res = await fetch(url, options); + if (res.status !== 200) { + throw new Error('Status code ' + res.status); + } + return await res.json(); } - return fetchSafe('/api/firstfactor', { - method: 'POST', - headers: headers, - body: JSON.stringify({ - username: username, - password: password, - keepMeLoggedIn: rememberMe, - }) - }); -} - -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, redirectionUrl: string | null) { - - const headers: Record = { - 'Accept': 'application/json', - 'Content-Type': 'application/json', + /** + * Fetch current authentication state. + */ + static async fetchState(): Promise { + return await this.fetchSafeJson('/api/state') } - if (redirectionUrl) { - headers['X-Target-Url'] = redirectionUrl; - } - return fetchSafe('/api/u2f/sign', { - method: 'POST', - headers: headers, - body: JSON.stringify(response), - }); -} -export async function verifyTotpToken( - token: string, redirectionUrl: string | null) { - + static async postFirstFactorAuth(username: string, password: string, + rememberMe: boolean, redirectionUrl: string | null) { + const headers: Record = { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - } - if (redirectionUrl) { - headers['X-Target-Url'] = redirectionUrl; - } - return fetchSafe('/api/totp', { - method: 'POST', - headers: headers, - 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', - }); -} + if (redirectionUrl) { + headers['X-Target-Url'] = redirectionUrl; + } -export async function resetPassword(newPassword: string) { - return fetchSafe('/api/password-reset', { - method: 'POST', - headers: { + return this.fetchSafe('/api/firstfactor', { + method: 'POST', + headers: headers, + body: JSON.stringify({ + username: username, + password: password, + keepMeLoggedIn: rememberMe, + }) + }); + } + + static async postLogout() { + return this.fetchSafe('/api/logout', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }) + } + + static async startU2FRegistrationIdentityProcess() { + return this.fetchSafe('/api/secondfactor/u2f/identity/start', { + method: 'POST', + }); + } + + static async startTOTPRegistrationIdentityProcess() { + return this.fetchSafe('/api/secondfactor/totp/identity/start', { + method: 'POST', + }); + } + + static async requestSigning() { + return this.fetchSafe('/api/u2f/sign_request') + .then(async (res) => { + const body = await res.json(); + return body as SignRequest; + }); + } + + static async completeSecurityKeySigning( + response: u2fApi.SignResponse, redirectionUrl: string | null) { + + const headers: Record = { 'Accept': 'application/json', 'Content-Type': 'application/json', - }, - body: JSON.stringify({password: newPassword}) - }); -} \ No newline at end of file + } + if (redirectionUrl) { + headers['X-Target-Url'] = redirectionUrl; + } + return this.fetchSafe('/api/u2f/sign', { + method: 'POST', + headers: headers, + body: JSON.stringify(response), + }); + } + + static async verifyTotpToken( + token: string, redirectionUrl: string | null) { + + const headers: Record = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + if (redirectionUrl) { + headers['X-Target-Url'] = redirectionUrl; + } + return this.fetchSafe('/api/totp', { + method: 'POST', + headers: headers, + body: JSON.stringify({token}), + }) + } + + static async initiatePasswordResetIdentityValidation(username: string) { + return this.fetchSafe('/api/password-reset/identity/start', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({username}) + }); + } + + static async completePasswordResetIdentityValidation(token: string) { + return fetch(`/api/password-reset/identity/finish?token=${token}`, { + method: 'POST', + }); + } + + static async resetPassword(newPassword: string) { + return this.fetchSafe('/api/password-reset', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({password: newPassword}) + }); + } + + static async fetchPrefered2faMethod(): Promise { + const doc = await this.fetchSafeJson('/api/secondfactor/preferences'); + return doc.method; + } + + static async setPrefered2faMethod(method: Method2FA): Promise { + await this.fetchSafe('/api/secondfactor/preferences', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({method}) + }); + } +} + +export default AutheliaService; \ No newline at end of file diff --git a/client/src/types/Method2FA.ts b/client/src/types/Method2FA.ts new file mode 100644 index 000000000..09e0b3c48 --- /dev/null +++ b/client/src/types/Method2FA.ts @@ -0,0 +1,4 @@ + +type Method2FA = "u2f" | "totp"; + +export default Method2FA; \ No newline at end of file diff --git a/client/src/views/AuthenticationView/RemoteState.ts b/client/src/views/AuthenticationView/RemoteState.ts index e99eca5b3..a7b552137 100644 --- a/client/src/views/AuthenticationView/RemoteState.ts +++ b/client/src/views/AuthenticationView/RemoteState.ts @@ -4,6 +4,7 @@ interface RemoteState { username: string; authentication_level: AuthenticationLevel; default_redirection_url: string; + method: 'u2f' | 'totp' } export default RemoteState; \ No newline at end of file diff --git a/example/compose/nginx/backend/html/secure/index.html b/example/compose/nginx/backend/html/secure/index.html new file mode 100644 index 000000000..733f9580e --- /dev/null +++ b/example/compose/nginx/backend/html/secure/index.html @@ -0,0 +1,13 @@ + + + + Public resource + + + +

Public resource

+

This is a public resource.
+ Go back to home page. +

+ + diff --git a/server/src/lib/routes/secondfactor/preferences/Get.spec.ts b/server/src/lib/routes/secondfactor/preferences/Get.spec.ts new file mode 100644 index 000000000..08900347e --- /dev/null +++ b/server/src/lib/routes/secondfactor/preferences/Get.spec.ts @@ -0,0 +1,36 @@ +import * as Express from "express"; +import * as Bluebird from "bluebird"; +import { ServerVariables } from "../../../ServerVariables"; +import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../../ServerVariablesMockBuilder.spec"; +import * as ExpressMock from "../../../stubs/express.spec"; +import Get from "./Get"; +import * as Assert from "assert"; + +describe("routes/secondfactor/Get", function() { + let vars: ServerVariables; + let mocks: ServerVariablesMock; + let req: Express.Request; + let res: ExpressMock.ResponseMock; + + beforeEach(function() { + const sv = ServerVariablesMockBuilder.build(); + vars = sv.variables; + mocks = sv.mocks; + + req = ExpressMock.RequestMock(); + res = ExpressMock.ResponseMock(); + }) + + it("should get the method from db", async function() { + mocks.userDataStore.retrievePrefered2FAMethodStub.returns(Bluebird.resolve('totp')); + await Get(vars)(req, res as any); + Assert(res.json.calledWith({method: 'totp'})); + }); + + it("should fail when database fail to retrieve method", async function() { + mocks.userDataStore.retrievePrefered2FAMethodStub.returns(Bluebird.reject(new Error('DB connection failed.'))); + await Get(vars)(req, res as any); + Assert(res.status.calledWith(200)); + Assert(res.send.calledWith({ error: "Operation failed." })); + }) +}); \ No newline at end of file diff --git a/server/src/lib/routes/secondfactor/preferences/Get.ts b/server/src/lib/routes/secondfactor/preferences/Get.ts new file mode 100644 index 000000000..d74bd7242 --- /dev/null +++ b/server/src/lib/routes/secondfactor/preferences/Get.ts @@ -0,0 +1,18 @@ +import * as Express from "express"; +import { ServerVariables } from "../../../ServerVariables"; +import { AuthenticationSessionHandler } from "../../../AuthenticationSessionHandler"; +import * as ErrorReplies from "../../../ErrorReplies"; +import * as UserMessage from "../../../../../../shared/UserMessages"; + + +export default function(vars: ServerVariables) { + return async function(req: Express.Request, res: Express.Response) { + try { + const authSession = AuthenticationSessionHandler.get(req, vars.logger); + const method = await vars.userDataStore.retrievePrefered2FAMethod(authSession.userid); + res.json({method}); + } catch (err) { + ErrorReplies.replyWithError200(req, res, vars.logger, UserMessage.OPERATION_FAILED)(err); + } + }; +} \ No newline at end of file diff --git a/server/src/lib/routes/secondfactor/preferences/Post.spec.ts b/server/src/lib/routes/secondfactor/preferences/Post.spec.ts new file mode 100644 index 000000000..2d55e5d37 --- /dev/null +++ b/server/src/lib/routes/secondfactor/preferences/Post.spec.ts @@ -0,0 +1,55 @@ +import * as Express from "express"; +import * as Bluebird from "bluebird"; +import { ServerVariables } from "../../../ServerVariables"; +import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../../ServerVariablesMockBuilder.spec"; +import * as ExpressMock from "../../../stubs/express.spec"; +import Post from "./Post"; +import * as Assert from "assert"; + +describe("routes/secondfactor/Post", function() { + let vars: ServerVariables; + let mocks: ServerVariablesMock; + let req: Express.Request; + let res: ExpressMock.ResponseMock; + + beforeEach(function() { + const sv = ServerVariablesMockBuilder.build(); + vars = sv.variables; + mocks = sv.mocks; + + req = ExpressMock.RequestMock(); + res = ExpressMock.ResponseMock(); + }) + + it("should save the method in DB", async function() { + mocks.userDataStore.savePrefered2FAMethodStub.returns(Bluebird.resolve()); + req.body.method = 'totp'; + req.session.auth = { + userid: 'john' + } + await Post(vars)(req, res as any); + Assert(mocks.userDataStore.savePrefered2FAMethodStub.calledWith('john', 'totp')); + Assert(res.status.calledWith(204)); + Assert(res.send.calledWith()); + }); + + it("should fail if no method is provided in body", async function() { + req.session.auth = { + userid: 'john' + } + await Post(vars)(req, res as any); + Assert(res.status.calledWith(200)); + Assert(res.send.calledWith({ error: "Operation failed." })); + }); + + it("should fail if access to DB fails", async function() { + mocks.userDataStore.savePrefered2FAMethodStub.returns(Bluebird.reject(new Error('DB access failed.'))); + req.body.method = 'totp' + req.session.auth = { + userid: 'john' + } + await Post(vars)(req, res as any); + Assert(res.status.calledWith(200)); + Assert(res.send.calledWith({ error: "Operation failed." })); + }); +}); \ No newline at end of file diff --git a/server/src/lib/routes/secondfactor/preferences/Post.ts b/server/src/lib/routes/secondfactor/preferences/Post.ts new file mode 100644 index 000000000..5abf4825a --- /dev/null +++ b/server/src/lib/routes/secondfactor/preferences/Post.ts @@ -0,0 +1,23 @@ +import * as Express from "express"; +import { ServerVariables } from "../../../ServerVariables"; +import { AuthenticationSessionHandler } from "../../../AuthenticationSessionHandler"; +import * as ErrorReplies from "../../../ErrorReplies"; +import * as UserMessage from "../../../../../../shared/UserMessages"; + + +export default function(vars: ServerVariables) { + return async function(req: Express.Request, res: Express.Response) { + try { + if (!(req.body && req.body.method)) { + throw new Error("No 'method' key in request body"); + } + + const authSession = AuthenticationSessionHandler.get(req, vars.logger); + await vars.userDataStore.savePrefered2FAMethod(authSession.userid, req.body.method); + res.status(204); + res.send(); + } catch (err) { + ErrorReplies.replyWithError200(req, res, vars.logger, UserMessage.OPERATION_FAILED)(err); + } + }; +} \ No newline at end of file diff --git a/server/src/lib/storage/CollectionFactoryStub.spec.ts b/server/src/lib/storage/CollectionFactoryStub.spec.ts index 17f8bb021..34dc40ecb 100644 --- a/server/src/lib/storage/CollectionFactoryStub.spec.ts +++ b/server/src/lib/storage/CollectionFactoryStub.spec.ts @@ -1,4 +1,3 @@ -import BluebirdPromise = require("bluebird"); import Sinon = require("sinon"); import { ICollection } from "./ICollection"; import { ICollectionFactory } from "./ICollectionFactory"; diff --git a/server/src/lib/storage/IUserDataStore.d.ts b/server/src/lib/storage/IUserDataStore.d.ts index 81df482aa..3fab1024b 100644 --- a/server/src/lib/storage/IUserDataStore.d.ts +++ b/server/src/lib/storage/IUserDataStore.d.ts @@ -5,6 +5,7 @@ import { U2FRegistration } from "../../../types/U2FRegistration"; import { TOTPSecret } from "../../../types/TOTPSecret"; import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument"; import { IdentityValidationDocument } from "./IdentityValidationDocument"; +import Method2FA from "../../../../shared/Method2FA"; export interface IUserDataStore { saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise; @@ -18,4 +19,7 @@ export interface IUserDataStore { saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise; retrieveTOTPSecret(userId: string): BluebirdPromise; + + savePrefered2FAMethod(userId: string, method: Method2FA): BluebirdPromise; + retrievePrefered2FAMethod(userId: string): BluebirdPromise; } \ No newline at end of file diff --git a/server/src/lib/storage/UserDataStore.spec.ts b/server/src/lib/storage/UserDataStore.spec.ts index 66fb85461..7c2f4be6d 100644 --- a/server/src/lib/storage/UserDataStore.spec.ts +++ b/server/src/lib/storage/UserDataStore.spec.ts @@ -46,11 +46,12 @@ describe("storage/UserDataStore", function () { it("should correctly creates collections", function () { new UserDataStore(factory); - Assert.equal(4, factory.buildStub.callCount); + Assert.equal(5, factory.buildStub.callCount); Assert(factory.buildStub.calledWith("authentication_traces")); Assert(factory.buildStub.calledWith("identity_validation_tokens")); Assert(factory.buildStub.calledWith("u2f_registrations")); Assert(factory.buildStub.calledWith("totp_secrets")); + Assert(factory.buildStub.calledWith("prefered_2fa_method")); }); describe("TOTP secrets collection", function () { @@ -261,4 +262,28 @@ describe("storage/UserDataStore", function () { }); }); }); + describe("Prefered 2FA method", function () { + it("should save a prefered 2FA method", async function () { + factory.buildStub.returns(collection); + collection.insertStub.returns(BluebirdPromise.resolve()); + + const dataStore = new UserDataStore(factory); + + await dataStore.savePrefered2FAMethod(userId, "totp") + Assert(collection.updateStub.calledOnce); + Assert(collection.updateStub.calledWith( + {userId}, {userId, method: "totp"}, {upsert: true})); + }); + + it("should retrieve a prefered 2FA method", async function () { + factory.buildStub.returns(collection); + collection.findOneStub.returns(BluebirdPromise.resolve()); + + const dataStore = new UserDataStore(factory); + + await dataStore.retrievePrefered2FAMethod(userId) + Assert(collection.findOneStub.calledOnce); + Assert(collection.findOneStub.calledWith({userId})); + }); + }); }); diff --git a/server/src/lib/storage/UserDataStore.ts b/server/src/lib/storage/UserDataStore.ts index 27b0cddbd..2fb8bab8a 100644 --- a/server/src/lib/storage/UserDataStore.ts +++ b/server/src/lib/storage/UserDataStore.ts @@ -1,5 +1,4 @@ import * as BluebirdPromise from "bluebird"; -import * as path from "path"; import { IUserDataStore } from "./IUserDataStore"; import { ICollection } from "./ICollection"; import { ICollectionFactory } from "./ICollectionFactory"; @@ -9,6 +8,7 @@ import { U2FRegistration } from "../../../types/U2FRegistration"; import { TOTPSecret } from "../../../types/TOTPSecret"; import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument"; import { IdentityValidationDocument } from "./IdentityValidationDocument"; +import Method2FA from "../../../../shared/Method2FA"; // Constants @@ -17,6 +17,7 @@ const AUTHENTICATION_TRACES_COLLECTION_NAME = "authentication_traces"; const U2F_REGISTRATIONS_COLLECTION_NAME = "u2f_registrations"; const TOTP_SECRETS_COLLECTION_NAME = "totp_secrets"; +const PREFERED_2FA_METHOD_COLLECTION_NAME = "prefered_2fa_method"; export interface U2FRegistrationKey { @@ -31,6 +32,7 @@ export class UserDataStore implements IUserDataStore { private identityCheckTokensCollection: ICollection; private authenticationTracesCollection: ICollection; private totpSecretCollection: ICollection; + private prefered2faMethodCollection: ICollection; private collectionFactory: ICollectionFactory; @@ -41,35 +43,24 @@ export class UserDataStore implements IUserDataStore { this.identityCheckTokensCollection = this.collectionFactory.build(IDENTITY_VALIDATION_TOKENS_COLLECTION_NAME); this.authenticationTracesCollection = this.collectionFactory.build(AUTHENTICATION_TRACES_COLLECTION_NAME); this.totpSecretCollection = this.collectionFactory.build(TOTP_SECRETS_COLLECTION_NAME); + this.prefered2faMethodCollection = this.collectionFactory.build(PREFERED_2FA_METHOD_COLLECTION_NAME); } saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise { - const newDocument: U2FRegistrationDocument = { - userId: userId, - appId: appId, - registration: registration - }; - - const filter: U2FRegistrationKey = { - userId: userId, - appId: appId - }; + const newDocument: U2FRegistrationDocument = {userId, appId, registration}; + const filter: U2FRegistrationKey = {userId, appId}; return this.u2fSecretCollection.update(filter, newDocument, { upsert: true }); } retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise { - const filter: U2FRegistrationKey = { - userId: userId, - appId: appId - }; + const filter: U2FRegistrationKey = { userId, appId }; return this.u2fSecretCollection.findOne(filter); } saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise { const newDocument: AuthenticationTraceDocument = { - userId: userId, - date: new Date(), + userId, date: new Date(), isAuthenticationSuccessful: isAuthenticationSuccessful, }; @@ -77,18 +68,12 @@ export class UserDataStore implements IUserDataStore { } retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise { - const q = { - userId: userId - }; - - return this.authenticationTracesCollection.find(q, { date: -1 }, count); + return this.authenticationTracesCollection.find({userId}, { date: -1 }, count); } produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise { const newDocument: IdentityValidationDocument = { - userId: userId, - token: token, - challenge: challenge, + userId, token, challenge, maxDate: new Date(new Date().getTime() + maxAge) }; @@ -97,10 +82,7 @@ export class UserDataStore implements IUserDataStore { consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise { const that = this; - const filter = { - token: token, - challenge: challenge - }; + const filter = {token, challenge}; let identityValidationDocument: IdentityValidationDocument; @@ -123,21 +105,23 @@ export class UserDataStore implements IUserDataStore { } saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise { - const doc = { - userId: userId, - secret: secret - }; - - const filter = { - userId: userId - }; - return this.totpSecretCollection.update(filter, doc, { upsert: true }); + const doc = {userId, secret}; + return this.totpSecretCollection.update({userId}, doc, { upsert: true }); } retrieveTOTPSecret(userId: string): BluebirdPromise { - const filter = { - userId: userId - }; - return this.totpSecretCollection.findOne(filter); + return this.totpSecretCollection.findOne({userId}); + } + + savePrefered2FAMethod(userId: string, method: Method2FA): BluebirdPromise { + const newDoc = {userId, method}; + return this.prefered2faMethodCollection.update({userId}, newDoc, {upsert: true}); + } + + retrievePrefered2FAMethod(userId: string): BluebirdPromise { + return this.prefered2faMethodCollection.findOne({userId}) + .then((doc) => { + return (doc && doc.method) ? doc.method : undefined; + }); } } diff --git a/server/src/lib/storage/UserDataStoreStub.spec.ts b/server/src/lib/storage/UserDataStoreStub.spec.ts index 5ea27a2de..6f062bfb3 100644 --- a/server/src/lib/storage/UserDataStoreStub.spec.ts +++ b/server/src/lib/storage/UserDataStoreStub.spec.ts @@ -1,5 +1,5 @@ -import Sinon = require("sinon"); -import BluebirdPromise = require("bluebird"); +import * as Sinon from "sinon"; +import * as BluebirdPromise from "bluebird"; import { TOTPSecretDocument } from "./TOTPSecretDocument"; import { U2FRegistrationDocument } from "./U2FRegistrationDocument"; @@ -8,6 +8,7 @@ import { TOTPSecret } from "../../../types/TOTPSecret"; import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument"; import { IdentityValidationDocument } from "./IdentityValidationDocument"; import { IUserDataStore } from "./IUserDataStore"; +import Method2FA from "../../../../shared/Method2FA"; export class UserDataStoreStub implements IUserDataStore { saveU2FRegistrationStub: Sinon.SinonStub; @@ -18,6 +19,8 @@ export class UserDataStoreStub implements IUserDataStore { consumeIdentityValidationTokenStub: Sinon.SinonStub; saveTOTPSecretStub: Sinon.SinonStub; retrieveTOTPSecretStub: Sinon.SinonStub; + savePrefered2FAMethodStub: Sinon.SinonStub; + retrievePrefered2FAMethodStub: Sinon.SinonStub; constructor() { this.saveU2FRegistrationStub = Sinon.stub(); @@ -28,6 +31,8 @@ export class UserDataStoreStub implements IUserDataStore { this.consumeIdentityValidationTokenStub = Sinon.stub(); this.saveTOTPSecretStub = Sinon.stub(); this.retrieveTOTPSecretStub = Sinon.stub(); + this.savePrefered2FAMethodStub = Sinon.stub(); + this.retrievePrefered2FAMethodStub = Sinon.stub(); } saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise { @@ -61,4 +66,12 @@ export class UserDataStoreStub implements IUserDataStore { retrieveTOTPSecret(userId: string): BluebirdPromise { return this.retrieveTOTPSecretStub(userId); } + + savePrefered2FAMethod(userId: string, method: Method2FA): BluebirdPromise { + return this.savePrefered2FAMethodStub(userId, method); + } + + retrievePrefered2FAMethod(userId: string): BluebirdPromise { + return this.retrievePrefered2FAMethodStub(userId); + } } \ No newline at end of file diff --git a/server/src/lib/web_server/RestApi.ts b/server/src/lib/web_server/RestApi.ts index 467f40c58..9d3d9f753 100644 --- a/server/src/lib/web_server/RestApi.ts +++ b/server/src/lib/web_server/RestApi.ts @@ -1,4 +1,6 @@ -import Express = require("express"); +import * as Express from "express"; +import SecondFactorPreferencesGet from "../routes/secondfactor/preferences/Get"; +import SecondFactorPreferencesPost from "../routes/secondfactor/preferences/Post"; import FirstFactorPost = require("../routes/firstfactor/post"); import LogoutPost from "../routes/logout/post"; @@ -92,6 +94,14 @@ export class RestApi { app.get(Endpoints.VERIFY_GET, VerifyGet.default(vars)); app.post(Endpoints.FIRST_FACTOR_POST, FirstFactorPost.default(vars)); + app.get(Endpoints.SECOND_FACTOR_PREFERENCES_GET, + RequireValidatedFirstFactor.middleware(vars.logger), + SecondFactorPreferencesGet(vars)); + + app.post(Endpoints.SECOND_FACTOR_PREFERENCES_POST, + RequireValidatedFirstFactor.middleware(vars.logger), + SecondFactorPreferencesPost(vars)); + setupTotp(app, vars); setupU2f(app, vars); setupResetPassword(app, vars); diff --git a/shared/Method2FA.ts b/shared/Method2FA.ts new file mode 100644 index 000000000..d6f1a911c --- /dev/null +++ b/shared/Method2FA.ts @@ -0,0 +1,3 @@ +import Method2FA from "../client/src/types/Method2FA"; + +export default Method2FA; \ No newline at end of file diff --git a/shared/RedirectionMessage.ts b/shared/RedirectionMessage.ts deleted file mode 100644 index 4c2dff078..000000000 --- a/shared/RedirectionMessage.ts +++ /dev/null @@ -1,4 +0,0 @@ - -export interface RedirectionMessage { - redirect: string; -} \ No newline at end of file diff --git a/shared/api.ts b/shared/api.ts index 18b1b6ee6..055def7af 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -155,12 +155,32 @@ export const SECOND_FACTOR_TOTP_IDENTITY_START_POST = "/api/secondfactor/totp/id * @apiUse UserSession * @apiUse IdentityValidationFinish * - * * @apiDescription Serves the TOTP registration page that displays the secret. * The secret is a QRCode and a base32 secret. */ export const SECOND_FACTOR_TOTP_IDENTITY_FINISH_POST = "/api/secondfactor/totp/identity/finish"; +/** + * @api {get} /api/secondfactor/preferences Retrieve the user preferences. + * @apiName GetUserPreferences + * @apiGroup 2FA + * @apiVersion 1.0.0 + * @apiUse UserSession + * + * @apiDescription Retrieve the user preferences sucha as the prefered method to use (TOTP or U2F). + */ +export const SECOND_FACTOR_PREFERENCES_GET = "/api/secondfactor/preferences"; + +/** + * @api {post} /api/secondfactor/preferences Set the user preferences. + * @apiName SetUserPreferences + * @apiGroup 2FA + * @apiVersion 1.0.0 + * @apiUse UserSession + * + * @apiDescription Set the user preferences sucha as the prefered method to use (TOTP or U2F). + */ +export const SECOND_FACTOR_PREFERENCES_POST = "/api/secondfactor/preferences"; /** diff --git a/test/helpers/LoginAs.ts b/test/helpers/LoginAs.ts index 02606ffa8..ed57756c2 100644 --- a/test/helpers/LoginAs.ts +++ b/test/helpers/LoginAs.ts @@ -4,6 +4,6 @@ import VisitPageAndWaitUrlIs from "./behaviors/VisitPageAndWaitUrlIs"; export default async function(driver: WebDriver, user: string, password: string, targetUrl?: string, timeout: number = 5000) { const urlExt = (targetUrl) ? ('rd=' + targetUrl) : ''; - await VisitPageAndWaitUrlIs(driver, "https://login.example.com:8080/#/" + urlExt, timeout); + await VisitPageAndWaitUrlIs(driver, "https://login.example.com:8080/#/?" + urlExt, timeout); await FillLoginPageAndClick(driver, user, password, false, timeout); } \ No newline at end of file diff --git a/test/helpers/assertions/VerifyIsOneTimePasswordView.ts b/test/helpers/assertions/VerifyIsOneTimePasswordView.ts new file mode 100644 index 000000000..650c30447 --- /dev/null +++ b/test/helpers/assertions/VerifyIsOneTimePasswordView.ts @@ -0,0 +1,6 @@ +import SeleniumWebDriver, { WebDriver } from "selenium-webdriver"; + +export default async function(driver: WebDriver, timeout: number = 5000) { + await driver.wait(SeleniumWebDriver.until.elementLocated( + SeleniumWebDriver.By.className('one-time-password-view')), timeout); +} \ No newline at end of file diff --git a/test/helpers/assertions/VerifyIsSecurityKeyView.ts b/test/helpers/assertions/VerifyIsSecurityKeyView.ts new file mode 100644 index 000000000..c836fc3f0 --- /dev/null +++ b/test/helpers/assertions/VerifyIsSecurityKeyView.ts @@ -0,0 +1,6 @@ +import SeleniumWebDriver, { WebDriver } from "selenium-webdriver"; + +export default async function(driver: WebDriver, timeout: number = 5000) { + await driver.wait(SeleniumWebDriver.until.elementLocated( + SeleniumWebDriver.By.className('security-key-view')), timeout); +} \ No newline at end of file diff --git a/test/helpers/assertions/VerifyIsUseAnotherMethodView.ts b/test/helpers/assertions/VerifyIsUseAnotherMethodView.ts new file mode 100644 index 000000000..a7a7a058d --- /dev/null +++ b/test/helpers/assertions/VerifyIsUseAnotherMethodView.ts @@ -0,0 +1,6 @@ +import SeleniumWebDriver, { WebDriver } from "selenium-webdriver"; + +export default async function(driver: WebDriver, timeout: number = 5000) { + await driver.wait(SeleniumWebDriver.until.elementLocated( + SeleniumWebDriver.By.className('use-another-method-view')), timeout); +} \ No newline at end of file diff --git a/test/helpers/behaviors/ClickOnButton.ts b/test/helpers/behaviors/ClickOnButton.ts new file mode 100644 index 000000000..abcf5a367 --- /dev/null +++ b/test/helpers/behaviors/ClickOnButton.ts @@ -0,0 +1,8 @@ +import SeleniumWebdriver, { WebDriver } from "selenium-webdriver"; + +export default async function(driver: WebDriver, text: string, timeout: number = 5000) { + const element = await driver.wait( + SeleniumWebdriver.until.elementLocated( + SeleniumWebdriver.By.xpath("//button[text()='" + text + "']")), timeout) + await element.click(); +}; \ No newline at end of file diff --git a/test/helpers/behaviors/LoginOneFactor.ts b/test/helpers/behaviors/LoginOneFactor.ts index 50c178e27..06cbb77eb 100644 --- a/test/helpers/behaviors/LoginOneFactor.ts +++ b/test/helpers/behaviors/LoginOneFactor.ts @@ -10,7 +10,7 @@ export default async function( targetUrl: string, timeout: number = 5000) { - await VisitPageAndWaitUrlIs(driver, `https://login.example.com:8080/#/?rd=${targetUrl}`, timeout); - await FillLoginPageAndClick(driver, username, password, false, timeout); - await VerifyUrlIs(driver, targetUrl, timeout); + await VisitPageAndWaitUrlIs(driver, `https://login.example.com:8080/#/?rd=${targetUrl}`, timeout); + await FillLoginPageAndClick(driver, username, password, false, timeout); + await VerifyUrlIs(driver, targetUrl, timeout); }; \ No newline at end of file diff --git a/test/helpers/context/AutheliaServer.ts b/test/helpers/context/AutheliaServer.ts index e14eedeb1..a36b9fbe8 100644 --- a/test/helpers/context/AutheliaServer.ts +++ b/test/helpers/context/AutheliaServer.ts @@ -6,9 +6,9 @@ import AutheliaServerFromDist from './AutheliaServerFromDist'; class AutheliaServer implements AutheliaServerInterface { private runnerImpl: AutheliaServerInterface; - constructor(configPath: string) { + constructor(configPath: string, watchPaths: string[] = []) { if (fs.existsSync('.suite')) { - this.runnerImpl = new AutheliaServerWithHotReload(configPath); + this.runnerImpl = new AutheliaServerWithHotReload(configPath, watchPaths); } else { this.runnerImpl = new AutheliaServerFromDist(configPath, true); } diff --git a/test/helpers/context/AutheliaServerWithHotReload.ts b/test/helpers/context/AutheliaServerWithHotReload.ts index 3e7f09ea1..edcb5b1c1 100644 --- a/test/helpers/context/AutheliaServerWithHotReload.ts +++ b/test/helpers/context/AutheliaServerWithHotReload.ts @@ -15,10 +15,10 @@ class AutheliaServerWithHotReload implements AutheliaServerInterface { private filesChangedBuffer: string[] = []; private changeInProgress: boolean = false; - constructor(configPath: string) { + constructor(configPath: string, watchedPaths: string[]) { this.configPath = configPath; this.watcher = Chokidar.watch(['server', 'shared/**/*.ts', 'node_modules', - this.AUTHELIA_INTERRUPT_FILENAME, configPath], { + this.AUTHELIA_INTERRUPT_FILENAME, configPath].concat(watchedPaths), { persistent: true, ignoreInitial: true, }); diff --git a/test/suites/acl-full-bypass/users_database.yml b/test/suites/acl-full-bypass/users_database.yml index 7832e85b3..6fe7a384d 100644 --- a/test/suites/acl-full-bypass/users_database.yml +++ b/test/suites/acl-full-bypass/users_database.yml @@ -15,7 +15,7 @@ users: harry: password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" - emails: harry.potter@authelia.com + email: harry.potter@authelia.com groups: [] bob: diff --git a/test/suites/basic/config.yml b/test/suites/basic/config.yml index 9fcd9e815..172aa3efb 100644 --- a/test/suites/basic/config.yml +++ b/test/suites/basic/config.yml @@ -45,6 +45,9 @@ access_control: - domain: public.example.com policy: bypass + - domain: secure.example.com + policy: two_factor + - domain: '*.example.com' subject: "group:admins" policy: two_factor diff --git a/test/suites/basic/environment.ts b/test/suites/basic/environment.ts index 8fa6b2f2a..cc2bb83fb 100644 --- a/test/suites/basic/environment.ts +++ b/test/suites/basic/environment.ts @@ -3,7 +3,7 @@ import { exec } from "../../helpers/utils/exec"; import AutheliaServer from "../../helpers/context/AutheliaServer"; import DockerEnvironment from "../../helpers/context/DockerEnvironment"; -const autheliaServer = new AutheliaServer(__dirname + '/config.yml'); +const autheliaServer = new AutheliaServer(__dirname + '/config.yml', [__dirname + '/users_database.yml']); const dockerEnv = new DockerEnvironment([ 'docker-compose.yml', 'example/compose/nginx/backend/docker-compose.yml', diff --git a/test/suites/basic/scenarii/Prefered2faMethod.ts b/test/suites/basic/scenarii/Prefered2faMethod.ts new file mode 100644 index 000000000..65ccff2c1 --- /dev/null +++ b/test/suites/basic/scenarii/Prefered2faMethod.ts @@ -0,0 +1,51 @@ +import { StartDriver, StopDriver } from "../../../helpers/context/WithDriver"; +import LoginAs from "../../../helpers/LoginAs"; +import VerifyIsOneTimePasswordView from "../../../helpers/assertions/VerifyIsOneTimePasswordView"; +import ClickOnLink from "../../../helpers/ClickOnLink"; +import VerifyIsUseAnotherMethodView from "../../../helpers/assertions/VerifyIsUseAnotherMethodView"; +import ClickOnButton from "../../../helpers/behaviors/ClickOnButton"; +import VerifyIsSecurityKeyView from "../../../helpers/assertions/VerifyIsSecurityKeyView"; +import VerifyIsSecondFactorStage from "../../../helpers/assertions/VerifyIsSecondFactorStage"; + + +// This fixture tests that the latest used method is still used when the user gets back. +export default function() { + before(async function() { + this.driver = await StartDriver(); + }); + + after(async function() { + await StopDriver(this.driver); + }); + + // The default method is TOTP and then everytime the user switches method, + // it get remembered and reloaded during next authentication. + it('should serve the correct method', async function() { + await LoginAs(this.driver, "john", "password", "https://secure.example.com:8080/"); + await VerifyIsSecondFactorStage(this.driver); + + await ClickOnLink(this.driver, 'Use another method'); + await VerifyIsUseAnotherMethodView(this.driver); + await ClickOnButton(this.driver, 'Security Key (U2F)'); + + // Verify that the user is redirected to the new method + await VerifyIsSecurityKeyView(this.driver); + await ClickOnLink(this.driver, "Logout"); + + // Login with another user to check that he gets TOTP view. + await LoginAs(this.driver, "harry", "password", "https://secure.example.com:8080/"); + await VerifyIsOneTimePasswordView(this.driver); + await ClickOnLink(this.driver, "Logout"); + + // Log john again to check that the prefered method has been persisted + await LoginAs(this.driver, "john", "password", "https://secure.example.com:8080/"); + await VerifyIsSecurityKeyView(this.driver); + + // Restore the prefered method to one-time password. + await ClickOnLink(this.driver, 'Use another method'); + await VerifyIsUseAnotherMethodView(this.driver); + await ClickOnButton(this.driver, 'One-Time Password'); + await VerifyIsOneTimePasswordView(this.driver); + await ClickOnLink(this.driver, "Logout"); + }); +} \ No newline at end of file diff --git a/test/suites/basic/test.ts b/test/suites/basic/test.ts index 1ef52c98c..006dbf4ac 100644 --- a/test/suites/basic/test.ts +++ b/test/suites/basic/test.ts @@ -10,6 +10,7 @@ import LogoutRedirectToAlreadyLoggedIn from './scenarii/LogoutRedirectToAlreadyL import { exec } from '../../helpers/utils/exec'; import TwoFactorAuthentication from "../../helpers/scenarii/TwoFactorAuthentication"; import BypassPolicy from "./scenarii/BypassPolicy"; +import Prefered2faMethod from "./scenarii/Prefered2faMethod"; AutheliaSuite(__dirname, function() { this.timeout(10000); @@ -28,4 +29,5 @@ AutheliaSuite(__dirname, function() { describe('TOTP Validation', TOTPValidation); describe('Required two factor', RequiredTwoFactor); describe('Logout endpoint redirect to already logged in page', LogoutRedirectToAlreadyLoggedIn); + describe('Prefered 2FA method', Prefered2faMethod); }); \ No newline at end of file diff --git a/test/suites/basic/users_database.yml b/test/suites/basic/users_database.yml index 7832e85b3..6fe7a384d 100644 --- a/test/suites/basic/users_database.yml +++ b/test/suites/basic/users_database.yml @@ -15,7 +15,7 @@ users: harry: password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" - emails: harry.potter@authelia.com + email: harry.potter@authelia.com groups: [] bob: diff --git a/test/suites/short-timeouts/users_database.yml b/test/suites/short-timeouts/users_database.yml index 7832e85b3..6fe7a384d 100644 --- a/test/suites/short-timeouts/users_database.yml +++ b/test/suites/short-timeouts/users_database.yml @@ -15,7 +15,7 @@ users: harry: password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" - emails: harry.potter@authelia.com + email: harry.potter@authelia.com groups: [] bob: