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 631cecdd..cdfad95e 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 00000000..29cdb5cd --- /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 00000000..1d8127b7 --- /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 00000000..ada449f3 --- /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 37db1e2c..2da2e9ea 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 a6b7b50b..082bba6b 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 00000000..82d97430 --- /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 a2ac3b3e..fef245ce 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 00000000..a64d3473 --- /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 00000000..d58899d0 --- /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 97a3eafd..e63b934e 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 0040a513..66b1e5ea 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 00000000..1f4a0404 --- /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 00000000..bbf645f9 --- /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 47203719..46921865 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 53fc7721..b27cb735 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 60840c17..4053ebbb 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 c934824e..eaeae9cf 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 86a44612..e12608e4 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 232e13ce..abbd5f05 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 860d56d1..a0c5d1f5 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 00000000..09e0b3c4 --- /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 e99eca5b..a7b55213 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 00000000..733f9580 --- /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 00000000..08900347 --- /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 00000000..d74bd724 --- /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 00000000..2d55e5d3 --- /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 00000000..5abf4825 --- /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 17f8bb02..34dc40ec 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 81df482a..3fab1024 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 66fb8546..7c2f4be6 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 27b0cddb..2fb8bab8 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 5ea27a2d..6f062bfb 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 467f40c5..9d3d9f75 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 00000000..d6f1a911 --- /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 4c2dff07..00000000 --- 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 18b1b6ee..055def7a 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 02606ffa..ed57756c 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 00000000..650c3044 --- /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 00000000..c836fc3f --- /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 00000000..a7a7a058 --- /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 00000000..abcf5a36 --- /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 50c178e2..06cbb77e 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 e14eedeb..a36b9fbe 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 3e7f09ea..edcb5b1c 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 7832e85b..6fe7a384 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 9fcd9e81..172aa3ef 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 8fa6b2f2..cc2bb83f 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 00000000..65ccff2c --- /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 1ef52c98..006dbf4a 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 7832e85b..6fe7a384 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 7832e85b..6fe7a384 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: