@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
394 lines (393 loc) • 16.7 kB
JavaScript
import { Action } from '@directus/constants';
import { useEnv } from '@directus/env';
import { InvalidCredentialsError, InvalidOtpError, ServiceUnavailableError, UserSuspendedError, } from '@directus/errors';
import jwt from 'jsonwebtoken';
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 { fetchRolesTree } from '../permissions/lib/fetch-roles-tree.js';
import { fetchGlobalAccess } from '../permissions/modules/fetch-global-access/fetch-global-access.js';
import { RateLimiterRes, createRateLimiter } from '../rate-limiter.js';
import { getMilliseconds } from '../utils/get-milliseconds.js';
import { getSecret } from '../utils/get-secret.js';
import { stall } from '../utils/stall.js';
import { ActivityService } from './activity.js';
import { SettingsService } from './settings.js';
import { TFAService } from './tfa.js';
const env = useEnv();
const loginAttemptsLimiter = createRateLimiter('RATE_LIMITER', { duration: 0 });
export class AuthenticationService {
knex;
accountability;
activityService;
schema;
constructor(options) {
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 = DEFAULT_AUTH_PROVIDER, payload, options) {
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('id', 'first_name', 'last_name', 'email', 'password', 'status', 'role', 'tfa_secret', 'provider', 'external_identifier', 'auth_data')
.from('directus_users')
.where('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) => {
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' || user?.provider !== providerName) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw new InvalidCredentialsError();
}
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 (error) {
if (error instanceof RateLimiterRes && error.remainingPoints === 0) {
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);
}
else {
throw new ServiceUnavailableError({
service: 'authentication',
reason: 'Rate limiter unreachable',
});
}
}
}
try {
await provider.login(clone(user), cloneDeep(updatedPayload));
}
catch (e) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw e;
}
if (user.tfa_secret && !options?.otp) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw new InvalidOtpError();
}
if (user.tfa_secret && options?.otp) {
const tfaService = new TFAService({ knex: this.knex, schema: this.schema });
const otpValid = await tfaService.verifyOTP(user.id, options?.otp);
if (otpValid === false) {
emitStatus('fail');
await stall(STALL_TIME, timeStart);
throw new InvalidOtpError();
}
}
const roles = await fetchRolesTree(user.role, this.knex);
const globalAccess = await fetchGlobalAccess({ roles, user: user.id, ip: this.accountability?.ip ?? null }, this.knex);
const tokenPayload = {
id: user.id,
role: user.role,
app_access: globalAccess.app,
admin_access: globalAccess.admin,
};
const refreshToken = nanoid(64);
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
if (options?.session) {
tokenPayload.session = refreshToken;
}
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 TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
const accessToken = jwt.sign(customClaims, getSecret(), {
expiresIn: TTL,
issuer: 'directus',
});
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(TTL),
id: user.id,
};
}
async refresh(refreshToken, options) {
const { nanoid } = await import('nanoid');
const STALL_TIME = env['LOGIN_STALL_TIME'];
const timeStart = performance.now();
if (!refreshToken) {
throw new InvalidCredentialsError();
}
const record = await this.knex
.select({
session_expires: 's.expires',
session_next_token: 's.next_token',
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',
user_role: 'u.role',
share_id: 'd.id',
share_start: 'd.date_start',
share_end: 'd.date_end',
})
.from('directus_sessions AS s')
.leftJoin('directus_users AS u', 's.user', 'u.id')
.leftJoin('directus_shares AS d', 's.share', 'd.id')
.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 InvalidCredentialsError();
}
if (record.user_id && record.user_status !== 'active') {
await this.knex('directus_sessions').where({ token: refreshToken }).del();
if (record.user_status === 'suspended') {
await stall(STALL_TIME, timeStart);
throw new UserSuspendedError();
}
else {
await stall(STALL_TIME, timeStart);
throw new InvalidCredentialsError();
}
}
const roles = await fetchRolesTree(record.user_role, this.knex);
const globalAccess = await fetchGlobalAccess({ user: record.user_id, roles, ip: this.accountability?.ip ?? null }, this.knex);
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.user_role,
app_access: globalAccess.app,
admin_access: globalAccess.admin,
});
}
let newRefreshToken = record.session_next_token ?? nanoid(64);
const sessionDuration = env[options?.session ? 'SESSION_COOKIE_TTL' : 'REFRESH_TOKEN_TTL'];
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(sessionDuration, 0));
const tokenPayload = {
id: record.user_id,
role: record.user_role,
app_access: globalAccess.app,
admin_access: globalAccess.admin,
};
if (options?.session) {
newRefreshToken = await this.updateStatefulSession(record, refreshToken, newRefreshToken, refreshTokenExpiration);
tokenPayload.session = newRefreshToken;
}
else {
// Original stateless token behavior
await this.knex('directus_sessions')
.update({
token: newRefreshToken,
expires: refreshTokenExpiration,
})
.where({ token: refreshToken });
}
if (record.share_id) {
tokenPayload.share = record.share_id;
tokenPayload.role = null;
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 TTL = env[options?.session ? 'SESSION_COOKIE_TTL' : 'ACCESS_TOKEN_TTL'];
const accessToken = jwt.sign(customClaims, getSecret(), {
expiresIn: TTL,
issuer: 'directus',
});
if (record.user_id) {
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: record.user_id });
}
// Clear expired sessions for the current user
await this.knex('directus_sessions')
.delete()
.where({
user: record.user_id,
share: record.share_id,
})
.andWhere('expires', '<', new Date());
return {
accessToken,
refreshToken: newRefreshToken,
expires: getMilliseconds(TTL),
id: record.user_id,
};
}
async updateStatefulSession(sessionRecord, oldSessionToken, newSessionToken, sessionExpiration) {
if (sessionRecord['session_next_token']) {
// The current session token was already refreshed and has a reference
// to the new session, update the new session timeout for the new refresh
await this.knex('directus_sessions')
.update({
expires: sessionExpiration,
})
.where({ token: newSessionToken });
return newSessionToken;
}
// Keep the old session active for a short period of time
const GRACE_PERIOD = getMilliseconds(env['SESSION_REFRESH_GRACE_PERIOD'], 10_000);
// Update the existing session record to have a short safety timeout
// before expiring, and add the reference to the new session token
const updatedSession = await this.knex('directus_sessions')
.update({
next_token: newSessionToken,
expires: new Date(Date.now() + GRACE_PERIOD),
}, ['next_token'])
.where({ token: oldSessionToken, next_token: null });
if (updatedSession.length === 0) {
// Don't create a new session record, we already have a "next_token" reference
const { next_token } = await this.knex('directus_sessions')
.select('next_token')
.where({ token: oldSessionToken })
.first();
return next_token;
}
// Instead of updating the current session record with a new token,
// create a new copy with the new token
await this.knex('directus_sessions').insert({
token: newSessionToken,
user: sessionRecord['user_id'],
share: sessionRecord['share_id'],
expires: sessionExpiration,
ip: this.accountability?.ip,
user_agent: this.accountability?.userAgent,
origin: this.accountability?.origin,
});
return newSessionToken;
}
async logout(refreshToken) {
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')
.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, password) {
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 InvalidCredentialsError();
}
const provider = getAuthProvider(user.provider);
await provider.verify(clone(user), password);
}
}