import jwt from 'jsonwebtoken'; import { Knex } from 'knex'; import ms from 'ms'; import { nanoid } from 'nanoid'; import getDatabase from '../database'; import emitter from '../emitter'; import env from '../env'; import { getAuthProvider } from '../auth'; import { DEFAULT_AUTH_PROVIDER } from '../constants'; import { InvalidCredentialsException, InvalidOTPException, UserSuspendedException } from '../exceptions'; import { createRateLimiter } from '../rate-limiter'; import { ActivityService } from './activity'; import { TFAService } from './tfa'; import { AbstractServiceOptions, Action, SchemaOverview, Session, User, SessionData } from '../types'; import { Accountability } from '@directus/shared/types'; import { SettingsService } from './settings'; import { clone, cloneDeep, omit } from 'lodash'; import { performance } from 'perf_hooks'; import { stall } from '../utils/stall'; import logger from '../logger'; const loginAttemptsLimiter = createRateLimiter({ 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, otp?: string ): Promise<{ accessToken: any; refreshToken: any; expires: any; id?: any }> { const STALL_TIME = 100; const timeStart = performance.now(); const provider = getAuthProvider(providerName); const user = await this.knex .select( 'id', 'first_name', 'last_name', 'email', 'password', 'status', 'role', 'tfa_secret', 'provider', 'external_identifier', 'auth_data' ) .from('directus_users') .where('id', await provider.getUserID(cloneDeep(payload))) .andWhere('provider', providerName) .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(); } } 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); } } let sessionData: SessionData = null; try { sessionData = 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, }; 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() + ms(env.REFRESH_TOKEN_TTL as string)); await this.knex('directus_sessions').insert({ token: refreshToken, user: user.id, 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()); if (this.accountability) { await this.activityService.createOne({ action: Action.LOGIN, user: user.id, ip: this.accountability.ip, user_agent: this.accountability.userAgent, 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: ms(env.ACCESS_TOKEN_TTL as string), id: user.id, }; } async refresh(refreshToken: string): Promise> { if (!refreshToken) { throw new InvalidCredentialsException(); } const record = await this.knex .select( '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') .where('s.token', refreshToken) .first(); if (!record || record.expires < new Date()) { throw new InvalidCredentialsException(); } 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 provider = getAuthProvider(user.provider); const newSessionData = await provider.refresh(clone(user), sessionData as SessionData); const tokenPayload = { id: user.id, }; const customClaims = await emitter.emitFilter( 'auth.jwt', tokenPayload, { status: 'pending', user: user?.id, provider: 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() + ms(env.REFRESH_TOKEN_TTL as string)); await this.knex('directus_sessions') .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 }); return { accessToken, refreshToken: newRefreshToken, expires: ms(env.ACCESS_TOKEN_TTL as string), id: user.id, }; } async logout(refreshToken: string): Promise { const record = await this.knex .select( 'u.id', 'u.first_name', 'u.last_name', 'u.email', 'u.password', 'u.status', 'u.role', 'u.provider', 'u.external_identifier', 'u.auth_data', 's.data' ) .from('directus_sessions as s') .innerJoin('directus_users as u', 's.user', 'u.id') .where('s.token', refreshToken) .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 provider = getAuthProvider(user.provider); await provider.logout(clone(user), sessionData as SessionData); await this.knex.delete().from('directus_sessions').where('token', refreshToken); } } async verifyPassword(userID: string, password: string): Promise { const user = await this.knex .select( '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); } }