Implement session inactivity timeout

This timeout will prevent an attacker from using a session that has been
inactive for too long.
This inactivity timeout combined with the timeout before expiration makes a
good combination of security mechanisms to prevent session theft.

If no activity timeout is provided, then the feature is disabled and only
session expiration remains as a protection.
pull/161/head
Clement Michaud 2017-10-17 00:38:10 +02:00
parent 9e275441c9
commit b842792a16
18 changed files with 265 additions and 66 deletions

1
.gitignore vendored
View File

@ -29,6 +29,7 @@ dist/
# Specific files
/config.yml
/config.test.yml
example/ldap/private.ldif

View File

@ -42,8 +42,8 @@ module.exports = function (grunt) {
args: ['--colors', '--compilers', 'ts:ts-node/register', '--recursive', 'client/test']
},
"test-int": {
cmd: "./node_modules/.bin/cucumber-js",
args: ["--colors", "--compiler", "ts:ts-node/register", "./test/features"]
cmd: "./scripts/run-cucumber.sh",
args: ["./test/features"]
},
"docker-build": {
cmd: "docker",

View File

@ -156,6 +156,9 @@ session:
# The time in ms before the cookie expires and session is reset.
expiration: 3600000 # 1 hour
# The inactivity time in ms before the session is reset.
inactivity: 300000 # 5 minutes
# The domain to protect.
# Note: the authenticator must also be in that domain. If empty, the cookie
# is restricted to the subdomain of the issuer.

View File

@ -23,8 +23,8 @@ ldap:
# An additional dn to define the scope to all users
additional_users_dn: ou=users
# The users filter.
# {0} is the matcher replaced by username.
# The users filter used to find the user DN
# {0} is a matcher replaced by username.
# 'cn={0}' by default.
users_filter: cn={0}
@ -47,6 +47,7 @@ ldap:
user: cn=admin,dc=example,dc=com
password: password
# Authentication methods
#
# Authentication methods can be defined per subdomain.
@ -65,17 +66,36 @@ authentication_methods:
# Access Control
#
# Access control is a set of rules you can use to restrict the user access.
# Default (anyone), per-user or per-group rules can be defined.
# Access control is a set of rules you can use to restrict user access to certain
# resources.
# Any (apply to anyone), per-user or per-group rules can be defined.
#
# If 'access_control' is not defined, ACL rules are disabled and a default policy
# is applied, i.e., access is allowed to anyone. Otherwise restrictions follow
# the rules defined below.
# If no rule is provided, all domains are denied.
# If 'access_control' is not defined, ACL rules are disabled and the `allow` default
# policy is applied, i.e., access is allowed to anyone. Otherwise restrictions follow
# the rules defined.
#
# Note: One can use the wildcard * to match any subdomain.
# It must stand at the beginning of the pattern. (example: *.mydomain.com)
#
# Note: You must put the pattern in simple quotes when using the wildcard for the YAML
# to be syntaxically correct.
#
# Definition: A `rule` is an object with the following keys: `domain`, `policy`
# and `resources`.
# - `domain` defines which domain or set of domains the rule applies to.
# - `policy` is the policy to apply to resources. It must be either `allow` or `deny`.
# - `resources` is a list of regular expressions that matches a set of resources to
# apply the policy to.
#
# Note: Rules follow an order of priority defined as follows:
# In each category (`any`, `groups`, `users`), the latest rules have the highest
# priority. In other words, it means that if a given resource matches two rules in the
# same category, the latest one overrides the first one.
# Each category has also its own priority. That is, `users` has the highest priority, then
# `groups` and `any` has the lowest priority. It means if two rules in different categories
# match a given resource, the one in the category with the highest priority overrides the
# other one.
#
# One can use the wildcard * to match any subdomain.
# Note 1: It must stand at the beginning of the pattern. (example: *.mydomain.com)
# Note 2: You must put the pattern in simple quotes when using the wildcard.
access_control:
# Default policy can either be `allow` or `deny`.
# It is the policy applied to any resource if it has not been overriden
@ -125,15 +145,19 @@ access_control:
resources:
- '^/users/bob/.*$'
# Configuration of session cookies
#
# The session cookies identify the user once logged in.
session:
# The secret to encrypt the session cookie.
secret: unsecure_secret
secret: unsecure_session_secret
# The time before the cookie expires.
expiration: 10000
# The time in ms before the cookie expires and session is reset.
expiration: 3600000 # 1 hour
# The inactivity time in ms before the session is reset.
inactivity: 300000 # 5 minutes
# The domain to protect.
# Note: the authenticator must also be in that domain. If empty, the cookie
@ -178,6 +202,10 @@ storage:
# registration or a TOTP registration.
# Use only an available configuration: filesystem, gmail
notifier:
# For testing purpose, notifications can be sent in a file
# filesystem:
# filename: /tmp/authelia/notification.txt
# Use your gmail account to send the notifications. You can use an app password.
# gmail:
# username: user@example.com
@ -187,7 +215,7 @@ notifier:
# Use a SMTP server for sending notifications
smtp:
username: test
password: test
password: password
secure: false
host: 'smtp'
port: 1025

View File

@ -0,0 +1,3 @@
#!/bin/bash
./node_modules/.bin/cucumber-js --colors --compiler ts:ts-node/register $*

View File

@ -9,7 +9,7 @@ export interface AuthenticationSession {
userid: string;
first_factor: boolean;
second_factor: boolean;
last_activity_datetime: Date;
last_activity_datetime: number;
identity_check?: {
challenge: string;
userid: string;
@ -40,7 +40,7 @@ export function reset(req: express.Request): void {
req.session.auth = Object.assign({}, INITIAL_AUTHENTICATION_SESSION, {});
// Initialize last activity with current time
req.session.auth.last_activity_datetime = new Date();
req.session.auth.last_activity_datetime = new Date().getTime();
}
export function get(req: express.Request): BluebirdPromise<AuthenticationSession> {

View File

@ -62,6 +62,7 @@ export interface SessionRedisOptions {
interface SessionCookieConfiguration {
secret: string;
expiration?: number;
inactivity?: number;
domain?: string;
redis?: SessionRedisOptions;
}

View File

@ -71,6 +71,7 @@ function adaptFromUserConfiguration(userConfiguration: UserConfiguration)
domain: ObjectPath.get<object, string>(userConfiguration, "session.domain"),
secret: ObjectPath.get<object, string>(userConfiguration, "session.secret"),
expiration: get_optional<number>(userConfiguration, "session.expiration", 3600000), // in ms
inactivity: get_optional<number>(userConfiguration, "session.inactivity", undefined),
redis: ObjectPath.get<object, SessionRedisOptions>(userConfiguration, "session.redis")
},
storage: {

View File

@ -13,6 +13,7 @@ import Util = require("util");
import { DomainExtractor } from "../../utils/DomainExtractor";
import { ServerVariables } from "../../ServerVariables";
import { AuthenticationMethodCalculator } from "../../AuthenticationMethodCalculator";
import { IRequestLogger } from "../../logging/IRequestLogger";
const FIRST_FACTOR_NOT_VALIDATED_MESSAGE = "First factor not yet validated";
const SECOND_FACTOR_NOT_VALIDATED_MESSAGE = "Second factor not yet validated";
@ -20,17 +21,48 @@ const SECOND_FACTOR_NOT_VALIDATED_MESSAGE = "Second factor not yet validated";
const REMOTE_USER = "Remote-User";
const REMOTE_GROUPS = "Remote-Groups";
function verify_inactivity(req: express.Request,
authSession: AuthenticationSession.AuthenticationSession,
configuration: AppConfiguration, logger: IRequestLogger)
: BluebirdPromise<void> {
const lastActivityTime = authSession.last_activity_datetime;
const currentTime = new Date().getTime();
authSession.last_activity_datetime = currentTime;
// 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.");
AuthenticationSession.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.AuthenticationSession;
let username: string;
let groups: string[];
return AuthenticationSession.get(req)
.then(function (authSession) {
_authSession = authSession;
username = _authSession.userid;
groups = _authSession.groups;
res.set("Redirect", encodeURIComponent("https://" + req.headers["host"] +
req.headers["x-original-uri"]));
const username = authSession.userid;
const groups = authSession.groups;
if (!authSession.userid)
if (!_authSession.userid)
return BluebirdPromise.reject(
new exceptions.AccessDeniedError(FIRST_FACTOR_NOT_VALIDATED_MESSAGE));
@ -44,23 +76,27 @@ function verify_filter(req: express.Request, res: express.Response,
vars.logger.debug(req, "domain=%s, path=%s, user=%s, groups=%s", domain, path,
username, groups.join(","));
if (!authSession.first_factor)
if (!_authSession.first_factor)
return BluebirdPromise.reject(
new exceptions.AccessDeniedError(FIRST_FACTOR_NOT_VALIDATED_MESSAGE));
if (authenticationMethod == "two_factor" && !_authSession.second_factor)
return BluebirdPromise.reject(
new exceptions.AccessDeniedError(SECOND_FACTOR_NOT_VALIDATED_MESSAGE));
const isAllowed = vars.accessController.isAccessAllowed(domain, path, username, groups);
if (!isAllowed) return BluebirdPromise.reject(
new exceptions.DomainAccessDenied(Util.format("User '%s' does not have access to '%s'",
username, domain)));
if (authenticationMethod == "two_factor" && !authSession.second_factor)
return BluebirdPromise.reject(
new exceptions.AccessDeniedError(SECOND_FACTOR_NOT_VALIDATED_MESSAGE));
return BluebirdPromise.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();
});
}

View File

@ -113,6 +113,7 @@ describe("test session configuration builder", function () {
domain: "example.com",
expiration: 3600,
secret: "secret",
inactivity: 4000,
redis: {
host: "redis.example.com",
port: 6379

View File

@ -60,7 +60,23 @@ describe("test config parser", function () {
});
});
describe("test session configuration", function() {
it("should get the session attributes", function () {
const yaml_config = buildYamlConfig();
yaml_config.session = {
domain: "example.com",
secret: "secret",
expiration: 3600,
inactivity: 4000
};
const config = ConfigurationParser.parse(yaml_config);
Assert.equal(config.session.domain, "example.com");
Assert.equal(config.session.secret, "secret");
Assert.equal(config.session.expiration, 3600);
Assert.equal(config.session.inactivity, 4000);
});
it("should be ok not specifying inactivity", function () {
const yaml_config = buildYamlConfig();
yaml_config.session = {
domain: "example.com",
@ -71,6 +87,8 @@ describe("test config parser", function () {
Assert.equal(config.session.domain, "example.com");
Assert.equal(config.session.secret, "secret");
Assert.equal(config.session.expiration, 3600);
Assert.equal(config.session.inactivity, undefined);
});
});
it("should get the log level", function () {

View File

@ -79,7 +79,7 @@ describe("test /verify endpoint", function () {
}
describe("given user tries to access a 2-factor endpoint", function () {
before(function() {
before(function () {
mocks.accessController.isAccessAllowedMock.returns(true);
});
@ -91,7 +91,7 @@ describe("test /verify endpoint", function () {
second_factor: false,
email: undefined,
groups: [],
last_activity_datetime: new Date()
last_activity_datetime: new Date().getTime()
});
});
@ -102,7 +102,7 @@ describe("test /verify endpoint", function () {
second_factor: true,
email: undefined,
groups: [],
last_activity_datetime: new Date()
last_activity_datetime: new Date().getTime()
});
});
@ -113,7 +113,7 @@ describe("test /verify endpoint", function () {
second_factor: false,
email: undefined,
groups: [],
last_activity_datetime: new Date()
last_activity_datetime: new Date().getTime()
});
});
@ -124,7 +124,7 @@ describe("test /verify endpoint", function () {
second_factor: false,
email: undefined,
groups: [],
last_activity_datetime: new Date()
last_activity_datetime: new Date().getTime()
});
});
@ -147,7 +147,7 @@ describe("test /verify endpoint", function () {
userid: "user",
groups: ["group1", "group2"],
email: undefined,
last_activity_datetime: new Date()
last_activity_datetime: new Date().getTime()
});
});
});
@ -191,5 +191,53 @@ describe("test /verify endpoint", function () {
});
});
});
describe("inactivity period", function () {
it("should update last inactivity period on requests on /verify", function () {
mocks.config.session.inactivity = 200000;
mocks.accessController.isAccessAllowedMock.returns(true);
const currentTime = new Date().getTime() - 1000;
AuthenticationSession.reset(req as any);
return AuthenticationSession.get(req as any)
.then(function (authSession: AuthenticationSession.AuthenticationSession) {
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 AuthenticationSession.get(req as any);
})
.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.accessController.isAccessAllowedMock.returns(true);
const currentTime = new Date().getTime() - 1000;
AuthenticationSession.reset(req as any);
return AuthenticationSession.get(req as any)
.then(function (authSession: AuthenticationSession.AuthenticationSession) {
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 AuthenticationSession.get(req as any);
})
.then(function (authSession) {
Assert.equal(authSession.first_factor, false);
Assert.equal(authSession.second_factor, false);
Assert.equal(authSession.userid, undefined);
});
});
});
});

