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) + ?
+ +
+ : 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
-
- - -
-
- ); - } - private renderUseAnotherMethodLink() { return (
- + Use another method
@@ -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 => ); + + 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