diff --git a/composer.json b/composer.json index 185f15ec80..b49277a916 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,9 @@ "ext-mbstring": "*", "ext-openssl": "*", "ext-gd": "*", - "webonyx/graphql-php": "^0.13.0" + "webonyx/graphql-php": "^0.13.0", + "char0n/ffmpeg-php": "^3.0.0", + "pragmarx/google2fa": "^5.0" }, "require-dev": { "phpunit/phpunit": "^5.7.25", diff --git a/migrations/db/seeds/FieldsSeeder.php b/migrations/db/seeds/FieldsSeeder.php index 5f99472cf0..3e608d8096 100644 --- a/migrations/db/seeds/FieldsSeeder.php +++ b/migrations/db/seeds/FieldsSeeder.php @@ -1567,6 +1567,14 @@ public function run() 'readonly' => 1, 'hidden_detail' => 1 ], + [ + 'collection' => 'directus_users', + 'field' => '2fa_secret', + 'type' => \Directus\Database\Schema\DataTypes::TYPE_STRING, + 'interface' => '2fa-secret', + 'locked' => 1, + 'readonly' => 1 + ], // User Roles Junction // ----------------------------------------------------------------- @@ -1592,6 +1600,12 @@ public function run() 'type' => \Directus\Database\Schema\DataTypes::TYPE_M2O, 'interface' => 'many-to-one', 'locked' => 1 + ], + [ + 'collection' => 'directus_user_roles', + 'field' => 'enforce_2fa', + 'type' => \Directus\Database\Schema\DataTypes::TYPE_BOOLEAN, + 'interface' => 'toggle' ] ]; diff --git a/migrations/upgrades/schemas/20190614103321_add_users_2fa_secret_field.php b/migrations/upgrades/schemas/20190614103321_add_users_2fa_secret_field.php new file mode 100644 index 0000000000..ff416e0675 --- /dev/null +++ b/migrations/upgrades/schemas/20190614103321_add_users_2fa_secret_field.php @@ -0,0 +1,31 @@ +table('directus_users'); + if (!$table->hasColumn('2fa_secret')) { + $table->addColumn('2fa_secret', 'string', [ + 'limit' => 255, + 'null' => true, + 'default' => null + ]); + + $table->save(); + } + + $collection = 'directus_users'; + $field = '2fa_secret'; + $checkSql = sprintf('SELECT 1 FROM `directus_fields` WHERE `collection` = "%s" AND `field` = "%s";', $collection, $field); + $result = $this->query($checkSql)->fetch(); + + if (!$result) { + $insertSqlFormat = 'INSERT INTO `directus_fields` (`collection`, `field`, `type`, `interface`, `readonly`, `hidden_detail`, `hidden_browse`) VALUES ("%s", "%s", "%s", "%s", "%s", "%s", "%s");'; + $insertSql = sprintf($insertSqlFormat, $collection, $field, 'string', '2fa-secret', 1, 0, 1); + $this->execute($insertSql); + } + } +} diff --git a/migrations/upgrades/schemas/20190618190024_add_enforce_2fa_role_field.php b/migrations/upgrades/schemas/20190618190024_add_enforce_2fa_role_field.php new file mode 100644 index 0000000000..5feaf23407 --- /dev/null +++ b/migrations/upgrades/schemas/20190618190024_add_enforce_2fa_role_field.php @@ -0,0 +1,40 @@ +addSetting(); + $this->addField(); + } + + protected function addSetting() + { + $table = $this->table('directus_roles'); + if (!$table->hasColumn('enforce_2fa')) { + $table->addColumn('enforce_2fa', 'boolean', [ + 'null' => true, + 'default' => null + ]); + + $table->save(); + } + } + + protected function addField() + { + $collection = 'directus_roles'; + $field = 'enforce_2fa'; + $checkSql = sprintf('SELECT 1 FROM `directus_fields` WHERE `collection` = "%s" AND `field` = "%s";', $collection, $field); + $result = $this->query($checkSql)->fetch(); + + if (!$result) { + $insertSqlFormat = 'INSERT INTO `directus_fields` (`collection`, `field`, `type`, `interface`) VALUES ("%s", "%s", "%s", "%s");'; + $insertSql = sprintf($insertSqlFormat, $collection, $field, 'boolean', 'toggle'); + $this->execute($insertSql); + } + } +} diff --git a/migrations/upgrades/schemas/20190819070856_update_directus_fields_field.php b/migrations/upgrades/schemas/20190819070856_update_directus_fields_field.php new file mode 100644 index 0000000000..906509d788 --- /dev/null +++ b/migrations/upgrades/schemas/20190819070856_update_directus_fields_field.php @@ -0,0 +1,22 @@ +execute(\Directus\phinx_update( + $this->getAdapter(), + 'directus_fields', + [ + 'readonly' => 0, + 'note' => 'Duration must be in seconds' + ], + ['collection' => 'directus_files', 'field' => 'duration'] + )); + + + } +} diff --git a/package.json b/package.json index 8cb52febdf..aafa3e4c59 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@directus/api", "private": true, - "version": "2.4.0", + "version": "2.5.0", "description": "Directus API", "main": "index.js", "repository": "directus/api", diff --git a/src/core/Directus/Application/Application.php b/src/core/Directus/Application/Application.php index 0b9d1c7149..ea57c7454b 100644 --- a/src/core/Directus/Application/Application.php +++ b/src/core/Directus/Application/Application.php @@ -13,7 +13,7 @@ class Application extends App * * @var string */ - const DIRECTUS_VERSION = '2.4.0'; + const DIRECTUS_VERSION = '2.5.0'; /** * NOT USED diff --git a/src/core/Directus/Application/CoreServicesProvider.php b/src/core/Directus/Application/CoreServicesProvider.php index 19853f19a8..c55f3ea680 100644 --- a/src/core/Directus/Application/CoreServicesProvider.php +++ b/src/core/Directus/Application/CoreServicesProvider.php @@ -273,11 +273,11 @@ protected function getEmitter() if ($dateCreated = $collection->getDateCreatedField()) { - $payload[$dateCreated->getName()] = DateTimeUtils::nowInUTC()->toString(); + $payload[$dateCreated->getName()] = DateTimeUtils::nowInTimezone()->toString(); } if ($dateModified = $collection->getDateModifiedField()) { - $payload[$dateModified->getName()] = DateTimeUtils::nowInUTC()->toString(); + $payload[$dateModified->getName()] = DateTimeUtils::nowInTimezone()->toString(); } // Directus Users created user are themselves (primary key) @@ -364,7 +364,7 @@ protected function getEmitter() /** @var Acl $acl */ $acl = $container->get('acl'); if ($dateModified = $collection->getDateModifiedField()) { - $payload[$dateModified->getName()] = DateTimeUtils::nowInUTC()->toString(); + $payload[$dateModified->getName()] = DateTimeUtils::nowInTimezone()->toString(); } if ($userModified = $collection->getUserModifiedField()) { diff --git a/src/core/Directus/Application/Http/Middleware/AuthenticationMiddleware.php b/src/core/Directus/Application/Http/Middleware/AuthenticationMiddleware.php index afc7fe72de..ecacbc5494 100644 --- a/src/core/Directus/Application/Http/Middleware/AuthenticationMiddleware.php +++ b/src/core/Directus/Application/Http/Middleware/AuthenticationMiddleware.php @@ -4,6 +4,7 @@ use Directus\Application\Http\Request; use Directus\Application\Http\Response; +use Directus\Authentication\Exception\TFAEnforcedException; use Directus\Authentication\Exception\UserNotAuthenticatedException; use Directus\Authentication\User\User; use Directus\Authentication\User\UserInterface; @@ -12,6 +13,7 @@ use function Directus\get_request_authorization_token; use Directus\Permissions\Acl; use Directus\Services\AuthService; +use Directus\Services\UsersService; use Zend\Db\Sql\Select; use Zend\Db\TableGateway\TableGateway; @@ -26,6 +28,7 @@ class AuthenticationMiddleware extends AbstractMiddleware * * @throws UnauthorizedLocationException * @throws UserNotAuthenticatedException + * @throws TFAEnforcedException */ public function __invoke(Request $request, Response $response, callable $next) { @@ -53,7 +56,19 @@ public function __invoke(Request $request, Response $response, callable $next) if (!is_null($user)) { $rolesIpWhitelist = $this->getUserRolesIPWhitelist($user->getId()); - $permissionsByCollection = $permissionsTable->getUserPermissions($user->getId()); + $permissionsByCollection = $permissionsTable->getUserPermissions($user->getId()); + + /** @var UsersService $usersService */ + $usersService = new UsersService($this->container); + $tfa_enforced = $usersService->has2FAEnforced($user->getId()); + $isUserEdit = $this->targetIsUserEdit($request, $user->getId()); + + if ($tfa_enforced && $user->get2FASecret() == null && !$isUserEdit) { + $exception = new TFAEnforcedException(); + $hookEmitter->run('auth.fail', [$exception]); + throw $exception; + } + $hookEmitter->run('auth.success', [$user]); } else { if (is_null($user) && $publicRoleId) { @@ -207,4 +222,32 @@ protected function getRoleIPWhitelist($roleId) return array_filter(preg_split('/,\s*/', $result['ip_whitelist'])); } + + /** + * Returns true if the request is a user update for the given id + * A user edit will submit a PATCH to both the user update endpoint + * + * @param Request $request + * @param int $id + * + * @return bool + */ + protected function targetIsUserEdit(Request $request, int $id) { + + $target_array = explode('/', $request->getRequestTarget()); + $num_elements = count($target_array); + + if (!$request->isPost()) { + return false; + } + + if ($num_elements > 3 + &&$target_array[$num_elements - 3] == 'users' + && $target_array[$num_elements - 2] == strval($id) + && $target_array[$num_elements - 1] == 'activate2FA') { + return true; + } + + return false; + } } diff --git a/src/core/Directus/Authentication/Exception/InvalidOTPException.php b/src/core/Directus/Authentication/Exception/InvalidOTPException.php new file mode 100644 index 0000000000..cf31676455 --- /dev/null +++ b/src/core/Directus/Authentication/Exception/InvalidOTPException.php @@ -0,0 +1,15 @@ +findUserWithCredentials($email, $password); + $user = $this->findUserWithCredentials($email, $password, $otp); $this->setUser($user); } @@ -153,8 +158,10 @@ public function findUserWithConditions(array $conditions) * @return UserInterface * * @throws InvalidUserCredentialsException + * @throws InvalidOTPException + * @throws Missing2FAPasswordException */ - public function findUserWithCredentials($email, $password) + public function findUserWithCredentials($email, $password, $otp=null) { try { $user = $this->findUserWithEmail($email); @@ -169,6 +176,20 @@ public function findUserWithCredentials($email, $password) throw new InvalidUserCredentialsException(); } + $tfa_secret = $user->get2FASecret(); + + if ($tfa_secret) { + $ga = new Google2FA(); + + if ($otp == null) { + throw new Missing2FAPasswordException(); + } + + if (!$ga->verifyKey($tfa_secret, $otp, 2)){ + throw new InvalidOTPException(); + } + } + $this->user = $user; return $user; @@ -405,9 +426,11 @@ public function getUserProvider() * * @param UserInterface $user * + * @param bool $needs2FA Whether the User needs 2FA + * * @return string */ - public function generateAuthToken(UserInterface $user) + public function generateAuthToken(UserInterface $user, $needs2FA = false) { $payload = [ 'id' => (int) $user->getId(), @@ -415,6 +438,10 @@ public function generateAuthToken(UserInterface $user) 'exp' => $this->getNewExpirationTime() ]; + if ($needs2FA == true) { + $payload['needs2FA'] = true; + } + return $this->generateToken(JWTUtils::TYPE_AUTH, $payload); } @@ -497,7 +524,7 @@ public function generateToken($type, array $payload) * @throws ExpiredTokenException * @throws InvalidTokenException */ - public function refreshToken($token) + public function refreshToken($token, $needs2FA = false) { $payload = $this->getTokenPayload($token); @@ -507,6 +534,16 @@ public function refreshToken($token) $payload->exp = $this->getNewExpirationTime(); + $payload_arr = json_decode($payload); + + if ($needs2FA == true) { + $payload_arr['needs2FA'] = true; + } else { + unset($payload_arr['needs2FA']); + } + + $payload = json_encode($payload_arr); + return JWTUtils::encode($payload, $this->getSecretKey(), $this->getTokenAlgorithm()); } diff --git a/src/core/Directus/Authentication/User/User.php b/src/core/Directus/Authentication/User/User.php index d6acaa4b82..f6d65b7a99 100644 --- a/src/core/Directus/Authentication/User/User.php +++ b/src/core/Directus/Authentication/User/User.php @@ -47,6 +47,14 @@ public function getEmail() return $this->get('email'); } + /** + * @inheritdoc + */ + public function get2FASecret() + { + return $this->get('2fa_secret'); + } + /** * @inheritdoc */ diff --git a/src/core/Directus/Authentication/User/UserInterface.php b/src/core/Directus/Authentication/User/UserInterface.php index 500a14d6ba..ba84c08722 100644 --- a/src/core/Directus/Authentication/User/UserInterface.php +++ b/src/core/Directus/Authentication/User/UserInterface.php @@ -27,6 +27,13 @@ public function getId(); */ public function getEmail(); + /** + * Gets the user 2FA code + * + * @return string + */ + public function get2FASecret(); + /** * Gets the user group id * diff --git a/src/core/Directus/Console/Common/User.php b/src/core/Directus/Console/Common/User.php index e3c2aac68a..af10b02049 100644 --- a/src/core/Directus/Console/Common/User.php +++ b/src/core/Directus/Console/Common/User.php @@ -16,7 +16,7 @@ class User private $db; private $usersTableGateway; - public function __construct($base_path, $projectName) + public function __construct($base_path, $projectName = null) { if ($base_path == null) { $base_path = \Directus\base_path(); diff --git a/src/core/Directus/Database/RowGateway/DirectusUsersRowGateway.php b/src/core/Directus/Database/RowGateway/DirectusUsersRowGateway.php index 5b101a2cf4..c61444eb9d 100644 --- a/src/core/Directus/Database/RowGateway/DirectusUsersRowGateway.php +++ b/src/core/Directus/Database/RowGateway/DirectusUsersRowGateway.php @@ -23,7 +23,7 @@ public function preSaveDataHook(array $rowData, $rowExistsInDatabase = false) // Updated their "last_access" value. if ($this->acl) { if (isset($rowData['id']) && $rowData['id'] == $this->acl->getUserId()) { - $rowData['last_access_on'] = DateTimeUtils::nowInUTC()->toString(); + $rowData['last_access_on'] = DateTimeUtils::nowInTimezone()->toString(); } } diff --git a/src/core/Directus/Database/TableGateway/RelationalTableGateway.php b/src/core/Directus/Database/TableGateway/RelationalTableGateway.php index 3b501f3c56..354afb2c7f 100644 --- a/src/core/Directus/Database/TableGateway/RelationalTableGateway.php +++ b/src/core/Directus/Database/TableGateway/RelationalTableGateway.php @@ -279,7 +279,7 @@ public function manageRecordUpdate($tableName, $recordData, array $params = [], $logEntryAction ), 'action_by' => $currentUserId, - 'action_on' => DateTimeUtils::nowInUTC()->toString(), + 'action_on' => DateTimeUtils::nowInTimezone()->toString(), 'ip' => \Directus\get_request_ip(), 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '', 'collection' => $tableName, @@ -320,7 +320,7 @@ public function manageRecordUpdate($tableName, $recordData, array $params = [], $logEntryAction ), 'action_by' => $currentUserId, - 'action_on' => DateTimeUtils::nowInUTC()->toString(), + 'action_on' => DateTimeUtils::nowInTimezone()->toString(), 'ip' => \Directus\get_request_ip(), 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '', 'collection' => $tableName, @@ -695,6 +695,15 @@ public function addOrUpdateToManyRelationships($schema, $parentRow, &$childLogEn } } + if (strtolower($field->getType()) == DataTypes::TYPE_TRANSLATION){ + $columns=SchemaService::getAllCollectionFields($foreignTableName); + foreach($columns as $column){ + if(strtolower($column->getType()) == DataTypes::TYPE_USER_CREATED || strtolower($column->getType()) == DataTypes::TYPE_USER_UPDATED){ + unset($foreignRecord[$column->getName()]); + } + } + } + $foreignRecord = $ForeignTable->manageRecordUpdate( $foreignTableName, $foreignRecord, @@ -703,6 +712,7 @@ public function addOrUpdateToManyRelationships($schema, $parentRow, &$childLogEn $parentCollectionRelationshipsChanged, $parentData ); + } // Once they're managed, remove the foreign collections from the record array @@ -2049,7 +2059,9 @@ public function loadOneToManyRelationships($entries, $columns, array $params = [ // Only select the fields not on the currently authenticated user group's read field blacklist $relationalColumnName = $alias->getRelationship()->getFieldMany(); $tableGateway = new RelationalTableGateway($relatedTableName, $this->adapter, $this->acl); - $filterFields = \Directus\get_array_flat_columns($columnsTree[$alias->getName()]); + if(!empty($columnsTree[$alias->getName()])){ + $filterFields = \Directus\get_array_flat_columns($columnsTree[$alias->getName()]); + } $filters = []; if (ArrayUtils::get($params, 'lang') && DataTypes::isTranslationsType($alias->getType())) { @@ -2067,7 +2079,7 @@ public function loadOneToManyRelationships($entries, $columns, array $params = [ } $results = $tableGateway->fetchItems(array_merge([ - 'fields' => array_merge([$relationalColumnName], $filterFields), + 'fields' => !empty($filterFields) ? array_merge([$relationalColumnName], $filterFields) : [$relationalColumnName], // Fetch all related data 'limit' => -1, 'filter' => array_merge($filters, [ @@ -2076,7 +2088,9 @@ public function loadOneToManyRelationships($entries, $columns, array $params = [ ], $params)); $relatedEntries = []; - $selectedFields = $tableGateway->getSelectedFields($filterFields); + if(!empty($filterFields)){ + $selectedFields = $tableGateway->getSelectedFields($filterFields); + } foreach ($results as $row) { // Quick fix @@ -2509,7 +2523,7 @@ protected function recordActivity($action, $payload, array $record, array $neste $action ), 'action_by' => $currentUserId, - 'action_on' => DateTimeUtils::nowInUTC()->toString(), + 'action_on' => DateTimeUtils::nowInTimezone()->toString(), 'ip' => \Directus\get_request_ip(), 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '', 'collection' => $this->getTable(), diff --git a/src/core/Directus/Embed/Provider/YoutubeProvider.php b/src/core/Directus/Embed/Provider/YoutubeProvider.php index 810cd6d38c..0dffd0a1b1 100644 --- a/src/core/Directus/Embed/Provider/YoutubeProvider.php +++ b/src/core/Directus/Embed/Provider/YoutubeProvider.php @@ -16,7 +16,7 @@ public function getProviderType() */ public function validateURL($url) { - return strpos($url, 'youtube.com') !== false; + return (bool) preg_match("@^(https?\:\/\/)?(www\.)|(m\.)?(youtube\.com|youtu\.?be)\/.+$@", $url); } /** @@ -33,9 +33,13 @@ public function getFormatUrl() protected function parseURL($url) { // Get ID from URL - parse_str(parse_url($url, PHP_URL_QUERY), $urlParameters); - $videoID = isset($urlParameters['v']) ? $urlParameters['v'] : false; - + if(strpos($url, 'youtu.be')) { + $urlElements = parse_url($url); + $videoID = isset($urlElements['path']) && strlen($urlElements['path']) > 1 ? ltrim($urlElements['path'], '/') : false; + } else { + parse_str(parse_url($url, PHP_URL_QUERY), $urlParameters); + $videoID = isset($urlParameters['v']) ? $urlParameters['v'] : false; + } // Can't find the video ID if (!$videoID) { throw new \Exception('YouTube ID not detected'); diff --git a/src/core/Directus/Filesystem/Files.php b/src/core/Directus/Filesystem/Files.php index f7a5fdc20b..2f9dd5495e 100644 --- a/src/core/Directus/Filesystem/Files.php +++ b/src/core/Directus/Filesystem/Files.php @@ -2,6 +2,7 @@ namespace Directus\Filesystem; +use Char0n\FFMpegPHP\Movie; use Directus\Application\Application; use function Directus\filename_put_ext; use function Directus\generate_uuid5; @@ -259,7 +260,7 @@ public function saveData($fileData, $fileName, $replace = false) // When file is uploaded via multipart form data then We will get object of Slim\Http\UploadFile // When file is uploaded via URL (Youtube, Vimeo, or image link) then we will get base64 encode string. $size = null; - + $title = $fileName; if (is_object($fileData)) { @@ -275,12 +276,15 @@ public function saveData($fileData, $fileName, $replace = false) $filePath = $this->getConfig('root') . '/' . $fileName; - - $this->emitter->run('file.save', ['name' => $fileName, 'size' => $size]); $this->write($fileName, $fileData, $replace); $this->emitter->run('file.save:after', ['name' => $fileName, 'size' => $size]); + #open local tmp file since s3 bucket is private + $handle = fopen($fileData->file, 'rb'); + $tmp = tempnam(sys_get_temp_dir(), $fileName); + file_put_contents($tmp, $handle); + unset($fileData); $fileData = $this->getFileInfo($fileName); @@ -290,6 +294,25 @@ public function saveData($fileData, $fileName, $replace = false) $fileData = array_merge($this->defaults, $fileData); + # Updates for file meta data tags + if (strpos($fileData['type'],'video') !== false) { + #use ffprobe on local file, can't stream data to it or reference + $output = shell_exec("ffprobe {$tmp} -show_entries format=duration:stream=height,width -v quiet -of json"); + #echo($output); + $media = json_decode($output); + $width = $media->streams[0]->width; + $height = $media->streams[0]->height; + $duration = $media->format->duration; #seconds + + } elseif (strpos($fileData['type'],'audio') !== false) { + $output = shell_exec("ffprobe {$tmp} -show_entries format=duration -v quiet -of json"); + $media = json_decode($output); + $duration = $media->format->duration; + } + + fclose($handle); + unset($tmpData); + return [ // The MIME type will be based on its extension, rather than its content 'type' => MimeTypeUtils::getFromFilename($fileData['filename']), @@ -300,10 +323,11 @@ public function saveData($fileData, $fileName, $replace = false) 'location' => $fileData['location'], 'charset' => $fileData['charset'], 'filesize' => $fileData['size'], - 'width' => $fileData['width'], - 'height' => $fileData['height'], + 'width' => isset($width) ? $width : $fileData['width'], + 'height' => isset($height) ? $width : $fileData['height'], 'storage' => $fileData['storage'], 'checksum' => $checksum, + 'duration' => isset($duration) ? $duration : 0 ]; } @@ -355,6 +379,7 @@ public function getFileInfo($path, $outside = false) public function getFileInfoFromPath($path) { + $mime = $this->filesystem->getAdapter()->getMimetype($path); $typeTokens = explode('/', $mime); @@ -537,7 +562,7 @@ private function processUpload($filePath, $targetName) $this->emitter->run('file.save:after', ['name' => $targetName, 'size' => strlen($data)]); $fileData['name'] = basename($finalPath); - $fileData['date_uploaded'] = DateTimeUtils::nowInUTC()->toString(); + $fileData['date_uploaded'] = DateTimeUtils::nowInTimezone()->toString(); $fileData['storage'] = $this->config['adapter']; return $fileData; diff --git a/src/core/Directus/GraphQL/Types.php b/src/core/Directus/GraphQL/Types.php index 3a1244500b..e2b5c53beb 100644 --- a/src/core/Directus/GraphQL/Types.php +++ b/src/core/Directus/GraphQL/Types.php @@ -136,12 +136,13 @@ public static function meta() public static function collections($type) { - if (!array_key_exists($type, self::$collections)) { + $key = is_subclass_of($type, 'GraphQL\Type\Definition\ObjectType') ? $type->name : $type; + if (!array_key_exists($key, self::$collections)) { $collectionType = new CollectionType($type); - self::$collections[$type] = $collectionType; + self::$collections[$key] = $collectionType; return $collectionType; } else { - return self::$collections[$type]; + return self::$collections[$key]; } } diff --git a/src/core/Directus/Services/AbstractService.php b/src/core/Directus/Services/AbstractService.php index cf39a90e6c..305ba568ad 100644 --- a/src/core/Directus/Services/AbstractService.php +++ b/src/core/Directus/Services/AbstractService.php @@ -113,11 +113,11 @@ protected function getAuth() * * @throws UnprocessableEntityException */ - public function validate(array $data, array $constraints) + public function validate(array $data, array $constraints, $errorCode = "") { $constraintViolations = $this->getViolations($data, $constraints); - $this->throwErrorIfAny($constraintViolations); + $this->throwErrorIfAny($constraintViolations,$errorCode); } /** @@ -162,7 +162,7 @@ protected function getViolations(array $data, array $constraints) * * @throws UnprocessableEntityException */ - protected function throwErrorIfAny(array $violations) + protected function throwErrorIfAny(array $violations,$errorCode = "") { $results = []; @@ -183,7 +183,7 @@ protected function throwErrorIfAny(array $violations) } if (count($results) > 0) { - throw new InvalidRequestException(implode(' ', $results)); + throw new InvalidRequestException(implode(' ', $results), $errorCode); } } @@ -196,7 +196,7 @@ protected function throwErrorIfAny(array $violations) * * @return array */ - protected function createConstraintFor($collectionName, array $fields = [], $skipRelatedCollectionField = '') + protected function createConstraintFor($collectionName, array $fields = [], $skipRelatedCollectionField = '', array $params = [] ) { /** @var SchemaManager $schemaManager */ $schemaManager = $this->container->get('schema_manager'); @@ -220,6 +220,10 @@ protected function createConstraintFor($collectionName, array $fields = [], $ski continue; } + if ($field->getName() == "password" && isset($params['select_existing_or_update'])) { + continue; + } + $isRequired = $field->isRequired(); $isStatusField = $field->isStatusType(); if (!$isRequired && $isStatusField && $field->getDefaultValue() === null) { @@ -376,13 +380,13 @@ protected function validatePayload($collectionName, $fields, array $payload, arr if (is_array($fields)) { $columnsToValidate = $fields; } - + $this->validatePayloadFields($collectionName, $payload); // TODO: Ideally this should be part of the validator constraints // we need to accept options for the constraint builder $this->validatePayloadWithFieldsValidation($collectionName, $payload); - $this->validate($payload, $this->createConstraintFor($collectionName, $columnsToValidate, $skipRelatedCollectionField)); + $this->validate($payload, $this->createConstraintFor($collectionName, $columnsToValidate, $skipRelatedCollectionField,$params)); } /** diff --git a/src/core/Directus/Services/AuthService.php b/src/core/Directus/Services/AuthService.php index 72bdce8947..aefb53b522 100644 --- a/src/core/Directus/Services/AuthService.php +++ b/src/core/Directus/Services/AuthService.php @@ -24,19 +24,22 @@ class AuthService extends AbstractService { + const AUTH_VALIDATION_ERROR_CODE = 109; + /** * Gets the user token using the authentication email/password combination * * @param string $email * @param string $password + * @param string $otp * * @return array * * @throws UnauthorizedException */ - public function loginWithCredentials($email, $password) + public function loginWithCredentials($email, $password, $otp=null) { - $this->validateCredentials($email, $password); + $this->validateCredentials($email, $password, $otp); /** @var Provider $auth */ $auth = $this->container->get('auth'); @@ -44,7 +47,8 @@ public function loginWithCredentials($email, $password) /** @var UserInterface $user */ $user = $auth->login([ 'email' => $email, - 'password' => $password + 'password' => $password, + 'otp' => $otp ]); $hookEmitter = $this->container->get('hook_emitter'); @@ -55,9 +59,19 @@ public function loginWithCredentials($email, $password) $activityTableGateway = $this->createTableGateway('directus_activity', false); $activityTableGateway->recordLogin($user->get('id')); + /** @var UsersService $usersService */ + $usersService = new UsersService($this->container); + $tfa_enforced = $usersService->has2FAEnforced($user->getId()); + + if ($tfa_enforced && $user->get2FASecret() == null) { + $token = $this->generateAuthToken($user, true); + } else { + $token = $this->generateAuthToken($user); + } + return [ 'data' => [ - 'token' => $this->generateAuthToken($user) + 'token' => $token ] ]; } @@ -289,14 +303,16 @@ public function authenticateWithSsoRequestToken($token) * * @param UserInterface $user * + * @param bool $needs2FA Whether the user needs 2FA + * * @return string */ - public function generateAuthToken(UserInterface $user) + public function generateAuthToken(UserInterface $user, bool $needs2FA = false) { /** @var Provider $auth */ $auth = $this->container->get('auth'); - return $auth->generateAuthToken($user); + return $auth->generateAuthToken($user, $needs2FA); } /** @@ -382,29 +398,46 @@ public function refreshToken($token) /** @var Provider $auth */ $auth = $this->container->get('auth'); - return ['data' => ['token' => $auth->refreshToken($token)]]; + $payload = JWTUtils::getPayload($token); + $userProvider = $auth->getUserProvider(); + $user = $userProvider->find($payload->id); + + /** @var UsersService $usersService */ + $usersService = new UsersService($this->container); + + $tfa_enforced = $usersService->has2FAEnforced($user->getId()); + + if ($tfa_enforced && $user->get2FASecret() == null) { + $new_token = $auth->refreshToken($token, true); + } else { + $new_token = $auth->refreshToken($token); + } + + return ['data' => ['token' => $new_token]]; } /** - * Validates email+password credentials + * Validates email+password+otp credentials * * @param $email * @param $password + * @param $otp * * @throws UnprocessableEntityException */ - protected function validateCredentials($email, $password) + protected function validateCredentials($email, $password, $otp) { $payload = [ 'email' => $email, - 'password' => $password + 'password' => $password, + 'otp' => $otp ]; $constraints = [ 'email' => 'required|string|email', - 'password' => 'required|string' + 'password' => 'required|string', ]; // throws an exception if the constraints are not met - $this->validate($payload, $constraints); + $this->validate($payload, $constraints, self::AUTH_VALIDATION_ERROR_CODE); } } diff --git a/src/core/Directus/Services/FilesServices.php b/src/core/Directus/Services/FilesServices.php index 62f4fe8954..ef0ffaff4a 100644 --- a/src/core/Directus/Services/FilesServices.php +++ b/src/core/Directus/Services/FilesServices.php @@ -88,13 +88,16 @@ public function update($id, array $data, array $params = []) $this->validatePayload($this->collection, array_keys($data), $data, $params); $files = $this->container->get('files'); - $result=$files->getFileSizeType($data['data']); - if(get_directus_setting('file_mimetype_whitelist') != null){ - validate_file($result['mimeType'],'mimeTypes'); - } - if(get_directus_setting('file_max_size') != null){ - validate_file($result['size'],'maxSize'); + if(isset($data['data'])){ + $result=$files->getFileSizeType($data['data']); + + if(get_directus_setting('file_mimetype_whitelist') != null){ + validate_file($result['mimeType'],'mimeTypes'); + } + if(get_directus_setting('file_max_size') != null){ + validate_file($result['size'],'maxSize'); + } } $tableGateway = $this->createTableGateway($this->collection); diff --git a/src/core/Directus/Services/ItemsService.php b/src/core/Directus/Services/ItemsService.php index d85535fa1e..fc0c0c61c0 100644 --- a/src/core/Directus/Services/ItemsService.php +++ b/src/core/Directus/Services/ItemsService.php @@ -30,7 +30,7 @@ public function createItem($collection, $payload, $params = []) { $this->enforceCreatePermissions($collection, $payload, $params); $this->validatePayload($collection, null, $payload, $params); - + // Validate Password if password policy settled in the system settings. if($collection == SchemaManager::COLLECTION_USERS){ $passwordValidation = get_directus_setting('password_policy'); @@ -38,14 +38,13 @@ public function createItem($collection, $payload, $params = []) $this->validate($payload,[static::PASSWORD_FIELD => ['regex:'.$passwordValidation ]]); } } - + //Validate nested payload $tableSchema = SchemaService::getCollection($collection); $collectionAliasColumns = $tableSchema->getAliasFields(); foreach ($collectionAliasColumns as $aliasColumnDetails) { if($this->isManyToManyField($aliasColumnDetails)){ - $this->validateManyToManyCollection($payload, $params, $aliasColumnDetails); }else{ $this->validateAliasCollection($payload, $params, $aliasColumnDetails, []); @@ -189,23 +188,38 @@ public function validateManyToManyCollection($payload, $params, $aliasColumnDeta $relationalCollectionName = $aliasColumnDetails->getRelationship()->getCollectionManyToMany(); if($relationalCollectionName && isset($payload[$colName])){ $relationalCollectionPrimaryKey = SchemaService::getCollectionPrimaryKey($relationalCollectionName); + $relationalCollectionPrimaryKeyObject = SchemaService::getField($relationalCollectionName,$relationalCollectionPrimaryKey); $relationalCollectionColumns = SchemaService::getAllCollectionFields($relationalCollectionName); foreach($payload[$colName] as $individual){ if(!isset($individual['$delete'])){ $aliasField = $aliasColumnDetails->getRelationship()->getJunctionOtherRelatedField(); $validatePayload = $individual[$aliasField]; - - + if(!isset($params['fields'])){ + $params['fields'] = "*.*"; + } foreach($relationalCollectionColumns as $column){ - if(!$column->isAlias() && !$column->hasPrimaryKey() && !empty($validatePayload[$relationalCollectionPrimaryKey])){ + if(!$column->hasPrimaryKey()){ $columnName = $column->getName(); - $relationalCollectionData = $this->findByIds( - $relationalCollectionName, - $validatePayload[$relationalCollectionPrimaryKey], - $params - ); - $validatePayload[$columnName] = array_key_exists($columnName, $validatePayload) ? $validatePayload[$columnName]: (isset($relationalCollectionData['data'][$columnName]) ? ((DataTypes::isJson($column->getType()) ? (array) $relationalCollectionData['data'][$columnName] : $relationalCollectionData['data'][$columnName])) : null); + /** + * The current system has INT / VARCHAR type primary key. For INT primary key will be auto-incremented so at the create time ID will be not passed but for VARCHAR ID will be passed in the payload for creating request. + * If datatype of a primary key is VARCHAR; ID will be passed as payload for creating/ select existing / update. If the ID exists in the system then the system will consider it as an update/select existing otherwise it will be considered as a create request. + * If datatype of the primary key is INT; if ID exists in the payload then the system will consider it as an update/select existing otherwise it will be considered as a create request. + * */ + + if(!empty($validatePayload[$relationalCollectionPrimaryKey]) && $relationalCollectionPrimaryKeyObject->hasAutoIncrement() && $relationalCollectionPrimaryKeyObject->getDataType() == "INT"){ + $relationalCollectionData = $this->findByIds($relationalCollectionName,$validatePayload[$relationalCollectionPrimaryKey],$params); + $validatePayload[$columnName] = array_key_exists($columnName, $validatePayload) ? $validatePayload[$columnName]: (isset($relationalCollectionData['data'][$columnName]) ? ((DataTypes::isJson($column->getType()) ? (array) $relationalCollectionData['data'][$columnName] : $relationalCollectionData['data'][$columnName])) : null); + $params['select_existing_or_update'] = true; + }else if(!empty($validatePayload[$relationalCollectionPrimaryKey]) && !$relationalCollectionPrimaryKeyObject->hasAutoIncrement()){ + try{ + $relationalCollectionData = $this->findByIds($relationalCollectionName,$validatePayload[$relationalCollectionPrimaryKey],$params); + $validatePayload[$columnName] = array_key_exists($columnName, $validatePayload) ? $validatePayload[$columnName]: (isset($relationalCollectionData['data'][$columnName]) ? ((DataTypes::isJson($column->getType()) ? (array) $relationalCollectionData['data'][$columnName] : $relationalCollectionData['data'][$columnName])) : null); + $params['select_existing_or_update'] = true; + }catch(\Exception $e){ + continue; + } + } } } $this->validatePayload($relationalCollectionName, null, $validatePayload,$params); @@ -228,7 +242,6 @@ public function validateAliasCollection($payload, $params, $aliasColumnDetails, $parentCollectionName = $aliasColumnDetails->getRelationship()->getCollectionMany(); } if($relationalCollectionName && isset($payload[$colName])){ - $relationalCollectionPrimaryKey = SchemaService::getCollectionPrimaryKey($relationalCollectionName); $parentCollectionPrimaryKey = SchemaService::getCollectionPrimaryKey($parentCollectionName); $relationalCollectionColumns = SchemaService::getAllCollectionFields($relationalCollectionName); @@ -238,7 +251,6 @@ public function validateAliasCollection($payload, $params, $aliasColumnDetails, if(!isset($individual['$delete'])){ foreach($relationalCollectionColumns as $key => $column){ - if(!$column->isAlias() && !$column->hasPrimaryKey() && !empty($individual[$relationalCollectionPrimaryKey])){ $columnName = $column->getName(); $relationalCollectionData = $this->findByIds( @@ -249,11 +261,11 @@ public function validateAliasCollection($payload, $params, $aliasColumnDetails, $individual[$columnName] = array_key_exists($columnName, $individual) ? $individual[$columnName]: (isset($relationalCollectionData['data'][$columnName]) ? ((DataTypes::isJson($column->getType()) ? (array) $relationalCollectionData['data'][$columnName] : $relationalCollectionData['data'][$columnName])) : null); } } - // only add parent id's to items that are lacking the parent column - if (empty($individual[$foreignJoinColumn]) && !empty($recordData[$parentCollectionPrimaryKey])) { - $individual[$foreignJoinColumn] = $recordData[$parentCollectionPrimaryKey]; + if (empty($individual[$foreignJoinColumn])) { + $individual[$foreignJoinColumn] = !empty($recordData[$parentCollectionPrimaryKey]) ? $recordData[$parentCollectionPrimaryKey] : 0; } + $this->validatePayload($relationalCollectionName, null, $individual,$params); } } diff --git a/src/core/Directus/Services/UsersService.php b/src/core/Directus/Services/UsersService.php index 0ab0c399d5..84ca5cd5d7 100644 --- a/src/core/Directus/Services/UsersService.php +++ b/src/core/Directus/Services/UsersService.php @@ -4,9 +4,11 @@ use Directus\Application\Container; use Directus\Authentication\Exception\ExpiredTokenException; +use Directus\Authentication\Exception\InvalidOTPException; use Directus\Authentication\Exception\InvalidTokenException; use Directus\Authentication\Exception\UserNotFoundException; use Directus\Authentication\Provider; +use Directus\Database\Exception\InvalidQueryException; use Directus\Database\Exception\ItemNotFoundException; use Directus\Database\Schema\SchemaManager; use Directus\Database\TableGateway\DirectusUsersTableGateway; @@ -18,6 +20,7 @@ use Directus\Util\ArrayUtils; use Directus\Util\DateTimeUtils; use Directus\Util\JWTUtils; +use PragmaRX\Google2FA\Google2FA; use Zend\Db\Sql\Delete; use Zend\Db\Sql\Select; use function Directus\get_directus_setting; @@ -239,7 +242,7 @@ protected function sendInvitationTo($email) if (!$user) { /** @var Provider $auth */ $auth = $this->container->get('auth'); - $datetime = DateTimeUtils::nowInUTC(); + $datetime = DateTimeUtils::nowInTimezone(); $invitationToken = $auth->generateInvitationToken([ 'date' => $datetime->toString(), 'exp' => $datetime->inDays(30)->getTimestamp(), @@ -366,6 +369,38 @@ protected function isLastAdmin($id) return in_array($id, $usersIds) && count($usersIds) === 1; } + /** + * Checks whether the given ID has 2FA enforced, as set by their role. + * When 2FA is enforced, the column enforce_2fa is set to 1. + * Otherwise, it is either set to null or 0. + * + * @param $id + * + * @return bool + */ + public function has2FAEnforced($id) + { + try { + $result = $this->createTableGateway(SchemaManager::COLLECTION_ROLES, false)->fetchAll(function (Select $select) use ($id) { + $select->columns(['enforce_2fa']); + $select->where(['user' => $id]); + $on = sprintf('%s.role = %s.id', SchemaManager::COLLECTION_USER_ROLES, SchemaManager::COLLECTION_ROLES); + $select->join(SchemaManager::COLLECTION_USER_ROLES, $on, ['id' => 'role']); + }); + + $enforce_2fa = $result->current()['enforce_2fa']; + + if ($enforce_2fa == null || $enforce_2fa == 0) { + return false; + } else { + return true; + } + } catch (InvalidQueryException $e) { + // Column enforce_2fa doesn't exist in directus_roles + return false; + } + } + /** * Throws an exception if the user is the last admin * @@ -379,4 +414,30 @@ protected function enforceLastAdmin($id) throw new ForbiddenLastAdminException(); } } + + /** + * Activate 2FA for the given user id if the OTP is valid for the given 2FA secret + * @param $id + * @param $tfa_secret + * @param $otp + * + * @return array + * + * @throws InvalidOTPException + */ + public function activate2FA($id, $tfa_secret, $otp) + { + $this->validate( + ['tfa_secret' => $tfa_secret, 'otp' => $otp], + ['tfa_secret' => 'required|string', 'otp' => 'required|string'] + ); + + $ga = new Google2FA(); + + if (!$ga->verifyKey($tfa_secret, $otp, 2)){ + throw new InvalidOTPException(); + } + + return $this->update($id, ['2fa_secret' => $tfa_secret]); + } } diff --git a/src/core/Directus/Services/UtilsService.php b/src/core/Directus/Services/UtilsService.php index 32232cb3fe..1c052b74b8 100644 --- a/src/core/Directus/Services/UtilsService.php +++ b/src/core/Directus/Services/UtilsService.php @@ -4,6 +4,8 @@ use Directus\Hash\HashManager; use Directus\Util\StringUtils; +use League\OAuth2\Client\Provider\Google; +use PragmaRX\Google2FA\Google2FA; class UtilsService extends AbstractService { @@ -62,4 +64,11 @@ public function randomString($length, $options = []) ] ]; } + + public function generate2FASecret() + { + $ga = new Google2FA(); + $tfa_secret = $ga->generateSecretKey(); + return ['2fa_secret' => $tfa_secret]; + } } diff --git a/src/core/Directus/Util/DateTimeUtils.php b/src/core/Directus/Util/DateTimeUtils.php index 677492a2cf..dd80353684 100644 --- a/src/core/Directus/Util/DateTimeUtils.php +++ b/src/core/Directus/Util/DateTimeUtils.php @@ -3,6 +3,7 @@ namespace Directus\Util; use DateTimeZone; +use function Directus\get_project_config; class DateTimeUtils extends \DateTime { @@ -82,9 +83,8 @@ public function __construct($time = null, $timezone = null) if ($timezone) { $timezone = $this->createTimeZone($timezone); } - + parent::__construct($time, $timezone); - if ($time === null) { $this->setTimestamp(time()); } @@ -107,6 +107,12 @@ public static function nowInUTC() return static::now('UTC'); } + public static function nowInTimezone() + { + $config = get_project_config();; + return static::now($config->get('app.timezone')); + } + /** * Creates a new DateTimeUtils instance from a \DateTime instance * @@ -176,11 +182,11 @@ public static function createTimeZone($timezone) if ($timezone instanceof DateTimeZone) { return $timezone; } - + if ($timezone === null) { return new DateTimeZone(date_default_timezone_get()); } - + try { $timezone = new DateTimeZone($timezone); } catch (\Exception $e) { @@ -188,7 +194,6 @@ public static function createTimeZone($timezone) sprintf('Unknown or bad timezone (%s)', $timezone) ); } - return $timezone; } diff --git a/src/core/Directus/Validator/Exception/InvalidRequestException.php b/src/core/Directus/Validator/Exception/InvalidRequestException.php index dcb8cbbc3d..19cd0ca4ab 100644 --- a/src/core/Directus/Validator/Exception/InvalidRequestException.php +++ b/src/core/Directus/Validator/Exception/InvalidRequestException.php @@ -6,10 +6,16 @@ class InvalidRequestException extends UnprocessableEntityException { - const ERROR_CODE = 4; + protected $uploadedError = 4; - public function __construct($message = '') + public function __construct($message = '', $errorCode = "") { - parent::__construct($message, static::ERROR_CODE); + $this->uploadedError = $errorCode ?: $this->uploadedError ; + parent::__construct($message, $this->uploadedError); + } + + public function getErrorCode() + { + return $this->uploadedError; } } diff --git a/src/endpoints/Auth.php b/src/endpoints/Auth.php index 4cb95ca9d7..f0f02da14f 100644 --- a/src/endpoints/Auth.php +++ b/src/endpoints/Auth.php @@ -46,7 +46,8 @@ public function authenticate(Request $request, Response $response) $responseData = $authService->loginWithCredentials( $request->getParsedBodyParam('email'), - $request->getParsedBodyParam('password') + $request->getParsedBodyParam('password'), + $request->getParsedBodyParam('otp') ); return $this->responseWithData($request, $response, $responseData); diff --git a/src/endpoints/Settings.php b/src/endpoints/Settings.php index b2c3bc41f9..c7e6f0d56a 100644 --- a/src/endpoints/Settings.php +++ b/src/endpoints/Settings.php @@ -40,13 +40,13 @@ public function create(Request $request, Response $response) if (isset($payload[0]) && is_array($payload[0])) { return $this->batch($request, $response); } - - /** - * Get interface based input - */ - $inputData = $this->getInterfaceBasedInput($request, $payload['key']); - $service = new SettingsService($this->container); + $fieldData = $service->findAllFields( + $request->getQueryParams() + ); + $inputData = $this->getInterfaceBasedInput($request, $payload['key'], $fieldData); + + $responseData = $service->create( $inputData, $request->getQueryParams() diff --git a/src/endpoints/Users.php b/src/endpoints/Users.php index a95fa62027..66bb249dde 100644 --- a/src/endpoints/Users.php +++ b/src/endpoints/Users.php @@ -35,6 +35,9 @@ public function __invoke(Application $app) // Tracking $app->patch('/{id}/tracking/page', [$this, 'trackPage']); + + // Enable 2FA + $app->post('/{id}/activate2FA', [$this, 'activate2FA']); } /** @@ -214,4 +217,24 @@ public function acceptInvitation(Request $request, Response $response) return $this->responseWithData($request, $response, $responseData); } + + /** + * @param Request $request + * @param Response $response + * + * @return Response + */ + public function activate2FA(Request $request, Response $response) + { + $this->validateRequestPayload($request); + $service = new UsersService($this->container); + $responseData = $service->activate2FA( + $request->getAttribute('id'), + $request->getParsedBodyParam('tfa_secret'), + $request->getParsedBodyParam('otp') + ); + + return $this->responseWithData($request, $response, $responseData); + } + } diff --git a/src/endpoints/Utils.php b/src/endpoints/Utils.php index 01f04f0674..8fb59782de 100644 --- a/src/endpoints/Utils.php +++ b/src/endpoints/Utils.php @@ -18,6 +18,7 @@ public function __invoke(Application $app) $app->post('/hash', [$this, 'hash']); $app->post('/hash/match', [$this, 'matchHash']); $app->post('/random/string', [$this, 'randomString']); + $app->get('/2fa_secret', [$this, 'generate2FASecret']); } /** @@ -88,4 +89,17 @@ public function randomString(Request $request, Response $response) return $this->responseWithData($request, $response, $responseData); } + + /** Endpoint to generate a 2FA secret + * @param Request $request + * @param Response $response + * + * @return Response + */ + public function generate2FASecret(Request $request, Response $response) + { + $service = new UtilsService($this->container); + $responseData = $service->generate2FASecret(); + return $this->responseWithData($request, $response, $responseData); + } } diff --git a/src/web.php b/src/web.php index 745ca58fa2..5742c11309 100644 --- a/src/web.php +++ b/src/web.php @@ -12,8 +12,10 @@ // If the server responds "pong" it means the rewriting works // NOTE: The API requires the default project to be configured to properly works // It should work without the default project being configured -if (!file_exists($basePath . '/config/api.php')) { - return \Directus\create_default_app($basePath); +if (getenv("DIRECTUS_USE_ENV") !== "1") { + if (!file_exists($basePath . '/config/api.php')) { + return \Directus\create_default_app($basePath); + } } // Get Environment name