Files
directus/api/src/services/authentication.ts
2020-08-25 15:26:35 -04:00

167 lines
4.8 KiB
TypeScript

import database from '../database';
import jwt from 'jsonwebtoken';
import argon2 from 'argon2';
import { nanoid } from 'nanoid';
import ms from 'ms';
import { InvalidCredentialsException, InvalidPayloadException, InvalidOTPException } from '../exceptions';
import { Session, Accountability, AbstractServiceOptions, Action } from '../types';
import Knex from 'knex';
import ActivityService from '../services/activity';
import env from '../env';
import { authenticator } from 'otplib';
type AuthenticateOptions = {
email: string;
password?: string;
ip?: string | null;
userAgent?: string | null;
otp?: string;
};
export default class AuthenticationService {
knex: Knex;
accountability: Accountability | null;
activityService: ActivityService;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
this.activityService = new ActivityService();
}
/**
* 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 authenticate({ email, password, ip, userAgent, otp }: AuthenticateOptions) {
const user = await database
.select('id', 'password', 'role', 'tfa_secret', 'status')
.from('directus_users')
.where({ email })
.first();
if (!user || user.status !== 'active') {
throw new InvalidCredentialsException();
}
if (password !== undefined && (await argon2.verify(user.password, password)) === false) {
throw new InvalidCredentialsException();
}
if (user.tfa_secret && !otp) {
throw new InvalidOTPException(`"otp" is required`);
}
if (user.tfa_secret && otp) {
const otpValid = await this.verifyOTP(user.id, otp);
if (otpValid === false) {
throw new InvalidOTPException(`"otp" is invalid`);
}
}
const payload = {
id: user.id,
};
/**
* @TODO
* Sign token with combination of server secret + user password hash
* That way, old tokens are immediately invalidated whenever the user changes their password
*/
const accessToken = jwt.sign(payload, env.SECRET as string, {
expiresIn: env.ACCESS_TOKEN_TTL,
});
const refreshToken = nanoid(64);
const refreshTokenExpiration = new Date(Date.now() + ms(env.REFRESH_TOKEN_TTL as string));
await database('directus_sessions').insert({
token: refreshToken,
user: user.id,
expires: refreshTokenExpiration,
ip,
user_agent: userAgent,
});
if (this.accountability) {
await this.activityService.create({
action: Action.AUTHENTICATE,
action_by: user.id,
ip: this.accountability.ip,
user_agent: this.accountability.userAgent,
collection: 'directus_users',
item: user.id,
});
}
return {
accessToken,
refreshToken,
expires: ms(env.ACCESS_TOKEN_TTL as string) / 1000,
id: user.id,
};
}
async refresh(refreshToken: string) {
if (!refreshToken) {
throw new InvalidCredentialsException();
}
const record = await database
.select<Session & { email: string, id: string }>('directus_sessions.*', 'directus_users.email', 'directus_users.id')
.from('directus_sessions')
.where({ 'directus_sessions.token': refreshToken })
.leftJoin('directus_users', 'directus_sessions.user', 'directus_users.id')
.first();
if (!record || !record.email || record.expires < new Date()) {
throw new InvalidCredentialsException();
}
const accessToken = jwt.sign({ id: record.id }, env.SECRET as string, {
expiresIn: env.ACCESS_TOKEN_TTL,
});
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 }).where({ token: refreshToken });
return {
accessToken,
refreshToken: newRefreshToken,
expires: ms(env.ACCESS_TOKEN_TTL as string) / 1000,
id: record.id,
};
}
async logout(refreshToken: string) {
await this.knex.delete().from('directus_sessions').where({ token: refreshToken });
}
generateTFASecret() {
const secret = authenticator.generateSecret();
return secret;
}
async generateOTPAuthURL(pk: string, secret: string) {
const user = await this.knex.select('first_name', 'last_name').from('directus_users').where({ id: pk }).first();
const name = `${user.first_name} ${user.last_name}`;
return authenticator.keyuri(name, 'Directus', secret);
}
async verifyOTP(pk: string, otp: string): Promise<boolean> {
const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first();
if (!user.tfa_secret) {
throw new InvalidPayloadException(`User "${pk}" doesn't have TFA enabled.`);
}
const secret = user.tfa_secret;
return authenticator.check(otp, secret);
}
}