From ec0aa802c52ed7f2a733b1d0529a9b95f23b045f Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Thu, 30 Jan 2020 11:44:49 +0100 Subject: [PATCH] Add more user management features --- .../jsonrpc/jsonrpcserverimplementation.cpp | 20 +- libnymea-core/jsonrpc/usershandler.cpp | 216 ++++++++++++++++++ libnymea-core/jsonrpc/usershandler.h | 39 ++++ libnymea-core/libnymea-core.pro | 22 +- libnymea-core/usermanager/tokeninfo.cpp | 12 + libnymea-core/usermanager/tokeninfo.h | 17 +- libnymea-core/usermanager/userinfo.cpp | 22 ++ libnymea-core/usermanager/userinfo.h | 53 +++++ libnymea-core/usermanager/usermanager.cpp | 125 +++++++--- libnymea-core/usermanager/usermanager.h | 11 +- nymea.pro | 3 +- tests/auto/api.json | 58 ++++- tests/auto/jsonrpc/testjsonrpc.cpp | 2 +- tests/auto/usermanager/testusermanager.cpp | 192 +++++++++++++++- 14 files changed, 728 insertions(+), 64 deletions(-) create mode 100644 libnymea-core/jsonrpc/usershandler.cpp create mode 100644 libnymea-core/jsonrpc/usershandler.h create mode 100644 libnymea-core/usermanager/userinfo.cpp create mode 100644 libnymea-core/usermanager/userinfo.h diff --git a/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp b/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp index 87f21f73..759eba61 100644 --- a/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp +++ b/libnymea-core/jsonrpc/jsonrpcserverimplementation.cpp @@ -70,6 +70,7 @@ #include "networkmanagerhandler.h" #include "tagshandler.h" #include "systemhandler.h" +#include "usershandler.h" #include #include @@ -194,13 +195,13 @@ JsonRPCServerImplementation::JsonRPCServerImplementation(const QSslConfiguration params.clear(); returns.clear(); description = "Return a list of TokenInfo objects of all the tokens for the current user."; returns.insert("tokenInfoList", QVariantList() << objectRef("TokenInfo")); - registerMethod("Tokens", description, params, returns); + registerMethod("Tokens", description, params, returns, "Use Users.GetTokens instead."); params.clear(); returns.clear(); description = "Revoke access for a given token."; params.insert("tokenId", enumValueName(Uuid)); returns.insert("error", enumRef()); - registerMethod("RemoveToken", description, params, returns); + registerMethod("RemoveToken", description, params, returns, "Use Users.RemoveToken instead."); params.clear(); returns.clear(); description = "Sets up the cloud connection by deploying a certificate and its configuration."; @@ -370,8 +371,8 @@ JsonReply *JsonRPCServerImplementation::Tokens(const QVariantMap ¶ms) const Q_UNUSED(params) QByteArray token = property("token").toByteArray(); - QString username = NymeaCore::instance()->userManager()->userForToken(token); - QList tokens = NymeaCore::instance()->userManager()->tokens(username); + TokenInfo tokenInfo = NymeaCore::instance()->userManager()->tokenInfo(token); + QList tokens = NymeaCore::instance()->userManager()->tokens(tokenInfo.username()); QVariantList retList; foreach (const TokenInfo &tokenInfo, tokens) { retList << pack(tokenInfo); @@ -569,6 +570,7 @@ void JsonRPCServerImplementation::setup() registerHandler(new NetworkManagerHandler(this)); registerHandler(new TagsHandler(this)); registerHandler(new SystemHandler(NymeaCore::instance()->platform(), this)); + registerHandler(new UsersHandler(NymeaCore::instance()->userManager(), this)); connect(NymeaCore::instance()->cloudManager(), &CloudManager::pairingReply, this, &JsonRPCServerImplementation::pairingFinished); connect(NymeaCore::instance()->cloudManager(), &CloudManager::connectionStateChanged, this, &JsonRPCServerImplementation::onCloudConnectionStateChanged); @@ -634,19 +636,19 @@ void JsonRPCServerImplementation::processJsonPacket(TransportInterface *interfac // check if authentication is required for this transport if (m_interfaces.value(interface)) { QByteArray token = message.value("token").toByteArray(); - QStringList authExemptMethodsNoUser = {"Introspect", "Hello", "CreateUser", "RequestPushButtonAuth"}; - QStringList authExemptMethodsWithUser = {"Introspect", "Hello", "Authenticate", "RequestPushButtonAuth"}; + QStringList authExemptMethodsNoUser = {"JSONRPC.Introspect", "JSONRPC.Hello", "JSONRPC.RequestPushButtonAuth", "JSONRPC.CreateUser", "Users.CreateUser"}; + QStringList authExemptMethodsWithUser = {"JSONRPC.Introspect", "JSONRPC.Hello", "JSONRPC.Authenticate", "JSONRPC.RequestPushButtonAuth"}; // if there is no user in the system yet, let's fail unless this is special method for authentication itself if (NymeaCore::instance()->userManager()->initRequired()) { - if (!(targetNamespace == "JSONRPC" && authExemptMethodsNoUser.contains(method)) && (token.isEmpty() || !NymeaCore::instance()->userManager()->verifyToken(token))) { - sendUnauthorizedResponse(interface, clientId, commandId, "Initial setup required. Call CreateUser first."); + if (!authExemptMethodsNoUser.contains(targetNamespace + "." + method) && (token.isEmpty() || !NymeaCore::instance()->userManager()->verifyToken(token))) { + sendUnauthorizedResponse(interface, clientId, commandId, "Initial setup required. Call Users.CreateUser first."); qCWarning(dcJsonRpc()) << "Initial setup required but client does not call the setup. Dropping connection."; interface->terminateClientConnection(clientId); return; } } else { // ok, we have a user. if there isn't a valid token, let's fail unless this is a Authenticate, Introspect Hello call - if (!(targetNamespace == "JSONRPC" && authExemptMethodsWithUser.contains(method)) && (token.isEmpty() || !NymeaCore::instance()->userManager()->verifyToken(token))) { + if (!authExemptMethodsWithUser.contains(targetNamespace + "." + method) && (token.isEmpty() || !NymeaCore::instance()->userManager()->verifyToken(token))) { sendUnauthorizedResponse(interface, clientId, commandId, "Forbidden: Invalid token."); qCWarning(dcJsonRpc()) << "Client did not not present a valid token. Dropping connection."; interface->terminateClientConnection(clientId); diff --git a/libnymea-core/jsonrpc/usershandler.cpp b/libnymea-core/jsonrpc/usershandler.cpp new file mode 100644 index 00000000..6b52e1aa --- /dev/null +++ b/libnymea-core/jsonrpc/usershandler.cpp @@ -0,0 +1,216 @@ +#include "usershandler.h" +#include "usermanager/usermanager.h" +#include "usermanager/userinfo.h" + +#include "loggingcategories.h" + +namespace nymeaserver { + +UsersHandler::UsersHandler(UserManager *userManager, QObject *parent): + JsonHandler(parent), + m_userManager(userManager) +{ + registerObject(); + registerObject(); + + QVariantMap params, returns; + QString description; + + params.clear(); returns.clear(); + description = "Create a new user in the API. Currently this is only allowed to be called once when a new nymea instance is set up. Call Authenticate after this to obtain a device token for this user."; + params.insert("username", enumValueName(String)); + params.insert("password", enumValueName(String)); + returns.insert("error", enumRef()); + registerMethod("CreateUser", description, params, returns); + + params.clear(); returns.clear(); + description = "Change the password for the currently logged in user."; + params.insert("newPassword", enumValueName(String)); + returns.insert("error", enumRef()); + registerMethod("ChangePassword", description, params, returns); + + params.clear(); returns.clear(); + description = "Get info about the current token (the currently logged in user)."; + returns.insert("o:userInfo", objectRef()); + returns.insert("error", enumRef()); + registerMethod("GetUserInfo", description, params, returns); + + params.clear(); returns.clear(); + description = "Get all the tokens for the current user."; + returns.insert("o:tokenInfoList", objectRef()); + returns.insert("error", enumRef()); + registerMethod("GetTokens", description, params, returns); + + params.clear(); returns.clear(); + description = "Revoke access for a given token."; + params.insert("tokenId", enumValueName(Uuid)); + returns.insert("error", enumRef()); + registerMethod("RemoveToken", description, params, returns); + +} + +QString UsersHandler::name() const +{ + return "Users"; +} + +JsonReply *UsersHandler::CreateUser(const QVariantMap ¶ms) +{ + QString username = params.value("username").toString(); + QString password = params.value("password").toString(); + + UserManager::UserError status = m_userManager->createUser(username, password); + + QVariantMap returns; + returns.insert("error", enumValueName(status)); + return createReply(returns); +} + +JsonReply *UsersHandler::ChangePassword(const QVariantMap ¶ms) +{ + QVariantMap ret; + + QByteArray currentToken = property("token").toByteArray(); + if (currentToken.isEmpty()) { + qCWarning(dcJsonRpc()) << "Cannot change 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. Is this an unauthenticated connection?"; + ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(ret); + } + + QString newPassword = params.value("newPassword").toString(); + QString username = m_userManager->userInfo(currentToken).username(); + + UserManager::UserError status = m_userManager->changePassword(username, newPassword); + ret.insert("error", enumValueName(status)); + return createReply(ret); +} + +JsonReply *UsersHandler::Authenticate(const QVariantMap ¶ms) +{ + QString username = params.value("username").toString(); + QString password = params.value("password").toString(); + QString deviceName = params.value("deviceName").toString(); + + QByteArray token = m_userManager->authenticate(username, password, deviceName); + QVariantMap ret; + ret.insert("success", !token.isEmpty()); + if (!token.isEmpty()) { + ret.insert("token", token); + } + return createReply(ret); +} + +JsonReply *UsersHandler::RequestPushButtonAuth(const QVariantMap ¶ms) +{ + QString deviceName = params.value("deviceName").toString(); + QUuid clientId = this->property("clientId").toUuid(); + + int transactionId = m_userManager->requestPushButtonAuth(deviceName); + m_pushButtonTransactions.insert(transactionId, clientId); + + QVariantMap data; + data.insert("transactionId", transactionId); + // TODO: return false if pushbutton auth is disabled in settings + data.insert("success", true); + return createReply(data); +} + +JsonReply *UsersHandler::GetUserInfo(const QVariantMap ¶ms) +{ + Q_UNUSED(params) + QVariantMap ret; + + QByteArray currentToken = property("token").toByteArray(); + if (currentToken.isEmpty()) { + qCWarning(dcJsonRpc()) << "Cannot get user info form 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. Is this an unauthenticated connection?"; + ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(ret); + } + + UserInfo userInfo = m_userManager->userInfo(currentToken); + ret.insert("userInfo", pack(userInfo)); + ret.insert("error", enumValueName(UserManager::UserErrorNoError)); + return createReply(ret); +} + +JsonReply *UsersHandler::GetTokens(const QVariantMap ¶ms) +{ + Q_UNUSED(params) + QVariantMap ret; + + QByteArray currentToken = property("token").toByteArray(); + if (currentToken.isEmpty()) { + qCWarning(dcJsonRpc()) << "Cannot fetch tokens form 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. Is this an unauthenticated connection?"; + ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(ret); + } + + TokenInfo tokenInfo = m_userManager->tokenInfo(currentToken); + qCDebug(dcJsonRpc()) << "Fetching tokens for user" << tokenInfo.username(); + QList tokens = m_userManager->tokens(tokenInfo.username()); + QVariantList retList; + foreach (const TokenInfo &tokenInfo, tokens) { + retList << pack(tokenInfo); + } + ret.insert("tokenInfoList", retList); + ret.insert("error", enumValueName(UserManager::UserErrorNoError)); + return createReply(ret); +} + +JsonReply *UsersHandler::RemoveToken(const QVariantMap ¶ms) +{ + QVariantMap ret; + + QByteArray currentToken = property("token").toByteArray(); + if (currentToken.isEmpty()) { + qCWarning(dcJsonRpc()) << "Cannot remove a token 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. Is this an unauthenticated connection?"; + ret.insert("error", enumValueName(UserManager::UserErrorPermissionDenied)); + return createReply(ret); + } + 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); + } + + 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); + } + + qCDebug(dcJsonRpc()) << "Removing token" << tokenId << "for user" << currentTokenInfo.username(); + + UserManager::UserError error = m_userManager->removeToken(tokenId); + ret.insert("error", enumValueName(error)); + return createReply(ret); +} + +} diff --git a/libnymea-core/jsonrpc/usershandler.h b/libnymea-core/jsonrpc/usershandler.h new file mode 100644 index 00000000..ddcd28bb --- /dev/null +++ b/libnymea-core/jsonrpc/usershandler.h @@ -0,0 +1,39 @@ +#ifndef USERSHANDLER_H +#define USERSHANDLER_H + +#include + +#include "jsonrpc/jsonhandler.h" + +namespace nymeaserver { + +class UserManager; + +class UsersHandler : public JsonHandler +{ + Q_OBJECT +public: + explicit UsersHandler(UserManager *userManager, QObject *parent = nullptr); + + QString name() const override; + + Q_INVOKABLE JsonReply *CreateUser(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *ChangePassword(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *Authenticate(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *RequestPushButtonAuth(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *GetUserInfo(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *GetTokens(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *RemoveToken(const QVariantMap ¶ms); + +signals: + void PushButtonAuthFinished(const QVariantMap ¶ms); + +private: + UserManager *m_userManager = nullptr; + QHash m_pushButtonTransactions; + +}; + +} + +#endif // USERSHANDLER_H diff --git a/libnymea-core/libnymea-core.pro b/libnymea-core/libnymea-core.pro index 9b229f42..c7494177 100644 --- a/libnymea-core/libnymea-core.pro +++ b/libnymea-core/libnymea-core.pro @@ -19,8 +19,6 @@ HEADERS += nymeacore.h \ devices/devicemanagerimplementation.h \ devices/translator.h \ experiences/experiencemanager.h \ - jsonrpc/jsonrpcserverimplementation.h \ - jsonrpc/scriptshandler.h \ ruleengine/ruleengine.h \ ruleengine/rule.h \ ruleengine/stateevaluator.h \ @@ -43,6 +41,7 @@ HEADERS += nymeacore.h \ servers/bluetoothserver.h \ servers/websocketserver.h \ servers/mqttbroker.h \ + jsonrpc/jsonrpcserverimplementation.h \ jsonrpc/jsonvalidator.h \ jsonrpc/devicehandler.h \ jsonrpc/ruleshandler.h \ @@ -52,6 +51,10 @@ HEADERS += nymeacore.h \ jsonrpc/logginghandler.h \ jsonrpc/configurationhandler.h \ jsonrpc/networkmanagerhandler.h \ + jsonrpc/tagshandler.h \ + jsonrpc/systemhandler.h \ + jsonrpc/scriptshandler.h \ + jsonrpc/usershandler.h \ logging/logging.h \ logging/logengine.h \ logging/logfilter.h \ @@ -66,6 +69,7 @@ HEADERS += nymeacore.h \ networkmanager/networksettings.h \ networkmanager/networkconnection.h \ networkmanager/wirednetworkdevice.h \ + usermanager/userinfo.h \ usermanager/usermanager.h \ usermanager/tokeninfo.h \ usermanager/pushbuttondbusservice.h \ @@ -90,18 +94,15 @@ HEADERS += nymeacore.h \ debugserverhandler.h \ tagging/tagsstorage.h \ tagging/tag.h \ - jsonrpc/tagshandler.h \ cloud/cloudtransport.h \ debugreportgenerator.h \ platform/platform.h \ - jsonrpc/systemhandler.h + SOURCES += nymeacore.cpp \ devices/devicemanagerimplementation.cpp \ devices/translator.cpp \ experiences/experiencemanager.cpp \ - jsonrpc/jsonrpcserverimplementation.cpp \ - jsonrpc/scriptshandler.cpp \ ruleengine/ruleengine.cpp \ ruleengine/rule.cpp \ ruleengine/stateevaluator.cpp \ @@ -124,6 +125,7 @@ SOURCES += nymeacore.cpp \ servers/websocketserver.cpp \ servers/bluetoothserver.cpp \ servers/mqttbroker.cpp \ + jsonrpc/jsonrpcserverimplementation.cpp \ jsonrpc/jsonvalidator.cpp \ jsonrpc/devicehandler.cpp \ jsonrpc/ruleshandler.cpp \ @@ -133,6 +135,10 @@ SOURCES += nymeacore.cpp \ jsonrpc/logginghandler.cpp \ jsonrpc/configurationhandler.cpp \ jsonrpc/networkmanagerhandler.cpp \ + jsonrpc/tagshandler.cpp \ + jsonrpc/systemhandler.cpp \ + jsonrpc/scriptshandler.cpp \ + jsonrpc/usershandler.cpp \ logging/logengine.cpp \ logging/logfilter.cpp \ logging/logentry.cpp \ @@ -145,6 +151,7 @@ SOURCES += nymeacore.cpp \ networkmanager/networksettings.cpp \ networkmanager/networkconnection.cpp \ networkmanager/wirednetworkdevice.cpp \ + usermanager/userinfo.cpp \ usermanager/usermanager.cpp \ usermanager/tokeninfo.cpp \ usermanager/pushbuttondbusservice.cpp \ @@ -169,11 +176,10 @@ SOURCES += nymeacore.cpp \ debugserverhandler.cpp \ tagging/tagsstorage.cpp \ tagging/tag.cpp \ - jsonrpc/tagshandler.cpp \ cloud/cloudtransport.cpp \ debugreportgenerator.cpp \ platform/platform.cpp \ - jsonrpc/systemhandler.cpp + versionAtLeast(QT_VERSION, 5.12.0) { HEADERS += \ diff --git a/libnymea-core/usermanager/tokeninfo.cpp b/libnymea-core/usermanager/tokeninfo.cpp index e94c3190..19385cae 100644 --- a/libnymea-core/usermanager/tokeninfo.cpp +++ b/libnymea-core/usermanager/tokeninfo.cpp @@ -42,6 +42,8 @@ #include "tokeninfo.h" +#include + namespace nymeaserver { TokenInfo::TokenInfo() @@ -83,4 +85,14 @@ QString TokenInfo::deviceName() const return m_deviceName; } +QVariant TokenInfoList::get(int index) const +{ + return QVariant::fromValue(at(index)); +} + +void TokenInfoList::put(const QVariant &variant) +{ + append(variant.value()); +} + } diff --git a/libnymea-core/usermanager/tokeninfo.h b/libnymea-core/usermanager/tokeninfo.h index 41b918e4..47277bc9 100644 --- a/libnymea-core/usermanager/tokeninfo.h +++ b/libnymea-core/usermanager/tokeninfo.h @@ -34,6 +34,7 @@ #include #include #include +#include namespace nymeaserver { @@ -43,7 +44,7 @@ class TokenInfo Q_PROPERTY(QUuid id READ id) Q_PROPERTY(QString username READ username) Q_PROPERTY(QDateTime creationTime READ creationTime) - Q_PROPERTY(QString deviveName READ deviceName) + Q_PROPERTY(QString deviceName READ deviceName) public: TokenInfo(); @@ -61,7 +62,17 @@ private: QString m_deviceName; }; -} -Q_DECLARE_METATYPE(nymeaserver::TokenInfo) +class TokenInfoList: public QList +{ + Q_GADGET + Q_PROPERTY(int count READ count) +public: + Q_INVOKABLE QVariant get(int index) const; + Q_INVOKABLE void put(const QVariant &variant); +}; +} + +Q_DECLARE_METATYPE(nymeaserver::TokenInfo) +Q_DECLARE_METATYPE(nymeaserver::TokenInfoList) #endif // TOKENINFO_H diff --git a/libnymea-core/usermanager/userinfo.cpp b/libnymea-core/usermanager/userinfo.cpp new file mode 100644 index 00000000..edd07451 --- /dev/null +++ b/libnymea-core/usermanager/userinfo.cpp @@ -0,0 +1,22 @@ +#include "userinfo.h" + +UserInfo::UserInfo() +{ + +} + +UserInfo::UserInfo(const QString &username): + m_username(username) +{ + +} + +QString UserInfo::username() const +{ + return m_username; +} + +void UserInfo::setUsername(const QString &username) +{ + m_username = username; +} diff --git a/libnymea-core/usermanager/userinfo.h b/libnymea-core/usermanager/userinfo.h new file mode 100644 index 00000000..8423d470 --- /dev/null +++ b/libnymea-core/usermanager/userinfo.h @@ -0,0 +1,53 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2020, 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 USERINFO_H +#define USERINFO_H + +#include +#include + +class UserInfo +{ + Q_GADGET + Q_PROPERTY(QString username READ username) + +public: + UserInfo(); + UserInfo(const QString &username); + + QString username() const; + void setUsername(const QString &username); + +private: + QString m_username; +}; + +#endif // USERINFO_H diff --git a/libnymea-core/usermanager/usermanager.cpp b/libnymea-core/usermanager/usermanager.cpp index 559d4d60..c3a13d8b 100644 --- a/libnymea-core/usermanager/usermanager.cpp +++ b/libnymea-core/usermanager/usermanager.cpp @@ -167,7 +167,7 @@ UserManager::UserError UserManager::createUser(const QString &username, const QS QByteArray salt = QUuid::createUuid().toString().remove(QRegExp("[{}]")).toUtf8(); QByteArray hashedPassword = QCryptographicHash::hash(QString(password + salt).toUtf8(), QCryptographicHash::Sha512).toBase64(); QString queryString = QString("INSERT INTO users(username, password, salt) values(\"%1\", \"%2\", \"%3\");") - .arg(username) + .arg(username.toLower()) .arg(QString::fromUtf8(hashedPassword)) .arg(QString::fromUtf8(salt)); m_db.exec(queryString); @@ -178,9 +178,41 @@ UserManager::UserError UserManager::createUser(const QString &username, const QS return UserErrorNoError; } -/*! Remove the user with the given \a username and all of its tokens. If the \a username is empty, all anonymous - tokens (e.g. issued by pushbutton auth) will be cleared. -*/ +UserManager::UserError UserManager::changePassword(const QString &username, const QString &newPassword) +{ + if (!validateUsername(username)) { + qCWarning(dcUserManager) << "Invalid username:" << username; + return UserErrorInvalidUserId; + } + + if (!validatePassword(newPassword)) { + qCWarning(dcUserManager) << "Password failed character validation. Must contain a letter, a number and a special charactar. Minimum length: 8"; + return UserErrorBadPassword; + } + + QString checkForUserExistingQuery = QString("SELECT * FROM users WHERE lower(username) = \"%1\";").arg(username.toLower()); + QSqlQuery result = m_db.exec(checkForUserExistingQuery); + if (!result.first()) { + qCWarning(dcUserManager) << "Username does not exist."; + return UserErrorInvalidUserId; + } + + // Update the password + QByteArray salt = QUuid::createUuid().toString().remove(QRegExp("[{}]")).toUtf8(); + QByteArray hashedPassword = QCryptographicHash::hash(QString(newPassword + salt).toUtf8(), QCryptographicHash::Sha512).toBase64(); + QString queryString = QString("UPDATE users SET password = \"%1\", salt = \"%2\" WHERE lower(username) = \"%3\";") + .arg(QString::fromUtf8(hashedPassword)) + .arg(QString::fromUtf8(salt)) + .arg(username.toLower()); + m_db.exec(queryString); + if (m_db.lastError().type() != QSqlError::NoError) { + qCWarning(dcUserManager) << "Error updating password for user:" << m_db.lastError().databaseText() << m_db.lastError().driverText(); + return UserErrorBackendError; + } + qCDebug(dcUserManager()) << "Password updated for user" << username; + return UserErrorNoError; +} + UserManager::UserError UserManager::removeUser(const QString &username) { if (!username.isEmpty()) { @@ -189,14 +221,11 @@ UserManager::UserError UserManager::removeUser(const QString &username) if (result.numRowsAffected() == 0) { return UserErrorInvalidUserId; } - - QString dropTokensQuery = QString("DELETE FROM tokens WHERE lower(username) = \"%1\";").arg(username.toLower()); - m_db.exec(dropTokensQuery); - } else { - QString dropTokensQuery = QString("DELETE FROM tokens WHERE username = \"\";").arg(username.toLower()); - m_db.exec(dropTokensQuery); } + QString dropTokensQuery = QString("DELETE FROM tokens WHERE lower(username) = \"%1\";").arg(username.toLower()); + m_db.exec(dropTokensQuery); + return UserErrorNoError; } @@ -233,7 +262,7 @@ QByteArray UserManager::authenticate(const QString &username, const QString &pas QByteArray token = QCryptographicHash::hash(QUuid::createUuid().toByteArray(), QCryptographicHash::Sha256).toBase64(); QString storeTokenQuery = QString("INSERT INTO tokens(id, username, token, creationdate, devicename) VALUES(\"%1\", \"%2\", \"%3\", \"%4\", \"%5\");") .arg(QUuid::createUuid().toString()) - .arg(username) + .arg(username.toLower()) .arg(QString::fromUtf8(token)) .arg(NymeaCore::instance()->timeManager()->currentDateTime().toString("yyyy-MM-dd hh:mm:ss")) .arg(deviceName); @@ -280,32 +309,32 @@ void UserManager::cancelPushButtonAuth(int transactionId) } -/*! Returns the username for the given \a token. If the token is invalid, an empty string will be returned. */ -QString UserManager::userForToken(const QByteArray &token) const +UserInfo UserManager::userInfo(const QByteArray &token) const { - if (!validateToken(token)) { - qCWarning(dcUserManager) << "Token failed character validation:" << token; - return QString(); + TokenInfo tokenInfo = this->tokenInfo(token); + + if (tokenInfo.id().isNull()) { + qCWarning(dcUserManager) << "Cannot fetch user info for invalid token:" << token; + return UserInfo(); } - QString getUserQuery = QString("SELECT * FROM tokens WHERE token = \"%1\";") - .arg(QString::fromUtf8(token)); + + // OK, this seems pointless, but data structures are prepared to have more details about users than just the username + // i.e. permissions etc will be in here at some point + QString getUserQuery = QString("SELECT username FROM users WHERE lower(username) = \"%1\";") + .arg(tokenInfo.username().toLower()); QSqlQuery result = m_db.exec(getUserQuery); if (m_db.lastError().type() != QSqlError::NoError) { - qCWarning(dcUserManager) << "Error fetching username for token:" << m_db.lastError().databaseText() << m_db.lastError().driverText() << getUserQuery; - return QString(); - } - if (!result.first()) { - qCWarning(dcUserManager) << "No such token in DB:" << token; - return QString(); + qCWarning(dcUserManager) << "Query for token failed:" << m_db.lastError().databaseText() << m_db.lastError().driverText() << getUserQuery; + return UserInfo(); } - return result.value("username").toString(); + if (!result.first()) { + return UserInfo(); + } + return UserInfo(result.value("username").toString()); + } -/*! Returns a list of tokens for the given \a username. - - \sa TokenInfo -*/ QList UserManager::tokens(const QString &username) const { QList ret; @@ -328,6 +357,44 @@ QList UserManager::tokens(const QString &username) const return ret; } +TokenInfo UserManager::tokenInfo(const QByteArray &token) const +{ + if (!validateToken(token)) { + qCWarning(dcUserManager) << "Token did not pass validation:" << token; + return TokenInfo(); + } + + QString getTokenQuery = QString("SELECT id, username, creationdate, deviceName FROM tokens WHERE token = \"%1\";") + .arg(QString::fromUtf8(token)); + QSqlQuery result = m_db.exec(getTokenQuery); + if (m_db.lastError().type() != QSqlError::NoError) { + qCWarning(dcUserManager) << "Query for token failed:" << m_db.lastError().databaseText() << m_db.lastError().driverText() << getTokenQuery; + return TokenInfo(); + } + + if (!result.first()) { + return TokenInfo(); + } + return TokenInfo(result.value("id").toUuid(), result.value("username").toString(), result.value("creationdate").toDateTime(), result.value("devicename").toString()); +} + +TokenInfo UserManager::tokenInfo(const QUuid &tokenId) const +{ + + QString getTokenQuery = QString("SELECT id, username, creationdate, deviceName FROM tokens WHERE id = \"%1\";") + .arg(tokenId.toString()); + QSqlQuery result = m_db.exec(getTokenQuery); + if (m_db.lastError().type() != QSqlError::NoError) { + qCWarning(dcUserManager) << "Query for token failed:" << m_db.lastError().databaseText() << m_db.lastError().driverText() << getTokenQuery; + return TokenInfo(); + } + + if (!result.first()) { + return TokenInfo(); + } + return TokenInfo(result.value("id").toUuid(), result.value("username").toString(), result.value("creationdate").toDateTime(), result.value("devicename").toString()); +} + /*! Removes the token with the given \a tokenId. Returns \l{UserError} to inform about the result. */ UserManager::UserError UserManager::removeToken(const QUuid &tokenId) { diff --git a/libnymea-core/usermanager/usermanager.h b/libnymea-core/usermanager/usermanager.h index 1ae2c261..547c7354 100644 --- a/libnymea-core/usermanager/usermanager.h +++ b/libnymea-core/usermanager/usermanager.h @@ -32,6 +32,7 @@ #define USERMANAGER_H #include "tokeninfo.h" +#include "userinfo.h" #include #include @@ -61,6 +62,7 @@ public: QStringList users() const; UserError createUser(const QString &username, const QString &password); + UserError changePassword(const QString &username, const QString &newPassword); UserError removeUser(const QString &username); bool pushButtonAuthAvailable() const; @@ -68,9 +70,14 @@ public: QByteArray authenticate(const QString &username, const QString &password, const QString &deviceName); int requestPushButtonAuth(const QString &deviceName); void cancelPushButtonAuth(int transactionId); - QString userForToken(const QByteArray &token) const; + + UserInfo userInfo(const QByteArray &token) const; + TokenInfo tokenInfo(const QByteArray &token) const; + TokenInfo tokenInfo(const QUuid &tokenId) const; QList tokens(const QString &username) const; - nymeaserver::UserManager::UserError removeToken(const QUuid &tokenId); + + UserError removeToken(const QUuid &tokenId); + bool verifyToken(const QByteArray &token); diff --git a/nymea.pro b/nymea.pro index 81a8c41a..ae3df7c8 100644 --- a/nymea.pro +++ b/nymea.pro @@ -5,7 +5,7 @@ NYMEA_VERSION_STRING=$$system('dpkg-parsechangelog | sed -n -e "s/^Version: //p" # define protocol versions JSON_PROTOCOL_VERSION_MAJOR=4 -JSON_PROTOCOL_VERSION_MINOR=1 +JSON_PROTOCOL_VERSION_MINOR=2 JSON_PROTOCOL_VERSION="$${JSON_PROTOCOL_VERSION_MAJOR}.$${JSON_PROTOCOL_VERSION_MINOR}" LIBNYMEA_API_VERSION_MAJOR=4 LIBNYMEA_API_VERSION_MINOR=0 @@ -105,3 +105,4 @@ coverage { ccache { message("Using ccache.") } + diff --git a/tests/auto/api.json b/tests/auto/api.json index 2fa78ed2..8c9c78ec 100644 --- a/tests/auto/api.json +++ b/tests/auto/api.json @@ -1,4 +1,4 @@ -4.1 +4.2 { "enums": { "BasicType": [ @@ -920,6 +920,7 @@ } }, "JSONRPC.RemoveToken": { + "deprecated": "Use Users.RemoveToken instead.", "description": "Revoke access for a given token.", "params": { "tokenId": "Uuid" @@ -974,6 +975,7 @@ } }, "JSONRPC.Tokens": { + "deprecated": "Use Users.GetTokens instead.", "description": "Return a list of TokenInfo objects of all the tokens for the current user.", "params": { }, @@ -1464,6 +1466,52 @@ "returns": { "tagError": "$ref:TagError" } + }, + "Users.ChangePassword": { + "description": "Change the password for the currently logged in user.", + "params": { + "newPassword": "String" + }, + "returns": { + "error": "$ref:UserError" + } + }, + "Users.CreateUser": { + "description": "Create a new user in the API. Currently this is only allowed to be called once when a new nymea instance is set up. Call Authenticate after this to obtain a device token for this user.", + "params": { + "password": "String", + "username": "String" + }, + "returns": { + "error": "$ref:UserError" + } + }, + "Users.GetTokens": { + "description": "Get all the tokens for the current user.", + "params": { + }, + "returns": { + "error": "$ref:UserError", + "o:tokenInfoList": "$ref:TokenInfoList" + } + }, + "Users.GetUserInfo": { + "description": "Get info about the current token (the currently logged in user).", + "params": { + }, + "returns": { + "error": "$ref:UserError", + "o:userInfo": "$ref:UserInfo" + } + }, + "Users.RemoveToken": { + "description": "Revoke access for a given token.", + "params": { + "tokenId": "Uuid" + }, + "returns": { + "error": "$ref:UserError" + } } }, "notifications": { @@ -2147,10 +2195,16 @@ ], "TokenInfo": { "r:creationTime": "Uint", - "r:deviveName": "String", + "r:deviceName": "String", "r:id": "Uuid", "r:username": "String" }, + "TokenInfoList": [ + "$ref:TokenInfo" + ], + "UserInfo": { + "r:username": "String" + }, "Vendor": { "displayName": "String", "id": "Uuid", diff --git a/tests/auto/jsonrpc/testjsonrpc.cpp b/tests/auto/jsonrpc/testjsonrpc.cpp index 962b6662..1c706d94 100644 --- a/tests/auto/jsonrpc/testjsonrpc.cpp +++ b/tests/auto/jsonrpc/testjsonrpc.cpp @@ -675,7 +675,7 @@ void TestJSONRPC::enableDisableNotifications_legacy() QStringList expectedNamespaces; if (enabled == "true") { - expectedNamespaces << "Actions" << "NetworkManager" << "Devices" << "System" << "Rules" << "States" << "Logging" << "Tags" << "JSONRPC" << "Configuration" << "Events" << "Scripts"; + expectedNamespaces << "Actions" << "NetworkManager" << "Devices" << "System" << "Rules" << "States" << "Logging" << "Tags" << "JSONRPC" << "Configuration" << "Events" << "Scripts" << "Users"; } std::sort(expectedNamespaces.begin(), expectedNamespaces.end()); diff --git a/tests/auto/usermanager/testusermanager.cpp b/tests/auto/usermanager/testusermanager.cpp index 24d6a5d1..6a7e1a6f 100644 --- a/tests/auto/usermanager/testusermanager.cpp +++ b/tests/auto/usermanager/testusermanager.cpp @@ -34,6 +34,7 @@ #include "nymeacore.h" #include "nymeatestbase.h" #include "usermanager/usermanager.h" +#include "servers/mocktcpserver.h" using namespace nymeaserver; @@ -44,11 +45,33 @@ public: TestUsermanager(QObject* parent = nullptr); private slots: - void createUser_data(); + void init(); + + void loginValidation_data(); + void loginValidation(); + void createUser(); + void authenticate(); + + void createDuplicateUser(); + + void getTokens(); + + void removeToken(); + + void unauthenticatedCallAfterTokenRemove(); + + void changePassword(); + + void authenticateAfterPasswordChangeOK(); + + void authenticateAfterPasswordChangeFail(); + + void getUserInfo(); private: - LogEngine *engine; + // m_apiToken is in testBase + QUuid m_tokenId; }; TestUsermanager::TestUsermanager(QObject *parent): NymeaTestBase(parent) @@ -56,7 +79,18 @@ TestUsermanager::TestUsermanager(QObject *parent): NymeaTestBase(parent) QCoreApplication::instance()->setOrganizationName("nymea-test"); } -void TestUsermanager::createUser_data() { +void TestUsermanager::init() +{ + UserManager *userManager = NymeaCore::instance()->userManager(); + foreach (const QString &user, userManager->users()) { + qCDebug(dcTests()) << "Removing user" << user; + userManager->removeUser(user); + } + userManager->removeUser(""); + +} + +void TestUsermanager::loginValidation_data() { QTest::addColumn("username"); QTest::addColumn("password"); QTest::addColumn("expectedError"); @@ -88,23 +122,163 @@ void TestUsermanager::createUser_data() { } -void TestUsermanager::createUser() +void TestUsermanager::loginValidation() { QFETCH(QString, username); QFETCH(QString, password); QFETCH(UserManager::UserError, expectedError); UserManager *userManager = NymeaCore::instance()->userManager(); - foreach (const QString &user, userManager->users()) { - userManager->removeUser(user); - } - userManager->removeUser(""); - UserManager::UserError error = userManager->createUser(username, password); qDebug() << "Error:" << error << "Expected:" << expectedError; QCOMPARE(error, expectedError); } +void TestUsermanager::createUser() +{ + QVariantMap params; + params.insert("username", "valid@user.test"); + params.insert("password", "Bla1234*"); + QVariant 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"); +} + +void TestUsermanager::authenticate() +{ + m_apiToken.clear(); + createUser(); + + QVariantMap params; + params.insert("username", "valid@user.test"); + params.insert("password", "Bla1234*"); + params.insert("deviceName", "autotests"); + QVariant response = injectAndWait("JSONRPC.Authenticate", params); + + m_apiToken = response.toMap().value("params").toMap().value("token").toByteArray(); + + QVERIFY2(response.toMap().value("status").toString() == "success", "Error authenticating"); + QVERIFY2(response.toMap().value("params").toMap().value("success").toString() == "true", "Error authenticating"); +} + +void TestUsermanager::createDuplicateUser() +{ + authenticate(); + + QVariantMap params; + params.insert("username", "valid@user.test"); + params.insert("password", "Bla1234*"); + QVariant response = injectAndWait("Users.CreateUser", params); + + QVERIFY2(response.toMap().value("status").toString() == "success", "Unexpected error code creating duplicate user"); + QVERIFY2(response.toMap().value("params").toMap().value("error").toString() == "UserErrorDuplicateUserId", "Unexpected error creating duplicate user"); +} + +void TestUsermanager::getTokens() +{ + authenticate(); + + QVariant response = injectAndWait("Users.GetTokens"); + QVERIFY2(response.toMap().value("status").toString() == "success", "Unexpected error code creating duplicate user"); + QCOMPARE(response.toMap().value("params").toMap().value("error").toString(), QString("UserErrorNoError")); + + QVariantList tokenInfoList = response.toMap().value("params").toMap().value("tokenInfoList").toList(); + QCOMPARE(tokenInfoList.count(), 1); + + m_tokenId = tokenInfoList.first().toMap().value("id").toUuid(); + QVERIFY2(!m_tokenId.isNull(), "Token ID should not be null"); + QCOMPARE(tokenInfoList.first().toMap().value("username").toString(), QString("valid@user.test")); + QCOMPARE(tokenInfoList.first().toMap().value("deviceName").toString(), QString("autotests")); +} + +void TestUsermanager::removeToken() +{ + getTokens(); + + QVariantMap params; + params.insert("tokenId", m_tokenId); + QVariant response = injectAndWait("Users.RemoveToken", params); + QCOMPARE(response.toMap().value("status").toString(), QString("success")); + QCOMPARE(response.toMap().value("params").toMap().value("error").toString(), QString("UserErrorNoError")); +} + +void TestUsermanager::changePassword() +{ + authenticate(); + + QVariantMap params; + params.insert("newPassword", "Blubb123"); + QVariant response = injectAndWait("Users.ChangePassword", params); + QCOMPARE(response.toMap().value("status").toString(), QString("success")); + QCOMPARE(response.toMap().value("params").toMap().value("error").toString(), QString("UserErrorNoError")); +} + +void TestUsermanager::authenticateAfterPasswordChangeOK() +{ + changePassword(); + + QVariantMap params; + params.insert("username", "valid@user.test"); + params.insert("password", "Blubb123"); // New password, should be ok + params.insert("deviceName", "autotests"); + QVariant response = injectAndWait("JSONRPC.Authenticate", params); + + m_apiToken = response.toMap().value("params").toMap().value("token").toByteArray(); + QVERIFY2(!m_apiToken.isEmpty(), "Token should not be empty"); + QVERIFY2(response.toMap().value("status").toString() == "success", "Error authenticating"); + QVERIFY2(response.toMap().value("params").toMap().value("success").toString() == "true", "Error authenticating"); +} + +void TestUsermanager::authenticateAfterPasswordChangeFail() +{ + changePassword(); + + QVariantMap params; + params.insert("username", "valid@user.test"); + params.insert("password", "Bla1234*"); // Original password, should not be ok + params.insert("deviceName", "autotests"); + QVariant response = injectAndWait("JSONRPC.Authenticate", params); + + m_apiToken = response.toMap().value("params").toMap().value("token").toByteArray(); + QVERIFY2(m_apiToken.isEmpty(), "Token should be empty"); + QVERIFY2(response.toMap().value("status").toString() == "success", "Error authenticating"); + QCOMPARE(response.toMap().value("params").toMap().value("success").toString(), QString("false")); +} + +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() +{ + removeToken(); + + QSignalSpy spy(m_mockTcpServer, &MockTcpServer::connectionTerminated); + + QVariant response = injectAndWait("Users.GetTokens"); + QCOMPARE(response.toMap().value("status").toString(), QString("unauthorized")); + + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY2(spy.count() == 1, "Connection should be terminated!"); + + // need to restart as our connection dies + restartServer(); +} + #include "testusermanager.moc" QTEST_MAIN(TestUsermanager)