From f77d94ef7b1bdaf897892b453561b086d0427601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Fri, 24 Oct 2025 16:25:59 +0200 Subject: [PATCH] Add initial test for thing based authentication --- libnymea-core/jsonrpc/integrationshandler.cpp | 196 ++++++++------- libnymea-core/jsonrpc/integrationshandler.h | 7 +- .../jsonrpc/jsonrpcserverimplementation.cpp | 2 +- libnymea-core/usermanager/usermanager.cpp | 56 +++-- libnymea-core/usermanager/usermanager.h | 5 +- libnymea/jsonrpc/jsonhandler.h | 1 - tests/auto/usermanager/testusermanager.cpp | 237 ++++++++++++------ tests/auto/usermanager/testusermanager.h | 125 +++++++++ tests/auto/usermanager/usermanager.pro | 3 +- 9 files changed, 435 insertions(+), 197 deletions(-) create mode 100644 tests/auto/usermanager/testusermanager.h diff --git a/libnymea-core/jsonrpc/integrationshandler.cpp b/libnymea-core/jsonrpc/integrationshandler.cpp index eef33a47..e9c74051 100644 --- a/libnymea-core/jsonrpc/integrationshandler.cpp +++ b/libnymea-core/jsonrpc/integrationshandler.cpp @@ -131,12 +131,12 @@ IntegrationsHandler::IntegrationsHandler(ThingManager *thingManager, QObject *pa params.clear(); returns.clear(); description = "Add a new thing to the system. " - "Only things with a setupMethod of SetupMethodJustAdd can be added this way. " - "For things with a setupMethod different than SetupMethodJustAdd, use PairThing. " - "Things with CreateMethodJustAdd require all parameters to be supplied here. " - "Things with CreateMethodDiscovery require the use of a thingDescriptorId. For discovered " - "things, params are not required and will be taken from the ThingDescriptor, however, they " - "may be overridden by supplying thingParams."; + "Only things with a setupMethod of SetupMethodJustAdd can be added this way. " + "For things with a setupMethod different than SetupMethodJustAdd, use PairThing. " + "Things with CreateMethodJustAdd require all parameters to be supplied here. " + "Things with CreateMethodDiscovery require the use of a thingDescriptorId. For discovered " + "things, params are not required and will be taken from the ThingDescriptor, however, they " + "may be overridden by supplying thingParams."; params.insert("o:thingClassId", enumValueName(Uuid)); params.insert("name", enumValueName(String)); params.insert("o:thingDescriptorId", enumValueName(Uuid)); @@ -148,23 +148,23 @@ IntegrationsHandler::IntegrationsHandler(ThingManager *thingManager, QObject *pa params.clear(); returns.clear(); description = "Pair a new thing. " - "Use this to set up or reconfigure things for ThingClasses with a setupMethod different than SetupMethodJustAdd. " - "Depending on the CreateMethod and whether a new thing is set up or an existing one is reconfigured, different parameters " - "are required:\n" - "CreateMethodJustAdd takes the thingClassId and the parameters you want to have with that thing. " - "If an existing thing should be reconfigured, the thingId of said thing should be given additionally.\n" - "CreateMethodDiscovery requires the use of a thingDescriptorId, previously obtained with DiscoverThings. Optionally, " - "parameters can be overridden with the give thingParams. ThingDescriptors containing a thingId will reconfigure an " - "existing thing, descriptors without a thingId will add a new thing to the system.\n" - "If success is true, the return values will contain a pairingTransactionId, a displayMessage and " - "the setupMethod. Depending on the setupMethod, the application should present the use an appropriate login mask, " - "that is, For SetupMethodDisplayPin the user should enter a pin that is displayed on the device or online service, for SetupMethodEnterPin the " - "application should present the given PIN so the user can enter it on the device or online service. For SetupMethodPushButton, the displayMessage " - "shall be presented to the user as informational hints to press a button on the device. For SetupMethodUserAndPassword a login " - "mask for a user and password login should be presented to the user. In case of SetupMethodOAuth, an OAuth URL will be returned " - "which shall be opened in a web view to allow the user logging in.\n" - "Once the login procedure has completed, the application shall proceed with ConfirmPairing, providing the results of the pairing " - "procedure."; + "Use this to set up or reconfigure things for ThingClasses with a setupMethod different than SetupMethodJustAdd. " + "Depending on the CreateMethod and whether a new thing is set up or an existing one is reconfigured, different parameters " + "are required:\n" + "CreateMethodJustAdd takes the thingClassId and the parameters you want to have with that thing. " + "If an existing thing should be reconfigured, the thingId of said thing should be given additionally.\n" + "CreateMethodDiscovery requires the use of a thingDescriptorId, previously obtained with DiscoverThings. Optionally, " + "parameters can be overridden with the give thingParams. ThingDescriptors containing a thingId will reconfigure an " + "existing thing, descriptors without a thingId will add a new thing to the system.\n" + "If success is true, the return values will contain a pairingTransactionId, a displayMessage and " + "the setupMethod. Depending on the setupMethod, the application should present the use an appropriate login mask, " + "that is, For SetupMethodDisplayPin the user should enter a pin that is displayed on the device or online service, for SetupMethodEnterPin the " + "application should present the given PIN so the user can enter it on the device or online service. For SetupMethodPushButton, the displayMessage " + "shall be presented to the user as informational hints to press a button on the device. For SetupMethodUserAndPassword a login " + "mask for a user and password login should be presented to the user. In case of SetupMethodOAuth, an OAuth URL will be returned " + "which shall be opened in a web view to allow the user logging in.\n" + "Once the login procedure has completed, the application shall proceed with ConfirmPairing, providing the results of the pairing " + "procedure."; params.insert("o:thingClassId", enumValueName(Uuid)); params.insert("o:name", enumValueName(String)); params.insert("o:thingDescriptorId", enumValueName(Uuid)); @@ -180,9 +180,9 @@ IntegrationsHandler::IntegrationsHandler(ThingManager *thingManager, QObject *pa params.clear(); returns.clear(); description = "Confirm an ongoing pairing. For SetupMethodUserAndPassword, provide the username in the \"username\" field " - "and the password in the \"secret\" field. For SetupMethodEnterPin and provide the PIN in the \"secret\" " - "field. In case of SetupMethodOAuth, the previously opened web view will eventually be redirected to http://128.0.0.1:8888 " - "and the OAuth code as query parameters to this url. Provide the entire unmodified URL in the secret field."; + "and the password in the \"secret\" field. For SetupMethodEnterPin and provide the PIN in the \"secret\" " + "field. In case of SetupMethodOAuth, the previously opened web view will eventually be redirected to http://128.0.0.1:8888 " + "and the OAuth code as query parameters to this url. Provide the entire unmodified URL in the secret field."; params.insert("pairingTransactionId", enumValueName(Uuid)); params.insert("o:username", enumValueName(String)); params.insert("o:secret", enumValueName(String)); @@ -200,10 +200,10 @@ IntegrationsHandler::IntegrationsHandler(ThingManager *thingManager, QObject *pa params.clear(); returns.clear(); description = "Performs a thing discovery for things of the given thingClassId and returns the results. " - "This function may take a while to return. Note that this method will include all the found " - "things, that is, including things that may already have been added. Those things will have " - "thingId set to the id of the already added thing. Such results may be used to reconfigure " - "existing things and might be filtered in cases where only unknown things are of interest."; + "This function may take a while to return. Note that this method will include all the found " + "things, that is, including things that may already have been added. Those things will have " + "thingId set to the id of the already added thing. Such results may be used to reconfigure " + "existing things and might be filtered in cases where only unknown things are of interest."; params.insert("thingClassId", enumValueName(Uuid)); params.insert("o:discoveryParams", objectRef()); returns.insert("thingError", enumRef()); @@ -314,26 +314,26 @@ IntegrationsHandler::IntegrationsHandler(ThingManager *thingManager, QObject *pa params.clear(); returns.clear(); description = "Browse a thing. " - "If a ThingClass indicates a thing is browsable, this method will return the BrowserItems. If no " - "parameter besides the thingId is used, the root node of this thingwill be returned. Any " - "returned item which is browsable can be passed as node. Results will be children of the given node.\n" - "In case of an error during browsing, the error will be indicated and the displayMessage may contain " - "additional information for the user. The displayMessage will be translated. A client UI showing this " - "message to the user should be prepared for empty, but also longer strings."; + "If a ThingClass indicates a thing is browsable, this method will return the BrowserItems. If no " + "parameter besides the thingId is used, the root node of this thingwill be returned. Any " + "returned item which is browsable can be passed as node. Results will be children of the given node.\n" + "In case of an error during browsing, the error will be indicated and the displayMessage may contain " + "additional information for the user. The displayMessage will be translated. A client UI showing this " + "message to the user should be prepared for empty, but also longer strings."; params.insert("thingId", enumValueName(Uuid)); params.insert("o:itemId", enumValueName(String)); returns.insert("thingError", enumRef()); returns.insert("o:displayMessage", enumValueName(String)); - returns.insert("items", QVariantList() << objectRef("BrowserItem")); + returns.insert("o:items", QVariantList() << objectRef("BrowserItem")); registerMethod("BrowseThing", description, params, returns, Types::PermissionScopeNone); params.clear(); returns.clear(); description = "Get a single item from the browser. " - "This won't give any more info on an item than a regular BrowseThing call, but it allows to fetch " - "details of an item if only the ID is known.\n" - "In case of an error during browsing, the error will be indicated and the displayMessage may contain " - "additional information for the user. The displayMessage will be translated. A client UI showing this " - "message to the user should be prepared for empty, but also longer strings."; + "This won't give any more info on an item than a regular BrowseThing call, but it allows to fetch " + "details of an item if only the ID is known.\n" + "In case of an error during browsing, the error will be indicated and the displayMessage may contain " + "additional information for the user. The displayMessage will be translated. A client UI showing this " + "message to the user should be prepared for empty, but also longer strings."; params.insert("thingId", enumValueName(Uuid)); params.insert("o:itemId", enumValueName(String)); returns.insert("thingError", enumRef()); @@ -530,7 +530,7 @@ QHash IntegrationsHandler::cacheHashes() const return m_cacheHashes; } -JsonReply* IntegrationsHandler::GetVendors(const QVariantMap ¶ms, const JsonContext &context) const +JsonReply *IntegrationsHandler::GetVendors(const QVariantMap ¶ms, const JsonContext &context) const { Q_UNUSED(params) QVariantList vendors; @@ -544,7 +544,7 @@ JsonReply* IntegrationsHandler::GetVendors(const QVariantMap ¶ms, const Json return createReply(returns); } -JsonReply* IntegrationsHandler::GetThingClasses(const QVariantMap ¶ms, const JsonContext &context) const +JsonReply *IntegrationsHandler::GetThingClasses(const QVariantMap ¶ms, const JsonContext &context) const { QVariantMap returns; QVariantList thingClasses; @@ -603,13 +603,13 @@ JsonReply *IntegrationsHandler::DiscoverThings(const QVariantMap ¶ms, const } reply->setData(returns); - reply->finished(); + emit reply->finished(); }); return reply; } -JsonReply* IntegrationsHandler::GetPlugins(const QVariantMap ¶ms, const JsonContext &context) const +JsonReply *IntegrationsHandler::GetPlugins(const QVariantMap ¶ms, const JsonContext &context) const { Q_UNUSED(params) QVariantList plugins; @@ -643,7 +643,7 @@ JsonReply *IntegrationsHandler::GetPluginConfiguration(const QVariantMap ¶ms return createReply(returns); } -JsonReply* IntegrationsHandler::SetPluginConfiguration(const QVariantMap ¶ms) +JsonReply *IntegrationsHandler::SetPluginConfiguration(const QVariantMap ¶ms) { QVariantMap returns; PluginId pluginId = PluginId(params.value("pluginId").toString()); @@ -653,7 +653,7 @@ JsonReply* IntegrationsHandler::SetPluginConfiguration(const QVariantMap ¶ms return createReply(returns); } -JsonReply* IntegrationsHandler::AddThing(const QVariantMap ¶ms, const JsonContext &context) +JsonReply *IntegrationsHandler::AddThing(const QVariantMap ¶ms, const JsonContext &context) { ThingClassId thingClassId(params.value("thingClassId").toString()); QString thingName = params.value("name").toString(); @@ -670,7 +670,7 @@ JsonReply* IntegrationsHandler::AddThing(const QVariantMap ¶ms, const JsonCo QVariantMap returns; returns.insert("thingError", enumValueName(Thing::ThingErrorMissingParameter)); jsonReply->setData(returns); - jsonReply->finished(); + emit jsonReply->finished(); return jsonReply; } info = m_thingManager->addConfiguredThing(thingClassId, thingParams, thingName); @@ -690,7 +690,7 @@ JsonReply* IntegrationsHandler::AddThing(const QVariantMap ¶ms, const JsonCo returns.insert("thingId", info->thing()->id()); } jsonReply->setData(returns); - jsonReply->finished(); + emit jsonReply->finished(); }); return jsonReply; @@ -735,7 +735,7 @@ JsonReply *IntegrationsHandler::PairThing(const QVariantMap ¶ms, const JsonC } jsonReply->setData(returns); - jsonReply->finished(); + emit jsonReply->finished(); }); return jsonReply; @@ -762,20 +762,20 @@ JsonReply *IntegrationsHandler::ConfirmPairing(const QVariantMap ¶ms) returns.insert("thingId", info->thingId().toString()); } jsonReply->setData(returns); - jsonReply->finished(); + emit jsonReply->finished(); }); return jsonReply; } -JsonReply* IntegrationsHandler::GetThings(const QVariantMap ¶ms, const JsonContext &context) const +JsonReply *IntegrationsHandler::GetThings(const QVariantMap ¶ms, const JsonContext &context) const { QVariantMap returns; QVariantList things; - - if (NymeaCore::instance()->userManager()->restrictedThingAccess(context.token())) { - QList allowedThingIds = NymeaCore::instance()->userManager()->allowedThingIds(context.token()); + if (NymeaCore::instance()->userManager()->hasRestrictedThingAccess(context.token())) { + // Restricted things access + QList allowedThingIds = NymeaCore::instance()->userManager()->getAllowedThingIdsForToken(context.token()); if (params.contains("thingId")) { ThingId thingId(params.value("thingId").toString()); Thing *thing = m_thingManager->findConfiguredThing(thingId); @@ -795,7 +795,6 @@ JsonReply* IntegrationsHandler::GetThings(const QVariantMap ¶ms, const JsonC if (!allowedThingIds.contains(thing->id())) continue; - QVariantMap packedThing = pack(thing).toMap(); QString translatedSetupStatus = m_thingManager->translate(thing->pluginId(), thing->setupDisplayMessage(), context.locale()); if (!translatedSetupStatus.isEmpty()) { @@ -858,13 +857,11 @@ JsonReply *IntegrationsHandler::ReconfigureThing(const QVariantMap ¶ms, cons } connect(info, &ThingSetupInfo::finished, jsonReply, [info, jsonReply, locale](){ - QVariantMap returns; returns.insert("thingError", enumValueName(info->status())); returns.insert("displayMessage", info->translatedDisplayMessage(locale)); jsonReply->setData(returns); - jsonReply->finished(); - + emit jsonReply->finished(); }); return jsonReply; @@ -882,7 +879,7 @@ JsonReply *IntegrationsHandler::EditThing(const QVariantMap ¶ms) return createReply(statusToReply(status)); } -JsonReply* IntegrationsHandler::RemoveThing(const QVariantMap ¶ms) +JsonReply *IntegrationsHandler::RemoveThing(const QVariantMap ¶ms) { QVariantMap returns; ThingId thingId = ThingId(params.value("thingId").toString()); @@ -937,7 +934,7 @@ JsonReply *IntegrationsHandler::SetStateFilter(const QVariantMap ¶ms) return createReply(statusToReply(status)); } -JsonReply* IntegrationsHandler::GetEventTypes(const QVariantMap ¶ms, const JsonContext &context) const +JsonReply *IntegrationsHandler::GetEventTypes(const QVariantMap ¶ms, const JsonContext &context) const { ThingClass thingClass = m_thingManager->findThingClass(ThingClassId(params.value("thingClassId").toString())); ThingClass translatedThingClass = m_thingManager->translateThingClass(thingClass, context.locale()); @@ -947,7 +944,7 @@ JsonReply* IntegrationsHandler::GetEventTypes(const QVariantMap ¶ms, const J return createReply(returns); } -JsonReply* IntegrationsHandler::GetActionTypes(const QVariantMap ¶ms, const JsonContext &context) const +JsonReply *IntegrationsHandler::GetActionTypes(const QVariantMap ¶ms, const JsonContext &context) const { ThingClass thingClass = m_thingManager->findThingClass(ThingClassId(params.value("thingClassId").toString())); ThingClass translatedThingClass = m_thingManager->translateThingClass(thingClass, context.locale()); @@ -957,7 +954,7 @@ JsonReply* IntegrationsHandler::GetActionTypes(const QVariantMap ¶ms, const return createReply(returns); } -JsonReply* IntegrationsHandler::GetStateTypes(const QVariantMap ¶ms, const JsonContext &context) const +JsonReply *IntegrationsHandler::GetStateTypes(const QVariantMap ¶ms, const JsonContext &context) const { ThingClass thingClass = m_thingManager->findThingClass(ThingClassId(params.value("thingClassId").toString())); ThingClass translatedThingClass = m_thingManager->translateThingClass(thingClass, context.locale()); @@ -967,28 +964,34 @@ JsonReply* IntegrationsHandler::GetStateTypes(const QVariantMap ¶ms, const J return createReply(returns); } -JsonReply* IntegrationsHandler::GetStateValue(const QVariantMap ¶ms) const +JsonReply *IntegrationsHandler::GetStateValue(const QVariantMap ¶ms, const JsonContext &context) const { - Thing *thing = m_thingManager->findConfiguredThing(ThingId(params.value("thingId").toString())); - if (!thing) { + ThingId thingId(params.value("thingId").toString()); + if (!NymeaCore::instance()->userManager()->accessToThingGranted(thingId, context.token())) return createReply(statusToReply(Thing::ThingErrorThingNotFound)); - } + + Thing *thing = m_thingManager->findConfiguredThing(thingId); + if (!thing) + return createReply(statusToReply(Thing::ThingErrorThingNotFound)); + StateTypeId stateTypeId = StateTypeId(params.value("stateTypeId").toString()); - if (!thing->hasState(stateTypeId)) { + if (!thing->hasState(stateTypeId)) return createReply(statusToReply(Thing::ThingErrorStateTypeNotFound)); - } QVariantMap returns = statusToReply(Thing::ThingErrorNoError); returns.insert("value", thing->state(stateTypeId).value()); return createReply(returns); } -JsonReply *IntegrationsHandler::GetStateValues(const QVariantMap ¶ms) const +JsonReply *IntegrationsHandler::GetStateValues(const QVariantMap ¶ms, const JsonContext &context) const { - Thing *thing = m_thingManager->findConfiguredThing(ThingId(params.value("thingId").toString())); - if (!thing) { + ThingId thingId(params.value("thingId").toString()); + if (!NymeaCore::instance()->userManager()->accessToThingGranted(thingId, context.token())) + return createReply(statusToReply(Thing::ThingErrorThingNotFound)); + + Thing *thing = m_thingManager->findConfiguredThing(thingId); + if (!thing) return createReply(statusToReply(Thing::ThingErrorThingNotFound)); - } QVariantMap returns = statusToReply(Thing::ThingErrorNoError); returns.insert("values", pack(thing->states())); @@ -997,7 +1000,10 @@ JsonReply *IntegrationsHandler::GetStateValues(const QVariantMap ¶ms) const JsonReply *IntegrationsHandler::BrowseThing(const QVariantMap ¶ms, const JsonContext &context) const { - ThingId thingId = ThingId(params.value("thingId").toString()); + ThingId thingId(params.value("thingId").toString()); + if (!NymeaCore::instance()->userManager()->accessToThingGranted(thingId, context.token())) + return createReply(statusToReply(Thing::ThingErrorThingNotFound)); + QString itemId = params.value("itemId").toString(); JsonReply *jsonReply = createAsyncReply("BrowseThing"); @@ -1007,15 +1013,16 @@ JsonReply *IntegrationsHandler::BrowseThing(const QVariantMap ¶ms, const Jso QVariantMap returns = statusToReply(result->status()); QVariantList list; - foreach (const BrowserItem &item, result->items()) { + foreach (const BrowserItem &item, result->items()) list.append(packBrowserItem(item)); - } + returns.insert("items", list); - if (!result->displayMessage().isEmpty()) { + + if (!result->displayMessage().isEmpty()) returns.insert("displayMessage", result->translatedDisplayMessage(context.locale())); - } + jsonReply->setData(returns); - jsonReply->finished(); + emit jsonReply->finished(); }); return jsonReply; @@ -1023,10 +1030,11 @@ JsonReply *IntegrationsHandler::BrowseThing(const QVariantMap ¶ms, const Jso JsonReply *IntegrationsHandler::GetBrowserItem(const QVariantMap ¶ms, const JsonContext &context) const { - QVariantMap returns; - ThingId thingId = ThingId(params.value("thingId").toString()); - QString itemId = params.value("itemId").toString(); + ThingId thingId(params.value("thingId").toString()); + if (!NymeaCore::instance()->userManager()->accessToThingGranted(thingId, context.token())) + return createReply(statusToReply(Thing::ThingErrorThingNotFound)); + QString itemId = params.value("itemId").toString(); JsonReply *jsonReply = createAsyncReply("GetBrowserItem"); BrowserItemResult *result = m_thingManager->browserItemDetails(thingId, itemId, context.locale()); @@ -1039,7 +1047,7 @@ JsonReply *IntegrationsHandler::GetBrowserItem(const QVariantMap ¶ms, const params.insert("displayMessage", result->translatedDisplayMessage(context.locale())); } jsonReply->setData(params); - jsonReply->finished(); + emit jsonReply->finished(); }); return jsonReply; @@ -1048,6 +1056,9 @@ JsonReply *IntegrationsHandler::GetBrowserItem(const QVariantMap ¶ms, const JsonReply *IntegrationsHandler::ExecuteAction(const QVariantMap ¶ms, const JsonContext &context) { ThingId thingId(params.value("thingId").toString()); + if (!NymeaCore::instance()->userManager()->accessToThingGranted(thingId, context.token())) + return createReply(statusToReply(Thing::ThingErrorThingNotFound)); + ActionTypeId actionTypeId(params.value("actionTypeId").toString()); ParamList actionParams = unpack(params.value("params")); QLocale locale = context.locale(); @@ -1065,7 +1076,7 @@ JsonReply *IntegrationsHandler::ExecuteAction(const QVariantMap ¶ms, const J data.insert("displayMessage", info->translatedDisplayMessage(locale)); } jsonReply->setData(data); - jsonReply->finished(); + emit jsonReply->finished(); }); return jsonReply; @@ -1074,6 +1085,9 @@ JsonReply *IntegrationsHandler::ExecuteAction(const QVariantMap ¶ms, const J JsonReply *IntegrationsHandler::ExecuteBrowserItem(const QVariantMap ¶ms, const JsonContext &context) { ThingId thingId = ThingId(params.value("thingId").toString()); + if (!NymeaCore::instance()->userManager()->accessToThingGranted(thingId, context.token())) + return createReply(statusToReply(Thing::ThingErrorThingNotFound)); + QString itemId = params.value("itemId").toString(); BrowserAction action(thingId, itemId); @@ -1087,7 +1101,7 @@ JsonReply *IntegrationsHandler::ExecuteBrowserItem(const QVariantMap ¶ms, co data.insert("displayMessage", info->translatedDisplayMessage(context.locale())); } jsonReply->setData(data); - jsonReply->finished(); + emit jsonReply->finished(); }); return jsonReply; @@ -1096,6 +1110,9 @@ JsonReply *IntegrationsHandler::ExecuteBrowserItem(const QVariantMap ¶ms, co JsonReply *IntegrationsHandler::ExecuteBrowserItemAction(const QVariantMap ¶ms, const JsonContext &context) { ThingId thingId = ThingId(params.value("thingId").toString()); + if (!NymeaCore::instance()->userManager()->accessToThingGranted(thingId, context.token())) + return createReply(statusToReply(Thing::ThingErrorThingNotFound)); + QString itemId = params.value("itemId").toString(); ActionTypeId actionTypeId = ActionTypeId(params.value("actionTypeId").toString()); ParamList paramList = unpack(params.value("params")); @@ -1111,15 +1128,18 @@ JsonReply *IntegrationsHandler::ExecuteBrowserItemAction(const QVariantMap ¶ data.insert("displayMessage", info->translatedDisplayMessage(context.locale())); } jsonReply->setData(data); - jsonReply->finished(); + emit jsonReply->finished(); }); return jsonReply; } -JsonReply *IntegrationsHandler::GetIOConnections(const QVariantMap ¶ms) +JsonReply *IntegrationsHandler::GetIOConnections(const QVariantMap ¶ms, const JsonContext &context) { ThingId thingId = params.value("thingId").toUuid(); + if (!NymeaCore::instance()->userManager()->accessToThingGranted(thingId, context.token())) + return createReply(statusToReply(Thing::ThingErrorThingNotFound)); + IOConnections ioConnections = m_thingManager->ioConnections(thingId); QVariantMap returns; QVariant bla = pack(ioConnections); diff --git a/libnymea-core/jsonrpc/integrationshandler.h b/libnymea-core/jsonrpc/integrationshandler.h index e907062a..56c56f9c 100644 --- a/libnymea-core/jsonrpc/integrationshandler.h +++ b/libnymea-core/jsonrpc/integrationshandler.h @@ -61,8 +61,9 @@ public: Q_INVOKABLE JsonReply *GetEventTypes(const QVariantMap ¶ms, const JsonContext &context) const; Q_INVOKABLE JsonReply *GetActionTypes(const QVariantMap ¶ms, const JsonContext &context) const; Q_INVOKABLE JsonReply *GetStateTypes(const QVariantMap ¶ms, const JsonContext &context) const; - Q_INVOKABLE JsonReply *GetStateValue(const QVariantMap ¶ms) const; - Q_INVOKABLE JsonReply *GetStateValues(const QVariantMap ¶ms) const; + + Q_INVOKABLE JsonReply *GetStateValue(const QVariantMap ¶ms, const JsonContext &context) const; + Q_INVOKABLE JsonReply *GetStateValues(const QVariantMap ¶ms, const JsonContext &context) const; Q_INVOKABLE JsonReply *BrowseThing(const QVariantMap ¶ms, const JsonContext &context) const; Q_INVOKABLE JsonReply *GetBrowserItem(const QVariantMap ¶ms, const JsonContext &context) const; @@ -71,7 +72,7 @@ public: Q_INVOKABLE JsonReply *ExecuteBrowserItem(const QVariantMap ¶ms, const JsonContext &context); Q_INVOKABLE JsonReply *ExecuteBrowserItemAction(const QVariantMap ¶ms, const JsonContext &context); - Q_INVOKABLE JsonReply *GetIOConnections(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *GetIOConnections(const QVariantMap ¶ms, const JsonContext &context); Q_INVOKABLE JsonReply *ConnectIO(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *DisconnectIO(const QVariantMap ¶ms); diff --git a/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp b/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp index 78b5990a..20eef40e 100644 --- a/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp +++ b/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp @@ -656,7 +656,7 @@ void JsonRPCServerImplementation::processJsonPacket(TransportInterface *interfac return; } if (!handler->jsonMethods().contains(method)) { - qCWarning(dcJsonRpc()) << QString("JSON RPC method called for invalid method: %1.%2").arg(targetNamespace).arg(method); + qCWarning(dcJsonRpc()) << QString("JSON RPC method called for invalid method: %1.%2").arg(targetNamespace, method); sendErrorResponse(interface, clientId, commandId, "No such method"); return; } diff --git a/libnymea-core/usermanager/usermanager.cpp b/libnymea-core/usermanager/usermanager.cpp index e95701d7..a36b7872 100644 --- a/libnymea-core/usermanager/usermanager.cpp +++ b/libnymea-core/usermanager/usermanager.cpp @@ -195,14 +195,16 @@ UserManager::UserError UserManager::createUser(const QString &username, const QS QByteArray salt = QUuid::createUuid().toString().remove(QRegularExpression("[{}]")).toUtf8(); QByteArray hashedPassword = QCryptographicHash::hash(QString(password + salt).toUtf8(), QCryptographicHash::Sha512).toBase64(); QSqlQuery query(m_db); - query.prepare("INSERT INTO users(username, email, displayName, password, salt, scopes, allowedThingIds) VALUES(?, ?, ?, ?, ?, ?, ?);"); - query.addBindValue(username.toLower()); - query.addBindValue(email); - query.addBindValue(displayName); - query.addBindValue(QString::fromUtf8(hashedPassword)); - query.addBindValue(QString::fromUtf8(salt)); - query.addBindValue(Types::scopesToStringList(scopes).join(',')); - query.addBindValue(Types::thingIdsToStringList(thingIds).join(',')); + query.prepare("INSERT INTO users(username, email, displayName, password, salt, scopes, allowedThingIds)" + "VALUES(:username, :email, :displayName, :password, :salt, :scopes, :allowedThingIds);"); + + query.bindValue(":username", username.toLower()); + query.bindValue(":email", email); + query.bindValue(":displayName", displayName); + query.bindValue(":password", QString::fromUtf8(hashedPassword)); + query.bindValue(":salt", QString::fromUtf8(salt)); + query.bindValue(":scopes", Types::scopesToStringList(scopes).join(',')); + query.bindValue(":allowedThingIds", Types::thingIdsToStringList(thingIds).join(',')); query.exec(); if (query.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Error creating user:" << query.lastError().databaseText() << query.lastError().driverText(); @@ -241,14 +243,15 @@ UserManager::UserError UserManager::changePassword(const QString &username, cons // Update the password QByteArray salt = QUuid::createUuid().toString().remove(QRegularExpression("[{}]")).toUtf8(); QByteArray hashedPassword = QCryptographicHash::hash(QString(newPassword + salt).toUtf8(), QCryptographicHash::Sha512).toBase64(); - QString updatePasswordQueryString = QString("UPDATE users SET password = \"%1\", salt = \"%2\" WHERE lower(username) = \"%3\";") - .arg(QString::fromUtf8(hashedPassword)) - .arg(QString::fromUtf8(salt)) - .arg(username.toLower()); QSqlQuery updatePasswordQuery(m_db); - if (!updatePasswordQuery.exec(updatePasswordQueryString)) { - qCWarning(dcUserManager()) << "Unable to execute SQL query" << updatePasswordQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); + updatePasswordQuery.prepare("UPDATE users SET password = :password, salt = :salt WHERE lower(username) = :username;"); + updatePasswordQuery.bindValue(":password", QString::fromUtf8(hashedPassword)); + updatePasswordQuery.bindValue(":salt", QString::fromUtf8(salt)); + updatePasswordQuery.bindValue(":username", username.toLower()); + + if (!updatePasswordQuery.exec()) { + qCWarning(dcUserManager()) << "Unable to execute SQL query" << updatePasswordQuery.executedQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserErrorBackendError; } @@ -263,7 +266,7 @@ UserManager::UserError UserManager::changePassword(const QString &username, cons UserManager::UserError UserManager::removeUser(const QString &username) { - QString dropUserQueryString = QString("DELETE FROM users WHERE lower(username) =\"%1\";").arg(username.toLower()); + QString dropUserQueryString = QString("DELETE FROM users WHERE lower(username) = \"%1\";").arg(username.toLower()); QSqlQuery dropUserQuery(m_db); if (!dropUserQuery.exec(dropUserQueryString)) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << dropUserQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); @@ -291,25 +294,27 @@ UserManager::UserError UserManager::setUserScopes(const QString &username, Types return UserErrorInconsistantScopes; } - // Verify thing IDs, if there is no thing with this id, we don't save it and it will not be verified. // We don't return an error, the thing might have dissapeared QList thingIds; foreach (const ThingId &thingId, allowedThingIds) { if (NymeaCore::instance()->thingManager()->configuredThings().findById(thingId) == nullptr) { - qCWarning(dcUserManager()) << "Cannot set user scope for" << username << "because there is no thing with ID "; + qCWarning(dcUserManager()) << "The user" << username << "should have access to thing with ID" << thingId.toString() << "but there is no such thing. Ignoring value."; } else { thingIds.append(thingId); } } + QString scopesString = Types::scopesToStringList(scopes).join(','); QString allowedThingIdsString = Types::thingIdsToStringList(thingIds).join(','); + + qCDebug(dcUserManager()) << "Updating scopes of user" << username << "Scopes:" << scopes << "Allowed things:" << allowedThingIds; QSqlQuery setScopesQuery(m_db); setScopesQuery.prepare("UPDATE users SET scopes = :scopes, allowedThingIds = :allowedThingIds WHERE username = :username"); + setScopesQuery.bindValue(":username", username); setScopesQuery.bindValue(":scopes", scopesString); setScopesQuery.bindValue(":allowedThingIds", allowedThingIdsString); - setScopesQuery.bindValue(":username", username); if (!setScopesQuery.exec()) { qCWarning(dcUserManager()) << "Error updating scopes for user" << username << setScopesQuery.lastError().databaseText() << setScopesQuery.lastError().driverText(); return UserErrorBackendError; @@ -575,16 +580,23 @@ bool UserManager::verifyToken(const QByteArray &token) return true; } -bool UserManager::restrictedThingAccess(const QByteArray &token) const +bool UserManager::hasRestrictedThingAccess(const QByteArray &token) const { UserInfo ui = userInfo(tokenInfo(token).username()); return !ui.scopes().testFlag(Types::PermissionScopeAccessAllThings); } -QList UserManager::allowedThingIds(const QByteArray &token) const +bool UserManager::accessToThingGranted(const ThingId &thingId, const QByteArray &token) { - UserInfo ui = userInfo(tokenInfo(token).username()); - return ui.allowedThingIds(); + if (!hasRestrictedThingAccess(token)) + return true; + + return getAllowedThingIdsForToken(token).contains(thingId); +} + +QList UserManager::getAllowedThingIdsForToken(const QByteArray &token) const +{ + return userInfo(tokenInfo(token).username()).allowedThingIds(); } bool UserManager::initDB() diff --git a/libnymea-core/usermanager/usermanager.h b/libnymea-core/usermanager/usermanager.h index 3c233a13..5ee0bf0c 100644 --- a/libnymea-core/usermanager/usermanager.h +++ b/libnymea-core/usermanager/usermanager.h @@ -77,8 +77,9 @@ public: bool verifyToken(const QByteArray &token); - bool restrictedThingAccess(const QByteArray &token) const; - QList allowedThingIds(const QByteArray &token) const; + bool hasRestrictedThingAccess(const QByteArray &token) const; + bool accessToThingGranted(const ThingId &thingId, const QByteArray &token); + QList getAllowedThingIdsForToken(const QByteArray &token) const; signals: void userAdded(const QString &username); diff --git a/libnymea/jsonrpc/jsonhandler.h b/libnymea/jsonrpc/jsonhandler.h index 33ce4e58..df17cdf5 100644 --- a/libnymea/jsonrpc/jsonhandler.h +++ b/libnymea/jsonrpc/jsonhandler.h @@ -69,7 +69,6 @@ public: QVariantMap jsonMethods() const; QVariantMap jsonNotifications() const; - template static QString enumRef(); template static QString flagRef(); template static QString objectRef(); diff --git a/tests/auto/usermanager/testusermanager.cpp b/tests/auto/usermanager/testusermanager.cpp index 79b309e2..6f9bd92b 100644 --- a/tests/auto/usermanager/testusermanager.cpp +++ b/tests/auto/usermanager/testusermanager.cpp @@ -22,9 +22,7 @@ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ -#include - -#include "logging/logengine.h" +#include "testusermanager.h" #include "nymeacore.h" #include "nymeatestbase.h" #include "usermanager/usermanager.h" @@ -33,83 +31,10 @@ #include "../../utils/pushbuttonagent.h" +#include "../plugins/mock/extern-plugininfo.h" + using namespace nymeaserver; -class TestUsermanager: public NymeaTestBase -{ - Q_OBJECT -public: - TestUsermanager(QObject* parent = nullptr); - -private slots: - void initTestCase(); - - void init(); - - void loginValidation_data(); - void loginValidation(); - - void createUser(); - - void authenticate(); - - /* - Cases for push button auth: - - Case 1: regular pushbutton - - alice sends Users.RequestPushButtonAuth, gets "OK" back (if push button hardware is available) - - alice pushes the hardware button and gets a notification on jsonrpc containing the token for local auth - */ - void authenticatePushButton(); - - /* - Case 2: if we have an attacker in the network, he could try to call requestPushButtonAuth and - hope someone would eventually press the button and give him a token. In order to prevent this, - any previous attempt for a push button auth needs to be cancelled when a new request comes in: - - * Mallory does RequestPushButtonAuth, gets OK back - * Alice does RequestPushButtonAuth, - * Mallory receives a "PushButtonFailed" notification - * Alice receives OK - * Alice presses the hardware button - * Alice reveices a notification with token, mallory receives nothing - - Case 3: Mallory tries to hijack it back again - - * Mallory does RequestPushButtonAuth, gets OK back - * Alice does RequestPusButtonAuth, - * Alice gets ok reply, Mallory gets failed notification - * Mallory quickly does RequestPushButtonAuth again to win the fight - * Alice gets failed notification and can instruct the user to _not_ press the button now until procedure is restarted - */ - void authenticatePushButtonAuthInterrupt(); - - void authenticatePushButtonAuthConnectionDrop(); - - void createDuplicateUser(); - - void getTokens(); - - void removeToken(); - - void unauthenticatedCallAfterTokenRemove(); - - void changePassword(); - - void authenticateAfterPasswordChangeOK(); - - void authenticateAfterPasswordChangeFail(); - - void getUserInfo(); - - void testScopeConsitancy_data(); - void testScopeConsitancy(); - -private: - // m_apiToken is in testBase - QUuid m_tokenId; -}; - TestUsermanager::TestUsermanager(QObject *parent): NymeaTestBase(parent) { QCoreApplication::instance()->setOrganizationName("nymea-test"); @@ -639,5 +564,159 @@ void TestUsermanager::testScopeConsitancy() QCOMPARE(response.toMap().value("params").toMap().value("error").toString(), error); } -#include "testusermanager.moc" +void TestUsermanager::testRestrictedThingAccess() +{ + // Add 2 mock things + ThingId thingIdOne; + ThingId thingIdTwo; + + QString usernameAdmin = "admin"; + QString passwordAdmin = "Bla1234*"; + + QString usernameGuest = "guest"; + QString passwordGuest = "Bla1234+"; + + QVariant response; + QVariantList thingParams; + QVariantMap params; + + injectAndWait("JSONRPC.Hello"); + + // Create admin user + params.clear(); + params.insert("username", usernameAdmin); + params.insert("password", passwordAdmin); + response = injectAndWait("JSONRPC.CreateUser", params); + QVERIFY2(response.toMap().value("status").toString() == "success", "Error creating user"); + QVERIFY2(response.toMap().value("params").toMap().value("error").toString() == "UserErrorNoError", "Error creating user"); + + // Authenticate admin user + params.clear(); + params.insert("username", usernameAdmin); + params.insert("password", passwordAdmin); + params.insert("deviceName", "autotests"); + response = injectAndWait("JSONRPC.Authenticate", params); + QVERIFY2(response.toMap().value("status").toString() == "success", "Error authenticating"); + QVERIFY2(response.toMap().value("params").toMap().value("success").toString() == "true", "Error authenticating"); + + m_adminToken = response.toMap().value("params").toMap().value("token").toByteArray(); + + // Use the admin token for now + m_apiToken = m_adminToken; + + // Add thing one + QVariantMap httpportParamOne; + httpportParamOne.insert("paramTypeId", mockThingHttpportParamTypeId.toString()); + httpportParamOne.insert("value", m_mockThing1Port - 1); + thingParams << httpportParamOne; + + params.clear(); + params.insert("thingClassId", mockThingClassId); + params.insert("name", "Test thing available for all users"); + params.insert("thingParams", thingParams); + response = injectAndWait("Integrations.AddThing", params); + verifyError(response, "thingError", enumValueName(Thing::ThingErrorNoError)); + thingIdOne = ThingId(response.toMap().value("params").toMap().value("thingId").toString()); + + // Add thing two + QVariantMap httpportParamTwo; + httpportParamOne.insert("paramTypeId", mockThingHttpportParamTypeId.toString()); + httpportParamOne.insert("value", m_mockThing1Port - 2); + thingParams.clear(); + thingParams << httpportParamOne; + + params.clear(); + params.insert("thingClassId", mockThingClassId); + params.insert("name", "Test thing available for all users"); + params.insert("thingParams", thingParams); + response = injectAndWait("Integrations.AddThing", params); + verifyError(response, "thingError", enumValueName(Thing::ThingErrorNoError)); + thingIdTwo = ThingId(response.toMap().value("params").toMap().value("thingId").toString()); + + + // Create guest user + QStringList scopes; + scopes << "PermissionScopeControlThings"; + QVariantList allowedThingIds; + allowedThingIds << thingIdTwo; + + params.clear(); + params.insert("username", usernameGuest); + params.insert("password", passwordGuest); + params.insert("scopes", scopes); + params.insert("allowedThingIds", allowedThingIds); + response = injectAndWait("Users.CreateUser", params); + QVERIFY2(response.toMap().value("status").toString() == "success", "Error creating user"); + QVERIFY2(response.toMap().value("params").toMap().value("error").toString() == "UserErrorNoError", "Error creating user"); + + response = injectAndWait("Integrations.GetThings"); + QVariantList things = response.toMap().value("params").toMap().value("things").toList(); + //qCDebug(dcTests()) << qUtf8Printable(QJsonDocument::fromVariant(things).toJson(QJsonDocument::Indented)); + QVERIFY2(things.count() >= 2, "Expected to get 2 or more things as admin"); + + // Everything set up, now authenticate as guest + + // Authenticate guest user + params.clear(); + params.insert("username", usernameGuest); + params.insert("password", passwordGuest); + params.insert("deviceName", "autotests"); + response = injectAndWait("JSONRPC.Authenticate", params); + QVERIFY2(response.toMap().value("status").toString() == "success", "Error authenticating"); + QVERIFY2(response.toMap().value("params").toMap().value("success").toString() == "true", "Error authenticating"); + + m_guestToken = response.toMap().value("params").toMap().value("token").toByteArray(); + + // Use the admin token for now + m_apiToken = m_guestToken; + + // Try to access restricted thing + + response = injectAndWait("Integrations.GetThings"); + verifyError(response, "thingError", enumValueName(Thing::ThingErrorNoError)); + things = response.toMap().value("params").toMap().value("things").toList(); + QVERIFY2(things.count() == 1, "Expected to get exactly 1 things as guest"); + + // GetThings (access) + params.clear(); + params.insert("thingId", thingIdTwo); + response = injectAndWait("Integrations.GetThings", params); + verifyError(response, "thingError", enumValueName(Thing::ThingErrorNoError)); + + // GetThings (no access) + params.clear(); + params.insert("thingId", thingIdOne); + response = injectAndWait("Integrations.GetThings", params); + verifyError(response, "thingError", enumValueName(Thing::ThingErrorThingNotFound)); + + // GetStateValue + params.clear(); + params.insert("thingId", thingIdOne); + params.insert("stateTypeId", mockConnectedStateTypeId); + response = injectAndWait("Integrations.GetStateValue", params); + verifyError(response, "thingError", enumValueName(Thing::ThingErrorThingNotFound)); + + // BrowseThing + params.clear(); + params.insert("thingId", thingIdOne); + response = injectAndWait("Integrations.BrowseThing", params); + verifyError(response, "thingError", enumValueName(Thing::ThingErrorThingNotFound)); + + // GetBrowserItem + params.clear(); + params.insert("thingId", thingIdOne); + response = injectAndWait("Integrations.GetBrowserItem", params); + verifyError(response, "thingError", enumValueName(Thing::ThingErrorThingNotFound)); + + // Make sure notification get received from allowed thing + + // Make sure no notification will be recived from restricted thing + + + // Clean up + + +} + QTEST_MAIN(TestUsermanager) + diff --git a/tests/auto/usermanager/testusermanager.h b/tests/auto/usermanager/testusermanager.h new file mode 100644 index 00000000..b7d35098 --- /dev/null +++ b/tests/auto/usermanager/testusermanager.h @@ -0,0 +1,125 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2025, 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 General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU General Public License as published by the Free Software +* Foundation, GNU 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 General +* Public License for more details. +* +* You should have received a copy of the GNU 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 TESTUSERMANAGER_H +#define TESTUSERMANAGER_H + +#include +#include "nymeatestbase.h" + +using namespace nymeaserver; + +class TestUsermanager: public NymeaTestBase +{ + Q_OBJECT +public: + TestUsermanager(QObject* parent = nullptr); + +private slots: + void initTestCase(); + + void init(); + + void loginValidation_data(); + void loginValidation(); + + void createUser(); + + void authenticate(); + + /* + Cases for push button auth: + + Case 1: regular pushbutton + - alice sends Users.RequestPushButtonAuth, gets "OK" back (if push button hardware is available) + - alice pushes the hardware button and gets a notification on jsonrpc containing the token for local auth + */ + void authenticatePushButton(); + + /* + Case 2: if we have an attacker in the network, he could try to call requestPushButtonAuth and + hope someone would eventually press the button and give him a token. In order to prevent this, + any previous attempt for a push button auth needs to be cancelled when a new request comes in: + + * Mallory does RequestPushButtonAuth, gets OK back + * Alice does RequestPushButtonAuth, + * Mallory receives a "PushButtonFailed" notification + * Alice receives OK + * Alice presses the hardware button + * Alice reveices a notification with token, mallory receives nothing + + Case 3: Mallory tries to hijack it back again + + * Mallory does RequestPushButtonAuth, gets OK back + * Alice does RequestPusButtonAuth, + * Alice gets ok reply, Mallory gets failed notification + * Mallory quickly does RequestPushButtonAuth again to win the fight + * Alice gets failed notification and can instruct the user to _not_ press the button now until procedure is restarted + */ + void authenticatePushButtonAuthInterrupt(); + + void authenticatePushButtonAuthConnectionDrop(); + + void createDuplicateUser(); + + void getTokens(); + + void removeToken(); + + void unauthenticatedCallAfterTokenRemove(); + + void changePassword(); + + void authenticateAfterPasswordChangeOK(); + + void authenticateAfterPasswordChangeFail(); + + void getUserInfo(); + + void testScopeConsitancy_data(); + void testScopeConsitancy(); + + void testRestrictedThingAccess(); + +private: + // m_apiToken is in testBase + QUuid m_tokenId; + + void authenticateTestuser(const QString &username); + + QString m_usernameAdmin = "admin"; + QString m_usernameGuest = "guest"; + + QByteArray m_adminToken; + QByteArray m_guestToken; + +}; + +#endif // TESTUSERMANAGER_H diff --git a/tests/auto/usermanager/usermanager.pro b/tests/auto/usermanager/usermanager.pro index 636688fc..bccad73e 100644 --- a/tests/auto/usermanager/usermanager.pro +++ b/tests/auto/usermanager/usermanager.pro @@ -3,7 +3,8 @@ include(../autotests.pri) TARGET = nymeatestusermanager -HEADERS += ../../utils/pushbuttonagent.h +HEADERS += ../../utils/pushbuttonagent.h \ + testusermanager.h SOURCES += testusermanager.cpp \ ../../utils/pushbuttonagent.cpp