directus

Форк
0
/
authentication.ts 
546 строк · 14.5 Кб
1
import { Action } from '@directus/constants';
2
import { useEnv } from '@directus/env';
3
import {
4
	InvalidCredentialsError,
5
	InvalidOtpError,
6
	ServiceUnavailableError,
7
	UserSuspendedError,
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';
26

27
const env = useEnv();
28

29
const loginAttemptsLimiter = createRateLimiter('RATE_LIMITER', { duration: 0 });
30

31
export class AuthenticationService {
32
	knex: Knex;
33
	accountability: Accountability | null;
34
	activityService: ActivityService;
35
	schema: SchemaOverview;
36

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;
42
	}
43

44
	/**
45
	 * Retrieve the tokens for a given user email.
46
	 *
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
49
	 */
50
	async login(
51
		providerName: string = DEFAULT_AUTH_PROVIDER,
52
		payload: Record<string, any>,
53
		options?: Partial<{
54
			otp: string;
55
			session: boolean;
56
		}>,
57
	): Promise<LoginResult> {
58
		const { nanoid } = await import('nanoid');
59

60
		const STALL_TIME = env['LOGIN_STALL_TIME'] as number;
61
		const timeStart = performance.now();
62

63
		const provider = getAuthProvider(providerName);
64

65
		let userId;
66

67
		try {
68
			userId = await provider.getUserID(cloneDeep(payload));
69
		} catch (err) {
70
			await stall(STALL_TIME, timeStart);
71
			throw err;
72
		}
73

74
		const user = await this.knex
75
			.select<User & { tfa_secret: string | null }>(
76
				'u.id',
77
				'u.first_name',
78
				'u.last_name',
79
				'u.email',
80
				'u.password',
81
				'u.status',
82
				'u.role',
83
				'r.admin_access',
84
				'r.app_access',
85
				'u.tfa_secret',
86
				'u.provider',
87
				'u.external_identifier',
88
				'u.auth_data',
89
			)
90
			.from('directus_users as u')
91
			.leftJoin('directus_roles as r', 'u.role', 'r.id')
92
			.where('u.id', userId)
93
			.first();
94

95
		const updatedPayload = await emitter.emitFilter(
96
			'auth.login',
97
			payload,
98
			{
99
				status: 'pending',
100
				user: user?.id,
101
				provider: providerName,
102
			},
103
			{
104
				database: this.knex,
105
				schema: this.schema,
106
				accountability: this.accountability,
107
			},
108
		);
109

110
		const emitStatus = (status: 'fail' | 'success') => {
111
			emitter.emitAction(
112
				'auth.login',
113
				{
114
					payload: updatedPayload,
115
					status,
116
					user: user?.id,
117
					provider: providerName,
118
				},
119
				{
120
					database: this.knex,
121
					schema: this.schema,
122
					accountability: this.accountability,
123
				},
124
			);
125
		};
126

127
		if (user?.status !== 'active' || user?.provider !== providerName) {
128
			emitStatus('fail');
129
			await stall(STALL_TIME, timeStart);
130
			throw new InvalidCredentialsError();
131
		}
132

133
		const settingsService = new SettingsService({
134
			knex: this.knex,
135
			schema: this.schema,
136
		});
137

138
		const { auth_login_attempts: allowedAttempts } = await settingsService.readSingleton({
139
			fields: ['auth_login_attempts'],
140
		});
141

142
		if (allowedAttempts !== null) {
143
			loginAttemptsLimiter.points = allowedAttempts;
144

145
			try {
146
				await loginAttemptsLimiter.consume(user.id);
147
			} catch (error) {
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';
151

152
					// This means that new attempts after the user has been re-activated will be accepted
153
					await loginAttemptsLimiter.set(user.id, 0, 0);
154
				} else {
155
					throw new ServiceUnavailableError({
156
						service: 'authentication',
157
						reason: 'Rate limiter unreachable',
158
					});
159
				}
160
			}
161
		}
162

163
		try {
164
			await provider.login(clone(user), cloneDeep(updatedPayload));
165
		} catch (e) {
166
			emitStatus('fail');
167
			await stall(STALL_TIME, timeStart);
168
			throw e;
169
		}
170

171
		if (user.tfa_secret && !options?.otp) {
172
			emitStatus('fail');
173
			await stall(STALL_TIME, timeStart);
174
			throw new InvalidOtpError();
175
		}
176

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);
180

