From 3a88ca95b8fa364fc62564bf29386f455f56cd8c Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 14 Oct 2017 15:23:00 +0200 Subject: [PATCH] Check TOTP token with window of 1 A window of 1 means the token is checked against current time slot T as well as at time slot T-1 and T+1. A time slot is 30 seconds by default in Authelia. --- package.json | 1 - server/src/lib/TOTPGenerator.ts | 18 +++-- server/src/lib/TOTPValidator.ts | 22 +++--- server/test/TOTPValidator.test.ts | 21 +++-- server/test/storage/UserDataStore.test.ts | 7 +- server/types/TOTPSecret.ts | 11 ++- server/types/speakeasy.d.ts | 96 +++++++++++++++++++++++ test/features/support/world.ts | 18 ++--- 8 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 server/types/speakeasy.d.ts diff --git a/package.json b/package.json index c2fef38ec..d0ebe5e9c 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "@types/request-promise": "^4.1.38", "@types/selenium-webdriver": "^3.0.4", "@types/sinon": "^2.2.1", - "@types/speakeasy": "^2.0.1", "@types/tmp": "0.0.33", "@types/winston": "^2.3.2", "@types/yamljs": "^0.2.30", diff --git a/server/src/lib/TOTPGenerator.ts b/server/src/lib/TOTPGenerator.ts index 1dece0361..c0c7133bf 100644 --- a/server/src/lib/TOTPGenerator.ts +++ b/server/src/lib/TOTPGenerator.ts @@ -1,16 +1,24 @@ -import * as speakeasy from "speakeasy"; -import { Speakeasy } from "../../types/Dependencies"; +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: Speakeasy; + private speakeasy: typeof Speakeasy; - constructor(speakeasy: Speakeasy) { + constructor(speakeasy: typeof Speakeasy) { this.speakeasy = speakeasy; } - generate(options?: speakeasy.GenerateOptions): speakeasy.Key { + 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 index cd6cba33d..d99be78b8 100644 --- a/server/src/lib/TOTPValidator.ts +++ b/server/src/lib/TOTPValidator.ts @@ -1,23 +1,27 @@ - -import { Speakeasy } from "../../types/Dependencies"; +import Speakeasy = require("speakeasy"); import BluebirdPromise = require("bluebird"); const TOTP_ENCODING = "base32"; +const WINDOW: number = 1; export class TOTPValidator { - private speakeasy: Speakeasy; + private speakeasy: typeof Speakeasy; - constructor(speakeasy: Speakeasy) { + constructor(speakeasy: typeof Speakeasy) { this.speakeasy = speakeasy; } validate(token: string, secret: string): BluebirdPromise { - const real_token = this.speakeasy.totp({ + const isValid = this.speakeasy.totp.verify({ secret: secret, - encoding: TOTP_ENCODING - }); + encoding: TOTP_ENCODING, + token: token, + window: WINDOW + } as any); - if (token == real_token) return BluebirdPromise.resolve(); - return BluebirdPromise.reject(new Error("Wrong challenge")); + if (isValid) + return BluebirdPromise.resolve(); + else + return BluebirdPromise.reject(new Error("Wrong TOTP token.")); } } \ No newline at end of file diff --git a/server/test/TOTPValidator.test.ts b/server/test/TOTPValidator.test.ts index 6fd3981f0..2856583f1 100644 --- a/server/test/TOTPValidator.test.ts +++ b/server/test/TOTPValidator.test.ts @@ -1,26 +1,37 @@ import { TOTPValidator } from "../src/lib/TOTPValidator"; -import sinon = require("sinon"); -import Promise = require("bluebird"); -import SpeakeasyMock = require("./mocks/speakeasy"); +import Sinon = require("sinon"); +import Speakeasy = require("speakeasy"); describe("test TOTP validation", function() { let totpValidator: TOTPValidator; + let totpValidateStub: Sinon.SinonStub; beforeEach(() => { - SpeakeasyMock.totp.returns("token"); - totpValidator = new TOTPValidator(SpeakeasyMock as any); + 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/storage/UserDataStore.test.ts b/server/test/storage/UserDataStore.test.ts index fcf2abbb5..c43d52a1d 100644 --- a/server/test/storage/UserDataStore.test.ts +++ b/server/test/storage/UserDataStore.test.ts @@ -29,7 +29,12 @@ describe("test user data store", function () { totpSecret = { ascii: "abc", base32: "ABCDKZLEFZGREJK", - otpauth_url: "totp://test" + otpauth_url: "totp://test", + google_auth_qr: "dummy", + hex: "dummy", + qr_code_ascii: "dummy", + qr_code_base32: "dummy", + qr_code_hex: "dummy" }; u2fRegistration = { diff --git a/server/types/TOTPSecret.ts b/server/types/TOTPSecret.ts index 33ce602c0..d6775f2f0 100644 --- a/server/types/TOTPSecret.ts +++ b/server/types/TOTPSecret.ts @@ -1,6 +1,11 @@ export interface TOTPSecret { - base32: string; ascii: string; - otpauth_url?: string; -} \ No newline at end of file + hex: string; + base32: string; + qr_code_ascii: string; + qr_code_hex: string; + qr_code_base32: string; + google_auth_qr: string; + otpauth_url: string; + } \ No newline at end of file diff --git a/server/types/speakeasy.d.ts b/server/types/speakeasy.d.ts new file mode 100644 index 000000000..6ea06948b --- /dev/null +++ b/server/types/speakeasy.d.ts @@ -0,0 +1,96 @@ +declare module "speakeasy" { + export = speakeasy + + interface SharedOptions { + encoding?: string + algorithm?: string + } + + interface DigestOptions extends SharedOptions { + secret: string + counter: number + } + + interface HOTPOptions extends SharedOptions { + secret: string + counter: number + digest?: Buffer + digits?: number + } + + interface HOTPVerifyOptions extends SharedOptions { + secret: string + token: string + counter: number + digits?: number + window?: number + } + + interface TOTPOptions extends SharedOptions { + secret: string + time?: number + step?: number + epoch?: number + counter?: number + digits?: number + } + + interface TOTPVerifyOptions extends SharedOptions { + secret: string + token: string + time?: number + step?: number + epoch?: number + counter?: number + digits?: number + window?: number + } + + interface GenerateSecretOptions { + length?: number + symbols?: boolean + otpauth_url?: boolean + name?: string + issuer?: string + } + + interface GeneratedSecret { + ascii: string + hex: string + base32: string + qr_code_ascii: string + qr_code_hex: string + qr_code_base32: string + google_auth_qr: string + otpauth_url: string + } + + interface OTPAuthURLOptions extends SharedOptions { + secret: string + label: string + type?: string + counter?: number + issuer?: string + digits?: number + period?: number + } + + interface Speakeasy { + digest: (options: DigestOptions) => Buffer + hotp: { + (options: HOTPOptions): string, + verifyDelta: (options: HOTPVerifyOptions) => boolean, + verify: (options: HOTPVerifyOptions) => boolean, + } + totp: { + (options: TOTPOptions): string + verifyDelta: (options: TOTPVerifyOptions) => boolean, + verify: (options: TOTPVerifyOptions) => boolean, + } + generateSecret: (options?: GenerateSecretOptions) => GeneratedSecret + generateSecretASCII: (length?: number, symbols?: boolean) => string + otpauthURL: (options: OTPAuthURLOptions) => string + } + + const speakeasy: Speakeasy +} \ No newline at end of file diff --git a/test/features/support/world.ts b/test/features/support/world.ts index 2bed95561..275135b5f 100644 --- a/test/features/support/world.ts +++ b/test/features/support/world.ts @@ -21,6 +21,7 @@ function CustomWorld() { }; this.setFieldTo = function (fieldName: string, content: string) { + const that = this; return this.driver.findElement(seleniumWebdriver.By.id(fieldName)) .sendKeys(content); }; @@ -49,22 +50,19 @@ function CustomWorld() { .findElement(seleniumWebdriver.By.tagName("button")) .findElement(seleniumWebdriver.By.xpath("//button[contains(.,'" + buttonText + "')]")) .click(); - }) - .then(function () { - return that.driver.sleep(1000); }); }; - this.waitUntilUrlContains = function(url: string) { + this.waitUntilUrlContains = function (url: string) { const that = this; return this.driver.wait(seleniumWebdriver.until.urlIs(url), 15000) - .then(function() {}, function(err: Error) { - that.driver.getCurrentUrl() - .then(function(current: string) { - console.error("====> Error due to: %s (current) != %s (expected)", current, url); + .then(function () { }, function (err: Error) { + that.driver.getCurrentUrl() + .then(function (current: string) { + console.error("====> Error due to: %s (current) != %s (expected)", current, url); + }); + return BluebirdPromise.reject(err); }); - return BluebirdPromise.reject(err); - }); }; this.loginWithUserPassword = function (username: string, password: string) {