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-bottom: ($theme-spacing) * 2;
|
||||||
padding-left: ($theme-spacing) * 2;
|
padding-left: ($theme-spacing) * 2;
|
||||||
padding-right: ($theme-spacing) * 2;
|
padding-right: ($theme-spacing) * 2;
|
||||||
|
margin: (($theme-spacing) * 2) 0px;
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid #e0e0e0;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
@ -36,40 +37,16 @@
|
||||||
margin-bottom: ($theme-spacing);
|
margin-bottom: ($theme-spacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
.methodU2f {
|
.anotherMethodLink {
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
padding: ($theme-spacing);
|
|
||||||
}
|
|
||||||
|
|
||||||
.methodTotp {
|
|
||||||
padding: ($theme-spacing);
|
|
||||||
padding-top: ($theme-spacing) * 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image {
|
|
||||||
width: '120px';
|
|
||||||
}
|
|
||||||
|
|
||||||
.imageContainer {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: ($theme-spacing) * 2;
|
font-size: (0.8em)
|
||||||
margin-bottom: ($theme-spacing) * 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.registerDeviceContainer {
|
.buttonsContainer {
|
||||||
text-align: right;
|
text-align: center;
|
||||||
font-size: 0.7em;
|
margin: ($theme-spacing) 0;
|
||||||
}
|
|
||||||
|
|
||||||
.totpField {
|
|
||||||
margin-top: ($theme-spacing) * 2;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.totpButton {
|
|
||||||
margin-top: ($theme-spacing);
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
margin: ($theme-spacing) 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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 { Dispatch } from "redux";
|
||||||
import * as AutheliaService from '../services/AutheliaService';
|
|
||||||
import { fetchStateFailure, fetchStateSuccess } from "../reducers/Portal/Authentication/actions";
|
import { fetchStateFailure, fetchStateSuccess } from "../reducers/Portal/Authentication/actions";
|
||||||
import to from "await-to-js";
|
import to from "await-to-js";
|
||||||
|
import AutheliaService from "../services/AutheliaService";
|
||||||
|
|
||||||
export default async function(dispatch: Dispatch) {
|
export default async function(dispatch: Dispatch) {
|
||||||
let err, res;
|
let err, res;
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Dispatch } from "redux";
|
import { Dispatch } from "redux";
|
||||||
import { logout, logoutFailure, logoutSuccess } from "../reducers/Portal/SecondFactor/actions";
|
import { logout, logoutFailure, logoutSuccess } from "../reducers/Portal/SecondFactor/actions";
|
||||||
import to from "await-to-js";
|
import to from "await-to-js";
|
||||||
import * as AutheliaService from '../services/AutheliaService';
|
|
||||||
import fetchState from "./FetchStateBehavior";
|
import fetchState from "./FetchStateBehavior";
|
||||||
|
import AutheliaService from "../services/AutheliaService";
|
||||||
|
|
||||||
export default async function(dispatch: Dispatch) {
|
export default async function(dispatch: Dispatch) {
|
||||||
await dispatch(logout());
|
await dispatch(logout());
|
||||||
|
|
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 React, { Component } from 'react';
|
||||||
import classnames from 'classnames';
|
|
||||||
|
|
||||||
import TextField, { Input } from '@material/react-text-field';
|
|
||||||
import Button from '@material/react-button';
|
|
||||||
|
|
||||||
import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss';
|
import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss';
|
||||||
import CircleLoader, { Status } from '../../components/CircleLoader/CircleLoader';
|
import Method2FA from '../../types/Method2FA';
|
||||||
import Notification from '../Notification/Notification';
|
import SecondFactorTOTP from '../../containers/components/SecondFactorTOTP/SecondFactorTOTP';
|
||||||
|
import SecondFactorU2F from '../../containers/components/SecondFactorU2F/SecondFactorU2F';
|
||||||
|
import { Button } from '@material/react-button';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
|
||||||
export interface OwnProps {
|
export interface OwnProps {
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -14,131 +12,63 @@ export interface OwnProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StateProps {
|
export interface StateProps {
|
||||||
securityKeySupported: boolean;
|
method: Method2FA | null;
|
||||||
securityKeyVerified: boolean;
|
useAnotherMethod: boolean;
|
||||||
securityKeyError: string | null;
|
|
||||||
|
|
||||||
oneTimePasswordVerificationInProgress: boolean,
|
|
||||||
oneTimePasswordVerificationError: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DispatchProps {
|
export interface DispatchProps {
|
||||||
onInit: () => void;
|
onInit: () => void;
|
||||||
onLogoutClicked: () => void;
|
onLogoutClicked: () => void;
|
||||||
onRegisterSecurityKeyClicked: () => void;
|
onOneTimePasswordMethodClicked: () => void;
|
||||||
onRegisterOneTimePasswordClicked: () => void;
|
onSecurityKeyMethodClicked: () => void;
|
||||||
|
onUseAnotherMethodClicked: () => void;
|
||||||
onOneTimePasswordValidationRequested: (token: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Props = OwnProps & StateProps & DispatchProps;
|
export type Props = OwnProps & StateProps & DispatchProps;
|
||||||
|
|
||||||
interface State {
|
class SecondFactorForm extends Component<Props> {
|
||||||
oneTimePassword: string;
|
componentDidMount() {
|
||||||
}
|
|
||||||
|
|
||||||
class SecondFactorView extends Component<Props, State> {
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
oneTimePassword: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillMount() {
|
|
||||||
this.props.onInit();
|
this.props.onInit();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderU2f(n: number) {
|
private renderMethod() {
|
||||||
let u2fStatus = Status.LOADING;
|
let method: Method2FA = (this.props.method) ? this.props.method : 'totp'
|
||||||
if (this.props.securityKeyVerified) {
|
let methodComponent, title: string;
|
||||||
u2fStatus = Status.SUCCESSFUL;
|
if (method == 'u2f') {
|
||||||
} else if (this.props.securityKeyError) {
|
title = "Security Key";
|
||||||
u2fStatus = Status.FAILURE;
|
methodComponent = (<SecondFactorU2F redirectionUrl={this.props.redirectionUrl}></SecondFactorU2F>);
|
||||||
|
} else {
|
||||||
|
title = "One-Time Password"
|
||||||
|
methodComponent = (<SecondFactorTOTP redirectionUrl={this.props.redirectionUrl}></SecondFactorTOTP>);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.methodU2f} key='u2f-method'>
|
<div className={classnames('second-factor-step')} key={method + '-method'}>
|
||||||
<div className={styles.methodName}>Option {n} - Security Key</div>
|
<div className={styles.methodName}>{title}</div>
|
||||||
<div>Insert your security key into a USB port and touch the gold disk.</div>
|
{methodComponent}
|
||||||
<div className={styles.imageContainer}>
|
|
||||||
<CircleLoader status={u2fStatus}></CircleLoader>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.registerDeviceContainer}>
|
);
|
||||||
<a className={classnames(styles.registerDevice, 'register-u2f')} href="#"
|
}
|
||||||
onClick={this.props.onRegisterSecurityKeyClicked}>
|
|
||||||
Register device
|
private renderUseAnotherMethod() {
|
||||||
|
return (
|
||||||
|
<div className={classnames('use-another-method-view')}>
|
||||||
|
<div>Choose a method</div>
|
||||||
|
<div className={styles.buttonsContainer}>
|
||||||
|
<Button raised onClick={this.props.onOneTimePasswordMethodClicked}>One-Time Password</Button>
|
||||||
|
<Button raised onClick={this.props.onSecurityKeyMethodClicked}>Security Key (U2F)</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderUseAnotherMethodLink() {
|
||||||
|
return (
|
||||||
|
<div className={styles.anotherMethodLink}>
|
||||||
|
<a href="#" onClick={this.props.onUseAnotherMethodClicked}>
|
||||||
|
Use another method
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private onOneTimePasswordChanged = (e: FormEvent<HTMLElement>) => {
|
|
||||||
this.setState({oneTimePassword: (e.target as HTMLInputElement).value});
|
|
||||||
}
|
|
||||||
|
|
||||||
private onTotpKeyPressed = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
this.onOneTimePasswordValidationRequested();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private onOneTimePasswordValidationRequested = () => {
|
|
||||||
if (this.props.oneTimePasswordVerificationInProgress) return;
|
|
||||||
this.props.onOneTimePasswordValidationRequested(this.state.oneTimePassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderTotp(n: number) {
|
|
||||||
return (
|
|
||||||
<div className={classnames(styles.methodTotp, 'second-factor-step')} key='totp-method'>
|
|
||||||
<div className={styles.methodName}>Option {n} - One-Time Password</div>
|
|
||||||
<Notification show={this.props.oneTimePasswordVerificationError !== null}>
|
|
||||||
{this.props.oneTimePasswordVerificationError}
|
|
||||||
</Notification>
|
|
||||||
<TextField
|
|
||||||
className={styles.totpField}
|
|
||||||
label="One-Time Password"
|
|
||||||
outlined={true}>
|
|
||||||
<Input
|
|
||||||
name='totp-token'
|
|
||||||
id='totp-token'
|
|
||||||
onChange={this.onOneTimePasswordChanged as any}
|
|
||||||
onKeyPress={this.onTotpKeyPressed}
|
|
||||||
value={this.state.oneTimePassword} />
|
|
||||||
</TextField>
|
|
||||||
<div className={styles.registerDeviceContainer}>
|
|
||||||
<a className={classnames(styles.registerDevice, 'register-totp')} href="#"
|
|
||||||
onClick={this.props.onRegisterOneTimePasswordClicked}>
|
|
||||||
Register device
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className={styles.totpButton}>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
raised={true}
|
|
||||||
id='totp-button'
|
|
||||||
onClick={this.onOneTimePasswordValidationRequested}
|
|
||||||
disabled={this.props.oneTimePasswordVerificationInProgress}>
|
|
||||||
OK
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderMode() {
|
|
||||||
const methods = [];
|
|
||||||
let n = 1;
|
|
||||||
if (this.props.securityKeySupported) {
|
|
||||||
methods.push(this.renderU2f(n));
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
methods.push(this.renderTotp(n));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.methodsContainer}>
|
|
||||||
{methods}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,11 +82,12 @@ class SecondFactorView extends Component<Props, State> {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{this.renderMode()}
|
{(this.props.useAnotherMethod) ? this.renderUseAnotherMethod() : this.renderMethod()}
|
||||||
</div>
|
</div>
|
||||||
|
{(this.props.useAnotherMethod) ? null : this.renderUseAnotherMethodLink()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SecondFactorView;
|
export default SecondFactorForm;
|
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 { authenticateFailure, authenticateSuccess, authenticate } from '../../../reducers/Portal/FirstFactor/actions';
|
||||||
import FirstFactorForm, { StateProps, OwnProps } from '../../../components/FirstFactorForm/FirstFactorForm';
|
import FirstFactorForm, { StateProps, OwnProps } from '../../../components/FirstFactorForm/FirstFactorForm';
|
||||||
import { RootState } from '../../../reducers';
|
import { RootState } from '../../../reducers';
|
||||||
import * as AutheliaService from '../../../services/AutheliaService';
|
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
|
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
|
||||||
|
import AutheliaService from '../../../services/AutheliaService';
|
||||||
|
|
||||||
const mapStateToProps = (state: RootState): StateProps => {
|
const mapStateToProps = (state: RootState): StateProps => {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,138 +1,37 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { RootState } from '../../../reducers';
|
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import u2fApi from 'u2f-api';
|
import SecondFactorForm from '../../../components/SecondFactorForm/SecondFactorForm';
|
||||||
import to from 'await-to-js';
|
|
||||||
import {
|
|
||||||
securityKeySignSuccess,
|
|
||||||
securityKeySign,
|
|
||||||
securityKeySignFailure,
|
|
||||||
setSecurityKeySupported,
|
|
||||||
oneTimePasswordVerification,
|
|
||||||
oneTimePasswordVerificationFailure,
|
|
||||||
oneTimePasswordVerificationSuccess
|
|
||||||
} from '../../../reducers/Portal/SecondFactor/actions';
|
|
||||||
import SecondFactorForm, { OwnProps, StateProps } from '../../../components/SecondFactorForm/SecondFactorForm';
|
|
||||||
import * as AutheliaService from '../../../services/AutheliaService';
|
|
||||||
import { push } from 'connected-react-router';
|
|
||||||
import fetchState from '../../../behaviors/FetchStateBehavior';
|
|
||||||
import LogoutBehavior from '../../../behaviors/LogoutBehavior';
|
import LogoutBehavior from '../../../behaviors/LogoutBehavior';
|
||||||
|
import { RootState } from '../../../reducers';
|
||||||
|
import { StateProps, DispatchProps } from '../../../components/SecondFactorForm/SecondFactorForm';
|
||||||
|
import FetchPrefered2faMethod from '../../../behaviors/FetchPrefered2faMethod';
|
||||||
|
import SetPrefered2faMethod from '../../../behaviors/SetPrefered2faMethod';
|
||||||
|
import { getPreferedMethodSuccess, setUseAnotherMethod } from '../../../reducers/Portal/SecondFactor/actions';
|
||||||
|
import Method2FA from '../../../types/Method2FA';
|
||||||
|
|
||||||
const mapStateToProps = (state: RootState): StateProps => ({
|
const mapStateToProps = (state: RootState): StateProps => {
|
||||||
securityKeySupported: state.secondFactor.securityKeySupported,
|
|
||||||
securityKeyVerified: state.secondFactor.securityKeySignSuccess || false,
|
|
||||||
securityKeyError: state.secondFactor.error,
|
|
||||||
|
|
||||||
oneTimePasswordVerificationInProgress: state.secondFactor.oneTimePasswordVerificationLoading,
|
|
||||||
oneTimePasswordVerificationError: state.secondFactor.oneTimePasswordVerificationError,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function triggerSecurityKeySigning(dispatch: Dispatch, redirectionUrl: string | null) {
|
|
||||||
let err, result;
|
|
||||||
dispatch(securityKeySign());
|
|
||||||
[err, result] = await to(AutheliaService.requestSigning());
|
|
||||||
if (err) {
|
|
||||||
await dispatch(securityKeySignFailure(err.message));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
await dispatch(securityKeySignFailure('No response'));
|
|
||||||
throw 'No response';
|
|
||||||
}
|
|
||||||
|
|
||||||
[err, result] = await to(u2fApi.sign(result, 60));
|
|
||||||
if (err) {
|
|
||||||
await dispatch(securityKeySignFailure(err.message));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
await dispatch(securityKeySignFailure('No response'));
|
|
||||||
throw 'No response';
|
|
||||||
}
|
|
||||||
|
|
||||||
[err, result] = await to(AutheliaService.completeSecurityKeySigning(result, redirectionUrl));
|
|
||||||
if (err) {
|
|
||||||
await dispatch(securityKeySignFailure(err.message));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await redirectIfPossible(dispatch, result as Response);
|
|
||||||
dispatch(securityKeySignSuccess());
|
|
||||||
await handleSuccess(dispatch, 1000);
|
|
||||||
} catch (err) {
|
|
||||||
dispatch(securityKeySignFailure(err.message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function redirectIfPossible(dispatch: Dispatch, res: Response) {
|
|
||||||
if (res.status === 204) return;
|
|
||||||
|
|
||||||
const body = await res.json();
|
|
||||||
if ('error' in body) {
|
|
||||||
throw new Error(body['error']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('redirect' in body) {
|
|
||||||
window.location.href = body['redirect'];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSuccess(dispatch: Dispatch, duration?: number) {
|
|
||||||
async function handle() {
|
|
||||||
await fetchState(dispatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration) {
|
|
||||||
setTimeout(handle, duration);
|
|
||||||
} else {
|
|
||||||
await handle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => {
|
|
||||||
return {
|
return {
|
||||||
onLogoutClicked: () => LogoutBehavior(dispatch),
|
method: state.secondFactor.preferedMethod,
|
||||||
onRegisterSecurityKeyClicked: async () => {
|
useAnotherMethod: state.secondFactor.userAnotherMethod,
|
||||||
await AutheliaService.startU2FRegistrationIdentityProcess();
|
|
||||||
await dispatch(push('/confirmation-sent'));
|
|
||||||
},
|
|
||||||
onRegisterOneTimePasswordClicked: async () => {
|
|
||||||
await AutheliaService.startTOTPRegistrationIdentityProcess();
|
|
||||||
await dispatch(push('/confirmation-sent'));
|
|
||||||
},
|
|
||||||
onInit: async () => {
|
|
||||||
const isU2FSupported = await u2fApi.isSupported();
|
|
||||||
if (isU2FSupported) {
|
|
||||||
await dispatch(setSecurityKeySupported(true));
|
|
||||||
await triggerSecurityKeySigning(dispatch, ownProps.redirectionUrl);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onOneTimePasswordValidationRequested: async (token: string) => {
|
|
||||||
let err, res;
|
|
||||||
dispatch(oneTimePasswordVerification());
|
|
||||||
[err, res] = await to(AutheliaService.verifyTotpToken(token, ownProps.redirectionUrl));
|
|
||||||
if (err) {
|
|
||||||
dispatch(oneTimePasswordVerificationFailure(err.message));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (!res) {
|
|
||||||
dispatch(oneTimePasswordVerificationFailure('No response'));
|
|
||||||
throw 'No response';
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
async function storeMethod(dispatch: Dispatch, method: Method2FA) {
|
||||||
await redirectIfPossible(dispatch, res);
|
// display the new option
|
||||||
dispatch(oneTimePasswordVerificationSuccess());
|
dispatch(getPreferedMethodSuccess(method));
|
||||||
await handleSuccess(dispatch);
|
dispatch(setUseAnotherMethod(false));
|
||||||
} catch (err) {
|
|
||||||
dispatch(oneTimePasswordVerificationFailure(err.message));
|
// And save the method for next time.
|
||||||
}
|
await SetPrefered2faMethod(dispatch, method);
|
||||||
},
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
|
||||||
|
return {
|
||||||
|
onInit: () => FetchPrefered2faMethod(dispatch),
|
||||||
|
onLogoutClicked: () => LogoutBehavior(dispatch),
|
||||||
|
onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'),
|
||||||
|
onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'),
|
||||||
|
onUseAnotherMethodClicked: () => dispatch(setUseAnotherMethod(true)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 { connect } from 'react-redux';
|
||||||
import QueryString from 'query-string';
|
import QueryString from 'query-string';
|
||||||
import AuthenticationView, {StateProps, Stage, DispatchProps, OwnProps} from '../../../views/AuthenticationView/AuthenticationView';
|
import AuthenticationView, {StateProps, Stage, OwnProps} from '../../../views/AuthenticationView/AuthenticationView';
|
||||||
import { RootState } from '../../../reducers';
|
import { RootState } from '../../../reducers';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import AuthenticationLevel from '../../../types/AuthenticationLevel';
|
import AuthenticationLevel from '../../../types/AuthenticationLevel';
|
||||||
|
|
|
@ -2,9 +2,9 @@ import { connect } from 'react-redux';
|
||||||
import { RootState } from '../../../reducers';
|
import { RootState } from '../../../reducers';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { push } from 'connected-react-router';
|
import { push } from 'connected-react-router';
|
||||||
import * as AutheliaService from '../../../services/AutheliaService';
|
|
||||||
import ForgotPasswordView from '../../../views/ForgotPasswordView/ForgotPasswordView';
|
import ForgotPasswordView from '../../../views/ForgotPasswordView/ForgotPasswordView';
|
||||||
import { forgotPasswordRequest, forgotPasswordSuccess, forgotPasswordFailure } from '../../../reducers/Portal/ForgotPassword/actions';
|
import { forgotPasswordRequest, forgotPasswordSuccess, forgotPasswordFailure } from '../../../reducers/Portal/ForgotPassword/actions';
|
||||||
|
import AutheliaService from '../../../services/AutheliaService';
|
||||||
|
|
||||||
const mapStateToProps = (state: RootState) => ({
|
const mapStateToProps = (state: RootState) => ({
|
||||||
disabled: state.forgotPassword.loading,
|
disabled: state.forgotPassword.loading,
|
||||||
|
|
|
@ -2,8 +2,8 @@ import { connect } from 'react-redux';
|
||||||
import { RootState } from '../../../reducers';
|
import { RootState } from '../../../reducers';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { push } from 'connected-react-router';
|
import { push } from 'connected-react-router';
|
||||||
import * as AutheliaService from '../../../services/AutheliaService';
|
|
||||||
import ResetPasswordView, { StateProps } from '../../../views/ResetPasswordView/ResetPasswordView';
|
import ResetPasswordView, { StateProps } from '../../../views/ResetPasswordView/ResetPasswordView';
|
||||||
|
import AutheliaService from '../../../services/AutheliaService';
|
||||||
|
|
||||||
const mapStateToProps = (state: RootState): StateProps => ({
|
const mapStateToProps = (state: RootState): StateProps => ({
|
||||||
disabled: state.resetPassword.loading,
|
disabled: state.resetPassword.loading,
|
||||||
|
|
|
@ -9,11 +9,37 @@ import {
|
||||||
SET_SECURITY_KEY_SUPPORTED,
|
SET_SECURITY_KEY_SUPPORTED,
|
||||||
ONE_TIME_PASSWORD_VERIFICATION_REQUEST,
|
ONE_TIME_PASSWORD_VERIFICATION_REQUEST,
|
||||||
ONE_TIME_PASSWORD_VERIFICATION_SUCCESS,
|
ONE_TIME_PASSWORD_VERIFICATION_SUCCESS,
|
||||||
ONE_TIME_PASSWORD_VERIFICATION_FAILURE
|
ONE_TIME_PASSWORD_VERIFICATION_FAILURE,
|
||||||
|
GET_PREFERED_METHOD,
|
||||||
|
GET_PREFERED_METHOD_SUCCESS,
|
||||||
|
GET_PREFERED_METHOD_FAILURE,
|
||||||
|
SET_PREFERED_METHOD,
|
||||||
|
SET_PREFERED_METHOD_FAILURE,
|
||||||
|
SET_PREFERED_METHOD_SUCCESS,
|
||||||
|
SET_USE_ANOTHER_METHOD
|
||||||
} from "../../constants";
|
} from "../../constants";
|
||||||
|
import Method2FA from "../../../types/Method2FA";
|
||||||
|
|
||||||
export const setSecurityKeySupported = createAction(SET_SECURITY_KEY_SUPPORTED, resolve => {
|
export const setSecurityKeySupported = createAction(SET_SECURITY_KEY_SUPPORTED, resolve => {
|
||||||
return (supported: boolean) => resolve(supported);
|
return (supported: boolean) => resolve(supported);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setUseAnotherMethod = createAction(SET_USE_ANOTHER_METHOD, resolve => {
|
||||||
|
return (useAnotherMethod: boolean) => resolve(useAnotherMethod);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPreferedMethod = createAction(GET_PREFERED_METHOD);
|
||||||
|
export const getPreferedMethodSuccess = createAction(GET_PREFERED_METHOD_SUCCESS, resolve => {
|
||||||
|
return (method: Method2FA) => resolve(method);
|
||||||
|
});
|
||||||
|
export const getPreferedMethodFailure = createAction(GET_PREFERED_METHOD_FAILURE, resolve => {
|
||||||
|
return (err: string) => resolve(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setPreferedMethod = createAction(SET_PREFERED_METHOD);
|
||||||
|
export const setPreferedMethodSuccess = createAction(SET_PREFERED_METHOD_SUCCESS);
|
||||||
|
export const setPreferedMethodFailure = createAction(SET_PREFERED_METHOD_FAILURE, resolve => {
|
||||||
|
return (err: string) => resolve(err);
|
||||||
})
|
})
|
||||||
|
|
||||||
export const securityKeySign = createAction(SECURITY_KEY_SIGN);
|
export const securityKeySign = createAction(SECURITY_KEY_SIGN);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
|
|
||||||
import * as Actions from './actions';
|
import * as Actions from './actions';
|
||||||
import { ActionType, getType, StateType } from 'typesafe-actions';
|
import { ActionType, getType, StateType } from 'typesafe-actions';
|
||||||
|
import Method2FA from '../../../types/Method2FA';
|
||||||
|
|
||||||
export type SecondFactorAction = ActionType<typeof Actions>;
|
export type SecondFactorAction = ActionType<typeof Actions>;
|
||||||
|
|
||||||
|
@ -9,6 +10,16 @@ interface SecondFactorState {
|
||||||
logoutSuccess: boolean | null;
|
logoutSuccess: boolean | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
|
userAnotherMethod: boolean;
|
||||||
|
|
||||||
|
preferedMethodLoading: boolean;
|
||||||
|
preferedMethodError: string | null;
|
||||||
|
preferedMethod: Method2FA | null;
|
||||||
|
|
||||||
|
setPreferedMethodLoading: boolean;
|
||||||
|
setPreferedMethodError: string | null;
|
||||||
|
setPreferedMethodSuccess: boolean | null;
|
||||||
|
|
||||||
securityKeySupported: boolean;
|
securityKeySupported: boolean;
|
||||||
securityKeySignLoading: boolean;
|
securityKeySignLoading: boolean;
|
||||||
securityKeySignSuccess: boolean | null;
|
securityKeySignSuccess: boolean | null;
|
||||||
|
@ -23,6 +34,16 @@ const secondFactorInitialState: SecondFactorState = {
|
||||||
logoutSuccess: null,
|
logoutSuccess: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
|
userAnotherMethod: false,
|
||||||
|
|
||||||
|
preferedMethod: null,
|
||||||
|
preferedMethodError: null,
|
||||||
|
preferedMethodLoading: false,
|
||||||
|
|
||||||
|
setPreferedMethodLoading: false,
|
||||||
|
setPreferedMethodError: null,
|
||||||
|
setPreferedMethodSuccess: null,
|
||||||
|
|
||||||
securityKeySupported: false,
|
securityKeySupported: false,
|
||||||
securityKeySignLoading: false,
|
securityKeySignLoading: false,
|
||||||
securityKeySignSuccess: null,
|
securityKeySignSuccess: null,
|
||||||
|
@ -99,6 +120,49 @@ export default (state = secondFactorInitialState, action: SecondFactorAction): S
|
||||||
oneTimePasswordVerificationLoading: false,
|
oneTimePasswordVerificationLoading: false,
|
||||||
oneTimePasswordVerificationError: action.payload,
|
oneTimePasswordVerificationError: action.payload,
|
||||||
}
|
}
|
||||||
|
case getType(Actions.getPreferedMethod):
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
preferedMethodLoading: true,
|
||||||
|
preferedMethod: null,
|
||||||
|
preferedMethodError: null,
|
||||||
|
}
|
||||||
|
case getType(Actions.getPreferedMethodSuccess):
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
preferedMethodLoading: false,
|
||||||
|
preferedMethod: action.payload,
|
||||||
|
}
|
||||||
|
case getType(Actions.getPreferedMethodFailure):
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
preferedMethodLoading: false,
|
||||||
|
preferedMethodError: action.payload,
|
||||||
|
}
|
||||||
|
case getType(Actions.setPreferedMethod):
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
setPreferedMethodLoading: true,
|
||||||
|
setPreferedMethodSuccess: null,
|
||||||
|
preferedMethodError: null,
|
||||||
|
}
|
||||||
|
case getType(Actions.getPreferedMethodSuccess):
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
setPreferedMethodLoading: false,
|
||||||
|
setPreferedMethodSuccess: true,
|
||||||
|
}
|
||||||
|
case getType(Actions.getPreferedMethodFailure):
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
setPreferedMethodLoading: false,
|
||||||
|
setPreferedMethodError: action.payload,
|
||||||
|
}
|
||||||
|
case getType(Actions.setUseAnotherMethod):
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
userAnotherMethod: action.payload,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
|
@ -10,6 +10,15 @@ export const AUTHENTICATE_FAILURE = '@portal/authenticate_failure';
|
||||||
|
|
||||||
// SECOND FACTOR PAGE
|
// SECOND FACTOR PAGE
|
||||||
export const SET_SECURITY_KEY_SUPPORTED = '@portal/second_factor/set_security_key_supported';
|
export const SET_SECURITY_KEY_SUPPORTED = '@portal/second_factor/set_security_key_supported';
|
||||||
|
export const SET_USE_ANOTHER_METHOD = '@portal/second_factor/set_use_another_method';
|
||||||
|
|
||||||
|
export const GET_PREFERED_METHOD = '@portal/second_factor/get_prefered_method';
|
||||||
|
export const GET_PREFERED_METHOD_SUCCESS = '@portal/second_factor/get_prefered_method_success';
|
||||||
|
export const GET_PREFERED_METHOD_FAILURE = '@portal/second_factor/get_prefered_method_failure';
|
||||||
|
|
||||||
|
export const SET_PREFERED_METHOD = '@portal/second_factor/set_prefered_method';
|
||||||
|
export const SET_PREFERED_METHOD_SUCCESS = '@portal/second_factor/set_prefered_method_success';
|
||||||
|
export const SET_PREFERED_METHOD_FAILURE = '@portal/second_factor/set_prefered_method_failure';
|
||||||
|
|
||||||
export const SECURITY_KEY_SIGN = '@portal/second_factor/security_key_sign';
|
export const SECURITY_KEY_SIGN = '@portal/second_factor/security_key_sign';
|
||||||
export const SECURITY_KEY_SIGN_SUCCESS = '@portal/second_factor/security_key_sign_success';
|
export const SECURITY_KEY_SIGN_SUCCESS = '@portal/second_factor/security_key_sign_success';
|
||||||
|
|
|
@ -1,28 +1,32 @@
|
||||||
import RemoteState from "../views/AuthenticationView/RemoteState";
|
import RemoteState from "../views/AuthenticationView/RemoteState";
|
||||||
import u2fApi, { SignRequest } from "u2f-api";
|
import u2fApi, { SignRequest } from "u2f-api";
|
||||||
|
import Method2FA from "../types/Method2FA";
|
||||||
|
|
||||||
async function fetchSafe(url: string, options?: RequestInit) {
|
class AutheliaService {
|
||||||
return fetch(url, options)
|
static async fetchSafe(url: string, options?: RequestInit): Promise<Response> {
|
||||||
.then(async (res) => {
|
const res = await fetch(url, options);
|
||||||
if (res.status !== 200 && res.status !== 204) {
|
if (res.status !== 200 && res.status !== 204) {
|
||||||
throw new Error('Status code ' + res.status);
|
throw new Error('Status code ' + res.status);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
static async fetchSafeJson(url: string, options?: RequestInit): Promise<any> {
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Status code ' + res.status);
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Fetch current authentication state.
|
* Fetch current authentication state.
|
||||||
*/
|
*/
|
||||||
export async function fetchState() {
|
static async fetchState(): Promise<RemoteState> {
|
||||||
return fetchSafe('/api/state')
|
return await this.fetchSafeJson('/api/state')
|
||||||
.then(async (res) => {
|
}
|
||||||
const body = await res.json() as RemoteState;
|
|
||||||
return body;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function postFirstFactorAuth(username: string, password: string,
|
static async postFirstFactorAuth(username: string, password: string,
|
||||||
rememberMe: boolean, redirectionUrl: string | null) {
|
rememberMe: boolean, redirectionUrl: string | null) {
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
|
@ -34,7 +38,7 @@ export async function postFirstFactorAuth(username: string, password: string,
|
||||||
headers['X-Target-Url'] = redirectionUrl;
|
headers['X-Target-Url'] = redirectionUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchSafe('/api/firstfactor', {
|
return this.fetchSafe('/api/firstfactor', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
@ -43,39 +47,39 @@ export async function postFirstFactorAuth(username: string, password: string,
|
||||||
keepMeLoggedIn: rememberMe,
|
keepMeLoggedIn: rememberMe,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postLogout() {
|
static async postLogout() {
|
||||||
return fetchSafe('/api/logout', {
|
return this.fetchSafe('/api/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startU2FRegistrationIdentityProcess() {
|
static async startU2FRegistrationIdentityProcess() {
|
||||||
return fetchSafe('/api/secondfactor/u2f/identity/start', {
|
return this.fetchSafe('/api/secondfactor/u2f/identity/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startTOTPRegistrationIdentityProcess() {
|
static async startTOTPRegistrationIdentityProcess() {
|
||||||
return fetchSafe('/api/secondfactor/totp/identity/start', {
|
return this.fetchSafe('/api/secondfactor/totp/identity/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestSigning() {
|
static async requestSigning() {
|
||||||
return fetchSafe('/api/u2f/sign_request')
|
return this.fetchSafe('/api/u2f/sign_request')
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
return body as SignRequest;
|
return body as SignRequest;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeSecurityKeySigning(
|
static async completeSecurityKeySigning(
|
||||||
response: u2fApi.SignResponse, redirectionUrl: string | null) {
|
response: u2fApi.SignResponse, redirectionUrl: string | null) {
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
|
@ -85,14 +89,14 @@ export async function completeSecurityKeySigning(
|
||||||
if (redirectionUrl) {
|
if (redirectionUrl) {
|
||||||
headers['X-Target-Url'] = redirectionUrl;
|
headers['X-Target-Url'] = redirectionUrl;
|
||||||
}
|
}
|
||||||
return fetchSafe('/api/u2f/sign', {
|
return this.fetchSafe('/api/u2f/sign', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: JSON.stringify(response),
|
body: JSON.stringify(response),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyTotpToken(
|
static async verifyTotpToken(
|
||||||
token: string, redirectionUrl: string | null) {
|
token: string, redirectionUrl: string | null) {
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
|
@ -102,15 +106,15 @@ export async function verifyTotpToken(
|
||||||
if (redirectionUrl) {
|
if (redirectionUrl) {
|
||||||
headers['X-Target-Url'] = redirectionUrl;
|
headers['X-Target-Url'] = redirectionUrl;
|
||||||
}
|
}
|
||||||
return fetchSafe('/api/totp', {
|
return this.fetchSafe('/api/totp', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: JSON.stringify({token}),
|
body: JSON.stringify({token}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initiatePasswordResetIdentityValidation(username: string) {
|
static async initiatePasswordResetIdentityValidation(username: string) {
|
||||||
return fetchSafe('/api/password-reset/identity/start', {
|
return this.fetchSafe('/api/password-reset/identity/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
@ -118,16 +122,16 @@ export async function initiatePasswordResetIdentityValidation(username: string)
|
||||||
},
|
},
|
||||||
body: JSON.stringify({username})
|
body: JSON.stringify({username})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completePasswordResetIdentityValidation(token: string) {
|
static async completePasswordResetIdentityValidation(token: string) {
|
||||||
return fetch(`/api/password-reset/identity/finish?token=${token}`, {
|
return fetch(`/api/password-reset/identity/finish?token=${token}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetPassword(newPassword: string) {
|
static async resetPassword(newPassword: string) {
|
||||||
return fetchSafe('/api/password-reset', {
|
return this.fetchSafe('/api/password-reset', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
@ -135,4 +139,23 @@ export async function resetPassword(newPassword: string) {
|
||||||
},
|
},
|
||||||
body: JSON.stringify({password: newPassword})
|
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;
|
username: string;
|
||||||
authentication_level: AuthenticationLevel;
|
authentication_level: AuthenticationLevel;
|
||||||
default_redirection_url: string;
|
default_redirection_url: string;
|
||||||
|
method: 'u2f' | 'totp'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RemoteState;
|
export default RemoteState;
|
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 Sinon = require("sinon");
|
||||||
import { ICollection } from "./ICollection";
|
import { ICollection } from "./ICollection";
|
||||||
import { ICollectionFactory } from "./ICollectionFactory";
|
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 { TOTPSecret } from "../../../types/TOTPSecret";
|
||||||
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
|
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
|
||||||
import { IdentityValidationDocument } from "./IdentityValidationDocument";
|
import { IdentityValidationDocument } from "./IdentityValidationDocument";
|
||||||
|
import Method2FA from "../../../../shared/Method2FA";
|
||||||
|
|
||||||
export interface IUserDataStore {
|
export interface IUserDataStore {
|
||||||
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void>;
|
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void>;
|
||||||
|
@ -18,4 +19,7 @@ export interface IUserDataStore {
|
||||||
|
|
||||||
saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void>;
|
saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void>;
|
||||||
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument>;
|
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument>;
|
||||||
|
|
||||||
|
savePrefered2FAMethod(userId: string, method: Method2FA): BluebirdPromise<void>;
|
||||||
|
retrievePrefered2FAMethod(userId: string): BluebirdPromise<Method2FA>;
|
||||||
}
|
}
|
|
@ -46,11 +46,12 @@ describe("storage/UserDataStore", function () {
|
||||||
it("should correctly creates collections", function () {
|
it("should correctly creates collections", function () {
|
||||||
new UserDataStore(factory);
|
new UserDataStore(factory);
|
||||||
|
|
||||||
Assert.equal(4, factory.buildStub.callCount);
|
Assert.equal(5, factory.buildStub.callCount);
|
||||||
Assert(factory.buildStub.calledWith("authentication_traces"));
|
Assert(factory.buildStub.calledWith("authentication_traces"));
|
||||||
Assert(factory.buildStub.calledWith("identity_validation_tokens"));
|
Assert(factory.buildStub.calledWith("identity_validation_tokens"));
|
||||||
Assert(factory.buildStub.calledWith("u2f_registrations"));
|
Assert(factory.buildStub.calledWith("u2f_registrations"));
|
||||||
Assert(factory.buildStub.calledWith("totp_secrets"));
|
Assert(factory.buildStub.calledWith("totp_secrets"));
|
||||||
|
Assert(factory.buildStub.calledWith("prefered_2fa_method"));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TOTP secrets collection", function () {
|
describe("TOTP secrets collection", function () {
|
||||||
|
@ -261,4 +262,28 @@ describe("storage/UserDataStore", function () {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe("Prefered 2FA method", function () {
|
||||||
|
it("should save a prefered 2FA method", async function () {
|
||||||
|
factory.buildStub.returns(collection);
|
||||||
|
collection.insertStub.returns(BluebirdPromise.resolve());
|
||||||
|
|
||||||
|
const dataStore = new UserDataStore(factory);
|
||||||
|
|
||||||
|
await dataStore.savePrefered2FAMethod(userId, "totp")
|
||||||
|
Assert(collection.updateStub.calledOnce);
|
||||||
|
Assert(collection.updateStub.calledWith(
|
||||||
|
{userId}, {userId, method: "totp"}, {upsert: true}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retrieve a prefered 2FA method", async function () {
|
||||||
|
factory.buildStub.returns(collection);
|
||||||
|
collection.findOneStub.returns(BluebirdPromise.resolve());
|
||||||
|
|
||||||
|
const dataStore = new UserDataStore(factory);
|
||||||
|
|
||||||
|
await dataStore.retrievePrefered2FAMethod(userId)
|
||||||
|
Assert(collection.findOneStub.calledOnce);
|
||||||
|
Assert(collection.findOneStub.calledWith({userId}));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import * as BluebirdPromise from "bluebird";
|
import * as BluebirdPromise from "bluebird";
|
||||||
import * as path from "path";
|
|
||||||
import { IUserDataStore } from "./IUserDataStore";
|
import { IUserDataStore } from "./IUserDataStore";
|
||||||
import { ICollection } from "./ICollection";
|
import { ICollection } from "./ICollection";
|
||||||
import { ICollectionFactory } from "./ICollectionFactory";
|
import { ICollectionFactory } from "./ICollectionFactory";
|
||||||
|
@ -9,6 +8,7 @@ import { U2FRegistration } from "../../../types/U2FRegistration";
|
||||||
import { TOTPSecret } from "../../../types/TOTPSecret";
|
import { TOTPSecret } from "../../../types/TOTPSecret";
|
||||||
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
|
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
|
||||||
import { IdentityValidationDocument } from "./IdentityValidationDocument";
|
import { IdentityValidationDocument } from "./IdentityValidationDocument";
|
||||||
|
import Method2FA from "../../../../shared/Method2FA";
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ const AUTHENTICATION_TRACES_COLLECTION_NAME = "authentication_traces";
|
||||||
|
|
||||||
const U2F_REGISTRATIONS_COLLECTION_NAME = "u2f_registrations";
|
const U2F_REGISTRATIONS_COLLECTION_NAME = "u2f_registrations";
|
||||||
const TOTP_SECRETS_COLLECTION_NAME = "totp_secrets";
|
const TOTP_SECRETS_COLLECTION_NAME = "totp_secrets";
|
||||||
|
const PREFERED_2FA_METHOD_COLLECTION_NAME = "prefered_2fa_method";
|
||||||
|
|
||||||
|
|
||||||
export interface U2FRegistrationKey {
|
export interface U2FRegistrationKey {
|
||||||
|
@ -31,6 +32,7 @@ export class UserDataStore implements IUserDataStore {
|
||||||
private identityCheckTokensCollection: ICollection;
|
private identityCheckTokensCollection: ICollection;
|
||||||
private authenticationTracesCollection: ICollection;
|
private authenticationTracesCollection: ICollection;
|
||||||
private totpSecretCollection: ICollection;
|
private totpSecretCollection: ICollection;
|
||||||
|
private prefered2faMethodCollection: ICollection;
|
||||||
|
|
||||||
private collectionFactory: ICollectionFactory;
|
private collectionFactory: ICollectionFactory;
|
||||||
|
|
||||||
|
@ -41,35 +43,24 @@ export class UserDataStore implements IUserDataStore {
|
||||||
this.identityCheckTokensCollection = this.collectionFactory.build(IDENTITY_VALIDATION_TOKENS_COLLECTION_NAME);
|
this.identityCheckTokensCollection = this.collectionFactory.build(IDENTITY_VALIDATION_TOKENS_COLLECTION_NAME);
|
||||||
this.authenticationTracesCollection = this.collectionFactory.build(AUTHENTICATION_TRACES_COLLECTION_NAME);
|
this.authenticationTracesCollection = this.collectionFactory.build(AUTHENTICATION_TRACES_COLLECTION_NAME);
|
||||||
this.totpSecretCollection = this.collectionFactory.build(TOTP_SECRETS_COLLECTION_NAME);
|
this.totpSecretCollection = this.collectionFactory.build(TOTP_SECRETS_COLLECTION_NAME);
|
||||||
|
this.prefered2faMethodCollection = this.collectionFactory.build(PREFERED_2FA_METHOD_COLLECTION_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void> {
|
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void> {
|
||||||
const newDocument: U2FRegistrationDocument = {
|
const newDocument: U2FRegistrationDocument = {userId, appId, registration};
|
||||||
userId: userId,
|
const filter: U2FRegistrationKey = {userId, appId};
|
||||||
appId: appId,
|
|
||||||
registration: registration
|
|
||||||
};
|
|
||||||
|
|
||||||
const filter: U2FRegistrationKey = {
|
|
||||||
userId: userId,
|
|
||||||
appId: appId
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.u2fSecretCollection.update(filter, newDocument, { upsert: true });
|
return this.u2fSecretCollection.update(filter, newDocument, { upsert: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise<U2FRegistrationDocument> {
|
retrieveU2FRegistration(userId: string, appId: string): BluebirdPromise<U2FRegistrationDocument> {
|
||||||
const filter: U2FRegistrationKey = {
|
const filter: U2FRegistrationKey = { userId, appId };
|
||||||
userId: userId,
|
|
||||||
appId: appId
|
|
||||||
};
|
|
||||||
return this.u2fSecretCollection.findOne(filter);
|
return this.u2fSecretCollection.findOne(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void> {
|
saveAuthenticationTrace(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise<void> {
|
||||||
const newDocument: AuthenticationTraceDocument = {
|
const newDocument: AuthenticationTraceDocument = {
|
||||||
userId: userId,
|
userId, date: new Date(),
|
||||||
date: new Date(),
|
|
||||||
isAuthenticationSuccessful: isAuthenticationSuccessful,
|
isAuthenticationSuccessful: isAuthenticationSuccessful,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -77,18 +68,12 @@ export class UserDataStore implements IUserDataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]> {
|
retrieveLatestAuthenticationTraces(userId: string, count: number): BluebirdPromise<AuthenticationTraceDocument[]> {
|
||||||
const q = {
|
return this.authenticationTracesCollection.find({userId}, { date: -1 }, count);
|
||||||
userId: userId
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.authenticationTracesCollection.find(q, { date: -1 }, count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any> {
|
produceIdentityValidationToken(userId: string, token: string, challenge: string, maxAge: number): BluebirdPromise<any> {
|
||||||
const newDocument: IdentityValidationDocument = {
|
const newDocument: IdentityValidationDocument = {
|
||||||
userId: userId,
|
userId, token, challenge,
|
||||||
token: token,
|
|
||||||
challenge: challenge,
|
|
||||||
maxDate: new Date(new Date().getTime() + maxAge)
|
maxDate: new Date(new Date().getTime() + maxAge)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -97,10 +82,7 @@ export class UserDataStore implements IUserDataStore {
|
||||||
|
|
||||||
consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise<IdentityValidationDocument> {
|
consumeIdentityValidationToken(token: string, challenge: string): BluebirdPromise<IdentityValidationDocument> {
|
||||||
const that = this;
|
const that = this;
|
||||||
const filter = {
|
const filter = {token, challenge};
|
||||||
token: token,
|
|
||||||
challenge: challenge
|
|
||||||
};
|
|
||||||
|
|
||||||
let identityValidationDocument: IdentityValidationDocument;
|
let identityValidationDocument: IdentityValidationDocument;
|
||||||
|
|
||||||
|
@ -123,21 +105,23 @@ export class UserDataStore implements IUserDataStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void> {
|
saveTOTPSecret(userId: string, secret: TOTPSecret): BluebirdPromise<void> {
|
||||||
const doc = {
|
const doc = {userId, secret};
|
||||||
userId: userId,
|
return this.totpSecretCollection.update({userId}, doc, { upsert: true });
|
||||||
secret: secret
|
|
||||||
};
|
|
||||||
|
|
||||||
const filter = {
|
|
||||||
userId: userId
|
|
||||||
};
|
|
||||||
return this.totpSecretCollection.update(filter, doc, { upsert: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> {
|
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> {
|
||||||
const filter = {
|
return this.totpSecretCollection.findOne({userId});
|
||||||
userId: userId
|
}
|
||||||
};
|
|
||||||
return this.totpSecretCollection.findOne(filter);
|
savePrefered2FAMethod(userId: string, method: Method2FA): BluebirdPromise<void> {
|
||||||
|
const newDoc = {userId, method};
|
||||||
|
return this.prefered2faMethodCollection.update({userId}, newDoc, {upsert: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
retrievePrefered2FAMethod(userId: string): BluebirdPromise<Method2FA | undefined> {
|
||||||
|
return this.prefered2faMethodCollection.findOne({userId})
|
||||||
|
.then((doc) => {
|
||||||
|
return (doc && doc.method) ? doc.method : undefined;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import Sinon = require("sinon");
|
import * as Sinon from "sinon";
|
||||||
import BluebirdPromise = require("bluebird");
|
import * as BluebirdPromise from "bluebird";
|
||||||
|
|
||||||
import { TOTPSecretDocument } from "./TOTPSecretDocument";
|
import { TOTPSecretDocument } from "./TOTPSecretDocument";
|
||||||
import { U2FRegistrationDocument } from "./U2FRegistrationDocument";
|
import { U2FRegistrationDocument } from "./U2FRegistrationDocument";
|
||||||
|
@ -8,6 +8,7 @@ import { TOTPSecret } from "../../../types/TOTPSecret";
|
||||||
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
|
import { AuthenticationTraceDocument } from "./AuthenticationTraceDocument";
|
||||||
import { IdentityValidationDocument } from "./IdentityValidationDocument";
|
import { IdentityValidationDocument } from "./IdentityValidationDocument";
|
||||||
import { IUserDataStore } from "./IUserDataStore";
|
import { IUserDataStore } from "./IUserDataStore";
|
||||||
|
import Method2FA from "../../../../shared/Method2FA";
|
||||||
|
|
||||||
export class UserDataStoreStub implements IUserDataStore {
|
export class UserDataStoreStub implements IUserDataStore {
|
||||||
saveU2FRegistrationStub: Sinon.SinonStub;
|
saveU2FRegistrationStub: Sinon.SinonStub;
|
||||||
|
@ -18,6 +19,8 @@ export class UserDataStoreStub implements IUserDataStore {
|
||||||
consumeIdentityValidationTokenStub: Sinon.SinonStub;
|
consumeIdentityValidationTokenStub: Sinon.SinonStub;
|
||||||
saveTOTPSecretStub: Sinon.SinonStub;
|
saveTOTPSecretStub: Sinon.SinonStub;
|
||||||
retrieveTOTPSecretStub: Sinon.SinonStub;
|
retrieveTOTPSecretStub: Sinon.SinonStub;
|
||||||
|
savePrefered2FAMethodStub: Sinon.SinonStub;
|
||||||
|
retrievePrefered2FAMethodStub: Sinon.SinonStub;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.saveU2FRegistrationStub = Sinon.stub();
|
this.saveU2FRegistrationStub = Sinon.stub();
|
||||||
|
@ -28,6 +31,8 @@ export class UserDataStoreStub implements IUserDataStore {
|
||||||
this.consumeIdentityValidationTokenStub = Sinon.stub();
|
this.consumeIdentityValidationTokenStub = Sinon.stub();
|
||||||
this.saveTOTPSecretStub = Sinon.stub();
|
this.saveTOTPSecretStub = Sinon.stub();
|
||||||
this.retrieveTOTPSecretStub = Sinon.stub();
|
this.retrieveTOTPSecretStub = Sinon.stub();
|
||||||
|
this.savePrefered2FAMethodStub = Sinon.stub();
|
||||||
|
this.retrievePrefered2FAMethodStub = Sinon.stub();
|
||||||
}
|
}
|
||||||
|
|
||||||
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void> {
|
saveU2FRegistration(userId: string, appId: string, registration: U2FRegistration): BluebirdPromise<void> {
|
||||||
|
@ -61,4 +66,12 @@ export class UserDataStoreStub implements IUserDataStore {
|
||||||
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> {
|
retrieveTOTPSecret(userId: string): BluebirdPromise<TOTPSecretDocument> {
|
||||||
return this.retrieveTOTPSecretStub(userId);
|
return this.retrieveTOTPSecretStub(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
savePrefered2FAMethod(userId: string, method: Method2FA): BluebirdPromise<void> {
|
||||||
|
return this.savePrefered2FAMethodStub(userId, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
retrievePrefered2FAMethod(userId: string): BluebirdPromise<Method2FA> {
|
||||||
|
return this.retrievePrefered2FAMethodStub(userId);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,4 +1,6 @@
|
||||||
import Express = require("express");
|
import * as Express from "express";
|
||||||
|
import SecondFactorPreferencesGet from "../routes/secondfactor/preferences/Get";
|
||||||
|
import SecondFactorPreferencesPost from "../routes/secondfactor/preferences/Post";
|
||||||
|
|
||||||
import FirstFactorPost = require("../routes/firstfactor/post");
|
import FirstFactorPost = require("../routes/firstfactor/post");
|
||||||
import LogoutPost from "../routes/logout/post";
|
import LogoutPost from "../routes/logout/post";
|
||||||
|
@ -92,6 +94,14 @@ export class RestApi {
|
||||||
app.get(Endpoints.VERIFY_GET, VerifyGet.default(vars));
|
app.get(Endpoints.VERIFY_GET, VerifyGet.default(vars));
|
||||||
app.post(Endpoints.FIRST_FACTOR_POST, FirstFactorPost.default(vars));
|
app.post(Endpoints.FIRST_FACTOR_POST, FirstFactorPost.default(vars));
|
||||||
|
|
||||||
|
app.get(Endpoints.SECOND_FACTOR_PREFERENCES_GET,
|
||||||
|
RequireValidatedFirstFactor.middleware(vars.logger),
|
||||||
|
SecondFactorPreferencesGet(vars));
|
||||||
|
|
||||||
|
app.post(Endpoints.SECOND_FACTOR_PREFERENCES_POST,
|
||||||
|
RequireValidatedFirstFactor.middleware(vars.logger),
|
||||||
|
SecondFactorPreferencesPost(vars));
|
||||||
|
|
||||||
setupTotp(app, vars);
|
setupTotp(app, vars);
|
||||||
setupU2f(app, vars);
|
setupU2f(app, vars);
|
||||||
setupResetPassword(app, vars);
|
setupResetPassword(app, vars);
|
||||||
|
|
3
shared/Method2FA.ts
Normal file
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 UserSession
|
||||||
* @apiUse IdentityValidationFinish
|
* @apiUse IdentityValidationFinish
|
||||||
*
|
*
|
||||||
*
|
|
||||||
* @apiDescription Serves the TOTP registration page that displays the secret.
|
* @apiDescription Serves the TOTP registration page that displays the secret.
|
||||||
* The secret is a QRCode and a base32 secret.
|
* The secret is a QRCode and a base32 secret.
|
||||||
*/
|
*/
|
||||||
export const SECOND_FACTOR_TOTP_IDENTITY_FINISH_POST = "/api/secondfactor/totp/identity/finish";
|
export const SECOND_FACTOR_TOTP_IDENTITY_FINISH_POST = "/api/secondfactor/totp/identity/finish";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {get} /api/secondfactor/preferences Retrieve the user preferences.
|
||||||
|
* @apiName GetUserPreferences
|
||||||
|
* @apiGroup 2FA
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiUse UserSession
|
||||||
|
*
|
||||||
|
* @apiDescription Retrieve the user preferences sucha as the prefered method to use (TOTP or U2F).
|
||||||
|
*/
|
||||||
|
export const SECOND_FACTOR_PREFERENCES_GET = "/api/secondfactor/preferences";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api {post} /api/secondfactor/preferences Set the user preferences.
|
||||||
|
* @apiName SetUserPreferences
|
||||||
|
* @apiGroup 2FA
|
||||||
|
* @apiVersion 1.0.0
|
||||||
|
* @apiUse UserSession
|
||||||
|
*
|
||||||
|
* @apiDescription Set the user preferences sucha as the prefered method to use (TOTP or U2F).
|
||||||
|
*/
|
||||||
|
export const SECOND_FACTOR_PREFERENCES_POST = "/api/secondfactor/preferences";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -4,6 +4,6 @@ import VisitPageAndWaitUrlIs from "./behaviors/VisitPageAndWaitUrlIs";
|
||||||
|
|
||||||
export default async function(driver: WebDriver, user: string, password: string, targetUrl?: string, timeout: number = 5000) {
|
export default async function(driver: WebDriver, user: string, password: string, targetUrl?: string, timeout: number = 5000) {
|
||||||
const urlExt = (targetUrl) ? ('rd=' + targetUrl) : '';
|
const urlExt = (targetUrl) ? ('rd=' + targetUrl) : '';
|
||||||
await VisitPageAndWaitUrlIs(driver, "https://login.example.com:8080/#/" + urlExt, timeout);
|
await VisitPageAndWaitUrlIs(driver, "https://login.example.com:8080/#/?" + urlExt, timeout);
|
||||||
await FillLoginPageAndClick(driver, user, password, false, timeout);
|
await FillLoginPageAndClick(driver, user, password, false, timeout);
|
||||||
}
|
}
|
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();
|
||||||
|
};
|
|
@ -6,9 +6,9 @@ import AutheliaServerFromDist from './AutheliaServerFromDist';
|
||||||
class AutheliaServer implements AutheliaServerInterface {
|
class AutheliaServer implements AutheliaServerInterface {
|
||||||
private runnerImpl: AutheliaServerInterface;
|
private runnerImpl: AutheliaServerInterface;
|
||||||
|
|
||||||
constructor(configPath: string) {
|
constructor(configPath: string, watchPaths: string[] = []) {
|
||||||
if (fs.existsSync('.suite')) {
|
if (fs.existsSync('.suite')) {
|
||||||
this.runnerImpl = new AutheliaServerWithHotReload(configPath);
|
this.runnerImpl = new AutheliaServerWithHotReload(configPath, watchPaths);
|
||||||
} else {
|
} else {
|
||||||
this.runnerImpl = new AutheliaServerFromDist(configPath, true);
|
this.runnerImpl = new AutheliaServerFromDist(configPath, true);
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,10 +15,10 @@ class AutheliaServerWithHotReload implements AutheliaServerInterface {
|
||||||
private filesChangedBuffer: string[] = [];
|
private filesChangedBuffer: string[] = [];
|
||||||
private changeInProgress: boolean = false;
|
private changeInProgress: boolean = false;
|
||||||
|
|
||||||
constructor(configPath: string) {
|
constructor(configPath: string, watchedPaths: string[]) {
|
||||||
this.configPath = configPath;
|
this.configPath = configPath;
|
||||||
this.watcher = Chokidar.watch(['server', 'shared/**/*.ts', 'node_modules',
|
this.watcher = Chokidar.watch(['server', 'shared/**/*.ts', 'node_modules',
|
||||||
this.AUTHELIA_INTERRUPT_FILENAME, configPath], {
|
this.AUTHELIA_INTERRUPT_FILENAME, configPath].concat(watchedPaths), {
|
||||||
persistent: true,
|
persistent: true,
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,7 +15,7 @@ users:
|
||||||
|
|
||||||
harry:
|
harry:
|
||||||
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
||||||
emails: harry.potter@authelia.com
|
email: harry.potter@authelia.com
|
||||||
groups: []
|
groups: []
|
||||||
|
|
||||||
bob:
|
bob:
|
||||||
|
|
|
@ -45,6 +45,9 @@ access_control:
|
||||||
- domain: public.example.com
|
- domain: public.example.com
|
||||||
policy: bypass
|
policy: bypass
|
||||||
|
|
||||||
|
- domain: secure.example.com
|
||||||
|
policy: two_factor
|
||||||
|
|
||||||
- domain: '*.example.com'
|
- domain: '*.example.com'
|
||||||
subject: "group:admins"
|
subject: "group:admins"
|
||||||
policy: two_factor
|
policy: two_factor
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { exec } from "../../helpers/utils/exec";
|
||||||
import AutheliaServer from "../../helpers/context/AutheliaServer";
|
import AutheliaServer from "../../helpers/context/AutheliaServer";
|
||||||
import DockerEnvironment from "../../helpers/context/DockerEnvironment";
|
import DockerEnvironment from "../../helpers/context/DockerEnvironment";
|
||||||
|
|
||||||
const autheliaServer = new AutheliaServer(__dirname + '/config.yml');
|
const autheliaServer = new AutheliaServer(__dirname + '/config.yml', [__dirname + '/users_database.yml']);
|
||||||
const dockerEnv = new DockerEnvironment([
|
const dockerEnv = new DockerEnvironment([
|
||||||
'docker-compose.yml',
|
'docker-compose.yml',
|
||||||
'example/compose/nginx/backend/docker-compose.yml',
|
'example/compose/nginx/backend/docker-compose.yml',
|
||||||
|
|
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 { exec } from '../../helpers/utils/exec';
|
||||||
import TwoFactorAuthentication from "../../helpers/scenarii/TwoFactorAuthentication";
|
import TwoFactorAuthentication from "../../helpers/scenarii/TwoFactorAuthentication";
|
||||||
import BypassPolicy from "./scenarii/BypassPolicy";
|
import BypassPolicy from "./scenarii/BypassPolicy";
|
||||||
|
import Prefered2faMethod from "./scenarii/Prefered2faMethod";
|
||||||
|
|
||||||
AutheliaSuite(__dirname, function() {
|
AutheliaSuite(__dirname, function() {
|
||||||
this.timeout(10000);
|
this.timeout(10000);
|
||||||
|
@ -28,4 +29,5 @@ AutheliaSuite(__dirname, function() {
|
||||||
describe('TOTP Validation', TOTPValidation);
|
describe('TOTP Validation', TOTPValidation);
|
||||||
describe('Required two factor', RequiredTwoFactor);
|
describe('Required two factor', RequiredTwoFactor);
|
||||||
describe('Logout endpoint redirect to already logged in page', LogoutRedirectToAlreadyLoggedIn);
|
describe('Logout endpoint redirect to already logged in page', LogoutRedirectToAlreadyLoggedIn);
|
||||||
|
describe('Prefered 2FA method', Prefered2faMethod);
|
||||||
});
|
});
|
|
@ -15,7 +15,7 @@ users:
|
||||||
|
|
||||||
harry:
|
harry:
|
||||||
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
||||||
emails: harry.potter@authelia.com
|
email: harry.potter@authelia.com
|
||||||
groups: []
|
groups: []
|
||||||
|
|
||||||
bob:
|
bob:
|
||||||
|
|
|
@ -15,7 +15,7 @@ users:
|
||||||
|
|
||||||
harry:
|
harry:
|
||||||
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
|
||||||
emails: harry.potter@authelia.com
|
email: harry.potter@authelia.com
|
||||||
groups: []
|
groups: []
|
||||||
|
|
||||||
bob:
|
bob:
|
||||||
|
|
Loading…
Reference in New Issue
Block a user