From 88aa22f3a21f879a9baef5b86db9e1c29ed4cddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Tue, 21 Oct 2025 14:51:09 +0200 Subject: [PATCH 01/10] UserManager: Add thing based user permissions --- libnymea-core/jsonrpc/integrationshandler.cpp | 73 ++++++++++++++----- libnymea-core/jsonrpc/usershandler.cpp | 6 +- libnymea-core/usermanager/userinfo.cpp | 10 +++ libnymea-core/usermanager/userinfo.h | 5 ++ libnymea-core/usermanager/usermanager.cpp | 12 +++ libnymea-core/usermanager/usermanager.h | 6 +- libnymea/integrations/thing.h | 2 +- libnymea/typeutils.h | 1 + 8 files changed, 94 insertions(+), 21 deletions(-) diff --git a/libnymea-core/jsonrpc/integrationshandler.cpp b/libnymea-core/jsonrpc/integrationshandler.cpp index 66976424..eef33a47 100644 --- a/libnymea-core/jsonrpc/integrationshandler.cpp +++ b/libnymea-core/jsonrpc/integrationshandler.cpp @@ -38,6 +38,9 @@ #include "integrations/browseritemresult.h" #include "ruleengine/ruleengine.h" +#include "nymeacore.h" +#include "usermanager/usermanager.h" + #include #include #include @@ -769,29 +772,65 @@ JsonReply* IntegrationsHandler::GetThings(const QVariantMap ¶ms, const JsonC { QVariantMap returns; QVariantList things; - if (params.contains("thingId")) { - Thing *thing = m_thingManager->findConfiguredThing(ThingId(params.value("thingId").toString())); - if (!thing) { - returns.insert("thingError", enumValueName(Thing::ThingErrorThingNotFound)); - return createReply(returns); - } else { - QVariantMap packedThing = pack(thing).toMap(); - QString translatedSetupStatus = m_thingManager->translate(thing->pluginId(), thing->setupDisplayMessage(), context.locale()); - if (!translatedSetupStatus.isEmpty()) { - packedThing["setupDisplayMessage"] = translatedSetupStatus; + + + if (NymeaCore::instance()->userManager()->restrictedThingAccess(context.token())) { + QList allowedThingIds = NymeaCore::instance()->userManager()->allowedThingIds(context.token()); + if (params.contains("thingId")) { + ThingId thingId(params.value("thingId").toString()); + Thing *thing = m_thingManager->findConfiguredThing(thingId); + if (!thing || !allowedThingIds.contains(thingId)) { + returns.insert("thingError", enumValueName(Thing::ThingErrorThingNotFound)); + return createReply(returns); + } else { + QVariantMap packedThing = pack(thing).toMap(); + QString translatedSetupStatus = m_thingManager->translate(thing->pluginId(), thing->setupDisplayMessage(), context.locale()); + if (!translatedSetupStatus.isEmpty()) { + packedThing["setupDisplayMessage"] = translatedSetupStatus; + } + things.append(packedThing); + } + } else { + foreach (Thing *thing, m_thingManager->configuredThings()) { + 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()) { + packedThing["setupDisplayMessage"] = translatedSetupStatus; + } + things.append(packedThing); } - things.append(packedThing); } } else { - foreach (Thing *thing, m_thingManager->configuredThings()) { - QVariantMap packedThing = pack(thing).toMap(); - QString translatedSetupStatus = m_thingManager->translate(thing->pluginId(), thing->setupDisplayMessage(), context.locale()); - if (!translatedSetupStatus.isEmpty()) { - packedThing["setupDisplayMessage"] = translatedSetupStatus; + // Unrestricted things access + if (params.contains("thingId")) { + Thing *thing = m_thingManager->findConfiguredThing(ThingId(params.value("thingId").toString())); + if (!thing) { + returns.insert("thingError", enumValueName(Thing::ThingErrorThingNotFound)); + return createReply(returns); + } else { + QVariantMap packedThing = pack(thing).toMap(); + QString translatedSetupStatus = m_thingManager->translate(thing->pluginId(), thing->setupDisplayMessage(), context.locale()); + if (!translatedSetupStatus.isEmpty()) { + packedThing["setupDisplayMessage"] = translatedSetupStatus; + } + things.append(packedThing); + } + } else { + foreach (Thing *thing, m_thingManager->configuredThings()) { + QVariantMap packedThing = pack(thing).toMap(); + QString translatedSetupStatus = m_thingManager->translate(thing->pluginId(), thing->setupDisplayMessage(), context.locale()); + if (!translatedSetupStatus.isEmpty()) { + packedThing["setupDisplayMessage"] = translatedSetupStatus; + } + things.append(packedThing); } - things.append(packedThing); } } + returns.insert("thingError", enumValueName(Thing::ThingErrorNoError)); returns.insert("things", things); return createReply(returns); diff --git a/libnymea-core/jsonrpc/usershandler.cpp b/libnymea-core/jsonrpc/usershandler.cpp index 95abdd09..1f72026e 100644 --- a/libnymea-core/jsonrpc/usershandler.cpp +++ b/libnymea-core/jsonrpc/usershandler.cpp @@ -42,12 +42,13 @@ UsersHandler::UsersHandler(UserManager *userManager, QObject *parent): QString description; params.clear(); returns.clear(); - description = "Create a new user in the API with the given username and password. Use scopes to define the permissions for the new user. If no scopes are given, this user will be an admin user. Call Authenticate after this to obtain a device token for this user."; + description = "Create a new user in the API with the given username and password. Use scopes to define the permissions for the new user. If the user has not the permission \"PermissionScopeAccessAllThings\", the list of things this user has access to can be defined in the \"allowedThingIds\" property. If no scopes are given, this user will be an admin user. Call Authenticate after this to obtain a device token for this user."; params.insert("username", enumValueName(String)); params.insert("password", enumValueName(String)); params.insert("o:email", enumValueName(String)); params.insert("o:displayName", enumValueName(String)); params.insert("o:scopes", flagRef()); + params.insert("o:allowedThingIds", QVariantList() << enumValueName(Uuid)); returns.insert("error", enumRef()); registerMethod("CreateUser", description, params, returns); @@ -87,9 +88,10 @@ UsersHandler::UsersHandler(UserManager *userManager, QObject *parent): registerMethod("RemoveUser", description, params, returns); params.clear(); returns.clear(); - description = "Set the permissions (scopes) for a given user."; + description = "Set the permissions (scopes) for a given user. If the user has not the permission \"PermissionScopeAccessAllThings\" the list of thing IDs this user has access to can be defined in the \"allowedThingIds\" property."; params.insert("username", enumValueName(String)); params.insert("scopes", flagRef()); + params.insert("o:allowedThingIds", QVariantList() << enumValueName(Uuid)); returns.insert("error", enumRef()); registerMethod("SetUserScopes", description, params, returns); diff --git a/libnymea-core/usermanager/userinfo.cpp b/libnymea-core/usermanager/userinfo.cpp index d23df318..dae0e3fc 100644 --- a/libnymea-core/usermanager/userinfo.cpp +++ b/libnymea-core/usermanager/userinfo.cpp @@ -79,6 +79,16 @@ void UserInfo::setScopes(Types::PermissionScopes scopes) m_scopes = scopes; } +void UserInfo::setAllowedThingIds(const QList &allowedThingIds) +{ + m_allowedThingIds = allowedThingIds; +} + +QList UserInfo::allowedThingIds() const +{ + return m_allowedThingIds; +} + QVariant UserInfoList::get(int index) const { return QVariant::fromValue(at(index)); diff --git a/libnymea-core/usermanager/userinfo.h b/libnymea-core/usermanager/userinfo.h index 4051fd49..ca91aeca 100644 --- a/libnymea-core/usermanager/userinfo.h +++ b/libnymea-core/usermanager/userinfo.h @@ -56,11 +56,16 @@ public: Types::PermissionScopes scopes() const; void setScopes(Types::PermissionScopes scopes); + void setAllowedThingIds(const QList &allowedThingIds); + QList allowedThingIds() const; + private: QString m_username; QString m_email; QString m_displayName; Types::PermissionScopes m_scopes = Types::PermissionScopeNone; + QList m_allowedThingIds; + }; class UserInfoList: public QList diff --git a/libnymea-core/usermanager/usermanager.cpp b/libnymea-core/usermanager/usermanager.cpp index 0db89958..080f94ff 100644 --- a/libnymea-core/usermanager/usermanager.cpp +++ b/libnymea-core/usermanager/usermanager.cpp @@ -539,6 +539,18 @@ bool UserManager::verifyToken(const QByteArray &token) return true; } +bool UserManager::restrictedThingAccess(const QByteArray &token) const +{ + UserInfo ui = userInfo(tokenInfo(token).username()); + return !ui.scopes().testFlag(Types::PermissionScopeAccessAllThings); +} + +QList UserManager::allowedThingIds(const QByteArray &token) const +{ + UserInfo ui = userInfo(tokenInfo(token).username()); + return ui.allowedThingIds(); +} + bool UserManager::initDB() { m_db.close(); diff --git a/libnymea-core/usermanager/usermanager.h b/libnymea-core/usermanager/usermanager.h index a55d3837..3b313613 100644 --- a/libnymea-core/usermanager/usermanager.h +++ b/libnymea-core/usermanager/usermanager.h @@ -74,9 +74,11 @@ public: UserError removeToken(const QUuid &tokenId); - bool verifyToken(const QByteArray &token); + bool restrictedThingAccess(const QByteArray &token) const; + QList allowedThingIds(const QByteArray &token) const; + signals: void userAdded(const QString &username); void userRemoved(const QString &username); @@ -102,7 +104,9 @@ private: QPair m_pushButtonTransaction; }; + } + Q_DECLARE_METATYPE(nymeaserver::UserManager::UserError) #endif // USERMANAGER_H diff --git a/libnymea/integrations/thing.h b/libnymea/integrations/thing.h index b7ecbf7e..3c862213 100644 --- a/libnymea/integrations/thing.h +++ b/libnymea/integrations/thing.h @@ -87,7 +87,7 @@ public: ThingErrorItemNotFound, ThingErrorItemNotExecutable, ThingErrorUnsupportedFeature, - ThingErrorTimeout, + ThingErrorTimeout }; Q_ENUM(ThingError) diff --git a/libnymea/typeutils.h b/libnymea/typeutils.h index b8d9387b..10a25377 100644 --- a/libnymea/typeutils.h +++ b/libnymea/typeutils.h @@ -202,6 +202,7 @@ public: PermissionScopeNone = 0x0000, PermissionScopeControlThings = 0x0001, PermissionScopeConfigureThings = 0x0003, + PermissionScopeAccessAllThings = 0x0004, PermissionScopeExecuteRules = 0x0010, PermissionScopeConfigureRules = 0x0030, PermissionScopeAdmin = 0xFFFF, From e638c8cab24cc1c5d198dd616dbe795ff1cf67dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Wed, 22 Oct 2025 13:59:11 +0200 Subject: [PATCH 02/10] Add scope verification and tests --- libnymea-core/usermanager/usermanager.cpp | 92 +++++++++++--- libnymea-core/usermanager/usermanager.h | 4 +- tests/auto/usermanager/testusermanager.cpp | 137 ++++++++++++++++++--- 3 files changed, 196 insertions(+), 37 deletions(-) diff --git a/libnymea-core/usermanager/usermanager.cpp b/libnymea-core/usermanager/usermanager.cpp index 080f94ff..1715cbf5 100644 --- a/libnymea-core/usermanager/usermanager.cpp +++ b/libnymea-core/usermanager/usermanager.cpp @@ -96,7 +96,7 @@ UserManager::UserManager(const QString &dbName, QObject *parent): if (!initDB()) { qCWarning(dcUserManager()) << "Error initializing user database. Trying to correct it."; - if (QFileInfo(m_db.databaseName()).exists()) { + if (QFileInfo::exists(m_db.databaseName())) { rotate(m_db.databaseName()); if (!initDB()) { qCWarning(dcUserManager()) << "Error fixing user database. Giving up. Users can't be stored."; @@ -141,6 +141,7 @@ UserInfoList UserManager::users() const qCWarning(dcUserManager()) << "Unable to execute SQL query" << userQuery << m_db.lastError().databaseText() << m_db.lastError().driverText(); return users; } + while (resultQuery.next()) { UserInfo info = UserInfo(resultQuery.value("username").toString()); info.setEmail(resultQuery.value("email").toString()); @@ -164,6 +165,11 @@ UserManager::UserError UserManager::createUser(const QString &username, const QS return UserErrorBadPassword; } + if (!validateScopes(scopes)) { + // The method warns about he specific validation + return UserErrorInconsistantScopes; + } + QSqlQuery checkForDuplicateUserQuery(m_db); checkForDuplicateUserQuery.prepare("SELECT * FROM users WHERE lower(username) = ?;"); // Note: We're using toLower() on the username mainly for the reason that in old versions the username used to be an email address @@ -223,9 +229,9 @@ UserManager::UserError UserManager::changePassword(const QString &username, cons 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()); + .arg(QString::fromUtf8(hashedPassword)) + .arg(QString::fromUtf8(salt)) + .arg(username.toLower()); QSqlQuery updatePasswordQuery(m_db); if (!updatePasswordQuery.exec(updatePasswordQueryString)) { @@ -267,6 +273,12 @@ UserManager::UserError UserManager::removeUser(const QString &username) UserManager::UserError UserManager::setUserScopes(const QString &username, Types::PermissionScopes scopes) { + if (!validateScopes(scopes)) { + // The method warns about he specific validation + return UserErrorInconsistantScopes; + } + + QString scopesString = Types::scopesToStringList(scopes).join(','); QSqlQuery setScopesQuery(m_db); setScopesQuery.prepare("UPDATE users SET scopes = ? WHERE username = ?"); @@ -332,11 +344,11 @@ QByteArray UserManager::authenticate(const QString &username, const QString &pas QByteArray token = QCryptographicHash::hash(QUuid::createUuid().toByteArray(), QCryptographicHash::Sha256).toBase64(); QString storeTokenQueryString = QString("INSERT INTO tokens(id, username, token, creationdate, devicename) VALUES(\"%1\", \"%2\", \"%3\", \"%4\", \"%5\");") - .arg(QUuid::createUuid().toString()) - .arg(username.toLower()) - .arg(QString::fromUtf8(token)) - .arg(NymeaCore::instance()->timeManager()->currentDateTime().toString("yyyy-MM-dd hh:mm:ss")) - .arg(deviceName); + .arg(QUuid::createUuid().toString()) + .arg(username.toLower()) + .arg(QString::fromUtf8(token)) + .arg(NymeaCore::instance()->timeManager()->currentDateTime().toString("yyyy-MM-dd hh:mm:ss")) + .arg(deviceName); QSqlQuery storeTokenQuery(m_db); if (!storeTokenQuery.exec(storeTokenQueryString)) { @@ -391,9 +403,8 @@ void UserManager::cancelPushButtonAuth(int transactionId) */ UserInfo UserManager::userInfo(const QString &username) const { - QString getUserQueryString = QString("SELECT * FROM users WHERE lower(username) = \"%1\";") - .arg(username); + .arg(username); QSqlQuery getUserQuery(m_db); if (!getUserQuery.exec(getUserQueryString)) { @@ -444,7 +455,7 @@ TokenInfo UserManager::tokenInfo(const QByteArray &token) const } QString getTokenQueryString = QString("SELECT id, username, creationdate, deviceName FROM tokens WHERE token = \"%1\";") - .arg(QString::fromUtf8(token)); + .arg(QString::fromUtf8(token)); QSqlQuery getTokenQuery(m_db); if (!getTokenQuery.exec(getTokenQueryString)) { @@ -467,7 +478,7 @@ TokenInfo UserManager::tokenInfo(const QUuid &tokenId) const { QString getTokenQueryString = QString("SELECT id, username, creationdate, deviceName FROM tokens WHERE id = \"%1\";") - .arg(tokenId.toString()); + .arg(tokenId.toString()); QSqlQuery getTokenQuery(m_db); if (!getTokenQuery.exec(getTokenQueryString)) { @@ -490,7 +501,7 @@ TokenInfo UserManager::tokenInfo(const QUuid &tokenId) const UserManager::UserError UserManager::removeToken(const QUuid &tokenId) { QString removeTokenQueryString = QString("DELETE FROM tokens WHERE id = \"%1\";") - .arg(tokenId.toString()); + .arg(tokenId.toString()); QSqlQuery removeTokenQuery(m_db); if (!removeTokenQuery.exec(removeTokenQueryString)) { @@ -519,7 +530,7 @@ bool UserManager::verifyToken(const QByteArray &token) return false; } QString getTokenQueryString = QString("SELECT * FROM tokens WHERE token = \"%1\";") - .arg(QString::fromUtf8(token)); + .arg(QString::fromUtf8(token)); QSqlQuery getTokenQuery(m_db); if (!getTokenQuery.exec(getTokenQueryString)) { @@ -738,6 +749,47 @@ bool UserManager::validateToken(const QByteArray &token) const return validator.match(token).hasMatch(); } +bool UserManager::validateScopes(Types::PermissionScopes scopes) const +{ + if (scopes.testFlag(Types::PermissionScopeAdmin) || scopes == Types::PermissionScopeNone || scopes == Types::PermissionScopeControlThings) + return true; + + if (scopes.testFlag(Types::PermissionScopeConfigureThings)) { + if (!scopes.testFlag(Types::PermissionScopeControlThings) || + !scopes.testFlag(Types::PermissionScopeAccessAllThings)) { + qCWarning(dcUserManager()) << "Invalid scopes combination. If a user can configure things he must have access to all things and must be able to control them."; + return false; + } + } + + // Note: if access to all things, there are no restrictions + if (!scopes.testFlag(Types::PermissionScopeAccessAllThings)) { + if (scopes.testFlag(Types::PermissionScopeControlThings) || + scopes.testFlag(Types::PermissionScopeConfigureRules)|| + scopes.testFlag(Types::PermissionScopeExecuteRules)) { + qCWarning(dcUserManager()) << "Invalid scopes combination. If a user has not access to all things, he can not configure them or create/execute rules."; + return false; + } + } + + if (scopes.testFlag(Types::PermissionScopeExecuteRules)) { + if (!scopes.testFlag(Types::PermissionScopeAccessAllThings)) { + qCWarning(dcUserManager()) << "Invalid scopes combination. If a user can execute rules, he must have access to all things."; + return false; + } + } + + if (scopes.testFlag(Types::PermissionScopeConfigureRules)) { + if (!scopes.testFlag(Types::PermissionScopeAccessAllThings) || + !scopes.testFlag(Types::PermissionScopeExecuteRules)) { + qCWarning(dcUserManager()) << "Invalid scopes combination. If a user can create rules, he must have access to all things and be able to execute them."; + return false; + } + } + + return true; +} + void UserManager::dumpDBError(const QString &message) { qCCritical(dcUserManager) << message << "Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText(); @@ -770,11 +822,11 @@ void UserManager::onPushButtonPressed() QByteArray token = QCryptographicHash::hash(QUuid::createUuid().toByteArray(), QCryptographicHash::Sha256).toBase64(); QString storeTokenQueryString = QString("INSERT INTO tokens(id, username, token, creationdate, devicename) VALUES(\"%1\", \"%2\", \"%3\", \"%4\", \"%5\");") - .arg(QUuid::createUuid().toString()) - .arg("") - .arg(QString::fromUtf8(token)) - .arg(NymeaCore::instance()->timeManager()->currentDateTime().toString("yyyy-MM-dd hh:mm:ss")) - .arg(m_pushButtonTransaction.second); + .arg(QUuid::createUuid().toString()) + .arg("") + .arg(QString::fromUtf8(token)) + .arg(NymeaCore::instance()->timeManager()->currentDateTime().toString("yyyy-MM-dd hh:mm:ss")) + .arg(m_pushButtonTransaction.second); QSqlQuery storeTokenQuery(m_db); if (!storeTokenQuery.exec(storeTokenQueryString) || m_db.lastError().type() != QSqlError::NoError) { diff --git a/libnymea-core/usermanager/usermanager.h b/libnymea-core/usermanager/usermanager.h index 3b313613..391cf1b4 100644 --- a/libnymea-core/usermanager/usermanager.h +++ b/libnymea-core/usermanager/usermanager.h @@ -46,7 +46,8 @@ public: UserErrorDuplicateUserId, UserErrorBadPassword, UserErrorTokenNotFound, - UserErrorPermissionDenied + UserErrorPermissionDenied, + UserErrorInconsistantScopes }; Q_ENUM(UserError) @@ -91,6 +92,7 @@ private: bool validateUsername(const QString &username) const; bool validatePassword(const QString &password) const; bool validateToken(const QByteArray &token) const; + bool validateScopes(Types::PermissionScopes scopes) const; void dumpDBError(const QString &message); diff --git a/tests/auto/usermanager/testusermanager.cpp b/tests/auto/usermanager/testusermanager.cpp index f95c1519..79b309e2 100644 --- a/tests/auto/usermanager/testusermanager.cpp +++ b/tests/auto/usermanager/testusermanager.cpp @@ -102,6 +102,9 @@ private slots: void getUserInfo(); + void testScopeConsitancy_data(); + void testScopeConsitancy(); + private: // m_apiToken is in testBase QUuid m_tokenId; @@ -116,11 +119,11 @@ void TestUsermanager::initTestCase() { NymeaDBusService::setBusType(QDBusConnection::SessionBus); NymeaTestBase::initTestCase("*.debug=false\n" - "Application.debug=true\n" - "Tests.debug=true\n" - "UserManager.debug=true\n" - "PushButtonAgent.debug=true\n" - "MockDevice.debug=true"); + "Application.debug=true\n" + "Tests.debug=true\n" + "UserManager.debug=true\n" + "PushButtonAgent.debug=true\n" + "MockDevice.debug=true"); } void TestUsermanager::init() @@ -357,7 +360,7 @@ void TestUsermanager::authenticatePushButtonAuthConnectionDrop() // Create a new clientId for alice and connect it to the server QUuid aliceId = QUuid::createUuid(); - m_mockTcpServer->clientConnected(aliceId); + emit m_mockTcpServer->clientConnected(aliceId); m_mockTcpServer->injectData(aliceId, "{\"id\": 0, \"method\": \"JSONRPC.Hello\"}"); if (clientSpy.count() == 0) clientSpy.wait(); @@ -368,12 +371,12 @@ void TestUsermanager::authenticatePushButtonAuthConnectionDrop() QCOMPARE(response.toMap().value("params").toMap().value("success").toBool(), true); // Disconnect alice - m_mockTcpServer->clientDisconnected(aliceId); + emit m_mockTcpServer->clientDisconnected(aliceId); // Now try with bob // Create a new clientId for bob and connect it to the server QUuid bobId = QUuid::createUuid(); - m_mockTcpServer->clientConnected(bobId); + emit m_mockTcpServer->clientConnected(bobId); clientSpy.clear(); m_mockTcpServer->injectData(bobId, "{\"id\": 0, \"method\": \"JSONRPC.Hello\"}"); if (clientSpy.count() == 0) clientSpy.wait(); @@ -495,9 +498,8 @@ void TestUsermanager::authenticateAfterPasswordChangeFail() QVERIFY2(disconnectedSpy.count() == 1, "Connection should have dropped"); QTest::qWait(3200); - m_mockTcpServer->clientConnected(m_clientId); + emit m_mockTcpServer->clientConnected(m_clientId); injectAndWait("JSONRPC.Hello"); - } void TestUsermanager::getUserInfo() @@ -505,14 +507,9 @@ void TestUsermanager::getUserInfo() authenticate(); QVariant response = injectAndWait("Users.GetUserInfo"); - QCOMPARE(response.toMap().value("status").toString(), QString("success")); - QVariantMap userInfoMap = response.toMap().value("params").toMap().value("userInfo").toMap(); - - QCOMPARE(userInfoMap.value("username").toString(), QString("valid@user.test")); - } void TestUsermanager::unauthenticatedCallAfterTokenRemove() @@ -530,9 +527,117 @@ void TestUsermanager::unauthenticatedCallAfterTokenRemove() QVERIFY2(spy.count() == 1, "Connection should be terminated!"); QTest::qWait(3200); - m_mockTcpServer->clientConnected(m_clientId); + emit m_mockTcpServer->clientConnected(m_clientId); injectAndWait("JSONRPC.Hello"); } +void TestUsermanager::testScopeConsitancy_data() +{ + QTest::addColumn>("scopes"); + QTest::addColumn("error"); + + QTest::newRow("valid: admin") + << (QList() + << Types::PermissionScopeAdmin) + << "UserErrorNoError"; + + QTest::newRow("valid: none") + << (QList() + << Types::PermissionScopeNone) + << "UserErrorNoError"; + + QTest::newRow("valid: only control, not all things") + << (QList() + << Types::PermissionScopeControlThings + << Types::PermissionScopeAccessAllThings) + << "UserErrorNoError"; + + QTest::newRow("valid: only control, not all things") + << (QList() + << Types::PermissionScopeControlThings + << Types::PermissionScopeConfigureThings + << Types::PermissionScopeAccessAllThings) + << "UserErrorNoError"; + + QTest::newRow("valid: only control, all things") + << (QList() + << Types::PermissionScopeControlThings + << Types::PermissionScopeAccessAllThings) + << "UserErrorNoError"; + + QTest::newRow("valid: control things/rules, all things") + << (QList() + << Types::PermissionScopeControlThings + << Types::PermissionScopeAccessAllThings + << Types::PermissionScopeExecuteRules) + << "UserErrorNoError"; + + QTest::newRow("valid: only execute rules") + << (QList() + << Types::PermissionScopeAccessAllThings + << Types::PermissionScopeExecuteRules) + << "UserErrorNoError"; + + + QTest::newRow("invalid: missing control and all things") + << (QList() + << Types::PermissionScopeConfigureThings) + << "UserErrorInconsistantScopes"; + + QTest::newRow("invalid: control/configure things. not all things") + << (QList() + << Types::PermissionScopeControlThings + << Types::PermissionScopeConfigureThings) + << "UserErrorInconsistantScopes"; + + QTest::newRow("invalid: only execute rules, not all things") + << (QList() + << Types::PermissionScopeExecuteRules) + << "UserErrorInconsistantScopes"; + + QTest::newRow("invalid: only configure rules") + << (QList() + << Types::PermissionScopeConfigureRules) + << "UserErrorInconsistantScopes"; + + QTest::newRow("invalid: configure and execute rules, not all things") + << (QList() + << Types::PermissionScopeExecuteRules + << Types::PermissionScopeConfigureRules) + << "UserErrorInconsistantScopes"; + + QTest::newRow("invalid: control things/rules, not all things") + << (QList() + << Types::PermissionScopeControlThings + << Types::PermissionScopeExecuteRules) + << "UserErrorInconsistantScopes"; +} + +void TestUsermanager::testScopeConsitancy() +{ + QFETCH(QList, scopes); + QFETCH(QString, error); + + authenticate(); + + QVariant response = injectAndWait("Users.GetUserInfo"); + QCOMPARE(response.toMap().value("status").toString(), QString("success")); + QVariantMap userInfoMap = response.toMap().value("params").toMap().value("userInfo").toMap(); + QCOMPARE(userInfoMap.value("username").toString(), QString("valid@user.test")); + + QMetaEnum metaEnum = QMetaEnum::fromType(); + QStringList scopesList; + foreach (Types::PermissionScope scope, scopes) + scopesList.append(metaEnum.valueToKey(scope)); + + // Now try to edit with the given scopes + QVariantMap params; + params.insert("username", userInfoMap.value("username").toString()); + params.insert("scopes", scopesList); + response = injectAndWait("Users.SetUserScopes", params); + QCOMPARE(response.toMap().value("status").toString(), QString("success")); + QCOMPARE(response.toMap().value("params").toMap().value("error").toString(), error); +} + #include "testusermanager.moc" QTEST_MAIN(TestUsermanager) From 71cd3561b6a1ca4f9063fe8e68c2fdd8e3c98cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Thu, 23 Oct 2025 13:37:10 +0200 Subject: [PATCH 03/10] UserManager: Update user database and migrate to version 2 --- .../jsonrpc/jsonrpcserverimplementation.cpp | 1 + .../jsonrpc/jsonrpcserverimplementation.h | 1 - libnymea-core/jsonrpc/usershandler.cpp | 8 +- libnymea-core/nymeacore.cpp | 1 + libnymea-core/usermanager/usermanager.cpp | 159 ++++++++++++++---- libnymea-core/usermanager/usermanager.h | 4 +- libnymea/types/typeutils.cpp | 18 ++ libnymea/typeutils.h | 4 + 8 files changed, 158 insertions(+), 38 deletions(-) diff --git a/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp b/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp index 13ed3c1b..78b5990a 100644 --- a/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp +++ b/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp @@ -43,6 +43,7 @@ #include "jsonrpc/jsonhandler.h" #include "jsonvalidator.h" #include "nymeacore.h" +#include "usermanager/usermanager.h" #include "integrations/thingmanager.h" #include "integrations/integrationplugin.h" #include "integrations/thing.h" diff --git a/libnymea-core/jsonrpc/jsonrpcserverimplementation.h b/libnymea-core/jsonrpc/jsonrpcserverimplementation.h index 2986bd41..d3c60737 100644 --- a/libnymea-core/jsonrpc/jsonrpcserverimplementation.h +++ b/libnymea-core/jsonrpc/jsonrpcserverimplementation.h @@ -28,7 +28,6 @@ #include "jsonrpc/jsonrpcserver.h" #include "jsonrpc/jsonhandler.h" #include "transportinterface.h" -#include "usermanager/usermanager.h" #include "types/thingclass.h" #include "types/action.h" diff --git a/libnymea-core/jsonrpc/usershandler.cpp b/libnymea-core/jsonrpc/usershandler.cpp index 1f72026e..4ec96e6a 100644 --- a/libnymea-core/jsonrpc/usershandler.cpp +++ b/libnymea-core/jsonrpc/usershandler.cpp @@ -159,8 +159,11 @@ JsonReply *UsersHandler::CreateUser(const QVariantMap ¶ms) QString displayName = params.value("displayName").toString(); QStringList scopesList = params.value("scopes", Types::scopesToStringList(Types::PermissionScopeAdmin)).toStringList(); Types::PermissionScopes scopes = Types::scopesFromStringList(scopesList); + QList allowedThingIds; + foreach (const QString &thingIdString, params.value("allowedThingIds").toStringList()) + allowedThingIds.append(ThingId(thingIdString)); - UserManager::UserError status = m_userManager->createUser(username, password, email, displayName, scopes); + UserManager::UserError status = m_userManager->createUser(username, password, email, displayName, scopes, allowedThingIds); QVariantMap returns; returns.insert("error", enumValueName(status)); @@ -310,7 +313,8 @@ JsonReply *UsersHandler::SetUserScopes(const QVariantMap ¶ms, const JsonCont Q_UNUSED(context) QString username = params.value("username").toString(); Types::PermissionScopes scopes = Types::scopesFromStringList(params.value("scopes").toStringList()); - UserManager::UserError error = m_userManager->setUserScopes(username, scopes); + QList allowedThingIds = Types::thingIdsFromStringList(params.value("allowedThingIds").toStringList()); + UserManager::UserError error = m_userManager->setUserScopes(username, scopes, allowedThingIds); QVariantMap returns; returns.insert("error", enumValueName(error)); return createReply(returns); diff --git a/libnymea-core/nymeacore.cpp b/libnymea-core/nymeacore.cpp index eb00b7ec..b0a59f0b 100644 --- a/libnymea-core/nymeacore.cpp +++ b/libnymea-core/nymeacore.cpp @@ -36,6 +36,7 @@ #include "scriptengine/scriptengine.h" #include "jsonrpc/scriptshandler.h" #include "jsonrpc/debughandler.h" +#include "usermanager/usermanager.h" #include "version.h" #include "integrations/thingmanagerimplementation.h" diff --git a/libnymea-core/usermanager/usermanager.cpp b/libnymea-core/usermanager/usermanager.cpp index 1715cbf5..e95701d7 100644 --- a/libnymea-core/usermanager/usermanager.cpp +++ b/libnymea-core/usermanager/usermanager.cpp @@ -147,13 +147,14 @@ UserInfoList UserManager::users() const info.setEmail(resultQuery.value("email").toString()); info.setDisplayName(resultQuery.value("displayName").toString()); info.setScopes(Types::scopesFromStringList(resultQuery.value("scopes").toString().split(','))); + info.setAllowedThingIds(Types::thingIdsFromStringList(resultQuery.value("allowedThingIds").toString().split(','))); users.append(info); } return users; } /*! Creates a new user with the given \a username and \a password. Returns the \l UserError to inform about the result. */ -UserManager::UserError UserManager::createUser(const QString &username, const QString &password, const QString &email, const QString &displayName, Types::PermissionScopes scopes) +UserManager::UserError UserManager::createUser(const QString &username, const QString &password, const QString &email, const QString &displayName, Types::PermissionScopes scopes, const QList &allowedThingIds) { if (!validateUsername(username)) { qCWarning(dcUserManager) << "Error creating user. Invalid username:" << username; @@ -170,6 +171,17 @@ UserManager::UserError UserManager::createUser(const QString &username, const QS 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 "; + } else { + thingIds.append(thingId); + } + } + QSqlQuery checkForDuplicateUserQuery(m_db); checkForDuplicateUserQuery.prepare("SELECT * FROM users WHERE lower(username) = ?;"); // Note: We're using toLower() on the username mainly for the reason that in old versions the username used to be an email address @@ -183,13 +195,14 @@ 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) VALUES(?, ?, ?, ?, ?, ?);"); + 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.exec(); if (query.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Error creating user:" << query.lastError().databaseText() << query.lastError().driverText(); @@ -271,7 +284,7 @@ UserManager::UserError UserManager::removeUser(const QString &username) return UserErrorNoError; } -UserManager::UserError UserManager::setUserScopes(const QString &username, Types::PermissionScopes scopes) +UserManager::UserError UserManager::setUserScopes(const QString &username, Types::PermissionScopes scopes, const QList &allowedThingIds) { if (!validateScopes(scopes)) { // The method warns about he specific validation @@ -279,13 +292,25 @@ UserManager::UserError UserManager::setUserScopes(const QString &username, Types } + // 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 "; + } else { + thingIds.append(thingId); + } + } + QString scopesString = Types::scopesToStringList(scopes).join(','); + QString allowedThingIdsString = Types::thingIdsToStringList(thingIds).join(','); QSqlQuery setScopesQuery(m_db); - setScopesQuery.prepare("UPDATE users SET scopes = ? WHERE username = ?"); - setScopesQuery.addBindValue(scopesString); - setScopesQuery.addBindValue(username); - setScopesQuery.exec(); - if (setScopesQuery.lastError().type() != QSqlError::NoError) { + setScopesQuery.prepare("UPDATE users SET scopes = :scopes, allowedThingIds = :allowedThingIds WHERE 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; } @@ -424,6 +449,7 @@ UserInfo UserManager::userInfo(const QString &username) const userInfo.setEmail(getUserQuery.value("email").toString()); userInfo.setDisplayName(getUserQuery.value("displayName").toString()); userInfo.setScopes(Types::scopesFromStringList(getUserQuery.value("scopes").toString().split(','))); + userInfo.setAllowedThingIds(Types::thingIdsFromStringList(getUserQuery.value("allowedThingIds").toString().split(','))); return userInfo; } @@ -476,7 +502,6 @@ TokenInfo UserManager::tokenInfo(const QByteArray &token) const TokenInfo UserManager::tokenInfo(const QUuid &tokenId) const { - QString getTokenQueryString = QString("SELECT id, username, creationdate, deviceName FROM tokens WHERE id = \"%1\";") .arg(tokenId.toString()); @@ -572,26 +597,32 @@ bool UserManager::initDB() } int currentVersion = -1; - int newVersion = 1; + int newVersion = 2; + if (m_db.tables().contains("metadata")) { QSqlQuery query(m_db); - if (!query.exec("SELECT data FROM metadata WHERE `key` = 'version';")) { + if (!query.exec("SELECT data FROM metadata WHERE key = 'version';")) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << query.executedQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); } else if (query.next()) { currentVersion = query.value("data").toInt(); + qCInfo(dcUserManager()) << "Current database version is" << currentVersion; + if (currentVersion == newVersion) { + qCInfo(dcUserManager()) << "The database version is up to date"; + } } } if (!m_db.tables().contains("users")) { - qCDebug(dcUserManager()) << "Empty user database. Setting up metadata..."; + qCDebug(dcUserManager()) << "No \"users\" table found. Creating the table..."; QSqlQuery query(m_db); - if (!query.exec("CREATE TABLE users (username VARCHAR(40) UNIQUE PRIMARY KEY, email VARCHAR(40), displayName VARCHAR(40), password VARCHAR(100), salt VARCHAR(100), scopes TEXT);") || m_db.lastError().isValid()) { + if (!query.exec("CREATE TABLE users (username VARCHAR(40) UNIQUE PRIMARY KEY, email VARCHAR(40), displayName VARCHAR(40), password VARCHAR(100), salt VARCHAR(100), scopes TEXT, allowedThingIds TEXT);") || m_db.lastError().isValid()) { dumpDBError("Error initializing user database (table users)."); m_db.close(); return false; } } else { if (currentVersion < 1) { + qCDebug(dcUserManager()) << "Start user table database migration to version 1"; QSqlQuery query = QSqlQuery(m_db); if (!query.exec("ALTER TABLE users ADD COLUMN scopes TEXT;") || m_db.lastError().isValid()) { dumpDBError("Error migrating user database (table users)."); @@ -631,44 +662,106 @@ bool UserManager::initDB() m_db.close(); return false; } - currentVersion = 1; + + qCDebug(dcUserManager()) << "Migrated successfully users table to database version 1"; } + + if (currentVersion < 2) { + // - Add new "allowedThingIds" row into the users table + // - New permission has been added "PermissionScopeAccessAllThings", the existing users require + // all this permission in order to have an unchainged behavior + qCDebug(dcUserManager()) << "Migrating user table to version 2"; + + // - Add new "allowedThingIds" row into the users table, it remains is empty at this point + QSqlQuery query = QSqlQuery(m_db); + if (!query.exec("ALTER TABLE users ADD COLUMN allowedThingIds TEXT;") || m_db.lastError().isValid()) { + dumpDBError("Error migrating user database (table users)."); + m_db.close(); + return false; + } + + if (!m_db.transaction()) { + dumpDBError("Error starting transaction for migrating user database (table users)."); + return false; + } + + QSqlQuery selectQuery(m_db); + if (!selectQuery.exec("SELECT username, scopes FROM users")) { + dumpDBError("Select failed: " + selectQuery.lastError().text()); + return false; + } + + QSqlQuery updateQuery(m_db); + updateQuery.prepare("UPDATE users SET scopes = :scopes WHERE username = :username"); + while (selectQuery.next()) { + QString username = selectQuery.value("username").toString(); + Types::PermissionScopes scopes = Types::scopesFromStringList(selectQuery.value("scopes").toString().split(',')); + + // In case this is an admin, make sure we store only the Admin scope + if (!scopes.testFlag(Types::PermissionScopeAdmin)) { + scopes.setFlag(Types::PermissionScopeAccessAllThings); + } + + updateQuery.bindValue(":scopes", Types::scopesToStringList(scopes).join(',')); + updateQuery.bindValue(":username", username); + + if (!updateQuery.exec()) { + qCWarning(dcUserManager()) << "Update failed for username" << username << ":" << updateQuery.lastError().text(); + m_db.rollback(); + return false; + } + } + + if (!m_db.commit()) { + dumpDBError("Error migrating user database (table users) to version 2. Rollback."); + m_db.rollback(); + return false; + } + + qCDebug(dcUserManager()) << "Migrated successfully users table to database version 2"; + } } if (!m_db.tables().contains("tokens")) { - qCDebug(dcUserManager()) << "Empty user database. Setting up metadata..."; + qCDebug(dcUserManager()) << "No \"tokens\" table found. Creating the table..."; QSqlQuery query(m_db); if (!query.exec("CREATE TABLE tokens (id VARCHAR(40) UNIQUE, username VARCHAR(40), token VARCHAR(100) UNIQUE, creationdate DATETIME, devicename VARCHAR(40));") || m_db.lastError().isValid()) { - dumpDBError("Error initializing user database (table tokens)."); + dumpDBError("Error initializing user database (table tokens)"); m_db.close(); return false; } } - if (m_db.tables().contains("metadata")) { - if (currentVersion < newVersion) { - QSqlQuery query(m_db); - if (!query.exec(QString("UPDATE metadata SET data = %1 WHERE `key` = 'version')").arg(newVersion)) || m_db.lastError().isValid()) { - dumpDBError("Error updating up user database schema version!"); - m_db.close(); - return false; - } - qCInfo(dcUserManager()) << "Successfully migrated user database."; - } - } else { + if (!m_db.tables().contains("metadata")) { + qCDebug(dcUserManager()) << "No \"metadata\" table found. Creating the table..."; QSqlQuery query(m_db); - if (!query.exec("CREATE TABLE metadata (`key` VARCHAR(10), data VARCHAR(40));") || m_db.lastError().isValid()) { + if (!query.exec("CREATE TABLE metadata (key VARCHAR(10), data VARCHAR(40));") || m_db.lastError().isValid()) { dumpDBError("Error setting up user database (table metadata)!"); m_db.close(); return false; } + query = QSqlQuery(m_db); - if (!query.exec(QString("INSERT INTO metadata (`key`, `data`) VALUES ('version', %1);").arg(newVersion)) || m_db.lastError().isValid()) { + query.prepare("INSERT INTO metadata (key, data) VALUES ('version', :version);"); + query.bindValue(":version", newVersion); + if (!query.exec() || m_db.lastError().isValid()) { dumpDBError("Error setting up user database (setting version metadata)!"); m_db.close(); return false; } - qCInfo(dcUserManager()) << "Successfully initialized user database."; + } else { + // All migrations have been done + if (currentVersion < newVersion) { + QSqlQuery query(m_db); + query.prepare("UPDATE metadata SET data = :version WHERE key = 'version'"); + query.bindValue(":version", newVersion); + if (!query.exec() || m_db.lastError().isValid()) { + dumpDBError("Error updating database version"); + m_db.close(); + return false; + } + qCInfo(dcUserManager()) << "Finished database migration to version" << newVersion; + } } @@ -700,17 +793,17 @@ bool UserManager::initDB() } } - - qCDebug(dcUserManager()) << "User database initialized successfully."; + qCDebug(dcUserManager()) << "User database initialized successfully"; return true; } void UserManager::rotate(const QString &dbName) { int index = 1; - while (QFileInfo(QString("%1.%2").arg(dbName).arg(index)).exists()) { + while (QFileInfo::exists(QString("%1.%2").arg(dbName).arg(index))) { index++; } + qCDebug(dcUserManager()) << "Backing up old database file to" << QString("%1.%2").arg(dbName).arg(index); QFile f(dbName); if (!f.rename(QString("%1.%2").arg(dbName).arg(index))) { diff --git a/libnymea-core/usermanager/usermanager.h b/libnymea-core/usermanager/usermanager.h index 391cf1b4..3c233a13 100644 --- a/libnymea-core/usermanager/usermanager.h +++ b/libnymea-core/usermanager/usermanager.h @@ -56,10 +56,10 @@ public: bool initRequired() const; UserInfoList users() const; - UserError createUser(const QString &username, const QString &password, const QString &email, const QString &displayName, Types::PermissionScopes scopes); + UserError createUser(const QString &username, const QString &password, const QString &email, const QString &displayName, Types::PermissionScopes scopes, const QList &allowedThingIds = QList()); UserError changePassword(const QString &username, const QString &newPassword); UserError removeUser(const QString &username); - UserError setUserScopes(const QString &username, Types::PermissionScopes scopes); + UserError setUserScopes(const QString &username, Types::PermissionScopes scopes, const QList &allowedThingIds = QList()); UserError setUserInfo(const QString &username, const QString &email, const QString &displayName); bool pushButtonAuthAvailable() const; diff --git a/libnymea/types/typeutils.cpp b/libnymea/types/typeutils.cpp index 837626ac..76128f3d 100644 --- a/libnymea/types/typeutils.cpp +++ b/libnymea/types/typeutils.cpp @@ -44,6 +44,24 @@ QString Types::scopeToString(Types::PermissionScope scope) return metaEnum.valueToKey(scope); } +QStringList Types::thingIdsToStringList(const QList &thingIds) +{ + QStringList stringList; + foreach (const ThingId &thingId, thingIds) + stringList.append(thingId.toString()); + + return stringList; +} + +QList Types::thingIdsFromStringList(const QStringList &stringList) +{ + QList thingIds; + foreach (const QString &idString, stringList) + thingIds.append(ThingId(idString)); + + return thingIds; +} + Types::PermissionScope Types::scopeFromString(const QString &scopeString) { QMetaEnum metaEnum = QMetaEnum::fromType(); diff --git a/libnymea/typeutils.h b/libnymea/typeutils.h index 10a25377..6f9a1152 100644 --- a/libnymea/typeutils.h +++ b/libnymea/typeutils.h @@ -216,6 +216,10 @@ public: static QStringList scopesToStringList(PermissionScopes scopes); static QString scopeToString(PermissionScope scope); + static QStringList thingIdsToStringList(const QList &thingIds); + static QList thingIdsFromStringList(const QStringList &stringList); + + enum LoggingType { LoggingTypeDiscrete, LoggingTypeSampled, 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 04/10] 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 From 82fe7c7ae3a8537aa8568b9461588d2b27ee9925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Mon, 27 Oct 2025 12:24:07 +0100 Subject: [PATCH 05/10] Bump JSON RPC Api to 8.4 --- nymea.pro | 2 +- tests/auto/api.json | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/nymea.pro b/nymea.pro index 066cdcab..37e28aa1 100644 --- a/nymea.pro +++ b/nymea.pro @@ -11,7 +11,7 @@ isEmpty(NYMEA_VERSION) { # define protocol versions JSON_PROTOCOL_VERSION_MAJOR=8 -JSON_PROTOCOL_VERSION_MINOR=3 +JSON_PROTOCOL_VERSION_MINOR=4 JSON_PROTOCOL_VERSION="$${JSON_PROTOCOL_VERSION_MAJOR}.$${JSON_PROTOCOL_VERSION_MINOR}" LIBNYMEA_API_VERSION_MAJOR=9 LIBNYMEA_API_VERSION_MINOR=0 diff --git a/tests/auto/api.json b/tests/auto/api.json index ad82c2fb..acebcdae 100644 --- a/tests/auto/api.json +++ b/tests/auto/api.json @@ -1,4 +1,4 @@ -8.3 +8.4 { "enums": { "BasicType": [ @@ -157,6 +157,7 @@ "PermissionScopeNone", "PermissionScopeControlThings", "PermissionScopeConfigureThings", + "PermissionScopeAccessAllThings", "PermissionScopeExecuteRules", "PermissionScopeConfigureRules", "PermissionScopeAdmin" @@ -365,7 +366,8 @@ "UserErrorDuplicateUserId", "UserErrorBadPassword", "UserErrorTokenNotFound", - "UserErrorPermissionDenied" + "UserErrorPermissionDenied", + "UserErrorInconsistantScopes" ], "ValueOperator": [ "ValueOperatorEquals", @@ -897,10 +899,10 @@ }, "permissionScope": "PermissionScopeNone", "returns": { - "items": [ + "o:displayMessage": "String", + "o:items": [ "$ref:BrowserItem" ], - "o:displayMessage": "String", "thingError": "$ref:ThingError" } }, @@ -1970,8 +1972,11 @@ } }, "Users.CreateUser": { - "description": "Create a new user in the API with the given username and password. Use scopes to define the permissions for the new user. If no scopes are given, this user will be an admin user. Call Authenticate after this to obtain a device token for this user.", + "description": "Create a new user in the API with the given username and password. Use scopes to define the permissions for the new user. If the user has not the permission \"PermissionScopeAccessAllThings\", the list of things this user has access to can be defined in the \"allowedThingIds\" property. If no scopes are given, this user will be an admin user. Call Authenticate after this to obtain a device token for this user.", "params": { + "o:allowedThingIds": [ + "Uuid" + ], "o:displayName": "String", "o:email": "String", "o:scopes": "$ref:PermissionScopes", @@ -2045,8 +2050,11 @@ } }, "Users.SetUserScopes": { - "description": "Set the permissions (scopes) for a given user.", + "description": "Set the permissions (scopes) for a given user. If the user has not the permission \"PermissionScopeAccessAllThings\" the list of thing IDs this user has access to can be defined in the \"allowedThingIds\" property.", "params": { + "o:allowedThingIds": [ + "Uuid" + ], "scopes": "$ref:PermissionScopes", "username": "String" }, From cfe4328776a01764ea55ceb91c1fd52bd6b1676b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Mon, 27 Oct 2025 15:53:38 +0100 Subject: [PATCH 06/10] Fix allowed things loading --- libnymea-core/jsonrpc/usershandler.cpp | 3 +++ libnymea-core/usermanager/userinfo.h | 1 + libnymea/types/typeutils.cpp | 7 +++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/libnymea-core/jsonrpc/usershandler.cpp b/libnymea-core/jsonrpc/usershandler.cpp index 4ec96e6a..e12220a6 100644 --- a/libnymea-core/jsonrpc/usershandler.cpp +++ b/libnymea-core/jsonrpc/usershandler.cpp @@ -311,6 +311,9 @@ JsonReply *UsersHandler::RemoveUser(const QVariantMap ¶ms, const JsonContext JsonReply *UsersHandler::SetUserScopes(const QVariantMap ¶ms, const JsonContext &context) { Q_UNUSED(context) + + qCWarning(dcJsonRpc()) << params; + QString username = params.value("username").toString(); Types::PermissionScopes scopes = Types::scopesFromStringList(params.value("scopes").toStringList()); QList allowedThingIds = Types::thingIdsFromStringList(params.value("allowedThingIds").toStringList()); diff --git a/libnymea-core/usermanager/userinfo.h b/libnymea-core/usermanager/userinfo.h index ca91aeca..770b9bb5 100644 --- a/libnymea-core/usermanager/userinfo.h +++ b/libnymea-core/usermanager/userinfo.h @@ -39,6 +39,7 @@ class UserInfo Q_PROPERTY(QString email READ email) Q_PROPERTY(QString displayName READ displayName) Q_PROPERTY(Types::PermissionScopes scopes READ scopes) + Q_PROPERTY(QList allowedThingIds READ allowedThingIds) public: UserInfo(); diff --git a/libnymea/types/typeutils.cpp b/libnymea/types/typeutils.cpp index 76128f3d..7f15fad0 100644 --- a/libnymea/types/typeutils.cpp +++ b/libnymea/types/typeutils.cpp @@ -56,8 +56,11 @@ QStringList Types::thingIdsToStringList(const QList &thingIds) QList Types::thingIdsFromStringList(const QStringList &stringList) { QList thingIds; - foreach (const QString &idString, stringList) - thingIds.append(ThingId(idString)); + foreach (const QString &idString, stringList) { + if (!idString.isEmpty()) { + thingIds.append(ThingId(idString)); + } + } return thingIds; } From b80ad6d83933c12358d34d420f78a55d943f2095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Tue, 28 Oct 2025 10:58:35 +0100 Subject: [PATCH 07/10] Add missing thingError fpr IO connections --- libnymea-core/jsonrpc/integrationshandler.cpp | 4 +++- libnymea-core/jsonrpc/usershandler.cpp | 10 ++++++++-- libnymea-core/usermanager/usermanager.cpp | 1 - 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/libnymea-core/jsonrpc/integrationshandler.cpp b/libnymea-core/jsonrpc/integrationshandler.cpp index e9c74051..d40e31ef 100644 --- a/libnymea-core/jsonrpc/integrationshandler.cpp +++ b/libnymea-core/jsonrpc/integrationshandler.cpp @@ -377,7 +377,8 @@ IntegrationsHandler::IntegrationsHandler(ThingManager *thingManager, QObject *pa params.clear(); returns.clear(); description = "Fetch IO connections. Optionally filtered by thingId and stateTypeId."; params.insert("o:thingId", enumValueName(Uuid)); - returns.insert("ioConnections", objectRef()); + returns.insert("o:ioConnections", objectRef()); + returns.insert("thingError", enumRef()); registerMethod("GetIOConnections", description, params, returns, Types::PermissionScopeNone); params.clear(); returns.clear(); @@ -1144,6 +1145,7 @@ JsonReply *IntegrationsHandler::GetIOConnections(const QVariantMap ¶ms, cons QVariantMap returns; QVariant bla = pack(ioConnections); returns.insert("ioConnections", pack(ioConnections)); + returns.insert("thingError", enumValueName(Thing::ThingErrorNoError)); return createReply(returns); } diff --git a/libnymea-core/jsonrpc/usershandler.cpp b/libnymea-core/jsonrpc/usershandler.cpp index e12220a6..1a6eb879 100644 --- a/libnymea-core/jsonrpc/usershandler.cpp +++ b/libnymea-core/jsonrpc/usershandler.cpp @@ -143,7 +143,6 @@ UsersHandler::UsersHandler(UserManager *userManager, QObject *parent): params.insert("username", username); emit UserRemoved(params); }); - } QString UsersHandler::name() const @@ -316,8 +315,15 @@ JsonReply *UsersHandler::SetUserScopes(const QVariantMap ¶ms, const JsonCont QString username = params.value("username").toString(); Types::PermissionScopes scopes = Types::scopesFromStringList(params.value("scopes").toStringList()); - QList allowedThingIds = Types::thingIdsFromStringList(params.value("allowedThingIds").toStringList()); + QList allowedThingIds; + if (params.contains("allowedThingIds")) { + allowedThingIds = Types::thingIdsFromStringList(params.value("allowedThingIds").toStringList()); + } else { + allowedThingIds = m_userManager->userInfo(username).allowedThingIds(); + } + UserManager::UserError error = m_userManager->setUserScopes(username, scopes, allowedThingIds); + QVariantMap returns; returns.insert("error", enumValueName(error)); return createReply(returns); diff --git a/libnymea-core/usermanager/usermanager.cpp b/libnymea-core/usermanager/usermanager.cpp index a36b7872..ac793e15 100644 --- a/libnymea-core/usermanager/usermanager.cpp +++ b/libnymea-core/usermanager/usermanager.cpp @@ -305,7 +305,6 @@ UserManager::UserError UserManager::setUserScopes(const QString &username, Types } } - QString scopesString = Types::scopesToStringList(scopes).join(','); QString allowedThingIdsString = Types::thingIdsToStringList(thingIds).join(','); From 360e287619df66fbf9c5297649cec8718bc103f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Mon, 3 Nov 2025 15:17:26 +0100 Subject: [PATCH 08/10] Add thing added and removed logic depending on users thing permission --- libnymea-core/jsonrpc/integrationshandler.cpp | 30 +++- libnymea-core/jsonrpc/integrationshandler.h | 18 +- .../jsonrpc/jsonrpcserverimplementation.cpp | 154 +++++++++++++----- .../jsonrpc/jsonrpcserverimplementation.h | 7 +- libnymea-core/nymeacore.cpp | 7 +- libnymea-core/usermanager/userinfo.h | 4 +- libnymea-core/usermanager/usermanager.cpp | 128 +++++++++------ libnymea-core/usermanager/usermanager.h | 7 + tests/auto/usermanager/testusermanager.cpp | 25 ++- 9 files changed, 253 insertions(+), 127 deletions(-) diff --git a/libnymea-core/jsonrpc/integrationshandler.cpp b/libnymea-core/jsonrpc/integrationshandler.cpp index d40e31ef..a26c24c2 100644 --- a/libnymea-core/jsonrpc/integrationshandler.cpp +++ b/libnymea-core/jsonrpc/integrationshandler.cpp @@ -446,7 +446,7 @@ IntegrationsHandler::IntegrationsHandler(ThingManager *thingManager, QObject *pa connect(m_thingManager, &ThingManager::eventTriggered, this, [this](const Event &event){ QVariantMap params; params.insert("event", pack(event)); - emit EventTriggered(params); + emit EventTriggered(params, event.thingId()); }); params.clear(); returns.clear(); @@ -519,6 +519,21 @@ IntegrationsHandler::IntegrationsHandler(ThingManager *thingManager, QObject *pa hash = QCryptographicHash::hash(QJsonDocument::fromVariant(pluginList).toJson(), QCryptographicHash::Md5).toHex(); m_cacheHashes.insert("GetPlugins", hash); }); + + connect(NymeaCore::instance()->userManager(), &UserManager::userThingRestrictionsChanged, this, [this](const UserInfo &userInfo, const ThingId &thingId, bool accessGranted){ + + if (accessGranted) { + QVariantMap params; + params.insert("thing", pack(m_thingManager->findConfiguredThing(thingId))); + emit ThingAdded(params, userInfo); + qCDebug(dcJsonRpc()) << "Notify user" << userInfo.username() << "that the permission to thing with ID" << thingId.toString() << "has been granted."; + } else { + QVariantMap params; + params.insert("thingId", thingId); + emit ThingRemoved(params, userInfo); + qCDebug(dcJsonRpc()) << "Notify user" << userInfo.username() << "that the permission to thing with ID" << thingId.toString() << "has been dropped."; + } + }); } QString IntegrationsHandler::name() const @@ -1143,8 +1158,7 @@ JsonReply *IntegrationsHandler::GetIOConnections(const QVariantMap ¶ms, cons IOConnections ioConnections = m_thingManager->ioConnections(thingId); QVariantMap returns; - QVariant bla = pack(ioConnections); - returns.insert("ioConnections", pack(ioConnections)); + returns.insert("ioConnections", pack(ioConnections)); returns.insert("thingError", enumValueName(Thing::ThingErrorNoError)); return createReply(returns); } @@ -1214,28 +1228,28 @@ void IntegrationsHandler::thingStateChanged(Thing *thing, const QUuid &stateType params.insert("minValue", minValue); params.insert("maxValue", maxValue); params.insert("possibleValues", possibleValues); - emit StateChanged(params); + emit StateChanged(params, thing->id()); } void IntegrationsHandler::thingRemovedNotification(const ThingId &thingId) { QVariantMap params; params.insert("thingId", thingId); - emit ThingRemoved(params); + emit ThingRemoved(params, thingId); } void IntegrationsHandler::thingAddedNotification(Thing *thing) { QVariantMap params; params.insert("thing", pack(thing)); - emit ThingAdded(params); + emit ThingAdded(params, thing->id()); } void IntegrationsHandler::thingChangedNotification(Thing *thing) { QVariantMap params; params.insert("thing", pack(thing)); - emit ThingChanged(params); + emit ThingChanged(params, thing->id()); } void IntegrationsHandler::thingSettingChangedNotification(const ThingId &thingId, const ParamTypeId ¶mTypeId, const QVariant &value) @@ -1244,7 +1258,7 @@ void IntegrationsHandler::thingSettingChangedNotification(const ThingId &thingId params.insert("thingId", thingId); params.insert("paramTypeId", paramTypeId.toString()); params.insert("value", value); - emit ThingSettingChanged(params); + emit ThingSettingChanged(params, thingId); } QVariantMap IntegrationsHandler::statusToReply(Thing::ThingError status) const diff --git a/libnymea-core/jsonrpc/integrationshandler.h b/libnymea-core/jsonrpc/integrationshandler.h index 56c56f9c..7c4c5c5a 100644 --- a/libnymea-core/jsonrpc/integrationshandler.h +++ b/libnymea-core/jsonrpc/integrationshandler.h @@ -26,6 +26,7 @@ #define INTEGRATIONSHANDLER_H #include "jsonrpc/jsonhandler.h" +#include "usermanager/userinfo.h" #include "integrations/thingmanager.h" namespace nymeaserver { @@ -80,15 +81,20 @@ public: signals: void PluginConfigurationChanged(const QVariantMap ¶ms); - void StateChanged(const QVariantMap ¶ms); - void ThingRemoved(const QVariantMap ¶ms); - void ThingAdded(const QVariantMap ¶ms); - void ThingChanged(const QVariantMap ¶ms); - void ThingSettingChanged(const QVariantMap ¶ms); - void EventTriggered(const QVariantMap ¶ms); + // Thing permission relevant notifications + void StateChanged(const QVariantMap ¶ms, const ThingId &thingId); + void ThingRemoved(const QVariantMap ¶ms, const ThingId &thingId); + void ThingAdded(const QVariantMap ¶ms, const ThingId &thingId); + void ThingChanged(const QVariantMap ¶ms, const ThingId &thingId); + void ThingSettingChanged(const QVariantMap ¶ms, const ThingId &thingId); + void EventTriggered(const QVariantMap ¶ms, const ThingId &thingId); void IOConnectionAdded(const QVariantMap ¶ms); void IOConnectionRemoved(const QVariantMap ¶ms); + // User specific notifications depending on the thing based permissions + void ThingRemoved(const QVariantMap ¶ms, const nymeaserver::UserInfo &userInfo); + void ThingAdded(const QVariantMap ¶ms, const nymeaserver::UserInfo &userInfo); + private slots: void pluginConfigChanged(const PluginId &id, const ParamList &config); diff --git a/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp b/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp index 20eef40e..4d7d20bc 100644 --- a/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp +++ b/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp @@ -44,19 +44,11 @@ #include "jsonvalidator.h" #include "nymeacore.h" #include "usermanager/usermanager.h" -#include "integrations/thingmanager.h" -#include "integrations/integrationplugin.h" -#include "integrations/thing.h" -#include "types/thingclass.h" -#include "ruleengine/rule.h" -#include "ruleengine/ruleengine.h" #include "loggingcategories.h" -#include "platform/platform.h" #include "version.h" #include "integrationshandler.h" #include "ruleshandler.h" -#include "scriptshandler.h" #include "logginghandler.h" #include "configurationhandler.h" #include "networkmanagerhandler.h" @@ -104,19 +96,19 @@ JsonRPCServerImplementation::JsonRPCServerImplementation(const QSslConfiguration // Methods QString description; QVariantMap returns; QVariantMap params; description = "Initiates a connection. Use this method to perform an initial handshake of the " - "connection. Optionally, a parameter \"locale\" is can be passed to set up the used " - "locale for this connection. Strings such as ThingClass displayNames etc will be " - "localized to this locale. If this parameter is omitted, the default system locale " - "(depending on the configuration) is used. The reply of this method contains information " - "about this core instance such as version information, uuid and its name. The locale value" - "indicates the locale used for this connection. Note: This method can be called multiple " - "times. The locale used in the last call for this connection will be used. Other values, " - "like initialSetupRequired might change if the setup has been performed in the meantime.\n " - "The field cacheHashes may contain a map of methods and MD5 hashes. As long as the hash for " - "a method does not change, a client may use a previously cached copy of the call instead of " - "fetching the content again. While the Hello call doesn't necessarily require a token, this " - "can be called with a token. If a token is provided, it will be verified and the reply contains " - "information about the tokens validity and the user and permissions for the given token."; + "connection. Optionally, a parameter \"locale\" is can be passed to set up the used " + "locale for this connection. Strings such as ThingClass displayNames etc will be " + "localized to this locale. If this parameter is omitted, the default system locale " + "(depending on the configuration) is used. The reply of this method contains information " + "about this core instance such as version information, uuid and its name. The locale value" + "indicates the locale used for this connection. Note: This method can be called multiple " + "times. The locale used in the last call for this connection will be used. Other values, " + "like initialSetupRequired might change if the setup has been performed in the meantime.\n " + "The field cacheHashes may contain a map of methods and MD5 hashes. As long as the hash for " + "a method does not change, a client may use a previously cached copy of the call instead of " + "fetching the content again. While the Hello call doesn't necessarily require a token, this " + "can be called with a token. If a token is provided, it will be verified and the reply contains " + "information about the tokens validity and the user and permissions for the given token."; params.insert("o:locale", enumValueName(String)); returns.insert("server", enumValueName(String)); returns.insert("name", enumValueName(String)); @@ -152,13 +144,13 @@ JsonRPCServerImplementation::JsonRPCServerImplementation(const QSslConfiguration params.clear(); returns.clear(); description = "Enable/Disable notifications for this connections. Either \"enabled\" or """ - "\"namespaces\" needs to be given but not both of them. The boolean based " - "\"enabled\" parameter will enable/disable all notifications at once. If " - "instead the list-based \"namespaces\" parameter is provided, all given namespaces" - "will be enabled, the others will be disabled. The return value of \"success\" will " - "indicate success of the operation. The \"enabled\" property in the return value is " - "deprecated and used for legacy compatibilty only. It will be set to true if at least " - "one namespace has been enabled."; + "\"namespaces\" needs to be given but not both of them. The boolean based " + "\"enabled\" parameter will enable/disable all notifications at once. If " + "instead the list-based \"namespaces\" parameter is provided, all given namespaces" + "will be enabled, the others will be disabled. The return value of \"success\" will " + "indicate success of the operation. The \"enabled\" property in the return value is " + "deprecated and used for legacy compatibilty only. It will be set to true if at least " + "one namespace has been enabled."; params.insert("o:namespaces", enumValueName(StringList)); params.insert("d:o:enabled", enumValueName(Bool)); returns.insert("namespaces", enumValueName(StringList)); @@ -178,9 +170,9 @@ JsonRPCServerImplementation::JsonRPCServerImplementation(const QSslConfiguration params.clear(); returns.clear(); description = "Authenticate a client to the api via user & password challenge. Provide " - "a device name which allows the user to identify the client and revoke the token in case " - "the device is lost or stolen. This will return a new token to be used to authorize a " - "client at the API."; + "a device name which allows the user to identify the client and revoke the token in case " + "the device is lost or stolen. This will return a new token to be used to authorize a " + "client at the API."; params.insert("username", enumValueName(String)); params.insert("password", enumValueName(String)); params.insert("deviceName", enumValueName(String)); @@ -192,18 +184,18 @@ JsonRPCServerImplementation::JsonRPCServerImplementation(const QSslConfiguration params.clear(); returns.clear(); description = "Authenticate a client to the api via Push Button method. " - "Provide a device name which allows the user to identify the client and revoke the " - "token in case the device is lost or stolen. If push button hardware is available, " - "this will return with success and start listening for push button presses. When the " - "push button is pressed, the PushButtonAuthFinished notification will be sent to the " - "requesting client. The procedure will be cancelled when the connection is interrupted. " - "If another client requests push button authentication while a procedure is still going " - "on, the second call will take over and the first one will be notified by the " - "PushButtonAuthFinished signal about the error. The application should make it clear " - "to the user to not press the button when the procedure fails as this can happen for 2 " - "reasons: a) a second user is trying to auth at the same time and only the currently " - "active user should press the button or b) it might indicate an attacker trying to take " - "over and snooping in for tokens."; + "Provide a device name which allows the user to identify the client and revoke the " + "token in case the device is lost or stolen. If push button hardware is available, " + "this will return with success and start listening for push button presses. When the " + "push button is pressed, the PushButtonAuthFinished notification will be sent to the " + "requesting client. The procedure will be cancelled when the connection is interrupted. " + "If another client requests push button authentication while a procedure is still going " + "on, the second call will take over and the first one will be notified by the " + "PushButtonAuthFinished signal about the error. The application should make it clear " + "to the user to not press the button when the procedure fails as this can happen for 2 " + "reasons: a) a second user is trying to auth at the same time and only the currently " + "active user should press the button or b) it might indicate an attacker trying to take " + "over and snooping in for tokens."; params.insert("deviceName", enumValueName(String)); returns.insert("success", enumValueName(Bool)); returns.insert("transactionId", enumValueName(Int)); @@ -228,6 +220,8 @@ JsonRPCServerImplementation::JsonRPCServerImplementation(const QSslConfiguration connect(NymeaCore::instance()->userManager(), &UserManager::pushButtonAuthFinished, this, &JsonRPCServerImplementation::onPushButtonAuthFinished); + + m_connectionLockdownTimer.setSingleShot(true); m_connectionLockdownTimer.setInterval(3000); } @@ -801,6 +795,69 @@ void JsonRPCServerImplementation::sendClientNotification(const QUuid &clientId, m_clientTransports.value(clientId)->sendData(clientId, data); } +void JsonRPCServerImplementation::sendClientNotification(const QVariantMap ¶ms, const ThingId &thingId) +{ + JsonHandler *handler = qobject_cast(sender()); + QMetaMethod method = handler->metaObject()->method(senderSignalIndex()); + + QVariantMap notification; + notification.insert("id", m_notificationId++); + notification.insert("notification", handler->name() + "." + method.name()); + + foreach (const QUuid &clientId, m_clientNotifications.keys()) { + + // Check if this client wants to be notified + if (!m_clientNotifications.value(clientId).contains(handler->name())) + continue; + + // Make sure this client is allowed to receive this notification + if (m_clientTokens.contains(clientId)) { + const QByteArray token = m_clientTokens.value(clientId); + if (!NymeaCore::instance()->userManager()->accessToThingGranted(thingId, token)) { + qCDebug(dcJsonRpc()) << "Not sending notification to client" << "to client" << clientId.toString() + << "due to missing thing permissions" << handler->name() + "." + method.name(); + continue; + } + } + + // Add deprecation warning if necessary + if (m_api.value("notifications").toMap().value(handler->name() + '.' + method.name()).toMap().contains("deprecated")) { + QString deprecationMessage = m_api.value("notifications").toMap().value(handler->name() + '.' + method.name()).toMap().value("deprecated").toString(); + qCWarning(dcJsonRpc()) << "Client" << clientId << "uses deprecated API. Please update client implementation!"; + qCWarning(dcJsonRpc()) << handler->name() + '.' + method.name() + ':' << deprecationMessage; + notification.insert("deprecationWarning", deprecationMessage); + } + + QLocale locale = m_clientLocales.value(clientId); + QVariantMap translatedParams = handler->translateNotification(method.name(), params, locale); + + JsonValidator validator; + Q_ASSERT_X(validator.validateNotificationParams(translatedParams, handler->name() + '.' + method.name(), m_api).success(), + validator.result().where().toUtf8(), + validator.result().errorString().toUtf8() + "\nGot:" + QJsonDocument::fromVariant(translatedParams).toJson(QJsonDocument::Indented)); + + notification.insert("params", translatedParams); + + QByteArray data = QJsonDocument::fromVariant(notification).toJson(QJsonDocument::Compact); + + qCDebug(dcJsonRpc()) << "Sending notification" << handler->name() + "." + method.name() << "to client" << clientId; + qCDebug(dcJsonRpcTraffic()) << "Notification content:" << data; + + m_clientTransports.value(clientId)->sendData(clientId, data); + } +} + +void JsonRPCServerImplementation::sendClientNotification(const QVariantMap ¶ms, const UserInfo &userInfo) +{ + // Send client specific notifications + qCDebug(dcJsonRpc()) << "Sending notification to client" << userInfo.username() << "connections..."; + foreach (const QByteArray &token, m_clientTokens) { + if (NymeaCore::instance()->userManager()->tokenInfo(token).username() == userInfo.username()) { + sendClientNotification(m_clientTokens.key(token), params); + } + } +} + void JsonRPCServerImplementation::asyncReplyFinished() { JsonReply *reply = qobject_cast(sender()); @@ -980,9 +1037,17 @@ bool JsonRPCServerImplementation::registerHandler(JsonHandler *handler) QMetaMethod method = handler->metaObject()->method(i); if (method.methodType() == QMetaMethod::Signal && QString(method.name()).contains(QRegularExpression("^[A-Z]"))) { if (method.parameterCount() == 1 && method.parameterType(0) == QMetaType::QVariantMap) { + // Generic notification for all subscribed clients QObject::connect(handler, method, this, metaObject()->method(metaObject()->indexOfSlot("sendNotification(QVariantMap)"))); } else if (method.parameterCount() == 2 && method.parameterType(0) == QMetaType::QUuid && method.parameterType(1) == QMetaType::QVariantMap) { + // Notifications for a specific client with the given UUID QObject::connect(handler, method, this, metaObject()->method(metaObject()->indexOfSlot("sendClientNotification(QUuid,QVariantMap)"))); + } else if (method.parameterCount() == 2 && method.parameterType(0) == QMetaType::QVariantMap && method.parameterType(1) == QMetaType::type("ThingId")) { + // Notifications which contains thing specific information which might be restricted for certain clients + QObject::connect(handler, method, this, metaObject()->method(metaObject()->indexOfSlot("sendClientNotification(QVariantMap,ThingId)"))); + } else if (method.parameterCount() == 2 && method.parameterType(0) == QMetaType::QVariantMap && method.parameterType(1) == QMetaType::type("nymeaserver::UserInfo")) { + // Notifications for a specific user + QObject::connect(handler, method, this, metaObject()->method(metaObject()->indexOfSlot("sendClientNotification(QVariantMap,nymeaserver::UserInfo)"))); } } } @@ -1012,6 +1077,7 @@ void JsonRPCServerImplementation::clientConnected(const QUuid &clientId) m_newConnectionWaitTimers.remove(clientId); interface->terminateClientConnection(clientId); }); + m_newConnectionWaitTimers.insert(clientId, timer); timer->start(10000); } @@ -1024,9 +1090,11 @@ void JsonRPCServerImplementation::clientDisconnected(const QUuid &clientId) m_clientBuffers.remove(clientId); m_clientLocales.remove(clientId); m_clientTokens.remove(clientId); + if (m_pushButtonTransactions.values().contains(clientId)) { NymeaCore::instance()->userManager()->cancelPushButtonAuth(m_pushButtonTransactions.key(clientId)); } + if (m_newConnectionWaitTimers.contains(clientId)) { delete m_newConnectionWaitTimers.take(clientId); } diff --git a/libnymea-core/jsonrpc/jsonrpcserverimplementation.h b/libnymea-core/jsonrpc/jsonrpcserverimplementation.h index d3c60737..85f7a1f4 100644 --- a/libnymea-core/jsonrpc/jsonrpcserverimplementation.h +++ b/libnymea-core/jsonrpc/jsonrpcserverimplementation.h @@ -27,12 +27,9 @@ #include "jsonrpc/jsonrpcserver.h" #include "jsonrpc/jsonhandler.h" +#include "usermanager/userinfo.h" #include "transportinterface.h" -#include "types/thingclass.h" -#include "types/action.h" -#include "types/event.h" - #include #include #include @@ -90,6 +87,8 @@ private slots: void sendNotification(const QVariantMap ¶ms); void sendClientNotification(const QUuid &clientId, const QVariantMap ¶ms); + void sendClientNotification(const QVariantMap ¶ms, const ThingId &thingId); + void sendClientNotification(const QVariantMap ¶ms, const nymeaserver::UserInfo &userInfo); void asyncReplyFinished(); diff --git a/libnymea-core/nymeacore.cpp b/libnymea-core/nymeacore.cpp index b0a59f0b..0872fd4a 100644 --- a/libnymea-core/nymeacore.cpp +++ b/libnymea-core/nymeacore.cpp @@ -160,10 +160,11 @@ void NymeaCore::init(const QStringList &additionalInterfaces, bool disableLogEng m_experienceManager = new ExperienceManager(m_thingManager, m_serverManager->jsonServer(), this); connect(m_configuration, &NymeaConfiguration::serverNameChanged, m_serverManager, &ServerManager::setServerName); - connect(m_thingManager, &ThingManagerImplementation::loaded, this, &NymeaCore::thingManagerLoaded); + connect(m_thingManager, &ThingManagerImplementation::thingRemoved, m_userManager, &UserManager::onThingRemoved); m_logger->log({"started"}, {{"version", NYMEA_VERSION_STRING}}); + #ifdef WITH_SYSTEMD sd_notify(0, "READY=1"); #endif @@ -296,7 +297,7 @@ QStringList NymeaCore::loggingFiltersPlugins() QStringList loggingFiltersPlugins; foreach (const QJsonObject &pluginMetadata, ThingManagerImplementation::pluginsMetadata()) { QString pluginName = pluginMetadata.value("name").toString(); - loggingFiltersPlugins << pluginName.left(1).toUpper() + pluginName.mid(1); + loggingFiltersPlugins << pluginName.at(0).toUpper() + pluginName.mid(1); } return loggingFiltersPlugins; } @@ -368,7 +369,6 @@ JsonRPCServerImplementation *NymeaCore::jsonRPCServer() const void NymeaCore::thingManagerLoaded() { - // Tell hardare resources we're done with loading stuff... m_hardwareManager->thingsLoaded(); @@ -396,7 +396,6 @@ void NymeaCore::thingManagerLoaded() m_tagsStorage->removeTag(tag); } } - } } diff --git a/libnymea-core/usermanager/userinfo.h b/libnymea-core/usermanager/userinfo.h index 770b9bb5..98faab86 100644 --- a/libnymea-core/usermanager/userinfo.h +++ b/libnymea-core/usermanager/userinfo.h @@ -66,7 +66,6 @@ private: QString m_displayName; Types::PermissionScopes m_scopes = Types::PermissionScopeNone; QList m_allowedThingIds; - }; class UserInfoList: public QList @@ -78,4 +77,7 @@ public: Q_INVOKABLE void put(const QVariant &variant); }; } + +Q_DECLARE_METATYPE(nymeaserver::UserInfo); + #endif // USERINFO_H diff --git a/libnymea-core/usermanager/usermanager.cpp b/libnymea-core/usermanager/usermanager.cpp index ac793e15..aecab7da 100644 --- a/libnymea-core/usermanager/usermanager.cpp +++ b/libnymea-core/usermanager/usermanager.cpp @@ -63,7 +63,6 @@ */ #include "usermanager.h" -#include "nymeasettings.h" #include "loggingcategories.h" #include "pushbuttondbusservice.h" #include "nymeacore.h" @@ -192,7 +191,8 @@ UserManager::UserError UserManager::createUser(const QString &username, const QS return UserErrorDuplicateUserId; } - QByteArray salt = QUuid::createUuid().toString().remove(QRegularExpression("[{}]")).toUtf8(); + static QRegularExpression bracketsRe("[{}]"); + QByteArray salt = QUuid::createUuid().toString().remove(bracketsRe).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)" @@ -305,6 +305,34 @@ UserManager::UserError UserManager::setUserScopes(const QString &username, Types } } + QList thingsAppeared; + QList thingsDisappeared; + + // Get the current allowed things + if (!scopes.testFlag(Types::PermissionScopeAccessAllThings)) { + + // Restricted thing access, let's notify this user if any things appeared or dissapeard for the user + UserInfo currentUserInfo = userInfo(username); + + // Get new appeared things for this user + foreach (const ThingId &thingId, thingIds) { + if (currentUserInfo.allowedThingIds().contains(thingId)) + continue; + + qCDebug(dcUserManager()) << "Thing with ID" << thingId.toString() << "now allowed for this user any more. Notify user" << username << "that thing appeared."; + thingsAppeared.append(thingId); + } + + // Get disappeared things for this user + foreach (const ThingId &thingId, currentUserInfo.allowedThingIds()) { + if (thingIds.contains(thingId)) + continue; + + qCDebug(dcUserManager()) << "Thing with ID" << thingId.toString() << "not allowed for this user any more. Notify user" << username << "that thing dissappeared."; + thingsDisappeared.append(thingId); + } + } + QString scopesString = Types::scopesToStringList(scopes).join(','); QString allowedThingIdsString = Types::thingIdsToStringList(thingIds).join(','); @@ -320,16 +348,25 @@ UserManager::UserError UserManager::setUserScopes(const QString &username, Types } emit userChanged(username); + + // Notify after updating the user information + UserInfo ui = userInfo(username); + foreach (const ThingId &thingId, thingsAppeared) + emit userThingRestrictionsChanged(ui, thingId, true); + + foreach (const ThingId &thingId, thingsDisappeared) + emit userThingRestrictionsChanged(ui, thingId, false); + return UserErrorNoError; } UserManager::UserError UserManager::setUserInfo(const QString &username, const QString &email, const QString &displayName) { QSqlQuery query(m_db); - query.prepare("UPDATE users SET email = ?, displayName = ? WHERE username = ?;"); - query.addBindValue(email); - query.addBindValue(displayName); - query.addBindValue(username); + query.prepare("UPDATE users SET email = :email, displayName = :displayName WHERE username = :username;"); + query.bindValue(":email", email); + query.bindValue(":displayName", displayName); + query.bindValue(":username", username); query.exec(); if (query.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager()) << "Error updating user info for user" << username << query.lastError().databaseText() << query.lastError().driverText() << query.executedQuery(); @@ -454,7 +491,6 @@ UserInfo UserManager::userInfo(const QString &username) const userInfo.setDisplayName(getUserQuery.value("displayName").toString()); userInfo.setScopes(Types::scopesFromStringList(getUserQuery.value("scopes").toString().split(','))); userInfo.setAllowedThingIds(Types::thingIdsFromStringList(getUserQuery.value("allowedThingIds").toString().split(','))); - return userInfo; } @@ -575,6 +611,7 @@ bool UserManager::verifyToken(const QByteArray &token) qCDebug(dcUserManager) << "Authorization failed for token" << token; return false; } + //qCDebug(dcUserManager) << "Token authorized for user" << result.value("username").toString(); return true; } @@ -598,6 +635,23 @@ QList UserManager::getAllowedThingIdsForToken(const QByteArray &token) return userInfo(tokenInfo(token).username()).allowedThingIds(); } +void UserManager::onThingRemoved(const ThingId &thingId) +{ + // If a thing has been removed from the system, clean up any thing based permissions + foreach (const UserInfo &userInfo, users()) { + if (userInfo.allowedThingIds().contains(thingId)) { + QList allowedThingIds = userInfo.allowedThingIds(); + allowedThingIds.removeAll(thingId); + + if (setUserScopes(userInfo.username(), userInfo.scopes(), allowedThingIds) != UserErrorNoError) { + qCWarning(dcUserManager()) << "Failed to remove thing with ID" << thingId.toString() << "from allowed things of user" << userInfo.username(); + } else { + qCDebug(dcUserManager()) << "Removed thing with ID" << thingId.toString() << "from allowed things of user" << userInfo.username(); + } + } + } +} + bool UserManager::initDB() { m_db.close(); @@ -775,35 +829,6 @@ bool UserManager::initDB() } } - - // Migration from before 1.0: - // Push button tokens were given out without an explicit user name - // If we have push button tokens (userId "") but no explicit user, let's create it as admin - // Users without valid username will have password login disabled. - QSqlQuery query(m_db); - query.prepare("SELECT * FROM tokens WHERE username = \"\";"); - query.exec(); - if (query.lastError().type() == QSqlError::NoError && query.next()) { - QSqlQuery query(m_db); - query.prepare("SELECT * FROM users WHERE username = \"\";"); - query.exec(); - if (!query.next()) { - qCDebug(dcUserManager()) << "Tokens existing but no user. Creating token admin user"; - QSqlQuery query(m_db); - query.prepare("INSERT INTO users(username, email, displayName, password, salt, scopes) values(?, ?, ?, ?, ?, ?);"); - query.addBindValue(""); - query.addBindValue(""); - query.addBindValue("Admin"); - query.addBindValue(""); - query.addBindValue(""); - query.addBindValue(Types::scopeToString(Types::PermissionScopeAdmin)); - query.exec(); - if (query.lastError().type() != QSqlError::NoError) { - qCWarning(dcUserManager) << "Error creating push button user:" << query.lastError().databaseText() << query.lastError().driverText(); - } - } - } - qCDebug(dcUserManager()) << "User database initialized successfully"; return true; } @@ -811,9 +836,8 @@ bool UserManager::initDB() void UserManager::rotate(const QString &dbName) { int index = 1; - while (QFileInfo::exists(QString("%1.%2").arg(dbName).arg(index))) { + while (QFileInfo::exists(QString("%1.%2").arg(dbName).arg(index))) index++; - } qCDebug(dcUserManager()) << "Backing up old database file to" << QString("%1.%2").arg(dbName).arg(index); QFile f(dbName); @@ -826,30 +850,33 @@ void UserManager::rotate(const QString &dbName) bool UserManager::validateUsername(const QString &username) const { - QRegularExpression validator("[a-zA-Z0-9_\\.+-@]{3,}"); + static QRegularExpression validator("[a-zA-Z0-9_\\.+-@]{3,}"); return validator.match(username).hasMatch(); } bool UserManager::validatePassword(const QString &password) const { - if (password.length() < 8) { + if (password.length() < 8) return false; - } - if (!password.contains(QRegularExpression("[a-z]"))) { + + static QRegularExpression lowerRe("[a-z]"); + if (!password.contains(lowerRe)) return false; - } - if (!password.contains(QRegularExpression("[A-Z]"))) { + + static QRegularExpression upperRe("[A-Z]"); + if (!password.contains(upperRe)) return false; - } - if (!password.contains(QRegularExpression("[0-9]"))) { + + static QRegularExpression numbersRe("[0-9]"); + if (!password.contains(numbersRe)) return false; - } + return true; } bool UserManager::validateToken(const QByteArray &token) const { - QRegularExpression validator(QRegularExpression("(^[a-zA-Z0-9_\\.+-/=]+$)")); + static QRegularExpression validator(QRegularExpression("(^[a-zA-Z0-9_\\.+-/=]+$)")); return validator.match(token).hasMatch(); } @@ -899,6 +926,11 @@ void UserManager::dumpDBError(const QString &message) qCCritical(dcUserManager) << message << "Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText(); } +void UserManager::evaluateAllowedThingsForUser() +{ + +} + void UserManager::onPushButtonPressed() { if (m_pushButtonTransaction.first == -1) { diff --git a/libnymea-core/usermanager/usermanager.h b/libnymea-core/usermanager/usermanager.h index 5ee0bf0c..dc741a67 100644 --- a/libnymea-core/usermanager/usermanager.h +++ b/libnymea-core/usermanager/usermanager.h @@ -81,12 +81,17 @@ public: bool accessToThingGranted(const ThingId &thingId, const QByteArray &token); QList getAllowedThingIdsForToken(const QByteArray &token) const; +public slots: + void onThingRemoved(const ThingId &thingId); + signals: void userAdded(const QString &username); void userRemoved(const QString &username); void userChanged(const QString &username); void pushButtonAuthFinished(int transactionId, bool success, const QByteArray &token); + void userThingRestrictionsChanged(const nymeaserver::UserInfo &userInfo, const ThingId &thingId, bool accessGranted); + private: bool initDB(); void rotate(const QString &dbName); @@ -97,6 +102,8 @@ private: void dumpDBError(const QString &message); + void evaluateAllowedThingsForUser(); + private slots: void onPushButtonPressed(); diff --git a/tests/auto/usermanager/testusermanager.cpp b/tests/auto/usermanager/testusermanager.cpp index 6f9bd92b..00bb2c73 100644 --- a/tests/auto/usermanager/testusermanager.cpp +++ b/tests/auto/usermanager/testusermanager.cpp @@ -620,10 +620,10 @@ void TestUsermanager::testRestrictedThingAccess() // Add thing two QVariantMap httpportParamTwo; - httpportParamOne.insert("paramTypeId", mockThingHttpportParamTypeId.toString()); - httpportParamOne.insert("value", m_mockThing1Port - 2); + httpportParamTwo.insert("paramTypeId", mockThingHttpportParamTypeId.toString()); + httpportParamTwo.insert("value", m_mockThing1Port - 2); thingParams.clear(); - thingParams << httpportParamOne; + thingParams << httpportParamTwo; params.clear(); params.insert("thingClassId", mockThingClassId); @@ -689,33 +689,32 @@ void TestUsermanager::testRestrictedThingAccess() response = injectAndWait("Integrations.GetThings", params); verifyError(response, "thingError", enumValueName(Thing::ThingErrorThingNotFound)); - // GetStateValue + // GetStateValue (no access) params.clear(); params.insert("thingId", thingIdOne); params.insert("stateTypeId", mockConnectedStateTypeId); response = injectAndWait("Integrations.GetStateValue", params); verifyError(response, "thingError", enumValueName(Thing::ThingErrorThingNotFound)); - // BrowseThing + // BrowseThing (no access) params.clear(); params.insert("thingId", thingIdOne); response = injectAndWait("Integrations.BrowseThing", params); verifyError(response, "thingError", enumValueName(Thing::ThingErrorThingNotFound)); - // GetBrowserItem + // GetBrowserItem (no access) 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 - - + UserManager *userManager = NymeaCore::instance()->userManager(); + foreach (const UserInfo &userInfo, userManager->users()) { + qCDebug(dcTests()) << "Removing user" << userInfo.username(); + userManager->removeUser(userInfo.username()); + } + userManager->removeUser(""); } QTEST_MAIN(TestUsermanager) From ded99e35d4e4fc5006383e0293b1b09e1a2d9d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Tue, 4 Nov 2025 10:57:39 +0100 Subject: [PATCH 09/10] Add admin management methods --- libnymea-core/jsonrpc/usershandler.cpp | 47 ++++++++++++++++++++++++-- libnymea-core/jsonrpc/usershandler.h | 1 + 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/libnymea-core/jsonrpc/usershandler.cpp b/libnymea-core/jsonrpc/usershandler.cpp index 1a6eb879..b75c403d 100644 --- a/libnymea-core/jsonrpc/usershandler.cpp +++ b/libnymea-core/jsonrpc/usershandler.cpp @@ -58,6 +58,13 @@ UsersHandler::UsersHandler(UserManager *userManager, QObject *parent): returns.insert("error", enumRef()); registerMethod("ChangePassword", description, params, returns); + params.clear(); returns.clear(); + description = "Change the password for the given user. All tokens for this user will be removed in order to force all clients to log in again."; + params.insert("username", enumValueName(String)); + params.insert("newPassword", enumValueName(String)); + returns.insert("error", enumRef()); + registerMethod("ChangeUserPassword", description, params, returns); + params.clear(); returns.clear(); description = "Get info about the current token (the currently logged in user)."; returns.insert("o:userInfo", objectRef()); @@ -68,13 +75,21 @@ UsersHandler::UsersHandler(UserManager *userManager, QObject *parent): description = "Get all the tokens for the current user."; returns.insert("o:tokenInfoList", objectRef()); returns.insert("error", enumRef()); - registerMethod("GetTokens", description, params, returns); + registerMethod("GetTokens", description, params, returns, Types::PermissionScopeNone); params.clear(); returns.clear(); - description = "Revoke access for a given token."; + description = "Get all the tokens for the given username."; + params.insert("username", enumValueName(String)); + returns.insert("o:tokenInfoList", objectRef()); + returns.insert("error", enumRef()); + registerMethod("GetUserTokens", description, params, returns, Types::PermissionScopeNone); + + + params.clear(); returns.clear(); + description = "Revoke access for a given token. Depending on the logged in user only the own tokens can be removed. If you are logged in as admin, any token can be removed."; params.insert("tokenId", enumValueName(Uuid)); returns.insert("error", enumRef()); - registerMethod("RemoveToken", description, params, returns); + registerMethod("RemoveToken", description, params, returns, Types::PermissionScopeNone); params.clear(); returns.clear(); description = "Return a list of all users in the system."; @@ -195,6 +210,32 @@ JsonReply *UsersHandler::ChangePassword(const QVariantMap ¶ms, const JsonCon return createReply(ret); } +JsonReply *UsersHandler::ChangeUserPassword(const QVariantMap ¶ms, const JsonContext &context) +{ + QVariantMap ret; + + QByteArray currentToken = context.token(); + if (currentToken.isEmpty()) { + qCWarning(dcJsonRpc()) << "Cannot change a user password from an unauthenticated connection"; + ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(ret); + } + + if (!m_userManager->verifyToken(currentToken)) { + // Might happen if the client is connecting via an unauthenticated connection but tries to sneak in an invalid token + qCWarning(dcJsonRpc()) << "Invalid token. Cannot change a user password from an unauthenticated connection"; + ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(ret); + } + + QString username = params.value("username").toString();; + QString newPassword = params.value("newPassword").toString(); + + UserManager::UserError status = m_userManager->changePassword(username, newPassword); + ret.insert("error", enumValueName(status)); + return createReply(ret); +} + JsonReply *UsersHandler::GetUserInfo(const QVariantMap ¶ms, const JsonContext &context) { Q_UNUSED(params) diff --git a/libnymea-core/jsonrpc/usershandler.h b/libnymea-core/jsonrpc/usershandler.h index e800e8b1..e958fa42 100644 --- a/libnymea-core/jsonrpc/usershandler.h +++ b/libnymea-core/jsonrpc/usershandler.h @@ -43,6 +43,7 @@ public: Q_INVOKABLE JsonReply *CreateUser(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *ChangePassword(const QVariantMap ¶ms, const JsonContext &context); + Q_INVOKABLE JsonReply *ChangeUserPassword(const QVariantMap ¶ms, const JsonContext &context); Q_INVOKABLE JsonReply *GetUserInfo(const QVariantMap ¶ms, const JsonContext &context); Q_INVOKABLE JsonReply *GetTokens(const QVariantMap ¶ms, const JsonContext &context); Q_INVOKABLE JsonReply *RemoveToken(const QVariantMap ¶ms, const JsonContext &context); From 662e313bd823527a1bc5eb989843d4df9c8c84f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Tue, 4 Nov 2025 16:26:00 +0100 Subject: [PATCH 10/10] Add admin methods for user management --- libnymea-core/jsonrpc/usershandler.cpp | 140 ++++++++++++++-------- libnymea-core/jsonrpc/usershandler.h | 1 + libnymea-core/usermanager/userinfo.h | 3 + libnymea-core/usermanager/usermanager.cpp | 132 +++++++++++--------- libnymea-core/usermanager/usermanager.h | 1 + libnymea/typeutils.h | 2 + tests/auto/api.json | 30 ++++- 7 files changed, 200 insertions(+), 109 deletions(-) diff --git a/libnymea-core/jsonrpc/usershandler.cpp b/libnymea-core/jsonrpc/usershandler.cpp index b75c403d..8bbb4dff 100644 --- a/libnymea-core/jsonrpc/usershandler.cpp +++ b/libnymea-core/jsonrpc/usershandler.cpp @@ -56,7 +56,7 @@ UsersHandler::UsersHandler(UserManager *userManager, QObject *parent): description = "Change the password for the currently logged in user."; params.insert("newPassword", enumValueName(String)); returns.insert("error", enumRef()); - registerMethod("ChangePassword", description, params, returns); + registerMethod("ChangePassword", description, params, returns); // TODO: PermissionScopeChangeUserInfos params.clear(); returns.clear(); description = "Change the password for the given user. All tokens for this user will be removed in order to force all clients to log in again."; @@ -75,21 +75,21 @@ UsersHandler::UsersHandler(UserManager *userManager, QObject *parent): description = "Get all the tokens for the current user."; returns.insert("o:tokenInfoList", objectRef()); returns.insert("error", enumRef()); - registerMethod("GetTokens", description, params, returns, Types::PermissionScopeNone); + registerMethod("GetTokens", description, params, returns); // TODO: PermissionScopeChangeUserInfos params.clear(); returns.clear(); description = "Get all the tokens for the given username."; params.insert("username", enumValueName(String)); returns.insert("o:tokenInfoList", objectRef()); returns.insert("error", enumRef()); - registerMethod("GetUserTokens", description, params, returns, Types::PermissionScopeNone); + registerMethod("GetUserTokens", description, params, returns); params.clear(); returns.clear(); description = "Revoke access for a given token. Depending on the logged in user only the own tokens can be removed. If you are logged in as admin, any token can be removed."; params.insert("tokenId", enumValueName(Uuid)); returns.insert("error", enumRef()); - registerMethod("RemoveToken", description, params, returns, Types::PermissionScopeNone); + registerMethod("RemoveToken", description, params, returns); // TODO: PermissionScopeChangeUserInfos params.clear(); returns.clear(); description = "Return a list of all users in the system."; @@ -186,19 +186,20 @@ JsonReply *UsersHandler::CreateUser(const QVariantMap ¶ms) JsonReply *UsersHandler::ChangePassword(const QVariantMap ¶ms, const JsonContext &context) { - QVariantMap ret; + QVariantMap returns; QByteArray currentToken = context.token(); if (currentToken.isEmpty()) { qCWarning(dcJsonRpc()) << "Cannot change password from an unauthenticated connection"; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } + if (!m_userManager->verifyToken(currentToken)) { // Might happen if the client is connecting via an unauthenticated connection but tries to sneak in an invalid token qCWarning(dcJsonRpc()) << "Invalid token. Is this an unauthenticated connection?"; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } QString newPassword = params.value("newPassword").toString(); @@ -206,78 +207,82 @@ JsonReply *UsersHandler::ChangePassword(const QVariantMap ¶ms, const JsonCon TokenInfo tokenInfo = m_userManager->tokenInfo(currentToken); UserManager::UserError status = m_userManager->changePassword(tokenInfo.username(), newPassword); - ret.insert("error", enumValueName(status)); - return createReply(ret); + returns.insert("error", enumValueName(status)); + return createReply(returns); } JsonReply *UsersHandler::ChangeUserPassword(const QVariantMap ¶ms, const JsonContext &context) { - QVariantMap ret; + QVariantMap returns; QByteArray currentToken = context.token(); if (currentToken.isEmpty()) { qCWarning(dcJsonRpc()) << "Cannot change a user password from an unauthenticated connection"; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } if (!m_userManager->verifyToken(currentToken)) { // Might happen if the client is connecting via an unauthenticated connection but tries to sneak in an invalid token qCWarning(dcJsonRpc()) << "Invalid token. Cannot change a user password from an unauthenticated connection"; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } QString username = params.value("username").toString();; QString newPassword = params.value("newPassword").toString(); UserManager::UserError status = m_userManager->changePassword(username, newPassword); - ret.insert("error", enumValueName(status)); - return createReply(ret); + returns.insert("error", enumValueName(status)); + return createReply(returns); } JsonReply *UsersHandler::GetUserInfo(const QVariantMap ¶ms, const JsonContext &context) { Q_UNUSED(params) - QVariantMap ret; + + QVariantMap returns; QByteArray currentToken = context.token(); if (currentToken.isEmpty()) { qCWarning(dcJsonRpc()) << "Cannot get user info from an unauthenticated connection"; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } + if (!m_userManager->verifyToken(currentToken)) { // Might happen if the client is connecting via an unauthenticated connection but tries to sneak in an invalid token qCWarning(dcJsonRpc()) << "Invalid token. Is this an unauthenticated connection?"; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } TokenInfo tokenInfo = m_userManager->tokenInfo(currentToken); UserInfo userInfo = m_userManager->userInfo(tokenInfo.username()); - ret.insert("userInfo", pack(userInfo)); - ret.insert("error", enumValueName(UserManager::UserErrorNoError)); - return createReply(ret); + returns.insert("userInfo", pack(userInfo)); + returns.insert("error", enumValueName(UserManager::UserErrorNoError)); + return createReply(returns); } JsonReply *UsersHandler::GetTokens(const QVariantMap ¶ms, const JsonContext &context) { Q_UNUSED(params) - QVariantMap ret; + + QVariantMap returns; QByteArray currentToken = context.token(); if (currentToken.isEmpty()) { qCWarning(dcJsonRpc()) << "Cannot fetch tokens for an unauthenticated connection"; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } + if (!m_userManager->verifyToken(currentToken)) { // Might happen if the client is connecting via an unauthenticated connection but tries to sneak in an invalid token qCWarning(dcJsonRpc()) << "Invalid token. Is this an unauthenticated connection?"; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } TokenInfo tokenInfo = m_userManager->tokenInfo(currentToken); @@ -287,52 +292,87 @@ JsonReply *UsersHandler::GetTokens(const QVariantMap ¶ms, const JsonContext foreach (const TokenInfo &tokenInfo, tokens) { retList << pack(tokenInfo); } - ret.insert("tokenInfoList", retList); - ret.insert("error", enumValueName(UserManager::UserErrorNoError)); - return createReply(ret); + returns.insert("tokenInfoList", retList); + returns.insert("error", enumValueName(UserManager::UserErrorNoError)); + return createReply(returns); +} + +JsonReply *UsersHandler::GetUserTokens(const QVariantMap ¶ms, const JsonContext &context) +{ + QVariantMap returns; + + QByteArray currentToken = context.token(); + if (currentToken.isEmpty()) { + qCWarning(dcJsonRpc()) << "Cannot fetch tokens for an unauthenticated connection"; + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); + } + + if (!m_userManager->verifyToken(currentToken)) { + // Might happen if the client is connecting via an unauthenticated connection but tries to sneak in an invalid token + qCWarning(dcJsonRpc()) << "Invalid token. Is this an unauthenticated connection?"; + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); + } + + QString username = params.value("username").toString();; + + qCDebug(dcJsonRpc()) << "Fetching tokens for user" << username; + QList tokens = m_userManager->tokens(username); + QVariantList retList; + foreach (const TokenInfo &tokenInfo, tokens) { + retList << pack(tokenInfo); + } + returns.insert("tokenInfoList", retList); + returns.insert("error", enumValueName(UserManager::UserErrorNoError)); + return createReply(returns); } JsonReply *UsersHandler::RemoveToken(const QVariantMap ¶ms, const JsonContext &context) { - QVariantMap ret; + QVariantMap returns; QByteArray currentToken = context.token(); if (currentToken.isEmpty()) { qCWarning(dcJsonRpc()) << "Cannot remove a token from an unauthenticated connection."; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } + if (!m_userManager->verifyToken(currentToken)) { // Might happen if the client is connecting via an unauthenticated connection but tries to sneak in an invalid token qCWarning(dcJsonRpc()) << "Invalid token. Is this an unauthenticated connection?"; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } + QUuid tokenId = params.value("tokenId").toUuid(); + TokenInfo tokenToRemove = m_userManager->tokenInfo(tokenId); if (tokenToRemove.id().isNull()) { qCWarning(dcJsonRpc()) << "Token with ID" << tokenId << "not found"; - ret.insert("error", enumValueName(UserManager::UserErrorTokenNotFound)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorTokenNotFound)); + return createReply(returns); } TokenInfo currentTokenInfo = m_userManager->tokenInfo(currentToken); if (currentTokenInfo.username() != tokenToRemove.username()) { qCWarning(dcJsonRpc()) << "Cannot remove a token from another user!"; - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } qCDebug(dcJsonRpc()) << "Removing token" << tokenId << "for user" << currentTokenInfo.username(); UserManager::UserError error = m_userManager->removeToken(tokenId); - ret.insert("error", enumValueName(error)); - return createReply(ret); + returns.insert("error", enumValueName(error)); + return createReply(returns); } JsonReply *UsersHandler::GetUsers(const QVariantMap ¶ms) { Q_UNUSED(params) + QVariantMap reply; reply.insert("users", pack(m_userManager->users())); return createReply(reply); @@ -372,7 +412,7 @@ JsonReply *UsersHandler::SetUserScopes(const QVariantMap ¶ms, const JsonCont JsonReply *UsersHandler::SetUserInfo(const QVariantMap ¶ms, const JsonContext &context) { - QVariantMap ret; + QVariantMap returns; TokenInfo callingTokenInfo = m_userManager->tokenInfo(context.token()); QString username; @@ -384,8 +424,8 @@ JsonReply *UsersHandler::SetUserInfo(const QVariantMap ¶ms, const JsonContex } if (callingTokenInfo.username() != username && !m_userManager->userInfo(callingTokenInfo.username()).scopes().testFlag(Types::PermissionScopeAdmin)) { - ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); - return createReply(ret); + returns.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(returns); } UserInfo changedUserInfo = m_userManager->userInfo(username); @@ -403,8 +443,8 @@ JsonReply *UsersHandler::SetUserInfo(const QVariantMap ¶ms, const JsonContex displayName = changedUserInfo.displayName(); } UserManager::UserError status = m_userManager->setUserInfo(username, email, displayName); - ret.insert("error", enumValueName(status)); - return createReply(ret); + returns.insert("error", enumValueName(status)); + return createReply(returns); } } diff --git a/libnymea-core/jsonrpc/usershandler.h b/libnymea-core/jsonrpc/usershandler.h index e958fa42..9105e74c 100644 --- a/libnymea-core/jsonrpc/usershandler.h +++ b/libnymea-core/jsonrpc/usershandler.h @@ -46,6 +46,7 @@ public: Q_INVOKABLE JsonReply *ChangeUserPassword(const QVariantMap ¶ms, const JsonContext &context); Q_INVOKABLE JsonReply *GetUserInfo(const QVariantMap ¶ms, const JsonContext &context); Q_INVOKABLE JsonReply *GetTokens(const QVariantMap ¶ms, const JsonContext &context); + Q_INVOKABLE JsonReply *GetUserTokens(const QVariantMap ¶ms, const JsonContext &context); Q_INVOKABLE JsonReply *RemoveToken(const QVariantMap ¶ms, const JsonContext &context); Q_INVOKABLE JsonReply *GetUsers(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *RemoveUser(const QVariantMap ¶ms, const JsonContext &context); diff --git a/libnymea-core/usermanager/userinfo.h b/libnymea-core/usermanager/userinfo.h index 98faab86..33dc9271 100644 --- a/libnymea-core/usermanager/userinfo.h +++ b/libnymea-core/usermanager/userinfo.h @@ -68,6 +68,7 @@ private: QList m_allowedThingIds; }; + class UserInfoList: public QList { Q_GADGET @@ -76,8 +77,10 @@ public: Q_INVOKABLE QVariant get(int index) const; Q_INVOKABLE void put(const QVariant &variant); }; + } Q_DECLARE_METATYPE(nymeaserver::UserInfo); +Q_DECLARE_METATYPE(nymeaserver::UserInfoList); #endif // USERINFO_H diff --git a/libnymea-core/usermanager/usermanager.cpp b/libnymea-core/usermanager/usermanager.cpp index aecab7da..11a5b40f 100644 --- a/libnymea-core/usermanager/usermanager.cpp +++ b/libnymea-core/usermanager/usermanager.cpp @@ -173,21 +173,28 @@ UserManager::UserError UserManager::createUser(const QString &username, const QS // 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 "; - } else { - thingIds.append(thingId); + ThingManager *thingManager = NymeaCore::instance()->thingManager(); + if (!thingManager) { + qCWarning(dcUserManager()) << "Cannot validate allowed things for user" << username + << "because thing manager is not available yet. Skipping validation."; + thingIds = allowedThingIds; + } else { + foreach (const ThingId &thingId, allowedThingIds) { + if (thingManager->configuredThings().findById(thingId) == nullptr) { + qCWarning(dcUserManager()) << "Cannot set user scope for" << username << "because there is no thing with ID "; + } else { + thingIds.append(thingId); + } } } QSqlQuery checkForDuplicateUserQuery(m_db); - checkForDuplicateUserQuery.prepare("SELECT * FROM users WHERE lower(username) = ?;"); + checkForDuplicateUserQuery.prepare("SELECT * FROM users WHERE lower(username) = :username;"); + checkForDuplicateUserQuery.bindValue(":username", username.toLower()); // Note: We're using toLower() on the username mainly for the reason that in old versions the username used to be an email address - checkForDuplicateUserQuery.addBindValue(username.toLower()); checkForDuplicateUserQuery.exec(); if (checkForDuplicateUserQuery.first()) { - qCWarning(dcUserManager) << "Username already in use"; + qCWarning(dcUserManager) << "Username" << username << "already in use"; return UserErrorDuplicateUserId; } @@ -297,11 +304,19 @@ UserManager::UserError UserManager::setUserScopes(const QString &username, Types // 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()) << "The user" << username << "should have access to thing with ID" << thingId.toString() << "but there is no such thing. Ignoring value."; - } else { - thingIds.append(thingId); + ThingManager *thingManager = NymeaCore::instance()->thingManager(); + if (!thingManager) { + qCWarning(dcUserManager()) << "Cannot validate allowed things for user" << username + << "because thing manager is not available yet. Skipping validation."; + thingIds = allowedThingIds; + } else { + foreach (const ThingId &thingId, allowedThingIds) { + if (thingManager->configuredThings().findById(thingId) == nullptr) { + 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); + } } } @@ -337,8 +352,9 @@ UserManager::UserError UserManager::setUserScopes(const QString &username, Types 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.prepare("UPDATE users SET scopes = :scopes, allowedThingIds = :allowedThingIds WHERE username = :username;"); setScopesQuery.bindValue(":username", username); setScopesQuery.bindValue(":scopes", scopesString); setScopesQuery.bindValue(":allowedThingIds", allowedThingIdsString); @@ -393,13 +409,14 @@ QByteArray UserManager::authenticate(const QString &username, const QString &pas } QSqlQuery passwordQuery(m_db); - passwordQuery.prepare("SELECT password, salt FROM users WHERE lower(username) = ?;"); - passwordQuery.addBindValue(username.toLower()); + passwordQuery.prepare("SELECT password, salt FROM users WHERE lower(username) = :username;"); + passwordQuery.bindValue(":username", username.toLower()); passwordQuery.exec(); if (!passwordQuery.first()) { qCWarning(dcUserManager) << "No such username" << username; return QByteArray(); } + QByteArray salt = passwordQuery.value("salt").toByteArray(); QByteArray hashedPassword = passwordQuery.value("password").toByteArray(); @@ -409,16 +426,18 @@ QByteArray UserManager::authenticate(const QString &username, const QString &pas } QByteArray token = QCryptographicHash::hash(QUuid::createUuid().toByteArray(), QCryptographicHash::Sha256).toBase64(); - QString storeTokenQueryString = QString("INSERT INTO tokens(id, username, token, creationdate, devicename) VALUES(\"%1\", \"%2\", \"%3\", \"%4\", \"%5\");") - .arg(QUuid::createUuid().toString()) - .arg(username.toLower()) - .arg(QString::fromUtf8(token)) - .arg(NymeaCore::instance()->timeManager()->currentDateTime().toString("yyyy-MM-dd hh:mm:ss")) - .arg(deviceName); QSqlQuery storeTokenQuery(m_db); - if (!storeTokenQuery.exec(storeTokenQueryString)) { - qCWarning(dcUserManager()) << "Unable to execute SQL query" << storeTokenQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); + storeTokenQuery.prepare("INSERT INTO tokens (id, username, token, creationdate, devicename)" + "VALUES (:id, :username, :token, :creationdate, :devicename)"); + storeTokenQuery.bindValue(":id", QUuid::createUuid().toString()); + storeTokenQuery.bindValue(":username", username.toLower()); + storeTokenQuery.bindValue(":token", QString::fromUtf8(token)); + storeTokenQuery.bindValue(":creationdate", NymeaCore::instance()->timeManager()->currentDateTime().toString("yyyy-MM-dd hh:mm:ss")); + storeTokenQuery.bindValue(":devicename", deviceName); + + if (!storeTokenQuery.exec()) { + qCWarning(dcUserManager()) << "Unable to execute SQL query" << storeTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return QByteArray(); } @@ -469,17 +488,16 @@ void UserManager::cancelPushButtonAuth(int transactionId) */ UserInfo UserManager::userInfo(const QString &username) const { - QString getUserQueryString = QString("SELECT * FROM users WHERE lower(username) = \"%1\";") - .arg(username); - QSqlQuery getUserQuery(m_db); - if (!getUserQuery.exec(getUserQueryString)) { - qCWarning(dcUserManager()) << "Unable to execute SQL query" << getUserQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); + getUserQuery.prepare("SELECT * FROM users WHERE lower(username) = :username;"); + getUserQuery.bindValue(":username", username); + if (!getUserQuery.exec()) { + qCWarning(dcUserManager()) << "Unable to execute SQL query" << getUserQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserInfo(); } if (m_db.lastError().type() != QSqlError::NoError) { - qCWarning(dcUserManager) << "Query for user" << username << "failed:" << getUserQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); + qCWarning(dcUserManager) << "Query for user" << username << "failed:" << getUserQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserInfo(); } @@ -499,8 +517,8 @@ QList UserManager::tokens(const QString &username) const QList ret; QSqlQuery query(m_db); - query.prepare("SELECT id, username, creationdate, deviceName FROM tokens WHERE lower(username) = ?;"); - query.addBindValue(username.toLower()); + query.prepare("SELECT id, username, creationdate, deviceName FROM tokens WHERE lower(username) = :username;"); + query.bindValue(":username", username.toLower()); query.exec(); if (m_db.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Query for tokens failed:" << query.lastError().databaseText() << query.lastError().driverText() << query.executedQuery(); @@ -520,17 +538,16 @@ TokenInfo UserManager::tokenInfo(const QByteArray &token) const return TokenInfo(); } - QString getTokenQueryString = QString("SELECT id, username, creationdate, deviceName FROM tokens WHERE token = \"%1\";") - .arg(QString::fromUtf8(token)); - QSqlQuery getTokenQuery(m_db); - if (!getTokenQuery.exec(getTokenQueryString)) { - qCWarning(dcUserManager()) << "Unable to execute SQL query" << getTokenQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); + getTokenQuery.prepare("SELECT id, username, creationdate, deviceName FROM tokens WHERE token = :token;"); + getTokenQuery.bindValue(":token", QString::fromUtf8(token)); + if (!getTokenQuery.exec()) { + qCWarning(dcUserManager()) << "Unable to execute SQL query" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return TokenInfo(); } if (m_db.lastError().type() != QSqlError::NoError) { - qCWarning(dcUserManager) << "Query for token failed:" << getTokenQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); + qCWarning(dcUserManager) << "Query for token failed:" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return TokenInfo(); } @@ -542,17 +559,16 @@ TokenInfo UserManager::tokenInfo(const QByteArray &token) const TokenInfo UserManager::tokenInfo(const QUuid &tokenId) const { - QString getTokenQueryString = QString("SELECT id, username, creationdate, deviceName FROM tokens WHERE id = \"%1\";") - .arg(tokenId.toString()); - QSqlQuery getTokenQuery(m_db); - if (!getTokenQuery.exec(getTokenQueryString)) { - qCWarning(dcUserManager()) << "Unable to execute SQL query" << getTokenQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); + getTokenQuery.prepare("SELECT id, username, creationdate, deviceName FROM tokens WHERE id = :id;"); + getTokenQuery.bindValue(":id", tokenId.toString()); + if (!getTokenQuery.exec()) { + qCWarning(dcUserManager()) << "Unable to execute SQL query" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return TokenInfo(); } if (m_db.lastError().type() != QSqlError::NoError) { - qCWarning(dcUserManager) << "Query for token failed:" << getTokenQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); + qCWarning(dcUserManager) << "Query for token failed:" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return TokenInfo(); } @@ -565,21 +581,22 @@ TokenInfo UserManager::tokenInfo(const QUuid &tokenId) const /*! Removes the token with the given \a tokenId. Returns \l{UserError} to inform about the result. */ UserManager::UserError UserManager::removeToken(const QUuid &tokenId) { - QString removeTokenQueryString = QString("DELETE FROM tokens WHERE id = \"%1\";") - .arg(tokenId.toString()); - QSqlQuery removeTokenQuery(m_db); - if (!removeTokenQuery.exec(removeTokenQueryString)) { - qCWarning(dcUserManager()) << "Unable to execute SQL query" << removeTokenQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); + removeTokenQuery.prepare("DELETE FROM tokens WHERE id = :id;"); + removeTokenQuery.bindValue(":id", tokenId.toString()); + + if (!removeTokenQuery.exec()) { + qCWarning(dcUserManager()) << "Unable to execute SQL query" << removeTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserErrorBackendError; } if (m_db.lastError().type() != QSqlError::NoError) { - qCWarning(dcUserManager) << "Removing token failed:" << m_db.lastError().databaseText() << m_db.lastError().driverText() << removeTokenQueryString; + qCWarning(dcUserManager) << "Removing token failed:" << removeTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserErrorBackendError; } + if (removeTokenQuery.numRowsAffected() != 1) { - qCWarning(dcUserManager) << "Token not found in DB"; + qCWarning(dcUserManager) << "Tried to remove token, but the token could not be found in the DB."; return UserErrorTokenNotFound; } @@ -594,25 +611,26 @@ bool UserManager::verifyToken(const QByteArray &token) qCWarning(dcUserManager) << "Token failed character validation" << token; return false; } - QString getTokenQueryString = QString("SELECT * FROM tokens WHERE token = \"%1\";") - .arg(QString::fromUtf8(token)); QSqlQuery getTokenQuery(m_db); - if (!getTokenQuery.exec(getTokenQueryString)) { - qCWarning(dcUserManager()) << "Unable to execute SQL query" << getTokenQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); + getTokenQuery.prepare("SELECT * FROM tokens WHERE token = :token;"); + getTokenQuery.bindValue(":token", QString::fromUtf8(token)); + + if (!getTokenQuery.exec()) { + qCWarning(dcUserManager()) << "Unable to execute SQL query" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return false; } if (m_db.lastError().type() != QSqlError::NoError) { - qCWarning(dcUserManager) << "Query for token failed:" << m_db.lastError().databaseText() << m_db.lastError().driverText() << getTokenQueryString; + qCWarning(dcUserManager) << "Query for token failed:" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText() << getTokenQuery.lastQuery(); return false; } + if (!getTokenQuery.first()) { qCDebug(dcUserManager) << "Authorization failed for token" << token; return false; } - //qCDebug(dcUserManager) << "Token authorized for user" << result.value("username").toString(); return true; } diff --git a/libnymea-core/usermanager/usermanager.h b/libnymea-core/usermanager/usermanager.h index dc741a67..f60b6549 100644 --- a/libnymea-core/usermanager/usermanager.h +++ b/libnymea-core/usermanager/usermanager.h @@ -75,6 +75,7 @@ public: UserError removeToken(const QUuid &tokenId); + bool verifyToken(const QByteArray &token); bool hasRestrictedThingAccess(const QByteArray &token) const; diff --git a/libnymea/typeutils.h b/libnymea/typeutils.h index 6f9a1152..2bddc25d 100644 --- a/libnymea/typeutils.h +++ b/libnymea/typeutils.h @@ -207,6 +207,8 @@ public: PermissionScopeConfigureRules = 0x0030, PermissionScopeAdmin = 0xFFFF, }; + // TODO: PermissionScopeChangeUserInfos = 0x0008, // Allow to change password, remove tokens, update user information (display name, email) + Q_ENUM(PermissionScope) Q_DECLARE_FLAGS(PermissionScopes, PermissionScope) Q_FLAG(PermissionScopes) diff --git a/tests/auto/api.json b/tests/auto/api.json index acebcdae..595f40de 100644 --- a/tests/auto/api.json +++ b/tests/auto/api.json @@ -1048,7 +1048,8 @@ }, "permissionScope": "PermissionScopeNone", "returns": { - "ioConnections": "$ref:IOConnections" + "o:ioConnections": "$ref:IOConnections", + "thingError": "$ref:ThingError" } }, "Integrations.GetPluginConfiguration": { @@ -1971,6 +1972,17 @@ "error": "$ref:UserError" } }, + "Users.ChangeUserPassword": { + "description": "Change the password for the given user. All tokens for this user will be removed in order to force all clients to log in again.", + "params": { + "newPassword": "String", + "username": "String" + }, + "permissionScope": "PermissionScopeAdmin", + "returns": { + "error": "$ref:UserError" + } + }, "Users.CreateUser": { "description": "Create a new user in the API with the given username and password. Use scopes to define the permissions for the new user. If the user has not the permission \"PermissionScopeAccessAllThings\", the list of things this user has access to can be defined in the \"allowedThingIds\" property. If no scopes are given, this user will be an admin user. Call Authenticate after this to obtain a device token for this user.", "params": { @@ -2008,6 +2020,17 @@ "o:userInfo": "$ref:UserInfo" } }, + "Users.GetUserTokens": { + "description": "Get all the tokens for the given username.", + "params": { + "username": "String" + }, + "permissionScope": "PermissionScopeAdmin", + "returns": { + "error": "$ref:UserError", + "o:tokenInfoList": "$ref:TokenInfoList" + } + }, "Users.GetUsers": { "description": "Return a list of all users in the system.", "params": { @@ -2018,7 +2041,7 @@ } }, "Users.RemoveToken": { - "description": "Revoke access for a given token.", + "description": "Revoke access for a given token. Depending on the logged in user only the own tokens can be removed. If you are logged in as admin, any token can be removed.", "params": { "tokenId": "Uuid" }, @@ -3257,6 +3280,9 @@ "sslEnabled": "Bool" }, "UserInfo": { + "r:allowedThingIds": [ + "Uuid" + ], "r:displayName": "String", "r:email": "String", "r:scopes": "$ref:PermissionScopes",