View File

@ -20,8 +20,6 @@ Feature: User is correctly redirected
And I visit "https://admin.test.local:8080/secret.html"
Then I get an error 403
Scenario: Redirection URL is propagated from restricted page to first factor
When I visit "https://public.test.local:8080/secret.html"
Then I'm redirected to "https://auth.test.local:8080/?redirect=https%3A%2F%2Fpublic.test.local%3A8080%2Fsecret.html"

View File

@ -1,6 +1,6 @@
@needs-regulation-config
Feature: Authelia regulates authentication to avoid brute force
@needs-test-config
@need-registered-user-blackhat
Scenario: Attacker tries too many authentication in a short period of time and get banned
Given I visit "https://auth.test.local:8080/"
@ -18,7 +18,6 @@ Feature: Authelia regulates authentication to avoid brute force
And I click on "Sign in"
Then I get a notification of type "error" with message "Authentication failed. Please check your credentials."
@needs-test-config
@need-registered-user-blackhat
Scenario: User is unbanned after a configured amount of time
Given I visit "https://auth.test.local:8080/?redirect=https%3A%2F%2Fpublic.test.local%3A8080%2Fsecret.html"

View File

@ -0,0 +1,24 @@
@needs-inactivity-config
Feature: Session is closed after a certain amount of time
@need-authenticated-user-john
Scenario: An authenticated user is disconnected after a certain inactivity period
Given I have access to:
| url |
| https://public.test.local:8080/secret.html |
When I sleep for 6 seconds
And I visit "https://public.test.local:8080/secret.html"
Then I'm redirected to "https://auth.test.local:8080/?redirect=https%3A%2F%2Fpublic.test.local%3A8080%2Fsecret.html"
@need-authenticated-user-john
Scenario: An authenticated user is disconnected after session expiration period
Given I have access to:
| url |
| https://public.test.local:8080/secret.html |
When I sleep for 4 seconds
And I visit "https://public.test.local:8080/secret.html"
And I sleep for 4 seconds
And I visit "https://public.test.local:8080/secret.html"
And I sleep for 4 seconds
And I visit "https://public.test.local:8080/secret.html"
Then I'm redirected to "https://auth.test.local:8080/?redirect=https%3A%2F%2Fpublic.test.local%3A8080%2Fsecret.html"

