feat: Full rework of account management

Complete rewrite of the account management and related systems.
Makes the architecture more modular,
allowing for easier extensions and configurations.
This commit is contained in:
Joachim Van Herwegen
2022-03-16 10:12:13 +01:00
parent ade977bb4f
commit a47f5236ef
366 changed files with 12345 additions and 5111 deletions

View File

@@ -0,0 +1,76 @@
import { object, string } from 'yup';
import { getLoggerFor } from '../../../logging/LogUtil';
import { ConflictHttpError } from '../../../util/errors/ConflictHttpError';
import type { AccountStore } from '../account/util/AccountStore';
import { addLoginEntry, getRequiredAccount } from '../account/util/AccountUtil';
import type { JsonRepresentation } from '../InteractionUtil';
import { JsonInteractionHandler } from '../JsonInteractionHandler';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import type { JsonView } from '../JsonView';
import { parseSchema, validateWithError } from '../YupUtil';
import type { PasswordIdRoute } from './util/PasswordIdRoute';
import { PASSWORD_METHOD } from './util/PasswordStore';
import type { PasswordStore } from './util/PasswordStore';
type OutType = { resource: string };
const inSchema = object({
// Store e-mail addresses in lower case
email: string().trim().email().lowercase()
.required(),
password: string().trim().min(1).required(),
});
/**
* Handles the creation of email/password login combinations for an account.
*/
export class CreatePasswordHandler extends JsonInteractionHandler<OutType> implements JsonView {
protected readonly logger = getLoggerFor(this);
private readonly passwordStore: PasswordStore;
private readonly accountStore: AccountStore;
private readonly passwordRoute: PasswordIdRoute;
public constructor(passwordStore: PasswordStore, accountStore: AccountStore, passwordRoute: PasswordIdRoute) {
super();
this.passwordStore = passwordStore;
this.accountStore = accountStore;
this.passwordRoute = passwordRoute;
}
public async getView(): Promise<JsonRepresentation> {
return { json: parseSchema(inSchema) };
}
public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise<JsonRepresentation<OutType>> {
const account = await getRequiredAccount(this.accountStore, accountId);
// Email will be in lowercase
const { email, password } = await validateWithError(inSchema, json);
if (account.logins[PASSWORD_METHOD]?.[email]) {
throw new ConflictHttpError('This account already has a login method for this e-mail address.');
}
const resource = this.passwordRoute.getPath({ accountId: account.id, passwordId: encodeURIComponent(email) });
// We need to create the password entry first before trying to add it to the account,
// otherwise it might be impossible to remove it from the account again since
// you can't remove a login method from an account if it is the last one.
await this.passwordStore.create(email, account.id, password);
// If we ever want to add email verification this would have to be checked separately
await this.passwordStore.confirmVerification(email);
try {
addLoginEntry(account, PASSWORD_METHOD, email, resource);
await this.accountStore.update(account);
} catch (error: unknown) {
this.logger.warn(`Error while updating account ${account.id}, reverting operation.`);
await this.passwordStore.delete(email);
throw error;
}
return { json: { resource }};
}
}

View File

