mirror of
https://github.com/directus/directus.git
synced 2026-01-29 22:08:05 -05:00
* query function added to list * dashboard reading query, adding to object * typecasting of filter vals needed still * numbers accepting strings too * json-to-graphql-query => devD * fixed unneeded return in list index.ts * stitching and calling but not actually calling * calls on panel change * query object += new panel before dashboard save * uuid generated in app not api * fixed panel ids in query * fixed the tests I just wrote * passing the query data down! * list showing data * objDiff test moved to test * metric bug fixes + data * dashboard logic * time series conversion started * timeseries GQL query almost there * query querying * chart loading * aggregate handling improved * error handling for aggregate+filter errors * removed query on empty queryObj * maybe more error handling * more error handling working * improvements to erorr handling * stitchGQL() error return type corrected * added string fields to COUNT * pushing up but needs work * not an endless recursion * its not pretty but it works. * throws an error * system collections supported * refactor to solve some errors * loading correct * metric function fixed * data loading but not blocking rendering * removed redundant code. * relational fields * deep nesting relations * options.precision has a default * relational fields fix. (thanks azri) * the limit * limit and time series * range has a default * datat to workspace * v-if * panels loading * workspaces dont get data anymore * package.json * requested changes * loading * get groups util * timeseries => script setup * list => script setup * metric => script setup * label => script setup * declare optional props * loadingPanels: only loading spinner on loading panels * remove unneeded parseDate!! * applyDataToPanels tests * -.only * remove unneeded steps * processQuery tests * tests * removed unused var * jest.config and some queryCaller tests * one more test * query tests * typo * clean up * fix some but not all bugs * bugs from merge fixed * Start cleaning up 🧹 * Refactor custom input type * Small tweaks in list index * Cleanup imports * Require Query object to be returned from query prop * Tweak return statement * Fix imports * Cleanup metric watch effect * Tweaks tweaks tweaks * Don't rely on options, simplify fetch logic * Add paths to validation errors * [WIP] Start handling things in the store * Rework query fetching logic into store * Clean up data passing * Use composition setup for insights store * Remove outdated * Fix missing return * Allow batch updating in REST API Allows sending an array of partial items to the endpoints, updating all to their own values * Add batch update to graphql * Start integrating edits * Readd clear * Add deletion * Add duplication * Finish create flow * Resolve cache refresh on panel config * Prevent warnings about component name * Improve loading state * Finalize dashboard overhaul * Add auto-refresh sidebar detail * Add efficient panel reloading * Set/remove errors on succeeded requests * Move options rendering to shared * Fix wrong imports, render options in app * Selectively reload panels with changed variables * Ensure newly added panels don't lose data * Only refresh panel if data query changed * Never use empty filter object in metric query * Add default value support to variable panel * Centralize no-data state * Only reload data on var change when query is altered * Fix build * Fix time series order * Remove unused utils * Remove no-longer-used logic * Mark batch update result as non-nullable in GraphQL schema * Interim flows fix * Skip parsing undefined keys * Refresh insights dashboard when discarding changes * Don't submit primary key when updating batch * Handle null prop field better * Tweak panel padding Co-authored-by: jaycammarano <jay.cammarano@gmail.com> Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> Co-authored-by: ian <licitdev@gmail.com>
425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
import jwt from 'jsonwebtoken';
|
|
import { Knex } from 'knex';
|
|
import { cloneDeep } from 'lodash';
|
|
import getDatabase from '../database';
|
|
import env from '../env';
|
|
import { FailedValidationException } from '@directus/shared/exceptions';
|
|
import { ForbiddenException, InvalidPayloadException, UnprocessableEntityException } from '../exceptions';
|
|
import { RecordNotUniqueException } from '../exceptions/database/record-not-unique';
|
|
import { AbstractServiceOptions, Item, PrimaryKey, MutationOptions } from '../types';
|
|
import { Query, SchemaOverview, Accountability } from '@directus/shared/types';
|
|
import isUrlAllowed from '../utils/is-url-allowed';
|
|
import { toArray } from '@directus/shared/utils';
|
|
import { Url } from '../utils/url';
|
|
import { ItemsService } from './items';
|
|
import { MailService } from './mail';
|
|
import { SettingsService } from './settings';
|
|
import { stall } from '../utils/stall';
|
|
import { performance } from 'perf_hooks';
|
|
import { getSimpleHash } from '@directus/shared/utils';
|
|
|
|
export class UsersService extends ItemsService {
|
|
knex: Knex;
|
|
accountability: Accountability | null;
|
|
schema: SchemaOverview;
|
|
|
|
constructor(options: AbstractServiceOptions) {
|
|
super('directus_users', options);
|
|
|
|
this.knex = options.knex || getDatabase();
|
|
this.accountability = options.accountability || null;
|
|
this.schema = options.schema;
|
|
}
|
|
|
|
/**
|
|
* User email has to be unique case-insensitive. This is an additional check to make sure that
|
|
* the email is unique regardless of casing
|
|
*/
|
|
private async checkUniqueEmails(emails: string[], excludeKey?: PrimaryKey): Promise<void> {
|
|
emails = emails.map((email) => email.toLowerCase());
|
|
|
|
const duplicates = emails.filter((value, index, array) => array.indexOf(value) !== index);
|
|
|
|
if (duplicates.length) {
|
|
throw new RecordNotUniqueException('email', {
|
|
collection: 'directus_users',
|
|
field: 'email',
|
|
invalid: duplicates[0],
|
|
});
|
|
}
|
|
|
|
const query = this.knex
|
|
.select('email')
|
|
.from('directus_users')
|
|
.whereRaw(`LOWER(??) IN (${emails.map(() => '?')})`, ['email', ...emails]);
|
|
|
|
if (excludeKey) {
|
|
query.whereNot('id', excludeKey);
|
|
}
|
|
|
|
const results = await query;
|
|
|
|
if (results.length) {
|
|
throw new RecordNotUniqueException('email', {
|
|
collection: 'directus_users',
|
|
field: 'email',
|
|
invalid: results[0].email,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the provided password matches the strictness as configured in
|
|
* directus_settings.auth_password_policy
|
|
*/
|
|
private async checkPasswordPolicy(passwords: string[]): Promise<void> {
|
|
const settingsService = new SettingsService({
|
|
schema: this.schema,
|
|
knex: this.knex,
|
|
});
|
|
|
|
const { auth_password_policy: policyRegExString } = await settingsService.readSingleton({
|
|
fields: ['auth_password_policy'],
|
|
});
|
|
|
|
if (!policyRegExString) {
|
|
return;
|
|
}
|
|
|
|
const wrapped = policyRegExString.startsWith('/') && policyRegExString.endsWith('/');
|
|
const regex = new RegExp(wrapped ? policyRegExString.slice(1, -1) : policyRegExString);
|
|
|
|
for (const password of passwords) {
|
|
if (!regex.test(password)) {
|
|
throw new FailedValidationException({
|
|
message: `Provided password doesn't match password policy`,
|
|
path: ['password'],
|
|
type: 'custom.pattern.base',
|
|
context: {
|
|
value: password,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private async checkRemainingAdminExistence(excludeKeys: PrimaryKey[]) {
|
|
// Make sure there's at least one admin user left after this deletion is done
|
|
const otherAdminUsers = await this.knex
|
|
.count('*', { as: 'count' })
|
|
.from('directus_users')
|
|
.whereNotIn('directus_users.id', excludeKeys)
|
|
.andWhere({ 'directus_roles.admin_access': true })
|
|
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
|
.first();
|
|
|
|
const otherAdminUsersCount = +(otherAdminUsers?.count || 0);
|
|
|
|
if (otherAdminUsersCount === 0) {
|
|
throw new UnprocessableEntityException(`You can't remove the last admin user from the role.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make sure there's at least one active admin user when updating user status
|
|
*/
|
|
private async checkRemainingActiveAdmin(excludeKeys: PrimaryKey[]): Promise<void> {
|
|
const otherAdminUsers = await this.knex
|
|
.count('*', { as: 'count' })
|
|
.from('directus_users')
|
|
.whereNotIn('directus_users.id', excludeKeys)
|
|
.andWhere({ 'directus_roles.admin_access': true })
|
|
.andWhere({ 'directus_users.status': 'active' })
|
|
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
|
.first();
|
|
|
|
const otherAdminUsersCount = +(otherAdminUsers?.count || 0);
|
|
|
|
if (otherAdminUsersCount === 0) {
|
|
throw new UnprocessableEntityException(`You can't change the active status of the last admin user.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new user
|
|
*/
|
|
async createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
|
|
const result = await this.createMany([data], opts);
|
|
return result[0];
|
|
}
|
|
|
|
/**
|
|
* Create multiple new users
|
|
*/
|
|
async createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
|
|
const emails = data.map((payload) => payload.email).filter((email) => email);
|
|
const passwords = data.map((payload) => payload.password).filter((password) => password);
|
|
|
|
if (emails.length) {
|
|
await this.checkUniqueEmails(emails);
|
|
}
|
|
|
|
if (passwords.length) {
|
|
await this.checkPasswordPolicy(passwords);
|
|
}
|
|
|
|
return await super.createMany(data, opts);
|
|
}
|
|
|
|
/**
|
|
* Update many users by query
|
|
*/
|
|
async updateByQuery(query: Query, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
|
|
const keys = await this.getKeysByQuery(query);
|
|
return keys.length ? await this.updateMany(keys, data, opts) : [];
|
|
}
|
|
|
|
/**
|
|
* Update a single user by primary key
|
|
*/
|
|
async updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
|
|
await this.updateMany([key], data, opts);
|
|
return key;
|
|
}
|
|
|
|
async updateBatch(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
|
|
const primaryKeyField = this.schema.collections[this.collection].primary;
|
|
|
|
const keys: PrimaryKey[] = [];
|
|
|
|
await this.knex.transaction(async (trx) => {
|
|
const service = new UsersService({
|
|
accountability: this.accountability,
|
|
knex: trx,
|
|
schema: this.schema,
|
|
});
|
|
|
|
for (const item of data) {
|
|
if (!item[primaryKeyField]) throw new InvalidPayloadException(`User in update misses primary key.`);
|
|
keys.push(await service.updateOne(item[primaryKeyField]!, item, opts));
|
|
}
|
|
});
|
|
|
|
return keys;
|
|
}
|
|
|
|
/**
|
|
* Update many users by primary key
|
|
*/
|
|
async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
|
|
if (data.role) {
|
|
// data.role will be an object with id with GraphQL mutations
|
|
const roleId = data.role?.id ?? data.role;
|
|
|
|
const newRole = await this.knex.select('admin_access').from('directus_roles').where('id', roleId).first();
|
|
|
|
if (!newRole?.admin_access) {
|
|
await this.checkRemainingAdminExistence(keys);
|
|
}
|
|
}
|
|
|
|
if (data.status !== undefined && data.status !== 'active') {
|
|
await this.checkRemainingActiveAdmin(keys);
|
|
}
|
|
|
|
if (data.email) {
|
|
if (keys.length > 1) {
|
|
throw new RecordNotUniqueException('email', {
|
|
collection: 'directus_users',
|
|
field: 'email',
|
|
invalid: data.email,
|
|
});
|
|
}
|
|
await this.checkUniqueEmails([data.email], keys[0]);
|
|
}
|
|
|
|
if (data.password) {
|
|
await this.checkPasswordPolicy([data.password]);
|
|
}
|
|
|
|
if (data.tfa_secret !== undefined) {
|
|
throw new InvalidPayloadException(`You can't change the "tfa_secret" value manually.`);
|
|
}
|
|
|
|
if (data.provider !== undefined) {
|
|
throw new InvalidPayloadException(`You can't change the "provider" value manually.`);
|
|
}
|
|
|
|
if (data.external_identifier !== undefined) {
|
|
throw new InvalidPayloadException(`You can't change the "external_identifier" value manually.`);
|
|
}
|
|
|
|
return await super.updateMany(keys, data, opts);
|
|
}
|
|
|
|
/**
|
|
* Delete a single user by primary key
|
|
*/
|
|
async deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise<PrimaryKey> {
|
|
await this.deleteMany([key], opts);
|
|
return key;
|
|
}
|
|
|
|
/**
|
|
* Delete multiple users by primary key
|
|
*/
|
|
async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise<PrimaryKey[]> {
|
|
await this.checkRemainingAdminExistence(keys);
|
|
|
|
await this.knex('directus_notifications').update({ sender: null }).whereIn('sender', keys);
|
|
|
|
await super.deleteMany(keys, opts);
|
|
return keys;
|
|
}
|
|
|
|
async deleteByQuery(query: Query, opts?: MutationOptions): Promise<PrimaryKey[]> {
|
|
const primaryKeyField = this.schema.collections[this.collection].primary;
|
|
const readQuery = cloneDeep(query);
|
|
readQuery.fields = [primaryKeyField];
|
|
|
|
// Not authenticated:
|
|
const itemsService = new ItemsService(this.collection, {
|
|
knex: this.knex,
|
|
schema: this.schema,
|
|
});
|
|
|
|
const itemsToDelete = await itemsService.readByQuery(readQuery);
|
|
const keys: PrimaryKey[] = itemsToDelete.map((item: Item) => item[primaryKeyField]);
|
|
|
|
if (keys.length === 0) return [];
|
|
|
|
return await this.deleteMany(keys, opts);
|
|
}
|
|
|
|
async inviteUser(email: string | string[], role: string, url: string | null, subject?: string | null): Promise<void> {
|
|
if (url && isUrlAllowed(url, env.USER_INVITE_URL_ALLOW_LIST) === false) {
|
|
throw new InvalidPayloadException(`Url "${url}" can't be used to invite users.`);
|
|
}
|
|
|
|
const emails = toArray(email);
|
|
const mailService = new MailService({
|
|
schema: this.schema,
|
|
accountability: this.accountability,
|
|
});
|
|
|
|
for (const email of emails) {
|
|
const payload = { email, scope: 'invite' };
|
|
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '7d', issuer: 'directus' });
|
|
const subjectLine = subject ?? "You've been invited";
|
|
const inviteURL = url ? new Url(url) : new Url(env.PUBLIC_URL).addPath('admin', 'accept-invite');
|
|
inviteURL.setQuery('token', token);
|
|
|
|
// Create user first to verify uniqueness
|
|
await this.createOne({ email, role, status: 'invited' });
|
|
|
|
await mailService.send({
|
|
to: email,
|
|
subject: subjectLine,
|
|
template: {
|
|
name: 'user-invitation',
|
|
data: {
|
|
url: inviteURL.toString(),
|
|
email,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
async acceptInvite(token: string, password: string): Promise<void> {
|
|
const { email, scope } = jwt.verify(token, env.SECRET as string, { issuer: 'directus' }) as {
|
|
email: string;
|
|
scope: string;
|
|
};
|
|
|
|
if (scope !== 'invite') throw new ForbiddenException();
|
|
|
|
const user = await this.knex.select('id', 'status').from('directus_users').where({ email }).first();
|
|
|
|
if (user?.status !== 'invited') {
|
|
throw new InvalidPayloadException(`Email address ${email} hasn't been invited.`);
|
|
}
|
|
|
|
// Allow unauthenticated update
|
|
const service = new UsersService({
|
|
knex: this.knex,
|
|
schema: this.schema,
|
|
});
|
|
|
|
await service.updateOne(user.id, { password, status: 'active' });
|
|
}
|
|
|
|
async requestPasswordReset(email: string, url: string | null, subject?: string | null): Promise<void> {
|
|
if (url && isUrlAllowed(url, env.PASSWORD_RESET_URL_ALLOW_LIST) === false) {
|
|
throw new InvalidPayloadException(`Url "${url}" can't be used to reset passwords.`);
|
|
}
|
|
|
|
const STALL_TIME = 500;
|
|
const timeStart = performance.now();
|
|
|
|
const user = await this.knex.select('status', 'password').from('directus_users').where({ email }).first();
|
|
|
|
if (user?.status !== 'active') {
|
|
await stall(STALL_TIME, timeStart);
|
|
throw new ForbiddenException();
|
|
}
|
|
|
|
const mailService = new MailService({
|
|
schema: this.schema,
|
|
knex: this.knex,
|
|
accountability: this.accountability,
|
|
});
|
|
|
|
const payload = { email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
|
|
const token = jwt.sign(payload, env.SECRET as string, { expiresIn: '1d', issuer: 'directus' });
|
|
const acceptURL = url
|
|
? new Url(url).setQuery('token', token).toString()
|
|
: new Url(env.PUBLIC_URL).addPath('admin', 'reset-password').setQuery('token', token);
|
|
const subjectLine = subject ? subject : 'Password Reset Request';
|
|
|
|
await mailService.send({
|
|
to: email,
|
|
subject: subjectLine,
|
|
template: {
|
|
name: 'password-reset',
|
|
data: {
|
|
url: acceptURL,
|
|
email,
|
|
},
|
|
},
|
|
});
|
|
|
|
await stall(STALL_TIME, timeStart);
|
|
}
|
|
|
|
async resetPassword(token: string, password: string): Promise<void> {
|
|
const { email, scope, hash } = jwt.verify(token, env.SECRET as string, { issuer: 'directus' }) as {
|
|
email: string;
|
|
scope: string;
|
|
hash: string;
|
|
};
|
|
|
|
if (scope !== 'password-reset' || !hash) throw new ForbiddenException();
|
|
|
|
await this.checkPasswordPolicy([password]);
|
|
|
|
const user = await this.knex.select('id', 'status', 'password').from('directus_users').where({ email }).first();
|
|
|
|
if (user?.status !== 'active' || hash !== getSimpleHash('' + user.password)) {
|
|
throw new ForbiddenException();
|
|
}
|
|
|
|
// Allow unauthenticated update
|
|
const service = new UsersService({
|
|
knex: this.knex,
|
|
schema: this.schema,
|
|
accountability: {
|
|
...(this.accountability ?? { role: null }),
|
|
admin: true, // We need to skip permissions checks for the update call below
|
|
},
|
|
});
|
|
|
|
await service.updateOne(user.id, { password, status: 'active' });
|
|
}
|
|
}
|