mirror of
https://github.com/0rangebananaspy/authelia.git
synced 2024-09-14 22:47:21 +07:00
Merge pull request #340 from clems4ever/2fa-opt-state
Display only one 2FA option.
This commit is contained in:
commit
090a74299f
|
@ -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