feat: add basic support for accepting otpauth:// URIs from the commandline

This change is a building block towards receiving decoded QR codes from other applications and adding corresponding accounts in Keysmith.

Issues: #7, #14
master
Johan Ouwerkerk 2020-09-25 19:47:59 +02:00
parent 937a48bed7
commit 4425795211
9 changed files with 425 additions and 3 deletions

View File

@ -41,7 +41,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
################# Find dependencies #################
find_package(Qt5 ${QT_MIN_VERSION} REQUIRED NO_MODULE COMPONENTS Core Quick Gui Svg QuickControls2)
find_package(Qt5 ${QT_MIN_VERSION} REQUIRED NO_MODULE COMPONENTS Core Quick Gui Svg QuickControls2 Concurrent)
find_package(KF5Kirigami2 ${KF5_MIN_VERSION} REQUIRED)
find_package(KF5I18n ${KF5_MIN_VERSION} REQUIRED)

View File

@ -14,3 +14,4 @@ add_subdirectory(secrets)
add_subdirectory(account)
add_subdirectory(model)
add_subdirectory(validators)
add_subdirectory(app)

View File

@ -0,0 +1,13 @@
#
# SPDX-License-Identifier: BSD-2-Clause
# SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
#
set(Test_DEP_LIBS Qt5::Core Qt5::Concurrent Qt5::Test keysmith_lib test_lib)
set(app_test_SRCS
commandline-account-job.cpp
commandline-options.cpp
)
ecm_add_tests(${app_test_SRCS} LINK_LIBRARIES ${Test_DEP_LIBS} NAME_PREFIX app-)

View File

@ -0,0 +1,110 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#include "app/cli.h"
#include "../test-utils/spy.h"
#include <QSignalSpy>
#include <QTest>
#include <QThreadPool>
#include <QtDebug>
class CommandLineAccountJobTest: public QObject // clazy:exclude=ctor-missing-parent-argument
{
Q_OBJECT
private Q_SLOTS:
void initTestCase(void);
void testValidAccountUri(void);
void testInvalidAccountUri(void);
void testExpiredRecipient(void);
};
void CommandLineAccountJobTest::initTestCase(void)
{
auto threadPool = QThreadPool::globalInstance();
QVERIFY2(threadPool, "should have a global thread pool by now");
threadPool->setMaxThreadCount(3);
}
void CommandLineAccountJobTest::testValidAccountUri(void)
{
model::AccountInput recipient;
auto uut = new app::CommandLineAccountJob(&recipient);
QSignalSpy invalid(uut, &app::CommandLineAccountJob::newAccountInvalid);
QSignalSpy processed(uut, &app::CommandLineAccountJob::newAccountProcessed);
QSignalSpy cleaned(uut, &QObject::destroyed);
uut->run(QStringLiteral("otpauth://hotp/issuer:valid?secret=VALUE&digits=8&period=60&issuer=issuer&counter=42&algorithm=sha512"));
QVERIFY2(test::signal_eventually_emitted_once(processed), "URI should be successfully processed by now");
QVERIFY2(test::signal_eventually_emitted_once(cleaned), "AccountJob should be disposed of by now");
QCOMPARE(invalid.count(), 0);
QCOMPARE(recipient.name(), QStringLiteral("valid"));
QCOMPARE(recipient.issuer(), QStringLiteral("issuer"));
QCOMPARE(recipient.counter(), QStringLiteral("42"));
QCOMPARE(recipient.secret(), QStringLiteral("VALUE==="));
QCOMPARE(recipient.tokenLength(), 8U);
QCOMPARE(recipient.timeStep(), 60U);
QCOMPARE(recipient.type(), model::AccountInput::TokenType::Hotp);
QCOMPARE(recipient.algorithm(), model::AccountInput::TOTPAlgorithm::Sha512);
QVERIFY2(QThreadPool::globalInstance()->waitForDone(500), "the global thread pool should be done by now");
}
void CommandLineAccountJobTest::testExpiredRecipient(void)
{
auto recipient = new model::AccountInput();
QSignalSpy expired(recipient, &QObject::destroyed);
auto uut = new app::CommandLineAccountJob(recipient);
QSignalSpy invalid(uut, &app::CommandLineAccountJob::newAccountInvalid);
QSignalSpy processed(uut, &app::CommandLineAccountJob::newAccountProcessed);
QSignalSpy cleaned(uut, &QObject::destroyed);
recipient->deleteLater();
QVERIFY2(test::signal_eventually_emitted_once(expired), "AccountInput should have expired by now");
uut->run(QStringLiteral("otpauth://hotp/issuer:valid?secret=VALUE&digits=8&period=60&issuer=issuer&counter=42&algorithm=sha512"));
QVERIFY2(test::signal_eventually_emitted_once(cleaned), "AccountJob should be disposed of by now");
QCOMPARE(processed.count(), 0);
QCOMPARE(invalid.count(), 0);
QVERIFY2(QThreadPool::globalInstance()->waitForDone(500), "the global thread pool should be done by now");
}
void CommandLineAccountJobTest::testInvalidAccountUri(void)
{
model::AccountInput recipient;
auto uut = new app::CommandLineAccountJob(&recipient);
QSignalSpy invalid(uut, &app::CommandLineAccountJob::newAccountInvalid);
QSignalSpy processed(uut, &app::CommandLineAccountJob::newAccountProcessed);
QSignalSpy cleaned(uut, &QObject::destroyed);
uut->run(QStringLiteral("not a valid otpauth:// URI"));
QVERIFY2(test::signal_eventually_emitted_once(invalid), "URI should be rejected by now");
QVERIFY2(test::signal_eventually_emitted_once(cleaned), "AccountJob should be disposed of by now");
QCOMPARE(processed.count(), 0);
QCOMPARE(recipient.name(), QString());
QCOMPARE(recipient.issuer(), QString());
QCOMPARE(recipient.counter(), QStringLiteral("0"));
QCOMPARE(recipient.secret(), QString());
QCOMPARE(recipient.tokenLength(), 6U);
QCOMPARE(recipient.timeStep(), 30U);
QCOMPARE(recipient.type(), model::AccountInput::TokenType::Totp);
QCOMPARE(recipient.algorithm(), model::AccountInput::TOTPAlgorithm::Sha1);
QVERIFY2(QThreadPool::globalInstance()->waitForDone(500), "the global thread pool should be done by now");
}
QTEST_GUILESS_MAIN(CommandLineAccountJobTest)
#include "commandline-account-job.moc"

