From 08727a07baa471aa504c1e6da9b973ba86c85a2e Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Tue, 1 Aug 2017 16:49:28 +0200 Subject: [PATCH] Added API authentication --- libguh/loggingcategories.cpp | 1 + libguh/loggingcategories.h | 1 + server/guhcore.cpp | 7 + server/guhcore.h | 2 + server/jsonrpc/jsonrpcserver.cpp | 79 ++++++++++- server/jsonrpc/jsonrpcserver.h | 7 +- server/jsonrpc/jsontypes.cpp | 9 ++ server/jsonrpc/jsontypes.h | 3 + server/logging/logengine.cpp | 62 ++++----- server/main.cpp | 1 + server/server.pri | 2 + server/usermanager.cpp | 181 ++++++++++++++++++++++++++ server/usermanager.h | 64 +++++++++ tests/auto/api.json | 29 +++++ tests/auto/guhtestbase.cpp | 4 + tests/auto/guhtestbase.h | 1 + tests/auto/jsonrpc/testjsonrpc.cpp | 148 +++++++++++++++++++++ tests/scripts/authenticate.sh | 7 + tests/scripts/createuser.sh | 7 + tests/scripts/getconfigureddevices.sh | 2 +- translations/guhd-de_DE.ts | 12 +- translations/guhd-en_US.ts | 12 +- 22 files changed, 589 insertions(+), 52 deletions(-) create mode 100644 server/usermanager.cpp create mode 100644 server/usermanager.h create mode 100755 tests/scripts/authenticate.sh create mode 100755 tests/scripts/createuser.sh diff --git a/libguh/loggingcategories.cpp b/libguh/loggingcategories.cpp index f26ffb2d..e2060c60 100644 --- a/libguh/loggingcategories.cpp +++ b/libguh/loggingcategories.cpp @@ -38,3 +38,4 @@ Q_LOGGING_CATEGORY(dcOAuth2, "OAuth2") Q_LOGGING_CATEGORY(dcAvahi, "Avahi") Q_LOGGING_CATEGORY(dcCloud, "Cloud") Q_LOGGING_CATEGORY(dcNetworkManager, "NetworkManager") +Q_LOGGING_CATEGORY(dcUserManager, "UserManager") diff --git a/libguh/loggingcategories.h b/libguh/loggingcategories.h index cf7dec19..c6d66bba 100644 --- a/libguh/loggingcategories.h +++ b/libguh/loggingcategories.h @@ -46,5 +46,6 @@ Q_DECLARE_LOGGING_CATEGORY(dcOAuth2) Q_DECLARE_LOGGING_CATEGORY(dcAvahi) Q_DECLARE_LOGGING_CATEGORY(dcCloud) Q_DECLARE_LOGGING_CATEGORY(dcNetworkManager) +Q_DECLARE_LOGGING_CATEGORY(dcUserManager) #endif // LOGGINGCATEGORYS_H diff --git a/server/guhcore.cpp b/server/guhcore.cpp index 4c89642c..6d21402d 100644 --- a/server/guhcore.cpp +++ b/server/guhcore.cpp @@ -404,6 +404,11 @@ NetworkManager *GuhCore::networkManager() const return m_networkManager; } +UserManager *GuhCore::userManager() const +{ + return m_userManager; +} + #ifdef TESTING_ENABLED MockTcpServer *GuhCore::tcpServer() const { @@ -462,6 +467,8 @@ GuhCore::GuhCore(QObject *parent) : // Create the NetworkManager m_networkManager = new NetworkManager(this); + m_userManager = new UserManager(this); + connect(m_configuration, &GuhConfiguration::localeChanged, this, &GuhCore::onLocaleChanged); connect(m_deviceManager, &DeviceManager::pluginConfigChanged, this, &GuhCore::pluginConfigChanged); diff --git a/server/guhcore.h b/server/guhcore.h index faa5c379..25be8d88 100644 --- a/server/guhcore.h +++ b/server/guhcore.h @@ -86,6 +86,7 @@ public: ServerManager *serverManager() const; BluetoothServer *bluetoothServer() const; NetworkManager *networkManager() const; + UserManager *userManager() const; static QStringList getAvailableLanguages(); @@ -125,6 +126,7 @@ private: TimeManager *m_timeManager; NetworkManager *m_networkManager; + UserManager *m_userManager; #ifdef TESTING_ENABLED MockTcpServer *m_tcpServer; diff --git a/server/jsonrpc/jsonrpcserver.cpp b/server/jsonrpc/jsonrpcserver.cpp index 191165b6..f2ae03a6 100644 --- a/server/jsonrpc/jsonrpcserver.cpp +++ b/server/jsonrpc/jsonrpcserver.cpp @@ -1,7 +1,7 @@ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Copyright (C) 2015 Simon Stürz * - * Copyright (C) 2014 Michael Zanetti * + * Copyright (C) 2014-2017 Michael Zanetti * * * * This file is part of guh. * * * @@ -95,6 +95,24 @@ JsonRPCServer::JsonRPCServer(const QSslConfiguration &sslConfiguration, QObject returns.insert("enabled", JsonTypes::basicTypeToString(JsonTypes::Bool)); setReturns("SetNotificationStatus", returns); + params.clear(); returns.clear(); + setDescription("CreateUser", "Create a new user in the API. Currently this is only allowed to be called once when a new guh instance is set up. Call Authenticate after this to obtain a device token for this user."); + params.insert("username", JsonTypes::basicTypeToString(JsonTypes::String)); + params.insert("password", JsonTypes::basicTypeToString(JsonTypes::String)); + setParams("CreateUser", params); + returns.insert("error", JsonTypes::userErrorRef()); + setReturns("CreateUser", returns); + + params.clear(); returns.clear(); + setDescription("Authenticate", "Authenticate a client to the api. This will return a new token to be used to authorize a client at the API."); + params.insert("username", JsonTypes::basicTypeToString(JsonTypes::String)); + params.insert("password", JsonTypes::basicTypeToString(JsonTypes::String)); + params.insert("deviceName", JsonTypes::basicTypeToString(JsonTypes::String)); + setParams("Authenticate", params); + returns.insert("success", JsonTypes::basicTypeToString(JsonTypes::String)); + returns.insert("o:token", JsonTypes::basicTypeToString(JsonTypes::String)); + setReturns("Authenticate", returns); + QMetaObject::invokeMethod(this, "setup", Qt::QueuedConnection); } @@ -144,6 +162,33 @@ JsonReply* JsonRPCServer::SetNotificationStatus(const QVariantMap ¶ms) return createReply(returns); } +JsonReply *JsonRPCServer::CreateUser(const QVariantMap ¶ms) +{ + QString username = params.value("username").toString(); + QString password = params.value("password").toString(); + + UserManager::UserError status = GuhCore::instance()->userManager()->createUser(username, password); + + QVariantMap returns; + returns.insert("error", JsonTypes::userErrorToString(status)); + return createReply(returns); +} + +JsonReply *JsonRPCServer::Authenticate(const QVariantMap ¶ms) +{ + QString username = params.value("username").toString(); + QString password = params.value("password").toString(); + QString deviceName = params.value("deviceName").toString(); + + QByteArray token = GuhCore::instance()->userManager()->authenticate(username, password, deviceName); + QVariantMap ret; + ret.insert("success", !token.isEmpty()); + if (!token.isEmpty()) { + ret.insert("token", token); + } + return createReply(ret); +} + /*! Returns the list of registred \l{JsonHandler}{JsonHandlers} and their name.*/ QHash JsonRPCServer::handlers() const { @@ -189,6 +234,16 @@ void JsonRPCServer::sendErrorResponse(TransportInterface *interface, const QUuid interface->sendData(clientId, QJsonDocument::fromVariant(errorResponse).toJson()); } +void JsonRPCServer::sendUnauthorizedResponse(TransportInterface *interface, const QUuid &clientId, int commandId, const QString &error) +{ + QVariantMap errorResponse; + errorResponse.insert("id", commandId); + errorResponse.insert("status", "unauthorized"); + errorResponse.insert("error", error); + + interface->sendData(clientId, QJsonDocument::fromVariant(errorResponse).toJson()); +} + void JsonRPCServer::setup() { registerHandler(this); @@ -230,10 +285,25 @@ void JsonRPCServer::processData(const QUuid &clientId, const QByteArray &data) sendErrorResponse(interface, clientId, commandId, QString("Error parsing method. Got: '%1'', Expected: 'Namespace.method'").arg(message.value("method").toString())); return; } - QString targetNamespace = commandList.first(); QString method = commandList.last(); + // if there is no user in the system yet, let's fail unless this is a CreateUser or Introspect call + if (GuhCore::instance()->userManager()->users().isEmpty()) { + if (!(targetNamespace == "JSONRPC" && (method == "CreateUser" || method == "Introspect"))) { + sendUnauthorizedResponse(interface, clientId, commandId, "Initial setup required. Call CreateUser first."); + return; + } + } else { + // ok, we have a user. if there isn't a valid token, let's fail unless this is a Authenticate or Introspect call + QByteArray token = message.value("token").toByteArray(); + if (!(targetNamespace == "JSONRPC" && (method == "Authenticate" || method == "Introspect")) && !GuhCore::instance()->userManager()->verifyToken(token)) { + sendUnauthorizedResponse(interface, clientId, commandId, "Forbidden: Invalid token."); + return; + } + } + // At this point we can assume all the calls are authorized + JsonHandler *handler = m_handlers.value(targetNamespace); if (!handler) { sendErrorResponse(interface, clientId, commandId, "No such namespace"); @@ -324,8 +394,8 @@ void JsonRPCServer::registerHandler(JsonHandler *handler) void JsonRPCServer::clientConnected(const QUuid &clientId) { - // Notifications enabled by default - m_clients.insert(clientId, true); + // Notifications disabled by default. Clients must enable them with a valid token + m_clients.insert(clientId, false); TransportInterface *interface = qobject_cast(sender()); @@ -337,6 +407,7 @@ void JsonRPCServer::clientConnected(const QUuid &clientId) handshake.insert("uuid", GuhCore::instance()->configuration()->serverUuid().toString()); handshake.insert("language", GuhCore::instance()->configuration()->locale().name()); handshake.insert("protocol version", JSON_PROTOCOL_VERSION); + handshake.insert("initialSetupRequired", GuhCore::instance()->userManager()->users().isEmpty()); interface->sendData(clientId, QJsonDocument::fromVariant(handshake).toJson()); } diff --git a/server/jsonrpc/jsonrpcserver.h b/server/jsonrpc/jsonrpcserver.h index ebe990ca..f46bcd28 100644 --- a/server/jsonrpc/jsonrpcserver.h +++ b/server/jsonrpc/jsonrpcserver.h @@ -1,7 +1,7 @@ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Copyright (C) 2015 Simon Stürz * - * Copyright (C) 2014 Michael Zanetti * + * Copyright (C) 2014-2017 Michael Zanetti * * * * This file is part of guh. * * * @@ -25,6 +25,7 @@ #include "plugin/deviceclass.h" #include "jsonhandler.h" #include "transportinterface.h" +#include "usermanager.h" #include "types/action.h" #include "types/event.h" @@ -50,6 +51,9 @@ public: Q_INVOKABLE JsonReply *Version(const QVariantMap ¶ms) const; Q_INVOKABLE JsonReply *SetNotificationStatus(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *CreateUser(const QVariantMap ¶ms); + Q_INVOKABLE JsonReply *Authenticate(const QVariantMap ¶ms); + QHash handlers() const; void registerTransportInterface(TransportInterface *interface, const bool &enabled = true); @@ -57,6 +61,7 @@ public: private: void sendResponse(TransportInterface *interface, const QUuid &clientId, int commandId, const QVariantMap ¶ms = QVariantMap()); void sendErrorResponse(TransportInterface *interface, const QUuid &clientId, int commandId, const QString &error); + void sendUnauthorizedResponse(TransportInterface *interface, const QUuid &clientId, int commandId, const QString &error); private slots: void setup(); diff --git a/server/jsonrpc/jsontypes.cpp b/server/jsonrpc/jsontypes.cpp index 8fd3a645..05ca6ccf 100644 --- a/server/jsonrpc/jsontypes.cpp +++ b/server/jsonrpc/jsontypes.cpp @@ -86,6 +86,7 @@ QVariantList JsonTypes::s_configurationError; QVariantList JsonTypes::s_networkManagerError; QVariantList JsonTypes::s_networkManagerState; QVariantList JsonTypes::s_networkDeviceState; +QVariantList JsonTypes::s_userError; QVariantMap JsonTypes::s_paramType; QVariantMap JsonTypes::s_param; @@ -141,6 +142,7 @@ void JsonTypes::init() s_networkManagerError = enumToStrings(NetworkManager::staticMetaObject, "NetworkManagerError"); s_networkManagerState = enumToStrings(NetworkManager::staticMetaObject, "NetworkManagerState"); s_networkDeviceState = enumToStrings(NetworkDevice::staticMetaObject, "NetworkDeviceState"); + s_userError = enumToStrings(UserManager::staticMetaObject, "UserError"); // ParamType s_paramType.insert("id", basicTypeToString(Uuid)); @@ -396,6 +398,7 @@ QVariantMap JsonTypes::allTypes() allTypes.insert("NetworkManagerError", networkManagerError()); allTypes.insert("NetworkManagerState", networkManagerState()); allTypes.insert("NetworkDeviceState", networkDeviceState()); + allTypes.insert("UserError", userError()); allTypes.insert("StateType", stateTypeDescription()); allTypes.insert("StateDescriptor", stateDescriptorDescription()); @@ -1917,6 +1920,12 @@ QPair JsonTypes::validateVariant(const QVariant &templateVariant, qCWarning(dcJsonRpc) << QString("Value %1 not allowed in %2").arg(variant.toString()).arg(networkDeviceStateRef()); return result; } + } else if (refName == userErrorRef()) { + QPair result = validateEnum(s_userError, variant); + if (!result.first) { + qCWarning(dcJsonRpc) << QString("Value %1 not allowed in %2").arg(variant.toString()).arg(userErrorRef()); + return result; + } } else { Q_ASSERT_X(false, "JsonTypes", QString("Unhandled ref: %1").arg(refName).toLatin1().data()); return report(false, QString("Unhandled ref %1. Server implementation incomplete.").arg(refName)); diff --git a/server/jsonrpc/jsontypes.h b/server/jsonrpc/jsontypes.h index 1ee7b2cf..beda25f6 100644 --- a/server/jsonrpc/jsontypes.h +++ b/server/jsonrpc/jsontypes.h @@ -2,6 +2,7 @@ * * * Copyright (C) 2015 Simon Stürz * * Copyright (C) 2014 Michael Zanetti * + * Copyright (C) 2017 Michael Zanetti * * * * This file is part of guh. * * * @@ -28,6 +29,7 @@ #include "devicemanager.h" #include "ruleengine.h" #include "guhconfiguration.h" +#include "usermanager.h" #include "types/event.h" #include "types/action.h" @@ -132,6 +134,7 @@ public: DECLARE_TYPE(networkManagerError, "NetworkManagerError", NetworkManager, NetworkManagerError) DECLARE_TYPE(networkManagerState, "NetworkManagerState", NetworkManager, NetworkManagerState) DECLARE_TYPE(networkDeviceState, "NetworkDeviceState", NetworkDevice, NetworkDeviceState) + DECLARE_TYPE(userError, "UserError", UserManager, UserError) DECLARE_OBJECT(paramType, "ParamType") DECLARE_OBJECT(param, "Param") diff --git a/server/logging/logengine.cpp b/server/logging/logengine.cpp index 1b78f9c6..61757f43 100644 --- a/server/logging/logengine.cpp +++ b/server/logging/logengine.cpp @@ -132,7 +132,7 @@ namespace guhserver { LogEngine::LogEngine(QObject *parent): QObject(parent) { - m_db = QSqlDatabase::addDatabase("QSQLITE"); + m_db = QSqlDatabase::addDatabase("QSQLITE", "logs"); m_db.setDatabaseName(GuhSettings::logPath()); m_dbMaxSize = 20000; @@ -175,14 +175,14 @@ QList LogEngine::logEntries(const LogFilter &filter) const QString queryCall = "SELECT * FROM entries ORDER BY timestamp;"; if (filter.isEmpty()) { - query.exec(queryCall); + query = m_db.exec(queryCall); } else { queryCall = QString("SELECT * FROM entries WHERE %1 ORDER BY timestamp;").arg(filter.queryString()); - query.exec(queryCall); + query = m_db.exec(queryCall); } - if (query.lastError().isValid()) { - qCWarning(dcLogEngine) << "Error fetching log entries. Driver error:" << query.lastError().driverText() << "Database error:" << query.lastError().databaseText(); + if (m_db.lastError().isValid()) { + qCWarning(dcLogEngine) << "Error fetching log entries. Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText(); return QList(); } @@ -209,10 +209,9 @@ void LogEngine::clearDatabase() { qCWarning(dcLogEngine) << "Clear logging database."; - QSqlQuery query; QString queryDeleteString = QString("DELETE FROM entries;"); - if (!query.exec(queryDeleteString)) { - qCWarning(dcLogEngine) << "Could not clear logging database. Driver error:" << query.lastError().driverText() << "Database error:" << query.lastError().databaseText(); + if (m_db.exec(queryDeleteString).lastError().type() != QSqlError::NoError) { + qCWarning(dcLogEngine) << "Could not clear logging database. Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText(); } emit logDatabaseUpdated(); @@ -309,10 +308,9 @@ void LogEngine::removeDeviceLogs(const DeviceId &deviceId) { qCDebug(dcLogEngine) << "Deleting log entries from device" << deviceId.toString(); - QSqlQuery query; QString queryDeleteString = QString("DELETE FROM entries WHERE deviceId = '%1';").arg(deviceId.toString()); - if (!query.exec(queryDeleteString)) { - qCWarning(dcLogEngine) << "Error deleting log entries from device" << deviceId.toString() << ". Driver error:" << query.lastError().driverText() << "Database error:" << query.lastError().databaseText(); + if (m_db.exec(queryDeleteString).lastError().type() != QSqlError::NoError) { + qCWarning(dcLogEngine) << "Error deleting log entries from device" << deviceId.toString() << ". Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText(); } else { emit logDatabaseUpdated(); } @@ -322,10 +320,9 @@ void LogEngine::removeRuleLogs(const RuleId &ruleId) { qCDebug(dcLogEngine) << "Deleting log entries from rule" << ruleId.toString(); - QSqlQuery query; QString queryDeleteString = QString("DELETE FROM entries WHERE typeId = '%1';").arg(ruleId.toString()); - if (!query.exec(queryDeleteString)) { - qCWarning(dcLogEngine) << "Error deleting log entries from rule" << ruleId.toString() << ". Driver error:" << query.lastError().driverText() << "Database error:" << query.lastError().databaseText(); + if (m_db.exec(queryDeleteString).lastError().type() != QSqlError::NoError) { + qCWarning(dcLogEngine) << "Error deleting log entries from rule" << ruleId.toString() << ". Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText(); } else { emit logDatabaseUpdated(); } @@ -345,9 +342,8 @@ void LogEngine::appendLogEntry(const LogEntry &entry) .arg(entry.active()) .arg(entry.errorCode()); - QSqlQuery query; - if (!query.exec(queryString)) { - qCWarning(dcLogEngine) << "Error writing log entry. Driver error:" << query.lastError().driverText() << "Database error:" << query.lastError().databaseText(); + if (m_db.exec(queryString).lastError().type() != QSqlError::NoError) { + qCWarning(dcLogEngine) << "Error writing log entry. Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText(); qCWarning(dcLogEngine) << entry; return; } @@ -358,8 +354,7 @@ void LogEngine::appendLogEntry(const LogEntry &entry) void LogEngine::checkDBSize() { QString queryString = "SELECT ROWID FROM entries;"; - QSqlQuery query; - query.exec(queryString); + QSqlQuery query = m_db.exec(queryString); int numRows = 0; if (m_db.driver()->hasFeature(QSqlDriver::QuerySize)) { numRows = query.size(); @@ -373,8 +368,8 @@ void LogEngine::checkDBSize() // keep only the latest m_dbMaxSize entries qCDebug(dcLogEngine) << "Deleting oldest entries and keep only the latest" << m_dbMaxSize << "entries."; QString queryDeleteString = QString("DELETE FROM entries WHERE ROWID IN (SELECT ROWID FROM entries ORDER BY timestamp DESC LIMIT -1 OFFSET %1);").arg(QString::number(m_dbMaxSize)); - if (!query.exec(queryDeleteString)) { - qCWarning(dcLogEngine) << "Error deleting oldest log entries to keep size. Driver error:" << query.lastError().driverText() << "Database error:" << query.lastError().databaseText(); + if (m_db.exec(queryDeleteString).lastError().type() != QSqlError::NoError) { + qCWarning(dcLogEngine) << "Error deleting oldest log entries to keep size. Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText(); } else { emit logDatabaseUpdated(); } @@ -386,14 +381,13 @@ void LogEngine::initDB() m_db.close(); m_db.open(); - QSqlQuery query; if (!m_db.tables().contains("metadata")) { - query.exec("CREATE TABLE metadata (key varchar(10), data varchar(40));"); - query.exec(QString("INSERT INTO metadata (key, data) VALUES('version', '%1');").arg(DB_SCHEMA_VERSION)); + m_db.exec("CREATE TABLE metadata (key varchar(10), data varchar(40));"); + m_db.exec(QString("INSERT INTO metadata (key, data) VALUES('version', '%1');").arg(DB_SCHEMA_VERSION)); } - query.exec("SELECT data FROM metadata WHERE key = 'version';"); + QSqlQuery query = m_db.exec("SELECT data FROM metadata WHERE key = 'version';"); if (query.next()) { int version = query.value("data").toInt(); if (version != DB_SCHEMA_VERSION) { @@ -406,27 +400,27 @@ void LogEngine::initDB() } if (!m_db.tables().contains("sourceTypes")) { - query.exec("CREATE TABLE sourceTypes (id int, name varchar(20), PRIMARY KEY(id));"); - //qCDebug(dcLogEngine) << query.lastError().databaseText(); + m_db.exec("CREATE TABLE sourceTypes (id int, name varchar(20), PRIMARY KEY(id));"); + //qCDebug(dcLogEngine) << m_db.lastError().databaseText(); QMetaEnum logTypes = Logging::staticMetaObject.enumerator(Logging::staticMetaObject.indexOfEnumerator("LoggingSource")); Q_ASSERT_X(logTypes.isValid(), "LogEngine", "Logging has no enum LoggingSource"); for (int i = 0; i < logTypes.keyCount(); i++) { - query.exec(QString("INSERT INTO sourceTypes (id, name) VALUES(%1, '%2');").arg(i).arg(logTypes.key(i))); + m_db.exec(QString("INSERT INTO sourceTypes (id, name) VALUES(%1, '%2');").arg(i).arg(logTypes.key(i))); } } if (!m_db.tables().contains("loggingEventTypes")) { - query.exec("CREATE TABLE loggingEventTypes (id int, name varchar(20), PRIMARY KEY(id));"); - //qCDebug(dcLogEngine) << query.lastError().databaseText(); + m_db.exec("CREATE TABLE loggingEventTypes (id int, name varchar(20), PRIMARY KEY(id));"); + //qCDebug(dcLogEngine) << m_db.lastError().databaseText(); QMetaEnum logTypes = Logging::staticMetaObject.enumerator(Logging::staticMetaObject.indexOfEnumerator("LoggingEventType")); Q_ASSERT_X(logTypes.isValid(), "LogEngine", "Logging has no enum LoggingEventType"); for (int i = 0; i < logTypes.keyCount(); i++) { - query.exec(QString("INSERT INTO loggingEventTypes (id, name) VALUES(%1, '%2');").arg(i).arg(logTypes.key(i))); + m_db.exec(QString("INSERT INTO loggingEventTypes (id, name) VALUES(%1, '%2');").arg(i).arg(logTypes.key(i))); } } if (!m_db.tables().contains("entries")) { - query.exec("CREATE TABLE entries " + m_db.exec("CREATE TABLE entries " "(" "timestamp int," "loggingLevel int," @@ -441,8 +435,8 @@ void LogEngine::initDB() "FOREIGN KEY(loggingEventType) REFERENCES loggingEventTypes(id)" ");"); - if (query.lastError().isValid()) - qCWarning(dcLogEngine) << "Error creating log table in database. Driver error:" << query.lastError().driverText() << "Database error:" << query.lastError().databaseText(); + if (m_db.lastError().isValid()) + qCWarning(dcLogEngine) << "Error creating log table in database. Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText(); } diff --git a/server/main.cpp b/server/main.cpp index 3d973fed..3e9f89cd 100644 --- a/server/main.cpp +++ b/server/main.cpp @@ -126,6 +126,7 @@ int main(int argc, char *argv[]) s_loggingFilters.insert("Avahi", false); s_loggingFilters.insert("Cloud", true); s_loggingFilters.insert("NetworkManager", true); + s_loggingFilters.insert("UserManager", true); QHash loggingFiltersPlugins; foreach (const QJsonObject &pluginMetadata, DeviceManager::pluginsMetadata()) { diff --git a/server/server.pri b/server/server.pri index f0b3e1ad..856f4049 100644 --- a/server/server.pri +++ b/server/server.pri @@ -56,6 +56,7 @@ HEADERS += $$top_srcdir/server/guhcore.h \ $$top_srcdir/server/networkmanager/networksettings.h \ $$top_srcdir/server/networkmanager/networkconnection.h \ $$top_srcdir/server/networkmanager/wirednetworkdevice.h \ + $$top_srcdir/server/usermanager.h \ SOURCES += $$top_srcdir/server/guhcore.cpp \ @@ -111,4 +112,5 @@ SOURCES += $$top_srcdir/server/guhcore.cpp \ $$top_srcdir/server/networkmanager/networksettings.cpp \ $$top_srcdir/server/networkmanager/networkconnection.cpp \ $$top_srcdir/server/networkmanager/wirednetworkdevice.cpp \ + $$top_srcdir/server/usermanager.cpp \ diff --git a/server/usermanager.cpp b/server/usermanager.cpp new file mode 100644 index 00000000..5dfa882e --- /dev/null +++ b/server/usermanager.cpp @@ -0,0 +1,181 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2017 Michael Zanetti * + * * + * This file is part of guh. * + * * + * Guh is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, version 2 of the License. * + * * + * Guh 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 guh. If not, see . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "usermanager.h" +#include "guhsettings.h" +#include "loggingcategories.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace guhserver { + +UserManager::UserManager(QObject *parent) : QObject(parent) +{ + m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), "users"); + m_db.setDatabaseName(GuhSettings::settingsPath() + "/user-db.sqlite"); + + if (!m_db.open()) { + qCWarning(dcUserManager) << "Error opening users database:" << m_db.lastError().driverText(); + return; + } + initDB(); +} + +QStringList UserManager::users() const +{ + QString userQuery("SELECT username FROM users;"); + QSqlQuery result = m_db.exec(userQuery); + QStringList ret; + while (result.next()) { + ret << result.value("username").toString(); + } + return ret; +} + +UserManager::UserError UserManager::createUser(const QString &username, const QString &password) +{ + if (!validateUsername(username)) { + qCWarning(dcUserManager) << "Error creating user. Invalid username"; + return UserErrorInvalidUserId; + } + + QRegExp passwordValidator = QRegExp("^(?=.*[A-Za-z])(?=.*\[0-9])(?=.*[$@$!%*#?&])[A-Za-z0-9$@$!%*#?&]{8,}$"); + if (!passwordValidator.exactMatch(password)) { + qCWarning(dcUserManager) << "Password failed character validation. Must contain a letter, a number and a special charactar. Minimum length: 8"; + return UserErrorBadPassword; + } + + QString checkForDuplicateUserQuery = QString("SELECT * FROM users WHERE username = \"%1\";").arg(username); + QSqlQuery result = m_db.exec(checkForDuplicateUserQuery); + if (result.first()) { + qCWarning(dcUserManager) << "Username already in use"; + return UserErrorDuplicateUserId; + } + + 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(QString::fromUtf8(hashedPassword)) + .arg(QString::fromUtf8(salt)); + m_db.exec(queryString); + if (m_db.lastError().type() != QSqlError::NoError) { + qCWarning(dcUserManager) << "Error creating user:" << m_db.lastError().databaseText() << m_db.lastError().driverText(); + return UserErrorBackendError; + } + return UserErrorNoError; +} + +UserManager::UserError UserManager::removeUser(const QString &username) +{ + QString dropUserQuery = QString("DELETE FROM users WHERE username =\"%1\";").arg(username); + QSqlQuery result = m_db.exec(dropUserQuery); + if (result.numRowsAffected() == 0) { + return UserErrorInvalidUserId; + } + + QString dropTokensQuery = QString("DELETE FROM tokens WHERE username = \"%1\";").arg(username); + m_db.exec(dropTokensQuery); + + return UserErrorNoError; +} + +QByteArray UserManager::authenticate(const QString &username, const QString &password, const QString &deviceName) +{ + if (!validateUsername(username)) { + qCWarning(dcUserManager) << "Username did not pass validation:" << username; + return QByteArray(); + } + + QString passwordQuery = QString("SELECT password, salt FROM users WHERE username = \"%1\";").arg(username); + QSqlQuery result = m_db.exec(passwordQuery); + if (!result.first()) { + qCWarning(dcUserManager) << "No such username" << username; + return QByteArray(); + } + QByteArray salt = result.value("salt").toByteArray(); + QByteArray hashedPassword = result.value("password").toByteArray(); + + if (hashedPassword != QCryptographicHash::hash(QString(password + salt).toUtf8(), QCryptographicHash::Sha512).toBase64()) { + qCWarning(dcUserManager) << "Authentication error for user:" << username; + return QByteArray(); + } + + QByteArray token = QCryptographicHash::hash(QUuid::createUuid().toByteArray(), QCryptographicHash::Sha256).toBase64(); + QString storeTokenQuery = QString("INSERT INTO tokens(username, token, creationdate, devicename) VALUES(\"%1\", \"%2\", \"%3\", \"%4\");") + .arg(username) + .arg(QString::fromUtf8(token)) + .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss")) + .arg(deviceName); + + m_db.exec(storeTokenQuery); + if (m_db.lastError().type() != QSqlError::NoError) { + qCWarning(dcUserManager) << "Error storing token in DB:" << m_db.lastError().databaseText() << m_db.lastError().driverText(); + return QByteArray(); + } + return token; +} + +bool UserManager::verifyToken(const QByteArray &token) +{ + QRegExp validator(QRegExp("(^[a-zA-Z0-9_.+-/=]+$)")); + if (!validator.exactMatch(token)) { + qCWarning(dcUserManager) << "Token failed character validation" << token; + return false; + } + QString getTokenQuery = QString("SELECT * 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 false; + } + if (!result.first()) { + qCDebug(dcUserManager) << "Authorisation failed for token" << token; + return false; + } + qCDebug(dcUserManager) << "Token authorized for user" << result.value("username").toString(); + return true; +} + +void UserManager::initDB() +{ + if (!m_db.tables().contains("users")) { + m_db.exec("CREATE TABLE users (username VARCHAR(40) UNIQUE, password VARCHAR(100), salt VARCHAR(100));"); + } + if (!m_db.tables().contains("tokens")) { + m_db.exec("CREATE TABLE tokens (username VARCHAR(40), token VARCHAR(100) UNIQUE, creationdate DATETIME, devicename VARCHAR(40));"); + } +} + +bool UserManager::validateUsername(const QString &username) const +{ + QRegExp validator("(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$)"); + return validator.exactMatch(username); +} + +} diff --git a/server/usermanager.h b/server/usermanager.h new file mode 100644 index 00000000..b49731f0 --- /dev/null +++ b/server/usermanager.h @@ -0,0 +1,64 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2017 Michael Zanetti * + * * + * This file is part of guh. * + * * + * Guh is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, version 2 of the License. * + * * + * Guh 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 guh. If not, see . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef USERMANAGER_H +#define USERMANAGER_H + +#include +#include + +namespace guhserver { + +class UserManager : public QObject +{ + Q_OBJECT +public: + enum UserError { + UserErrorNoError, + UserErrorBackendError, + UserErrorInvalidUserId, + UserErrorDuplicateUserId, + UserErrorBadPassword + }; + Q_ENUM(UserError) + + explicit UserManager(QObject *parent = 0); + + QStringList users() const; + + UserError createUser(const QString &username, const QString &password); + UserError removeUser(const QString &username); + + QByteArray authenticate(const QString &username, const QString &password, const QString &deviceName); + + bool verifyToken(const QByteArray &token); + +private: + void initDB(); + bool validateUsername(const QString &username) const; + +private: + QSqlDatabase m_db; + +}; + +} + +#endif // USERMANAGER_H diff --git a/tests/auto/api.json b/tests/auto/api.json index 0e5eb7b8..ec70cdb0 100644 --- a/tests/auto/api.json +++ b/tests/auto/api.json @@ -372,6 +372,28 @@ "o:eventType": "$ref:EventType" } }, + "JSONRPC.Authenticate": { + "description": "Authenticate a client to the api. This will return a new token to be used to authorize a client at the API.", + "params": { + "deviceName": "String", + "password": "String", + "username": "String" + }, + "returns": { + "o:token": "String", + "success": "String" + } + }, + "JSONRPC.CreateUser": { + "description": "Create a new user in the API. Currently this is only allowed to be called once when a new guh 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" + } + }, "JSONRPC.Introspect": { "description": "Introspect this API.", "params": { @@ -1321,6 +1343,13 @@ "UnitVoltAmpereReactive", "UnitAmpereHour" ], + "UserError": [ + "UserErrorNoError", + "UserErrorBackendError", + "UserErrorInvalidUserId", + "UserErrorDuplicateUserId", + "UserErrorBadPassword" + ], "ValueOperator": [ "ValueOperatorEquals", "ValueOperatorNotEquals", diff --git a/tests/auto/guhtestbase.cpp b/tests/auto/guhtestbase.cpp index 1d5330c3..0c992c06 100644 --- a/tests/auto/guhtestbase.cpp +++ b/tests/auto/guhtestbase.cpp @@ -117,6 +117,9 @@ GuhTestBase::GuhTestBase(QObject *parent) : m_mockDevice1Port = 1337 + (qrand() % 1000); m_mockDevice2Port = 7331 + (qrand() % 1000); QCoreApplication::instance()->setOrganizationName("guh-test"); + + GuhCore::instance()->userManager()->createUser("dummy@guh.io", "DummyPW1!"); + m_apiToken = GuhCore::instance()->userManager()->authenticate("dummy@guh.io", "DummyPW1!", "testcase"); } void GuhTestBase::initTestCase() @@ -208,6 +211,7 @@ QVariant GuhTestBase::injectAndWait(const QString &method, const QVariantMap &pa call.insert("id", m_commandId); call.insert("method", method); call.insert("params", params); + call.insert("token", m_apiToken); QJsonDocument jsonDoc = QJsonDocument::fromVariant(call); QSignalSpy spy(m_mockTcpServer, SIGNAL(outgoingData(QUuid,QByteArray))); diff --git a/tests/auto/guhtestbase.h b/tests/auto/guhtestbase.h index 3fc526b6..e66f9728 100644 --- a/tests/auto/guhtestbase.h +++ b/tests/auto/guhtestbase.h @@ -188,6 +188,7 @@ protected: int m_mockDevice2Port; DeviceId m_mockDeviceId; + QByteArray m_apiToken; }; diff --git a/tests/auto/jsonrpc/testjsonrpc.cpp b/tests/auto/jsonrpc/testjsonrpc.cpp index a9ee5fd9..0071caff 100644 --- a/tests/auto/jsonrpc/testjsonrpc.cpp +++ b/tests/auto/jsonrpc/testjsonrpc.cpp @@ -42,6 +42,8 @@ class TestJSONRPC: public GuhTestBase private slots: void testHandshake(); + void testInitialSetup(); + void testBasicCall_data(); void testBasicCall(); @@ -107,6 +109,152 @@ void TestJSONRPC::testHandshake() m_mockTcpServer->clientDisconnected(newClientId); } +void TestJSONRPC::testInitialSetup() +{ + foreach (const QString &user, GuhCore::instance()->userManager()->users()) { + GuhCore::instance()->userManager()->removeUser(user); + } + QCOMPARE(GuhCore::instance()->userManager()->users().count(), 0); + + QSignalSpy spy(m_mockTcpServer, SIGNAL(outgoingData(QUuid,QByteArray))); + QVERIFY(spy.isValid()); + + // Introspect call should work in any case + m_mockTcpServer->injectData(m_clientId, "{\"id\": 555, \"method\": \"JSONRPC.Introspect\"}"); + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY(spy.count() == 1); + QJsonDocument jsonDoc = QJsonDocument::fromJson(spy.first().at(1).toByteArray()); + QVariantMap response = jsonDoc.toVariant().toMap(); + qWarning() << "Calling introspect on uninitialized instance:" << response.value("status").toString() << response.value("error").toString(); + QCOMPARE(response.value("status").toString(), QStringLiteral("success")); + + + // Any other call should fail with "unauthorized" even if we use a previously valid token + spy.clear(); + m_mockTcpServer->injectData(m_clientId, "{\"id\": 555, \"token\": \"" + m_apiToken + "\", \"method\": \"JSONRPC.Version\"}"); + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY(spy.count() == 1); + jsonDoc = QJsonDocument::fromJson(spy.first().at(1).toByteArray()); + response = jsonDoc.toVariant().toMap(); + qWarning() << "Calling Version on uninitialized instance:" << response.value("status").toString() << response.value("error").toString(); + QCOMPARE(response.value("status").toString(), QStringLiteral("unauthorized")); + + // Except CreateUser + + // But it should still fail when giving a an invalid username + spy.clear(); + m_mockTcpServer->injectData(m_clientId, "{\"id\": 555, \"method\": \"JSONRPC.CreateUser\", \"params\": {\"username\": \"dummy\", \"password\": \"DummyPW1!\"}}"); + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY(spy.count() == 1); + jsonDoc = QJsonDocument::fromJson(spy.first().at(1).toByteArray()); + response = jsonDoc.toVariant().toMap(); + qWarning() << "Calling CreateUser on uninitialized instance with invalid user:" << response.value("status").toString() << response.value("params").toMap().value("error").toString(); + QCOMPARE(response.value("status").toString(), QStringLiteral("success")); + QCOMPARE(GuhCore::instance()->userManager()->users().count(), 0); + + // or when giving a bad password + spy.clear(); + m_mockTcpServer->injectData(m_clientId, "{\"id\": 555, \"method\": \"JSONRPC.CreateUser\", \"params\": {\"username\": \"dummy@guh.io\", \"password\": \"weak\"}}"); + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY(spy.count() == 1); + jsonDoc = QJsonDocument::fromJson(spy.first().at(1).toByteArray()); + response = jsonDoc.toVariant().toMap(); + qWarning() << "Calling CreateUser on uninitialized instance with weak password:" << response.value("status").toString() << response.value("params").toMap().value("error").toString(); + QCOMPARE(response.value("status").toString(), QStringLiteral("success")); + QCOMPARE(GuhCore::instance()->userManager()->users().count(), 0); + + // Now lets play by the rules + spy.clear(); + m_mockTcpServer->injectData(m_clientId, "{\"id\": 555, \"method\": \"JSONRPC.CreateUser\", \"params\": {\"username\": \"dummy@guh.io\", \"password\": \"DummyPW1!\"}}"); + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY(spy.count() == 1); + jsonDoc = QJsonDocument::fromJson(spy.first().at(1).toByteArray()); + response = jsonDoc.toVariant().toMap(); + qWarning() << "Calling CreateUser on uninitialized instance:" << response.value("status").toString() << response.value("error").toString(); + QCOMPARE(response.value("status").toString(), QStringLiteral("success")); + QCOMPARE(GuhCore::instance()->userManager()->users().count(), 1); + + // Calls should still fail, given we didn't get a new token yet + spy.clear(); + m_mockTcpServer->injectData(m_clientId, "{\"id\": 555, \"token\": \"" + m_apiToken + "\", \"method\": \"JSONRPC.Version\"}"); + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY(spy.count() == 1); + jsonDoc = QJsonDocument::fromJson(spy.first().at(1).toByteArray()); + response = jsonDoc.toVariant().toMap(); + qWarning() << "Calling Version with old token:" << response.value("status").toString() << response.value("error").toString(); + QCOMPARE(response.value("status").toString(), QStringLiteral("unauthorized")); + + // Now lets authenticate with a wrong user + spy.clear(); + m_mockTcpServer->injectData(m_clientId, "{\"id\": 555, \"method\": \"JSONRPC.Authenticate\", \"params\": {\"username\": \"dummy@wrong.domain\", \"password\": \"DummyPW1!\", \"deviceName\": \"testcase\"}}"); + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY(spy.count() == 1); + jsonDoc = QJsonDocument::fromJson(spy.first().at(1).toByteArray()); + response = jsonDoc.toVariant().toMap(); + qWarning() << "Calling Authenticate with wrong user:" << response.value("params").toMap().value("success").toString() << response.value("params").toMap().value("token").toString(); + QCOMPARE(response.value("status").toString(), QStringLiteral("success")); + QCOMPARE(response.value("params").toMap().value("success").toBool(), false); + QVERIFY(response.value("params").toMap().value("token").toByteArray().isEmpty()); + + + // Now lets authenticate with a wrong password + spy.clear(); + m_mockTcpServer->injectData(m_clientId, "{\"id\": 555, \"method\": \"JSONRPC.Authenticate\", \"params\": {\"username\": \"dummy@guh.io\", \"password\": \"wrongpw\", \"deviceName\": \"testcase\"}}"); + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY(spy.count() == 1); + jsonDoc = QJsonDocument::fromJson(spy.first().at(1).toByteArray()); + response = jsonDoc.toVariant().toMap(); + qWarning() << "Calling Authenticate with wrong password:" << response.value("params").toMap().value("success").toString() << response.value("params").toMap().value("token").toString(); + QCOMPARE(response.value("status").toString(), QStringLiteral("success")); + QCOMPARE(response.value("params").toMap().value("success").toBool(), false); + QVERIFY(response.value("params").toMap().value("token").toByteArray().isEmpty()); + + + // Now lets authenticate for real + spy.clear(); + m_mockTcpServer->injectData(m_clientId, "{\"id\": 555, \"method\": \"JSONRPC.Authenticate\", \"params\": {\"username\": \"dummy@guh.io\", \"password\": \"DummyPW1!\", \"deviceName\": \"testcase\"}}"); + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY(spy.count() == 1); + jsonDoc = QJsonDocument::fromJson(spy.first().at(1).toByteArray()); + response = jsonDoc.toVariant().toMap(); + qWarning() << "Calling Authenticate with valid credentials:" << response.value("params").toMap().value("success").toString() << response.value("params").toMap().value("token").toString(); + QCOMPARE(response.value("status").toString(), QStringLiteral("success")); + QCOMPARE(response.value("params").toMap().value("success").toBool(), true); + m_apiToken = response.value("params").toMap().value("token").toByteArray(); + QVERIFY(!m_apiToken.isEmpty()); + + // Now do a Version call with the valid token and it should work + spy.clear(); + m_mockTcpServer->injectData(m_clientId, "{\"id\": 555, \"token\": \"" + m_apiToken + "\", \"method\": \"JSONRPC.Version\"}"); + if (spy.count() == 0) { + spy.wait(); + } + QVERIFY(spy.count() == 1); + jsonDoc = QJsonDocument::fromJson(spy.first().at(1).toByteArray()); + response = jsonDoc.toVariant().toMap(); + qWarning() << "Calling Version with valid token:" << response.value("status").toString() << response.value("error").toString(); + QCOMPARE(response.value("status").toString(), QStringLiteral("success")); + +} + void TestJSONRPC::testBasicCall_data() { QTest::addColumn("call"); diff --git a/tests/scripts/authenticate.sh b/tests/scripts/authenticate.sh new file mode 100755 index 00000000..78f9824b --- /dev/null +++ b/tests/scripts/authenticate.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +if [ -z $4 ]; then + echo "usage: $0 host username password devicename" +else + (echo '{"id":1, "method": "JSONRPC.Authenticate", "params": { "username": "'$2'", "password": "'$3'", "deviceName": "'$4'"}}'; sleep 1) | nc $1 2222 +fi diff --git a/tests/scripts/createuser.sh b/tests/scripts/createuser.sh new file mode 100755 index 00000000..5ecf8f59 --- /dev/null +++ b/tests/scripts/createuser.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +if [ -z $3 ]; then + echo "usage: $0 host username password" +else + (echo '{"id":1, "method": "JSONRPC.CreateUser", "params": { "username": "'$2'", "password": "'$3'"}}'; sleep 1) | nc $1 2222 +fi diff --git a/tests/scripts/getconfigureddevices.sh b/tests/scripts/getconfigureddevices.sh index 1e2dd0b5..57fa5a6f 100755 --- a/tests/scripts/getconfigureddevices.sh +++ b/tests/scripts/getconfigureddevices.sh @@ -3,5 +3,5 @@ if [ -z $1 ]; then echo "usage: $0 host" else - (echo '{"id":1, "method":"Devices.GetConfiguredDevices"}'; sleep 1) | nc $1 2222 + (echo '{"id":1, "token": "'$2'", "method":"Devices.GetConfiguredDevices"}'; sleep 1) | nc $1 2222 fi diff --git a/translations/guhd-de_DE.ts b/translations/guhd-de_DE.ts index 1f7c4b64..11d15659 100644 --- a/translations/guhd-de_DE.ts +++ b/translations/guhd-de_DE.ts @@ -4,7 +4,7 @@ main - + guh ( /[guːh]/ ) is an open source IoT (Internet of Things) server, which allows to control a lot of different devices from many different @@ -23,12 +23,12 @@ Szenen undVerhaltensweisen des Systems festzulegen. - + Run guhd in the foreground, not as daemon. Starte guhd im Vordergrund, nicht als Service. - + Debug categories to enable. Prefix with "No" to disable. Warnings from all categories will be printed unless explicitly muted with "NoWarnings". Categories are: @@ -36,17 +36,17 @@ Categories are: Es gibt folgende Kategorien: - + Enables all debug categories. This parameter overrides all debug category parameters. Aktiviere alle Debug-Kategorien. Dieser Parameter überschreibt alle anderen Debug-Kategorien Parameter. - + Specify a log file to write to, If this option is not specified, logs will be printed to the standard output. - + No such debug category: Diese Debug-Kategorie existiert nicht: diff --git a/translations/guhd-en_US.ts b/translations/guhd-en_US.ts index efcb73cf..1d1175e6 100644 --- a/translations/guhd-en_US.ts +++ b/translations/guhd-en_US.ts @@ -4,7 +4,7 @@ main - + guh ( /[guːh]/ ) is an open source IoT (Internet of Things) server, which allows to control a lot of different devices from many different @@ -23,12 +23,12 @@ for your environment. - + Run guhd in the foreground, not as daemon. Run guhd in the foreground, not as daemon. - + Debug categories to enable. Prefix with "No" to disable. Warnings from all categories will be printed unless explicitly muted with "NoWarnings". Categories are: @@ -37,17 +37,17 @@ Categories are: Categories are: - + Enables all debug categories. This parameter overrides all debug category parameters. Enables all debug categories. This parameter overrides all debug category parameters. - + Specify a log file to write to, If this option is not specified, logs will be printed to the standard output. - + No such debug category: No such debug category: