Fix e2e tests for complete configuration.

This commit is contained in:
Clement Michaud 2019-01-30 22:44:03 +01:00
parent 387187b152
commit d2a547eca6
23 changed files with 254 additions and 127 deletions

View File

@ -0,0 +1,15 @@
import { Dispatch } from "redux";
import * as AutheliaService from '../services/AutheliaService';
export default async function(url: string, dispatch: Dispatch) {
try {
// Check the url against the backend before redirecting.
await AutheliaService.checkRedirection(url);
window.location.href = url;
} catch (e) {
console.error(
'Cannot redirect since the URL is not in the protected domain.' +
'This behavior could be malicious so please the issue to an administrator.');
throw e;
}
}

View File

@ -9,6 +9,7 @@ import * as AutheliaService from '../../../services/AutheliaService';
import { push } from 'connected-react-router';
import fetchState from '../../../behaviors/FetchStateBehavior';
import LogoutBehavior from '../../../behaviors/LogoutBehavior';
import SafelyRedirectBehavior from '../../../behaviors/SafelyRedirectBehavior';
const mapStateToProps = (state: RootState): StateProps => ({
securityKeySupported: state.secondFactor.securityKeySupported,
@ -52,19 +53,23 @@ async function triggerSecurityKeySigning(dispatch: Dispatch) {
await dispatch(securityKeySignSuccess());
}
function redirectOnSuccess(dispatch: Dispatch, ownProps: OwnProps, duration?: number) {
function redirect() {
async function handleSuccess(dispatch: Dispatch, ownProps: OwnProps, duration?: number) {
async function handle() {
if (ownProps.redirection) {
window.location.href = ownProps.redirection;
try {
await SafelyRedirectBehavior(ownProps.redirection, dispatch);
} catch (e) {
await fetchState(dispatch);
}
} else {
fetchState(dispatch);
await fetchState(dispatch);
}
}
if (duration) {
setTimeout(redirect, duration);
setTimeout(handle, duration);
} else {
redirect();
await handle();
}
}
@ -84,7 +89,7 @@ const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => {
if (isU2FSupported) {
await dispatch(setSecurityKeySupported(true));
await triggerSecurityKeySigning(dispatch);
redirectOnSuccess(dispatch, ownProps, 1000);
await handleSuccess(dispatch, ownProps, 1000);
}
},
onOneTimePasswordValidationRequested: async (token: string) => {
@ -106,7 +111,7 @@ const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => {
throw body['error'];
}
dispatch(oneTimePasswordVerificationSuccess());
redirectOnSuccess(dispatch, ownProps);
await handleSuccess(dispatch, ownProps);
},
}
}

View File

@ -1,10 +1,10 @@
import { connect } from 'react-redux';
import AuthenticationView, {StateProps, Stage, DispatchProps} from '../../../views/AuthenticationView/AuthenticationView';
import QueryString from 'query-string';
import AuthenticationView, {StateProps, Stage, DispatchProps, OwnProps} from '../../../views/AuthenticationView/AuthenticationView';
import { RootState } from '../../../reducers';
import { Dispatch } from 'redux';
import AuthenticationLevel from '../../../types/AuthenticationLevel';
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
import { setRedirectionUrl } from '../../../reducers/Portal/Authentication/actions';
function authenticationLevelToStage(level: AuthenticationLevel): Stage {
switch (level) {
@ -17,12 +17,21 @@ function authenticationLevelToStage(level: AuthenticationLevel): Stage {
}
}
const mapStateToProps = (state: RootState): StateProps => {
const mapStateToProps = (state: RootState, ownProps: OwnProps): StateProps => {
const stage = (state.authentication.remoteState)
? authenticationLevelToStage(state.authentication.remoteState.authentication_level)
: Stage.FIRST_FACTOR;
let url: string | null = null;
if (ownProps.location) {
const params = QueryString.parse(ownProps.location.search);
if ('rd' in params) {
url = params['rd'] as string;
}
}
return {
redirectionUrl: state.authentication.redirectionUrl,
redirectionUrl: url,
remoteState: state.authentication.remoteState,
stage: stage,
};
@ -30,11 +39,8 @@ const mapStateToProps = (state: RootState): StateProps => {
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
return {
onInit: async (redirectionUrl?: string) => {
onInit: async () => {
await FetchStateBehavior(dispatch);
if (redirectionUrl) {
await dispatch(setRedirectionUrl(redirectionUrl));
}
}
}
}

View File

@ -114,4 +114,24 @@ export async function resetPassword(newPassword: string) {
},
body: JSON.stringify({password: newPassword})
});
}
export async function checkRedirection(url: string) {
const res = await fetch('/api/redirect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({url})
})
if (res.status !== 200) {
throw new Error('Status code ' + res.status);
}
const text = await res.text();
if (text !== 'OK') {
throw new Error('Cannot redirect');
}
return;
}

View File

@ -3,8 +3,7 @@ import AlreadyAuthenticated from "../../containers/components/AlreadyAuthenticat
import FirstFactorForm from "../../containers/components/FirstFactorForm/FirstFactorForm";
import SecondFactorForm from "../../containers/components/SecondFactorForm/SecondFactorForm";
import RemoteState from "./RemoteState";
import { RouterProps } from "react-router";
import queryString from 'query-string';
import { RouterProps, RouteProps } from "react-router";
export enum Stage {
FIRST_FACTOR,
@ -12,6 +11,8 @@ export enum Stage {
ALREADY_AUTHENTICATED,
}
export interface OwnProps extends RouteProps {}
export interface StateProps {
stage: Stage;
remoteState: RemoteState | null;
@ -19,19 +20,13 @@ export interface StateProps {
}
export interface DispatchProps {
onInit: (redirectionUrl?: string) => void;
onInit: () => void;
}
export type Props = StateProps & DispatchProps & RouterProps;
class AuthenticationView extends Component<Props> {
componentDidMount() {
if (this.props.history.location) {
const params = queryString.parse(this.props.history.location.search);
if ('rd' in params) {
this.props.onInit(params['rd'] as string);
}
}
componentWillMount() {
this.props.onInit();
}

View File

@ -4,6 +4,8 @@
port: 9091
log_level: debug
authentication_backend:
file:
path: ./users_database.yml

24
package-lock.json generated
View File

@ -532,6 +532,11 @@
"integrity": "sha512-vOVmaruQG5EatOU/jM6yU2uCp3Lz6mK1P5Ztu4iJjfM4SVHU9XYktPUQtKlIXuahqXHdEyUarMrBEwg5Cwu+bA==",
"dev": true
},
"@types/url-parse": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@types/url-parse/-/url-parse-1.4.2.tgz",
"integrity": "sha512-Z25Ef2iI0lkiBAoe8qATRHQWy+BWFWuWasD6HB5prsWx2QjLmB/ngXv8v9dePw2jDwdSvET4/6J5HxmeqhslmQ=="
},
"@types/winston": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@types/winston/-/winston-2.3.9.tgz",
@ -7053,6 +7058,11 @@
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
"dev": true
},
"querystringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.1.0.tgz",
"integrity": "sha512-sluvZZ1YiTLD5jsqZcDmFyV2EwToyXZBfpoVOmktMmW+VEnhgakFHnasVph65fOjGPTWN0Nw3+XQaSeMayr0kg=="
},
"random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
@ -7424,6 +7434,11 @@
"semver": "5.5.0"
}
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"resolve": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz",
@ -8924,6 +8939,15 @@
}
}
},
"url-parse": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.4.tgz",
"integrity": "sha512-/92DTTorg4JjktLNLe6GPS2/RvAd/RGr6LuktmWSMLEOa6rjnlrFXNgSbSmkNvCoL2T028A0a1JaJLzRMlFoHg==",
"requires": {
"querystringify": "2.1.0",
"requires-port": "1.0.0"
}
},
"url-parse-lax": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz",