View File

@ -0,0 +1,126 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#include "app/cli.h"
#include "../test-utils/spy.h"
#include <QCoreApplication>
#include <QSignalSpy>
#include <QTest>
#include <QThreadPool>
#include <QtDebug>
class CommandLineOptionsTest: public QObject // clazy:exclude=ctor-missing-parent-argument
{
Q_OBJECT
private Q_SLOTS:
void initTestCase(void);
void testValidAccountUri(void);
void testInvalidAccountUri(void);
void testInvalidCommandLine(void);
};
void CommandLineOptionsTest::initTestCase(void)
{
auto threadPool = QThreadPool::globalInstance();
QVERIFY2(threadPool, "should have a global thread pool by now");
threadPool->setMaxThreadCount(3);
}
static bool prime(QCommandLineParser &parser, const QStringList &argv)
{
app::CommandLineOptions::addOptions(parser);
return parser.parse(argv);
}
void CommandLineOptionsTest::testValidAccountUri(void)
{
QCommandLineParser parser;
model::AccountInput recipient;
const auto argv = QStringList()
<< QStringLiteral("<dummy app>")
<< QStringLiteral("otpauth://hotp/issuer:valid?secret=VALUE&digits=8&period=60&issuer=issuer&counter=42&algorithm=sha512");
app::CommandLineOptions uut(parser, prime(parser, argv));
QSignalSpy invalid(&uut, &app::CommandLineOptions::newAccountInvalid);
QSignalSpy processed(&uut, &app::CommandLineOptions::newAccountProcessed);
QVERIFY2(uut.newAccountRequested(), "Account URI parsing should be requested");
QVERIFY2(uut.optionsOk(), "Commandline options should be marked as 'ok' (valid)");
QCOMPARE(uut.errorText(), QString());
uut.handleNewAccount(&recipient);
QVERIFY2(test::signal_eventually_emitted_once(processed), "Account URI should be processed by now");
QCOMPARE(invalid.count(), 0);
QCOMPARE(recipient.name(), QStringLiteral("valid"));
QCOMPARE(recipient.issuer(), QStringLiteral("issuer"));
QCOMPARE(recipient.counter(), QStringLiteral("42"));
QCOMPARE(recipient.secret(), QStringLiteral("VALUE==="));
QCOMPARE(recipient.tokenLength(), 8U);
QCOMPARE(recipient.timeStep(), 60U);
QCOMPARE(recipient.type(), model::AccountInput::TokenType::Hotp);
QCOMPARE(recipient.algorithm(), model::AccountInput::TOTPAlgorithm::Sha512);
QVERIFY2(QThreadPool::globalInstance()->waitForDone(500), "the global thread pool should be done by now");
}
void CommandLineOptionsTest::testInvalidAccountUri(void)
{
QCommandLineParser parser;
model::AccountInput recipient;
const auto argv = QStringList()
<< QStringLiteral("<dummy app>")
<< QStringLiteral("not a valid otpauth:// URI");
app::CommandLineOptions uut(parser, prime(parser, argv));
QSignalSpy invalid(&uut, &app::CommandLineOptions::newAccountInvalid);
QSignalSpy processed(&uut, &app::CommandLineOptions::newAccountProcessed);
QVERIFY2(uut.newAccountRequested(), "Account URI parsing should be requested");
QVERIFY2(uut.optionsOk(), "Commandline options should be marked as 'ok' (valid)");
QCOMPARE(uut.errorText(), QString());
uut.handleNewAccount(&recipient);
QVERIFY2(test::signal_eventually_emitted_once(invalid), "Account URI should have been rejected by now");
QCOMPARE(processed.count(), 0);
QCOMPARE(recipient.name(), QString());
QCOMPARE(recipient.issuer(), QString());
QCOMPARE(recipient.counter(), QStringLiteral("0"));
QCOMPARE(recipient.secret(), QString());
QCOMPARE(recipient.tokenLength(), 6U);
QCOMPARE(recipient.timeStep(), 30U);
QCOMPARE(recipient.type(), model::AccountInput::TokenType::Totp);
QCOMPARE(recipient.algorithm(), model::AccountInput::TOTPAlgorithm::Sha1);
QVERIFY2(QThreadPool::globalInstance()->waitForDone(500), "the global thread pool should be done by now");
}
void CommandLineOptionsTest::testInvalidCommandLine(void)
{
QCommandLineParser parser;
const auto argv = QStringList()
<< QStringLiteral("<dummy app>")
<< QStringLiteral("--invalid-option");
app::CommandLineOptions uut(parser, prime(parser, argv));
QSignalSpy invalid(&uut, &app::CommandLineOptions::newAccountInvalid);
QSignalSpy processed(&uut, &app::CommandLineOptions::newAccountProcessed);
QVERIFY2(!uut.optionsOk(), "Commandline options should not be marked as 'ok' (invalid)");
QVERIFY2(!uut.errorText().isEmpty(), "Commandline error message should not be empty");
QVERIFY2(!uut.newAccountRequested(), "Account URI parsing should not be requested");
QCoreApplication::processEvents(QEventLoop::AllEvents, 500);
QCOMPARE(processed.count(), 0);
QCOMPARE(invalid.count(), 0);
QVERIFY2(QThreadPool::globalInstance()->waitForDone(500), "the global thread pool should be done by now");
}
QTEST_GUILESS_MAIN(CommandLineOptionsTest)
#include "commandline-options.moc"

