Add ability to share items with people outside the platform (#10663)

* Add directus_shares

* Don't check for usage limit on refresh

* Add all endpoints to the shares controller

* Move route `/auth/shared` to `/shared/auth`

* Add password protection

* Add `share` action in permissions

* Add `shares/:pk/info`

* Start on shared-view

* Add basic styling for full shared view

* Fixed migrations

* Add inline style for shared view

* Allow title override

* Finish /info endpoint for shares

* Add basic UUID validation to share/info endpont

* Add UUID validation to other routes

* Add not found state

* Cleanup /extract/finish share login endpoint

* Cleanup auth

* Added `share_start` and `share_end`

* Add share sidebar details.

* Allow share permissions configuration

* Hide the `new_share` button for unauthorized users

* Fix uses_left displayed value

* Show expired / upcoming shares

* Improved expired/upcoming styling

* Fixed share login query

* Fix check-ip and get-permissions middlewares behaviour when role is null

* Simplify cache key

* Fix typescript linting issues

* Handle app auth flow for shared page

* Fixed /users/me response

* Show when user is authenticated

* Try showing item drawer in shared page

* Improved shared card styling

* Add shares permissions and change share card styling

* Pull in schema/permissions on share

* Create getPermissionForShare file

* Change getPermissionsForShare signature

* Render form + item on share after auth

* Finalize public front end

* Handle fake o2m field in applyQuery

* [WIP]

* New translations en-US.yaml (Bulgarian) (#10585)

* smaller label height (#10587)

* Update to the latest Material Icons (#10573)

The icons are based on https://fonts.google.com/icons

* New translations en-US.yaml (Arabic) (#10593)

* New translations en-US.yaml (Arabic) (#10594)

* New translations en-US.yaml (Portuguese, Brazilian) (#10604)

* New translations en-US.yaml (French) (#10605)

* New translations en-US.yaml (Italian) (#10613)

* fix M2A list not updating (#10617)

* Fix filters

* Add admin filter on m2o role selection

* Add admin filter on m2o role selection

* Add o2m permissions traversing

* Finish relational tree permissions generation

* Handle implicit a2o relation

* Update implicit relation regex

* Fix regex

* Fix implicitRelation unnesting for new regex

* Fix implicitRelation length check

* Rename m2a to a2o internally

* Add auto-gen permissions for a2o

* [WIP] Improve share UX

* Add ctx menu options

* Add share dialog

* Add email notifications

* Tweak endpoint

* Tweak file interface disabled state

* Add nicer invalid state to password input

* Dont return info for expired/upcoming shares

* Tweak disabled state for relational interfaces

* Fix share button for non admin roles

* Show/hide edit/delete based on permissions to shares

* Fix imports of mutationtype

* Resolve (my own) suggestions

* Fix migration for ms sql

* Resolve last suggestion

Co-authored-by: Oreilles <oreilles.github@nitoref.io>
Co-authored-by: Oreilles <33065839+oreilles@users.noreply.github.com>
Co-authored-by: Ben Haynes <ben@rngr.org>
Co-authored-by: Thien Nguyen <72242664+tatthien@users.noreply.github.com>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
This commit is contained in:
Rijk van Zanten
2021-12-23 18:51:59 -05:00
committed by GitHub
parent d947c4f962
commit dbf35a1736
89 changed files with 2422 additions and 376 deletions

View File

@@ -11,13 +11,20 @@ import { InvalidCredentialsException, InvalidOTPException, UserSuspendedExceptio
import { createRateLimiter } from '../rate-limiter';
import { ActivityService } from './activity';
import { TFAService } from './tfa';
import { AbstractServiceOptions, Action, SchemaOverview, Session, User, SessionData } from '../types';
import {
AbstractServiceOptions,
Action,
SchemaOverview,
Session,
User,
DirectusTokenPayload,
LoginResult,
} from '../types';
import { Accountability } from '@directus/shared/types';
import { SettingsService } from './settings';
import { clone, cloneDeep, omit } from 'lodash';
import { clone, cloneDeep } from 'lodash';
import { performance } from 'perf_hooks';
import { stall } from '../utils/stall';
import logger from '../logger';
const loginAttemptsLimiter = createRateLimiter({ duration: 0 });
@@ -44,7 +51,7 @@ export class AuthenticationService {
providerName: string = DEFAULT_AUTH_PROVIDER,
payload: Record<string, any>,
otp?: string
): Promise<{ accessToken: any; refreshToken: any; expires: any; id?: any }> {
): Promise<LoginResult> {
const STALL_TIME = 100;
const timeStart = performance.now();
@@ -52,21 +59,24 @@ export class AuthenticationService {
const user = await this.knex
.select<User & { tfa_secret: string | null }>(
'id',
'first_name',
'last_name',
'email',
'password',
'status',
'role',
'tfa_secret',
'provider',
'external_identifier',
'auth_data'
'u.id',
'u.first_name',
'u.last_name',
'u.email',
'u.password',
'u.status',
'u.role',
'r.admin_access',
'r.app_access',
'u.tfa_secret',
'u.provider',
'u.external_identifier',
'u.auth_data'
)
.from('directus_users')
.where('id', await provider.getUserID(cloneDeep(payload)))
.andWhere('provider', providerName)
.from('directus_users as u')
.innerJoin('directus_roles as r', 'u.role', 'r.id')
.where('u.id', await provider.getUserID(cloneDeep(payload)))
.andWhere('u.provider', providerName)
.first();
const updatedPayload = await emitter.emitFilter(
@@ -136,10 +146,8 @@ export class AuthenticationService {
}
}
let sessionData: SessionData = null;
try {
sessionData = await provider.login(clone(user), cloneDeep(updatedPayload));
await provider.login(clone(user), cloneDeep(updatedPayload));
} catch (e) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
@@ -165,6 +173,9 @@ export class AuthenticationService {
const tokenPayload = {
id: user.id,
role: user.role,
app_access: user.app_access,
admin_access: user.admin_access,
};
const customClaims = await emitter.emitFilter(
@@ -197,7 +208,6 @@ export class AuthenticationService {
expires: refreshTokenExpiration,
ip: this.accountability?.ip,
user_agent: this.accountability?.userAgent,
data: sessionData && JSON.stringify(sessionData),
});
await this.knex('directus_sessions').delete().where('expires', '<', new Date());
@@ -237,55 +247,89 @@ export class AuthenticationService {
}
const record = await this.knex
.select<Session & User>(
's.expires',
's.data',
'u.id',
'u.first_name',
'u.last_name',
'u.email',
'u.password',
'u.status',
'u.role',
'u.provider',
'u.external_identifier',
'u.auth_data'
)
.from('directus_sessions as s')
.innerJoin('directus_users as u', 's.user', 'u.id')
.select({
session_expires: 's.expires',
user_id: 'u.id',
user_first_name: 'u.first_name',
user_last_name: 'u.last_name',
user_email: 'u.email',
user_password: 'u.password',
user_status: 'u.status',
user_provider: 'u.provider',
user_external_identifier: 'u.external_identifier',
user_auth_data: 'u.auth_data',
role_id: 'r.id',
role_admin_access: 'r.admin_access',
role_app_access: 'r.app_access',
share_id: 'd.id',
share_item: 'd.item',
share_role: 'd.role',
share_collection: 'd.collection',
share_start: 'd.date_start',
share_end: 'd.date_end',
share_times_used: 'd.times_used',
share_max_uses: 'd.max_uses',
})
.from('directus_sessions AS s')
.leftJoin('directus_users AS u', 's.user', 'u.id')
.leftJoin('directus_shares AS d', 's.share', 'd.id')
.joinRaw('LEFT JOIN directus_roles AS r ON r.id IN (u.role, d.role)')
.where('s.token', refreshToken)
.andWhere('s.expires', '>=', this.knex.fn.now())
.andWhere((subQuery) => {
subQuery.whereNull('d.date_end').orWhere('d.date_end', '>=', this.knex.fn.now());
})
.andWhere((subQuery) => {
subQuery.whereNull('d.date_start').orWhere('d.date_start', '<=', this.knex.fn.now());
})
.first();
if (!record || record.expires < new Date()) {
if (!record || (!record.share_id && !record.user_id)) {
throw new InvalidCredentialsException();
}
let { data: sessionData } = record;
const user = omit(record, 'data');
if (record.user_id) {
const provider = getAuthProvider(record.user_provider);
if (typeof sessionData === 'string') {
try {
sessionData = JSON.parse(sessionData);
} catch {
logger.warn(`Session data isn't valid JSON: ${sessionData}`);
}
await provider.refresh({
id: record.user_id,
first_name: record.user_first_name,
last_name: record.user_last_name,
email: record.user_email,
password: record.user_password,
status: record.user_status,
provider: record.user_provider,
external_identifier: record.user_external_identifier,
auth_data: record.user_auth_data,
role: record.role_id,
app_access: record.role_app_access,
admin_access: record.role_admin_access,
});
}
const provider = getAuthProvider(user.provider);
const newSessionData = await provider.refresh(clone(user), sessionData as SessionData);
const tokenPayload = {
id: user.id,
const tokenPayload: DirectusTokenPayload = {
id: record.user_id,
role: record.role_id,
app_access: record.role_app_access,
admin_access: record.role_admin_access,
};
if (record.share_id) {
tokenPayload.share = record.share_id;
tokenPayload.role = record.share_role;
tokenPayload.share_scope = {
collection: record.share_collection,
item: record.share_item,
};
}
const customClaims = await emitter.emitFilter(
'auth.jwt',
tokenPayload,
{
status: 'pending',
user: user?.id,
provider: user.provider,
user: record.user_id,
provider: record.user_provider,
type: 'refresh',
},
{
@@ -307,17 +351,18 @@ export class AuthenticationService {
.update({
token: newRefreshToken,
expires: refreshTokenExpiration,
data: newSessionData && JSON.stringify(newSessionData),
})
.where({ token: refreshToken });
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id });
if (record.user_id) {
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: record.user_id });
}
return {
accessToken,
refreshToken: newRefreshToken,
expires: ms(env.ACCESS_TOKEN_TTL as string),
id: user.id,
id: record.user_id,
};
}
@@ -333,8 +378,7 @@ export class AuthenticationService {
'u.role',
'u.provider',
'u.external_identifier',
'u.auth_data',
's.data'
'u.auth_data'
)
.from('directus_sessions as s')
.innerJoin('directus_users as u', 's.user', 'u.id')
@@ -342,19 +386,10 @@ export class AuthenticationService {
.first();
if (record) {
let { data: sessionData } = record;
const user = omit(record, 'data');
if (typeof sessionData === 'string') {
try {
sessionData = JSON.parse(sessionData);
} catch {
logger.warn(`Session data isn't valid JSON: ${sessionData}`);
}
}
const user = record;
const provider = getAuthProvider(user.provider);
await provider.logout(clone(user), sessionData as SessionData);
await provider.logout(clone(user));
await this.knex.delete().from('directus_sessions').where('token', refreshToken);
}