@@ -0,0 +1,44 @@
import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError';
import type { EmptyObject } from '../../../util/map/MapUtil';
import type { AccountStore } from '../account/util/AccountStore';
import { ensureResource, getRequiredAccount, safeUpdate } from '../account/util/AccountUtil';
import type { JsonRepresentation } from '../InteractionUtil';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import { JsonInteractionHandler } from '../JsonInteractionHandler';
import { PASSWORD_METHOD } from './util/PasswordStore';
import type { PasswordStore } from './util/PasswordStore';
/**
* Handles the deletion of a password login method.
*/
export class DeletePasswordHandler extends JsonInteractionHandler<EmptyObject> {
private readonly accountStore: AccountStore;
private readonly passwordStore: PasswordStore;
public constructor(accountStore: AccountStore, passwordStore: PasswordStore) {
super();
this.accountStore = accountStore;
this.passwordStore = passwordStore;
}
public async handle({ target, accountId }: JsonInteractionHandlerInput): Promise<JsonRepresentation<EmptyObject>> {
const account = await getRequiredAccount(this.accountStore, accountId);
const passwordLogins = account.logins[PASSWORD_METHOD];
if (!passwordLogins) {
throw new NotFoundHttpError();
}
const email = ensureResource(passwordLogins, target.path);
// This needs to happen first since this checks that there is at least 1 login method
delete passwordLogins[email];
// Delete the password data and revert if something goes wrong
await safeUpdate(account,
this.accountStore,
(): Promise<any> => this.passwordStore.delete(email));
return { json: {}};
}
}

View File

@@ -0,0 +1,115 @@
import { object, string } from 'yup';
import { getLoggerFor } from '../../../logging/LogUtil';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import type { TemplateEngine } from '../../../util/templates/TemplateEngine';
import type { JsonRepresentation } from '../InteractionUtil';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import { JsonInteractionHandler } from '../JsonInteractionHandler';
import type { JsonView } from '../JsonView';
import type { InteractionRoute } from '../routing/InteractionRoute';
import { parseSchema, validateWithError } from '../YupUtil';
import type { EmailSender } from './util/EmailSender';
import type { ForgotPasswordStore } from './util/ForgotPasswordStore';
import type { PasswordStore } from './util/PasswordStore';
const inSchema = object({
email: string().trim().email().required(),
});
export interface ForgotPasswordHandlerArgs {
/**
* Store containing the password login information.
*/
passwordStore: PasswordStore;
/**
* Store containing the forgot password records.
*/
forgotPasswordStore: ForgotPasswordStore;
/**
* Template engine that will be used to generate the email body.
*/
templateEngine: TemplateEngine<{ resetLink: string }>;
/**
* Sender to send the actual email.
*/
emailSender: EmailSender;
/**
* Route used to generate the reset link for the user.
*/
resetRoute: InteractionRoute;
}
type OutType = { email: string };
/**
* Responsible for the case where a user forgot their password and asks for a reset.
* Will send out the necessary mail if the email address is known.
* The JSON response will always be the same to prevent leaking which email addresses are stored.
*/
export class ForgotPasswordHandler extends JsonInteractionHandler<OutType> implements JsonView {
protected readonly logger = getLoggerFor(this);
private readonly passwordStore: PasswordStore;
private readonly forgotPasswordStore: ForgotPasswordStore;
private readonly templateEngine: TemplateEngine<{ resetLink: string }>;
private readonly emailSender: EmailSender;
private readonly resetRoute: InteractionRoute;
public constructor(args: ForgotPasswordHandlerArgs) {
super();
this.passwordStore = args.passwordStore;
this.forgotPasswordStore = args.forgotPasswordStore;
this.templateEngine = args.templateEngine;
this.emailSender = args.emailSender;
this.resetRoute = args.resetRoute;
}
public async getView(): Promise<JsonRepresentation> {
return { json: parseSchema(inSchema) };
}
/**
* Generates a record to reset the password for the given email address and then mails it.
* In case there is no account, no error wil be thrown for privacy reasons.
* Nothing will happen instead.
*/
public async handle({ json }: JsonInteractionHandlerInput): Promise<JsonRepresentation<OutType>> {
const { email } = await validateWithError(inSchema, json);
const accountId = await this.passwordStore.get(email);
if (accountId) {
try {
const recordId = await this.forgotPasswordStore.generate(email);
await this.sendResetMail(recordId, email);
} catch (error: unknown) {
// This error can not be thrown for privacy reasons.
// If there always is an error, because there is a problem with the mail server for example,
// errors would only be thrown for registered accounts.
// Although we do also leak this information when an account tries to register an email address,
// so this might be removed in the future.
this.logger.error(`Problem sending a recovery mail: ${createErrorMessage(error)}`);
}
} else {
// Don't emit an error for privacy reasons
this.logger.warn(`Password reset request for unknown email ${email}`);
}
return { json: { email }};
}
/**
* Generates the link necessary for resetting the password and mails it to the given email address.
*/
private async sendResetMail(recordId: string, email: string): Promise<void> {
this.logger.info(`Sending password reset to ${email}`);
const resetLink = `${this.resetRoute.getPath()}?rid=${encodeURIComponent(recordId)}`;
const renderedEmail = await this.templateEngine.handleSafe({ contents: { resetLink }});
await this.emailSender.handleSafe({
recipient: email,
subject: 'Reset your password',
text: `To reset your password, go to this link: ${resetLink}`,
html: renderedEmail,
});
}
}

