diff --git a/lib/Controller/PushController.php b/lib/Controller/PushController.php index c1caae987..f1b20a08c 100644 --- a/lib/Controller/PushController.php +++ b/lib/Controller/PushController.php @@ -118,8 +118,8 @@ public function registerDevice(string $pushTokenHash, string $devicePublicKey, s $key = $this->identityProof->getKey($user); $deviceIdentifier = json_encode([$user->getCloudId(), $token->getId()]); - openssl_sign($deviceIdentifier, $signature, $key->getPrivate(), OPENSSL_ALGO_SHA512); $deviceIdentifier = base64_encode(hash('sha512', $deviceIdentifier, true)); + openssl_sign($deviceIdentifier, $signature, $key->getPrivate(), OPENSSL_ALGO_SHA512); $appType = 'unknown'; if ($this->request->isUserAgent([ diff --git a/tests/Integration/features/bootstrap/FeatureContext.php b/tests/Integration/features/bootstrap/FeatureContext.php index 893d47009..b6e42c61b 100644 --- a/tests/Integration/features/bootstrap/FeatureContext.php +++ b/tests/Integration/features/bootstrap/FeatureContext.php @@ -56,6 +56,12 @@ class FeatureContext implements Context, SnippetAcceptingContext { /** @var string */ protected $lastEtag; + /** @var resource */ + protected $deviceKey; + + /** @var string[] */ + protected $appPasswords; + /** * FeatureContext constructor. */ @@ -221,6 +227,99 @@ public function deleteAllNotification($api) { $this->sendingTo('DELETE', '/apps/notifications/api/' . $api . '/notifications'); } + /** + * @Then /^user "([^"]*)" unregisters from push notifications/ + * + * @param string $user + */ + public function unregisterForPushNotifications(string $user) { + $currentUser = $this->currentUser; + $this->setCurrentUser($user); + $this->sendingToWith('DELETE', '/apps/notifications/api/v2/push?format=json'); + $this->setCurrentUser($currentUser); + } + + /** + * @Then /^user "([^"]*)" registers for push notifications with$/ + * + * @param string $user + * @param TableNode|null $formData + */ + public function registerForPushNotifications(string $user, TableNode $formData) { + $data = $formData->getRowsHash(); + + if ($data['devicePublicKey'] === 'VALID_KEY') { + $config = [ + 'digest_alg' => 'sha512', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + $this->deviceKey = openssl_pkey_new($config); + $keyDetails = openssl_pkey_get_details($this->deviceKey); + $publicKey = $keyDetails['key']; + + $data['devicePublicKey'] = $publicKey; + } + + $currentUser = $this->currentUser; + $this->setCurrentUser($user); + $this->sendingToWith('POST', '/apps/notifications/api/v2/push?format=json', $data); + $this->setCurrentUser($currentUser); + } + + /** + * @Then /^can validate the response and signature$/ + */ + public function validateResponseAndSignature(): void { + $response = $this->getArrayOfNotificationsResponded($this->response); + + Assert::assertStringStartsWith('-----BEGIN PUBLIC KEY-----' . "\n", $response['publicKey']); + Assert::assertStringEndsWith('-----END PUBLIC KEY-----' . "\n", $response['publicKey']); + Assert::assertNotEmpty($response['deviceIdentifier'], 'Device identifier should not be empty'); + Assert::assertNotEmpty($response['signature'], 'Signature should not be empty'); + + $result = openssl_verify($response['deviceIdentifier'], base64_decode($response['signature']), $response['publicKey'], OPENSSL_ALGO_SHA512); + Assert::assertEquals(true, $result, 'Failed to verify the signature'); + } + + /** + * @Then /^user "([^"]*)" creates an app password$/ + * + * @param string $user + */ + public function createAppPassword(string $user) { + $currentUser = $this->currentUser; + $this->setCurrentUser($user); + $this->sendingToWith('GET', '/core/getapppassword?format=json'); + $this->setCurrentUser($currentUser); + + $response = $this->getArrayOfNotificationsResponded($this->response); + Assert::assertNotEquals('', $response['apppassword']); + $this->appPasswords[$user] = $response['apppassword']; + } + + /** + * @Then /^user "([^"]*)" forgets the app password$/ + * + * @param string $user + */ + public function removeAppPassword(string $user) { + unset($this->appPasswords[$user]); + } + + /** + * @Then /^error "([^"]*)" is expected with status code ([0-9]*)$/ + * + * @param string $error + * @param int $statusCode + */ + public function expectedErrorOnLastRequest(string $error, int $statusCode) { + $this->assertStatusCode($this->response, $statusCode); + $response = $this->getArrayOfNotificationsResponded($this->response); + + Assert::assertEquals($error, $response['message']); + } + /** * @Then /^status code is ([0-9]*)$/ * @@ -347,15 +446,17 @@ public function sendingTo(string $verb, string $url) { * @When /^sending "([^"]*)" to "([^"]*)" with$/ * @param string $verb * @param string $url - * @param TableNode $body + * @param TableNode|array|null $body * @param array $headers */ - public function sendingToWith(string $verb, string $url, TableNode $body = null, array $headers = []) { + public function sendingToWith(string $verb, string $url, $body = null, array $headers = []) { $fullUrl = $this->baseUrl . 'ocs/v2.php' . $url; $client = new Client(); $options = []; - if ($this->currentUser === 'admin') { - $options['auth'] = ['admin', 'admin']; + if (isset($this->appPasswords[$this->currentUser])) { + $options['auth'] = [$this->currentUser, $this->appPasswords[$this->currentUser]]; + } elseif ($this->currentUser === 'admin') { + $options['auth'] = [$this->currentUser, 'admin']; } else { $options['auth'] = [$this->currentUser, '123456']; } diff --git a/tests/Integration/features/push-registration.feature b/tests/Integration/features/push-registration.feature new file mode 100644 index 000000000..3d1eea99a --- /dev/null +++ b/tests/Integration/features/push-registration.feature @@ -0,0 +1,64 @@ +Feature: Push registration + Background: + Given user "test1" exists + Given as user "test1" + + Scenario: Invalid push token hash + Given user "test1" registers for push notifications with + | pushTokenHash | 12345 | + | devicePublicKey | INVALID_KEY | + | proxyServer | nextcloud | + Then error "INVALID_PUSHTOKEN_HASH" is expected with status code 400 + + Scenario: Invalid device key + Given user "test1" registers for push notifications with + | pushTokenHash | 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678 | + | devicePublicKey | INVALID_KEY | + | proxyServer | nextcloud | + Then error "INVALID_DEVICE_KEY" is expected with status code 400 + + Scenario: Invalid proxy server + Given user "test1" registers for push notifications with + | pushTokenHash | 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678 | + | devicePublicKey | VALID_KEY | + | proxyServer | nextcloud | + Then error "INVALID_PROXY_SERVER" is expected with status code 400 + + Scenario: Invalid session token: not using an app password + Given user "test1" registers for push notifications with + | pushTokenHash | 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678 | + | devicePublicKey | VALID_KEY | + | proxyServer | https://push-notifications.nextcloud.com/ | + Then error "INVALID_SESSION_TOKEN" is expected with status code 400 + + Scenario: Successful registration + Given user "test1" creates an app password + Given user "test1" registers for push notifications with + | pushTokenHash | 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678 | + | devicePublicKey | VALID_KEY | + | proxyServer | https://push-notifications.nextcloud.com/ | + Then status code is 201 + And can validate the response and signature + + Scenario: Unregistering from push notifications without app password + Given user "test1" forgets the app password + Given user "test1" unregisters from push notifications + Then error "INVALID_SESSION_TOKEN" is expected with status code 400 + + Scenario: Unregistering from push notifications successfully + Given user "test1" creates an app password + Given user "test1" registers for push notifications with + | pushTokenHash | 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678 | + | devicePublicKey | VALID_KEY | + | proxyServer | https://push-notifications.nextcloud.com/ | + Then status code is 201 + And can validate the response and signature + Given user "test1" unregisters from push notifications + Then status code is 202 + Given user "test1" unregisters from push notifications + Then status code is 200 + + Scenario: Unregistering from push notifications without registering + Given user "test1" creates an app password + Given user "test1" unregisters from push notifications + Then status code is 200 diff --git a/tests/Unit/Controller/PushControllerTest.php b/tests/Unit/Controller/PushControllerTest.php index 9c84da24b..3ac462ff9 100644 --- a/tests/Unit/Controller/PushControllerTest.php +++ b/tests/Unit/Controller/PushControllerTest.php @@ -302,7 +302,7 @@ public function dataRegisterDevice() { [ 'publicKey' => $this->userPublicKey, 'deviceIdentifier' => 'XUCEZ1EHvTUcVhIvrQQQ1XcP0ZD2BFdFqw4EYbOhBfiEgXgirurR4x/ve4GSSyfivvbQOdOkZUM+g4m+tSb0Ew==', - 'signature' => 'LRhbXO71WYX9qqDbQX7C+87YaaFfWoT/vG0DlaXdBz6+lhyOA0dw/1Ggz3fd7RerCQ0MfgnnTyxO+cSeRpUaPdA2yPjfoiPpfYA5SOJQGF3comS/HYna3fHiFDbOoM3BJOnjvqiSZdxA/ICdyl2mEEC5wO7AZ4OZKBTa5XfL7eSCXZLEv1YldqcLOStbXrI7voDQocTMJxoQZI/j8BVcf2i3D6F454aXIFDrYYzC2PQY+CKJoXZW0m0RMWaTM2B8tBmFFwrmaGLDqcjjpd33TsTtsV5DB7WimffLBPpOuGV4Z1Kiagp/mxpPLz2NImNV79mDX9gY3ZppCZTwChP5qQ==', + 'signature' => 'X9+J7NNLfG9Ft6C36zrYLVJ5aH5euIROzdV937hsU81jL7WvOwzBfc7bImzxU3Bnev5wEKwkw7Ts/2q/+UUkOxgtEZinp52s87S5obKtsVXsczHbsqg4p/ueoBPhF17VsP1e8kMtxZ4snk/iArX4Eu1cfaM3+OckmpO0MYXy0rUbYpQPAJo4VgRFKKjFvfEVOj8N74DTIJ+TjRsvvDhJbb9KpeFe3a6Rv9mIo0AqoK+deAbUkWY0aM+74noVXvPtNzExgK4mWJ02+JHEuQEUbCuQsgoBia0vC3fILbwVxHzrieWGEnE7vkRyFEzlkeo7ZSMawDPxsPN5HxwBs2SZig==', ], Http::STATUS_CREATED, ], @@ -317,7 +317,7 @@ public function dataRegisterDevice() { [ 'publicKey' => $this->userPublicKey, 'deviceIdentifier' => 'x9vSImcGjhzR9BfZ/XbbUqqCCNC4bHKsX7vkQWNZRd1/MiY+OuF02fx8K08My0RpkNnwj/rQ/gVSU1oEdFwkww==', - 'signature' => 'J9AcdJt5youJmMnBhS+Cc9ytArynIKtCRoNf/m0oOFO/e0hWHqs1NRdQBe81qzYIjf0+bj0Q97X9Xv1rnVJesPkQUbGaa4nAPt+viGSfvzTptjX4LKgqm8B3UkduBA262IcaWgM5P84gUqelkQIC1nIqq/MJTuC6oQ5lUwIV1a92ZurDjhwH4b3f7/ZLTTOTRD0DWN9W/yOyF1qECivgePR3eu+mkcBzXVU/TDZDJic9G7xhqcTnWV6qk+aKyzdNo1tu5W7mF+v5vF6rrGZrq55vPLWAHApTD7P+NFV01BnaCuN7/qGJNVs7m7EH03jpOw7y3jqNMmcmonYrJSMVqg==', + 'signature' => 'GFpnv3MO7mcBef2RJ4Ayrl6RQakGM7AvlKhoTr3DUWnv+iBzwGy8YV34HIPoArz4tyqonHRlLsxPYq4ENPfGO99KrIS16z4RUq0wiCBGf+S8/K8lM9cE9EBKE9yrkTsSvZGICEusvxQ+cTfVr30bnavvi1wL1UuxxDBlJebda9FJ9HfaS24j4rT7K78oMguqDVM+4hhr6BMhcpUVV+kTpOaBpluw5pRDwUP3jJBmkkOa57WRKFcu0Lr/XIx/G0c8Si+BAfM//CTMstwp5XDFn4W9EYSStjNrvsULdV+tOKFwnowqts+UFzEDvmZ1g4qIMWUUPBF4/pjaiDqtMojgrA==', ], Http::STATUS_OK, ],