diff --git a/.travis.yml b/.travis.yml
index debdccbe..4d922109 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,4 +1,5 @@
language: node_js
+required: sudo
node_js:
- '9'
services:
@@ -12,19 +13,6 @@ addons:
packages:
- libgif-dev
- google-chrome-stable
- hosts:
- - admin.example.com
- - login.example.com
- - singlefactor.example.com
- - dev.example.com
- - home.example.com
- - mx1.mail.example.com
- - mx2.mail.example.com
- - public.example.com
- - secure.example.com
- - authelia.example.com
- - admin.example.com
- - mail.example.com
before_script:
- export DISPLAY=:99.0
diff --git a/README.md b/README.md
index d98936e3..ad4e1861 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,9 @@
[![Gitter](https://img.shields.io/gitter/room/badges/shields.svg)](https://gitter.im/authelia/general?utm_source=share-link&utm_medium=link&utm_campaign=share-link)
[![Donate](https://img.shields.io/badge/Donate-PayPal-orange.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=clement%2emichaud34%40gmail%2ecom&lc=FR&item_name=Authelia¤cy_code=EUR&bn=PP%2dDonationsBF%3abtn_donate_SM%2egif%3aNonHosted)
-**Authelia** is an open-source authentication and authorization providing
- 2-factor authentication and single sign-on (SSO) for your applications.
+**Authelia** is an open-source authentication and authorization server
+providing 2-factor authentication and single sign-on (SSO) for your
+applications.
It acts as a companion of reverse proxies by handling authentication and
authorization requests.
@@ -20,15 +21,17 @@ for specific services in only few seconds.
-
+
## Features summary
Here is the list of the main available features:
-* **[U2F] - Universal 2-Factor -** support with [Yubikey].
-* **[TOTP] - Time-Base One Time password -** support with [Google Authenticator].
+* Several kind of second factor:
+ * **[Security Key (U2F)](./docs/2factor/security-key.md)** support with [Yubikey].
+ * **[Time-based One-Time password](./docs/2factor/time-based-one-time-password.md)** support with [Google Authenticator].
+ * **[Mobile Push Notifications](./docs/2factor/duo-push-notifications.md)** with [Duo](https://duo.com/).
* Password reset with identity verification using email.
* Single-factor only authentication method available.
* Access restriction after too many authentication attempts.
@@ -43,6 +46,7 @@ For more details about the features, follow [Features](./docs/features.md).
You can start off with
+ git clone https://github.com/clems4ever/authelia.git
source bootstrap.sh
If you want to go further, please read [Getting Started](./docs/getting-started.md).
@@ -113,8 +117,8 @@ Wanna see more features? Then fuel us with a few beers!
[MIT License]: https://opensource.org/licenses/MIT
[TOTP]: https://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm
-[U2F]: https://www.yubico.com/about/background/fido/
+[Security Key]: https://www.yubico.com/about/background/fido/
[Yubikey]: https://www.yubico.com/products/yubikey-hardware/yubikey4/
[auth_request]: http://nginx.org/en/docs/http/ngx_http_auth_request_module.html
[Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en
-[config.template.yml]: https://github.com/clems4ever/authelia/blob/master/config.template.yml
+[config.template.yml]: https://github.com/clems4ever/authelia/blob/master/config.template.yml
\ No newline at end of file
diff --git a/bootstrap.sh b/bootstrap.sh
index 4c92e6a5..8d5fc04f 100644
--- a/bootstrap.sh
+++ b/bootstrap.sh
@@ -24,24 +24,6 @@ then
return;
fi
-echo "[BOOTSTRAP] Checking if example.com domain is forwarded to your machine..."
-cat /etc/hosts | grep "login.example.com" > /dev/null
-if [ $? -ne 0 ];
-then
- echo "[ERROR] Please add those lines to /etc/hosts:
-
-127.0.0.1 home.example.com
-127.0.0.1 public.example.com
-127.0.0.1 secure.example.com
-127.0.0.1 dev.example.com
-127.0.0.1 admin.example.com
-127.0.0.1 mx1.mail.example.com
-127.0.0.1 mx2.mail.example.com
-127.0.0.1 singlefactor.example.com
-127.0.0.1 login.example.com"
- return;
-fi
-
echo "[BOOTSTRAP] Running additional bootstrap steps..."
authelia-scripts bootstrap
diff --git a/client/src/assets/scss/components/SecondFactorDuoPush/SecondFactorDuoPush.module.scss b/client/src/assets/scss/components/SecondFactorDuoPush/SecondFactorDuoPush.module.scss
new file mode 100644
index 00000000..22787100
--- /dev/null
+++ b/client/src/assets/scss/components/SecondFactorDuoPush/SecondFactorDuoPush.module.scss
@@ -0,0 +1,15 @@
+@import '../../variables.scss';
+
+.image {
+ width: '120px';
+}
+
+.imageContainer {
+ text-align: center;
+ margin-top: ($theme-spacing) * 2;
+ margin-bottom: ($theme-spacing) * 2;
+}
+
+.retryContainer {
+ text-align: center;
+}
\ No newline at end of file
diff --git a/client/src/behaviors/GetAvailable2faMethods.ts b/client/src/behaviors/GetAvailable2faMethods.ts
new file mode 100644
index 00000000..431dfde1
--- /dev/null
+++ b/client/src/behaviors/GetAvailable2faMethods.ts
@@ -0,0 +1,13 @@
+import { Dispatch } from "redux";
+import AutheliaService from "../services/AutheliaService";
+import { getAvailbleMethods, getAvailbleMethodsSuccess, getAvailbleMethodsFailure } from "../reducers/Portal/SecondFactor/actions";
+
+export default async function(dispatch: Dispatch) {
+ dispatch(getAvailbleMethods());
+ try {
+ const methods = await AutheliaService.getAvailable2faMethods();
+ dispatch(getAvailbleMethodsSuccess(methods));
+ } catch (err) {
+ dispatch(getAvailbleMethodsFailure(err.message))
+ }
+}
\ No newline at end of file
diff --git a/client/src/behaviors/TriggerDuoPushAuth.ts b/client/src/behaviors/TriggerDuoPushAuth.ts
new file mode 100644
index 00000000..61cbe67f
--- /dev/null
+++ b/client/src/behaviors/TriggerDuoPushAuth.ts
@@ -0,0 +1,18 @@
+import { Dispatch } from "redux";
+import AutheliaService from "../services/AutheliaService";
+import { triggerDuoPushAuth, triggerDuoPushAuthSuccess, triggerDuoPushAuthFailure } from "../reducers/Portal/SecondFactor/actions";
+
+export default async function(dispatch: Dispatch, redirectionUrl: string | null) {
+ dispatch(triggerDuoPushAuth());
+ try {
+ const res = await AutheliaService.triggerDuoPush(redirectionUrl);
+ const body = await res.json();
+ if ('error' in body) {
+ throw new Error(body['error']);
+ }
+ dispatch(triggerDuoPushAuthSuccess());
+ return body;
+ } catch (err) {
+ dispatch(triggerDuoPushAuthFailure(err.message))
+ }
+}
\ No newline at end of file
diff --git a/client/src/components/SecondFactorDuoPush/SecondFactorDuoPush.tsx b/client/src/components/SecondFactorDuoPush/SecondFactorDuoPush.tsx
new file mode 100644
index 00000000..29d30973
--- /dev/null
+++ b/client/src/components/SecondFactorDuoPush/SecondFactorDuoPush.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+
+import classnames from 'classnames';
+import CircleLoader, { Status } from '../../components/CircleLoader/CircleLoader';
+import styles from '../../assets/scss/components/SecondFactorDuoPush/SecondFactorDuoPush.module.scss';
+import { Button } from '@material/react-button';
+
+export interface OwnProps {
+ redirectionUrl: string | null;
+}
+
+export interface StateProps {
+ duoPushVerified: boolean | null;
+ duoPushError: string | null;
+}
+
+export interface DispatchProps {
+ onInit: () => void;
+ onRetryClicked: () => void;
+}
+
+export type Props = OwnProps & StateProps & DispatchProps;
+
+export default class SecondFactorDuoPush extends React.Component {
+ componentWillMount() {
+ this.props.onInit();
+ }
+
+ render() {
+ let u2fStatus = Status.LOADING;
+ if (this.props.duoPushVerified === true) {
+ u2fStatus = Status.SUCCESSFUL;
+ } else if (this.props.duoPushError) {
+ u2fStatus = Status.FAILURE;
+ }
+ return (
+
+
You will soon receive a push notification on your phone.
+
+
+
+ {(u2fStatus == Status.FAILURE)
+ ?
+ Retry
+
+ : null}
+
+ )
+ }
+}
\ No newline at end of file
diff --git a/client/src/components/SecondFactorForm/SecondFactorForm.tsx b/client/src/components/SecondFactorForm/SecondFactorForm.tsx
index fef245ce..ce2b0b05 100644
--- a/client/src/components/SecondFactorForm/SecondFactorForm.tsx
+++ b/client/src/components/SecondFactorForm/SecondFactorForm.tsx
@@ -3,8 +3,9 @@ import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorFo
import Method2FA from '../../types/Method2FA';
import SecondFactorTOTP from '../../containers/components/SecondFactorTOTP/SecondFactorTOTP';
import SecondFactorU2F from '../../containers/components/SecondFactorU2F/SecondFactorU2F';
-import { Button } from '@material/react-button';
import classnames from 'classnames';
+import SecondFactorDuoPush from '../../containers/components/SecondFactorDuoPush/SecondFactorDuoPush';
+import UseAnotherMethod from '../../containers/components/UseAnotherMethod/UseAnotherMethod';
export interface OwnProps {
username: string;
@@ -19,8 +20,6 @@ export interface StateProps {
export interface DispatchProps {
onInit: () => void;
onLogoutClicked: () => void;
- onOneTimePasswordMethodClicked: () => void;
- onSecurityKeyMethodClicked: () => void;
onUseAnotherMethodClicked: () => void;
}
@@ -37,6 +36,9 @@ class SecondFactorForm extends Component {
if (method == 'u2f') {
title = "Security Key";
methodComponent = ( );
+ } else if (method == "duo_push") {
+ title = "Duo Push Notification";
+ methodComponent = ( );
} else {
title = "One-Time Password"
methodComponent = ( );
@@ -50,22 +52,10 @@ class SecondFactorForm extends Component {
);
}
- private renderUseAnotherMethod() {
- return (
-
-
Choose a method
-
- One-Time Password
- Security Key (U2F)
-
-
- );
- }
-
private renderUseAnotherMethodLink() {
return (
@@ -78,11 +68,11 @@ class SecondFactorForm extends Component {
Hello {this.props.username}
- {(this.props.useAnotherMethod) ? this.renderUseAnotherMethod() : this.renderMethod()}
+ {(this.props.useAnotherMethod) ? : this.renderMethod()}
{(this.props.useAnotherMethod) ? null : this.renderUseAnotherMethodLink()}
diff --git a/client/src/components/SecondFactorTOTP/SecondFactorTOTP.tsx b/client/src/components/SecondFactorTOTP/SecondFactorTOTP.tsx
index a64d3473..ba79ef5d 100644
--- a/client/src/components/SecondFactorTOTP/SecondFactorTOTP.tsx
+++ b/client/src/components/SecondFactorTOTP/SecondFactorTOTP.tsx
@@ -68,7 +68,7 @@ export default class SecondFactorTOTP extends React.Component {
value={this.state.oneTimePassword} />
-
Register new device
diff --git a/client/src/components/SecondFactorU2F/SecondFactorU2F.tsx b/client/src/components/SecondFactorU2F/SecondFactorU2F.tsx
index d58899d0..9ce86cf9 100644
--- a/client/src/components/SecondFactorU2F/SecondFactorU2F.tsx
+++ b/client/src/components/SecondFactorU2F/SecondFactorU2F.tsx
@@ -41,7 +41,7 @@ export default class SecondFactorU2F extends React.Component
{
-
Register new device
diff --git a/client/src/components/UseAnotherMethod/UseAnotherMethod.tsx b/client/src/components/UseAnotherMethod/UseAnotherMethod.tsx
new file mode 100644
index 00000000..8f520d58
--- /dev/null
+++ b/client/src/components/UseAnotherMethod/UseAnotherMethod.tsx
@@ -0,0 +1,67 @@
+import React, { Component } from 'react';
+import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss';
+import Method2FA from '../../types/Method2FA';
+import { Button } from '@material/react-button';
+import classnames from 'classnames';
+
+export interface OwnProps {}
+
+export interface StateProps {
+ availableMethods: Method2FA[] | null;
+ isSecurityKeySupported: boolean;
+}
+
+export interface DispatchProps {
+ onOneTimePasswordMethodClicked: () => void;
+ onSecurityKeyMethodClicked: () => void;
+ onDuoPushMethodClicked: () => void;
+}
+
+export type Props = OwnProps & StateProps & DispatchProps;
+
+interface MethodDescription {
+ name: string;
+ onClicked: () => void;
+ key: Method2FA;
+}
+
+class UseAnotherMethod extends Component
{
+ render() {
+ const methods: MethodDescription[] = [
+ {
+ name: "One-Time Password",
+ onClicked: this.props.onOneTimePasswordMethodClicked,
+ key: "totp"
+ },
+ {
+ name: "Security Key (U2F)",
+ onClicked: this.props.onSecurityKeyMethodClicked,
+ key: "u2f"
+ },
+ {
+ name: "Duo Push Notification",
+ onClicked: this.props.onDuoPushMethodClicked,
+ key: "duo_push"
+ }
+ ];
+
+ const methodsComponents = methods
+ // Filter out security key if not supported by browser.
+ .filter(m => m.key !== "u2f" || (m.key === "u2f" && this.props.isSecurityKeySupported))
+ // Filter out the methods that are not supported by the server.
+ .filter(m => this.props.availableMethods && this.props.availableMethods.includes(m.key))
+ // Create the buttons
+ .map(m => {m.name} );
+
+ return (
+
+
Choose a method
+
+ {methodsComponents}
+
+
+ )
+ }
+}
+
+export default UseAnotherMethod;
\ No newline at end of file
diff --git a/client/src/containers/components/SecondFactorDuoPush/SecondFactorDuoPush.ts b/client/src/containers/components/SecondFactorDuoPush/SecondFactorDuoPush.ts
new file mode 100644
index 00000000..2f23d23e
--- /dev/null
+++ b/client/src/containers/components/SecondFactorDuoPush/SecondFactorDuoPush.ts
@@ -0,0 +1,55 @@
+import { connect } from 'react-redux';
+import { RootState } from '../../../reducers';
+import { Dispatch } from 'redux';
+import SecondFactorDuoPush, { StateProps, OwnProps, DispatchProps } from '../../../components/SecondFactorDuoPush/SecondFactorDuoPush';
+import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
+import TriggerDuoPushAuth from '../../../behaviors/TriggerDuoPushAuth';
+
+
+const mapStateToProps = (state: RootState): StateProps => ({
+ duoPushVerified: state.secondFactor.duoPushVerificationSuccess,
+ duoPushError: state.secondFactor.duoPushVerificationError,
+});
+
+async function redirectIfPossible(body: any) {
+ if ('redirect' in body) {
+ window.location.href = body['redirect'];
+ return true;
+ }
+ return false;
+}
+
+async function handleSuccess(dispatch: Dispatch, res: Response, duration?: number) {
+ async function handle() {
+ const redirected = await redirectIfPossible(res);
+ if (!redirected) {
+ await FetchStateBehavior(dispatch);
+ }
+ }
+
+ if (duration) {
+ setTimeout(handle, duration);
+ } else {
+ await handle();
+ }
+}
+
+async function triggerDuoPushAuth(dispatch: Dispatch, redirectionUrl: string | null) {
+ const res = await TriggerDuoPushAuth(dispatch, redirectionUrl);
+ if (!res) return;
+ await handleSuccess(dispatch, res, 2000);
+}
+
+const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps): DispatchProps => {
+ return {
+ onInit: async () => {
+ await triggerDuoPushAuth(dispatch, ownProps.redirectionUrl);
+ },
+ onRetryClicked: async () => {
+ await triggerDuoPushAuth(dispatch, ownProps.redirectionUrl);
+ }
+ }
+}
+
+
+export default connect(mapStateToProps, mapDispatchToProps)(SecondFactorDuoPush);
\ No newline at end of file
diff --git a/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts b/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts
index 66b1e5ea..f2cb5038 100644
--- a/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts
+++ b/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts
@@ -5,9 +5,10 @@ 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';
+import { setUseAnotherMethod, setSecurityKeySupported } from '../../../reducers/Portal/SecondFactor/actions';
+import GetAvailable2faMethods from '../../../behaviors/GetAvailable2faMethods';
+import u2fApi from 'u2f-api';
+
const mapStateToProps = (state: RootState): StateProps => {
return {
@@ -16,21 +17,14 @@ const mapStateToProps = (state: RootState): StateProps => {
}
}
-async function storeMethod(dispatch: Dispatch, method: Method2FA) {
- // display the new option
- dispatch(getPreferedMethodSuccess(method));
- dispatch(setUseAnotherMethod(false));
-
- // And save the method for next time.
- await SetPrefered2faMethod(dispatch, method);
-}
-
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
return {
- onInit: () => FetchPrefered2faMethod(dispatch),
+ onInit: async () => {
+ dispatch(setSecurityKeySupported(await u2fApi.isSupported()));
+ FetchPrefered2faMethod(dispatch);
+ GetAvailable2faMethods(dispatch);
+ },
onLogoutClicked: () => LogoutBehavior(dispatch),
- onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'),
- onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'),
onUseAnotherMethodClicked: () => dispatch(setUseAnotherMethod(true)),
}
}
diff --git a/client/src/containers/components/SecondFactorU2F/SecondFactorU2F.ts b/client/src/containers/components/SecondFactorU2F/SecondFactorU2F.ts
index bbf645f9..d05f31a9 100644
--- a/client/src/containers/components/SecondFactorU2F/SecondFactorU2F.ts
+++ b/client/src/containers/components/SecondFactorU2F/SecondFactorU2F.ts
@@ -10,7 +10,6 @@ import {
securityKeySignSuccess,
securityKeySign,
securityKeySignFailure,
- setSecurityKeySupported
} from '../../../reducers/Portal/SecondFactor/actions';
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
@@ -94,11 +93,7 @@ const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => {
await dispatch(push('/confirmation-sent'));
},
onInit: async () => {
- const isU2FSupported = await u2fApi.isSupported();
- if (isU2FSupported) {
- await dispatch(setSecurityKeySupported(true));
- await triggerSecurityKeySigning(dispatch, ownProps.redirectionUrl);
- }
+ await triggerSecurityKeySigning(dispatch, ownProps.redirectionUrl);
},
}
}
diff --git a/client/src/containers/components/UseAnotherMethod/UseAnotherMethod.tsx b/client/src/containers/components/UseAnotherMethod/UseAnotherMethod.tsx
new file mode 100644
index 00000000..adcd88ee
--- /dev/null
+++ b/client/src/containers/components/UseAnotherMethod/UseAnotherMethod.tsx
@@ -0,0 +1,31 @@
+import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
+import { RootState } from '../../../reducers';
+import SetPrefered2faMethod from '../../../behaviors/SetPrefered2faMethod';
+import { getPreferedMethodSuccess, setUseAnotherMethod, setSecurityKeySupported } from '../../../reducers/Portal/SecondFactor/actions';
+import Method2FA from '../../../types/Method2FA';
+import UseAnotherMethod, {StateProps, DispatchProps} from '../../../components/UseAnotherMethod/UseAnotherMethod';
+
+const mapStateToProps = (state: RootState): StateProps => ({
+ availableMethods: state.secondFactor.getAvailableMethodResponse,
+ isSecurityKeySupported: state.secondFactor.securityKeySupported,
+})
+
+async function storeMethod(dispatch: Dispatch, method: Method2FA) {
+ // display the new option
+ dispatch(getPreferedMethodSuccess(method));
+ dispatch(setUseAnotherMethod(false));
+
+ // And save the method for next time.
+ await SetPrefered2faMethod(dispatch, method);
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
+ return {
+ onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'),
+ onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'),
+ onDuoPushMethodClicked: () => storeMethod(dispatch, "duo_push"),
+ }
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(UseAnotherMethod);
\ No newline at end of file
diff --git a/client/src/index.css b/client/src/index.css
index 4821f2cc..b3757ca5 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -9,3 +9,8 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
+
+a {
+ text-decoration: underline;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/client/src/reducers/Portal/SecondFactor/actions.ts b/client/src/reducers/Portal/SecondFactor/actions.ts
index eaeae9cf..327fafbc 100644
--- a/client/src/reducers/Portal/SecondFactor/actions.ts
+++ b/client/src/reducers/Portal/SecondFactor/actions.ts
@@ -16,7 +16,13 @@ import {
SET_PREFERED_METHOD,
SET_PREFERED_METHOD_FAILURE,
SET_PREFERED_METHOD_SUCCESS,
- SET_USE_ANOTHER_METHOD
+ SET_USE_ANOTHER_METHOD,
+ TRIGGER_DUO_PUSH_AUTH,
+ TRIGGER_DUO_PUSH_AUTH_SUCCESS,
+ TRIGGER_DUO_PUSH_AUTH_FAILURE,
+ GET_AVAILABLE_METHODS,
+ GET_AVAILABLE_METHODS_SUCCESS,
+ GET_AVAILABLE_METHODS_FAILURE
} from "../../constants";
import Method2FA from "../../../types/Method2FA";
@@ -28,6 +34,16 @@ export const setUseAnotherMethod = createAction(SET_USE_ANOTHER_METHOD, resolve
return (useAnotherMethod: boolean) => resolve(useAnotherMethod);
});
+
+export const getAvailbleMethods = createAction(GET_AVAILABLE_METHODS);
+export const getAvailbleMethodsSuccess = createAction(GET_AVAILABLE_METHODS_SUCCESS, resolve => {
+ return (methods: Method2FA[]) => resolve(methods);
+});
+export const getAvailbleMethodsFailure = createAction(GET_AVAILABLE_METHODS_FAILURE, resolve => {
+ return (err: string) => resolve(err);
+});
+
+
export const getPreferedMethod = createAction(GET_PREFERED_METHOD);
export const getPreferedMethodSuccess = createAction(GET_PREFERED_METHOD_SUCCESS, resolve => {
return (method: Method2FA) => resolve(method);
@@ -36,18 +52,21 @@ export const getPreferedMethodFailure = createAction(GET_PREFERED_METHOD_FAILURE
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 securityKeySignSuccess = createAction(SECURITY_KEY_SIGN_SUCCESS);
export const securityKeySignFailure = createAction(SECURITY_KEY_SIGN_FAILURE, resolve => {
return (error: string) => resolve(error);
});
+
export const oneTimePasswordVerification = createAction(ONE_TIME_PASSWORD_VERIFICATION_REQUEST);
export const oneTimePasswordVerificationSuccess = createAction(ONE_TIME_PASSWORD_VERIFICATION_SUCCESS);
export const oneTimePasswordVerificationFailure = createAction(ONE_TIME_PASSWORD_VERIFICATION_FAILURE, resolve => {
@@ -55,6 +74,13 @@ export const oneTimePasswordVerificationFailure = createAction(ONE_TIME_PASSWORD
});
+export const triggerDuoPushAuth = createAction(TRIGGER_DUO_PUSH_AUTH);
+export const triggerDuoPushAuthSuccess = createAction(TRIGGER_DUO_PUSH_AUTH_SUCCESS);
+export const triggerDuoPushAuthFailure = createAction(TRIGGER_DUO_PUSH_AUTH_FAILURE, resolve => {
+ return (err: string) => resolve(err);
+});
+
+
export const logout = createAction(LOGOUT_REQUEST);
export const logoutSuccess = createAction(LOGOUT_SUCCESS);
export const logoutFailure = createAction(LOGOUT_FAILURE, resolve => {
diff --git a/client/src/reducers/Portal/SecondFactor/reducer.ts b/client/src/reducers/Portal/SecondFactor/reducer.ts
index e12608e4..15bd3f49 100644
--- a/client/src/reducers/Portal/SecondFactor/reducer.ts
+++ b/client/src/reducers/Portal/SecondFactor/reducer.ts
@@ -12,6 +12,10 @@ interface SecondFactorState {
userAnotherMethod: boolean;
+ getAvailableMethodsLoading: boolean;
+ getAvailableMethodResponse: Method2FA[] | null;
+ getAvailableMethodError: string | null;
+
preferedMethodLoading: boolean;
preferedMethodError: string | null;
preferedMethod: Method2FA | null;
@@ -27,6 +31,10 @@ interface SecondFactorState {
oneTimePasswordVerificationLoading: boolean,
oneTimePasswordVerificationSuccess: boolean | null,
oneTimePasswordVerificationError: string | null,
+
+ duoPushVerificationLoading: boolean;
+ duoPushVerificationSuccess: boolean | null;
+ duoPushVerificationError: string | null;
}
const secondFactorInitialState: SecondFactorState = {
@@ -36,6 +44,10 @@ const secondFactorInitialState: SecondFactorState = {
userAnotherMethod: false,
+ getAvailableMethodsLoading: false,
+ getAvailableMethodResponse: null,
+ getAvailableMethodError: null,
+
preferedMethod: null,
preferedMethodError: null,
preferedMethodLoading: false,
@@ -51,6 +63,10 @@ const secondFactorInitialState: SecondFactorState = {
oneTimePasswordVerificationLoading: false,
oneTimePasswordVerificationError: null,
oneTimePasswordVerificationSuccess: null,
+
+ duoPushVerificationLoading: false,
+ duoPushVerificationSuccess: null,
+ duoPushVerificationError: null,
}
export type PortalState = StateType;
@@ -163,6 +179,45 @@ export default (state = secondFactorInitialState, action: SecondFactorAction): S
...state,
userAnotherMethod: action.payload,
}
+ case getType(Actions.triggerDuoPushAuth):
+ return {
+ ...state,
+ duoPushVerificationLoading: true,
+ duoPushVerificationError: null,
+ duoPushVerificationSuccess: null,
+ }
+ case getType(Actions.triggerDuoPushAuthSuccess):
+ return {
+ ...state,
+ duoPushVerificationLoading: false,
+ duoPushVerificationSuccess: true,
+ }
+ case getType(Actions.triggerDuoPushAuthFailure):
+ return {
+ ...state,
+ duoPushVerificationLoading: false,
+ duoPushVerificationError: action.payload,
+ }
+
+ case getType(Actions.getPreferedMethod):
+ return {
+ ...state,
+ getAvailableMethodsLoading: true,
+ getAvailableMethodResponse: null,
+ getAvailableMethodError: null,
+ }
+ case getType(Actions.getAvailbleMethodsSuccess):
+ return {
+ ...state,
+ getAvailableMethodsLoading: false,
+ getAvailableMethodResponse: action.payload,
+ }
+ case getType(Actions.getAvailbleMethodsFailure):
+ return {
+ ...state,
+ getAvailableMethodsLoading: false,
+ getAvailableMethodError: action.payload,
+ }
}
return state;
}
\ No newline at end of file
diff --git a/client/src/reducers/constants.ts b/client/src/reducers/constants.ts
index abbd5f05..7e37d3e3 100644
--- a/client/src/reducers/constants.ts
+++ b/client/src/reducers/constants.ts
@@ -12,6 +12,10 @@ export const AUTHENTICATE_FAILURE = '@portal/authenticate_failure';
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_AVAILABLE_METHODS = '@portal/second_factor/get_available_methods';
+export const GET_AVAILABLE_METHODS_SUCCESS = '@portal/second_factor/get_available_methods_success';
+export const GET_AVAILABLE_METHODS_FAILURE = '@portal/second_factor/get_available_methods_failure';
+
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';
@@ -28,6 +32,10 @@ export const ONE_TIME_PASSWORD_VERIFICATION_REQUEST = '@portal/second_factor/one
export const ONE_TIME_PASSWORD_VERIFICATION_SUCCESS = '@portal/second_factor/one_time_password_verification_success';
export const ONE_TIME_PASSWORD_VERIFICATION_FAILURE = '@portal/second_factor/one_time_password_verification_failure';
+export const TRIGGER_DUO_PUSH_AUTH = '@portal/second_factor/trigger_duo_push_auth_request';
+export const TRIGGER_DUO_PUSH_AUTH_SUCCESS = '@portal/second_factor/trigger_duo_push_auth_request_success';
+export const TRIGGER_DUO_PUSH_AUTH_FAILURE = '@portal/second_factor/trigger_duo_push_auth_request_failure';
+
export const LOGOUT_REQUEST = '@portal/logout_request';
export const LOGOUT_SUCCESS = '@portal/logout_success';
export const LOGOUT_FAILURE = '@portal/logout_failure';
diff --git a/client/src/services/AutheliaService.ts b/client/src/services/AutheliaService.ts
index a0c5d1f5..2e4ee0fd 100644
--- a/client/src/services/AutheliaService.ts
+++ b/client/src/services/AutheliaService.ts
@@ -113,6 +113,21 @@ class AutheliaService {
})
}
+ static async triggerDuoPush(redirectionUrl: string | null): Promise {
+
+ const headers: Record = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ }
+ if (redirectionUrl) {
+ headers['X-Target-Url'] = redirectionUrl;
+ }
+ return this.fetchSafe('/api/duo-push', {
+ method: 'POST',
+ headers: headers,
+ })
+ }
+
static async initiatePasswordResetIdentityValidation(username: string) {
return this.fetchSafe('/api/password-reset/identity/start', {
method: 'POST',
@@ -156,6 +171,10 @@ class AutheliaService {
body: JSON.stringify({method})
});
}
+
+ static async getAvailable2faMethods(): Promise {
+ return await this.fetchSafeJson('/api/secondfactor/available');
+ }
}
export default AutheliaService;
\ No newline at end of file
diff --git a/client/src/types/Method2FA.ts b/client/src/types/Method2FA.ts
index 09e0b3c4..c46b4b27 100644
--- a/client/src/types/Method2FA.ts
+++ b/client/src/types/Method2FA.ts
@@ -1,4 +1,4 @@
-type Method2FA = "u2f" | "totp";
+type Method2FA = "u2f" | "totp" | "duo_push";
export default Method2FA;
\ No newline at end of file
diff --git a/config.template.yml b/config.template.yml
index a29682b8..727858bf 100644
--- a/config.template.yml
+++ b/config.template.yml
@@ -29,6 +29,15 @@ default_redirection_url: https://home.example.com:8080/
totp:
issuer: authelia.com
+# Duo Push API
+#
+# Parameters used to contact the Duo API. Those are generated when you protect an application
+# of type "Partner Auth API" in the management panel.
+duo_api:
+ hostname: api-123456789.example.com
+ integration_key: ABCDEF
+ secret_key: 1234567890abcdefghifjkl
+
# The authentication backend to use for verifying user passwords
# and retrieve information such as email address and groups
# users belong to.
diff --git a/docs/2factor/duo-push-notifications.md b/docs/2factor/duo-push-notifications.md
new file mode 100644
index 00000000..7b2eaf59
--- /dev/null
+++ b/docs/2factor/duo-push-notifications.md
@@ -0,0 +1,47 @@
+# Duo Push Notification
+
+Using mobile push notifications is becoming the new trendy way to validate
+the second factor of a 2FA authentication process. [Duo](https://duo.com/) is offering an API
+to integrate this kind validation and **Authelia** leverages this mechanism
+so that you can simply push a button on your smartphone to be securely granted
+access to your services.
+
+
+
+
+
+In order to use this feature, you should first create a free account on Duo
+(up to 10 users), create a user account and attach it a mobile device. The name
+of the user must match the name of the user in your internal database.
+Then, click on *Applications* and *Protect an Application*. Then select the option
+called *Partner Auth API*. This will generate an integration key, a secret key and
+a hostname. You can set the name of the application to **Authelia** and then you
+must add the generated information to your configuration as:
+
+ duo_api:
+ hostname: api-123456789.example.com
+ integration_key: ABCDEF
+ secret_key: 1234567890abcdefghifjkl
+
+This can be seen in [config.template.yml](../../config.template.yml) file.
+
+When selecting *Duo Push Notification* at the second factor stage, you will
+automatically receive a push notification on your phone to grant or deny access.
+
+
+
+
+
+
+## Limitations
+
+Users must be enrolled via the Duo Admin panel, they cannot enroll a device from
+**Authelia** yet.
+
+
+## FAQ
+
+### Why don't I have access to the *Duo Push Notification* option?
+
+It's likely that you have not configured **Authelia** correctly. Please read this
+documentation again and be sure you had a look at [config.template.yml](../../config.template.yml).
\ No newline at end of file
diff --git a/docs/2factor/security-key.md b/docs/2factor/security-key.md
new file mode 100644
index 00000000..c1f748cf
--- /dev/null
+++ b/docs/2factor/security-key.md
@@ -0,0 +1,40 @@
+# Security Keys (U2F)
+
+**Authelia** also offers authentication using Security Keys supporting U2F
+like [Yubikey](Yubikey) USB devices. U2F is one of the most secure
+authentication protocol and is already available for Google, Facebook, Github
+accounts and more.
+
+The protocol requires your security key being enrolled before authenticating.
+
+
+
+
+
+To do so, select the *Security Key* method in the second factor page and click
+on the *register new device* link. This will send a link to the
+user email address. This e-mail will likely be sent to https://mail.example.com:8080/
+if you're testing Authelia and you've not configured anything.
+
+Confirm your identity by clicking on **Continue** and you'll be asked to
+touch the token of your security key to enroll.
+
+
+
+
+
+Upon successful registration, you can authenticate using your security key by simply
+touching the token again.
+
+Easy, right?!
+
+## FAQ
+
+### Why don't I have access to the *Security Key* option?
+
+U2F protocol is a new protocol that is only supported by recent browser
+and must even be enabled on some of them like Firefox. Please be sure
+your browser supports U2F and that the feature is enabled to make the
+option available in **Authelia**.
+
+[Yubikey]: https://www.yubico.com/products/yubikey-hardware/yubikey4/
diff --git a/docs/2factor/time-based-one-time-password.md b/docs/2factor/time-based-one-time-password.md
new file mode 100644
index 00000000..2ef9bbf7
--- /dev/null
+++ b/docs/2factor/time-based-one-time-password.md
@@ -0,0 +1,29 @@
+# One-Time Passwords
+
+In **Authelia**, your users can use [Google Authenticator] for generating unique
+tokens that they can use to pass the second factor.
+
+
+
+
+
+Select the *One-Time Password method* and click on the *register new device* link.
+Then, check the email sent by **Authelia** to your email address
+to validate your identity. If you're testing **Authelia**, it's likely
+that this e-mail has been sent to https://mail.example.com:8080/
+
+Confirm your identity by clicking on **Continue** and you'll get redirected
+on a page where your secret will be displayed as QRCode and in Base32 formats.
+
+
+
+
+
+You can use [Google Authenticator] to store it.
+
+From now on, you'll get generated
+tokens from your phone that you can use to validate the second factor in **Authelia**.
+
+
+
+[Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en
\ No newline at end of file
diff --git a/docs/features.md b/docs/features.md
index b601cda9..f811973e 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -15,39 +15,16 @@ You can find an example of the configuration of the LDAP backend in
-## Second factor with TOTP
+## Second factor
-In **Authelia**, you can register a per user TOTP (Time-Based One Time
-Password) secret before being being able to authenticate. Click on the
-register button and check the email **Authelia** sent to your email address
-to validate your identity.
+**Authelia** comes with three kind of second factor.
-Confirm your identity by clicking on **Continue** and you'll get redirected
-on a page where your secret will be displayed in QRCode and Base32 formats.
-You can use [Google Authenticator] to store it and get the generated tokens.
+* Security keys like [Yubikey]. More info [here](./2factor/security-key.md).
+* One-Time Passwords generated by [Google Authenticator]. More info [here](./2factor/time-based-one-time-password.md).
+* Duo Push Notifications to use with [Duo mobile application](https://play.google.com/store/apps/details?id=com.duosecurity.duomobile&hl=en) available on Android, iOS and Windows. More info [here](./2factor/duo-push-notifications.md).
-
-
-
-## Second factor with U2F security keys
-
-**Authelia** also offers authentication using U2F (Universal 2-Factor) devices
-like [Yubikey](Yubikey) USB security keys. U2F is one of the most secure
-authentication protocol and is already available for Google, Facebook, Github
-accounts and more.
-
-Like TOTP, U2F requires you register your security key before authenticating.
-To do so, click on the register button. This will send a link to the
-user email address.
-Confirm your identity by clicking on **Continue** and you'll be asked to
-touch the token of your device to register. Upon successful registration,
-you can authenticate using your U2F device by simply touching the token.
-
-Easy, right?!
-
-
-
+
## Password reset
@@ -96,5 +73,5 @@ Redis key/value store. You can specify your own Redis instance in
[basic authentication]: https://en.wikipedia.org/wiki/Basic_access_authentication
[config.template.yml]: https://github.com/clems4ever/authelia/blob/master/config.template.yml
-[Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en
[Yubikey]: https://www.yubico.com/products/yubikey-hardware/yubikey4/
+[Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en
\ No newline at end of file
diff --git a/example/compose/duo-api/Dockerfile b/example/compose/duo-api/Dockerfile
new file mode 100644
index 00000000..fc70d55d
--- /dev/null
+++ b/example/compose/duo-api/Dockerfile
@@ -0,0 +1,12 @@
+FROM node:8.7.0-alpine
+
+WORKDIR /usr/app/src
+
+ADD package.json package.json
+RUN npm install --production --quiet
+
+ADD duo_api.js duo_api.js
+
+EXPOSE 3000
+
+CMD ["node", "duo_api.js"]
\ No newline at end of file
diff --git a/example/compose/duo-api/docker-compose.yml b/example/compose/duo-api/docker-compose.yml
new file mode 100644
index 00000000..0932084a
--- /dev/null
+++ b/example/compose/duo-api/docker-compose.yml
@@ -0,0 +1,6 @@
+version: '2'
+services:
+ duo-api:
+ image: authelia-duo-api
+ networks:
+ - authelianet
diff --git a/example/compose/duo-api/duo_api.js b/example/compose/duo-api/duo_api.js
new file mode 100644
index 00000000..5181f5c7
--- /dev/null
+++ b/example/compose/duo-api/duo_api.js
@@ -0,0 +1,66 @@
+/*
+ * This is a script to fake the Duo API for push notifications.
+ *
+ * Access is allowed by default but one can change the behavior at runtime
+ * by POSTing to /allow or /deny. Then the /auth/v2/auth endpoint will act
+ * accordingly.
+ */
+
+const express = require("express");
+const app = express();
+const port = 3000;
+
+app.set('trust proxy', true);
+
+let permission = 'allow';
+
+app.post('/allow', (req, res) => {
+ permission = 'allow';
+ res.send('ALLOWED');
+});
+
+app.post('/deny', (req, res) => {
+ permission = 'deny';
+ res.send('DENIED');
+});
+
+app.post('/auth/v2/auth', (req, res) => {
+ let response;
+ if (permission == 'allow') {
+ response = {
+ response: {
+ result: 'allow',
+ status: 'allow',
+ status_msg: 'The user allowed access.',
+ },
+ stat: 'OK',
+ };
+ } else {
+ response = {
+ response: {
+ result: 'deny',
+ status: 'deny',
+ status_msg: 'The user denied access.',
+ },
+ stat: 'OK',
+ };
+ }
+ setTimeout(() => res.json(response), 2000);
+});
+
+app.listen(port, () => console.log(`Duo API listening on port ${port}!`));
+
+// The signals we want to handle
+// NOTE: although it is tempting, the SIGKILL signal (9) cannot be intercepted and handled
+var signals = {
+ 'SIGHUP': 1,
+ 'SIGINT': 2,
+ 'SIGTERM': 15
+};
+// Create a listener for each of the signals that we want to handle
+Object.keys(signals).forEach((signal) => {
+ process.on(signal, () => {
+ console.log(`process received a ${signal} signal`);
+ process.exit(128 + signals[signal]);
+ });
+});
\ No newline at end of file
diff --git a/example/compose/duo-api/duo_client.js b/example/compose/duo-api/duo_client.js
new file mode 100644
index 00000000..ee6b2a11
--- /dev/null
+++ b/example/compose/duo-api/duo_client.js
@@ -0,0 +1,10 @@
+/*
+ * This is just client script to test the fake API.
+ */
+
+const DuoApi = require("@duosecurity/duo_api");
+
+process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;
+
+const client = new DuoApi.Client("ABCDEFG", "SECRET", "duo.example.com");
+client.jsonApiCall("POST", "/auth/v2/auth", { username: 'john', factor: "push", device: "auto" }, console.log);
\ No newline at end of file
diff --git a/example/compose/duo-api/package-lock.json b/example/compose/duo-api/package-lock.json
new file mode 100644
index 00000000..06cb0628
--- /dev/null
+++ b/example/compose/duo-api/package-lock.json
@@ -0,0 +1,358 @@
+{
+ "name": "duo-api",
+ "version": "1.0.0",
+ "lockfileVersion": 1,
+ "requires": true,
+ "dependencies": {
+ "accepts": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz",
+ "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=",
+ "requires": {
+ "mime-types": "2.1.22",
+ "negotiator": "0.6.1"
+ }
+ },
+ "array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
+ },
+ "body-parser": {
+ "version": "1.18.3",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.3.tgz",
+ "integrity": "sha1-WykhmP/dVTs6DyDe0FkrlWlVyLQ=",
+ "requires": {
+ "bytes": "3.0.0",
+ "content-type": "1.0.4",
+ "debug": "2.6.9",
+ "depd": "1.1.2",
+ "http-errors": "1.6.3",
+ "iconv-lite": "0.4.23",
+ "on-finished": "2.3.0",
+ "qs": "6.5.2",
+ "raw-body": "2.3.3",
+ "type-is": "1.6.16"
+ }
+ },
+ "bytes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
+ "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg="
+ },
+ "content-disposition": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
+ "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ="
+ },
+ "content-type": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+ "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="
+ },
+ "cookie": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
+ "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
+ },
+ "cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
+ },
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "depd": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
+ "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak="
+ },
+ "destroy": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
+ "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
+ },
+ "ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
+ },
+ "encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
+ },
+ "escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
+ },
+ "etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
+ },
+ "express": {
+ "version": "4.16.4",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz",
+ "integrity": "sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg==",
+ "requires": {
+ "accepts": "1.3.5",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.18.3",
+ "content-disposition": "0.5.2",
+ "content-type": "1.0.4",
+ "cookie": "0.3.1",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "1.1.2",
+ "encodeurl": "1.0.2",
+ "escape-html": "1.0.3",
+ "etag": "1.8.1",
+ "finalhandler": "1.1.1",
+ "fresh": "0.5.2",
+ "merge-descriptors": "1.0.1",
+ "methods": "1.1.2",
+ "on-finished": "2.3.0",
+ "parseurl": "1.3.2",
+ "path-to-regexp": "0.1.7",
+ "proxy-addr": "2.0.4",
+ "qs": "6.5.2",
+ "range-parser": "1.2.0",
+ "safe-buffer": "5.1.2",
+ "send": "0.16.2",
+ "serve-static": "1.13.2",
+ "setprototypeof": "1.1.0",
+ "statuses": "1.4.0",
+ "type-is": "1.6.16",
+ "utils-merge": "1.0.1",
+ "vary": "1.1.2"
+ }
+ },
+ "finalhandler": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
+ "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==",
+ "requires": {
+ "debug": "2.6.9",
+ "encodeurl": "1.0.2",
+ "escape-html": "1.0.3",
+ "on-finished": "2.3.0",
+ "parseurl": "1.3.2",
+ "statuses": "1.4.0",
+ "unpipe": "1.0.0"
+ }
+ },
+ "forwarded": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
+ "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ="
+ },
+ "fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac="
+ },
+ "http-errors": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz",
+ "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=",
+ "requires": {
+ "depd": "1.1.2",
+ "inherits": "2.0.3",
+ "setprototypeof": "1.1.0",
+ "statuses": "1.4.0"
+ }
+ },
+ "iconv-lite": {
+ "version": "0.4.23",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz",
+ "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==",
+ "requires": {
+ "safer-buffer": "2.1.2"
+ }
+ },
+ "inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+ },
+ "ipaddr.js": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.8.0.tgz",
+ "integrity": "sha1-6qM9bd16zo9/b+DJygRA5wZzix4="
+ },
+ "media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
+ },
+ "merge-descriptors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+ "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
+ },
+ "methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4="
+ },
+ "mime": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz",
+ "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ=="
+ },
+ "mime-db": {
+ "version": "1.38.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.38.0.tgz",
+ "integrity": "sha512-bqVioMFFzc2awcdJZIzR3HjZFX20QhilVS7hytkKrv7xFAn8bM1gzc/FOX2awLISvWe0PV8ptFKcon+wZ5qYkg=="
+ },
+ "mime-types": {
+ "version": "2.1.22",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.22.tgz",
+ "integrity": "sha512-aGl6TZGnhm/li6F7yx82bJiBZwgiEa4Hf6CNr8YO+r5UHr53tSTYZb102zyU50DOWWKeOv0uQLRL0/9EiKWCog==",
+ "requires": {
+ "mime-db": "1.38.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+ },
+ "negotiator": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz",
+ "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk="
+ },
+ "on-finished": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
+ "requires": {
+ "ee-first": "1.1.1"
+ }
+ },
+ "parseurl": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz",
+ "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M="
+ },
+ "path-to-regexp": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+ "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
+ },
+ "proxy-addr": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.4.tgz",
+ "integrity": "sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA==",
+ "requires": {
+ "forwarded": "0.1.2",
+ "ipaddr.js": "1.8.0"
+ }
+ },
+ "qs": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
+ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
+ },
+ "range-parser": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
+ "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4="
+ },
+ "raw-body": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.3.tgz",
+ "integrity": "sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw==",
+ "requires": {
+ "bytes": "3.0.0",
+ "http-errors": "1.6.3",
+ "iconv-lite": "0.4.23",
+ "unpipe": "1.0.0"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "send": {
+ "version": "0.16.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz",
+ "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==",
+ "requires": {
+ "debug": "2.6.9",
+ "depd": "1.1.2",
+ "destroy": "1.0.4",
+ "encodeurl": "1.0.2",
+ "escape-html": "1.0.3",
+ "etag": "1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "1.6.3",
+ "mime": "1.4.1",
+ "ms": "2.0.0",
+ "on-finished": "2.3.0",
+ "range-parser": "1.2.0",
+ "statuses": "1.4.0"
+ }
+ },
+ "serve-static": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz",
+ "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==",
+ "requires": {
+ "encodeurl": "1.0.2",
+ "escape-html": "1.0.3",
+ "parseurl": "1.3.2",
+ "send": "0.16.2"
+ }
+ },
+ "setprototypeof": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz",
+ "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ=="
+ },
+ "statuses": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz",
+ "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew=="
+ },
+ "type-is": {
+ "version": "1.6.16",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz",
+ "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==",
+ "requires": {
+ "media-typer": "0.3.0",
+ "mime-types": "2.1.22"
+ }
+ },
+ "unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
+ },
+ "utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
+ },
+ "vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
+ }
+ }
+}
diff --git a/example/compose/duo-api/package.json b/example/compose/duo-api/package.json
new file mode 100644
index 00000000..4d8267ba
--- /dev/null
+++ b/example/compose/duo-api/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "duo-api",
+ "version": "1.0.0",
+ "description": "",
+ "main": "duo_api.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "express": "^4.16.4"
+ }
+}
diff --git a/example/compose/nginx/backend/html/home/index.html b/example/compose/nginx/backend/html/home/index.html
index 58b86b9f..c17a53d6 100644
--- a/example/compose/nginx/backend/html/home/index.html
+++ b/example/compose/nginx/backend/html/home/index.html
@@ -12,10 +12,10 @@
one of the following links to test access control powered by Authelia.
- public.example.com / index.html
+ public.example.com /
- secure.example.com / secret.html
+ secure.example.com / secret.html
singlefactor.example.com / secret.html
diff --git a/example/compose/nginx/portal/docker-compose.yml b/example/compose/nginx/portal/docker-compose.yml
index d5fa8b3e..44622085 100644
--- a/example/compose/nginx/portal/docker-compose.yml
+++ b/example/compose/nginx/portal/docker-compose.yml
@@ -8,4 +8,6 @@ services:
ports:
- "8080:443"
networks:
- - authelianet
+ authelianet:
+ # Set the IP to be able to query on port 443
+ ipv4_address: 192.168.240.100
diff --git a/example/compose/nginx/portal/nginx.conf.ejs b/example/compose/nginx/portal/nginx.conf.ejs
index 3306e45a..2f7c14c9 100644
--- a/example/compose/nginx/portal/nginx.conf.ejs
+++ b/example/compose/nginx/portal/nginx.conf.ejs
@@ -431,5 +431,24 @@ http {
proxy_pass $upstream_endpoint;
}
}
+
+ server {
+ listen 443 ssl;
+ server_name duo.example.com;
+
+ resolver 127.0.0.11 ipv6=off;
+ set $upstream_endpoint http://duo-api:3000;
+
+ ssl_certificate /etc/ssl/server.crt;
+ ssl_certificate_key /etc/ssl/server.key;
+
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+ add_header X-Frame-Options "SAMEORIGIN";
+
+ location / {
+ proxy_set_header Host $http_host;
+ proxy_pass $upstream_endpoint;
+ }
+ }
}
diff --git a/images/2factor_duo.png b/images/2factor_duo.png
new file mode 100644
index 00000000..f68d1b48
Binary files /dev/null and b/images/2factor_duo.png differ
diff --git a/images/2factor_totp.png b/images/2factor_totp.png
new file mode 100644
index 00000000..d09a8f2e
Binary files /dev/null and b/images/2factor_totp.png differ
diff --git a/images/2factor_u2f.png b/images/2factor_u2f.png
new file mode 100644
index 00000000..8cdd2d83
Binary files /dev/null and b/images/2factor_u2f.png differ
diff --git a/images/duo-push-1.jpg b/images/duo-push-1.jpg
new file mode 100644
index 00000000..d1a74a8c
Binary files /dev/null and b/images/duo-push-1.jpg differ
diff --git a/images/duo-push-2.png b/images/duo-push-2.png
new file mode 100644
index 00000000..23b5d73e
Binary files /dev/null and b/images/duo-push-2.png differ
diff --git a/images/first_factor.png b/images/first_factor.png
index c6821ca5..60330118 100644
Binary files a/images/first_factor.png and b/images/first_factor.png differ
diff --git a/images/use-another-method.png b/images/use-another-method.png
new file mode 100644
index 00000000..560679b6
Binary files /dev/null and b/images/use-another-method.png differ
diff --git a/package-lock.json b/package-lock.json
index ba64b8be..e29d9f0b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -171,6 +171,14 @@
"to-fast-properties": "2.0.0"
}
},
+ "@duosecurity/duo_api": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@duosecurity/duo_api/-/duo_api-1.2.0.tgz",
+ "integrity": "sha512-Jxmeo5VZtaut9hELnBNZyvA7kojwRBAHl0uOk0dZSfBbphjr3QJ+92dnm/I++GPUEhEKjALLeQ9fCABwo5HsPQ==",
+ "requires": {
+ "nopt": "3.0.6"
+ }
+ },
"@sinonjs/commons": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.3.0.tgz",
@@ -482,12 +490,6 @@
"integrity": "sha512-txsii9cwD2OUOPukfPu3Jpoi3CnznBAwRX3JF26EC4p5T6IA8AaL6PBilACyY2fJkk+ydDNo4BJrJOo/OmNaZw==",
"dev": true
},
- "@types/proxyquire": {
- "version": "1.3.28",
- "resolved": "https://registry.npmjs.org/@types/proxyquire/-/proxyquire-1.3.28.tgz",
- "integrity": "sha512-SQaNzWQ2YZSr7FqAyPPiA3FYpux2Lqh3HWMZQk47x3xbMCqgC/w0dY3dw9rGqlweDDkrySQBcaScXWeR+Yb11Q==",
- "dev": true
- },
"@types/query-string": {
"version": "5.1.0",
"resolved": "http://registry.npmjs.org/@types/query-string/-/query-string-5.1.0.tgz",
@@ -595,8 +597,7 @@
"abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
- "dev": true
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
},
"accepts": {
"version": "1.3.5",
@@ -2530,16 +2531,6 @@
"integrity": "sha1-peeo/7+kk7Q7kju9TKiaU7Y7YSs=",
"dev": true
},
- "fill-keys": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz",
- "integrity": "sha1-mo+jb06K1jTjv2tPPIiCVRRS6yA=",
- "dev": true,
- "requires": {
- "is-object": "1.0.1",
- "merge-descriptors": "1.0.1"
- }
- },
"fill-range": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
@@ -4201,12 +4192,6 @@
"integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=",
"dev": true
},
- "is-object": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz",
- "integrity": "sha1-iVJojF7C/9awPsyF52ngKQMINHA=",
- "dev": true
- },
"is-path-cwd": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz",
@@ -5163,12 +5148,6 @@
"integrity": "sha1-WuDA6vj+I+AJzQH5iJtCxPY0rxI=",
"dev": true
},
- "module-not-found-error": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz",
- "integrity": "sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=",
- "dev": true
- },
"moment": {
"version": "2.22.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.1.tgz",
@@ -5812,7 +5791,6 @@
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
"integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=",
- "dev": true,
"requires": {
"abbrev": "1.1.1"
}
@@ -7288,28 +7266,6 @@
"ipaddr.js": "1.6.0"
}
},
- "proxyquire": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.0.tgz",
- "integrity": "sha512-kptdFArCfGRtQFv3Qwjr10lwbEV0TBJYvfqzhwucyfEXqVgmnAkyEw/S3FYzR5HI9i5QOq4rcqQjZ6AlknlCDQ==",
- "dev": true,
- "requires": {
- "fill-keys": "1.0.2",
- "module-not-found-error": "1.0.1",
- "resolve": "1.8.1"
- },
- "dependencies": {
- "resolve": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz",
- "integrity": "sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA==",
- "dev": true,
- "requires": {
- "path-parse": "1.0.5"
- }
- }
- }
- },
"pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
diff --git a/package.json b/package.json
index 589edd3d..727ede86 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"title": "Authelia API documentation"
},
"dependencies": {
+ "@duosecurity/duo_api": "^1.2.0",
"ajv": "^6.3.0",
"bluebird": "^3.5.0",
"body-parser": "^1.15.2",
diff --git a/scripts/authelia-scripts-bootstrap b/scripts/authelia-scripts-bootstrap
index d2832c21..2e58fb58 100755
--- a/scripts/authelia-scripts-bootstrap
+++ b/scripts/authelia-scripts-bootstrap
@@ -3,10 +3,60 @@
var { exec } = require('./utils/exec');
var fs = require('fs');
-async function main() {
+async function buildDockerImages() {
+ console.log("[BOOTSTRAP] Building required Docker images...");
+
console.log('Build authelia-example-backend docker image.')
await exec('docker build -t authelia-example-backend example/compose/nginx/backend');
+ console.log('Build authelia-duo-api docker image.')
+ await exec('docker build -t authelia-duo-api example/compose/duo-api');
+}
+
+async function checkHostsFile() {
+ async function checkAndFixEntry(entries, domain, ip) {
+ const foundEntry = entries.filter(l => l[1] == domain);
+ if (foundEntry.length > 0) {
+ if (foundEntry[0][0] == ip) {
+ // The entry exists and is correct.
+ return;
+ }
+ else {
+ // We need to remove the entry and replace it.
+ console.log(`Update entry for ${domain}.`);
+ await exec(`cat /etc/hosts | grep -v "${domain}" | /usr/bin/sudo tee /etc/hosts > /dev/null`);
+ await exec(`echo "${ip} ${domain}" | /usr/bin/sudo tee -a /etc/hosts > /dev/null`);
+ }
+ }
+ else {
+ // We need to add the new entry.
+ console.log(`Add entry for ${domain}.`);
+ await exec(`echo "${ip} ${domain}" | /usr/bin/sudo tee -a /etc/hosts > /dev/null`);
+ }
+ }
+
+ console.log("[BOOTSTRAP] Checking if example.com domain is forwarded to your machine...");
+ const actualEntries = fs.readFileSync("/etc/hosts").toString("utf-8")
+ .split("\n").filter(l => l !== '').map(l => l.split(" ").filter(w => w !== ''));
+
+ await checkAndFixEntry(actualEntries, 'login.example.com', '127.0.0.1');
+ await checkAndFixEntry(actualEntries, 'admin.example.com', '127.0.0.1');
+ await checkAndFixEntry(actualEntries, 'singlefactor.example.com', '127.0.0.1');
+ await checkAndFixEntry(actualEntries, 'dev.example.com', '127.0.0.1');
+ await checkAndFixEntry(actualEntries, 'home.example.com', '127.0.0.1');
+ await checkAndFixEntry(actualEntries, 'mx1.mail.example.com', '127.0.0.1');
+ await checkAndFixEntry(actualEntries, 'mx2.mail.example.com', '127.0.0.1');
+ await checkAndFixEntry(actualEntries, 'public.example.com', '127.0.0.1');
+ await checkAndFixEntry(actualEntries, 'secure.example.com', '127.0.0.1');
+ await checkAndFixEntry(actualEntries, 'authelia.example.com', '127.0.0.1');
+ await checkAndFixEntry(actualEntries, 'mail.example.com', '127.0.0.1');
+
+ await checkAndFixEntry(actualEntries, 'duo.example.com', '192.168.240.100');
+}
+
+async function checkKubernetesDependencies() {
+ console.log("[BOOTSTRAP] Checking Kubernetes tools in /tmp to allow testing a Kube cluster... (no junk installed on host)");
+
if (!fs.existsSync('/tmp/kind')) {
console.log('Install Kind for spawning a Kubernetes cluster.');
await exec('wget https://github.com/clems4ever/kind/releases/download/0.1.0-cmic1/kind-linux-amd64 -O /tmp/kind && chmod +x /tmp/kind');
@@ -18,6 +68,12 @@ async function main() {
}
}
+async function main() {
+ await checkHostsFile();
+ await buildDockerImages();
+ await checkKubernetesDependencies();
+}
+
main().catch((err) => {
console.error(err);
process.exit(1);
diff --git a/server/src/lib/Server.ts b/server/src/lib/Server.ts
index c36854d1..346965c7 100644
--- a/server/src/lib/Server.ts
+++ b/server/src/lib/Server.ts
@@ -40,6 +40,9 @@ export default class Server {
displayableConfiguration.notifier.email.password = STARS;
if (displayableConfiguration.notifier && displayableConfiguration.notifier.smtp)
displayableConfiguration.notifier.smtp.password = STARS;
+ if (displayableConfiguration.duo_api) {
+ displayableConfiguration.duo_api.secret_key = STARS;
+ }
this.globalLogger.debug("User configuration is %s",
JSON.stringify(displayableConfiguration, undefined, 2));
diff --git a/server/src/lib/configuration/schema/Configuration.ts b/server/src/lib/configuration/schema/Configuration.ts
index 8d16a5fb..d0584732 100644
--- a/server/src/lib/configuration/schema/Configuration.ts
+++ b/server/src/lib/configuration/schema/Configuration.ts
@@ -5,6 +5,7 @@ import { RegulationConfiguration, complete as RegulationConfigurationComplete }
import { SessionConfiguration, complete as SessionConfigurationComplete } from "./SessionConfiguration";
import { StorageConfiguration, complete as StorageConfigurationComplete } from "./StorageConfiguration";
import { TotpConfiguration, complete as TotpConfigurationComplete } from "./TotpConfiguration";
+import { DuoPushConfiguration } from "./DuoPushConfiguration";
export interface Configuration {
access_control?: ACLConfiguration;
@@ -17,6 +18,7 @@ export interface Configuration {
session?: SessionConfiguration;
storage?: StorageConfiguration;
totp?: TotpConfiguration;
+ duo_api?: DuoPushConfiguration;
}
export function complete(
diff --git a/server/src/lib/configuration/schema/DuoPushConfiguration.ts b/server/src/lib/configuration/schema/DuoPushConfiguration.ts
new file mode 100644
index 00000000..bede3767
--- /dev/null
+++ b/server/src/lib/configuration/schema/DuoPushConfiguration.ts
@@ -0,0 +1,6 @@
+
+export interface DuoPushConfiguration {
+ hostname: string;
+ integration_key: string;
+ secret_key: string;
+}
\ No newline at end of file
diff --git a/server/src/lib/routes/secondfactor/available/Get.spec.ts b/server/src/lib/routes/secondfactor/available/Get.spec.ts
new file mode 100644
index 00000000..ff700805
--- /dev/null
+++ b/server/src/lib/routes/secondfactor/available/Get.spec.ts
@@ -0,0 +1,36 @@
+import * as Express from "express";
+import { ServerVariables } from "../../../ServerVariables";
+import { ServerVariablesMockBuilder } from "../../../ServerVariablesMockBuilder.spec";
+import * as ExpressMock from "../../../stubs/express.spec";
+import Get from "./Get";
+import * as Assert from "assert";
+
+
+describe("routes/secondfactor/duo-push/Post", function() {
+ let vars: ServerVariables;
+ let req: Express.Request;
+ let res: ExpressMock.ResponseMock;
+
+ beforeEach(function() {
+ const sv = ServerVariablesMockBuilder.build();
+ vars = sv.variables;
+
+ req = ExpressMock.RequestMock();
+ res = ExpressMock.ResponseMock();
+ })
+
+ it("should return default available methods", async function() {
+ await Get(vars)(req, res as any);
+ Assert(res.json.calledWith(["u2f", "totp"]));
+ });
+
+ it("should return duo as an available method", async function() {
+ vars.config.duo_api = {
+ hostname: "example.com",
+ integration_key: "ABCDEFG",
+ secret_key: "ekjfzelfjz",
+ }
+ await Get(vars)(req, res as any);
+ Assert(res.json.calledWith(["u2f", "totp", "duo_push"]));
+ });
+});
\ No newline at end of file
diff --git a/server/src/lib/routes/secondfactor/available/Get.ts b/server/src/lib/routes/secondfactor/available/Get.ts
new file mode 100644
index 00000000..74175ae3
--- /dev/null
+++ b/server/src/lib/routes/secondfactor/available/Get.ts
@@ -0,0 +1,14 @@
+import * as Express from "express";
+import { ServerVariables } from "../../../ServerVariables";
+import Method2FA from "../../../../../../shared/Method2FA";
+
+
+export default function(vars: ServerVariables) {
+ return async function(_: Express.Request, res: Express.Response) {
+ const availableMethods: Method2FA[] = ["u2f", "totp"];
+ if (vars.config.duo_api) {
+ availableMethods.push("duo_push");
+ }
+ res.json(availableMethods);
+ };
+}
\ No newline at end of file
diff --git a/server/src/lib/routes/secondfactor/duo-push/Post.spec.ts b/server/src/lib/routes/secondfactor/duo-push/Post.spec.ts
new file mode 100644
index 00000000..cd3fa1e4
--- /dev/null
+++ b/server/src/lib/routes/secondfactor/duo-push/Post.spec.ts
@@ -0,0 +1,109 @@
+import * as Express from "express";
+import { ServerVariables } from "../../../ServerVariables";
+import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../../ServerVariablesMockBuilder.spec";
+import * as ExpressMock from "../../../stubs/express.spec";
+import Post from "./Post";
+import * as Sinon from "sinon";
+import * as Assert from "assert";
+import { Level } from "../../../authentication/Level";
+const DuoApi = require("@duosecurity/duo_api");
+
+
+describe("routes/secondfactor/duo-push/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;
+
+ vars.config.duo_api = {
+ hostname: 'abc',
+ integration_key: 'xyz',
+ secret_key: 'secret',
+ }
+
+ req = ExpressMock.RequestMock();
+ res = ExpressMock.ResponseMock();
+ })
+
+ it("should raise authentication level of user", async function() {
+ const mock = Sinon.stub(DuoApi, "Client");
+ mock.returns({
+ jsonApiCall: Sinon.stub().yields({response: {result: 'allow'}})
+ });
+ req.session.auth = {
+ userid: 'john'
+ }
+
+ Assert.equal(req.session.auth.authentication_level, undefined);
+ await Post(vars)(req, res as any);
+ Assert(res.status.calledWith(204));
+ Assert(res.send.calledWith());
+ Assert.equal(req.session.auth.authentication_level, Level.TWO_FACTOR);
+ mock.restore();
+ });
+
+ it("should block if no duo API is configured", async function() {
+ const mock = Sinon.stub(DuoApi, "Client");
+ mock.returns({
+ jsonApiCall: Sinon.stub().yields({response: {result: 'allow'}})
+ });
+ req.session.auth = {
+ userid: 'john'
+ }
+ vars.config.duo_api = undefined;
+
+ Assert.equal(req.session.auth.authentication_level, undefined);
+ await Post(vars)(req, res as any);
+ Assert(res.status.calledWith(200));
+ Assert(res.send.calledWith({error: 'Operation failed.'}));
+ Assert.equal(req.session.auth.authentication_level, undefined);
+ mock.restore();
+ });
+
+ it("should block if user denied notification", async function() {
+ const mock = Sinon.stub(DuoApi, "Client");
+ mock.returns({
+ jsonApiCall: Sinon.stub().yields({response: {result: 'deny'}})
+ });
+ req.session.auth = {
+ userid: 'john'
+ }
+
+ Assert.equal(req.session.auth.authentication_level, undefined);
+ await Post(vars)(req, res as any);
+ Assert(res.status.calledWith(200));
+ Assert(res.send.calledWith({error: 'Operation failed.'}));
+ Assert.equal(req.session.auth.authentication_level, undefined);
+ mock.restore();
+ });
+
+ it("should block if duo push service is down", function() {
+ const mock = Sinon.stub(DuoApi, "Client");
+ const timerMock = Sinon.useFakeTimers();
+ mock.returns({
+ jsonApiCall: Sinon.stub()
+ });
+ req.session.auth = {
+ userid: 'john'
+ }
+
+ Assert.equal(req.session.auth.authentication_level, undefined);
+ const promise = Post(vars)(req, res as any)
+ .then(() => {
+ Assert(res.status.calledWith(200));
+ Assert(res.send.calledWith({error: 'Operation failed.'}));
+ Assert.equal(req.session.auth.authentication_level, undefined);
+
+ mock.restore();
+ timerMock.restore();
+ });
+ // Move forward in time to timeout.
+ timerMock.tick(62000);
+ return promise;
+ });
+});
\ No newline at end of file
diff --git a/server/src/lib/routes/secondfactor/duo-push/Post.ts b/server/src/lib/routes/secondfactor/duo-push/Post.ts
new file mode 100644
index 00000000..f3891a8e
--- /dev/null
+++ b/server/src/lib/routes/secondfactor/duo-push/Post.ts
@@ -0,0 +1,51 @@
+import * as Express from "express";
+import { ServerVariables } from "../../../ServerVariables";
+import { AuthenticationSessionHandler } from "../../../AuthenticationSessionHandler";
+import * as ErrorReplies from "../../../ErrorReplies";
+import * as UserMessage from "../../../../../../shared/UserMessages";
+import redirect from "../redirect";
+import { Level } from "../../../authentication/Level";
+import { DuoPushConfiguration } from "../../../configuration/schema/DuoPushConfiguration";
+const DuoApi = require("@duosecurity/duo_api");
+
+interface DuoResponse {
+ response: {
+ result: "allow" | "deny";
+ status: "allow" | "deny" | "fraud";
+ status_msg: string;
+ };
+ stat: "OK" | "FAIL";
+}
+
+function triggerAuth(username: string, config: DuoPushConfiguration): Promise {
+ return new Promise((resolve, reject) => {
+ const client = new DuoApi.Client(config.integration_key, config.secret_key, config.hostname);
+ const timer = setTimeout(() => reject(new Error("Call to duo push API timed out.")), 60000);
+ client.jsonApiCall("POST", "/auth/v2/auth", { username, factor: "push", device: "auto" }, (data: DuoResponse) => {
+ clearTimeout(timer);
+ resolve(data);
+ });
+ });
+}
+
+
+export default function(vars: ServerVariables) {
+ return async function(req: Express.Request, res: Express.Response) {
+ try {
+ if (!vars.config.duo_api) {
+ throw new Error("Duo Push Notification is not configured.");
+ }
+
+ const authSession = AuthenticationSessionHandler.get(req, vars.logger);
+ const authRes = await triggerAuth(authSession.userid, vars.config.duo_api);
+ if (authRes.response.result !== "allow") {
+ throw new Error("User denied access.");
+ }
+ vars.logger.debug(req, "Access allowed by user via Duo Push.");
+ authSession.authentication_level = Level.TWO_FACTOR;
+ await redirect(vars)(req, res);
+ } catch (err) {
+ ErrorReplies.replyWithError200(req, res, vars.logger, UserMessage.OPERATION_FAILED)(err);
+ }
+ };
+}
\ No newline at end of file
diff --git a/server/src/lib/routes/secondfactor/preferences/Get.spec.ts b/server/src/lib/routes/secondfactor/preferences/Get.spec.ts
index 08900347..7767672c 100644
--- a/server/src/lib/routes/secondfactor/preferences/Get.spec.ts
+++ b/server/src/lib/routes/secondfactor/preferences/Get.spec.ts
@@ -6,7 +6,7 @@ import * as ExpressMock from "../../../stubs/express.spec";
import Get from "./Get";
import * as Assert from "assert";
-describe("routes/secondfactor/Get", function() {
+describe("routes/secondfactor/preferences/Get", function() {
let vars: ServerVariables;
let mocks: ServerVariablesMock;
let req: Express.Request;
diff --git a/server/src/lib/routes/secondfactor/preferences/Post.spec.ts b/server/src/lib/routes/secondfactor/preferences/Post.spec.ts
index 2d55e5d3..da2b71e8 100644
--- a/server/src/lib/routes/secondfactor/preferences/Post.spec.ts
+++ b/server/src/lib/routes/secondfactor/preferences/Post.spec.ts
@@ -6,7 +6,7 @@ import * as ExpressMock from "../../../stubs/express.spec";
import Post from "./Post";
import * as Assert from "assert";
-describe("routes/secondfactor/Post", function() {
+describe("routes/secondfactor/preferences/Post", function() {
let vars: ServerVariables;
let mocks: ServerVariablesMock;
let req: Express.Request;
diff --git a/server/src/lib/routes/verify/CheckAuthorizations.ts b/server/src/lib/routes/verify/CheckAuthorizations.ts
index 0374a1f4..95984225 100644
--- a/server/src/lib/routes/verify/CheckAuthorizations.ts
+++ b/server/src/lib/routes/verify/CheckAuthorizations.ts
@@ -36,11 +36,11 @@ export default function (
}
else if (user && authorizationLevel == AuthorizationLevel.DENY) {
throw new Exceptions.NotAuthorizedError(
- Util.format("User %s is not authorized to access %s%s", user, domain, resource));
+ Util.format("User %s is not authorized to access %s%s", (user) ? user : "unknown", domain, resource));
}
else if (!isAuthorized(authorizationLevel, authenticationLevel)) {
throw new Exceptions.NotAuthenticatedError(Util.format(
- "User '%s' is not sufficiently authorized to access %s%s.", user, domain, resource));
+ "User '%s' is not sufficiently authorized to access %s%s.", (user) ? user : "unknown", domain, resource));
}
return authorizationLevel;
}
\ No newline at end of file
diff --git a/server/src/lib/web_server/RestApi.ts b/server/src/lib/web_server/RestApi.ts
index 9d3d9f75..efb792ec 100644
--- a/server/src/lib/web_server/RestApi.ts
+++ b/server/src/lib/web_server/RestApi.ts
@@ -1,6 +1,8 @@
import * as Express from "express";
import SecondFactorPreferencesGet from "../routes/secondfactor/preferences/Get";
import SecondFactorPreferencesPost from "../routes/secondfactor/preferences/Post";
+import SecondFactorDuoPushPost from "../routes/secondfactor/duo-push/Post";
+import SecondFactorAvailableGet from "../routes/secondfactor/available/Get";
import FirstFactorPost = require("../routes/firstfactor/post");
import LogoutPost from "../routes/logout/post";
@@ -102,6 +104,16 @@ export class RestApi {
RequireValidatedFirstFactor.middleware(vars.logger),
SecondFactorPreferencesPost(vars));
+ if (vars.config.duo_api) {
+ app.post(Endpoints.SECOND_FACTOR_DUO_PUSH_POST,
+ RequireValidatedFirstFactor.middleware(vars.logger),
+ SecondFactorDuoPushPost(vars));
+ }
+
+ app.get(Endpoints.SECOND_FACTOR_AVAILABLE_GET,
+ RequireValidatedFirstFactor.middleware(vars.logger),
+ SecondFactorAvailableGet(vars));
+
setupTotp(app, vars);
setupU2f(app, vars);
setupResetPassword(app, vars);
diff --git a/shared/api.ts b/shared/api.ts
index 055def7a..73af9af0 100644
--- a/shared/api.ts
+++ b/shared/api.ts
@@ -107,6 +107,21 @@ export const SECOND_FACTOR_U2F_SIGN_REQUEST_GET = "/api/u2f/sign_request";
*/
export const SECOND_FACTOR_TOTP_POST = "/api/totp";
+/**
+ * @api {post} /api/duo-push Complete Duo Push Factor
+ * @apiName ValidateDuoPushSecondFactor
+ * @apiGroup DuoPush
+ * @apiVersion 1.0.0
+ * @apiUse UserSession
+ * @apiUse InternalError
+ *
+ * @apiSuccess (Success 302) Redirect to the URL that has been stored during last call to /api/verify.
+ * @apiError (Error 401) {none} error TOTP token is invalid.
+ *
+ * @apiDescription Verify TOTP token. The user is authenticated upon success.
+ */
+export const SECOND_FACTOR_DUO_PUSH_POST = "/api/duo-push";
+
/**
* @api {get} /api/secondfactor/u2f/identity/start Start U2F registration identity validation
@@ -182,6 +197,16 @@ export const SECOND_FACTOR_PREFERENCES_GET = "/api/secondfactor/preferences";
*/
export const SECOND_FACTOR_PREFERENCES_POST = "/api/secondfactor/preferences";
+/**
+ * @api {post} /api/secondfactor/available List the available methods.
+ * @apiName GetAvailableMethods
+ * @apiGroup 2FA
+ * @apiVersion 1.0.0
+ *
+ * @apiDescription Get the available 2FA methods.
+ */
+export const SECOND_FACTOR_AVAILABLE_GET = "/api/secondfactor/available";
+
/**
* @api {post} /api/password-reset Set new password
diff --git a/test/helpers/assertions/VerifyButtonDoesNotExist.ts b/test/helpers/assertions/VerifyButtonDoesNotExist.ts
new file mode 100644
index 00000000..d22c9eb0
--- /dev/null
+++ b/test/helpers/assertions/VerifyButtonDoesNotExist.ts
@@ -0,0 +1,16 @@
+import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
+import VerifyElementDoesNotExist from "./VerifyElementDoesNotExist";
+
+/**
+ * Verify that an element does not exist.
+ *
+ * @param driver The selenium driver
+ * @param content The content of the button to select.
+ */
+export default async function(driver: WebDriver, content: string) {
+ try {
+ await VerifyElementDoesNotExist(driver, SeleniumWebDriver.By.xpath("//button[text()='" + content + "']"));
+ } catch (err) {
+ throw new Error(`Button with content "${content}" should not exist.`);
+ }
+}
\ No newline at end of file
diff --git a/test/helpers/assertions/VerifyButtonHasAppeared.ts b/test/helpers/assertions/VerifyButtonHasAppeared.ts
new file mode 100644
index 00000000..84ce2ad5
--- /dev/null
+++ b/test/helpers/assertions/VerifyButtonHasAppeared.ts
@@ -0,0 +1,15 @@
+import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
+import VerifyHasAppeared from "./VerifyHasAppeared";
+
+/**
+ * Verify if a button with given content exists in the DOM.
+ * @param driver The selenium web driver.
+ * @param content The content of the button to find in the DOM.
+ */
+export default async function(driver: WebDriver, content: string) {
+ try {
+ await VerifyHasAppeared(driver, SeleniumWebDriver.By.xpath("//button[text()='" + content + "']"));
+ } catch (err) {
+ throw new Error(`Button with content "${content}" should have appeared.`);
+ }
+}
\ No newline at end of file
diff --git a/test/helpers/assertions/VerifyElementDoesNotExist.ts b/test/helpers/assertions/VerifyElementDoesNotExist.ts
new file mode 100644
index 00000000..25c6fa97
--- /dev/null
+++ b/test/helpers/assertions/VerifyElementDoesNotExist.ts
@@ -0,0 +1,13 @@
+import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
+
+/**
+ *
+ * @param driver The selenium web driver
+ * @param locator The locator of the element to check it does not exist.
+ */
+export default async function(driver: WebDriver, locator: SeleniumWebDriver.Locator) {
+ const els = await driver.findElements(locator);
+ if (els.length > 0) {
+ throw new Error("Element exists.");
+ }
+}
\ No newline at end of file
diff --git a/test/helpers/assertions/VerifyElementExists.ts b/test/helpers/assertions/VerifyElementExists.ts
new file mode 100644
index 00000000..aaa69d46
--- /dev/null
+++ b/test/helpers/assertions/VerifyElementExists.ts
@@ -0,0 +1,13 @@
+import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
+
+/**
+ *
+ * @param driver The selenium web driver.
+ * @param locator The locator of the element to find in the DOM.
+ */
+export default async function(driver: WebDriver, locator: SeleniumWebDriver.Locator) {
+ const els = await driver.findElements(locator);
+ if (els.length == 0) {
+ throw new Error("Element does not exist.");
+ }
+}
\ No newline at end of file
diff --git a/test/helpers/assertions/VerifyHasAppeared.ts b/test/helpers/assertions/VerifyHasAppeared.ts
new file mode 100644
index 00000000..fea58cc8
--- /dev/null
+++ b/test/helpers/assertions/VerifyHasAppeared.ts
@@ -0,0 +1,5 @@
+import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
+
+export default async function(driver: WebDriver, locator: SeleniumWebDriver.Locator, timeout: number = 5000) {
+ await driver.wait(SeleniumWebDriver.until.elementLocated(locator), timeout);
+}
\ No newline at end of file
diff --git a/test/helpers/assertions/VerifyIsDuoPushNotificationView.ts b/test/helpers/assertions/VerifyIsDuoPushNotificationView.ts
new file mode 100644
index 00000000..9c327706
--- /dev/null
+++ b/test/helpers/assertions/VerifyIsDuoPushNotificationView.ts
@@ -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('duo-push-view')), timeout);
+}
\ No newline at end of file
diff --git a/test/helpers/context/AutheliaServerWithHotReload.ts b/test/helpers/context/AutheliaServerWithHotReload.ts
index edcb5b1c..2a8ad315 100644
--- a/test/helpers/context/AutheliaServerWithHotReload.ts
+++ b/test/helpers/context/AutheliaServerWithHotReload.ts
@@ -17,8 +17,10 @@ class AutheliaServerWithHotReload implements AutheliaServerInterface {
constructor(configPath: string, watchedPaths: string[]) {
this.configPath = configPath;
- this.watcher = Chokidar.watch(['server', 'shared/**/*.ts', 'node_modules',
- this.AUTHELIA_INTERRUPT_FILENAME, configPath].concat(watchedPaths), {
+ const pathsToReload = ['server', 'shared/**/*.ts', 'node_modules',
+ this.AUTHELIA_INTERRUPT_FILENAME, configPath].concat(watchedPaths);
+ console.log("Authelia will reload on changes of files or directories in " + pathsToReload.join(', '));
+ this.watcher = Chokidar.watch(pathsToReload, {
persistent: true,
ignoreInitial: true,
});
@@ -29,7 +31,10 @@ class AutheliaServerWithHotReload implements AutheliaServerInterface {
await exec('./node_modules/.bin/tslint -c server/tslint.json -p server/tsconfig.json')
this.serverProcess = ChildProcess.spawn('./node_modules/.bin/ts-node',
['-P', './server/tsconfig.json', './server/src/index.ts', this.configPath], {
- env: {...process.env},
+ env: {
+ ...process.env,
+ NODE_TLS_REJECT_UNAUTHORIZED: 0,
+ },
});
this.serverProcess.stdout.pipe(process.stdout);
this.serverProcess.stderr.pipe(process.stderr);
diff --git a/test/helpers/context/DockerCompose.ts b/test/helpers/context/DockerCompose.ts
index ddf6c963..0c9bb90f 100644
--- a/test/helpers/context/DockerCompose.ts
+++ b/test/helpers/context/DockerCompose.ts
@@ -23,6 +23,10 @@ class DockerCompose {
async ps() {
return Promise.resolve(execSync(this.commandPrefix + ' ps').toString('utf-8'));
}
+
+ async logs(service: string) {
+ await exec(this.commandPrefix + ' logs ' + service)
+ }
}
export default DockerCompose;
\ No newline at end of file
diff --git a/test/helpers/context/DockerEnvironment.ts b/test/helpers/context/DockerEnvironment.ts
index 5d3f1e76..fe955ce2 100644
--- a/test/helpers/context/DockerEnvironment.ts
+++ b/test/helpers/context/DockerEnvironment.ts
@@ -11,6 +11,10 @@ class DockerEnvironment {
await this.dockerCompose.up();
}
+ async logs(service: string) {
+ await this.dockerCompose.logs(service);
+ }
+
async stop() {
await this.dockerCompose.down();
}
diff --git a/test/suites/.gitignore b/test/suites/.gitignore
index 4df2d1dc..83d1ce3c 100644
--- a/test/suites/.gitignore
+++ b/test/suites/.gitignore
@@ -1 +1,3 @@
-users_database.test.yml
\ No newline at end of file
+users_database.test.yml
+
+private-*/
\ No newline at end of file
diff --git a/test/suites/basic/config.yml b/test/suites/basic/config.yml
index 172aa3ef..534f2568 100644
--- a/test/suites/basic/config.yml
+++ b/test/suites/basic/config.yml
@@ -86,12 +86,6 @@ regulation:
# The length of time before a banned user can login again.
ban_time: 900
-# Default redirection URL
-#
-# Note: this parameter is optional. If not provided, user won't
-# be redirected upon successful authentication.
-#default_redirection_url: https://authelia.example.domain
-
notifier:
# For testing purpose, notifications can be sent in a file
# filesystem:
diff --git a/test/suites/basic/scenarii/NoDuoPushOption.ts b/test/suites/basic/scenarii/NoDuoPushOption.ts
new file mode 100644
index 00000000..4ab0a7ea
--- /dev/null
+++ b/test/suites/basic/scenarii/NoDuoPushOption.ts
@@ -0,0 +1,30 @@
+import { StartDriver, StopDriver } from "../../../helpers/context/WithDriver";
+import LoginAs from "../../../helpers/LoginAs";
+import VerifyIsSecondFactorStage from "../../../helpers/assertions/VerifyIsSecondFactorStage";
+import ClickOnLink from "../../../helpers/ClickOnLink";
+import VerifyIsUseAnotherMethodView from "../../../helpers/assertions/VerifyIsUseAnotherMethodView";
+import VerifyButtonDoesNotExist from "../../../helpers/assertions/VerifyButtonDoesNotExist";
+import VerifyButtonHasAppeared from "../../../helpers/assertions/VerifyButtonHasAppeared";
+
+
+
+export default function() {
+ before(async function() {
+ this.driver = await StartDriver();
+ });
+
+ after(async function() {
+ await StopDriver(this.driver);
+ });
+
+ // The Duo API is not configured so we should not see the method.
+ it("should not display duo push notification 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 VerifyButtonHasAppeared(this.driver, "One-Time Password");
+ await VerifyButtonDoesNotExist(this.driver, "Duo Push Notification");
+ });
+}
\ No newline at end of file
diff --git a/test/suites/basic/test.ts b/test/suites/basic/test.ts
index 006dbf4a..02e03593 100644
--- a/test/suites/basic/test.ts
+++ b/test/suites/basic/test.ts
@@ -10,7 +10,7 @@ import LogoutRedirectToAlreadyLoggedIn from './scenarii/LogoutRedirectToAlreadyL
import { exec } from '../../helpers/utils/exec';
import TwoFactorAuthentication from "../../helpers/scenarii/TwoFactorAuthentication";
import BypassPolicy from "./scenarii/BypassPolicy";
-import Prefered2faMethod from "./scenarii/Prefered2faMethod";
+import NoDuoPushOption from "./scenarii/NoDuoPushOption";
AutheliaSuite(__dirname, function() {
this.timeout(10000);
@@ -29,5 +29,5 @@ AutheliaSuite(__dirname, function() {
describe('TOTP Validation', TOTPValidation);
describe('Required two factor', RequiredTwoFactor);
describe('Logout endpoint redirect to already logged in page', LogoutRedirectToAlreadyLoggedIn);
- describe('Prefered 2FA method', Prefered2faMethod);
+ describe('No Duo Push method available', NoDuoPushOption);
});
\ No newline at end of file
diff --git a/test/suites/duo-push/README.md b/test/suites/duo-push/README.md
new file mode 100644
index 00000000..d8d83dd3
--- /dev/null
+++ b/test/suites/duo-push/README.md
@@ -0,0 +1,12 @@
+# Duo Push Notification suite
+
+This suite has been created to test Authelia against the Duo API for push notifications.
+It allows a user to validate second factor with a mobile phone.
+
+## Components
+
+Authelia, nginx, Duo fake API
+
+## Tests
+
+Test allowed and denied access via push notifications.
\ No newline at end of file
diff --git a/test/suites/duo-push/config.yml b/test/suites/duo-push/config.yml
new file mode 100644
index 00000000..c3320d65
--- /dev/null
+++ b/test/suites/duo-push/config.yml
@@ -0,0 +1,116 @@
+###############################################################
+# Authelia minimal configuration #
+###############################################################
+
+port: 9091
+
+logs_level: debug
+
+default_redirection_url: https://home.example.com:8080/
+
+authentication_backend:
+ file:
+ path: ./test/suites/basic/users_database.test.yml
+
+session:
+ secret: unsecure_session_secret
+ domain: example.com
+ expiration: 3600000 # 1 hour
+ inactivity: 300000 # 5 minutes
+
+# Configuration of the storage backend used to store data and secrets. i.e. totp data
+storage:
+ local:
+ path: /tmp/authelia/db
+
+# TOTP Issuer Name
+#
+# This will be the issuer name displayed in Google Authenticator
+# See: https://github.com/google/google-authenticator/wiki/Key-Uri-Format for more info on issuer names
+totp:
+ issuer: example.com
+
+# The Duo Push Notification API configuration
+duo_api:
+ hostname: duo.example.com
+ integration_key: ABCDEFGHIJKL
+ secret_key: abcdefghijklmnopqrstuvwxyz123456789
+
+# Access Control
+#
+# Access control is a set of rules you can use to restrict user access to certain
+# resources.
+access_control:
+ # Default policy can either be `bypass`, `one_factor`, `two_factor` or `deny`.
+ default_policy: deny
+
+ rules:
+ - domain: singlefactor.example.com
+ policy: one_factor
+
+ - domain: public.example.com
+ policy: bypass
+
+ - domain: secure.example.com
+ policy: two_factor
+
+ - domain: '*.example.com'
+ subject: "group:admins"
+ policy: two_factor
+
+ - domain: dev.example.com
+ resources:
+ - '^/users/john/.*$'
+ subject: "user:john"
+ policy: two_factor
+
+ - domain: dev.example.com
+ resources:
+ - '^/users/harry/.*$'
+ subject: "user:harry"
+ policy: two_factor
+
+ - domain: '*.mail.example.com'
+ subject: "user:bob"
+ policy: two_factor
+
+ - domain: dev.example.com
+ resources:
+ - '^/users/bob/.*$'
+ subject: "user:bob"
+ policy: two_factor
+
+
+# Configuration of the authentication regulation mechanism.
+regulation:
+ # Set it to 0 to disable max_retries.
+ max_retries: 3
+
+ # The user is banned if the authenticaction failed `max_retries` times in a `find_time` seconds window.
+ find_time: 300
+
+ # The length of time before a banned user can login again.
+ ban_time: 900
+
+notifier:
+ # For testing purpose, notifications can be sent in a file
+ # filesystem:
+ # filename: /tmp/authelia/notification.txt
+
+ # Use your email account to send the notifications. You can use an app password.
+ # List of valid services can be found here: https://nodemailer.com/smtp/well-known/
+ ## email:
+ ## username: user@example.com
+ ## password: yourpassword
+ ## sender: admin@example.com
+ ## service: gmail
+
+ # Use a SMTP server for sending notifications
+ smtp:
+ username: test
+ password: password
+ secure: false
+ host: 127.0.0.1
+ port: 1025
+ sender: admin@example.com
+
diff --git a/test/suites/duo-push/environment.ts b/test/suites/duo-push/environment.ts
new file mode 100644
index 00000000..c612370b
--- /dev/null
+++ b/test/suites/duo-push/environment.ts
@@ -0,0 +1,38 @@
+import fs from 'fs';
+import { exec } from "../../helpers/utils/exec";
+import AutheliaServer from "../../helpers/context/AutheliaServer";
+import DockerEnvironment from "../../helpers/context/DockerEnvironment";
+
+process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0 as any;
+
+const autheliaServer = new AutheliaServer(__dirname + '/config.yml', [__dirname + '/users_database.yml']);
+const dockerEnv = new DockerEnvironment([
+ 'docker-compose.yml',
+ 'example/compose/nginx/backend/docker-compose.yml',
+ 'example/compose/nginx/portal/docker-compose.yml',
+ 'example/compose/duo-api/docker-compose.yml',
+])
+
+async function setup() {
+ await exec(`cp ${__dirname}/users_database.yml ${__dirname}/users_database.test.yml`);
+ await exec('mkdir -p /tmp/authelia/db');
+ await exec('./example/compose/nginx/portal/render.js ' + (fs.existsSync('.suite') ? '': '--production'));
+ await dockerEnv.start();
+ await autheliaServer.start();
+}
+
+async function teardown() {
+ await autheliaServer.stop();
+ await dockerEnv.stop();
+ await exec('rm -rf /tmp/authelia/db');
+}
+
+const setup_timeout = 30000;
+const teardown_timeout = 30000;
+
+export {
+ setup,
+ setup_timeout,
+ teardown,
+ teardown_timeout
+};
\ No newline at end of file
diff --git a/test/suites/duo-push/scenarii/DuoPushNotification.ts b/test/suites/duo-push/scenarii/DuoPushNotification.ts
new file mode 100644
index 00000000..ab8875d2
--- /dev/null
+++ b/test/suites/duo-push/scenarii/DuoPushNotification.ts
@@ -0,0 +1,64 @@
+import { StartDriver, StopDriver } from "../../../helpers/context/WithDriver";
+import LoginAs from "../../../helpers/LoginAs";
+import VerifyIsSecondFactorStage from "../../../helpers/assertions/VerifyIsSecondFactorStage";
+import ClickOnLink from "../../../helpers/ClickOnLink";
+import VerifyIsUseAnotherMethodView from "../../../helpers/assertions/VerifyIsUseAnotherMethodView";
+import ClickOnButton from "../../../helpers/behaviors/ClickOnButton";
+import VerifySecretObserved from "../../../helpers/assertions/VerifySecretObserved";
+import Request from 'request-promise';
+import VerifyUrlIs from "../../../helpers/assertions/VerifyUrlIs";
+import VerifyHasAppeared from "../../../helpers/assertions/VerifyHasAppeared";
+import SeleniumWebDriver from "selenium-webdriver";
+import VisitPage from "../../../helpers/VisitPage";
+
+
+export default function() {
+ before(async function() {
+ this.driver = await StartDriver();
+ });
+
+ after(async function () {
+ await StopDriver(this.driver);
+ });
+
+ describe('Allow access', function() {
+ before(async function() {
+ // Configure the fake API to return allowing response.
+ await Request('https://duo.example.com/allow', {method: 'POST'});
+ });
+
+ it('should grant access with Duo API', async function() {
+ await LoginAs(this.driver, "john", "password", "https://secure.example.com:8080/secret.html");
+ await VerifyIsSecondFactorStage(this.driver);
+
+ await ClickOnLink(this.driver, 'Use another method');
+ await VerifyIsUseAnotherMethodView(this.driver);
+ await ClickOnButton(this.driver, 'Duo Push Notification');
+
+ await VerifyUrlIs(this.driver, "https://secure.example.com:8080/secret.html");
+ await VerifySecretObserved(this.driver);
+
+ await VisitPage(this.driver, "https://login.example.com:8080/#/");
+ await ClickOnButton(this.driver, "Logout");
+ });
+ });
+
+ describe('Deny access', function() {
+ before(async function() {
+ // Configure the fake API to return denying response.
+ await Request('https://duo.example.com/deny', {method: 'POST'});
+ });
+
+ it('should grant access with Duo API', async function() {
+ await LoginAs(this.driver, "john", "password", "https://secure.example.com:8080/secret.html");
+ await VerifyIsSecondFactorStage(this.driver);
+
+ await ClickOnLink(this.driver, 'Use another method');
+ await VerifyIsUseAnotherMethodView(this.driver);
+ await ClickOnButton(this.driver, 'Duo Push Notification');
+
+ // The retry button appeared.
+ await VerifyHasAppeared(this.driver, SeleniumWebDriver.By.tagName("button"));
+ });
+ });
+}
\ No newline at end of file
diff --git a/test/suites/basic/scenarii/Prefered2faMethod.ts b/test/suites/duo-push/scenarii/Prefered2faMethod.ts
similarity index 88%
rename from test/suites/basic/scenarii/Prefered2faMethod.ts
rename to test/suites/duo-push/scenarii/Prefered2faMethod.ts
index 65ccff2c..20895dd7 100644
--- a/test/suites/basic/scenarii/Prefered2faMethod.ts
+++ b/test/suites/duo-push/scenarii/Prefered2faMethod.ts
@@ -6,6 +6,7 @@ import VerifyIsUseAnotherMethodView from "../../../helpers/assertions/VerifyIsUs
import ClickOnButton from "../../../helpers/behaviors/ClickOnButton";
import VerifyIsSecurityKeyView from "../../../helpers/assertions/VerifyIsSecurityKeyView";
import VerifyIsSecondFactorStage from "../../../helpers/assertions/VerifyIsSecondFactorStage";
+import VerifyIsDuoPushNotificationView from "../../../helpers/assertions/VerifyIsDuoPushNotificationView";
// This fixture tests that the latest used method is still used when the user gets back.
@@ -26,10 +27,10 @@ export default function() {
await ClickOnLink(this.driver, 'Use another method');
await VerifyIsUseAnotherMethodView(this.driver);
- await ClickOnButton(this.driver, 'Security Key (U2F)');
+ await ClickOnButton(this.driver, 'Duo Push Notification');
// Verify that the user is redirected to the new method
- await VerifyIsSecurityKeyView(this.driver);
+ await VerifyIsDuoPushNotificationView(this.driver);
await ClickOnLink(this.driver, "Logout");
// Login with another user to check that he gets TOTP view.
@@ -39,7 +40,7 @@ export default function() {
// 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);
+ await VerifyIsDuoPushNotificationView(this.driver);
// Restore the prefered method to one-time password.
await ClickOnLink(this.driver, 'Use another method');
diff --git a/test/suites/duo-push/test.ts b/test/suites/duo-push/test.ts
new file mode 100644
index 00000000..d8863bc2
--- /dev/null
+++ b/test/suites/duo-push/test.ts
@@ -0,0 +1,18 @@
+import AutheliaSuite from "../../helpers/context/AutheliaSuite";
+import { exec } from '../../helpers/utils/exec';
+import DuoPushNotification from "./scenarii/DuoPushNotification";
+import Prefered2faMethod from "./scenarii/Prefered2faMethod";
+
+// required to query duo-api over https
+process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0 as any;
+
+AutheliaSuite(__dirname, function() {
+ this.timeout(10000);
+
+ beforeEach(async function() {
+ await exec(`cp ${__dirname}/users_database.yml ${__dirname}/users_database.test.yml`);
+ });
+
+ describe("Duo Push Notication", DuoPushNotification);
+ describe("Prefered 2FA methods", Prefered2faMethod);
+});
\ No newline at end of file
diff --git a/test/suites/duo-push/users_database.yml b/test/suites/duo-push/users_database.yml
new file mode 100644
index 00000000..6fe7a384
--- /dev/null
+++ b/test/suites/duo-push/users_database.yml
@@ -0,0 +1,29 @@
+###############################################################
+# Users Database #
+###############################################################
+
+# This file can be used if you do not have an LDAP set up.
+
+# List of users
+users:
+ john:
+ password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
+ email: john.doe@authelia.com
+ groups:
+ - admins
+ - dev
+
+ harry:
+ password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
+ email: harry.potter@authelia.com
+ groups: []
+
+ bob:
+ password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
+ email: bob.dylan@authelia.com
+ groups:
+ - dev
+
+ james:
+ password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
+ email: james.dean@authelia.com
\ No newline at end of file