From 40574bc8ecffdce945425e91692a0e6d596b8343 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Fri, 22 Mar 2019 16:54:27 +0100 Subject: [PATCH] Fix the bypass strategy. Before this fix an anonymous user was not able to access a resource that were configured with a bypass policy. This was due to a useless check of the userid in the auth session. Moreover, in the case of an anonymous user, we should not check the inactivity period since there is no session. Also refactor /verify endpoint for better testability and add tests in a new suite. --- package-lock.json | 56 ++++ package.json | 1 + server/src/lib/Exceptions.ts | 2 +- .../lib/ServerVariablesMockBuilder.spec.ts | 2 +- server/src/lib/authorization/Authorizer.ts | 4 +- .../lib/authorization/AuthorizerStub.spec.ts | 2 +- .../routes/verify/CheckAuthorizations.spec.ts | 100 ++++++ .../lib/routes/verify/CheckAuthorizations.ts | 46 +++ .../lib/routes/verify/CheckInactivity.spec.ts | 60 ++++ .../src/lib/routes/verify/CheckInactivity.ts | 31 ++ server/src/lib/routes/verify/Get.spec.ts | 96 ++++++ server/src/lib/routes/verify/Get.ts | 71 ++++ .../lib/routes/verify/GetBasicAuth.spec.ts | 65 ++++ server/src/lib/routes/verify/GetBasicAuth.ts | 49 +++ .../routes/verify/GetSessionCookie.spec.ts | 57 ++++ .../src/lib/routes/verify/GetSessionCookie.ts | 43 +++ .../verify/SetUserAndGroupsHeaders.spec.ts | 13 + .../routes/verify/SetUserAndGroupsHeaders.ts | 7 + .../src/lib/routes/verify/access_control.ts | 50 --- server/src/lib/routes/verify/get.spec.ts | 314 ------------------ server/src/lib/routes/verify/get.ts | 104 ------ .../src/lib/routes/verify/get_basic_auth.ts | 54 --- .../lib/routes/verify/get_session_cookie.ts | 74 ----- server/src/lib/utils/AssertRejects.ts | 11 + server/src/lib/utils/GetHeader.spec.ts | 2 +- server/src/lib/utils/HasHeader.spec.ts | 20 ++ server/src/lib/utils/HasHeader.ts | 12 + server/src/lib/web_server/RestApi.ts | 2 +- shared/constants.ts | 5 +- test/suites/acl-full-bypass/README.md | 11 + test/suites/acl-full-bypass/config.yml | 37 +++ test/suites/acl-full-bypass/environment.ts | 36 ++ .../acl-full-bypass/scenarii/BypassPolicy.ts | 23 ++ test/suites/acl-full-bypass/test.ts | 13 + .../suites/acl-full-bypass/users_database.yml | 29 ++ test/suites/basic/config.yml | 3 + test/suites/basic/scenarii/BypassPolicy.ts | 18 + .../basic/scenarii/RequiredTwoFactor.ts | 2 + test/suites/basic/test.ts | 2 + .../short-timeouts/scenarii/Inactivity.ts | 19 +- 40 files changed, 933 insertions(+), 613 deletions(-) create mode 100644 server/src/lib/routes/verify/CheckAuthorizations.spec.ts create mode 100644 server/src/lib/routes/verify/CheckAuthorizations.ts create mode 100644 server/src/lib/routes/verify/CheckInactivity.spec.ts create mode 100644 server/src/lib/routes/verify/CheckInactivity.ts create mode 100644 server/src/lib/routes/verify/Get.spec.ts create mode 100644 server/src/lib/routes/verify/Get.ts create mode 100644 server/src/lib/routes/verify/GetBasicAuth.spec.ts create mode 100644 server/src/lib/routes/verify/GetBasicAuth.ts create mode 100644 server/src/lib/routes/verify/GetSessionCookie.spec.ts create mode 100644 server/src/lib/routes/verify/GetSessionCookie.ts create mode 100644 server/src/lib/routes/verify/SetUserAndGroupsHeaders.spec.ts create mode 100644 server/src/lib/routes/verify/SetUserAndGroupsHeaders.ts delete mode 100644 server/src/lib/routes/verify/access_control.ts delete mode 100644 server/src/lib/routes/verify/get.spec.ts delete mode 100644 server/src/lib/routes/verify/get.ts delete mode 100644 server/src/lib/routes/verify/get_basic_auth.ts delete mode 100644 server/src/lib/routes/verify/get_session_cookie.ts create mode 100644 server/src/lib/utils/AssertRejects.ts create mode 100644 server/src/lib/utils/HasHeader.spec.ts create mode 100644 server/src/lib/utils/HasHeader.ts create mode 100644 test/suites/acl-full-bypass/README.md create mode 100644 test/suites/acl-full-bypass/config.yml create mode 100644 test/suites/acl-full-bypass/environment.ts create mode 100644 test/suites/acl-full-bypass/scenarii/BypassPolicy.ts create mode 100644 test/suites/acl-full-bypass/test.ts create mode 100644 test/suites/acl-full-bypass/users_database.yml create mode 100644 test/suites/basic/scenarii/BypassPolicy.ts diff --git a/package-lock.json b/package-lock.json index 2a435d61..ba64b8be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -482,6 +482,12 @@ "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", @@ -2524,6 +2530,16 @@ "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", @@ -4185,6 +4201,12 @@ "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", @@ -5141,6 +5163,12 @@ "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", @@ -7260,6 +7288,28 @@ "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", @@ -8522,6 +8572,12 @@ "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", "dev": true }, + "ts-mock-imports": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/ts-mock-imports/-/ts-mock-imports-1.2.3.tgz", + "integrity": "sha512-pKeHFhlM4s4LvAPiixTsBTzJ65SY0pcXYFQ6nAmDOHl3lYZk4zi2zZFC3et6xX6tKhCCkt2NaYAY+vciPJlo8Q==", + "dev": true + }, "ts-node": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-6.0.2.tgz", diff --git a/package.json b/package.json index eebb268f..589edd3d 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "sinon": "^5.0.7", "tmp": "0.0.33", "tree-kill": "^1.2.1", + "ts-mock-imports": "^1.2.3", "ts-node": "^6.0.1", "tslint": "^5.2.0", "typescript": "^2.9.2", diff --git a/server/src/lib/Exceptions.ts b/server/src/lib/Exceptions.ts index 83fa4eb6..8db02826 100644 --- a/server/src/lib/Exceptions.ts +++ b/server/src/lib/Exceptions.ts @@ -66,7 +66,7 @@ export class NotAuthenticatedError extends Error { export class NotAuthorizedError extends Error { constructor(message?: string) { super(message); - this.name = "NotAuthanticatedError"; + this.name = "NotAuthenticatedError"; (Object).setPrototypeOf(this, NotAuthorizedError.prototype); } } diff --git a/server/src/lib/ServerVariablesMockBuilder.spec.ts b/server/src/lib/ServerVariablesMockBuilder.spec.ts index 7874702a..8e0eae06 100644 --- a/server/src/lib/ServerVariablesMockBuilder.spec.ts +++ b/server/src/lib/ServerVariablesMockBuilder.spec.ts @@ -2,7 +2,7 @@ import { ServerVariables } from "./ServerVariables"; import { Configuration } from "./configuration/schema/Configuration"; import { IUsersDatabaseStub } from "./authentication/backends/IUsersDatabaseStub.spec"; -import { AuthorizerStub } from "./authorization/AuthorizerStub.spec"; +import AuthorizerStub from "./authorization/AuthorizerStub.spec"; import { RequestLoggerStub } from "./logging/RequestLoggerStub.spec"; import { NotifierStub } from "./notifiers/NotifierStub.spec"; import { RegulatorStub } from "./regulation/RegulatorStub.spec"; diff --git a/server/src/lib/authorization/Authorizer.ts b/server/src/lib/authorization/Authorizer.ts index f6f9cf41..64332724 100644 --- a/server/src/lib/authorization/Authorizer.ts +++ b/server/src/lib/authorization/Authorizer.ts @@ -1,5 +1,5 @@ -import { ACLConfiguration, ACLRule } from "../configuration/schema/AclConfiguration"; +import { ACLConfiguration, ACLRule, ACLPolicy } from "../configuration/schema/AclConfiguration"; import { IAuthorizer } from "./IAuthorizer"; import { Winston } from "../../../types/Dependencies"; import { MultipleDomainMatcher } from "./MultipleDomainMatcher"; @@ -60,7 +60,7 @@ export class Authorizer implements IAuthorizer { .filter(MatchSubject(subject)); } - private ruleToLevel(policy: string): Level { + private ruleToLevel(policy: ACLPolicy): Level { if (policy == "bypass") { return Level.BYPASS; } else if (policy == "one_factor") { diff --git a/server/src/lib/authorization/AuthorizerStub.spec.ts b/server/src/lib/authorization/AuthorizerStub.spec.ts index 9bd6f4a8..957e0b38 100644 --- a/server/src/lib/authorization/AuthorizerStub.spec.ts +++ b/server/src/lib/authorization/AuthorizerStub.spec.ts @@ -4,7 +4,7 @@ import { Level } from "./Level"; import { Object } from "./Object"; import { Subject } from "./Subject"; -export class AuthorizerStub implements IAuthorizer { +export default class AuthorizerStub implements IAuthorizer { authorizationMock: Sinon.SinonStub; constructor() { diff --git a/server/src/lib/routes/verify/CheckAuthorizations.spec.ts b/server/src/lib/routes/verify/CheckAuthorizations.spec.ts new file mode 100644 index 00000000..75f8b25e --- /dev/null +++ b/server/src/lib/routes/verify/CheckAuthorizations.spec.ts @@ -0,0 +1,100 @@ +import CheckAuthorizations from "./CheckAuthorizations"; +import AuthorizerStub from "../../authorization/AuthorizerStub.spec"; +import { Level } from "../../authentication/Level"; +import { Level as AuthorizationLevel } from "../../authorization/Level"; +import * as Assert from "assert"; +import { NotAuthenticatedError, NotAuthorizedError } from "../../Exceptions"; + +describe('routes/verify/CheckAuthorizations', function() { + describe('bypass policy', function() { + it('should allow an anonymous user', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.BYPASS); + CheckAuthorizations(authorizer, "public.example.com", "/index.html", undefined, + undefined, Level.NOT_AUTHENTICATED); + }); + + it('should allow an authenticated user (1FA)', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.BYPASS); + CheckAuthorizations(authorizer, "public.example.com", "/index.html", "john", + ["group1", "group2"], Level.ONE_FACTOR); + }); + + it('should allow an authenticated user (2FA)', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.BYPASS); + CheckAuthorizations(authorizer, "public.example.com", "/index.html", "john", + ["group1", "group2"], Level.TWO_FACTOR); + }); + }); + + describe('one_factor policy', function() { + it('should not allow an anonymous user', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.ONE_FACTOR); + Assert.throws(() => { CheckAuthorizations(authorizer, "public.example.com", "/index.html", undefined, + undefined, Level.NOT_AUTHENTICATED) }, NotAuthenticatedError); + }); + + it('should allow an authenticated user (1FA)', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.ONE_FACTOR); + CheckAuthorizations(authorizer, "public.example.com", "/index.html", "john", + ["group1", "group2"], Level.ONE_FACTOR); + }); + + it('should allow an authenticated user (2FA)', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.ONE_FACTOR); + CheckAuthorizations(authorizer, "public.example.com", "/index.html", "john", + ["group1", "group2"], Level.TWO_FACTOR); + }); + }); + + describe('two_factor policy', function() { + it('should not allow an anonymous user', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + Assert.throws(() => CheckAuthorizations(authorizer, "public.example.com", "/index.html", undefined, + undefined, Level.NOT_AUTHENTICATED), NotAuthenticatedError); + }); + + it('should not allow an authenticated user (1FA)', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + Assert.throws(() => CheckAuthorizations(authorizer, "public.example.com", "/index.html", "john", + ["group1", "group2"], Level.ONE_FACTOR), NotAuthenticatedError); + }); + + it('should allow an authenticated user (2FA)', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); + CheckAuthorizations(authorizer, "public.example.com", "/index.html", "john", + ["group1", "group2"], Level.TWO_FACTOR); + }); + }); + + describe('deny policy', function() { + it('should not allow an anonymous user', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.DENY); + Assert.throws(() => CheckAuthorizations(authorizer, "public.example.com", "/index.html", undefined, + undefined, Level.NOT_AUTHENTICATED), NotAuthenticatedError); + }); + + it('should not allow an authenticated user (1FA)', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.DENY); + Assert.throws(() => CheckAuthorizations(authorizer, "public.example.com", "/index.html", "john", + ["group1", "group2"], Level.ONE_FACTOR), NotAuthorizedError); + }); + + it('should not allow an authenticated user (2FA)', function() { + const authorizer = new AuthorizerStub(); + authorizer.authorizationMock.returns(AuthorizationLevel.DENY); + Assert.throws(() => CheckAuthorizations(authorizer, "public.example.com", "/index.html", "john", + ["group1", "group2"], Level.TWO_FACTOR), NotAuthorizedError); + }); + }); +}); \ No newline at end of file diff --git a/server/src/lib/routes/verify/CheckAuthorizations.ts b/server/src/lib/routes/verify/CheckAuthorizations.ts new file mode 100644 index 00000000..0374a1f4 --- /dev/null +++ b/server/src/lib/routes/verify/CheckAuthorizations.ts @@ -0,0 +1,46 @@ +import * as Util from "util"; + +import Exceptions = require("../../Exceptions"); + +import { Level as AuthorizationLevel } from "../../authorization/Level"; +import { Level as AuthenticationLevel } from "../../authentication/Level"; +import { IAuthorizer } from "../../authorization/IAuthorizer"; + +function isAuthorized( + authorization: AuthorizationLevel, + authentication: AuthenticationLevel): boolean { + + if (authorization == AuthorizationLevel.BYPASS) { + return true; + } else if (authorization == AuthorizationLevel.ONE_FACTOR && + authentication >= AuthenticationLevel.ONE_FACTOR) { + return true; + } else if (authorization == AuthorizationLevel.TWO_FACTOR && + authentication >= AuthenticationLevel.TWO_FACTOR) { + return true; + } + return false; +} + +export default function ( + authorizer: IAuthorizer, + domain: string, resource: string, + user: string, groups: string[], + authenticationLevel: AuthenticationLevel): AuthorizationLevel { + + const authorizationLevel = authorizer + .authorization({domain, resource}, {user, groups}); + + if (authorizationLevel == AuthorizationLevel.BYPASS) { + return authorizationLevel; + } + else if (user && authorizationLevel == AuthorizationLevel.DENY) { + throw new Exceptions.NotAuthorizedError( + Util.format("User %s is not authorized to access %s%s", user, 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)); + } + return authorizationLevel; +} \ No newline at end of file diff --git a/server/src/lib/routes/verify/CheckInactivity.spec.ts b/server/src/lib/routes/verify/CheckInactivity.spec.ts new file mode 100644 index 00000000..29656991 --- /dev/null +++ b/server/src/lib/routes/verify/CheckInactivity.spec.ts @@ -0,0 +1,60 @@ +import * as Express from "express"; +import * as ExpressMock from "../../stubs/express.spec"; +import * as Sinon from "sinon"; +import * as Assert from "assert"; +import CheckInactivity from "./CheckInactivity"; +import { AuthenticationSession } from "../../../../types/AuthenticationSession"; +import { Configuration } from "../../configuration/schema/Configuration"; +import { RequestLoggerStub } from "../../logging/RequestLoggerStub.spec"; + + +describe('routes/verify/VerifyInactivity', function() { + let req: Express.Request; + let authSession: AuthenticationSession; + let configuration: Configuration; + let logger: RequestLoggerStub; + + beforeEach(function() { + req = ExpressMock.RequestMock(); + authSession = {} as any; + configuration = { + session: { + domain: 'example.com', + secret: 'abc', + inactivity: 1000, + }, + authentication_backend: { + file: { + path: 'abc' + } + } + } + logger = new RequestLoggerStub(); + }); + + it('should not throw if inactivity timeout is disabled', function() { + delete configuration.session.inactivity; + CheckInactivity(req, authSession, configuration, logger); + }); + + it('should not throw if keep me logged in has been checked', function() { + authSession.keep_me_logged_in = true; + CheckInactivity(req, authSession, configuration, logger); + }); + + it('should not throw if the inactivity timeout has not timed out', function() { + this.clock = Sinon.useFakeTimers(); + authSession.last_activity_datetime = new Date().getTime(); + this.clock.tick(200); + CheckInactivity(req, authSession, configuration, logger); + this.clock.restore(); + }); + + it('should throw if the inactivity timeout has timed out', function() { + this.clock = Sinon.useFakeTimers(); + authSession.last_activity_datetime = new Date().getTime(); + this.clock.tick(2000); + Assert.throws(() => CheckInactivity(req, authSession, configuration, logger)); + this.clock.restore(); + }); +}); \ No newline at end of file diff --git a/server/src/lib/routes/verify/CheckInactivity.ts b/server/src/lib/routes/verify/CheckInactivity.ts new file mode 100644 index 00000000..db61a6fb --- /dev/null +++ b/server/src/lib/routes/verify/CheckInactivity.ts @@ -0,0 +1,31 @@ +import * as Express from "express"; +import { AuthenticationSession } from "AuthenticationSession"; +import { Configuration } from "../../configuration/schema/Configuration"; +import { IRequestLogger } from "../../logging/IRequestLogger"; +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; + +export default function(req: Express.Request, + authSession: AuthenticationSession, + configuration: Configuration, logger: IRequestLogger): void { + + // If inactivity is not specified, then inactivity timeout does not apply + if (!configuration.session.inactivity || authSession.keep_me_logged_in) { + return; + } + + const lastActivityTime = authSession.last_activity_datetime; + const currentTime = new Date().getTime(); + authSession.last_activity_datetime = currentTime; + + const inactivityPeriodMs = currentTime - lastActivityTime; + logger.debug(req, "Inactivity period was %s sec and max period was %s sec.", + inactivityPeriodMs / 1000, configuration.session.inactivity / 1000); + + if (inactivityPeriodMs < configuration.session.inactivity) { + return; + } + + logger.debug(req, "Session has been reset after too long inactivity period."); + AuthenticationSessionHandler.reset(req); + throw new Error("Inactivity period exceeded."); +} diff --git a/server/src/lib/routes/verify/Get.spec.ts b/server/src/lib/routes/verify/Get.spec.ts new file mode 100644 index 00000000..d5d5d5b4 --- /dev/null +++ b/server/src/lib/routes/verify/Get.spec.ts @@ -0,0 +1,96 @@ + +import * as Assert from "assert"; +import * as Express from "express"; +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import { AuthenticationSession } from "../../../../types/AuthenticationSession"; +import ExpressMock = require("../../stubs/express.spec"); +import { ServerVariables } from "../../ServerVariables"; +import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../ServerVariablesMockBuilder.spec"; +import { HEADER_X_ORIGINAL_URL } from "../../../../../shared/constants"; +import Get from "./Get"; +import { ImportMock } from 'ts-mock-imports'; +import * as GetBasicAuth from "./GetBasicAuth"; +import * as GetSessionCookie from "./GetSessionCookie"; +import { NotAuthorizedError, NotAuthenticatedError } from "../../Exceptions"; + + +describe("routes/verify/get", function () { + let req: Express.Request; + let res: ExpressMock.ResponseMock; + let mocks: ServerVariablesMock; + let vars: ServerVariables; + let authSession: AuthenticationSession; + + beforeEach(function () { + req = ExpressMock.RequestMock(); + res = ExpressMock.ResponseMock(); + req.query = { + redirect: "undefined" + }; + AuthenticationSessionHandler.reset(req as any); + req.headers[HEADER_X_ORIGINAL_URL] = "https://secret.example.com/"; + const s = ServerVariablesMockBuilder.build(false); + mocks = s.mocks; + vars = s.variables; + authSession = AuthenticationSessionHandler.get(req as any, vars.logger); + }); + + describe("with basic auth", function () { + it('should allow access to user', async function() { + req.headers['proxy-authorization'] = 'zglfzeljfzelmkj'; + const mock = ImportMock.mockOther(GetBasicAuth, "default", () => Promise.resolve()); + await Get(vars)(req, res as any); + Assert(res.send.calledWithExactly()); + Assert(res.status.calledWithExactly(204)) + mock.restore(); + }); + }); + + describe("with session cookie", function () { + it('should allow access to user', async function() { + const mock = ImportMock.mockOther(GetSessionCookie, "default", () => Promise.resolve()); + await Get(vars)(req, res as any); + Assert(res.send.calledWithExactly()); + Assert(res.status.calledWithExactly(204)) + mock.restore(); + }); + }); + + describe('Deny access', function() { + it('should deny access to user on NotAuthorizedError', async function() { + req.headers['proxy-authorization'] = 'zglfzeljfzelmkj'; + const mock = ImportMock.mockOther(GetBasicAuth, "default", () => Promise.reject(new NotAuthorizedError('No!'))); + await Get(vars)(req, res as any); + Assert(res.status.calledWith(403)); + mock.restore(); + }); + + it('should deny access to user on NotAuthenticatedError', async function() { + req.headers['proxy-authorization'] = 'zglfzeljfzelmkj'; + const mock = ImportMock.mockOther(GetBasicAuth, "default", () => Promise.reject(new NotAuthenticatedError('No!'))); + await Get(vars)(req, res as any); + Assert(res.status.calledWith(401)); + mock.restore(); + }); + + it('should deny access to user on any exception', async function() { + req.headers['proxy-authorization'] = 'zglfzeljfzelmkj'; + const mock = ImportMock.mockOther(GetBasicAuth, "default", () => Promise.reject(new Error('No!'))); + await Get(vars)(req, res as any); + Assert(res.status.calledWith(401)); + mock.restore(); + }); + }) + + describe('Kubernetes ingress controller', function() { + it('should redirect user to login portal', async function() { + req.headers['proxy-authorization'] = 'zglfzeljfzelmkj'; + req.query.rd = 'https://login.example.com/'; + const mock = ImportMock.mockOther(GetBasicAuth, "default", () => Promise.reject(new NotAuthenticatedError('No!'))); + await Get(vars)(req, res as any); + Assert(res.redirect.calledWith('https://login.example.com/')); + mock.restore(); + }); + }); +}); + diff --git a/server/src/lib/routes/verify/Get.ts b/server/src/lib/routes/verify/Get.ts new file mode 100644 index 00000000..b647334d --- /dev/null +++ b/server/src/lib/routes/verify/Get.ts @@ -0,0 +1,71 @@ +import Express = require("express"); +import Exceptions = require("../../Exceptions"); +import ErrorReplies = require("../../ErrorReplies"); +import { ServerVariables } from "../../ServerVariables"; +import GetSessionCookie from "./GetSessionCookie"; +import GetBasicAuth from "./GetBasicAuth"; +import Constants = require("../../../../../shared/constants"); +import { AuthenticationSessionHandler } + from "../../AuthenticationSessionHandler"; +import { AuthenticationSession } + from "../../../../types/AuthenticationSession"; +import GetHeader from "../../utils/GetHeader"; +import HasHeader from "../..//utils/HasHeader"; + + +async function verifyWithSelectedMethod(req: Express.Request, res: Express.Response, + vars: ServerVariables, authSession: AuthenticationSession | undefined) + : Promise { + if (HasHeader(req, Constants.HEADER_PROXY_AUTHORIZATION)) { + await GetBasicAuth(req, res, vars); + } + else { + await GetSessionCookie(req, res, vars, authSession); + } +} + +/** + * The Redirect header is used to set the target URL in the login portal. + * + * @param req The request to extract X-Original-Url from. + * @param res The response to write Redirect header to. + */ +function setRedirectHeader(req: Express.Request, res: Express.Response) { + const originalUrl = GetHeader(req, Constants.HEADER_X_ORIGINAL_URL); + res.set(Constants.HEADER_REDIRECT, originalUrl); +} + +function getRedirectParam(req: Express.Request) { + return req.query[Constants.REDIRECT_QUERY_PARAM] != "undefined" + ? req.query[Constants.REDIRECT_QUERY_PARAM] + : undefined; +} + +export default function (vars: ServerVariables) { + return async function (req: Express.Request, res: Express.Response) + : Promise { + const authSession = AuthenticationSessionHandler.get(req, vars.logger); + setRedirectHeader(req, res); + + try { + await verifyWithSelectedMethod(req, res, vars, authSession); + res.status(204); + res.send(); + } catch (err) { + // This redirect parameter is used in Kubernetes to annotate the ingress with + // the url to the authentication portal. + const redirectUrl = getRedirectParam(req); + if (redirectUrl) { + ErrorReplies.redirectTo(redirectUrl, req, res, vars.logger)(err); + return; + } + + if (err instanceof Exceptions.NotAuthorizedError) { + ErrorReplies.replyWithError403(req, res, vars.logger)(err); + } else { + ErrorReplies.replyWithError401(req, res, vars.logger)(err); + } + } + }; +} + diff --git a/server/src/lib/routes/verify/GetBasicAuth.spec.ts b/server/src/lib/routes/verify/GetBasicAuth.spec.ts new file mode 100644 index 00000000..06ad59ee --- /dev/null +++ b/server/src/lib/routes/verify/GetBasicAuth.spec.ts @@ -0,0 +1,65 @@ +import * as Express from "express"; +import { ServerVariables } from "../../ServerVariables"; +import * as ExpressMock from "../../stubs/express.spec"; +import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../ServerVariablesMockBuilder.spec"; +import { HEADER_X_ORIGINAL_URL } from "../../../../../shared/constants"; +import { Level } from "../../authorization/Level"; +import GetBasicAuthModule from "./GetBasicAuth"; +import * as CheckAuthorizations from "./CheckAuthorizations"; +import { ImportMock } from 'ts-mock-imports'; +import AssertRejects from "../../utils/AssertRejects"; + +describe('routes/verify/GetBasicAuth', function() { + let req: Express.Request; + let res: ExpressMock.ResponseMock; + let vars: ServerVariables; + let mocks: ServerVariablesMock; + + beforeEach(function() { + req = ExpressMock.RequestMock(); + res = ExpressMock.ResponseMock(); + req.headers[HEADER_X_ORIGINAL_URL] = 'https://secure.example.com'; + const sv = ServerVariablesMockBuilder.build(); + vars = sv.variables; + mocks = sv.mocks; + }) + + it('should fail on invalid format of token', async function() { + req.headers['proxy-authorization'] = 'Basic abc'; + AssertRejects(async () => GetBasicAuthModule(req, res as any, vars)); + }); + + it('should fail decoded token is not of form user:pass', function() { + req.headers['proxy-authorization'] = 'Basic aGVsbG93b3JsZAo='; + AssertRejects(async () => GetBasicAuthModule(req, res as any, vars)); + }); + + it('should fail when credentials are wrong', function() { + req.headers['proxy-authorization'] = 'Basic aGVsbG8xOndvcmxkCg=='; + mocks.usersDatabase.checkUserPasswordStub.rejects(new Error('Bad credentials')); + AssertRejects(async () => await GetBasicAuthModule(req, res as any, vars)); + }); + + it('should fail when authorizations are not sufficient', function() { + req.headers['proxy-authorization'] = 'Basic aGVsbG8xOndvcmxkCg=='; + const mock = ImportMock.mockOther(CheckAuthorizations, 'default', () => { throw new Error('Not enough permissions.')}); + mocks.usersDatabase.checkUserPasswordStub.resolves({ + email: 'john@example.com', + groups: ['group1', 'group2'], + }); + AssertRejects(async () => await GetBasicAuthModule(req, res as any, vars)); + mock.restore(); + }); + + it('should succeed when user is authenticated and authorizations are sufficient', async function() { + req.headers['proxy-authorization'] = 'Basic aGVsbG8xOndvcmxkCg=='; + const mock = ImportMock.mockOther(CheckAuthorizations, 'default', () => Level.TWO_FACTOR); + mocks.usersDatabase.checkUserPasswordStub.resolves({ + email: 'john@example.com', + groups: ['group1', 'group2'], + }); + await GetBasicAuthModule(req, res as any, vars); + mock.restore(); + }); + +}) \ No newline at end of file diff --git a/server/src/lib/routes/verify/GetBasicAuth.ts b/server/src/lib/routes/verify/GetBasicAuth.ts new file mode 100644 index 00000000..f28f1911 --- /dev/null +++ b/server/src/lib/routes/verify/GetBasicAuth.ts @@ -0,0 +1,49 @@ +import Express = require("express"); +import { ServerVariables } from "../../ServerVariables"; +import { URLDecomposer } from "../../utils/URLDecomposer"; +import { Level } from "../../authentication/Level"; +import GetHeader from "../../utils/GetHeader"; +import { HEADER_X_ORIGINAL_URL, HEADER_PROXY_AUTHORIZATION } from "../../../../../shared/constants"; +import setUserAndGroupsHeaders from "./SetUserAndGroupsHeaders"; +import CheckAuthorizations from "./CheckAuthorizations"; +import { Level as AuthorizationLevel } from "../../authorization/Level"; + +export default async function(req: Express.Request, res: Express.Response, + vars: ServerVariables) + : Promise { + const authorizationValue = GetHeader(req, HEADER_PROXY_AUTHORIZATION); + + if (!authorizationValue.startsWith("Basic ")) { + throw new Error("The authorization header should be of the form 'Basic XXXXXX'"); + } + + const base64Re = new RegExp("^Basic ((?:[A-Za-z0-9+/]{4})*" + + "(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)$"); + const isTokenValidBase64 = base64Re.test(authorizationValue); + + if (!isTokenValidBase64) { + throw new Error("No valid base64 token found in the header"); + } + + const tokenMatches = authorizationValue.match(base64Re); + const base64Token = tokenMatches[1]; + const decodedToken = Buffer.from(base64Token, "base64").toString(); + const splittedToken = decodedToken.split(":"); + + if (splittedToken.length != 2) { + throw new Error("The authorization token is invalid. Expecting 'userid:password'"); + } + + const username = splittedToken[0]; + const password = splittedToken[1]; + const groupsAndEmails = await vars.usersDatabase.checkUserPassword(username, password); + + const uri = GetHeader(req, HEADER_X_ORIGINAL_URL); + const urlDecomposition = URLDecomposer.fromUrl(uri); + const authorizationLevel = CheckAuthorizations(vars.authorizer, urlDecomposition.domain, urlDecomposition.path, + username, groupsAndEmails.groups, Level.ONE_FACTOR); + + if (authorizationLevel > AuthorizationLevel.BYPASS) { + setUserAndGroupsHeaders(res, username, groupsAndEmails.groups); + } +} \ No newline at end of file diff --git a/server/src/lib/routes/verify/GetSessionCookie.spec.ts b/server/src/lib/routes/verify/GetSessionCookie.spec.ts new file mode 100644 index 00000000..374e836c --- /dev/null +++ b/server/src/lib/routes/verify/GetSessionCookie.spec.ts @@ -0,0 +1,57 @@ +import * as Express from "express"; +import * as ExpressMock from "../../stubs/express.spec"; +import { ImportMock } from 'ts-mock-imports'; +import * as CheckAuthorizations from "./CheckAuthorizations"; +import * as CheckInactivity from "./CheckInactivity"; +import GetSessionCookie from "./GetSessionCookie"; +import { ServerVariables } from "../../ServerVariables"; +import { ServerVariablesMockBuilder } from "../../ServerVariablesMockBuilder.spec"; +import { AuthenticationSession } from "../../../../types/AuthenticationSession"; +import AssertRejects from "../../utils/AssertRejects"; +import { Level } from "../../authorization/Level"; + + +describe('routes/verify/GetSessionCookie', function() { + let req: Express.Request; + let res: ExpressMock.ResponseMock; + let vars: ServerVariables; + let authSession: AuthenticationSession; + + beforeEach(function() { + req = ExpressMock.RequestMock(); + res = ExpressMock.ResponseMock(); + const sv = ServerVariablesMockBuilder.build(); + vars = sv.variables; + authSession = {} as any; + }); + + it("should fail when target url is not provided", async function() { + AssertRejects(async () => await GetSessionCookie(req, res as any, vars, authSession)); + }); + + it("should not let unauthorized users in", async function() { + req.originalUrl = "https://public.example.com"; + const mock = ImportMock.mockOther(CheckAuthorizations, "default", () => { throw new Error('Not authorized')}); + AssertRejects(async () => await GetSessionCookie(req, res as any, vars, authSession)); + mock.restore(); + }); + + it("should not let authorize user after a long period of inactivity", async function() { + req.originalUrl = "https://public.example.com"; + const checkAuthorizationsMock = ImportMock.mockOther(CheckAuthorizations, "default", () => Level.ONE_FACTOR); + const checkInactivityMock = ImportMock.mockOther(CheckInactivity, "default", () => { throw new Error('Timed out')}); + AssertRejects(async () => await GetSessionCookie(req, res as any, vars, authSession)); + checkInactivityMock.restore(); + checkAuthorizationsMock.restore(); + }); + + it("should let the user in", async function() { + req.headers['x-original-url'] = "https://public.example.com"; + + const checkAuthorizationsMock = ImportMock.mockOther(CheckAuthorizations, "default", () => Level.ONE_FACTOR); + const checkInactivityMock = ImportMock.mockOther(CheckInactivity, "default", () => {}); + await GetSessionCookie(req, res as any, vars, authSession); + checkInactivityMock.restore(); + checkAuthorizationsMock.restore(); + }); +}); \ No newline at end of file diff --git a/server/src/lib/routes/verify/GetSessionCookie.ts b/server/src/lib/routes/verify/GetSessionCookie.ts new file mode 100644 index 00000000..47bc3c27 --- /dev/null +++ b/server/src/lib/routes/verify/GetSessionCookie.ts @@ -0,0 +1,43 @@ +import Express = require("express"); +import { ServerVariables } from "../../ServerVariables"; +import { AuthenticationSession } + from "../../../../types/AuthenticationSession"; +import { URLDecomposer } from "../../utils/URLDecomposer"; +import GetHeader from "../../utils/GetHeader"; +import { + HEADER_X_ORIGINAL_URL, +} from "../../../../../shared/constants"; +import { Level as AuthorizationLevel } from "../../authorization/Level"; +import setUserAndGroupsHeaders from "./SetUserAndGroupsHeaders"; +import CheckAuthorizations from "./CheckAuthorizations"; +import CheckInactivity from "./CheckInactivity"; + + +export default async function (req: Express.Request, res: Express.Response, + vars: ServerVariables, authSession: AuthenticationSession | undefined) + : Promise { + if (!authSession) { + throw new Error("No cookie detected."); + } + + const originalUrl = GetHeader(req, HEADER_X_ORIGINAL_URL); + + if (!originalUrl) { + throw new Error("Cannot detect the original URL from headers."); + } + + const d = URLDecomposer.fromUrl(originalUrl); + + const username = authSession.userid; + const groups = authSession.groups; + + vars.logger.debug(req, "domain=%s, path=%s, user=%s, groups=%s", d.domain, + d.path, (username) ? username : "unknown", (groups instanceof Array && groups.length > 0) ? groups.join(",") : "unknown"); + const authorizationLevel = CheckAuthorizations(vars.authorizer, d.domain, d.path, username, groups, + authSession.authentication_level); + + if (authorizationLevel > AuthorizationLevel.BYPASS) { + CheckInactivity(req, authSession, vars.config, vars.logger); + setUserAndGroupsHeaders(res, username, groups); + } +} \ No newline at end of file diff --git a/server/src/lib/routes/verify/SetUserAndGroupsHeaders.spec.ts b/server/src/lib/routes/verify/SetUserAndGroupsHeaders.spec.ts new file mode 100644 index 00000000..f9dd3585 --- /dev/null +++ b/server/src/lib/routes/verify/SetUserAndGroupsHeaders.spec.ts @@ -0,0 +1,13 @@ +import * as ExpressMock from "../../stubs/express.spec"; +import * as Assert from "assert"; +import { HEADER_REMOTE_USER, HEADER_REMOTE_GROUPS } from "../../../../../shared/constants"; +import SetUserAndGroupsHeaders from "./SetUserAndGroupsHeaders"; + +describe("routes/verify/SetUserAndGroupsHeaders", function() { + it('should set the correct headers', function() { + const res = ExpressMock.ResponseMock(); + SetUserAndGroupsHeaders(res as any, "john", ["group1", "group2"]); + Assert(res.setHeader.calledWith(HEADER_REMOTE_USER, "john")); + Assert(res.setHeader.calledWith(HEADER_REMOTE_GROUPS, "group1,group2")); + }) +}) \ No newline at end of file diff --git a/server/src/lib/routes/verify/SetUserAndGroupsHeaders.ts b/server/src/lib/routes/verify/SetUserAndGroupsHeaders.ts new file mode 100644 index 00000000..167ec88d --- /dev/null +++ b/server/src/lib/routes/verify/SetUserAndGroupsHeaders.ts @@ -0,0 +1,7 @@ +import * as Express from "express"; +import { HEADER_REMOTE_USER, HEADER_REMOTE_GROUPS } from "../../../../../shared/constants"; + +export default function(res: Express.Response, username: string | undefined, groups: string[] | undefined) { + if (username) res.setHeader(HEADER_REMOTE_USER, username); + if (groups instanceof Array) res.setHeader(HEADER_REMOTE_GROUPS, groups.join(",")); +} \ No newline at end of file diff --git a/server/src/lib/routes/verify/access_control.ts b/server/src/lib/routes/verify/access_control.ts deleted file mode 100644 index cbf97ab3..00000000 --- a/server/src/lib/routes/verify/access_control.ts +++ /dev/null @@ -1,50 +0,0 @@ -import Express = require("express"); -import BluebirdPromise = require("bluebird"); -import Util = require("util"); - -import Exceptions = require("../../Exceptions"); - -import { Level as AuthorizationLevel } from "../../authorization/Level"; -import { Level as AuthenticationLevel } from "../../authentication/Level"; -import { ServerVariables } from "../../ServerVariables"; - -function isAuthorized( - authorization: AuthorizationLevel, - authentication: AuthenticationLevel): boolean { - - if (authorization == AuthorizationLevel.BYPASS) { - return true; - } else if (authorization == AuthorizationLevel.ONE_FACTOR && - authentication >= AuthenticationLevel.ONE_FACTOR) { - return true; - } else if (authorization == AuthorizationLevel.TWO_FACTOR && - authentication >= AuthenticationLevel.TWO_FACTOR) { - return true; - } - return false; -} - -export default function ( - req: Express.Request, - vars: ServerVariables, - domain: string, resource: string, - user: string, groups: string[], - authenticationLevel: AuthenticationLevel) { - - return new BluebirdPromise(function (resolve, reject) { - const authorizationLevel = vars.authorizer - .authorization({domain, resource}, {user, groups}); - - if (!isAuthorized(authorizationLevel, authenticationLevel)) { - if (authorizationLevel == AuthorizationLevel.DENY) { - reject(new Exceptions.NotAuthorizedError( - Util.format("User %s is not authorized to access %s%s", user, domain, resource))); - return; - } - reject(new Exceptions.NotAuthenticatedError(Util.format( - "User '%s' is not sufficiently authorized to access %s%s.", user, domain, resource))); - return; - } - resolve(); - }); -} \ No newline at end of file diff --git a/server/src/lib/routes/verify/get.spec.ts b/server/src/lib/routes/verify/get.spec.ts deleted file mode 100644 index ec6751d3..00000000 --- a/server/src/lib/routes/verify/get.spec.ts +++ /dev/null @@ -1,314 +0,0 @@ - -import * as Assert from "assert"; -import * as Express from "express"; -import * as Sinon from "sinon"; - -import VerifyGet = require("./get"); -import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; -import { AuthenticationSession } from "../../../../types/AuthenticationSession"; -import ExpressMock = require("../../stubs/express.spec"); -import { ServerVariables } from "../../ServerVariables"; -import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../ServerVariablesMockBuilder.spec"; -import { Level } from "../../authentication/Level"; -import { Level as AuthorizationLevel } from "../../authorization/Level"; -import { HEADER_X_ORIGINAL_URL } from "../../../../../shared/constants"; - -describe("routes/verify/get", function () { - let req: Express.Request; - let res: ExpressMock.ResponseMock; - let mocks: ServerVariablesMock; - let vars: ServerVariables; - let authSession: AuthenticationSession; - - beforeEach(function () { - req = ExpressMock.RequestMock(); - res = ExpressMock.ResponseMock(); - req.originalUrl = "/api/xxxx"; - req.query = { - redirect: "undefined" - }; - AuthenticationSessionHandler.reset(req as any); - req.headers[HEADER_X_ORIGINAL_URL] = "https://secret.example.com/"; - const s = ServerVariablesMockBuilder.build(false); - mocks = s.mocks; - vars = s.variables; - authSession = AuthenticationSessionHandler.get(req as any, vars.logger); - }); - - describe("with session cookie", function () { - it("should be already authenticated", async function () { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - authSession.authentication_level = Level.TWO_FACTOR; - authSession.userid = "myuser"; - authSession.groups = ["mygroup", "othergroup"]; - await VerifyGet.default(vars)(req as Express.Request, res as any); - res.setHeader.calledWith("Remote-User", "myuser"); - res.setHeader.calledWith("Remote-Groups", "mygroup,othergroup"); - Assert.equal(204, res.status.getCall(0).args[0]); - }); - - function test_session(_authSession: AuthenticationSession, status_code: number) { - const GetMock = Sinon.stub(AuthenticationSessionHandler, 'get'); - GetMock.returns(_authSession); - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - Assert.equal(status_code, res.status.getCall(0).args[0]); - GetMock.restore(); - }) - } - - function test_non_authenticated_401(_authSession: AuthenticationSession) { - return test_session(_authSession, 401); - } - - function test_unauthorized_403(_authSession: AuthenticationSession) { - return test_session(_authSession, 403); - } - - describe("given user tries to access a 2-factor endpoint", function () { - beforeEach(function () { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - }); - - describe("given different cases of session", function () { - it("should not be authenticated when second factor is missing", function () { - return test_non_authenticated_401({ - keep_me_logged_in: false, - userid: "user", - authentication_level: Level.ONE_FACTOR, - email: undefined, - groups: [], - last_activity_datetime: new Date().getTime() - }); - }); - - it("should not be authenticated when userid is missing", function () { - return test_non_authenticated_401({ - keep_me_logged_in: false, - userid: undefined, - authentication_level: Level.TWO_FACTOR, - email: undefined, - groups: [], - last_activity_datetime: new Date().getTime() - }); - }); - - it("should not be authenticated when level is insufficient", function () { - return test_non_authenticated_401({ - keep_me_logged_in: false, - userid: "user", - authentication_level: Level.NOT_AUTHENTICATED, - email: undefined, - groups: [], - last_activity_datetime: new Date().getTime() - }); - }); - - it("should not be authenticated when session has not be initiated", function () { - return test_non_authenticated_401(undefined); - }); - - it("should not be authenticated when domain is not allowed for user", function () { - authSession.authentication_level = Level.TWO_FACTOR; - authSession.userid = "myuser"; - req.headers[HEADER_X_ORIGINAL_URL] = "https://test.example.com/"; - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.DENY); - - return test_unauthorized_403({ - keep_me_logged_in: false, - authentication_level: Level.TWO_FACTOR, - userid: "user", - groups: ["group1", "group2"], - email: undefined, - last_activity_datetime: new Date().getTime() - }); - }); - }); - }); - - describe("given user tries to access a single factor endpoint", function () { - beforeEach(function () { - req.headers[HEADER_X_ORIGINAL_URL] = "https://redirect.url/"; - }); - - it("should be authenticated when first factor is validated", function () { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.ONE_FACTOR); - authSession.authentication_level = Level.ONE_FACTOR; - authSession.userid = "user1"; - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - Assert(res.status.calledWith(204)); - Assert(res.send.calledOnce); - }); - }); - - it("should be rejected with 401 when not authenticated", function () { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.ONE_FACTOR); - authSession.authentication_level = Level.NOT_AUTHENTICATED; - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - Assert(res.status.calledWith(401)); - }); - }); - }); - - describe("inactivity period", function () { - it("should update last inactivity period on requests on /api/verify", function () { - mocks.config.session.inactivity = 200000; - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - const currentTime = new Date().getTime() - 1000; - AuthenticationSessionHandler.reset(req as any); - authSession.authentication_level = Level.TWO_FACTOR; - authSession.userid = "myuser"; - authSession.groups = ["mygroup", "othergroup"]; - authSession.last_activity_datetime = currentTime; - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - return AuthenticationSessionHandler.get(req as any, vars.logger); - }) - .then(function (authSession) { - Assert(authSession.last_activity_datetime > currentTime); - }); - }); - - it("should reset session when max inactivity period has been reached", function () { - mocks.config.session.inactivity = 1; - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - const currentTime = new Date().getTime() - 1000; - AuthenticationSessionHandler.reset(req as any); - authSession.authentication_level = Level.TWO_FACTOR; - authSession.userid = "myuser"; - authSession.groups = ["mygroup", "othergroup"]; - authSession.last_activity_datetime = currentTime; - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - return AuthenticationSessionHandler.get(req as any, vars.logger); - }) - .then(function (authSession) { - Assert.equal(authSession.authentication_level, Level.NOT_AUTHENTICATED); - Assert.equal(authSession.userid, undefined); - }); - }); - }); - }); - - describe("response type 401 | 302", function() { - it("should return error code 401", function() { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - mocks.config.access_control.default_policy = "one_factor"; - mocks.usersDatabase.checkUserPasswordStub.rejects(new Error( - "Invalid credentials")); - req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; - - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - Assert(res.status.calledWithExactly(401)); - }); - }); - - it("should redirect to provided redirection url", function() { - const REDIRECT_URL = "http://redirection_url.com"; - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - mocks.config.access_control.default_policy = "one_factor"; - mocks.usersDatabase.checkUserPasswordStub.rejects(new Error( - "Invalid credentials")); - req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; - req.query["rd"] = REDIRECT_URL; - - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - Assert(res.redirect.calledWithExactly(REDIRECT_URL)); - }); - }); - }); - - describe("with basic auth", function () { - it("should authenticate correctly", async function () { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.ONE_FACTOR); - mocks.config.access_control.default_policy = "one_factor"; - mocks.usersDatabase.checkUserPasswordStub.returns({ - groups: ["mygroup", "othergroup"], - }); - req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; - - await VerifyGet.default(vars)(req as Express.Request, res as any) - res.setHeader.calledWith("Remote-User", "john"); - res.setHeader.calledWith("Remote-Groups", "mygroup,othergroup"); - Assert.equal(204, res.status.getCall(0).args[0]); - }); - - it("should fail when endpoint is protected by two factors", function () { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - mocks.config.access_control.default_policy = "one_factor"; - mocks.config.access_control.rules = [{ - domain: "secret.example.com", - policy: "two_factor" - }]; - mocks.usersDatabase.checkUserPasswordStub.resolves({ - groups: ["mygroup", "othergroup"], - }); - req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; - - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - Assert(res.status.calledWithExactly(401)); - }); - }); - - it("should fail when base64 token is not valid", function () { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - mocks.config.access_control.default_policy = "one_factor"; - mocks.usersDatabase.checkUserPasswordStub.resolves({ - groups: ["mygroup", "othergroup"], - }); - req.headers["proxy-authorization"] = "Basic i_m*not_a_base64*token"; - - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - Assert(res.status.calledWithExactly(401)); - }); - }); - - it("should fail when base64 token has not format user:psswd", function () { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - mocks.config.access_control.default_policy = "one_factor"; - mocks.usersDatabase.checkUserPasswordStub.resolves({ - groups: ["mygroup", "othergroup"], - }); - req.headers["proxy-authorization"] = "Basic am9objpwYXNzOmJhZA=="; - - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - Assert(res.status.calledWithExactly(401)); - }); - }); - - it("should fail when bad user password is provided", function () { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - mocks.config.access_control.default_policy = "one_factor"; - mocks.usersDatabase.checkUserPasswordStub.rejects(new Error( - "Invalid credentials")); - req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; - - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - Assert(res.status.calledWithExactly(401)); - }); - }); - - it("should fail when resource is restricted", function () { - mocks.authorizer.authorizationMock.returns(AuthorizationLevel.TWO_FACTOR); - mocks.config.access_control.default_policy = "one_factor"; - mocks.usersDatabase.checkUserPasswordStub.resolves({ - groups: ["mygroup", "othergroup"], - }); - req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; - - return VerifyGet.default(vars)(req as Express.Request, res as any) - .then(function () { - Assert(res.status.calledWithExactly(401)); - }); - }); - }); -}); - diff --git a/server/src/lib/routes/verify/get.ts b/server/src/lib/routes/verify/get.ts deleted file mode 100644 index 40e1b940..00000000 --- a/server/src/lib/routes/verify/get.ts +++ /dev/null @@ -1,104 +0,0 @@ -import BluebirdPromise = require("bluebird"); -import Express = require("express"); -import Exceptions = require("../../Exceptions"); -import ErrorReplies = require("../../ErrorReplies"); -import { ServerVariables } from "../../ServerVariables"; -import GetWithSessionCookieMethod from "./get_session_cookie"; -import GetWithBasicAuthMethod from "./get_basic_auth"; -import Constants = require("../../../../../shared/constants"); -import { AuthenticationSessionHandler } - from "../../AuthenticationSessionHandler"; -import { AuthenticationSession } - from "../../../../types/AuthenticationSession"; -import GetHeader from "../../utils/GetHeader"; - -const REMOTE_USER = "Remote-User"; -const REMOTE_GROUPS = "Remote-Groups"; - - -function verifyWithSelectedMethod(req: Express.Request, res: Express.Response, - vars: ServerVariables, authSession: AuthenticationSession | undefined) - : () => BluebirdPromise<{ username: string, groups: string[] }> { - return function () { - const authorization = GetHeader(req, Constants.HEADER_PROXY_AUTHORIZATION); - if (authorization) { - if (authorization.startsWith("Basic ")) { - return GetWithBasicAuthMethod(req, res, vars, authorization); - } - else { - throw new Error("The authorization header should be of the form 'Basic XXXXXX'"); - } - } - else { - if (authSession) { - return GetWithSessionCookieMethod(req, res, vars, authSession); - } - else { - throw new Error("No cookie detected."); - } - } - }; -} - -function setRedirectHeader(req: Express.Request, res: Express.Response) { - return function () { - const originalUrl = GetHeader(req, Constants.HEADER_X_ORIGINAL_URL); - res.set(Constants.HEADER_REDIRECT, originalUrl); - return BluebirdPromise.resolve(); - }; -} - -function setUserAndGroupsHeaders(res: Express.Response) { - return function (u: { username: string, groups: string[] }) { - res.setHeader(REMOTE_USER, u.username); - res.setHeader(REMOTE_GROUPS, u.groups.join(",")); - return BluebirdPromise.resolve(); - }; -} - -function replyWith200(res: Express.Response) { - return function () { - res.status(204); - res.send(); - }; -} - -function getRedirectParam(req: Express.Request) { - return req.query[Constants.REDIRECT_QUERY_PARAM] != "undefined" - ? req.query[Constants.REDIRECT_QUERY_PARAM] - : undefined; -} - -export default function (vars: ServerVariables) { - return function (req: Express.Request, res: Express.Response) - : BluebirdPromise { - let authSession: AuthenticationSession | undefined; - return new BluebirdPromise(function (resolve, reject) { - authSession = AuthenticationSessionHandler.get(req, vars.logger); - resolve(); - }) - .then(setRedirectHeader(req, res)) - .then(verifyWithSelectedMethod(req, res, vars, authSession)) - .then(setUserAndGroupsHeaders(res)) - .then(replyWith200(res)) - // The user is authenticated but has restricted access -> 403 - .catch(Exceptions.NotAuthorizedError, - ErrorReplies.replyWithError403(req, res, vars.logger)) - .catch(Exceptions.NotAuthenticatedError, - ErrorReplies.replyWithError401(req, res, vars.logger)) - // The user is not yet authenticated -> 401 - .catch((err) => { - console.error(err); - // This redirect parameter is used in Kubernetes to annotate the ingress with - // the url to the authentication portal. - const redirectUrl = getRedirectParam(req); - if (redirectUrl) { - ErrorReplies.redirectTo(redirectUrl, req, res, vars.logger)(err); - } - else { - ErrorReplies.replyWithError401(req, res, vars.logger)(err); - } - }); - }; -} - diff --git a/server/src/lib/routes/verify/get_basic_auth.ts b/server/src/lib/routes/verify/get_basic_auth.ts deleted file mode 100644 index 73e81063..00000000 --- a/server/src/lib/routes/verify/get_basic_auth.ts +++ /dev/null @@ -1,54 +0,0 @@ -import Express = require("express"); -import BluebirdPromise = require("bluebird"); -import { ServerVariables } from "../../ServerVariables"; -import AccessControl from "./access_control"; -import { URLDecomposer } from "../../utils/URLDecomposer"; -import { Level } from "../../authentication/Level"; -import GetHeader from "../../utils/GetHeader"; -import { HEADER_X_ORIGINAL_URL } from "../../../../../shared/constants"; - -export default function (req: Express.Request, res: Express.Response, - vars: ServerVariables, authorizationHeader: string) - : BluebirdPromise<{ username: string, groups: string[] }> { - let username: string; - const uri = GetHeader(req, HEADER_X_ORIGINAL_URL); - const urlDecomposition = URLDecomposer.fromUrl(uri); - - return BluebirdPromise.resolve() - .then(() => { - const base64Re = new RegExp("^Basic ((?:[A-Za-z0-9+/]{4})*" + - "(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)$"); - const isTokenValidBase64 = base64Re.test(authorizationHeader); - - if (!isTokenValidBase64) { - return BluebirdPromise.reject(new Error("No valid base64 token found in the header")); - } - - const tokenMatches = authorizationHeader.match(base64Re); - const base64Token = tokenMatches[1]; - const decodedToken = Buffer.from(base64Token, "base64").toString(); - const splittedToken = decodedToken.split(":"); - - if (splittedToken.length != 2) { - return BluebirdPromise.reject(new Error( - "The authorization token is invalid. Expecting 'userid:password'")); - } - - username = splittedToken[0]; - const password = splittedToken[1]; - return vars.usersDatabase.checkUserPassword(username, password); - }) - .then(function (groupsAndEmails) { - return AccessControl(req, vars, urlDecomposition.domain, urlDecomposition.path, - username, groupsAndEmails.groups, Level.ONE_FACTOR) - .then(() => BluebirdPromise.resolve({ - username: username, - groups: groupsAndEmails.groups - })); - }) - .catch(function (err: Error) { - return BluebirdPromise.reject( - new Error("Unable to authenticate the user with basic auth. Cause: " - + err.message)); - }); -} \ No newline at end of file diff --git a/server/src/lib/routes/verify/get_session_cookie.ts b/server/src/lib/routes/verify/get_session_cookie.ts deleted file mode 100644 index d7bfc04d..00000000 --- a/server/src/lib/routes/verify/get_session_cookie.ts +++ /dev/null @@ -1,74 +0,0 @@ -import Express = require("express"); -import BluebirdPromise = require("bluebird"); -import Exceptions = require("../../Exceptions"); -import { Configuration } from "../../configuration/schema/Configuration"; -import { ServerVariables } from "../../ServerVariables"; -import { IRequestLogger } from "../../logging/IRequestLogger"; -import { AuthenticationSession } - from "../../../../types/AuthenticationSession"; -import { AuthenticationSessionHandler } - from "../../AuthenticationSessionHandler"; -import AccessControl from "./access_control"; -import { URLDecomposer } from "../../utils/URLDecomposer"; -import GetHeader from "../../utils/GetHeader"; -import { HEADER_X_ORIGINAL_URL } from "../../../../../shared/constants"; - -function verify_inactivity(req: Express.Request, - authSession: AuthenticationSession, - configuration: Configuration, logger: IRequestLogger) - : BluebirdPromise { - - // If inactivity is not specified, then inactivity timeout does not apply - if (!configuration.session.inactivity || authSession.keep_me_logged_in) { - return BluebirdPromise.resolve(); - } - - const lastActivityTime = authSession.last_activity_datetime; - const currentTime = new Date().getTime(); - authSession.last_activity_datetime = currentTime; - - const inactivityPeriodMs = currentTime - lastActivityTime; - logger.debug(req, "Inactivity period was %s s and max period was %s.", - inactivityPeriodMs / 1000, configuration.session.inactivity / 1000); - if (inactivityPeriodMs < configuration.session.inactivity) { - return BluebirdPromise.resolve(); - } - - logger.debug(req, "Session has been reset after too long inactivity period."); - AuthenticationSessionHandler.reset(req); - return BluebirdPromise.reject(new Error("Inactivity period exceeded.")); -} - -export default function (req: Express.Request, res: Express.Response, - vars: ServerVariables, authSession: AuthenticationSession) - : BluebirdPromise<{ username: string, groups: string[] }> { - - return BluebirdPromise.resolve() - .then(() => { - const username = authSession.userid; - const groups = authSession.groups; - - if (!authSession.userid) { - return BluebirdPromise.reject(new Exceptions.AccessDeniedError( - "userid is missing")); - } - - const originalUrl = GetHeader(req, HEADER_X_ORIGINAL_URL); - - const d = URLDecomposer.fromUrl(originalUrl); - vars.logger.debug(req, "domain=%s, path=%s, user=%s, groups=%s", d.domain, - d.path, username, groups.join(",")); - return AccessControl(req, vars, d.domain, d.path, username, groups, - authSession.authentication_level); - }) - .then(() => { - return verify_inactivity(req, authSession, - vars.config, vars.logger); - }) - .then(() => { - return BluebirdPromise.resolve({ - username: authSession.userid, - groups: authSession.groups - }); - }); -} \ No newline at end of file diff --git a/server/src/lib/utils/AssertRejects.ts b/server/src/lib/utils/AssertRejects.ts new file mode 100644 index 00000000..cca93b63 --- /dev/null +++ b/server/src/lib/utils/AssertRejects.ts @@ -0,0 +1,11 @@ + +export default function(fn: () => Promise, expectedErrorType: any = Error, logs = false): void { + fn().then(() => { + throw new Error("Should reject"); + }, (err: Error) => { + if (!(err instanceof expectedErrorType)) { + throw new Error(`Received error ${typeof err} != Expected error ${expectedErrorType}`); + } + if (logs) console.error(err); + }); +} \ No newline at end of file diff --git a/server/src/lib/utils/GetHeader.spec.ts b/server/src/lib/utils/GetHeader.spec.ts index d2543719..0377f173 100644 --- a/server/src/lib/utils/GetHeader.spec.ts +++ b/server/src/lib/utils/GetHeader.spec.ts @@ -3,7 +3,7 @@ import GetHeader from "./GetHeader"; import { RequestMock } from "../stubs/express.spec"; import * as Assert from "assert"; -describe('GetHeader', function() { +describe('utils/GetHeader', function() { let req: Express.Request; beforeEach(() => { req = RequestMock(); diff --git a/server/src/lib/utils/HasHeader.spec.ts b/server/src/lib/utils/HasHeader.spec.ts new file mode 100644 index 00000000..f435117d --- /dev/null +++ b/server/src/lib/utils/HasHeader.spec.ts @@ -0,0 +1,20 @@ +import * as Express from "express"; +import HasHeader from "./HasHeader"; +import { RequestMock } from "../stubs/express.spec"; +import * as Assert from "assert"; + +describe('utils/HasHeader', function() { + let req: Express.Request; + beforeEach(() => { + req = RequestMock(); + }); + + it('should return the header if it exists', function() { + req.headers["x-target-url"] = 'www.example.com'; + Assert(HasHeader(req, 'x-target-url')); + }); + + it('should return undefined if header does not exist', function() { + Assert(!HasHeader(req, 'x-target-url')); + }); +}); \ No newline at end of file diff --git a/server/src/lib/utils/HasHeader.ts b/server/src/lib/utils/HasHeader.ts new file mode 100644 index 00000000..74ce354d --- /dev/null +++ b/server/src/lib/utils/HasHeader.ts @@ -0,0 +1,12 @@ +import * as Express from "express"; +import * as ObjectPath from "object-path"; + +/** + * + * @param req The express request to extract headers from + * @param header The name of the header to check the existence of. + * @returns true if the header is found, otherwise false. + */ +export default function(req: Express.Request, header: string): boolean { + return ObjectPath.has(req, "headers." + header); +} \ No newline at end of file diff --git a/server/src/lib/web_server/RestApi.ts b/server/src/lib/web_server/RestApi.ts index 1a6ab725..467f40c5 100644 --- a/server/src/lib/web_server/RestApi.ts +++ b/server/src/lib/web_server/RestApi.ts @@ -3,7 +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 VerifyGet = require("../routes/verify/get"); +import VerifyGet = require("../routes/verify/Get"); import TOTPSignGet = require("../routes/secondfactor/totp/sign/post"); import IdentityCheckMiddleware = require("../IdentityCheckMiddleware"); diff --git a/shared/constants.ts b/shared/constants.ts index 8a1ca26d..72ce3511 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -8,4 +8,7 @@ export const HEADER_X_ORIGINAL_URL = "x-original-url"; export const HEADER_PROXY_AUTHORIZATION = "proxy-authorization"; export const HEADER_REDIRECT = "redirect"; -export const GET_VARIABLE_KEY = "variables"; \ No newline at end of file +export const GET_VARIABLE_KEY = "variables"; + +export const HEADER_REMOTE_USER = "Remote-User"; +export const HEADER_REMOTE_GROUPS = "Remote-Groups"; \ No newline at end of file diff --git a/test/suites/acl-full-bypass/README.md b/test/suites/acl-full-bypass/README.md new file mode 100644 index 00000000..22e53301 --- /dev/null +++ b/test/suites/acl-full-bypass/README.md @@ -0,0 +1,11 @@ +# ACL full bypass suite + +This suite has been created to test Authelia with a bypass policy on all resources + +## Components + +Authelia, nginx, fake webmail for registering devices. + +## Tests + +Check access to secret of multiple domains. \ No newline at end of file diff --git a/test/suites/acl-full-bypass/config.yml b/test/suites/acl-full-bypass/config.yml new file mode 100644 index 00000000..5adfe2f8 --- /dev/null +++ b/test/suites/acl-full-bypass/config.yml @@ -0,0 +1,37 @@ +############################################################### +# Authelia minimal configuration # +############################################################### + +port: 9091 + +logs_level: debug + +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 + +storage: + local: + path: /tmp/authelia/db + +access_control: + default_policy: bypass + rules: + - domain: 'public.example.com' + policy: bypass + +notifier: + smtp: + username: test + password: password + secure: false + host: 127.0.0.1 + port: 1025 + sender: admin@example.com + diff --git a/test/suites/acl-full-bypass/environment.ts b/test/suites/acl-full-bypass/environment.ts new file mode 100644 index 00000000..8fa6b2f2 --- /dev/null +++ b/test/suites/acl-full-bypass/environment.ts @@ -0,0 +1,36 @@ +import fs from 'fs'; +import { exec } from "../../helpers/utils/exec"; +import AutheliaServer from "../../helpers/context/AutheliaServer"; +import DockerEnvironment from "../../helpers/context/DockerEnvironment"; + +const autheliaServer = new AutheliaServer(__dirname + '/config.yml'); +const dockerEnv = new DockerEnvironment([ + 'docker-compose.yml', + 'example/compose/nginx/backend/docker-compose.yml', + 'example/compose/nginx/portal/docker-compose.yml', + 'example/compose/smtp/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 +}; \ No newline at end of file diff --git a/test/suites/acl-full-bypass/scenarii/BypassPolicy.ts b/test/suites/acl-full-bypass/scenarii/BypassPolicy.ts new file mode 100644 index 00000000..c556ffd0 --- /dev/null +++ b/test/suites/acl-full-bypass/scenarii/BypassPolicy.ts @@ -0,0 +1,23 @@ +import { StartDriver, StopDriver } from "../../../helpers/context/WithDriver"; +import VerifySecretObserved from "../../../helpers/assertions/VerifySecretObserved"; +import VisitPageAndWaitUrlIs from "../../../helpers/behaviors/VisitPageAndWaitUrlIs"; + +export default function() { + before(async function() { + this.driver = await StartDriver(); + }); + + after(async function () { + await StopDriver(this.driver); + }); + + it('should have access to admin.example.com/secret.html', async function () { + await VisitPageAndWaitUrlIs(this.driver, "https://admin.example.com:8080/secret.html"); + await VerifySecretObserved(this.driver); + }); + + it('should have access to public.example.com/secret.html', async function () { + await VisitPageAndWaitUrlIs(this.driver, "https://public.example.com:8080/secret.html"); + await VerifySecretObserved(this.driver); + }); +} \ No newline at end of file diff --git a/test/suites/acl-full-bypass/test.ts b/test/suites/acl-full-bypass/test.ts new file mode 100644 index 00000000..167a323b --- /dev/null +++ b/test/suites/acl-full-bypass/test.ts @@ -0,0 +1,13 @@ +import AutheliaSuite from "../../helpers/context/AutheliaSuite"; +import { exec } from '../../helpers/utils/exec'; +import BypassPolicy from "./scenarii/BypassPolicy"; + +AutheliaSuite(__dirname, function() { + this.timeout(10000); + + beforeEach(async function() { + await exec(`cp ${__dirname}/users_database.yml ${__dirname}/users_database.test.yml`); + }); + + describe('Bypass policy', BypassPolicy); +}); \ No newline at end of file diff --git a/test/suites/acl-full-bypass/users_database.yml b/test/suites/acl-full-bypass/users_database.yml new file mode 100644 index 00000000..7832e85b --- /dev/null +++ b/test/suites/acl-full-bypass/users_database.yml @@ -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/" + emails: 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 \ No newline at end of file diff --git a/test/suites/basic/config.yml b/test/suites/basic/config.yml index d1516034..9fcd9e81 100644 --- a/test/suites/basic/config.yml +++ b/test/suites/basic/config.yml @@ -42,6 +42,9 @@ access_control: - domain: singlefactor.example.com policy: one_factor + - domain: public.example.com + policy: bypass + - domain: '*.example.com' subject: "group:admins" policy: two_factor diff --git a/test/suites/basic/scenarii/BypassPolicy.ts b/test/suites/basic/scenarii/BypassPolicy.ts new file mode 100644 index 00000000..f63dbe75 --- /dev/null +++ b/test/suites/basic/scenarii/BypassPolicy.ts @@ -0,0 +1,18 @@ +import { StartDriver, StopDriver } from "../../../helpers/context/WithDriver"; +import VerifySecretObserved from "../../../helpers/assertions/VerifySecretObserved"; +import VisitPageAndWaitUrlIs from "../../../helpers/behaviors/VisitPageAndWaitUrlIs"; + +export default function() { + before(async function() { + this.driver = await StartDriver(); + }); + + after(async function () { + await StopDriver(this.driver); + }); + + it('should have access to public.example.com/secret.html', async function () { + await VisitPageAndWaitUrlIs(this.driver, "https://public.example.com:8080/secret.html"); + await VerifySecretObserved(this.driver); + }); +} \ No newline at end of file diff --git a/test/suites/basic/scenarii/RequiredTwoFactor.ts b/test/suites/basic/scenarii/RequiredTwoFactor.ts index 76b83f6f..a6f831dc 100644 --- a/test/suites/basic/scenarii/RequiredTwoFactor.ts +++ b/test/suites/basic/scenarii/RequiredTwoFactor.ts @@ -4,6 +4,7 @@ import { StartDriver, StopDriver } from '../../../helpers/context/WithDriver'; import VerifyIsSecondFactorStage from '../../../helpers/assertions/VerifyIsSecondFactorStage'; import VisitPage from '../../../helpers/VisitPage'; import FillLoginPageAndClick from '../../../helpers/FillLoginPageAndClick'; +import Logout from '../../../helpers/Logout'; export default function() { describe('User tries to access a page protected by second factor while he only passed first factor', function() { @@ -19,6 +20,7 @@ export default function() { }); after(async function() { + await Logout(this.driver); await StopDriver(this.driver); }); diff --git a/test/suites/basic/test.ts b/test/suites/basic/test.ts index e6f5c870..1ef52c98 100644 --- a/test/suites/basic/test.ts +++ b/test/suites/basic/test.ts @@ -9,6 +9,7 @@ import RequiredTwoFactor from './scenarii/RequiredTwoFactor'; import LogoutRedirectToAlreadyLoggedIn from './scenarii/LogoutRedirectToAlreadyLoggedIn'; import { exec } from '../../helpers/utils/exec'; import TwoFactorAuthentication from "../../helpers/scenarii/TwoFactorAuthentication"; +import BypassPolicy from "./scenarii/BypassPolicy"; AutheliaSuite(__dirname, function() { this.timeout(10000); @@ -18,6 +19,7 @@ AutheliaSuite(__dirname, function() { }); describe('Two-factor authentication', TwoFactorAuthentication()); + describe('Bypass policy', BypassPolicy) describe('Backend protection', BackendProtection); describe('Verify API endpoint', VerifyEndpoint); describe('Bad password', BadPassword); diff --git a/test/suites/short-timeouts/scenarii/Inactivity.ts b/test/suites/short-timeouts/scenarii/Inactivity.ts index a3c1c717..9a0f03a3 100644 --- a/test/suites/short-timeouts/scenarii/Inactivity.ts +++ b/test/suites/short-timeouts/scenarii/Inactivity.ts @@ -5,6 +5,7 @@ import VisitPageAndWaitUrlIs from "../../../helpers/behaviors/VisitPageAndWaitUr import VisitPage from "../../../helpers/VisitPage"; import VerifyUrlIs from "../../../helpers/assertions/VerifyUrlIs"; import { StartDriver, StopDriver } from "../../../helpers/context/WithDriver"; +import Logout from "../../../helpers/Logout"; export default function(this: Mocha.ISuiteCallbackContext) { this.timeout(20000); @@ -25,7 +26,7 @@ export default function(this: Mocha.ISuiteCallbackContext) { await VerifyUrlIs(this.driver, "https://admin.example.com:8080/secret.html"); await VisitPageAndWaitUrlIs(this.driver, "https://home.example.com:8080/"); await this.driver.sleep(6000); - await this.driver.get("https://admin.example.com:8080/secret.html"); + await VisitPage(this.driver, "https://admin.example.com:8080/secret.html"); await VerifyUrlIs(this.driver, "https://login.example.com:8080/#/?rd=https://admin.example.com:8080/secret.html"); }); @@ -34,15 +35,15 @@ export default function(this: Mocha.ISuiteCallbackContext) { await FillLoginPageWithUserAndPasswordAndClick(this.driver, 'john', 'password', false); await ValidateTotp(this.driver, this.secret); await VerifyUrlIs(this.driver, "https://admin.example.com:8080/secret.html"); - await VisitPageAndWaitUrlIs(this.driver, "https://home.example.com:8080/"); - - await this.driver.sleep(4000); - await this.driver.get("https://admin.example.com:8080/secret.html"); - await this.driver.sleep(2000); - await this.driver.get("https://admin.example.com:8080/secret.html"); await this.driver.sleep(2000); - await this.driver.get("https://admin.example.com:8080/secret.html"); + await VisitPageAndWaitUrlIs(this.driver, "https://admin.example.com:8080/secret.html"); + await this.driver.sleep(2000); + await VisitPageAndWaitUrlIs(this.driver, "https://admin.example.com:8080/secret.html"); + await this.driver.sleep(2000); + await VisitPageAndWaitUrlIs(this.driver, "https://admin.example.com:8080/secret.html"); + await this.driver.sleep(2000); + await VisitPage(this.driver, "https://admin.example.com:8080/secret.html"); await VerifyUrlIs(this.driver, "https://login.example.com:8080/#/?rd=https://admin.example.com:8080/secret.html"); }); @@ -53,7 +54,7 @@ export default function(this: Mocha.ISuiteCallbackContext) { await ValidateTotp(this.driver, this.secret); await VerifyUrlIs(this.driver, "https://admin.example.com:8080/secret.html"); await VisitPageAndWaitUrlIs(this.driver, "https://home.example.com:8080/"); - await this.driver.sleep(6000); + await this.driver.sleep(9000); await VisitPage(this.driver, "https://admin.example.com:8080/secret.html"); await VerifyUrlIs(this.driver, "https://admin.example.com:8080/secret.html"); });