// SPDX-License-Identifier: LGPL-3.0-or-later /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Copyright (C) 2013 - 2024, nymea GmbH * Copyright (C) 2024 - 2025, chargebyte austria GmbH * * This file is part of nymea. * * nymea is free software: you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public License * as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * nymea 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 Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with nymea. If not, see . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ /*! \class nymeaserver::UserManager \brief This class represents the manager for the users in nymead. \ingroup user \inmodule core The user manager is responsible for managing the user database, tokens and authentication. The user manager creates a user database where all relevant information will be stored. \sa TokenInfo, PushButtonDBusService */ /*! \enum nymeaserver::UserManager::UserError This enum represents the possible errors the \l{UserManager} can have. \value UserErrorNoError No error occurred. Everything is ok.4 \value UserErrorBackendError Something went wrong in the manager. This is probably caused by a database error. \value UserErrorInvalidUserId The given user name is not valid. \value UserErrorDuplicateUserId The given user name already exits. Please use a different user name. \value UserErrorBadPassword The given password is to weak. Please use a stronger password. \value UserErrorTokenNotFound The given token is unknown to the UserManager. \value UserErrorPermissionDenied The permission is denied. Either invalid username, password or token. */ /*! \fn void nymeaserver::UserManager::pushButtonAuthFinished(int transactionId, bool success, const QByteArray &token); This signal is emitted when the push authentication for the given \a transactionId is finished. If \a success is true, the resulting \a token contains a non empty string. \sa requestPushButtonAuth */ #include "usermanager.h" #include "loggingcategories.h" #include "pushbuttondbusservice.h" #include "nymeacore.h" #include #include #include #include #include #include #include #include #include namespace nymeaserver { /*! Constructs a new UserManager with the given \a dbName and \a parent. */ UserManager::UserManager(const QString &dbName, QObject *parent): QObject(parent) { m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), "users"); m_db.setDatabaseName(dbName); qCDebug(dcUserManager()) << "Opening user database" << m_db.databaseName(); if (!m_db.isValid()) { qCWarning(dcUserManager()) << "The database is not valid:" << m_db.lastError().driverText() << m_db.lastError().databaseText(); rotate(m_db.databaseName()); } if (!initDB()) { qCWarning(dcUserManager()) << "Error initializing user database. Trying to correct it."; if (QFileInfo::exists(m_db.databaseName())) { rotate(m_db.databaseName()); if (!initDB()) { qCWarning(dcUserManager()) << "Error fixing user database. Giving up. Users can't be stored."; } } } m_pushButtonDBusService = new PushButtonDBusService("/io/nymea/nymead/UserManager", this); connect(m_pushButtonDBusService, &PushButtonDBusService::pushButtonPressed, this, &UserManager::onPushButtonPressed); m_pushButtonTransaction = QPair(-1, QString()); } /*! Will return true if the database is working fine but doesn't have any information on users whatsoever. * That is, neither a user nor an anonymous token. * This may be used to determine whether a first-time setup is required. */ bool UserManager::initRequired() const { QString getTokensQuery = QString("SELECT id, username, creationdate, deviceName FROM tokens;"); QSqlQuery resultQuery(m_db); if (!resultQuery.exec(getTokensQuery)) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << getTokensQuery << m_db.lastError().databaseText() << m_db.lastError().driverText(); return false; } if (m_db.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Query for tokens failed:" << m_db.lastError().databaseText() << m_db.lastError().driverText() << getTokensQuery; // Note: do not return true in case the database access fails. return false; } return users().isEmpty() && !resultQuery.first(); } /*! Returns the list of user names for this UserManager. */ UserInfoList UserManager::users() const { UserInfoList users; QString userQuery("SELECT * FROM users;"); QSqlQuery resultQuery(m_db); if (!resultQuery.exec(userQuery)) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << userQuery << m_db.lastError().databaseText() << m_db.lastError().driverText(); return users; } while (resultQuery.next()) { UserInfo info = UserInfo(resultQuery.value("username").toString()); info.setEmail(resultQuery.value("email").toString()); info.setDisplayName(resultQuery.value("displayName").toString()); info.setScopes(Types::scopesFromStringList(resultQuery.value("scopes").toString().split(','))); info.setAllowedThingIds(Types::thingIdsFromStringList(resultQuery.value("allowedThingIds").toString().split(','))); users.append(info); } return users; } /*! Creates a new user with the given \a username and \a password. Returns the \l UserError to inform about the result. */ UserManager::UserError UserManager::createUser(const QString &username, const QString &password, const QString &email, const QString &displayName, Types::PermissionScopes scopes, const QList &allowedThingIds) { if (!validateUsername(username)) { qCWarning(dcUserManager) << "Error creating user. Invalid username:" << username; return UserErrorInvalidUserId; } if (!validatePassword(password)) { qCWarning(dcUserManager) << "Password failed character validation. Must contain a letter, a number and a special charactar. Minimum length: 8"; return UserErrorBadPassword; } if (!validateScopes(scopes)) { // The method warns about he specific validation return UserErrorInconsistantScopes; } // Verify thing IDs, if there is no thing with this id, we don't save it and it will not be verified. // We don't return an error, the thing might have dissapeared QList thingIds; ThingManager *thingManager = NymeaCore::instance()->thingManager(); if (!thingManager) { qCWarning(dcUserManager()) << "Cannot validate allowed things for user" << username << "because thing manager is not available yet. Skipping validation."; thingIds = allowedThingIds; } else { foreach (const ThingId &thingId, allowedThingIds) { if (thingManager->configuredThings().findById(thingId) == nullptr) { qCWarning(dcUserManager()) << "Cannot set user scope for" << username << "because there is no thing with ID "; } else { thingIds.append(thingId); } } } QSqlQuery checkForDuplicateUserQuery(m_db); checkForDuplicateUserQuery.prepare("SELECT * FROM users WHERE lower(username) = :username;"); checkForDuplicateUserQuery.bindValue(":username", username.toLower()); // Note: We're using toLower() on the username mainly for the reason that in old versions the username used to be an email address checkForDuplicateUserQuery.exec(); if (checkForDuplicateUserQuery.first()) { qCWarning(dcUserManager) << "Username" << username << "already in use"; return UserErrorDuplicateUserId; } static QRegularExpression bracketsRe("[{}]"); QByteArray salt = QUuid::createUuid().toString().remove(bracketsRe).toUtf8(); QByteArray hashedPassword = QCryptographicHash::hash(QString(password + salt).toUtf8(), QCryptographicHash::Sha512).toBase64(); QSqlQuery query(m_db); query.prepare("INSERT INTO users(username, email, displayName, password, salt, scopes, allowedThingIds)" "VALUES(:username, :email, :displayName, :password, :salt, :scopes, :allowedThingIds);"); query.bindValue(":username", username.toLower()); query.bindValue(":email", email); query.bindValue(":displayName", displayName); query.bindValue(":password", QString::fromUtf8(hashedPassword)); query.bindValue(":salt", QString::fromUtf8(salt)); query.bindValue(":scopes", Types::scopesToStringList(scopes).join(',')); query.bindValue(":allowedThingIds", Types::thingIdsToStringList(thingIds).join(',')); query.exec(); if (query.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Error creating user:" << query.lastError().databaseText() << query.lastError().driverText(); return UserErrorBackendError; } qCInfo(dcUserManager()) << "New user" << username << "added to the system with permissions:" << Types::scopesToStringList(scopes); emit userAdded(username); return UserErrorNoError; } 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 checkForUserExistingQueryString = QString("SELECT * FROM users WHERE lower(username) = \"%1\";").arg(username.toLower()); QSqlQuery checkForUserExistingQuery(m_db); if (!checkForUserExistingQuery.exec(checkForUserExistingQueryString)) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << checkForUserExistingQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserErrorBackendError; } if (!checkForUserExistingQuery.first()) { qCWarning(dcUserManager) << "Username does not exist."; return UserErrorInvalidUserId; } // Update the password QByteArray salt = QUuid::createUuid().toString().remove(QRegularExpression("[{}]")).toUtf8(); QByteArray hashedPassword = QCryptographicHash::hash(QString(newPassword + salt).toUtf8(), QCryptographicHash::Sha512).toBase64(); QSqlQuery updatePasswordQuery(m_db); updatePasswordQuery.prepare("UPDATE users SET password = :password, salt = :salt WHERE lower(username) = :username;"); updatePasswordQuery.bindValue(":password", QString::fromUtf8(hashedPassword)); updatePasswordQuery.bindValue(":salt", QString::fromUtf8(salt)); updatePasswordQuery.bindValue(":username", username.toLower()); if (!updatePasswordQuery.exec()) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << updatePasswordQuery.executedQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserErrorBackendError; } 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) { QString dropUserQueryString = QString("DELETE FROM users WHERE lower(username) = \"%1\";").arg(username.toLower()); QSqlQuery dropUserQuery(m_db); if (!dropUserQuery.exec(dropUserQueryString)) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << dropUserQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserErrorBackendError; } if (dropUserQuery.numRowsAffected() == 0) return UserErrorInvalidUserId; QString dropTokensQueryString = QString("DELETE FROM tokens WHERE lower(username) = \"%1\";").arg(username.toLower()); QSqlQuery dropTokensQuery(m_db); if (!dropTokensQuery.exec(dropTokensQueryString)) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << dropTokensQueryString << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserErrorBackendError; } emit userRemoved(username); return UserErrorNoError; } UserManager::UserError UserManager::setUserScopes(const QString &username, Types::PermissionScopes scopes, const QList &allowedThingIds) { if (!validateScopes(scopes)) { // The method warns about he specific validation return UserErrorInconsistantScopes; } // Verify thing IDs, if there is no thing with this id, we don't save it and it will not be verified. // We don't return an error, the thing might have dissapeared QList thingIds; ThingManager *thingManager = NymeaCore::instance()->thingManager(); if (!thingManager) { qCWarning(dcUserManager()) << "Cannot validate allowed things for user" << username << "because thing manager is not available yet. Skipping validation."; thingIds = allowedThingIds; } else { foreach (const ThingId &thingId, allowedThingIds) { if (thingManager->configuredThings().findById(thingId) == nullptr) { qCWarning(dcUserManager()) << "The user" << username << "should have access to thing with ID" << thingId.toString() << "but there is no such thing. Ignoring value."; } else { thingIds.append(thingId); } } } QList thingsAppeared; QList thingsDisappeared; // Get the current allowed things if (!scopes.testFlag(Types::PermissionScopeAccessAllThings)) { // Restricted thing access, let's notify this user if any things appeared or dissapeard for the user UserInfo currentUserInfo = userInfo(username); // Get new appeared things for this user foreach (const ThingId &thingId, thingIds) { if (currentUserInfo.allowedThingIds().contains(thingId)) continue; qCDebug(dcUserManager()) << "Thing with ID" << thingId.toString() << "now allowed for this user any more. Notify user" << username << "that thing appeared."; thingsAppeared.append(thingId); } // Get disappeared things for this user foreach (const ThingId &thingId, currentUserInfo.allowedThingIds()) { if (thingIds.contains(thingId)) continue; qCDebug(dcUserManager()) << "Thing with ID" << thingId.toString() << "not allowed for this user any more. Notify user" << username << "that thing dissappeared."; thingsDisappeared.append(thingId); } } QString scopesString = Types::scopesToStringList(scopes).join(','); QString allowedThingIdsString = Types::thingIdsToStringList(thingIds).join(','); qCDebug(dcUserManager()) << "Updating scopes of user" << username << "Scopes:" << scopes << "Allowed things:" << allowedThingIds; QSqlQuery setScopesQuery(m_db); setScopesQuery.prepare("UPDATE users SET scopes = :scopes, allowedThingIds = :allowedThingIds WHERE username = :username;"); setScopesQuery.bindValue(":username", username); setScopesQuery.bindValue(":scopes", scopesString); setScopesQuery.bindValue(":allowedThingIds", allowedThingIdsString); if (!setScopesQuery.exec()) { qCWarning(dcUserManager()) << "Error updating scopes for user" << username << setScopesQuery.lastError().databaseText() << setScopesQuery.lastError().driverText(); return UserErrorBackendError; } emit userChanged(username); // Notify after updating the user information UserInfo ui = userInfo(username); foreach (const ThingId &thingId, thingsAppeared) emit userThingRestrictionsChanged(ui, thingId, true); foreach (const ThingId &thingId, thingsDisappeared) emit userThingRestrictionsChanged(ui, thingId, false); return UserErrorNoError; } UserManager::UserError UserManager::setUserInfo(const QString &username, const QString &email, const QString &displayName) { QSqlQuery query(m_db); query.prepare("UPDATE users SET email = :email, displayName = :displayName WHERE username = :username;"); query.bindValue(":email", email); query.bindValue(":displayName", displayName); query.bindValue(":username", username); query.exec(); if (query.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager()) << "Error updating user info for user" << username << query.lastError().databaseText() << query.lastError().driverText() << query.executedQuery(); return UserErrorBackendError; } emit userChanged(username); return UserErrorNoError; } /*! Returns true if the push button authentication is available for this system. */ bool UserManager::pushButtonAuthAvailable() const { return m_pushButtonDBusService->agentAvailable(); } /*! Authenticated the given \a username with the given \a password for the \a deviceName. If the authentication was successful, the token will be returned, otherwise the return value will be an empty byte array. */ QByteArray UserManager::authenticate(const QString &username, const QString &password, const QString &deviceName) { if (!validateUsername(username)) { qCWarning(dcUserManager) << "Authenticate: Username did not pass validation:" << username; return QByteArray(); } QSqlQuery passwordQuery(m_db); passwordQuery.prepare("SELECT password, salt FROM users WHERE lower(username) = :username;"); passwordQuery.bindValue(":username", username.toLower()); passwordQuery.exec(); if (!passwordQuery.first()) { qCWarning(dcUserManager) << "No such username" << username; return QByteArray(); } QByteArray salt = passwordQuery.value("salt").toByteArray(); QByteArray hashedPassword = passwordQuery.value("password").toByteArray(); 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(); QSqlQuery storeTokenQuery(m_db); storeTokenQuery.prepare("INSERT INTO tokens (id, username, token, creationdate, devicename)" "VALUES (:id, :username, :token, :creationdate, :devicename)"); storeTokenQuery.bindValue(":id", QUuid::createUuid().toString()); storeTokenQuery.bindValue(":username", username.toLower()); storeTokenQuery.bindValue(":token", QString::fromUtf8(token)); storeTokenQuery.bindValue(":creationdate", NymeaCore::instance()->timeManager()->currentDateTime().toString("yyyy-MM-dd hh:mm:ss")); storeTokenQuery.bindValue(":devicename", deviceName); if (!storeTokenQuery.exec()) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << storeTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return QByteArray(); } 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; } /*! Start the push button authentication for the device with the given \a deviceName. Returns the transaction id as refference to the request. */ int UserManager::requestPushButtonAuth(const QString &deviceName) { if (m_pushButtonTransaction.first != -1) { qCWarning(dcUserManager()) << "PushButton authentication already in progress for device" << m_pushButtonTransaction.second << ". Cancelling..."; cancelPushButtonAuth(m_pushButtonTransaction.first); } qCDebug(dcUserManager()) << "Starting PushButton authentication for device" << deviceName; int transactionId = ++m_pushButtonTransactionIdCounter; m_pushButtonTransaction = QPair(transactionId, deviceName); return transactionId; } /*! Cancel the push button authentication with the given \a transactionId. \sa requestPushButtonAuth */ void UserManager::cancelPushButtonAuth(int transactionId) { if (m_pushButtonTransaction.first == -1) { qCWarning(dcUserManager()) << "No PushButton transaction in progress. Nothing to cancel."; return; } if (m_pushButtonTransaction.first != transactionId) { qCWarning(dcUserManager()) << "PushButton transaction" << transactionId << "not in progress. Cannot cancel."; return; } qCDebug(dcUserManager()) << "Cancelling PushButton transaction for device:" << m_pushButtonTransaction.second; emit pushButtonAuthFinished(m_pushButtonTransaction.first, false, QByteArray()); m_pushButtonTransaction.first = -1; } /*! Request UserInfo. The UserInfo for the given username is returned. */ UserInfo UserManager::userInfo(const QString &username) const { QSqlQuery getUserQuery(m_db); getUserQuery.prepare("SELECT * FROM users WHERE lower(username) = :username;"); getUserQuery.bindValue(":username", username); if (!getUserQuery.exec()) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << getUserQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserInfo(); } if (m_db.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Query for user" << username << "failed:" << getUserQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserInfo(); } if (!getUserQuery.first()) return UserInfo(); UserInfo userInfo = UserInfo(getUserQuery.value("username").toString()); userInfo.setEmail(getUserQuery.value("email").toString()); userInfo.setDisplayName(getUserQuery.value("displayName").toString()); userInfo.setScopes(Types::scopesFromStringList(getUserQuery.value("scopes").toString().split(','))); userInfo.setAllowedThingIds(Types::thingIdsFromStringList(getUserQuery.value("allowedThingIds").toString().split(','))); return userInfo; } QList UserManager::tokens(const QString &username) const { QList ret; QSqlQuery query(m_db); query.prepare("SELECT id, username, creationdate, deviceName FROM tokens WHERE lower(username) = :username;"); query.bindValue(":username", username.toLower()); query.exec(); if (m_db.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Query for tokens failed:" << query.lastError().databaseText() << query.lastError().driverText() << query.executedQuery(); return ret; } while (query.next()) { ret << TokenInfo(query.value("id").toUuid(), query.value("username").toString(), query.value("creationdate").toDateTime(), query.value("devicename").toString()); } return ret; } TokenInfo UserManager::tokenInfo(const QByteArray &token) const { if (!validateToken(token)) { qCWarning(dcUserManager) << "Token did not pass validation:" << token; return TokenInfo(); } QSqlQuery getTokenQuery(m_db); getTokenQuery.prepare("SELECT id, username, creationdate, deviceName FROM tokens WHERE token = :token;"); getTokenQuery.bindValue(":token", QString::fromUtf8(token)); if (!getTokenQuery.exec()) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return TokenInfo(); } if (m_db.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Query for token failed:" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return TokenInfo(); } if (!getTokenQuery.first()) return TokenInfo(); return TokenInfo(getTokenQuery.value("id").toUuid(), getTokenQuery.value("username").toString(), getTokenQuery.value("creationdate").toDateTime(), getTokenQuery.value("devicename").toString()); } TokenInfo UserManager::tokenInfo(const QUuid &tokenId) const { QSqlQuery getTokenQuery(m_db); getTokenQuery.prepare("SELECT id, username, creationdate, deviceName FROM tokens WHERE id = :id;"); getTokenQuery.bindValue(":id", tokenId.toString()); if (!getTokenQuery.exec()) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return TokenInfo(); } if (m_db.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Query for token failed:" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return TokenInfo(); } if (!getTokenQuery.first()) { return TokenInfo(); } return TokenInfo(getTokenQuery.value("id").toUuid(), getTokenQuery.value("username").toString(), getTokenQuery.value("creationdate").toDateTime(), getTokenQuery.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) { QSqlQuery removeTokenQuery(m_db); removeTokenQuery.prepare("DELETE FROM tokens WHERE id = :id;"); removeTokenQuery.bindValue(":id", tokenId.toString()); if (!removeTokenQuery.exec()) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << removeTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserErrorBackendError; } if (m_db.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Removing token failed:" << removeTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return UserErrorBackendError; } if (removeTokenQuery.numRowsAffected() != 1) { qCWarning(dcUserManager) << "Tried to remove token, but the token could not be found in the DB."; return UserErrorTokenNotFound; } qCDebug(dcUserManager) << "Token" << tokenId << "removed from DB"; return UserErrorNoError; } /*! Returns true, if the given \a token is valid. */ bool UserManager::verifyToken(const QByteArray &token) { if (!validateToken(token)) { qCWarning(dcUserManager) << "Token failed character validation" << token; return false; } QSqlQuery getTokenQuery(m_db); getTokenQuery.prepare("SELECT * FROM tokens WHERE token = :token;"); getTokenQuery.bindValue(":token", QString::fromUtf8(token)); if (!getTokenQuery.exec()) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); return false; } if (m_db.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Query for token failed:" << getTokenQuery.lastQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText() << getTokenQuery.lastQuery(); return false; } if (!getTokenQuery.first()) { qCDebug(dcUserManager) << "Authorization failed for token" << token; return false; } return true; } bool UserManager::hasRestrictedThingAccess(const QByteArray &token) const { UserInfo ui = userInfo(tokenInfo(token).username()); return !ui.scopes().testFlag(Types::PermissionScopeAccessAllThings); } bool UserManager::accessToThingGranted(const ThingId &thingId, const QByteArray &token) { if (!hasRestrictedThingAccess(token)) return true; return getAllowedThingIdsForToken(token).contains(thingId); } QList UserManager::getAllowedThingIdsForToken(const QByteArray &token) const { return userInfo(tokenInfo(token).username()).allowedThingIds(); } void UserManager::onThingRemoved(const ThingId &thingId) { // If a thing has been removed from the system, clean up any thing based permissions foreach (const UserInfo &userInfo, users()) { if (userInfo.allowedThingIds().contains(thingId)) { QList allowedThingIds = userInfo.allowedThingIds(); allowedThingIds.removeAll(thingId); if (setUserScopes(userInfo.username(), userInfo.scopes(), allowedThingIds) != UserErrorNoError) { qCWarning(dcUserManager()) << "Failed to remove thing with ID" << thingId.toString() << "from allowed things of user" << userInfo.username(); } else { qCDebug(dcUserManager()) << "Removed thing with ID" << thingId.toString() << "from allowed things of user" << userInfo.username(); } } } } bool UserManager::initDB() { m_db.close(); if (!m_db.open()) { dumpDBError("Can't open user database. Init failed."); return false; } int currentVersion = -1; int newVersion = 2; if (m_db.tables().contains("metadata")) { QSqlQuery query(m_db); if (!query.exec("SELECT data FROM metadata WHERE key = 'version';")) { qCWarning(dcUserManager()) << "Unable to execute SQL query" << query.executedQuery() << m_db.lastError().databaseText() << m_db.lastError().driverText(); } else if (query.next()) { currentVersion = query.value("data").toInt(); qCInfo(dcUserManager()) << "Current database version is" << currentVersion; if (currentVersion == newVersion) { qCInfo(dcUserManager()) << "The database version is up to date"; } } } if (!m_db.tables().contains("users")) { qCDebug(dcUserManager()) << "No \"users\" table found. Creating the table..."; QSqlQuery query(m_db); if (!query.exec("CREATE TABLE users (username VARCHAR(40) UNIQUE PRIMARY KEY, email VARCHAR(40), displayName VARCHAR(40), password VARCHAR(100), salt VARCHAR(100), scopes TEXT, allowedThingIds TEXT);") || m_db.lastError().isValid()) { dumpDBError("Error initializing user database (table users)."); m_db.close(); return false; } } else { if (currentVersion < 1) { qCDebug(dcUserManager()) << "Start user table database migration to version 1"; QSqlQuery query = QSqlQuery(m_db); if (!query.exec("ALTER TABLE users ADD COLUMN scopes TEXT;") || m_db.lastError().isValid()) { dumpDBError("Error migrating user database (table users)."); m_db.close(); return false; } // Migrated existing users from before multiuser support are admins by default query = QSqlQuery(m_db); query.prepare("UPDATE users SET scopes = ?;"); query.addBindValue(Types::scopesToStringList(Types::PermissionScopeAdmin).join(',')); if (!query.exec() || query.lastError().isValid()) { dumpDBError("Error migrating user database (updating existing users)."); m_db.close(); return false; } query = QSqlQuery(m_db); if (!query.exec("ALTER TABLE users ADD COLUMN email VARCHAR(40);") || m_db.lastError().isValid()) { dumpDBError("Error migrating user database (table users)."); m_db.close(); return false; } query = QSqlQuery(m_db); if (!query.exec("ALTER TABLE users ADD COLUMN displayName VARCHAR(40);") || m_db.lastError().isValid()) { dumpDBError("Error migrating user database (table users)."); m_db.close(); return false; } // Up until schema 1, username was an email. Copy it to initialize the email field. query = QSqlQuery(m_db); if (!query.exec("UPDATE users SET email = username;") || m_db.lastError().isValid()) { dumpDBError("Error migrating user database (table users)."); m_db.close(); return false; } qCDebug(dcUserManager()) << "Migrated successfully users table to database version 1"; } if (currentVersion < 2) { // - Add new "allowedThingIds" row into the users table // - New permission has been added "PermissionScopeAccessAllThings", the existing users require // all this permission in order to have an unchainged behavior qCDebug(dcUserManager()) << "Migrating user table to version 2"; // - Add new "allowedThingIds" row into the users table, it remains is empty at this point QSqlQuery query = QSqlQuery(m_db); if (!query.exec("ALTER TABLE users ADD COLUMN allowedThingIds TEXT;") || m_db.lastError().isValid()) { dumpDBError("Error migrating user database (table users)."); m_db.close(); return false; } if (!m_db.transaction()) { dumpDBError("Error starting transaction for migrating user database (table users)."); return false; } QSqlQuery selectQuery(m_db); if (!selectQuery.exec("SELECT username, scopes FROM users")) { dumpDBError("Select failed: " + selectQuery.lastError().text()); return false; } QSqlQuery updateQuery(m_db); updateQuery.prepare("UPDATE users SET scopes = :scopes WHERE username = :username"); while (selectQuery.next()) { QString username = selectQuery.value("username").toString(); Types::PermissionScopes scopes = Types::scopesFromStringList(selectQuery.value("scopes").toString().split(',')); // In case this is an admin, make sure we store only the Admin scope if (!scopes.testFlag(Types::PermissionScopeAdmin)) { scopes.setFlag(Types::PermissionScopeAccessAllThings); } updateQuery.bindValue(":scopes", Types::scopesToStringList(scopes).join(',')); updateQuery.bindValue(":username", username); if (!updateQuery.exec()) { qCWarning(dcUserManager()) << "Update failed for username" << username << ":" << updateQuery.lastError().text(); m_db.rollback(); return false; } } if (!m_db.commit()) { dumpDBError("Error migrating user database (table users) to version 2. Rollback."); m_db.rollback(); return false; } qCDebug(dcUserManager()) << "Migrated successfully users table to database version 2"; } } if (!m_db.tables().contains("tokens")) { qCDebug(dcUserManager()) << "No \"tokens\" table found. Creating the table..."; QSqlQuery query(m_db); if (!query.exec("CREATE TABLE tokens (id VARCHAR(40) UNIQUE, username VARCHAR(40), token VARCHAR(100) UNIQUE, creationdate DATETIME, devicename VARCHAR(40));") || m_db.lastError().isValid()) { dumpDBError("Error initializing user database (table tokens)"); m_db.close(); return false; } } if (!m_db.tables().contains("metadata")) { qCDebug(dcUserManager()) << "No \"metadata\" table found. Creating the table..."; QSqlQuery query(m_db); if (!query.exec("CREATE TABLE metadata (key VARCHAR(10), data VARCHAR(40));") || m_db.lastError().isValid()) { dumpDBError("Error setting up user database (table metadata)!"); m_db.close(); return false; } query = QSqlQuery(m_db); query.prepare("INSERT INTO metadata (key, data) VALUES ('version', :version);"); query.bindValue(":version", newVersion); if (!query.exec() || m_db.lastError().isValid()) { dumpDBError("Error setting up user database (setting version metadata)!"); m_db.close(); return false; } } else { // All migrations have been done if (currentVersion < newVersion) { QSqlQuery query(m_db); query.prepare("UPDATE metadata SET data = :version WHERE key = 'version'"); query.bindValue(":version", newVersion); if (!query.exec() || m_db.lastError().isValid()) { dumpDBError("Error updating database version"); m_db.close(); return false; } qCInfo(dcUserManager()) << "Finished database migration to version" << newVersion; } } qCDebug(dcUserManager()) << "User database initialized successfully"; return true; } void UserManager::rotate(const QString &dbName) { int index = 1; while (QFileInfo::exists(QString("%1.%2").arg(dbName).arg(index))) index++; qCDebug(dcUserManager()) << "Backing up old database file to" << QString("%1.%2").arg(dbName).arg(index); QFile f(dbName); if (!f.rename(QString("%1.%2").arg(dbName).arg(index))) { qCWarning(dcUserManager()) << "Error backing up old database."; } else { qCDebug(dcUserManager()) << "Successfully moved old database"; } } bool UserManager::validateUsername(const QString &username) const { static QRegularExpression validator("[a-zA-Z0-9_\\.+-@]{3,}"); return validator.match(username).hasMatch(); } bool UserManager::validatePassword(const QString &password) const { if (password.length() < 8) return false; static QRegularExpression lowerRe("[a-z]"); if (!password.contains(lowerRe)) return false; static QRegularExpression upperRe("[A-Z]"); if (!password.contains(upperRe)) return false; static QRegularExpression numbersRe("[0-9]"); if (!password.contains(numbersRe)) return false; return true; } bool UserManager::validateToken(const QByteArray &token) const { static QRegularExpression validator(QRegularExpression("(^[a-zA-Z0-9_\\.+-/=]+$)")); return validator.match(token).hasMatch(); } bool UserManager::validateScopes(Types::PermissionScopes scopes) const { if (scopes.testFlag(Types::PermissionScopeAdmin) || scopes == Types::PermissionScopeNone || scopes == Types::PermissionScopeControlThings) return true; if (scopes.testFlag(Types::PermissionScopeConfigureThings)) { if (!scopes.testFlag(Types::PermissionScopeControlThings) || !scopes.testFlag(Types::PermissionScopeAccessAllThings)) { qCWarning(dcUserManager()) << "Invalid scopes combination. If a user can configure things he must have access to all things and must be able to control them."; return false; } } // Note: if access to all things, there are no restrictions if (!scopes.testFlag(Types::PermissionScopeAccessAllThings)) { if (scopes.testFlag(Types::PermissionScopeControlThings) || scopes.testFlag(Types::PermissionScopeConfigureRules)|| scopes.testFlag(Types::PermissionScopeExecuteRules)) { qCWarning(dcUserManager()) << "Invalid scopes combination. If a user has not access to all things, he can not configure them or create/execute rules."; return false; } } if (scopes.testFlag(Types::PermissionScopeExecuteRules)) { if (!scopes.testFlag(Types::PermissionScopeAccessAllThings)) { qCWarning(dcUserManager()) << "Invalid scopes combination. If a user can execute rules, he must have access to all things."; return false; } } if (scopes.testFlag(Types::PermissionScopeConfigureRules)) { if (!scopes.testFlag(Types::PermissionScopeAccessAllThings) || !scopes.testFlag(Types::PermissionScopeExecuteRules)) { qCWarning(dcUserManager()) << "Invalid scopes combination. If a user can create rules, he must have access to all things and be able to execute them."; return false; } } return true; } void UserManager::dumpDBError(const QString &message) { qCCritical(dcUserManager) << message << "Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText(); } void UserManager::evaluateAllowedThingsForUser() { } void UserManager::onPushButtonPressed() { if (m_pushButtonTransaction.first == -1) { qCDebug(dcUserManager()) << "PushButton pressed without a client waiting for it. Ignoring the signal."; return; } // Creating a user without username and password. It won't be able to log in via user/password QSqlQuery query(m_db); query.prepare("SELECT * FROM users WHERE username = \"\";"); query.exec(); if (!query.next()) { qCDebug(dcUserManager()) << "Creating token admin user"; QSqlQuery query(m_db); query.prepare("INSERT INTO users(username, password, salt, scopes) values(?, ?, ?, ?);"); query.addBindValue(""); query.addBindValue(""); query.addBindValue(""); query.addBindValue(Types::scopeToString(Types::PermissionScopeAdmin)); query.exec(); if (query.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager) << "Error creating push button user:" << query.lastError().databaseText() << query.lastError().driverText(); } } QByteArray token = QCryptographicHash::hash(QUuid::createUuid().toByteArray(), QCryptographicHash::Sha256).toBase64(); QString storeTokenQueryString = QString("INSERT INTO tokens(id, username, token, creationdate, devicename) VALUES(\"%1\", \"%2\", \"%3\", \"%4\", \"%5\");") .arg(QUuid::createUuid().toString()) .arg("") .arg(QString::fromUtf8(token)) .arg(NymeaCore::instance()->timeManager()->currentDateTime().toString("yyyy-MM-dd hh:mm:ss")) .arg(m_pushButtonTransaction.second); QSqlQuery storeTokenQuery(m_db); if (!storeTokenQuery.exec(storeTokenQueryString) || m_db.lastError().type() != QSqlError::NoError) { qCWarning(dcUserManager()) << "Error storing token in DB:" << m_db.lastError().databaseText() << m_db.lastError().driverText(); qCWarning(dcUserManager()) << "PushButton Auth failed."; emit pushButtonAuthFinished(m_pushButtonTransaction.first, false, QByteArray()); } else { qCDebug(dcUserManager()) << "PushButton Auth succeeded."; emit pushButtonAuthFinished(m_pushButtonTransaction.first, true, token); } m_pushButtonTransaction.first = -1; } }