From 54854bacb18c57fdb2f1332ffc93630f506e820f Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Mon, 23 Oct 2017 23:42:30 +0200 Subject: [PATCH] Use issuer and label when generating otpauthURL for TOTP Issuer is customizable in configuration so that a company can set its own name or website. If not provided, default value is 'authelia.com'. The username is used as label. --- .../lib/authentication/totp/ITotpHandler.ts | 10 +- .../lib/authentication/totp/TotpHandler.ts | 17 +- .../src/lib/configuration/Configuration.d.ts | 162 +++++++++--------- .../lib/configuration/ConfigurationParser.ts | 5 +- .../lib/configuration/adapters/TOTPAdapter.ts | 20 +++ .../totp/identity/RegistrationHandler.ts | 19 +- server/src/lib/web_server/RestApi.ts | 2 +- .../test/SessionConfigurationBuilder.test.ts | 6 + .../test/mocks/ServerVariablesMockBuilder.ts | 3 + server/test/mocks/TotpHandlerStub.ts | 6 +- .../totp/register/RegistrationHandler.test.ts | 69 +++++--- test/features/registration.feature | 14 ++ test/features/step_definitions/hooks.ts | 11 +- .../features/step_definitions/registration.ts | 15 ++ 14 files changed, 233 insertions(+), 126 deletions(-) create mode 100644 server/src/lib/configuration/adapters/TOTPAdapter.ts create mode 100644 test/features/registration.feature create mode 100644 test/features/step_definitions/registration.ts diff --git a/server/src/lib/authentication/totp/ITotpHandler.ts b/server/src/lib/authentication/totp/ITotpHandler.ts index 2830790f..d600d31e 100644 --- a/server/src/lib/authentication/totp/ITotpHandler.ts +++ b/server/src/lib/authentication/totp/ITotpHandler.ts @@ -1,14 +1,6 @@ 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; + generate(label: string, issuer: string): 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 index 9e1419e9..f51124a8 100644 --- a/server/src/lib/authentication/totp/TotpHandler.ts +++ b/server/src/lib/authentication/totp/TotpHandler.ts @@ -1,4 +1,4 @@ -import { ITotpHandler, GenerateSecretOptions } from "./ITotpHandler"; +import { ITotpHandler } from "./ITotpHandler"; import { TOTPSecret } from "../../../../types/TOTPSecret"; import Speakeasy = require("speakeasy"); @@ -12,8 +12,17 @@ export class TotpHandler implements ITotpHandler { this.speakeasy = speakeasy; } - generate(options?: GenerateSecretOptions): TOTPSecret { - return this.speakeasy.generateSecret(options); + generate(label: string, issuer: string): TOTPSecret { + const secret = this.speakeasy.generateSecret({ + otpauth_url: false + }); + + secret.otpauth_url = this.speakeasy.otpauthURL({ + secret: secret.ascii, + label: label, + issuer: issuer + }); + return secret; } validate(token: string, secret: string): boolean { @@ -22,6 +31,6 @@ export class TotpHandler implements ITotpHandler { encoding: TOTP_ENCODING, token: token, window: WINDOW - } as any); + }); } } \ No newline at end of file diff --git a/server/src/lib/configuration/Configuration.d.ts b/server/src/lib/configuration/Configuration.d.ts index a0075da7..fca4547a 100644 --- a/server/src/lib/configuration/Configuration.d.ts +++ b/server/src/lib/configuration/Configuration.d.ts @@ -1,46 +1,46 @@ export interface UserLdapConfiguration { - url: string; - base_dn: string; + url: string; + base_dn: string; - additional_users_dn?: string; - users_filter?: string; + additional_users_dn?: string; + users_filter?: string; - additional_groups_dn?: string; - groups_filter?: string; + additional_groups_dn?: string; + groups_filter?: string; - group_name_attribute?: string; - mail_attribute?: string; + group_name_attribute?: string; + mail_attribute?: string; - user: string; // admin username - password: string; // admin password + user: string; // admin username + password: string; // admin password } export interface LdapConfiguration { - url: string; + url: string; - users_dn: string; - users_filter: string; + users_dn: string; + users_filter: string; - groups_dn: string; - groups_filter: string; + groups_dn: string; + groups_filter: string; - group_name_attribute: string; - mail_attribute: string; + group_name_attribute: string; + mail_attribute: string; - user: string; // admin username - password: string; // admin password + user: string; // admin username + password: string; // admin password } type UserName = string; type GroupName = string; type DomainPattern = string; -export type ACLPolicy = 'deny' | 'allow'; +export type ACLPolicy = 'deny' | 'allow'; export type ACLRule = { - domain: string; - policy: ACLPolicy; - resources?: string[]; + domain: string; + policy: ACLPolicy; + resources?: string[]; } export type ACLDefaultRules = ACLRule[]; @@ -48,101 +48,107 @@ export type ACLGroupsRules = { [group: string]: ACLRule[]; }; export type ACLUsersRules = { [user: string]: ACLRule[]; }; export interface ACLConfiguration { - default_policy?: ACLPolicy; - any?: ACLDefaultRules; - groups?: ACLGroupsRules; - users?: ACLUsersRules; + default_policy?: ACLPolicy; + any?: ACLDefaultRules; + groups?: ACLGroupsRules; + users?: ACLUsersRules; } export interface SessionRedisOptions { - host: string; - port: number; + host: string; + port: number; } interface SessionCookieConfiguration { - secret: string; - expiration?: number; - inactivity?: number; - domain?: string; - redis?: SessionRedisOptions; + secret: string; + expiration?: number; + inactivity?: number; + domain?: string; + redis?: SessionRedisOptions; } export interface EmailNotifierConfiguration { - username: string; - password: string; - sender: string; - service: string; + username: string; + password: string; + sender: string; + service: string; } export interface SmtpNotifierConfiguration { - username?: string; - password?: string; - host: string; - port: number; - secure: boolean; - sender: string; + username?: string; + password?: string; + host: string; + port: number; + secure: boolean; + sender: string; } export interface FileSystemNotifierConfiguration { - filename: string; + filename: string; } export interface NotifierConfiguration { - email?: EmailNotifierConfiguration; - smtp?: SmtpNotifierConfiguration; - filesystem?: FileSystemNotifierConfiguration; + email?: EmailNotifierConfiguration; + smtp?: SmtpNotifierConfiguration; + filesystem?: FileSystemNotifierConfiguration; } export interface MongoStorageConfiguration { - url: string; + url: string; } export interface LocalStorageConfiguration { - path?: string; - in_memory?: boolean; + path?: string; + in_memory?: boolean; } export interface StorageConfiguration { - local?: LocalStorageConfiguration; - mongo?: MongoStorageConfiguration; + local?: LocalStorageConfiguration; + mongo?: MongoStorageConfiguration; } export interface RegulationConfiguration { - max_retries: number; - find_time: number; - ban_time: number; + max_retries: number; + find_time: number; + ban_time: number; } declare type AuthenticationMethod = 'two_factor' | 'single_factor'; declare type AuthenticationMethodPerSubdomain = { [subdomain: string]: AuthenticationMethod } export interface AuthenticationMethodsConfiguration { - default_method: AuthenticationMethod; - per_subdomain_methods?: AuthenticationMethodPerSubdomain; + default_method: AuthenticationMethod; + per_subdomain_methods?: AuthenticationMethodPerSubdomain; +} + +export interface TOTPConfiguration { + issuer: string; } export interface UserConfiguration { - port?: number; - logs_level?: string; - ldap: UserLdapConfiguration; - session: SessionCookieConfiguration; - storage: StorageConfiguration; - notifier: NotifierConfiguration; - authentication_methods?: AuthenticationMethodsConfiguration; - access_control?: ACLConfiguration; - regulation: RegulationConfiguration; - default_redirection_url?: string; + port?: number; + logs_level?: string; + ldap: UserLdapConfiguration; + session: SessionCookieConfiguration; + storage: StorageConfiguration; + notifier: NotifierConfiguration; + authentication_methods?: AuthenticationMethodsConfiguration; + access_control?: ACLConfiguration; + regulation: RegulationConfiguration; + default_redirection_url?: string; + totp?: TOTPConfiguration; } export interface AppConfiguration { - port: number; - logs_level: string; - ldap: LdapConfiguration; - session: SessionCookieConfiguration; - storage: StorageConfiguration; - notifier: NotifierConfiguration; - authentication_methods: AuthenticationMethodsConfiguration; - access_control?: ACLConfiguration; - regulation: RegulationConfiguration; - default_redirection_url?: string; + port: number; + logs_level: string; + ldap: LdapConfiguration; + session: SessionCookieConfiguration; + storage: StorageConfiguration; + notifier: NotifierConfiguration; + authentication_methods: AuthenticationMethodsConfiguration; + access_control?: ACLConfiguration; + regulation: RegulationConfiguration; + default_redirection_url?: string; + totp: TOTPConfiguration; } diff --git a/server/src/lib/configuration/ConfigurationParser.ts b/server/src/lib/configuration/ConfigurationParser.ts index 02cf8077..85893631 100644 --- a/server/src/lib/configuration/ConfigurationParser.ts +++ b/server/src/lib/configuration/ConfigurationParser.ts @@ -8,6 +8,7 @@ import { } from "./Configuration"; import Util = require("util"); import { ACLAdapter } from "./adapters/ACLAdapter"; +import { TOTPAdapter } from "./adapters/TOTPAdapter"; import { AuthenticationMethodsAdapter } from "./adapters/AuthenticationMethodsAdapter"; import { Validator } from "./Validator"; @@ -63,6 +64,7 @@ function adaptFromUserConfiguration(userConfiguration: UserConfiguration) const ldapConfiguration = adaptLdapConfiguration(userConfiguration.ldap); const authenticationMethods = AuthenticationMethodsAdapter .adapt(userConfiguration.authentication_methods); + const totpConfiguration = TOTPAdapter.adapt(userConfiguration.totp); return { port: port, @@ -83,7 +85,8 @@ function adaptFromUserConfiguration(userConfiguration: UserConfiguration) access_control: ACLAdapter.adapt(userConfiguration.access_control), regulation: userConfiguration.regulation, authentication_methods: authenticationMethods, - default_redirection_url: userConfiguration.default_redirection_url + default_redirection_url: userConfiguration.default_redirection_url, + totp: totpConfiguration }; } diff --git a/server/src/lib/configuration/adapters/TOTPAdapter.ts b/server/src/lib/configuration/adapters/TOTPAdapter.ts new file mode 100644 index 00000000..198c6ddb --- /dev/null +++ b/server/src/lib/configuration/adapters/TOTPAdapter.ts @@ -0,0 +1,20 @@ +import { TOTPConfiguration } from "../Configuration"; +import { ObjectCloner } from "../../utils/ObjectCloner"; + +const DEFAULT_ISSUER = "authelia.com"; + +export class TOTPAdapter { + static adapt(configuration: TOTPConfiguration): TOTPConfiguration { + const newConfiguration = { + issuer: DEFAULT_ISSUER + }; + + if (!configuration) + return newConfiguration; + + if (configuration && configuration.issuer) + newConfiguration.issuer = configuration.issuer; + + return newConfiguration; + } +} \ 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 db743ed4..d8394aad 100644 --- a/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts +++ b/server/src/lib/routes/secondfactor/totp/identity/RegistrationHandler.ts @@ -16,19 +16,22 @@ import { IRequestLogger } from "../../../../logging/IRequestLogger"; import { IUserDataStore } from "../../../../storage/IUserDataStore"; import { ITotpHandler } from "../../../../authentication/totp/ITotpHandler"; import { TOTPSecret } from "../../../../../../types/TOTPSecret"; +import { TOTPConfiguration } from "../../../../configuration/Configuration"; export default class RegistrationHandler implements IdentityValidable { private logger: IRequestLogger; private userDataStore: IUserDataStore; private totp: ITotpHandler; + private configuration: TOTPConfiguration; constructor(logger: IRequestLogger, userDataStore: IUserDataStore, - totp: ITotpHandler) { + totp: ITotpHandler, configuration: TOTPConfiguration) { this.logger = logger; this.userDataStore = userDataStore; this.totp = totp; + this.configuration = configuration; } challenge(): string { @@ -70,22 +73,24 @@ export default class RegistrationHandler implements IdentityValidable { return FirstFactorValidator.validate(req, this.logger); } - postValidationResponse(req: express.Request, res: express.Response): BluebirdPromise { + postValidationResponse(req: express.Request, res: express.Response) + : BluebirdPromise { const that = this; let secret: TOTPSecret; let userId: string; return new BluebirdPromise(function (resolve, reject) { const authSession = AuthenticationSessionHandler.get(req, that.logger); - const challenge = authSession.identity_check.challenge; - userId = authSession.identity_check.userid; + userId = authSession.userid; - if (challenge != Constants.CHALLENGE || !userId) { + if (authSession.identity_check.challenge != Constants.CHALLENGE + || !userId) return reject(new Error("Bad challenge.")); - } + resolve(); }) .then(function () { - secret = that.totp.generate(); + secret = that.totp.generate(userId, + that.configuration.issuer); that.logger.debug(req, "Save the TOTP secret in DB"); return that.userDataStore.saveTOTPSecret(userId, secret); }) diff --git a/server/src/lib/web_server/RestApi.ts b/server/src/lib/web_server/RestApi.ts index def5ea40..833b9591 100644 --- a/server/src/lib/web_server/RestApi.ts +++ b/server/src/lib/web_server/RestApi.ts @@ -55,7 +55,7 @@ function setupTotp(app: Express.Application, vars: ServerVariables) { Endpoints.SECOND_FACTOR_TOTP_IDENTITY_START_GET, Endpoints.SECOND_FACTOR_TOTP_IDENTITY_FINISH_GET, new TOTPRegistrationIdentityHandler(vars.logger, - vars.userDataStore, vars.totpHandler), + vars.userDataStore, vars.totpHandler, vars.config.totp), vars); } diff --git a/server/test/SessionConfigurationBuilder.test.ts b/server/test/SessionConfigurationBuilder.test.ts index 4318747c..037c90a0 100644 --- a/server/test/SessionConfigurationBuilder.test.ts +++ b/server/test/SessionConfigurationBuilder.test.ts @@ -16,6 +16,9 @@ describe("test session configuration builder", function () { users: {}, groups: {} }, + totp: { + issuer: "authelia.com" + }, ldap: { url: "ldap://ldap", user: "user", @@ -90,6 +93,9 @@ describe("test session configuration builder", function () { users: {}, groups: {} }, + totp: { + issuer: "authelia.com" + }, ldap: { url: "ldap://ldap", user: "user", diff --git a/server/test/mocks/ServerVariablesMockBuilder.ts b/server/test/mocks/ServerVariablesMockBuilder.ts index b8872721..794c310a 100644 --- a/server/test/mocks/ServerVariablesMockBuilder.ts +++ b/server/test/mocks/ServerVariablesMockBuilder.ts @@ -35,6 +35,9 @@ export class ServerVariablesMockBuilder { authentication_methods: { default_method: "two_factor" }, + totp: { + issuer: "authelia.com" + }, ldap: { url: "ldap://ldap", user: "user", diff --git a/server/test/mocks/TotpHandlerStub.ts b/server/test/mocks/TotpHandlerStub.ts index 276dbb15..8b261eff 100644 --- a/server/test/mocks/TotpHandlerStub.ts +++ b/server/test/mocks/TotpHandlerStub.ts @@ -1,6 +1,6 @@ import Sinon = require("sinon"); import BluebirdPromise = require("bluebird"); -import { ITotpHandler, GenerateSecretOptions } from "../../src/lib/authentication/totp/ITotpHandler"; +import { ITotpHandler } from "../../src/lib/authentication/totp/ITotpHandler"; import { TOTPSecret } from "../../types/TOTPSecret"; export class TotpHandlerStub implements ITotpHandler { @@ -12,8 +12,8 @@ export class TotpHandlerStub implements ITotpHandler { this.validateStub = Sinon.stub(); } - generate(options?: GenerateSecretOptions): TOTPSecret { - return this.generateStub(options); + generate(label: string, issuer: string): TOTPSecret { + return this.generateStub(label, issuer); } validate(token: string, secret: string): boolean { diff --git a/server/test/routes/secondfactor/totp/register/RegistrationHandler.test.ts b/server/test/routes/secondfactor/totp/register/RegistrationHandler.test.ts index 37192702..1b0aaf34 100644 --- a/server/test/routes/secondfactor/totp/register/RegistrationHandler.test.ts +++ b/server/test/routes/secondfactor/totp/register/RegistrationHandler.test.ts @@ -1,13 +1,13 @@ import Sinon = require("sinon"); -import winston = require("winston"); import RegistrationHandler from "../../../../../src/lib/routes/secondfactor/totp/identity/RegistrationHandler"; import { Identity } from "../../../../../types/Identity"; import { UserDataStore } from "../../../../../src/lib/storage/UserDataStore"; -import assert = require("assert"); import BluebirdPromise = require("bluebird"); import ExpressMock = require("../../../../mocks/express"); -import { ServerVariablesMock, ServerVariablesMockBuilder } from "../../../../mocks/ServerVariablesMockBuilder"; +import { ServerVariablesMock, ServerVariablesMockBuilder } + from "../../../../mocks/ServerVariablesMockBuilder"; import { ServerVariables } from "../../../../../src/lib/ServerVariables"; +import Assert = require("assert"); describe("test totp register", function () { let req: ExpressMock.RequestMock; @@ -26,7 +26,11 @@ describe("test totp register", function () { userid: "user", email: "user@example.com", first_factor: true, - second_factor: false + second_factor: false, + identity_check: { + userid: "user", + challenge: "totp-register" + } } }; req.headers = {}; @@ -36,23 +40,29 @@ describe("test totp register", function () { inMemoryOnly: true }; - mocks.userDataStore.saveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); - mocks.userDataStore.retrieveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); - mocks.userDataStore.produceIdentityValidationTokenStub.returns(BluebirdPromise.resolve({})); - mocks.userDataStore.consumeIdentityValidationTokenStub.returns(BluebirdPromise.resolve({})); - mocks.userDataStore.saveTOTPSecretStub.returns(BluebirdPromise.resolve({})); + mocks.userDataStore.saveU2FRegistrationStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.retrieveU2FRegistrationStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.produceIdentityValidationTokenStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.consumeIdentityValidationTokenStub + .returns(BluebirdPromise.resolve({})); + mocks.userDataStore.saveTOTPSecretStub + .returns(BluebirdPromise.resolve({})); res = ExpressMock.ResponseMock(); }); - describe("test totp registration check", test_registration_check); - - function test_registration_check() { + describe("test totp registration pre validation", function () { it("should fail if first_factor has not been passed", function () { req.session.auth.first_factor = false; - return new RegistrationHandler(vars.logger, vars.userDataStore, vars.totpHandler) + return new RegistrationHandler(vars.logger, vars.userDataStore, + vars.totpHandler, vars.config.totp) .preValidationInit(req as any) - .then(function () { return BluebirdPromise.reject(new Error("It should fail")); }) + .then(function () { + return BluebirdPromise.reject(new Error("It should fail")); + }) .catch(function (err: Error) { return BluebirdPromise.resolve(); }); @@ -62,7 +72,8 @@ describe("test totp register", function () { req.session.auth.first_factor = false; req.session.auth.userid = undefined; - new RegistrationHandler(vars.logger, vars.userDataStore, vars.totpHandler) + new RegistrationHandler(vars.logger, vars.userDataStore, vars.totpHandler, + vars.config.totp) .preValidationInit(req as any) .catch(function (err: Error) { done(); @@ -73,19 +84,33 @@ describe("test totp register", function () { req.session.auth.first_factor = false; req.session.auth.email = undefined; - new RegistrationHandler(vars.logger, vars.userDataStore, vars.totpHandler) + new RegistrationHandler(vars.logger, vars.userDataStore, vars.totpHandler, + vars.config.totp) .preValidationInit(req as any) .catch(function (err: Error) { done(); }); }); - it("should succeed if first factor passed, userid and email are provided", function (done) { - new RegistrationHandler(vars.logger, vars.userDataStore, vars.totpHandler) - .preValidationInit(req as any) - .then(function (identity: Identity) { - done(); + it("should succeed if first factor passed, userid and email are provided", + function () { + return new RegistrationHandler(vars.logger, vars.userDataStore, + vars.totpHandler, vars.config.totp) + .preValidationInit(req as any); + }); + }); + + describe("test totp registration post validation", function () { + it("should generate a secret using userId as label and issuer defined in config", function () { + vars.config.totp = { + issuer: "issuer" + }; + return new RegistrationHandler(vars.logger, vars.userDataStore, + vars.totpHandler, vars.config.totp) + .postValidationResponse(req as any, res as any) + .then(function() { + Assert(mocks.totpHandler.generateStub.calledWithExactly("user", "issuer")); }); }); - } + }); }); diff --git a/test/features/registration.feature b/test/features/registration.feature new file mode 100644 index 00000000..1f0d5e83 --- /dev/null +++ b/test/features/registration.feature @@ -0,0 +1,14 @@ +Feature: Register secret for second factor + + Scenario: Register a TOTP secret with correct label and issuer + Given I visit "https://auth.test.local:8080/" + And I login with user "john" and password "password" + When I register a TOTP secret called "Sec0" + Then the otpauth url has label "john" and issuer "authelia.com" + + @needs-totp_issuer-config + Scenario: Register a TOTP secret with correct label and custom issuer + Given I visit "https://auth.test.local:8080/" + And I login with user "john" and password "password" + When I register a TOTP secret called "Sec0" + Then the otpauth url has label "john" and issuer "custom.com" \ No newline at end of file diff --git a/test/features/step_definitions/hooks.ts b/test/features/step_definitions/hooks.ts index c7715910..afef2bda 100644 --- a/test/features/step_definitions/hooks.ts +++ b/test/features/step_definitions/hooks.ts @@ -43,6 +43,14 @@ Cucumber.defineSupportCode(function ({ After, Before }) { "); } + function createCustomTotpIssuerConfiguration(): BluebirdPromise { + return exec("\ + cat config.template.yml > config.test.yml && \ + echo 'totp:' >> config.test.yml && \ + echo ' issuer: custom.com' >> config.test.yml \ + "); + } + function declareNeedsConfiguration(tag: string, cb: () => BluebirdPromise) { Before({ tags: "@needs-" + tag + "-config", timeout: 20 * 1000 }, function () { return cb() @@ -62,6 +70,7 @@ Cucumber.defineSupportCode(function ({ After, Before }) { declareNeedsConfiguration("regulation", createRegulationConfiguration); declareNeedsConfiguration("inactivity", createInactivityConfiguration); declareNeedsConfiguration("single_factor", createSingleFactorConfiguration); + declareNeedsConfiguration("totp_issuer", createCustomTotpIssuerConfiguration); function registerUser(context: any, username: string) { let secret: Speakeasy.Key; @@ -72,7 +81,7 @@ Cucumber.defineSupportCode(function ({ After, Before }) { const userDataStore = new UserDataStore(collectionFactory); const generator = new TotpHandler(Speakeasy); - secret = generator.generate(); + secret = generator.generate("user", "authelia.com"); return userDataStore.saveTOTPSecret(username, secret); }) .then(function () { diff --git a/test/features/step_definitions/registration.ts b/test/features/step_definitions/registration.ts new file mode 100644 index 00000000..beab5c5b --- /dev/null +++ b/test/features/step_definitions/registration.ts @@ -0,0 +1,15 @@ +import Cucumber = require("cucumber"); +import seleniumWebdriver = require("selenium-webdriver"); +import Assert = require("assert"); + +Cucumber.defineSupportCode(function ({ Given, When, Then }) { + When("the otpauth url has label {stringInDoubleQuotes} and issuer \ +{stringInDoubleQuotes}", function (label: string, issuer: string) { + return this.driver.findElement(seleniumWebdriver.By.id("qrcode")) + .getAttribute("title") + .then(function (title: string) { + const re = `^otpauth://totp/${label}\\?secret=[A-Z0-9]+&issuer=${issuer}$`; + Assert(new RegExp(re).test(title)); + }) + }); +});