View File

@@ -0,0 +1,52 @@
import { boolean, object, string } from 'yup';
import { getLoggerFor } from '../../../logging/LogUtil';
import type { AccountIdRoute } from '../account/AccountIdRoute';
import type { AccountStore } from '../account/util/AccountStore';
import type { CookieStore } from '../account/util/CookieStore';
import type { JsonRepresentation } from '../InteractionUtil';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import type { JsonView } from '../JsonView';
import type { LoginOutputType } from '../login/ResolveLoginHandler';
import { ResolveLoginHandler } from '../login/ResolveLoginHandler';
import { parseSchema, validateWithError } from '../YupUtil';
import type { PasswordStore } from './util/PasswordStore';
const inSchema = object({
email: string().trim().email().required(),
password: string().trim().required(),
remember: boolean().default(false),
});
export interface PasswordLoginHandlerArgs {
accountStore: AccountStore;
passwordStore: PasswordStore;
cookieStore: CookieStore;
accountRoute: AccountIdRoute;
}
/**
* Handles the submission of the Login Form and logs the user in.
*/
export class PasswordLoginHandler extends ResolveLoginHandler implements JsonView {
protected readonly logger = getLoggerFor(this);
private readonly passwordStore: PasswordStore;
public constructor(args: PasswordLoginHandlerArgs) {
super(args.accountStore, args.cookieStore, args.accountRoute);
this.passwordStore = args.passwordStore;
}
public async getView(): Promise<JsonRepresentation> {
return { json: parseSchema(inSchema) };
}
public async login({ json }: JsonInteractionHandlerInput): Promise<JsonRepresentation<LoginOutputType>> {
const { email, password, remember } = await validateWithError(inSchema, json);
// Try to log in, will error if email/password combination is invalid
const accountId = await this.passwordStore.authenticate(email, password);
this.logger.debug(`Logging in user ${email}`);
return { json: { accountId, remember }};
}
}

View File

@@ -0,0 +1,62 @@
import { object, string } from 'yup';
import { getLoggerFor } from '../../../logging/LogUtil';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import type { EmptyObject } from '../../../util/map/MapUtil';
import type { JsonRepresentation } from '../InteractionUtil';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import { JsonInteractionHandler } from '../JsonInteractionHandler';
import type { JsonView } from '../JsonView';
import { parseSchema, validateWithError } from '../YupUtil';
import type { ForgotPasswordStore } from './util/ForgotPasswordStore';
import type { PasswordStore } from './util/PasswordStore';
const inSchema = object({
recordId: string().trim().min(1).required(),
password: string().trim().required(),
});
/**
* Resets a password if a valid `recordId` is provided,
* which should have been generated by a different handler.
*/
export class ResetPasswordHandler extends JsonInteractionHandler<EmptyObject> implements JsonView {
protected readonly logger = getLoggerFor(this);
private readonly passwordStore: PasswordStore;
private readonly forgotPasswordStore: ForgotPasswordStore;
public constructor(passwordStore: PasswordStore, forgotPasswordStore: ForgotPasswordStore) {
super();
this.passwordStore = passwordStore;
this.forgotPasswordStore = forgotPasswordStore;
}
public async getView(): Promise<JsonRepresentation> {
return { json: parseSchema(inSchema) };
}
public async handle({ json }: JsonInteractionHandlerInput): Promise<JsonRepresentation<EmptyObject>> {
// Validate input data
const { password, recordId } = await validateWithError(inSchema, json);
await this.resetPassword(recordId, password);
return { json: {}};
}
/**
* Resets the password for the account associated with the given recordId.
*/
private async resetPassword(recordId: string, newPassword: string): Promise<void> {
const email = await this.forgotPasswordStore.get(recordId);
if (!email) {
this.logger.warn(`Trying to use invalid reset URL with record ID ${recordId}`);
throw new BadRequestHttpError('This reset password link is no longer valid.');
}
await this.passwordStore.update(email, newPassword);
await this.forgotPasswordStore.delete(recordId);
this.logger.debug(`Resetting password for user ${email}`);
}
}

