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.
This commit is contained in:
Clement Michaud 2017-11-01 19:23:45 +01:00
parent e3e1235755
commit 009e7c2b78
12 changed files with 560 additions and 248 deletions

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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();
});
}

View File

@ -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<void> {
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();
return GetWithSessionCookieMethod(req, res, vars, authSession);
};
}
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."));
}
function verify_filter(req: express.Request, res: express.Response,
vars: ServerVariables): BluebirdPromise<void> {
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"]));
if (!authSession.userid) {
reject(new exceptions.AccessDeniedError(
Util.format("%s: %s.", FIRST_FACTOR_NOT_VALIDATED_MESSAGE, "userid is missing")));
return;
return BluebirdPromise.resolve();
};
}
const host = objectPath.get<express.Request, string>(req, "headers.host");
const path = objectPath.get<express.Request, string>(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(","));
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();
};
}
export default function (vars: ServerVariables) {
return function (req: express.Request, res: express.Response)
return function (req: Express.Request, res: Express.Response)
: BluebirdPromise<void> {
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));

View File

@ -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<Express.Request, string>(req, "headers.host");
domain = DomainExtractor.fromHostHeader(host);
path =
ObjectPath.get<Express.Request, string>(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));
});
}

View File

@ -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<void> {
// 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<Express.Request, string>(req, "headers.host");
path =
ObjectPath.get<Express.Request, string>(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
});
});
}

View File

@ -28,14 +28,17 @@ 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);
});
describe("with session cookie", function () {
beforeEach(function () {
vars.config.authentication_methods.default_method = "two_factor";
});
it("should be already authenticated", function () {
mocks.accessController.isAccessAllowedMock.returns(true);
authSession.first_factor = true;
@ -142,7 +145,7 @@ describe("test /api/verify endpoint", function () {
});
});
describe("given user tries to access a basic auth endpoint", function () {
describe("given user tries to access a single factor endpoint", function () {
beforeEach(function () {
req.query = {
redirect: "http://redirect.url"
@ -217,3 +220,94 @@ describe("test /api/verify endpoint", function () {
});
});
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 = {
"secret.example.com": "two_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));
});
});
it("should fail when base64 token is not valid", function () {
mocks.accessController.isAccessAllowedMock.returns(true);
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";
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.accessController.isAccessAllowedMock.returns(true);
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 () {
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));
});
});
});
});

View File

@ -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"

View File

@ -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

View File

@ -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.")));
})
});