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: