Display only one 2FA option.

Displaying only one option at 2FA stage will allow to add more options
like DUO push or OAuth.

The user can switch to other option and in this case the option is
remembered so that next time, the user will see the same option. The
latest option is considered as the prefered option by Authelia.
This commit is contained in:
Clement Michaud 2019-03-23 15:44:46 +01:00
parent 92eb897a03
commit d9e487c99f
52 changed files with 1077 additions and 467 deletions

View File

@ -26,6 +26,7 @@
padding-bottom: ($theme-spacing) * 2; padding-bottom: ($theme-spacing) * 2;
padding-left: ($theme-spacing) * 2; padding-left: ($theme-spacing) * 2;
padding-right: ($theme-spacing) * 2; padding-right: ($theme-spacing) * 2;
margin: (($theme-spacing) * 2) 0px;
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
border-radius: 2px; border-radius: 2px;
} }
@ -36,40 +37,16 @@
margin-bottom: ($theme-spacing); margin-bottom: ($theme-spacing);
} }
.methodU2f { .anotherMethodLink {
border-bottom: 1px solid #e0e0e0;
padding: ($theme-spacing);
}
.methodTotp {
padding: ($theme-spacing);
padding-top: ($theme-spacing) * 2;
}
.image {
width: '120px';
}
.imageContainer {
text-align: center; text-align: center;
margin-top: ($theme-spacing) * 2; font-size: (0.8em)
margin-bottom: ($theme-spacing) * 2;
} }
.registerDeviceContainer { .buttonsContainer {
text-align: right; text-align: center;
font-size: 0.7em; margin: ($theme-spacing) 0;
}
.totpField {
margin-top: ($theme-spacing) * 2;
width: 100%;
}
.totpButton {
margin-top: ($theme-spacing);
button { button {
width: 100%; margin: ($theme-spacing) 0;
} }
} }

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import * as AutheliaService from '../services/AutheliaService';
import { fetchStateFailure, fetchStateSuccess } from "../reducers/Portal/Authentication/actions"; import { fetchStateFailure, fetchStateSuccess } from "../reducers/Portal/Authentication/actions";
import to from "await-to-js"; import to from "await-to-js";
import AutheliaService from "../services/AutheliaService";
export default async function(dispatch: Dispatch) { export default async function(dispatch: Dispatch) {
let err, res; let err, res;

View File

@ -1,8 +1,8 @@
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import { logout, logoutFailure, logoutSuccess } from "../reducers/Portal/SecondFactor/actions"; import { logout, logoutFailure, logoutSuccess } from "../reducers/Portal/SecondFactor/actions";
import to from "await-to-js"; import to from "await-to-js";
import * as AutheliaService from '../services/AutheliaService';
import fetchState from "./FetchStateBehavior"; import fetchState from "./FetchStateBehavior";
import AutheliaService from "../services/AutheliaService";
export default async function(dispatch: Dispatch) { export default async function(dispatch: Dispatch) {
await dispatch(logout()); await dispatch(logout());

View File

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

View File

@ -1,12 +1,10 @@
import React, { Component, KeyboardEvent, FormEvent } from 'react'; import React, { Component } from 'react';
import classnames from 'classnames';
import TextField, { Input } from '@material/react-text-field';
import Button from '@material/react-button';
import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss'; import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss';
import CircleLoader, { Status } from '../../components/CircleLoader/CircleLoader'; import Method2FA from '../../types/Method2FA';
import Notification from '../Notification/Notification'; 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 { export interface OwnProps {
username: string; username: string;
@ -14,130 +12,62 @@ export interface OwnProps {
} }
export interface StateProps { export interface StateProps {
securityKeySupported: boolean; method: Method2FA | null;
securityKeyVerified: boolean; useAnotherMethod: boolean;
securityKeyError: string | null;
oneTimePasswordVerificationInProgress: boolean,
oneTimePasswordVerificationError: string | null;
} }
export interface DispatchProps { export interface DispatchProps {
onInit: () => void; onInit: () => void;
onLogoutClicked: () => void; onLogoutClicked: () => void;
onRegisterSecurityKeyClicked: () => void; onOneTimePasswordMethodClicked: () => void;
onRegisterOneTimePasswordClicked: () => void; onSecurityKeyMethodClicked: () => void;
onUseAnotherMethodClicked: () => void;
onOneTimePasswordValidationRequested: (token: string) => void;
} }
export type Props = OwnProps & StateProps & DispatchProps; export type Props = OwnProps & StateProps & DispatchProps;
interface State { class SecondFactorForm extends Component<Props> {
oneTimePassword: string; componentDidMount() {
}
class SecondFactorView extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
oneTimePassword: '',
}
}
componentWillMount() {
this.props.onInit(); this.props.onInit();
} }
private renderU2f(n: number) { private renderMethod() {
let u2fStatus = Status.LOADING; let method: Method2FA = (this.props.method) ? this.props.method : 'totp'
if (this.props.securityKeyVerified) { let methodComponent, title: string;
u2fStatus = Status.SUCCESSFUL; if (method == 'u2f') {
} else if (this.props.securityKeyError) { title = "Security Key";
u2fStatus = Status.FAILURE; methodComponent = (<SecondFactorU2F redirectionUrl={this.props.redirectionUrl}></SecondFactorU2F>);
} else {
title = "One-Time Password"
methodComponent = (<SecondFactorTOTP redirectionUrl={this.props.redirectionUrl}></SecondFactorTOTP>);
} }
return ( return (
<div className={styles.methodU2f} key='u2f-method'> <div className={classnames('second-factor-step')} key={method + '-method'}>
<div className={styles.methodName}>Option {n} - Security Key</div> <div className={styles.methodName}>{title}</div>
<div>Insert your security key into a USB port and touch the gold disk.</div> {methodComponent}
<div className={styles.imageContainer}> </div>
<CircleLoader status={u2fStatus}></CircleLoader> );
</div> }
<div className={styles.registerDeviceContainer}>
<a className={classnames(styles.registerDevice, 'register-u2f')} href="#" private renderUseAnotherMethod() {
onClick={this.props.onRegisterSecurityKeyClicked}> return (
Register device <div className={classnames('use-another-method-view')}>
</a> <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>
</div> </div>
</div> </div>
) );
} }
private onOneTimePasswordChanged = (e: FormEvent<HTMLElement>) => { private renderUseAnotherMethodLink() {
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) {
return ( return (
<div className={classnames(styles.methodTotp, 'second-factor-step')} key='totp-method'> <div className={styles.anotherMethodLink}>
<div className={styles.methodName}>Option {n} - One-Time Password</div> <a href="#" onClick={this.props.onUseAnotherMethodClicked}>
<Notification show={this.props.oneTimePasswordVerificationError !== null}> Use another method
{this.props.oneTimePasswordVerificationError} </a>
</Notification>
<TextField
className={styles.totpField}
label="One-Time Password"
outlined={true}>
<Input
name='totp-token'
id='totp-token'
onChange={this.onOneTimePasswordChanged as any}
onKeyPress={this.onTotpKeyPressed}
value={this.state.oneTimePassword} />
</TextField>
<div className={styles.registerDeviceContainer}>
<a className={classnames(styles.registerDevice, 'register-totp')} href="#"
onClick={this.props.onRegisterOneTimePasswordClicked}>
Register device
</a>
</div>
<div className={styles.totpButton}>
<Button
color="primary"
raised={true}
id='totp-button'
onClick={this.onOneTimePasswordValidationRequested}
disabled={this.props.oneTimePasswordVerificationInProgress}>
OK
</Button>
</div>
</div>
)
}
private renderMode() {
const methods = [];
let n = 1;
if (this.props.securityKeySupported) {
methods.push(this.renderU2f(n));
n++;
}
methods.push(this.renderTotp(n));
return (
<div className={styles.methodsContainer}>
{methods}
</div> </div>
); );
} }
@ -152,11 +82,12 @@ class SecondFactorView extends Component<Props, State> {
</div> </div>
</div> </div>
<div className={styles.body}> <div className={styles.body}>
{this.renderMode()} {(this.props.useAnotherMethod) ? this.renderUseAnotherMethod() : this.renderMethod()}
</div> </div>
{(this.props.useAnotherMethod) ? null : this.renderUseAnotherMethodLink()}
</div> </div>
) )
} }
} }
export default SecondFactorView; export default SecondFactorForm;

