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.
pull/142/head
Clement Michaud 2017-10-14 15:23:00 +02:00
parent c02d9b4a6e
commit 3a88ca95b8
8 changed files with 160 additions and 34 deletions

View File

@ -71,7 +71,6 @@
"@types/request-promise": "^4.1.38", "@types/request-promise": "^4.1.38",
"@types/selenium-webdriver": "^3.0.4", "@types/selenium-webdriver": "^3.0.4",
"@types/sinon": "^2.2.1", "@types/sinon": "^2.2.1",
"@types/speakeasy": "^2.0.1",
"@types/tmp": "0.0.33", "@types/tmp": "0.0.33",
"@types/winston": "^2.3.2", "@types/winston": "^2.3.2",
"@types/yamljs": "^0.2.30", "@types/yamljs": "^0.2.30",

View File

@ -1,16 +1,24 @@
import * as speakeasy from "speakeasy"; import Speakeasy = require("speakeasy");
import { Speakeasy } from "../../types/Dependencies";
import BluebirdPromise = require("bluebird"); 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 { export class TOTPGenerator {
private speakeasy: Speakeasy; private speakeasy: typeof Speakeasy;
constructor(speakeasy: Speakeasy) { constructor(speakeasy: typeof Speakeasy) {
this.speakeasy = speakeasy; this.speakeasy = speakeasy;
} }
generate(options?: speakeasy.GenerateOptions): speakeasy.Key { generate(options?: GenerateSecretOptions): TOTPSecret {
return this.speakeasy.generateSecret(options); return this.speakeasy.generateSecret(options);
} }
} }

View File

@ -1,23 +1,27 @@
import Speakeasy = require("speakeasy");
import { Speakeasy } from "../../types/Dependencies";
import BluebirdPromise = require("bluebird"); import BluebirdPromise = require("bluebird");
const TOTP_ENCODING = "base32"; const TOTP_ENCODING = "base32";
const WINDOW: number = 1;
export class TOTPValidator { export class TOTPValidator {
private speakeasy: Speakeasy; private speakeasy: typeof Speakeasy;
constructor(speakeasy: Speakeasy) { constructor(speakeasy: typeof Speakeasy) {
this.speakeasy = speakeasy; this.speakeasy = speakeasy;
} }
validate(token: string, secret: string): BluebirdPromise<void> { validate(token: string, secret: string): BluebirdPromise<void> {
const real_token = this.speakeasy.totp({ const isValid = this.speakeasy.totp.verify({
secret: secret, secret: secret,
encoding: TOTP_ENCODING encoding: TOTP_ENCODING,
}); token: token,
window: WINDOW
} as any);
if (token == real_token) return BluebirdPromise.resolve(); if (isValid)
return BluebirdPromise.reject(new Error("Wrong challenge")); return BluebirdPromise.resolve();
else
return BluebirdPromise.reject(new Error("Wrong TOTP token."));
} }
} }

View File

@ -1,26 +1,37 @@
import { TOTPValidator } from "../src/lib/TOTPValidator"; import { TOTPValidator } from "../src/lib/TOTPValidator";
import sinon = require("sinon"); import Sinon = require("sinon");
import Promise = require("bluebird"); import Speakeasy = require("speakeasy");
import SpeakeasyMock = require("./mocks/speakeasy");
describe("test TOTP validation", function() { describe("test TOTP validation", function() {
let totpValidator: TOTPValidator; let totpValidator: TOTPValidator;
let totpValidateStub: Sinon.SinonStub;
beforeEach(() => { beforeEach(() => {
SpeakeasyMock.totp.returns("token"); totpValidateStub = Sinon.stub(Speakeasy.totp, "verify");
totpValidator = new TOTPValidator(SpeakeasyMock as any); totpValidator = new TOTPValidator(Speakeasy);
});
afterEach(function() {
totpValidateStub.restore();
}); });
it("should validate the TOTP token", function() { it("should validate the TOTP token", function() {
const totp_secret = "NBD2ZV64R9UV1O7K"; const totp_secret = "NBD2ZV64R9UV1O7K";
const token = "token"; const token = "token";
totpValidateStub.withArgs({
secret: totp_secret,
token: token,
encoding: "base32",
window: 1
}).returns(true);
return totpValidator.validate(token, totp_secret); return totpValidator.validate(token, totp_secret);
}); });
it("should not validate a wrong TOTP token", function(done) { it("should not validate a wrong TOTP token", function(done) {
const totp_secret = "NBD2ZV64R9UV1O7K"; const totp_secret = "NBD2ZV64R9UV1O7K";
const token = "wrong token"; const token = "wrong token";
totpValidateStub.returns(false);
totpValidator.validate(token, totp_secret) totpValidator.validate(token, totp_secret)
.catch(function() { .catch(function() {
done(); done();

View File

@ -29,7 +29,12 @@ describe("test user data store", function () {
totpSecret = { totpSecret = {
ascii: "abc", ascii: "abc",
base32: "ABCDKZLEFZGREJK", 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 = { u2fRegistration = {

View File

@ -1,6 +1,11 @@
export interface TOTPSecret { export interface TOTPSecret {
base32: string;
ascii: string; ascii: string;
otpauth_url?: string; hex: string;
} base32: string;
qr_code_ascii: string;
qr_code_hex: string;
qr_code_base32: string;
google_auth_qr: string;
otpauth_url: string;
}

96
server/types/speakeasy.d.ts vendored 100644
View File

@ -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
}

View File

@ -21,6 +21,7 @@ function CustomWorld() {
}; };
this.setFieldTo = function (fieldName: string, content: string) { this.setFieldTo = function (fieldName: string, content: string) {
const that = this;
return this.driver.findElement(seleniumWebdriver.By.id(fieldName)) return this.driver.findElement(seleniumWebdriver.By.id(fieldName))
.sendKeys(content); .sendKeys(content);
}; };
@ -49,22 +50,19 @@ function CustomWorld() {
.findElement(seleniumWebdriver.By.tagName("button")) .findElement(seleniumWebdriver.By.tagName("button"))
.findElement(seleniumWebdriver.By.xpath("//button[contains(.,'" + buttonText + "')]")) .findElement(seleniumWebdriver.By.xpath("//button[contains(.,'" + buttonText + "')]"))
.click(); .click();
})
.then(function () {
return that.driver.sleep(1000);
}); });
}; };
this.waitUntilUrlContains = function(url: string) { this.waitUntilUrlContains = function (url: string) {
const that = this; const that = this;
return this.driver.wait(seleniumWebdriver.until.urlIs(url), 15000) return this.driver.wait(seleniumWebdriver.until.urlIs(url), 15000)
.then(function() {}, function(err: Error) { .then(function () { }, function (err: Error) {
that.driver.getCurrentUrl() that.driver.getCurrentUrl()
.then(function(current: string) { .then(function (current: string) {
console.error("====> Error due to: %s (current) != %s (expected)", current, url); 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) { this.loginWithUserPassword = function (username: string, password: string) {