mirror of
https://github.com/0rangebananaspy/authelia.git
synced 2024-09-14 22:47:21 +07:00
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:
parent
92eb897a03
commit
d9e487c99f
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
13
client/src/behaviors/FetchPrefered2faMethod.ts
Normal file
13
client/src/behaviors/FetchPrefered2faMethod.ts
Normal 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))
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
|
|
14
client/src/behaviors/SetPrefered2faMethod.ts
Normal file
14
client/src/behaviors/SetPrefered2faMethod.ts
Normal 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))
|
||||
}
|
||||
}
|
|
@ -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,130 +12,62 @@ 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>
|
||||
<div className={styles.registerDeviceContainer}>
|
||||
<a className={classnames(styles.registerDevice, 'register-u2f')} href="#"
|
||||
onClick={this.props.onRegisterSecurityKeyClicked}>
|
||||
Register device
|
||||
</a>
|
||||
<div className={classnames('second-factor-step')} key={method + '-method'}>
|
||||
<div className={styles.methodName}>{title}</div>
|
||||
{methodComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 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) {
|
||||
private renderUseAnotherMethodLink() {
|
||||
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 className={styles.anotherMethodLink}>
|
||||
<a href="#" onClick={this.props.onUseAnotherMethodClicked}>
|
||||
Use another method
|
||||
</a>
|
||||
</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;
|
89
client/src/components/SecondFactorTOTP/SecondFactorTOTP.tsx
Normal file
89
client/src/components/SecondFactorTOTP/SecondFactorTOTP.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
52
client/src/components/SecondFactorU2F/SecondFactorU2F.tsx
Normal file
52
client/src/components/SecondFactorU2F/SecondFactorU2F.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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);
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -1,138 +1,161 @@
|
|||
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) => {
|
||||
if (res.status !== 200 && res.status !== 204) {
|
||||
throw new Error('Status code ' + res.status);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current authentication state.
|
||||
*/
|
||||
export async function fetchState() {
|
||||
return fetchSafe('/api/state')
|
||||
.then(async (res) => {
|
||||
const body = await res.json() as RemoteState;
|
||||
return body;
|
||||
});
|
||||
}
|
||||
|
||||
export async function postFirstFactorAuth(username: string, password: string,
|
||||
rememberMe: boolean, redirectionUrl: string | null) {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
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;
|
||||
}
|
||||
|
||||
if (redirectionUrl) {
|
||||
headers['X-Target-Url'] = redirectionUrl;
|
||||
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();
|
||||
}
|
||||
|
||||
return fetchSafe('/api/firstfactor', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
keepMeLoggedIn: rememberMe,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function postLogout() {
|
||||
return fetchSafe('/api/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function startU2FRegistrationIdentityProcess() {
|
||||
return fetchSafe('/api/secondfactor/u2f/identity/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function startTOTPRegistrationIdentityProcess() {
|
||||
return fetchSafe('/api/secondfactor/totp/identity/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestSigning() {
|
||||
return fetchSafe('/api/u2f/sign_request')
|
||||
.then(async (res) => {
|
||||
const body = await res.json();
|
||||
return body as SignRequest;
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeSecurityKeySigning(
|
||||
response: u2fApi.SignResponse, redirectionUrl: string | null) {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
/**
|
||||
* Fetch current authentication state.
|
||||
*/
|
||||
static async fetchState(): Promise<RemoteState> {
|
||||
return await this.fetchSafeJson('/api/state')
|
||||
}
|
||||
if (redirectionUrl) {
|
||||
headers['X-Target-Url'] = redirectionUrl;
|
||||
}
|
||||
return fetchSafe('/api/u2f/sign', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(response),
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(
|
||||
token: string, redirectionUrl: string | null) {
|
||||
static async postFirstFactorAuth(username: string, password: string,
|
||||
rememberMe: boolean, redirectionUrl: string | null) {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (redirectionUrl) {
|
||||
headers['X-Target-Url'] = redirectionUrl;
|
||||
}
|
||||
return fetchSafe('/api/totp', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify({token}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function initiatePasswordResetIdentityValidation(username: string) {
|
||||
return fetchSafe('/api/password-reset/identity/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({username})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function completePasswordResetIdentityValidation(token: string) {
|
||||
return fetch(`/api/password-reset/identity/finish?token=${token}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
if (redirectionUrl) {
|
||||
headers['X-Target-Url'] = redirectionUrl;
|
||||
}
|
||||
|
||||
export async function resetPassword(newPassword: string) {
|
||||
return fetchSafe('/api/password-reset', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
return this.fetchSafe('/api/firstfactor', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
keepMeLoggedIn: rememberMe,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
static async postLogout() {
|
||||
return this.fetchSafe('/api/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
static async startU2FRegistrationIdentityProcess() {
|
||||
return this.fetchSafe('/api/secondfactor/u2f/identity/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
static async startTOTPRegistrationIdentityProcess() {
|
||||
return this.fetchSafe('/api/secondfactor/totp/identity/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
static async requestSigning() {
|
||||
return this.fetchSafe('/api/u2f/sign_request')
|
||||
.then(async (res) => {
|
||||
const body = await res.json();
|
||||
return body as SignRequest;
|
||||
});
|
||||
}
|
||||
|
||||
static async completeSecurityKeySigning(
|
||||
response: u2fApi.SignResponse, redirectionUrl: string | null) {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({password: newPassword})
|
||||
});
|
||||
}
|
||||
if (redirectionUrl) {
|
||||
headers['X-Target-Url'] = redirectionUrl;
|
||||
}
|
||||
return this.fetchSafe('/api/u2f/sign', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(response),
|
||||
});
|
||||
}
|
||||
|
||||
static async verifyTotpToken(
|
||||
token: string, redirectionUrl: string | null) {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (redirectionUrl) {
|
||||
headers['X-Target-Url'] = redirectionUrl;
|
||||
}
|
||||
return this.fetchSafe('/api/totp', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify({token}),
|
||||
})
|
||||
}
|
||||
|
||||
static async initiatePasswordResetIdentityValidation(username: string) {
|
||||
return this.fetchSafe('/api/password-reset/identity/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({username})
|
||||
});
|
||||
}
|
||||
|
||||
static async completePasswordResetIdentityValidation(token: string) {
|
||||
return fetch(`/api/password-reset/identity/finish?token=${token}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
static async resetPassword(newPassword: string) {
|
||||
return this.fetchSafe('/api/password-reset', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({password: newPassword})
|
||||
});
|
||||
}
|
||||
|
||||
static async fetchPrefered2faMethod(): Promise<Method2FA> {
|
||||
const doc = await this.fetchSafeJson('/api/secondfactor/preferences');
|
||||
return doc.method;
|
||||
}
|
||||
|
||||
static async setPrefered2faMethod(method: Method2FA): Promise<void> {
|
||||
await this.fetchSafe('/api/secondfactor/preferences', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({method})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AutheliaService;
|
4
client/src/types/Method2FA.ts
Normal file
4
client/src/types/Method2FA.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
type Method2FA = "u2f" | "totp";
|
||||
|
||||
export default Method2FA;
|
|
@ -4,6 +4,7 @@ interface RemoteState {
|
|||
username: string;
|
||||
authentication_level: AuthenticationLevel;
|
||||
default_redirection_url: string;
|
||||
method: 'u2f' | 'totp'
|
||||
}
|
||||
|
||||
export default RemoteState;
|
13
example/compose/nginx/backend/html/secure/index.html
Normal file
13
example/compose/nginx/backend/html/secure/index.html
Normal 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>
|
36
server/src/lib/routes/secondfactor/preferences/Get.spec.ts
Normal file
36
server/src/lib/routes/secondfactor/preferences/Get.spec.ts
Normal 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." }));
|
||||
})
|
||||
});
|
18
server/src/lib/routes/secondfactor/preferences/Get.ts
Normal file
18
server/src/lib/routes/secondfactor/preferences/Get.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
55
server/src/lib/routes/secondfactor/preferences/Post.spec.ts
Normal file
55
server/src/lib/routes/secondfactor/preferences/Post.spec.ts
Normal 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." }));
|
||||
});
|
||||
});
|
23
server/src/lib/routes/secondfactor/preferences/Post.ts
Normal file
23
server/src/lib/routes/secondfactor/preferences/Post.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
import BluebirdPromise = require("bluebird");
|
||||
import Sinon = require("sinon");
|
||||
import { ICollection } from "./ICollection";
|
||||
import { ICollectionFactory } from "./ICollectionFactory";
|
||||
|
|
4
server/src/lib/storage/IUserDataStore.d.ts
vendored
4
server/src/lib/storage/IUserDataStore.d.ts
vendored
|
@ -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>;
|
||||
}
|
|
@ -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}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
3
shared/Method2FA.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Method2FA from "../client/src/types/Method2FA";
|
||||
|
||||
export default Method2FA;
|
|
@ -1,4 +0,0 @@
|
|||
|
||||
export interface RedirectionMessage {
|
||||
redirect: string;
|
||||
}
|
|
@ -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";
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
6
test/helpers/assertions/VerifyIsOneTimePasswordView.ts
Normal file
6
test/helpers/assertions/VerifyIsOneTimePasswordView.ts
Normal 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);
|
||||
}
|
6
test/helpers/assertions/VerifyIsSecurityKeyView.ts
Normal file
6
test/helpers/assertions/VerifyIsSecurityKeyView.ts
Normal 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);
|
||||
}
|
6
test/helpers/assertions/VerifyIsUseAnotherMethodView.ts
Normal file
6
test/helpers/assertions/VerifyIsUseAnotherMethodView.ts
Normal 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);
|
||||
}
|
8
test/helpers/behaviors/ClickOnButton.ts
Normal file
8
test/helpers/behaviors/ClickOnButton.ts
Normal 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();
|
||||
};
|
|
@ -10,7 +10,7 @@ export default async function(
|
|||
targetUrl: string,
|
||||
timeout: number = 5000) {
|
||||
|
||||
await VisitPageAndWaitUrlIs(driver, `https://login.example.com:8080/#/?rd=${targetUrl}`, timeout);
|
||||
await FillLoginPageAndClick(driver, username, password, false, timeout);
|
||||
await VerifyUrlIs(driver, targetUrl, timeout);
|
||||
await VisitPageAndWaitUrlIs(driver, `https://login.example.com:8080/#/?rd=${targetUrl}`, timeout);
|
||||
await FillLoginPageAndClick(driver, username, password, false, timeout);
|
||||
await VerifyUrlIs(driver, targetUrl, timeout);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
51
test/suites/basic/scenarii/Prefered2faMethod.ts
Normal file
51
test/suites/basic/scenarii/Prefered2faMethod.ts
Normal 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");
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue
Block a user