From 6c4ccb334de93d42451f9443afebfc0bc264b95b Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 30 Aug 2021 16:47:34 +0200 Subject: [PATCH] feat: Store account settings separately Account settings are stored using the WebID as key. Reason for using the WebID is that this allows faster access to the settings in authenticated requests. A consequence of this is that passwords are now always required during registration, and that there can only be 1 account per WebID. --- .../email-password/handler/LoginHandler.ts | 6 + .../email-password/storage/AccountStore.ts | 67 +++++++---- .../storage/BaseAccountStore.ts | 56 ++++++--- .../util/RegistrationManager.ts | 35 +++--- .../email-password/register-partial.html.ejs | 11 +- test/integration/DynamicPods.test.ts | 13 ++- test/integration/Identity.test.ts | 16 ++- test/integration/Subdomains.test.ts | 12 +- .../handler/LoginHandler.test.ts | 34 +++--- .../storage/BaseAccountStore.test.ts | 53 ++++++--- .../util/RegistrationManager.test.ts | 109 ++++++++---------- 11 files changed, 246 insertions(+), 166 deletions(-) diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts index c8996de58..288d6b07a 100644 --- a/src/identity/interaction/email-password/handler/LoginHandler.ts +++ b/src/identity/interaction/email-password/handler/LoginHandler.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import type { Operation } from '../../../../ldp/operations/Operation'; import { getLoggerFor } from '../../../../logging/LogUtil'; +import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError'; import { readJsonStream } from '../../../../util/StreamUtil'; import type { AccountStore } from '../storage/AccountStore'; import { InteractionHandler } from './InteractionHandler'; @@ -23,6 +24,11 @@ export class LoginHandler extends InteractionHandler { const { email, password, remember } = await this.parseInput(operation); // Try to log in, will error if email/password combination is invalid const webId = await this.accountStore.authenticate(email, password); + const settings = await this.accountStore.getSettings(webId); + if (!settings.useIdp) { + // There is an account but is not used for identification with the IDP + throw new BadRequestHttpError('This server is not an identity provider for this account.'); + } this.logger.debug(`Logging in user ${email}`); return { type: 'complete', diff --git a/src/identity/interaction/email-password/storage/AccountStore.ts b/src/identity/interaction/email-password/storage/AccountStore.ts index 6a512b47e..6de898163 100644 --- a/src/identity/interaction/email-password/storage/AccountStore.ts +++ b/src/identity/interaction/email-password/storage/AccountStore.ts @@ -1,42 +1,67 @@ +/** + * Options that can be set on an account. + */ +export interface AccountSettings { + /** + * If this account can be used to identify as the corresponding WebID in the IDP. + */ + useIdp: boolean; +} + /** * Storage needed for the email-password interaction */ export interface AccountStore { /** - * Authenticate if the username and password are correct and return the webId - * if it is. Return an error if it is not. - * @param email - the user's email - * @param password - this user's password - * @returns The user's WebId + * Authenticate if the username and password are correct and return the WebID + * if it is. Throw an error if it is not. + * @param email - The user's email. + * @param password - This user's password. + * @returns The user's WebID. */ authenticate: (email: string, password: string) => Promise; /** - * Creates a new account - * @param email - the account email - * @param webId - account webId - * @param password - account password + * Creates a new account. + * @param email - Account email. + * @param webId - Account WebID. + * @param password - Account password. + * @param settings - Specific settings for the account. */ - create: (email: string, webId: string, password: string) => Promise; + create: (email: string, webId: string, password: string, settings: AccountSettings) => Promise; /** * Verifies the account creation. This can be used with, for example, e-mail verification. * The account can only be used after it is verified. * In case verification is not required, this should be called immediately after the `create` call. - * @param email - the account email + * @param email - The account email. */ verify: (email: string) => Promise; /** - * Changes the password - * @param email - the user's email - * @param password - the user's password + * Changes the password. + * @param email - The user's email. + * @param password - The user's password. */ changePassword: (email: string, password: string) => Promise; /** - * Delete the account - * @param email - the user's email + * Gets the settings associated with this account. + * Errors if there is no matching account. + * @param webId - The account WebID. + */ + getSettings: (webId: string) => Promise; + + /** + * Updates the settings associated with this account. + * @param webId - The account WebID. + * @param settings - New settings for the account. + */ + updateSettings: (webId: string, settings: AccountSettings) => Promise; + + /** + * Delete the account. + * @param email - The user's email. */ deleteAccount: (email: string) => Promise; @@ -44,22 +69,22 @@ export interface AccountStore { * Creates a Forgot Password Confirmation Record. This will be to remember that * a user has made a request to reset a password. Throws an error if the email doesn't * exist - * @param email - the user's email - * @returns the record id. This should be included in the reset password link + * @param email - The user's email. + * @returns The record id. This should be included in the reset password link. */ generateForgotPasswordRecord: (email: string) => Promise; /** * Gets the email associated with the forgot password confirmation record or undefined * if it's not present - * @param recordId - the record id retrieved from the link - * @returns the user's email + * @param recordId - The record id retrieved from the link. + * @returns The user's email. */ getForgotPasswordRecord: (recordId: string) => Promise; /** * Deletes the Forgot Password Confirmation Record - * @param recordId - the record id of the forgot password confirmation record + * @param recordId - The record id of the forgot password confirmation record. */ deleteForgotPasswordRecord: (recordId: string) => Promise; } diff --git a/src/identity/interaction/email-password/storage/BaseAccountStore.ts b/src/identity/interaction/email-password/storage/BaseAccountStore.ts index 844bebc29..4cfc04d0b 100644 --- a/src/identity/interaction/email-password/storage/BaseAccountStore.ts +++ b/src/identity/interaction/email-password/storage/BaseAccountStore.ts @@ -2,7 +2,7 @@ import assert from 'assert'; import { hash, compare } from 'bcrypt'; import { v4 } from 'uuid'; import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage'; -import type { AccountStore } from './AccountStore'; +import type { AccountSettings, AccountStore } from './AccountStore'; /** * A payload to persist a user account @@ -23,7 +23,7 @@ export interface ForgotPasswordPayload { recordId: string; } -export type EmailPasswordData = AccountPayload | ForgotPasswordPayload; +export type EmailPasswordData = AccountPayload | ForgotPasswordPayload | AccountSettings; /** * A EmailPasswordStore that uses a KeyValueStorage @@ -52,58 +52,84 @@ export class BaseAccountStore implements AccountStore { return `forgot-password-resource-identifier/${encodeURIComponent(recordId)}`; } + /* eslint-disable lines-between-class-members */ /** * Helper function that converts the given e-mail to an account identifier * and retrieves the account data from the internal storage. + * + * Will error if `checkExistence` is true and the account does not exist. */ - private async getAccountPayload(email: string): Promise<{ key: string; account?: AccountPayload }> { + private async getAccountPayload(email: string, checkExistence: true): + Promise<{ key: string; account: AccountPayload }>; + private async getAccountPayload(email: string, checkExistence: false): + Promise<{ key: string; account?: AccountPayload }>; + private async getAccountPayload(email: string, checkExistence: boolean): + Promise<{ key: string; account?: AccountPayload }> { const key = this.getAccountResourceIdentifier(email); const account = await this.storage.get(key) as AccountPayload | undefined; + assert(!checkExistence || account, 'Account does not exist'); return { key, account }; } + /* eslint-enable lines-between-class-members */ public async authenticate(email: string, password: string): Promise { - const { account } = await this.getAccountPayload(email); - assert(account, 'No account by that email'); + const { account } = await this.getAccountPayload(email, true); assert(account.verified, 'Account still needs to be verified'); assert(await compare(password, account.password), 'Incorrect password'); return account.webId; } - public async create(email: string, webId: string, password: string): Promise { - const { key, account } = await this.getAccountPayload(email); + public async create(email: string, webId: string, password: string, settings: AccountSettings): Promise { + const { key, account } = await this.getAccountPayload(email, false); assert(!account, 'Account already exists'); + // Make sure there is no other account for this WebID + const storedSettings = await this.storage.get(webId); + assert(!storedSettings, 'There already is an account for this WebID'); const payload: AccountPayload = { email, - webId, password: await hash(password, this.saltRounds), verified: false, + webId, }; await this.storage.set(key, payload); + await this.storage.set(webId, settings); } public async verify(email: string): Promise { - const { key, account } = await this.getAccountPayload(email); - assert(account, 'Account does not exist'); + const { key, account } = await this.getAccountPayload(email, true); account.verified = true; await this.storage.set(key, account); } public async changePassword(email: string, password: string): Promise { - const { key, account } = await this.getAccountPayload(email); - assert(account, 'Account does not exist'); + const { key, account } = await this.getAccountPayload(email, true); account.password = await hash(password, this.saltRounds); await this.storage.set(key, account); } + public async getSettings(webId: string): Promise { + const settings = await this.storage.get(webId) as AccountSettings | undefined; + assert(settings, 'Account does not exist'); + return settings; + } + + public async updateSettings(webId: string, settings: AccountSettings): Promise { + const oldSettings = await this.storage.get(webId); + assert(oldSettings, 'Account does not exist'); + await this.storage.set(webId, settings); + } + public async deleteAccount(email: string): Promise { - await this.storage.delete(this.getAccountResourceIdentifier(email)); + const { key, account } = await this.getAccountPayload(email, false); + if (account) { + await this.storage.delete(key); + await this.storage.delete(account.webId); + } } public async generateForgotPasswordRecord(email: string): Promise { const recordId = v4(); - const { account } = await this.getAccountPayload(email); - assert(account, 'Account does not exist'); + await this.getAccountPayload(email, true); await this.storage.set( this.getForgotPasswordRecordResourceIdentifier(recordId), { recordId, email }, diff --git a/src/identity/interaction/email-password/util/RegistrationManager.ts b/src/identity/interaction/email-password/util/RegistrationManager.ts index cddc73e98..476ffab23 100644 --- a/src/identity/interaction/email-password/util/RegistrationManager.ts +++ b/src/identity/interaction/email-password/util/RegistrationManager.ts @@ -7,7 +7,7 @@ import type { PodSettings } from '../../../../pods/settings/PodSettings'; import { joinUrl } from '../../../../util/PathUtil'; import type { OwnershipValidator } from '../../../ownership/OwnershipValidator'; import { assertPassword } from '../EmailPasswordUtil'; -import type { AccountStore } from '../storage/AccountStore'; +import type { AccountSettings, AccountStore } from '../storage/AccountStore'; export interface RegistrationManagerArgs { /** @@ -42,7 +42,7 @@ export interface RegistrationManagerArgs { export interface RegistrationParams { email: string; webId?: string; - password?: string; + password: string; podName?: string; template?: string; createWebId: boolean; @@ -123,8 +123,11 @@ export class RegistrationManager { const trimmedEmail = this.trimString(email); assert(trimmedEmail && emailRegex.test(trimmedEmail), 'Please enter a valid e-mail address.'); + assertPassword(password, confirmPassword); + const validated: RegistrationParams = { email: trimmedEmail, + password, register: Boolean(register) || Boolean(createWebId), createPod: Boolean(createPod) || Boolean(createWebId), createWebId: Boolean(createWebId), @@ -147,14 +150,6 @@ export class RegistrationManager { validated.podName = trimmedPodName; } - // Parse account - if (validated.register) { - const trimmedPassword = this.trimString(password); - const trimmedConfirmPassword = this.trimString(confirmPassword); - assertPassword(trimmedPassword, trimmedConfirmPassword); - validated.password = trimmedPassword; - } - // Parse template if there is one if (template) { validated.template = this.trimString(template); @@ -194,9 +189,10 @@ export class RegistrationManager { } // Register the account - if (input.register) { - await this.accountStore.create(input.email, input.webId!, input.password!); - } + const settings: AccountSettings = { + useIdp: input.register, + }; + await this.accountStore.create(input.email, input.webId!, input.password, settings); // Create the pod if (input.createPod) { @@ -216,20 +212,15 @@ export class RegistrationManager { // Only allow overwrite for root pods await this.podManager.createPod(podBaseUrl!, podSettings, allowRoot); } catch (error: unknown) { - // In case pod creation errors we don't want to keep the account - if (input.register) { - await this.accountStore.deleteAccount(input.email); - } + await this.accountStore.deleteAccount(input.email); throw error; } } // Verify the account - if (input.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(input.email); - } + // 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(input.email); return { webId: input.webId, diff --git a/templates/identity/email-password/register-partial.html.ejs b/templates/identity/email-password/register-partial.html.ejs index b4e2ee6c6..dfe67bea5 100644 --- a/templates/identity/email-password/register-partial.html.ejs +++ b/templates/identity/email-password/register-partial.html.ejs @@ -107,13 +107,6 @@ - @@ -132,7 +125,7 @@ 'mainForm', 'createWebIdOn', 'createWebIdOff', 'createWebIdForm', 'existingWebIdForm', 'webId', 'createPod', 'createPodForm', 'rootPodOn', 'rootPodOff', 'podNameForm', 'podName', - 'register', 'passwordForm', 'noPasswordForm', + 'register', 'passwordForm', ].forEach(registerElement); // Conditions under which elements should be visible @@ -141,8 +134,6 @@ existingWebIdForm: () => elements.createWebIdOff.checked, createPodForm: () => elements.createPod.checked, podNameForm: () => !elements.rootPodOn.checked, - passwordForm: () => elements.createWebIdOn.checked || elements.register.checked, - noPasswordForm: () => !isVisible('passwordForm'), }; // Ensures that the only relevant input fields are visible and enabled diff --git a/test/integration/DynamicPods.test.ts b/test/integration/DynamicPods.test.ts index 4f3ff971e..a8e22de90 100644 --- a/test/integration/DynamicPods.test.ts +++ b/test/integration/DynamicPods.test.ts @@ -23,7 +23,15 @@ const configs: [string, any][] = [ // Tests are very similar to subdomain/pod tests. Would be nice if they can be combined describe.each(configs)('A dynamic pod server with template config %s', (template, { teardown }): void => { let app: App; - const settings = { podName: 'alice', webId: 'http://test.com/#alice', email: 'alice@test.email', template, createPod: true }; + const settings = { + podName: 'alice', + webId: 'http://test.com/#alice', + email: 'alice@test.email', + password: 'password', + confirmPassword: 'password', + template, + createPod: true, + }; const podUrl = `${baseUrl}${settings.podName}/`; beforeAll(async(): Promise => { @@ -109,10 +117,11 @@ describe.each(configs)('A dynamic pod server with template config %s', (template }); it('should not be able to create a pod with the same name.', async(): Promise => { + const newSettings = { ...settings, webId: 'http://test.com/#bob', email: 'bob@test.email' }; const res = await fetch(`${baseUrl}idp/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify(settings), + body: JSON.stringify(newSettings), }); expect(res.status).toBe(409); await expect(res.text()).resolves.toContain(`There already is a pod at ${podUrl}`); diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts index f81d41861..2c1026a9d 100644 --- a/test/integration/Identity.test.ts +++ b/test/integration/Identity.test.ts @@ -53,6 +53,7 @@ describe('A Solid server with IDP', (): void => { const oidcIssuer = baseUrl; const card = joinUrl(baseUrl, 'profile/card'); const webId = `${card}#me`; + const webId2 = `${card}#someoneElse`; const email = 'test@test.com'; const password = 'password!'; const password2 = 'password2!'; @@ -241,25 +242,32 @@ describe('A Solid server with IDP', (): void => { }); }); - describe('creating pods without registering', (): void => { + describe('creating pods without registering with the IDP', (): void => { let formBody: string; let registrationTriple: string; const podName = 'myPod'; beforeAll(async(): Promise => { // We will need this twice - formBody = stringify({ email, webId, podName, createPod: 'ok' }); + formBody = stringify({ + email: 'bob@test.email', + webId: webId2, + password, + confirmPassword: password, + podName, + createPod: 'ok', + }); }); it('sends the form once to receive the registration triple.', async(): Promise => { const res = await postForm(`${baseUrl}idp/register`, formBody); expect(res.status).toBe(400); - registrationTriple = extractRegistrationTriple(await res.text(), webId); + registrationTriple = extractRegistrationTriple(await res.text(), webId2); }); it('updates the webId with the registration token.', async(): Promise => { const patchBody = `INSERT DATA { ${registrationTriple} }`; - const res = await fetch(webId, { + const res = await fetch(webId2, { method: 'PATCH', headers: { 'content-type': 'application/sparql-update' }, body: patchBody, diff --git a/test/integration/Subdomains.test.ts b/test/integration/Subdomains.test.ts index e59e4449d..f96e624b7 100644 --- a/test/integration/Subdomains.test.ts +++ b/test/integration/Subdomains.test.ts @@ -28,7 +28,14 @@ const stores: [string, any][] = [ // Simulating subdomains using the forwarded header so no DNS changes are required describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardown }): void => { let app: App; - const settings = { podName: 'alice', webId: 'http://test.com/#alice', email: 'alice@test.email', createPod: true }; + const settings = { + podName: 'alice', + webId: 'http://test.com/#alice', + email: 'alice@test.email', + password: 'password', + confirmPassword: 'password', + createPod: true, + }; const podHost = `alice.localhost:${port}`; const podUrl = `http://${podHost}/`; @@ -142,10 +149,11 @@ describe.each(stores)('A subdomain server with %s', (name, { storeConfig, teardo }); it('should not be able to create a pod with the same name.', async(): Promise => { + const newSettings = { ...settings, webId: 'http://test.com/#bob', email: 'bob@test.email' }; const res = await fetch(`${baseUrl}idp/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify(settings), + body: JSON.stringify(newSettings), }); expect(res.status).toBe(409); await expect(res.text()).resolves.toContain(`There already is a resource at ${podUrl}`); diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts index fc7209122..7b3642194 100644 --- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts +++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts @@ -9,42 +9,44 @@ describe('A LoginHandler', (): void => { const webId = 'http://alice.test.com/card#me'; const email = 'alice@test.email'; let input: InteractionHandlerInput; - let storageAdapter: AccountStore; + let accountStore: jest.Mocked; let handler: LoginHandler; beforeEach(async(): Promise => { input = {} as any; - storageAdapter = { + accountStore = { authenticate: jest.fn().mockResolvedValue(webId), + getSettings: jest.fn().mockResolvedValue({ useIdp: true }), } as any; - handler = new LoginHandler(storageAdapter); + handler = new LoginHandler(accountStore); }); it('errors on invalid emails.', async(): Promise => { input.operation = createPostJsonOperation({}); - let prom = handler.handle(input); - await expect(prom).rejects.toThrow('Email required'); + await expect(handler.handle(input)).rejects.toThrow('Email required'); input.operation = createPostJsonOperation({ email: [ 'a', 'b' ]}); - prom = handler.handle(input); - await expect(prom).rejects.toThrow('Email required'); + await expect(handler.handle(input)).rejects.toThrow('Email required'); }); it('errors on invalid passwords.', async(): Promise => { input.operation = createPostJsonOperation({ email }); - let prom = handler.handle(input); - await expect(prom).rejects.toThrow('Password required'); + await expect(handler.handle(input)).rejects.toThrow('Password required'); input.operation = createPostJsonOperation({ email, password: [ 'a', 'b' ]}); - prom = handler.handle(input); - await expect(prom).rejects.toThrow('Password required'); + await expect(handler.handle(input)).rejects.toThrow('Password required'); }); it('throws an error if there is a problem.', async(): Promise => { input.operation = createPostJsonOperation({ email, password: 'password!' }); - (storageAdapter.authenticate as jest.Mock).mockRejectedValueOnce(new Error('auth failed!')); - const prom = handler.handle(input); - await expect(prom).rejects.toThrow('auth failed!'); + accountStore.authenticate.mockRejectedValueOnce(new Error('auth failed!')); + await expect(handler.handle(input)).rejects.toThrow('auth failed!'); + }); + + it('throws an error if the account does not have the correct settings.', async(): Promise => { + input.operation = createPostJsonOperation({ email, password: 'password!' }); + accountStore.getSettings.mockResolvedValueOnce({ useIdp: false }); + await expect(handler.handle(input)).rejects.toThrow('This server is not an identity provider for this account.'); }); it('returns an InteractionCompleteResult when done.', async(): Promise => { @@ -53,7 +55,7 @@ describe('A LoginHandler', (): void => { type: 'complete', details: { webId, shouldRemember: false }, }); - expect(storageAdapter.authenticate).toHaveBeenCalledTimes(1); - expect(storageAdapter.authenticate).toHaveBeenLastCalledWith(email, 'password!'); + expect(accountStore.authenticate).toHaveBeenCalledTimes(1); + expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!'); }); }); diff --git a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts index 1a97af295..e640b6b53 100644 --- a/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts +++ b/test/unit/identity/interaction/email-password/storage/BaseAccountStore.test.ts @@ -1,3 +1,4 @@ +import type { AccountSettings } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore'; import type { EmailPasswordData, } from '../../../../../../src/identity/interaction/email-password/storage/BaseAccountStore'; @@ -11,6 +12,7 @@ describe('A BaseAccountStore', (): void => { const email = 'test@test.com'; const webId = 'http://test.com/#webId'; const password = 'password!'; + const settings: AccountSettings = { useIdp: true }; beforeEach(async(): Promise => { const map = new Map(); @@ -24,20 +26,26 @@ describe('A BaseAccountStore', (): void => { }); it('can create accounts.', async(): Promise => { - await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); }); it('errors when creating a second account for an email.', async(): Promise => { - await expect(store.create(email, webId, password)).resolves.toBeUndefined(); - await expect(store.create(email, 'diffId', 'diffPass')).rejects.toThrow('Account already exists'); + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); + await expect(store.create(email, webId, 'diffPass', settings)).rejects.toThrow('Account already exists'); + }); + + it('errors when creating a second account for a WebID.', async(): Promise => { + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); + await expect(store.create('bob@test.email', webId, 'diffPass', settings)) + .rejects.toThrow('There already is an account for this WebID'); }); it('errors when authenticating a non-existent account.', async(): Promise => { - await expect(store.authenticate(email, password)).rejects.toThrow('No account by that email'); + await expect(store.authenticate(email, password)).rejects.toThrow('Account does not exist'); }); it('errors when authenticating an unverified account.', async(): Promise => { - await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Account still needs to be verified'); }); @@ -46,13 +54,13 @@ describe('A BaseAccountStore', (): void => { }); it('errors when authenticating with the wrong password.', async(): Promise => { - await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); await expect(store.verify(email)).resolves.toBeUndefined(); await expect(store.authenticate(email, 'wrongPassword')).rejects.toThrow('Incorrect password'); }); it('can authenticate.', async(): Promise => { - await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); await expect(store.verify(email)).resolves.toBeUndefined(); await expect(store.authenticate(email, password)).resolves.toBe(webId); }); @@ -63,16 +71,35 @@ describe('A BaseAccountStore', (): void => { it('can change the password.', async(): Promise => { const newPassword = 'newPassword!'; - await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); await expect(store.verify(email)).resolves.toBeUndefined(); await expect(store.changePassword(email, newPassword)).resolves.toBeUndefined(); await expect(store.authenticate(email, newPassword)).resolves.toBe(webId); }); + it('can get the settings.', async(): Promise => { + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); + await expect(store.verify(email)).resolves.toBeUndefined(); + await expect(store.getSettings(webId)).resolves.toBe(settings); + }); + + it('can update the settings.', async(): Promise => { + const newSettings = { webId, useIdp: false }; + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); + await expect(store.verify(email)).resolves.toBeUndefined(); + await expect(store.updateSettings(webId, newSettings)).resolves.toBeUndefined(); + await expect(store.getSettings(webId)).resolves.toBe(newSettings); + }); + it('can delete an account.', async(): Promise => { - await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); + await expect(store.deleteAccount(email)).resolves.toBeUndefined(); + await expect(store.authenticate(email, password)).rejects.toThrow('Account does not exist'); + await expect(store.getSettings(webId)).rejects.toThrow('Account does not exist'); + }); + + it('does nothing when deleting non-existent accounts.', async(): Promise => { await expect(store.deleteAccount(email)).resolves.toBeUndefined(); - await expect(store.authenticate(email, password)).rejects.toThrow('No account by that email'); }); it('errors when forgetting the password of an account that does not exist.', async(): Promise => { @@ -80,7 +107,7 @@ describe('A BaseAccountStore', (): void => { }); it('generates a recordId when a password was forgotten.', async(): Promise => { - await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); const recordId = await store.generateForgotPasswordRecord(email); expect(typeof recordId).toBe('string'); }); @@ -90,13 +117,13 @@ describe('A BaseAccountStore', (): void => { }); it('returns the email matching the forgotten password record.', async(): Promise => { - await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); const recordId = await store.generateForgotPasswordRecord(email); await expect(store.getForgotPasswordRecord(recordId)).resolves.toBe(email); }); it('can delete stored forgotten password records.', async(): Promise => { - await expect(store.create(email, webId, password)).resolves.toBeUndefined(); + await expect(store.create(email, webId, password, settings)).resolves.toBeUndefined(); const recordId = await store.generateForgotPasswordRecord(email); await expect(store.deleteForgotPasswordRecord(recordId)).resolves.toBeUndefined(); await expect(store.getForgotPasswordRecord('unknownRecord')).resolves.toBeUndefined(); diff --git a/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts b/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts index d81c97e67..b99f3f0ba 100644 --- a/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts +++ b/test/unit/identity/interaction/email-password/util/RegistrationManager.test.ts @@ -74,70 +74,72 @@ describe('A RegistrationManager', (): void => { expect((): any => manager.validateInput(input)).toThrow('Please enter a valid e-mail address.'); }); + it('errors on invalid passwords.', async(): Promise => { + const input: any = { email, webId, password, confirmPassword: 'bad' }; + expect((): any => manager.validateInput(input)).toThrow('Your password and confirmation did not match.'); + }); + + it('errors on missing passwords.', async(): Promise => { + const input: any = { email, webId }; + expect((): any => manager.validateInput(input)).toThrow('Please enter a password.'); + }); + it('errors when setting rootPod to true when not allowed.', async(): Promise => { - const input = { email, createWebId, rootPod }; + const input = { email, password, confirmPassword, createWebId, rootPod }; expect((): any => manager.validateInput(input)).toThrow('Creating a root pod is not supported.'); }); it('errors when a required WebID is not valid.', async(): Promise => { - let input: any = { email, register, webId: undefined }; + let input: any = { email, password, confirmPassword, register, webId: undefined }; expect((): any => manager.validateInput(input)).toThrow('Please enter a valid WebID.'); - input = { email, register, webId: '' }; + input = { email, password, confirmPassword, register, webId: '' }; expect((): any => manager.validateInput(input)).toThrow('Please enter a valid WebID.'); }); - it('errors on invalid passwords when registering.', async(): Promise => { - const input: any = { email, webId, password, confirmPassword: 'bad', register }; - expect((): any => manager.validateInput(input)).toThrow('Your password and confirmation did not match.'); - }); - it('errors on invalid pod names when required.', async(): Promise => { - let input: any = { email, webId, createPod, podName: undefined }; + let input: any = { email, webId, password, confirmPassword, createPod, podName: undefined }; expect((): any => manager.validateInput(input)).toThrow('Please specify a Pod name.'); - input = { email, webId, createPod, podName: ' ' }; + input = { email, webId, password, confirmPassword, createPod, podName: ' ' }; expect((): any => manager.validateInput(input)).toThrow('Please specify a Pod name.'); - input = { email, webId, createWebId }; + input = { email, webId, password, confirmPassword, createWebId }; expect((): any => manager.validateInput(input)).toThrow('Please specify a Pod name.'); }); - it('errors when trying to create a WebID without registering or creating a pod.', async(): Promise => { - let input: any = { email, podName, createWebId }; - expect((): any => manager.validateInput(input)).toThrow('Please enter a password.'); - - input = { email, podName, createWebId, createPod }; - expect((): any => manager.validateInput(input)).toThrow('Please enter a password.'); - - input = { email, podName, createWebId, createPod, register }; - expect((): any => manager.validateInput(input)).toThrow('Please enter a password.'); - }); - it('errors when no option is chosen.', async(): Promise => { - const input = { email, webId }; + const input = { email, webId, password, confirmPassword }; expect((): any => manager.validateInput(input)).toThrow('Please register for a WebID or create a Pod.'); }); it('adds the template parameter if there is one.', async(): Promise => { - const input = { email, webId, podName, template: 'template', createPod }; + const input = { email, webId, password, confirmPassword, podName, template: 'template', createPod }; expect(manager.validateInput(input)).toEqual({ - email, webId, podName, template: 'template', createWebId: false, register: false, createPod, rootPod: false, + email, + webId, + password, + podName, + template: 'template', + createWebId: false, + register: false, + createPod, + rootPod: false, }); }); it('does not require a pod name when creating a root pod.', async(): Promise => { - const input = { email, webId, createPod, rootPod }; + const input = { email, password, confirmPassword, webId, createPod, rootPod }; expect(manager.validateInput(input, true)).toEqual({ - email, webId, createWebId: false, register: false, createPod, rootPod, + email, password, webId, createWebId: false, register: false, createPod, rootPod, }); }); - it('trims input parameters.', async(): Promise => { + it('trims non-password input parameters.', async(): Promise => { let input: any = { email: ` ${email} `, - password: ` ${password} `, - confirmPassword: ` ${password} `, + password: ' a ', + confirmPassword: ' a ', podName: ` ${podName} `, template: ' template ', createWebId, @@ -145,19 +147,19 @@ describe('A RegistrationManager', (): void => { createPod, }; expect(manager.validateInput(input)).toEqual({ - email, password, podName, template: 'template', createWebId, register, createPod, rootPod: false, + email, password: ' a ', podName, template: 'template', createWebId, register, createPod, rootPod: false, }); - input = { email, webId: ` ${webId} `, password, confirmPassword, register: true }; + input = { email, webId: ` ${webId} `, password: ' a ', confirmPassword: ' a ', register: true }; expect(manager.validateInput(input)).toEqual({ - email, webId, password, createWebId: false, register, createPod: false, rootPod: false, + email, webId, password: ' a ', createWebId: false, register, createPod: false, rootPod: false, }); }); }); describe('handling data', (): void => { it('can register a user.', async(): Promise => { - const params: any = { email, webId, password, confirmPassword, register, createPod: false, createWebId: false }; + const params: any = { email, webId, password, register, createPod: false, createWebId: false }; await expect(manager.register(params)).resolves.toEqual({ email, webId, @@ -170,7 +172,7 @@ describe('A RegistrationManager', (): void => { expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password); + expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true }); expect(accountStore.verify).toHaveBeenCalledTimes(1); expect(accountStore.verify).toHaveBeenLastCalledWith(email); @@ -180,7 +182,7 @@ describe('A RegistrationManager', (): void => { }); it('can create a pod.', async(): Promise => { - const params: any = { email, webId, podName, createPod, createWebId: false, register: false }; + const params: any = { email, webId, password, podName, createPod, createWebId: false, register: false }; await expect(manager.register(params)).resolves.toEqual({ email, webId, @@ -197,9 +199,10 @@ describe('A RegistrationManager', (): void => { expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); expect(podManager.createPod).toHaveBeenCalledTimes(1); expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: false }); + expect(accountStore.verify).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenCalledTimes(0); - expect(accountStore.verify).toHaveBeenCalledTimes(0); expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); }); @@ -219,7 +222,7 @@ describe('A RegistrationManager', (): void => { expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password); + expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true }); expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); expect(podManager.createPod).toHaveBeenCalledTimes(1); @@ -239,7 +242,7 @@ describe('A RegistrationManager', (): void => { expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password); + expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true }); expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); expect(podManager.createPod).toHaveBeenCalledTimes(1); @@ -250,23 +253,6 @@ describe('A RegistrationManager', (): void => { expect(accountStore.verify).toHaveBeenCalledTimes(0); }); - it('does not try to delete an account on failure if there was no registration.', async(): Promise => { - const params: any = { email, webId, podName, createPod }; - (podManager.createPod as jest.Mock).mockRejectedValueOnce(new Error('pod error')); - await expect(manager.register(params)).rejects.toThrow('pod error'); - - expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1); - expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); - expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); - expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); - expect(podManager.createPod).toHaveBeenCalledTimes(1); - expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false); - - expect(accountStore.create).toHaveBeenCalledTimes(0); - expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); - expect(accountStore.verify).toHaveBeenCalledTimes(0); - }); - it('can create a WebID with an account and pod.', async(): Promise => { const params: any = { email, password, confirmPassword, podName, createWebId, register, createPod }; const generatedWebID = joinUrl(baseUrl, podName, webIdSuffix); @@ -286,7 +272,7 @@ describe('A RegistrationManager', (): void => { expect(identifierGenerator.generate).toHaveBeenCalledTimes(1); expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName); expect(accountStore.create).toHaveBeenCalledTimes(1); - expect(accountStore.create).toHaveBeenLastCalledWith(email, generatedWebID, password); + expect(accountStore.create).toHaveBeenLastCalledWith(email, generatedWebID, password, { useIdp: true }); expect(accountStore.verify).toHaveBeenCalledTimes(1); expect(accountStore.verify).toHaveBeenLastCalledWith(email); expect(podManager.createPod).toHaveBeenCalledTimes(1); @@ -297,7 +283,7 @@ describe('A RegistrationManager', (): void => { }); it('can create a root pod.', async(): Promise => { - const params: any = { email, webId, createPod, rootPod, createWebId: false, register: false }; + const params: any = { email, webId, password, createPod, rootPod, createWebId: false, register: false }; podSettings.podBaseUrl = baseUrl; await expect(manager.register(params, true)).resolves.toEqual({ email, @@ -313,10 +299,11 @@ describe('A RegistrationManager', (): void => { expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId }); expect(podManager.createPod).toHaveBeenCalledTimes(1); expect(podManager.createPod).toHaveBeenLastCalledWith({ path: baseUrl }, podSettings, true); + expect(accountStore.create).toHaveBeenCalledTimes(1); + expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: false }); + expect(accountStore.verify).toHaveBeenCalledTimes(1); expect(identifierGenerator.generate).toHaveBeenCalledTimes(0); - expect(accountStore.create).toHaveBeenCalledTimes(0); - expect(accountStore.verify).toHaveBeenCalledTimes(0); expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0); }); });