diff --git a/client/src/behaviors/GetAvailable2faMethods.ts b/client/src/behaviors/GetAvailable2faMethods.ts new file mode 100644 index 000000000..431dfde1c --- /dev/null +++ b/client/src/behaviors/GetAvailable2faMethods.ts @@ -0,0 +1,13 @@ +import { Dispatch } from "redux"; +import AutheliaService from "../services/AutheliaService"; +import { getAvailbleMethods, getAvailbleMethodsSuccess, getAvailbleMethodsFailure } from "../reducers/Portal/SecondFactor/actions"; + +export default async function(dispatch: Dispatch) { + dispatch(getAvailbleMethods()); + try { + const methods = await AutheliaService.getAvailable2faMethods(); + dispatch(getAvailbleMethodsSuccess(methods)); + } catch (err) { + dispatch(getAvailbleMethodsFailure(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 9e3ea8845..ce2b0b05a 100644 --- a/client/src/components/SecondFactorForm/SecondFactorForm.tsx +++ b/client/src/components/SecondFactorForm/SecondFactorForm.tsx @@ -3,9 +3,9 @@ import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorFo 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'; import SecondFactorDuoPush from '../../containers/components/SecondFactorDuoPush/SecondFactorDuoPush'; +import UseAnotherMethod from '../../containers/components/UseAnotherMethod/UseAnotherMethod'; export interface OwnProps { username: string; @@ -20,9 +20,6 @@ export interface StateProps { export interface DispatchProps { onInit: () => void; onLogoutClicked: () => void; - onOneTimePasswordMethodClicked: () => void; - onSecurityKeyMethodClicked: () => void; - onDuoPushMethodClicked: () => void; onUseAnotherMethodClicked: () => void; } @@ -55,19 +52,6 @@ class SecondFactorForm extends Component { ); } - private renderUseAnotherMethod() { - return ( -
-
Choose a method
-
- - - -
-
- ); - } - private renderUseAnotherMethodLink() { return (
@@ -88,7 +72,7 @@ class SecondFactorForm extends Component {
- {(this.props.useAnotherMethod) ? this.renderUseAnotherMethod() : this.renderMethod()} + {(this.props.useAnotherMethod) ? : this.renderMethod()}
{(this.props.useAnotherMethod) ? null : this.renderUseAnotherMethodLink()} diff --git a/client/src/components/UseAnotherMethod/UseAnotherMethod.tsx b/client/src/components/UseAnotherMethod/UseAnotherMethod.tsx new file mode 100644 index 000000000..f9a4199bd --- /dev/null +++ b/client/src/components/UseAnotherMethod/UseAnotherMethod.tsx @@ -0,0 +1,66 @@ +import React, { Component } from 'react'; +import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss'; +import Method2FA from '../../types/Method2FA'; +import { Button } from '@material/react-button'; +import classnames from 'classnames'; + +export interface OwnProps {} + +export interface StateProps { + availableMethods: Method2FA[] | null; +} + +export interface DispatchProps { + onInit: () => void; + onOneTimePasswordMethodClicked: () => void; + onSecurityKeyMethodClicked: () => void; + onDuoPushMethodClicked: () => void; +} + +export type Props = OwnProps & StateProps & DispatchProps; + +interface MethodDescription { + name: string; + onClicked: () => void; + key: Method2FA; +} + +class UseAnotherMethod extends Component { + componentDidMount() { + this.props.onInit(); + } + + render() { + const methods: MethodDescription[] = [ + { + name: "One-Time Password", + onClicked: this.props.onOneTimePasswordMethodClicked, + key: "totp" + }, + { + name: "Security Key (U2F)", + onClicked: this.props.onSecurityKeyMethodClicked, + key: "u2f" + }, + { + name: "Duo Push Notification", + onClicked: this.props.onDuoPushMethodClicked, + key: "duo_push" + } + ]; + const methodsComponents = methods + .filter(m => this.props.availableMethods && this.props.availableMethods.includes(m.key)) + .map(m => ); + + return ( +
+
Choose a method
+
+ {methodsComponents} +
+
+ ) + } +} + +export default UseAnotherMethod; \ No newline at end of file diff --git a/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts b/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts index 01dde7894..78c608bc3 100644 --- a/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts +++ b/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts @@ -29,9 +29,6 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { return { onInit: () => FetchPrefered2faMethod(dispatch), onLogoutClicked: () => LogoutBehavior(dispatch), - onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'), - onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'), - onDuoPushMethodClicked: () => storeMethod(dispatch, "duo_push"), onUseAnotherMethodClicked: () => dispatch(setUseAnotherMethod(true)), } } diff --git a/client/src/containers/components/UseAnotherMethod/UseAnotherMethod.tsx b/client/src/containers/components/UseAnotherMethod/UseAnotherMethod.tsx new file mode 100644 index 000000000..9e502a1f3 --- /dev/null +++ b/client/src/containers/components/UseAnotherMethod/UseAnotherMethod.tsx @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; +import { RootState } from '../../../reducers'; +import SetPrefered2faMethod from '../../../behaviors/SetPrefered2faMethod'; +import { getPreferedMethodSuccess, setUseAnotherMethod } from '../../../reducers/Portal/SecondFactor/actions'; +import Method2FA from '../../../types/Method2FA'; +import UseAnotherMethod, {StateProps, DispatchProps} from '../../../components/UseAnotherMethod/UseAnotherMethod'; +import GetAvailable2faMethods from '../../../behaviors/GetAvailable2faMethods'; + +const mapStateToProps = (state: RootState): StateProps => ({ + availableMethods: state.secondFactor.getAvailableMethodResponse, +}) + +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: () => GetAvailable2faMethods(dispatch), + onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'), + onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'), + onDuoPushMethodClicked: () => storeMethod(dispatch, "duo_push"), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(UseAnotherMethod); \ No newline at end of file diff --git a/client/src/reducers/Portal/SecondFactor/actions.ts b/client/src/reducers/Portal/SecondFactor/actions.ts index d7c235760..327fafbc6 100644 --- a/client/src/reducers/Portal/SecondFactor/actions.ts +++ b/client/src/reducers/Portal/SecondFactor/actions.ts @@ -19,7 +19,10 @@ import { SET_USE_ANOTHER_METHOD, TRIGGER_DUO_PUSH_AUTH, TRIGGER_DUO_PUSH_AUTH_SUCCESS, - TRIGGER_DUO_PUSH_AUTH_FAILURE + TRIGGER_DUO_PUSH_AUTH_FAILURE, + GET_AVAILABLE_METHODS, + GET_AVAILABLE_METHODS_SUCCESS, + GET_AVAILABLE_METHODS_FAILURE } from "../../constants"; import Method2FA from "../../../types/Method2FA"; @@ -31,6 +34,16 @@ export const setUseAnotherMethod = createAction(SET_USE_ANOTHER_METHOD, resolve return (useAnotherMethod: boolean) => resolve(useAnotherMethod); }); + +export const getAvailbleMethods = createAction(GET_AVAILABLE_METHODS); +export const getAvailbleMethodsSuccess = createAction(GET_AVAILABLE_METHODS_SUCCESS, resolve => { + return (methods: Method2FA[]) => resolve(methods); +}); +export const getAvailbleMethodsFailure = createAction(GET_AVAILABLE_METHODS_FAILURE, resolve => { + return (err: string) => resolve(err); +}); + + export const getPreferedMethod = createAction(GET_PREFERED_METHOD); export const getPreferedMethodSuccess = createAction(GET_PREFERED_METHOD_SUCCESS, resolve => { return (method: Method2FA) => resolve(method); @@ -39,30 +52,35 @@ export const getPreferedMethodFailure = createAction(GET_PREFERED_METHOD_FAILURE 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); export const securityKeySignSuccess = createAction(SECURITY_KEY_SIGN_SUCCESS); export const securityKeySignFailure = createAction(SECURITY_KEY_SIGN_FAILURE, resolve => { return (error: string) => resolve(error); }); + export const oneTimePasswordVerification = createAction(ONE_TIME_PASSWORD_VERIFICATION_REQUEST); export const oneTimePasswordVerificationSuccess = createAction(ONE_TIME_PASSWORD_VERIFICATION_SUCCESS); export const oneTimePasswordVerificationFailure = createAction(ONE_TIME_PASSWORD_VERIFICATION_FAILURE, resolve => { return (err: string) => resolve(err); }); + export const triggerDuoPushAuth = createAction(TRIGGER_DUO_PUSH_AUTH); export const triggerDuoPushAuthSuccess = createAction(TRIGGER_DUO_PUSH_AUTH_SUCCESS); export const triggerDuoPushAuthFailure = createAction(TRIGGER_DUO_PUSH_AUTH_FAILURE, resolve => { return (err: string) => resolve(err); }); + export const logout = createAction(LOGOUT_REQUEST); export const logoutSuccess = createAction(LOGOUT_SUCCESS); export const logoutFailure = createAction(LOGOUT_FAILURE, resolve => { diff --git a/client/src/reducers/Portal/SecondFactor/reducer.ts b/client/src/reducers/Portal/SecondFactor/reducer.ts index cf6605e20..15bd3f49e 100644 --- a/client/src/reducers/Portal/SecondFactor/reducer.ts +++ b/client/src/reducers/Portal/SecondFactor/reducer.ts @@ -12,6 +12,10 @@ interface SecondFactorState { userAnotherMethod: boolean; + getAvailableMethodsLoading: boolean; + getAvailableMethodResponse: Method2FA[] | null; + getAvailableMethodError: string | null; + preferedMethodLoading: boolean; preferedMethodError: string | null; preferedMethod: Method2FA | null; @@ -40,6 +44,10 @@ const secondFactorInitialState: SecondFactorState = { userAnotherMethod: false, + getAvailableMethodsLoading: false, + getAvailableMethodResponse: null, + getAvailableMethodError: null, + preferedMethod: null, preferedMethodError: null, preferedMethodLoading: false, @@ -190,6 +198,26 @@ export default (state = secondFactorInitialState, action: SecondFactorAction): S duoPushVerificationLoading: false, duoPushVerificationError: action.payload, } + + case getType(Actions.getPreferedMethod): + return { + ...state, + getAvailableMethodsLoading: true, + getAvailableMethodResponse: null, + getAvailableMethodError: null, + } + case getType(Actions.getAvailbleMethodsSuccess): + return { + ...state, + getAvailableMethodsLoading: false, + getAvailableMethodResponse: action.payload, + } + case getType(Actions.getAvailbleMethodsFailure): + return { + ...state, + getAvailableMethodsLoading: false, + getAvailableMethodError: 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 9332d4eed..7e37d3e30 100644 --- a/client/src/reducers/constants.ts +++ b/client/src/reducers/constants.ts @@ -12,6 +12,10 @@ export const AUTHENTICATE_FAILURE = '@portal/authenticate_failure'; 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_AVAILABLE_METHODS = '@portal/second_factor/get_available_methods'; +export const GET_AVAILABLE_METHODS_SUCCESS = '@portal/second_factor/get_available_methods_success'; +export const GET_AVAILABLE_METHODS_FAILURE = '@portal/second_factor/get_available_methods_failure'; + 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'; diff --git a/client/src/services/AutheliaService.ts b/client/src/services/AutheliaService.ts index cf5b9b072..2e4ee0fde 100644 --- a/client/src/services/AutheliaService.ts +++ b/client/src/services/AutheliaService.ts @@ -171,6 +171,10 @@ class AutheliaService { body: JSON.stringify({method}) }); } + + static async getAvailable2faMethods(): Promise { + return await this.fetchSafeJson('/api/secondfactor/available'); + } } export default AutheliaService; \ No newline at end of file diff --git a/server/src/lib/routes/secondfactor/available/Get.spec.ts b/server/src/lib/routes/secondfactor/available/Get.spec.ts new file mode 100644 index 000000000..ff700805e --- /dev/null +++ b/server/src/lib/routes/secondfactor/available/Get.spec.ts @@ -0,0 +1,36 @@ +import * as Express from "express"; +import { ServerVariables } from "../../../ServerVariables"; +import { ServerVariablesMockBuilder } from "../../../ServerVariablesMockBuilder.spec"; +import * as ExpressMock from "../../../stubs/express.spec"; +import Get from "./Get"; +import * as Assert from "assert"; + + +describe("routes/secondfactor/duo-push/Post", function() { + let vars: ServerVariables; + let req: Express.Request; + let res: ExpressMock.ResponseMock; + + beforeEach(function() { + const sv = ServerVariablesMockBuilder.build(); + vars = sv.variables; + + req = ExpressMock.RequestMock(); + res = ExpressMock.ResponseMock(); + }) + + it("should return default available methods", async function() { + await Get(vars)(req, res as any); + Assert(res.json.calledWith(["u2f", "totp"])); + }); + + it("should return duo as an available method", async function() { + vars.config.duo_api = { + hostname: "example.com", + integration_key: "ABCDEFG", + secret_key: "ekjfzelfjz", + } + await Get(vars)(req, res as any); + Assert(res.json.calledWith(["u2f", "totp", "duo_push"])); + }); +}); \ No newline at end of file diff --git a/server/src/lib/routes/secondfactor/available/Get.ts b/server/src/lib/routes/secondfactor/available/Get.ts new file mode 100644 index 000000000..74175ae30 --- /dev/null +++ b/server/src/lib/routes/secondfactor/available/Get.ts @@ -0,0 +1,14 @@ +import * as Express from "express"; +import { ServerVariables } from "../../../ServerVariables"; +import Method2FA from "../../../../../../shared/Method2FA"; + + +export default function(vars: ServerVariables) { + return async function(_: Express.Request, res: Express.Response) { + const availableMethods: Method2FA[] = ["u2f", "totp"]; + if (vars.config.duo_api) { + availableMethods.push("duo_push"); + } + res.json(availableMethods); + }; +} \ 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 04a15b5bf..efb792ec9 100644 --- a/server/src/lib/web_server/RestApi.ts +++ b/server/src/lib/web_server/RestApi.ts @@ -2,6 +2,7 @@ import * as Express from "express"; import SecondFactorPreferencesGet from "../routes/secondfactor/preferences/Get"; import SecondFactorPreferencesPost from "../routes/secondfactor/preferences/Post"; import SecondFactorDuoPushPost from "../routes/secondfactor/duo-push/Post"; +import SecondFactorAvailableGet from "../routes/secondfactor/available/Get"; import FirstFactorPost = require("../routes/firstfactor/post"); import LogoutPost from "../routes/logout/post"; @@ -109,6 +110,10 @@ export class RestApi { SecondFactorDuoPushPost(vars)); } + app.get(Endpoints.SECOND_FACTOR_AVAILABLE_GET, + RequireValidatedFirstFactor.middleware(vars.logger), + SecondFactorAvailableGet(vars)); + setupTotp(app, vars); setupU2f(app, vars); setupResetPassword(app, vars); diff --git a/shared/api.ts b/shared/api.ts index 890232771..73af9af00 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -197,6 +197,16 @@ export const SECOND_FACTOR_PREFERENCES_GET = "/api/secondfactor/preferences"; */ export const SECOND_FACTOR_PREFERENCES_POST = "/api/secondfactor/preferences"; +/** + * @api {post} /api/secondfactor/available List the available methods. + * @apiName GetAvailableMethods + * @apiGroup 2FA + * @apiVersion 1.0.0 + * + * @apiDescription Get the available 2FA methods. + */ +export const SECOND_FACTOR_AVAILABLE_GET = "/api/secondfactor/available"; + /** * @api {post} /api/password-reset Set new password diff --git a/test/helpers/assertions/VerifyButtonDoesNotExist.ts b/test/helpers/assertions/VerifyButtonDoesNotExist.ts new file mode 100644 index 000000000..e0a63d71f --- /dev/null +++ b/test/helpers/assertions/VerifyButtonDoesNotExist.ts @@ -0,0 +1,12 @@ +import SeleniumWebDriver, { WebDriver } from "selenium-webdriver"; +import VerifyElementDoesNotExist from "./VerifyElementDoesNotExist"; + +/** + * Verify that an element does not exist. + * + * @param driver The selenium driver + * @param content The content of the button to select. + */ +export default async function(driver: WebDriver, content: string) { + await VerifyElementDoesNotExist(driver, SeleniumWebDriver.By.xpath("//button[text()='" + content + "']")) +} \ No newline at end of file diff --git a/test/helpers/assertions/VerifyButtonExists.ts b/test/helpers/assertions/VerifyButtonExists.ts new file mode 100644 index 000000000..a9f45085c --- /dev/null +++ b/test/helpers/assertions/VerifyButtonExists.ts @@ -0,0 +1,11 @@ +import SeleniumWebDriver, { WebDriver } from "selenium-webdriver"; +import VerifyElementExists from "./VerifyElementExists"; + +/** + * Verify if a button with given content exists in the DOM. + * @param driver The selenium web driver. + * @param content The content of the button to find in the DOM. + */ +export default async function(driver: WebDriver, content: string) { + await VerifyElementExists(driver, SeleniumWebDriver.By.xpath("//button[text()='" + content + "']")); +} \ No newline at end of file diff --git a/test/helpers/assertions/VerifyElementDoesNotExist.ts b/test/helpers/assertions/VerifyElementDoesNotExist.ts new file mode 100644 index 000000000..25c6fa972 --- /dev/null +++ b/test/helpers/assertions/VerifyElementDoesNotExist.ts @@ -0,0 +1,13 @@ +import SeleniumWebDriver, { WebDriver } from "selenium-webdriver"; + +/** + * + * @param driver The selenium web driver + * @param locator The locator of the element to check it does not exist. + */ +export default async function(driver: WebDriver, locator: SeleniumWebDriver.Locator) { + const els = await driver.findElements(locator); + if (els.length > 0) { + throw new Error("Element exists."); + } +} \ No newline at end of file diff --git a/test/helpers/assertions/VerifyElementExists.ts b/test/helpers/assertions/VerifyElementExists.ts new file mode 100644 index 000000000..aaa69d468 --- /dev/null +++ b/test/helpers/assertions/VerifyElementExists.ts @@ -0,0 +1,13 @@ +import SeleniumWebDriver, { WebDriver } from "selenium-webdriver"; + +/** + * + * @param driver The selenium web driver. + * @param locator The locator of the element to find in the DOM. + */ +export default async function(driver: WebDriver, locator: SeleniumWebDriver.Locator) { + const els = await driver.findElements(locator); + if (els.length == 0) { + throw new Error("Element does not exist."); + } +} \ No newline at end of file diff --git a/test/suites/basic/scenarii/NoDuoPushOption.ts b/test/suites/basic/scenarii/NoDuoPushOption.ts new file mode 100644 index 000000000..d5929d5a9 --- /dev/null +++ b/test/suites/basic/scenarii/NoDuoPushOption.ts @@ -0,0 +1,33 @@ +import { StartDriver, StopDriver } from "../../../helpers/context/WithDriver"; +import LoginAs from "../../../helpers/LoginAs"; +import VerifyIsSecondFactorStage from "../../../helpers/assertions/VerifyIsSecondFactorStage"; +import ClickOnLink from "../../../helpers/ClickOnLink"; +import VerifyIsUseAnotherMethodView from "../../../helpers/assertions/VerifyIsUseAnotherMethodView"; +import VerifyElementDoesNotExist from "../../../helpers/assertions/VerifyElementDoesNotExist"; +import SeleniumWebDriver from "selenium-webdriver"; +import VerifyButtonDoesNotExist from "../../../helpers/assertions/VerifyButtonDoesNotExist"; +import VerifyButtonExists from "../../../helpers/assertions/VerifyButtonExists"; + + + +export default function() { + before(async function() { + this.driver = await StartDriver(); + }); + + after(async function() { + await StopDriver(this.driver); + }); + + // The Duo API is not configured so we should not see the method. + it("should not display duo push notification 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 VerifyButtonExists(this.driver, "Security Key (U2F)"); + await VerifyButtonExists(this.driver, "One-Time Password"); + await VerifyButtonDoesNotExist(this.driver, "Duo Push Notification"); + }); +} \ No newline at end of file diff --git a/test/suites/basic/test.ts b/test/suites/basic/test.ts index 006dbf4ac..12ecce4b9 100644 --- a/test/suites/basic/test.ts +++ b/test/suites/basic/test.ts @@ -11,6 +11,7 @@ import { exec } from '../../helpers/utils/exec'; import TwoFactorAuthentication from "../../helpers/scenarii/TwoFactorAuthentication"; import BypassPolicy from "./scenarii/BypassPolicy"; import Prefered2faMethod from "./scenarii/Prefered2faMethod"; +import NoDuoPushOption from "./scenarii/NoDuoPushOption"; AutheliaSuite(__dirname, function() { this.timeout(10000); @@ -30,4 +31,5 @@ AutheliaSuite(__dirname, function() { describe('Required two factor', RequiredTwoFactor); describe('Logout endpoint redirect to already logged in page', LogoutRedirectToAlreadyLoggedIn); describe('Prefered 2FA method', Prefered2faMethod); + describe('No Duo Push method available', NoDuoPushOption); }); \ No newline at end of file