View File

@ -6,7 +6,7 @@ import { UserDataStore } from "../../../server/src/lib/storage/UserDataStore";
import { CollectionFactoryFactory } from "../../../server/src/lib/storage/CollectionFactoryFactory";
import { MongoConnector } from "../../../server/src/lib/connectors/mongo/MongoConnector";
import { IMongoClient } from "../../../server/src/lib/connectors/mongo/IMongoClient";
import { TOTPGenerator } from "../../../server/src/lib/TOTPGenerator";
import { TotpHandler } from "../../../server/src/lib/authentication/totp/TotpHandler";
import Speakeasy = require("speakeasy");
Cucumber.defineSupportCode(function ({ setDefaultTimeout }) {
@ -14,19 +14,46 @@ Cucumber.defineSupportCode(function ({ setDefaultTimeout }) {
});
Cucumber.defineSupportCode(function ({ After, Before }) {
const exec = BluebirdPromise.promisify(ChildProcess.exec);
const exec = BluebirdPromise.promisify<any, any>(ChildProcess.exec);
After(function () {
return this.driver.quit();
});
Before({ tags: "@needs-test-config", timeout: 20 * 1000 }, function () {
return exec("./scripts/example-commit/dc-example.sh -f docker-compose.test.yml up -d authelia && sleep 2");
function createRegulationConfiguration(): BluebirdPromise<void> {
return exec("\
cat config.template.yml | \
sed 's/find_time: [0-9]\\+/find_time: 15/' | \
sed 's/ban_time: [0-9]\\+/ban_time: 4/' > config.test.yml \
");
}
function createInactivityConfiguration(): BluebirdPromise<void> {
return exec("\
cat config.template.yml | \
sed 's/expiration: [0-9]\\+/expiration: 10000/' | \
sed 's/inactivity: [0-9]\\+/inactivity: 5000/' > config.test.yml \
");
}
function declareNeedsConfiguration(tag: string, cb: () => BluebirdPromise<void>) {
Before({ tags: "@needs-" + tag + "-config", timeout: 20 * 1000 }, function () {
return cb()
.then(function () {
return exec("./scripts/example-commit/dc-example.sh -f docker-compose.test.yml up -d authelia && sleep 1");
})
});
After({ tags: "@needs-test-config", timeout: 20 * 1000 }, function () {
return exec("./scripts/example-commit/dc-example.sh up -d authelia && sleep 2");
After({ tags: "@needs-" + tag + "-config", timeout: 20 * 1000 }, function () {
return exec("rm config.test.yml")
.then(function () {
return exec("./scripts/example-commit/dc-example.sh up -d authelia && sleep 1");
});
});
}
declareNeedsConfiguration("regulation", createRegulationConfiguration);
declareNeedsConfiguration("inactivity", createInactivityConfiguration);
function registerUser(context: any, username: string) {
let secret: Speakeasy.Key;
@ -36,7 +63,7 @@ Cucumber.defineSupportCode(function ({ After, Before }) {
const collectionFactory = CollectionFactoryFactory.createMongo(mongoClient);
const userDataStore = new UserDataStore(collectionFactory);
const generator = new TOTPGenerator(Speakeasy);
const generator = new TotpHandler(Speakeasy);
secret = generator.generate();
return userDataStore.saveTOTPSecret(username, secret);
})
@ -56,7 +83,10 @@ Cucumber.defineSupportCode(function ({ After, Before }) {
}
function needAuthenticatedUser(context: any, username: string): BluebirdPromise<void> {
return context.visit("https://auth.test.local:8080/")
return context.visit("https://auth.test.local:8080/logout")
.then(function () {
return context.visit("https://auth.test.local:8080/");
})
.then(function () {
return registerUser(context, username);
})
@ -66,7 +96,7 @@ Cucumber.defineSupportCode(function ({ After, Before }) {
.then(function () {
return context.useTotpTokenHandle("REGISTERED");
})
.then(function() {
.then(function () {
return context.clickOnButton("TOTP");
});
}

View File

@ -7,6 +7,6 @@ import BluebirdPromise = require("bluebird");
Cucumber.defineSupportCode(function ({ Given, When, Then }) {
When(/^the application restarts$/, {timeout: 15 * 1000}, function () {
const exec = BluebirdPromise.promisify(ChildProcess.exec);
return exec("./scripts/example-commit/dc-example.sh restart authelia && sleep 2");
return exec("./scripts/example-commit/dc-example.sh restart authelia && sleep 1");
});
});

View File

@ -0,0 +1,8 @@
import Cucumber = require("cucumber");
import seleniumWebdriver = require("selenium-webdriver");
Cucumber.defineSupportCode(function ({ Given, When, Then }) {
When("I sleep for {number} seconds", function (seconds: number) {
return this.driver.sleep(seconds * 1000);
});
});