mirror of
https://github.com/0rangebananaspy/authelia.git
synced 2024-09-14 22:47:21 +07:00
Fix open redirection vulnerability.
In order to redirect the user after authentication, Authelia uses rd query parameter provided by the proxy. However an attacker could use phishing to make the user be redirected to a bad domain. In order to avoid the user to be redirected to a bad location, Authelia now verifies the redirection URL is under the protected domain.
This commit is contained in:
parent
5f8e33d6ac
commit
42581dfe93
10
Gruntfile.js
10
Gruntfile.js
|
@ -9,6 +9,9 @@ module.exports = function (grunt) {
|
||||||
},
|
},
|
||||||
"env-test-client-unit": {
|
"env-test-client-unit": {
|
||||||
TS_NODE_PROJECT: "client/tsconfig.json"
|
TS_NODE_PROJECT: "client/tsconfig.json"
|
||||||
|
},
|
||||||
|
"env-test-shared-unit": {
|
||||||
|
TS_NODE_PROJECT: "server/tsconfig.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
run: {
|
run: {
|
||||||
|
@ -37,6 +40,10 @@ module.exports = function (grunt) {
|
||||||
cmd: "./node_modules/.bin/mocha",
|
cmd: "./node_modules/.bin/mocha",
|
||||||
args: ['--colors', '--require', 'ts-node/register', 'server/src/**/*.spec.ts']
|
args: ['--colors', '--require', 'ts-node/register', 'server/src/**/*.spec.ts']
|
||||||
},
|
},
|
||||||
|
"test-shared-unit": {
|
||||||
|
cmd: "./node_modules/.bin/mocha",
|
||||||
|
args: ['--colors', '--require', 'ts-node/register', 'shared/**/*.spec.ts']
|
||||||
|
},
|
||||||
"test-client-unit": {
|
"test-client-unit": {
|
||||||
cmd: "./node_modules/.bin/mocha",
|
cmd: "./node_modules/.bin/mocha",
|
||||||
args: ['--colors', '--require', 'ts-node/register', 'client/test/**/*.test.ts']
|
args: ['--colors', '--require', 'ts-node/register', 'client/test/**/*.test.ts']
|
||||||
|
@ -193,8 +200,9 @@ module.exports = function (grunt) {
|
||||||
grunt.registerTask('compile-client', ['run:lint-client', 'run:compile-client'])
|
grunt.registerTask('compile-client', ['run:lint-client', 'run:compile-client'])
|
||||||
|
|
||||||
grunt.registerTask('test-server', ['env:env-test-server-unit', 'run:test-server-unit'])
|
grunt.registerTask('test-server', ['env:env-test-server-unit', 'run:test-server-unit'])
|
||||||
|
grunt.registerTask('test-shared', ['env:env-test-shared-unit', 'run:test-shared-unit'])
|
||||||
grunt.registerTask('test-client', ['env:env-test-client-unit', 'run:test-client-unit'])
|
grunt.registerTask('test-client', ['env:env-test-client-unit', 'run:test-client-unit'])
|
||||||
grunt.registerTask('test-unit', ['test-server', 'test-client']);
|
grunt.registerTask('test-unit', ['test-server', 'test-client', 'test-shared']);
|
||||||
grunt.registerTask('test-int', ['run:test-cucumber', 'run:test-minimal-config', 'run:test-complete-config', 'run:test-inactivity']);
|
grunt.registerTask('test-int', ['run:test-cucumber', 'run:test-minimal-config', 'run:test-complete-config', 'run:test-inactivity']);
|
||||||
|
|
||||||
grunt.registerTask('copy-resources', ['copy:resources', 'copy:views', 'copy:images', 'copy:thirdparties', 'concat:css']);
|
grunt.registerTask('copy-resources', ['copy:resources', 'copy:views', 'copy:images', 'copy:thirdparties', 'concat:css']);
|
||||||
|
|
10
client/src/lib/SafeRedirect.ts
Normal file
10
client/src/lib/SafeRedirect.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { BelongToDomain } from "../../../shared/BelongToDomain";
|
||||||
|
|
||||||
|
export function SafeRedirect(url: string, cb: () => void): void {
|
||||||
|
const domain = window.location.hostname.split(".").slice(-2).join(".");
|
||||||
|
if (url.startsWith("/") || BelongToDomain(url, domain)) {
|
||||||
|
window.location.href = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb();
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import { QueryParametersRetriever } from "../QueryParametersRetriever";
|
||||||
import Constants = require("../../../../shared/constants");
|
import Constants = require("../../../../shared/constants");
|
||||||
import Endpoints = require("../../../../shared/api");
|
import Endpoints = require("../../../../shared/api");
|
||||||
import UserMessages = require("../../../../shared/UserMessages");
|
import UserMessages = require("../../../../shared/UserMessages");
|
||||||
|
import { SafeRedirect } from "../SafeRedirect";
|
||||||
|
|
||||||
export default function (window: Window, $: JQueryStatic,
|
export default function (window: Window, $: JQueryStatic,
|
||||||
firstFactorValidator: typeof FirstFactorValidator, jslogger: typeof JSLogger) {
|
firstFactorValidator: typeof FirstFactorValidator, jslogger: typeof JSLogger) {
|
||||||
|
@ -28,7 +29,9 @@ export default function (window: Window, $: JQueryStatic,
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFirstFactorSuccess(redirectUrl: string) {
|
function onFirstFactorSuccess(redirectUrl: string) {
|
||||||
window.location.href = redirectUrl;
|
SafeRedirect(redirectUrl, () => {
|
||||||
|
notifier.error("Cannot redirect to an external domain.");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFirstFactorFailure(err: Error) {
|
function onFirstFactorFailure(err: Error) {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import U2f = require("u2f");
|
import U2f = require("u2f");
|
||||||
import U2fApi from "u2f-api";
|
import U2fApi from "u2f-api";
|
||||||
import BluebirdPromise = require("bluebird");
|
import BluebirdPromise = require("bluebird");
|
||||||
import { SignMessage } from "../../../../shared/SignMessage";
|
|
||||||
import Endpoints = require("../../../../shared/api");
|
import Endpoints = require("../../../../shared/api");
|
||||||
import UserMessages = require("../../../../shared/UserMessages");
|
import UserMessages = require("../../../../shared/UserMessages");
|
||||||
import { INotifier } from "../INotifier";
|
import { INotifier } from "../INotifier";
|
||||||
|
@ -31,24 +30,13 @@ function finishU2fAuthentication(responseData: U2fApi.SignResponse,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startU2fAuthentication($: JQueryStatic, notifier: INotifier)
|
export function validate($: JQueryStatic): BluebirdPromise<string> {
|
||||||
: BluebirdPromise<string> {
|
|
||||||
|
|
||||||
return GetPromised($, Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET, {},
|
return GetPromised($, Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET, {},
|
||||||
undefined, "json")
|
undefined, "json")
|
||||||
.then(function (signRequest: U2f.Request) {
|
.then(function (signRequest: U2f.Request) {
|
||||||
notifier.info(UserMessages.PLEASE_TOUCH_TOKEN);
|
|
||||||
return U2fApi.sign(signRequest, 60);
|
return U2fApi.sign(signRequest, 60);
|
||||||
})
|
})
|
||||||
.then(function (signResponse: U2fApi.SignResponse) {
|
.then(function (signResponse: U2fApi.SignResponse) {
|
||||||
return finishU2fAuthentication(signResponse, $);
|
return finishU2fAuthentication(signResponse, $);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validate($: JQueryStatic, notifier: INotifier) {
|
|
||||||
return startU2fAuthentication($, notifier)
|
|
||||||
.catch(function (err: Error) {
|
|
||||||
notifier.error(UserMessages.U2F_TRANSACTION_FINISH_FAILED);
|
|
||||||
return BluebirdPromise.reject(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,38 +3,44 @@ import U2FValidator = require("./U2FValidator");
|
||||||
import ClientConstants = require("./constants");
|
import ClientConstants = require("./constants");
|
||||||
import { Notifier } from "../Notifier";
|
import { Notifier } from "../Notifier";
|
||||||
import { QueryParametersRetriever } from "../QueryParametersRetriever";
|
import { QueryParametersRetriever } from "../QueryParametersRetriever";
|
||||||
import Endpoints = require("../../../../shared/api");
|
|
||||||
import ServerConstants = require("../../../../shared/constants");
|
|
||||||
import UserMessages = require("../../../../shared/UserMessages");
|
import UserMessages = require("../../../../shared/UserMessages");
|
||||||
import SharedConstants = require("../../../../shared/constants");
|
import SharedConstants = require("../../../../shared/constants");
|
||||||
|
import { SafeRedirect } from "../SafeRedirect";
|
||||||
|
|
||||||
export default function (window: Window, $: JQueryStatic) {
|
export default function (window: Window, $: JQueryStatic) {
|
||||||
const notifierTotp = new Notifier(".notification-totp", $);
|
const notifier = new Notifier(".notification", $);
|
||||||
const notifierU2f = new Notifier(".notification-u2f", $);
|
|
||||||
|
|
||||||
function onAuthenticationSuccess(serverRedirectUrl: string, notifier: Notifier) {
|
function onAuthenticationSuccess(serverRedirectUrl: string) {
|
||||||
if (QueryParametersRetriever.get(SharedConstants.REDIRECT_QUERY_PARAM))
|
const queryRedirectUrl = QueryParametersRetriever.get(SharedConstants.REDIRECT_QUERY_PARAM);
|
||||||
window.location.href = QueryParametersRetriever.get(SharedConstants.REDIRECT_QUERY_PARAM);
|
if (queryRedirectUrl) {
|
||||||
else if (serverRedirectUrl)
|
SafeRedirect(queryRedirectUrl, () => {
|
||||||
window.location.href = serverRedirectUrl;
|
notifier.error(UserMessages.CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN);
|
||||||
else
|
});
|
||||||
|
} else if (serverRedirectUrl) {
|
||||||
|
SafeRedirect(serverRedirectUrl, () => {
|
||||||
|
notifier.error(UserMessages.CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
notifier.success(UserMessages.AUTHENTICATION_SUCCEEDED);
|
notifier.success(UserMessages.AUTHENTICATION_SUCCEEDED);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onSecondFactorTotpSuccess(redirectUrl: string) {
|
function onSecondFactorTotpSuccess(redirectUrl: string) {
|
||||||
onAuthenticationSuccess(redirectUrl, notifierTotp);
|
onAuthenticationSuccess(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSecondFactorTotpFailure(err: Error) {
|
function onSecondFactorTotpFailure(err: Error) {
|
||||||
notifierTotp.error(UserMessages.AUTHENTICATION_TOTP_FAILED);
|
notifier.error(UserMessages.AUTHENTICATION_TOTP_FAILED);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onU2fAuthenticationSuccess(redirectUrl: string) {
|
function onU2fAuthenticationSuccess(redirectUrl: string) {
|
||||||
onAuthenticationSuccess(redirectUrl, notifierU2f);
|
onAuthenticationSuccess(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onU2fAuthenticationFailure() {
|
function onU2fAuthenticationFailure() {
|
||||||
notifierU2f.error(UserMessages.AUTHENTICATION_U2F_FAILED);
|
// TODO(clems4ever): we should not display this error message until a device
|
||||||
|
// is registered.
|
||||||
|
// notifier.error(UserMessages.AUTHENTICATION_U2F_FAILED);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTOTPFormSubmitted(): boolean {
|
function onTOTPFormSubmitted(): boolean {
|
||||||
|
@ -47,7 +53,7 @@ export default function (window: Window, $: JQueryStatic) {
|
||||||
|
|
||||||
$(window.document).ready(function () {
|
$(window.document).ready(function () {
|
||||||
$(ClientConstants.TOTP_FORM_SELECTOR).on("submit", onTOTPFormSubmitted);
|
$(ClientConstants.TOTP_FORM_SELECTOR).on("submit", onTOTPFormSubmitted);
|
||||||
U2FValidator.validate($, notifierU2f)
|
U2FValidator.validate($)
|
||||||
.then(onU2fAuthenticationSuccess, onU2fAuthenticationFailure);
|
.then(onU2fAuthenticationSuccess, onU2fAuthenticationFailure);
|
||||||
});
|
});
|
||||||
}
|
}
|
|
@ -8,6 +8,7 @@ import Endpoints = require("../../../../shared/api");
|
||||||
import UserMessages = require("../../../../shared/UserMessages");
|
import UserMessages = require("../../../../shared/UserMessages");
|
||||||
import { RedirectionMessage } from "../../../../shared/RedirectionMessage";
|
import { RedirectionMessage } from "../../../../shared/RedirectionMessage";
|
||||||
import { ErrorMessage } from "../../../../shared/ErrorMessage";
|
import { ErrorMessage } from "../../../../shared/ErrorMessage";
|
||||||
|
import { SafeRedirect } from "../SafeRedirect";
|
||||||
|
|
||||||
export default function (window: Window, $: JQueryStatic) {
|
export default function (window: Window, $: JQueryStatic) {
|
||||||
const notifier = new Notifier(".notification", $);
|
const notifier = new Notifier(".notification", $);
|
||||||
|
@ -44,7 +45,9 @@ export default function (window: Window, $: JQueryStatic) {
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
requestRegistration()
|
requestRegistration()
|
||||||
.then((redirectionUrl: string) => {
|
.then((redirectionUrl: string) => {
|
||||||
document.location.href = redirectionUrl;
|
SafeRedirect(redirectionUrl, () => {
|
||||||
|
notifier.error(UserMessages.CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN);
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
onRegisterFailure(err);
|
onRegisterFailure(err);
|
||||||
|
|
|
@ -7,45 +7,61 @@ import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler
|
||||||
import Constants = require("../../../../../shared/constants");
|
import Constants = require("../../../../../shared/constants");
|
||||||
import Util = require("util");
|
import Util = require("util");
|
||||||
import { ServerVariables } from "../../ServerVariables";
|
import { ServerVariables } from "../../ServerVariables";
|
||||||
|
import { SafeRedirector } from "../../utils/SafeRedirection";
|
||||||
|
|
||||||
function getRedirectParam(req: express.Request) {
|
function getRedirectParam(
|
||||||
|
req: express.Request) {
|
||||||
return req.query[Constants.REDIRECT_QUERY_PARAM] != "undefined"
|
return req.query[Constants.REDIRECT_QUERY_PARAM] != "undefined"
|
||||||
? req.query[Constants.REDIRECT_QUERY_PARAM]
|
? req.query[Constants.REDIRECT_QUERY_PARAM]
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectToSecondFactorPage(req: express.Request, res: express.Response) {
|
function redirectToSecondFactorPage(
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response) {
|
||||||
|
|
||||||
const redirectUrl = getRedirectParam(req);
|
const redirectUrl = getRedirectParam(req);
|
||||||
if (!redirectUrl)
|
if (!redirectUrl)
|
||||||
res.redirect(Endpoints.SECOND_FACTOR_GET);
|
res.redirect(Endpoints.SECOND_FACTOR_GET);
|
||||||
else
|
else
|
||||||
res.redirect(Util.format("%s?%s=%s", Endpoints.SECOND_FACTOR_GET,
|
res.redirect(
|
||||||
|
Util.format("%s?%s=%s",
|
||||||
|
Endpoints.SECOND_FACTOR_GET,
|
||||||
Constants.REDIRECT_QUERY_PARAM,
|
Constants.REDIRECT_QUERY_PARAM,
|
||||||
redirectUrl));
|
redirectUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectToService(req: express.Request, res: express.Response) {
|
function redirectToService(
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
redirector: SafeRedirector) {
|
||||||
const redirectUrl = getRedirectParam(req);
|
const redirectUrl = getRedirectParam(req);
|
||||||
if (!redirectUrl)
|
if (!redirectUrl) {
|
||||||
res.redirect(Endpoints.LOGGED_IN);
|
res.redirect(Endpoints.LOGGED_IN);
|
||||||
else
|
} else {
|
||||||
res.redirect(redirectUrl);
|
redirector.redirectOrElse(res, redirectUrl, Endpoints.LOGGED_IN);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFirstFactor(res: express.Response) {
|
function renderFirstFactor(
|
||||||
|
res: express.Response) {
|
||||||
|
|
||||||
res.render("firstfactor", {
|
res.render("firstfactor", {
|
||||||
first_factor_post_endpoint: Endpoints.FIRST_FACTOR_POST,
|
first_factor_post_endpoint: Endpoints.FIRST_FACTOR_POST,
|
||||||
reset_password_request_endpoint: Endpoints.RESET_PASSWORD_REQUEST_GET
|
reset_password_request_endpoint: Endpoints.RESET_PASSWORD_REQUEST_GET
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function (vars: ServerVariables) {
|
export default function (
|
||||||
|
vars: ServerVariables) {
|
||||||
|
|
||||||
|
const redirector = new SafeRedirector(vars.config.session.domain);
|
||||||
return function (req: express.Request, res: express.Response): BluebirdPromise<void> {
|
return function (req: express.Request, res: express.Response): BluebirdPromise<void> {
|
||||||
return new BluebirdPromise(function (resolve, reject) {
|
return new BluebirdPromise(function (resolve, reject) {
|
||||||
const authSession = AuthenticationSessionHandler.get(req, vars.logger);
|
const authSession = AuthenticationSessionHandler.get(req, vars.logger);
|
||||||
if (authSession.first_factor) {
|
if (authSession.first_factor) {
|
||||||
if (authSession.second_factor)
|
if (authSession.second_factor)
|
||||||
redirectToService(req, res);
|
redirectToService(req, res, redirector);
|
||||||
else
|
else
|
||||||
redirectToSecondFactorPage(req, res);
|
redirectToSecondFactorPage(req, res);
|
||||||
resolve();
|
resolve();
|
||||||
|
|
|
@ -9,7 +9,7 @@ import Endpoint = require("../../../../../shared/api");
|
||||||
import ErrorReplies = require("../../ErrorReplies");
|
import ErrorReplies = require("../../ErrorReplies");
|
||||||
import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler";
|
import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler";
|
||||||
import Constants = require("../../../../../shared/constants");
|
import Constants = require("../../../../../shared/constants");
|
||||||
import { DomainExtractor } from "../../utils/DomainExtractor";
|
import { DomainExtractor } from "../../../../../shared/DomainExtractor";
|
||||||
import UserMessages = require("../../../../../shared/UserMessages");
|
import UserMessages = require("../../../../../shared/UserMessages");
|
||||||
import { MethodCalculator } from "../../authentication/MethodCalculator";
|
import { MethodCalculator } from "../../authentication/MethodCalculator";
|
||||||
import { ServerVariables } from "../../ServerVariables";
|
import { ServerVariables } from "../../ServerVariables";
|
||||||
|
@ -51,14 +51,16 @@ export default function (vars: ServerVariables) {
|
||||||
authSession.userid = username;
|
authSession.userid = username;
|
||||||
authSession.keep_me_logged_in = keepMeLoggedIn;
|
authSession.keep_me_logged_in = keepMeLoggedIn;
|
||||||
authSession.first_factor = true;
|
authSession.first_factor = true;
|
||||||
const redirectUrl = req.query[Constants.REDIRECT_QUERY_PARAM] !== "undefined"
|
const redirectUrl: string = req.query[Constants.REDIRECT_QUERY_PARAM] !== "undefined"
|
||||||
// Fuck, don't know why it is a string!
|
// Fuck, don't know why it is a string!
|
||||||
? req.query[Constants.REDIRECT_QUERY_PARAM]
|
? req.query[Constants.REDIRECT_QUERY_PARAM]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const emails: string[] = groupsAndEmails.emails;
|
const emails: string[] = groupsAndEmails.emails;
|
||||||
const groups: string[] = groupsAndEmails.groups;
|
const groups: string[] = groupsAndEmails.groups;
|
||||||
const redirectHost: string = DomainExtractor.fromUrl(redirectUrl);
|
|
||||||
|
const domain = DomainExtractor.fromUrl(redirectUrl);
|
||||||
|
const redirectHost = (domain) ? domain : "";
|
||||||
const authMethod = MethodCalculator.compute(
|
const authMethod = MethodCalculator.compute(
|
||||||
vars.config.authentication_methods, redirectHost);
|
vars.config.authentication_methods, redirectHost);
|
||||||
vars.logger.debug(req, "Authentication method for \"%s\" is \"%s\"",
|
vars.logger.debug(req, "Authentication method for \"%s\" is \"%s\"",
|
||||||
|
@ -72,7 +74,7 @@ export default function (vars: ServerVariables) {
|
||||||
vars.regulator.mark(username, true);
|
vars.regulator.mark(username, true);
|
||||||
|
|
||||||
if (authMethod == "single_factor") {
|
if (authMethod == "single_factor") {
|
||||||
let newRedirectionUrl: string = redirectUrl;
|
let newRedirectionUrl = redirectUrl;
|
||||||
if (!newRedirectionUrl)
|
if (!newRedirectionUrl)
|
||||||
newRedirectionUrl = Endpoint.LOGGED_IN;
|
newRedirectionUrl = Endpoint.LOGGED_IN;
|
||||||
res.send({
|
res.send({
|
||||||
|
|
|
@ -24,7 +24,7 @@ export default function (vars: ServerVariables) {
|
||||||
})
|
})
|
||||||
.then(function (doc: U2FRegistrationDocument): BluebirdPromise<void> {
|
.then(function (doc: U2FRegistrationDocument): BluebirdPromise<void> {
|
||||||
if (!doc)
|
if (!doc)
|
||||||
return BluebirdPromise.reject(new exceptions.AccessDeniedError("No U2F registration found"));
|
return BluebirdPromise.reject(new exceptions.AccessDeniedError("No U2F registration document found."));
|
||||||
|
|
||||||
const appId: string = u2f_common.extract_app_id(req);
|
const appId: string = u2f_common.extract_app_id(req);
|
||||||
vars.logger.info(req, "Start authentication of app '%s'", appId);
|
vars.logger.info(req, "Start authentication of app '%s'", appId);
|
||||||
|
|
|
@ -4,7 +4,7 @@ import ObjectPath = require("object-path");
|
||||||
import { ServerVariables } from "../../ServerVariables";
|
import { ServerVariables } from "../../ServerVariables";
|
||||||
import { AuthenticationSession }
|
import { AuthenticationSession }
|
||||||
from "../../../../types/AuthenticationSession";
|
from "../../../../types/AuthenticationSession";
|
||||||
import { DomainExtractor } from "../../utils/DomainExtractor";
|
import { DomainExtractor } from "../../../../../shared/DomainExtractor";
|
||||||
import { MethodCalculator } from "../../authentication/MethodCalculator";
|
import { MethodCalculator } from "../../authentication/MethodCalculator";
|
||||||
import AccessControl from "./access_control";
|
import AccessControl from "./access_control";
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import ObjectPath = require("object-path");
|
||||||
import Exceptions = require("../../Exceptions");
|
import Exceptions = require("../../Exceptions");
|
||||||
import { Configuration } from "../../configuration/schema/Configuration";
|
import { Configuration } from "../../configuration/schema/Configuration";
|
||||||
import Constants = require("../../../../../shared/constants");
|
import Constants = require("../../../../../shared/constants");
|
||||||
import { DomainExtractor } from "../../utils/DomainExtractor";
|
import { DomainExtractor } from "../../../../../shared/DomainExtractor";
|
||||||
import { ServerVariables } from "../../ServerVariables";
|
import { ServerVariables } from "../../ServerVariables";
|
||||||
import { MethodCalculator } from "../../authentication/MethodCalculator";
|
import { MethodCalculator } from "../../authentication/MethodCalculator";
|
||||||
import { IRequestLogger } from "../../logging/IRequestLogger";
|
import { IRequestLogger } from "../../logging/IRequestLogger";
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
export class DomainExtractor {
|
|
||||||
static fromUrl(url: string): string {
|
|
||||||
if (!url) return "";
|
|
||||||
return url.match(/https?:\/\/([^\/:]+).*/)[1];
|
|
||||||
}
|
|
||||||
}
|
|
33
server/src/lib/utils/SafeRedirection.spec.ts
Normal file
33
server/src/lib/utils/SafeRedirection.spec.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import Assert = require("assert");
|
||||||
|
import Sinon = require("sinon");
|
||||||
|
import { SafeRedirector } from "./SafeRedirection";
|
||||||
|
|
||||||
|
describe("web_server/middlewares/SafeRedirection", () => {
|
||||||
|
describe("Url is in protected domain", () => {
|
||||||
|
before(() => {
|
||||||
|
this.redirector = new SafeRedirector("example.com");
|
||||||
|
this.res = {redirect: Sinon.stub()};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redirect to provided url", () => {
|
||||||
|
this.redirector.redirectOrElse(this.res,
|
||||||
|
"https://mysubdomain.example.com:8080/abc",
|
||||||
|
"https://authelia.example.com");
|
||||||
|
Assert(this.res.redirect.calledWith("https://mysubdomain.example.com:8080/abc"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redirect to default url when wrong domain", () => {
|
||||||
|
this.redirector.redirectOrElse(this.res,
|
||||||
|
"https://mysubdomain.domain.rtf:8080/abc",
|
||||||
|
"https://authelia.example.com");
|
||||||
|
Assert(this.res.redirect.calledWith("https://authelia.example.com"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redirect to default url when not terminating by domain", () => {
|
||||||
|
this.redirector.redirectOrElse(this.res,
|
||||||
|
"https://mysubdomain.example.com.rtf:8080/abc",
|
||||||
|
"https://authelia.example.com");
|
||||||
|
Assert(this.res.redirect.calledWith("https://authelia.example.com"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
22
server/src/lib/utils/SafeRedirection.ts
Normal file
22
server/src/lib/utils/SafeRedirection.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import Express = require("express");
|
||||||
|
import { DomainExtractor } from "../../../../shared/DomainExtractor";
|
||||||
|
import { BelongToDomain } from "../../../../shared/BelongToDomain";
|
||||||
|
|
||||||
|
|
||||||
|
export class SafeRedirector {
|
||||||
|
private domain: string;
|
||||||
|
|
||||||
|
constructor(domain: string) {
|
||||||
|
this.domain = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectOrElse(
|
||||||
|
res: Express.Response,
|
||||||
|
url: string,
|
||||||
|
defaultUrl: string): void {
|
||||||
|
if (BelongToDomain(url, this.domain)) {
|
||||||
|
res.redirect(url);
|
||||||
|
}
|
||||||
|
res.redirect(defaultUrl);
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@ block form-header
|
||||||
|
|
||||||
block content
|
block content
|
||||||
div
|
div
|
||||||
div(class="notification notification-totp")
|
div(class="notification")
|
||||||
h3 Hi <b>#{username}</b>
|
h3 Hi <b>#{username}</b>
|
||||||
div(class="row")
|
div(class="row")
|
||||||
div(class="u2f-token")
|
div(class="u2f-token")
|
||||||
|
|
8
shared/BelongToDomain.ts
Normal file
8
shared/BelongToDomain.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { DomainExtractor } from "./DomainExtractor";
|
||||||
|
|
||||||
|
export function BelongToDomain(url: string, domain: string): boolean {
|
||||||
|
const urlDomain = DomainExtractor.fromUrl(url);
|
||||||
|
if (!urlDomain) return false;
|
||||||
|
const idx = urlDomain.indexOf(domain);
|
||||||
|
return idx + domain.length == urlDomain.length;
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import { DomainExtractor } from "./DomainExtractor";
|
import { DomainExtractor } from "./DomainExtractor";
|
||||||
import Assert = require("assert");
|
import Assert = require("assert");
|
||||||
|
|
||||||
describe("utils/DomainExtractor", function () {
|
describe.only("shared/DomainExtractor", function () {
|
||||||
describe("test fromUrl", function () {
|
describe("test fromUrl", function () {
|
||||||
it("should return domain from https url", function () {
|
it("should return domain from https url", function () {
|
||||||
const domain = DomainExtractor.fromUrl("https://www.example.com/test/abc");
|
const domain = DomainExtractor.fromUrl("https://www.example.com/test/abc");
|
||||||
|
@ -17,5 +17,16 @@ describe("utils/DomainExtractor", function () {
|
||||||
const domain = DomainExtractor.fromUrl("https://www.example.com:8080/test/abc");
|
const domain = DomainExtractor.fromUrl("https://www.example.com:8080/test/abc");
|
||||||
Assert.equal(domain, "www.example.com");
|
Assert.equal(domain, "www.example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return domain when url contains redirect param", function () {
|
||||||
|
const domain0 = DomainExtractor.fromUrl("https://www.example.com:8080/test/abc?rd=https://cool.test.com");
|
||||||
|
Assert.equal(domain0, "www.example.com");
|
||||||
|
|
||||||
|
const domain1 = DomainExtractor.fromUrl("https://login.example.com:8080/?rd=https://public.example.com:8080/");
|
||||||
|
Assert.equal(domain1, "login.example.com");
|
||||||
|
|
||||||
|
const domain2 = DomainExtractor.fromUrl("https://single_factor.example.com:8080/secret.html");
|
||||||
|
Assert.equal(domain2, "single_factor.example.com");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
11
shared/DomainExtractor.ts
Normal file
11
shared/DomainExtractor.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export class DomainExtractor {
|
||||||
|
static fromUrl(url: string): string {
|
||||||
|
if (!url) return;
|
||||||
|
const matches = url.match(/(https?:\/\/)?([a-zA-Z0-9_.-]+).*/);
|
||||||
|
|
||||||
|
if (matches.length > 2) {
|
||||||
|
return matches[2];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,8 @@
|
||||||
export const AUTHENTICATION_FAILED = "Authentication failed. Please check your credentials.";
|
export const AUTHENTICATION_FAILED = "Authentication failed. Please check your credentials.";
|
||||||
export const AUTHENTICATION_SUCCEEDED = "Authentication succeeded. You can now access your services.";
|
export const AUTHENTICATION_SUCCEEDED = "Authentication succeeded. You can now access your services.";
|
||||||
|
|
||||||
|
export const CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN = "Cannot redirect to an external domain.";
|
||||||
|
|
||||||
export const AUTHENTICATION_U2F_FAILED = "Authentication failed. Have you already registered your device?";
|
export const AUTHENTICATION_U2F_FAILED = "Authentication failed. Have you already registered your device?";
|
||||||
export const AUTHENTICATION_TOTP_FAILED = "Authentication failed. Have you already registered your secret?";
|
export const AUTHENTICATION_TOTP_FAILED = "Authentication failed. Have you already registered your secret?";
|
||||||
|
|
||||||
|
|
41
test/complete-config/closed-redirection.ts
Normal file
41
test/complete-config/closed-redirection.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import WithDriver from "../helpers/with-driver";
|
||||||
|
import LoginAndRegisterTotp from "../helpers/login-and-register-totp";
|
||||||
|
import SeeNotification from "../helpers/see-notification";
|
||||||
|
import VisitPage from "../helpers/visit-page";
|
||||||
|
import FillLoginPageWithUserAndPasswordAndClick from '../helpers/fill-login-page-and-click';
|
||||||
|
import ValidateTotp from "../helpers/validate-totp";
|
||||||
|
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");
|
||||||
|
})
|
|
@ -1,19 +1,17 @@
|
||||||
require("chromedriver");
|
|
||||||
import SeleniumWebdriver = require("selenium-webdriver");
|
|
||||||
import WithDriver from '../helpers/with-driver';
|
import WithDriver from '../helpers/with-driver';
|
||||||
import LoginAndRegisterTotp from '../helpers/login-and-register-totp';
|
import fullLogin from '../helpers/full-login';
|
||||||
import LoginAs from '../helpers/login-as';
|
import loginAndRegisterTotp from '../helpers/login-and-register-totp';
|
||||||
import VisitPage from '../helpers/visit-page';
|
|
||||||
|
|
||||||
describe('Connection retry when mongo fails or restarts', function() {
|
describe("Connection retry when mongo fails or restarts", function() {
|
||||||
this.timeout(20000);
|
this.timeout(30000);
|
||||||
WithDriver();
|
WithDriver();
|
||||||
|
|
||||||
it('should be able to login after mongo restarts', function() {
|
it("should be able to login after mongo restarts", function() {
|
||||||
const that = this;
|
const that = this;
|
||||||
return that.environment.stop_service("mongo")
|
let secret;
|
||||||
.then(() => that.environment.restart_service("authelia", 2000))
|
return loginAndRegisterTotp(that.driver, "john", true)
|
||||||
.then(() => that.environment.restart_service("mongo"))
|
.then(_secret => secret = _secret)
|
||||||
.then(() => LoginAs(that.driver, "john"));
|
.then(() => that.environment.restart_service("mongo", 1000))
|
||||||
|
.then(() => fullLogin(that.driver, "https://admin.example.com:8080/secret.html", "john", secret));
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,7 +15,7 @@ export class Environment {
|
||||||
private runCommand(command: string, timeout?: number): Bluebird<void> {
|
private runCommand(command: string, timeout?: number): Bluebird<void> {
|
||||||
return new Bluebird<void>((resolve, reject) => {
|
return new Bluebird<void>((resolve, reject) => {
|
||||||
console.log('[ENVIRONMENT] Running: %s', command);
|
console.log('[ENVIRONMENT] Running: %s', command);
|
||||||
exec(command, (err, stdout, stderr) => {
|
exec(command, (err: any, stdout: any, stderr: any) => {
|
||||||
if(err) {
|
if(err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
return;
|
return;
|
||||||
|
|
12
test/helpers/full-login.ts
Normal file
12
test/helpers/full-login.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import VisitPage from "./visit-page";
|
||||||
|
import FillLoginPageWithUserAndPasswordAndClick from "./fill-login-page-and-click";
|
||||||
|
import ValidateTotp from "./validate-totp";
|
||||||
|
import WaitRedirected from "./wait-redirected";
|
||||||
|
|
||||||
|
// Validate the two factors!
|
||||||
|
export default function(driver: any, url: string, user: string, secret: string) {
|
||||||
|
return VisitPage(driver, `https://login.example.com:8080/?rd=${url}`)
|
||||||
|
.then(() => FillLoginPageWithUserAndPasswordAndClick(driver, user, 'password'))
|
||||||
|
.then(() => ValidateTotp(driver, secret))
|
||||||
|
.then(() => WaitRedirected(driver, "https://admin.example.com:8080/secret.html"));
|
||||||
|
}
|
|
@ -1,10 +1,9 @@
|
||||||
import VisitPage from "./visit-page";
|
|
||||||
import FillLoginPageAndClick from './fill-login-page-and-click';
|
|
||||||
import RegisterTotp from './register-totp';
|
import RegisterTotp from './register-totp';
|
||||||
import WaitRedirected from './wait-redirected';
|
import WaitRedirected from './wait-redirected';
|
||||||
import LoginAs from './login-as';
|
import LoginAs from './login-as';
|
||||||
|
import Bluebird = require("bluebird");
|
||||||
|
|
||||||
export default function(driver: any, user: string, email?: boolean) {
|
export default function(driver: any, user: string, email?: boolean): Bluebird<string> {
|
||||||
return LoginAs(driver, user)
|
return LoginAs(driver, user)
|
||||||
.then(() => WaitRedirected(driver, "https://login.example.com:8080/secondfactor"))
|
.then(() => WaitRedirected(driver, "https://login.example.com:8080/secondfactor"))
|
||||||
.then(() => RegisterTotp(driver, email));
|
.then(() => RegisterTotp(driver, email));
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import VisitPage from "./visit-page";
|
import VisitPage from "./visit-page";
|
||||||
import FillLoginPageAndClick from './fill-login-page-and-click';
|
import FillLoginPageAndClick from './fill-login-page-and-click';
|
||||||
import RegisterTotp from './register-totp';
|
|
||||||
import WaitRedirected from './wait-redirected';
|
|
||||||
|
|
||||||
export default function(driver: any, user: string) {
|
export default function(driver: any, user: string) {
|
||||||
return VisitPage(driver, "https://login.example.com:8080/")
|
return VisitPage(driver, "https://login.example.com:8080/")
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
import Bluebird = require("bluebird");
|
|
||||||
import SeleniumWebdriver = require("selenium-webdriver");
|
|
||||||
import Fs = require("fs");
|
|
||||||
import Speakeasy = require("speakeasy");
|
|
||||||
import WithDriver from '../helpers/with-driver';
|
import WithDriver from '../helpers/with-driver';
|
||||||
import FillLoginPageWithUserAndPasswordAndClick from '../helpers/fill-login-page-and-click';
|
import FillLoginPageWithUserAndPasswordAndClick from '../helpers/fill-login-page-and-click';
|
||||||
import WaitRedirected from '../helpers/wait-redirected';
|
|
||||||
import VisitPage from '../helpers/visit-page';
|
import VisitPage from '../helpers/visit-page';
|
||||||
import SeeNotification from '../helpers/see-notification';
|
import SeeNotification from '../helpers/see-notification';
|
||||||
|
import {AUTHENTICATION_FAILED} from '../../shared/UserMessages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When user provides bad password,
|
* When user provides bad password,
|
||||||
|
@ -28,7 +24,7 @@ describe("Provide bad password", function() {
|
||||||
|
|
||||||
it('should get a notification message', function() {
|
it('should get a notification message', function() {
|
||||||
this.timeout(10000);
|
this.timeout(10000);
|
||||||
return SeeNotification(this.driver, "error", "Authentication failed. Please check your credentials.");
|
return SeeNotification(this.driver, "error", AUTHENTICATION_FAILED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,17 +1,11 @@
|
||||||
require("chromedriver");
|
require("chromedriver");
|
||||||
import Bluebird = require("bluebird");
|
|
||||||
import SeleniumWebdriver = require("selenium-webdriver");
|
|
||||||
import Fs = require("fs");
|
|
||||||
import Speakeasy = require("speakeasy");
|
|
||||||
import WithDriver from '../helpers/with-driver';
|
import WithDriver from '../helpers/with-driver';
|
||||||
import FillLoginPageWithUserAndPasswordAndClick from '../helpers/fill-login-page-and-click';
|
import FillLoginPageWithUserAndPasswordAndClick from '../helpers/fill-login-page-and-click';
|
||||||
import WaitRedirected from '../helpers/wait-redirected';
|
|
||||||
import VisitPage from '../helpers/visit-page';
|
import VisitPage from '../helpers/visit-page';
|
||||||
import RegisterTotp from '../helpers/register-totp';
|
|
||||||
import ValidateTotp from '../helpers/validate-totp';
|
import ValidateTotp from '../helpers/validate-totp';
|
||||||
import AccessSecret from "../helpers/access-secret";
|
|
||||||
import LoginAndRegisterTotp from '../helpers/login-and-register-totp';
|
import LoginAndRegisterTotp from '../helpers/login-and-register-totp';
|
||||||
import seeNotification from "../helpers/see-notification";
|
import seeNotification from "../helpers/see-notification";
|
||||||
|
import {AUTHENTICATION_TOTP_FAILED} from '../../shared/UserMessages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given john has registered a TOTP secret,
|
* Given john has registered a TOTP secret,
|
||||||
|
@ -24,7 +18,6 @@ describe('Fail TOTP challenge', function() {
|
||||||
|
|
||||||
describe('successfully login as john', function() {
|
describe('successfully login as john', function() {
|
||||||
before(function() {
|
before(function() {
|
||||||
const that = this;
|
|
||||||
return LoginAndRegisterTotp(this.driver, "john", true);
|
return LoginAndRegisterTotp(this.driver, "john", true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -39,7 +32,7 @@ describe('Fail TOTP challenge', function() {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("get a notification message", function() {
|
it("get a notification message", function() {
|
||||||
return seeNotification(this.driver, "error", "Authentication failed. Have you already registered your secret?");
|
return seeNotification(this.driver, "error", AUTHENTICATION_TOTP_FAILED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
require("chromedriver");
|
require("chromedriver");
|
||||||
import Bluebird = require("bluebird");
|
import Bluebird = require("bluebird");
|
||||||
import ChildProcess = require("child_process");
|
import ChildProcess = require("child_process");
|
||||||
import SeleniumWebdriver = require("selenium-webdriver");
|
|
||||||
|
|
||||||
import WithDriver from '../helpers/with-driver';
|
import WithDriver from '../helpers/with-driver';
|
||||||
import VisitPage from '../helpers/visit-page';
|
import VisitPage from '../helpers/visit-page';
|
||||||
|
|
|
@ -1,13 +1,9 @@
|
||||||
require("chromedriver");
|
require("chromedriver");
|
||||||
import Bluebird = require("bluebird");
|
import Bluebird = require("bluebird");
|
||||||
import SeleniumWebdriver = require("selenium-webdriver");
|
|
||||||
import Fs = require("fs");
|
|
||||||
import Speakeasy = require("speakeasy");
|
|
||||||
import WithDriver from '../helpers/with-driver';
|
import WithDriver from '../helpers/with-driver';
|
||||||
import FillLoginPageWithUserAndPasswordAndClick from '../helpers/fill-login-page-and-click';
|
import FillLoginPageWithUserAndPasswordAndClick from '../helpers/fill-login-page-and-click';
|
||||||
import WaitRedirected from '../helpers/wait-redirected';
|
import WaitRedirected from '../helpers/wait-redirected';
|
||||||
import VisitPage from '../helpers/visit-page';
|
import VisitPage from '../helpers/visit-page';
|
||||||
import RegisterTotp from '../helpers/register-totp';
|
|
||||||
import ValidateTotp from '../helpers/validate-totp';
|
import ValidateTotp from '../helpers/validate-totp';
|
||||||
import AccessSecret from "../helpers/access-secret";
|
import AccessSecret from "../helpers/access-secret";
|
||||||
import LoginAndRegisterTotp from '../helpers/login-and-register-totp';
|
import LoginAndRegisterTotp from '../helpers/login-and-register-totp';
|
||||||
|
@ -37,15 +33,9 @@ describe('Validate TOTP factor', function() {
|
||||||
const driver = this.driver;
|
const driver = this.driver;
|
||||||
|
|
||||||
return VisitPage(driver, "https://login.example.com:8080/?rd=https://admin.example.com:8080/secret.html")
|
return VisitPage(driver, "https://login.example.com:8080/?rd=https://admin.example.com:8080/secret.html")
|
||||||
.then(() => {
|
.then(() => FillLoginPageWithUserAndPasswordAndClick(driver, 'john', 'password'))
|
||||||
return FillLoginPageWithUserAndPasswordAndClick(driver, 'john', 'password');
|
.then(() => ValidateTotp(driver, secret))
|
||||||
})
|
.then(() => WaitRedirected(driver, "https://admin.example.com:8080/secret.html"));
|
||||||
.then(() => {
|
|
||||||
return ValidateTotp(driver, secret);
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
return WaitRedirected(driver, "https://admin.example.com:8080/secret.html")
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should access the secret", function() {
|
it("should access the secret", function() {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user