Add Duo Push Notification option as 2FA.

pull/342/head
Clement Michaud 2019-03-24 15:15:49 +01:00
parent 090a74299f
commit 8ef402511c
44 changed files with 1197 additions and 70 deletions

View File

@ -25,6 +25,7 @@ addons:
- authelia.example.com - authelia.example.com
- admin.example.com - admin.example.com
- mail.example.com - mail.example.com
- duo.example.com
before_script: before_script:
- export DISPLAY=:99.0 - export DISPLAY=:99.0

View File

@ -38,7 +38,8 @@ then
127.0.0.1 mx1.mail.example.com 127.0.0.1 mx1.mail.example.com
127.0.0.1 mx2.mail.example.com 127.0.0.1 mx2.mail.example.com
127.0.0.1 singlefactor.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; return;
fi fi

View File

@ -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;
}

View File

@ -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))
}
}

View File

@ -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<Props> {
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 (
<div className={classnames('duo-push-view')}>
<div>You will soon receive a push notification on your phone.</div>
<div className={styles.imageContainer}>
<CircleLoader status={u2fStatus}></CircleLoader>
</div>
{(u2fStatus == Status.FAILURE)
? <div className={styles.retryContainer}>
<Button raised onClick={this.props.onRetryClicked}>Retry</Button>
</div>
: null}
</div>
)
}
}

View File

@ -5,6 +5,7 @@ import SecondFactorTOTP from '../../containers/components/SecondFactorTOTP/Secon
import SecondFactorU2F from '../../containers/components/SecondFactorU2F/SecondFactorU2F'; import SecondFactorU2F from '../../containers/components/SecondFactorU2F/SecondFactorU2F';
import { Button } from '@material/react-button'; import { Button } from '@material/react-button';
import classnames from 'classnames'; import classnames from 'classnames';
import SecondFactorDuoPush from '../../containers/components/SecondFactorDuoPush/SecondFactorDuoPush';
export interface OwnProps { export interface OwnProps {
username: string; username: string;
@ -21,6 +22,7 @@ export interface DispatchProps {
onLogoutClicked: () => void; onLogoutClicked: () => void;
onOneTimePasswordMethodClicked: () => void; onOneTimePasswordMethodClicked: () => void;
onSecurityKeyMethodClicked: () => void; onSecurityKeyMethodClicked: () => void;
onDuoPushMethodClicked: () => void;
onUseAnotherMethodClicked: () => void; onUseAnotherMethodClicked: () => void;
} }
@ -37,6 +39,9 @@ class SecondFactorForm extends Component<Props> {
if (method == 'u2f') { if (method == 'u2f') {
title = "Security Key"; title = "Security Key";
methodComponent = (<SecondFactorU2F redirectionUrl={this.props.redirectionUrl}></SecondFactorU2F>); methodComponent = (<SecondFactorU2F redirectionUrl={this.props.redirectionUrl}></SecondFactorU2F>);
} else if (method == "duo_push") {
title = "Duo Push Notification";
methodComponent = (<SecondFactorDuoPush redirectionUrl={this.props.redirectionUrl}></SecondFactorDuoPush>);
} else { } else {
title = "One-Time Password" title = "One-Time Password"
methodComponent = (<SecondFactorTOTP redirectionUrl={this.props.redirectionUrl}></SecondFactorTOTP>); methodComponent = (<SecondFactorTOTP redirectionUrl={this.props.redirectionUrl}></SecondFactorTOTP>);
@ -57,6 +62,7 @@ class SecondFactorForm extends Component<Props> {
<div className={styles.buttonsContainer}> <div className={styles.buttonsContainer}>
<Button raised onClick={this.props.onOneTimePasswordMethodClicked}>One-Time Password</Button> <Button raised onClick={this.props.onOneTimePasswordMethodClicked}>One-Time Password</Button>
<Button raised onClick={this.props.onSecurityKeyMethodClicked}>Security Key (U2F)</Button> <Button raised onClick={this.props.onSecurityKeyMethodClicked}>Security Key (U2F)</Button>
<Button raised onClick={this.props.onDuoPushMethodClicked}>Duo Push Notification</Button>
</div> </div>
</div> </div>
); );

View File

@ -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);

View File

@ -31,6 +31,7 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
onLogoutClicked: () => LogoutBehavior(dispatch), onLogoutClicked: () => LogoutBehavior(dispatch),
onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'), onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'),
onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'), onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'),
onDuoPushMethodClicked: () => storeMethod(dispatch, "duo_push"),
onUseAnotherMethodClicked: () => dispatch(setUseAnotherMethod(true)), onUseAnotherMethodClicked: () => dispatch(setUseAnotherMethod(true)),
} }
} }

View File