View File

@@ -0,0 +1,59 @@
import { object, string } from 'yup';
import { getLoggerFor } from '../../../logging/LogUtil';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import type { EmptyObject } from '../../../util/map/MapUtil';
import type { AccountStore } from '../account/util/AccountStore';
import { ensureResource, getRequiredAccount } from '../account/util/AccountUtil';
import type { JsonRepresentation } from '../InteractionUtil';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import { JsonInteractionHandler } from '../JsonInteractionHandler';
import type { JsonView } from '../JsonView';
import { parseSchema, validateWithError } from '../YupUtil';
import { PASSWORD_METHOD } from './util/PasswordStore';
import type { PasswordStore } from './util/PasswordStore';
const inSchema = object({
oldPassword: string().trim().min(1).required(),
newPassword: string().trim().min(1).required(),
});
/**
* Allows the password of a login to be updated.
*/
export class UpdatePasswordHandler extends JsonInteractionHandler<EmptyObject> implements JsonView {
private readonly logger = getLoggerFor(this);
private readonly accountStore: AccountStore;
private readonly passwordStore: PasswordStore;
public constructor(accountStore: AccountStore, passwordStore: PasswordStore) {
super();
this.accountStore = accountStore;
this.passwordStore = passwordStore;
}
public async getView(): Promise<JsonRepresentation> {
return { json: parseSchema(inSchema) };
}
public async handle(input: JsonInteractionHandlerInput): Promise<JsonRepresentation<EmptyObject>> {
const { target, accountId, json } = input;
const account = await getRequiredAccount(this.accountStore, accountId);
const email = ensureResource(account.logins[PASSWORD_METHOD], target.path);
const { oldPassword, newPassword } = await validateWithError(inSchema, json);
// Make sure the old password is correct
try {
await this.passwordStore.authenticate(email, oldPassword);
} catch {
this.logger.warn(`Invalid password when trying to reset for email ${email}`);
throw new BadRequestHttpError('Old password is invalid.');
}
await this.passwordStore.update(email, newPassword);
return { json: {}};
}
}

View File

@@ -0,0 +1,44 @@
import { createTransport } from 'nodemailer';
import type Mail from 'nodemailer/lib/mailer';
import { getLoggerFor } from '../../../../logging/LogUtil';
import { EmailSender } from './EmailSender';
import type { EmailArgs } from './EmailSender';
export interface EmailSenderArgs {
emailConfig: {
host: string;
port: number;
auth: {
user: string;
pass: string;
};
};
senderName?: string;
}
/**
* Sends e-mails using nodemailer.
*/
export class BaseEmailSender extends EmailSender {
private readonly logger = getLoggerFor(this);
private readonly mailTransporter: Mail;
private readonly senderName: string;
public constructor(args: EmailSenderArgs) {
super();
this.mailTransporter = createTransport(args.emailConfig);
this.senderName = args.senderName ?? 'Solid';
}
public async handle({ recipient, subject, text, html }: EmailArgs): Promise<void> {
await this.mailTransporter.sendMail({
from: this.senderName,
to: recipient,
subject,
text,
html,
});
this.logger.debug(`Sending recovery mail to ${recipient}`);
}
}

