diff --git a/debian/control b/debian/control index 48bd0e9d..7bfb4c70 100644 --- a/debian/control +++ b/debian/control @@ -18,6 +18,7 @@ Build-depends: debhelper (>= 0.0.0), libsodium-dev, libudev-dev, libhidapi-dev, + libssl-dev, Package: nymea-plugin-anel diff --git a/pushnotifications/googleoauth2.cpp b/pushnotifications/googleoauth2.cpp new file mode 100644 index 00000000..4ede63b2 --- /dev/null +++ b/pushnotifications/googleoauth2.cpp @@ -0,0 +1,217 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2024, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project 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 +* Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License +* along with this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "googleoauth2.h" + +#include +#include +#include +#include + +#include +#include +#include + +#include "extern-plugininfo.h" + +GoogleOAuth2::GoogleOAuth2(NetworkAccessManager *networkManager, const ApiKey &apiKey, QObject *parent) + : QObject{parent}, + m_networkManager{networkManager}, + m_apiKey{apiKey} +{ + connect(&m_refreshTimer, &QTimer::timeout, this, &GoogleOAuth2::authorize); +} + +QString GoogleOAuth2::accessToken() const +{ + return m_accessToken; +} + +void GoogleOAuth2::authorize() +{ + qCDebug(dcPushNotifications()) << "OAuth2: Authorize client and request access token"; + + QVariantMap jwtHeader; + jwtHeader.insert("alg", "RS256"); + jwtHeader.insert("typ", "JWT"); + jwtHeader.insert("kid", m_apiKey.data("private_key_id")); + + QVariantMap jwtClaim; + jwtClaim.insert("iss", m_apiKey.data("client_email")); + jwtClaim.insert("scope", "https://www.googleapis.com/auth/firebase.messaging"); + jwtClaim.insert("aud", "https://oauth2.googleapis.com/token"); + jwtClaim.insert("iat", QDateTime::currentDateTime().toSecsSinceEpoch()); + jwtClaim.insert("exp", QDateTime::currentDateTime().addSecs(3600).toSecsSinceEpoch()); + + QByteArray headerData = QJsonDocument::fromVariant(jwtHeader).toJson(QJsonDocument::Compact); + QByteArray claimData = QJsonDocument::fromVariant(jwtClaim).toJson(QJsonDocument::Compact); + + // qCDebug(dcPushNotifications()) << "OAuth2: JWT Header:" << qUtf8Printable(headerData); + // qCDebug(dcPushNotifications()) << "OAuth2: JWT Claim:" << qUtf8Printable(claimData); + + QString dataToSign = QString("%1.%2").arg(QString(headerData.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals))) + .arg(QString(claimData.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals))); + + QByteArray signature = signData(dataToSign.toUtf8(), m_apiKey.data("private_key")); + QString jwt = QString("%1.%2").arg(dataToSign).arg(QString(signature.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals))); + + // qCDebug(dcPushNotifications()) << "OAuth2: JWT for bearer access token request:" << jwt; + + QNetworkRequest request(QUrl("https://oauth2.googleapis.com/token")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QUrlQuery payloadQuery; + payloadQuery.addQueryItem("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"); + payloadQuery.addQueryItem("assertion", jwt); + + qCDebug(dcPushNotifications()) << "OAuth2: Request access token from" << request.url().toString(); + + QNetworkReply *reply = m_networkManager->post(request, payloadQuery.toString().toUtf8()); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, this, [this, reply, request](){ + + QByteArray data = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcPushNotifications()) << "OAuth2: Failed to request access token from" << request.url().toString() << reply->errorString(); + if (!data.isEmpty()) + qCWarning(dcPushNotifications()) << "OAuth2: Response data:" << qUtf8Printable(data); + + setAuthenticated(false); + + // Retry in 30 seconds, maybe we are not online yet + m_refreshTimer.start(60 * 1000); + return; + } + + //qCDebug(dcPushNotifications()) << "OAuth2: Request access token response" << qUtf8Printable(data); + + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcPushNotifications()) << "OAuth2: Failed to get access token. Response data JSON error:" << error.errorString(); + setAuthenticated(false); + return; + } + + QVariantMap responseMap = jsonDoc.toVariant().toMap(); + if (!responseMap.contains("access_token")) { + qCWarning(dcPushNotifications()) << "OAuth2: Failed to get access token. Response has no property \"access_token\"" << qUtf8Printable(data); + setAuthenticated(false); + return; + } + + if (!responseMap.contains("expires_in")) { + qCWarning(dcPushNotifications()) << "OAuth2: Failed to get access token. Response has no property \"expires_in\"" << qUtf8Printable(data); + setAuthenticated(false); + return; + } + + uint expiresIn = responseMap.value("expires_in").toUInt(); + uint refreshTimeout = expiresIn - 120; // Refresh two minutes before expiration + qCDebug(dcPushNotifications()) << "The token will expire in" << expiresIn << "seconds. Refreshing access token in" << refreshTimeout << "seconds."; + m_refreshTimer.start(refreshTimeout * 1000); + + QString accessToken = responseMap.value("access_token").toString(); + if (accessToken.isEmpty()) { + qCWarning(dcPushNotifications()) << "OAuth2: Received empty access token."; + setAuthenticated(false); + return; + } + + setAuthenticated(true); + + if (m_accessToken != accessToken) { + qCDebug(dcPushNotifications()) << "OAuth2: New access token available."; + m_accessToken = accessToken; + emit accessTokenChanged(m_accessToken); + } + }); + +} + +bool GoogleOAuth2::authenticated() const +{ + return m_authenticated; +} + +void GoogleOAuth2::setAuthenticated(bool authenticated) +{ + if (m_authenticated == authenticated) + return; + + m_authenticated = authenticated; + emit authenticatedChanged(m_authenticated); + + if (m_authenticated) { + qCDebug(dcPushNotifications()) << "OAuth2: Authenticated"; + } else { + qCWarning(dcPushNotifications()) << "OAuth2: Not authenticated any more."; + if (!m_accessToken.isEmpty()) { + qCWarning(dcPushNotifications()) << "OAuth2: Forgetting current access token."; + m_accessToken.clear(); + } + } +} + +QByteArray GoogleOAuth2::signData(const QByteArray &data, const QByteArray &key) +{ + // Inspired by: https://jorisvergeer.nl/2023/03/22/c-qt-openssl-jwt-minimalistic-implementation-to-create-a-signed-jtw-token/ + + // qCDebug(dcPushNotifications()) << "Signing data" << qUtf8Printable(data); + + QSharedPointer bioSet = QSharedPointer(BIO_new_mem_buf(key.constData(), -1), &BIO_free_all); + if (!bioSet) { + qCWarning(dcPushNotifications()) << "Failed to create data buffer for signing"; + return QByteArray(); + } + + QSharedPointer rsaKey = QSharedPointer(PEM_read_bio_RSAPrivateKey(bioSet.data(), nullptr, nullptr, nullptr), &RSA_free); + if (!rsaKey) { + qCWarning(dcPushNotifications()) << "Failed to load private key for singing JWT into buffer"; + return QByteArray(); + } + + QByteArray sha256_hash(SHA256_DIGEST_LENGTH, 0); + SHA256(reinterpret_cast(data.constData()), data.length(), + reinterpret_cast(sha256_hash.data())); + + uint signatureLength = 0; + QByteArray signatureBuffer(RSA_size(rsaKey.data()), 0); + + if (RSA_sign(NID_sha256, + reinterpret_cast(sha256_hash.data()), SHA256_DIGEST_LENGTH, + reinterpret_cast(signatureBuffer.data()), &signatureLength, rsaKey.data()) != 1) { + qCWarning(dcPushNotifications()) << "Failed to signing data from JWT"; + return QByteArray(); + } + + return signatureBuffer.left(signatureLength); +} + diff --git a/pushnotifications/googleoauth2.h b/pushnotifications/googleoauth2.h new file mode 100644 index 00000000..e96356ea --- /dev/null +++ b/pushnotifications/googleoauth2.h @@ -0,0 +1,72 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2024, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project 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 +* Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License +* along with this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef GOOGLEOAUTH2_H +#define GOOGLEOAUTH2_H + +#include +#include +#include + +#include +#include + +// https://developers.google.com/identity/protocols/oauth2/service-account + +class GoogleOAuth2 : public QObject +{ + Q_OBJECT +public: + explicit GoogleOAuth2(NetworkAccessManager *networkManager, const ApiKey &apiKey, QObject *parent = nullptr); + + QString accessToken() const; + bool authenticated() const; + +public slots: + void authorize(); + +signals: + void authenticatedChanged(bool authenticated); + void accessTokenChanged(QString accessToken); + +private: + NetworkAccessManager *m_networkManager; + ApiKey m_apiKey; + + QTimer m_refreshTimer; + bool m_authenticated = false; + QString m_accessToken; + + void setAuthenticated(bool authenticated); + + QByteArray signData(const QByteArray &data, const QByteArray &key); +}; + +#endif // GOOGLEOAUTH2_H diff --git a/pushnotifications/integrationpluginpushnotifications.cpp b/pushnotifications/integrationpluginpushnotifications.cpp index af9c8b96..24d118aa 100644 --- a/pushnotifications/integrationpluginpushnotifications.cpp +++ b/pushnotifications/integrationpluginpushnotifications.cpp @@ -1,6 +1,6 @@ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -* Copyright 2013 - 2020, nymea GmbH +* Copyright 2013 - 2024, nymea GmbH * Contact: contact@nymea.io * * This file is part of nymea. @@ -36,39 +36,8 @@ #include -// Example payload for Firebase + GCM -//{ -// "android": { -// "notification": { -// "sound": "default" -// }, -// "priority": "high" -// }, -// "data": { -// "body": "text", -// "title": "title" -// }, -// "to": "" -//} - - -// Example payload for Firebase + APNs -//{ -// "apns": { -// "headers": { -// "apns-priority": "10" -// } -// }, -// "notification": { -// "body": "text", -// "sound": "default", -// "title": "title" -// }, -// "to": "" -//} - - -IntegrationPluginPushNotifications::IntegrationPluginPushNotifications(QObject* parent): IntegrationPlugin (parent) +IntegrationPluginPushNotifications::IntegrationPluginPushNotifications(QObject* parent) + : IntegrationPlugin{parent} { } @@ -77,6 +46,11 @@ IntegrationPluginPushNotifications::~IntegrationPluginPushNotifications() } +void IntegrationPluginPushNotifications::init() +{ + +} + void IntegrationPluginPushNotifications::setupThing(ThingSetupInfo *info) { Thing *thing = info->thing(); @@ -101,10 +75,20 @@ void IntegrationPluginPushNotifications::setupThing(ThingSetupInfo *info) } // In case of Firebase, check if we have the required API key - if (pushService.startsWith("FB") && apiKeyStorage()->requestKey("firebase").data("apiKey").isEmpty()) { - //: Error setting up thing - info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Firebase server API key not installed.")); - return; + if (pushService.startsWith("FB")) { + ApiKey apiKey = apiKeyStorage()->requestKey("firebase"); + if (apiKey.data("project_id").isEmpty() || apiKey.data("private_key_id").isEmpty() || + apiKey.data("private_key").isEmpty() || apiKey.data("client_email").isEmpty()) { + //: Error setting up thing + info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Firebase server API key not installed.")); + return; + } + } + + if (!m_google) { + qCDebug(dcPushNotifications()) << "Creating google OAuth2 client..."; + m_google = new GoogleOAuth2(hardwareManager()->networkManager(), apiKeyStorage()->requestKey("firebase"), this); + m_google->authorize(); } info->finish(Thing::ThingErrorNoError); @@ -123,24 +107,24 @@ void IntegrationPluginPushNotifications::executeAction(ThingActionInfo *info) QString body = action.param(pushNotificationsNotifyActionBodyParamTypeId).value().toString(); QString data = action.paramValue(pushNotificationsNotifyActionDataParamTypeId).toString(); QString notificationId = action.paramValue(pushNotificationsNotifyActionNotificationIdParamTypeId).toString(); - bool remove = action.paramValue(pushNotificationsNotifyActionRemoveParamTypeId).toBool(); bool sound = action.paramValue(pushNotificationsNotifyActionSoundParamTypeId).toBool(); if (pushService != "None" && token.isEmpty()) { - return info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Push notifications need to be reconfigured.")); + info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Push notifications need to be reconfigured.")); + return; } if (notificationId.isEmpty()) { notificationId = QUuid::createUuid().toString(); } - - QVariantMap nymeaData; // FIXME: This is quite ugly but there isn't an API that allows to retrieve the server UUID yet NymeaSettings settings(NymeaSettings::SettingsRoleGlobal); settings.beginGroup("nymead"); QUuid serverUuid = settings.value("uuid").toUuid(); settings.endGroup(); + + QVariantMap nymeaData; nymeaData.insert("serverUuid", serverUuid); nymeaData.insert("data", data); @@ -149,57 +133,52 @@ void IntegrationPluginPushNotifications::executeAction(ThingActionInfo *info) if (pushService.startsWith("FB")) { - ApiKey apiKey = apiKeyStorage()->requestKey("firebase"); - if (apiKey.data("apiKey").isEmpty()) { - info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Firebase server API key not installed.")); + if (!m_google->authenticated()) { + qCWarning(dcPushNotifications()) << "Google OAUth2 client is not authorized. Retry autorizing ..."; + info->finish(Thing::ThingErrorAuthenticationFailure, QT_TR_NOOP("Cannot access notification service. Please try again later.")); + m_google->authorize(); return; } - request = QNetworkRequest(QUrl("https://fcm.googleapis.com/fcm/send")); - request.setRawHeader("Authorization", "key=" + apiKey.data("apiKey")); + ApiKey apiKey = apiKeyStorage()->requestKey("firebase"); + request = QNetworkRequest(QUrl(QString("https://fcm.googleapis.com/v1/projects/%1/messages:send").arg(QString::fromUtf8(apiKey.data("project_id"))))); + request.setRawHeader("Authorization", "Bearer " + m_google->accessToken().toUtf8()); request.setRawHeader("Content-Type", "application/json"); - payload.insert("to", token.toUtf8().trimmed()); + // https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages + QVariantMap message; + message.insert("token", token.toUtf8().trimmed()); QVariantMap notification; notification.insert("title", title); notification.insert("body", body); - notification.insert("nymeaData", nymeaData); - notification.insert("notificationId", notificationId); - notification.insert("remove", remove); + message.insert("notification", notification); + // The data map must be a Map + QVariantMap dataMap; + dataMap.insert("nymeaData", QString::fromUtf8(QJsonDocument::fromVariant(nymeaData).toJson(QJsonDocument::Compact))); + dataMap.insert("sound", sound ? "true" : "false"); + message.insert("data", dataMap); if (pushService == "FB-GCM") { - notification.insert("sound", sound); - QVariantMap android; android.insert("priority", "high"); - payload.insert("android", android); - payload.insert("data", notification); + message.insert("android", android); } else if (pushService == "FB-APNs") { - if (sound) { - notification.insert("sound", "default"); - } - QVariantMap headers; - headers.insert("apns-priority", sound ? "10" : "1"); - headers.insert("apns-collapse-id", notificationId); + headers.insert("apns-priority", "10"); QVariantMap apns; apns.insert("headers", headers); - notification.insert("tag", notificationId); - - payload.insert("notification", notification); - payload.insert("apns", apns); - - payload.insert("collapse_key", notificationId); + message.insert("apns", apns); } + payload.insert("message", message); } else if (pushService == "UBPorts") { request = QNetworkRequest(QUrl("https://push.ubports.com/notify")); @@ -234,10 +213,11 @@ void IntegrationPluginPushNotifications::executeAction(ThingActionInfo *info) qCDebug(dcPushNotifications()) << "Sending notification" << request.url().toString() << qUtf8Printable(QJsonDocument::fromVariant(payload).toJson()); QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(payload).toJson(QJsonDocument::Compact)); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); - connect(reply, &QNetworkReply::finished, info, [reply, pushService, info, this]{ + connect(reply, &QNetworkReply::finished, info, [reply, pushService, info]{ + if (reply->error() != QNetworkReply::NoError) { qCWarning(dcPushNotifications()) << "Push message sending failed for" << info->thing()->name() << info->thing()->id() << reply->errorString() << reply->error(); - emit info->finish(Thing::ThingErrorHardwareNotAvailable); + info->finish(Thing::ThingErrorHardwareNotAvailable); return; } @@ -253,28 +233,11 @@ void IntegrationPluginPushNotifications::executeAction(ThingActionInfo *info) } QVariantMap replyMap = jsonDoc.toVariant().toMap(); - // qDebug(dcPushNotifications) << qUtf8Printable(jsonDoc.toJson()); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (pushService == "FB-GCM" || pushService == "FB-APNs") { - if (replyMap.value("success").toInt() != 1) { + qDebug(dcPushNotifications) << status << qUtf8Printable(jsonDoc.toJson()); - // While GCM seems rock solid, APNs fails rather often with Internal Server Error. - // According to Firebase support this is "expected" and one should retry with a exponential back-off timer. - // As we only have 30 secs until the info times out, let's try repeatedly until the info object dies. - // In my tests, so far it succeeded every time on the second attempt. - // https://stackoverflow.com/questions/63382257/firebase-messaging-fails-sporadically-with-internal-error - if (replyMap.value("results").toList().count() > 0 && replyMap.value("results").toList().first().toMap().value("error").toString() == "InternalServerError") { - qCDebug(dcPushNotifications()) << "Sending push message failed. Retrying..."; - executeAction(info); - return; - } - - // On any other error, bail out... - qCWarning(dcPushNotifications()) << "Error sending push notification:" << qUtf8Printable(jsonDoc.toJson()); - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - } else if (pushService == "UBPorts") { + if (pushService == "UBPorts") { if (!replyMap.value("ok").toBool()) { qCWarning(dcPushNotifications()) << "Error sending push notification:" << qUtf8Printable(jsonDoc.toJson()); info->finish(Thing::ThingErrorHardwareFailure); diff --git a/pushnotifications/integrationpluginpushnotifications.h b/pushnotifications/integrationpluginpushnotifications.h index 73312627..69e98ef7 100644 --- a/pushnotifications/integrationpluginpushnotifications.h +++ b/pushnotifications/integrationpluginpushnotifications.h @@ -1,6 +1,6 @@ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -* Copyright 2013 - 2020, nymea GmbH +* Copyright 2013 - 2024, nymea GmbH * Contact: contact@nymea.io * * This file is part of nymea. @@ -33,6 +33,7 @@ #include "integrations/integrationplugin.h" +#include "googleoauth2.h" #include "extern-plugininfo.h" class IntegrationPluginPushNotifications: public IntegrationPlugin @@ -45,6 +46,7 @@ class IntegrationPluginPushNotifications: public IntegrationPlugin public: explicit IntegrationPluginPushNotifications(QObject *parent = nullptr); ~IntegrationPluginPushNotifications() override; + void init() override; void setupThing(ThingSetupInfo *info) override; void executeAction(ThingActionInfo *info) override; @@ -52,6 +54,7 @@ public: private: QHash m_tokenParamTypeIds; QByteArray m_firebaseServerToken; + GoogleOAuth2 *m_google = nullptr; }; #endif diff --git a/pushnotifications/pushnotifications.pro b/pushnotifications/pushnotifications.pro index ac1ed1f7..f6d81fd7 100644 --- a/pushnotifications/pushnotifications.pro +++ b/pushnotifications/pushnotifications.pro @@ -4,10 +4,15 @@ TARGET = $$qtLibraryTarget(nymea_integrationpluginpushnotifications) QT+= network +# For RSA signing JWT +PKGCONFIG += openssl + SOURCES += \ + googleoauth2.cpp \ integrationpluginpushnotifications.cpp HEADERS += \ + googleoauth2.h \ integrationpluginpushnotifications.h