@ -16,7 +16,10 @@ import {
SET_PREFERED_METHOD, SET_PREFERED_METHOD,
SET_PREFERED_METHOD_FAILURE, SET_PREFERED_METHOD_FAILURE,
SET_PREFERED_METHOD_SUCCESS, 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"; } from "../../constants";
import Method2FA from "../../../types/Method2FA"; import Method2FA from "../../../types/Method2FA";
@ -54,6 +57,11 @@ export const oneTimePasswordVerificationFailure = createAction(ONE_TIME_PASSWORD
return (err: string) => resolve(err); 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 logout = createAction(LOGOUT_REQUEST);
export const logoutSuccess = createAction(LOGOUT_SUCCESS); export const logoutSuccess = createAction(LOGOUT_SUCCESS);

View File

@ -27,6 +27,10 @@ interface SecondFactorState {
oneTimePasswordVerificationLoading: boolean, oneTimePasswordVerificationLoading: boolean,
oneTimePasswordVerificationSuccess: boolean | null, oneTimePasswordVerificationSuccess: boolean | null,
oneTimePasswordVerificationError: string | null, oneTimePasswordVerificationError: string | null,
duoPushVerificationLoading: boolean;
duoPushVerificationSuccess: boolean | null;
duoPushVerificationError: string | null;
} }
const secondFactorInitialState: SecondFactorState = { const secondFactorInitialState: SecondFactorState = {
@ -51,6 +55,10 @@ const secondFactorInitialState: SecondFactorState = {
oneTimePasswordVerificationLoading: false, oneTimePasswordVerificationLoading: false,
oneTimePasswordVerificationError: null, oneTimePasswordVerificationError: null,
oneTimePasswordVerificationSuccess: null, oneTimePasswordVerificationSuccess: null,
duoPushVerificationLoading: false,
duoPushVerificationSuccess: null,
duoPushVerificationError: null,
} }
export type PortalState = StateType<SecondFactorState>; export type PortalState = StateType<SecondFactorState>;
@ -163,6 +171,25 @@ export default (state = secondFactorInitialState, action: SecondFactorAction): S
...state, ...state,
userAnotherMethod: action.payload, 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; return state;
} }

View File

@ -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_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 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_REQUEST = '@portal/logout_request';
export const LOGOUT_SUCCESS = '@portal/logout_success'; export const LOGOUT_SUCCESS = '@portal/logout_success';
export const LOGOUT_FAILURE = '@portal/logout_failure'; export const LOGOUT_FAILURE = '@portal/logout_failure';

View File

@ -113,6 +113,21 @@ class AutheliaService {
}) })
} }
static async triggerDuoPush(redirectionUrl: string | null): Promise<any> {
const headers: Record<string, string> = {
'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) { static async initiatePasswordResetIdentityValidation(username: string) {
return this.fetchSafe('/api/password-reset/identity/start', { return this.fetchSafe('/api/password-reset/identity/start', {
method: 'POST', method: 'POST',

View File

@ -1,4 +1,4 @@
type Method2FA = "u2f" | "totp"; type Method2FA = "u2f" | "totp" | "duo_push";
export default Method2FA; export default Method2FA;

View File

@ -29,6 +29,15 @@ default_redirection_url: https://home.example.com:8080/
totp: totp:
issuer: authelia.com 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 # The authentication backend to use for verifying user passwords
# and retrieve information such as email address and groups # and retrieve information such as email address and groups
# users belong to. # users belong to.

View File

@ -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"]

View File

@ -0,0 +1,6 @@
version: '2'
services:
duo-api:
image: authelia-duo-api
networks:
- authelianet

View File

@ -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]);
});
});

View File

@ -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);

View File

@ -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="
}
}
}

View File

@ -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"
}
}

View File

@ -8,4 +8,6 @@ services:
ports: ports:
- "8080:443" - "8080:443"
networks: networks:
- authelianet authelianet:
# Set the IP to be able to query on port 443
ipv4_address: 192.168.240.100

View File

@ -431,5 +431,24 @@ http {
proxy_pass $upstream_endpoint; 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;
}
}
} }

62
package-lock.json generated
View File

