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

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 * as AutheliaService from '../services/AutheliaService';
import { fetchStateFailure, fetchStateSuccess } from "../reducers/Portal/Authentication/actions";
import to from "await-to-js";
import AutheliaService from "../services/AutheliaService";
export default async function(dispatch: Dispatch) {
let err, res;

View File

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

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 classnames from 'classnames';
import TextField, { Input } from '@material/react-text-field';
import Button from '@material/react-button';
import React, { Component } from 'react';
import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss';
import CircleLoader, { Status } from '../../components/CircleLoader/CircleLoader';
import Notification from '../Notification/Notification';
import Method2FA from '../../types/Method2FA';
import SecondFactorTOTP from '../../containers/components/SecondFactorTOTP/SecondFactorTOTP';
import SecondFactorU2F from '../../containers/components/SecondFactorU2F/SecondFactorU2F';
import { Button } from '@material/react-button';
import classnames from 'classnames';
export interface OwnProps {
username: string;
@ -14,131 +12,63 @@ export interface OwnProps {
}
export interface StateProps {
securityKeySupported: boolean;
securityKeyVerified: boolean;
securityKeyError: string | null;
oneTimePasswordVerificationInProgress: boolean,
oneTimePasswordVerificationError: string | null;
method: Method2FA | null;
useAnotherMethod: boolean;
}
export interface DispatchProps {
onInit: () => void;
onLogoutClicked: () => void;
onRegisterSecurityKeyClicked: () => void;
onRegisterOneTimePasswordClicked: () => void;
onOneTimePasswordValidationRequested: (token: string) => void;
onOneTimePasswordMethodClicked: () => void;
onSecurityKeyMethodClicked: () => void;
onUseAnotherMethodClicked: () => void;
}
export type Props = OwnProps & StateProps & DispatchProps;
interface State {
oneTimePassword: string;
}
class SecondFactorView extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
oneTimePassword: '',
}
}
componentWillMount() {
class SecondFactorForm extends Component<Props> {
componentDidMount() {
this.props.onInit();
}
private renderU2f(n: number) {
let u2fStatus = Status.LOADING;
if (this.props.securityKeyVerified) {
u2fStatus = Status.SUCCESSFUL;
} else if (this.props.securityKeyError) {
u2fStatus = Status.FAILURE;
private renderMethod() {
let method: Method2FA = (this.props.method) ? this.props.method : 'totp'
let methodComponent, title: string;
if (method == 'u2f') {
title = "Security Key";
methodComponent = (<SecondFactorU2F redirectionUrl={this.props.redirectionUrl}></SecondFactorU2F>);
} else {
title = "One-Time Password"
methodComponent = (<SecondFactorTOTP redirectionUrl={this.props.redirectionUrl}></SecondFactorTOTP>);
}
return (
<div className={styles.methodU2f} key='u2f-method'>
<div className={styles.methodName}>Option {n} - Security Key</div>
<div>Insert your security key into a USB port and touch the gold disk.</div>
<div className={styles.imageContainer}>
<CircleLoader status={u2fStatus}></CircleLoader>
<div className={classnames('second-factor-step')} key={method + '-method'}>
<div className={styles.methodName}>{title}</div>
{methodComponent}
</div>
<div className={styles.registerDeviceContainer}>
<a className={classnames(styles.registerDevice, 'register-u2f')} href="#"
onClick={this.props.onRegisterSecurityKeyClicked}>
Register device
);
}
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>
</div>
</div>
);
}
private renderUseAnotherMethodLink() {
return (
<div className={styles.anotherMethodLink}>
<a href="#" onClick={this.props.onUseAnotherMethodClicked}>
Use another method
</a>
</div>
</div>
)
}
private onOneTimePasswordChanged = (e: FormEvent<HTMLElement>) => {
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 (
<div className={classnames(styles.methodTotp, 'second-factor-step')} key='totp-method'>
<div className={styles.methodName}>Option {n} - One-Time Password</div>
<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 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>
);
}
@ -152,11 +82,12 @@ class SecondFactorView extends Component<Props, State> {
</div>
</div>
<div className={styles.body}>
{this.renderMode()}
{(this.props.useAnotherMethod) ? this.renderUseAnotherMethod() : this.renderMethod()}
</div>
{(this.props.useAnotherMethod) ? null : this.renderUseAnotherMethodLink()}
</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 FirstFactorForm, { StateProps, OwnProps } from '../../../components/FirstFactorForm/FirstFactorForm';
import { RootState } from '../../../reducers';
import * as AutheliaService from '../../../services/AutheliaService';
import to from 'await-to-js';
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
import AutheliaService from '../../../services/AutheliaService';
const mapStateToProps = (state: RootState): StateProps => {
return {

View File

@ -1,138 +1,37 @@
import { connect } from 'react-redux';
import { RootState } from '../../../reducers';
import { Dispatch } from 'redux';
import u2fApi from 'u2f-api';
import to from 'await-to-js';
import {
securityKeySignSuccess,
securityKeySign,
securityKeySignFailure,
setSecurityKeySupported,
oneTimePasswordVerification,
oneTimePasswordVerificationFailure,
oneTimePasswordVerificationSuccess
} from '../../../reducers/Portal/SecondFactor/actions';
import SecondFactorForm, { OwnProps, StateProps } from '../../../components/SecondFactorForm/SecondFactorForm';
import * as AutheliaService from '../../../services/AutheliaService';
import { push } from 'connected-react-router';
import fetchState from '../../../behaviors/FetchStateBehavior';
import SecondFactorForm from '../../../components/SecondFactorForm/SecondFactorForm';
import LogoutBehavior from '../../../behaviors/LogoutBehavior';
import { RootState } from '../../../reducers';
import { StateProps, DispatchProps } from '../../../components/SecondFactorForm/SecondFactorForm';
import FetchPrefered2faMethod from '../../../behaviors/FetchPrefered2faMethod';
import SetPrefered2faMethod from '../../../behaviors/SetPrefered2faMethod';
import { getPreferedMethodSuccess, setUseAnotherMethod } from '../../../reducers/Portal/SecondFactor/actions';
import Method2FA from '../../../types/Method2FA';
const mapStateToProps = (state: RootState): StateProps => ({
securityKeySupported: state.secondFactor.securityKeySupported,
securityKeyVerified: state.secondFactor.securityKeySignSuccess || false,
securityKeyError: state.secondFactor.error,
oneTimePasswordVerificationInProgress: state.secondFactor.oneTimePasswordVerificationLoading,
oneTimePasswordVerificationError: state.secondFactor.oneTimePasswordVerificationError,
});
async function triggerSecurityKeySigning(dispatch: Dispatch, redirectionUrl: string | null) {
let err, result;
dispatch(securityKeySign());
[err, result] = await to(AutheliaService.requestSigning());
if (err) {
await dispatch(securityKeySignFailure(err.message));
throw err;
}
if (!result) {
await dispatch(securityKeySignFailure('No response'));
throw 'No response';
}
[err, result] = await to(u2fApi.sign(result, 60));
if (err) {
await dispatch(securityKeySignFailure(err.message));
throw err;
}
if (!result) {
await dispatch(securityKeySignFailure('No response'));
throw 'No response';
}
[err, result] = await to(AutheliaService.completeSecurityKeySigning(result, redirectionUrl));
if (err) {
await dispatch(securityKeySignFailure(err.message));
throw err;
}
try {
await redirectIfPossible(dispatch, result as Response);
dispatch(securityKeySignSuccess());
await handleSuccess(dispatch, 1000);
} catch (err) {
dispatch(securityKeySignFailure(err.message));
}
}
async function redirectIfPossible(dispatch: Dispatch, res: Response) {
if (res.status === 204) return;
const body = await res.json();
if ('error' in body) {
throw new Error(body['error']);
}
if ('redirect' in body) {
window.location.href = body['redirect'];
return;
}
return;
}
async function handleSuccess(dispatch: Dispatch, duration?: number) {
async function handle() {
await fetchState(dispatch);
}
if (duration) {
setTimeout(handle, duration);
} else {
await handle();
}
}
const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => {
const mapStateToProps = (state: RootState): StateProps => {
return {
onLogoutClicked: () => LogoutBehavior(dispatch),
onRegisterSecurityKeyClicked: async () => {
await AutheliaService.startU2FRegistrationIdentityProcess();
await dispatch(push('/confirmation-sent'));
},
onRegisterOneTimePasswordClicked: async () => {
await AutheliaService.startTOTPRegistrationIdentityProcess();
await dispatch(push('/confirmation-sent'));
},
onInit: async () => {
const isU2FSupported = await u2fApi.isSupported();
if (isU2FSupported) {
await dispatch(setSecurityKeySupported(true));
await triggerSecurityKeySigning(dispatch, ownProps.redirectionUrl);
}
},
onOneTimePasswordValidationRequested: async (token: string) => {
let err, res;
dispatch(oneTimePasswordVerification());
[err, res] = await to(AutheliaService.verifyTotpToken(token, ownProps.redirectionUrl));
if (err) {
dispatch(oneTimePasswordVerificationFailure(err.message));
throw err;
}
if (!res) {
dispatch(oneTimePasswordVerificationFailure('No response'));
throw 'No response';
method: state.secondFactor.preferedMethod,
useAnotherMethod: state.secondFactor.userAnotherMethod,
}
}
try {
await redirectIfPossible(dispatch, res);
dispatch(oneTimePasswordVerificationSuccess());
await handleSuccess(dispatch);
} catch (err) {
dispatch(oneTimePasswordVerificationFailure(err.message));
}
},
async function storeMethod(dispatch: Dispatch, method: Method2FA) {
// display the new option
dispatch(getPreferedMethodSuccess(method));
dispatch(setUseAnotherMethod(false));
// And save the method for next time.
await SetPrefered2faMethod(dispatch, method);
}
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
return {
onInit: () => FetchPrefered2faMethod(dispatch),
onLogoutClicked: () => LogoutBehavior(dispatch),
onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'),
onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'),
onUseAnotherMethodClicked: () => dispatch(setUseAnotherMethod(true)),
}
}

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 QueryString from 'query-string';
import AuthenticationView, {StateProps, Stage, DispatchProps, OwnProps} from '../../../views/AuthenticationView/AuthenticationView';
import AuthenticationView, {StateProps, Stage, OwnProps} from '../../../views/AuthenticationView/AuthenticationView';
import { RootState } from '../../../reducers';
import { Dispatch } from 'redux';
import AuthenticationLevel from '../../../types/AuthenticationLevel';

View File

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

View File

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

View File

@ -9,11 +9,37 @@ import {
SET_SECURITY_KEY_SUPPORTED,
ONE_TIME_PASSWORD_VERIFICATION_REQUEST,
ONE_TIME_PASSWORD_VERIFICATION_SUCCESS,
ONE_TIME_PASSWORD_VERIFICATION_FAILURE
ONE_TIME_PASSWORD_VERIFICATION_FAILURE,
GET_PREFERED_METHOD,
GET_PREFERED_METHOD_SUCCESS,
GET_PREFERED_METHOD_FAILURE,
SET_PREFERED_METHOD,
SET_PREFERED_METHOD_FAILURE,
SET_PREFERED_METHOD_SUCCESS,
SET_USE_ANOTHER_METHOD
} from "../../constants";
import Method2FA from "../../../types/Method2FA";
export const setSecurityKeySupported = createAction(SET_SECURITY_KEY_SUPPORTED, resolve => {
return (supported: boolean) => resolve(supported);
});
export const setUseAnotherMethod = createAction(SET_USE_ANOTHER_METHOD, resolve => {
return (useAnotherMethod: boolean) => resolve(useAnotherMethod);
});
export const getPreferedMethod = createAction(GET_PREFERED_METHOD);
export const getPreferedMethodSuccess = createAction(GET_PREFERED_METHOD_SUCCESS, resolve => {
return (method: Method2FA) => resolve(method);
});
export const getPreferedMethodFailure = createAction(GET_PREFERED_METHOD_FAILURE, resolve => {
return (err: string) => resolve(err);
});
export const setPreferedMethod = createAction(SET_PREFERED_METHOD);
export const setPreferedMethodSuccess = createAction(SET_PREFERED_METHOD_SUCCESS);
export const setPreferedMethodFailure = createAction(SET_PREFERED_METHOD_FAILURE, resolve => {
return (err: string) => resolve(err);
})
export const securityKeySign = createAction(SECURITY_KEY_SIGN);

View File

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

View File

@ -10,6 +10,15 @@ export const AUTHENTICATE_FAILURE = '@portal/authenticate_failure';
// SECOND FACTOR PAGE
export const SET_SECURITY_KEY_SUPPORTED = '@portal/second_factor/set_security_key_supported';
export const SET_USE_ANOTHER_METHOD = '@portal/second_factor/set_use_another_method';
export const GET_PREFERED_METHOD = '@portal/second_factor/get_prefered_method';
export const GET_PREFERED_METHOD_SUCCESS = '@portal/second_factor/get_prefered_method_success';
export const GET_PREFERED_METHOD_FAILURE = '@portal/second_factor/get_prefered_method_failure';
export const SET_PREFERED_METHOD = '@portal/second_factor/set_prefered_method';
export const SET_PREFERED_METHOD_SUCCESS = '@portal/second_factor/set_prefered_method_success';
export const SET_PREFERED_METHOD_FAILURE = '@portal/second_factor/set_prefered_method_failure';
export const SECURITY_KEY_SIGN = '@portal/second_factor/security_key_sign';
export const SECURITY_KEY_SIGN_SUCCESS = '@portal/second_factor/security_key_sign_success';

View File

@ -1,28 +1,32 @@
import RemoteState from "../views/AuthenticationView/RemoteState";
import u2fApi, { SignRequest } from "u2f-api";
import Method2FA from "../types/Method2FA";
async function fetchSafe(url: string, options?: RequestInit) {
return fetch(url, options)
.then(async (res) => {
class AutheliaService {
static async fetchSafe(url: string, options?: RequestInit): Promise<Response> {
const res = await fetch(url, options);
if (res.status !== 200 && res.status !== 204) {
throw new Error('Status code ' + res.status);
}
return res;
});
}
}
/**
static async fetchSafeJson(url: string, options?: RequestInit): Promise<any> {
const res = await fetch(url, options);
if (res.status !== 200) {
throw new Error('Status code ' + res.status);
}
return await res.json();
}
/**
* Fetch current authentication state.
*/
export async function fetchState() {
return fetchSafe('/api/state')
.then(async (res) => {
const body = await res.json() as RemoteState;
return body;
});
}
static async fetchState(): Promise<RemoteState> {
return await this.fetchSafeJson('/api/state')
}
export async function postFirstFactorAuth(username: string, password: string,
static async postFirstFactorAuth(username: string, password: string,
rememberMe: boolean, redirectionUrl: string | null) {
const headers: Record<string, string> = {
@ -34,7 +38,7 @@ export async function postFirstFactorAuth(username: string, password: string,
headers['X-Target-Url'] = redirectionUrl;
}
return fetchSafe('/api/firstfactor', {
return this.fetchSafe('/api/firstfactor', {
method: 'POST',
headers: headers,
body: JSON.stringify({
@ -43,39 +47,39 @@ export async function postFirstFactorAuth(username: string, password: string,
keepMeLoggedIn: rememberMe,
})
});
}
}
export async function postLogout() {
return fetchSafe('/api/logout', {
static async postLogout() {
return this.fetchSafe('/api/logout', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
})
}
}
export async function startU2FRegistrationIdentityProcess() {
return fetchSafe('/api/secondfactor/u2f/identity/start', {
static async startU2FRegistrationIdentityProcess() {
return this.fetchSafe('/api/secondfactor/u2f/identity/start', {
method: 'POST',
});
}
}
export async function startTOTPRegistrationIdentityProcess() {
return fetchSafe('/api/secondfactor/totp/identity/start', {
static async startTOTPRegistrationIdentityProcess() {
return this.fetchSafe('/api/secondfactor/totp/identity/start', {
method: 'POST',
});
}
}
export async function requestSigning() {
return fetchSafe('/api/u2f/sign_request')
static async requestSigning() {
return this.fetchSafe('/api/u2f/sign_request')
.then(async (res) => {
const body = await res.json();
return body as SignRequest;
});
}
}
export async function completeSecurityKeySigning(
static async completeSecurityKeySigning(
response: u2fApi.SignResponse, redirectionUrl: string | null) {
const headers: Record<string, string> = {
@ -85,14 +89,14 @@ export async function completeSecurityKeySigning(
if (redirectionUrl) {
headers['X-Target-Url'] = redirectionUrl;
}
return fetchSafe('/api/u2f/sign', {
return this.fetchSafe('/api/u2f/sign', {
method: 'POST',
headers: headers,
body: JSON.stringify(response),
});
}
}
export async function verifyTotpToken(
static async verifyTotpToken(
token: string, redirectionUrl: string | null) {
const headers: Record<string, string> = {
@ -102,15 +106,15 @@ export async function verifyTotpToken(
if (redirectionUrl) {
headers['X-Target-Url'] = redirectionUrl;
}
return fetchSafe('/api/totp', {
return this.fetchSafe('/api/totp', {
method: 'POST',
headers: headers,
body: JSON.stringify({token}),
})
}
}
export async function initiatePasswordResetIdentityValidation(username: string) {
return fetchSafe('/api/password-reset/identity/start', {
static async initiatePasswordResetIdentityValidation(username: string) {
return this.fetchSafe('/api/password-reset/identity/start', {
method: 'POST',
headers: {
'Accept': 'application/json',
@ -118,16 +122,16 @@ export async function initiatePasswordResetIdentityValidation(username: string)
},
body: JSON.stringify({username})
});
}
}
export async function completePasswordResetIdentityValidation(token: string) {
static async completePasswordResetIdentityValidation(token: string) {
return fetch(`/api/password-reset/identity/finish?token=${token}`, {
method: 'POST',
});
}
}
export async function resetPassword(newPassword: string) {
return fetchSafe('/api/password-reset', {
static async resetPassword(newPassword: string) {
return this.fetchSafe('/api/password-reset', {
method: 'POST',
headers: {
'Accept': 'application/json',
@ -135,4 +139,23 @@ export async function resetPassword(newPassword: string) {
},
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;
authentication_level: AuthenticationLevel;
default_redirection_url: string;
method: 'u2f' | 'totp'
}
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 { ICollection } from "./ICollection";
import { ICollectionFactory } from "./ICollectionFactory";

View File

@ -5,6 +5,7 @@ import { U2FRegistration } from "../../../types/U2FRegistration";
import { TOTPSecret } from "../../../types/TOTPSecret";
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
import { IdentityValidationDocument } from "./IdentityValidationDocument";
import Method2FA from "../../../../shared/Method2FA";
export interface IUserDataStore {
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void>;
@ -18,4 +19,7 @@ export interface IUserDataStore {
saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void>;
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 () {
new UserDataStore(factory);
Assert.equal(4, factory.buildStub.callCount);
Assert.equal(5, factory.buildStub.callCount);
Assert(factory.buildStub.calledWith("authentication_traces"));
Assert(factory.buildStub.calledWith("identity_validation_tokens"));
Assert(factory.buildStub.calledWith("u2f_registrations"));
Assert(factory.buildStub.calledWith("totp_secrets"));
Assert(factory.buildStub.calledWith("prefered_2fa_method"));
});
describe("TOTP secrets collection", function () {
@ -261,4 +262,28 @@ describe("storage/UserDataStore", function () {
});
});
});
describe("Prefered 2FA method", function () {
it("should save a prefered 2FA method", async function () {
factory.buildStub.returns(collection);
collection.insertStub.returns(BluebirdPromise.resolve());
const dataStore = new UserDataStore(factory);
await dataStore.savePrefered2FAMethod(userId, "totp")
Assert(collection.updateStub.calledOnce);
Assert(collection.updateStub.calledWith(
{userId}, {userId, method: "totp"}, {upsert: true}));
});
it("should retrieve a prefered 2FA method", async function () {
factory.buildStub.returns(collection);
collection.findOneStub.returns(BluebirdPromise.resolve());
const dataStore = new UserDataStore(factory);
await dataStore.retrievePrefered2FAMethod(userId)
Assert(collection.findOneStub.calledOnce);
Assert(collection.findOneStub.calledWith({userId}));
});
});
});

View File

@ -1,5 +1,4 @@
import * as BluebirdPromise from "bluebird";
import * as path from "path";
import { IUserDataStore } from "./IUserDataStore";
import { ICollection } from "./ICollection";
import { ICollectionFactory } from "./ICollectionFactory";
@ -9,6 +8,7 @@ import { U2FRegistration } from "../../../types/U2FRegistration";
import { TOTPSecret } from "../../../types/TOTPSecret";
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
import { IdentityValidationDocument } from "./IdentityValidationDocument";
import Method2FA from "../../../../shared/Method2FA";
// Constants
@ -17,6 +17,7 @@ const AUTHENTICATION_TRACES_COLLECTION_NAME = "authentication_traces";
const U2F_REGISTRATIONS_COLLECTION_NAME = "u2f_registrations";
const TOTP_SECRETS_COLLECTION_NAME = "totp_secrets";
const PREFERED_2FA_METHOD_COLLECTION_NAME = "prefered_2fa_method";
export interface U2FRegistrationKey {
@ -31,6 +32,7 @@ export class UserDataStore implements IUserDataStore {
private identityCheckTokensCollection: ICollection;
private authenticationTracesCollection: ICollection;
private totpSecretCollection: ICollection;
private prefered2faMethodCollection: ICollection;
private collectionFactory: ICollectionFactory;
@ -41,35 +43,24 @@ export class UserDataStore implements IUserDataStore {
this.identityCheckTokensCollection = this.collectionFactory.build(IDENTITY_VALIDATION_TOKENS_COLLECTION_NAME);
this.authenticationTracesCollection = this.collectionFactory.build(AUTHENTICATION_TRACES_COLLECTION_NAME);
this.totpSecretCollection = this.collectionFactory.build(TOTP_SECRETS_COLLECTION_NAME);
this.prefered2faMethodCollection = this.collectionFactory.build(PREFERED_2FA_METHOD_COLLECTION_NAME);
}
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void> {
const newDocument: U2FRegistrationDocument = {
userId: userId,
appId: appId,
registration: registration
};
const filter: U2FRegistrationKey = {
userId: userId,
appId: appId
};
const newDocument: U2FRegistrationDocument = {userId, appId, registration};
const filter: U2FRegistrationKey = {userId, appId};
return this.u2fSecretCollection.update(filter, newDocument, { upsert: true });
}
retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise<U2FRegistrationDocument> {
const filter: U2FRegistrationKey = {
userId: userId,
appId: appId
};
const filter: U2FRegistrationKey = { userId, appId };
return this.u2fSecretCollection.findOne(filter);
}
saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void> {
const newDocument: AuthenticationTraceDocument = {
userId: userId,
date: new Date(),
userId, date: new Date(),
isAuthenticationSuccessful: isAuthenticationSuccessful,
};
@ -77,18 +68,12 @@ export class UserDataStore implements IUserDataStore {
}
retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]> {
const q = {
userId: userId
};
return this.authenticationTracesCollection.find(q, { date: -1 }, count);
return this.authenticationTracesCollection.find({userId}, { date: -1 }, count);
}
produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any> {
const newDocument: IdentityValidationDocument = {
userId: userId,
token: token,
challenge: challenge,
userId, token, challenge,
maxDate: new Date(new Date().getTime() + maxAge)
};
@ -97,10 +82,7 @@ export class UserDataStore implements IUserDataStore {
consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise<IdentityValidationDocument> {
const that = this;
const filter = {
token: token,
challenge: challenge
};
const filter = {token, challenge};
let identityValidationDocument: IdentityValidationDocument;
@ -123,21 +105,23 @@ export class UserDataStore implements IUserDataStore {
}
saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void> {
const doc = {
userId: userId,
secret: secret
};
const filter = {
userId: userId
};
return this.totpSecretCollection.update(filter, doc, { upsert: true });
const doc = {userId, secret};
return this.totpSecretCollection.update({userId}, doc, { upsert: true });
}
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> {
const filter = {
userId: userId
};
return this.totpSecretCollection.findOne(filter);
return this.totpSecretCollection.findOne({userId});
}
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 BluebirdPromise = require("bluebird");
import * as Sinon from "sinon";
import * as BluebirdPromise from "bluebird";
import { TOTPSecretDocument } from "./TOTPSecretDocument";
import { U2FRegistrationDocument } from "./U2FRegistrationDocument";
@ -8,6 +8,7 @@ import { TOTPSecret } from "../../../types/TOTPSecret";
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
import { IdentityValidationDocument } from "./IdentityValidationDocument";
import { IUserDataStore } from "./IUserDataStore";
import Method2FA from "../../../../shared/Method2FA";
export class UserDataStoreStub implements IUserDataStore {
saveU2FRegistrationStub: Sinon.SinonStub;
@ -18,6 +19,8 @@ export class UserDataStoreStub implements IUserDataStore {
consumeIdentityValidationTokenStub: Sinon.SinonStub;
saveTOTPSecretStub: Sinon.SinonStub;
retrieveTOTPSecretStub: Sinon.SinonStub;
savePrefered2FAMethodStub: Sinon.SinonStub;
retrievePrefered2FAMethodStub: Sinon.SinonStub;
constructor() {
this.saveU2FRegistrationStub = Sinon.stub();
@ -28,6 +31,8 @@ export class UserDataStoreStub implements IUserDataStore {
this.consumeIdentityValidationTokenStub = Sinon.stub();
this.saveTOTPSecretStub = Sinon.stub();
this.retrieveTOTPSecretStub = Sinon.stub();
this.savePrefered2FAMethodStub = Sinon.stub();
this.retrievePrefered2FAMethodStub = Sinon.stub();
}
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void> {
@ -61,4 +66,12 @@ export class UserDataStoreStub implements IUserDataStore {
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> {
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 LogoutPost from "../routes/logout/post";
@ -92,6 +94,14 @@ export class RestApi {
app.get(Endpoints.VERIFY_GET, VerifyGet.default(vars));
app.post(Endpoints.FIRST_FACTOR_POST, FirstFactorPost.default(vars));
app.get(Endpoints.SECOND_FACTOR_PREFERENCES_GET,
RequireValidatedFirstFactor.middleware(vars.logger),
SecondFactorPreferencesGet(vars));
app.post(Endpoints.SECOND_FACTOR_PREFERENCES_POST,
RequireValidatedFirstFactor.middleware(vars.logger),
SecondFactorPreferencesPost(vars));
setupTotp(app, vars);
setupU2f(app, vars);
setupResetPassword(app, vars);

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 IdentityValidationFinish
*
*
* @apiDescription Serves the TOTP registration page that displays the secret.
* The secret is a QRCode and a base32 secret.
*/
export const SECOND_FACTOR_TOTP_IDENTITY_FINISH_POST = "/api/secondfactor/totp/identity/finish";
/**
* @api {get} /api/secondfactor/preferences Retrieve the user preferences.
* @apiName GetUserPreferences
* @apiGroup 2FA
* @apiVersion 1.0.0
* @apiUse UserSession
*
* @apiDescription Retrieve the user preferences sucha as the prefered method to use (TOTP or U2F).
*/
export const SECOND_FACTOR_PREFERENCES_GET = "/api/secondfactor/preferences";
/**
* @api {post} /api/secondfactor/preferences Set the user preferences.
* @apiName SetUserPreferences
* @apiGroup 2FA
* @apiVersion 1.0.0
* @apiUse UserSession
*
* @apiDescription Set the user preferences sucha as the prefered method to use (TOTP or U2F).
*/
export const SECOND_FACTOR_PREFERENCES_POST = "/api/secondfactor/preferences";
/**

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

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

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { exec } from "../../helpers/utils/exec";
import AutheliaServer from "../../helpers/context/AutheliaServer";
import DockerEnvironment from "../../helpers/context/DockerEnvironment";
const autheliaServer = new AutheliaServer(__dirname + '/config.yml');
const autheliaServer = new AutheliaServer(__dirname + '/config.yml', [__dirname + '/users_database.yml']);
const dockerEnv = new DockerEnvironment([
'docker-compose.yml',
'example/compose/nginx/backend/docker-compose.yml',

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

View File

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

View File

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