View File

@ -27,6 +27,7 @@
"title": "Authelia API documentation"
},
"dependencies": {
"@types/url-parse": "^1.4.2",
"ajv": "^6.3.0",
"bluebird": "^3.5.0",
"body-parser": "^1.15.2",
@ -50,6 +51,7 @@
"speakeasy": "^2.0.0",
"u2f": "^0.1.2",
"u2f-api": "^1.0.7",
"url-parse": "^1.4.4",
"winston": "^2.3.1",
"yamljs": "^0.3.0"
},

View File

@ -0,0 +1,31 @@
import * as Express from "express";
import { ServerVariables } from "../../ServerVariables";
import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler";
import { Level } from "../../authentication/Level";
import * as URLParse from "url-parse";
export default function (vars: ServerVariables) {
return function (req: Express.Request, res: Express.Response) {
if (!req.body.url) {
res.status(400);
vars.logger.error(req, "Provide url for verification to be done.");
return;
}
const authSession = AuthenticationSessionHandler.get(req, vars.logger);
const url = new URLParse(req.body.url);
const urlInDomain = url.hostname.endsWith(vars.config.session.domain);
vars.logger.debug(req, "Check domain %s is in url %s.", vars.config.session.domain, url.hostname);
const sufficientPermissions = authSession.authentication_level >= Level.TWO_FACTOR;
vars.logger.debug(req, "Check that protocol %s is HTTPS.", url.protocol);
const protocolIsHttps = url.protocol === "https:";
if (sufficientPermissions && urlInDomain && protocolIsHttps) {
res.send("OK");
return;
}
res.send("NOK");
};
}