View File

@ -5,7 +5,8 @@
set(keysmith_SRCS
keysmith.cpp
cli.cpp
)
add_library(keysmith_lib STATIC ${keysmith_SRCS})
target_link_libraries(keysmith_lib Qt5::Core Qt5::Gui model_lib account_lib)
target_link_libraries(keysmith_lib Qt5::Core Qt5::Gui Qt5::Concurrent KF5::I18n model_lib account_lib)

110
src/app/cli.cpp Normal file
View File

@ -0,0 +1,110 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#include "cli.h"
#include "../logging_p.h"
#include "../model/qr.h"
#include <KLocalizedString>
#include <QCommandLineOption>
#include <QtConcurrent>
KEYSMITH_LOGGER(logger, ".app.cli")
namespace app
{
void CommandLineOptions::addOptions(QCommandLineParser &parser)
{
parser.addPositionalArgument(
QStringLiteral("<uri>"),
i18nc("@info (<uri> placeholder)", "Optional account to add, formatted as otpauth:// URI (e.g. from a QR code)")
);
}
CommandLineOptions::CommandLineOptions(QCommandLineParser &parser, bool parseOk, QObject *parent) :
QObject(parent), m_parseOk(parseOk), m_errorText(parseOk ? QString() : parser.errorText()), m_parser(parser)
{
}
QString CommandLineOptions::errorText(void) const
{
return m_errorText;
}
bool CommandLineOptions::optionsOk(void) const
{
return m_parseOk;
}
bool CommandLineOptions::newAccountRequested(void) const
{
return optionsOk() && !m_parser.positionalArguments().isEmpty();
}
void CommandLineOptions::handleNewAccount(model::AccountInput *recipient)
{
if (!newAccountRequested()) {
qCDebug(logger) << "Ignoring request to handle new account:"
<< "Invalid commandline options or no URI was received on the commandline";
return;
}
auto job = new CommandLineAccountJob(recipient);
const auto argv = m_parser.positionalArguments();
QObject::connect(job, &CommandLineAccountJob::newAccountProcessed, this, &CommandLineOptions::newAccountProcessed);
QObject::connect(job, &CommandLineAccountJob::newAccountInvalid, this, &CommandLineOptions::newAccountInvalid);
job->run(argv[0]);
}
CommandLineAccountJob::CommandLineAccountJob(model::AccountInput *recipient) :
QObject(), m_alive(true), m_recipient(recipient)
{
QObject::connect(recipient, &QObject::destroyed, this, &CommandLineAccountJob::expired);
}
void CommandLineAccountJob::expired(void)
{
m_alive = false;
}
void CommandLineAccountJob::run(const QString &uri)
{
QtConcurrent::run(&CommandLineAccountJob::processNewAccount, this, uri);
}
void CommandLineAccountJob::processNewAccount(CommandLineAccountJob *target, const QString &uri)
{
const auto result = model::QrParameters::parse(uri);
bool invoked = false;
if (result) {
qCInfo(logger) << "Successfully parsed the URI passed on the commandline";
invoked = QMetaObject::invokeMethod(target, [target, result](void) -> void {
if (target->m_alive) {
result->populate(target->m_recipient);
qCDebug(logger) << "Reporting success parsing URI from commandline";
Q_EMIT target->newAccountProcessed();
} else {
qCDebug(logger) << "Not reporting success parsing URI from commandline: recipient has expired";
}
QTimer::singleShot(0, target, &QObject::deleteLater);
});
} else {
qCInfo(logger) << "Failed to parse the URI passed on the commandline";
invoked = QMetaObject::invokeMethod(target, [target](void) -> void {
if (target->m_alive) {
qCDebug(logger) << "Reporting failure to parse URI from commandline";
Q_EMIT target->newAccountInvalid();
} else {
qCDebug(logger) << "Not reporting failure to parse URI from commandline: recipient has expired";
}
QTimer::singleShot(0, target, &QObject::deleteLater);
});
}
if (!invoked) {
Q_ASSERT_X(false, Q_FUNC_INFO, "should be able to invoke meta method");
qCDebug(logger) << "Failed to signal result of processing the URI passed on the commandline";
}
}
}

