mirror of https://github.com/nymea/nymea.git
466 lines
19 KiB
C++
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;
|
|
}
|
|
|
|
}
|