From 9e275441c9dff027f79d71349b21bedc1a56d7a4 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Tue, 17 Oct 2017 00:35:34 +0200 Subject: [PATCH 1/2] Refactor endpoints to get server variables as input parameters This refactoring aims to ease testability and clean up a lot of soft touchy typings in test code. This is the first step of this refactoring introducing the concept and implementing missing interfaces and stubs. At the end of the day, ServerVariablesHandler should completely disappear and every variable should be injected in the endpoint handler builder itself. --- Gruntfile.js | 2 + config.template.yml | 4 +- config.test.yml | 2 +- scripts/travis.sh | 3 +- .../src/lib/AuthenticationMethodCalculator.ts | 5 +- server/src/lib/AuthenticationSession.ts | 5 + server/src/lib/RestApi.ts | 9 +- server/src/lib/Server.ts | 23 ++- server/src/lib/ServerVariables.ts | 28 ++- server/src/lib/ServerVariablesHandler.ts | 52 +++--- server/src/lib/TOTPGenerator.ts | 24 --- server/src/lib/TOTPValidator.ts | 27 --- .../lib/authentication/totp/ITotpHandler.ts | 14 ++ .../lib/authentication/totp/TotpHandler.ts | 27 +++ .../src/lib/authentication/u2f/IU2fHandler.ts | 9 + .../src/lib/authentication/u2f/U2fHandler.ts | 24 +++ server/src/lib/regulation/IRegulator.ts | 6 + .../Regulator.ts} | 9 +- server/src/lib/routes/firstfactor/post.ts | 168 +++++++++--------- .../src/lib/routes/secondfactor/redirect.ts | 3 +- .../totp/identity/RegistrationHandler.ts | 4 +- .../lib/routes/secondfactor/totp/sign/post.ts | 50 +++--- server/src/lib/routes/verify/get.ts | 53 +++--- server/test/ServerConfiguration.test.ts | 11 +- server/test/TOTPValidator.test.ts | 41 ----- .../authentication/totp/Validator.test.ts | 39 ++++ server/test/mocks/NotifierStub.ts | 16 ++ server/test/mocks/RegulatorStub.ts | 21 +++ .../test/mocks/ServerVariablesMockBuilder.ts | 91 ++++++++++ server/test/mocks/TotpHandlerStub.ts | 22 +++ server/test/mocks/U2fHandlerStub.ts | 31 ++++ server/test/mocks/ldap/AuthenticatorStub.ts | 16 ++ server/test/mocks/ldap/EmailsRetrieverStub.ts | 16 ++ server/test/mocks/ldap/PasswordUpdaterStub.ts | 16 ++ .../Regulator.test.ts} | 26 +-- server/test/routes/firstfactor/post.test.ts | 64 +++---- .../secondfactor/totp/sign/post.test.ts | 42 ++--- server/test/routes/verify/get.test.ts | 79 ++++---- 38 files changed, 667 insertions(+), 415 deletions(-) delete mode 100644 server/src/lib/TOTPGenerator.ts delete mode 100644 server/src/lib/TOTPValidator.ts create mode 100644 server/src/lib/authentication/totp/ITotpHandler.ts create mode 100644 server/src/lib/authentication/totp/TotpHandler.ts create mode 100644 server/src/lib/authentication/u2f/IU2fHandler.ts create mode 100644 server/src/lib/authentication/u2f/U2fHandler.ts create mode 100644 server/src/lib/regulation/IRegulator.ts rename server/src/lib/{AuthenticationRegulator.ts => regulation/Regulator.ts} (87%) delete mode 100644 server/test/TOTPValidator.test.ts create mode 100644 server/test/authentication/totp/Validator.test.ts create mode 100644 server/test/mocks/NotifierStub.ts create mode 100644 server/test/mocks/RegulatorStub.ts create mode 100644 server/test/mocks/ServerVariablesMockBuilder.ts create mode 100644 server/test/mocks/TotpHandlerStub.ts create mode 100644 server/test/mocks/U2fHandlerStub.ts create mode 100644 server/test/mocks/ldap/AuthenticatorStub.ts create mode 100644 server/test/mocks/ldap/EmailsRetrieverStub.ts create mode 100644 server/test/mocks/ldap/PasswordUpdaterStub.ts rename server/test/{AuthenticationRegulator.test.ts => regulation/Regulator.test.ts} (84%) diff --git a/Gruntfile.js b/Gruntfile.js index 5564951cf..e0400f536 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -194,6 +194,8 @@ module.exports = function (grunt) { grunt.registerTask('build', ['build-client', 'build-server']); grunt.registerTask('build-dist', ['build', 'run:minify', 'cssmin', 'run:include-minified-script']); + grunt.registerTask('schema', ['run:generate-config-schema']) + grunt.registerTask('docker-build', ['run:docker-build']); grunt.registerTask('default', ['build-dist']); diff --git a/config.template.yml b/config.template.yml index 83f1fed72..c0239420e 100644 --- a/config.template.yml +++ b/config.template.yml @@ -153,8 +153,8 @@ session: # The secret to encrypt the session cookie. secret: unsecure_session_secret - # The time before the cookie expires. - expiration: 3600000 + # The time in ms before the cookie expires and session is reset. + expiration: 3600000 # 1 hour # The domain to protect. # Note: the authenticator must also be in that domain. If empty, the cookie diff --git a/config.test.yml b/config.test.yml index ca70f5275..8f22fd83a 100644 --- a/config.test.yml +++ b/config.test.yml @@ -133,7 +133,7 @@ session: secret: unsecure_secret # The time before the cookie expires. - expiration: 3600000 + expiration: 10000 # The domain to protect. # Note: the authenticator must also be in that domain. If empty, the cookie diff --git a/scripts/travis.sh b/scripts/travis.sh index 332b24501..bd6ac908c 100755 --- a/scripts/travis.sh +++ b/scripts/travis.sh @@ -5,7 +5,8 @@ set -e docker --version docker-compose --version -grunt run:generate-config-schema +# Generate configuration schema +grunt schema # Run unit tests grunt test-unit diff --git a/server/src/lib/AuthenticationMethodCalculator.ts b/server/src/lib/AuthenticationMethodCalculator.ts index 3791a9dbe..8f0810dcf 100644 --- a/server/src/lib/AuthenticationMethodCalculator.ts +++ b/server/src/lib/AuthenticationMethodCalculator.ts @@ -8,8 +8,11 @@ export class AuthenticationMethodCalculator { } compute(subDomain: string): AuthenticationMethod { - if (subDomain in this.configuration.per_subdomain_methods) + if (this.configuration + && this.configuration.per_subdomain_methods + && subDomain in this.configuration.per_subdomain_methods) { return this.configuration.per_subdomain_methods[subDomain]; + } return this.configuration.default_method; } } \ No newline at end of file diff --git a/server/src/lib/AuthenticationSession.ts b/server/src/lib/AuthenticationSession.ts index 9414d55d7..ad692f96f 100644 --- a/server/src/lib/AuthenticationSession.ts +++ b/server/src/lib/AuthenticationSession.ts @@ -9,6 +9,7 @@ export interface AuthenticationSession { userid: string; first_factor: boolean; second_factor: boolean; + last_activity_datetime: Date; identity_check?: { challenge: string; userid: string; @@ -23,6 +24,7 @@ export interface AuthenticationSession { const INITIAL_AUTHENTICATION_SESSION: AuthenticationSession = { first_factor: false, second_factor: false, + last_activity_datetime: undefined, userid: undefined, email: undefined, groups: [], @@ -36,6 +38,9 @@ export function reset(req: express.Request): void { const logger = ServerVariablesHandler.getLogger(req.app); logger.debug(req, "Authentication session %s is being reset.", req.sessionID); req.session.auth = Object.assign({}, INITIAL_AUTHENTICATION_SESSION, {}); + + // Initialize last activity with current time + req.session.auth.last_activity_datetime = new Date(); } export function get(req: express.Request): BluebirdPromise { diff --git a/server/src/lib/RestApi.ts b/server/src/lib/RestApi.ts index 34686c719..74a240d09 100644 --- a/server/src/lib/RestApi.ts +++ b/server/src/lib/RestApi.ts @@ -33,6 +33,7 @@ import Error404Get = require("./routes/error/404/get"); import LoggedIn = require("./routes/loggedin/get"); import { ServerVariablesHandler } from "./ServerVariablesHandler"; +import { ServerVariables } from "./ServerVariables"; import Endpoints = require("../../../shared/api"); @@ -45,7 +46,7 @@ function withLog(fn: (req: Express.Request, res: Express.Response) => void) { } export class RestApi { - static setup(app: Express.Application): void { + static setup(app: Express.Application, vars: ServerVariables): void { app.get(Endpoints.FIRST_FACTOR_GET, withLog(FirstFactorGet.default)); app.get(Endpoints.SECOND_FACTOR_GET, withLog(SecondFactorGet.default)); app.get(Endpoints.LOGOUT_GET, withLog(LogoutGet.default)); @@ -62,9 +63,9 @@ export class RestApi { app.get(Endpoints.RESET_PASSWORD_REQUEST_GET, withLog(ResetPasswordRequestPost.default)); app.post(Endpoints.RESET_PASSWORD_FORM_POST, withLog(ResetPasswordFormPost.default)); - app.get(Endpoints.VERIFY_GET, withLog(VerifyGet.default)); - app.post(Endpoints.FIRST_FACTOR_POST, withLog(FirstFactorPost.default)); - app.post(Endpoints.SECOND_FACTOR_TOTP_POST, withLog(TOTPSignGet.default)); + app.get(Endpoints.VERIFY_GET, withLog(VerifyGet.default(vars))); + app.post(Endpoints.FIRST_FACTOR_POST, withLog(FirstFactorPost.default(vars))); + app.post(Endpoints.SECOND_FACTOR_TOTP_POST, withLog(TOTPSignGet.default(vars))); app.get(Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET, withLog(U2FSignRequestGet.default)); app.post(Endpoints.SECOND_FACTOR_U2F_SIGN_POST, withLog(U2FSignPost.default)); diff --git a/server/src/lib/Server.ts b/server/src/lib/Server.ts index f043d4f33..c0538e680 100644 --- a/server/src/lib/Server.ts +++ b/server/src/lib/Server.ts @@ -4,16 +4,14 @@ import ObjectPath = require("object-path"); import { AccessController } from "./access_control/AccessController"; import { AppConfiguration, UserConfiguration } from "./configuration/Configuration"; import { GlobalDependencies } from "../../types/Dependencies"; -import { AuthenticationRegulator } from "./AuthenticationRegulator"; import { UserDataStore } from "./storage/UserDataStore"; import { ConfigurationParser } from "./configuration/ConfigurationParser"; -import { TOTPValidator } from "./TOTPValidator"; -import { TOTPGenerator } from "./TOTPGenerator"; import { RestApi } from "./RestApi"; -import { ServerVariablesHandler } from "./ServerVariablesHandler"; +import { ServerVariablesHandler, ServerVariablesInitializer } from "./ServerVariablesHandler"; import { SessionConfigurationBuilder } from "./configuration/SessionConfigurationBuilder"; import { GlobalLogger } from "./logging/GlobalLogger"; import { RequestLogger } from "./logging/RequestLogger"; +import { ServerVariables } from "./ServerVariables"; import * as Express from "express"; import * as BodyParser from "body-parser"; @@ -37,13 +35,16 @@ export default class Server { private httpServer: http.Server; private globalLogger: GlobalLogger; private requestLogger: RequestLogger; + private serverVariables: ServerVariables; constructor(deps: GlobalDependencies) { this.globalLogger = new GlobalLogger(deps.winston); this.requestLogger = new RequestLogger(deps.winston); } - private setupExpressApplication(config: AppConfiguration, app: Express.Application, deps: GlobalDependencies): void { + private setupExpressApplication(config: AppConfiguration, + app: Express.Application, + deps: GlobalDependencies): void { const viewsDirectory = Path.resolve(__dirname, "../views"); const publicHtmlDirectory = Path.resolve(__dirname, "../public_html"); @@ -60,7 +61,7 @@ export default class Server { app.set(VIEWS, viewsDirectory); app.set(VIEW_ENGINE, PUG); - RestApi.setup(app); + RestApi.setup(app, this.serverVariables); } private displayConfigurations(userConfiguration: UserConfiguration, @@ -90,8 +91,14 @@ export default class Server { } private setup(config: AppConfiguration, app: Express.Application, deps: GlobalDependencies): BluebirdPromise { - this.setupExpressApplication(config, app, deps); - return ServerVariablesHandler.initialize(app, config, this.requestLogger, deps); + const that = this; + return ServerVariablesInitializer.initialize(config, this.requestLogger, deps) + .then(function (vars: ServerVariables) { + that.serverVariables = vars; + that.setupExpressApplication(config, app, deps); + ServerVariablesHandler.setup(app, vars); + return BluebirdPromise.resolve(); + }); } private startServer(app: Express.Application, port: number) { diff --git a/server/src/lib/ServerVariables.ts b/server/src/lib/ServerVariables.ts index 065a1dafa..a7b1e2e5e 100644 --- a/server/src/lib/ServerVariables.ts +++ b/server/src/lib/ServerVariables.ts @@ -1,33 +1,25 @@ -import U2F = require("u2f"); - import { IRequestLogger } from "./logging/IRequestLogger"; import { IAuthenticator } from "./ldap/IAuthenticator"; import { IPasswordUpdater } from "./ldap/IPasswordUpdater"; import { IEmailsRetriever } from "./ldap/IEmailsRetriever"; - -import { TOTPValidator } from "./TOTPValidator"; -import { TOTPGenerator } from "./TOTPGenerator"; +import { ITotpHandler } from "./authentication/totp/ITotpHandler"; +import { IU2fHandler } from "./authentication/u2f/IU2fHandler"; import { IUserDataStore } from "./storage/IUserDataStore"; import { INotifier } from "./notifiers/INotifier"; -import { AuthenticationRegulator } from "./AuthenticationRegulator"; -import Configuration = require("./configuration/Configuration"); -import { AccessController } from "./access_control/AccessController"; -import { AuthenticationMethodCalculator } from "./AuthenticationMethodCalculator"; - - +import { IRegulator } from "./regulation/IRegulator"; +import { AppConfiguration } from "./configuration/Configuration"; +import { IAccessController } from "./access_control/IAccessController"; export interface ServerVariables { logger: IRequestLogger; ldapAuthenticator: IAuthenticator; ldapPasswordUpdater: IPasswordUpdater; ldapEmailsRetriever: IEmailsRetriever; - totpValidator: TOTPValidator; - totpGenerator: TOTPGenerator; - u2f: typeof U2F; + totpHandler: ITotpHandler; + u2f: IU2fHandler; userDataStore: IUserDataStore; notifier: INotifier; - regulator: AuthenticationRegulator; - config: Configuration.AppConfiguration; - accessController: AccessController; - authenticationMethodsCalculator: AuthenticationMethodCalculator; + regulator: IRegulator; + config: AppConfiguration; + accessController: IAccessController; } \ No newline at end of file diff --git a/server/src/lib/ServerVariablesHandler.ts b/server/src/lib/ServerVariablesHandler.ts index caf7a6332..c0a84765f 100644 --- a/server/src/lib/ServerVariablesHandler.ts +++ b/server/src/lib/ServerVariablesHandler.ts @@ -16,18 +16,19 @@ import { EmailsRetriever } from "./ldap/EmailsRetriever"; import { ClientFactory } from "./ldap/ClientFactory"; import { LdapClientFactory } from "./ldap/LdapClientFactory"; -import { TOTPValidator } from "./TOTPValidator"; -import { TOTPGenerator } from "./TOTPGenerator"; - +import { TotpHandler } from "./authentication/totp/TotpHandler"; +import { ITotpHandler } from "./authentication/totp/ITotpHandler"; import { NotifierFactory } from "./notifiers/NotifierFactory"; import { MailSenderBuilder } from "./notifiers/MailSenderBuilder"; import { IUserDataStore } from "./storage/IUserDataStore"; import { UserDataStore } from "./storage/UserDataStore"; import { INotifier } from "./notifiers/INotifier"; -import { AuthenticationRegulator } from "./AuthenticationRegulator"; +import { Regulator } from "./regulation/Regulator"; +import { IRegulator } from "./regulation/IRegulator"; import Configuration = require("./configuration/Configuration"); import { AccessController } from "./access_control/AccessController"; +import { IAccessController } from "./access_control/IAccessController"; import { CollectionFactoryFactory } from "./storage/CollectionFactoryFactory"; import { ICollectionFactory } from "./storage/ICollectionFactory"; import { MongoCollectionFactory } from "./storage/mongo/MongoCollectionFactory"; @@ -67,9 +68,9 @@ class UserDataStoreFactory { } } -export class ServerVariablesHandler { - static initialize(app: express.Application, config: Configuration.AppConfiguration, requestLogger: IRequestLogger, - deps: GlobalDependencies): BluebirdPromise { +export class ServerVariablesInitializer { + static initialize(config: Configuration.AppConfiguration, requestLogger: IRequestLogger, + deps: GlobalDependencies): BluebirdPromise { const mailSenderBuilder = new MailSenderBuilder(Nodemailer); const notifier = NotifierFactory.build(config.notifier, mailSenderBuilder); const ldapClientFactory = new LdapClientFactory(config.ldap, deps.ldapjs); @@ -79,13 +80,11 @@ export class ServerVariablesHandler { const ldapPasswordUpdater = new PasswordUpdater(config.ldap, clientFactory); const ldapEmailsRetriever = new EmailsRetriever(config.ldap, clientFactory); const accessController = new AccessController(config.access_control, deps.winston); - const totpValidator = new TOTPValidator(deps.speakeasy); - const totpGenerator = new TOTPGenerator(deps.speakeasy); - const authenticationMethodCalculator = new AuthenticationMethodCalculator(config.authentication_methods); + const totpHandler = new TotpHandler(deps.speakeasy); return UserDataStoreFactory.create(config) .then(function (userDataStore: UserDataStore) { - const regulator = new AuthenticationRegulator(userDataStore, config.regulation.max_retries, + const regulator = new Regulator(userDataStore, config.regulation.max_retries, config.regulation.find_time, config.regulation.ban_time); const variables: ServerVariables = { @@ -97,15 +96,18 @@ export class ServerVariablesHandler { logger: requestLogger, notifier: notifier, regulator: regulator, - totpGenerator: totpGenerator, - totpValidator: totpValidator, + totpHandler: totpHandler, u2f: deps.u2f, - userDataStore: userDataStore, - authenticationMethodsCalculator: authenticationMethodCalculator + userDataStore: userDataStore }; - - app.set(VARIABLES_KEY, variables); + return BluebirdPromise.resolve(variables); }); + } +} + +export class ServerVariablesHandler { + static setup(app: express.Application, variables: ServerVariables): void { + app.set(VARIABLES_KEY, variables); } static getLogger(app: express.Application): IRequestLogger { @@ -136,27 +138,19 @@ export class ServerVariablesHandler { return (app.get(VARIABLES_KEY) as ServerVariables).config; } - static getAuthenticationRegulator(app: express.Application): AuthenticationRegulator { + static getAuthenticationRegulator(app: express.Application): IRegulator { return (app.get(VARIABLES_KEY) as ServerVariables).regulator; } - static getAccessController(app: express.Application): AccessController { + static getAccessController(app: express.Application): IAccessController { return (app.get(VARIABLES_KEY) as ServerVariables).accessController; } - static getTOTPGenerator(app: express.Application): TOTPGenerator { - return (app.get(VARIABLES_KEY) as ServerVariables).totpGenerator; - } - - static getTOTPValidator(app: express.Application): TOTPValidator { - return (app.get(VARIABLES_KEY) as ServerVariables).totpValidator; + static getTotpHandler(app: express.Application): ITotpHandler { + return (app.get(VARIABLES_KEY) as ServerVariables).totpHandler; } static getU2F(app: express.Application): typeof U2F { return (app.get(VARIABLES_KEY) as ServerVariables).u2f; } - - static getAuthenticationMethodCalculator(app: express.Application): AuthenticationMethodCalculator { - return (app.get(VARIABLES_KEY) as ServerVariables).authenticationMethodsCalculator; - } } diff --git a/server/src/lib/TOTPGenerator.ts b/server/src/lib/TOTPGenerator.ts deleted file mode 100644 index c0c7133bf..000000000 --- a/server/src/lib/TOTPGenerator.ts +++ /dev/null @@ -1,24 +0,0 @@ - -import Speakeasy = require("speakeasy"); -import BluebirdPromise = require("bluebird"); -import { TOTPSecret } from "../../types/TOTPSecret"; - -interface GenerateSecretOptions { - length?: number; - symbols?: boolean; - otpauth_url?: boolean; - name?: string; - issuer?: string; -} - -export class TOTPGenerator { - private speakeasy: typeof Speakeasy; - - constructor(speakeasy: typeof Speakeasy) { - this.speakeasy = speakeasy; - } - - generate(options?: GenerateSecretOptions): TOTPSecret { - return this.speakeasy.generateSecret(options); - } -} \ No newline at end of file diff --git a/server/src/lib/TOTPValidator.ts b/server/src/lib/TOTPValidator.ts deleted file mode 100644 index d99be78b8..000000000 --- a/server/src/lib/TOTPValidator.ts +++ /dev/null @@ -1,27 +0,0 @@ -import Speakeasy = require("speakeasy"); -import BluebirdPromise = require("bluebird"); - -const TOTP_ENCODING = "base32"; -const WINDOW: number = 1; - -export class TOTPValidator { - private speakeasy: typeof Speakeasy; - - constructor(speakeasy: typeof Speakeasy) { - this.speakeasy = speakeasy; - } - - validate(token: string, secret: string): BluebirdPromise { - const isValid = this.speakeasy.totp.verify({ - secret: secret, - encoding: TOTP_ENCODING, - token: token, - window: WINDOW - } as any); - - if (isValid) - return BluebirdPromise.resolve(); - else - return BluebirdPromise.reject(new Error("Wrong TOTP token.")); - } -} \ No newline at end of file diff --git a/server/src/lib/authentication/totp/ITotpHandler.ts b/server/src/lib/authentication/totp/ITotpHandler.ts new file mode 100644 index 000000000..2830790f5 --- /dev/null +++ b/server/src/lib/authentication/totp/ITotpHandler.ts @@ -0,0 +1,14 @@ +import { TOTPSecret } from "../../../../types/TOTPSecret"; + +export interface GenerateSecretOptions { + length?: number; + symbols?: boolean; + otpauth_url?: boolean; + name?: string; + issuer?: string; +} + +export interface ITotpHandler { + generate(options?: GenerateSecretOptions): TOTPSecret; + validate(token: string, secret: string): boolean; +} \ No newline at end of file diff --git a/server/src/lib/authentication/totp/TotpHandler.ts b/server/src/lib/authentication/totp/TotpHandler.ts new file mode 100644 index 000000000..9e1419e92 --- /dev/null +++ b/server/src/lib/authentication/totp/TotpHandler.ts @@ -0,0 +1,27 @@ +import { ITotpHandler, GenerateSecretOptions } from "./ITotpHandler"; +import { TOTPSecret } from "../../../../types/TOTPSecret"; +import Speakeasy = require("speakeasy"); + +const TOTP_ENCODING = "base32"; +const WINDOW: number = 1; + +export class TotpHandler implements ITotpHandler { + private speakeasy: typeof Speakeasy; + + constructor(speakeasy: typeof Speakeasy) { + this.speakeasy = speakeasy; + } + + generate(options?: GenerateSecretOptions): TOTPSecret { + return this.speakeasy.generateSecret(options); + } + + validate(token: string, secret: string): boolean { + return this.speakeasy.totp.verify({ + secret: secret, + encoding: TOTP_ENCODING, + token: token, + window: WINDOW + } as any); + } +} \ No newline at end of file diff --git a/server/src/lib/authentication/u2f/IU2fHandler.ts b/server/src/lib/authentication/u2f/IU2fHandler.ts new file mode 100644 index 000000000..b9b7d6f26 --- /dev/null +++ b/server/src/lib/authentication/u2f/IU2fHandler.ts @@ -0,0 +1,9 @@ +import U2f = require("u2f"); + +export interface IU2fHandler { + request(appId: string, keyHandle?: string): U2f.Request; + checkRegistration(registrationRequest: U2f.Request, registrationResponse: U2f.RegistrationData) + : U2f.RegistrationResult | U2f.Error; + checkSignature(signatureRequest: U2f.Request, signatureResponse: U2f.SignatureData, publicKey: string) + : U2f.SignatureResult | U2f.Error; +} \ No newline at end of file diff --git a/server/src/lib/authentication/u2f/U2fHandler.ts b/server/src/lib/authentication/u2f/U2fHandler.ts new file mode 100644 index 000000000..bf3891e5b --- /dev/null +++ b/server/src/lib/authentication/u2f/U2fHandler.ts @@ -0,0 +1,24 @@ +import { IU2fHandler } from "./IU2fHandler"; +import U2f = require("u2f"); + +export class U2fHandler implements IU2fHandler { + private u2f: typeof U2f; + + constructor(u2f: typeof U2f) { + this.u2f = u2f; + } + + request(appId: string, keyHandle?: string): U2f.Request { + return this.u2f.request(appId, keyHandle); + } + + checkRegistration(registrationRequest: U2f.Request, registrationResponse: U2f.RegistrationData) + : U2f.RegistrationResult | U2f.Error { + return this.u2f.checkRegistration(registrationRequest, registrationResponse); + } + + checkSignature(signatureRequest: U2f.Request, signatureResponse: U2f.SignatureData, publicKey: string) + : U2f.SignatureResult | U2f.Error { + return this.u2f.checkSignature(signatureRequest, signatureResponse, publicKey); + } +} diff --git a/server/src/lib/regulation/IRegulator.ts b/server/src/lib/regulation/IRegulator.ts new file mode 100644 index 000000000..c49425b24 --- /dev/null +++ b/server/src/lib/regulation/IRegulator.ts @@ -0,0 +1,6 @@ +import BluebirdPromise = require("bluebird"); + +export interface IRegulator { + mark(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise; + regulate(userId: string): BluebirdPromise; +} \ No newline at end of file diff --git a/server/src/lib/AuthenticationRegulator.ts b/server/src/lib/regulation/Regulator.ts similarity index 87% rename from server/src/lib/AuthenticationRegulator.ts rename to server/src/lib/regulation/Regulator.ts index a04547335..1037a6a17 100644 --- a/server/src/lib/AuthenticationRegulator.ts +++ b/server/src/lib/regulation/Regulator.ts @@ -1,10 +1,11 @@ import * as BluebirdPromise from "bluebird"; -import exceptions = require("./Exceptions"); -import { IUserDataStore } from "./storage/IUserDataStore"; -import { AuthenticationTraceDocument } from "./storage/AuthenticationTraceDocument"; +import exceptions = require("../Exceptions"); +import { IUserDataStore } from "../storage/IUserDataStore"; +import { AuthenticationTraceDocument } from "../storage/AuthenticationTraceDocument"; +import { IRegulator } from "./IRegulator"; -export class AuthenticationRegulator { +export class Regulator implements IRegulator { private userDataStore: IUserDataStore; private banTime: number; private findTime: number; diff --git a/server/src/lib/routes/firstfactor/post.ts b/server/src/lib/routes/firstfactor/post.ts index e32ffc58e..1aacb50ce 100644 --- a/server/src/lib/routes/firstfactor/post.ts +++ b/server/src/lib/routes/firstfactor/post.ts @@ -4,7 +4,7 @@ import objectPath = require("object-path"); import BluebirdPromise = require("bluebird"); import express = require("express"); import { AccessController } from "../../access_control/AccessController"; -import { AuthenticationRegulator } from "../../AuthenticationRegulator"; +import { Regulator } from "../../regulation/Regulator"; import { GroupsAndEmails } from "../../ldap/IClient"; import Endpoint = require("../../../../../shared/api"); import ErrorReplies = require("../../ErrorReplies"); @@ -13,89 +13,89 @@ import AuthenticationSession = require("../../AuthenticationSession"); import Constants = require("../../../../../shared/constants"); import { DomainExtractor } from "../../utils/DomainExtractor"; import UserMessages = require("../../../../../shared/UserMessages"); +import { AuthenticationMethodCalculator } from "../../AuthenticationMethodCalculator"; +import { ServerVariables } from "../../ServerVariables"; -export default function (req: express.Request, res: express.Response): BluebirdPromise { - const username: string = req.body.username; - const password: string = req.body.password; +export default function (vars: ServerVariables) { + return function (req: express.Request, res: express.Response) + : BluebirdPromise { + const username: string = req.body.username; + const password: string = req.body.password; + let authSession: AuthenticationSession.AuthenticationSession; - const logger = ServerVariablesHandler.getLogger(req.app); - const ldap = ServerVariablesHandler.getLdapAuthenticator(req.app); - const config = ServerVariablesHandler.getConfiguration(req.app); - const regulator = ServerVariablesHandler.getAuthenticationRegulator(req.app); - const accessController = ServerVariablesHandler.getAccessController(req.app); - const authenticationMethodsCalculator = - ServerVariablesHandler.getAuthenticationMethodCalculator(req.app); - let authSession: AuthenticationSession.AuthenticationSession; - - return BluebirdPromise.resolve() - .then(function () { - if (!username || !password) { - return BluebirdPromise.reject(new Error("No username or password.")); - } - logger.info(req, "Starting authentication of user \"%s\"", username); - return AuthenticationSession.get(req); - }) - .then(function (_authSession: AuthenticationSession.AuthenticationSession) { - authSession = _authSession; - return regulator.regulate(username); - }) - .then(function () { - logger.info(req, "No regulation applied."); - return ldap.authenticate(username, password); - }) - .then(function (groupsAndEmails: GroupsAndEmails) { - logger.info(req, "LDAP binding successful. Retrieved information about user are %s", - JSON.stringify(groupsAndEmails)); - authSession.userid = username; - authSession.first_factor = true; - const redirectUrl = req.query[Constants.REDIRECT_QUERY_PARAM] !== "undefined" - // Fuck, don't know why it is a string! - ? req.query[Constants.REDIRECT_QUERY_PARAM] - : undefined; - - const emails: string[] = groupsAndEmails.emails; - const groups: string[] = groupsAndEmails.groups; - const redirectHost: string = DomainExtractor.fromUrl(redirectUrl); - const authMethod = authenticationMethodsCalculator.compute(redirectHost); - logger.debug(req, "Authentication method for \"%s\" is \"%s\"", redirectHost, authMethod); - - if (!emails || emails.length <= 0) { - const errMessage = "No emails found. The user should have at least one email address to reset password."; - logger.error(req, "%s", errMessage); - return BluebirdPromise.reject(new Error(errMessage)); - } - - authSession.email = emails[0]; - authSession.groups = groups; - - logger.debug(req, "Mark successful authentication to regulator."); - regulator.mark(username, true); - - if (authMethod == "basic_auth") { - res.send({ - redirect: redirectUrl - }); - logger.debug(req, "Redirect to '%s'", redirectUrl); - } - else if (authMethod == "two_factor") { - let newRedirectUrl = Endpoint.SECOND_FACTOR_GET; - if (redirectUrl) { - newRedirectUrl += "?" + Constants.REDIRECT_QUERY_PARAM + "=" - + encodeURIComponent(redirectUrl); + return BluebirdPromise.resolve() + .then(function () { + if (!username || !password) { + return BluebirdPromise.reject(new Error("No username or password.")); } - logger.debug(req, "Redirect to '%s'", newRedirectUrl, typeof redirectUrl); - res.send({ - redirect: newRedirectUrl - }); - } - else { - return BluebirdPromise.reject(new Error("Unknown authentication method for this domain.")); - } - return BluebirdPromise.resolve(); - }) - .catch(Exceptions.LdapBindError, function (err: Error) { - regulator.mark(username, false); - return ErrorReplies.replyWithError200(req, res, logger, UserMessages.OPERATION_FAILED)(err); - }) - .catch(ErrorReplies.replyWithError200(req, res, logger, UserMessages.OPERATION_FAILED)); -} + vars.logger.info(req, "Starting authentication of user \"%s\"", username); + return AuthenticationSession.get(req); + }) + .then(function (_authSession: AuthenticationSession.AuthenticationSession) { + authSession = _authSession; + return vars.regulator.regulate(username); + }) + .then(function () { + vars.logger.info(req, "No regulation applied."); + return vars.ldapAuthenticator.authenticate(username, password); + }) + .then(function (groupsAndEmails: GroupsAndEmails) { + vars.logger.info(req, "LDAP binding successful. Retrieved information about user are %s", + JSON.stringify(groupsAndEmails)); + authSession.userid = username; + authSession.first_factor = true; + const redirectUrl = req.query[Constants.REDIRECT_QUERY_PARAM] !== "undefined" + // Fuck, don't know why it is a string! + ? req.query[Constants.REDIRECT_QUERY_PARAM] + : undefined; + + const emails: string[] = groupsAndEmails.emails; + const groups: string[] = groupsAndEmails.groups; + const redirectHost: string = DomainExtractor.fromUrl(redirectUrl); + const authMethod = + new AuthenticationMethodCalculator(vars.config.authentication_methods) + .compute(redirectHost); + vars.logger.debug(req, "Authentication method for \"%s\" is \"%s\"", redirectHost, authMethod); + + if (!emails || emails.length <= 0) { + const errMessage = + "No emails found. The user should have at least one email address to reset password."; + vars.logger.error(req, "%s", errMessage); + return BluebirdPromise.reject(new Error(errMessage)); + } + + authSession.email = emails[0]; + authSession.groups = groups; + + vars.logger.debug(req, "Mark successful authentication to regulator."); + vars.regulator.mark(username, true); + + if (authMethod == "basic_auth") { + res.send({ + redirect: redirectUrl + }); + vars.logger.debug(req, "Redirect to '%s'", redirectUrl); + } + else if (authMethod == "two_factor") { + let newRedirectUrl = Endpoint.SECOND_FACTOR_GET; + if (redirectUrl) { + newRedirectUrl += "?" + Constants.REDIRECT_QUERY_PARAM + "=" + + encodeURIComponent(redirectUrl); + } + vars.logger.debug(req, "Redirect to '%s'", newRedirectUrl, typeof redirectUrl); + res.send({ + redirect: newRedirectUrl + }); + } + else { + return BluebirdPromise.reject(new Error("Unknown authentication method for this domain.")); + } + return BluebirdPromise.resolve(); + }) + .catch(Exceptions.LdapBindError, function (err: Error) { + vars.regulator.mark(username, false); + return ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.OPERATION_FAILED)(err); + }) + .catch(ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.OPERATION_FAILED)); + }; +} \ No newline at end of file diff --git a/server/src/lib/routes/secondfactor/redirect.ts b/server/src/lib/routes/secondfactor/redirect.ts index 4f258e8ab..cbfba27b7 100644 --- a/server/src/lib/routes/secondfactor/redirect.ts +++ b/server/src/lib/routes/secondfactor/redirect.ts @@ -7,6 +7,7 @@ import { ServerVariablesHandler } from "../../ServerVariablesHandler"; import AuthenticationSession = require("../../AuthenticationSession"); import BluebirdPromise = require("bluebird"); import ErrorReplies = require("../../ErrorReplies"); +import UserMessages = require("../../../../../shared/UserMessages"); export default function (req: express.Request, res: express.Response): BluebirdPromise { const logger = ServerVariablesHandler.getLogger(req.app); @@ -19,5 +20,5 @@ export default function (req: express.Request, res: express.Response): BluebirdP return BluebirdPromise.resolve(); }) .catch(ErrorReplies.replyWithError200(req, res, logger, - "Unexpected error.")); + UserMessages.OPERATION_FAILED)); } \ No newline at end of file diff --git a/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts b/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts index d289773fe..c63cbae7b 100644 --- a/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts +++ b/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts @@ -67,8 +67,8 @@ export default class RegistrationHandler implements IdentityValidable { } const userDataStore = ServerVariablesHandler.getUserDataStore(req.app); - const totpGenerator = ServerVariablesHandler.getTOTPGenerator(req.app); - const secret = totpGenerator.generate(); + const totpHandler = ServerVariablesHandler.getTotpHandler(req.app); + const secret = totpHandler.generate(); logger.debug(req, "Save the TOTP secret in DB"); return userDataStore.saveTOTPSecret(userid, secret) diff --git a/server/src/lib/routes/secondfactor/totp/sign/post.ts b/server/src/lib/routes/secondfactor/totp/sign/post.ts index d9b5afd9f..194242bbf 100644 --- a/server/src/lib/routes/secondfactor/totp/sign/post.ts +++ b/server/src/lib/routes/secondfactor/totp/sign/post.ts @@ -11,34 +11,34 @@ import ErrorReplies = require("../../../../ErrorReplies"); import { ServerVariablesHandler } from "./../../../../ServerVariablesHandler"; import AuthenticationSession = require("../../../../AuthenticationSession"); import UserMessages = require("../../../../../../../shared/UserMessages"); +import { ServerVariables } from "../../../../ServerVariables"; const UNAUTHORIZED_MESSAGE = "Unauthorized access"; -export default FirstFactorBlocker(handler); +export default function (vars: ServerVariables) { + function handler(req: express.Request, res: express.Response): BluebirdPromise { + let authSession: AuthenticationSession.AuthenticationSession; + const token = req.body.token; -export function handler(req: express.Request, res: express.Response): BluebirdPromise { - let authSession: AuthenticationSession.AuthenticationSession; - const logger = ServerVariablesHandler.getLogger(req.app); - const token = req.body.token; - const totpValidator = ServerVariablesHandler.getTOTPValidator(req.app); - const userDataStore = ServerVariablesHandler.getUserDataStore(req.app); + return AuthenticationSession.get(req) + .then(function (_authSession: AuthenticationSession.AuthenticationSession) { + authSession = _authSession; + vars.logger.info(req, "Initiate TOTP validation for user '%s'.", authSession.userid); + return vars.userDataStore.retrieveTOTPSecret(authSession.userid); + }) + .then(function (doc: TOTPSecretDocument) { + vars.logger.debug(req, "TOTP secret is %s", JSON.stringify(doc)); - return AuthenticationSession.get(req) - .then(function (_authSession: AuthenticationSession.AuthenticationSession) { - authSession = _authSession; - logger.info(req, "Initiate TOTP validation for user '%s'.", authSession.userid); - return userDataStore.retrieveTOTPSecret(authSession.userid); - }) - .then(function (doc: TOTPSecretDocument) { - logger.debug(req, "TOTP secret is %s", JSON.stringify(doc)); - return totpValidator.validate(token, doc.secret.base32); - }) - .then(function () { - logger.debug(req, "TOTP validation succeeded."); - authSession.second_factor = true; - redirect(req, res); - return BluebirdPromise.resolve(); - }) - .catch(ErrorReplies.replyWithError200(req, res, logger, - UserMessages.OPERATION_FAILED)); + if (!vars.totpHandler.validate(token, doc.secret.base32)) + return BluebirdPromise.reject(new Error("Invalid TOTP token.")); + + vars.logger.debug(req, "TOTP validation succeeded."); + authSession.second_factor = true; + redirect(req, res); + return BluebirdPromise.resolve(); + }) + .catch(ErrorReplies.replyWithError200(req, res, vars.logger, + UserMessages.OPERATION_FAILED)); + } + return FirstFactorBlocker(handler); } diff --git a/server/src/lib/routes/verify/get.ts b/server/src/lib/routes/verify/get.ts index 87c40f891..1afb53852 100644 --- a/server/src/lib/routes/verify/get.ts +++ b/server/src/lib/routes/verify/get.ts @@ -6,19 +6,22 @@ import exceptions = require("../../Exceptions"); import winston = require("winston"); import AuthenticationValidator = require("../../AuthenticationValidator"); import ErrorReplies = require("../../ErrorReplies"); -import { ServerVariablesHandler } from "../../ServerVariablesHandler"; +import { AppConfiguration } from "../../configuration/Configuration"; import AuthenticationSession = require("../../AuthenticationSession"); import Constants = require("../../../../../shared/constants"); import Util = require("util"); import { DomainExtractor } from "../../utils/DomainExtractor"; +import { ServerVariables } from "../../ServerVariables"; +import { AuthenticationMethodCalculator } from "../../AuthenticationMethodCalculator"; const FIRST_FACTOR_NOT_VALIDATED_MESSAGE = "First factor not yet validated"; const SECOND_FACTOR_NOT_VALIDATED_MESSAGE = "Second factor not yet validated"; -function verify_filter(req: express.Request, res: express.Response): BluebirdPromise { - const logger = ServerVariablesHandler.getLogger(req.app); - const accessController = ServerVariablesHandler.getAccessController(req.app); - const authenticationMethodsCalculator = ServerVariablesHandler.getAuthenticationMethodCalculator(req.app); +const REMOTE_USER = "Remote-User"; +const REMOTE_GROUPS = "Remote-Groups"; + +function verify_filter(req: express.Request, res: express.Response, + vars: ServerVariables): BluebirdPromise { return AuthenticationSession.get(req) .then(function (authSession) { @@ -35,15 +38,17 @@ function verify_filter(req: express.Request, res: express.Response): BluebirdPro const path = objectPath.get(req, "headers.x-original-uri"); const domain = DomainExtractor.fromHostHeader(host); - const authenticationMethod = authenticationMethodsCalculator.compute(domain); - logger.debug(req, "domain=%s, path=%s, user=%s, groups=%s", domain, path, + const authenticationMethod = + new AuthenticationMethodCalculator(vars.config.authentication_methods) + .compute(domain); + vars.logger.debug(req, "domain=%s, path=%s, user=%s, groups=%s", domain, path, username, groups.join(",")); if (!authSession.first_factor) return BluebirdPromise.reject( new exceptions.AccessDeniedError(FIRST_FACTOR_NOT_VALIDATED_MESSAGE)); - const isAllowed = accessController.isAccessAllowed(domain, path, username, groups); + 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))); @@ -52,25 +57,27 @@ function verify_filter(req: express.Request, res: express.Response): BluebirdPro return BluebirdPromise.reject( new exceptions.AccessDeniedError(SECOND_FACTOR_NOT_VALIDATED_MESSAGE)); - res.setHeader("Remote-User", username); - res.setHeader("Remote-Groups", groups.join(",")); + res.setHeader(REMOTE_USER, username); + res.setHeader(REMOTE_GROUPS, groups.join(",")); return BluebirdPromise.resolve(); }); } -export default function (req: express.Request, res: express.Response): BluebirdPromise { - const logger = ServerVariablesHandler.getLogger(req.app); - return verify_filter(req, res) - .then(function () { - res.status(204); - res.send(); - return BluebirdPromise.resolve(); - }) - // The user is authenticated but has restricted access -> 403 - .catch(exceptions.DomainAccessDenied, ErrorReplies - .replyWithError403(req, res, logger)) - // The user is not yet authenticated -> 401 - .catch(ErrorReplies.replyWithError401(req, res, logger)); +export default function (vars: ServerVariables) { + return function (req: express.Request, res: express.Response) + : BluebirdPromise { + return verify_filter(req, res, vars) + .then(function () { + res.status(204); + res.send(); + return BluebirdPromise.resolve(); + }) + // The user is authenticated but has restricted access -> 403 + .catch(exceptions.DomainAccessDenied, ErrorReplies + .replyWithError403(req, res, vars.logger)) + // The user is not yet authenticated -> 401 + .catch(ErrorReplies.replyWithError401(req, res, vars.logger)); + }; } diff --git a/server/test/ServerConfiguration.test.ts b/server/test/ServerConfiguration.test.ts index e33277655..9d67027ac 100644 --- a/server/test/ServerConfiguration.test.ts +++ b/server/test/ServerConfiguration.test.ts @@ -1,5 +1,5 @@ -import assert = require("assert"); +import Assert = require("assert"); import Sinon = require("sinon"); import nedb = require("nedb"); import express = require("express"); @@ -72,9 +72,10 @@ describe("test server configuration", function () { }; const server = new Server(deps); - server.start(config, deps); - - assert(sessionMock.calledOnce); - assert.equal(sessionMock.getCall(0).args[0].cookie.domain, "example.com"); + server.start(config, deps) + .then(function () { + Assert(sessionMock.calledOnce); + Assert.equal(sessionMock.getCall(0).args[0].cookie.domain, "example.com"); + }); }); }); diff --git a/server/test/TOTPValidator.test.ts b/server/test/TOTPValidator.test.ts deleted file mode 100644 index 2856583f1..000000000 --- a/server/test/TOTPValidator.test.ts +++ /dev/null @@ -1,41 +0,0 @@ - -import { TOTPValidator } from "../src/lib/TOTPValidator"; -import Sinon = require("sinon"); -import Speakeasy = require("speakeasy"); - -describe("test TOTP validation", function() { - let totpValidator: TOTPValidator; - let totpValidateStub: Sinon.SinonStub; - - beforeEach(() => { - totpValidateStub = Sinon.stub(Speakeasy.totp, "verify"); - totpValidator = new TOTPValidator(Speakeasy); - }); - - afterEach(function() { - totpValidateStub.restore(); - }); - - it("should validate the TOTP token", function() { - const totp_secret = "NBD2ZV64R9UV1O7K"; - const token = "token"; - totpValidateStub.withArgs({ - secret: totp_secret, - token: token, - encoding: "base32", - window: 1 - }).returns(true); - return totpValidator.validate(token, totp_secret); - }); - - it("should not validate a wrong TOTP token", function(done) { - const totp_secret = "NBD2ZV64R9UV1O7K"; - const token = "wrong token"; - totpValidateStub.returns(false); - totpValidator.validate(token, totp_secret) - .catch(function() { - done(); - }); - }); -}); - diff --git a/server/test/authentication/totp/Validator.test.ts b/server/test/authentication/totp/Validator.test.ts new file mode 100644 index 000000000..3d0518440 --- /dev/null +++ b/server/test/authentication/totp/Validator.test.ts @@ -0,0 +1,39 @@ + +import { TotpHandler } from "../../../src/lib/authentication/totp/TotpHandler"; +import Sinon = require("sinon"); +import Speakeasy = require("speakeasy"); +import Assert = require("assert"); + +describe("test TOTP validation", function() { + let totpValidator: TotpHandler; + let validateStub: Sinon.SinonStub; + + beforeEach(() => { + validateStub = Sinon.stub(Speakeasy.totp, "verify"); + totpValidator = new TotpHandler(Speakeasy); + }); + + afterEach(function() { + validateStub.restore(); + }); + + it("should validate the TOTP token", function() { + const totp_secret = "NBD2ZV64R9UV1O7K"; + const token = "token"; + validateStub.withArgs({ + secret: totp_secret, + token: token, + encoding: "base32", + window: 1 + }).returns(true); + Assert(totpValidator.validate(token, totp_secret)); + }); + + it("should not validate a wrong TOTP token", function() { + const totp_secret = "NBD2ZV64R9UV1O7K"; + const token = "wrong token"; + validateStub.returns(false); + Assert(!totpValidator.validate(token, totp_secret)); + }); +}); + diff --git a/server/test/mocks/NotifierStub.ts b/server/test/mocks/NotifierStub.ts new file mode 100644 index 000000000..94a178e82 --- /dev/null +++ b/server/test/mocks/NotifierStub.ts @@ -0,0 +1,16 @@ +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); + +import { INotifier } from "../../src/lib/notifiers/INotifier"; + +export class NotifierStub implements INotifier { + notifyStub: Sinon.SinonStub; + + constructor() { + this.notifyStub = Sinon.stub(); + } + + notify(to: string, subject: string, link: string): BluebirdPromise { + return this.notifyStub(to, subject, link); + } +} \ No newline at end of file diff --git a/server/test/mocks/RegulatorStub.ts b/server/test/mocks/RegulatorStub.ts new file mode 100644 index 000000000..203c0e23a --- /dev/null +++ b/server/test/mocks/RegulatorStub.ts @@ -0,0 +1,21 @@ +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import { IRegulator } from "../../src/lib/regulation/IRegulator"; + +export class RegulatorStub implements IRegulator { + markStub: Sinon.SinonStub; + regulateStub: Sinon.SinonStub; + + constructor() { + this.markStub = Sinon.stub(); + this.regulateStub = Sinon.stub(); + } + + mark(userId: string, isAuthenticationSuccessful: boolean): BluebirdPromise { + return this.markStub(userId, isAuthenticationSuccessful); + } + + regulate(userId: string): BluebirdPromise { + return this.regulateStub(userId); + } +} \ No newline at end of file diff --git a/server/test/mocks/ServerVariablesMockBuilder.ts b/server/test/mocks/ServerVariablesMockBuilder.ts new file mode 100644 index 000000000..accfb0652 --- /dev/null +++ b/server/test/mocks/ServerVariablesMockBuilder.ts @@ -0,0 +1,91 @@ +import { ServerVariables } from "../../src/lib/ServerVariables"; + +import { AppConfiguration } from "../../src/lib/configuration/Configuration"; +import { AuthenticatorStub } from "./ldap/AuthenticatorStub"; +import { EmailsRetrieverStub } from "./ldap/EmailsRetrieverStub"; +import { PasswordUpdaterStub } from "./ldap/PasswordUpdaterStub"; +import { AccessControllerStub } from "./AccessControllerStub"; +import { RequestLoggerStub } from "./RequestLoggerStub"; +import { NotifierStub } from "./NotifierStub"; +import { RegulatorStub } from "./RegulatorStub"; +import { TotpHandlerStub } from "./TotpHandlerStub"; +import { UserDataStoreStub } from "./storage/UserDataStoreStub"; +import { U2fHandlerStub } from "./U2fHandlerStub"; + +export interface ServerVariablesMock { + accessController: AccessControllerStub; + config: AppConfiguration; + ldapAuthenticator: AuthenticatorStub; + ldapEmailsRetriever: EmailsRetrieverStub; + ldapPasswordUpdater: PasswordUpdaterStub; + logger: RequestLoggerStub; + notifier: NotifierStub; + regulator: RegulatorStub; + totpHandler: TotpHandlerStub; + userDataStore: UserDataStoreStub; + u2f: U2fHandlerStub; +} + +export class ServerVariablesMockBuilder { + static build(): { variables: ServerVariables, mocks: ServerVariablesMock} { + const mocks: ServerVariablesMock = { + accessController: new AccessControllerStub(), + config: { + access_control: {}, + authentication_methods: { + default_method: "two_factor" + }, + ldap: { + url: "ldap://ldap", + user: "user", + password: "password", + mail_attribute: "mail", + users_dn: "ou=users,dc=example,dc=com", + groups_dn: "ou=groups,dc=example,dc=com", + users_filter: "cn={0}", + groups_filter: "member={dn}", + group_name_attribute: "cn" + }, + logs_level: "debug", + notifier: {}, + port: 8080, + regulation: { + ban_time: 50, + find_time: 50, + max_retries: 3 + }, + session: { + secret: "my_secret" + }, + storage: {} + }, + ldapAuthenticator: new AuthenticatorStub(), + ldapEmailsRetriever: new EmailsRetrieverStub(), + ldapPasswordUpdater: new PasswordUpdaterStub(), + logger: new RequestLoggerStub(), + notifier: new NotifierStub(), + regulator: new RegulatorStub(), + totpHandler: new TotpHandlerStub(), + userDataStore: new UserDataStoreStub(), + u2f: new U2fHandlerStub() + }; + const vars: ServerVariables = { + accessController: mocks.accessController, + config: mocks.config, + ldapAuthenticator: mocks.ldapAuthenticator, + ldapEmailsRetriever: mocks.ldapEmailsRetriever, + ldapPasswordUpdater: mocks.ldapPasswordUpdater, + logger: mocks.logger, + notifier: mocks.notifier, + regulator: mocks.regulator, + totpHandler: mocks.totpHandler, + userDataStore: mocks.userDataStore, + u2f: mocks.u2f + }; + + return { + variables: vars, + mocks: mocks + }; + } +} \ No newline at end of file diff --git a/server/test/mocks/TotpHandlerStub.ts b/server/test/mocks/TotpHandlerStub.ts new file mode 100644 index 000000000..276dbb15a --- /dev/null +++ b/server/test/mocks/TotpHandlerStub.ts @@ -0,0 +1,22 @@ +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import { ITotpHandler, GenerateSecretOptions } from "../../src/lib/authentication/totp/ITotpHandler"; +import { TOTPSecret } from "../../types/TOTPSecret"; + +export class TotpHandlerStub implements ITotpHandler { + generateStub: Sinon.SinonStub; + validateStub: Sinon.SinonStub; + + constructor() { + this.generateStub = Sinon.stub(); + this.validateStub = Sinon.stub(); + } + + generate(options?: GenerateSecretOptions): TOTPSecret { + return this.generateStub(options); + } + + validate(token: string, secret: string): boolean { + return this.validateStub(token, secret); + } +} \ No newline at end of file diff --git a/server/test/mocks/U2fHandlerStub.ts b/server/test/mocks/U2fHandlerStub.ts new file mode 100644 index 000000000..95256641f --- /dev/null +++ b/server/test/mocks/U2fHandlerStub.ts @@ -0,0 +1,31 @@ +import Sinon = require("sinon"); +import BluebirdPromise = require("bluebird"); +import U2f = require("u2f"); +import { IU2fHandler } from "../../src/lib/authentication/u2f/IU2fHandler"; + + +export class U2fHandlerStub implements IU2fHandler { + requestStub: Sinon.SinonStub; + checkRegistrationStub: Sinon.SinonStub; + checkSignatureStub: Sinon.SinonStub; + + constructor() { + this.requestStub = Sinon.stub(); + this.checkRegistrationStub = Sinon.stub(); + this.checkSignatureStub = Sinon.stub(); + } + + request(appId: string, keyHandle?: string): U2f.Request { + return this.requestStub(appId, keyHandle); + } + + checkRegistration(registrationRequest: U2f.Request, registrationResponse: U2f.RegistrationData) + : U2f.RegistrationResult | U2f.Error { + return this.checkRegistrationStub(registrationRequest, registrationResponse); + } + + checkSignature(signatureRequest: U2f.Request, signatureResponse: U2f.SignatureData, publicKey: string) + : U2f.SignatureResult | U2f.Error { + return this.checkSignatureStub(signatureRequest, signatureResponse, publicKey); + } +} \ No newline at end of file diff --git a/server/test/mocks/ldap/AuthenticatorStub.ts b/server/test/mocks/ldap/AuthenticatorStub.ts new file mode 100644 index 000000000..4102b0fe8 --- /dev/null +++ b/server/test/mocks/ldap/AuthenticatorStub.ts @@ -0,0 +1,16 @@ +import BluebirdPromise = require("bluebird"); +import { IAuthenticator } from "../../../src/lib/ldap/IAuthenticator"; +import { GroupsAndEmails } from "../../../src/lib/ldap/IClient"; +import Sinon = require("sinon"); + +export class AuthenticatorStub implements IAuthenticator { + authenticateStub: Sinon.SinonStub; + + constructor() { + this.authenticateStub = Sinon.stub(); + } + + authenticate(username: string, password: string): BluebirdPromise { + return this.authenticateStub(username, password); + } +} \ No newline at end of file diff --git a/server/test/mocks/ldap/EmailsRetrieverStub.ts b/server/test/mocks/ldap/EmailsRetrieverStub.ts new file mode 100644 index 000000000..0e2b87546 --- /dev/null +++ b/server/test/mocks/ldap/EmailsRetrieverStub.ts @@ -0,0 +1,16 @@ +import BluebirdPromise = require("bluebird"); +import { IClient } from "../../../src/lib/ldap/IClient"; +import { IEmailsRetriever } from "../../../src/lib/ldap/IEmailsRetriever"; +import Sinon = require("sinon"); + +export class EmailsRetrieverStub implements IEmailsRetriever { + retrieveStub: Sinon.SinonStub; + + constructor() { + this.retrieveStub = Sinon.stub(); + } + + retrieve(username: string, client?: IClient): BluebirdPromise { + return this.retrieveStub(username, client); + } +} \ No newline at end of file diff --git a/server/test/mocks/ldap/PasswordUpdaterStub.ts b/server/test/mocks/ldap/PasswordUpdaterStub.ts new file mode 100644 index 000000000..9443dddbb --- /dev/null +++ b/server/test/mocks/ldap/PasswordUpdaterStub.ts @@ -0,0 +1,16 @@ +import BluebirdPromise = require("bluebird"); +import { IClient } from "../../../src/lib/ldap/IClient"; +import { IPasswordUpdater } from "../../../src/lib/ldap/IPasswordUpdater"; +import Sinon = require("sinon"); + +export class PasswordUpdaterStub implements IPasswordUpdater { + updatePasswordStub: Sinon.SinonStub; + + constructor() { + this.updatePasswordStub = Sinon.stub(); + } + + updatePassword(username: string, newPassword: string): BluebirdPromise { + return this.updatePasswordStub(username, newPassword); + } +} \ No newline at end of file diff --git a/server/test/AuthenticationRegulator.test.ts b/server/test/regulation/Regulator.test.ts similarity index 84% rename from server/test/AuthenticationRegulator.test.ts rename to server/test/regulation/Regulator.test.ts index f3e40aa2d..5f10fd056 100644 --- a/server/test/AuthenticationRegulator.test.ts +++ b/server/test/regulation/Regulator.test.ts @@ -3,10 +3,10 @@ import Sinon = require("sinon"); import BluebirdPromise = require("bluebird"); import Assert = require("assert"); -import { AuthenticationRegulator } from "../src/lib/AuthenticationRegulator"; +import { Regulator } from "../../src/lib/regulation/Regulator"; import MockDate = require("mockdate"); -import exceptions = require("../src/lib/Exceptions"); -import { UserDataStoreStub } from "./mocks/storage/UserDataStoreStub"; +import exceptions = require("../../src/lib/Exceptions"); +import { UserDataStoreStub } from "../mocks/storage/UserDataStoreStub"; describe("test authentication regulator", function () { const USER1 = "USER1"; @@ -39,13 +39,13 @@ describe("test authentication regulator", function () { MockDate.reset(); }); - function markAuthenticationAt(regulator: AuthenticationRegulator, user: string, time: string, success: boolean) { + function markAuthenticationAt(regulator: Regulator, user: string, time: string, success: boolean) { MockDate.set(time); return regulator.mark(user, success); } it("should mark 2 authentication and regulate (accept)", function () { - const regulator = new AuthenticationRegulator(userDataStoreStub, 3, 10, 10); + const regulator = new Regulator(userDataStoreStub, 3, 10, 10); return regulator.mark(USER1, false) .then(function () { @@ -57,7 +57,7 @@ describe("test authentication regulator", function () { }); it("should mark 3 authentications and regulate (reject)", function () { - const regulator = new AuthenticationRegulator(userDataStoreStub, 3, 10, 10); + const regulator = new Regulator(userDataStoreStub, 3, 10, 10); return regulator.mark(USER1, false) .then(function () { @@ -76,7 +76,7 @@ describe("test authentication regulator", function () { }); it("should mark 1 failed, 1 successful and 1 failed authentications within minimum time and regulate (accept)", function () { - const regulator = new AuthenticationRegulator(userDataStoreStub, 3, 60, 30); + const regulator = new Regulator(userDataStoreStub, 3, 60, 30); return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:00", false) .then(function () { @@ -109,7 +109,7 @@ describe("test authentication regulator", function () { }); it("should regulate user if number of failures is greater than 3 in allowed time lapse", function () { - function markAuthentications(regulator: AuthenticationRegulator, user: string) { + function markAuthentications(regulator: Regulator, user: string) { return markAuthenticationAt(regulator, user, "1/2/2000 00:00:00", false) .then(function () { return markAuthenticationAt(regulator, user, "1/2/2000 00:00:45", false); @@ -122,8 +122,8 @@ describe("test authentication regulator", function () { }); } - const regulator1 = new AuthenticationRegulator(userDataStoreStub, 3, 60, 60); - const regulator2 = new AuthenticationRegulator(userDataStoreStub, 3, 2 * 60, 60); + const regulator1 = new Regulator(userDataStoreStub, 3, 60, 60); + const regulator2 = new Regulator(userDataStoreStub, 3, 2 * 60, 60); const p1 = markAuthentications(regulator1, USER1); const p2 = markAuthentications(regulator2, USER2); @@ -138,7 +138,7 @@ describe("test authentication regulator", function () { }); it("should user wait after regulation to authenticate again", function () { - function markAuthentications(regulator: AuthenticationRegulator, user: string) { + function markAuthentications(regulator: Regulator, user: string) { return markAuthenticationAt(regulator, user, "1/2/2000 00:00:00", false) .then(function () { return markAuthenticationAt(regulator, user, "1/2/2000 00:00:10", false); @@ -161,13 +161,13 @@ describe("test authentication regulator", function () { }); } - const regulator = new AuthenticationRegulator(userDataStoreStub, 4, 30, 30); + const regulator = new Regulator(userDataStoreStub, 4, 30, 30); return markAuthentications(regulator, USER1); }); it("should disable regulation when max_retries is set to 0", function () { const maxRetries = 0; - const regulator = new AuthenticationRegulator(userDataStoreStub, maxRetries, 60, 30); + const regulator = new Regulator(userDataStoreStub, maxRetries, 60, 30); return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:00", false) .then(function () { return markAuthenticationAt(regulator, USER1, "1/2/2000 00:00:10", false); diff --git a/server/test/routes/firstfactor/post.test.ts b/server/test/routes/firstfactor/post.test.ts index 98de498bb..eb5fafec3 100644 --- a/server/test/routes/firstfactor/post.test.ts +++ b/server/test/routes/firstfactor/post.test.ts @@ -1,8 +1,8 @@ -import sinon = require("sinon"); +import Sinon = require("sinon"); import BluebirdPromise = require("bluebird"); import Assert = require("assert"); -import winston = require("winston"); +import Winston = require("winston"); import FirstFactorPost = require("../../../src/lib/routes/firstfactor/post"); import exceptions = require("../../../src/lib/Exceptions"); @@ -12,7 +12,7 @@ import Endpoints = require("../../../../shared/api"); import AuthenticationRegulatorMock = require("../../mocks/AuthenticationRegulator"); import { AccessControllerStub } from "../../mocks/AccessControllerStub"; import ExpressMock = require("../../mocks/express"); -import ServerVariablesMock = require("../../mocks/ServerVariablesMock"); +import { ServerVariablesMock, ServerVariablesMockBuilder } from "../../mocks/ServerVariablesMockBuilder"; import { ServerVariables } from "../../../src/lib/ServerVariables"; describe("test the first factor validation route", function () { @@ -20,32 +20,23 @@ describe("test the first factor validation route", function () { let res: ExpressMock.ResponseMock; let emails: string[]; let groups: string[]; - let configuration; - let regulator: AuthenticationRegulatorMock.AuthenticationRegulatorMock; - let accessController: AccessControllerStub; - let serverVariables: ServerVariables; + let vars: ServerVariables; + let mocks: ServerVariablesMock; beforeEach(function () { - configuration = { - ldap: { - base_dn: "ou=users,dc=example,dc=com", - user_name_attribute: "uid" - } - }; - emails = ["test_ok@example.com"]; groups = ["group1", "group2" ]; + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; - accessController = new AccessControllerStub(); - accessController.isAccessAllowedMock.returns(true); - - regulator = AuthenticationRegulatorMock.AuthenticationRegulatorMock(); - regulator.regulate.returns(BluebirdPromise.resolve()); - regulator.mark.returns(BluebirdPromise.resolve()); + mocks.accessController.isAccessAllowedMock.returns(true); + mocks.regulator.regulateStub.returns(BluebirdPromise.resolve()); + mocks.regulator.markStub.returns(BluebirdPromise.resolve()); req = { app: { - get: sinon.stub().returns({ logger: winston }) + get: Sinon.stub().returns({ logger: Winston }) }, body: { username: "username", @@ -62,20 +53,11 @@ describe("test the first factor validation route", function () { }; AuthenticationSession.reset(req as any); - - serverVariables = ServerVariablesMock.mock(req.app); - serverVariables.ldapAuthenticator = { - authenticate: sinon.stub() - } as any; - serverVariables.config = configuration as any; - serverVariables.regulator = regulator as any; - serverVariables.accessController = accessController as any; - res = ExpressMock.ResponseMock(); }); it("should reply with 204 if success", function () { - (serverVariables.ldapAuthenticator as any).authenticate.withArgs("username", "password") + mocks.ldapAuthenticator.authenticateStub.withArgs("username", "password") .returns(BluebirdPromise.resolve({ emails: emails, groups: groups @@ -84,7 +66,7 @@ describe("test the first factor validation route", function () { return AuthenticationSession.get(req as any) .then(function (_authSession: AuthenticationSession.AuthenticationSession) { authSession = _authSession; - return FirstFactorPost.default(req as any, res as any); + return FirstFactorPost.default(vars)(req as any, res as any); }) .then(function () { Assert.equal("username", authSession.userid); @@ -93,15 +75,15 @@ describe("test the first factor validation route", function () { }); it("should retrieve email from LDAP", function () { - (serverVariables.ldapAuthenticator as any).authenticate.withArgs("username", "password") + mocks.ldapAuthenticator.authenticateStub.withArgs("username", "password") .returns(BluebirdPromise.resolve([{ mail: ["test@example.com"] }])); - return FirstFactorPost.default(req as any, res as any); + return FirstFactorPost.default(vars)(req as any, res as any); }); it("should set first email address as user session variable", function () { const emails = ["test_ok@example.com"]; let authSession: AuthenticationSession.AuthenticationSession; - (serverVariables.ldapAuthenticator as any).authenticate.withArgs("username", "password") + mocks.ldapAuthenticator.authenticateStub.withArgs("username", "password") .returns(BluebirdPromise.resolve({ emails: emails, groups: groups @@ -110,7 +92,7 @@ describe("test the first factor validation route", function () { return AuthenticationSession.get(req as any) .then(function (_authSession: AuthenticationSession.AuthenticationSession) { authSession = _authSession; - return FirstFactorPost.default(req as any, res as any); + return FirstFactorPost.default(vars)(req as any, res as any); }) .then(function () { Assert.equal("test_ok@example.com", authSession.email); @@ -118,13 +100,13 @@ describe("test the first factor validation route", function () { }); it("should return error message when LDAP authenticator throws", function () { - (serverVariables.ldapAuthenticator as any).authenticate.withArgs("username", "password") + mocks.ldapAuthenticator.authenticateStub.withArgs("username", "password") .returns(BluebirdPromise.reject(new exceptions.LdapBindError("Bad credentials"))); - return FirstFactorPost.default(req as any, res as any) + return FirstFactorPost.default(vars)(req as any, res as any) .then(function () { Assert.equal(res.status.getCall(0).args[0], 200); - Assert.equal(regulator.mark.getCall(0).args[0], "username"); + Assert.equal(mocks.regulator.markStub.getCall(0).args[0], "username"); Assert.deepEqual(res.send.getCall(0).args[0], { error: "Operation failed." }); @@ -133,8 +115,8 @@ describe("test the first factor validation route", function () { it("should return error message when regulator rejects authentication", function () { const err = new exceptions.AuthenticationRegulationError("Authentication regulation..."); - regulator.regulate.returns(BluebirdPromise.reject(err)); - return FirstFactorPost.default(req as any, res as any) + mocks.regulator.regulateStub.returns(BluebirdPromise.reject(err)); + return FirstFactorPost.default(vars)(req as any, res as any) .then(function () { Assert.equal(res.status.getCall(0).args[0], 200); Assert.deepEqual(res.send.getCall(0).args[0], { diff --git a/server/test/routes/secondfactor/totp/sign/post.test.ts b/server/test/routes/secondfactor/totp/sign/post.test.ts index 22bfa5134..373308c3f 100644 --- a/server/test/routes/secondfactor/totp/sign/post.test.ts +++ b/server/test/routes/secondfactor/totp/sign/post.test.ts @@ -1,41 +1,44 @@ import BluebirdPromise = require("bluebird"); -import sinon = require("sinon"); +import Sinon = require("sinon"); import assert = require("assert"); import winston = require("winston"); import exceptions = require("../../../../../src/lib/Exceptions"); import AuthenticationSession = require("../../../../../src/lib/AuthenticationSession"); import SignPost = require("../../../../../src/lib/routes/secondfactor/totp/sign/post"); +import { ServerVariables } from "../../../../../src/lib/ServerVariables"; import ExpressMock = require("../../../../mocks/express"); -import TOTPValidatorMock = require("../../../../mocks/TOTPValidator"); -import ServerVariablesMock = require("../../../../mocks/ServerVariablesMock"); import { UserDataStoreStub } from "../../../../mocks/storage/UserDataStoreStub"; +import { ServerVariablesMock, ServerVariablesMockBuilder } from "../../../../mocks/ServerVariablesMockBuilder"; describe("test totp route", function () { let req: ExpressMock.RequestMock; let res: ExpressMock.ResponseMock; - let totpValidator: TOTPValidatorMock.TOTPValidatorMock; let authSession: AuthenticationSession.AuthenticationSession; + let vars: ServerVariables; + let mocks: ServerVariablesMock; beforeEach(function () { - const app_get = sinon.stub(); + const s = ServerVariablesMockBuilder.build(); + vars = s.variables; + mocks = s.mocks; + const app_get = Sinon.stub(); req = { app: { - get: sinon.stub().returns({ logger: winston }) + get: Sinon.stub().returns({ logger: winston }) }, body: { token: "abc" }, - session: {} + session: {}, + query: { + redirect: "http://redirect" + } }; - AuthenticationSession.reset(req as any); - const mocks = ServerVariablesMock.mock(req.app); res = ExpressMock.ResponseMock(); - - const config = { totp_secret: "secret" }; - totpValidator = TOTPValidatorMock.TOTPValidatorMock(); + AuthenticationSession.reset(req as any); const doc = { userid: "user", @@ -44,9 +47,6 @@ describe("test totp route", function () { } }; mocks.userDataStore.retrieveTOTPSecretStub.returns(BluebirdPromise.resolve(doc)); - mocks.totpValidator = totpValidator; - mocks.config = config; - return AuthenticationSession.get(req as any) .then(function (_authSession: AuthenticationSession.AuthenticationSession) { authSession = _authSession; @@ -58,8 +58,8 @@ describe("test totp route", function () { it("should send status code 200 when totp is valid", function () { - totpValidator.validate.returns(BluebirdPromise.resolve("ok")); - return SignPost.default(req as any, res as any) + mocks.totpHandler.validateStub.returns(true); + return SignPost.default(vars)(req as any, res as any) .then(function () { assert.equal(true, authSession.second_factor); return BluebirdPromise.resolve(); @@ -67,8 +67,8 @@ describe("test totp route", function () { }); it("should send error message when totp is not valid", function () { - totpValidator.validate.returns(BluebirdPromise.reject(new exceptions.InvalidTOTPError("Bad TOTP token"))); - return SignPost.default(req as any, res as any) + mocks.totpHandler.validateStub.returns(false); + return SignPost.default(vars)(req as any, res as any) .then(function () { assert.equal(false, authSession.second_factor); assert.equal(res.status.getCall(0).args[0], 200); @@ -80,9 +80,9 @@ describe("test totp route", function () { }); it("should send status code 401 when session has not been initiated", function () { - totpValidator.validate.returns(BluebirdPromise.resolve("abc")); + mocks.totpHandler.validateStub.returns(true); req.session = {}; - return SignPost.default(req as any, res as any) + return SignPost.default(vars)(req as any, res as any) .then(function () { return BluebirdPromise.reject(new Error("It should fail")); }) .catch(function () { assert.equal(401, res.status.getCall(0).args[0]); diff --git a/server/test/routes/verify/get.test.ts b/server/test/routes/verify/get.test.ts index 7167199b1..cc89a2fee 100644 --- a/server/test/routes/verify/get.test.ts +++ b/server/test/routes/verify/get.test.ts @@ -4,27 +4,21 @@ import VerifyGet = require("../../../src/lib/routes/verify/get"); import AuthenticationSession = require("../../../src/lib/AuthenticationSession"); import { AuthenticationMethodCalculator } from "../../../src/lib/AuthenticationMethodCalculator"; import { AuthenticationMethodsConfiguration } from "../../../src/lib/configuration/Configuration"; - import Sinon = require("sinon"); import winston = require("winston"); import BluebirdPromise = require("bluebird"); - import express = require("express"); - import ExpressMock = require("../../mocks/express"); -import { AccessControllerStub } from "../../mocks/AccessControllerStub"; -import ServerVariablesMock = require("../../mocks/ServerVariablesMock"); +import { ServerVariables } from "../../../src/lib/ServerVariables"; +import { ServerVariablesMockBuilder, ServerVariablesMock } from "../../mocks/ServerVariablesMockBuilder"; -describe("test authentication token verification", function () { +describe("test /verify endpoint", function () { let req: ExpressMock.RequestMock; let res: ExpressMock.ResponseMock; - let accessController: AccessControllerStub; - let mocks: any; + let mocks: ServerVariablesMock; + let vars: ServerVariables; beforeEach(function () { - accessController = new AccessControllerStub(); - accessController.isAccessAllowedMock.returns(true); - req = ExpressMock.RequestMock(); res = ExpressMock.ResponseMock(); req.session = {}; @@ -37,20 +31,14 @@ describe("test authentication token verification", function () { AuthenticationSession.reset(req as any); req.headers = {}; req.headers.host = "secret.example.com"; - mocks = ServerVariablesMock.mock(req.app); - mocks.config = {} as any; - mocks.accessController = accessController as any; - const options: AuthenticationMethodsConfiguration = { - default_method: "two_factor", - per_subdomain_methods: { - "redirect.url": "basic_auth" - } - }; - mocks.authenticationMethodsCalculator = new AuthenticationMethodCalculator(options); + const s = ServerVariablesMockBuilder.build(); + mocks = s.mocks; + vars = s.variables; }); it("should be already authenticated", function () { req.session = {}; + mocks.accessController.isAccessAllowedMock.returns(true); AuthenticationSession.reset(req as any); return AuthenticationSession.get(req as any) .then(function (authSession: AuthenticationSession.AuthenticationSession) { @@ -58,7 +46,7 @@ describe("test authentication token verification", function () { authSession.second_factor = true; authSession.userid = "myuser"; authSession.groups = ["mygroup", "othergroup"]; - return VerifyGet.default(req as express.Request, res as any); + return VerifyGet.default(vars)(req as express.Request, res as any); }) .then(function () { Sinon.assert.calledWithExactly(res.setHeader, "Remote-User", "myuser"); @@ -71,26 +59,30 @@ describe("test authentication token verification", function () { return AuthenticationSession.get(req as any) .then(function (authSession) { authSession = _authSession; - return VerifyGet.default(req as express.Request, res as any); + return VerifyGet.default(vars)(req as express.Request, res as any); }) .then(function () { Assert.equal(status_code, res.status.getCall(0).args[0]); }); } - function test_non_authenticated_401(auth_session: AuthenticationSession.AuthenticationSession) { - return test_session(auth_session, 401); + function test_non_authenticated_401(authSession: AuthenticationSession.AuthenticationSession) { + return test_session(authSession, 401); } - function test_unauthorized_403(auth_session: AuthenticationSession.AuthenticationSession) { - return test_session(auth_session, 403); + function test_unauthorized_403(authSession: AuthenticationSession.AuthenticationSession) { + return test_session(authSession, 403); } - function test_authorized(auth_session: AuthenticationSession.AuthenticationSession) { - return test_session(auth_session, 204); + function test_authorized(authSession: AuthenticationSession.AuthenticationSession) { + return test_session(authSession, 204); } describe("given user tries to access a 2-factor endpoint", function () { + before(function() { + mocks.accessController.isAccessAllowedMock.returns(true); + }); + describe("given different cases of session", function () { it("should not be authenticated when second factor is missing", function () { return test_non_authenticated_401({ @@ -99,6 +91,7 @@ describe("test authentication token verification", function () { second_factor: false, email: undefined, groups: [], + last_activity_datetime: new Date() }); }); @@ -109,6 +102,7 @@ describe("test authentication token verification", function () { second_factor: true, email: undefined, groups: [], + last_activity_datetime: new Date() }); }); @@ -119,6 +113,7 @@ describe("test authentication token verification", function () { second_factor: false, email: undefined, groups: [], + last_activity_datetime: new Date() }); }); @@ -129,6 +124,7 @@ describe("test authentication token verification", function () { second_factor: false, email: undefined, groups: [], + last_activity_datetime: new Date() }); }); @@ -138,22 +134,20 @@ describe("test authentication token verification", function () { it("should not be authenticated when domain is not allowed for user", function () { return AuthenticationSession.get(req as any) - .then(function (authSession: AuthenticationSession.AuthenticationSession) { + .then(function (authSession) { authSession.first_factor = true; authSession.second_factor = true; authSession.userid = "myuser"; - req.headers.host = "test.example.com"; - - accessController.isAccessAllowedMock.returns(false); - accessController.isAccessAllowedMock.withArgs("test.example.com", "user", ["group1", "group2"]).returns(true); + mocks.accessController.isAccessAllowedMock.returns(false); return test_unauthorized_403({ first_factor: true, second_factor: true, userid: "user", groups: ["group1", "group2"], - email: undefined + email: undefined, + last_activity_datetime: new Date() }); }); }); @@ -166,14 +160,18 @@ describe("test authentication token verification", function () { redirect: "http://redirect.url" }; req.headers["host"] = "redirect.url"; + mocks.config.authentication_methods.per_subdomain_methods = { + "redirect.url": "basic_auth" + }; }); - it("should be authenticated when first factor is validated and not second factor", function () { + it("should be authenticated when first factor is validated and second factor is not", function () { + mocks.accessController.isAccessAllowedMock.returns(true); return AuthenticationSession.get(req as any) - .then(function (authSession: AuthenticationSession.AuthenticationSession) { + .then(function (authSession) { authSession.first_factor = true; authSession.userid = "user1"; - return VerifyGet.default(req as express.Request, res as any); + return VerifyGet.default(vars)(req as express.Request, res as any); }) .then(function () { Assert(res.status.calledWith(204)); @@ -182,10 +180,11 @@ describe("test authentication token verification", function () { }); it("should be rejected with 401 when first factor is not validated", function () { + mocks.accessController.isAccessAllowedMock.returns(true); return AuthenticationSession.get(req as any) - .then(function (authSession: AuthenticationSession.AuthenticationSession) { + .then(function (authSession) { authSession.first_factor = false; - return VerifyGet.default(req as express.Request, res as any); + return VerifyGet.default(vars)(req as express.Request, res as any); }) .then(function () { Assert(res.status.calledWith(401)); From b842792a16953823332c5d709d8245bafe1a2ad3 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Tue, 17 Oct 2017 00:38:10 +0200 Subject: [PATCH 2/2] 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. --- .gitignore | 1 + Gruntfile.js | 4 +- config.template.yml | 3 + config.test.yml | 64 +++++++++++++------ scripts/run-cucumber.sh | 3 + server/src/lib/AuthenticationSession.ts | 4 +- .../src/lib/configuration/Configuration.d.ts | 1 + .../lib/configuration/ConfigurationParser.ts | 1 + server/src/lib/routes/verify/get.ts | 58 +++++++++++++---- .../test/SessionConfigurationBuilder.test.ts | 1 + .../configuration/ConfigurationParser.test.ts | 40 ++++++++---- server/test/routes/verify/get.test.ts | 60 +++++++++++++++-- test/features/redirection.feature | 2 - test/features/regulation.feature | 3 +- test/features/session-timeout.feature | 24 +++++++ test/features/step_definitions/hooks.ts | 52 +++++++++++---- test/features/step_definitions/resilience.ts | 2 +- .../step_definitions/session-timeout.ts | 8 +++ 18 files changed, 265 insertions(+), 66 deletions(-) create mode 100755 scripts/run-cucumber.sh create mode 100644 test/features/session-timeout.feature create mode 100644 test/features/step_definitions/session-timeout.ts diff --git a/.gitignore b/.gitignore index f9cbd72b5..fc2aac8b3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ dist/ # Specific files /config.yml +/config.test.yml example/ldap/private.ldif diff --git a/Gruntfile.js b/Gruntfile.js index e0400f536..200799537 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -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", diff --git a/config.template.yml b/config.template.yml index c0239420e..77a2cbcf8 100644 --- a/config.template.yml +++ b/config.template.yml @@ -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. diff --git a/config.test.yml b/config.test.yml index 8f22fd83a..ea8518afc 100644 --- a/config.test.yml +++ b/config.test.yml @@ -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 @@ -156,10 +180,10 @@ regulation: max_retries: 3 # The length of time between login attempts before user is banned. - find_time: 15 + find_time: 15 # The length of time before a banned user can login again. - ban_time: 4 + ban_time: 4 # Configuration of the storage backend used to store data and secrets. # @@ -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,8 +215,8 @@ notifier: # Use a SMTP server for sending notifications smtp: username: test - password: test + password: password secure: false host: 'smtp' port: 1025 - sender: admin@example.com + sender: admin@example.com \ No newline at end of file diff --git a/scripts/run-cucumber.sh b/scripts/run-cucumber.sh new file mode 100755 index 000000000..af64ff08c --- /dev/null +++ b/scripts/run-cucumber.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +./node_modules/.bin/cucumber-js --colors --compiler ts:ts-node/register $* diff --git a/server/src/lib/AuthenticationSession.ts b/server/src/lib/AuthenticationSession.ts index ad692f96f..93317132b 100644 --- a/server/src/lib/AuthenticationSession.ts +++ b/server/src/lib/AuthenticationSession.ts @@ -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 { diff --git a/server/src/lib/configuration/Configuration.d.ts b/server/src/lib/configuration/Configuration.d.ts index 199a84bc0..a924551b3 100644 --- a/server/src/lib/configuration/Configuration.d.ts +++ b/server/src/lib/configuration/Configuration.d.ts @@ -62,6 +62,7 @@ export interface SessionRedisOptions { interface SessionCookieConfiguration { secret: string; expiration?: number; + inactivity?: number; domain?: string; redis?: SessionRedisOptions; } diff --git a/server/src/lib/configuration/ConfigurationParser.ts b/server/src/lib/configuration/ConfigurationParser.ts index bbbe9acf1..186ce5dae 100644 --- a/server/src/lib/configuration/ConfigurationParser.ts +++ b/server/src/lib/configuration/ConfigurationParser.ts @@ -71,6 +71,7 @@ function adaptFromUserConfiguration(userConfiguration: UserConfiguration) domain: ObjectPath.get(userConfiguration, "session.domain"), secret: ObjectPath.get(userConfiguration, "session.secret"), expiration: get_optional(userConfiguration, "session.expiration", 3600000), // in ms + inactivity: get_optional(userConfiguration, "session.inactivity", undefined), redis: ObjectPath.get(userConfiguration, "session.redis") }, storage: { diff --git a/server/src/lib/routes/verify/get.ts b/server/src/lib/routes/verify/get.ts index 1afb53852..13a86c0f1 100644 --- a/server/src/lib/routes/verify/get.ts +++ b/server/src/lib/routes/verify/get.ts @@ -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 { + + 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 { + 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(); }); } diff --git a/server/test/SessionConfigurationBuilder.test.ts b/server/test/SessionConfigurationBuilder.test.ts index c5a8cd91d..6273382f0 100644 --- a/server/test/SessionConfigurationBuilder.test.ts +++ b/server/test/SessionConfigurationBuilder.test.ts @@ -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 diff --git a/server/test/configuration/ConfigurationParser.test.ts b/server/test/configuration/ConfigurationParser.test.ts index f851dde02..e3b8c8904 100644 --- a/server/test/configuration/ConfigurationParser.test.ts +++ b/server/test/configuration/ConfigurationParser.test.ts @@ -60,17 +60,35 @@ describe("test config parser", function () { }); }); - it("should get the session attributes", function () { - const yaml_config = buildYamlConfig(); - yaml_config.session = { - domain: "example.com", - secret: "secret", - expiration: 3600 - }; - 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); + 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", + secret: "secret", + expiration: 3600 + }; + 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, undefined); + }); }); it("should get the log level", function () { diff --git a/server/test/routes/verify/get.test.ts b/server/test/routes/verify/get.test.ts index cc89a2fee..61c8e053d 100644 --- a/server/test/routes/verify/get.test.ts +++ b/server/test/routes/verify/get.test.ts @@ -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); + }); + }); + }); }); diff --git a/test/features/redirection.feature b/test/features/redirection.feature index 12fba93b2..1693357c1 100644 --- a/test/features/redirection.feature +++ b/test/features/redirection.feature @@ -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" diff --git a/test/features/regulation.feature b/test/features/regulation.feature index 69e5a69ba..baac96a2b 100644 --- a/test/features/regulation.feature +++ b/test/features/regulation.feature @@ -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" diff --git a/test/features/session-timeout.feature b/test/features/session-timeout.feature new file mode 100644 index 000000000..ebf429a42 --- /dev/null +++ b/test/features/session-timeout.feature @@ -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" \ No newline at end of file diff --git a/test/features/step_definitions/hooks.ts b/test/features/step_definitions/hooks.ts index b8dcddfcd..b2ff9f75a 100644 --- a/test/features/step_definitions/hooks.ts +++ b/test/features/step_definitions/hooks.ts @@ -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(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 { + 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 \ + "); + } - After({ tags: "@needs-test-config", timeout: 20 * 1000 }, function () { - return exec("./scripts/example-commit/dc-example.sh up -d authelia && sleep 2"); - }); + function createInactivityConfiguration(): BluebirdPromise { + 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) { + 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-" + 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 { - 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"); }); } diff --git a/test/features/step_definitions/resilience.ts b/test/features/step_definitions/resilience.ts index ad75a1437..509308416 100644 --- a/test/features/step_definitions/resilience.ts +++ b/test/features/step_definitions/resilience.ts @@ -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"); }); }); \ No newline at end of file diff --git a/test/features/step_definitions/session-timeout.ts b/test/features/step_definitions/session-timeout.ts new file mode 100644 index 000000000..98131274b --- /dev/null +++ b/test/features/step_definitions/session-timeout.ts @@ -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); + }); +}); \ No newline at end of file