Skip to content

Commit

Permalink
WIP: UnifiedPush support for Nextcloud Notifications
Browse files Browse the repository at this point in the history
- based of a commit from early 2023
- also I did not continued to work on this since then
- started as an approach to fix nextcloud#1225
- to all: feel free to extend this code, I hereby release my changes
  under the license in ./LICENSE (AGPL 3.0)
  • Loading branch information
Zocker1999NET committed Aug 1, 2024
1 parent 686cb5b commit edeeeea
Show file tree
Hide file tree
Showing 28 changed files with 2,267 additions and 625 deletions.
6 changes: 4 additions & 2 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
['name' => 'Endpoint#getNotification', 'url' => '/api/{apiVersion}/notifications/{id}', 'verb' => 'GET', 'requirements' => ['apiVersion' => 'v(1|2)', 'id' => '\d+']],
['name' => 'Endpoint#deleteNotification', 'url' => '/api/{apiVersion}/notifications/{id}', 'verb' => 'DELETE', 'requirements' => ['apiVersion' => 'v(1|2)', 'id' => '\d+']],
['name' => 'Endpoint#deleteAllNotifications', 'url' => '/api/{apiVersion}/notifications', 'verb' => 'DELETE', 'requirements' => ['apiVersion' => 'v(1|2)']],
['name' => 'Push#registerDevice', 'url' => '/api/{apiVersion}/push', 'verb' => 'POST', 'requirements' => ['apiVersion' => 'v2']],
['name' => 'Push#removeDevice', 'url' => '/api/{apiVersion}/push', 'verb' => 'DELETE', 'requirements' => ['apiVersion' => 'v2']],
['name' => 'ProxyPush#registerDevice', 'url' => '/api/{apiVersion}/push', 'verb' => 'POST', 'requirements' => ['apiVersion' => 'v2']],
['name' => 'ProxyPush#removeDevice', 'url' => '/api/{apiVersion}/push', 'verb' => 'DELETE', 'requirements' => ['apiVersion' => 'v2']],
['name' => 'UnifiedPush#registerDevice', 'url' => '/api/{apiVersion}/unified_push', 'verb' => 'POST', 'requirements' => ['apiVersion' => 'v2']],
['name' => 'UnifiedPush#removeDevice', 'url' => '/api/{apiVersion}/unified_push', 'verb' => 'DELETE', 'requirements' => ['apiVersion' => 'v2']],

['name' => 'API#generateNotification', 'url' => '/api/{apiVersion}/admin_notifications/{userId}', 'verb' => 'POST', 'requirements' => ['apiVersion' => 'v(1|2)']],

Expand Down
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
"phpunit/phpunit": "^9.5",
"nextcloud/coding-standard": "^1.0.0",
"vimeo/psalm": "^5.1.0",
"nextcloud/ocp": "dev-master"
},
"nextcloud/ocp": "dev-master",
"ext-openssl": "*"
},
"config": {
"optimize-autoloader": true,
"classmap-authoritative": true,
Expand Down
201 changes: 201 additions & 0 deletions lib/Controller/GeneralPushController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<?php

declare(strict_types=1);

/**
* @copyright Copyright (c) 2023 Felix Stupp <[email protected]>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OCA\Notifications\Controller;

use OC\Authentication\Exceptions\InvalidTokenException;
use OC\Authentication\Token\IProvider;
use OC\Authentication\Token\IToken;
use OC\Security\IdentityProof\Key;
use OC\Security\IdentityProof\Manager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUser;
use OCP\IUserSession;

abstract class GeneralPushController extends OCSController {

public const LOCAL_DOMAIN_SUFFIXES = [
// sorted alphabetically
// omit optional trailing separator '.' here
'internal', // from Appendix G, RFC 6762
'local', // used by mDNS, RFC 6762
'localhost', // Section 6.3, RFC 6761
];


/** @var ISession */
protected ISession $session;

/** @var IUserSession */
protected IUserSession $userSession;

/** @var IProvider */
protected IProvider $tokenProvider;

/** @var Manager */
protected Manager $identityProof;

public function __construct(string $appName,
IRequest $request,
ISession $session,
IUserSession $userSession,
IProvider $tokenProvider,
Manager $identityProof) {
parent::__construct($appName, $request);
$this->session = $session;
$this->userSession = $userSession;
$this->tokenProvider = $tokenProvider;
$this->identityProof = $identityProof;
}

/**
* Verifies that a given URI is safe to use for notification purposes.
*
* First an URI needs to meet the requirements of RFC2396
* Second an URI needs to meet one of the following requirements to be considered safe:
* - either https:// is used (not http://)
* - or a local host is addressed using http:// (see $this->verifyNonLocalHost for more info)
*
* @return bool if URI seems safe to use
*/
protected function isUriSafe(string $uri): bool {
if (\filter_var($uri, FILTER_VALIDATE_URL) === false) {
return false;
}
$parts = \parse_url($uri);
// scheme & host (at least when scheme != "file") are required & so expected to be set
if ($parts['scheme'] === 'https') {
// https is allowed for everyone
return true;
}
if ($parts['scheme'] === 'http') {
// http is only allowed for local connections
if ($this->isLocalHost($parts['host'])) {
return true;
}
return false;
}
// other schemes are not supported
return false;
}

/**
* Checks that a host is a local one.
*
* See self::LOCAL_DOMAIN_SUFFIXES for a list of considered local domains / domain suffixes.
*
* @return bool If host seems local
*/
protected function isLocalHost(string $host): bool {
$escape = function ($s) {
return \preg_quote($s, '/');
};
$suffixesRe = \implode('|', \array_map($escape, self::LOCAL_DOMAIN_SUFFIXES));
// (^|\.) = separated domain label
// \.?$ = trailing separators might be used
$domainRe = '/(^|\.)(' . $suffixesRe . ')\.?$/';
if (\preg_match($domainRe, $host)) {
return false;
}
return true;
}

/**
* Verifies if the given string represents a valid public key for OpenSSL to support.
*
* @return ?DataResponse Null if public key is in a valid format, otherwise a valid HTTP response with an error description.
*/
protected function verifyPublicKeyOrError(string $publicKey): ?DataResponse {
if (
\strpos($publicKey, '-----BEGIN PUBLIC KEY-----' . "\n") !== 0 ||
((\strlen($publicKey) !== 450 || \strpos($publicKey, "\n" . '-----END PUBLIC KEY-----') !== 425) &&
(\strlen($publicKey) !== 451 || \strpos($publicKey, "\n" . '-----END PUBLIC KEY-----' . "\n") !== 425))
) {
return new DataResponse(['message' => 'INVALID_DEVICE_KEY'], Http::STATUS_BAD_REQUEST);
}
return null;
}

/**
* Checks if user is logged in & and returns its object.
*
* @return ?IUser None if user is not logged in else IUser object.
*/
protected function getUser(): ?IUser {
$user = $this->userSession->getUser();
if ($user instanceof IUser) {
return $user;
}
return null;
}

protected function getSessionToken(): ?IToken {
$tokenId = $this->session->get('token-id');
if (!\is_int($tokenId)) {
return null;
}
try {
return $this->tokenProvider->getTokenById($tokenId);
} catch (InvalidTokenException $e) {
return null;
}
}

protected function determineAppType(): string {
if ($this->request->isUserAgent([
IRequest::USER_AGENT_TALK_ANDROID,
IRequest::USER_AGENT_TALK_IOS,
])) {
return 'talk';
} elseif ($this->request->isUserAgent([
IRequest::USER_AGENT_CLIENT_ANDROID,
IRequest::USER_AGENT_CLIENT_IOS,
])) {
return 'nextcloud';
}
return 'unknown';
}

/**
* @param IToken
* @param IUser
* @return string[] first deviceIdentifier hashed as SHA512, second the OpenSSL signature
*/
protected function signAndHashDeviceIdentifier(IToken $token, IUser $user, Key $key) {
$deviceIdentifier = json_encode([$user->getCloudId(), $token->getId()]);
openssl_sign($deviceIdentifier, $signature, $key->getPrivate(), OPENSSL_ALGO_SHA512);
/**
* For some reason the push proxy's golang code needs the signature
* of the deviceIdentifier before the sha512 hashing. Assumption is that
* openssl_sign already does the sha512 internally.
*/
$deviceIdentifier = base64_encode(hash('sha512', $deviceIdentifier, true));
return [$deviceIdentifier, $signature];
}

}
148 changes: 148 additions & 0 deletions lib/Controller/ProxyPushController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

