Merge pull request #340 from clems4ever/2fa-opt-state

Display only one 2FA option.
This commit is contained in:
Clément Michaud 2019-03-23 20:53:37 +01:00 committed by GitHub
commit 090a74299f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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: