Merge pull request #342 from clems4ever/duo-push
Add Duo Push Notification option as 2FA.pull/344/head
14
.travis.yml
|
@ -1,4 +1,5 @@
|
|||
language: node_js
|
||||
required: sudo
|
||||
node_js:
|
||||
- '9'
|
||||
services:
|
||||
|
@ -12,19 +13,6 @@ addons:
|
|||
packages:
|
||||
- libgif-dev
|
||||
- google-chrome-stable
|
||||
hosts:
|
||||
- admin.example.com
|
||||
- login.example.com
|
||||
- singlefactor.example.com
|
||||
- dev.example.com
|
||||
- home.example.com
|
||||
- mx1.mail.example.com
|
||||
- mx2.mail.example.com
|
||||
- public.example.com
|
||||
- secure.example.com
|
||||
- authelia.example.com
|
||||
- admin.example.com
|
||||
- mail.example.com
|
||||
|
||||
before_script:
|
||||
- export DISPLAY=:99.0
|
||||
|
|
16
README.md
|
@ -8,8 +8,9 @@
|
|||
[![Gitter](https://img.shields.io/gitter/room/badges/shields.svg)](https://gitter.im/authelia/general?utm_source=share-link&utm_medium=link&utm_campaign=share-link)
|
||||
[![Donate](https://img.shields.io/badge/Donate-PayPal-orange.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=clement%2emichaud34%40gmail%2ecom&lc=FR&item_name=Authelia¤cy_code=EUR&bn=PP%2dDonationsBF%3abtn_donate_SM%2egif%3aNonHosted)
|
||||
|
||||
**Authelia** is an open-source authentication and authorization providing
|
||||
2-factor authentication and single sign-on (SSO) for your applications.
|
||||
**Authelia** is an open-source authentication and authorization server
|
||||
providing 2-factor authentication and single sign-on (SSO) for your
|
||||
applications.
|
||||
It acts as a companion of reverse proxies by handling authentication and
|
||||
authorization requests.
|
||||
|
||||
|
@ -20,15 +21,17 @@ for specific services in only few seconds.
|
|||
|
||||
<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
|
||||
|
|
18
bootstrap.sh
|
@ -24,24 +24,6 @@ then
|
|||
return;
|
||||
fi
|
||||
|
||||
echo "[BOOTSTRAP] Checking if example.com domain is forwarded to your machine..."
|
||||
cat /etc/hosts | grep "login.example.com" > /dev/null
|
||||
if [ $? -ne 0 ];
|
||||
then
|
||||
echo "[ERROR] Please add those lines to /etc/hosts:
|
||||
|
||||
127.0.0.1 home.example.com
|
||||
127.0.0.1 public.example.com
|
||||
127.0.0.1 secure.example.com
|
||||
127.0.0.1 dev.example.com
|
||||
127.0.0.1 admin.example.com
|
||||
127.0.0.1 mx1.mail.example.com
|
||||
127.0.0.1 mx2.mail.example.com
|
||||
127.0.0.1 singlefactor.example.com
|
||||
127.0.0.1 login.example.com"
|
||||
return;
|
||||
fi
|
||||
|
||||
echo "[BOOTSTRAP] Running additional bootstrap steps..."
|
||||
authelia-scripts bootstrap
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
|
@ -9,3 +9,8 @@ code {
|
|||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
|
@ -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 => {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
|
@ -1,4 +1,4 @@
|
|||
|
||||
type Method2FA = "u2f" | "totp";
|
||||
type Method2FA = "u2f" | "totp" | "duo_push";
|
||||
|
||||
export default Method2FA;
|
|
@ -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.
|
||||
|
|
|
@ -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).
|
|
@ -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/
|
|
@ -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
|
|
@ -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
|
|
@ -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"]
|
|
@ -0,0 +1,6 @@
|
|||
version: '2'
|
||||
services:
|
||||
duo-api:
|
||||
image: authelia-duo-api
|
||||
networks:
|
||||
- authelianet
|
|
@ -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]);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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="
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 51 KiB |
After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 26 KiB |
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
export interface DuoPushConfiguration {
|
||||
hostname: string;
|
||||
integration_key: string;
|
||||
secret_key: string;
|
||||
}
|
|
@ -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"]));
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.`);
|
||||
}
|
||||
}
|
|
@ -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.`);
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
users_database.test.yml
|
||||
|
||||
private-*/
|
|
@ -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:
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
|
@ -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.
|
|
@ -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
|
||||
|
|
@ -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
|
||||
};
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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');
|
|
@ -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);
|
||||
});
|
|
@ -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
|