Files
directus/api/src/services/authentication.ts
Rijk van Zanten 2983e61870 The Great TypeScript Modernization Program Season 3 Episode 6: The Big One (#18014)
* Step 1

* Step 2

* False sense of confidence

* Couple more before dinner

* Update schema package

* Update format-title

* Upgrade specs file

* Close

* Replace ts-node-dev with tsx, and various others

* Replace lodash with lodash-es

* Add lodash-es types

* Update knex import

* More fun is had

* FSE

* Consolidate repos

* Various tweaks and fixes

* Fix specs

* Remove dependency on knex-schema-inspector

* Fix wrong imports of inspector

* Move shared exceptions to new package

* Move constants to separate module

* Move types to new types package

* Use directus/types

* I believe this is no longer needed

* [WIP] Start moving utils to esm

* ESMify Shared

* Move shared utils to  @directus/utils

* Use @directus/utils instead of @directus/shared/utils

* It runs!

* Use correct schemaoverview type

* Fix imports

* Fix the thing

* Start on new update-checker lib

* Use new update-check package

* Swap out directus/shared in app

* Pushing through the last bits now

* Dangerously make extensions SDK ESM

* Use @directus/types in tests

* Copy util function to test

* Fix linter config

* Add missing import

* Hot takes

* Fix build

* Curse these default exports

* No tests in constants

* Add tests

* Remove tests from types

* Add tests for exceptions

* Fix test

* Fix app tests

* Fix import in test

* Fix various tests

* Fix specs export

* Some more tests

* Remove broken integration tests

These were broken beyond repair.. They were also written before we really knew what we we're doing with tests, so I think it's better to say goodbye and start over with these

* Regenerate lockfile

* Fix imports from merge

* I create my own problems

* Make sharp play nice

* Add vitest config

* Install missing blackbox dep

* Consts shouldn't be in types

tsk tsk tsk tsk

* Fix type/const usage in extensions-sdk

* cursed.default

* Reduce circular deps

* Fix circular dep in items service

* vvv

* Trigger testing for all vendors

* Add workaround for rollup

* Prepend the file protocol for the ESM loader to be compatible with Windows
"WARN: Only URLs with a scheme in: file and data are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'"

* Fix postgres

* Schema package updates

Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>

* Resolve cjs/mjs extensions

* Clean-up eslint config

* fixed extension concatination

* using string interpolation for consistency

* Revert MySQL optimisation

* Revert testing for all vendors

* Replace tsx with esbuild-kit/esm-loader

Is a bit faster and we can rely on the built-in `watch` and `inspect`
functionalities of Node.js

Note: The possibility to watch other files (.env in our case) might be
added in the future, see https://github.com/nodejs/node/issues/45467

* Use exact version for esbuild-kit/esm-loader

* Fix import

---------

Co-authored-by: ian <licitdev@gmail.com>
Co-authored-by: Brainslug <tim@brainslug.nl>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
2023-04-04 17:41:56 -04:00

443 lines
11 KiB
TypeScript

import { Accountability, Action, SchemaOverview } from '@directus/types';
import jwt from 'jsonwebtoken';
import type { Knex } from 'knex';
import { clone, cloneDeep } from 'lodash-es';
import { performance } from 'perf_hooks';
import { getAuthProvider } from '../auth.js';
import { DEFAULT_AUTH_PROVIDER } from '../constants.js';
import getDatabase from '../database/index.js';
import emitter from '../emitter.js';
import env from '../env.js';
import {
InvalidCredentialsException,
InvalidOTPException,
InvalidProviderException,
UserSuspendedException,
} from '../exceptions/index.js';
import { createRateLimiter } from '../rate-limiter.js';
import type { AbstractServiceOptions, DirectusTokenPayload, LoginResult, Session, User } from '../types/index.js';
import { getMilliseconds } from '../utils/get-milliseconds.js';
import { stall } from '../utils/stall.js';
import { ActivityService } from './activity.js';
import { SettingsService } from './settings.js';
import { TFAService } from './tfa.js';
const loginAttemptsLimiter = createRateLimiter('RATE_LIMITER', { duration: 0 });
export class AuthenticationService {
knex: Knex;
accountability: Accountability | null;
activityService: ActivityService;
schema: SchemaOverview;
constructor(options: AbstractServiceOptions) {
this.knex = options.knex || getDatabase();
this.accountability = options.accountability || null;
this.activityService = new ActivityService({ knex: this.knex, schema: options.schema });
this.schema = options.schema;
}
/**
* Retrieve the tokens for a given user email.
*
* Password is optional to allow usage of this function within the SSO flow and extensions. Make sure
* to handle password existence checks elsewhere
*/
async login(
providerName: string = DEFAULT_AUTH_PROVIDER,
payload: Record<string, any>,
otp?: string
): Promise<LoginResult> {
const { nanoid } = await import('nanoid');
const STALL_TIME = env['LOGIN_STALL_TIME'];
const timeStart = performance.now();
const provider = getAuthProvider(providerName);
let userId;
try {
userId = await provider.getUserID(cloneDeep(payload));
} catch (err) {
await stall(STALL_TIME, timeStart);
throw err;
}
const user = await this.knex
.select<User & { tfa_secret: string | null }>(
'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 as u')
.leftJoin('directus_roles as r', 'u.role', 'r.id')
.where('u.id', userId)
.first();
const updatedPayload = await emitter.emitFilter(
'auth.login',
payload,
{
status: 'pending',
user: user?.id,
provider: providerName,
},
{
database: this.knex,
schema: this.schema,
accountability: this.accountability,
}
);
const emitStatus = (status: 'fail' | 'success') => {
emitter.emitAction(
'auth.login',
{
payload: updatedPayload,
status,
user: user?.id,
provider: providerName,
},
{
database: this.knex,
schema: this.schema,
accountability: this.accountability,
}
);
};
if (user?.status !== 'active') {
emitStatus('fail');
if (user?.status === 'suspended') {
await stall(STALL_TIME, timeStart);
throw new UserSuspendedException();
} else {
await stall(STALL_TIME, timeStart);
throw new InvalidCredentialsException();
}
} else if (user.provider !== providerName) {
await stall(STALL_TIME, timeStart);
throw new InvalidProviderException();
}
const settingsService = new SettingsService({
knex: this.knex,
schema: this.schema,
});
const { auth_login_attempts: allowedAttempts } = await settingsService.readSingleton({
fields: ['auth_login_attempts'],
});
if (allowedAttempts !== null) {
loginAttemptsLimiter.points = allowedAttempts;
try {
await loginAttemptsLimiter.consume(user.id);
} catch {
await this.knex('directus_users').update({ status: 'suspended' }).where({ id: user.id });
user.status = 'suspended';
// This means that new attempts after the user has been re-activated will be accepted
await loginAttemptsLimiter.set(user.id, 0, 0);
}
}
try {
await provider.login(clone(user), cloneDeep(updatedPayload));
} catch (e) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw e;
}
if (user.tfa_secret && !otp) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw new InvalidOTPException(`"otp" is required`);
}
if (user.tfa_secret && otp) {
const tfaService = new TFAService({ knex: this.knex, schema: this.schema });
const otpValid = await tfaService.verifyOTP(user.id, otp);
if (otpValid === false) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw new InvalidOTPException(`"otp" is invalid`);
}
}
const tokenPayload = {
id: user.id,
role: user.role,
app_access: user.app_access,
admin_access: user.admin_access,
};
const customClaims = await emitter.emitFilter(
'auth.jwt',
tokenPayload,
{
status: 'pending',
user: user?.id,
provider: providerName,
type: 'login',
},
{
database: this.knex,
schema: this.schema,
accountability: this.accountability,
}
);
const accessToken = jwt.sign(customClaims, env['SECRET'] as string, {
expiresIn: env['ACCESS_TOKEN_TTL'],
issuer: 'directus',
});
const refreshToken = nanoid(64);
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
await this.knex('directus_sessions').insert({
token: refreshToken,
user: user.id,
expires: refreshTokenExpiration,
ip: this.accountability?.ip,
user_agent: this.accountability?.userAgent,
origin: this.accountability?.origin,
});
await this.knex('directus_sessions').delete().where('expires', '<', new Date());
if (this.accountability) {
await this.activityService.createOne({
action: Action.LOGIN,
user: user.id,
ip: this.accountability.ip,
user_agent: this.accountability.userAgent,
origin: this.accountability.origin,
collection: 'directus_users',
item: user.id,
});
}
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id });
emitStatus('success');
if (allowedAttempts !== null) {
await loginAttemptsLimiter.set(user.id, 0, 0);
}
await stall(STALL_TIME, timeStart);
return {
accessToken,
refreshToken,
expires: getMilliseconds(env['ACCESS_TOKEN_TTL']),
id: user.id,
};
}
async refresh(refreshToken: string): Promise<Record<string, any>> {
const { nanoid } = await import('nanoid');
if (!refreshToken) {
throw new InvalidCredentialsException();
}
const record = await this.knex
.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')
.leftJoin('directus_roles AS r', (join) => {
join.onIn('r.id', [this.knex.ref('u.role'), this.knex.ref('d.role')]);
})
.where('s.token', refreshToken)
.andWhere('s.expires', '>=', new Date())
.andWhere((subQuery) => {
subQuery.whereNull('d.date_end').orWhere('d.date_end', '>=', new Date());
})
.andWhere((subQuery) => {
subQuery.whereNull('d.date_start').orWhere('d.date_start', '<=', new Date());
})
.first();
if (!record || (!record.share_id && !record.user_id)) {
throw new InvalidCredentialsException();
}
if (record.user_id) {
const provider = getAuthProvider(record.user_provider);
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 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,
};
tokenPayload.app_access = false;
tokenPayload.admin_access = false;
delete tokenPayload.id;
}
const customClaims = await emitter.emitFilter(
'auth.jwt',
tokenPayload,
{
status: 'pending',
user: record.user_id,
provider: record.user_provider,
type: 'refresh',
},
{
database: this.knex,
schema: this.schema,
accountability: this.accountability,
}
);
const accessToken = jwt.sign(customClaims, env['SECRET'] as string, {
expiresIn: env['ACCESS_TOKEN_TTL'],
issuer: 'directus',
});
const newRefreshToken = nanoid(64);
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
await this.knex('directus_sessions')
.update({
token: newRefreshToken,
expires: refreshTokenExpiration,
})
.where({ token: refreshToken });
if (record.user_id) {
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: record.user_id });
}
return {
accessToken,
refreshToken: newRefreshToken,
expires: getMilliseconds(env['ACCESS_TOKEN_TTL']),
id: record.user_id,
};
}
async logout(refreshToken: string): Promise<void> {
const record = await this.knex
.select<User & Session>(
'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')
.where('s.token', refreshToken)
.first();
if (record) {
const user = record;
const provider = getAuthProvider(user.provider);
await provider.logout(clone(user));
await this.knex.delete().from('directus_sessions').where('token', refreshToken);
}
}
async verifyPassword(userID: string, password: string): Promise<void> {
const user = await this.knex
.select<User>(
'id',
'first_name',
'last_name',
'email',
'password',
'status',
'role',
'provider',
'external_identifier',
'auth_data'
)
.from('directus_users')
.where('id', userID)
.first();
if (!user) {
throw new InvalidCredentialsException();
}
const provider = getAuthProvider(user.provider);
await provider.verify(clone(user), password);
}
}