mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
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:
76
src/identity/interaction/password/CreatePasswordHandler.ts
Normal file
76
src/identity/interaction/password/CreatePasswordHandler.ts
Normal 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 }};
|
||||
}
|
||||
}
|
||||
44
src/identity/interaction/password/DeletePasswordHandler.ts
Normal file
44
src/identity/interaction/password/DeletePasswordHandler.ts
Normal 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: {}};
|
||||
}
|
||||
}
|
||||
115
src/identity/interaction/password/ForgotPasswordHandler.ts
Normal file
115
src/identity/interaction/password/ForgotPasswordHandler.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
52
src/identity/interaction/password/PasswordLoginHandler.ts
Normal file
52
src/identity/interaction/password/PasswordLoginHandler.ts
Normal 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 }};
|
||||
}
|
||||
}
|
||||
62
src/identity/interaction/password/ResetPasswordHandler.ts
Normal file
62
src/identity/interaction/password/ResetPasswordHandler.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
59
src/identity/interaction/password/UpdatePasswordHandler.ts
Normal file
59
src/identity/interaction/password/UpdatePasswordHandler.ts
Normal 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: {}};
|
||||
}
|
||||
}
|
||||
44
src/identity/interaction/password/util/BaseEmailSender.ts
Normal file
44
src/identity/interaction/password/util/BaseEmailSender.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
103
src/identity/interaction/password/util/BasePasswordStore.ts
Normal file
103
src/identity/interaction/password/util/BasePasswordStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/identity/interaction/password/util/EmailSender.ts
Normal file
13
src/identity/interaction/password/util/EmailSender.ts
Normal 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> {}
|
||||
@@ -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>;
|
||||
}
|
||||
19
src/identity/interaction/password/util/PasswordIdRoute.ts
Normal file
19
src/identity/interaction/password/util/PasswordIdRoute.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
54
src/identity/interaction/password/util/PasswordStore.ts
Normal file
54
src/identity/interaction/password/util/PasswordStore.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user