From 009e7c2b78977be428072d4bc2037647ef36d4e0 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Wed, 1 Nov 2017 19:23:45 +0100 Subject: [PATCH] Add basic authorization support for single-factor protected endpoints One can now access a service using the basic authorization mechanism. Note the service must not be protected by 2 factors. The Remote-User and Remote-Groups are forwarded from Authelia like any browser authentication. --- README.md | 20 +- example/nginx/nginx.conf | 20 +- .../src/lib/routes/verify/access_control.ts | 22 + server/src/lib/routes/verify/get.ts | 139 +++---- .../src/lib/routes/verify/get_basic_auth.ts | 75 ++++ .../lib/routes/verify/get_session_cookie.ts | 102 +++++ server/test/routes/verify/get.test.ts | 378 +++++++++++------- test/features/forward-headers.feature | 5 + test/features/single-factor-domain.feature | 6 +- ...ture => single-factor-only-server.feature} | 0 .../step_definitions/forward-headers.ts | 2 +- .../step_definitions/single-factor.ts | 39 ++ 12 files changed, 560 insertions(+), 248 deletions(-) create mode 100644 server/src/lib/routes/verify/access_control.ts create mode 100644 server/src/lib/routes/verify/get_basic_auth.ts create mode 100644 server/src/lib/routes/verify/get_session_cookie.ts rename test/features/{single-factor-server.feature => single-factor-only-server.feature} (100%) create mode 100644 test/features/step_definitions/single-factor.ts diff --git a/README.md b/README.md index 3b6002d0b..fa7035b5f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ used in production to secure internal services in a small docker swarm cluster. 3. [Second factor with U2F security keys](#second-factor-with-u2f-security-keys) 4. [Password reset](#password-reset) 5. [Access control](#access-control) - 6. [Basic authentication](#basic-authentication) + 6. [Single factor authentication](#single-factor-authentication) 7. [Session management with Redis](#session-management-with-redis) 4. [Security](#security) 5. [Documentation](#documentation) @@ -37,12 +37,12 @@ used in production to secure internal services in a small docker swarm cluster. * Two-factor authentication using either **[TOTP] - Time-Base One Time password -** or **[U2F] - Universal 2-Factor -** as 2nd factor. -* Password reset with identity verification by sending links to user email -address. -* Two-factor and basic authentication methods available. +* Password reset with identity verification using email. +* Single and two factors authentication methods available. * Access restriction after too many authentication attempts. -* Session management using Redis key/value store. * User-defined access control per subdomain and resource. +* Support of [basic authentication] for endpoints protected by single factor. +* High-availability using a highly-available distributed database and KV store. ## Deployment @@ -190,11 +190,14 @@ user access to some resources and subdomains. Those rules are defined and fully in the configuration file. They can apply to users, groups or everyone. Check out [config.template.yml] to see how they are defined. -### Basic Authentication -Authelia allows you to customize the authentication method to use for each sub-domain. -The supported methods are either "single_factor" and "two_factor". +### Single factor authentication +Authelia allows you to customize the authentication method to use for each +sub-domain.The supported methods are either "single_factor" or "two_factor". Please see [config.template.yml] to see an example of configuration. +It is also possible to use [basic authentication] to access a resource +protected by a single factor. + ### Session management with Redis When your users authenticate against Authelia, sessions are stored in a Redis key/value store. You can specify your own Redis instance in [config.template.yml]. @@ -293,3 +296,4 @@ Follow [contributing](CONTRIBUTORS.md) file. [Google Authenticator]: https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en [config.template.yml]: https://github.com/clems4ever/authelia/blob/master/config.template.yml [HSTS]: https://www.nginx.com/blog/http-strict-transport-security-hsts-and-nginx/ +[basic authentication]: https://en.wikipedia.org/wiki/Basic_access_authentication diff --git a/example/nginx/nginx.conf b/example/nginx/nginx.conf index 685138bb5..ed82f7723 100644 --- a/example/nginx/nginx.conf +++ b/example/nginx/nginx.conf @@ -119,7 +119,7 @@ http { error_page 401 =302 https://auth.test.local:8080?redirect=$redirect; error_page 403 = https://auth.test.local:8080/error/403; - } + } } server { @@ -262,6 +262,7 @@ http { proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_set_header Content-Length ""; + proxy_set_header Proxy-Authorization $http_authorization; proxy_pass http://authelia/api/verify; } @@ -280,6 +281,23 @@ http { error_page 401 =302 https://auth.test.local:8080?redirect=$redirect; error_page 403 = https://auth.test.local:8080/error/403; } + + location /headers { + auth_request /auth_verify; + + auth_request_set $redirect $upstream_http_redirect; + + auth_request_set $user $upstream_http_remote_user; + proxy_set_header Custom-Forwarded-User $user; + + auth_request_set $groups $upstream_http_remote_groups; + proxy_set_header Custom-Forwarded-Groups $groups; + + proxy_pass http://httpbin:8000/headers; + + error_page 401 =302 https://auth.test.local:8080?redirect=$redirect; + error_page 403 = https://auth.test.local:8080/error/403; + } } } diff --git a/server/src/lib/routes/verify/access_control.ts b/server/src/lib/routes/verify/access_control.ts new file mode 100644 index 000000000..99a7f807d --- /dev/null +++ b/server/src/lib/routes/verify/access_control.ts @@ -0,0 +1,22 @@ +import Express = require("express"); +import BluebirdPromise = require("bluebird"); +import Util = require("util"); + +import { ServerVariables } from "../../ServerVariables"; +import Exceptions = require("../../Exceptions"); + +export default function (req: Express.Request, vars: ServerVariables, + domain: string, path: string, username: string, groups: string[]) { + + return new BluebirdPromise(function (resolve, reject) { + const isAllowed = vars.accessController + .isAccessAllowed(domain, path, username, groups); + + if (!isAllowed) { + reject(new Exceptions.DomainAccessDenied(Util.format( + "User '%s' does not have access to '%s'", username, domain))); + return; + } + resolve(); + }); +} \ No newline at end of file diff --git a/server/src/lib/routes/verify/get.ts b/server/src/lib/routes/verify/get.ts index cec6e157c..54f0c2ec5 100644 --- a/server/src/lib/routes/verify/get.ts +++ b/server/src/lib/routes/verify/get.ts @@ -1,118 +1,69 @@ - -import objectPath = require("object-path"); import BluebirdPromise = require("bluebird"); -import express = require("express"); -import exceptions = require("../../Exceptions"); -import winston = require("winston"); +import Express = require("express"); +import Exceptions = require("../../Exceptions"); import ErrorReplies = require("../../ErrorReplies"); -import { AppConfiguration } from "../../configuration/Configuration"; -import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; -import { AuthenticationSession } from "../../../../types/AuthenticationSession"; -import Constants = require("../../../../../shared/constants"); -import Util = require("util"); -import { DomainExtractor } from "../../utils/DomainExtractor"; import { ServerVariables } from "../../ServerVariables"; -import { MethodCalculator } from "../../authentication/MethodCalculator"; -import { IRequestLogger } from "../../logging/IRequestLogger"; +import GetWithSessionCookieMethod from "./get_session_cookie"; +import GetWithBasicAuthMethod from "./get_basic_auth"; -const FIRST_FACTOR_NOT_VALIDATED_MESSAGE = "First factor not yet validated"; -const SECOND_FACTOR_NOT_VALIDATED_MESSAGE = "Second factor not yet validated"; +import { AuthenticationSessionHandler } + from "../../AuthenticationSessionHandler"; +import { AuthenticationSession } + from "../../../../types/AuthenticationSession"; const REMOTE_USER = "Remote-User"; const REMOTE_GROUPS = "Remote-Groups"; -function verify_inactivity(req: express.Request, - authSession: AuthenticationSession, - configuration: AppConfiguration, logger: IRequestLogger) - : BluebirdPromise { - const lastActivityTime = authSession.last_activity_datetime; - const currentTime = new Date().getTime(); - authSession.last_activity_datetime = currentTime; +function verifyWithSelectedMethod(req: Express.Request, res: Express.Response, + vars: ServerVariables, authSession: AuthenticationSession) + : () => BluebirdPromise<{ username: string, groups: string[] }> { + return function () { + const authorization: string = "" + req.headers["proxy-authorization"]; + if (authorization && authorization.startsWith("Basic ")) + return GetWithBasicAuthMethod(req, res, vars, authorization); - // If inactivity is not specified, then inactivity timeout does not apply - if (!configuration.session.inactivity) { - return BluebirdPromise.resolve(); - } - - 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.")); + return GetWithSessionCookieMethod(req, res, vars, authSession); + }; } -function verify_filter(req: express.Request, res: express.Response, - vars: ServerVariables): BluebirdPromise { - let authSession: AuthenticationSession; - let username: string; - let groups: string[]; - - return new BluebirdPromise(function (resolve, reject) { - authSession = AuthenticationSessionHandler.get(req, vars.logger); - username = authSession.userid; - groups = authSession.groups; - +function setRedirectHeader(req: Express.Request, res: Express.Response) { + return function () { res.set("Redirect", encodeURIComponent("https://" + req.headers["host"] + req.headers["x-original-uri"])); + return BluebirdPromise.resolve(); + }; +} - if (!authSession.userid) { - reject(new exceptions.AccessDeniedError( - Util.format("%s: %s.", FIRST_FACTOR_NOT_VALIDATED_MESSAGE, "userid is missing"))); - return; - } +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(); + }; +} - const host = objectPath.get(req, "headers.host"); - const path = objectPath.get(req, "headers.x-original-uri"); - - const domain = DomainExtractor.fromHostHeader(host); - const authenticationMethod = - MethodCalculator.compute(vars.config.authentication_methods, domain); - vars.logger.debug(req, "domain=%s, path=%s, user=%s, groups=%s", domain, path, - username, groups.join(",")); - - if (!authSession.first_factor) - return reject(new exceptions.AccessDeniedError( - Util.format("%s: %s.", FIRST_FACTOR_NOT_VALIDATED_MESSAGE, "first factor is false"))); - - if (authenticationMethod == "two_factor" && !authSession.second_factor) - return reject(new exceptions.AccessDeniedError( - Util.format("%s: %s.", SECOND_FACTOR_NOT_VALIDATED_MESSAGE, "second factor is false"))); - - const isAllowed = vars.accessController.isAccessAllowed(domain, path, username, groups); - if (!isAllowed) return reject( - new exceptions.DomainAccessDenied(Util.format("User '%s' does not have access to '%s'", - username, domain))); - - resolve(); - }) - .then(function () { - return verify_inactivity(req, authSession, - vars.config, vars.logger); - }) - .then(function () { - res.setHeader(REMOTE_USER, username); - res.setHeader(REMOTE_GROUPS, groups.join(",")); - return BluebirdPromise.resolve(); - }); +function replyWith200(res: Express.Response) { + return function () { + res.status(204); + res.send(); + }; } export default function (vars: ServerVariables) { - return function (req: express.Request, res: express.Response) + return function (req: Express.Request, res: Express.Response) : BluebirdPromise { - return verify_filter(req, res, vars) - .then(function () { - res.status(204); - res.send(); - return BluebirdPromise.resolve(); - }) + let authSession: AuthenticationSession; + 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.DomainAccessDenied, ErrorReplies + .catch(Exceptions.DomainAccessDenied, ErrorReplies .replyWithError403(req, res, vars.logger)) // The user is not yet authenticated -> 401 .catch(ErrorReplies.replyWithError401(req, res, vars.logger)); diff --git a/server/src/lib/routes/verify/get_basic_auth.ts b/server/src/lib/routes/verify/get_basic_auth.ts new file mode 100644 index 000000000..6d8dda8cb --- /dev/null +++ b/server/src/lib/routes/verify/get_basic_auth.ts @@ -0,0 +1,75 @@ +import Express = require("express"); +import BluebirdPromise = require("bluebird"); +import ObjectPath = require("object-path"); +import { ServerVariables } from "../../ServerVariables"; +import { AuthenticationSession } + from "../../../../types/AuthenticationSession"; +import { DomainExtractor } from "../../utils/DomainExtractor"; +import { MethodCalculator } from "../../authentication/MethodCalculator"; +import AccessControl from "./access_control"; + +export default function (req: Express.Request, res: Express.Response, + vars: ServerVariables, authorizationHeader: string) + : BluebirdPromise<{ username: string, groups: string[] }> { + let username: string; + let groups: string[]; + let domain: string; + let path: string; + + return new BluebirdPromise<[string, string]>(function (resolve, reject) { + const host = ObjectPath.get(req, "headers.host"); + domain = DomainExtractor.fromHostHeader(host); + path = + ObjectPath.get(req, "headers.x-original-uri"); + const authenticationMethod = + MethodCalculator.compute(vars.config.authentication_methods, domain); + + if (authenticationMethod != "single_factor") { + reject(new Error("This domain is not protected with single factor. " + + "You cannot log in with basic authentication.")); + return; + } + + 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) { + reject(new Error("No valid base64 token found in the header")); + return; + } + + const tokenMatches = authorizationHeader.match(base64Re); + const base64Token = tokenMatches[1]; + const decodedToken = Buffer.from(base64Token, "base64").toString(); + const splittedToken = decodedToken.split(":"); + + if (splittedToken.length != 2) { + reject(new Error( + "The authorization token is invalid. Expecting 'userid:password'")); + return; + } + + username = splittedToken[0]; + const password = splittedToken[1]; + resolve([username, password]); + }) + .then(function ([userid, password]) { + return vars.ldapAuthenticator.authenticate(userid, password); + }) + .then(function (groupsAndEmails) { + groups = groupsAndEmails.groups; + return AccessControl(req, vars, domain, path, username, groups); + }) + .then(function () { + return BluebirdPromise.resolve({ + username: username, + groups: 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 new file mode 100644 index 000000000..e96374c65 --- /dev/null +++ b/server/src/lib/routes/verify/get_session_cookie.ts @@ -0,0 +1,102 @@ +import Express = require("express"); +import BluebirdPromise = require("bluebird"); +import Util = require("util"); +import ObjectPath = require("object-path"); + +import Exceptions = require("../../Exceptions"); +import { AppConfiguration } from "../../configuration/Configuration"; +import Constants = require("../../../../../shared/constants"); +import { DomainExtractor } from "../../utils/DomainExtractor"; +import { ServerVariables } from "../../ServerVariables"; +import { MethodCalculator } from "../../authentication/MethodCalculator"; +import { IRequestLogger } from "../../logging/IRequestLogger"; +import { AuthenticationSession } + from "../../../../types/AuthenticationSession"; +import { AuthenticationSessionHandler } + from "../../AuthenticationSessionHandler"; +import AccessControl from "./access_control"; + +const FIRST_FACTOR_NOT_VALIDATED_MESSAGE = "First factor not yet validated"; +const SECOND_FACTOR_NOT_VALIDATED_MESSAGE = "Second factor not yet validated"; + +function verify_inactivity(req: Express.Request, + authSession: AuthenticationSession, + configuration: AppConfiguration, logger: IRequestLogger) + : BluebirdPromise { + + // If inactivity is not specified, then inactivity timeout does not apply + if (!configuration.session.inactivity) { + 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[] }> { + let username: string; + let groups: string[]; + let domain: string; + let path: string; + + return new BluebirdPromise(function (resolve, reject) { + username = authSession.userid; + groups = authSession.groups; + + if (!authSession.userid) { + reject(new Exceptions.AccessDeniedError( + Util.format("%s: %s.", FIRST_FACTOR_NOT_VALIDATED_MESSAGE, + "userid is missing"))); + return; + } + + const host = ObjectPath.get(req, "headers.host"); + path = + ObjectPath.get(req, "headers.x-original-uri"); + + domain = DomainExtractor.fromHostHeader(host); + const authenticationMethod = + MethodCalculator.compute(vars.config.authentication_methods, domain); + vars.logger.debug(req, "domain=%s, path=%s, user=%s, groups=%s", domain, + path, username, groups.join(",")); + + if (!authSession.first_factor) + return reject(new Exceptions.AccessDeniedError( + Util.format("%s: %s.", FIRST_FACTOR_NOT_VALIDATED_MESSAGE, + "first factor is false"))); + + if (authenticationMethod == "two_factor" && !authSession.second_factor) + return reject(new Exceptions.AccessDeniedError( + Util.format("%s: %s.", SECOND_FACTOR_NOT_VALIDATED_MESSAGE, + "second factor is false"))); + + resolve(); + }) + .then(function () { + return AccessControl(req, vars, domain, path, username, groups); + }) + .then(function () { + return verify_inactivity(req, authSession, + vars.config, vars.logger); + }) + .then(function () { + return BluebirdPromise.resolve({ + username: authSession.userid, + groups: authSession.groups + }); + }); +} \ No newline at end of file diff --git a/server/test/routes/verify/get.test.ts b/server/test/routes/verify/get.test.ts index b4684e30e..099e763b6 100644 --- a/server/test/routes/verify/get.test.ts +++ b/server/test/routes/verify/get.test.ts @@ -28,190 +28,284 @@ describe("test /api/verify endpoint", function () { }; AuthenticationSessionHandler.reset(req as any); req.headers.host = "secret.example.com"; - const s = ServerVariablesMockBuilder.build(); + const s = ServerVariablesMockBuilder.build(true); mocks = s.mocks; vars = s.variables; - vars.config.authentication_methods.default_method = "two_factor"; - authSession = AuthenticationSessionHandler.get(req as any, vars.logger); }); - it("should be already authenticated", function () { - mocks.accessController.isAccessAllowedMock.returns(true); - authSession.first_factor = true; - authSession.second_factor = true; - authSession.userid = "myuser"; - authSession.groups = ["mygroup", "othergroup"]; - return VerifyGet.default(vars)(req as express.Request, res as any) - .then(function () { - Sinon.assert.calledWithExactly(res.setHeader, "Remote-User", "myuser"); - Sinon.assert.calledWithExactly(res.setHeader, "Remote-Groups", "mygroup,othergroup"); - Assert.equal(204, res.status.getCall(0).args[0]); - }); - }); - - function test_session(_authSession: AuthenticationSession, status_code: number) { - return VerifyGet.default(vars)(req as express.Request, res as any) - .then(function () { - Assert.equal(status_code, res.status.getCall(0).args[0]); - }); - } - - function test_non_authenticated_401(authSession: AuthenticationSession) { - return test_session(authSession, 401); - } - - function test_unauthorized_403(authSession: AuthenticationSession) { - return test_session(authSession, 403); - } - - function test_authorized(authSession: AuthenticationSession) { - return test_session(authSession, 204); - } - - describe("given user tries to access a 2-factor endpoint", function () { - before(function () { - mocks.accessController.isAccessAllowedMock.returns(true); + describe("with session cookie", function () { + beforeEach(function () { + vars.config.authentication_methods.default_method = "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({ - userid: "user", - first_factor: true, - second_factor: false, - email: undefined, - groups: [], - last_activity_datetime: new Date().getTime() + it("should be already authenticated", function () { + mocks.accessController.isAccessAllowedMock.returns(true); + authSession.first_factor = true; + authSession.second_factor = true; + authSession.userid = "myuser"; + authSession.groups = ["mygroup", "othergroup"]; + return VerifyGet.default(vars)(req as express.Request, res as any) + .then(function () { + Sinon.assert.calledWithExactly(res.setHeader, "Remote-User", "myuser"); + Sinon.assert.calledWithExactly(res.setHeader, "Remote-Groups", "mygroup,othergroup"); + Assert.equal(204, res.status.getCall(0).args[0]); + }); + }); + + function test_session(_authSession: AuthenticationSession, status_code: number) { + return VerifyGet.default(vars)(req as express.Request, res as any) + .then(function () { + Assert.equal(status_code, res.status.getCall(0).args[0]); + }); + } + + function test_non_authenticated_401(authSession: AuthenticationSession) { + return test_session(authSession, 401); + } + + function test_unauthorized_403(authSession: AuthenticationSession) { + return test_session(authSession, 403); + } + + function test_authorized(authSession: AuthenticationSession) { + return test_session(authSession, 204); + } + + describe("given user tries to access a 2-factor endpoint", function () { + before(function () { + mocks.accessController.isAccessAllowedMock.returns(true); + }); + + describe("given different cases of session", function () { + it("should not be authenticated when second factor is missing", function () { + return test_non_authenticated_401({ + userid: "user", + first_factor: true, + second_factor: false, + email: undefined, + groups: [], + last_activity_datetime: new Date().getTime() + }); + }); + + it("should not be authenticated when first factor is missing", function () { + return test_non_authenticated_401({ + userid: "user", + first_factor: false, + second_factor: true, + email: undefined, + groups: [], + last_activity_datetime: new Date().getTime() + }); + }); + + it("should not be authenticated when userid is missing", function () { + return test_non_authenticated_401({ + userid: undefined, + first_factor: true, + second_factor: false, + email: undefined, + groups: [], + last_activity_datetime: new Date().getTime() + }); + }); + + it("should not be authenticated when first and second factor are missing", function () { + return test_non_authenticated_401({ + userid: "user", + first_factor: false, + second_factor: false, + 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.first_factor = true; + authSession.second_factor = true; + authSession.userid = "myuser"; + req.headers.host = "test.example.com"; + mocks.accessController.isAccessAllowedMock.returns(false); + + return test_unauthorized_403({ + first_factor: true, + second_factor: true, + userid: "user", + groups: ["group1", "group2"], + email: undefined, + last_activity_datetime: new Date().getTime() + }); }); }); + }); - it("should not be authenticated when first factor is missing", function () { - return test_non_authenticated_401({ - userid: "user", - first_factor: false, - second_factor: true, - email: undefined, - groups: [], - last_activity_datetime: new Date().getTime() - }); + describe("given user tries to access a single factor endpoint", function () { + beforeEach(function () { + req.query = { + redirect: "http://redirect.url" + }; + req.headers["host"] = "redirect.url"; + mocks.config.authentication_methods.per_subdomain_methods = { + "redirect.url": "single_factor" + }; }); - it("should not be authenticated when userid is missing", function () { - return test_non_authenticated_401({ - userid: undefined, - first_factor: true, - second_factor: false, - email: undefined, - groups: [], - last_activity_datetime: new Date().getTime() - }); + it("should be authenticated when first factor is validated and second factor is not", function () { + mocks.accessController.isAccessAllowedMock.returns(true); + authSession.first_factor = true; + 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 not be authenticated when first and second factor are missing", function () { - return test_non_authenticated_401({ - userid: "user", - first_factor: false, - second_factor: false, - email: undefined, - groups: [], - last_activity_datetime: new Date().getTime() - }); + it("should be rejected with 401 when first factor is not validated", function () { + mocks.accessController.isAccessAllowedMock.returns(true); + authSession.first_factor = false; + return VerifyGet.default(vars)(req as express.Request, res as any) + .then(function () { + Assert(res.status.calledWith(401)); + }); }); + }); - 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 () { + describe("inactivity period", function () { + it("should update last inactivity period on requests on /api/verify", function () { + mocks.config.session.inactivity = 200000; + mocks.accessController.isAccessAllowedMock.returns(true); + const currentTime = new Date().getTime() - 1000; + AuthenticationSessionHandler.reset(req as any); authSession.first_factor = true; authSession.second_factor = true; authSession.userid = "myuser"; - req.headers.host = "test.example.com"; - mocks.accessController.isAccessAllowedMock.returns(false); + 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); + }); + }); - return test_unauthorized_403({ - first_factor: true, - second_factor: true, - userid: "user", - groups: ["group1", "group2"], - email: undefined, - last_activity_datetime: new Date().getTime() - }); + it("should reset session when max inactivity period has been reached", function () { + mocks.config.session.inactivity = 1; + mocks.accessController.isAccessAllowedMock.returns(true); + const currentTime = new Date().getTime() - 1000; + AuthenticationSessionHandler.reset(req as any); + authSession.first_factor = true; + authSession.second_factor = true; + 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.first_factor, false); + Assert.equal(authSession.second_factor, false); + Assert.equal(authSession.userid, undefined); + }); }); }); }); - describe("given user tries to access a basic auth endpoint", function () { - beforeEach(function () { - req.query = { - redirect: "http://redirect.url" - }; - req.headers["host"] = "redirect.url"; + describe("with basic auth", function () { + it("should authenticate correctly", function () { + mocks.accessController.isAccessAllowedMock.returns(true); + mocks.config.authentication_methods.default_method = "single_factor"; + mocks.ldapAuthenticator.authenticateStub.returns({ + groups: ["mygroup", "othergroup"], + }); + req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; + + return VerifyGet.default(vars)(req as express.Request, res as any) + .then(function () { + Sinon.assert.calledWithExactly(res.setHeader, "Remote-User", "john"); + Sinon.assert.calledWithExactly(res.setHeader, "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.accessController.isAccessAllowedMock.returns(true); + mocks.config.authentication_methods.default_method = "single_factor"; mocks.config.authentication_methods.per_subdomain_methods = { - "redirect.url": "single_factor" + "secret.example.com": "two_factor" }; - }); + mocks.ldapAuthenticator.authenticateStub.resolves({ + groups: ["mygroup", "othergroup"], + }); + req.headers["proxy-authorization"] = "Basic am9objpwYXNzd29yZA=="; - it("should be authenticated when first factor is validated and second factor is not", function () { - mocks.accessController.isAccessAllowedMock.returns(true); - authSession.first_factor = true; - 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); + Assert(res.status.calledWithExactly(401)); }); }); - it("should be rejected with 401 when first factor is not validated", function () { + it("should fail when base64 token is not valid", function () { mocks.accessController.isAccessAllowedMock.returns(true); - authSession.first_factor = false; - return VerifyGet.default(vars)(req as express.Request, res as any) - .then(function () { - Assert(res.status.calledWith(401)); - }); - }); - }); + mocks.config.authentication_methods.default_method = "single_factor"; + mocks.ldapAuthenticator.authenticateStub.resolves({ + groups: ["mygroup", "othergroup"], + }); + req.headers["proxy-authorization"] = "Basic i_m*not_a_base64*token"; - describe("inactivity period", function () { - it("should update last inactivity period on requests on /api/verify", function () { - mocks.config.session.inactivity = 200000; - mocks.accessController.isAccessAllowedMock.returns(true); - const currentTime = new Date().getTime() - 1000; - AuthenticationSessionHandler.reset(req as any); - authSession.first_factor = true; - authSession.second_factor = true; - 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); + Assert(res.status.calledWithExactly(401)); }); }); - it("should reset session when max inactivity period has been reached", function () { - mocks.config.session.inactivity = 1; + it("should fail when base64 token has not format user:psswd", function () { mocks.accessController.isAccessAllowedMock.returns(true); - const currentTime = new Date().getTime() - 1000; - AuthenticationSessionHandler.reset(req as any); - authSession.first_factor = true; - authSession.second_factor = true; - authSession.userid = "myuser"; - authSession.groups = ["mygroup", "othergroup"]; - authSession.last_activity_datetime = currentTime; + mocks.config.authentication_methods.default_method = "single_factor"; + mocks.ldapAuthenticator.authenticateStub.resolves({ + groups: ["mygroup", "othergroup"], + }); + req.headers["proxy-authorization"] = "Basic am9objpwYXNzOmJhZA=="; + 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.first_factor, false); - Assert.equal(authSession.second_factor, false); - Assert.equal(authSession.userid, undefined); + Assert(res.status.calledWithExactly(401)); + }); + }); + + it("should fail when bad user password is provided", function () { + mocks.accessController.isAccessAllowedMock.returns(true); + mocks.config.authentication_methods.default_method = "single_factor"; + mocks.ldapAuthenticator.authenticateStub.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.accessController.isAccessAllowedMock.returns(false); + mocks.config.authentication_methods.default_method = "single_factor"; + mocks.ldapAuthenticator.authenticateStub.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/test/features/forward-headers.feature b/test/features/forward-headers.feature index 3b3635269..a55a81cbc 100644 --- a/test/features/forward-headers.feature +++ b/test/features/forward-headers.feature @@ -4,3 +4,8 @@ Feature: User and groups headers are correctly forwarded to backend When I visit "https://public.test.local:8080/headers" Then I see header "Custom-Forwarded-User" set to "john" Then I see header "Custom-Forwarded-Groups" set to "dev,admin" + + Scenario: Custom-Forwarded-User and Custom-Forwarded-Groups are correctly forwarded to protected backend when basic auth is used + When I request "https://single_factor.test.local:8080/headers" with username "john" and password "password" using basic authentication + Then I received header "Custom-Forwarded-User" set to "john" + And I received header "Custom-Forwarded-Groups" set to "dev,admin" \ No newline at end of file diff --git a/test/features/single-factor-domain.feature b/test/features/single-factor-domain.feature index cb1c7e04f..f15d9c4cd 100644 --- a/test/features/single-factor-domain.feature +++ b/test/features/single-factor-domain.feature @@ -1,13 +1,15 @@ Feature: User can access certain subdomains with single factor - @need-registered-user-john Scenario: User is redirected to service after first factor if allowed When I visit "https://auth.test.local:8080/?redirect=https%3A%2F%2Fsingle_factor.test.local%3A8080%2Fsecret.html" And I login with user "john" and password "password" Then I'm redirected to "https://single_factor.test.local:8080/secret.html" - @need-registered-user-john Scenario: Redirection after first factor fails if single_factor not allowed. It redirects user to first factor. When I visit "https://auth.test.local:8080/?redirect=https%3A%2F%2Fadmin.test.local%3A8080%2Fsecret.html" And I login with user "john" and password "password" Then I'm redirected to "https://auth.test.local:8080/?redirect=https%3A%2F%2Fadmin.test.local%3A8080%2Fsecret.html" + + Scenario: User can login using basic authentication + When I request "https://single_factor.test.local:8080/secret.html" with username "john" and password "password" using basic authentication + Then I receive the secret page \ No newline at end of file diff --git a/test/features/single-factor-server.feature b/test/features/single-factor-only-server.feature similarity index 100% rename from test/features/single-factor-server.feature rename to test/features/single-factor-only-server.feature diff --git a/test/features/step_definitions/forward-headers.ts b/test/features/step_definitions/forward-headers.ts index 2e61771cc..cc53683a2 100644 --- a/test/features/step_definitions/forward-headers.ts +++ b/test/features/step_definitions/forward-headers.ts @@ -10,7 +10,7 @@ Cucumber.defineSupportCode(function ({ Given, When, Then }) { function (expectedHeaderName: string, expectedValue: string) { return this.driver.findElement(seleniumWebdriver.By.tagName("body")).getText() .then(function (txt: string) { - const expectedLine = Util.format("\"%s\": \"%s\"", expectedHeaderName, expectedValue); + const expectedLine = Util.format("\"%s\": \"%s\"", expectedHeaderName, expectedValue); if (txt.indexOf(expectedLine) > 0) return BluebirdPromise.resolve(); else diff --git a/test/features/step_definitions/single-factor.ts b/test/features/step_definitions/single-factor.ts new file mode 100644 index 000000000..cde0dd90d --- /dev/null +++ b/test/features/step_definitions/single-factor.ts @@ -0,0 +1,39 @@ +import Cucumber = require("cucumber"); +import seleniumWebdriver = require("selenium-webdriver"); +import Request = require("request-promise"); +import BluebirdPromise = require("bluebird"); +import Util = require("util"); + +Cucumber.defineSupportCode(function ({ Given, When, Then }) { + When("I request {stringInDoubleQuotes} with username {stringInDoubleQuotes}" + + " and password {stringInDoubleQuotes} using basic authentication", + function (url: string, username: string, password: string) { + const that = this; + return Request(url, { + auth: { + username: username, + password: password + }, + resolveWithFullResponse: true + }) + .then(function (response: any) { + that.response = response; + }); + }); + + Then("I receive the secret page", function () { + if (this.response.body.match("This is a very important secret!")) + return BluebirdPromise.resolve(); + return BluebirdPromise.reject(new Error("Secret page not received.")); + }); + + Then("I received header {stringInDoubleQuotes} set to {stringInDoubleQuotes}", + function (expectedHeaderName: string, expectedValue: string) { + const expectedLine = Util.format("\"%s\": \"%s\"", expectedHeaderName, + expectedValue); + if (this.response.body.indexOf(expectedLine) > 0) + return BluebirdPromise.resolve(); + return BluebirdPromise.reject(new Error( + Util.format("No such header or with unexpected value."))); + }) +}); \ No newline at end of file