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.
This commit is contained in:
Joachim Van Herwegen
2021-08-30 16:47:34 +02:00
parent f40e2c768f
commit 6c4ccb334d
11 changed files with 246 additions and 166 deletions

View File

@@ -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',

View File

@@ -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<string>;
/**
* 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<void>;
create: (email: string, webId: string, password: string, settings: AccountSettings) => Promise<void>;
/**
* 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<void>;
/**
* 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<void>;
/**
* 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<AccountSettings>;
/**
* 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<void>;
/**
* Delete the account.
* @param email - The user's email.
*/
deleteAccount: (email: string) => Promise<void>;
@@ -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<string>;
/**
* 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<string | undefined>;
/**
* 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<void>;
}

View File

@@ -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<string> {
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<void> {
const { key, account } = await this.getAccountPayload(email);
public async create(email: string, webId: string, password: string, settings: AccountSettings): Promise<void> {
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<void> {
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<void> {
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<AccountSettings> {
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<void> {
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<void> {
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<string> {
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 },

View File

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