padloc/packages/core/src/server.ts

2122 lines
73 KiB
TypeScript

import { Serializable, stringToBase64, bytesToBase64 } from "./encoding";
import {
API,
StartCreateSessionParams,
StartCreateSessionResponse,
CreateAccountParams,
RecoverAccountParams,
CompleteCreateSessionParams,
GetInviteParams,
GetAttachmentParams,
DeleteAttachmentParams,
GetLegacyDataParams,
StartRegisterAuthenticatorParams,
StartRegisterAuthenticatorResponse,
CompleteRegisterMFAuthenticatorParams,
CompleteRegisterMFAuthenticatorResponse,
StartAuthRequestResponse,
CompleteAuthRequestResponse,
CompleteAuthRequestParams,
StartAuthRequestParams,
CreateKeyStoreEntryParams,
GetKeyStoreEntryParams,
AuthInfo,
UpdateAuthParams,
} from "./api";
import { Storage } from "./storage";
import { Attachment, AttachmentStorage } from "./attachment";
import { Session, SessionID } from "./session";
import { Account, AccountID } from "./account";
import { Auth, AccountStatus } from "./auth";
import {
AuthRequest,
AuthPurpose,
Authenticator,
AuthServer,
AuthType,
AuthenticatorStatus,
AuthRequestStatus,
} from "./auth";
import { Request, Response } from "./transport";
import { Err, ErrorCode } from "./error";
import { Vault, VaultID } from "./vault";
import { Org, OrgID, OrgMember, OrgMemberStatus, OrgRole, ScimSettings } from "./org";
import { Invite } from "./invite";
import {
ConfirmMembershipInviteMessage,
ErrorMessage,
JoinOrgInviteAcceptedMessage,
JoinOrgInviteCompletedMessage,
JoinOrgInviteMessage,
Messenger,
} from "./messenger";
import { Server as SRPServer, SRPSession } from "./srp";
import { DeviceInfo, getCryptoProvider } from "./platform";
import { getIdFromEmail, uuid, removeTrailingSlash } from "./util";
import { loadLanguage } from "@padloc/locale/src/translate";
import { Logger, VoidLogger } from "./logging";
import { PBES2Container } from "./container";
import { KeyStoreEntry } from "./key-store";
import { Config, ConfigParam } from "./config";
import { Provisioner, Provisioning, ProvisioningStatus, StubProvisioner } from "./provisioning";
import { V3Compat } from "./v3-compat";
/** Server configuration */
export class ServerConfig extends Config {
/** URL where the client interface is hosted. Used for creating links into the application */
@ConfigParam()
clientUrl = "http://localhost:8080";
/** Email address to report critical errors to */
@ConfigParam()
reportErrors = "";
/** Maximum accepted request age */
@ConfigParam("number")
maxRequestAge = 60 * 60 * 1000;
/** Whether or not to require email verification before creating an account */
@ConfigParam("boolean")
verifyEmailOnSignup = true;
@ConfigParam("string[]")
defaultAuthTypes: AuthType[] = [AuthType.Email];
/** URL where the SCIM directory server is hosted, if used. Used for creating URLs for integrations */
@ConfigParam()
scimServerUrl = "http://localhost:5000";
constructor(init: Partial<ServerConfig> = {}) {
super();
Object.assign(this, init);
}
}
/**
* Request context
*/
export interface Context {
/** Current [[Session]] */
session?: Session;
/** [[Account]] associated with current session */
account?: Account;
/** [[Auth]] associated with current session */
auth?: Auth;
/** [[Auth]] associated with current session */
provisioning?: Provisioning;
/** Information about the device the request is coming from */
device?: DeviceInfo;
location?: {
city?: string;
country?: string;
};
}
export interface LegacyServer {
getStore(email: string): Promise<PBES2Container | null>;
deleteAccount(email: string): Promise<void>;
}
/**
* Controller class for processing api requests
*/
export class Controller extends API {
constructor(public server: Server, public context: Context) {
super();
}
get config() {
return this.server.config;
}
get storage() {
return this.server.storage;
}
get messenger() {
return this.server.messenger;
}
get attachmentStorage() {
return this.server.attachmentStorage;
}
get legacyServer() {
return this.server.legacyServer;
}
get authServers() {
return this.server.authServers;
}
get provisioner() {
return this.server.provisioner;
}
async authenticate(req: Request, ctx: Context) {
if (!req.auth) {
return;
}
let session: Session;
// Find the session with the id specified in the [[Request.auth]] property
try {
session = await this.storage.get(Session, req.auth.session);
} catch (e) {
if (e.code === ErrorCode.NOT_FOUND) {
throw new Err(ErrorCode.INVALID_SESSION);
} else {
throw e;
}
}
// Reject expired sessions
if (session.expires && session.expires < new Date()) {
throw new Err(ErrorCode.SESSION_EXPIRED);
}
// Verify request signature
if (!(await session.verify(req))) {
throw new Err(ErrorCode.INVALID_REQUEST, "Failed to verify request signature!");
}
// Reject requests/responses older than a certain age to mitigate replay attacks
const age = Date.now() - new Date(req.auth.time).getTime();
if (age > this.config.maxRequestAge) {
throw new Err(
ErrorCode.MAX_REQUEST_AGE_EXCEEDED,
"The request was rejected because it's timestamp is too far in the past. " +
"Please make sure your local clock is set to the correct time and try again!"
);
}
// Get account associated with this session
const account = await this.storage.get(Account, session.account);
const auth = await this._getAuth(account.email);
// Store account and session on context
ctx.session = session;
ctx.account = account;
ctx.auth = auth;
ctx.location = req.location;
// Update session info
session.lastUsed = new Date();
session.device = ctx.device;
session.lastLocation = req.location;
session.updated = new Date();
const i = auth.sessions.findIndex(({ id }) => id === session.id);
if (i !== -1) {
auth.sessions[i] = session.info;
} else {
auth.sessions.push(session.info);
}
ctx.provisioning = await this.provisioner.getProvisioning(auth, session);
await Promise.all([this.storage.save(session), this.storage.save(account), this.storage.save(auth)]);
}
async process(req: Request) {
const def = this.handlerDefinitions.find((def) => def.method === req.method);
if (!def) {
throw new Err(ErrorCode.INVALID_REQUEST);
}
const clientVersion = (req.device && req.device.appVersion) || undefined;
const param = req.params && req.params[0];
const input = def.input && param ? new def.input().fromRaw(param) : param;
const result = await this[def.method](input);
const toRaw = (obj: any) => (obj instanceof Serializable ? obj.toRaw(clientVersion) : obj);
return Array.isArray(result) ? result.map(toRaw) : toRaw(result);
}
log(type: string, data: any = {}) {
return this.server.log(type, this.context, data);
}
async startRegisterAuthenticator({ type, purposes, data, device }: StartRegisterAuthenticatorParams) {
const { auth } = this._requireAuth();
const authenticator = new Authenticator({ type, purposes, device });
await authenticator.init();
const provider = this._getAuthServer(type);
const responseData = await provider.initAuthenticator(authenticator, auth, data);
auth.authenticators.push(authenticator);
await this.storage.save(auth);
return new StartRegisterAuthenticatorResponse({
id: authenticator.id,
data: responseData,
type,
});
}
async completeRegisterAuthenticator({ id, data }: CompleteRegisterMFAuthenticatorParams) {
const { auth } = this._requireAuth();
const authenticator = auth.authenticators.find((m) => m.id === id);
if (!authenticator) {
throw new Err(ErrorCode.AUTHENTICATION_FAILED, "Failed to complete authenticator registration.");
}
const provider = this._getAuthServer(authenticator.type);
const responseData = await provider.activateAuthenticator(authenticator, data);
authenticator.status = AuthenticatorStatus.Active;
await this.storage.save(auth);
this.log("account.registerAuthenticator", {
authenticator: {
id: authenticator.id,
type: authenticator.type,
description: authenticator.description,
purposes: authenticator.purposes,
},
});
return new CompleteRegisterMFAuthenticatorResponse({ id: authenticator.id, data: responseData });
}
async deleteAuthenticator(id: string) {
const { auth } = this._requireAuth();
// if (auth.authenticators.length <= 1) {
// throw new Err(
// ErrorCode.BAD_REQUEST,
// "Cannot delete multi-factor authenticator. At least one authenticator is required."
// );
// }
const index = auth.authenticators.findIndex((a) => a.id === id);
const authenticator = auth.authenticators[index];
if (index < 0) {
throw new Err(ErrorCode.NOT_FOUND, "An authenticator with this ID does not exist!");
}
auth.authenticators.splice(index, 1);
await this.storage.save(auth);
this.log("account.deleteAuthenticator", {
authenticator: {
id: authenticator.id,
type: authenticator.type,
description: authenticator.description,
purposes: authenticator.purposes,
},
});
}
async startAuthRequest({
email,
authenticatorId,
authenticatorIndex,
type,
supportedTypes,
purpose,
data,
}: StartAuthRequestParams): Promise<StartAuthRequestResponse> {
const auth = (this.context.auth = await this._getAuth(email));
const provisioning = (this.context.provisioning = await this.provisioner.getProvisioning(auth));
const authenticators = await this._getAuthenticators(auth);
const availableAuthenticators = authenticators.filter(
(m) =>
(typeof authenticatorId === "undefined" || m.id === authenticatorId) &&
(typeof type === "undefined" || m.type === type) &&
(typeof supportedTypes === "undefined" || supportedTypes.includes(m.type)) &&
(purpose === AuthPurpose.TestAuthenticator || m.purposes.includes(purpose)) &&
m.status === AuthenticatorStatus.Active
);
const authenticator = availableAuthenticators[authenticatorIndex || 0];
if (!authenticator) {
throw new Err(ErrorCode.NOT_FOUND, "No appropriate authenticator found!");
}
const provider = this._getAuthServer(authenticator.type);
const request = new AuthRequest({
authenticatorId: authenticator.id,
type: authenticator.type,
purpose:
purpose === AuthPurpose.Login && auth.accountStatus !== AccountStatus.Active
? AuthPurpose.Signup
: purpose,
device: this.context.device,
});
await request.init();
auth.authRequests.push(request);
const deviceTrusted =
this.context.device && auth.trustedDevices.some(({ id }) => id === this.context.device!.id);
const response = new StartAuthRequestResponse({
id: request.id,
email,
token: request.token,
type: request.type,
purpose: request.purpose,
authenticatorId: authenticator.id,
requestStatus: request.status,
deviceTrusted,
});
if (request.purpose === AuthPurpose.Login && deviceTrusted) {
request.verified = new Date();
response.requestStatus = request.status = AuthRequestStatus.Verified;
response.accountStatus = auth.accountStatus;
response.provisioning = provisioning.account;
} else {
response.data = await provider.initAuthRequest(authenticator, request, data);
authenticator.lastUsed = new Date();
}
await this.storage.save(auth);
this.log("account.startAuthRequest", {
authRequest: {
id: request.id,
type: request.type,
purpose: request.purpose,
},
});
return response;
}
async completeAuthRequest({ email, id, data }: CompleteAuthRequestParams) {
const auth = (this.context.auth = await this._getAuth(email));
const provisioning = (this.context.provisioning = await this.provisioner.getProvisioning(auth));
const request = auth.authRequests.find((m) => m.id === id);
if (!request) {
throw new Err(ErrorCode.AUTHENTICATION_FAILED, "Failed to complete auth request.");
}
if (request.tries >= 3) {
throw new Err(ErrorCode.AUTHENTICATION_TRIES_EXCEEDED, "You have exceed your allowed numer of tries!");
}
const authenticators = await this._getAuthenticators(auth);
const authenticator = authenticators.find((m) => m.id === request.authenticatorId);
if (!authenticator) {
throw new Err(ErrorCode.AUTHENTICATION_FAILED, "Failed to start auth request.");
}
if (request.type !== authenticator.type) {
throw new Err(
ErrorCode.AUTHENTICATION_FAILED,
"The auth request type and authenticator type do not match!"
);
}
const provider = this._getAuthServer(request.type);
try {
await provider.verifyAuthRequest(authenticator, request, data);
request.status = AuthRequestStatus.Verified;
request.verified = new Date();
if (request.purpose === AuthPurpose.TestAuthenticator) {
// We're merely testing the authenticator, so we can get rid of the
// mfa token right away.
await this.storage.save(auth);
await this._useAuthToken({
email,
requestId: request.id,
...request,
});
} else {
authenticator.lastUsed = new Date();
await this.storage.save(auth);
}
} catch (e) {
request.tries++;
await this.storage.save(auth);
this.log("account.completeAuthRequest", {
authRequest: {
id: request.id,
type: request.type,
purpose: request.purpose,
},
success: false,
error: typeof e === "string" ? e : e.message,
});
throw e;
}
await this.storage.save(auth);
const deviceTrusted =
auth && this.context.device && auth.trustedDevices.some(({ id }) => id === this.context.device!.id);
this.log("account.completeAuthRequest", {
authRequest: {
id: request.id,
type: request.type,
purpose: request.purpose,
},
success: true,
});
return new CompleteAuthRequestResponse({
accountStatus: auth.accountStatus,
deviceTrusted,
provisioning: provisioning.account,
legacyData: auth.legacyData,
});
}
async updateAuth({ verifier, keyParams, mfaOrder }: UpdateAuthParams): Promise<void> {
const { auth } = this._requireAuth();
if (verifier) {
auth.verifier = verifier;
this.log("account.updatePassword");
}
if (keyParams) {
auth.keyParams = keyParams;
}
if (mfaOrder) {
auth.mfaOrder = mfaOrder;
this.log("account.updateMFAOrder");
}
await this.storage.save(auth);
}
async removeTrustedDevice(id: string): Promise<void> {
const { auth } = this._requireAuth();
const index = auth.trustedDevices.findIndex((d) => d.id === id);
const device = auth.trustedDevices[index];
if (index < 0) {
throw new Err(ErrorCode.NOT_FOUND, "No trusted device with this ID was found!");
}
auth.trustedDevices.splice(index, 1);
this.log("account.removeTrustedDevice", { removedDevice: device.toRaw() });
await this.storage.save(auth);
}
async startCreateSession({ email, authToken }: StartCreateSessionParams): Promise<StartCreateSessionResponse> {
const auth = await this._getAuth(email);
const deviceTrusted =
auth && this.context.device && auth.trustedDevices.some(({ id }) => id === this.context.device!.id);
if (!deviceTrusted) {
if (!authToken) {
throw new Err(ErrorCode.AUTHENTICATION_REQUIRED);
} else {
await this._useAuthToken({ email, token: authToken, purpose: AuthPurpose.Login });
}
}
if (!auth.account) {
// The user has successfully verified their email address so it's safe to
// tell them that this account doesn't exist.
throw new Err(ErrorCode.NOT_FOUND, "An account with this email does not exist!");
}
const srpState = new SRPSession();
await srpState.init();
// Initiate SRP key exchange using the accounts verifier. This also
// generates the random `B` value which will be passed back to the
// client.
const srp = new SRPServer(srpState);
await srp.initialize(auth.verifier!);
auth.srpSessions.push(srpState);
await this.storage.save(auth);
return new StartCreateSessionResponse({
accountId: auth.account,
keyParams: auth.keyParams,
B: srp.B!,
srpId: srpState.id,
});
}
async completeCreateSession({
accountId: account,
srpId,
A,
M,
addTrustedDevice,
}: CompleteCreateSessionParams): Promise<Session> {
// Fetch the account in question
const acc = (this.context.account = await this.storage.get(Account, account));
const auth = (this.context.auth = await this._getAuth(acc.email));
this.context.provisioning = await this.provisioner.getProvisioning(auth);
// Get the pending SRP context for the given account
const srpState = auth.srpSessions.find((s) => s.id === srpId);
if (!srpState) {
throw new Err(ErrorCode.INVALID_CREDENTIALS, "No srp session with the given id found!");
}
const srp = new SRPServer(srpState);
// Apply `A` received from the client to the SRP context. This will
// compute the common session key and verification value.
await srp.setA(A);
// Verify `M`, which is the clients way of proving that they know the
// accounts master password. This also guarantees that the session key
// computed by the client and server are identical an can be used for
// authentication.
if (!(await getCryptoProvider().timingSafeEqual(M, srp.M1!))) {
this.log("account.createSession", { success: false });
throw new Err(ErrorCode.INVALID_CREDENTIALS);
}
// Create a new session object
const session = new Session();
session.id = await uuid();
session.created = new Date();
session.account = account;
session.device = this.context.device;
session.key = srp.K!;
// Add the session to the list of active sessions
auth.sessions.push(session.info);
// Delete pending SRP context
auth.srpSessions = auth.srpSessions.filter((s) => s.id !== srpState.id);
// Persist changes
await Promise.all([this.storage.save(session), this.storage.save(acc)]);
// Add device to trusted devices
if (
this.context.device &&
!auth.trustedDevices.some(({ id }) => id === this.context.device!.id) &&
addTrustedDevice
) {
auth.trustedDevices.push(this.context.device);
}
await this.storage.save(auth);
// Although the session key is secret in the sense that it should never
// be transmitted between client and server, it still needs to be
// stored on both sides, which is why it is included in the [[Session]]
// classes serialization. So we have to make sure to remove the key
// explicitly before returning.
delete session.key;
this.log("account.createSession", { success: true });
return session;
}
async revokeSession(id: SessionID) {
const { account, auth } = this._requireAuth();
const session = await this.storage.get(Session, id);
if (session.account !== account.id) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS);
}
const i = auth.sessions.findIndex((s) => s.id === id);
auth.sessions.splice(i, 1);
await Promise.all([this.storage.delete(session), this.storage.save(auth)]);
this.log("account.revokeSession", { revokedSession: { id, device: session.device } });
}
async createAccount({
account,
auth: { verifier, keyParams },
authToken,
verify,
}: CreateAccountParams): Promise<Account> {
// For compatibility with v3 clients, which still use the deprecated `verify` property name
if (verify && !authToken) {
authToken = verify;
}
if (this.config.verifyEmailOnSignup) {
await this._useAuthToken({ email: account.email, token: authToken, purpose: AuthPurpose.Signup });
}
const auth = (this.context.auth = await this._getAuth(account.email));
this.context.provisioning = await this.provisioner.getProvisioning(auth);
// Make sure that no account with this email exists and that the email is not blocked from signing up
if (auth.account) {
throw new Err(ErrorCode.ACCOUNT_EXISTS, "This account already exists!");
}
// Most of the account object is constructed locally but account id and
// revision are exclusively managed by the server
account.id = await uuid();
account.revision = await uuid();
auth.account = account.id;
auth.verifier = verifier;
auth.keyParams = keyParams;
auth.accountStatus = AccountStatus.Active;
// Add device to trusted devices
if (this.context.device && !auth.trustedDevices.some(({ id }) => id === this.context.device!.id)) {
auth.trustedDevices.push(this.context.device);
}
// Provision the private vault for this account
const vault = new Vault();
vault.id = await uuid();
vault.name = "My Vault";
vault.owner = account.id;
vault.created = new Date();
vault.updated = new Date();
account.mainVault = { id: vault.id };
// Persist data
await Promise.all([this.storage.save(account), this.storage.save(vault), this.storage.save(auth)]);
account = await this.storage.get(Account, account.id);
this.log("account.create");
return account;
}
async getAccount() {
const { account } = this._requireAuth();
this.log("account.getAccount");
return account;
}
async getAuthInfo() {
const { auth, account, provisioning } = this._requireAuth();
this.log("account.getAuthInfo");
for (const { autoCreate, orgId, orgName } of provisioning.orgs) {
if (autoCreate && !account.orgs.some((org) => org.id === orgId)) {
const org = new Org();
org.name = orgName;
org.id = orgId;
org.revision = await uuid();
org.members = [
new OrgMember({
accountId: account.id,
email: account.email,
status: OrgMemberStatus.Provisioned,
role: OrgRole.Owner,
}),
];
org.created = new Date();
org.updated = new Date();
await this.storage.save(org);
account.orgs.push(org.info);
await this.storage.save(account);
}
}
return new AuthInfo({
trustedDevices: auth.trustedDevices,
authenticators: auth.authenticators,
mfaOrder: auth.mfaOrder,
sessions: auth.sessions,
keyStoreEntries: auth.keyStoreEntries,
invites: auth.invites,
provisioning,
});
}
async updateAccount({ name, email, publicKey, keyParams, encryptionParams, encryptedData, revision }: Account) {
const { account } = this._requireAuth();
// Check the revision id to make sure the changes are based on the most
// recent version stored on the server. This is to ensure continuity in
// case two clients try to make changes to an account at the same time.
if (revision !== account.revision) {
throw new Err(ErrorCode.OUTDATED_REVISION);
}
// Update revision id
account.revision = await uuid();
const nameChanged = account.name !== name;
// Update account object
Object.assign(account, { name, email, publicKey, keyParams, encryptionParams, encryptedData });
// Persist changes
account.updated = new Date();
await this.storage.save(account);
// If the accounts name has changed, well need to update the
// corresponding member object on all organizations this account is a
// member of.
if (nameChanged) {
for (const { id } of account.orgs) {
const org = await this.storage.get(Org, id);
org.getMember(account)!.name = name;
await this.updateMetaData(org);
await this.storage.save(org);
}
}
this.log("account.update");
return account;
}
async recoverAccount({
account: { email, publicKey, keyParams, encryptionParams, encryptedData },
auth: { keyParams: authKeyParams, verifier },
verify,
}: RecoverAccountParams) {
// Check the email verification token
await this._useAuthToken({ email, token: verify, purpose: AuthPurpose.Recover });
// Find the existing auth information for this email address
const auth = (this.context.auth = await this._getAuth(email));
this.context.provisioning = await this.provisioner.getProvisioning(auth);
if (!auth.account) {
throw new Err(ErrorCode.NOT_FOUND, "There is no account with this email address!");
}
// Fetch existing account
const account = await this.storage.get(Account, auth.account);
// Update account object
Object.assign(account, { email, publicKey, keyParams, encryptionParams, encryptedData });
Object.assign(auth, {
keyParams: authKeyParams,
verifier,
trustedDevices: [],
mfAuthenticators: [],
mfaRequests: [],
});
// Create a new private vault, discarding the old one
const mainVault = new Vault();
mainVault.id = account.mainVault.id;
mainVault.name = "My Vault";
mainVault.owner = account.id;
mainVault.created = new Date();
mainVault.updated = new Date();
// The new auth object has all the information except the account id
this.context.device && auth.trustedDevices.push(this.context.device);
// Revoke all sessions
auth.sessions.forEach((s) => this.storage.delete(Object.assign(new Session(), s)));
// Suspend memberships for all orgs that the account is not the owner of.
// Since the accounts public key has changed, they will need to go through
// the invite flow again to confirm their membership.
for (const { id } of account.orgs) {
const org = await this.storage.get(Org, id);
if (!org.isOwner(account)) {
const member = org.getMember(account)!;
member.status = OrgMemberStatus.Suspended;
await this.storage.save(org);
}
}
// Persist changes
await Promise.all([this.storage.save(account), this.storage.save(auth), this.storage.save(mainVault)]);
this.log("account.recover");
return account;
}
async deleteAccount() {
const { account, auth } = this._requireAuth();
// Make sure that the account is not owner of any organizations
const orgs = await Promise.all(account.orgs.map(({ id }) => this.storage.get(Org, id)));
for (const org of orgs) {
if (org.isOwner(account)) {
await this.deleteOrg(org.id);
} else {
await org.removeMember(account, false);
await this.storage.save(org);
}
}
await this.provisioner.accountDeleted(auth);
// Delete main vault
await this.storage.delete(Object.assign(new Vault(), { id: account.mainVault }));
// Revoke all sessions
await auth.sessions.map((s) => this.storage.delete(Object.assign(new Session(), s)));
// Delete auth object
await this.storage.delete(auth);
// Delete account object
await this.storage.delete(account);
this.log("account.delete");
}
async createOrg(org: Org) {
const { account, provisioning } = this._requireAuth();
if (!org.name) {
throw new Err(ErrorCode.BAD_REQUEST, "Please provide an organization name!");
}
if (
provisioning.account.status !== ProvisioningStatus.Active ||
provisioning.account.features.createOrg.disabled
) {
throw new Err(
ErrorCode.PROVISIONING_NOT_ALLOWED,
"You're not allowed to create an organization right now."
);
}
org.id = await uuid();
org.revision = await uuid();
org.created = new Date();
org.updated = new Date();
org.members = [
new OrgMember({
accountId: account.id,
email: account.email,
role: OrgRole.Owner,
status: OrgMemberStatus.Provisioned,
}),
];
await this.storage.save(org);
account.orgs.push(org.info);
await this.storage.save(account);
this.log("org.create", { org: { name: org.name, id: org.id, owner: org.owner } });
return org;
}
async getOrg(id: OrgID) {
const { account } = this._requireAuth();
const org = await this.storage.get(Org, id);
// Only members can read organization data. For non-members,
// we pretend the organization doesn't exist.
if (!org.isMember(account)) {
throw new Err(ErrorCode.NOT_FOUND);
}
this.log("org.get", { org: { name: org.name, id: org.id, owner: org.owner } });
return org;
}
async updateOrg({
id,
name,
publicKey,
keyParams,
encryptionParams,
encryptedData,
signingParams,
accessors,
members,
groups,
vaults,
invites,
revision,
minMemberUpdated,
owner,
directory,
}: Org) {
const { account, provisioning } = this._requireAuth();
// Get existing org based on the id
const org = await this.storage.get(Org, id);
const orgInfo = org.info;
const orgProvisioning = provisioning.orgs.find((o) => o.orgId === id);
if (!orgProvisioning) {
throw new Err(ErrorCode.PROVISIONING_NOT_ALLOWED, "Could not find provisioning for this organization!");
}
// Check the revision id to make sure the changes are based on the most
// recent version stored on the server. This is to ensure continuity in
// case two clients try to make changes to an organization at the same
// time.
if (revision !== org.revision) {
throw new Err(ErrorCode.OUTDATED_REVISION);
}
const isOwner = org.owner?.id === account.id || org.isOwner(account);
const isAdmin = isOwner || org.isAdmin(account);
// Only admins can make any changes to organizations at all.
if (!isAdmin) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS, "Only admins can make changes to organizations!");
}
// Verify that `minMemberUpdated` is equal to or larger than the previous value
if (minMemberUpdated < org.minMemberUpdated) {
throw new Err(
ErrorCode.BAD_REQUEST,
"`minMemberUpdated` property needs to be equal to or larger than the previous one!"
);
}
const addedMembers = members.filter((m) => !org.isMember(m));
const removedMembers = org.members.filter(({ email }) => !members.some((m) => email === m.email));
const addedInvites = invites.filter(({ id }) => !org.getInvite(id));
const removedInvites = org.invites.filter(({ id }) => !invites.some((inv) => id === inv.id));
const addedGroups = groups.filter((group) => !org.getGroup(group.name));
// Only org owners can add or remove members, change roles, create invites or transfer ownership
if (
!isOwner &&
(owner?.email !== org.owner?.email ||
addedMembers.length ||
removedMembers.length ||
addedInvites.length ||
removedInvites.length ||
members.some(({ email, role, status }) => {
const member = org.getMember({ email });
return !member || member.role !== role || member.status !== status;
}))
) {
throw new Err(
ErrorCode.INSUFFICIENT_PERMISSIONS,
"Only organization owners can add or remove members, change roles or create invites!"
);
}
// Check members quota
if (
addedMembers.length &&
orgProvisioning.quota.members !== -1 &&
members.length > orgProvisioning.quota.members
) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the maximum number of members for this organization!"
);
}
// Check groups quota
if (addedGroups.length && orgProvisioning.quota.groups !== -1 && groups.length > orgProvisioning.quota.groups) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the maximum number of groups for this organization!"
);
}
Object.assign(org, {
members,
groups,
vaults,
directory,
});
if (org.directory.syncProvider === "scim") {
if (!org.directory.scim) {
org.directory.scim = new ScimSettings();
org.directory.scim.secret = await getCryptoProvider().randomBytes(16);
const scimSecret = bytesToBase64(org.directory.scim.secret, true);
org.directory.scim.secretToken = scimSecret;
org.directory.scim.url = `${this.config.scimServerUrl}/${org.id}`;
}
} else if (org.directory.syncProvider === "none") {
org.directory.scim = undefined;
org.directory.syncGroups = false;
org.directory.syncMembers = false;
}
if (org.owner && owner && org.owner.email !== owner.email) {
await this.provisioner.orgOwnerChanged(org, org.getMember(org.owner)!, org.getMember(owner)!);
}
// certain properties may only be updated by organization owners
if (isOwner) {
Object.assign(org, {
name,
publicKey,
keyParams,
encryptionParams,
encryptedData,
signingParams,
accessors,
invites,
minMemberUpdated,
});
}
const promises: Promise<void>[] = [];
// New invites
for (const invite of addedInvites) {
promises.push(
(async () => {
const auth = await this._getAuth(invite.email);
auth.invites.push({
id: invite.id,
orgId: org.id,
orgName: org.name,
expires: invite.expires.toISOString(),
});
let path = "";
const params = new URLSearchParams();
params.set("email", invite.email);
// params.set("accountStatus", auth.accountStatus);
params.set(
"invite",
stringToBase64(
JSON.stringify({
id: invite.id,
invitor: account.name ? `${account.name} (${account.email})` : account.email,
orgId: org.id,
orgName: org.name,
email: invite.email,
})
)
);
// If account does not exist yet, create a email verification code
// and send it along with the url so they can skip that step
if (auth.accountStatus === AccountStatus.Unregistered) {
// account does not exist yet; add verification code to link
const signupRequest = new AuthRequest({
type: AuthType.Email,
purpose: AuthPurpose.Signup,
});
await signupRequest.init();
signupRequest.verified = new Date();
signupRequest.status = AuthRequestStatus.Verified;
auth.authRequests.push(signupRequest);
params.set("authToken", signupRequest.token);
path = "/signup";
}
await this.storage.save(auth);
const messageClass =
invite.purpose === "confirm_membership" ? ConfirmMembershipInviteMessage : JoinOrgInviteMessage;
try {
// Send invite link to invitees email address
await this.messenger.send(
invite.email,
new messageClass({
orgName: invite.org.name,
invitedBy: invite.invitedBy!.name || invite.invitedBy!.email,
acceptInviteUrl: `${removeTrailingSlash(
this.config.clientUrl
)}${path}?${params.toString()}`,
})
);
} catch (e) {}
})()
);
this.log("org.createInvite", {
org: orgInfo,
invite: { id: invite.id, email: invite.email, purpose: invite.purpose },
});
}
for (const invite of removedInvites) {
promises.push(
(async () => {
try {
const auth = await this._getAuth(invite.email);
auth.invites = auth.invites.filter((inv) => inv.id !== invite.id);
await this.storage.save(auth);
} catch (e) {
if (e.code !== ErrorCode.NOT_FOUND) {
throw e;
}
}
})()
);
this.log("org.deleteInvite", {
org: orgInfo,
invite: { id: invite.id, email: invite.email, purpose: invite.purpose },
});
}
// Removed members
for (const { accountId, name, email } of removedMembers) {
if (!accountId) {
continue;
}
promises.push(
(async () => {
try {
const acc = await this.storage.get(Account, accountId);
acc.orgs = acc.orgs.filter((o) => o.id !== org.id);
await this.storage.save(acc);
} catch (e) {
if (e.code !== ErrorCode.NOT_FOUND) {
throw e;
}
}
})()
);
this.log("org.removeMember", {
org: orgInfo,
member: { accountId: id, email, name },
});
}
await this.updateMetaData(org);
// Send a notification email to let the new member know they've been added
for (const member of addedMembers) {
if (member.id !== account.id) {
try {
await this.messenger.send(
member.email,
new JoinOrgInviteCompletedMessage({
orgName: org.name,
openAppUrl: `${removeTrailingSlash(this.config.clientUrl)}/org/${org.id}`,
})
);
} catch (e) {}
}
this.log("org.addMember", {
org: orgInfo,
member: { accountId: member.id, email: member.email, name: member.name },
});
}
await Promise.all(promises);
await this.storage.save(org);
this.log("org.update", { org: orgInfo });
return org;
}
async deleteOrg(id: OrgID) {
const { account } = this._requireAuth();
const org = await this.storage.get(Org, id);
if (!org.isOwner(account)) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS);
}
// Delete all associated vaults
await Promise.all(org.vaults.map((v) => this.storage.delete(Object.assign(new Vault(), v))));
// Remove org from all member accounts
await Promise.all(
org.members
.filter((m) => !!m.accountId)
.map(async (member) => {
const acc = await this.storage.get(Account, member.accountId!);
acc.orgs = acc.orgs.filter(({ id }) => id !== org.id);
await this.storage.save(acc);
})
);
await this.storage.delete(org);
await this.provisioner.orgDeleted(org);
this.log("org.delete", { org: { name: org.name, id: org.id, owner: org.owner } });
}
async getVault(id: VaultID) {
const { account } = this._requireAuth();
const vault = await this.storage.get(Vault, id);
const org = vault.org && (await this.storage.get(Org, vault.org.id));
if (org && org.isSuspended(account)) {
throw new Err(
ErrorCode.INSUFFICIENT_PERMISSIONS,
"This vault cannot be synchronized because you're suspended from it's organization."
);
}
// Accounts can only read their private vaults and vaults they have been assigned to
// on an organization level. For everyone else, pretend like the vault doesn't exist.
if ((org && !org.canRead(vault, account)) || (!org && vault.owner !== account.id)) {
throw new Err(ErrorCode.NOT_FOUND);
}
this.log("vault.get", {
vault: { id: vault.id, name: vault.name },
org: (org && { id: org.id, name: org.name, owner: org.owner }) || undefined,
});
return vault;
}
async updateVault({ id, keyParams, encryptionParams, accessors, encryptedData, revision }: Vault) {
const { account, provisioning } = this._requireAuth();
const vault = await this.storage.get(Vault, id);
const org = vault.org && (await this.storage.get(Org, vault.org.id));
const orgProvisioning = org && provisioning.orgs.find((o) => o.orgId === org.id);
const prov = orgProvisioning || provisioning.account;
if (!prov) {
throw new Err(ErrorCode.PROVISIONING_NOT_ALLOWED, "No provisioning found for this vault!");
}
if (org && org.isSuspended(account)) {
throw new Err(
ErrorCode.INSUFFICIENT_PERMISSIONS,
"This vault cannot be synchronized because you're suspended from it's organization."
);
}
// Accounts can only read their private vaults and vaults they have been assigned to
// on an organization level. For everyone else, pretend like the vault doesn't exist.
if ((org && !org.canRead(vault, account)) || (!org && vault.owner !== account.id)) {
throw new Err(ErrorCode.NOT_FOUND);
}
// Vaults can only be updated by accounts that have explicit write access
if (org && !org.canWrite(vault, account)) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS);
}
if (prov.status === ProvisioningStatus.Frozen) {
throw new Err(
ErrorCode.PROVISIONING_NOT_ALLOWED,
org
? 'You can not make any updates to a vault while it\'s organization is in "frozen" state!'
: 'You can\'t make any updates to your vault while your account is in "frozen" state!'
);
}
// Check the revision id to make sure the changes are based on the most
// recent version stored on the server. This is to ensure continuity in
// case two clients try to make changes to an organization at the same
// time.
if (revision !== vault.revision) {
throw new Err(ErrorCode.OUTDATED_REVISION);
}
// Update vault properties
Object.assign(vault, { keyParams, encryptionParams, accessors, encryptedData });
// update revision
vault.revision = await uuid();
vault.updated = new Date();
// Persist changes
await this.storage.save(vault);
if (org) {
// Update Org revision (since vault info has changed)
await this.updateMetaData(org);
await this.storage.save(org);
} else {
// Update main vault revision info on account
account.mainVault.revision = vault.revision;
await this.storage.save(account);
}
this.log("vault.update", {
vault: { id: vault.id, name: vault.name, owner: vault.owner },
org: (org && { id: org.id, name: org.name, owner: org.owner }) || undefined,
});
return this.storage.get(Vault, vault.id);
}
async createVault(vault: Vault) {
const { account, provisioning } = this._requireAuth();
// Explicitly creating vaults only works in the context of an
// organization (private vaults are created automatically)
if (!vault.org) {
throw new Err(ErrorCode.BAD_REQUEST, "Shared vaults have to be attached to an organization.");
}
const org = await this.storage.get(Org, vault.org.id);
const orgProvisioning = org && provisioning.orgs.find((o) => o.orgId === org.id);
if (!orgProvisioning) {
throw new Err(ErrorCode.PROVISIONING_NOT_ALLOWED, "No provisioning found for this vault!");
}
// Only admins can create new vaults for an organization
if (!org.isAdmin(account)) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS);
}
// Create vault object
vault.id = await uuid();
vault.owner = account.id;
vault.created = vault.updated = new Date();
vault.revision = await uuid();
// Add to organization
org.vaults.push({ id: vault.id, name: vault.name });
org.revision = await uuid();
// Check vault quota of organization
if (orgProvisioning.quota.vaults !== -1 && org.vaults.length > orgProvisioning.quota.vaults) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the maximum number of vaults for this organization!"
);
}
// Persist cahnges
await Promise.all([this.storage.save(vault), this.storage.save(org)]);
this.log("vault.create", {
vault: { id: vault.id, name: vault.name, owner: vault.owner },
org: (org && { id: org.id, name: org.name, owner: org.owner }) || undefined,
});
return vault;
}
async deleteVault(id: VaultID) {
const { account } = this._requireAuth();
const vault = await this.storage.get(Vault, id);
// Only vaults that have been created in the context of an
// organization can be deleted (private vaults are managed
// by the server implicitly)
if (!vault.org) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS);
}
const org = await this.storage.get(Org, vault.org.id);
// Only org admins can delete vaults
if (!org.isAdmin(account)) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS);
}
// Delete all attachments associated with this vault
await this.attachmentStorage.deleteAll(vault.id);
// Remove vault from org
org.vaults = org.vaults.filter((v) => v.id !== vault.id);
// Remove any assignments to this vault from members and groups
for (const each of [...org.getGroupsForVault(vault), ...org.getMembersForVault(vault)]) {
each.vaults = each.vaults.filter((v) => v.id !== vault.id);
}
await this.updateMetaData(org);
// Save org
await this.storage.save(org);
// Delete vault
await this.storage.delete(vault);
this.log("vault.delete", {
vault: { id: vault.id, name: vault.name, owner: vault.owner },
org: (org && { id: org.id, name: org.name, owner: org.owner }) || undefined,
});
}
async getInvite({ org: orgId, id }: GetInviteParams) {
const { account } = this._requireAuth();
const org = await this.storage.get(Org, orgId);
const invite = org.getInvite(id);
if (
!invite ||
// User may only see invite if they are a vault owner or the invite recipient
(!org.isOwner(account) && invite.email !== account.email)
) {
throw new Err(ErrorCode.NOT_FOUND);
}
this.log("org.getInvite", {
invite: { id: invite.id, email: invite.email, purpose: invite.purpose },
org: { id: org.id, name: org.name, owner: org.owner },
});
return invite;
}
async acceptInvite(invite: Invite) {
// Passed invite object need to have *accepted* status
if (!invite.accepted) {
throw new Err(ErrorCode.BAD_REQUEST);
}
const { account } = this._requireAuth();
// Get existing invite object
const org = await this.storage.get(Org, invite.org.id);
const existing = org.getInvite(invite.id);
if (!existing) {
throw new Err(ErrorCode.NOT_FOUND);
}
// Only the invite recipient can accept the invite
if (existing.email !== account.email) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS, "Only the invite recipient can accept the invite.");
}
if (!existing.accepted && invite.invitedBy) {
// Send message to the creator of the invite notifying them that
// the recipient has accepted the invite
try {
await this.messenger.send(
invite.invitedBy.email,
new JoinOrgInviteAcceptedMessage({
orgName: org.name,
invitee: invite.invitee.name || invite.invitee.email,
confirmMemberUrl: `${removeTrailingSlash(this.config.clientUrl)}/invite/${org.id}/${invite.id}`,
})
);
} catch (e) {}
}
// Update invite object
org.invites[org.invites.indexOf(existing)] = invite;
await this.updateMetaData(org);
// Persist changes
await this.storage.save(org);
this.log("org.acceptInvite", {
invite: { id: invite.id, email: invite.email, purpose: invite.purpose },
org: { id: org.id, name: org.name, owner: org.owner },
});
}
async createAttachment(att: Attachment) {
const { account, provisioning } = this._requireAuth();
const vault = await this.storage.get(Vault, att.vault);
const org = vault.org && (await this.storage.get(Org, vault.org.id));
const allowed = org ? org.canWrite(vault, account) : vault.owner === account.id;
if (!allowed) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS);
}
if (vault.org) {
const prov = provisioning.orgs.find((o) => o.orgId === vault.org!.id);
const quota = prov?.quota.storage || 0;
const org = await this.storage.get(Org, vault.org.id);
const usagePerVault = await Promise.all(org.vaults.map((v) => this.attachmentStorage.getUsage(v.id)));
const usage = usagePerVault.reduce((total, each) => total + each, 0);
if (quota !== -1 && usage + att.size > quota * 1e6) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the file storage limit for this org!"
);
}
} else {
const quota = provisioning.account.quota.storage;
const usage = await this.attachmentStorage.getUsage(account.mainVault.id);
if (quota !== -1 && usage + att.size > quota * 1e6) {
throw new Err(
ErrorCode.PROVISIONING_QUOTA_EXCEEDED,
"You have reached the file storage limit for this account!"
);
}
}
att.id = await uuid();
await this.attachmentStorage.put(att);
this.log("vault.createAttachment", {
attachment: { type: att.type, size: att.size, id: att.id },
vault: { id: vault.id, name: vault.name, owner: vault.owner },
org: (org && { id: org.id, name: org.name, owner: org.owner }) || undefined,
});
return att.id;
}
async getAttachment({ id, vault: vaultId }: GetAttachmentParams) {
const { account } = this._requireAuth();
const vault = await this.storage.get(Vault, vaultId);
const org = vault.org && (await this.storage.get(Org, vault.org.id));
const allowed = org ? org.canRead(vault, account) : vault.owner === account.id;
if (!allowed) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS);
}
const att = await this.attachmentStorage.get(vaultId, id);
this.log("vault.getAttachment", {
attachment: { type: att.type, size: att.size, id: att.id },
vault: { id: vault.id, name: vault.name, owner: vault.owner },
org: (org && { id: org.id, name: org.name, owner: org.owner }) || undefined,
});
return att;
}
async deleteAttachment({ vault: vaultId, id }: DeleteAttachmentParams) {
const { account } = this._requireAuth();
const vault = await this.storage.get(Vault, vaultId);
const org = vault.org && (await this.storage.get(Org, vault.org.id));
const allowed = org ? org.canWrite(vault, account) : vault.owner === account.id;
if (!allowed) {
throw new Err(ErrorCode.INSUFFICIENT_PERMISSIONS);
}
await this.attachmentStorage.delete(vaultId, id);
this.log("vault.deleteAttachment", {
attachment: { id },
vault: { id: vault.id, name: vault.name, owner: vault.owner },
org: (org && { id: org.id, name: org.name, owner: org.owner }) || undefined,
});
}
async getLegacyData({ email, verify }: GetLegacyDataParams) {
const auth = (this.context.auth = await this._getAuth(email));
this.context.provisioning = await this.provisioner.getProvisioning(auth);
if (verify) {
await this._useAuthToken({ email, token: verify, purpose: AuthPurpose.GetLegacyData });
} else {
const { account } = this._requireAuth();
if (account.email !== email) {
throw new Err(ErrorCode.BAD_REQUEST);
}
}
if (!this.legacyServer) {
throw new Err(ErrorCode.NOT_SUPPORTED, "This Padloc instance does not support this feature!");
}
const data = await this.legacyServer.getStore(email);
if (!data) {
throw new Err(ErrorCode.NOT_FOUND, "No legacy account found.");
}
this.log("account.getLegacyData");
return data;
}
async deleteLegacyAccount() {
if (!this.legacyServer) {
throw new Err(ErrorCode.NOT_SUPPORTED, "This Padloc instance does not support this feature!");
}
const { account } = this._requireAuth();
await this.legacyServer.deleteAccount(account.email);
this.log("account.deleteLegacyAccount");
}
updateMetaData(org: Org) {
return this.server.updateMetaData(org);
}
async createKeyStoreEntry({ data, authenticatorId }: CreateKeyStoreEntryParams) {
const { account, auth } = this._requireAuth();
const authenticator = auth.authenticators.find(
(a) => a.id === authenticatorId && a.purposes.includes(AuthPurpose.AccessKeyStore)
);
if (!authenticator) {
throw new Err(ErrorCode.NOT_FOUND, "No suitable authenticator found!");
}
const entry = new KeyStoreEntry({ accountId: account.id, data, authenticatorId });
await entry.init();
await this.storage.save(entry);
auth.keyStoreEntries.push(entry.info);
await this.storage.save(auth);
this.log("account.createKeyStoreEntry", {
keystoreEntry: { id: entry.id, authenticator: { id: authenticator.id, type: authenticator.type } },
});
return entry;
}
async getKeyStoreEntry({ id, authToken }: GetKeyStoreEntryParams) {
const { account } = this._requireAuth();
const entry = await this.storage.get(KeyStoreEntry, id);
await this._useAuthToken({
email: account.email,
token: authToken,
purpose: AuthPurpose.AccessKeyStore,
authenticatorId: entry.authenticatorId,
});
this.log("account.getKeyStoreEntry", {
keyStoreEntry: { id: entry.id },
});
return entry;
}
async deleteKeyStoreEntry(id: string) {
const { account, auth } = this._requireAuth();
const entry = await this.storage.get(KeyStoreEntry, id);
if (entry.accountId !== account.id) {
throw new Err(
ErrorCode.INSUFFICIENT_PERMISSIONS,
"You don't have the necessary permissions to perform this action!"
);
}
await this.storage.delete(entry);
auth.keyStoreEntries = auth.keyStoreEntries.filter((e) => e.id !== entry.id);
await this.storage.save(auth);
this.log("account.deleteKeyStoreEntry", {
keyStoreEntry: { id: entry.id },
});
}
private _requireAuth(): { account: Account; session: Session; auth: Auth; provisioning: Provisioning } {
const { account, session, auth, provisioning } = this.context;
if (!session || !account || !auth || !provisioning) {
throw new Err(ErrorCode.INVALID_SESSION);
}
return { account, session, auth, provisioning };
}
protected async _getAuthenticators(auth: Auth) {
const purposes = [AuthPurpose.Signup, AuthPurpose.Login, AuthPurpose.Recover, AuthPurpose.GetLegacyData];
const adHocAuthenticators = await Promise.all(
this.config.defaultAuthTypes.map((type) => this._createAdHocAuthenticator(auth, purposes, type))
);
const authenticators = [
...auth.authenticators.sort((a, b) => auth.mfaOrder.indexOf(a.id) - auth.mfaOrder.indexOf(b.id)),
...adHocAuthenticators,
];
return authenticators;
}
private async _createAdHocAuthenticator(auth: Auth, purposes: AuthPurpose[], type: AuthType) {
const authServer = this._getAuthServer(type);
const authenticator = new Authenticator({
type,
status: AuthenticatorStatus.Active,
purposes,
id: `ad_hoc_${auth.email}_${type}`,
});
await authServer.initAuthenticator(authenticator, auth);
return authenticator;
}
protected async _getAuth(email: string) {
let auth: Auth | null = null;
try {
auth = await this.storage.get(Auth, await getIdFromEmail(email));
} catch (e) {
if (e.code !== ErrorCode.NOT_FOUND) {
throw e;
}
}
if (!auth) {
// In previous versions the accounts plain email address was used
// as the key directly, check if one such entry exists and if so,
// take it and migrate it to the new key format.
try {
auth = await this.storage.get(Auth, email);
await auth.init();
await this.storage.save(auth);
await this.storage.delete(Object.assign(new Auth(), { id: auth.email }));
} catch (e) {}
}
if (!auth) {
auth = new Auth(email);
await auth.init();
// We didn't find anything for this user in the database.
// Let's see if there is any legacy (v2) data for this user.
const legacyData = await this.legacyServer?.getStore(email);
if (legacyData) {
auth.legacyData = legacyData;
}
}
let updateAuth = false;
// Revoke unused sessions older than 2 weeks
const expiredSessions = auth.sessions.filter(
(session) =>
Math.max(session.created.getTime(), session.lastUsed.getTime()) < Date.now() - 14 * 24 * 60 * 60 * 1000
);
for (const session of expiredSessions) {
await this.storage.delete(Object.assign(new Session(), session));
auth.sessions.splice(auth.sessions.indexOf(session), 1);
updateAuth = true;
}
// Remove pending auth requests older than 1 hour
const expiredAuthRequests = auth.authRequests.filter(
(authRequest) => authRequest.created.getTime() < Date.now() - 1 * 60 * 60 * 1000
);
for (const authRequest of expiredAuthRequests) {
await this.storage.delete(authRequest);
auth.authRequests.splice(auth.authRequests.indexOf(authRequest), 1);
updateAuth = true;
}
// Remove pending srp sessions older than 1 hour
const expiredSRPSessions = auth.srpSessions.filter(
(SRPSession) => SRPSession.created.getTime() < Date.now() - 1 * 60 * 60 * 1000
);
for (const srpSession of expiredSRPSessions) {
await this.storage.delete(srpSession);
auth.srpSessions.splice(auth.srpSessions.indexOf(srpSession), 1);
updateAuth = true;
}
// Remove expired invites
const nonExpiredInvites = auth.invites.filter((invite) => new Date(invite.expires || 0) > new Date());
if (nonExpiredInvites.length < auth.invites.length) {
auth.invites = nonExpiredInvites;
updateAuth = true;
}
if (updateAuth) {
await this.storage.save(auth);
}
return auth;
}
protected _getAuthServer(type: AuthType) {
const provider = this.authServers.find((prov) => prov.supportsType(type));
if (!provider) {
throw new Err(
ErrorCode.NOT_SUPPORTED,
`This multi factor authentication type is not supported by this server!`
);
}
return provider;
}
private async _useAuthToken({
email,
token,
purpose,
authenticatorId,
requestId,
}: {
email: string;
token: string;
purpose: AuthPurpose;
authenticatorId?: string;
requestId?: string;
}) {
const auth = await this._getAuth(email);
if (!auth) {
throw new Err(ErrorCode.AUTHENTICATION_FAILED, "Failed to verify auth token");
}
const request = auth.authRequests.find(
(r) =>
(typeof authenticatorId === "undefined" || r.authenticatorId === authenticatorId) &&
(typeof requestId === "undefined" || r.id === requestId) &&
r.token === token &&
r.status === AuthRequestStatus.Verified &&
r.purpose === purpose
);
if (!request) {
throw new Err(ErrorCode.AUTHENTICATION_FAILED, "Failed to verify auth token");
}
auth.authRequests = auth.authRequests.filter((r) => r.id !== request.id);
await this.storage.save(auth);
}
}
/**
* The Padloc server acts as a central repository for [[Account]]s, [[Org]]s
* and [[Vault]]s. [[Server]] handles authentication, enforces user privileges
* and acts as a mediator for key exchange between clients.
*
* The server component acts on a strict zero-trust, zero-knowledge principle
* when it comes to sensitive data, meaning no sensitive data is ever exposed
* to the server at any point, nor should the server (or the person controlling
* it) ever be able to temper with critical data or trick users into granting
* them access to encrypted information.
*/
export class Server {
constructor(
public config: ServerConfig,
public storage: Storage,
public messenger: Messenger,
/** Logger to use */
public logger: Logger = new VoidLogger(),
public authServers: AuthServer[] = [],
/** Attachment storage */
public attachmentStorage: AttachmentStorage,
public provisioner: Provisioner = new StubProvisioner(),
public legacyServer?: LegacyServer
) {}
private _requestQueue = new Map<AccountID | OrgID, Promise<void>>();
makeController(ctx: Context) {
return new (V3Compat(Controller))(this, ctx);
}
log(type: string, context: Context, data: any = {}) {
const auth = context.auth;
const provisioning = context.provisioning?.account;
return this.logger.log(type, {
account: auth && {
email: auth.email,
status: auth.accountStatus,
id: auth.accountId,
},
provisioning: provisioning && {
status: provisioning.status,
metaData: provisioning.metaData || undefined,
},
device: context.device?.toRaw(),
sessionId: context.session?.id,
location: context.location,
...data,
});
}
/** Handles an incoming [[Request]], processing it and constructing a [[Reponse]] */
async handle(req: Request) {
const res = new Response();
const context: Context = {};
const start = Date.now();
try {
context.device = req.device;
try {
await loadLanguage((context.device && context.device.locale) || "en");
} catch (e) {}
const controller = this.makeController(context);
await controller.authenticate(req, context);
const done = await this._addToQueue(context);
try {
res.result = (await controller.process(req)) || null;
} finally {
done();
}
if (context.session) {
await context.session.authenticate(res);
}
} catch (e) {
this._handleError(e, req, res, context);
}
const duration = Date.now() - start;
this.log("request", context, {
request: {
method: req.method,
error: res.error,
duration,
},
});
return res;
}
async updateMetaData(org: Org) {
org.revision = await uuid();
org.updated = new Date();
const promises: Promise<void>[] = [];
const deletedVaults = new Set<VaultID>();
const deletedMembers = new Set<AccountID>();
// Updated related vaults
for (const vaultInfo of org.vaults) {
promises.push(
(async () => {
try {
const vault = await this.storage.get(Vault, vaultInfo.id);
if (
vaultInfo.name !== vault.name ||
!vault.org ||
Object.entries(vaultInfo).some(([key, value]) => vault.org![key] !== value)
) {
vault.revision = await uuid();
vault.name = vaultInfo.name;
vault.org = org.info;
await this.storage.save(vault);
}
vaultInfo.revision = vault.revision;
} catch (e) {
if (e.code !== ErrorCode.NOT_FOUND) {
throw e;
}
deletedVaults.add(vaultInfo.id);
}
})()
);
}
// Update org info on members
for (const member of org.members) {
if (!member.accountId) {
continue;
}
promises.push(
(async () => {
try {
const acc = await this.storage.get(Account, member.accountId!);
acc.orgs = [...acc.orgs.filter((o) => o.id !== org.id), org.info];
await this.storage.save(acc);
member.name = acc.name;
} catch (e) {
if (e.code !== ErrorCode.NOT_FOUND) {
throw e;
}
deletedMembers.add(member.accountId!);
}
})()
);
}
await Promise.all(promises);
org.vaults = org.vaults.filter((v) => !deletedVaults.has(v.id));
org.members = org.members.filter((m) => !m.accountId || !deletedMembers.has(m.accountId));
}
private async _addToQueue(context: Context) {
if (!context.account) {
return () => {};
}
const account = context.account;
const resolveFuncs: (() => void)[] = [];
const promises: Promise<void>[] = [];
for (const { id } of [account, ...account.orgs]) {
const promise = this._requestQueue.get(id);
if (promise) {
promises.push(promise);
}
this._requestQueue.set(id, new Promise((resolve) => resolveFuncs.push(resolve)));
}
await Promise.all(promises);
return () => resolveFuncs.forEach((resolve) => resolve());
}
private async _handleError(error: Error, req: Request, res: Response, context: Context) {
console.error(error);
const e =
error instanceof Err
? error
: new Err(
ErrorCode.SERVER_ERROR,
"Something went wrong while we were processing your request. " +
"Our team has been notified and will resolve the problem as soon as possible!",
{ report: true, error }
);
res.error = {
code: e.code,
message: e.message,
};
if (e.report) {
const evt = this.log("error", context, {
error: e.toRaw(),
request: {
method: req.method,
params: e.report ? req.params : undefined,
},
});
if (this.config.reportErrors) {
try {
await this.messenger.send(
this.config.reportErrors,
new ErrorMessage({
time: e.time.toISOString(),
code: e.code,
message: e.message,
eventId: evt.id,
})
);
} catch (e) {}
}
}
}
}