Files
directus/api/src/services/authentication.ts
2021-11-09 14:01:38 -05:00

389 lines
9.5 KiB
TypeScript

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<string, any>,
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<User & { tfa_secret: string | null }>(
'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<Record<string, any>> {
if (!refreshToken) {
throw new InvalidCredentialsException();
}
const record = await this.knex
.select<Session & User>(
'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<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',
'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<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);
}
}