181
			if (otpValid === false) {
182
				emitStatus('fail');
183
				await stall(STALL_TIME, timeStart);
184
				throw new InvalidOtpError();
185
			}
186
		}
187

188
		const tokenPayload: DirectusTokenPayload = {
189
			id: user.id,
190
			role: user.role,
191
			app_access: user.app_access,
192
			admin_access: user.admin_access,
193
		};
194

195
		const refreshToken = nanoid(64);
196
		const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
197

198
		if (options?.session) {
199
			tokenPayload.session = refreshToken;
200
		}
201

202
		const customClaims = await emitter.emitFilter(
203
			'auth.jwt',
204
			tokenPayload,
205
			{
206
				status: 'pending',
207
				user: user?.id,
208
				provider: providerName,
209
				type: 'login',
210
			},
211
			{
212
				database: this.knex,
213
				schema: this.schema,
214
				accountability: this.accountability,
215
			},
216
		);
217

218
		const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'] as string;
219

220
		const accessToken = jwt.sign(customClaims, getSecret(), {
221
			expiresIn: TTL,
222
			issuer: 'directus',
223
		});
224

225
		await this.knex('directus_sessions').insert({
226
			token: refreshToken,
227
			user: user.id,
228
			expires: refreshTokenExpiration,
229
			ip: this.accountability?.ip,
230
			user_agent: this.accountability?.userAgent,
231
			origin: this.accountability?.origin,
232
		});
233

234
		await this.knex('directus_sessions').delete().where('expires', '<', new Date());
235

236
		if (this.accountability) {
237
			await this.activityService.createOne({
238
				action: Action.LOGIN,
239
				user: user.id,
240
				ip: this.accountability.ip,
241
				user_agent: this.accountability.userAgent,
242
				origin: this.accountability.origin,
243
				collection: 'directus_users',
244
				item: user.id,
245
			});
246
		}
247

248
		await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id });
249

250
		emitStatus('success');
251

252
		if (allowedAttempts !== null) {
253
			await loginAttemptsLimiter.set(user.id, 0, 0);
254
		}
255

256
		await stall(STALL_TIME, timeStart);
257

258
		return {
259
			accessToken,
260
			refreshToken,
261
			expires: getMilliseconds(TTL),
262
			id: user.id,
263
		};
264
	}
265

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();
270

271
		if (!refreshToken) {
272
			throw new InvalidCredentialsError();
273
		}
274

275
		const record = await this.knex
276
			.select({
277
				session_expires: 's.expires',
278
				session_next_token: 's.next_token',
279
				user_id: 'u.id',
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',
288
				role_id: 'r.id',
289
				role_admin_access: 'r.admin_access',
290
				role_app_access: 'r.app_access',
291
				share_id: 'd.id',
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',
299
			})
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')]);
305
			})
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());
310
			})
311
			.andWhere((subQuery) => {
312
				subQuery.whereNull('d.date_start').orWhere('d.date_start', '<=', new Date());
313
			})
314
			.first();
315

316
		if (!record || (!record.share_id && !record.user_id)) {
317
			throw new InvalidCredentialsError();
318
		}
319

320
		if (record.user_id && record.user_status !== 'active') {
321
			await this.knex('directus_sessions').where({ token: refreshToken }).del();
322

323
			if (record.user_status === 'suspended') {
324
				await stall(STALL_TIME, timeStart);
325
				throw new UserSuspendedError();
326
			} else {
327
				await stall(STALL_TIME, timeStart);
328
				throw new InvalidCredentialsError();
329
			}
330
		}
331

332
		if (record.user_id) {
333
			const provider = getAuthProvider(record.user_provider);
334

335
			await provider.refresh({
336
				id: record.user_id,
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,
348
			});
349
		}
350

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));
354

355
		const tokenPayload: DirectusTokenPayload = {
356
			id: record.user_id,
357
			role: record.role_id,
358
			app_access: record.role_app_access,
359
			admin_access: record.role_admin_access,
360
		};
361