View File

@@ -0,0 +1,30 @@
import { v4 } from 'uuid';
import type { ExpiringStorage } from '../../../../storage/keyvalue/ExpiringStorage';
import type { ForgotPasswordStore } from './ForgotPasswordStore';
/**
* A {@link ForgotPasswordStore} using an {@link ExpiringStorage} to hold the necessary records.
*/
export class BaseForgotPasswordStore implements ForgotPasswordStore {
private readonly storage: ExpiringStorage<string, string>;
private readonly ttl: number;
public constructor(storage: ExpiringStorage<string, string>, ttl = 15) {
this.storage = storage;
this.ttl = ttl * 60 * 1000;
}
public async generate(email: string): Promise<string> {
const recordId = v4();
await this.storage.set(recordId, email, this.ttl);
return recordId;
}
public async get(recordId: string): Promise<string | undefined> {
return this.storage.get(recordId);
}
public async delete(recordId: string): Promise<boolean> {
return this.storage.delete(recordId);
}
}

View File

@@ -0,0 +1,103 @@
import { hash, compare } from 'bcryptjs';
import { getLoggerFor } from '../../../../logging/LogUtil';
import type { KeyValueStorage } from '../../../../storage/keyvalue/KeyValueStorage';
import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError';
import { ForbiddenHttpError } from '../../../../util/errors/ForbiddenHttpError';
import type { PasswordStore } from './PasswordStore';
/**
* A payload to persist a user account
*/
export interface LoginPayload {
accountId: string;
password: string;
verified: boolean;
}
/**
* A {@link PasswordStore} that uses a {@link KeyValueStorage} to store the entries.
* Passwords are hashed and salted.
* Default `saltRounds` is 10.
*/
export class BasePasswordStore implements PasswordStore {
private readonly logger = getLoggerFor(this);
private readonly storage: KeyValueStorage<string, LoginPayload>;
private readonly saltRounds: number;
public constructor(storage: KeyValueStorage<string, LoginPayload>, saltRounds = 10) {
this.storage = storage;
this.saltRounds = saltRounds;
}
/**
* Helper function that converts the given e-mail to a resource identifier
* and retrieves the login data from the internal storage.
*
* Will error if `checkExistence` is true and there is no login data for that email.
*/
private async getLoginPayload(email: string, checkExistence: true): Promise<{ key: string; payload: LoginPayload }>;
private async getLoginPayload(email: string, checkExistence: false): Promise<{ key: string; payload?: LoginPayload }>;
private async getLoginPayload(email: string, checkExistence: boolean):
Promise<{ key: string; payload?: LoginPayload }> {
const key = encodeURIComponent(email.toLowerCase());
const payload = await this.storage.get(key);
if (checkExistence && !payload) {
this.logger.warn(`Trying to get account info for unknown email ${email}`);
throw new ForbiddenHttpError('Login does not exist.');
}
return { key, payload };
}
public async get(email: string): Promise<string | undefined> {
const { payload } = await this.getLoginPayload(email, false);
return payload?.accountId;
}
public async authenticate(email: string, password: string): Promise<string> {
const { payload } = await this.getLoginPayload(email, true);
if (!payload.verified) {
this.logger.warn(`Trying to get account info for unverified email ${email}`);
throw new ForbiddenHttpError('Login still needs to be verified.');
}
if (!await compare(password, payload.password)) {
this.logger.warn(`Incorrect password for email ${email}`);
throw new ForbiddenHttpError('Incorrect password.');
}
return payload.accountId;
}
public async create(email: string, accountId: string, password: string): Promise<void> {
const { key, payload } = await this.getLoginPayload(email, false);
if (payload) {
this.logger.warn(`Trying to create duplicate login for email ${email}`);
throw new BadRequestHttpError('There already is a login for this e-mail address.');
}
await this.storage.set(key, {
accountId,
password: await hash(password, this.saltRounds),
verified: false,
});
}
public async confirmVerification(email: string): Promise<void> {
const { key, payload } = await this.getLoginPayload(email, true);
payload.verified = true;
await this.storage.set(key, payload);
}
public async update(email: string, password: string): Promise<void> {
const { key, payload } = await this.getLoginPayload(email, true);
payload.password = await hash(password, this.saltRounds);
await this.storage.set(key, payload);
}
public async delete(email: string): Promise<boolean> {
const { key, payload } = await this.getLoginPayload(email, false);
const exists = Boolean(payload);
if (exists) {
await this.storage.delete(key);
}
return exists;
}
}

