Commit 16530edd authored by swentel's avatar swentel

Issue #3166426: generate keys per user

parent bc51688b
ActivityPub
# ActivityPub for Drupal
## Public and Private keys
Public and private keys are saved in private://activitypub/keys.
The default path can be overridden in settings.php via config:
```
$config['activitypub.settings']['keys_path'] = '/your/path/';
```
......@@ -3,6 +3,7 @@ name: ActivityPub
description: 'Connect your site with the Fediverse.'
core_version_requirement: ^8.8 || ^9
dependencies:
- drupal:image
- webfinger:webfinger
test_dependencies:
- webfinger:webfinger
<?php
use Drupal\Core\File\FileSystemInterface;
/**
* Implements hook_requirements().
*/
function activitypub_requirements($phase) {
$requirements = [];
if ($phase == 'runtime') {
$directory = \Drupal::config('activitypub.settings')->get('keys_path');
if (\Drupal::hasService('stream_wrapper.private') && !is_dir($directory)) {
\Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
}
if (!\Drupal::hasService('stream_wrapper.private') || !is_dir($directory)) {
$description = t('An automated attempt to create the directory %directory failed, possibly due to a permissions problem. To proceed with the installation, either create the directory and modify its permissions manually or ensure that the installer has the permissions to create it automatically. For more information, see INSTALL.txt or the <a href=":handbook_url">online handbook</a>.', ['%directory' => $directory, ':handbook_url' => 'https://www.drupal.org/server-permissions']);
$requirements['activitypub keys_directory'] = [
'title' => t('ActivityPub keys directory'),
'description' => $description,
'severity' => REQUIREMENT_ERROR,
];
}
if (!extension_loaded('openssl')) {
$description = t('OpenSSL PHP extension is not loaded.');
$requirements['activitypub_openssl'] = [
'title' => t('IndieAuth directory'),
'description' => $description,
'severity' => REQUIREMENT_ERROR,
];
}
}
return $requirements;
}
activitypub.settings:
title: 'ActivityPub'
parent: system.admin_config_services
description: 'Configure global ActivityPub settings.'
route_name: activitypub.settings
administer activitypub:
title: 'Administer ActivityPub'
description: ''
restrict access: true
access activitypub actor overview:
title: 'Access activitypub actor overview page'
......
activitypub.settings:
path: '/admin/config/services/activitypub'
defaults:
_form: '\Drupal\activitypub\Form\ActivityPubSettingsForm'
_title: 'ActivityPub'
requirements:
_permission: 'administer activitypub'
activitypub.inbox:
path: '/person/{activitypub_actor}/inbox'
defaults:
......
services:
activitypub.form_alter:
class: Drupal\activitypub\Services\ActivityPubFormAlter
arguments: ['@entity_type.manager', '@request_stack']
arguments: ['@entity_type.manager', '@activitypub.keys', '@request_stack']
activitypub_actor:
class: Drupal\activitypub\ParamConverter\ActivityPubActorConverter
arguments: ['@entity_type.manager']
tags:
- { name: paramconverter }
lazy: true
activitypub.keys:
class: Drupal\activitypub\Services\ActivityPubKeys
arguments: ['@config.factory', '@file_system', '@logger.channel.activitypub']
activitypub.webfinger_subscriber:
class: Drupal\activitypub\EventSubscriber\WebfingerProfileSubscriber
arguments: ['@current_user', '@entity_type.manager', '@logger.channel.activitypub']
......
......@@ -16,7 +16,9 @@
"require": {
"drupal/core": "^8.8 || ^9",
"drupal/webfinger": "^8.8 || ^9",
"landrok/activitypub": "^0.3.0"
"landrok/activitypub": "^0.3.0",
"ext-openssl": "*",
"ext-json": "*"
},
"extra": {
"drush": {
......
avatar_style: 'thumbnail'
keys_path: 'private://activitypub/keys'
# schema for activitypub.settings
activitypub.settings:
type: config_object
label: 'ActivityPub'
mapping:
avatar_style:
type: string
label: 'Avatar style'
keys_path:
type: string
label: 'Path where the public and private keys are stored'
......@@ -2,15 +2,51 @@
namespace Drupal\activitypub\Controller;
use Drupal\activitypub\Services\ActivityPubKeysInterface;
use Drupal\activitypub\Services\ActivityPubUtility;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Controller\ControllerBase;
use Drupal\user\UserInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class ActivityStreamController extends ControllerBase {
use ActivityPubUtility;
/**
* The ActivityPub keys service
*
* @var \Drupal\activitypub\Services\ActivityPubKeysInterface
*/
protected $activityPubKeys;
/**
* ActivityStreamController constructor.
*
* @param \Drupal\activitypub\Services\ActivityPubKeysInterface|null $activityPubKeys
*/
public function __construct(ActivityPubKeysInterface $activityPubKeys = NULL) {
$this->activityPubKeys = $activityPubKeys;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('activitypub.keys')
);
}
/**
* Returns the Activity Stream information for a user.
*
* @param \Drupal\user\UserInterface $user
*
* @return \Drupal\Core\Cache\CacheableJsonResponse
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
public function stream(UserInterface $user) {
/** @var \Drupal\activitypub\Entity\Storage\ActivityPubActorStorageInterface $storage */
......@@ -24,7 +60,12 @@ class ActivityStreamController extends ControllerBase {
'name' => $user->getAccountName(),
'preferredUsername' => $user->getAccountName(),
'id' => $this->getActivityPubURL($actor, \Drupal::requestStack()),
'publicKey' => [
'id' => $actor->getName(),
'publicKeyPem' => $this->activityPubKeys->getPublicKey($actor->getName()),
],
];
// TODO cache metadata
$response = new CacheableJsonResponse($data);
$response->headers->set('Content-Type', 'application/activity+json; charset=utf-8');
return $response;
......
......@@ -3,7 +3,6 @@
namespace Drupal\activitypub\Controller;
use Drupal\activitypub\Entity\ActivitypubActorInterface;
use Drupal\activitypub\Services\ActivityPubUtility;
use Drupal\Core\Controller\ControllerBase;
use Laminas\Diactoros\Response\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
......
<?php
namespace Drupal\activitypub\Form;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
class ActivityPubSettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return ['activitypub.settings'];
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'activitypub_settings_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config('activitypub.settings');
$form['avatar_style'] = [
'#title' => $this->t('Avatar image style'),
'#description' => $this->t('This image style will be used for the avatar for local and remote users.'),
'#default_value' => $config->get('avatar_style'),
'#type' => 'select',
'#required' => TRUE,
'#options' => image_style_options(FALSE),
];
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->config('activitypub.settings')
->set('avatar_style', $form_state->getValue('avatar_style'))
->save();
parent::submitForm($form, $form_state);
}
}
......@@ -23,6 +23,13 @@ class ActivityPubFormAlter implements ActivityPubFormAlterInterface {
*/
protected $actorStorage;
/**
* The ActivityPub keys class.
*
* @var \Drupal\activitypub\Services\ActivityPubKeysInterface
*/
protected $activityPubKeys;
/**
* The request stack.
*
......@@ -36,13 +43,15 @@ class ActivityPubFormAlter implements ActivityPubFormAlterInterface {
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*
* @param \Drupal\activitypub\Services\ActivityPubKeysInterface $activityPubKeys
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, RequestStack $request_stack) {
public function __construct(EntityTypeManagerInterface $entity_type_manager, ActivityPubKeysInterface $activityPubKeys, RequestStack $request_stack) {
$this->actorStorage = $entity_type_manager->getStorage('activitypub_actor');
$this->activityPubKeys = $activityPubKeys;
$this->requestStack = $request_stack;
}
......@@ -146,6 +155,10 @@ class ActivityPubFormAlter implements ActivityPubFormAlterInterface {
$actor = $this->actorStorage->create($values);
$actor->save();
if ($actor) {
$this->activityPubKeys->generateKeys($actor->getName());
}
}
}
......
<?php
namespace Drupal\activitypub\Services;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Psr\Log\LoggerInterface;
class ActivityPubKeys implements ActivityPubKeysInterface {
const CERT_CONFIG = [
"digest_alg" => "sha512",
"private_key_bits" => 4096,
"private_key_type" => OPENSSL_KEYTYPE_RSA,
];
/**
* The system theme config object.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The file system service.
*
* @var \Drupal\Core\File\FileSystemInterface
*/
protected $fileSystem;
/**
* A logger instance.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* Constructs a ActivityPubKeys object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\File\FileSystemInterface $file_system
* @param \Psr\Log\LoggerInterface $logger
* A logger
*/
public function __construct(ConfigFactoryInterface $config_factory, FileSystemInterface $file_system, LoggerInterface $logger) {
$this->configFactory = $config_factory;
$this->fileSystem = $file_system;
$this->logger = $logger;
}
/**
* {@inheritdoc}
*/
public function getPublicKey($path) {
$key_path = rtrim($this->configFactory->get('activitypub.settings')->get('keys_path'), '/') . '/' . $path . '/public.key';
return file_get_contents($key_path);
}
/**
* {@inheritdoc}
*/
public function generateKeys($path) {
$return = FALSE;
if (!extension_loaded('openssl')) {
$this->logger->error('OpenSSL PHP extension is not loaded to generate keys.');
return $return;
}
try {
// Generate Resource.
$resource = openssl_pkey_new(self::CERT_CONFIG);
// Get Private Key.
openssl_pkey_export($resource, $pkey);
// Get Public Key.
$pubkey = openssl_pkey_get_details($resource);
$keys = [
'private' => $pkey,
'public' => $pubkey['key'],
];
$dir_path = rtrim($this->configFactory->get('activitypub.settings')->get('keys_path'), '/') . '/' . $path;
$this->fileSystem->prepareDirectory($dir_path, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
foreach (['public', 'private'] as $name) {
// Key uri.
$key_uri = "$dir_path/$name.key";
// Write key content to key file.
$this->fileSystem->saveData($keys[$name], $key_uri, FileSystemInterface::EXISTS_REPLACE);
// Set correct permission to key file.
$this->fileSystem->chmod($key_uri, 0600);
}
$return = TRUE;
}
catch (\Exception $e) {
$this->logger->error('Error generating keys: @message', ['@message' => $e->getMessage()]);
}
return $return;
}
}
<?php
namespace Drupal\activitypub\Services;
interface ActivityPubKeysInterface {
/**
* Generate keys in a path.
*
* @param $path
*
* @return bool
*/
public function generateKeys($path);
/**
* Get the public key for a path.
*
* @param $path
*
* @return string
*/
public function getPublicKey($path);
}
......@@ -31,11 +31,18 @@ abstract class ActivityPubTestBase extends BrowserTestBase {
protected $httpClient;
/**
* The main account name used.
* The name for the first account.
*
* @var string
*/
protected $accountName = 'fediverseaccountname';
protected $accountNameOne = 'NameOne';
/**
* The name for the second account.
*
* @var string
*/
protected $accountNameTwo = 'NameTwo';
/**
* The default theme to use.
......@@ -103,8 +110,9 @@ abstract class ActivityPubTestBase extends BrowserTestBase {
* Helper method to activate ActivityPub for authenticated user One.
*
* @param $assert_session
* @param bool $enable_second_account
*/
protected function enableActivityPub($assert_session) {
protected function enableActivityPub($assert_session, $enable_second_account = FALSE) {
$this->drupalLogin($this->authenticatedUserOne);
$edit = [
'activitypub_enable' => TRUE,
......@@ -113,9 +121,18 @@ abstract class ActivityPubTestBase extends BrowserTestBase {
$assert_session->responseContains('Please enter your ActivityPub username.');
$edit = [
'activitypub_enable' => TRUE,
'activitypub_name' => $this->accountName,
'activitypub_name' => $this->accountNameOne,
];
$this->drupalPostForm('user/' . $this->authenticatedUserOne->id() . '/edit', $edit, 'Save');
if ($enable_second_account) {
$this->drupalLogin($this->authenticatedUserTwo);
$edit = [
'activitypub_enable' => TRUE,
'activitypub_name' => $this->accountNameTwo,
];
$this->drupalPostForm('user/' . $this->authenticatedUserTwo->id() . '/edit', $edit, 'Save');
}
}
/**
......
......@@ -21,11 +21,11 @@ class ActivityTest extends ActivityPubTestBase {
public function testActivityStorage() {
$assert_session = $this->assertSession();
$this->enableActivityPub($assert_session);
$this->enableActivityPub($assert_session, TRUE);
$this->drupalLogout();
$payload = ['type' => 'Create', 'object' => ['type' => 'Note']];
$url = Url::fromRoute('activitypub.inbox', ['activitypub_actor' => $this->accountName])->toString();
$url = Url::fromRoute('activitypub.inbox', ['activitypub_actor' => $this->accountNameOne])->toString();
$response = $this->sendRequest($url, $payload);
self::assertEquals(202, $response->getStatusCode());
......
......@@ -34,23 +34,23 @@ class ActorTest extends ActivityPubTestBase {
$this->drupalPostForm('user/' . $this->authenticatedUserTwo->id() . '/edit', $edit, 'Save');
$assert_session->responseContains('The username can only contain letters and numbers.');
$edit['activitypub_name'] = $this->accountName;
$edit['activitypub_name'] = $this->accountNameOne;
$this->drupalPostForm('user/' . $this->authenticatedUserTwo->id() . '/edit', $edit, 'Save');
$assert_session->responseContains('This username is already taken.');
$this->drupalLogout();
$this->drupalGet(Url::fromRoute('activitypub.inbox', ['activitypub_actor' => $this->accountName])->toString());
$this->drupalGet(Url::fromRoute('activitypub.inbox', ['activitypub_actor' => $this->accountNameOne])->toString());
$assert_session->statusCodeEquals(202);
$this->drupalLogin($this->authenticatedUserOne);
$this->drupalGet('activitypub/actor/delete/unknown');
$assert_session->statusCodeEquals(404);
$this->drupalPostForm('activitypub/actor/delete/' . $this->accountName, array(), 'Confirm');
$this->drupalPostForm('activitypub/actor/delete/' . $this->accountNameOne, array(), 'Confirm');
$this->drupalGet('user/' . $this->authenticatedUserOne->id() . '/edit');
$assert_session->responseNotContains('ActivityPub is enabled.');
$this->drupalLogout();
$this->drupalGet(Url::fromRoute('activitypub.inbox', ['activitypub_actor' => $this->accountName])->toString());
$this->drupalGet(Url::fromRoute('activitypub.inbox', ['activitypub_actor' => $this->accountNameOne])->toString());
$assert_session->statusCodeEquals(404);
}
......
......@@ -28,13 +28,13 @@ class WebfingerTest extends ActivityPubTestBase {
$assert_session->statusCodeEquals(404);
$assert_session->responseHeaderContains('Content-Type', 'application/jrd+json; charset=utf-8');
$resource = $this->getResourceUrl($this->accountName);
$resource = $this->getResourceUrl($this->accountNameOne);
$this->drupalGet('.well-known/webfinger', ['query' => ['resource' => $resource]]);
$assert_session->statusCodeEquals(200);
$content = json_decode($page->getContent());
self::assertEquals(Url::fromRoute('entity.user.canonical', ['user' => $this->authenticatedUserOne->id()], ['absolute' => TRUE])->toString(), $content->aliases[0]);
$resource = $this->getResourceUrl($this->accountName, FALSE);
$resource = $this->getResourceUrl($this->accountNameOne, FALSE);
$this->drupalGet('.well-known/webfinger', ['query' => ['resource' => $resource]]);
$assert_session->statusCodeEquals(200);
$content = json_decode($page->getContent());
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment