Merge branch 'main' of https://github.com/directus/next into main

This commit is contained in:
Ben Haynes
2020-08-17 11:51:21 -04:00
25 changed files with 801 additions and 90 deletions

745
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -101,6 +101,7 @@
"nanoid": "^3.1.12",
"nodemailer": "^6.4.11",
"ora": "^4.1.1",
"otplib": "^12.0.1",
"pino": "^6.4.1",
"pino-colada": "^2.1.0",
"resolve-cwd": "^3.0.0",

View File

@@ -11,27 +11,27 @@ import errorHandler from './middleware/error-handler';
import extractToken from './middleware/extract-token';
import authenticate from './middleware/authenticate';
import activityRouter from './routes/activity';
import assetsRouter from './routes/assets';
import authRouter from './routes/auth';
import collectionsRouter from './routes/collections';
import extensionsRouter from './routes/extensions';
import fieldsRouter from './routes/fields';
import filesRouter from './routes/files';
import foldersRouter from './routes/folders';
import itemsRouter from './routes/items';
import permissionsRouter from './routes/permissions';
import presetsRouter from './routes/presets';
import relationsRouter from './routes/relations';
import revisionsRouter from './routes/revisions';
import rolesRouter from './routes/roles';
import serverRouter from './routes/server';
import settingsRouter from './routes/settings';
import usersRouter from './routes/users';
import utilsRouter from './routes/utils';
import webhooksRouter from './routes/webhooks';
import activityRouter from './controllers/activity';
import assetsRouter from './controllers/assets';
import authRouter from './controllers/auth';
import collectionsRouter from './controllers/collections';
import extensionsRouter from './controllers/extensions';
import fieldsRouter from './controllers/fields';
import filesRouter from './controllers/files';
import foldersRouter from './controllers/folders';
import itemsRouter from './controllers/items';
import permissionsRouter from './controllers/permissions';
import presetsRouter from './controllers/presets';
import relationsRouter from './controllers/relations';
import revisionsRouter from './controllers/revisions';
import rolesRouter from './controllers/roles';
import serverRouter from './controllers/server';
import settingsRouter from './controllers/settings';
import usersRouter from './controllers/users';
import utilsRouter from './controllers/utils';
import webhooksRouter from './controllers/webhooks';
import notFoundHandler from './routes/not-found';
import notFoundHandler from './controllers/not-found';
const app = express().disable('x-powered-by').set('trust proxy', true);

View File

@@ -17,6 +17,7 @@ const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required(),
mode: Joi.string().valid('cookie', 'json'),
otp: Joi.string()
});
router.post(
@@ -35,7 +36,7 @@ router.post(
const { error } = loginSchema.validate(req.body);
if (error) throw new InvalidPayloadException(error.message);
const { email, password } = req.body;
const { email, password, otp } = req.body;
const mode = req.body.mode || 'json';
@@ -48,6 +49,7 @@ router.post(
userAgent,
email,
password,
otp,
}
);

View File

@@ -6,6 +6,7 @@ import { InvalidPayloadException, InvalidCredentialsException } from '../excepti
import useCollection from '../middleware/use-collection';
import UsersService from '../services/users';
import MetaService from '../services/meta';
import AuthService from '../services/authentication';
const router = express.Router();
@@ -152,4 +153,38 @@ router.post(
})
);
router.post('/me/tfa/enable/', asyncHandler(async (req, res) => {
if (!req.accountability?.user) {
throw new InvalidCredentialsException();
}
const service = new UsersService({ accountability: req.accountability });
const url = await service.enableTFA(req.accountability.user);
return res.json({ data: { otpauth_url: url }});
}));
router.post('/me/tfa/disable', asyncHandler(async (req, res) => {
if (!req.accountability?.user) {
throw new InvalidCredentialsException();
}
if (!req.body.otp) {
throw new InvalidPayloadException(`"otp" is required`);
}
const service = new UsersService({ accountability: req.accountability });
const authService = new AuthService({ accountability: req.accountability });
const otpValid = await authService.verifyOTP(req.accountability.user, req.body.otp);
if (otpValid === false) {
throw new InvalidPayloadException(`"otp" is invalid`);
}
await service.disableTFA(req.accountability.user);
return res.status(200).end();
}));
export default router;

View File

@@ -3,17 +3,19 @@ import jwt from 'jsonwebtoken';
import argon2 from 'argon2';
import { nanoid } from 'nanoid';
import ms from 'ms';
import { InvalidCredentialsException } from '../exceptions';
import { InvalidCredentialsException, InvalidPayloadException } 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 {
@@ -33,16 +35,14 @@ export default class AuthenticationService {
* 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 }: AuthenticateOptions) {
async authenticate({ email, password, ip, userAgent, otp }: AuthenticateOptions) {
const user = await database
.select('id', 'password', 'role')
.select('id', 'password', 'role', 'tfa_secret', 'status')
.from('directus_users')
.where({ email })
.first();
/** @todo check for status */
if (!user) {
if (!user || user.status !== 'active') {
throw new InvalidCredentialsException();
}
@@ -50,6 +50,18 @@ export default class AuthenticationService {
throw new InvalidCredentialsException();
}
if (user.tfa_secret && !otp) {
throw new InvalidPayloadException(`"otp" is required`);
}
if (user.tfa_secret && otp) {
const otpValid = await this.verifyOTP(user.id, otp);
if (otpValid === false) {
throw new InvalidPayloadException(`"otp" is invalid`);
}
}
const payload = {
id: user.id,
};
@@ -127,4 +139,26 @@ export default class AuthenticationService {
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);
}
}

View File

@@ -1,3 +1,4 @@
import AuthService from './authentication';
import ItemsService from './items';
import jwt from 'jsonwebtoken';
import { sendInviteMail } from '../mail';
@@ -50,4 +51,23 @@ export default class UsersService extends ItemsService {
.update({ password: passwordHashed, status: 'active' })
.where({ id: user.id });
}
async enableTFA(pk: string) {
const user = await this.knex.select('tfa_secret').from('directus_users').where({ id: pk }).first();
if (user?.tfa_secret !== null) {
throw new InvalidPayloadException('TFA Secret is already set for this user');
}
const authService = new AuthService();
const secret = authService.generateTFASecret();
await this.knex('directus_users').update({ tfa_secret: secret }).where({ id: pk });
return await authService.generateOTPAuthURL(pk, secret);
}
async disableTFA(pk: string) {
await this.knex('directus_users').update({ tfa_secret: null }).where({ id: pk });
}
}