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, #14master
parent
937a48bed7
commit
4425795211
|
@ -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)
|
||||
|
||||
|
|
|
@ -14,3 +14,4 @@ add_subdirectory(secrets)
|
|||
add_subdirectory(account)
|
||||
add_subdirectory(model)
|
||||
add_subdirectory(validators)
|
||||
add_subdirectory(app)
|
||||
|
|
|
@ -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-)
|
|
@ -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"
|
|
@ -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"
|
|
@ -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)
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -8,7 +8,7 @@
|
|||
#include <QClipboard>
|
||||
#include <QGuiApplication>
|
||||
|
||||
KEYSMITH_LOGGER(logger, ".app")
|
||||
KEYSMITH_LOGGER(logger, ".app.keysmith")
|
||||
|
||||
namespace app
|
||||
{
|
||||
|
|
Loading…
Reference in New Issue