nymea/libnymea-core/usermanager.cpp

466 lines
19 KiB
C++

/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* *
* Copyright (C) 2017 Michael Zanetti <michael.zanetti@guh.io> *
* *
* This file is part of nymea. *
* *
* nymea 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. *
* *
* 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 General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with nymea. If not, see <http://www.gnu.org/licenses/>. *
* *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/*!
\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 occured. 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 "nymeasettings.h"
#include "loggingcategories.h"
#include "pushbuttondbusservice.h"
#include "nymeacore.h"
#include <QUuid>
#include <QCryptographicHash>
#include <QSqlQuery>
#include <QVariant>
#include <QSqlError>
#include <QRegExpValidator>
#include <QDateTime>
#include <QDebug>
#include <QFileInfo>
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(m_db.databaseName()).exists()) {
rotate(m_db.databaseName());
if (!initDB()) {
qCWarning(dcLogEngine()) << "Error fixing user database. Giving up. Users can't be stored.";
}
}
}
m_pushButtonDBusService = new PushButtonDBusService("/io/guh/nymead/UserManager", this);
connect(m_pushButtonDBusService, &PushButtonDBusService::pushButtonPressed, this, &UserManager::onPushButtonPressed);
m_pushButtonTransaction = qMakePair<int, QString>(-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 anonimous 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 result = m_db.exec(getTokensQuery);
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() && !result.first();
}
/*! Returns the list of user names for this UserManager. */
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;
}
/*! 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)
{
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;
}
QString checkForDuplicateUserQuery = QString("SELECT * FROM users WHERE lower(username) = \"%1\";").arg(username.toLower());
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;
}
/*! Remove the user with the given \a username and all of its tokens. If the \a username is empty, all anonymous
tokens (e.g. issued by pushbutton auth) will be cleared.
*/
UserManager::UserError UserManager::removeUser(const QString &username)
{
if (!username.isEmpty()) {
QString dropUserQuery = QString("DELETE FROM users WHERE lower(username) =\"%1\";").arg(username.toLower());
QSqlQuery result = m_db.exec(dropUserQuery);
if (result.numRowsAffected() == 0) {
return UserErrorInvalidUserId;
}
QString dropTokensQuery = QString("DELETE FROM tokens WHERE lower(username) = \"%1\";").arg(username.toLower());
m_db.exec(dropTokensQuery);
} else {
QString dropTokensQuery = QString("DELETE FROM tokens WHERE username = \"\";").arg(username.toLower());
m_db.exec(dropTokensQuery);
}
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
successfull, 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) << "Username did not pass validation:" << username;
return QByteArray();
}
QString passwordQuery = QString("SELECT password, salt FROM users WHERE lower(username) = \"%1\";").arg(username.toLower());
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(id, username, token, creationdate, devicename) VALUES(\"%1\", \"%2\", \"%3\", \"%4\", \"%5\");")
.arg(QUuid::createUuid().toString())
.arg(username)
.arg(QString::fromUtf8(token))
.arg(NymeaCore::instance()->timeManager()->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;
}
/*! 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 = qMakePair<int, QString>(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;
}
/*! Returns the username for the given \a token. If the token is invalid, an empty string will be returned. */
QString UserManager::userForToken(const QByteArray &token) const
{
if (!validateToken(token)) {
qCWarning(dcUserManager) << "Token failed character validation:" << token;
return QString();
}
QString getUserQuery = QString("SELECT * FROM tokens WHERE token = \"%1\";")
.arg(QString::fromUtf8(token));
QSqlQuery result = m_db.exec(getUserQuery);
if (m_db.lastError().type() != QSqlError::NoError) {
qCWarning(dcUserManager) << "Error fetching username for token:" << m_db.lastError().databaseText() << m_db.lastError().driverText() << getUserQuery;
return QString();
}
if (!result.first()) {
qCWarning(dcUserManager) << "No such token in DB:" << token;
return QString();
}
return result.value("username").toString();
}
/*! Returns a list of tokens for the given \a username.
\sa TokenInfo
*/
QList<TokenInfo> UserManager::tokens(const QString &username) const
{
QList<TokenInfo> ret;
if (!validateUsername(username)) {
qCWarning(dcUserManager) << "Username did not pass validation:" << username;
return ret;
}
QString getTokensQuery = QString("SELECT id, username, creationdate, deviceName FROM tokens WHERE lower(username) = \"%1\";")
.arg(username.toLower());
QSqlQuery result = m_db.exec(getTokensQuery);
if (m_db.lastError().type() != QSqlError::NoError) {
qCWarning(dcUserManager) << "Query for tokens failed:" << m_db.lastError().databaseText() << m_db.lastError().driverText() << getTokensQuery;
return ret;
}
while (result.next()) {
ret << TokenInfo(result.value("id").toUuid(), result.value("username").toString(), result.value("creationdate").toDateTime(), result.value("devicename").toString());
}
return ret;
}
/*! Removes the token with the given \a tokenId. Returns \l{UserError} to inform about the result. */
UserManager::UserError UserManager::removeToken(const QUuid &tokenId)
{
QString removeTokenQuery = QString("DELETE FROM tokens WHERE id = \"%1\";")
.arg(tokenId.toString());
QSqlQuery result = m_db.exec(removeTokenQuery);
if (m_db.lastError().type() != QSqlError::NoError) {
qCWarning(dcUserManager) << "Removing token failed:" << m_db.lastError().databaseText() << m_db.lastError().driverText() << removeTokenQuery;
return UserErrorBackendError;
}
if (result.numRowsAffected() != 1) {
qCWarning(dcUserManager) << "Token not found in 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;
}
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) << "Authorization failed for token" << token;
return false;
}
//qCDebug(dcUserManager) << "Token authorized for user" << result.value("username").toString();
return true;
}
bool UserManager::initDB()
{
m_db.close();
if (!m_db.open()) {
qCWarning(dcUserManager()) << "Can't open user database. Init failed.";
return false;
}
if (!m_db.tables().contains("users")) {
qCDebug(dcUserManager()) << "Empty user database. Setting up metadata...";
m_db.exec("CREATE TABLE users (username VARCHAR(40) UNIQUE, password VARCHAR(100), salt VARCHAR(100));");
if (m_db.lastError().isValid()) {
qCWarning(dcUserManager) << "Error initualizing user database. Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText();
return false;
}
}
if (!m_db.tables().contains("tokens")) {
qCDebug(dcUserManager()) << "Empty user database. Setting up metadata...";
m_db.exec("CREATE TABLE tokens (id VARCHAR(40) UNIQUE, username VARCHAR(40), token VARCHAR(100) UNIQUE, creationdate DATETIME, devicename VARCHAR(40));");
if (m_db.lastError().isValid()) {
qCWarning(dcUserManager()) << "Error initualizing user database. Driver error:" << m_db.lastError().driverText() << "Database error:" << m_db.lastError().databaseText();
return false;
}
}
qCDebug(dcUserManager()) << "User database initialized successfully.";
return true;
}
void UserManager::rotate(const QString &dbName)
{
int index = 1;
while (QFileInfo(QString("%1.%2").arg(dbName).arg(index)).exists()) {
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
{
QRegExp validator("(^[a-zA-Z0-9_\\.+-]+@[a-zA-Z0-9-_]+\\.[a-zA-Z]+$)");
return validator.exactMatch(username);
}
bool UserManager::validatePassword(const QString &password) const
{
if (password.length() < 8) {
return false;
}
if (!password.contains(QRegExp("[a-z]"))) {
return false;
}
if (!password.contains(QRegExp("[0-9]"))) {
return false;
}
if (!password.contains(QRegExp("[!\"§$%&/()#*\\'+\\.\\\\@€µ~]"))) {
return false;
}
return true;
}
bool UserManager::validateToken(const QByteArray &token) const
{
QRegExp validator(QRegExp("(^[a-zA-Z0-9_\\.+-/=]+$)"));
return validator.exactMatch(token);
}
void UserManager::onPushButtonPressed()
{
if (m_pushButtonTransaction.first == -1) {
qCDebug(dcUserManager()) << "PushButton pressed without a client waiting for it. Ignoring the signal.";
return;
}
QByteArray token = QCryptographicHash::hash(QUuid::createUuid().toByteArray(), QCryptographicHash::Sha256).toBase64();
QString storeTokenQuery = QString("INSERT INTO tokens(id, username, token, creationdate, devicename) VALUES(\"%1\", \"%2\", \"%3\", \"%4\", \"%5\");")
.arg(QUuid::createUuid().toString())
.arg("")
.arg(QString::fromUtf8(token))
.arg(NymeaCore::instance()->timeManager()->currentDateTime().toString("yyyy-MM-dd hh:mm:ss"))
.arg(m_pushButtonTransaction.second);
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();
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;
}
}