362
		if (options?.session) {
363
			newRefreshToken = await this.updateStatefulSession(record, refreshToken, newRefreshToken, refreshTokenExpiration);
364
			tokenPayload.session = newRefreshToken;
365
		} else {
366
			// Original stateless token behavior
367
			await this.knex('directus_sessions')
368
				.update({
369
					token: newRefreshToken,
370
					expires: refreshTokenExpiration,
371
				})
372
				.where({ token: refreshToken });
373
		}
374

375
		if (record.share_id) {
376
			tokenPayload.share = record.share_id;
377
			tokenPayload.role = record.share_role;
378

379
			tokenPayload.share_scope = {
380
				collection: record.share_collection,
381
				item: record.share_item,
382
			};
383

384
			tokenPayload.app_access = false;
385
			tokenPayload.admin_access = false;
386

387
			delete tokenPayload.id;
388
		}
389

390
		const customClaims = await emitter.emitFilter(
391
			'auth.jwt',
392
			tokenPayload,
393
			{
394
				status: 'pending',
395
				user: record.user_id,
396
				provider: record.user_provider,
397
				type: 'refresh',
398
			},
399
			{
400
				database: this.knex,
401
				schema: this.schema,
402
				accountability: this.accountability,
403
			},
404
		);
405

406
		const TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'] as string;
407

408
		const accessToken = jwt.sign(customClaims, getSecret(), {
409
			expiresIn: TTL,
410
			issuer: 'directus',
411
		});
412

413
		if (record.user_id) {
414
			await this.knex('directus_users').update({ last_access: new Date() }).where({ id: record.user_id });
415
		}
416

417
		// Clear expired sessions for the current user
418
		await this.knex('directus_sessions')
419
			.delete()
420
			.where({
421
				user: record.user_id,
422
				share: record.share_id,
423
			})
424
			.andWhere('expires', '<', new Date());
425

426
		return {
427
			accessToken,
428
			refreshToken: newRefreshToken,
429
			expires: getMilliseconds(TTL),
430
			id: record.user_id,
431
		};
432
	}
433

434
	private async updateStatefulSession(
435
		sessionRecord: Record<string, any>,
436
		oldSessionToken: string,
437
		newSessionToken: string,
438
		sessionExpiration: Date,
439
	): Promise<string> {
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')
444
				.update({
445
					expires: sessionExpiration,
446
				})
447
				.where({ token: newSessionToken });
448

449
			return newSessionToken;
450
		}
451

452
		// Keep the old session active for a short period of time
453
		const GRACE_PERIOD = getMilliseconds(env['SESSION_REFRESH_GRACE_PERIOD'], 10_000);
454

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')
458
			.update(
459
				{
460
					next_token: newSessionToken,
461
					expires: new Date(Date.now() + GRACE_PERIOD),
462
				},
463
				['next_token'],
464
			)
465
			.where({ token: oldSessionToken, next_token: null });
466

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 })
472
				.first();
473

474
			return next_token;
475
		}
476

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,
487
		});
488

489
		return newSessionToken;
490
	}
491

492
	async logout(refreshToken: string): Promise<void> {
493
		const record = await this.knex
494
			.select<User & Session>(
495
				'u.id',
496
				'u.first_name',
497
				'u.last_name',
498
				'u.email',
499
				'u.password',
500
				'u.status',
501
				'u.role',
502
				'u.provider',
503
				'u.external_identifier',
504
				'u.auth_data',
505
			)
506
			.from('directus_sessions as s')
507
			.innerJoin('directus_users as u', 's.user', 'u.id')
508
			.where('s.token', refreshToken)
509
			.first();
510

511
		if (record) {
512
			const user = record;
513

514
			const provider = getAuthProvider(user.provider);
515
			await provider.logout(clone(user));
516

517
			await this.knex.delete().from('directus_sessions').where('token', refreshToken);
518
		}
519
	}
520

521
	async verifyPassword(userID: string, password: string): Promise<void> {
522
		const user = await this.knex
523
			.select<User>(
524
				'id',
525
				'first_name',
526
				'last_name',
527
				'email',
528
				'password',
529
				'status',
530
				'role',
531
				'provider',
532
				'external_identifier',
533
				'auth_data',
534
			)
535
			.from('directus_users')
536
			.where('id', userID)
537
			.first();
538

539
		if (!user) {
540
			throw new InvalidCredentialsError();
541
		}
542

543
		const provider = getAuthProvider(user.provider);
544
		await provider.verify(clone(user), password);
545
	}
546
}
547

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.