diff --git a/Gruntfile.js b/Gruntfile.js index 775c85d3..0c977751 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -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 } } }, diff --git a/README.md b/README.md index 450c7106..14108ddf 100644 --- a/README.md +++ b/README.md @@ -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: -### 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. -### 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. -### 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 diff --git a/config.template.yml b/config.template.yml index acff1362..c44ac688 100644 --- a/config.template.yml +++ b/config.template.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index deee95c9..ace1d5db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 - diff --git a/example/redis/docker-compose.yml b/example/redis/docker-compose.yml new file mode 100644 index 00000000..5e415362 --- /dev/null +++ b/example/redis/docker-compose.yml @@ -0,0 +1,6 @@ +version: '2' +services: + redis: + image: redis + networks: + - example-network diff --git a/package.json b/package.json index 9dd508f4..fa8d9788 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-services.sh b/scripts/check-services.sh index 773ffca5..a1085e56 100755 --- a/scripts/check-services.sh +++ b/scripts/check-services.sh @@ -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 diff --git a/scripts/dc-example.sh b/scripts/dc-example.sh index 1abd427b..4b04a22e 100755 --- a/scripts/dc-example.sh +++ b/scripts/dc-example.sh @@ -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 $* diff --git a/scripts/dc-test.sh b/scripts/dc-test.sh index df285b0c..533f363c 100755 --- a/scripts/dc-test.sh +++ b/scripts/dc-test.sh @@ -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 $* diff --git a/scripts/run-int-test.sh b/scripts/run-int-test.sh index efa09a77..cf3df97a 100755 --- a/scripts/run-int-test.sh +++ b/scripts/run-int-test.sh @@ -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 diff --git a/src/server/index.ts b/src/server/index.ts index 68fa6e20..70f2b72c 100755 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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 "); 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!"); }); diff --git a/src/server/lib/ConfigurationAdapter.ts b/src/server/lib/ConfigurationAdapter.ts index 4779fa08..3cbca916 100644 --- a/src/server/lib/ConfigurationAdapter.ts +++ b/src/server/lib/ConfigurationAdapter.ts @@ -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(userConfiguration, "session.domain"), secret: ObjectPath.get(userConfiguration, "session.secret"), expiration: get_optional(userConfiguration, "session.expiration", 3600000), // in ms + redis: ObjectPath.get(userConfiguration, "session.redis") }, store_directory: get_optional(userConfiguration, "store_directory", undefined), logs_level: get_optional(userConfiguration, "logs_level", "info"), diff --git a/src/server/lib/Server.ts b/src/server/lib/Server.ts index 1b1525ea..adda7e43 100644 --- a/src/server/lib/Server.ts +++ b/src/server/lib/Server.ts @@ -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 { - const config = ConfigurationAdapter.adapt(yaml_configuration); + start(yamlConfiguration: UserConfiguration, deps: GlobalDependencies): BluebirdPromise { + 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((resolve, reject) => { diff --git a/src/server/lib/SessionConfigurationBuilder.ts b/src/server/lib/SessionConfigurationBuilder.ts new file mode 100644 index 00000000..3fa6c661 --- /dev/null +++ b/src/server/lib/SessionConfigurationBuilder.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/types/Configuration.ts b/src/types/Configuration.ts index ece9acfc..8c2c9192 100644 --- a/src/types/Configuration.ts +++ b/src/types/Configuration.ts @@ -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 { diff --git a/src/types/Dependencies.ts b/src/types/Dependencies.ts index 261cd2ff..beabd9cf 100644 --- a/src/types/Dependencies.ts +++ b/src/types/Dependencies.ts @@ -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; diff --git a/test/integration/Dockerfile b/test/integration/Dockerfile index fff70158..16e8a209 100644 --- a/test/integration/Dockerfile +++ b/test/integration/Dockerfile @@ -2,4 +2,3 @@ FROM node:7-alpine WORKDIR /usr/src -CMD ["./node_modules/.bin/mocha", "--compilers", "ts:ts-node/register", "--recursive", "test/integration"] diff --git a/test/integration/config.yml b/test/integration/config.yml index 5ec8a6d7..7854350e 100644 --- a/test/integration/config.yml +++ b/test/integration/config.yml @@ -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 diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml index 2920725a..afaab5b1 100644 --- a/test/integration/docker-compose.yml +++ b/test/integration/docker-compose.yml @@ -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: diff --git a/test/integration/redis.test.ts b/test/integration/redis.test.ts new file mode 100644 index 00000000..185d9da4 --- /dev/null +++ b/test/integration/redis.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/test/server/config_adapter.test.ts b/test/server/ConfigurationAdapter.test.ts similarity index 100% rename from test/server/config_adapter.test.ts rename to test/server/ConfigurationAdapter.test.ts diff --git a/test/server/DataPersistence.test.ts b/test/server/DataPersistence.test.ts index e6c33f7d..ae4d85ff 100644 --- a/test/server/DataPersistence.test.ts +++ b/test/server/DataPersistence.test.ts @@ -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(); diff --git a/test/server/ServerConfig.test.ts b/test/server/ServerConfiguration.test.ts similarity index 94% rename from test/server/ServerConfig.test.ts rename to test/server/ServerConfiguration.test.ts index 4773539a..f9052bd9 100644 --- a/test/server/ServerConfig.test.ts +++ b/test/server/ServerConfiguration.test.ts @@ -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() }; }); diff --git a/test/server/SessionConfigurationBuilder.test.ts b/test/server/SessionConfigurationBuilder.test.ts new file mode 100644 index 00000000..e6f94b23 --- /dev/null +++ b/test/server/SessionConfigurationBuilder.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/test/server/server/PrivatePages.ts b/test/server/server/PrivatePages.ts new file mode 100644 index 00000000..dcc690ab --- /dev/null +++ b/test/server/server/PrivatePages.ts @@ -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 { + 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 { + 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 { + return should_post_and_reply_with(url, 401); + } + function should_get_and_reply_with_401(url: string): BluebirdPromise { + 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); + }); + }); +}); + diff --git a/test/server/server/PublicPages.ts b/test/server/server/PublicPages.ts new file mode 100644 index 00000000..5d8fcec3 --- /dev/null +++ b/test/server/server/PublicPages.ts @@ -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(); + }); + }); + } +}); + diff --git a/test/server/Server.test.ts b/test/server/server/Server.test.ts similarity index 53% rename from test/server/Server.test.ts rename to test/server/server/Server.test.ts index 5b4673fe..b7396aa0 100644 --- a/test/server/Server.test.ts +++ b/test/server/server/Server.test.ts @@ -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 { - 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 { - 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 { - return should_post_and_reply_with(url, 401); - } - function should_get_and_reply_with_401(url: string): BluebirdPromise { - 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(); }); });