1
import { Action } from '@directus/constants';
2
import { useEnv } from '@directus/env';
4
InvalidCredentialsError,
6
ServiceUnavailableError,
8
} from '@directus/errors';
9
import type { Accountability, SchemaOverview } from '@directus/types';
10
import jwt from 'jsonwebtoken';
11
import type { Knex } from 'knex';
12
import { clone, cloneDeep } from 'lodash-es';
13
import { performance } from 'perf_hooks';
14
import { getAuthProvider } from '../auth.js';
15
import { DEFAULT_AUTH_PROVIDER } from '../constants.js';
16
import getDatabase from '../database/index.js';
17
import emitter from '../emitter.js';
18
import { RateLimiterRes, createRateLimiter } from '../rate-limiter.js';
19
import type { AbstractServiceOptions, DirectusTokenPayload, LoginResult, Session, User } from '../types/index.js';
20
import { getMilliseconds } from '../utils/get-milliseconds.js';
21
import { getSecret } from '../utils/get-secret.js';
22
import { stall } from '../utils/stall.js';
23
import { ActivityService } from './activity.js';
24
import { SettingsService } from './settings.js';
25
import { TFAService } from './tfa.js';
29
const loginAttemptsLimiter = createRateLimiter('RATE_LIMITER', { duration: 0 });
31
export class AuthenticationService {
33
accountability: Accountability | null;
34
activityService: ActivityService;
35
schema: SchemaOverview;
37
constructor(options: AbstractServiceOptions) {
38
this.knex = options.knex || getDatabase();
39
this.accountability = options.accountability || null;
40
this.activityService = new ActivityService({ knex: this.knex, schema: options.schema });
41
this.schema = options.schema;
45
* Retrieve the tokens for a given user email.
47
* Password is optional to allow usage of this function within the SSO flow and extensions. Make sure
48
* to handle password existence checks elsewhere
51
providerName: string = DEFAULT_AUTH_PROVIDER,
52
payload: Record<string, any>,
57
): Promise<LoginResult> {
58
const { nanoid } = await import('nanoid');
60
const STALL_TIME = env['LOGIN_STALL_TIME'] as number;
61
const timeStart = performance.now();
63
const provider = getAuthProvider(providerName);
68
userId = await provider.getUserID(cloneDeep(payload));
70
await stall(STALL_TIME, timeStart);
74
const user = await this.knex
75
.select<User & { tfa_secret: string | null }>(
87
'u.external_identifier',
90
.from('directus_users as u')
91
.leftJoin('directus_roles as r', 'u.role', 'r.id')
92
.where('u.id', userId)
95
const updatedPayload = await emitter.emitFilter(
101
provider: providerName,
106
accountability: this.accountability,
110
const emitStatus = (status: 'fail' | 'success') => {
114
payload: updatedPayload,
117
provider: providerName,
122
accountability: this.accountability,
127
if (user?.status !== 'active' || user?.provider !== providerName) {
129
await stall(STALL_TIME, timeStart);
130
throw new InvalidCredentialsError();
133
const settingsService = new SettingsService({
138
const { auth_login_attempts: allowedAttempts } = await settingsService.readSingleton({
139
fields: ['auth_login_attempts'],
142
if (allowedAttempts !== null) {
143
loginAttemptsLimiter.points = allowedAttempts;
146
await loginAttemptsLimiter.consume(user.id);
148
if (error instanceof RateLimiterRes && error.remainingPoints === 0) {
149
await this.knex('directus_users').update({ status: 'suspended' }).where({ id: user.id });
150
user.status = 'suspended';
152
// This means that new attempts after the user has been re-activated will be accepted
153
await loginAttemptsLimiter.set(user.id, 0, 0);
155
throw new ServiceUnavailableError({
156
service: 'authentication',
157
reason: 'Rate limiter unreachable',
164
await provider.login(clone(user), cloneDeep(updatedPayload));
167
await stall(STALL_TIME, timeStart);
171
if (user.tfa_secret && !options?.otp) {
173
await stall(STALL_TIME, timeStart);
174
throw new InvalidOtpError();
177
if (user.tfa_secret && options?.otp) {
178
const tfaService = new TFAService({ knex: this.knex, schema: this.schema });
179
const otpValid = await tfaService.verifyOTP(user.id, options?.otp);
181
if (otpValid === false) {
183
await stall(STALL_TIME, timeStart);
184
throw new InvalidOtpError();
188
const tokenPayload: DirectusTokenPayload = {
191
app_access: user.app_access,
192
admin_access: user.admin_access,
195
const refreshToken = nanoid(64);
196
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
198
if (options?.session) {
199
tokenPayload.session = refreshToken;
202
const customClaims = await emitter.emitFilter(
208
provider: providerName,
214
accountability: this.accountability,
218
const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'] as string;
220
const accessToken = jwt.sign(customClaims, getSecret(), {
225
await this.knex('directus_sessions').insert({
228
expires: refreshTokenExpiration,
229
ip: this.accountability?.ip,
230
user_agent: this.accountability?.userAgent,
231
origin: this.accountability?.origin,
234
await this.knex('directus_sessions').delete().where('expires', '<', new Date());
236
if (this.accountability) {
237
await this.activityService.createOne({
238
action: Action.LOGIN,
240
ip: this.accountability.ip,
241
user_agent: this.accountability.userAgent,
242
origin: this.accountability.origin,
243
collection: 'directus_users',
248
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id });
250
emitStatus('success');
252
if (allowedAttempts !== null) {
253
await loginAttemptsLimiter.set(user.id, 0, 0);
256
await stall(STALL_TIME, timeStart);
261
expires: getMilliseconds(TTL),
266
async refresh(refreshToken: string, options?: Partial<{ session: boolean }>): Promise<LoginResult> {
267
const { nanoid } = await import('nanoid');
268
const STALL_TIME = env['LOGIN_STALL_TIME'] as number;
269
const timeStart = performance.now();
272
throw new InvalidCredentialsError();
275
const record = await this.knex
277
session_expires: 's.expires',
278
session_next_token: 's.next_token',
280
user_first_name: 'u.first_name',
281
user_last_name: 'u.last_name',
282
user_email: 'u.email',
283
user_password: 'u.password',
284
user_status: 'u.status',
285
user_provider: 'u.provider',
286
user_external_identifier: 'u.external_identifier',
287
user_auth_data: 'u.auth_data',
289
role_admin_access: 'r.admin_access',
290
role_app_access: 'r.app_access',
292
share_item: 'd.item',
293
share_role: 'd.role',
294
share_collection: 'd.collection',
295
share_start: 'd.date_start',
296
share_end: 'd.date_end',
297
share_times_used: 'd.times_used',
298
share_max_uses: 'd.max_uses',
300
.from('directus_sessions AS s')
301
.leftJoin('directus_users AS u', 's.user', 'u.id')
302
.leftJoin('directus_shares AS d', 's.share', 'd.id')
303
.leftJoin('directus_roles AS r', (join) => {
304
join.onIn('r.id', [this.knex.ref('u.role'), this.knex.ref('d.role')]);
306
.where('s.token', refreshToken)
307
.andWhere('s.expires', '>=', new Date())
308
.andWhere((subQuery) => {
309
subQuery.whereNull('d.date_end').orWhere('d.date_end', '>=', new Date());
311
.andWhere((subQuery) => {
312
subQuery.whereNull('d.date_start').orWhere('d.date_start', '<=', new Date());
316
if (!record || (!record.share_id && !record.user_id)) {
317
throw new InvalidCredentialsError();
320
if (record.user_id && record.user_status !== 'active') {
321
await this.knex('directus_sessions').where({ token: refreshToken }).del();
323
if (record.user_status === 'suspended') {
324
await stall(STALL_TIME, timeStart);
325
throw new UserSuspendedError();
327
await stall(STALL_TIME, timeStart);
328
throw new InvalidCredentialsError();
332
if (record.user_id) {
333
const provider = getAuthProvider(record.user_provider);
335
await provider.refresh({
337
first_name: record.user_first_name,
338
last_name: record.user_last_name,
339
email: record.user_email,
340
password: record.user_password,
341
status: record.user_status,
342
provider: record.user_provider,
343
external_identifier: record.user_external_identifier,
344
auth_data: record.user_auth_data,
345
role: record.role_id,
346
app_access: record.role_app_access,
347
admin_access: record.role_admin_access,
351
let newRefreshToken = record.session_next_token ?? nanoid(64);
352
const sessionDuration = env[options?.session ? 'SESSION_COOKIE_TTL' : 'REFRESH_TOKEN_TTL'];
353
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(sessionDuration, 0));
355
const tokenPayload: DirectusTokenPayload = {
357
role: record.role_id,
358
app_access: record.role_app_access,
359
admin_access: record.role_admin_access,
362
if (options?.session) {
363
newRefreshToken = await this.updateStatefulSession(record, refreshToken, newRefreshToken, refreshTokenExpiration);
364
tokenPayload.session = newRefreshToken;
366
// Original stateless token behavior
367
await this.knex('directus_sessions')
369
token: newRefreshToken,
370
expires: refreshTokenExpiration,
372
.where({ token: refreshToken });
375
if (record.share_id) {
376
tokenPayload.share = record.share_id;
377
tokenPayload.role = record.share_role;
379
tokenPayload.share_scope = {
380
collection: record.share_collection,
381
item: record.share_item,
384
tokenPayload.app_access = false;
385
tokenPayload.admin_access = false;
387
delete tokenPayload.id;
390
const customClaims = await emitter.emitFilter(
395
user: record.user_id,
396
provider: record.user_provider,
402
accountability: this.accountability,
406
const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'] as string;
408
const accessToken = jwt.sign(customClaims, getSecret(), {
413
if (record.user_id) {
414
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: record.user_id });
417
// Clear expired sessions for the current user
418
await this.knex('directus_sessions')
421
user: record.user_id,
422
share: record.share_id,
424
.andWhere('expires', '<', new Date());
428
refreshToken: newRefreshToken,
429
expires: getMilliseconds(TTL),
434
private async updateStatefulSession(
435
sessionRecord: Record<string, any>,
436
oldSessionToken: string,
437
newSessionToken: string,
438
sessionExpiration: Date,
440
if (sessionRecord['session_next_token']) {
441
// The current session token was already refreshed and has a reference
442
// to the new session, update the new session timeout for the new refresh
443
await this.knex('directus_sessions')
445
expires: sessionExpiration,
447
.where({ token: newSessionToken });
449
return newSessionToken;
452
// Keep the old session active for a short period of time
453
const GRACE_PERIOD = getMilliseconds(env['SESSION_REFRESH_GRACE_PERIOD'], 10_000);
455
// Update the existing session record to have a short safety timeout
456
// before expiring, and add the reference to the new session token
457
const updatedSession = await this.knex('directus_sessions')
460
next_token: newSessionToken,
461
expires: new Date(Date.now() + GRACE_PERIOD),
465
.where({ token: oldSessionToken, next_token: null });
467
if (updatedSession.length === 0) {
468
// Don't create a new session record, we already have a "next_token" reference
469
const { next_token } = await this.knex('directus_sessions')
470
.select('next_token')
471
.where({ token: oldSessionToken })
477
// Instead of updating the current session record with a new token,
478
// create a new copy with the new token
479
await this.knex('directus_sessions').insert({
480
token: newSessionToken,
481
user: sessionRecord['user_id'],
482
share: sessionRecord['share_id'],
483
expires: sessionExpiration,
484
ip: this.accountability?.ip,
485
user_agent: this.accountability?.userAgent,
486
origin: this.accountability?.origin,
489
return newSessionToken;
492
async logout(refreshToken: string): Promise<void> {
493
const record = await this.knex
494
.select<User & Session>(
503
'u.external_identifier',
506
.from('directus_sessions as s')
507
.innerJoin('directus_users as u', 's.user', 'u.id')
508
.where('s.token', refreshToken)
514
const provider = getAuthProvider(user.provider);
515
await provider.logout(clone(user));
517
await this.knex.delete().from('directus_sessions').where('token', refreshToken);
521
async verifyPassword(userID: string, password: string): Promise<void> {
522
const user = await this.knex
532
'external_identifier',
535
.from('directus_users')
540
throw new InvalidCredentialsError();
543
const provider = getAuthProvider(user.provider);
544
await provider.verify(clone(user), password);