Merge pull request #342 from clems4ever/duo-push

Add Duo Push Notification option as 2FA.
This commit is contained in:
Clément Michaud 2019-03-24 23:55:44 +01:00 committed by GitHub
commit 30f47a1451
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 1747 additions and 187 deletions

View File

@ -1,4 +1,5 @@
language: node_js
required: sudo
node_js:
- '9'
services:
@ -12,19 +13,6 @@ addons:
packages:
- libgif-dev
- google-chrome-stable
hosts:
- admin.example.com
- login.example.com
- singlefactor.example.com
- dev.example.com
- home.example.com
- mx1.mail.example.com
- mx2.mail.example.com
- public.example.com
- secure.example.com
- authelia.example.com
- admin.example.com
- mail.example.com
before_script:
- export DISPLAY=:99.0

View File

@ -8,8 +8,9 @@
[![Gitter](https://img.shields.io/gitter/room/badges/shields.svg)](https://gitter.im/authelia/general?utm_source=share-link&utm_medium=link&utm_campaign=share-link)
[![Donate](https://img.shields.io/badge/Donate-PayPal-orange.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=clement%2emichaud34%40gmail%2ecom&lc=FR&item_name=Authelia&currency_code=EUR&bn=PP%2dDonationsBF%3abtn_donate_SM%2egif%3aNonHosted)
**Authelia** is an open-source authentication and authorization providing
2-factor authentication and single sign-on (SSO) for your applications.
**Authelia** is an open-source authentication and authorization server
providing 2-factor authentication and single sign-on (SSO) for your
applications.
It acts as a companion of reverse proxies by handling authentication and
authorization requests.
@ -20,15 +21,17 @@ for specific services in only few seconds.
<p align="center">
<img src="images/first_factor.png" width="400" />
<img src="images/second_factor.png" width="400" />
<img src="images/use-another-method.png" width="400" />
</p>
## Features summary
Here is the list of the main available features:
* **[U2F] - Universal 2-Factor -** support with [Yubikey].
* **[TOTP] - Time-Base One Time password -** support with [Google Authenticator].
* Several kind of second factor:
* **[Security Key (U2F)](./docs/2factor/security-key.md)** support with [Yubikey].
* **[Time-based One-Time password](./docs/2factor/time-based-one-time-password.md)** support with [Google Authenticator].
* **[Mobile Push Notifications](./docs/2factor/duo-push-notifications.md)** with [Duo](https://duo.com/).
* Password reset with identity verification using email.
* Single-factor only authentication method available.
* Access restriction after too many authentication attempts.
@ -43,6 +46,7 @@ For more details about the features, follow [Features](./docs/features.md).
You can start off with
git clone https://github.com/clems4ever/authelia.git
source bootstrap.sh
If you want to go further, please read [Getting Started](./docs/getting-started.md).
@ -113,7 +117,7 @@ Wanna see more features? Then fuel us with a few beers!
[MIT License]: https://opensource.org/licenses/MIT
[TOTP]: https://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm
[U2F]: https://www.yubico.com/about/background/fido/
[Security Key]: https://www.yubico.com/about/background/fido/
[Yubikey]: https://www.yubico.com/products/yubikey-hardware/yubikey4/
[auth_request]: http://nginx.org/en/docs/http/ngx_http_auth_request_module.html
[Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en

View File

@ -24,24 +24,6 @@ then
return;
fi
echo "[BOOTSTRAP] Checking if example.com domain is forwarded to your machine..."
cat /etc/hosts | grep "login.example.com" > /dev/null
if [ $? -ne 0 ];
then
echo "[ERROR] Please add those lines to /etc/hosts:
127.0.0.1 home.example.com
127.0.0.1 public.example.com
127.0.0.1 secure.example.com
127.0.0.1 dev.example.com
127.0.0.1 admin.example.com
127.0.0.1 mx1.mail.example.com
127.0.0.1 mx2.mail.example.com
127.0.0.1 singlefactor.example.com
127.0.0.1 login.example.com"
return;
fi
echo "[BOOTSTRAP] Running additional bootstrap steps..."
authelia-scripts bootstrap

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,13 @@
import { Dispatch } from "redux";
import AutheliaService from "../services/AutheliaService";
import { getAvailbleMethods, getAvailbleMethodsSuccess, getAvailbleMethodsFailure } from "../reducers/Portal/SecondFactor/actions";
export default async function(dispatch: Dispatch) {
dispatch(getAvailbleMethods());
try {
const methods = await AutheliaService.getAvailable2faMethods();
dispatch(getAvailbleMethodsSuccess(methods));
} catch (err) {
dispatch(getAvailbleMethodsFailure(err.message))
}
}

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

@ -3,8 +3,9 @@ import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorFo
import Method2FA from '../../types/Method2FA';
import SecondFactorTOTP from '../../containers/components/SecondFactorTOTP/SecondFactorTOTP';
import SecondFactorU2F from '../../containers/components/SecondFactorU2F/SecondFactorU2F';
import { Button } from '@material/react-button';
import classnames from 'classnames';
import SecondFactorDuoPush from '../../containers/components/SecondFactorDuoPush/SecondFactorDuoPush';
import UseAnotherMethod from '../../containers/components/UseAnotherMethod/UseAnotherMethod';
export interface OwnProps {
username: string;
@ -19,8 +20,6 @@ export interface StateProps {
export interface DispatchProps {
onInit: () => void;
onLogoutClicked: () => void;
onOneTimePasswordMethodClicked: () => void;
onSecurityKeyMethodClicked: () => void;
onUseAnotherMethodClicked: () => void;
}
@ -37,6 +36,9 @@ class SecondFactorForm extends Component<Props> {
if (method == 'u2f') {
title = "Security Key";
methodComponent = (<SecondFactorU2F redirectionUrl={this.props.redirectionUrl}></SecondFactorU2F>);
} else if (method == "duo_push") {
title = "Duo Push Notification";
methodComponent = (<SecondFactorDuoPush redirectionUrl={this.props.redirectionUrl}></SecondFactorDuoPush>);
} else {
title = "One-Time Password"
methodComponent = (<SecondFactorTOTP redirectionUrl={this.props.redirectionUrl}></SecondFactorTOTP>);
@ -50,22 +52,10 @@ class SecondFactorForm extends Component<Props> {
);
}
private renderUseAnotherMethod() {
return (
<div className={classnames('use-another-method-view')}>
<div>Choose a method</div>
<div className={styles.buttonsContainer}>
<Button raised onClick={this.props.onOneTimePasswordMethodClicked}>One-Time Password</Button>
<Button raised onClick={this.props.onSecurityKeyMethodClicked}>Security Key (U2F)</Button>
</div>
</div>
);
}
private renderUseAnotherMethodLink() {
return (
<div className={styles.anotherMethodLink}>
<a href="#" onClick={this.props.onUseAnotherMethodClicked}>
<a onClick={this.props.onUseAnotherMethodClicked}>
Use another method
</a>
</div>
@ -78,11 +68,11 @@ class SecondFactorForm extends Component<Props> {
<div className={styles.header}>
<div className={styles.hello}>Hello <b>{this.props.username}</b></div>
<div className={styles.logout}>
<a onClick={this.props.onLogoutClicked} href="#">Logout</a>
<a onClick={this.props.onLogoutClicked}>Logout</a>
</div>
</div>
<div className={styles.body}>
{(this.props.useAnotherMethod) ? this.renderUseAnotherMethod() : this.renderMethod()}
{(this.props.useAnotherMethod) ? <UseAnotherMethod/> : this.renderMethod()}
</div>
{(this.props.useAnotherMethod) ? null : this.renderUseAnotherMethodLink()}
</div>

View File

@ -68,7 +68,7 @@ export default class SecondFactorTOTP extends React.Component<Props, State> {
value={this.state.oneTimePassword} />
</TextField>
<div className={styles.registerDeviceContainer}>
<a className={classnames(styles.registerDevice, 'register-totp')} href="#"
<a className={classnames(styles.registerDevice, 'register-totp')}
onClick={this.props.onRegisterOneTimePasswordClicked}>
Register new device
</a>

View File

@ -41,7 +41,7 @@ export default class SecondFactorU2F extends React.Component<Props, State> {
<CircleLoader status={u2fStatus}></CircleLoader>
</div>
<div className={styles.registerDeviceContainer}>
<a className={classnames(styles.registerDevice, 'register-u2f')} href="#"
<a className={classnames(styles.registerDevice, 'register-u2f')}
onClick={this.props.onRegisterSecurityKeyClicked}>
Register new device
</a>

View File

@ -0,0 +1,67 @@
import React, { Component } from 'react';
import styles from '../../assets/scss/components/SecondFactorForm/SecondFactorForm.module.scss';
import Method2FA from '../../types/Method2FA';
import { Button } from '@material/react-button';
import classnames from 'classnames';
export interface OwnProps {}
export interface StateProps {
availableMethods: Method2FA[] | null;
isSecurityKeySupported: boolean;
}
export interface DispatchProps {
onOneTimePasswordMethodClicked: () => void;
onSecurityKeyMethodClicked: () => void;
onDuoPushMethodClicked: () => void;
}
export type Props = OwnProps & StateProps & DispatchProps;
interface MethodDescription {
name: string;
onClicked: () => void;
key: Method2FA;
}
class UseAnotherMethod extends Component<Props> {
render() {
const methods: MethodDescription[] = [
{
name: "One-Time Password",
onClicked: this.props.onOneTimePasswordMethodClicked,
key: "totp"
},
{
name: "Security Key (U2F)",
onClicked: this.props.onSecurityKeyMethodClicked,
key: "u2f"
},
{
name: "Duo Push Notification",
onClicked: this.props.onDuoPushMethodClicked,
key: "duo_push"
}
];
const methodsComponents = methods
// Filter out security key if not supported by browser.
.filter(m => m.key !== "u2f" || (m.key === "u2f" && this.props.isSecurityKeySupported))
// Filter out the methods that are not supported by the server.
.filter(m => this.props.availableMethods && this.props.availableMethods.includes(m.key))
// Create the buttons
.map(m => <Button raised onClick={m.onClicked} key={m.key}>{m.name}</Button>);
return (
<div className={classnames('use-another-method-view')}>
<div>Choose a method</div>
<div className={styles.buttonsContainer}>
{methodsComponents}
</div>
</div>
)
}
}
export default UseAnotherMethod;

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

@ -5,9 +5,10 @@ import LogoutBehavior from '../../../behaviors/LogoutBehavior';
import { RootState } from '../../../reducers';
import { StateProps, DispatchProps } from '../../../components/SecondFactorForm/SecondFactorForm';
import FetchPrefered2faMethod from '../../../behaviors/FetchPrefered2faMethod';
import SetPrefered2faMethod from '../../../behaviors/SetPrefered2faMethod';
import { getPreferedMethodSuccess, setUseAnotherMethod } from '../../../reducers/Portal/SecondFactor/actions';
import Method2FA from '../../../types/Method2FA';
import { setUseAnotherMethod, setSecurityKeySupported } from '../../../reducers/Portal/SecondFactor/actions';
import GetAvailable2faMethods from '../../../behaviors/GetAvailable2faMethods';
import u2fApi from 'u2f-api';
const mapStateToProps = (state: RootState): StateProps => {
return {
@ -16,21 +17,14 @@ const mapStateToProps = (state: RootState): StateProps => {
}
}
async function storeMethod(dispatch: Dispatch, method: Method2FA) {
// display the new option
dispatch(getPreferedMethodSuccess(method));
dispatch(setUseAnotherMethod(false));
// And save the method for next time.
await SetPrefered2faMethod(dispatch, method);
}
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
return {
onInit: () => FetchPrefered2faMethod(dispatch),
onInit: async () => {
dispatch(setSecurityKeySupported(await u2fApi.isSupported()));
FetchPrefered2faMethod(dispatch);
GetAvailable2faMethods(dispatch);
},
onLogoutClicked: () => LogoutBehavior(dispatch),
onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'),
onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'),
onUseAnotherMethodClicked: () => dispatch(setUseAnotherMethod(true)),
}
}

View File

@ -10,7 +10,6 @@ import {
securityKeySignSuccess,
securityKeySign,
securityKeySignFailure,
setSecurityKeySupported
} from '../../../reducers/Portal/SecondFactor/actions';
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
@ -94,11 +93,7 @@ const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => {
await dispatch(push('/confirmation-sent'));
},
onInit: async () => {
const isU2FSupported = await u2fApi.isSupported();
if (isU2FSupported) {
await dispatch(setSecurityKeySupported(true));
await triggerSecurityKeySigning(dispatch, ownProps.redirectionUrl);
}
await triggerSecurityKeySigning(dispatch, ownProps.redirectionUrl);
},
}
}

View File

@ -0,0 +1,31 @@
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { RootState } from '../../../reducers';
import SetPrefered2faMethod from '../../../behaviors/SetPrefered2faMethod';
import { getPreferedMethodSuccess, setUseAnotherMethod, setSecurityKeySupported } from '../../../reducers/Portal/SecondFactor/actions';
import Method2FA from '../../../types/Method2FA';
import UseAnotherMethod, {StateProps, DispatchProps} from '../../../components/UseAnotherMethod/UseAnotherMethod';
const mapStateToProps = (state: RootState): StateProps => ({
availableMethods: state.secondFactor.getAvailableMethodResponse,
isSecurityKeySupported: state.secondFactor.securityKeySupported,
})
async function storeMethod(dispatch: Dispatch, method: Method2FA) {
// display the new option
dispatch(getPreferedMethodSuccess(method));
dispatch(setUseAnotherMethod(false));
// And save the method for next time.
await SetPrefered2faMethod(dispatch, method);
}
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
return {
onOneTimePasswordMethodClicked: () => storeMethod(dispatch, 'totp'),
onSecurityKeyMethodClicked: () => storeMethod(dispatch, 'u2f'),
onDuoPushMethodClicked: () => storeMethod(dispatch, "duo_push"),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(UseAnotherMethod);

View File

@ -9,3 +9,8 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
a {
text-decoration: underline;
cursor: pointer;
}

View File

@ -16,7 +16,13 @@ import {
SET_PREFERED_METHOD,
SET_PREFERED_METHOD_FAILURE,
SET_PREFERED_METHOD_SUCCESS,
SET_USE_ANOTHER_METHOD
SET_USE_ANOTHER_METHOD,
TRIGGER_DUO_PUSH_AUTH,
TRIGGER_DUO_PUSH_AUTH_SUCCESS,
TRIGGER_DUO_PUSH_AUTH_FAILURE,
GET_AVAILABLE_METHODS,
GET_AVAILABLE_METHODS_SUCCESS,
GET_AVAILABLE_METHODS_FAILURE
} from "../../constants";
import Method2FA from "../../../types/Method2FA";
@ -28,6 +34,16 @@ export const setUseAnotherMethod = createAction(SET_USE_ANOTHER_METHOD, resolve
return (useAnotherMethod: boolean) => resolve(useAnotherMethod);
});
export const getAvailbleMethods = createAction(GET_AVAILABLE_METHODS);
export const getAvailbleMethodsSuccess = createAction(GET_AVAILABLE_METHODS_SUCCESS, resolve => {
return (methods: Method2FA[]) => resolve(methods);
});
export const getAvailbleMethodsFailure = createAction(GET_AVAILABLE_METHODS_FAILURE, resolve => {
return (err: string) => resolve(err);
});
export const getPreferedMethod = createAction(GET_PREFERED_METHOD);
export const getPreferedMethodSuccess = createAction(GET_PREFERED_METHOD_SUCCESS, resolve => {
return (method: Method2FA) => resolve(method);
@ -36,18 +52,21 @@ export const getPreferedMethodFailure = createAction(GET_PREFERED_METHOD_FAILURE
return (err: string) => resolve(err);
});
export const setPreferedMethod = createAction(SET_PREFERED_METHOD);
export const setPreferedMethodSuccess = createAction(SET_PREFERED_METHOD_SUCCESS);
export const setPreferedMethodFailure = createAction(SET_PREFERED_METHOD_FAILURE, resolve => {
return (err: string) => resolve(err);
})
export const securityKeySign = createAction(SECURITY_KEY_SIGN);
export const securityKeySignSuccess = createAction(SECURITY_KEY_SIGN_SUCCESS);
export const securityKeySignFailure = createAction(SECURITY_KEY_SIGN_FAILURE, resolve => {
return (error: string) => resolve(error);
});
export const oneTimePasswordVerification = createAction(ONE_TIME_PASSWORD_VERIFICATION_REQUEST);
export const oneTimePasswordVerificationSuccess = createAction(ONE_TIME_PASSWORD_VERIFICATION_SUCCESS);
export const oneTimePasswordVerificationFailure = createAction(ONE_TIME_PASSWORD_VERIFICATION_FAILURE, resolve => {
@ -55,6 +74,13 @@ export const oneTimePasswordVerificationFailure = createAction(ONE_TIME_PASSWORD
});
export const triggerDuoPushAuth = createAction(TRIGGER_DUO_PUSH_AUTH);
export const triggerDuoPushAuthSuccess = createAction(TRIGGER_DUO_PUSH_AUTH_SUCCESS);
export const triggerDuoPushAuthFailure = createAction(TRIGGER_DUO_PUSH_AUTH_FAILURE, resolve => {
return (err: string) => resolve(err);
});
export const logout = createAction(LOGOUT_REQUEST);
export const logoutSuccess = createAction(LOGOUT_SUCCESS);
export const logoutFailure = createAction(LOGOUT_FAILURE, resolve => {

View File

@ -12,6 +12,10 @@ interface SecondFactorState {
userAnotherMethod: boolean;
getAvailableMethodsLoading: boolean;
getAvailableMethodResponse: Method2FA[] | null;
getAvailableMethodError: string | null;
preferedMethodLoading: boolean;
preferedMethodError: string | null;
preferedMethod: Method2FA | null;
@ -27,6 +31,10 @@ interface SecondFactorState {
oneTimePasswordVerificationLoading: boolean,
oneTimePasswordVerificationSuccess: boolean | null,
oneTimePasswordVerificationError: string | null,
duoPushVerificationLoading: boolean;
duoPushVerificationSuccess: boolean | null;
duoPushVerificationError: string | null;
}
const secondFactorInitialState: SecondFactorState = {
@ -36,6 +44,10 @@ const secondFactorInitialState: SecondFactorState = {
userAnotherMethod: false,
getAvailableMethodsLoading: false,
getAvailableMethodResponse: null,
getAvailableMethodError: null,
preferedMethod: null,
preferedMethodError: null,
preferedMethodLoading: false,
@ -51,6 +63,10 @@ const secondFactorInitialState: SecondFactorState = {
oneTimePasswordVerificationLoading: false,
oneTimePasswordVerificationError: null,
oneTimePasswordVerificationSuccess: null,
duoPushVerificationLoading: false,
duoPushVerificationSuccess: null,
duoPushVerificationError: null,
}
export type PortalState = StateType<SecondFactorState>;
@ -163,6 +179,45 @@ export default (state = secondFactorInitialState, action: SecondFactorAction): S
...state,
userAnotherMethod: action.payload,
}
case getType(Actions.triggerDuoPushAuth):
return {
...state,
duoPushVerificationLoading: true,
duoPushVerificationError: null,
duoPushVerificationSuccess: null,
}
case getType(Actions.triggerDuoPushAuthSuccess):
return {
...state,
duoPushVerificationLoading: false,
duoPushVerificationSuccess: true,
}
case getType(Actions.triggerDuoPushAuthFailure):
return {
...state,
duoPushVerificationLoading: false,
duoPushVerificationError: action.payload,
}
case getType(Actions.getPreferedMethod):
return {
...state,
getAvailableMethodsLoading: true,
getAvailableMethodResponse: null,
getAvailableMethodError: null,
}
case getType(Actions.getAvailbleMethodsSuccess):
return {
...state,
getAvailableMethodsLoading: false,
getAvailableMethodResponse: action.payload,
}
case getType(Actions.getAvailbleMethodsFailure):
return {
...state,
getAvailableMethodsLoading: false,
getAvailableMethodError: action.payload,
}
}
return state;
}

View File

@ -12,6 +12,10 @@ export const AUTHENTICATE_FAILURE = '@portal/authenticate_failure';
export const SET_SECURITY_KEY_SUPPORTED = '@portal/second_factor/set_security_key_supported';
export const SET_USE_ANOTHER_METHOD = '@portal/second_factor/set_use_another_method';
export const GET_AVAILABLE_METHODS = '@portal/second_factor/get_available_methods';
export const GET_AVAILABLE_METHODS_SUCCESS = '@portal/second_factor/get_available_methods_success';
export const GET_AVAILABLE_METHODS_FAILURE = '@portal/second_factor/get_available_methods_failure';
export const GET_PREFERED_METHOD = '@portal/second_factor/get_prefered_method';
export const GET_PREFERED_METHOD_SUCCESS = '@portal/second_factor/get_prefered_method_success';
export const GET_PREFERED_METHOD_FAILURE = '@portal/second_factor/get_prefered_method_failure';
@ -28,6 +32,10 @@ export const ONE_TIME_PASSWORD_VERIFICATION_REQUEST = '@portal/second_factor/one
export const ONE_TIME_PASSWORD_VERIFICATION_SUCCESS = '@portal/second_factor/one_time_password_verification_success';
export const ONE_TIME_PASSWORD_VERIFICATION_FAILURE = '@portal/second_factor/one_time_password_verification_failure';
export const TRIGGER_DUO_PUSH_AUTH = '@portal/second_factor/trigger_duo_push_auth_request';
export const TRIGGER_DUO_PUSH_AUTH_SUCCESS = '@portal/second_factor/trigger_duo_push_auth_request_success';
export const TRIGGER_DUO_PUSH_AUTH_FAILURE = '@portal/second_factor/trigger_duo_push_auth_request_failure';
export const LOGOUT_REQUEST = '@portal/logout_request';
export const LOGOUT_SUCCESS = '@portal/logout_success';
export const LOGOUT_FAILURE = '@portal/logout_failure';

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) {
return this.fetchSafe('/api/password-reset/identity/start', {
method: 'POST',
@ -156,6 +171,10 @@ class AutheliaService {
body: JSON.stringify({method})
});
}
static async getAvailable2faMethods(): Promise<Method2FA[]> {
return await this.fetchSafeJson('/api/secondfactor/available');
}
}
export default AutheliaService;

View File

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

View File

@ -29,6 +29,15 @@ default_redirection_url: https://home.example.com:8080/
totp:
issuer: authelia.com
# Duo Push API
#
# Parameters used to contact the Duo API. Those are generated when you protect an application
# of type "Partner Auth API" in the management panel.
duo_api:
hostname: api-123456789.example.com
integration_key: ABCDEF
secret_key: 1234567890abcdefghifjkl
# The authentication backend to use for verifying user passwords
# and retrieve information such as email address and groups
# users belong to.

View File

@ -0,0 +1,47 @@
# Duo Push Notification
Using mobile push notifications is becoming the new trendy way to validate
the second factor of a 2FA authentication process. [Duo](https://duo.com/) is offering an API
to integrate this kind validation and **Authelia** leverages this mechanism
so that you can simply push a button on your smartphone to be securely granted
access to your services.
<p align="center">
<img src="../../images/2factor_duo.png" width="400">
</p>
In order to use this feature, you should first create a free account on Duo
(up to 10 users), create a user account and attach it a mobile device. The name
of the user must match the name of the user in your internal database.
Then, click on *Applications* and *Protect an Application*. Then select the option
called *Partner Auth API*. This will generate an integration key, a secret key and
a hostname. You can set the name of the application to **Authelia** and then you
must add the generated information to your configuration as:
duo_api:
hostname: api-123456789.example.com
integration_key: ABCDEF
secret_key: 1234567890abcdefghifjkl
This can be seen in [config.template.yml](../../config.template.yml) file.
When selecting *Duo Push Notification* at the second factor stage, you will
automatically receive a push notification on your phone to grant or deny access.
<p align="center">
<img src="../../images/duo-push-1.jpg" width="400">
<img src="../../images/duo-push-2.png" width="400">
</p>
## Limitations
Users must be enrolled via the Duo Admin panel, they cannot enroll a device from
**Authelia** yet.
## FAQ
### Why don't I have access to the *Duo Push Notification* option?
It's likely that you have not configured **Authelia** correctly. Please read this
documentation again and be sure you had a look at [config.template.yml](../../config.template.yml).

View File

@ -0,0 +1,40 @@
# Security Keys (U2F)
**Authelia** also offers authentication using Security Keys supporting U2F
like [Yubikey](Yubikey) USB devices. U2F is one of the most secure
authentication protocol and is already available for Google, Facebook, Github
accounts and more.
The protocol requires your security key being enrolled before authenticating.
<p align="center">
<img src="../../images/2factor_u2f.png" width="400">
</p>
To do so, select the *Security Key* method in the second factor page and click
on the *register new device* link. This will send a link to the
user email address. This e-mail will likely be sent to https://mail.example.com:8080/
if you're testing Authelia and you've not configured anything.
Confirm your identity by clicking on **Continue** and you'll be asked to
touch the token of your security key to enroll.
<p align="center">
<img src="../../images/u2f.png" width="400">
</p>
Upon successful registration, you can authenticate using your security key by simply
touching the token again.
Easy, right?!
## FAQ
### Why don't I have access to the *Security Key* option?
U2F protocol is a new protocol that is only supported by recent browser
and must even be enabled on some of them like Firefox. Please be sure
your browser supports U2F and that the feature is enabled to make the
option available in **Authelia**.
[Yubikey]: https://www.yubico.com/products/yubikey-hardware/yubikey4/

View File

@ -0,0 +1,29 @@
# One-Time Passwords
In **Authelia**, your users can use [Google Authenticator] for generating unique
tokens that they can use to pass the second factor.
<p align="center">
<img src="../../images/2factor_totp.png" width="400">
</p>
Select the *One-Time Password method* and click on the *register new device* link.
Then, check the email sent by **Authelia** to your email address
to validate your identity. If you're testing **Authelia**, it's likely
that this e-mail has been sent to https://mail.example.com:8080/
Confirm your identity by clicking on **Continue** and you'll get redirected
on a page where your secret will be displayed as QRCode and in Base32 formats.
<p align="center">
<img src="../../images/totp.png" width="400">
</p>
You can use [Google Authenticator] to store it.
From now on, you'll get generated
tokens from your phone that you can use to validate the second factor in **Authelia**.
[Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en

View File

@ -15,39 +15,16 @@ You can find an example of the configuration of the LDAP backend in
</p>
## Second factor with TOTP
## Second factor
In **Authelia**, you can register a per user TOTP (Time-Based One Time
Password) secret before being being able to authenticate. Click on the
register button and check the email **Authelia** sent to your email address
to validate your identity.
**Authelia** comes with three kind of second factor.
Confirm your identity by clicking on **Continue** and you'll get redirected
on a page where your secret will be displayed in QRCode and Base32 formats.
You can use [Google Authenticator] to store it and get the generated tokens.
* Security keys like [Yubikey]. More info [here](./2factor/security-key.md).
* One-Time Passwords generated by [Google Authenticator]. More info [here](./2factor/time-based-one-time-password.md).
* Duo Push Notifications to use with [Duo mobile application](https://play.google.com/store/apps/details?id=com.duosecurity.duomobile&hl=en) available on Android, iOS and Windows. More info [here](./2factor/duo-push-notifications.md).
<p align="center">
<img src="../images/totp.png" width="400">
</p>
## Second factor with U2F security keys
**Authelia** also offers authentication using U2F (Universal 2-Factor) devices
like [Yubikey](Yubikey) USB security keys. U2F is one of the most secure
authentication protocol and is already available for Google, Facebook, Github
accounts and more.
Like TOTP, U2F requires you register your security key before authenticating.
To do so, click on the register button. This will send a link to the
user email address.
Confirm your identity by clicking on **Continue** and you'll be asked to
touch the token of your device to register. Upon successful registration,
you can authenticate using your U2F device by simply touching the token.
Easy, right?!
<p align="center">
<img src="../images/u2f.png" width="400">
<img src="../images/use-another-method.png" width="400">
</p>
## Password reset
@ -96,5 +73,5 @@ Redis key/value store. You can specify your own Redis instance in
[basic authentication]: https://en.wikipedia.org/wiki/Basic_access_authentication
[config.template.yml]: https://github.com/clems4ever/authelia/blob/master/config.template.yml
[Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en
[Yubikey]: https://www.yubico.com/products/yubikey-hardware/yubikey4/
[Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en

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

358
example/compose/duo-api/package-lock.json generated Normal file
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

@ -12,10 +12,10 @@
one of the following links to test access control powered by Authelia.<br/>
<ul>
<li>
public.example.com <a href="https://public.example.com:8080/"> / index.html</a>
public.example.com <a href="https://public.example.com:8080/"> /</a>
</li>
<li>
secure.example.com <a href="https://secure.example.com:8080/"> / secret.html</a>
secure.example.com <a href="https://secure.example.com:8080/secret.html"> / secret.html</a>
</li>
<li>
singlefactor.example.com <a href="https://singlefactor.example.com:8080/secret.html"> / secret.html</a>

View File

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

View File

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

BIN
images/2factor_duo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
images/2factor_totp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
images/2factor_u2f.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
images/duo-push-1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
images/duo-push-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

62
package-lock.json generated
View File

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

View File

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

View File

@ -3,10 +3,60 @@
var { exec } = require('./utils/exec');
var fs = require('fs');
async function main() {
async function buildDockerImages() {
console.log("[BOOTSTRAP] Building required Docker images...");
console.log('Build authelia-example-backend docker image.')
await exec('docker build -t authelia-example-backend example/compose/nginx/backend');
console.log('Build authelia-duo-api docker image.')
await exec('docker build -t authelia-duo-api example/compose/duo-api');
}
async function checkHostsFile() {
async function checkAndFixEntry(entries, domain, ip) {
const foundEntry = entries.filter(l => l[1] == domain);
if (foundEntry.length > 0) {
if (foundEntry[0][0] == ip) {
// The entry exists and is correct.
return;
}
else {
// We need to remove the entry and replace it.
console.log(`Update entry for ${domain}.`);
await exec(`cat /etc/hosts | grep -v "${domain}" | /usr/bin/sudo tee /etc/hosts > /dev/null`);
await exec(`echo "${ip} ${domain}" | /usr/bin/sudo tee -a /etc/hosts > /dev/null`);
}
}
else {
// We need to add the new entry.
console.log(`Add entry for ${domain}.`);
await exec(`echo "${ip} ${domain}" | /usr/bin/sudo tee -a /etc/hosts > /dev/null`);
}
}
console.log("[BOOTSTRAP] Checking if example.com domain is forwarded to your machine...");
const actualEntries = fs.readFileSync("/etc/hosts").toString("utf-8")
.split("\n").filter(l => l !== '').map(l => l.split(" ").filter(w => w !== ''));
await checkAndFixEntry(actualEntries, 'login.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'admin.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'singlefactor.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'dev.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'home.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'mx1.mail.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'mx2.mail.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'public.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'secure.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'authelia.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'mail.example.com', '127.0.0.1');
await checkAndFixEntry(actualEntries, 'duo.example.com', '192.168.240.100');
}
async function checkKubernetesDependencies() {
console.log("[BOOTSTRAP] Checking Kubernetes tools in /tmp to allow testing a Kube cluster... (no junk installed on host)");
if (!fs.existsSync('/tmp/kind')) {
console.log('Install Kind for spawning a Kubernetes cluster.');
await exec('wget https://github.com/clems4ever/kind/releases/download/0.1.0-cmic1/kind-linux-amd64 -O /tmp/kind && chmod +x /tmp/kind');
@ -18,6 +68,12 @@ async function main() {
}
}
async function main() {
await checkHostsFile();
await buildDockerImages();
await checkKubernetesDependencies();
}
main().catch((err) => {
console.error(err);
process.exit(1);

View File

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

View File

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

View File

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

View File

@ -0,0 +1,36 @@
import * as Express from "express";
import { ServerVariables } from "../../../ServerVariables";
import { ServerVariablesMockBuilder } from "../../../ServerVariablesMockBuilder.spec";
import * as ExpressMock from "../../../stubs/express.spec";
import Get from "./Get";
import * as Assert from "assert";
describe("routes/secondfactor/duo-push/Post", function() {
let vars: ServerVariables;
let req: Express.Request;
let res: ExpressMock.ResponseMock;
beforeEach(function() {
const sv = ServerVariablesMockBuilder.build();
vars = sv.variables;
req = ExpressMock.RequestMock();
res = ExpressMock.ResponseMock();
})
it("should return default available methods", async function() {
await Get(vars)(req, res as any);
Assert(res.json.calledWith(["u2f", "totp"]));
});
it("should return duo as an available method", async function() {
vars.config.duo_api = {
hostname: "example.com",
integration_key: "ABCDEFG",
secret_key: "ekjfzelfjz",
}
await Get(vars)(req, res as any);
Assert(res.json.calledWith(["u2f", "totp", "duo_push"]));
});
});

View File

@ -0,0 +1,14 @@
import * as Express from "express";
import { ServerVariables } from "../../../ServerVariables";
import Method2FA from "../../../../../../shared/Method2FA";
export default function(vars: ServerVariables) {
return async function(_: Express.Request, res: Express.Response) {
const availableMethods: Method2FA[] = ["u2f", "totp"];
if (vars.config.duo_api) {
availableMethods.push("duo_push");
}
res.json(availableMethods);
};
}

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 * 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;

View File

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

View File

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

View File

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

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";
/**
* @api {post} /api/duo-push Complete Duo Push Factor
* @apiName ValidateDuoPushSecondFactor
* @apiGroup DuoPush
* @apiVersion 1.0.0
* @apiUse UserSession
* @apiUse InternalError
*
* @apiSuccess (Success 302) Redirect to the URL that has been stored during last call to /api/verify.
* @apiError (Error 401) {none} error TOTP token is invalid.
*
* @apiDescription Verify TOTP token. The user is authenticated upon success.
*/
export const SECOND_FACTOR_DUO_PUSH_POST = "/api/duo-push";
/**
* @api {get} /api/secondfactor/u2f/identity/start Start U2F registration identity validation
@ -182,6 +197,16 @@ export const SECOND_FACTOR_PREFERENCES_GET = "/api/secondfactor/preferences";
*/
export const SECOND_FACTOR_PREFERENCES_POST = "/api/secondfactor/preferences";
/**
* @api {post} /api/secondfactor/available List the available methods.
* @apiName GetAvailableMethods
* @apiGroup 2FA
* @apiVersion 1.0.0
*
* @apiDescription Get the available 2FA methods.
*/
export const SECOND_FACTOR_AVAILABLE_GET = "/api/secondfactor/available";
/**
* @api {post} /api/password-reset Set new password

View File

@ -0,0 +1,16 @@
import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
import VerifyElementDoesNotExist from "./VerifyElementDoesNotExist";
/**
* Verify that an element does not exist.
*
* @param driver The selenium driver
* @param content The content of the button to select.
*/
export default async function(driver: WebDriver, content: string) {
try {
await VerifyElementDoesNotExist(driver, SeleniumWebDriver.By.xpath("//button[text()='" + content + "']"));
} catch (err) {
throw new Error(`Button with content "${content}" should not exist.`);
}
}

View File

@ -0,0 +1,15 @@
import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
import VerifyHasAppeared from "./VerifyHasAppeared";
/**
* Verify if a button with given content exists in the DOM.
* @param driver The selenium web driver.
* @param content The content of the button to find in the DOM.
*/
export default async function(driver: WebDriver, content: string) {
try {
await VerifyHasAppeared(driver, SeleniumWebDriver.By.xpath("//button[text()='" + content + "']"));
} catch (err) {
throw new Error(`Button with content "${content}" should have appeared.`);
}
}

View File

@ -0,0 +1,13 @@
import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
/**
*
* @param driver The selenium web driver
* @param locator The locator of the element to check it does not exist.
*/
export default async function(driver: WebDriver, locator: SeleniumWebDriver.Locator) {
const els = await driver.findElements(locator);
if (els.length > 0) {
throw new Error("Element exists.");
}
}

View File

@ -0,0 +1,13 @@
import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
/**
*
* @param driver The selenium web driver.
* @param locator The locator of the element to find in the DOM.
*/
export default async function(driver: WebDriver, locator: SeleniumWebDriver.Locator) {
const els = await driver.findElements(locator);
if (els.length == 0) {
throw new Error("Element does not exist.");
}
}

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

@ -0,0 +1,6 @@
import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
export default async function(driver: WebDriver, timeout: number = 5000) {
await driver.wait(SeleniumWebDriver.until.elementLocated(
SeleniumWebDriver.By.className('duo-push-view')), timeout);
}

View File

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

View File

@ -23,6 +23,10 @@ class DockerCompose {
async ps() {
return Promise.resolve(execSync(this.commandPrefix + ' ps').toString('utf-8'));
}
async logs(service: string) {
await exec(this.commandPrefix + ' logs ' + service)
}
}
export default DockerCompose;

View File

@ -11,6 +11,10 @@ class DockerEnvironment {
await this.dockerCompose.up();
}
async logs(service: string) {
await this.dockerCompose.logs(service);
}
async stop() {
await this.dockerCompose.down();
}

View File

@ -1 +1,3 @@
users_database.test.yml
private-*/

View File

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

View File

@ -0,0 +1,30 @@
import { StartDriver, StopDriver } from "../../../helpers/context/WithDriver";
import LoginAs from "../../../helpers/LoginAs";
import VerifyIsSecondFactorStage from "../../../helpers/assertions/VerifyIsSecondFactorStage";
import ClickOnLink from "../../../helpers/ClickOnLink";
import VerifyIsUseAnotherMethodView from "../../../helpers/assertions/VerifyIsUseAnotherMethodView";
import VerifyButtonDoesNotExist from "../../../helpers/assertions/VerifyButtonDoesNotExist";
import VerifyButtonHasAppeared from "../../../helpers/assertions/VerifyButtonHasAppeared";
export default function() {
before(async function() {
this.driver = await StartDriver();
});
after(async function() {
await StopDriver(this.driver);
});
// The Duo API is not configured so we should not see the method.
it("should not display duo push notification method", async function() {
await LoginAs(this.driver, "john", "password", "https://secure.example.com:8080/");
await VerifyIsSecondFactorStage(this.driver);
await ClickOnLink(this.driver, 'Use another method');
await VerifyIsUseAnotherMethodView(this.driver);
await VerifyButtonHasAppeared(this.driver, "One-Time Password");
await VerifyButtonDoesNotExist(this.driver, "Duo Push Notification");
});
}

View File

@ -10,7 +10,7 @@ import LogoutRedirectToAlreadyLoggedIn from './scenarii/LogoutRedirectToAlreadyL
import { exec } from '../../helpers/utils/exec';
import TwoFactorAuthentication from "../../helpers/scenarii/TwoFactorAuthentication";
import BypassPolicy from "./scenarii/BypassPolicy";
import Prefered2faMethod from "./scenarii/Prefered2faMethod";
import NoDuoPushOption from "./scenarii/NoDuoPushOption";
AutheliaSuite(__dirname, function() {
this.timeout(10000);
@ -29,5 +29,5 @@ AutheliaSuite(__dirname, function() {
describe('TOTP Validation', TOTPValidation);
describe('Required two factor', RequiredTwoFactor);
describe('Logout endpoint redirect to already logged in page', LogoutRedirectToAlreadyLoggedIn);
describe('Prefered 2FA method', Prefered2faMethod);
describe('No Duo Push method available', NoDuoPushOption);
});

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,38 @@
import fs from 'fs';
import { exec } from "../../helpers/utils/exec";
import AutheliaServer from "../../helpers/context/AutheliaServer";
import DockerEnvironment from "../../helpers/context/DockerEnvironment";
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0 as any;
const autheliaServer = new AutheliaServer(__dirname + '/config.yml', [__dirname + '/users_database.yml']);
const dockerEnv = new DockerEnvironment([
'docker-compose.yml',
'example/compose/nginx/backend/docker-compose.yml',
'example/compose/nginx/portal/docker-compose.yml',
'example/compose/duo-api/docker-compose.yml',
])
async function setup() {
await exec(`cp ${__dirname}/users_database.yml ${__dirname}/users_database.test.yml`);
await exec('mkdir -p /tmp/authelia/db');
await exec('./example/compose/nginx/portal/render.js ' + (fs.existsSync('.suite') ? '': '--production'));
await dockerEnv.start();
await autheliaServer.start();
}
async function teardown() {
await autheliaServer.stop();
await dockerEnv.stop();
await exec('rm -rf /tmp/authelia/db');
}
const setup_timeout = 30000;
const teardown_timeout = 30000;
export {
setup,
setup_timeout,
teardown,
teardown_timeout
};

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

@ -6,6 +6,7 @@ import VerifyIsUseAnotherMethodView from "../../../helpers/assertions/VerifyIsUs
import ClickOnButton from "../../../helpers/behaviors/ClickOnButton";
import VerifyIsSecurityKeyView from "../../../helpers/assertions/VerifyIsSecurityKeyView";
import VerifyIsSecondFactorStage from "../../../helpers/assertions/VerifyIsSecondFactorStage";
import VerifyIsDuoPushNotificationView from "../../../helpers/assertions/VerifyIsDuoPushNotificationView";
// This fixture tests that the latest used method is still used when the user gets back.
@ -26,10 +27,10 @@ export default function() {
await ClickOnLink(this.driver, 'Use another method');
await VerifyIsUseAnotherMethodView(this.driver);
await ClickOnButton(this.driver, 'Security Key (U2F)');
await ClickOnButton(this.driver, 'Duo Push Notification');
// Verify that the user is redirected to the new method
await VerifyIsSecurityKeyView(this.driver);
await VerifyIsDuoPushNotificationView(this.driver);
await ClickOnLink(this.driver, "Logout");
// Login with another user to check that he gets TOTP view.
@ -39,7 +40,7 @@ export default function() {
// Log john again to check that the prefered method has been persisted
await LoginAs(this.driver, "john", "password", "https://secure.example.com:8080/");
await VerifyIsSecurityKeyView(this.driver);
await VerifyIsDuoPushNotificationView(this.driver);
// Restore the prefered method to one-time password.
await ClickOnLink(this.driver, 'Use another method');

View File

@ -0,0 +1,18 @@
import AutheliaSuite from "../../helpers/context/AutheliaSuite";
import { exec } from '../../helpers/utils/exec';
import DuoPushNotification from "./scenarii/DuoPushNotification";
import Prefered2faMethod from "./scenarii/Prefered2faMethod";
// required to query duo-api over https
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0 as any;
AutheliaSuite(__dirname, function() {
this.timeout(10000);
beforeEach(async function() {
await exec(`cp ${__dirname}/users_database.yml ${__dirname}/users_database.test.yml`);
});
describe("Duo Push Notication", DuoPushNotification);
describe("Prefered 2FA methods", Prefered2faMethod);
});

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