From 864dd7c2e015d33ef6535e7c3e882bc108bda224 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 6 Oct 2023 15:10:07 +0200 Subject: [PATCH] feat: Add support for initializing a server with a root pod --- RELEASE_NOTES.md | 1 + config/app/README.md | 3 + config/app/init/initialize-root-pod.json | 17 +++++ config/app/init/initializers/root-pod.json | 21 ++++++ config/file-root-pod.json | 51 ++++++++++++++ src/identity/AccountInitializer.ts | 70 +++++++++++++++++++ src/index.ts | 2 + test/unit/identity/AccountInitializer.test.ts | 55 +++++++++++++++ 8 files changed, 220 insertions(+) create mode 100644 config/app/init/initialize-root-pod.json create mode 100644 config/app/init/initializers/root-pod.json create mode 100644 config/file-root-pod.json create mode 100644 src/identity/AccountInitializer.ts create mode 100644 test/unit/identity/AccountInitializer.test.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 98f7354fb..473c22d48 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -46,6 +46,7 @@ The following changes pertain to the imports in the default configs: - There is a new `identity/oidc` import set that needs to be added to each config. Options are `default.json` and `disabled.json`. - There is a new `static-root.json` import option for `app/init`, setting a static page for the root container. +- There is a new `initialize-root-pod.json` import option for `app/init`, initializing a pod in the root. - There are more `identity/handler` options to finetune account management availability. - There is a new set of imports `storage/location` to determine where the root storage of the server is located. - The `app/setup`and `identity/registration` imports have been removed. diff --git a/config/app/README.md b/config/app/README.md index 89492860e..872db221e 100644 --- a/config/app/README.md +++ b/config/app/README.md @@ -11,6 +11,9 @@ Contains a list of initializer that need to be run when starting the server. * *initialize-prefilled-root*: Similar to `initialize-root` but adds an index page to the root container. * *initialize-intro*: Similar to `initialize-prefilled-root` but adds an index page specific to the memory-based server of the default configuration. +* *initialize-root-pod*: Initializes the server with a pod in the root. + Email and password have not yet been set and need to be defined in the base configuration. + See `file-root-pod.json` for an example. * *static-root*: Shows a static introduction page at the server root. This is not a Solid resource. ## Main diff --git a/config/app/init/initialize-root-pod.json b/config/app/init/initialize-root-pod.json new file mode 100644 index 000000000..760b4403f --- /dev/null +++ b/config/app/init/initialize-root-pod.json @@ -0,0 +1,17 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/app/init/default.json", + "css:config/app/init/initializers/root-pod.json" + ], + "@graph": [ + { + "comment": "Initializes the root container resource.", + "@id": "urn:solid-server:default:PrimaryParallelInitializer", + "@type": "ParallelHandler", + "handlers": [ + { "@id": "urn:solid-server:default:RootInitializer" } + ] + } + ] +} diff --git a/config/app/init/initializers/root-pod.json b/config/app/init/initializers/root-pod.json new file mode 100644 index 000000000..28f8bb0c8 --- /dev/null +++ b/config/app/init/initializers/root-pod.json @@ -0,0 +1,21 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Makes sure the root container exists and contains the necessary resources.", + "@id": "urn:solid-server:default:RootInitializer", + "@type": "ConditionalHandler", + "storageKey": "rootInitialized", + "storageValue": true, + "handleStorage": true, + "storage": { "@id": "urn:solid-server:default:SetupStorage" }, + "source": { + "@id": "urn:solid-server:default:RootPodInitializer", + "@type": "AccountInitializer", + "accountStore": { "@id": "urn:solid-server:default:AccountStore" }, + "passwordStore": { "@id": "urn:solid-server:default:PasswordStore" }, + "podCreator": { "@id": "urn:solid-server:default:PodCreator" } + } + } + ] +} diff --git a/config/file-root-pod.json b/config/file-root-pod.json new file mode 100644 index 000000000..84437da3e --- /dev/null +++ b/config/file-root-pod.json @@ -0,0 +1,51 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld", + "import": [ + "css:config/app/main/default.json", + "css:config/app/init/initialize-root-pod.json", + "css:config/app/variables/default.json", + "css:config/http/handler/default.json", + "css:config/http/middleware/default.json", + "css:config/http/notifications/websockets.json", + "css:config/http/server-factory/http.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/no-accounts.json", + "css:config/identity/oidc/default.json", + "css:config/identity/ownership/token.json", + "css:config/identity/pod/static.json", + "css:config/ldp/authentication/dpop-bearer.json", + "css:config/ldp/authorization/webacl.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + "css:config/storage/backend/file.json", + "css:config/storage/key-value/resource-store.json", + "css:config/storage/location/root.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acl.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + "css:config/util/resource-locker/file.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": [ + "A Solid server that stores its resources on disk and uses WAC for authorization.", + "A pod will be created in the root with the email/password login defined here.", + "It is advised to immediately change this password after starting the server." + ] + }, + { + "@id": "urn:solid-server:default:RootPodInitializer", + "@type": "AccountInitializer", + "email": "test@example.com", + "password": "secret!" + } + ] +} diff --git a/src/identity/AccountInitializer.ts b/src/identity/AccountInitializer.ts new file mode 100644 index 000000000..740496aeb --- /dev/null +++ b/src/identity/AccountInitializer.ts @@ -0,0 +1,70 @@ +import { Initializer } from '../init/Initializer'; +import { getLoggerFor } from '../logging/LogUtil'; +import type { AccountStore } from './interaction/account/util/AccountStore'; +import type { PasswordStore } from './interaction/password/util/PasswordStore'; +import type { PodCreator } from './interaction/pod/util/PodCreator'; + +export interface AccountInitializerArgs { + /** + * Creates the accounts. + */ + accountStore: AccountStore; + /** + * Adds the login methods. + */ + passwordStore: PasswordStore; + /** + * Creates the pods. + */ + podCreator: PodCreator; + /** + * Email address for the account login. + */ + email: string; + /** + * Password for the account login. + */ + password: string; + /** + * Name to use for the pod. If undefined the pod will be made in the root of the server. + */ + name?: string; +} + +/** + * Initializes an account with email/password login and a pod with the provided name. + */ +export class AccountInitializer extends Initializer { + protected readonly logger = getLoggerFor(this); + + private readonly accountStore: AccountStore; + private readonly passwordStore: PasswordStore; + private readonly podCreator: PodCreator; + private email: string | undefined; + private password: string | undefined; + private readonly name: string | undefined; + + public constructor(args: AccountInitializerArgs) { + super(); + this.accountStore = args.accountStore; + this.passwordStore = args.passwordStore; + this.podCreator = args.podCreator; + + this.email = args.email; + this.password = args.password; + this.name = args.name; + } + + public async handle(): Promise { + this.logger.info(`Creating account for ${this.email}`); + const accountId = await this.accountStore.create(); + const id = await this.passwordStore.create(this.email!, accountId, this.password!); + await this.passwordStore.confirmVerification(id); + this.logger.info(`Creating pod ${this.name ? `with name ${this.name}` : 'at the root'}`); + await this.podCreator.handleSafe({ accountId, name: this.name }); + + // Not really necessary but don't want to keep passwords in memory if not required + delete this.email; + delete this.password; + } +} diff --git a/src/index.ts b/src/index.ts index 27e28d236..b3d4dd42c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -258,7 +258,9 @@ export * from './identity/storage/PassthroughAdapterFactory'; export * from './identity/storage/WebIdAdapterFactory'; // Identity +export * from './identity/AccountInitializer'; export * from './identity/IdentityProviderHttpHandler'; +export * from './identity/IdentityUtil'; export * from './identity/OidcHttpHandler'; // Init/Cluster diff --git a/test/unit/identity/AccountInitializer.test.ts b/test/unit/identity/AccountInitializer.test.ts new file mode 100644 index 000000000..4186172bc --- /dev/null +++ b/test/unit/identity/AccountInitializer.test.ts @@ -0,0 +1,55 @@ +import { AccountInitializer } from '../../../src/identity/AccountInitializer'; +import { AccountStore } from '../../../src/identity/interaction/account/util/AccountStore'; +import { PasswordStore } from '../../../src/identity/interaction/password/util/PasswordStore'; +import { PodCreator } from '../../../src/identity/interaction/pod/util/PodCreator'; + +describe('An AccountInitializer', (): void => { + const email = 'email@example.com'; + const password = 'password!'; + let accountStore: jest.Mocked; + let passwordStore: jest.Mocked; + let podCreator: jest.Mocked; + let initializer: AccountInitializer; + + beforeEach(async(): Promise => { + accountStore = { + create: jest.fn().mockResolvedValue('account-id'), + } satisfies Partial as any; + + passwordStore = { + create: jest.fn().mockResolvedValue('password-id'), + confirmVerification: jest.fn(), + } satisfies Partial as any; + + podCreator = { + handleSafe: jest.fn(), + } satisfies Partial as any; + + initializer = new AccountInitializer({ + accountStore, passwordStore, podCreator, email, password, + }); + }); + + it('creates the account/login/pod.', async(): Promise => { + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(passwordStore.create).toHaveBeenCalledTimes(1); + expect(passwordStore.create).toHaveBeenLastCalledWith(email, 'account-id', password); + expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(1); + expect(podCreator.handleSafe).toHaveBeenCalledTimes(1); + expect(podCreator.handleSafe).toHaveBeenLastCalledWith({ accountId: 'account-id' }); + }); + + it('can create a pod with a name.', async(): Promise => { + initializer = new AccountInitializer({ + accountStore, passwordStore, podCreator, email, password, name: 'name', + }); + await expect(initializer.handle()).resolves.toBeUndefined(); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(passwordStore.create).toHaveBeenCalledTimes(1); + expect(passwordStore.create).toHaveBeenLastCalledWith(email, 'account-id', password); + expect(passwordStore.confirmVerification).toHaveBeenCalledTimes(1); + expect(podCreator.handleSafe).toHaveBeenCalledTimes(1); + expect(podCreator.handleSafe).toHaveBeenLastCalledWith({ accountId: 'account-id', name: 'name' }); + }); +});