@ -171,6 +171,14 @@
"to-fast-properties": "2.0.0" "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": { "@sinonjs/commons": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.3.0.tgz",
@ -482,12 +490,6 @@
"integrity": "sha512-txsii9cwD2OUOPukfPu3Jpoi3CnznBAwRX3JF26EC4p5T6IA8AaL6PBilACyY2fJkk+ydDNo4BJrJOo/OmNaZw==", "integrity": "sha512-txsii9cwD2OUOPukfPu3Jpoi3CnznBAwRX3JF26EC4p5T6IA8AaL6PBilACyY2fJkk+ydDNo4BJrJOo/OmNaZw==",
"dev": true "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": { "@types/query-string": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "http://registry.npmjs.org/@types/query-string/-/query-string-5.1.0.tgz", "resolved": "http://registry.npmjs.org/@types/query-string/-/query-string-5.1.0.tgz",
@ -595,8 +597,7 @@
"abbrev": { "abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
"dev": true
}, },
"accepts": { "accepts": {
"version": "1.3.5", "version": "1.3.5",
@ -2530,16 +2531,6 @@
"integrity": "sha1-peeo/7+kk7Q7kju9TKiaU7Y7YSs=", "integrity": "sha1-peeo/7+kk7Q7kju9TKiaU7Y7YSs=",
"dev": true "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": { "fill-range": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
@ -4201,12 +4192,6 @@
"integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=",
"dev": true "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": { "is-path-cwd": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz",
@ -5163,12 +5148,6 @@
"integrity": "sha1-WuDA6vj+I+AJzQH5iJtCxPY0rxI=", "integrity": "sha1-WuDA6vj+I+AJzQH5iJtCxPY0rxI=",
"dev": true "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": { "moment": {
"version": "2.22.1", "version": "2.22.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.1.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.1.tgz",
@ -5812,7 +5791,6 @@
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
"integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=",
"dev": true,
"requires": { "requires": {
"abbrev": "1.1.1" "abbrev": "1.1.1"
} }
@ -7288,28 +7266,6 @@
"ipaddr.js": "1.6.0" "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": { "pseudomap": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",

View File

@ -31,6 +31,7 @@
"title": "Authelia API documentation" "title": "Authelia API documentation"
}, },
"dependencies": { "dependencies": {
"@duosecurity/duo_api": "^1.2.0",
"ajv": "^6.3.0", "ajv": "^6.3.0",
"bluebird": "^3.5.0", "bluebird": "^3.5.0",
"body-parser": "^1.15.2", "body-parser": "^1.15.2",

View File

@ -7,6 +7,9 @@ async function main() {
console.log('Build authelia-example-backend docker image.') console.log('Build authelia-example-backend docker image.')
await exec('docker build -t authelia-example-backend example/compose/nginx/backend'); 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')) { if (!fs.existsSync('/tmp/kind')) {
console.log('Install Kind for spawning a Kubernetes cluster.'); 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'); await exec('wget https://github.com/clems4ever/kind/releases/download/0.1.0-cmic1/kind-linux-amd64 -O /tmp/kind && chmod +x /tmp/kind');

View File

@ -40,6 +40,9 @@ export default class Server {
displayableConfiguration.notifier.email.password = STARS; displayableConfiguration.notifier.email.password = STARS;
if (displayableConfiguration.notifier && displayableConfiguration.notifier.smtp) if (displayableConfiguration.notifier && displayableConfiguration.notifier.smtp)
displayableConfiguration.notifier.smtp.password = STARS; displayableConfiguration.notifier.smtp.password = STARS;
if (displayableConfiguration.duo_api) {
displayableConfiguration.duo_api.secret_key = STARS;
}
this.globalLogger.debug("User configuration is %s", this.globalLogger.debug("User configuration is %s",
JSON.stringify(displayableConfiguration, undefined, 2)); JSON.stringify(displayableConfiguration, undefined, 2));

View File

@ -5,6 +5,7 @@ import { RegulationConfiguration, complete as RegulationConfigurationComplete }
import { SessionConfiguration, complete as SessionConfigurationComplete } from "./SessionConfiguration"; import { SessionConfiguration, complete as SessionConfigurationComplete } from "./SessionConfiguration";
import { StorageConfiguration, complete as StorageConfigurationComplete } from "./StorageConfiguration"; import { StorageConfiguration, complete as StorageConfigurationComplete } from "./StorageConfiguration";
import { TotpConfiguration, complete as TotpConfigurationComplete } from "./TotpConfiguration"; import { TotpConfiguration, complete as TotpConfigurationComplete } from "./TotpConfiguration";
import { DuoPushConfiguration } from "./DuoPushConfiguration";
export interface Configuration { export interface Configuration {
access_control?: ACLConfiguration; access_control?: ACLConfiguration;
@ -17,6 +18,7 @@ export interface Configuration {
session?: SessionConfiguration; session?: SessionConfiguration;
storage?: StorageConfiguration; storage?: StorageConfiguration;
totp?: TotpConfiguration; totp?: TotpConfiguration;
duo_api?: DuoPushConfiguration;
} }
export function complete( export function complete(

View File

@ -0,0 +1,6 @@
export interface DuoPushConfiguration {
hostname: string;
integration_key: string;
secret_key: string;
}

View File

@ -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;
});
});

View File

@ -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<DuoResponse> {
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);
}
};
}

View File

@ -6,7 +6,7 @@ import * as ExpressMock from "../../../stubs/express.spec";
import Get from "./Get"; import Get from "./Get";
import * as Assert from "assert"; import * as Assert from "assert";
describe("routes/secondfactor/Get", function() { describe("routes/secondfactor/preferences/Get", function() {
let vars: ServerVariables; let vars: ServerVariables;
let mocks: ServerVariablesMock; let mocks: ServerVariablesMock;
let req: Express.Request; let req: Express.Request;

View File

@ -6,7 +6,7 @@ import * as ExpressMock from "../../../stubs/express.spec";
import Post from "./Post"; import Post from "./Post";
import * as Assert from "assert"; import * as Assert from "assert";
describe("routes/secondfactor/Post", function() { describe("routes/secondfactor/preferences/Post", function() {
let vars: ServerVariables; let vars: ServerVariables;
let mocks: ServerVariablesMock; let mocks: ServerVariablesMock;
let req: Express.Request; let req: Express.Request;

View File

@ -36,11 +36,11 @@ export default function (
} }
else if (user && authorizationLevel == AuthorizationLevel.DENY) { else if (user && authorizationLevel == AuthorizationLevel.DENY) {
throw new Exceptions.NotAuthorizedError( 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)) { else if (!isAuthorized(authorizationLevel, authenticationLevel)) {
throw new Exceptions.NotAuthenticatedError(Util.format( 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; return authorizationLevel;
} }

View File

@ -1,6 +1,7 @@
import * as Express from "express"; import * as Express from "express";
import SecondFactorPreferencesGet from "../routes/secondfactor/preferences/Get"; import SecondFactorPreferencesGet from "../routes/secondfactor/preferences/Get";
import SecondFactorPreferencesPost from "../routes/secondfactor/preferences/Post"; import SecondFactorPreferencesPost from "../routes/secondfactor/preferences/Post";
import SecondFactorDuoPushPost from "../routes/secondfactor/duo-push/Post";
import FirstFactorPost = require("../routes/firstfactor/post"); import FirstFactorPost = require("../routes/firstfactor/post");
import LogoutPost from "../routes/logout/post"; import LogoutPost from "../routes/logout/post";
@ -102,6 +103,12 @@ export class RestApi {
RequireValidatedFirstFactor.middleware(vars.logger), RequireValidatedFirstFactor.middleware(vars.logger),
SecondFactorPreferencesPost(vars)); SecondFactorPreferencesPost(vars));
if (vars.config.duo_api) {
app.post(Endpoints.SECOND_FACTOR_DUO_PUSH_POST,
RequireValidatedFirstFactor.middleware(vars.logger),
SecondFactorDuoPushPost(vars));
}
setupTotp(app, vars); setupTotp(app, vars);
setupU2f(app, vars); setupU2f(app, vars);
setupResetPassword(app, vars); setupResetPassword(app, vars);

View File

@ -107,6 +107,21 @@ export const SECOND_FACTOR_U2F_SIGN_REQUEST_GET = "/api/u2f/sign_request";
*/ */
export const SECOND_FACTOR_TOTP_POST = "/api/totp"; 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 * @api {get} /api/secondfactor/u2f/identity/start Start U2F registration identity validation

View File

@ -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);
}

View File

@ -17,8 +17,10 @@ class AutheliaServerWithHotReload implements AutheliaServerInterface {
constructor(configPath: string, watchedPaths: string[]) { constructor(configPath: string, watchedPaths: string[]) {
this.configPath = configPath; this.configPath = configPath;
this.watcher = Chokidar.watch(['server', 'shared/**/*.ts', 'node_modules', const pathsToReload = ['server', 'shared/**/*.ts', 'node_modules',
this.AUTHELIA_INTERRUPT_FILENAME, configPath].concat(watchedPaths), { 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, persistent: true,
ignoreInitial: 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') await exec('./node_modules/.bin/tslint -c server/tslint.json -p server/tsconfig.json')
this.serverProcess = ChildProcess.spawn('./node_modules/.bin/ts-node', this.serverProcess = ChildProcess.spawn('./node_modules/.bin/ts-node',
['-P', './server/tsconfig.json', './server/src/index.ts', this.configPath], { ['-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.stdout.pipe(process.stdout);
this.serverProcess.stderr.pipe(process.stderr); this.serverProcess.stderr.pipe(process.stderr);

View File

@ -86,12 +86,6 @@ regulation:
# The length of time before a banned user can login again. # The length of time before a banned user can login again.
ban_time: 900 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: notifier:
# For testing purpose, notifications can be sent in a file # For testing purpose, notifications can be sent in a file
# filesystem: # filesystem:

View File

@ -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.

View File

@ -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

View File

@ -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
};

View File

@ -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"));
});
});
}

View File

@ -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);
});

View File

@ -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