61
src/app/cli.h Normal file
View File

@ -0,0 +1,61 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
#ifndef APP_COMMAND_LINE_H
#define APP_COMMAND_LINE_H
#include "../model/input.h"
#include <QCommandLineParser>
#include <QObject>
#include <QString>
namespace app
{
class CommandLineAccountJob: public QObject
{
Q_OBJECT
public:
explicit CommandLineAccountJob(model::AccountInput *recipient);
void run(const QString &uri);
Q_SIGNALS:
void newAccountInvalid(void);
void newAccountProcessed(void);
private:
static void processNewAccount(CommandLineAccountJob *target, const QString &uri);
private Q_SLOTS:
void expired(void);
private:
bool m_alive;
model::AccountInput * const m_recipient;
};
class CommandLineOptions: public QObject
{
Q_OBJECT
Q_PROPERTY(bool optionsOk READ optionsOk CONSTANT);
Q_PROPERTY(QString errorText READ errorText CONSTANT);
Q_PROPERTY(bool newAccountRequested READ newAccountRequested CONSTANT);
public:
static void addOptions(QCommandLineParser &parser);
explicit CommandLineOptions(QCommandLineParser &parser, bool parseOk, QObject *parent = nullptr);
QString errorText(void) const;
bool optionsOk(void) const;
bool newAccountRequested(void) const;
public Q_SLOTS:
void handleNewAccount(model::AccountInput *recipient);
Q_SIGNALS:
void newAccountInvalid(void);
void newAccountProcessed(void);
private:
static void processNewAccount(CommandLineOptions *target, model::AccountInput *recipient);
private:
const bool m_parseOk;
const QString m_errorText;
QCommandLineParser &m_parser;
};
};
#endif

View File

@ -8,7 +8,7 @@
#include <QClipboard>
#include <QGuiApplication>
KEYSMITH_LOGGER(logger, ".app")
KEYSMITH_LOGGER(logger, ".app.keysmith")
namespace app
{