View File

@@ -0,0 +1,13 @@
import { AsyncHandler } from '../../../../util/handlers/AsyncHandler';
export interface EmailArgs {
recipient: string;
subject: string;
text: string;
html: string;
}
/**
* A class that can send an e-mail.
*/
export abstract class EmailSender extends AsyncHandler<EmailArgs> {}

View File

@@ -0,0 +1,27 @@
/**
* Responsible for storing the records that are used when a user forgets their password.
*/
export interface ForgotPasswordStore {
/**
* 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.
*/
generate: (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.
*/
get: (recordId: string) => Promise<string | undefined>;
/**
* Deletes the Forgot Password Confirmation Record.
* @param recordId - The record id of the forgot password confirmation record.
*/
delete: (recordId: string) => Promise<boolean>;
}

View File

@@ -0,0 +1,19 @@
import type { AccountIdKey, AccountIdRoute } from '../../account/AccountIdRoute';
import { IdInteractionRoute } from '../../routing/IdInteractionRoute';
import type { ExtendedRoute } from '../../routing/InteractionRoute';
export type PasswordIdKey = 'passwordId';
/**
* An {@link AccountIdRoute} that also includes a password login identifier.
*/
export type PasswordIdRoute = ExtendedRoute<AccountIdRoute, PasswordIdKey>;
/**
* Implementation of an {@link PasswordIdRoute} that adds the identifier relative to a base {@link AccountIdRoute}.
*/
export class BasePasswordIdRoute extends IdInteractionRoute<AccountIdKey, PasswordIdKey> {
public constructor(base: AccountIdRoute) {
super(base, 'passwordId');
}
}

View File

@@ -0,0 +1,54 @@
/**
* The constant used to identify email/password based login combinations in the map of logins an account has.
*/
export const PASSWORD_METHOD = 'password';
/**
* Responsible for storing everything related to email/password based login combinations.
*/
export interface PasswordStore {
/**
* Finds the Account ID linked to this email address.
* @param email - The email address of which to find the account.
* @returns The relevant Account ID or `undefined` if there is no match.
*/
get: (email: string) => Promise<string | undefined>;
/**
* Authenticate if the email and password are correct and return the Account ID 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 Account ID.
*/
authenticate: (email: string, password: string) => Promise<string>;
/**
* Stores a new login entry for this account.
* @param email - Account email.
* @param accountId - Account ID.
* @param password - Account password.
*/
create: (email: string, accountId: string, password: string) => Promise<void>;
/**
* Confirms that the e-mail address has been verified. This can be used with, for example, email verification.
* The login 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.
*/
confirmVerification: (email: string) => Promise<void>;
/**
* Changes the password.
* @param email - The user's email.
* @param password - The user's password.
*/
update: (email: string, password: string) => Promise<void>;
/**
* Delete the login entry of this email address.
* @param email - The user's email.
*/
delete: (email: string) => Promise<boolean>;
}