Added API authentication

This commit is contained in:
Michael Zanetti 2017-08-01 16:49:28 +02:00
parent 1b08bfb1d7
commit 08727a07ba
22 changed files with 589 additions and 52 deletions

View File

@ -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")

View File

@ -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

View File

@ -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);

View File

@ -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;

View File

@ -1,7 +1,7 @@
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* *
* Copyright (C) 2015 Simon Stürz <simon.stuerz@guh.io> *
* Copyright (C) 2014 Michael Zanetti <michael_zanetti@gmx.net> *
* Copyright (C) 2014-2017 Michael Zanetti <michael.zanetti@guh.io> *
* *
* 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 &params)
return createReply(returns);
}
JsonReply *JsonRPCServer::CreateUser(const QVariantMap &params)
{
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 &params)
{
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<QString, JsonHandler *> 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<TransportInterface *>(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());
}

View File

@ -1,7 +1,7 @@
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* *
* Copyright (C) 2015 Simon Stürz <simon.stuerz@guh.io> *
* Copyright (C) 2014 Michael Zanetti <michael_zanetti@gmx.net> *
* Copyright (C) 2014-2017 Michael Zanetti <michael.zanetti@guh.io> *
* *
* 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 &params) const;
Q_INVOKABLE JsonReply *SetNotificationStatus(const QVariantMap &params);
Q_INVOKABLE JsonReply *CreateUser(const QVariantMap &params);
Q_INVOKABLE JsonReply *Authenticate(const QVariantMap &params);
QHash<QString, JsonHandler *> 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 &params = 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();

View File

@ -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<bool, QString> 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<bool, QString> 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));

View File

@ -2,6 +2,7 @@
* *
* Copyright (C) 2015 Simon Stürz <simon.stuerz@guh.io> *
* Copyright (C) 2014 Michael Zanetti <michael_zanetti@gmx.net> *
* Copyright (C) 2017 Michael Zanetti <michael.zanetti@guh.io> *
* *
* 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")

View File

@ -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<LogEntry> 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<LogEntry>();
}
@ -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();
}

View File

@ -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<QString, bool> loggingFiltersPlugins;
foreach (const QJsonObject &pluginMetadata, DeviceManager::pluginsMetadata()) {

View File

@ -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 \

181
server/usermanager.cpp Normal file
View File

@ -0,0 +1,181 @@
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* *
* Copyright (C) 2017 Michael Zanetti <michael.zanetti@guh.io> *
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "usermanager.h"
#include "guhsettings.h"
#include "loggingcategories.h"
#include <QUuid>
#include <QCryptographicHash>
#include <QSqlQuery>
#include <QVariant>
#include <QSqlError>
#include <QRegExpValidator>
#include <QDateTime>
#include <QDebug>
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);
}
}

64
server/usermanager.h Normal file
View File

@ -0,0 +1,64 @@
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* *
* Copyright (C) 2017 Michael Zanetti <michael.zanetti@guh.io> *
* *
* 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 <http://www.gnu.org/licenses/>. *
* *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef USERMANAGER_H
#define USERMANAGER_H
#include <QObject>
#include <QSqlDatabase>
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

View File

@ -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",

View File

@ -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)));

View File

@ -188,6 +188,7 @@ protected:
int m_mockDevice2Port;
DeviceId m_mockDeviceId;
QByteArray m_apiToken;
};

View File

@ -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<QByteArray>("call");

7
tests/scripts/authenticate.sh Executable file
View File

@ -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

7
tests/scripts/createuser.sh Executable file
View File

@ -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

View File

@ -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

View File

@ -4,7 +4,7 @@
<context>
<name>main</name>
<message>
<location filename="../server/main.cpp" line="149"/>
<location filename="../server/main.cpp" line="150"/>
<source>
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.
</translation>
</message>
<message>
<location filename="../server/main.cpp" line="161"/>
<location filename="../server/main.cpp" line="162"/>
<source>Run guhd in the foreground, not as daemon.</source>
<translation>Starte guhd im Vordergrund, nicht als Service.</translation>
</message>
<message>
<location filename="../server/main.cpp" line="164"/>
<location filename="../server/main.cpp" line="165"/>
<source>Debug categories to enable. Prefix with &quot;No&quot; to disable. Warnings from all categories will be printed unless explicitly muted with &quot;NoWarnings&quot;.
Categories are:</source>
@ -36,17 +36,17 @@ Categories are:</source>
Es gibt folgende Kategorien:</translation>
</message>
<message>
<location filename="../server/main.cpp" line="181"/>
<location filename="../server/main.cpp" line="182"/>
<source>Enables all debug categories. This parameter overrides all debug category parameters.</source>
<translation>Aktiviere alle Debug-Kategorien. Dieser Parameter überschreibt alle anderen Debug-Kategorien Parameter.</translation>
</message>
<message>
<location filename="../server/main.cpp" line="186"/>
<location filename="../server/main.cpp" line="187"/>
<source>Specify a log file to write to, If this option is not specified, logs will be printed to the standard output.</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../server/main.cpp" line="218"/>
<location filename="../server/main.cpp" line="219"/>
<source>No such debug category:</source>
<translation>Diese Debug-Kategorie existiert nicht:</translation>
</message>

View File

@ -4,7 +4,7 @@
<context>
<name>main</name>
<message>
<location filename="../server/main.cpp" line="149"/>
<location filename="../server/main.cpp" line="150"/>
<source>
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.
</translation>
</message>
<message>
<location filename="../server/main.cpp" line="161"/>
<location filename="../server/main.cpp" line="162"/>
<source>Run guhd in the foreground, not as daemon.</source>
<translation>Run guhd in the foreground, not as daemon.</translation>
</message>
<message>
<location filename="../server/main.cpp" line="164"/>
<location filename="../server/main.cpp" line="165"/>
<source>Debug categories to enable. Prefix with &quot;No&quot; to disable. Warnings from all categories will be printed unless explicitly muted with &quot;NoWarnings&quot;.
Categories are:</source>
@ -37,17 +37,17 @@ Categories are:</source>
Categories are:</translation>
</message>
<message>
<location filename="../server/main.cpp" line="181"/>
<location filename="../server/main.cpp" line="182"/>
<source>Enables all debug categories. This parameter overrides all debug category parameters.</source>
<translation>Enables all debug categories. This parameter overrides all debug category parameters.</translation>
</message>
<message>
<location filename="../server/main.cpp" line="186"/>
<location filename="../server/main.cpp" line="187"/>
<source>Specify a log file to write to, If this option is not specified, logs will be printed to the standard output.</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../server/main.cpp" line="218"/>
<location filename="../server/main.cpp" line="219"/>
<source>No such debug category:</source>
<translation>No such debug category:</translation>
</message>