mirror of
https://github.com/directus/directus.git
synced 2026-01-26 09:58:03 -05:00
Merge branch 'main' of https://github.com/directus/next into main
This commit is contained in:
745
api/package-lock.json
generated
745
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user