declare(strict_types=1);

/**
* @copyright Copyright (c) 2023 Felix Stupp <[email protected]>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OCA\Notifications\Controller;

use OC\Authentication\Token\IProvider;
use OC\Security\IdentityProof\Manager;
use OCA\Notifications\Devices\ProxyPush\ProxyPushDevice;
use OCA\Notifications\Devices\ProxyPush\ProxyPushDeviceManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUserSession;

class ProxyPushController extends GeneralPushController {

private ProxyPushDeviceManager $deviceManager;

public function __construct(
string $appName,
IRequest $request,
ISession $session,
IUserSession $userSession,
IProvider $tokenProvider,
Manager $identityProof,
ProxyPushDeviceManager $deviceManager,
) {
parent::__construct(
$appName,
$request,
$session,
$userSession,
$tokenProvider,
$identityProof,
);
$this->deviceManager = $deviceManager;
}

/**
* @NoAdminRequired
*
* @param string $pushTokenHash
* @param string $devicePublicKey
* @param string $proxyServer
* @return DataResponse
*/
public function registerDevice(
string $pushTokenHash,
string $devicePublicKey,
string $proxyServer,
): DataResponse {
$user = $this->getUser();
if (is_null($user)) {
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
}

if (!preg_match('/^([a-f0-9]{128})$/', $pushTokenHash)) {
return new DataResponse(['message' => 'INVALID_PUSHTOKEN_HASH'],
Http::STATUS_BAD_REQUEST);
}

if (!is_null($err = $this->verifyPublicKeyOrError($devicePublicKey))) {
return $err;
}

if (\strlen($proxyServer) > 256 || !$this->isUriSafe($proxyServer)) {
return new DataResponse(['message' => 'INVALID_PROXY_SERVER'],
Http::STATUS_BAD_REQUEST);
}

$token = $this->getSessionToken();
if (is_null($token)) {
return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'],
Http::STATUS_BAD_REQUEST);
}

$key = $this->identityProof->getKey($user);
[$deviceIdentifier, $signature] =
$this->signAndHashDeviceIdentifier($token, $user, $key);

$appType = $this->determineAppType();

$device = new ProxyPushDevice(
$user->getUID(),
$token->getId(),
$deviceIdentifier,
$devicePublicKey,
null, // gets generated automatically
$pushTokenHash,
$proxyServer,
$appType
);
$created = $this->deviceManager->saveDevice($device);

return new DataResponse([
'publicKey' => $key->getPublic(),
'deviceIdentifier' => $deviceIdentifier,
'signature' => base64_encode($signature),
], $created ? Http::STATUS_CREATED : Http::STATUS_OK);
}

/**
* @NoAdminRequired
*
* @return DataResponse
*/
public function removeDevice(): DataResponse {
$user = $this->getUser();
if (is_null($user)) {
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
}

$token = $this->getSessionToken();
if (is_null($token)) {
return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'],
Http::STATUS_BAD_REQUEST);
}

if ($this->deviceManager->deleteByUidToken($user->getUID(), $token->getId())) {
return new DataResponse([], Http::STATUS_ACCEPTED);
}

return new DataResponse([], Http::STATUS_OK);
}

}
Loading

0 comments on commit edeeeea

Please sign in to comment.