View File

@ -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<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
oneTimePassword: '',
}
}
private onOneTimePasswordChanged = (e: FormEvent<HTMLInputElement>) => {
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 (
<div className={classnames('one-time-password-view')}>
<Notification show={this.props.oneTimePasswordVerificationError !== null}>
{this.props.oneTimePasswordVerificationError}
</Notification>
<TextField
className={styles.totpField}
label="One-Time Password"
outlined={true}>
<Input
name='totp-token'
id='totp-token'
onChange={this.onOneTimePasswordChanged as any}
onKeyPress={this.onTotpKeyPressed}
value={this.state.oneTimePassword} />
</TextField>
<div className={styles.registerDeviceContainer}>
<a className={classnames(styles.registerDevice, 'register-totp')} href="#"
onClick={this.props.onRegisterOneTimePasswordClicked}>
Register new device
</a>
</div>
<div className={styles.totpButton}>
<Button
color="primary"
raised={true}
id='totp-button'
onClick={this.onOneTimePasswordValidationRequested}
disabled={this.props.oneTimePasswordVerificationInProgress}>
OK
</Button>
</div>
</div>
)
}
}

View File

@ -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<Props, State> {
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 (
<div className={classnames('security-key-view')}>
<div>Insert your security key into a USB port and touch the gold disk.</div>
<div className={styles.imageContainer}>
<CircleLoader status={u2fStatus}></CircleLoader>
</div>
<div className={styles.registerDeviceContainer}>
<a className={classnames(styles.registerDevice, 'register-u2f')} href="#"
onClick={this.props.onRegisterSecurityKeyClicked}>
Register new device
</a>
</div>
</div>
)
}
}

View File

@ -3,9 +3,9 @@ import { Dispatch } from 'redux';
import { authenticateFailure, authenticateSuccess, authenticate } from '../../../reducers/Portal/FirstFactor/actions'; import { authenticateFailure, authenticateSuccess, authenticate } from '../../../reducers/Portal/FirstFactor/actions';
import FirstFactorForm, { StateProps, OwnProps } from '../../../components/FirstFactorForm/FirstFactorForm'; import FirstFactorForm, { StateProps, OwnProps } from '../../../components/FirstFactorForm/FirstFactorForm';
import { RootState } from '../../../reducers'; import { RootState } from '../../../reducers';
import * as AutheliaService from '../../../services/AutheliaService';
import to from 'await-to-js'; import to from 'await-to-js';
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior'; import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
import AutheliaService from '../../../services/AutheliaService';
const mapStateToProps = (state: RootState): StateProps => { const mapStateToProps = (state: RootState): StateProps => {
return { return {

View File

@ -1,138 +1,37 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { RootState } from '../../../reducers';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import u2fApi from 'u2f-api'; import SecondFactorForm from '../../../components/SecondFactorForm/SecondFactorForm';
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 LogoutBehavior from '../../../behaviors/LogoutBehavior'; 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 => ({ 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) => {
return { return {
onLogoutClicked: () => LogoutBehavior(dispatch), method: state.secondFactor.preferedMethod,
onRegisterSecurityKeyClicked: async () => { useAnotherMethod: state.secondFactor.userAnotherMethod,
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';
}
try { async function storeMethod(dispatch: Dispatch, method: Method2FA) {
await redirectIfPossible(dispatch, res); // display the new option
dispatch(oneTimePasswordVerificationSuccess()); dispatch(getPreferedMethodSuccess(method));
await handleSuccess(dispatch); dispatch(setUseAnotherMethod(false));
} catch (err) {
dispatch(oneTimePasswordVerificationFailure(err.message)); // 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)),
} }
} }

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import QueryString from 'query-string'; 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 { RootState } from '../../../reducers';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import AuthenticationLevel from '../../../types/AuthenticationLevel'; import AuthenticationLevel from '../../../types/AuthenticationLevel';

View File

@ -2,9 +2,9 @@ import { connect } from 'react-redux';
import { RootState } from '../../../reducers'; import { RootState } from '../../../reducers';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { push } from 'connected-react-router'; import { push } from 'connected-react-router';
import * as AutheliaService from '../../../services/AutheliaService';
import ForgotPasswordView from '../../../views/ForgotPasswordView/ForgotPasswordView'; import ForgotPasswordView from '../../../views/ForgotPasswordView/ForgotPasswordView';
import { forgotPasswordRequest, forgotPasswordSuccess, forgotPasswordFailure } from '../../../reducers/Portal/ForgotPassword/actions'; import { forgotPasswordRequest, forgotPasswordSuccess, forgotPasswordFailure } from '../../../reducers/Portal/ForgotPassword/actions';
import AutheliaService from '../../../services/AutheliaService';
const mapStateToProps = (state: RootState) => ({ const mapStateToProps = (state: RootState) => ({
disabled: state.forgotPassword.loading, disabled: state.forgotPassword.loading,

View File

@ -2,8 +2,8 @@ import { connect } from 'react-redux';
import { RootState } from '../../../reducers'; import { RootState } from '../../../reducers';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { push } from 'connected-react-router'; import { push } from 'connected-react-router';
import * as AutheliaService from '../../../services/AutheliaService';
import ResetPasswordView, { StateProps } from '../../../views/ResetPasswordView/ResetPasswordView'; import ResetPasswordView, { StateProps } from '../../../views/ResetPasswordView/ResetPasswordView';
import AutheliaService from '../../../services/AutheliaService';
const mapStateToProps = (state: RootState): StateProps => ({ const mapStateToProps = (state: RootState): StateProps => ({
disabled: state.resetPassword.loading, disabled: state.resetPassword.loading,

View File

@ -9,11 +9,37 @@ import {
SET_SECURITY_KEY_SUPPORTED, SET_SECURITY_KEY_SUPPORTED,
ONE_TIME_PASSWORD_VERIFICATION_REQUEST, ONE_TIME_PASSWORD_VERIFICATION_REQUEST,
ONE_TIME_PASSWORD_VERIFICATION_SUCCESS, 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"; } from "../../constants";
import Method2FA from "../../../types/Method2FA";
export const setSecurityKeySupported = createAction(SET_SECURITY_KEY_SUPPORTED, resolve => { export const setSecurityKeySupported = createAction(SET_SECURITY_KEY_SUPPORTED, resolve => {
return (supported: boolean) => resolve(supported); 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); export const securityKeySign = createAction(SECURITY_KEY_SIGN);

View File

@ -1,6 +1,7 @@
import * as Actions from './actions'; import * as Actions from './actions';
import { ActionType, getType, StateType } from 'typesafe-actions'; import { ActionType, getType, StateType } from 'typesafe-actions';
import Method2FA from '../../../types/Method2FA';
export type SecondFactorAction = ActionType<typeof Actions>; export type SecondFactorAction = ActionType<typeof Actions>;
@ -9,6 +10,16 @@ interface SecondFactorState {
logoutSuccess: boolean | null; logoutSuccess: boolean | null;
error: string | null; error: string | null;
userAnotherMethod: boolean;
preferedMethodLoading: boolean;
preferedMethodError: string | null;
preferedMethod: Method2FA | null;
setPreferedMethodLoading: boolean;
setPreferedMethodError: string | null;
setPreferedMethodSuccess: boolean | null;
securityKeySupported: boolean; securityKeySupported: boolean;
securityKeySignLoading: boolean; securityKeySignLoading: boolean;
securityKeySignSuccess: boolean | null; securityKeySignSuccess: boolean | null;
@ -23,6 +34,16 @@ const secondFactorInitialState: SecondFactorState = {
logoutSuccess: null, logoutSuccess: null,
error: null, error: null,
userAnotherMethod: false,
preferedMethod: null,
preferedMethodError: null,
preferedMethodLoading: false,
setPreferedMethodLoading: false,
setPreferedMethodError: null,
setPreferedMethodSuccess: null,
securityKeySupported: false, securityKeySupported: false,
securityKeySignLoading: false, securityKeySignLoading: false,
securityKeySignSuccess: null, securityKeySignSuccess: null,
@ -99,6 +120,49 @@ export default (state = secondFactorInitialState, action: SecondFactorAction): S
oneTimePasswordVerificationLoading: false, oneTimePasswordVerificationLoading: false,
oneTimePasswordVerificationError: action.payload, 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; return state;
} }

View File

@ -10,6 +10,15 @@ export const AUTHENTICATE_FAILURE = '@portal/authenticate_failure';
// SECOND FACTOR PAGE // SECOND FACTOR PAGE
export const SET_SECURITY_KEY_SUPPORTED = '@portal/second_factor/set_security_key_supported'; 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 = '@portal/second_factor/security_key_sign';
export const SECURITY_KEY_SIGN_SUCCESS = '@portal/second_factor/security_key_sign_success'; export const SECURITY_KEY_SIGN_SUCCESS = '@portal/second_factor/security_key_sign_success';

View File

@ -1,138 +1,161 @@
import RemoteState from "../views/AuthenticationView/RemoteState"; import RemoteState from "../views/AuthenticationView/RemoteState";
import u2fApi, { SignRequest } from "u2f-api"; import u2fApi, { SignRequest } from "u2f-api";
import Method2FA from "../types/Method2FA";
async function fetchSafe(url: string, options?: RequestInit) { class AutheliaService {
return fetch(url, options) static async fetchSafe(url: string, options?: RequestInit): Promise<Response> {
.then(async (res) => { const res = await fetch(url, options);
if (res.status !== 200 && res.status !== 204) { if (res.status !== 200 && res.status !== 204) {
throw new Error('Status code ' + res.status); throw new Error('Status code ' + res.status);
} }
return res; 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<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
} }
if (redirectionUrl) { static async fetchSafeJson(url: string, options?: RequestInit): Promise<any> {
headers['X-Target-Url'] = redirectionUrl; 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', * Fetch current authentication state.
headers: headers, */
body: JSON.stringify({ static async fetchState(): Promise<RemoteState> {
username: username, return await this.fetchSafeJson('/api/state')
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<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
} }
if (redirectionUrl) {
headers['X-Target-Url'] = redirectionUrl;
}
return fetchSafe('/api/u2f/sign', {
method: 'POST',
headers: headers,
body: JSON.stringify(response),
});
}
export async function verifyTotpToken( static async postFirstFactorAuth(username: string, password: string,
token: string, redirectionUrl: string | null) { rememberMe: boolean, redirectionUrl: string | null) {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'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', 'Accept': 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, }
body: JSON.stringify({username})
});
}
export async function completePasswordResetIdentityValidation(token: string) { if (redirectionUrl) {
return fetch(`/api/password-reset/identity/finish?token=${token}`, { headers['X-Target-Url'] = redirectionUrl;
method: 'POST', }
});
}
export async function resetPassword(newPassword: string) { return this.fetchSafe('/api/firstfactor', {
return fetchSafe('/api/password-reset', { method: 'POST',
method: 'POST', headers: 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<string, string> = {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, }
body: JSON.stringify({password: newPassword}) 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<string, string> = {
'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<Method2FA> {
const doc = await this.fetchSafeJson('/api/secondfactor/preferences');
return doc.method;
}
static async setPrefered2faMethod(method: Method2FA): Promise<void> {
await this.fetchSafe('/api/secondfactor/preferences', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({method})
});
}
}
export default AutheliaService;

View File

@ -0,0 +1,4 @@
type Method2FA = "u2f" | "totp";
export default Method2FA;

View File

@ -4,6 +4,7 @@ interface RemoteState {
username: string; username: string;
authentication_level: AuthenticationLevel; authentication_level: AuthenticationLevel;
default_redirection_url: string; default_redirection_url: string;
method: 'u2f' | 'totp'
} }
export default RemoteState; export default RemoteState;

View File

@ -0,0 +1,13 @@
<!DOCTYPE>
<html>
<head>
<title>Public resource</title>
<link rel="icon" href="/icon.png" type="image/png" />
</head>
<body>
<h1>Public resource</h1>
<p>This is a public resource.<br/>
Go back to <a href="https://home.example.com:8080/">home page</a>.
</p>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import BluebirdPromise = require("bluebird");
import Sinon = require("sinon"); import Sinon = require("sinon");
import { ICollection } from "./ICollection"; import { ICollection } from "./ICollection";
import { ICollectionFactory } from "./ICollectionFactory"; import { ICollectionFactory } from "./ICollectionFactory";

View File

@ -5,6 +5,7 @@ import { U2FRegistration } from "../../../types/U2FRegistration";
import { TOTPSecret } from "../../../types/TOTPSecret"; import { TOTPSecret } from "../../../types/TOTPSecret";
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument"; import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
import { IdentityValidationDocument } from "./IdentityValidationDocument"; import { IdentityValidationDocument } from "./IdentityValidationDocument";
import Method2FA from "../../../../shared/Method2FA";
export interface IUserDataStore { export interface IUserDataStore {
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void>; saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void>;
@ -18,4 +19,7 @@ export interface IUserDataStore {
saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void>; saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void>;
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument>; retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument>;
savePrefered2FAMethod(userId: string, method: Method2FA): BluebirdPromise<void>;
retrievePrefered2FAMethod(userId: string): BluebirdPromise<Method2FA>;
} }

View File

@ -46,11 +46,12 @@ describe("storage/UserDataStore", function () {
it("should correctly creates collections", function () { it("should correctly creates collections", function () {
new UserDataStore(factory); 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("authentication_traces"));
Assert(factory.buildStub.calledWith("identity_validation_tokens")); Assert(factory.buildStub.calledWith("identity_validation_tokens"));
Assert(factory.buildStub.calledWith("u2f_registrations")); Assert(factory.buildStub.calledWith("u2f_registrations"));
Assert(factory.buildStub.calledWith("totp_secrets")); Assert(factory.buildStub.calledWith("totp_secrets"));
Assert(factory.buildStub.calledWith("prefered_2fa_method"));
}); });
describe("TOTP secrets collection", function () { 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}));
});
});
}); });

View File

@ -1,5 +1,4 @@
import * as BluebirdPromise from "bluebird"; import * as BluebirdPromise from "bluebird";
import * as path from "path";
import { IUserDataStore } from "./IUserDataStore"; import { IUserDataStore } from "./IUserDataStore";
import { ICollection } from "./ICollection"; import { ICollection } from "./ICollection";
import { ICollectionFactory } from "./ICollectionFactory"; import { ICollectionFactory } from "./ICollectionFactory";
@ -9,6 +8,7 @@ import { U2FRegistration } from "../../../types/U2FRegistration";
import { TOTPSecret } from "../../../types/TOTPSecret"; import { TOTPSecret } from "../../../types/TOTPSecret";
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument"; import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
import { IdentityValidationDocument } from "./IdentityValidationDocument"; import { IdentityValidationDocument } from "./IdentityValidationDocument";
import Method2FA from "../../../../shared/Method2FA";
// Constants // Constants
@ -17,6 +17,7 @@ const AUTHENTICATION_TRACES_COLLECTION_NAME = "authentication_traces";
const U2F_REGISTRATIONS_COLLECTION_NAME = "u2f_registrations"; const U2F_REGISTRATIONS_COLLECTION_NAME = "u2f_registrations";
const TOTP_SECRETS_COLLECTION_NAME = "totp_secrets"; const TOTP_SECRETS_COLLECTION_NAME = "totp_secrets";
const PREFERED_2FA_METHOD_COLLECTION_NAME = "prefered_2fa_method";
export interface U2FRegistrationKey { export interface U2FRegistrationKey {
@ -31,6 +32,7 @@ export class UserDataStore implements IUserDataStore {
private identityCheckTokensCollection: ICollection; private identityCheckTokensCollection: ICollection;
private authenticationTracesCollection: ICollection; private authenticationTracesCollection: ICollection;
private totpSecretCollection: ICollection; private totpSecretCollection: ICollection;
private prefered2faMethodCollection: ICollection;
private collectionFactory: ICollectionFactory; private collectionFactory: ICollectionFactory;
@ -41,35 +43,24 @@ export class UserDataStore implements IUserDataStore {
this.identityCheckTokensCollection = this.collectionFactory.build(IDENTITY_VALIDATION_TOKENS_COLLECTION_NAME); this.identityCheckTokensCollection = this.collectionFactory.build(IDENTITY_VALIDATION_TOKENS_COLLECTION_NAME);
this.authenticationTracesCollection = this.collectionFactory.build(AUTHENTICATION_TRACES_COLLECTION_NAME); this.authenticationTracesCollection = this.collectionFactory.build(AUTHENTICATION_TRACES_COLLECTION_NAME);
this.totpSecretCollection = this.collectionFactory.build(TOTP_SECRETS_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<void> { saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void> {
const newDocument: U2FRegistrationDocument = { const newDocument: U2FRegistrationDocument = {userId, appId, registration};
userId: userId, const filter: U2FRegistrationKey = {userId, appId};
appId: appId,
registration: registration
};
const filter: U2FRegistrationKey = {
userId: userId,
appId: appId
};
return this.u2fSecretCollection.update(filter, newDocument, { upsert: true }); return this.u2fSecretCollection.update(filter, newDocument, { upsert: true });
} }
retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise<U2FRegistrationDocument> { retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise<U2FRegistrationDocument> {
const filter: U2FRegistrationKey = { const filter: U2FRegistrationKey = { userId, appId };
userId: userId,
appId: appId
};
return this.u2fSecretCollection.findOne(filter); return this.u2fSecretCollection.findOne(filter);
} }
saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void> { saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void> {
const newDocument: AuthenticationTraceDocument = { const newDocument: AuthenticationTraceDocument = {
userId: userId, userId, date: new Date(),
date: new Date(),
isAuthenticationSuccessful: isAuthenticationSuccessful, isAuthenticationSuccessful: isAuthenticationSuccessful,
}; };
@ -77,18 +68,12 @@ export class UserDataStore implements IUserDataStore {
} }
retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]> { retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]> {
const q = { return this.authenticationTracesCollection.find({userId}, { date: -1 }, count);
userId: userId
};
return this.authenticationTracesCollection.find(q, { date: -1 }, count);
} }
produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any> { produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any> {
const newDocument: IdentityValidationDocument = { const newDocument: IdentityValidationDocument = {
userId: userId, userId, token, challenge,
token: token,
challenge: challenge,
maxDate: new Date(new Date().getTime() + maxAge) maxDate: new Date(new Date().getTime() + maxAge)
}; };
@ -97,10 +82,7 @@ export class UserDataStore implements IUserDataStore {
consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise<IdentityValidationDocument> { consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise<IdentityValidationDocument> {
const that = this; const that = this;
const filter = { const filter = {token, challenge};
token: token,
challenge: challenge
};
let identityValidationDocument: IdentityValidationDocument; let identityValidationDocument: IdentityValidationDocument;
@ -123,21 +105,23 @@ export class UserDataStore implements IUserDataStore {
} }
saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void> { saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void> {
const doc = { const doc = {userId, secret};
userId: userId, return this.totpSecretCollection.update({userId}, doc, { upsert: true });
secret: secret
};
const filter = {
userId: userId
};
return this.totpSecretCollection.update(filter, doc, { upsert: true });
} }
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> { retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> {
const filter = { return this.totpSecretCollection.findOne({userId});
userId: userId }
};
return this.totpSecretCollection.findOne(filter); savePrefered2FAMethod(userId: string, method: Method2FA): BluebirdPromise<void> {
const newDoc = {userId, method};
return this.prefered2faMethodCollection.update({userId}, newDoc, {upsert: true});
}
retrievePrefered2FAMethod(userId: string): BluebirdPromise<Method2FA | undefined> {
return this.prefered2faMethodCollection.findOne({userId})
.then((doc) => {
return (doc && doc.method) ? doc.method : undefined;
});
} }
} }

View File

@ -1,5 +1,5 @@
import Sinon = require("sinon"); import * as Sinon from "sinon";
import BluebirdPromise = require("bluebird"); import * as BluebirdPromise from "bluebird";
import { TOTPSecretDocument } from "./TOTPSecretDocument"; import { TOTPSecretDocument } from "./TOTPSecretDocument";
import { U2FRegistrationDocument } from "./U2FRegistrationDocument"; import { U2FRegistrationDocument } from "./U2FRegistrationDocument";
@ -8,6 +8,7 @@ import { TOTPSecret } from "../../../types/TOTPSecret";
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument"; import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
import { IdentityValidationDocument } from "./IdentityValidationDocument"; import { IdentityValidationDocument } from "./IdentityValidationDocument";
import { IUserDataStore } from "./IUserDataStore"; import { IUserDataStore } from "./IUserDataStore";
import Method2FA from "../../../../shared/Method2FA";
export class UserDataStoreStub implements IUserDataStore { export class UserDataStoreStub implements IUserDataStore {
saveU2FRegistrationStub: Sinon.SinonStub; saveU2FRegistrationStub: Sinon.SinonStub;
@ -18,6 +19,8 @@ export class UserDataStoreStub implements IUserDataStore {
consumeIdentityValidationTokenStub: Sinon.SinonStub; consumeIdentityValidationTokenStub: Sinon.SinonStub;
saveTOTPSecretStub: Sinon.SinonStub; saveTOTPSecretStub: Sinon.SinonStub;
retrieveTOTPSecretStub: Sinon.SinonStub; retrieveTOTPSecretStub: Sinon.SinonStub;
savePrefered2FAMethodStub: Sinon.SinonStub;
retrievePrefered2FAMethodStub: Sinon.SinonStub;
constructor() { constructor() {
this.saveU2FRegistrationStub = Sinon.stub(); this.saveU2FRegistrationStub = Sinon.stub();
@ -28,6 +31,8 @@ export class UserDataStoreStub implements IUserDataStore {
this.consumeIdentityValidationTokenStub = Sinon.stub(); this.consumeIdentityValidationTokenStub = Sinon.stub();
this.saveTOTPSecretStub = Sinon.stub(); this.saveTOTPSecretStub = Sinon.stub();
this.retrieveTOTPSecretStub = Sinon.stub(); this.retrieveTOTPSecretStub = Sinon.stub();
this.savePrefered2FAMethodStub = Sinon.stub();
this.retrievePrefered2FAMethodStub = Sinon.stub();
} }
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void> { saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void> {
@ -61,4 +66,12 @@ export class UserDataStoreStub implements IUserDataStore {
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> { retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> {
return this.retrieveTOTPSecretStub(userId); return this.retrieveTOTPSecretStub(userId);
} }
savePrefered2FAMethod(userId: string, method: Method2FA): BluebirdPromise<void> {
return this.savePrefered2FAMethodStub(userId, method);
}
retrievePrefered2FAMethod(userId: string): BluebirdPromise<Method2FA> {
return this.retrievePrefered2FAMethodStub(userId);
}
} }

View File

@ -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 FirstFactorPost = require("../routes/firstfactor/post");
import LogoutPost from "../routes/logout/post"; import LogoutPost from "../routes/logout/post";
@ -92,6 +94,14 @@ export class RestApi {
app.get(Endpoints.VERIFY_GET, VerifyGet.default(vars)); app.get(Endpoints.VERIFY_GET, VerifyGet.default(vars));
app.post(Endpoints.FIRST_FACTOR_POST, FirstFactorPost.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); setupTotp(app, vars);
setupU2f(app, vars); setupU2f(app, vars);
setupResetPassword(app, vars); setupResetPassword(app, vars);

3
shared/Method2FA.ts Normal file
View File

@ -0,0 +1,3 @@
import Method2FA from "../client/src/types/Method2FA";
export default Method2FA;

View File

@ -1,4 +0,0 @@
export interface RedirectionMessage {
redirect: string;
}

View File

@ -155,12 +155,32 @@ export const SECOND_FACTOR_TOTP_IDENTITY_START_POST = "/api/secondfactor/totp/id
* @apiUse UserSession * @apiUse UserSession
* @apiUse IdentityValidationFinish * @apiUse IdentityValidationFinish
* *
*
* @apiDescription Serves the TOTP registration page that displays the secret. * @apiDescription Serves the TOTP registration page that displays the secret.
* The secret is a QRCode and a base32 secret. * The secret is a QRCode and a base32 secret.
*/ */
export const SECOND_FACTOR_TOTP_IDENTITY_FINISH_POST = "/api/secondfactor/totp/identity/finish"; 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";
/** /**

View File

@ -4,6 +4,6 @@ import VisitPageAndWaitUrlIs from "./behaviors/VisitPageAndWaitUrlIs";
export default async function(driver: WebDriver, user: string, password: string, targetUrl?: string, timeout: number = 5000) { export default async function(driver: WebDriver, user: string, password: string, targetUrl?: string, timeout: number = 5000) {
const urlExt = (targetUrl) ? ('rd=' + targetUrl) : ''; 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); await FillLoginPageAndClick(driver, user, password, false, timeout);
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ export default async function(
targetUrl: string, targetUrl: string,
timeout: number = 5000) { timeout: number = 5000) {
await VisitPageAndWaitUrlIs(driver, `https://login.example.com:8080/#/?rd=${targetUrl}`, timeout); await VisitPageAndWaitUrlIs(driver, `https://login.example.com:8080/#/?rd=${targetUrl}`, timeout);
await FillLoginPageAndClick(driver, username, password, false, timeout); await FillLoginPageAndClick(driver, username, password, false, timeout);
await VerifyUrlIs(driver, targetUrl, timeout); await VerifyUrlIs(driver, targetUrl, timeout);
}; };

View File

@ -6,9 +6,9 @@ import AutheliaServerFromDist from './AutheliaServerFromDist';
class AutheliaServer implements AutheliaServerInterface { class AutheliaServer implements AutheliaServerInterface {
private runnerImpl: AutheliaServerInterface; private runnerImpl: AutheliaServerInterface;
constructor(configPath: string) { constructor(configPath: string, watchPaths: string[] = []) {
if (fs.existsSync('.suite')) { if (fs.existsSync('.suite')) {
this.runnerImpl = new AutheliaServerWithHotReload(configPath); this.runnerImpl = new AutheliaServerWithHotReload(configPath, watchPaths);
} else { } else {
this.runnerImpl = new AutheliaServerFromDist(configPath, true); this.runnerImpl = new AutheliaServerFromDist(configPath, true);
} }

View File

@ -15,10 +15,10 @@ class AutheliaServerWithHotReload implements AutheliaServerInterface {
private filesChangedBuffer: string[] = []; private filesChangedBuffer: string[] = [];
private changeInProgress: boolean = false; private changeInProgress: boolean = false;
constructor(configPath: string) { constructor(configPath: string, watchedPaths: string[]) {
this.configPath = configPath; this.configPath = configPath;
this.watcher = Chokidar.watch(['server', 'shared/**/*.ts', 'node_modules', this.watcher = Chokidar.watch(['server', 'shared/**/*.ts', 'node_modules',
this.AUTHELIA_INTERRUPT_FILENAME, configPath], { this.AUTHELIA_INTERRUPT_FILENAME, configPath].concat(watchedPaths), {
persistent: true, persistent: true,
ignoreInitial: true, ignoreInitial: true,
}); });

View File

@ -15,7 +15,7 @@ users:
harry: harry:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
emails: harry.potter@authelia.com email: harry.potter@authelia.com
groups: [] groups: []
bob: bob:

View File

@ -45,6 +45,9 @@ access_control:
- domain: public.example.com - domain: public.example.com
policy: bypass policy: bypass
- domain: secure.example.com
policy: two_factor
- domain: '*.example.com' - domain: '*.example.com'
subject: "group:admins" subject: "group:admins"
policy: two_factor policy: two_factor

View File

@ -3,7 +3,7 @@ import { exec } from "../../helpers/utils/exec";
import AutheliaServer from "../../helpers/context/AutheliaServer"; import AutheliaServer from "../../helpers/context/AutheliaServer";
import DockerEnvironment from "../../helpers/context/DockerEnvironment"; 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([ const dockerEnv = new DockerEnvironment([
'docker-compose.yml', 'docker-compose.yml',
'example/compose/nginx/backend/docker-compose.yml', 'example/compose/nginx/backend/docker-compose.yml',

View File

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

View File

@ -10,6 +10,7 @@ import LogoutRedirectToAlreadyLoggedIn from './scenarii/LogoutRedirectToAlreadyL
import { exec } from '../../helpers/utils/exec'; import { exec } from '../../helpers/utils/exec';
import TwoFactorAuthentication from "../../helpers/scenarii/TwoFactorAuthentication"; import TwoFactorAuthentication from "../../helpers/scenarii/TwoFactorAuthentication";
import BypassPolicy from "./scenarii/BypassPolicy"; import BypassPolicy from "./scenarii/BypassPolicy";
import Prefered2faMethod from "./scenarii/Prefered2faMethod";
AutheliaSuite(__dirname, function() { AutheliaSuite(__dirname, function() {
this.timeout(10000); this.timeout(10000);
@ -28,4 +29,5 @@ AutheliaSuite(__dirname, function() {
describe('TOTP Validation', TOTPValidation); describe('TOTP Validation', TOTPValidation);
describe('Required two factor', RequiredTwoFactor); describe('Required two factor', RequiredTwoFactor);
describe('Logout endpoint redirect to already logged in page', LogoutRedirectToAlreadyLoggedIn); describe('Logout endpoint redirect to already logged in page', LogoutRedirectToAlreadyLoggedIn);
describe('Prefered 2FA method', Prefered2faMethod);
}); });

View File

@ -15,7 +15,7 @@ users:
harry: harry:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
emails: harry.potter@authelia.com email: harry.potter@authelia.com
groups: [] groups: []
bob: bob:

View File

@ -15,7 +15,7 @@ users:
harry: harry:
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
emails: harry.potter@authelia.com email: harry.potter@authelia.com
groups: [] groups: []
bob: bob: