diff --git a/config/constants.config.php b/config/constants.config.php index 3928bc1761d..0ab61dd73b2 100644 --- a/config/constants.config.php +++ b/config/constants.config.php @@ -42,3 +42,6 @@ // Define default latest year offset from current year for date ranges defined('VUFIND_DEFAULT_LATEST_YEAR_OFFSET') || define('VUFIND_DEFAULT_LATEST_YEAR_OFFSET', 1); + +// Define default API key header field name +defined('VUFIND_API_KEY_DEFAULT_HEADER_FIELD') || define('VUFIND_API_KEY_DEFAULT_HEADER_FIELD', 'X-API-KEY'); diff --git a/config/vufind/config.ini b/config/vufind/config.ini index 7ddff8338be..333a7222f4d 100644 --- a/config/vufind/config.ini +++ b/config/vufind/config.ini @@ -2689,6 +2689,24 @@ description = "The REST API provides access to search functions and records cont ; URL pointing to a Terms of Service page (optional, default is none): ;termsOfServiceUrl = "https://something" +; These settings control usage of API keys +[API_Keys] +; Mode for using API keys. +; 'disabled' means API keys are not in use and cannot be created. Default. +; 'optional' allows users to create API keys and use them, but it is not mandatory. +; 'enforced' forces users to create and provide an API key in a header field (see +; header_field setting below). +;mode = 'optional' +; REQUIRED: Token salt to add when generating a new API key for a user. +; Minimum allowed length for salt is 10 characters. +; Example command for creating a salt in terminal: +; php -r "echo hash('sha256', random_bytes(16)) . PHP_EOL;" +;token_salt = +; The header field key that should be used to provide the API key. Default is X-API-KEY. +;header_field = 'X-API-KEY' +; How many API keys can a user have at maximum. Default is 5. +;key_limit = 5 + [Sorting] ; By default, VuFind sorts text in a locale-agnostic way; if this setting is ; turned on, the current user-selected locale will impact sort order. diff --git a/config/vufind/permissionBehavior.ini b/config/vufind/permissionBehavior.ini index c519d4aa7bc..0936b3d3068 100644 --- a/config/vufind/permissionBehavior.ini +++ b/config/vufind/permissionBehavior.ini @@ -83,6 +83,10 @@ defaultDeniedTemplateBehavior = false [access.EITModule] deniedTemplateBehavior = showTemplate:error/loginForAccess +; Configuration for developer settings +[feature.Developer] +deniedTemplateBehavior = "showTemplate:error/developer-settings-denied" + ; Example configuration for non-standard favorites permission behavior: ;[feature.Favorites] ;deniedTemplateBehavior = "showMessage:Login for Favorites" diff --git a/config/vufind/permissions.ini b/config/vufind/permissions.ini index b6dbb116dbe..d56875442fa 100644 --- a/config/vufind/permissions.ini +++ b/config/vufind/permissions.ini @@ -13,6 +13,10 @@ ; combined may vary from provider to provider (see below). ; permission - The name(s) of the permission(s) to grant. May be a single string or ; an array of strings. +; assertion - The name(s) of the assertion(s) class(es) to check for permission. +; May be a single assertion name or an array of assertion names. +; - Currently supported assertions: +; - VerifiedEmail: Asserts that the user has a verified email. ; ; Any other keys in the section should be the names of permission provider services. ; The values associated with these keys will be passed along to the services. @@ -75,6 +79,7 @@ ; ipRange[] = "1.2.3.7-1.2.5.254" ; insecureCookie = "VUFIND_CUSTOM_COOKIE_NAME" ; permission = sample.permission +; assertion[] = VerifiedEmail ; sessionKey = "VUFIND_SESSION_KEY_NAME" ; Example configuration (grants the "sample.permission" permission to users @@ -91,6 +96,7 @@ ; access.PrimoModule - Controls access to ALL Primo content ; access.StaffViewTab - Controls access to the staff view tab in record mode ; access.SummonExtendedResults - Controls visibility of protected Summon results +; feature.Developer - Controls access to developer settings (including API keys) ; feature.Favorites - Controls access to the "save favorites" feature ; See https://vufind.org/wiki/configuration:permission_options for further information. @@ -118,6 +124,13 @@ permission = access.StaffViewTab role[] = loggedin permission = feature.Favorites +; This default example configuration allows logged in users +; with verified email addresses to use developer settings. +[default.Developer] +role[] = loggedin +permission = feature.Developer +assertion[] = VerifiedEmail + ; Example for dynamic debug mode ;[default.DebugMode] ;username[] = admin diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php index cc53128a01d..6e7fea98118 100644 --- a/module/VuFind/config/module.config.php +++ b/module/VuFind/config/module.config.php @@ -170,6 +170,7 @@ 'VuFind\Controller\AjaxController' => 'VuFind\Controller\AjaxControllerFactory', 'VuFind\Controller\AlmaController' => 'VuFind\Controller\AbstractBaseFactory', 'VuFind\Controller\AlphabrowseController' => 'VuFind\Controller\AbstractBaseFactory', + 'VuFind\Controller\DeveloperSettingsController' => 'VuFind\Controller\AbstractBaseFactory', 'VuFind\Controller\AuthorController' => 'VuFind\Controller\AbstractBaseFactory', 'VuFind\Controller\AuthorityController' => 'VuFind\Controller\AbstractBaseFactory', 'VuFind\Controller\AuthorityRecordController' => 'VuFind\Controller\AbstractBaseFactory', @@ -246,6 +247,8 @@ 'alma' => 'VuFind\Controller\AlmaController', 'Alphabrowse' => 'VuFind\Controller\AlphabrowseController', 'alphabrowse' => 'VuFind\Controller\AlphabrowseController', + 'DeveloperSettings' => 'VuFind\Controller\DeveloperSettingsController', + 'developersettings' => 'VuFind\Controller\DeveloperSettingsController', 'Author' => 'VuFind\Controller\AuthorController', 'author' => 'VuFind\Controller\AuthorController', 'Authority' => 'VuFind\Controller\AuthorityController', @@ -424,6 +427,7 @@ 'League\CommonMark\MarkdownConverter' => 'VuFind\Service\MarkdownFactory', 'VuFind\Account\UserAccountService' => 'VuFind\Account\UserAccountServiceFactory', 'VuFind\AjaxHandler\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory', + 'VuFind\DeveloperSettings\DeveloperSettingsService' => 'VuFind\DeveloperSettings\DeveloperSettingsServiceFactory', 'VuFind\Auth\EmailAuthenticator' => 'VuFind\Auth\EmailAuthenticatorFactory', 'VuFind\Auth\ILSAuthenticator' => 'VuFind\Auth\ILSAuthenticatorFactory', 'VuFind\Auth\LoginTokenManager' => 'VuFind\Auth\LoginTokenManagerFactory', @@ -571,6 +575,7 @@ 'VuFindHttp\HttpService' => 'VuFind\Service\HttpServiceFactory', 'VuFindSearch\Service' => 'VuFind\Service\SearchServiceFactory', 'Laminas\Session\SessionManager' => 'VuFind\Session\ManagerFactory', + 'Lmc\Rbac\Mvc\Service\AuthorizationService' => 'VuFind\Service\AuthorizationServiceFactory', ], 'delegators' => [ 'Laminas\Mvc\I18n\Translator' => [ @@ -787,6 +792,14 @@ ], ], 'vufind_permission_provider_manager' => [ /* see VuFind\Role\PermissionProvider\PluginManager for defaults */ ], + 'assertion_manager' => [ + 'factories' => [ + 'VuFind\Role\Assertion\HasVerifiedEmailAssertion' => 'Laminas\ServiceManager\Factory\InvokableFactory', + ], + 'aliases' => [ + 'VerifiedEmail' => 'VuFind\Role\Assertion\HasVerifiedEmailAssertion', + ], + ], ], ]; @@ -889,6 +902,9 @@ 'Confirm/Confirm', 'Cover/Show', 'Cover/Unavailable', + 'DeveloperSettings/DeleteApiKey', + 'DeveloperSettings/DisplaySettings', + 'DeveloperSettings/GenerateApiKey', 'EDS/Advanced', 'EDS/Home', 'EDS/Search', diff --git a/module/VuFind/sql/migrations/mysql/11.0/012-add-api-key-table.sql b/module/VuFind/sql/migrations/mysql/11.0/012-add-api-key-table.sql new file mode 100644 index 00000000000..b4095922b82 --- /dev/null +++ b/module/VuFind/sql/migrations/mysql/11.0/012-add-api-key-table.sql @@ -0,0 +1,13 @@ +CREATE TABLE `api_key` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `title` varchar(255) NOT NULL, + `token` varchar(255) NOT NULL, + `revoked` tinyint(1) NOT NULL DEFAULT 0, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `api_key_user_id_idx` (`user_id`), + KEY `api_key_token_idx` (`token`), + CONSTRAINT `api_key_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/module/VuFind/sql/migrations/pgsql/11.0/012-add-api-key-table.sql b/module/VuFind/sql/migrations/pgsql/11.0/012-add-api-key-table.sql new file mode 100644 index 00000000000..e3ab89ed58f --- /dev/null +++ b/module/VuFind/sql/migrations/pgsql/11.0/012-add-api-key-table.sql @@ -0,0 +1,14 @@ +CREATE TABLE api_key ( + id SERIAL, + user_id int NOT NULL, + title varchar(255) NOT NULL, + token varchar(255) NOT NULL, + revoked boolean NOT NULL DEFAULT '0', + created timestamp NOT NULL default CURRENT_TIMESTAMP, + last_used timestamp NOT NULL default CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX api_key_user_id_idx ON api_key (user_id); +CREATE INDEX api_key_token_idx ON api_key (token); +ALTER TABLE api_key +ADD CONSTRAINT api_key_ibfk_1 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE; diff --git a/module/VuFind/sql/mysql.sql b/module/VuFind/sql/mysql.sql index 3f65272fcdd..e59f8da9b4c 100644 --- a/module/VuFind/sql/mysql.sql +++ b/module/VuFind/sql/mysql.sql @@ -421,6 +421,27 @@ CREATE TABLE `access_token` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `api_key` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `api_key` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `title` varchar(255) NOT NULL, + `token` varchar(255) NOT NULL, + `revoked` tinyint(1) NOT NULL DEFAULT 0, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `api_key_user_id_idx` (`user_id`), + KEY `api_key_token_idx` (`token`), + CONSTRAINT `api_key_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `login_token` -- diff --git a/module/VuFind/sql/pgsql.sql b/module/VuFind/sql/pgsql.sql index cd048de72cb..70669f36a61 100644 --- a/module/VuFind/sql/pgsql.sql +++ b/module/VuFind/sql/pgsql.sql @@ -393,6 +393,21 @@ PRIMARY KEY (id, type) ); CREATE INDEX access_token_user_id_idx ON access_token (user_id); +DROP TABLE IF EXISTS "api_key"; + +CREATE TABLE api_key ( + id SERIAL, + user_id int NOT NULL, + title varchar(255) NOT NULL, + token varchar(255) NOT NULL, + revoked boolean NOT NULL DEFAULT '0', + created timestamp NOT NULL default CURRENT_TIMESTAMP, + last_used timestamp NOT NULL default CURRENT_TIMESTAMP, + PRIMARY KEY (id) +); +CREATE INDEX api_key_user_id_idx ON api_key (user_id); +CREATE INDEX api_key_token_idx ON api_key (token); + -- -- Table structure for table `login_token` -- @@ -614,3 +629,9 @@ ADD CONSTRAINT payment_fee_ibfk_1 FOREIGN KEY (payment_id) REFERENCES "payment" ALTER TABLE audit_event ADD CONSTRAINT audit_event_ibfk_1 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE SET NULL, ADD CONSTRAINT audit_event_ibfk_2 FOREIGN KEY (payment_id) REFERENCES "payment" (id) ON DELETE CASCADE; + +--- +-- Constraints for table api_key +--- +ALTER TABLE api_key +ADD CONSTRAINT api_key_ibfk_1 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE; diff --git a/module/VuFind/src/VuFind/Controller/AbstractBase.php b/module/VuFind/src/VuFind/Controller/AbstractBase.php index ca673c9d0d3..a96d26b2701 100644 --- a/module/VuFind/src/VuFind/Controller/AbstractBase.php +++ b/module/VuFind/src/VuFind/Controller/AbstractBase.php @@ -38,6 +38,7 @@ use Laminas\View\Model\ViewModel; use VuFind\Config\Feature\EmailSettingsTrait; use VuFind\Controller\Feature\AccessPermissionInterface; +use VuFind\Controller\Feature\RequestHelperTrait; use VuFind\Db\Entity\UserEntityInterface; use VuFind\Db\Service\AuditEventServiceInterface; use VuFind\Db\Service\PluginManager as DatabaseServiceManager; @@ -82,6 +83,7 @@ class AbstractBase extends AbstractActionController implements AccessPermissionI use EmailSettingsTrait; use GetServiceTrait; use TranslatorAwareTrait; + use RequestHelperTrait; /** * Permission that must be granted to access this module (false for no diff --git a/module/VuFind/src/VuFind/Controller/DeveloperSettingsController.php b/module/VuFind/src/VuFind/Controller/DeveloperSettingsController.php new file mode 100644 index 00000000000..352b0b2dc56 --- /dev/null +++ b/module/VuFind/src/VuFind/Controller/DeveloperSettingsController.php @@ -0,0 +1,123 @@ +. + * + * @category VuFind + * @package Controller + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\Controller; + +use VuFind\DeveloperSettings\DeveloperSettingsService; +use VuFind\Exception\Forbidden; + +/** + * Controller for developer settings i.e API keys + * + * @category VuFind + * @package Controller + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +class DeveloperSettingsController extends AbstractBase +{ + /** + * Display developer settings + * + * @return mixed + */ + public function displaySettingsAction() + { + if (!$user = $this->getUser()) { + return $this->forceLogin(); + } + $developerSettingsService = $this->getService(DeveloperSettingsService::class); + if (!$developerSettingsService->apiKeysEnabled()) { + throw new Forbidden('Developer settings disabled.'); + } + $view = $this->createViewModel(); + $view->apiKeys = $developerSettingsService->getApiKeysForUser($user); + $view->createAllowed = !$developerSettingsService->apiKeysBlocked($view->apiKeys); + return $view; + } + + /** + * Generate an API key for a user. + * + * @return mixed + */ + public function generateApiKeyAction() + { + if (!$user = $this->getUser()) { + return $this->forceLogin(); + } + $developerSettingsService = $this->getService(DeveloperSettingsService::class); + if (!$developerSettingsService->apiKeysEnabled() || !$this->permission()->isAuthorized('feature.Developer')) { + throw new Forbidden('Access denied.'); + } + + $view = $this->createViewModel(); + if ($this->formWasSubmitted()) { + if ($title = $this->getParam('title', true)) { + if ($apiKey = $developerSettingsService->generateApiKeyForUser($user, $title)) { + $successMsg = $this->translate( + 'Developer::api_key_generation_success', + ['%%TOKEN%%' => $apiKey->getToken()] + ); + $this->flashMessenger()->addSuccessMessage($successMsg); + return $view; + } + } + $this->flashMessenger()->addErrorMessage('An error has occurred'); + } + + return $view; + } + + /** + * Delete an API key for a user. + * + * @return mixed + */ + public function deleteApiKeyAction() + { + if (!$user = $this->getUser()) { + return $this->forceLogin(); + } + $developerSettingsService = $this->getService(DeveloperSettingsService::class); + if (!$developerSettingsService->apiKeysEnabled() || !$this->permission()->isAuthorized('feature.Developer')) { + throw new Forbidden('Access denied.'); + } + if ($this->getParam('confirm') === '1') { + $id = $this->getParam('id'); + if ($id && $developerSettingsService->deleteApiKeyForUser($user, $id)) { + $this->flashMessenger()->addSuccessMessage('Developer::api_key_deletion_success'); + } else { + $this->flashMessenger()->addErrorMessage('An error has occurred'); + } + } + return $this->redirect()->toRoute('developersettings-displaysettings'); + } +} diff --git a/module/VuFind/src/VuFind/Controller/Feature/RequestHelperTrait.php b/module/VuFind/src/VuFind/Controller/Feature/RequestHelperTrait.php new file mode 100644 index 00000000000..e60d32bbddf --- /dev/null +++ b/module/VuFind/src/VuFind/Controller/Feature/RequestHelperTrait.php @@ -0,0 +1,85 @@ +. + * + * @category VuFind + * @package Controller_Plugins + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Page + */ + +namespace VuFind\Controller\Feature; + +use function is_callable; + +/** + * Request helper trait + * + * @category VuFind + * @package Controller_Plugins + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Page + */ +trait RequestHelperTrait +{ + /** + * Get the url parameters + * + * @param string $param A key to check the url params for. + * @param bool $prioritizePost If true, check the POST params first + * @param mixed $default Value to return if no param found. Default is null. + * + * @return mixed + */ + protected function getParam($param, $prioritizePost = true, $default = null) + { + $primary = $prioritizePost ? 'fromPost' : 'fromQuery'; + $secondary = $prioritizePost ? 'fromQuery' : 'fromPost'; + return $this->params()->$primary($param) + ?? $this->params()->$secondary($param) + ?? $default; + } + + /** + * Get all parameters from post and query as an associative array. + * + * @return array + */ + protected function getAllRequestParams(): array + { + return $this->params()->fromPost() + $this->params()->fromQuery(); + } + + /** + * Get header field value + * + * @param string $headerKey Header field key to get + * + * @return mixed + */ + protected function getHeader(string $headerKey): mixed + { + $header = $this->params()->fromHeader($headerKey); + return is_callable([$header, 'getFieldValue']) ? $header->getFieldValue() : null; + } +} diff --git a/module/VuFind/src/VuFind/Db/Entity/ApiKey.php b/module/VuFind/src/VuFind/Db/Entity/ApiKey.php new file mode 100644 index 00000000000..30789338efa --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Entity/ApiKey.php @@ -0,0 +1,281 @@ +. + * + * @category VuFind + * @package Database + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ + +namespace VuFind\Db\Entity; + +use DateTime; +use Doctrine\ORM\Mapping as ORM; +use VuFind\Db\Feature\DateTimeTrait; + +/** + * Entity model for api_key table + * + * @category VuFind + * @package Database + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ +#[ORM\Table(name: 'api_key')] +#[ORM\Index(name: 'api_key_user_id_idx', columns: ['user_id'])] +#[ORM\Index(name: 'api_key_token_idx', columns: ['token'])] +#[ORM\Entity] +class ApiKey implements ApiKeyEntityInterface +{ + use DateTimeTrait; + + /** + * Unique ID. + * + * @var int + */ + #[ORM\Id] + #[ORM\Column(name: 'id', type: 'integer', nullable: false)] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + protected int $id; + + /** + * User. + * + * @var ?UserEntityInterface + */ + #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + #[ORM\ManyToOne(targetEntity: UserEntityInterface::class)] + protected UserEntityInterface $user; + + /** + * Creation date. + * + * @var DateTime + */ + #[ORM\Column(name: 'created', type: 'datetime', nullable: false, options: ['default' => 'CURRENT_TIMESTAMP'])] + protected DateTime $created; + + /** + * Last used date. + * + * @var DateTime + */ + #[ORM\Column(name: 'last_used', type: 'datetime', nullable: false, options: ['default' => 'CURRENT_TIMESTAMP'])] + protected DateTime $lastUsed; + + /** + * Data. + * + * @var string + */ + #[ORM\Column(name: 'token', type: 'string', length: 255, nullable: false)] + protected string $token; + + /** + * Flag indicating status of the token. + * + * @var bool + */ + #[ORM\Column(name: 'revoked', type: 'boolean', nullable: false, options: ['default' => false])] + protected bool $revoked = false; + + /** + * Token title. + * + * @var string + */ + #[ORM\Column(name: 'title', type: 'string', length: 255, nullable: false)] + protected string $title = ''; + + /** + * Constructor. + */ + public function __construct() + { + // Set the default value as a DateTime object + $this->created = $this->getUnassignedDefaultDateTime(); + } + + /** + * Set API key identifier. + * + * @param string $id API Key Identifier + * + * @return static + */ + public function setId(string $id): static + { + $this->id = $id; + return $this; + } + + /** + * Get identifier (returns null for an uninitialized or non-persisted object). + * + * @return ?string + */ + public function getId(): ?string + { + return $this->id; + } + + /** + * Get title. + * + * @return string + */ + public function getTitle(): string + { + return $this->title; + } + + /** + * Set title + * + * @param string $title Title + * + * @return static + */ + public function setTitle(string $title): static + { + $this->title = $title; + return $this; + } + + /** + * Set user. + * + * @param UserEntityInterface $user User owning token + * + * @return static + */ + public function setUser(UserEntityInterface $user): static + { + $this->user = $user; + return $this; + } + + /** + * Get user. + * + * @return UserEntityInterface + */ + public function getUser(): UserEntityInterface + { + return $this->user; + } + + /** + * Get created date. + * + * @return DateTime + */ + public function getCreated(): DateTime + { + return $this->created; + } + + /** + * Set created date. + * + * @param DateTime $dateTime Created date + * + * @return static + */ + public function setCreated(DateTime $dateTime): static + { + $this->created = $dateTime; + return $this; + } + + /** + * Get last used date. + * + * @return DateTime + */ + public function getLastUsed(): DateTime + { + return $this->lastUsed; + } + + /** + * Set last used date. + * + * @param DateTime $dateTime Last used date + * + * @return static + */ + public function setLastUsed(DateTime $dateTime): static + { + $this->lastUsed = $dateTime; + return $this; + } + + /** + * Get token. + * + * @return string + */ + public function getToken(): string + { + return $this->token; + } + + /** + * Set token. + * + * @param string $token Token + * + * @return static + */ + public function setToken(string $token): static + { + $this->token = $token; + return $this; + } + + /** + * Is the API key revoked? + * + * @return bool + */ + public function isRevoked(): bool + { + return $this->revoked; + } + + /** + * Set revoked status. + * + * @param bool $revoked Revoked + * + * @return static + */ + public function setRevoked(bool $revoked): static + { + $this->revoked = $revoked; + return $this; + } +} diff --git a/module/VuFind/src/VuFind/Db/Entity/ApiKeyEntityInterface.php b/module/VuFind/src/VuFind/Db/Entity/ApiKeyEntityInterface.php new file mode 100644 index 00000000000..d6048964de9 --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Entity/ApiKeyEntityInterface.php @@ -0,0 +1,156 @@ +. + * + * @category VuFind + * @package Database + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ + +namespace VuFind\Db\Entity; + +use DateTime; + +/** + * Entity model interface for api_key table + * + * @category VuFind + * @package Database + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ +interface ApiKeyEntityInterface extends EntityInterface +{ + /** + * Set API key identifier. + * + * @param string $id API Key Identifier + * + * @return static + */ + public function setId(string $id): static; + + /** + * Get identifier (returns null for an uninitialized or non-persisted object). + * + * @return ?string + */ + public function getId(): ?string; + + /** + * Get title. + * + * @return string + */ + public function getTitle(): string; + + /** + * Set title + * + * @param string $title Title + * + * @return string + */ + public function setTitle(string $title): static; + + /** + * Set user. + * + * @param UserEntityInterface $user User owning token + * + * @return static + */ + public function setUser(UserEntityInterface $user): static; + + /** + * Get user. + * + * @return UserEntityInterface + */ + public function getUser(): UserEntityInterface; + + /** + * Get created date. + * + * @return DateTime + */ + public function getCreated(): DateTime; + + /** + * Set created date. + * + * @param DateTime $dateTime Created date + * + * @return static + */ + public function setCreated(DateTime $dateTime): static; + + /** + * Get token. + * + * @return string + */ + public function getToken(): string; + + /** + * Set token. + * + * @param string $token Token + * + * @return static + */ + public function setToken(string $token): static; + + /** + * Is the API key revoked? + * + * @return bool + */ + public function isRevoked(): bool; + + /** + * Set revoked status. + * + * @param bool $revoked Revoked + * + * @return static + */ + public function setRevoked(bool $revoked): static; + + /** + * Get last used date. + * + * @return DateTime + */ + public function getLastUsed(): DateTime; + + /** + * Set last used date. + * + * @param DateTime $dateTime Last used date + * + * @return static + */ + public function setLastUsed(DateTime $dateTime): static; +} diff --git a/module/VuFind/src/VuFind/Db/Entity/PluginManager.php b/module/VuFind/src/VuFind/Db/Entity/PluginManager.php index c701d70f5c2..803e182af95 100644 --- a/module/VuFind/src/VuFind/Db/Entity/PluginManager.php +++ b/module/VuFind/src/VuFind/Db/Entity/PluginManager.php @@ -49,6 +49,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager */ protected $aliases = [ AccessTokenEntityInterface::class => AccessToken::class, + ApiKeyEntityInterface::class => ApiKey::class, AuthHashEntityInterface::class => AuthHash::class, ChangeTrackerEntityInterface::class => ChangeTracker::class, CommentsEntityInterface::class => Comments::class, @@ -80,6 +81,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager */ protected $factories = [ AccessToken::class => InvokableFactory::class, + ApiKey::class => InvokableFactory::class, AuthHash::class => InvokableFactory::class, ChangeTracker::class => InvokableFactory::class, Comments::class => InvokableFactory::class, diff --git a/module/VuFind/src/VuFind/Db/Service/ApiKeyService.php b/module/VuFind/src/VuFind/Db/Service/ApiKeyService.php new file mode 100644 index 00000000000..ab48aee7a63 --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Service/ApiKeyService.php @@ -0,0 +1,121 @@ +. + * + * @category VuFind + * @package Database + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ + +namespace VuFind\Db\Service; + +use VuFind\Db\Entity\ApiKeyEntityInterface; +use VuFind\Db\Entity\UserEntityInterface; + +/** + * Database service for API keys. + * + * @category VuFind + * @package Database + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ +class ApiKeyService extends AbstractDbService implements ApiKeyServiceInterface +{ + /** + * Create an api_key entity object. + * + * @return ApiKeyEntityInterface + */ + public function createEntity(): ApiKeyEntityInterface + { + return $this->entityPluginManager->get(ApiKeyEntityInterface::class); + } + + /** + * Get API keys for a user. + * + * @param UserEntityInterface $user User + * + * @return ApiKeyEntityInterface[] + */ + public function getApiKeysForUser(UserEntityInterface $user): array + { + $dql = 'SELECT ak ' + . 'FROM ' . ApiKeyEntityInterface::class . ' ak ' + . 'WHERE ak.user = :user'; + $query = $this->entityManager->createQuery($dql); + $query->setParameters(compact('user')); + return $query->getResult(); + } + + /** + * Get an API key with user and id. + * + * @param UserEntityInterface $user User + * @param int $id API key id + * + * @return ?ApiKeyEntityInterface + */ + public function getByUserAndId( + UserEntityInterface $user, + int $id + ): ?ApiKeyEntityInterface { + $dql = 'SELECT ak ' + . 'FROM ' . ApiKeyEntityInterface::class . ' ak ' + . 'WHERE ak.id = :id AND ak.user = :user'; + $query = $this->entityManager->createQuery($dql); + $query->setParameters(compact('user', 'id')); + return $query->getOneOrNullResult(); + } + + /** + * Retrieve an API key from the database based on token. + * + * @param string $token API key token. + * + * @return ?ApiKeyEntityInterface + */ + public function getByToken(string $token): ?ApiKeyEntityInterface + { + $dql = 'SELECT ak ' + . 'FROM ' . ApiKeyEntityInterface::class . ' ak ' + . 'WHERE ak.token = :token'; + $query = $this->entityManager->createQuery($dql); + $query->setParameters(['token' => $token]); + return $query->getOneOrNullResult(); + } + + /** + * Delete API key + * + * @param ApiKeyEntityInterface $apiKey API key entity + * + * @return void + */ + public function deleteApiKey(ApiKeyEntityInterface $apiKey): void + { + $this->deleteEntity($apiKey); + } +} diff --git a/module/VuFind/src/VuFind/Db/Service/ApiKeyServiceInterface.php b/module/VuFind/src/VuFind/Db/Service/ApiKeyServiceInterface.php new file mode 100644 index 00000000000..7dd6ce7c419 --- /dev/null +++ b/module/VuFind/src/VuFind/Db/Service/ApiKeyServiceInterface.php @@ -0,0 +1,89 @@ +. + * + * @category VuFind + * @package Database + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ + +namespace VuFind\Db\Service; + +use VuFind\Db\Entity\ApiKeyEntityInterface; +use VuFind\Db\Entity\UserEntityInterface; + +/** + * Database service interface for API keys. + * + * @category VuFind + * @package Database + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ +interface ApiKeyServiceInterface extends DbServiceInterface +{ + /** + * Create an api_key entity object. + * + * @return ApiKeyEntityInterface + */ + public function createEntity(): ApiKeyEntityInterface; + + /** + * Get API keys for a user. + * + * @param UserEntityInterface $user User + * + * @return ApiKeyEntityInterface[] + */ + public function getApiKeysForUser(UserEntityInterface $user): array; + + /** + * Get an API key with user and id. + * + * @param UserEntityInterface $user User + * @param int $id API key id + * + * @return ?ApiKeyEntityInterface + */ + public function getByUserAndId(UserEntityInterface $user, int $id): ?ApiKeyEntityInterface; + + /** + * Retrieve an API key from the database based on token. + * + * @param string $token API key token. + * + * @return ?ApiKeyEntityInterface + */ + public function getByToken(string $token): ?ApiKeyEntityInterface; + + /** + * Delete API key + * + * @param ApiKeyEntityInterface $apiKey API key entity + * + * @return void + */ + public function deleteApiKey(ApiKeyEntityInterface $apiKey): void; +} diff --git a/module/VuFind/src/VuFind/Db/Service/PluginManager.php b/module/VuFind/src/VuFind/Db/Service/PluginManager.php index 1d3fe9ee1e2..b7051d0124f 100644 --- a/module/VuFind/src/VuFind/Db/Service/PluginManager.php +++ b/module/VuFind/src/VuFind/Db/Service/PluginManager.php @@ -49,6 +49,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager */ protected $aliases = [ AccessTokenServiceInterface::class => AccessTokenService::class, + ApiKeyServiceInterface::class => ApiKeyService::class, AuthHashServiceInterface::class => AuthHashService::class, ChangeTrackerServiceInterface::class => ChangeTrackerService::class, CommentsServiceInterface::class => CommentsService::class, @@ -81,6 +82,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager */ protected $factories = [ AccessTokenService::class => AbstractDbServiceFactory::class, + ApiKeyService::class => AbstractDbServiceFactory::class, AuthHashService::class => AbstractDbServiceFactory::class, ChangeTrackerService::class => AbstractDbServiceFactory::class, CommentsService::class => AbstractDbServiceFactory::class, diff --git a/module/VuFind/src/VuFind/DeveloperSettings/DeveloperSettingsService.php b/module/VuFind/src/VuFind/DeveloperSettings/DeveloperSettingsService.php new file mode 100644 index 00000000000..5a1791b33a0 --- /dev/null +++ b/module/VuFind/src/VuFind/DeveloperSettings/DeveloperSettingsService.php @@ -0,0 +1,239 @@ +. + * + * @category VuFind + * @package Developer_Settings + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ + +namespace VuFind\DeveloperSettings; + +use DateTime; +use VuFind\Db\Entity\ApiKeyEntityInterface; +use VuFind\Db\Entity\UserEntityInterface; +use VuFind\Db\Service\ApiKeyServiceInterface; + +use function count; +use function strlen; + +/** + * Service for managing API keys + * + * @category VuFind + * @package Developer_Settings + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ +class DeveloperSettingsService +{ + /** + * Limit for how many API keys can a user have. Default is 10. + * + * @var int + */ + protected int $keyLimitPerUser; + + /** + * Update interval to update last_used values in minutes. + * + * @var int + */ + protected int $updateInterval = 60; + + /** + * Constructor. + * + * @param ApiKeyServiceInterface $apiKeyService API key database service + * @param array $apiKeySettings Section API_Keys from main configuration. + */ + public function __construct( + protected ApiKeyServiceInterface $apiKeyService, + protected array $apiKeySettings + ) { + $this->keyLimitPerUser = $apiKeySettings['key_limit'] ?? 5; + } + + /** + * Generate a new api key token + * + * @param UserEntityInterface $user User to create salt for + * + * @return string + */ + protected function createNewToken(UserEntityInterface $user): string + { + $salt = $this->apiKeySettings['token_salt'] ?? null; + if (!$salt || strlen($salt) < 10) { + throw new \Exception('DeveloperSettingsService: Invalid token_salt provided'); + } + $valuesForToken = [ + $user->getEmail(), + $user->getFirstname(), + $user->getLastname(), + time(), + $salt, + ]; + return hash('sha256', implode('|', $valuesForToken)); + } + + /** + * Get current API key mode as a developer setting status enum. + * + * @return DeveloperSettingsStatus + */ + public function getApiKeyMode(): DeveloperSettingsStatus + { + return DeveloperSettingsStatus::fromSetting($this->apiKeySettings['mode'] ?? ''); + } + + /** + * Retrieve API keys for user. + * + * @param UserEntityInterface $user User + * + * @return ApiKeyEntityInterface[] + */ + public function getApiKeysForUser(UserEntityInterface $user): array + { + return $this->apiKeyService->getApiKeysForUser($user); + } + + /** + * Generate an API key for a user. + * + * @param UserEntityInterface $user User + * @param string $title Title for the API key + * + * @return ApiKeyEntityInterface|false API key entity on success, false on failure. + */ + public function generateApiKeyForUser(UserEntityInterface $user, string $title): ApiKeyEntityInterface|false + { + $tokens = $this->apiKeyService->getApiKeysForUser($user); + if ($this->apiKeysBlocked($tokens)) { + return false; + } + // Generate unique id from date and users id. + $newKey = $this->apiKeyService->createEntity(); + $date = new DateTime(); + $newKey->setToken($this->createNewToken($user)) + ->setUser($user) + ->setCreated($date) + ->setLastUsed($date) + ->setTitle($title); + $this->apiKeyService->persistEntity($newKey); + return $newKey; + } + + /** + * Set the last used value to the API key. By default this will be only updated once every hour. + * + * @param ApiKeyEntityInterface $apiKey API key + * + * @return void + */ + protected function updateLastUsed(ApiKeyEntityInterface $apiKey): void + { + if (time() - $apiKey->getLastUsed()->getTimestamp() >= $this->updateInterval * 60) { + $apiKey->setLastUsed(new \DateTime()); + $this->apiKeyService->persistEntity($apiKey); + } + } + + /** + * Is the user blocked from generating new API keys? + * + * @param ApiKeyEntityInterface[] $keys Users keys + * + * @return bool + */ + public function apiKeysBlocked(array $keys): bool + { + foreach ($keys as $key) { + if ($key->isRevoked()) { + return true; + } + } + return count($keys) >= $this->keyLimitPerUser; + } + + /** + * Delete an API key for a user + * + * @param UserEntityInterface $user User + * @param int $id API key id + * + * @return bool + */ + public function deleteApiKeyForUser(UserEntityInterface $user, int $id): bool + { + $key = $this->apiKeyService->getByUserAndId( + $user, + $id + ); + if (false === $key?->isRevoked()) { + $this->apiKeyService->deleteApiKey($key); + return true; + } + return false; + } + + /** + * Check if API keys are enabled. + * + * @return bool + */ + public function apiKeysEnabled(): bool + { + return DeveloperSettingsStatus::settingEnabled($this->apiKeySettings['mode'] ?? ''); + } + + /** + * Get API key with provided token and check if the API key is allowed. + * API key is not allowed if it has been marked as revoked. + * + * Disabled mode returns always true for any token. + * Optional mode returns true for null token or for tokens which are for allowed API keys. + * Enforced mode returns true only for tokens which are for allowed API keys. + * + * @param ?string $token Token to search for API key + * + * @return bool + */ + public function isApiKeyAllowed(?string $token): bool + { + if (!$this->apiKeysEnabled()) { + return true; + } + if ($apiKey = $this->apiKeyService->getByToken((string)$token)) { + $this->updateLastUsed($apiKey); + return !$apiKey->isRevoked(); + } + // The token counts as valid if user did not provide one and mode is optional. + if ($this->apiKeySettings['mode'] === DeveloperSettingsStatus::OPTIONAL->value) { + return null === $token; + } + return false; + } +} diff --git a/module/VuFind/src/VuFind/DeveloperSettings/DeveloperSettingsServiceFactory.php b/module/VuFind/src/VuFind/DeveloperSettings/DeveloperSettingsServiceFactory.php new file mode 100644 index 00000000000..e38a040213d --- /dev/null +++ b/module/VuFind/src/VuFind/DeveloperSettings/DeveloperSettingsServiceFactory.php @@ -0,0 +1,80 @@ +. + * + * @category VuFind + * @package Developer_Settings + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ + +namespace VuFind\DeveloperSettings; + +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\ServiceManager\Factory\FactoryInterface; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; +use VuFind\Config\ConfigManagerInterface; +use VuFind\Db\Service\ApiKeyService; +use VuFind\Db\Service\PluginManager; + +/** + * Developer settings service factory + * + * @category VuFind + * @package Developer_Settings + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:database_gateways Wiki + */ +class DeveloperSettingsServiceFactory implements FactoryInterface +{ + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException&\Throwable if any other error occurs + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + ?array $options = null + ) { + if (!empty($options)) { + throw new \Exception('Unexpected options sent to factory!'); + } + + return new $requestedName( + $container->get(PluginManager::class)->get(ApiKeyService::class), + $container->get(ConfigManagerInterface::class)->getConfigArray('config')['API_Keys'] ?? [] + ); + } +} diff --git a/module/VuFind/src/VuFind/DeveloperSettings/DeveloperSettingsStatus.php b/module/VuFind/src/VuFind/DeveloperSettings/DeveloperSettingsStatus.php new file mode 100644 index 00000000000..d54dc20dd6b --- /dev/null +++ b/module/VuFind/src/VuFind/DeveloperSettings/DeveloperSettingsStatus.php @@ -0,0 +1,95 @@ +. + * + * @category VuFind + * @package Developer_Settings + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFind\DeveloperSettings; + +use function in_array; + +/** + * Developer settings status enum + * + * @category VuFind + * @package Developer_Settings + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +enum DeveloperSettingsStatus: string +{ + case DISABLED = 'disabled'; + case OPTIONAL = 'optional'; + case ENFORCED = 'enforced'; + + /** + * Helper method to get from setting value or disabled if not found. + * + * @param string $setting Setting value obtained from configuration file. + * + * @return static + */ + public static function fromSetting(string $setting): static + { + if ($mode = self::tryFrom($setting)) { + return $mode; + } + return self::from('disabled'); + } + + /** + * Helper method to get proper unauthorized error message using DeveloperSettingsStatus. + * + * @return string + */ + public function getUnauthorizedMessage(): string + { + return match ($this->value) { + self::OPTIONAL->value => 'API key invalid', + self::ENFORCED->value => 'API key missing or invalid', + default => '' + }; + } + + /** + * Helper method to check if given setting value from config is considered being enabled. + * + * @param string $setting Setting value usually obtained from a configuration file. + * + * @return bool + */ + public static function settingEnabled(string $setting): bool + { + return in_array( + self::tryFrom($setting), + [ + self::OPTIONAL, + self::ENFORCED, + ] + ); + } +} diff --git a/module/VuFind/src/VuFind/Role/Assertion/HasVerifiedEmailAssertion.php b/module/VuFind/src/VuFind/Role/Assertion/HasVerifiedEmailAssertion.php new file mode 100644 index 00000000000..b5339f85203 --- /dev/null +++ b/module/VuFind/src/VuFind/Role/Assertion/HasVerifiedEmailAssertion.php @@ -0,0 +1,66 @@ +. + * + * @category VuFind + * @package Authorization + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org/wiki/ Wiki + */ + +namespace VuFind\Role\Assertion; + +use Lmc\Rbac\Assertion\AssertionInterface; +use Lmc\Rbac\Identity\IdentityInterface; +use VuFind\Db\Entity\UserEntityInterface; + +/** + * Asserts that user has a verified email + * + * @category VuFind + * @package Authorization + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org/wiki/ Wiki + */ +class HasVerifiedEmailAssertion implements AssertionInterface +{ + /** + * Check if user has verified email to display developer settings + * + * @param string $permission Permission + * @param ?IdentityInterface $identity Identity to check + * @param mixed $context Permission context + * + * @return bool + */ + public function assert( + string $permission, + ?IdentityInterface $identity = null, + mixed $context = null + ): bool { + if ($identity instanceof UserEntityInterface) { + return (bool)$identity->getEmailVerified(); + } + return false; + } +} diff --git a/module/VuFind/src/VuFind/Role/DynamicRoleProvider.php b/module/VuFind/src/VuFind/Role/DynamicRoleProvider.php index 00722dd44fc..ff223a8a018 100644 --- a/module/VuFind/src/VuFind/Role/DynamicRoleProvider.php +++ b/module/VuFind/src/VuFind/Role/DynamicRoleProvider.php @@ -160,10 +160,11 @@ protected function getRolesForSettings($settings) $mode = 'ALL'; } - // Extract permission setting: + // Extract permission setting and ignore assertion setting (it's processed in PermissionManagerFactory): $permissions = isset($settings['permission']) ? (array)$settings['permission'] : []; unset($settings['permission']); + unset($settings['assertion']); // Process everything: $roles = null; diff --git a/module/VuFind/src/VuFind/Service/AuthorizationServiceFactory.php b/module/VuFind/src/VuFind/Service/AuthorizationServiceFactory.php new file mode 100644 index 00000000000..8f9f0d70c28 --- /dev/null +++ b/module/VuFind/src/VuFind/Service/AuthorizationServiceFactory.php @@ -0,0 +1,89 @@ +. + * + * @category VuFind + * @package Service + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFind\Service; + +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Lmc\Rbac\Assertion\AssertionPluginManager; +use Lmc\Rbac\Mvc\Service\AuthorizationService; +use Lmc\Rbac\Mvc\Service\AuthorizationServiceFactory as LmcRbacAuthorizationServiceFactory; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; + +/** + * Authorization service factory + * + * @category VuFind + * @package Service + * @author Demian Katz + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class AuthorizationServiceFactory extends LmcRbacAuthorizationServiceFactory +{ + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException&\Throwable if any other error occurs + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + ?array $options = null + ): AuthorizationService { + if (!empty($options)) { + throw new \Exception('Unexpected options passed to factory.'); + } + $authorizationService = parent::__invoke($container, $requestedName, $options); + $permissions = $container->get(\VuFind\Config\ConfigManagerInterface::class)->getConfigArray('permissions'); + $assertionPluginManager = $container->get(AssertionPluginManager::class); + foreach ($permissions as $key => $settings) { + $sectionPermissions = (array)($settings['permission'] ?? []); + $assertions = (array)($settings['assertion'] ?? []); + if ($sectionPermissions && $assertions) { + foreach ($sectionPermissions as $permission) { + foreach ($assertions as $assertion) { + $authorizationService->setAssertion($permission, $assertionPluginManager->get($assertion)); + } + } + } + } + return $authorizationService; + } +} diff --git a/module/VuFind/src/VuFind/View/Helper/Root/DeveloperSettings.php b/module/VuFind/src/VuFind/View/Helper/Root/DeveloperSettings.php new file mode 100644 index 00000000000..fdf1aa5e8b7 --- /dev/null +++ b/module/VuFind/src/VuFind/View/Helper/Root/DeveloperSettings.php @@ -0,0 +1,64 @@ +. + * + * @category VuFind + * @package View_Helpers + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org/wiki/ Wiki + */ + +namespace VuFind\View\Helper\Root; + +use Laminas\View\Helper\AbstractHelper; +use VuFind\DeveloperSettings\DeveloperSettingsService; + +/** + * Developer settings helper + * + * @category VuFind + * @package View_Helpers + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org/wiki/ Wiki + */ +class DeveloperSettings extends AbstractHelper +{ + /** + * Constructor + * + * @param DeveloperSettingsService $developerSettingsService Developer settings service + */ + public function __construct(protected DeveloperSettingsService $developerSettingsService) + { + } + + /** + * Are developer settings enabled? This includes in example: API keys + * + * @return bool + */ + public function isEnabled(): bool + { + return $this->developerSettingsService->apiKeysEnabled(); + } +} diff --git a/module/VuFind/src/VuFind/View/Helper/Root/DeveloperSettingsFactory.php b/module/VuFind/src/VuFind/View/Helper/Root/DeveloperSettingsFactory.php new file mode 100644 index 00000000000..4a7e022026a --- /dev/null +++ b/module/VuFind/src/VuFind/View/Helper/Root/DeveloperSettingsFactory.php @@ -0,0 +1,72 @@ +. + * + * @category VuFind + * @package View_Helpers + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org/wiki/ Wiki + */ + +namespace VuFind\View\Helper\Root; + +use Laminas\ServiceManager\Factory\FactoryInterface; +use Psr\Container\ContainerInterface; + +/** + * Developer settings helper factory + * + * @category VuFind + * @package View_Helpers + * @author Juha Luoma + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org/wiki/ Wiki + */ +class DeveloperSettingsFactory implements FactoryInterface +{ + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException&\Throwable if any other error occurs + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + ?array $options = null + ) { + if (!empty($options)) { + throw new \Exception('Unexpected options passed to factory.'); + } + return new $requestedName( + $container->get(\VuFind\DeveloperSettings\DeveloperSettingsService::class) + ); + } +} diff --git a/module/VuFind/src/VuFindTest/Feature/LiveDatabaseTrait.php b/module/VuFind/src/VuFindTest/Feature/LiveDatabaseTrait.php index 6acdb01de7a..963baa96161 100644 --- a/module/VuFind/src/VuFindTest/Feature/LiveDatabaseTrait.php +++ b/module/VuFind/src/VuFindTest/Feature/LiveDatabaseTrait.php @@ -34,12 +34,17 @@ use Throwable; use VuFind\Account\UserAccountService; use VuFind\Db\PersistenceManager; +use VuFind\Db\Service\AbstractDbServiceFactory; +use VuFind\Db\Service\ApiKeyService; +use VuFind\Db\Service\ApiKeyServiceInterface; use VuFind\Db\Service\DbServiceInterface; use VuFind\Db\Service\PluginManager as ServiceManager; use VuFind\Db\Service\ResourceTagsServiceInterface; use VuFind\Db\Service\TagServiceInterface; use VuFind\Db\Service\UserListServiceInterface; use VuFind\Db\Service\UserService; +use VuFind\DeveloperSettings\DeveloperSettingsService; +use VuFind\DeveloperSettings\DeveloperSettingsServiceFactory; use VuFind\Favorites\FavoritesService; use VuFind\Favorites\FavoritesServiceFactory; use VuFind\Record\ResourcePopulator; @@ -219,6 +224,14 @@ public function getLiveDatabaseContainer(): MockContainer $container->get(ResourcePopulator::class) ) ); + $dbServiceFactory = new AbstractDbServiceFactory(); + $apiKeyService = $dbServiceFactory($container, ApiKeyService::class); + $container->set(ApiKeyServiceInterface::class, $apiKeyService); + + $developerSettingsServiceFactory = new DeveloperSettingsServiceFactory(); + $developerSettingsService = $developerSettingsServiceFactory($container, DeveloperSettingsService::class); + $container->set(DeveloperSettingsService::class, $developerSettingsService); + $favoritesFactory = new FavoritesServiceFactory(); $favoritesService = $favoritesFactory($container, FavoritesService::class); $container->set(FavoritesService::class, $favoritesService); diff --git a/module/VuFind/src/VuFindTest/Feature/UserCreationTrait.php b/module/VuFind/src/VuFindTest/Feature/UserCreationTrait.php index e76fd7a70a9..eeabe56e42e 100644 --- a/module/VuFind/src/VuFindTest/Feature/UserCreationTrait.php +++ b/module/VuFind/src/VuFindTest/Feature/UserCreationTrait.php @@ -162,4 +162,22 @@ protected function submitLoginForm(Element $page, $inModal = true, $prefix = '') $button = $this->findCss($page, $prefix . 'input.btn.btn-primary'); $button->click(); } + + /** + * Function to press the login button and create a default user + * + * @param Element $page Page element. + * @param array $overrides Optional overrides for form values. + * + * @return void + */ + protected function createAndLoginUser(Element $page, array $overrides = []): void + { + $this->clickCss($page, '#loginOptions a'); + $this->clickCss($page, '.modal-body .createAccountLink'); + $this->fillInAccountForm($page, $overrides); + + $this->clickCss($page, '.modal-body .btn.btn-primary'); + $this->waitForPageLoad($page); + } } diff --git a/module/VuFind/src/VuFindTest/Integration/Session.php b/module/VuFind/src/VuFindTest/Integration/Session.php index 3534b6e7bc4..2fcdcba5d5e 100644 --- a/module/VuFind/src/VuFindTest/Integration/Session.php +++ b/module/VuFind/src/VuFindTest/Integration/Session.php @@ -61,6 +61,13 @@ class Session extends \Behat\Mink\Session */ protected $disableWhoops = false; + /** + * API key token to request header + * + * @var ?string + */ + protected ?string $apiKeyToken = null; + /** * Set remote code coverage configuration * @@ -89,6 +96,19 @@ public function setWhoopsDisabled(bool $disable): void $this->disableWhoops = $disable; } + /** + * Set API key token to request header + * + * @param string $token API key token + * + * @return static + */ + public function setApiKeyToken(string $token): static + { + $this->apiKeyToken = $token; + return $this; + } + /** * Visit specified URL and automatically start session if not already running. * @@ -114,6 +134,9 @@ public function visit($url) if ($this->disableWhoops) { $this->setRequestHeader('X-VuFind-Disable-Whoops', '1'); } + if ($this->apiKeyToken) { + $this->setRequestHeader(VUFIND_API_KEY_DEFAULT_HEADER_FIELD, $this->apiKeyToken); + } parent::visit($url); } diff --git a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/ApiTest.php b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/ApiTest.php index 1dd5a449a49..b7b5662a96a 100644 --- a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/ApiTest.php +++ b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/ApiTest.php @@ -30,28 +30,123 @@ namespace VuFindTest\Mink; use Behat\Mink\Element\Element; +use VuFind\Db\Entity\ApiKeyEntityInterface; +use VuFind\Db\Service\ApiKeyServiceInterface; +use VuFind\Db\Service\UserServiceInterface; +use VuFind\DeveloperSettings\DeveloperSettingsService; +use VuFind\DeveloperSettings\DeveloperSettingsStatus; + +use function strlen; /** * Mink test class for the VuFind APIs. * + * Class must be final due to use of "new static()" by LiveDatabaseTrait. + * * @category VuFind * @package Tests * @author Demian Katz * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License * @link https://vufind.org Main Page */ -class ApiTest extends \VuFindTest\Integration\MinkTestCase +final class ApiTest extends \VuFindTest\Integration\MinkTestCase { + use \VuFindTest\Feature\LiveDatabaseTrait; + use \VuFindTest\Feature\LiveDetectionTrait; + use \VuFindTest\Feature\UserCreationTrait; + use \VuFindTest\Feature\DemoDriverTestTrait; + use \VuFindTest\Feature\EmailTrait; + + /** + * Standard setup method. + * + * @return void + */ + public static function setUpBeforeClass(): void + { + static::failIfDataExists(); + } + + /** + * Helper function to create a new API key entity + * + * @param string $title API key title + * + * @return ApiKeyEntityInterface + */ + protected function getApiKey(string $title = 'test_title'): ApiKeyEntityInterface + { + $userService = $this->getLiveDbServiceManager()->get(UserServiceInterface::class); + $user = $userService->getUserByUsername('username1'); + $developerSettingsService = $this->getLiveDatabaseContainer()->get(DeveloperSettingsService::class); + $apiKey = $developerSettingsService->generateApiKeyForUser($user, $title); + return $apiKey; + } + + /** + * Helper function to get a revoked API key entity + * + * @return ApiKeyEntityInterface + */ + protected function getRevokedApiKey(): ApiKeyEntityInterface + { + $apiKey = $this->getApiKey('fail_title'); + $apiKey->setRevoked(true); + $apiKeyService = $this->getLiveDbServiceManager()->get(ApiKeyServiceInterface::class); + $apiKeyService->persistEntity($apiKey); + return $apiKey; + } + + /** + * Helper function to set correct API key configs + * + * @param string $mode API key mode + * + * @return void + */ + protected function setApiKeyConfigs(string $mode = 'disabled'): void + { + $this->changeConfigs( + [ + 'config' => [ + 'API_Keys' => [ + 'mode' => $mode, + 'token_salt' => 'test_token_salt', + 'key_limit' => 10, + ], + ], + 'permissions' => [ + 'default.Developer' => [ + 'permission' => 'feature.Developer', + 'role' => 'loggedin', + ], + 'enable-record-api' => [ + 'permission' => 'access.api.Record', + 'require' => 'ANY', + 'role' => 'guest', + ], + ], + ], + [ + 'permissions', + ] + ); + } + /** * Make a record retrieval API call and return the resulting page object. * - * @param string $id Record ID to retrieve. + * @param string $id Record ID to retrieve. + * @param ?string $apiKeyToken API key token. * * @return Element */ - protected function makeRecordApiCall($id = 'testbug2'): Element + protected function makeRecordApiCall($id = 'testbug2', ?string $apiKeyToken = null): Element { $session = $this->getMinkSession(); + if ($apiKeyToken) { + $session->setApiKeyToken($apiKeyToken); + } $session->visit($this->getVuFindUrl() . '/api'); $page = $session->getPage(); $this->clickCss($page, '#operations-Record-get_record button'); @@ -101,4 +196,161 @@ public function testEnabledRecordApi(): void $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') ); } + + /** + * Test generating API keys + * + * @return void + */ + public function testApiKeys(): void + { + $this->setApiKeyConfigs(DeveloperSettingsStatus::OPTIONAL->value); + $session = $this->getMinkSession(); + $session->visit($this->getVuFindUrl()); + $page = $session->getPage(); + $this->createAndLoginUser($page); + + // Go to profile page: + $session->visit($this->getVuFindUrl('/MyResearch/Profile')); + $this->waitForPageLoad($page); + + // Now click the developer settings button: + $this->findAndAssertLink($page, 'Developer settings')->click(); + $this->waitForPageLoad($page); + + // Now click the Generate new key button: + $this->findAndAssertLink($page, 'Generate new key')->click(); + + $this->findCssAndSetValue($page, '#api-key-title', 'test title'); + $this->clickCss($page, '.btn.btn-primary[name="submitButton"]'); + $text = $this->findCssAndGetText($page, '.alert-success'); + + $this->assertStringStartsWith( + 'API key was generated successfully. Key will be displayed only once, so save it now:', + $text + ); + $testToken = trim(substr($text, strpos($text, ':') + 1)); + $this->assertTrue(strlen($testToken) > 0); + + $this->clickCss($page, '.btn-default[data-bs-dismiss="modal"]'); + + $this->waitForPageLoad($page); + $this->assertEquals( + 'test title', + $this->findCssAndGetText($page, '.table.table-striped th', index: 0) + ); + } + + /** + * Test API keys set to disabled. + * + * @return void + */ + #[\VuFindTest\Attribute\HtmlValidation(false)] + public function testApiKeysDisabled(): void + { + $this->setApiKeyConfigs(); + + $page = $this->makeRecordApiCall(); + $this->assertEquals( + '200', + $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') + ); + + $page = $this->makeRecordApiCall(apiKeyToken: 'failing_token_123'); + $this->assertEquals( + '200', + $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') + ); + } + + /** + * Test API keys set to Optional. + * + * @return void + */ + #[\VuFindTest\Attribute\HtmlValidation(false)] + public function testApiKeysOptional(): void + { + $this->setApiKeyConfigs(DeveloperSettingsStatus::OPTIONAL->value); + $apiKey = $this->getApiKey(); + + $page = $this->makeRecordApiCall(); + $this->assertEquals( + '200', + $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') + ); + + $page = $this->makeRecordApiCall(apiKeyToken: $apiKey->getToken()); + $this->assertEquals( + '200', + $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') + ); + $page = $this->makeRecordApiCall(apiKeyToken: 'failing_token'); + $this->assertEquals( + '401', + $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') + ); + $revokedKey = $this->getRevokedApiKey(); + $page = $this->makeRecordApiCall(apiKeyToken: $revokedKey->getToken()); + $this->assertEquals( + '401', + $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') + ); + + // Delete any created keys after tests are done + $apiKeyService = $this->getLiveDatabaseContainer()->get(ApiKeyServiceInterface::class); + $apiKeyService->deleteEntity($apiKey); + $apiKeyService->deleteEntity($revokedKey); + } + + /** + * Test API keys set to enforced. + * + * @return void + */ + #[\VuFindTest\Attribute\HtmlValidation(false)] + public function testApiKeysEnforced(): void + { + $this->setApiKeyConfigs(DeveloperSettingsStatus::ENFORCED->value); + $apiKey = $this->getApiKey(); + + $page = $this->makeRecordApiCall(); + $this->assertEquals( + '401', + $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') + ); + + $page = $this->makeRecordApiCall(apiKeyToken: $apiKey->getToken()); + $this->assertEquals( + '200', + $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') + ); + $page = $this->makeRecordApiCall(apiKeyToken: 'failing_token'); + $this->assertEquals( + '401', + $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') + ); + $revokedKey = $this->getRevokedApiKey(); + $page = $this->makeRecordApiCall(apiKeyToken: $revokedKey->getToken()); + $this->assertEquals( + '401', + $this->findCssAndGetText($page, '.live-responses-table .response td.response-col_status') + ); + + // Delete any created keys after tests are done + $apiKeyService = $this->getLiveDatabaseContainer()->get(ApiKeyServiceInterface::class); + $apiKeyService->deleteEntity($apiKey); + $apiKeyService->deleteEntity($revokedKey); + } + + /** + * Standard teardown method. + * + * @return void + */ + public static function tearDownAfterClass(): void + { + static::removeUsers(['username1']); + } } diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/DeveloperSettings/DeveloperSettingsServiceTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/DeveloperSettings/DeveloperSettingsServiceTest.php new file mode 100644 index 00000000000..bdbc62bf92f --- /dev/null +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/DeveloperSettings/DeveloperSettingsServiceTest.php @@ -0,0 +1,392 @@ +. + * + * @category VuFind + * @package Tests + * @author Demian Katz + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ + +namespace VuFindTest\DeveloperSettings; + +use DateTime; +use Generator; +use PHPUnit\Framework\MockObject\MockObject; +use VuFind\Db\Entity\ApiKey; +use VuFind\Db\Entity\ApiKeyEntityInterface; +use VuFind\Db\Entity\UserEntityInterface; +use VuFind\Db\Service\ApiKeyServiceInterface; +use VuFind\DeveloperSettings\DeveloperSettingsService; +use VuFind\DeveloperSettings\DeveloperSettingsStatus; + +use function is_bool; + +/** + * DeveloperSettingsService Test Class + * + * @category VuFind + * @package Tests + * @author Demian Katz + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class DeveloperSettingsServiceTest extends \PHPUnit\Framework\TestCase +{ + /** + * Get a FavoritesService object. + * + * @param array $config Configuration file presenting the API_Keys section + * @param ?ApiKeyServiceInterface $apiKeyService ApiKeyService mocked with methods + * + * @return DeveloperSettingsService + */ + protected function getService( + array $config, + ?ApiKeyServiceInterface $apiKeyService = null + ): DeveloperSettingsService { + return new DeveloperSettingsService( + $apiKeyService ?? $this->createMock(ApiKeyServiceInterface::class), + $config + ); + } + + /** + * Create a mock of a class with methods. + * + * An example of how to use the method to create a mocked user entity with three methods: + * + * ``` + * $mockUser = $this->createMockWithMethods( + * UserEntityInterface::class, + * [ + * 'getId' => 1, + * 'getFirstname' => 'Test', + * 'getLastname' => 'Tester', + * ] + * ); + * ``` + * + * @param class-string $name Class name. + * @param array $methodsAndReturns Methods and returns for the mock as an associative array. + * + * @template T + * @return T + */ + protected function createMockWithMethods(string $name, array $methodsAndReturns = []): MockObject + { + $mockObject = $this->createMock($name); + foreach ($methodsAndReturns as $method => $return) { + $mockObject->expects($this->any())->method($method)->willReturn($return); + } + return $mockObject; + } + + /** + * Get test generate new key data + * + * @return Generator + */ + public static function getTestGenerateApiKeyForUserData(): Generator + { + $user = [ + 'getEmailVerified' => new DateTime('1990-1-1'), + 'getFirstname' => 'Test', + 'getLastname' => 'Tester', + ]; + yield 'user has no existing tokens' => [ + ['token_salt' => 'RandomTestSalt'], + [], + $user, + [ + 'result' => ApiKeyEntityInterface::class, + ], + ]; + yield 'user has no revoked tokens' => [ + ['token_salt' => 'RandomTestSalt'], + [ + [ + 'isRevoked' => false, + ], + [ + 'isRevoked' => false, + ], + [ + 'isRevoked' => false, + ], + ], + $user, + [ + 'result' => ApiKeyEntityInterface::class, + ], + ]; + yield 'user has five tokens' => [ + ['token_salt' => 'RandomTestSalt'], + [ + [ + 'isRevoked' => false, + ], + [ + 'isRevoked' => false, + ], + [ + 'isRevoked' => false, + ], + [ + 'isRevoked' => false, + ], + [ + 'isRevoked' => false, + ], + ], + $user, + [ + 'result' => false, + ], + ]; + yield 'user has a revoked token' => [ + ['token_salt' => 'RandomTestSalt'], + [ + [ + 'isRevoked' => false, + ], + [ + 'isRevoked' => true, + ], + [ + 'isRevoked' => false, + ], + ], + $user, + [ + 'result' => false, + ], + ]; + yield 'salt is missing from the configuration' => [ + [], + [], + $user, + [ + 'error' => 'DeveloperSettingsService: Invalid token_salt provided', + ], + ]; + yield 'salt is under 10 characters long' => [ + ['token_salt' => '123456'], + [], + $user, + [ + 'error' => 'DeveloperSettingsService: Invalid token_salt provided', + ], + ]; + } + + /** + * Test generating new apiKey for user + * + * @param array $config Config + * @param array $tokens Token methods and returns + * @param array $user User data + * @param array $expected Expected value in result key, omit when error expected. + * + * @dataProvider getTestGenerateApiKeyForUserData + * @return void + */ + public function testGenerateApiKeyForUser( + array $config, + array $tokens, + array $user, + array $expected + ): void { + if (isset($expected['error'])) { + $this->expectExceptionMessage($expected['error']); + } + $apiKeyNew = $this->createMockWithMethods(ApiKeyEntityInterface::class, ['getTitle' => 'test']); + + $apiKeys = array_map( + fn ($apiKey) => $this->createMockWithMethods(ApiKeyEntityInterface::class, $apiKey), + $tokens + ); + $apiKeyService = $this->createMockWithMethods( + ApiKeyServiceInterface::class, + [ + 'getApiKeysForUser' => $apiKeys, + 'createEntity' => $apiKeyNew, + ] + ); + + $userEntity = $this->createMockWithMethods(UserEntityInterface::class, $user); + + $result = $this->getService($config, $apiKeyService)->generateApiKeyForUser($userEntity, 'test'); + // No need to assert if test expects an error to be thrown. + if (isset($expected['error'])) { + return; + } + if (is_bool($result)) { + $this->assertEquals($expected['result'], $result); + } else { + $this->assertEquals('test', $result->getTitle()); + } + } + + /** + * Get testIsApiKeyAllowed data + * + * @return Generator + */ + public static function getTestIsApiKeyAllowedData(): Generator + { + yield 'API keys disabled' => [ + null, + [ + 'mode' => DeveloperSettingsStatus::DISABLED->value, + ], + null, + true, + ]; + yield 'API keys disabled and provided' => [ + 'testtoken', + [ + 'mode' => DeveloperSettingsStatus::DISABLED->value, + ], + null, + true, + ]; + yield 'API keys optional and not provided' => [ + null, + [ + 'mode' => DeveloperSettingsStatus::OPTIONAL->value, + ], + null, + true, + ]; + yield 'API keys optional and provided' => [ + 'testtoken', + [ + 'mode' => DeveloperSettingsStatus::OPTIONAL->value, + ], + [ + 'isRevoked' => false, + ], + true, + ]; + yield 'API keys optional and revoked' => [ + 'testtoken', + [ + 'mode' => DeveloperSettingsStatus::OPTIONAL->value, + ], + [ + 'isRevoked' => true, + ], + false, + ]; + yield 'API keys optional and provided missing' => [ + 'testtoken', + [ + 'mode' => DeveloperSettingsStatus::OPTIONAL->value, + ], + null, + false, + ]; + yield 'API keys enforced and not provided' => [ + null, + [ + 'mode' => DeveloperSettingsStatus::ENFORCED->value, + ], + null, + false, + ]; + yield 'API keys enforced and provided' => [ + 'testtoken', + [ + 'mode' => DeveloperSettingsStatus::ENFORCED->value, + ], + [ + 'isRevoked' => false, + ], + true, + ]; + yield 'API keys enforced and revoked' => [ + 'testtoken', + [ + 'mode' => DeveloperSettingsStatus::ENFORCED->value, + ], + [ + 'isRevoked' => true, + ], + false, + ]; + yield 'API keys enforced and provided missing' => [ + 'testtoken', + [ + 'mode' => DeveloperSettingsStatus::ENFORCED->value, + ], + null, + false, + ]; + } + + /** + * Test token is valid method + * + * @param ?string $token Token provided in request or null + * @param array $config Config + * @param ?array $apiKey Methods and returns for API key entity + * @param bool $expected Expected value + * + * @dataProvider getTestIsApiKeyAllowedData + * @return void + */ + public function testIsApiKeyAllowed(?string $token, array $config, ?array $apiKey, bool $expected): void + { + $apiKey = $apiKey ? $this->createMockWithMethods(ApiKeyEntityInterface::class, $apiKey) : null; + $apiKeyService = $this->createMockWithMethods(ApiKeyServiceInterface::class, ['getByToken' => $apiKey]); + $result = $this->getService($config, $apiKeyService)->isApiKeyAllowed($token); + $this->assertEquals($expected, $result); + } + + /** + * Test update last used. + * + * @return void + */ + public function testUpdateLastUsed(): void + { + $config = [ + 'mode' => DeveloperSettingsStatus::OPTIONAL->value, + 'token_salt' => 'test_salt_thing', + ]; + $apiKey = new ApiKey(); + $date = new DateTime('01-01-1999'); + $apiKey->setTitle('heitest')->setCreated($date)->setLastUsed($date)->setRevoked(false); + $apiKeyService = $this->createMockWithMethods(ApiKeyServiceInterface::class, ['getByToken' => $apiKey]); + $apiKeyService->expects($this->once())->method('persistEntity')->willReturnCallback( + function ($apiKey) use ($date) { + $this->assertNotEquals( + $apiKey->getLastUsed()->getTimestamp(), + $date->getTimestamp() + ); + } + ); + $result = $this->getService($config, $apiKeyService)->isApiKeyAllowed('test'); + $this->assertTrue($result); + } +} diff --git a/module/VuFindAdmin/src/VuFindAdmin/Controller/AbstractAdmin.php b/module/VuFindAdmin/src/VuFindAdmin/Controller/AbstractAdmin.php index a8ec9f9eb75..c051aec22b1 100644 --- a/module/VuFindAdmin/src/VuFindAdmin/Controller/AbstractAdmin.php +++ b/module/VuFindAdmin/src/VuFindAdmin/Controller/AbstractAdmin.php @@ -94,21 +94,4 @@ public function disabledAction() { return $this->createViewModel(); } - - /** - * Get the url parameters - * - * @param string $param A key to check the url params for - * @param bool $prioritizePost If true, check the POST params first - * @param mixed $default Default value if no value found - * - * @return string|array - */ - protected function getParam($param, $prioritizePost = true, $default = null) - { - $primary = $prioritizePost ? 'fromPost' : 'fromQuery'; - $secondary = $prioritizePost ? 'fromQuery' : 'fromPost'; - return $this->params()->$primary($param) - ?? $this->params()->$secondary($param, $default); - } } diff --git a/module/VuFindApi/src/VuFindApi/Controller/ApiController.php b/module/VuFindApi/src/VuFindApi/Controller/ApiController.php index e373637e1c4..2e0744771eb 100644 --- a/module/VuFindApi/src/VuFindApi/Controller/ApiController.php +++ b/module/VuFindApi/src/VuFindApi/Controller/ApiController.php @@ -104,8 +104,13 @@ public function indexAction() */ protected function getApiSpecFragment() { + $config = $this->getConfigArray(); + $this->initApiKeySettings($config['API_Keys'] ?? []); $params = [ - 'config' => $this->getConfigArray(), + 'config' => $config, + 'apiKeysEnabled' => $this->developerSettingsService?->apiKeysEnabled() ?? false, + 'apiKeyHeaderField' => $this->apiKeyHeaderField, + 'apiKeyMode' => $this->developerSettingsService?->getApiKeyMode(), 'version' => \VuFind\Config\Version::getBuildVersion(), ]; return $this->getViewRenderer()->render('api/openapi', $params); diff --git a/module/VuFindApi/src/VuFindApi/Controller/ApiInterface.php b/module/VuFindApi/src/VuFindApi/Controller/ApiInterface.php index 58b079b1fc5..94eaefc9ad4 100644 --- a/module/VuFindApi/src/VuFindApi/Controller/ApiInterface.php +++ b/module/VuFindApi/src/VuFindApi/Controller/ApiInterface.php @@ -43,6 +43,7 @@ interface ApiInterface // define some status constants public const STATUS_OK = 'OK'; // good public const STATUS_ERROR = 'ERROR'; // bad + public const STATUS_UNAUTHORIZED = 'UNAUTHORIZED'; /** * Get API specification JSON fragment for services provided by the diff --git a/module/VuFindApi/src/VuFindApi/Controller/ApiTrait.php b/module/VuFindApi/src/VuFindApi/Controller/ApiTrait.php index 0f86ab9eb3a..30fc5dff41b 100644 --- a/module/VuFindApi/src/VuFindApi/Controller/ApiTrait.php +++ b/module/VuFindApi/src/VuFindApi/Controller/ApiTrait.php @@ -33,6 +33,7 @@ use Laminas\Http\Exception\InvalidArgumentException; use Laminas\Http\Header\ContentType; use Laminas\Mvc\Exception\DomainException; +use VuFind\DeveloperSettings\DeveloperSettingsService; /** * Additional functionality for API controllers. @@ -73,6 +74,20 @@ trait ApiTrait */ protected bool $returnUnicode = false; + /** + * Name of HTTP header + * + * @var string + */ + protected string $apiKeyHeaderField = VUFIND_API_KEY_DEFAULT_HEADER_FIELD; + + /** + * API key service + * + * @var ?DeveloperSettingsService + */ + protected ?DeveloperSettingsService $developerSettingsService = null; + /** * Execute the request * @@ -202,4 +217,52 @@ protected function output($data, $status, $httpCode = null, $message = '') $headers->addHeader($contentTypeHeader); return $response; } + + /** + * Init API key settings + * + * @param array $settings API key settings from config.ini + * + * @return void; + */ + protected function initApiKeySettings(array $settings): void + { + $this->developerSettingsService = $this->getService(DeveloperSettingsService::class); + if ($field = $settings['header_field'] ?? null) { + $this->apiKeyHeaderField = $field; + } + } + + /** + * Check request for API key if mode is not set to disabled. + * + * @return bool + * @throws \Exception + */ + protected function checkRequestForApiKey(): bool + { + if (!$this->developerSettingsService) { + throw new \Exception('ApiTrait: Developer settings service not initialized'); + } + return $this->developerSettingsService->isApiKeyAllowed($this->getHeader($this->apiKeyHeaderField)); + } + + /** + * Return output if request is missing an API key and API keys are enforced + * + * @return \Laminas\Http\Response + * @throws \Exception + */ + protected function outputMissingAPIKey(): \Laminas\Http\Response + { + if (!$this->developerSettingsService) { + throw new \Exception('ApiTrait: Developer settings service not initialized'); + } + return $this->output( + [], + ApiInterface::STATUS_UNAUTHORIZED, + 401, + $this->developerSettingsService->getApiKeyMode()->getUnauthorizedMessage() + ); + } } diff --git a/module/VuFindApi/src/VuFindApi/Controller/SearchApiController.php b/module/VuFindApi/src/VuFindApi/Controller/SearchApiController.php index c45c8701a9b..2bf77f483cd 100644 --- a/module/VuFindApi/src/VuFindApi/Controller/SearchApiController.php +++ b/module/VuFindApi/src/VuFindApi/Controller/SearchApiController.php @@ -52,7 +52,8 @@ * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License * @link https://vufind.org/wiki/development:plugins:controllers Wiki */ -class SearchApiController extends \VuFind\Controller\AbstractSearch implements ApiInterface +class SearchApiController extends \VuFind\Controller\AbstractSearch implements + ApiInterface { use ApiTrait; use \VuFind\ResumptionToken\ResumptionTokenTrait; @@ -157,7 +158,7 @@ public function __construct( } } // Load configurations from the search options class: - $options = $sm->get(\VuFind\Search\Options\PluginManager::class)->get($this->searchClassId); + $options = $this->getService(\VuFind\Search\Options\PluginManager::class)->get($this->searchClassId); $settings = $options->getAPISettings(); $this->facetConfig = $this->getConfigArray($options->getFacetsIni()); $this->hierarchicalFacets = $this->facetConfig['SpecialFacets']['hierarchical'] ?? []; @@ -170,6 +171,8 @@ public function __construct( $this->$key = $settings[$key]; } } + $config = $this->getConfigArray()['API_Keys'] ?? []; + $this->initApiKeySettings($config); } /** @@ -200,6 +203,9 @@ public function getApiSpecFragment() 'indexLabel' => $this->indexLabel, 'modelPrefix' => $this->modelPrefix, 'maxLimit' => $this->maxLimit, + 'apiKeysEnabled' => $this->developerSettingsService?->apiKeysEnabled() ?? false, + 'apiKeyHeaderField' => $this->apiKeyHeaderField, + 'apiKeyMode' => $this->developerSettingsService?->getApiKeyMode(), ]; $json = $this->getViewRenderer()->render( 'searchapi/openapi', @@ -255,9 +261,11 @@ public function recordAction() return $result; } - $request = $this->getRequest()->getQuery()->toArray() - + $this->getRequest()->getPost()->toArray(); + $request = $this->getAllRequestParams(); + if (!$this->checkRequestForApiKey()) { + return $this->outputMissingAPIKey(); + } if (!isset($request['id'])) { return $this->output([], self::STATUS_ERROR, 400, 'Missing id'); } @@ -311,10 +319,11 @@ public function searchAction() if ($result = $this->isAccessDenied($this->searchAccessPermission)) { return $result; } - + if (!$this->checkRequestForApiKey()) { + return $this->outputMissingAPIKey(); + } // Send both GET and POST variables to search class: - $request = $this->getRequest()->getQuery()->toArray() - + $this->getRequest()->getPost()->toArray(); + $request = $this->getAllRequestParams(); $isCursorSearch = ($request['resumptionToken'] ?? false); try { diff --git a/module/VuFindApi/tests/unit-tests/src/VuFindTest/Controller/SearchApiControllerTest.php b/module/VuFindApi/tests/unit-tests/src/VuFindTest/Controller/SearchApiControllerTest.php new file mode 100644 index 00000000000..c1bfdcd6a53 --- /dev/null +++ b/module/VuFindApi/tests/unit-tests/src/VuFindTest/Controller/SearchApiControllerTest.php @@ -0,0 +1,298 @@ +. + * + * @category VuFind + * @package Tests + * @author Juha Luoma + * @license https://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Page + */ + +declare(strict_types=1); + +namespace VuFindTest\Controller; + +use Generator; +use Laminas\Stdlib\Parameters; +use PHPUnit\Framework\MockObject\MockObject; +use VuFind\Config\ConfigManager; +use VuFind\Config\ConfigManagerInterface; +use VuFind\Db\Service\OaiResumptionServiceInterface; +use VuFind\Db\Service\PluginManager as DbPluginManager; +use VuFind\DeveloperSettings\DeveloperSettingsService; +use VuFind\DeveloperSettings\DeveloperSettingsStatus; +use VuFind\Http\PhpEnvironment\Request; +use VuFind\Record\Loader; +use VuFind\RecordDriver\SolrMarc; +use VuFind\Search\Options\PluginManager as SearchPluginManager; +use VuFind\Search\Solr\Options; +use VuFindApi\Controller\SearchApiController; +use VuFindApi\Formatter\FacetFormatter; +use VuFindApi\Formatter\RecordFormatter; +use VuFindTest\Container\MockContainer; + +/** + * Search api controller tests + * + * @category VuFind + * @package Tests + * @author Juha Luoma + * @license https://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Page + */ +class SearchApiControllerTest extends \PHPUnit\Framework\TestCase +{ + /** + * Data provider for testApiKeys functions + * + * @return Generator + */ + public static function getTestApiKeysData(): Generator + { + yield 'test keys disabled' => [ + [], + [ + 'queryAndPost' => [ + 'id' => 'record.1111', + ], + ], + [ + 'code' => 200, + 'content' => '{"resultCount":1,"records":[{"id":"record.1111","title":"hai!"}],"status":"OK"}', + ], + ]; + $config = [ + 'API_Keys' => [ + 'mode' => DeveloperSettingsStatus::OPTIONAL->value, + 'log_requests' => true, + 'header_field' => 'test-field', + ], + ]; + yield 'test keys enabled and provided' => [ + $config, + [ + 'queryAndPost' => [ + 'id' => 'record.1111', + ], + 'headers' => [ + ['test-field', '999999'], + ], + ], + [ + 'code' => 200, + 'content' => '{"resultCount":1,"records":[{"id":"record.1111","title":"hai!"}],"status":"OK"}', + ], + ]; + yield 'test keys enabled and provided non-working' => [ + $config, + [ + 'queryAndPost' => [ + 'id' => 'record.1111', + ], + 'headers' => [ + ['test-field', '51'], + ], + ], + [ + 'code' => 401, + 'content' => '{"status":"UNAUTHORIZED","statusMessage":"API key invalid"}', + ], + ]; + yield 'test keys enabled and not provided' => [ + $config, + [ + 'queryAndPost' => [ + 'id' => 'record.1111', + ], + ], + [ + 'code' => 200, + 'content' => '{"resultCount":1,"records":[{"id":"record.1111","title":"hai!"}],"status":"OK"}', + ], + ]; + $config['API_Keys']['mode'] = DeveloperSettingsStatus::ENFORCED->value; + yield 'test keys enforced and provided' => [ + $config, + [ + 'queryAndPost' => [ + 'id' => 'record.1111', + ], + 'headers' => [ + ['test-field', '999999'], + ], + ], + [ + 'code' => 200, + 'content' => '{"resultCount":1,"records":[{"id":"record.1111","title":"hai!"}],"status":"OK"}', + ], + ]; + yield 'test keys enforced and not provided' => [ + $config, + [ + 'queryAndPost' => [ + 'id' => 'record.1111', + ], + ], + [ + 'code' => 401, + 'content' => '{"status":"UNAUTHORIZED","statusMessage":"API key missing or invalid"}', + ], + ]; + } + + /** + * Get an instance of a searchApiController + * + * @param array $config Main config + * @param array $paramsArray Parameters + * + * @return MockObject&SearchApiController + */ + protected function createController( + array $config = [], + array $paramsArray = [], + ): MockObject&SearchApiController { + $solrOptions = $this->createMock(Options::class); + $solrOptions->expects($this->any())->method('getAPISettings')->willReturn([]); + $solrOptions->expects($this->any())->method('getFacetsIni')->willReturn(''); + $optionsPluginManager = $this->createMock(SearchPluginManager::class); + $optionsPluginManager->expects($this->any())->method('get')->willReturn($solrOptions); + $apiKeyMode = DeveloperSettingsStatus::fromSetting($config['API_Keys']['mode'] ?? ''); + $apiKeysEnabled = DeveloperSettingsStatus::settingEnabled($apiKeyMode->value); + $developerSettingsService = $this->createMock(DeveloperSettingsService::class); + $developerSettingsService->expects($this->any())->method('apiKeysEnabled')->willReturn($apiKeysEnabled); + $developerSettingsService->expects($this->any())->method('getApiKeyMode')->willReturnCallback( + fn () => $apiKeyMode + ); + $developerSettingsService->expects($this->any())->method('isApiKeyAllowed')->willReturnCallback( + function ($token) use ($apiKeyMode, $apiKeysEnabled) { + if (!$apiKeysEnabled) { + return true; + } + if ($apiKeyMode === DeveloperSettingsStatus::ENFORCED) { + return $token === '999999'; + } + return null === $token || $token === '999999'; + } + ); + + $mockRecord = $this->createMock(SolrMarc::class); + $recordMap = [ + ['record.1111', DEFAULT_SEARCH_BACKEND, false, null, $mockRecord], + ]; + + $recordLoader = $this->createMock(Loader::class); + $recordLoader->expects($this->any())->method('load')->willReturn($recordMap); + $recordLoader->expects($this->any())->method('loadBatchForSource')->willReturn($recordMap); + + $resumptionService = $this->getMockBuilder(OaiResumptionServiceInterface::class)->disableOriginalConstructor() + ->onlyMethods([])->getMock(); + $dbServiceMap = [ + [OaiResumptionServiceInterface::class, null, $resumptionService], + ]; + + $dbPluginManager = $this->getMockBuilder(DbPluginManager::class)->disableOriginalConstructor() + ->onlyMethods(['get'])->getMock(); + $dbPluginManager->expects($this->any())->method('get')->willReturnMap($dbServiceMap); + $facetFormatter = $this->createMock(FacetFormatter::class); + $recordFormatter = $this->createMock(RecordFormatter::class); + $recordFormatter->expects($this->any())->method('getRecordFields')->willReturn([]); + $recordFormatter->expects($this->any())->method('format')->willReturn([ + [ + 'id' => 'record.1111', + 'title' => 'hai!', + ], + ]); + $configManager = $this->getMockBuilder(ConfigManager::class)->disableOriginalConstructor()->getMock(); + $configManager->expects($this->any())->method('getConfigArray')->willReturn($config); + + $container = new MockContainer($this); + $container->set(SearchPluginManager::class, $optionsPluginManager); + $container->set(Loader::class, $recordLoader); + $container->set(DeveloperSettingsService::class, $developerSettingsService); + $container->set(DbPluginManager::class, $dbPluginManager); + $container->set(ConfigManagerInterface::class, $configManager); + $controller = $this->getMockBuilder(SearchApiController::class) + ->onlyMethods( + [ + 'getRequest', + 'disableSessionWrites', + 'determineOutputMode', + 'isAccessDenied', + 'doCursorSearch', + 'doDefaultSearch', + 'getConfig', + 'setResumptionService', + 'getAllRequestParams', + 'getHeader', + ] + )->setConstructorArgs([$container, $recordFormatter, $facetFormatter]) + ->getMock(); + $controller->expects($this->any())->method('isAccessDenied')->willReturn(false); + $controller->expects($this->any())->method('getAllRequestParams')->willReturn($paramsArray['queryAndPost']); + $controller->expects($this->any())->method('getHeader')->willReturnMap($paramsArray['headers'] ?? []); + $searchResponse = [ + 'resultCount' => 1, + 'records' => [ + ['id' => 'record.1111', 'title' => 'hai!'], + ], + ]; + $controller->expects($this->any())->method('doDefaultSearch')->willReturn($searchResponse); + return $controller; + } + + /** + * Test API Keys record + * + * @param array $config Main config + * @param array $requestParams Users request as params array + * @param array $expected Expected results + * + * @return void + * @dataProvider getTestApiKeysData + */ + public function testApiKeysRecord(array $config, array $requestParams, array $expected): void + { + $controller = $this->createController($config, $requestParams); + $result = $controller->recordAction(); + $this->assertEquals($expected['code'], $result->getStatusCode()); + $this->assertEquals($expected['content'], $result->getContent()); + } + + /** + * Test API Keys search + * + * @param array $config Main config + * @param array $requestParams Users request as params array + * @param array $expected Expected results + * + * @return void + * @dataProvider getTestApiKeysData + */ + public function testApiKeysSearch(array $config, array $requestParams, array $expected): void + { + $controller = $this->createController($config, $requestParams); + $result = $controller->searchAction(); + $this->assertEquals($expected['code'], $result->getStatusCode()); + $this->assertEquals($expected['content'], $result->getContent()); + } +} diff --git a/tests/phpstan-constants.php b/tests/phpstan-constants.php index 710736f7588..3beded1f64f 100644 --- a/tests/phpstan-constants.php +++ b/tests/phpstan-constants.php @@ -8,3 +8,4 @@ const VUFIND_DATABASE_DATETIME_FORMAT = 'Y-m-d H:i:s'; const VUFIND_DEFAULT_EARLIEST_YEAR = 1400; const VUFIND_DEFAULT_LATEST_YEAR_OFFSET = 1; +const VUFIND_API_KEY_DEFAULT_HEADER_FIELD = 'X-API-KEY'; diff --git a/themes/bootstrap5/templates/developersettings/displaysettings.phtml b/themes/bootstrap5/templates/developersettings/displaysettings.phtml new file mode 100644 index 00000000000..eb0963b2068 --- /dev/null +++ b/themes/bootstrap5/templates/developersettings/displaysettings.phtml @@ -0,0 +1,44 @@ +breadcrumbs()->set($this->translate('Your Account'), $this->url('myresearch-home')) + ->add($this->translate('Profile')); +?> + +component('show-account-menu-button') ?> +
+ component('page-title', ['title' => $this->translate('Developer::settings')]); ?> + flashmessages(); ?> + +
+

transEsc('Developer::api_keys') ?>

+ + apiKeys as $apiKey): ?> + + + + + +
escapeHtml($apiKey->getTitle()); ?> +
+ isRevoked()): ?> + transEsc('Developer::api_key_locked'); ?> + + component('confirm-button', [ + 'buttonLink' => $this->url('developersettings-deleteapikey', [], ['query' => ['id' => $apiKey->getId()]]), + 'buttonLabel' => 'Delete', + 'confirmLink' => $this->url('developersettings-deleteapikey', [], ['query' => ['id' => $apiKey->getId(), 'confirm' => 1]]), + 'header' => 'confirm_delete', + ] + );?> + +
+
+ createAllowed): ?> + transEsc('Developer::api_key_generate') ?> + +
+
+ + diff --git a/themes/bootstrap5/templates/developersettings/generateapikey.phtml b/themes/bootstrap5/templates/developersettings/generateapikey.phtml new file mode 100644 index 00000000000..845947c48fd --- /dev/null +++ b/themes/bootstrap5/templates/developersettings/generateapikey.phtml @@ -0,0 +1,16 @@ +breadcrumbs()->set($this->translate('Your Account'), $this->url('myresearch-home')) + ->add($this->translate('Profile'), active: true); +?> +component('page-title', ['title' => $this->translate('Developer::api_key_generate')]); ?> + +flashmessages() ?> + +
+
+ + +
+ +
diff --git a/themes/bootstrap5/templates/error/developer-settings-denied.phtml b/themes/bootstrap5/templates/error/developer-settings-denied.phtml new file mode 100644 index 00000000000..1c662ac5039 --- /dev/null +++ b/themes/bootstrap5/templates/error/developer-settings-denied.phtml @@ -0,0 +1,4 @@ +

transEsc('Developer::settings') ?>

+
+ transEsc('Developer::verify_email_address') ?> +
diff --git a/themes/bootstrap5/templates/myresearch/profile.phtml b/themes/bootstrap5/templates/myresearch/profile.phtml index dd2846a9c1d..2d7adac5a2a 100644 --- a/themes/bootstrap5/templates/myresearch/profile.phtml +++ b/themes/bootstrap5/templates/myresearch/profile.phtml @@ -170,6 +170,14 @@ ils()->getOfflineMode() && $this->patronLoginView && !empty($this->patronLoginView->getTemplate())): ?> partial($this->patronLoginView);?> + + developerSettings()->isEnabled()): ?> + permission()->allowDisplay('feature.Developer')): ?> + icon('options') . ' ' . $this->transEsc('Developer::settings');?> + permission()->getAlternateContent('feature.Developer')): ?> + + +