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.
This commit is contained in:
Clement Michaud 2017-10-23 23:42:30 +02:00
parent 87056c14e2
commit 54854bacb1
14 changed files with 233 additions and 126 deletions

View File

@ -1,14 +1,6 @@
import { TOTPSecret } from "../../../../types/TOTPSecret"; import { TOTPSecret } from "../../../../types/TOTPSecret";
export interface GenerateSecretOptions {
length?: number;
symbols?: boolean;
otpauth_url?: boolean;
name?: string;
issuer?: string;
}
export interface ITotpHandler { export interface ITotpHandler {
generate(options?: GenerateSecretOptions): TOTPSecret; generate(label: string, issuer: string): TOTPSecret;
validate(token: string, secret: string): boolean; validate(token: string, secret: string): boolean;
} }

View File

@ -1,4 +1,4 @@
import { ITotpHandler, GenerateSecretOptions } from "./ITotpHandler"; import { ITotpHandler } from "./ITotpHandler";
import { TOTPSecret } from "../../../../types/TOTPSecret"; import { TOTPSecret } from "../../../../types/TOTPSecret";
import Speakeasy = require("speakeasy"); import Speakeasy = require("speakeasy");
@ -12,8 +12,17 @@ export class TotpHandler implements ITotpHandler {
this.speakeasy = speakeasy; this.speakeasy = speakeasy;
} }
generate(options?: GenerateSecretOptions): TOTPSecret { generate(label: string, issuer: string): TOTPSecret {
return this.speakeasy.generateSecret(options); 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 { validate(token: string, secret: string): boolean {
@ -22,6 +31,6 @@ export class TotpHandler implements ITotpHandler {
encoding: TOTP_ENCODING, encoding: TOTP_ENCODING,
token: token, token: token,
window: WINDOW window: WINDOW
} as any); });
} }
} }

View File

@ -35,7 +35,7 @@ type UserName = string;
type GroupName = string; type GroupName = string;
type DomainPattern = string; type DomainPattern = string;
export type ACLPolicy = 'deny' | 'allow'; export type ACLPolicy = 'deny' | 'allow';
export type ACLRule = { export type ACLRule = {
domain: string; domain: string;
@ -121,6 +121,10 @@ export interface AuthenticationMethodsConfiguration {
per_subdomain_methods?: AuthenticationMethodPerSubdomain; per_subdomain_methods?: AuthenticationMethodPerSubdomain;
} }
export interface TOTPConfiguration {
issuer: string;
}
export interface UserConfiguration { export interface UserConfiguration {
port?: number; port?: number;
logs_level?: string; logs_level?: string;
@ -132,6 +136,7 @@ export interface UserConfiguration {
access_control?: ACLConfiguration; access_control?: ACLConfiguration;
regulation: RegulationConfiguration; regulation: RegulationConfiguration;
default_redirection_url?: string; default_redirection_url?: string;
totp?: TOTPConfiguration;
} }
export interface AppConfiguration { export interface AppConfiguration {
@ -145,4 +150,5 @@ export interface AppConfiguration {
access_control?: ACLConfiguration; access_control?: ACLConfiguration;
regulation: RegulationConfiguration; regulation: RegulationConfiguration;
default_redirection_url?: string; default_redirection_url?: string;
totp: TOTPConfiguration;
} }

View File

@ -8,6 +8,7 @@ import {
} from "./Configuration"; } from "./Configuration";
import Util = require("util"); import Util = require("util");
import { ACLAdapter } from "./adapters/ACLAdapter"; import { ACLAdapter } from "./adapters/ACLAdapter";
import { TOTPAdapter } from "./adapters/TOTPAdapter";
import { AuthenticationMethodsAdapter } from "./adapters/AuthenticationMethodsAdapter"; import { AuthenticationMethodsAdapter } from "./adapters/AuthenticationMethodsAdapter";
import { Validator } from "./Validator"; import { Validator } from "./Validator";
@ -63,6 +64,7 @@ function adaptFromUserConfiguration(userConfiguration: UserConfiguration)
const ldapConfiguration = adaptLdapConfiguration(userConfiguration.ldap); const ldapConfiguration = adaptLdapConfiguration(userConfiguration.ldap);
const authenticationMethods = AuthenticationMethodsAdapter const authenticationMethods = AuthenticationMethodsAdapter
.adapt(userConfiguration.authentication_methods); .adapt(userConfiguration.authentication_methods);
const totpConfiguration = TOTPAdapter.adapt(userConfiguration.totp);
return { return {
port: port, port: port,
@ -83,7 +85,8 @@ function adaptFromUserConfiguration(userConfiguration: UserConfiguration)
access_control: ACLAdapter.adapt(userConfiguration.access_control), access_control: ACLAdapter.adapt(userConfiguration.access_control),
regulation: userConfiguration.regulation, regulation: userConfiguration.regulation,
authentication_methods: authenticationMethods, authentication_methods: authenticationMethods,
default_redirection_url: userConfiguration.default_redirection_url default_redirection_url: userConfiguration.default_redirection_url,
totp: totpConfiguration
}; };
} }

