Merge pull request #47 from clems4ever/redis-sessions

Use Redis as a store of user sessions for resilience
This commit is contained in:
Clément Michaud 2017-07-14 17:01:50 +02:00 committed by GitHub
commit 1e1653d81f
27 changed files with 700 additions and 204 deletions

View File

@ -13,16 +13,20 @@ module.exports = function (grunt) {
args: ['-c', 'tslint.json', '-p', 'tsconfig.json']
},
"test": {
cmd: "npm",
args: ['run', 'test']
cmd: "./node_modules/.bin/mocha",
args: ['--compilers', 'ts:ts-node/register', '--recursive', 'test/client', 'test/server']
},
"test-int": {
cmd: "./node_modules/.bin/mocha",
args: ['--compilers', 'ts:ts-node/register', '--recursive', 'test/integration']
},
"docker-build": {
cmd: "docker",
args: ['build', '-t', 'clems4ever/authelia', '.']
},
"docker-restart": {
cmd: "docker-compose",
args: ['-f', 'docker-compose.yml', '-f', 'docker-compose.dev.yml', 'restart', 'auth']
cmd: "./scripts/dc-example.sh",
args: ['up', '-d']
},
"minify": {
cmd: "./node_modules/.bin/uglifyjs",
@ -109,7 +113,7 @@ module.exports = function (grunt) {
},
client: {
files: ['src/client/**/*.ts', 'test/client/**/*.ts'],
tasks: ['build'],
tasks: ['build-dev'],
options: {
interrupt: true,
atBegin: true
@ -117,9 +121,10 @@ module.exports = function (grunt) {
},
server: {
files: ['src/server/**/*.ts', 'test/server/**/*.ts'],
tasks: ['build', 'run:docker-restart', 'run:make-dev-views' ],
tasks: ['build-dev', 'run:docker-restart', 'run:make-dev-views' ],
options: {
interrupt: true,
atBegin: true
}
}
},

View File

@ -7,13 +7,37 @@
nginx. It has been made to work with nginx [auth_request] module and is currently
used in production to secure internal services in a small docker swarm cluster.
## Features
# Table of Contents
1. [Features summary](#features-summary)
2. [Deployment](#deployment)
1. [With NPM](#with-npm)
2. [With Docker](#with-docker)
3. [Getting started](#getting-started)
1. [Pre-requisites](#pre-requisites)
2. [Run it!](#run-it)
4. [Features in details](#features-in-details)
1. [First factor with LDAP and ACL](#first-factor-with-ldap-and-acl)
2. [Second factor with TOTP](#second-factor-with-totp)
3. [Second factor with U2F security keys](#second-factor-with-u2f-security-keys)
4. [Password reset](#password-reset)
5. [Access control](#access-control)
6. [Session management with Redis](#session-management-with-redis)
4. [Documentation](#documentation)
1. [Authelia configuration](#authelia-configuration)
1. [API documentation](#api-documentation)
5. [Contributing to Authelia](#contributing-to-authelia)
6. [License](#license)
---
## Features summary
* 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.
* Access restriction after too many authentication attempts.
* Session management using Redis key/value store.
## Deployment
@ -73,7 +97,7 @@ Add the following lines to your **/etc/hosts** to alias multiple subdomains so t
127.0.0.1 mx2.mail.test.local
127.0.0.1 auth.test.local
### Deployment
### Run it!
Deploy **Authelia** example with the following command:
@ -93,7 +117,9 @@ Below is what the login page looks like:
<img src="https://raw.githubusercontent.com/clems4ever/authelia/master/images/first_factor.png" width="400">
### First factor: LDAP and ACL
## Features in details
### First factor with LDAP and ACL
An LDAP server has been deployed for you with the following credentials and
access control list:
@ -117,8 +143,8 @@ your credentials are wrong.
<img src="https://raw.githubusercontent.com/clems4ever/authelia/master/images/second_factor.png" width="400">
### Second factor: TOTP (Time-Base One Time Password)
In **Authelia**, you need to register a per user TOTP secret before
### Second factor with TOTP
In **Authelia**, you need to register a per user TOTP (Time-Based One Time Password) secret before
authenticating. To do that, you need to click on the register button. It will
send a link to the user email address. Since this is an example, no email will
be sent, the link is rather delivered in the file
@ -129,8 +155,8 @@ to store them and get the generated tokens with the app.
<img src="https://raw.githubusercontent.com/clems4ever/authelia/master/images/totp.png" width="400">
### 2nd factor: U2F (Universal 2-Factor) with security keys
**Authelia** also offers authentication using U2F devices like [Yubikey](Yubikey)
### Second factor with U2F security keys
**Authelia** also offers authentication using U2F (Universal 2-Factor) devices like [Yubikey](Yubikey)
USB security keys. U2F is one of the most secure authentication protocol and is
already available for Google, Facebook, Github accounts and more.
@ -160,8 +186,11 @@ the user access to some subdomains. Those rules are defined in the
configuration file and can be set either for everyone, per-user or per-group policies.
Check out the *config.template.yml* to see how they are defined.
### 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 the [configuration file](#authelia-configuration).
## Documentation
### Configuration
### Authelia configuration
The configuration of the server is defined in the file
**configuration.template.yml**. All the details are documented there.
You can specify another configuration file by giving it as first argument of

View File

@ -73,7 +73,9 @@ session:
secret: unsecure_secret
expiration: 3600000
domain: test.local
redis:
host: redis
port: 6379
# The directory where the DB files will be saved
store_directory: /var/lib/authelia/store

View File

@ -6,6 +6,7 @@ services:
volumes:
- ./config.template.yml:/etc/authelia/config.yml:ro
- ./notifications:/var/lib/authelia/notifications
depends_on:
- redis
networks:
- example-network

View File

@ -0,0 +1,6 @@
version: '2'
services:
redis:
image: redis
networks:
- example-network

View File

@ -7,7 +7,7 @@
"authelia": "dist/src/server/index.js"
},
"scripts": {
"test": "./node_modules/.bin/mocha --compilers ts:ts-node/register --recursive test/client test/server",
"test": "./node_modules/.bin/grunt test",
"cover": "NODE_ENV=test nyc npm t",
"serve": "node dist/server/index.js"
},
@ -27,6 +27,7 @@
"@types/cors": "^2.8.1",
"bluebird": "^3.4.7",
"body-parser": "^1.15.2",
"connect-redis": "^3.3.0",
"dovehash": "0.0.5",
"ejs": "^2.5.5",
"express": "^4.14.0",
@ -45,6 +46,7 @@
"devDependencies": {
"@types/bluebird": "^3.5.4",
"@types/body-parser": "^1.16.3",
"@types/connect-redis": "0.0.6",
"@types/ejs": "^2.3.33",
"@types/express": "^4.0.35",
"@types/express-session": "0.0.32",
@ -59,7 +61,7 @@
"@types/proxyquire": "^1.3.27",
"@types/query-string": "^4.3.1",
"@types/randomstring": "^1.1.5",
"@types/request": "0.0.45",
"@types/request": "0.0.46",
"@types/sinon": "^2.2.1",
"@types/speakeasy": "^2.0.1",
"@types/tmp": "0.0.33",

View File

@ -2,7 +2,7 @@
service_count=`docker ps -a | grep "Up " | wc -l`
if [ "${service_count}" -eq "3" ]
if [ "${service_count}" -eq "4" ]
then
echo "Service are up and running."
exit 0

View File

@ -2,4 +2,4 @@
set -e
docker-compose -f docker-compose.base.yml -f docker-compose.yml -f example/nginx/docker-compose.yml -f example/ldap/docker-compose.yml $*
docker-compose -f docker-compose.base.yml -f docker-compose.yml -f example/redis/docker-compose.yml -f example/nginx/docker-compose.yml -f example/ldap/docker-compose.yml $*

View File

@ -2,4 +2,4 @@
set -e
docker-compose -f docker-compose.base.yml -f example/ldap/docker-compose.yml -f test/integration/docker-compose.yml $*
docker-compose -f docker-compose.base.yml -f example/redis/docker-compose.yml -f example/ldap/docker-compose.yml -f test/integration/docker-compose.yml $*

View File

@ -6,7 +6,9 @@ echo "Build services images..."
./scripts/dc-test.sh build
echo "Start services..."
./scripts/dc-test.sh up -d authelia nginx openldap
./scripts/dc-test.sh up -d redis openldap
sleep 2
./scripts/dc-test.sh up -d authelia nginx
sleep 3
docker ps -a

View File

@ -3,31 +3,33 @@
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
import Server from "./lib/Server";
import { GlobalDependencies } from "../types/Dependencies";
const YAML = require("yamljs");
const config_path = process.argv[2];
if (!config_path) {
const configurationFilepath = process.argv[2];
if (!configurationFilepath) {
console.log("No config file has been provided.");
console.log("Usage: authelia <config>");
process.exit(0);
}
console.log("Parse configuration file: %s", config_path);
console.log("Parse configuration file: %s", configurationFilepath);
const yaml_config = YAML.load(config_path);
const yamlContent = YAML.load(configurationFilepath);
const deps = {
const deps: GlobalDependencies = {
u2f: require("u2f"),
nodemailer: require("nodemailer"),
ldapjs: require("ldapjs"),
session: require("express-session"),
winston: require("winston"),
speakeasy: require("speakeasy"),
nedb: require("nedb")
nedb: require("nedb"),
ConnectRedis: require("connect-redis")
};
const server = new Server();
server.start(yaml_config, deps)
server.start(yamlContent, deps)
.then(() => {
console.log("The server is started!");
});

View File

@ -1,6 +1,6 @@
import * as ObjectPath from "object-path";
import { AppConfiguration, UserConfiguration, NotifierConfiguration, ACLConfiguration, LdapConfiguration } from "./../../types/Configuration";
import { AppConfiguration, UserConfiguration, NotifierConfiguration, ACLConfiguration, LdapConfiguration, SessionRedisOptions } from "./../../types/Configuration";
const LDAP_URL_ENV_VARIABLE = "LDAP_URL";
@ -32,6 +32,7 @@ function adaptFromUserConfiguration(userConfiguration: UserConfiguration): AppCo
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
redis: ObjectPath.get<object, SessionRedisOptions>(userConfiguration, "session.redis")
},
store_directory: get_optional<string>(userConfiguration, "store_directory", undefined),
logs_level: get_optional<string>(userConfiguration, "logs_level", "info"),

View File

@ -5,12 +5,13 @@ import { GlobalDependencies } from "../../types/Dependencies";
import { AuthenticationRegulator } from "./AuthenticationRegulator";
import UserDataStore from "./UserDataStore";
import ConfigurationAdapter from "./ConfigurationAdapter";
import {  TOTPValidator } from "./TOTPValidator";
import { TOTPValidator } from "./TOTPValidator";
import { TOTPGenerator } from "./TOTPGenerator";
import RestApi from "./RestApi";
import { LdapClient } from "./LdapClient";
import BluebirdPromise = require("bluebird");
import ServerVariables = require("./ServerVariables");
import SessionConfigurationBuilder from "./SessionConfigurationBuilder";
import * as Express from "express";
import * as BodyParser from "body-parser";
@ -20,40 +21,32 @@ import * as http from "http";
export default class Server {
private httpServer: http.Server;
start(yaml_configuration: UserConfiguration, deps: GlobalDependencies): BluebirdPromise<void> {
const config = ConfigurationAdapter.adapt(yaml_configuration);
start(yamlConfiguration: UserConfiguration, deps: GlobalDependencies): BluebirdPromise<void> {
const config = ConfigurationAdapter.adapt(yamlConfiguration);
const view_directory = Path.resolve(__dirname, "../views");
const public_html_directory = Path.resolve(__dirname, "../public_html");
const viewsDirectory = Path.resolve(__dirname, "../views");
const publicHtmlDirectory = Path.resolve(__dirname, "../public_html");
const expressSessionOptions = SessionConfigurationBuilder.build(config, deps);
const app = Express();
app.use(Express.static(public_html_directory));
app.use(Express.static(publicHtmlDirectory));
app.use(BodyParser.urlencoded({ extended: false }));
app.use(BodyParser.json());
app.use(deps.session(expressSessionOptions));
app.set("trust proxy", 1); // trust first proxy
app.use(deps.session({
secret: config.session.secret,
resave: false,
saveUninitialized: true,
cookie: {
secure: false,
maxAge: config.session.expiration,
domain: config.session.domain
},
}));
app.set("views", view_directory);
app.set("trust proxy", 1);
app.set("views", viewsDirectory);
app.set("view engine", "pug");
// by default the level of logs is info
deps.winston.level = config.logs_level;
console.log("Log level = ", deps.winston.level);
deps.winston.debug("Content of YAML configuration file is %s", JSON.stringify(yamlConfiguration, undefined, 2));
deps.winston.debug("Authelia configuration is %s", JSON.stringify(config, undefined, 2));
ServerVariables.fill(app, config, deps);
RestApi.setup(app);
return new BluebirdPromise<void>((resolve, reject) => {

View File

@ -0,0 +1,37 @@
import ExpressSession = require("express-session");
import { AppConfiguration } from "../../types/Configuration";
import { GlobalDependencies } from "../../types/Dependencies";
export default class SessionConfigurationBuilder {
static build(configuration: AppConfiguration, deps: GlobalDependencies): ExpressSession.SessionOptions {
const sessionOptions: ExpressSession.SessionOptions = {
secret: configuration.session.secret,
resave: false,
saveUninitialized: true,
cookie: {
secure: false,
maxAge: configuration.session.expiration,
domain: configuration.session.domain
},
};
if (configuration.session.redis) {
let redisOptions;
if (configuration.session.redis.host
&& configuration.session.redis.port) {
redisOptions = {
host: configuration.session.redis.host,
port: configuration.session.redis.port
};
}
if (redisOptions) {
const RedisStore = deps.ConnectRedis(deps.session);
sessionOptions.store = new RedisStore(redisOptions);
}
}
return sessionOptions;
}
}

View File

@ -24,10 +24,16 @@ export interface ACLConfiguration {
users: ACLUsersRules;
}
export interface SessionRedisOptions {
host: string;
port: number;
}
interface SessionCookieConfiguration {
secret: string;
expiration?: number;
domain?: string;
redis?: SessionRedisOptions;
}
export interface GmailNotifierConfiguration {

View File

@ -5,6 +5,7 @@ import session = require("express-session");
import nedb = require("nedb");
import ldapjs = require("ldapjs");
import u2f = require("u2f");
import RedisSession = require("connect-redis");
export type Nodemailer = typeof nodemailer;
export type Speakeasy = typeof speakeasy;
@ -13,12 +14,14 @@ export type Session = typeof session;
export type Nedb = typeof nedb;
export type Ldapjs = typeof ldapjs;
export type U2f = typeof u2f;
export type ConnectRedis = typeof RedisSession;
export interface GlobalDependencies {
u2f: U2f;
nodemailer: Nodemailer;
ldapjs: Ldapjs;
session: Session;
ConnectRedis: ConnectRedis;
winston: Winston;
speakeasy: Speakeasy;
nedb: Nedb;

View File

@ -2,4 +2,3 @@ FROM node:7-alpine
WORKDIR /usr/src
CMD ["./node_modules/.bin/mocha", "--compilers", "ts:ts-node/register", "--recursive", "test/integration"]

View File

@ -73,6 +73,9 @@ session:
secret: unsecure_secret
expiration: 3600000
domain: test.local
redis:
host: redis
port: 6379
# The directory where the DB files will be saved

View File

@ -11,6 +11,7 @@ services:
int-test:
build: ./test/integration
command: ./node_modules/.bin/mocha --compilers ts:ts-node/register --recursive test/integration
volumes:
- ./:/usr/src
networks:

View File

@ -0,0 +1,23 @@
import Redis = require("redis");
import Assert = require("assert");
const redisOptions = {
host: "redis",
port: 6379
};
describe("test redis is correctly used", function () {
let redisClient: Redis.RedisClient;
before(function () {
redisClient = Redis.createClient(redisOptions);
});
it("should have registered at least one session", function (done) {
redisClient.dbsize(function (err: Error, count: number) {
Assert.equal(1, count);
done();
});
});
});

View File

@ -100,15 +100,16 @@ describe("test data persistence", function () {
sendMail: sinon.stub().yields()
};
const deps = {
const deps: GlobalDependencies = {
u2f: u2f,
nedb: nedb,
nodemailer: nodemailer,
session: session,
winston: winston,
ldapjs: ldap,
speakeasy: speakeasy
} as GlobalDependencies;
speakeasy: speakeasy,
ConnectRedis: sinon.spy()
};
const j1 = request.jar();
const j2 = request.jar();

View File

@ -38,11 +38,12 @@ describe("test server configuration", function () {
createClient: sinon.spy(function () {
return {
on: sinon.spy(),
bind: sinon.spy()
bind: sinon.spy(),
};
})
},
session: sessionMock as any
session: sessionMock as any,
ConnectRedis: sinon.spy()
};
});

View File

@ -0,0 +1,131 @@
import SessionConfigurationBuilder from "../../src/server/lib/SessionConfigurationBuilder";
import { AppConfiguration } from "../../src/types/Configuration";
import { GlobalDependencies } from "../../src/types/Dependencies";
import ExpressSession = require("express-session");
import ConnectRedis = require("connect-redis");
import sinon = require("sinon");
import Assert = require("assert");
describe("test session configuration builder", function () {
it("should return session options without redis options", function () {
const configuration: AppConfiguration = {
access_control: {
default: [],
users: {},
groups: {}
},
ldap: {
url: "ldap://ldap",
base_dn: "dc=example,dc=com",
user: "user",
password: "password"
},
logs_level: "debug",
notifier: {
filesystem: {
filename: "/test"
}
},
port: 8080,
session: {
domain: "example.com",
expiration: 3600,
secret: "secret"
},
store_in_memory: true
};
const deps: GlobalDependencies = {
ConnectRedis: sinon.spy() as any,
ldapjs: sinon.spy() as any,
nedb: sinon.spy() as any,
nodemailer: sinon.spy() as any,
session: sinon.spy() as any,
speakeasy: sinon.spy() as any,
u2f: sinon.spy() as any,
winston: sinon.spy() as any
};
const options = SessionConfigurationBuilder.build(configuration, deps);
const expectedOptions = {
secret: "secret",
resave: false,
saveUninitialized: true,
cookie: {
secure: false,
maxAge: 3600,
domain: "example.com"
}
};
Assert.deepEqual(expectedOptions, options);
});
it("should return session options with redis options", function () {
const configuration: AppConfiguration = {
access_control: {
default: [],
users: {},
groups: {}
},
ldap: {
url: "ldap://ldap",
base_dn: "dc=example,dc=com",
user: "user",
password: "password"
},
logs_level: "debug",
notifier: {
filesystem: {
filename: "/test"
}
},
port: 8080,
session: {
domain: "example.com",
expiration: 3600,
secret: "secret",
redis: {
host: "redis.example.com",
port: 6379
}
},
store_in_memory: true
};
const RedisStoreMock = sinon.spy();
const deps: GlobalDependencies = {
ConnectRedis: sinon.stub().returns(RedisStoreMock) as any,
ldapjs: sinon.spy() as any,
nedb: sinon.spy() as any,
nodemailer: sinon.spy() as any,
session: sinon.spy() as any,
speakeasy: sinon.spy() as any,
u2f: sinon.spy() as any,
winston: sinon.spy() as any
};
const options = SessionConfigurationBuilder.build(configuration, deps);
const expectedOptions: ExpressSession.SessionOptions = {
secret: "secret",
resave: false,
saveUninitialized: true,
cookie: {
secure: false,
maxAge: 3600,
domain: "example.com"
},
store: sinon.match.object as any
};
Assert((deps.ConnectRedis as sinon.SinonStub).calledWith(deps.session));
Assert.equal(options.secret, expectedOptions.secret);
Assert.equal(options.resave, expectedOptions.resave);
Assert.equal(options.saveUninitialized, expectedOptions.saveUninitialized);
Assert.deepEqual(options.cookie, expectedOptions.cookie);
Assert(options.store != undefined);
});
});

View File

@ -0,0 +1,177 @@
import Server from "../../../src/server/lib/Server";
import LdapClient = require("../../../src/server/lib/LdapClient");
import BluebirdPromise = require("bluebird");
import speakeasy = require("speakeasy");
import request = require("request");
import nedb = require("nedb");
import { GlobalDependencies } from "../../../src/types/Dependencies";
import { TOTPSecret } from "../../../src/types/TOTPSecret";
import U2FMock = require("./../mocks/u2f");
import Endpoints = require("../../../src/server/endpoints");
import Requests = require("../requests");
import Assert = require("assert");
import Sinon = require("sinon");
import Winston = require("winston");
import MockDate = require("mockdate");
import ExpressSession = require("express-session");
import ldapjs = require("ldapjs");
const requestp = BluebirdPromise.promisifyAll(request) as typeof request;
const PORT = 8090;
const BASE_URL = "http://localhost:" + PORT;
const requests = Requests(PORT);
describe("Private pages of the server must not be accessible without session", function () {
let server: Server;
let transporter: any;
let u2f: U2FMock.U2FMock;
beforeEach(function () {
const config = {
port: PORT,
ldap: {
url: "ldap://127.0.0.1:389",
base_dn: "ou=users,dc=example,dc=com",
user_name_attribute: "cn",
user: "cn=admin,dc=example,dc=com",
password: "password",
},
session: {
secret: "session_secret",
expiration: 50000,
},
store_in_memory: true,
notifier: {
gmail: {
username: "user@example.com",
password: "password"
}
}
};
const ldap_client = {
bind: Sinon.stub(),
search: Sinon.stub(),
modify: Sinon.stub(),
on: Sinon.spy()
};
const ldap = {
Change: Sinon.spy(),
createClient: Sinon.spy(function () {
return ldap_client;
})
};
u2f = U2FMock.U2FMock();
transporter = {
sendMail: Sinon.stub().yields()
};
const nodemailer = {
createTransport: Sinon.spy(function () {
return transporter;
})
};
const ldap_document = {
object: {
mail: "test_ok@example.com",
}
};
const search_res = {
on: Sinon.spy(function (event: string, fn: (s: any) => void) {
if (event != "error") fn(ldap_document);
})
};
ldap_client.bind.withArgs("cn=test_ok,ou=users,dc=example,dc=com",
"password").yields(undefined);
ldap_client.bind.withArgs("cn=admin,dc=example,dc=com",
"password").yields(undefined);
ldap_client.bind.withArgs("cn=test_nok,ou=users,dc=example,dc=com",
"password").yields("error");
ldap_client.modify.yields(undefined);
ldap_client.search.yields(undefined, search_res);
const deps: GlobalDependencies = {
u2f: u2f,
nedb: nedb,
nodemailer: nodemailer,
ldapjs: ldap,
session: ExpressSession,
winston: Winston,
speakeasy: speakeasy,
ConnectRedis: Sinon.spy()
};
server = new Server();
return server.start(config, deps);
});
afterEach(function () {
server.stop();
});
describe("Second factor endpoints must be protected if first factor is not validated", function () {
function should_post_and_reply_with(url: string, status_code: number): BluebirdPromise<void> {
return requestp.postAsync(url).then(function (response: request.RequestResponse) {
Assert.equal(response.statusCode, status_code);
return BluebirdPromise.resolve();
});
}
function should_get_and_reply_with(url: string, status_code: number): BluebirdPromise<void> {
return requestp.getAsync(url).then(function (response: request.RequestResponse) {
Assert.equal(response.statusCode, status_code);
return BluebirdPromise.resolve();
});
}
function should_post_and_reply_with_401(url: string): BluebirdPromise<void> {
return should_post_and_reply_with(url, 401);
}
function should_get_and_reply_with_401(url: string): BluebirdPromise<void> {
return should_get_and_reply_with(url, 401);
}
it("should block " + Endpoints.SECOND_FACTOR_GET, function () {
return should_get_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_GET);
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_IDENTITY_START_GET, function () {
return should_get_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_IDENTITY_START_GET);
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_IDENTITY_FINISH_GET, function () {
return should_get_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_IDENTITY_FINISH_GET + "?identity_token=dummy");
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_REGISTER_REQUEST_GET, function () {
return should_get_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_REGISTER_REQUEST_GET);
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_REGISTER_POST, function () {
return should_post_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_REGISTER_POST);
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET, function () {
return should_get_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET);
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_SIGN_POST, function () {
return should_post_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_SIGN_POST);
});
it("should block " + Endpoints.SECOND_FACTOR_TOTP_POST, function () {
return should_post_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_TOTP_POST);
});
});
});

View File

@ -0,0 +1,165 @@
import Server from "../../../src/server/lib/Server";
import LdapClient = require("../../../src/server/lib/LdapClient");
import BluebirdPromise = require("bluebird");
import speakeasy = require("speakeasy");
import Request = require("request");
import nedb = require("nedb");
import { GlobalDependencies } from "../../../src/types/Dependencies";
import { TOTPSecret } from "../../../src/types/TOTPSecret";
import U2FMock = require("./../mocks/u2f");
import Endpoints = require("../../../src/server/endpoints");
import Requests = require("../requests");
import Assert = require("assert");
import Sinon = require("sinon");
import Winston = require("winston");
import MockDate = require("mockdate");
import ExpressSession = require("express-session");
import ldapjs = require("ldapjs");
const requestp = BluebirdPromise.promisifyAll(Request) as typeof Request;
const PORT = 8090;
const BASE_URL = "http://localhost:" + PORT;
const requests = Requests(PORT);
describe("Public pages of the server must be accessible without session", function () {
let server: Server;
let transporter: object;
let u2f: U2FMock.U2FMock;
beforeEach(function () {
const config = {
port: PORT,
ldap: {
url: "ldap://127.0.0.1:389",
base_dn: "ou=users,dc=example,dc=com",
user_name_attribute: "cn",
user: "cn=admin,dc=example,dc=com",
password: "password",
},
session: {
secret: "session_secret",
expiration: 50000,
},
store_in_memory: true,
notifier: {
gmail: {
username: "user@example.com",
password: "password"
}
}
};
const ldap_client = {
bind: Sinon.stub(),
search: Sinon.stub(),
modify: Sinon.stub(),
on: Sinon.spy()
};
const ldap = {
Change: Sinon.spy(),
createClient: Sinon.spy(function () {
return ldap_client;
})
};
u2f = U2FMock.U2FMock();
transporter = {
sendMail: Sinon.stub().yields()
};
const nodemailer = {
createTransport: Sinon.spy(function () {
return transporter;
})
};
const ldap_document = {
object: {
mail: "test_ok@example.com",
}
};
const search_res = {
on: Sinon.spy(function (event: string, fn: (s: any) => void) {
if (event != "error") fn(ldap_document);
})
};
ldap_client.bind.withArgs("cn=test_ok,ou=users,dc=example,dc=com",
"password").yields(undefined);
ldap_client.bind.withArgs("cn=admin,dc=example,dc=com",
"password").yields(undefined);
ldap_client.bind.withArgs("cn=test_nok,ou=users,dc=example,dc=com",
"password").yields("error");
ldap_client.modify.yields(undefined);
ldap_client.search.yields(undefined, search_res);
const deps: GlobalDependencies = {
u2f: u2f,
nedb: nedb,
nodemailer: nodemailer,
ldapjs: ldap,
session: ExpressSession,
winston: Winston,
speakeasy: speakeasy,
ConnectRedis: Sinon.spy()
};
server = new Server();
return server.start(config, deps);
});
afterEach(function () {
server.stop();
});
describe("test GET " + Endpoints.FIRST_FACTOR_GET, function () {
test_login();
});
describe("test GET " + Endpoints.LOGOUT_GET, function () {
test_logout();
});
describe("test GET" + Endpoints.RESET_PASSWORD_REQUEST_GET, function () {
test_reset_password_form();
});
function test_reset_password_form() {
it("should serve the reset password form page", function (done) {
requestp.getAsync(BASE_URL + Endpoints.RESET_PASSWORD_REQUEST_GET)
.then(function (response: Request.RequestResponse) {
Assert.equal(response.statusCode, 200);
done();
});
});
}
function test_login() {
it("should serve the login page", function (done) {
requestp.getAsync(BASE_URL + Endpoints.FIRST_FACTOR_GET)
.then(function (response: Request.RequestResponse) {
Assert.equal(response.statusCode, 200);
done();
});
});
}
function test_logout() {
it("should logout and redirect to /", function (done) {
requestp.getAsync(BASE_URL + Endpoints.LOGOUT_GET)
.then(function (response: any) {
Assert.equal(response.req.path, "/");
done();
});
});
}
});

View File

@ -1,32 +1,34 @@
import Server from "../../src/server/lib/Server";
import LdapClient = require("../../src/server/lib/LdapClient");
import { LdapjsClientMock } from "./mocks/ldapjs";
import Server from "../../../src/server/lib/Server";
import LdapClient = require("../../../src/server/lib/LdapClient");
import { LdapjsClientMock } from "./../mocks/ldapjs";
import BluebirdPromise = require("bluebird");
import speakeasy = require("speakeasy");
import request = require("request");
import nedb = require("nedb");
import { TOTPSecret } from "../../src/types/TOTPSecret";
import U2FMock = require("./mocks/u2f");
import Endpoints = require("../../src/server/endpoints");
import { GlobalDependencies } from "../../../src/types/Dependencies";
import { TOTPSecret } from "../../../src/types/TOTPSecret";
import U2FMock = require("./../mocks/u2f");
import Endpoints = require("../../../src/server/endpoints");
import Requests = require("../requests");
import Assert = require("assert");
import Sinon = require("sinon");
import Winston = require("winston");
import MockDate = require("mockdate");
import ExpressSession = require("express-session");
import ldapjs = require("ldapjs");
const requestp = BluebirdPromise.promisifyAll(request) as typeof request;
const assert = require("assert");
const sinon = require("sinon");
const MockDate = require("mockdate");
const session = require("express-session");
const winston = require("winston");
const ldapjs = require("ldapjs");
const PORT = 8090;
const BASE_URL = "http://localhost:" + PORT;
const requests = require("./requests")(PORT);
const requests = Requests(PORT);
describe("test the server", function () {
let server: Server;
let transporter: object;
let transporter: any;
let u2f: U2FMock.U2FMock;
beforeEach(function () {
@ -54,8 +56,8 @@ describe("test the server", function () {
const ldapClient = LdapjsClientMock();
const ldap = {
Change: sinon.spy(),
createClient: sinon.spy(function () {
Change: Sinon.spy(),
createClient: Sinon.spy(function () {
return ldapClient;
})
};
@ -63,11 +65,11 @@ describe("test the server", function () {
u2f = U2FMock.U2FMock();
transporter = {
sendMail: sinon.stub().yields()
sendMail: Sinon.stub().yields()
};
const nodemailer = {
createTransport: sinon.spy(function () {
createTransport: Sinon.spy(function () {
return transporter;
})
};
@ -79,7 +81,7 @@ describe("test the server", function () {
};
const search_res = {
on: sinon.spy(function (event: string, fn: (s: any) => void) {
on: Sinon.spy(function (event: string, fn: (s: any) => void) {
if (event != "error") fn(ldapDocument);
})
};
@ -96,14 +98,15 @@ describe("test the server", function () {
ldapClient.modify.yields();
ldapClient.search.yields(undefined, search_res);
const deps = {
const deps: GlobalDependencies = {
u2f: u2f,
nedb: nedb,
nodemailer: nodemailer,
ldapjs: ldap,
session: session,
winston: winston,
speakeasy: speakeasy
session: ExpressSession,
winston: Winston,
speakeasy: speakeasy,
ConnectRedis: Sinon.spy()
};
server = new Server();
@ -114,114 +117,17 @@ describe("test the server", function () {
server.stop();
});
describe("test GET " + Endpoints.FIRST_FACTOR_GET, function () {
test_login();
});
describe("test GET " + Endpoints.LOGOUT_GET, function () {
test_logout();
});
describe("test GET" + Endpoints.RESET_PASSWORD_REQUEST_GET, function () {
test_reset_password_form();
});
describe("Second factor endpoints must be protected if first factor is not validated", function () {
function should_post_and_reply_with(url: string, status_code: number): BluebirdPromise<void> {
return requestp.postAsync(url).then(function (response: request.RequestResponse) {
assert.equal(response.statusCode, status_code);
return BluebirdPromise.resolve();
});
}
function should_get_and_reply_with(url: string, status_code: number): BluebirdPromise<void> {
return requestp.getAsync(url).then(function (response: request.RequestResponse) {
assert.equal(response.statusCode, status_code);
return BluebirdPromise.resolve();
});
}
function should_post_and_reply_with_401(url: string): BluebirdPromise<void> {
return should_post_and_reply_with(url, 401);
}
function should_get_and_reply_with_401(url: string): BluebirdPromise<void> {
return should_get_and_reply_with(url, 401);
}
it("should block " + Endpoints.SECOND_FACTOR_GET, function () {
return should_get_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_GET);
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_IDENTITY_START_GET, function () {
return should_get_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_IDENTITY_START_GET);
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_IDENTITY_FINISH_GET, function () {
return should_get_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_IDENTITY_FINISH_GET + "?identity_token=dummy");
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_REGISTER_REQUEST_GET, function () {
return should_get_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_REGISTER_REQUEST_GET);
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_REGISTER_POST, function () {
return should_post_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_REGISTER_POST);
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET, function () {
return should_get_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET);
});
it("should block " + Endpoints.SECOND_FACTOR_U2F_SIGN_POST, function () {
return should_post_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_U2F_SIGN_POST);
});
it("should block " + Endpoints.SECOND_FACTOR_TOTP_POST, function () {
return should_post_and_reply_with_401(BASE_URL + Endpoints.SECOND_FACTOR_TOTP_POST);
});
});
describe("test authentication and verification", function () {
test_authentication();
test_reset_password();
test_regulation();
});
function test_reset_password_form() {
it("should serve the reset password form page", function (done) {
requestp.getAsync(BASE_URL + Endpoints.RESET_PASSWORD_REQUEST_GET)
.then(function (response: request.RequestResponse) {
assert.equal(response.statusCode, 200);
done();
});
});
}
function test_login() {
it("should serve the login page", function (done) {
requestp.getAsync(BASE_URL + Endpoints.FIRST_FACTOR_GET)
.then(function (response: request.RequestResponse) {
assert.equal(response.statusCode, 200);
done();
});
});
}
function test_logout() {
it("should logout and redirect to /", function (done) {
requestp.getAsync(BASE_URL + Endpoints.LOGOUT_GET)
.then(function (response: any) {
assert.equal(response.req.path, "/");
done();
});
});
}
function test_authentication() {
it("should return status code 401 when user is not authenticated", function () {
return requestp.getAsync({ url: BASE_URL + Endpoints.VERIFY_GET })
.then(function (response: request.RequestResponse) {
assert.equal(response.statusCode, 401);
Assert.equal(response.statusCode, 401);
return BluebirdPromise.resolve();
});
});
@ -230,11 +136,11 @@ describe("test the server", function () {
const j = requestp.jar();
return requests.login(j)
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 200, "get login page failed");
Assert.equal(res.statusCode, 200, "get login page failed");
return requests.first_factor(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 302, "first factor failed");
Assert.equal(res.statusCode, 302, "first factor failed");
return requests.register_totp(j, transporter);
})
.then(function (base32_secret: string) {
@ -245,11 +151,11 @@ describe("test the server", function () {
return requests.totp(j, realToken);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 200, "second factor failed");
Assert.equal(res.statusCode, 200, "second factor failed");
return requests.verify(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 204, "verify failed");
Assert.equal(res.statusCode, 204, "verify failed");
return BluebirdPromise.resolve();
})
.catch(function (err: Error) { return BluebirdPromise.reject(err); });
@ -259,11 +165,11 @@ describe("test the server", function () {
const j = requestp.jar();
return requests.login(j)
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 200, "get login page failed");
Assert.equal(res.statusCode, 200, "get login page failed");
return requests.first_factor(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 302, "first factor failed");
Assert.equal(res.statusCode, 302, "first factor failed");
return requests.register_totp(j, transporter);
})
.then(function (base32_secret: string) {
@ -274,15 +180,15 @@ describe("test the server", function () {
return requests.totp(j, realToken);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 200, "second factor failed");
Assert.equal(res.statusCode, 200, "second factor failed");
return requests.login(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 200, "login page loading failed");
Assert.equal(res.statusCode, 200, "login page loading failed");
return requests.verify(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 204, "verify failed");
Assert.equal(res.statusCode, 204, "verify failed");
return BluebirdPromise.resolve();
})
.catch(function (err: Error) { return BluebirdPromise.reject(err); });
@ -300,25 +206,25 @@ describe("test the server", function () {
const j = requestp.jar();
return requests.login(j)
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 200, "get login page failed");
Assert.equal(res.statusCode, 200, "get login page failed");
return requests.first_factor(j);
})
.then(function (res: request.RequestResponse) {
// console.log(res);
assert.equal(res.headers.location, Endpoints.SECOND_FACTOR_GET);
assert.equal(res.statusCode, 302, "first factor failed");
Assert.equal(res.headers.location, Endpoints.SECOND_FACTOR_GET);
Assert.equal(res.statusCode, 302, "first factor failed");
return requests.u2f_registration(j, transporter);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 200, "second factor, finish register failed");
Assert.equal(res.statusCode, 200, "second factor, finish register failed");
return requests.u2f_authentication(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 200, "second factor, finish sign failed");
Assert.equal(res.statusCode, 200, "second factor, finish sign failed");
return requests.verify(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 204, "verify failed");
Assert.equal(res.statusCode, 204, "verify failed");
return BluebirdPromise.resolve();
});
});
@ -329,16 +235,16 @@ describe("test the server", function () {
const j = requestp.jar();
return requests.login(j)
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 200, "get login page failed");
Assert.equal(res.statusCode, 200, "get login page failed");
return requests.first_factor(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.headers.location, Endpoints.SECOND_FACTOR_GET);
assert.equal(res.statusCode, 302, "first factor failed");
Assert.equal(res.headers.location, Endpoints.SECOND_FACTOR_GET);
Assert.equal(res.statusCode, 302, "first factor failed");
return requests.reset_password(j, transporter, "user", "new-password");
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 204, "second factor, finish register failed");
Assert.equal(res.statusCode, 204, "second factor, finish register failed");
return BluebirdPromise.resolve();
});
});
@ -350,28 +256,28 @@ describe("test the server", function () {
MockDate.set("1/2/2017 00:00:00");
return requests.login(j)
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 200, "get login page failed");
Assert.equal(res.statusCode, 200, "get login page failed");
return requests.failing_first_factor(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 401, "first factor failed");
Assert.equal(res.statusCode, 401, "first factor failed");
return requests.failing_first_factor(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 401, "first factor failed");
Assert.equal(res.statusCode, 401, "first factor failed");
return requests.failing_first_factor(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 401, "first factor failed");
Assert.equal(res.statusCode, 401, "first factor failed");
return requests.failing_first_factor(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 403, "first factor failed");
Assert.equal(res.statusCode, 403, "first factor failed");
MockDate.set("1/2/2017 00:30:00");
return requests.failing_first_factor(j);
})
.then(function (res: request.RequestResponse) {
assert.equal(res.statusCode, 401, "first factor failed");
Assert.equal(res.statusCode, 401, "first factor failed");
return BluebirdPromise.resolve();
});
});