View File

@ -3,6 +3,7 @@ import Express = require("express");
import FirstFactorPost = require("../routes/firstfactor/post");
import LogoutPost from "../routes/logout/post";
import StateGet from "../routes/state/get";
import RedirectPost from "../routes/redirect/post";
import VerifyGet = require("../routes/verify/get");
import TOTPSignGet = require("../routes/secondfactor/totp/sign/post");
@ -86,6 +87,7 @@ function setupResetPassword(app: Express.Application, vars: ServerVariables) {
export class RestApi {
static setup(app: Express.Application, vars: ServerVariables): void {
app.get(Endpoints.STATE_GET, StateGet(vars));
app.post(Endpoints.REDIRECT_POST, RedirectPost(vars));
app.post(Endpoints.LOGOUT_POST, LogoutPost(vars));

View File

@ -287,3 +287,17 @@ export const VERIFY_GET = "/api/verify";
*/
export const LOGOUT_POST = "/api/logout";
/**
* @api {post} /api/redirect Url redirection checking endpoint
* @apiName Redirect
* @apiGroup Authentication
* @apiVersion 1.0.0
* @apiDescription Check if the user can be redirected to the url provided.
* The level of permissions for this user are checked and the url must be
* in the domain protected by authelia.
*
* @apiSuccess (Success 200)
*
* @apiDescription Resets the session to logout the user.
*/
export const REDIRECT_POST = "/api/redirect";

View File

@ -1,28 +0,0 @@
require("chromedriver");
import Environment = require('../environment');
const includes = [
"docker-compose.yml",
"example/compose/docker-compose.base.yml",
"example/compose/mongo/docker-compose.yml",
"example/compose/redis/docker-compose.yml",
"example/compose/nginx/backend/docker-compose.yml",
"example/compose/nginx/portal/docker-compose.yml",
"example/compose/smtp/docker-compose.yml",
"example/compose/httpbin/docker-compose.yml",
"example/compose/ldap/docker-compose.yml"
];
before(function() {
this.timeout(20000);
this.environment = new Environment.Environment(includes);
return this.environment.setup(5000);
});
after(function() {
this.timeout(30000);
if(process.env.KEEP_ENV != "true") {
return this.environment.cleanup();
}
});

View File

@ -1,41 +0,0 @@
import WithDriver from "../helpers/context/WithDriver";
import LoginAndRegisterTotp from "../helpers/LoginAndRegisterTotp";
import SeeNotification from "../helpers/SeeNotification";
import VisitPage from "../helpers/VisitPage";
import FillLoginPageWithUserAndPasswordAndClick from '../helpers/FillLoginPageAndClick';
import ValidateTotp from "../helpers/ValidateTotp";
import {CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN} from '../../shared/UserMessages';
/*
* Authelia should not be vulnerable to open redirection. Otherwise it would aid an
* attacker in conducting a phishing attack.
*
* To avoid the issue, Authelia's client scans the URL and prevent any redirection if
* the URL is pointing to an external domain.
*/
describe("Redirection should be performed only if in domain", function() {
this.timeout(10000);
WithDriver();
before(function() {
const that = this;
return LoginAndRegisterTotp(this.driver, "john", true)
.then((secret: string) => that.secret = secret)
});
function DoNotRedirect(url: string) {
it(`should see an error message instead of redirecting to ${url}`, function() {
const driver = this.driver;
const secret = this.secret;
return VisitPage(driver, `https://login.example.com:8080/?rd=${url}`)
.then(() => FillLoginPageWithUserAndPasswordAndClick(driver, 'john', 'password'))
.then(() => ValidateTotp(driver, secret))
.then(() => SeeNotification(driver, "error", CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN))
.then(() => driver.get(`https://login.example.com:8080/logout`));
});
}
DoNotRedirect("www.google.fr");
DoNotRedirect("http://www.google.fr");
DoNotRedirect("https://www.google.fr");
})

View File

@ -1,17 +0,0 @@
import WithDriver from '../helpers/context/WithDriver';
import fullLogin from '../helpers/FullLogin';
import loginAndRegisterTotp from '../helpers/LoginAndRegisterTotp';
describe("Connection retry when mongo fails or restarts", function() {
this.timeout(30000);
WithDriver();
it("should be able to login after mongo restarts", function() {
const that = this;
let secret;
return loginAndRegisterTotp(that.driver, "john", true)
.then(_secret => secret = _secret)
.then(() => that.environment.restart_service("mongo", 1000))
.then(() => fullLogin(that.driver, "https://admin.example.com:8080/secret.html", "john", secret));
})
});

View File

@ -0,0 +1,5 @@
import SeleniumWebDriver, { WebDriver } from "selenium-webdriver";
export default async function(driver: WebDriver) {
await driver.wait(SeleniumWebDriver.until.elementLocated(SeleniumWebDriver.By.className('already-authenticated-step')));
}

5
test/helpers/Logout.ts Normal file
View File

@ -0,0 +1,5 @@
import { WebDriver } from "selenium-webdriver";
export default async function(driver: WebDriver) {
await driver.get(`https://login.example.com:8080/logout`);
}

View File

@ -4,15 +4,15 @@ import WithDriver from "./WithDriver";
let running = false;
interface AutheliaSuiteType {
(description: string, cb: (this: Mocha.ISuiteCallbackContext) => void): Mocha.ISuite;
only: (description: string, cb: (this: Mocha.ISuiteCallbackContext) => void) => Mocha.ISuite;
(description: string, configPath: string, cb: (this: Mocha.ISuiteCallbackContext) => void): Mocha.ISuite;
only: (description: string, configPath: string, cb: (this: Mocha.ISuiteCallbackContext) => void) => Mocha.ISuite;
}
function AutheliaSuiteBase(description: string,
context: (description: string, ctx: (this: Mocha.ISuiteCallbackContext) => void) => Mocha.ISuite,
cb: (this: Mocha.ISuiteCallbackContext) => void) {
function AutheliaSuiteBase(description: string, configPath: string,
cb: (this: Mocha.ISuiteCallbackContext) => void,
context: (description: string, ctx: (this: Mocha.ISuiteCallbackContext) => void) => Mocha.ISuite) {
if (!running && process.env['WITH_SERVER'] == 'y') {
WithAutheliaRunning();
WithAutheliaRunning(configPath);
running = true;
}
@ -22,13 +22,16 @@ function AutheliaSuiteBase(description: string,
});
}
const AutheliaSuite = <AutheliaSuiteType>function(description: string, cb: (this: Mocha.ISuiteCallbackContext) => void) {
return AutheliaSuiteBase(description, describe, cb);
const AutheliaSuite = <AutheliaSuiteType>function(
description: string, configPath: string,
cb: (this: Mocha.ISuiteCallbackContext) => void) {
return AutheliaSuiteBase(description, configPath, cb, describe);
}
AutheliaSuite.only = function(description: string, cb: (this: Mocha.ISuiteCallbackContext) => void) {
return AutheliaSuiteBase(description, describe.only, cb);
AutheliaSuite.only = function(description: string, configPath: string,
cb: (this: Mocha.ISuiteCallbackContext) => void) {
return AutheliaSuiteBase(description, configPath, cb, describe.only);
}
export default AutheliaSuite as AutheliaSuiteType;

View File

@ -1,12 +1,12 @@
import ChildProcess from 'child_process';
export default function WithAutheliaRunning(waitTimeout: number = 3000) {
export default function WithAutheliaRunning(configPath: string, waitTimeout: number = 3000) {
before(function() {
this.timeout(5000);
const authelia = ChildProcess.spawn(
'./scripts/authelia-scripts',
['serve', '--no-watch', '--config', 'config.minimal.yml'],
['serve', '--no-watch', '--config', configPath],
{detached: true});
this.authelia = authelia;

View File

@ -8,8 +8,7 @@ export default function() {
beforeEach(function() {
const driver = new SeleniumWebdriver.Builder()
.forBrowser("chrome")
.setChromeOptions(
new chrome.Options().headless())
.setChromeOptions(new chrome.Options().headless())
.build();
this.driver = driver;
});

View File

@ -0,0 +1,10 @@
import AutheliaSuite from "../../helpers/context/AutheliaSuite";
import MongoConnectionRecovery from "./scenarii/MongoConnectionRecovery";
import EnforceInternalRedirectionsOnly from "./scenarii/EnforceInternalRedirectionsOnly";
AutheliaSuite('Complete configuration', 'config.template.yml', function() {
this.timeout(10000);
describe('Mongo broken connection recovery', MongoConnectionRecovery);
describe('Enforce internal redirections only', EnforceInternalRedirectionsOnly);
});

View File

@ -0,0 +1,63 @@
import LoginAndRegisterTotp from "../../../helpers/LoginAndRegisterTotp";
import VisitPage from "../../../helpers/VisitPage";
import FillLoginPageWithUserAndPasswordAndClick from '../../../helpers/FillLoginPageAndClick';
import ValidateTotp from "../../../helpers/ValidateTotp";
import Logout from "../../../helpers/Logout";
import WaitRedirected from "../../../helpers/WaitRedirected";
import IsAlreadyAuthenticatedStage from "../../../helpers/IsAlreadyAuthenticatedStage";
/*
* Authelia should not be vulnerable to open redirection. Otherwise it would aid an
* attacker in conducting a phishing attack.
*
* To avoid the issue, Authelia's client scans the URL and prevent any redirection if
* the URL is pointing to an external domain.
*/
export default function() {
describe("Only redirection to a subdomain of the protected domain should be allowed", function() {
this.timeout(10000);
let secret: string;
beforeEach(async function() {
secret = await LoginAndRegisterTotp(this.driver, "john", true)
});
afterEach(async function() {
await Logout(this.driver);
})
function CannotRedirectTo(url: string) {
it(`should redirect to already authenticated page when requesting ${url}`, async function() {
await VisitPage(this.driver, `https://login.example.com:8080/?rd=${url}`);
await FillLoginPageWithUserAndPasswordAndClick(this.driver, 'john', 'password');
await ValidateTotp(this.driver, secret);
await IsAlreadyAuthenticatedStage(this.driver);
});
}
function CanRedirectTo(url: string) {
it(`should redirect to ${url}`, async function() {
await VisitPage(this.driver, `https://login.example.com:8080/?rd=${url}`);
await FillLoginPageWithUserAndPasswordAndClick(this.driver, 'john', 'password');
await ValidateTotp(this.driver, secret);
await WaitRedirected(this.driver, url);
});
}
describe('blocked redirection', function() {
// Do not redirect to another domain than example.com
CannotRedirectTo("https://www.google.fr");
// Do not redirect to rogue domain
CannotRedirectTo("https://public.example.com.a:8080");
// Do not redirect to http website
CannotRedirectTo("http://public.example.com:8080");
});
describe('allowed redirection', function() {
// Can redirect to any subdomain of the domain protected by Authelia.
CanRedirectTo("https://public.example.com:8080/");
});
});
}

View File

@ -0,0 +1,12 @@
import LoginAndRegisterTotp from "../../../helpers/LoginAndRegisterTotp";
import FullLogin from "../../../helpers/FullLogin";
import child_process from 'child_process';
export default function() {
it("should be able to login after mongo restarts", async function() {
this.timeout(30000);
const secret = await LoginAndRegisterTotp(this.driver, "john", true);
child_process.execSync("./scripts/dc-dev.sh restart mongo");
await FullLogin(this.driver, "https://admin.example.com:8080/secret.html", "john", secret);
});
}

View File

@ -9,7 +9,7 @@ import TOTPValidation from './scenarii/TOTPValidation';
const execAsync = Bluebird.promisify(ChildProcess.exec);
AutheliaSuite('Minimal configuration', function() {
AutheliaSuite('Minimal configuration', 'config.minimal.yml', function() {
this.timeout(10000);
beforeEach(function() {
return execAsync("cp users_database.example.yml users_database.yml");