Display only available 2FA methods.

For instance Duo Push Notification method is not displayed if the API
is not configured.
This commit is contained in:
Clement Michaud 2019-03-24 22:19:43 +01:00
parent d09a307ff8
commit a717b965c1
19 changed files with 317 additions and 22 deletions

View File

@ -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))
}
}

View File

@ -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<Props> {
);
}
private renderUseAnotherMethod() {
return (
<div className={classnames('use-another-method-view')}>
<div>Choose a method</div>
<div className={styles.buttonsContainer}>
<Button raised onClick={this.props.onOneTimePasswordMethodClicked}>One-Time Password</Button>
<Button raised onClick={this.props.onSecurityKeyMethodClicked}>Security Key (U2F)</Button>
<Button raised onClick={this.props.onDuoPushMethodClicked}>Duo Push Notification</Button>
</div>
</div>
);
}
private renderUseAnotherMethodLink() {
return (
<div className={styles.anotherMethodLink}>
@ -88,7 +72,7 @@ class SecondFactorForm extends Component<Props> {
</div>
</div>
<div className={styles.body}>
{(this.props.useAnotherMethod) ? this.renderUseAnotherMethod() : this.renderMethod()}
{(this.props.useAnotherMethod) ? <UseAnotherMethod/> : this.renderMethod()}
</div>
{(this.props.useAnotherMethod) ? null : this.renderUseAnotherMethodLink()}
</div>

View File

@ -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<Props> {
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 => <Button raised onClick={m.onClicked} key={m.key}>{m.name}</Button>);
return (
<div className={classnames('use-another-method-view')}>
<div>Choose a method</div>
<div className={styles.buttonsContainer}>
{methodsComponents}
</div>
</div>
)
}
}
export default UseAnotherMethod;

View File

@ -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)),
}
}

View File

@ -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);

View File

@ -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 => {

View File

@ -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;
}

View File

@ -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';

View File

@ -171,6 +171,10 @@ class AutheliaService {
body: JSON.stringify({method})
});
}
static async getAvailable2faMethods(): Promise<Method2FA[]> {
return await this.fetchSafeJson('/api/secondfactor/available');
}
}
export default AutheliaService;

View File

@ -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"]));
});
});

View File

@ -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);
};
}

View File

@ -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);

View File

@ -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

View File

@ -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 + "']"))
}

View File

@ -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 + "']"));
}

View File

@ -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.");
}
}

View File

@ -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.");
}
}

View File

@ -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");
});
}

View File

@ -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);
});