View File

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

View File

@ -16,19 +16,22 @@ import { IRequestLogger } from "../../../../logging/IRequestLogger";
import { IUserDataStore } from "../../../../storage/IUserDataStore"; import { IUserDataStore } from "../../../../storage/IUserDataStore";
import { ITotpHandler } from "../../../../authentication/totp/ITotpHandler"; import { ITotpHandler } from "../../../../authentication/totp/ITotpHandler";
import { TOTPSecret } from "../../../../../../types/TOTPSecret"; import { TOTPSecret } from "../../../../../../types/TOTPSecret";
import { TOTPConfiguration } from "../../../../configuration/Configuration";
export default class RegistrationHandler implements IdentityValidable { export default class RegistrationHandler implements IdentityValidable {
private logger: IRequestLogger; private logger: IRequestLogger;
private userDataStore: IUserDataStore; private userDataStore: IUserDataStore;
private totp: ITotpHandler; private totp: ITotpHandler;
private configuration: TOTPConfiguration;
constructor(logger: IRequestLogger, constructor(logger: IRequestLogger,
userDataStore: IUserDataStore, userDataStore: IUserDataStore,
totp: ITotpHandler) { totp: ITotpHandler, configuration: TOTPConfiguration) {
this.logger = logger; this.logger = logger;
this.userDataStore = userDataStore; this.userDataStore = userDataStore;
this.totp = totp; this.totp = totp;
this.configuration = configuration;
} }
challenge(): string { challenge(): string {
@ -70,22 +73,24 @@ export default class RegistrationHandler implements IdentityValidable {
return FirstFactorValidator.validate(req, this.logger); return FirstFactorValidator.validate(req, this.logger);
} }
postValidationResponse(req: express.Request, res: express.Response): BluebirdPromise<void> { postValidationResponse(req: express.Request, res: express.Response)
: BluebirdPromise<void> {
const that = this; const that = this;
let secret: TOTPSecret; let secret: TOTPSecret;
let userId: string; let userId: string;
return new BluebirdPromise(function (resolve, reject) { return new BluebirdPromise(function (resolve, reject) {
const authSession = AuthenticationSessionHandler.get(req, that.logger); const authSession = AuthenticationSessionHandler.get(req, that.logger);
const challenge = authSession.identity_check.challenge; userId = authSession.userid;
userId = authSession.identity_check.userid;
if (challenge != Constants.CHALLENGE || !userId) { if (authSession.identity_check.challenge != Constants.CHALLENGE
|| !userId)
return reject(new Error("Bad challenge.")); return reject(new Error("Bad challenge."));
}
resolve(); resolve();
}) })
.then(function () { .then(function () {
secret = that.totp.generate(); secret = that.totp.generate(userId,
that.configuration.issuer);
that.logger.debug(req, "Save the TOTP secret in DB"); that.logger.debug(req, "Save the TOTP secret in DB");
return that.userDataStore.saveTOTPSecret(userId, secret); return that.userDataStore.saveTOTPSecret(userId, secret);
}) })

View File

@ -55,7 +55,7 @@ function setupTotp(app: Express.Application, vars: ServerVariables) {
Endpoints.SECOND_FACTOR_TOTP_IDENTITY_START_GET, Endpoints.SECOND_FACTOR_TOTP_IDENTITY_START_GET,
Endpoints.SECOND_FACTOR_TOTP_IDENTITY_FINISH_GET, Endpoints.SECOND_FACTOR_TOTP_IDENTITY_FINISH_GET,
new TOTPRegistrationIdentityHandler(vars.logger, new TOTPRegistrationIdentityHandler(vars.logger,
vars.userDataStore, vars.totpHandler), vars.userDataStore, vars.totpHandler, vars.config.totp),
vars); vars);
} }

View File

@ -16,6 +16,9 @@ describe("test session configuration builder", function () {
users: {}, users: {},
groups: {} groups: {}
}, },
totp: {
issuer: "authelia.com"
},
ldap: { ldap: {
url: "ldap://ldap", url: "ldap://ldap",
user: "user", user: "user",
@ -90,6 +93,9 @@ describe("test session configuration builder", function () {
users: {}, users: {},
groups: {} groups: {}
}, },
totp: {
issuer: "authelia.com"
},
ldap: { ldap: {
url: "ldap://ldap", url: "ldap://ldap",
user: "user", user: "user",

View File

@ -35,6 +35,9 @@ export class ServerVariablesMockBuilder {
authentication_methods: { authentication_methods: {
default_method: "two_factor" default_method: "two_factor"
}, },
totp: {
issuer: "authelia.com"
},
ldap: { ldap: {
url: "ldap://ldap", url: "ldap://ldap",
user: "user", user: "user",

View File

@ -1,6 +1,6 @@
import Sinon = require("sinon"); import Sinon = require("sinon");
import BluebirdPromise = require("bluebird"); 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"; import { TOTPSecret } from "../../types/TOTPSecret";
export class TotpHandlerStub implements ITotpHandler { export class TotpHandlerStub implements ITotpHandler {
@ -12,8 +12,8 @@ export class TotpHandlerStub implements ITotpHandler {
this.validateStub = Sinon.stub(); this.validateStub = Sinon.stub();
} }
generate(options?: GenerateSecretOptions): TOTPSecret { generate(label: string, issuer: string): TOTPSecret {
return this.generateStub(options); return this.generateStub(label, issuer);
} }
validate(token: string, secret: string): boolean { validate(token: string, secret: string): boolean {

View File

@ -1,13 +1,13 @@
import Sinon = require("sinon"); import Sinon = require("sinon");
import winston = require("winston");
import RegistrationHandler from "../../../../../src/lib/routes/secondfactor/totp/identity/RegistrationHandler"; import RegistrationHandler from "../../../../../src/lib/routes/secondfactor/totp/identity/RegistrationHandler";
import { Identity } from "../../../../../types/Identity"; import { Identity } from "../../../../../types/Identity";
import { UserDataStore } from "../../../../../src/lib/storage/UserDataStore"; import { UserDataStore } from "../../../../../src/lib/storage/UserDataStore";
import assert = require("assert");
import BluebirdPromise = require("bluebird"); import BluebirdPromise = require("bluebird");
import ExpressMock = require("../../../../mocks/express"); import ExpressMock = require("../../../../mocks/express");
import { ServerVariablesMock, ServerVariablesMockBuilder } from "../../../../mocks/ServerVariablesMockBuilder"; import { ServerVariablesMock, ServerVariablesMockBuilder }
from "../../../../mocks/ServerVariablesMockBuilder";
import { ServerVariables } from "../../../../../src/lib/ServerVariables"; import { ServerVariables } from "../../../../../src/lib/ServerVariables";
import Assert = require("assert");
describe("test totp register", function () { describe("test totp register", function () {
let req: ExpressMock.RequestMock; let req: ExpressMock.RequestMock;
@ -26,7 +26,11 @@ describe("test totp register", function () {
userid: "user", userid: "user",
email: "user@example.com", email: "user@example.com",
first_factor: true, first_factor: true,
second_factor: false second_factor: false,
identity_check: {
userid: "user",
challenge: "totp-register"
}
} }
}; };
req.headers = {}; req.headers = {};
@ -36,23 +40,29 @@ describe("test totp register", function () {
inMemoryOnly: true inMemoryOnly: true
}; };
mocks.userDataStore.saveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); mocks.userDataStore.saveU2FRegistrationStub
mocks.userDataStore.retrieveU2FRegistrationStub.returns(BluebirdPromise.resolve({})); .returns(BluebirdPromise.resolve({}));
mocks.userDataStore.produceIdentityValidationTokenStub.returns(BluebirdPromise.resolve({})); mocks.userDataStore.retrieveU2FRegistrationStub
mocks.userDataStore.consumeIdentityValidationTokenStub.returns(BluebirdPromise.resolve({})); .returns(BluebirdPromise.resolve({}));
mocks.userDataStore.saveTOTPSecretStub.returns(BluebirdPromise.resolve({})); mocks.userDataStore.produceIdentityValidationTokenStub
.returns(BluebirdPromise.resolve({}));
mocks.userDataStore.consumeIdentityValidationTokenStub
.returns(BluebirdPromise.resolve({}));
mocks.userDataStore.saveTOTPSecretStub
.returns(BluebirdPromise.resolve({}));
res = ExpressMock.ResponseMock(); res = ExpressMock.ResponseMock();
}); });
describe("test totp registration check", test_registration_check); describe("test totp registration pre validation", function () {
function test_registration_check() {
it("should fail if first_factor has not been passed", function () { it("should fail if first_factor has not been passed", function () {
req.session.auth.first_factor = false; 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) .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) { .catch(function (err: Error) {
return BluebirdPromise.resolve(); return BluebirdPromise.resolve();
}); });
@ -62,7 +72,8 @@ describe("test totp register", function () {
req.session.auth.first_factor = false; req.session.auth.first_factor = false;
req.session.auth.userid = undefined; 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) .preValidationInit(req as any)
.catch(function (err: Error) { .catch(function (err: Error) {
done(); done();
@ -73,19 +84,33 @@ describe("test totp register", function () {
req.session.auth.first_factor = false; req.session.auth.first_factor = false;
req.session.auth.email = undefined; 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) .preValidationInit(req as any)
.catch(function (err: Error) { .catch(function (err: Error) {
done(); done();
}); });
}); });
it("should succeed if first factor passed, userid and email are provided", function (done) { it("should succeed if first factor passed, userid and email are provided",
new RegistrationHandler(vars.logger, vars.userDataStore, vars.totpHandler) function () {
.preValidationInit(req as any) return new RegistrationHandler(vars.logger, vars.userDataStore,
.then(function (identity: Identity) { vars.totpHandler, vars.config.totp)
done(); .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"));
});
}); });
}); });
}
}); });

View File

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

View File

@ -43,6 +43,14 @@ Cucumber.defineSupportCode(function ({ After, Before }) {
"); ");
} }
function createCustomTotpIssuerConfiguration(): BluebirdPromise<void> {
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<void>) { function declareNeedsConfiguration(tag: string, cb: () => BluebirdPromise<void>) {
Before({ tags: "@needs-" + tag + "-config", timeout: 20 * 1000 }, function () { Before({ tags: "@needs-" + tag + "-config", timeout: 20 * 1000 }, function () {
return cb() return cb()
@ -62,6 +70,7 @@ Cucumber.defineSupportCode(function ({ After, Before }) {
declareNeedsConfiguration("regulation", createRegulationConfiguration); declareNeedsConfiguration("regulation", createRegulationConfiguration);
declareNeedsConfiguration("inactivity", createInactivityConfiguration); declareNeedsConfiguration("inactivity", createInactivityConfiguration);
declareNeedsConfiguration("single_factor", createSingleFactorConfiguration); declareNeedsConfiguration("single_factor", createSingleFactorConfiguration);
declareNeedsConfiguration("totp_issuer", createCustomTotpIssuerConfiguration);
function registerUser(context: any, username: string) { function registerUser(context: any, username: string) {
let secret: Speakeasy.Key; let secret: Speakeasy.Key;
@ -72,7 +81,7 @@ Cucumber.defineSupportCode(function ({ After, Before }) {
const userDataStore = new UserDataStore(collectionFactory); const userDataStore = new UserDataStore(collectionFactory);
const generator = new TotpHandler(Speakeasy); const generator = new TotpHandler(Speakeasy);
secret = generator.generate(); secret = generator.generate("user", "authelia.com");
return userDataStore.saveTOTPSecret(username, secret); return userDataStore.saveTOTPSecret(username, secret);
}) })
.then(function () { .then(function () {

View File

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