diff --git a/.travis.yml b/.travis.yml index debdccbe2..681936a54 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,6 +25,7 @@ addons: - authelia.example.com - admin.example.com - mail.example.com + - duo.example.com before_script: - export DISPLAY=:99.0 diff --git a/bootstrap.sh b/bootstrap.sh index 4c92e6a51..c3eee60e1 100644 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -38,7 +38,8 @@ then 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" +127.0.0.1 login.example.com +192.168.240.100 duo.example.com" return; fi 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 000000000..22787100f --- /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/TriggerDuoPushAuth.ts b/client/src/behaviors/TriggerDuoPushAuth.ts new file mode 100644 index 000000000..61cbe67f3 --- /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 000000000..29d309733 --- /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 fef245ce6..111d6021b 100644 --- a/client/src/components/SecondFactorForm/SecondFactorForm.tsx +++ b/client/src/components/SecondFactorForm/SecondFactorForm.tsx @@ -5,6 +5,7 @@ import SecondFactorTOTP from '../../containers/components/SecondFactorTOTP/Secon import SecondFactorU2F from '../../containers/components/SecondFactorU2F/SecondFactorU2F'; import { Button } from '@material/react-button'; import classnames from 'classnames'; +import SecondFactorDuoPush from '../../containers/components/SecondFactorDuoPush/SecondFactorDuoPush'; export interface OwnProps { username: string; @@ -21,6 +22,7 @@ export interface DispatchProps { onLogoutClicked: () => void; onOneTimePasswordMethodClicked: () => void; onSecurityKeyMethodClicked: () => void; + onDuoPushMethodClicked: () => void; onUseAnotherMethodClicked: () => void; } @@ -37,6 +39,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 = (); @@ -57,6 +62,7 @@ class SecondFactorForm extends Component {
+
); diff --git a/client/src/containers/components/SecondFactorDuoPush/SecondFactorDuoPush.ts b/client/src/containers/components/SecondFactorDuoPush/SecondFactorDuoPush.ts new file mode 100644 index 000000000..2f23d23ea --- /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 66b1e5ea0..01dde7894 100644 --- a/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts +++ b/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts @@ -31,6 +31,7 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { onLogoutClicked: () => LogoutBehavior(dispatch), onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'), onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'), + onDuoPushMethodClicked: () => storeMethod(dispatch, "duo_push"), onUseAnotherMethodClicked: () => dispatch(setUseAnotherMethod(true)), } } diff --git a/client/src/reducers/Portal/SecondFactor/actions.ts b/client/src/reducers/Portal/SecondFactor/actions.ts index eaeae9cf7..d7c235760 100644 --- a/client/src/reducers/Portal/SecondFactor/actions.ts +++ b/client/src/reducers/Portal/SecondFactor/actions.ts @@ -16,7 +16,10 @@ 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 } from "../../constants"; import Method2FA from "../../../types/Method2FA"; @@ -54,6 +57,11 @@ export const oneTimePasswordVerificationFailure = createAction(ONE_TIME_PASSWORD return (err: string) => resolve(err); }); +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); diff --git a/client/src/reducers/Portal/SecondFactor/reducer.ts b/client/src/reducers/Portal/SecondFactor/reducer.ts index e12608e4f..cf6605e20 100644 --- a/client/src/reducers/Portal/SecondFactor/reducer.ts +++ b/client/src/reducers/Portal/SecondFactor/reducer.ts @@ -27,6 +27,10 @@ interface SecondFactorState { oneTimePasswordVerificationLoading: boolean, oneTimePasswordVerificationSuccess: boolean | null, oneTimePasswordVerificationError: string | null, + + duoPushVerificationLoading: boolean; + duoPushVerificationSuccess: boolean | null; + duoPushVerificationError: string | null; } const secondFactorInitialState: SecondFactorState = { @@ -51,6 +55,10 @@ const secondFactorInitialState: SecondFactorState = { oneTimePasswordVerificationLoading: false, oneTimePasswordVerificationError: null, oneTimePasswordVerificationSuccess: null, + + duoPushVerificationLoading: false, + duoPushVerificationSuccess: null, + duoPushVerificationError: null, } export type PortalState = StateType; @@ -163,6 +171,25 @@ 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, + } } return state; } \ No newline at end of file diff --git a/client/src/reducers/constants.ts b/client/src/reducers/constants.ts index abbd5f055..9332d4eed 100644 --- a/client/src/reducers/constants.ts +++ b/client/src/reducers/constants.ts @@ -28,6 +28,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 a0c5d1f55..cf5b9b072 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', diff --git a/client/src/types/Method2FA.ts b/client/src/types/Method2FA.ts index 09e0b3c48..c46b4b27d 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 a29682b88..0587fa530 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.duosecurity.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/example/compose/duo-api/Dockerfile b/example/compose/duo-api/Dockerfile new file mode 100644 index 000000000..fc70d55d3 --- /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 000000000..0932084ad --- /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 000000000..5181f5c7f --- /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 000000000..ee6b2a110 --- /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 000000000..06cb0628c --- /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 000000000..4d8267ba5 --- /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/portal/docker-compose.yml b/example/compose/nginx/portal/docker-compose.yml index d5fa8b3e4..446220852 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 3306e45a6..2f7c14c98 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/package-lock.json b/package-lock.json index ba64b8bef..e29d9f0b7 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 589edd3d6..727ede867 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 d2832c21a..b4ec915f7 100755 --- a/scripts/authelia-scripts-bootstrap +++ b/scripts/authelia-scripts-bootstrap @@ -7,6 +7,9 @@ async function main() { 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'); + 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'); diff --git a/server/src/lib/Server.ts b/server/src/lib/Server.ts index c36854d15..346965c75 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 8d16a5fb2..d0584732b 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 000000000..bede37674 --- /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/duo-push/Post.spec.ts b/server/src/lib/routes/secondfactor/duo-push/Post.spec.ts new file mode 100644 index 000000000..cd3fa1e4a --- /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 000000000..f3891a8e8 --- /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 08900347e..7767672cc 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 2d55e5d37..da2b71e82 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 0374a1f48..95984225c 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 9d3d9f753..04a15b5bf 100644 --- a/server/src/lib/web_server/RestApi.ts +++ b/server/src/lib/web_server/RestApi.ts @@ -1,6 +1,7 @@ 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 FirstFactorPost = require("../routes/firstfactor/post"); import LogoutPost from "../routes/logout/post"; @@ -102,6 +103,12 @@ 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)); + } + setupTotp(app, vars); setupU2f(app, vars); setupResetPassword(app, vars); diff --git a/shared/api.ts b/shared/api.ts index 055def7af..890232771 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 diff --git a/test/helpers/assertions/VerifyHasAppeared.ts b/test/helpers/assertions/VerifyHasAppeared.ts new file mode 100644 index 000000000..fea58cc89 --- /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/context/AutheliaServerWithHotReload.ts b/test/helpers/context/AutheliaServerWithHotReload.ts index edcb5b1c1..2a8ad3158 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/suites/basic/config.yml b/test/suites/basic/config.yml index 172aa3efb..534f25686 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/duo-push/README.md b/test/suites/duo-push/README.md new file mode 100644 index 000000000..d8d83dd3d --- /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 000000000..c3320d656 --- /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 000000000..e52b040aa --- /dev/null +++ b/test/suites/duo-push/environment.ts @@ -0,0 +1,36 @@ +import fs from 'fs'; +import { exec } from "../../helpers/utils/exec"; +import AutheliaServer from "../../helpers/context/AutheliaServer"; +import DockerEnvironment from "../../helpers/context/DockerEnvironment"; + +const autheliaServer = new AutheliaServer(__dirname + '/config.yml', [__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 000000000..ab8875d29 --- /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/duo-push/test.ts b/test/suites/duo-push/test.ts new file mode 100644 index 000000000..1dab3d4d3 --- /dev/null +++ b/test/suites/duo-push/test.ts @@ -0,0 +1,16 @@ +import AutheliaSuite from "../../helpers/context/AutheliaSuite"; +import { exec } from '../../helpers/utils/exec'; +import DuoPushNotification from "./scenarii/DuoPushNotification"; + +// 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); +}); \ 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 000000000..6fe7a384d --- /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