feat: Combine pod creation with IDP registration

This commit is contained in:
Joachim Van Herwegen
2021-05-28 16:38:35 +02:00
parent 9bb42ddf0d
commit 4d7d939dc4
18 changed files with 700 additions and 255 deletions

View File

@@ -1,83 +1,241 @@
import assert from 'assert';
import urljoin from 'url-join';
import type { ResourceIdentifier } from '../../../../ldp/representation/ResourceIdentifier';
import { getLoggerFor } from '../../../../logging/LogUtil';
import type { IdentifierGenerator } from '../../../../pods/generate/IdentifierGenerator';
import type { PodManager } from '../../../../pods/PodManager';
import type { HttpHandlerInput } from '../../../../server/HttpHandler';
import { HttpHandler } from '../../../../server/HttpHandler';
import type { HttpRequest } from '../../../../server/HttpRequest';
import type { InteractionHttpHandlerInput } from '../../InteractionHttpHandler';
import { InteractionHttpHandler } from '../../InteractionHttpHandler';
import type { RenderHandler } from '../../../../server/util/RenderHandler';
import type { OwnershipValidator } from '../../../ownership/OwnershipValidator';
import { getFormDataRequestBody } from '../../util/FormDataUtil';
import type { InteractionCompleter } from '../../util/InteractionCompleter';
import type { OwnershipValidator } from '../../util/OwnershipValidator';
import { assertPassword, throwIdpInteractionError } from '../EmailPasswordUtil';
import type { AccountStore } from '../storage/AccountStore';
const emailRegex = /^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/u;
interface RegistrationHandlerArgs {
export interface RegistrationHandlerArgs {
/**
* Used to set the `oidcIssuer` value of newly registered pods.
*/
baseUrl: string;
/**
* Appended to the generated pod identifier to create the corresponding WebID.
*/
webIdSuffix: string;
/**
* Generates identifiers for new pods.
*/
identifierGenerator: IdentifierGenerator;
/**
* Verifies the user is the owner of the WebID they provide.
*/
ownershipValidator: OwnershipValidator;
/**
* Stores all the registered account information.
*/
accountStore: AccountStore;
interactionCompleter: InteractionCompleter;
/**
* Creates the new pods.
*/
podManager: PodManager;
/**
* Renders the response when registration is successful.
*/
responseHandler: RenderHandler<NodeJS.Dict<any>>;
}
// Results when parsing the input form data
type ParseResult = {
/**
* All the parameters that will be parsed from a request.
* `data` contains all the raw values to potentially be used by pod templates.
*/
interface ParseResult {
email: string;
password: string;
webId: string;
remember: boolean;
};
password?: string;
podName?: string;
webId?: string;
createWebId: boolean;
register: boolean;
createPod: boolean;
data: NodeJS.Dict<string>;
}
/**
* Handles the submission of the registration form.
* Creates the user and logs them in if successful.
* This class handles the 3 potential steps of the registration process:
* 1. Generating a new WebID.
* 2. Registering a WebID with the IDP.
* 3. Creating a new pod for a given WebID.
*
* All of these steps are optional and will be determined based on the input parameters of a request,
* with the following considerations:
* * At least one option needs to be chosen.
* * In case a new WebID needs to be created, the other 2 steps are obligatory.
* * Ownership will be verified when the WebID is provided.
* * When registering and creating a pod, the base URL will be used as oidcIssuer value.
*/
export class RegistrationHandler extends InteractionHttpHandler {
export class RegistrationHandler extends HttpHandler {
protected readonly logger = getLoggerFor(this);
private readonly baseUrl: string;
private readonly webIdSuffix: string;
private readonly identifierGenerator: IdentifierGenerator;
private readonly ownershipValidator: OwnershipValidator;
private readonly accountStore: AccountStore;
private readonly interactionCompleter: InteractionCompleter;
private readonly podManager: PodManager;
private readonly responseHandler: RenderHandler<NodeJS.Dict<any>>;
public constructor(args: RegistrationHandlerArgs) {
super();
this.baseUrl = args.baseUrl;
this.webIdSuffix = args.webIdSuffix;
this.identifierGenerator = args.identifierGenerator;
this.ownershipValidator = args.ownershipValidator;
this.accountStore = args.accountStore;
this.interactionCompleter = args.interactionCompleter;
this.podManager = args.podManager;
this.responseHandler = args.responseHandler;
}
public async handle(input: InteractionHttpHandlerInput): Promise<void> {
const { email, webId, password, remember } = await this.parseInput(input.request);
public async handle({ request, response }: HttpHandlerInput): Promise<void> {
const result = await this.parseInput(request);
try {
// Check if WebId contains required triples and register new account if successful
await this.ownershipValidator.handleSafe({ webId });
await this.accountStore.create(email, webId, password);
await this.interactionCompleter.handleSafe({
...input,
webId,
shouldRemember: Boolean(remember),
});
this.logger.debug(`Registering agent ${email} with WebId ${webId}`);
} catch (err: unknown) {
throwIdpInteractionError(err, { email, webId });
const props = await this.register(result);
await this.responseHandler.handleSafe({ response, props });
} catch (error: unknown) {
throwIdpInteractionError(error, result.data as Record<string, string>);
}
}
/**
* Parses and validates the input form data.
* Will throw an {@link IdpInteractionError} in case something is wrong.
* All relevant data that was correct up to that point will be prefilled.
* Does the full registration and pod creation process,
* with the steps chosen by the values in the `ParseResult`.
*/
private async register(result: ParseResult): Promise<NodeJS.Dict<any>> {
// This is only used when createWebId and/or createPod are true
let podBaseUrl: ResourceIdentifier | undefined;
// Create or verify the WebID
if (result.createWebId) {
podBaseUrl = this.identifierGenerator.generate(result.podName!);
result.webId = urljoin(podBaseUrl.path, this.webIdSuffix);
} else {
await this.ownershipValidator.handleSafe({ webId: result.webId! });
}
// Register the account
if (result.register) {
await this.accountStore.create(result.email, result.webId!, result.password!);
// Add relevant data for the templates
result.data.oidcIssuer = this.baseUrl;
}
// Create the pod
if (result.createPod) {
podBaseUrl = podBaseUrl ?? this.identifierGenerator.generate(result.podName!);
try {
await this.podManager.createPod(podBaseUrl, { ...result.data, webId: result.webId! });
} catch (error: unknown) {
// In case pod creation errors we don't want to keep the account
if (result.register) {
await this.accountStore.deleteAccount(result.email);
}
throw error;
}
}
// Verify the account
if (result.register) {
// This prevents there being a small timeframe where the account can be used before the pod creation is finished.
// That timeframe could potentially be used by malicious users.
await this.accountStore.verify(result.email);
}
return {
webId: result.webId,
email: result.email,
oidcIssuer: this.baseUrl,
podBaseUrl: podBaseUrl?.path,
createWebId: result.createWebId,
register: result.register,
createPod: result.createPod,
};
}
/**
* Parses the input request into a `ParseResult`.
*/
private async parseInput(request: HttpRequest): Promise<ParseResult> {
const prefilled: Record<string, string> = {};
const parsed = await getFormDataRequestBody(request);
let prefilled: Record<string, string> = {};
try {
const { email, webId, password, confirmPassword, remember } = await getFormDataRequestBody(request);
assert(typeof email === 'string' && email.length > 0, 'Email required');
assert(emailRegex.test(email), 'Invalid email');
prefilled.email = email;
assert(typeof webId === 'string' && webId.length > 0, 'WebId required');
prefilled.webId = webId;
assertPassword(password, confirmPassword);
return { email, password, webId, remember: Boolean(remember) };
for (const key of Object.keys(parsed)) {
if (Array.isArray(parsed[key])) {
throw new Error(`Multiple values found for key ${key}`);
}
}
prefilled = parsed as Record<string, string>;
return this.validateInput(prefilled);
} catch (err: unknown) {
throwIdpInteractionError(err, prefilled);
}
}
/**
* Converts the raw input date into a `ParseResult`.
* Verifies that all the data combinations make sense.
*/
private validateInput(parsed: NodeJS.Dict<string>): ParseResult {
const { email, password, confirmPassword, podName, webId } = parsed;
assert(typeof email === 'string' && email.length > 0 && emailRegex.test(email),
'A valid e-mail address is required');
const result: ParseResult = {
email,
createWebId: Boolean(parsed.createWebId),
register: Boolean(parsed.register),
createPod: Boolean(parsed.createPod),
data: parsed,
};
const validWebId = typeof webId === 'string' && webId.length > 0;
if (result.createWebId) {
if (validWebId) {
throw new Error('A WebID should only be provided when no new one is being created');
}
} else {
if (!validWebId) {
throw new Error('A WebID is required if no new one is being created');
}
result.webId = webId;
}
if (result.register) {
assertPassword(password, confirmPassword);
result.password = password;
} else if (typeof password === 'string' && password.length > 0) {
throw new Error('A password should only be provided when registering');
}
if (result.createWebId || result.createPod) {
assert(typeof podName === 'string' && podName.length > 0,
'A pod name is required when creating a pod and/or WebID');
result.podName = podName;
} else if (typeof podName === 'string' && podName.length > 0) {
throw new Error('A pod name should only be provided when creating a pod and/or WebID');
}
if (result.createWebId && !(result.register && result.createPod)) {
throw new Error('Creating a WebID is only possible when also registering and creating a pod');
}
if (!result.createWebId && !result.register && !result.createPod) {
throw new Error('At least one option needs to be chosen');
}
return result;
}
}

View File

@@ -3,7 +3,6 @@ import { getLoggerFor } from '../logging/LogUtil';
import type { KeyValueStorage } from '../storage/keyvalue/KeyValueStorage';
import type { ResourceStore } from '../storage/ResourceStore';
import { addGeneratedResources } from './generate/GenerateUtil';
import type { IdentifierGenerator } from './generate/IdentifierGenerator';
import type { PodGenerator } from './generate/PodGenerator';
import type { ResourcesGenerator } from './generate/ResourcesGenerator';
import type { PodManager } from './PodManager';
@@ -22,27 +21,23 @@ import type { PodSettings } from './settings/PodSettings';
*/
export class ConfigPodManager implements PodManager {
protected readonly logger = getLoggerFor(this);
private readonly idGenerator: IdentifierGenerator;
private readonly podGenerator: PodGenerator;
private readonly routingStorage: KeyValueStorage<string, ResourceStore>;
private readonly resourcesGenerator: ResourcesGenerator;
/**
* @param idGenerator - Generator for the pod identifiers.
* @param podGenerator - Generator for the pod stores.
* @param resourcesGenerator - Generator for the pod resources.
* @param routingStorage - Where to store the generated pods so they can be routed to.
*/
public constructor(idGenerator: IdentifierGenerator, podGenerator: PodGenerator,
resourcesGenerator: ResourcesGenerator, routingStorage: KeyValueStorage<string, ResourceStore>) {
this.idGenerator = idGenerator;
public constructor(podGenerator: PodGenerator, resourcesGenerator: ResourcesGenerator,
routingStorage: KeyValueStorage<string, ResourceStore>) {
this.podGenerator = podGenerator;
this.routingStorage = routingStorage;
this.resourcesGenerator = resourcesGenerator;
}
public async createPod(settings: PodSettings): Promise<ResourceIdentifier> {
const identifier = this.idGenerator.generate(settings.login);
public async createPod(identifier: ResourceIdentifier, settings: PodSettings): Promise<void> {
this.logger.info(`Creating pod ${identifier.path}`);
// Will error in case there already is a store for the given identifier
@@ -52,7 +47,5 @@ export class ConfigPodManager implements PodManager {
this.logger.info(`Added ${count} resources to ${identifier.path}`);
await this.routingStorage.set(identifier.path, store);
return identifier;
}
}

View File

@@ -3,7 +3,6 @@ import { getLoggerFor } from '../logging/LogUtil';
import type { ResourceStore } from '../storage/ResourceStore';
import { ConflictHttpError } from '../util/errors/ConflictHttpError';
import { addGeneratedResources } from './generate/GenerateUtil';
import type { IdentifierGenerator } from './generate/IdentifierGenerator';
import type { ResourcesGenerator } from './generate/ResourcesGenerator';
import type { PodManager } from './PodManager';
import type { PodSettings } from './settings/PodSettings';
@@ -16,29 +15,24 @@ export class GeneratedPodManager implements PodManager {
protected readonly logger = getLoggerFor(this);
private readonly store: ResourceStore;
private readonly idGenerator: IdentifierGenerator;
private readonly resourcesGenerator: ResourcesGenerator;
public constructor(store: ResourceStore, idGenerator: IdentifierGenerator, resourcesGenerator: ResourcesGenerator) {
public constructor(store: ResourceStore, resourcesGenerator: ResourcesGenerator) {
this.store = store;
this.idGenerator = idGenerator;
this.resourcesGenerator = resourcesGenerator;
}
/**
* Creates a new pod, pre-populating it with the resources created by the data generator.
* Pod identifiers are created based on the identifier generator.
* Will throw an error if the given identifier already has a resource.
*/
public async createPod(settings: PodSettings): Promise<ResourceIdentifier> {
const podIdentifier = this.idGenerator.generate(settings.login);
this.logger.info(`Creating pod ${podIdentifier.path}`);
if (await this.store.resourceExists(podIdentifier)) {
throw new ConflictHttpError(`There already is a resource at ${podIdentifier.path}`);
public async createPod(identifier: ResourceIdentifier, settings: PodSettings): Promise<void> {
this.logger.info(`Creating pod ${identifier.path}`);
if (await this.store.resourceExists(identifier)) {
throw new ConflictHttpError(`There already is a resource at ${identifier.path}`);
}
const count = await addGeneratedResources(podIdentifier, settings, this.resourcesGenerator, this.store);
this.logger.info(`Added ${count} resources to ${podIdentifier.path}`);
return podIdentifier;
const count = await addGeneratedResources(identifier, settings, this.resourcesGenerator, this.store);
this.logger.info(`Added ${count} resources to ${identifier.path}`);
}
}

View File

@@ -8,8 +8,8 @@ import type { PodSettings } from './settings/PodSettings';
export interface PodManager {
/**
* Creates a pod for the given settings.
* @param identifier - Root identifier indicating where the pod should be created.
* @param settings - Settings describing the pod.
* @returns {@link ResourceIdentifier} of the newly created pod.
*/
createPod: (settings: PodSettings) => Promise<ResourceIdentifier>;
createPod: (identifier: ResourceIdentifier, settings: PodSettings) => Promise<void>;
}

View File

@@ -4,10 +4,6 @@
* they give an indication of what is sometimes expected.
*/
export interface PodSettings extends NodeJS.Dict<string> {
/**
* The name of the pod. Will be used for generating the identifier.
*/
login: string;
/**
* The WebId of the owner of this pod.
*/
@@ -29,8 +25,4 @@ export interface PodSettings extends NodeJS.Dict<string> {
* The OIDC issuer of the owner's WebId.
*/
oidcIssuer?: string;
/**
* A registration token for linking the owner's WebId to an IDP.
*/
oidcIssuerRegistrationToken?: string;
}