Add 2FA suggestions report

Fixes #4096

Signed-off-by: Thomas Hobson <thomas@hexf.me>
This commit is contained in:
Thomas Hobson 2022-04-02 03:02:05 +13:00
parent aca197a96f
commit e5dc3bd037
8 changed files with 555 additions and 0 deletions

View File

@ -170,6 +170,8 @@ set(keepassx_SOURCES
gui/reports/ReportsPageHibp.cpp
gui/reports/ReportsWidgetStatistics.cpp
gui/reports/ReportsPageStatistics.cpp
gui/reports/ReportsPage2FA.cpp
gui/reports/ReportsWidget2FA.cpp
gui/osutils/OSUtilsBase.cpp
gui/osutils/ScreenLockListener.cpp
gui/osutils/ScreenLockListenerPrivate.cpp

View File

@ -21,6 +21,8 @@
#include "ReportsPageHealthcheck.h"
#include "ReportsPageHibp.h"
#include "ReportsPageStatistics.h"
#include "ReportsPage2FA.h"
#ifdef WITH_XC_BROWSER
#include "ReportsPageBrowserStatistics.h"
#include "ReportsWidgetBrowserStatistics.h"
@ -62,6 +64,7 @@ ReportsDialog::ReportsDialog(QWidget* parent)
, m_healthPage(new ReportsPageHealthcheck())
, m_hibpPage(new ReportsPageHibp())
, m_statPage(new ReportsPageStatistics())
, m_2faPage(new ReportsPage2FA())
#ifdef WITH_XC_BROWSER
, m_browserStatPage(new ReportsPageBrowserStatistics())
#endif
@ -76,6 +79,7 @@ ReportsDialog::ReportsDialog(QWidget* parent)
#endif
addPage(m_healthPage);
addPage(m_hibpPage);
addPage(m_2faPage);
m_ui->stackedWidget->setCurrentIndex(0);
@ -87,6 +91,8 @@ ReportsDialog::ReportsDialog(QWidget* parent)
connect(m_ui->categoryList, SIGNAL(categoryChanged(int)), m_ui->stackedWidget, SLOT(setCurrentIndex(int)));
connect(m_healthPage->m_healthWidget, SIGNAL(entryActivated(Entry*)), SLOT(entryActivationSignalReceived(Entry*)));
connect(m_hibpPage->m_hibpWidget, SIGNAL(entryActivated(Entry*)), SLOT(entryActivationSignalReceived(Entry*)));
connect(m_2faPage->m_2faWidget, SIGNAL(entryActivated(Entry*)), SLOT(entryActivationSignalReceived(Entry*)));
#ifdef WITH_XC_BROWSER
connect(m_browserStatPage->m_browserWidget,
SIGNAL(entryActivated(Entry*)),

View File

@ -29,6 +29,7 @@ class QTabWidget;
class ReportsPageHealthcheck;
class ReportsPageHibp;
class ReportsPageStatistics;
class ReportsPage2FA;
#ifdef WITH_XC_BROWSER
class ReportsPageBrowserStatistics;
#endif
@ -77,6 +78,7 @@ private:
const QSharedPointer<ReportsPageHealthcheck> m_healthPage;
const QSharedPointer<ReportsPageHibp> m_hibpPage;
const QSharedPointer<ReportsPageStatistics> m_statPage;
const QSharedPointer<ReportsPage2FA> m_2faPage;
#ifdef WITH_XC_BROWSER
const QSharedPointer<ReportsPageBrowserStatistics> m_browserStatPage;
#endif

View File

@ -0,0 +1,42 @@
//
// Created by Thomas on 1/04/2022.
//
#include "ReportsPage2FA.h"
#include "ReportsWidget2FA.h"
#include "gui/Icons.h"
QString ReportsPage2FA::name()
{
return QObject::tr("2FA");
}
QIcon ReportsPage2FA::icon()
{
return icons()->icon("chronometer");
}
QWidget* ReportsPage2FA::createWidget()
{
return m_2faWidget;
}
void ReportsPage2FA::loadSettings(QWidget* widget, QSharedPointer<Database> db)
{
ReportsWidget2FA* settingsWidget = reinterpret_cast<ReportsWidget2FA*>(widget);
settingsWidget->loadSettings(db);
}
void ReportsPage2FA::saveSettings(QWidget* widget)
{
ReportsWidget2FA* settingsWidget = reinterpret_cast<ReportsWidget2FA*>(widget);
settingsWidget->saveSettings();
}
ReportsPage2FA::ReportsPage2FA()
: m_2faWidget(new ReportsWidget2FA())
{
}

View File

@ -0,0 +1,25 @@
//
// Created by Thomas on 1/04/2022.
//
#ifndef KEEPASSXC_REPORTSPAGE2FA_H
#define KEEPASSXC_REPORTSPAGE2FA_H
#include "ReportsDialog.h"
#include "ReportsWidget2FA.h"
class ReportsPage2FA : public IReportsPage
{
QString name() override;
QIcon icon() override;
QWidget* createWidget() override;
void loadSettings(QWidget* widget, QSharedPointer<Database> db) override;
void saveSettings(QWidget* widget) override;
public:
ReportsWidget2FA* m_2faWidget;
ReportsPage2FA();
};
#endif // KEEPASSXC_REPORTSPAGE2FA_H

View File

@ -0,0 +1,258 @@
//
// Created by Thomas on 1/04/2022.
//
#include <QNetworkRequest>
#include <QNetworkAccessManager>
#include <QJsonObject>
#include <QJsonArray>
#include <utility>
#include <QDesktopServices>
#include "ReportsWidget2FA.h"
#include "ui_ReportsWidget2FA.h"
#include "gui/Icons.h"
#include "core/Group.h"
#include "core/NetworkManager.h"
ReportsWidget2FA::ReportsWidget2FA(QWidget* parent)
: QWidget(parent)
, m_ui(new Ui::ReportsWidget2FA)
{
m_ui->setupUi(this);
m_ui->downloadingDirectory->hide();
m_ui->downloadDirectoryError->hide();
#ifdef WITH_XC_NETWORKING
m_ui->noNetwork->hide();
#endif
m_referencesModel.reset(new QStandardItemModel());
m_ui->mfaTableView->setModel(m_referencesModel.data());
m_ui->mfaTableView->setSelectionMode(QAbstractItemView::NoSelection);
m_ui->mfaTableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
connect(m_ui->enabledFilter, SIGNAL(clicked(bool)), this, SLOT(refresh2FATable()));
connect(m_ui->disabledFilter, SIGNAL(clicked(bool)), this, SLOT(refresh2FATable()));
connect(m_ui->notSupportedFilter, SIGNAL(clicked(bool)), this, SLOT(refresh2FATable()));
connect(m_ui->supportedFilter, SIGNAL(clicked(bool)), this, SLOT(refresh2FATable()));
connect(m_ui->mfaTableView, SIGNAL(doubleClicked(QModelIndex)), SLOT(cellDoubleClick(QModelIndex)));
}
ReportsWidget2FA::~ReportsWidget2FA()
{
delete m_ui;
}
void ReportsWidget2FA::saveSettings()
{
// no settings to save
}
void ReportsWidget2FA::loadSettings(QSharedPointer<Database> db)
{
m_db = std::move(db);
m_referencesModel->clear();
auto row = QList<QStandardItem*>();
row << new QStandardItem("Please wait while your database is analysed.");
m_referencesModel->appendRow(row);
#ifdef WITH_XC_NETWORKING
fetchDirectory();
#else
make2FATable(false);
#endif
}
void ReportsWidget2FA::fetchDirectory()
{
m_ui->downloadingDirectory->show();
m_ui->downloadDirectoryError->hide();
m_bytesReceived.clear();
QUrl directoryUrl = QUrl("https://2fa.directory/api/v1/data.json");
QNetworkRequest request(directoryUrl);
m_reply = getNetMgr()->get(request);
connect(m_reply, &QNetworkReply::finished, this, &ReportsWidget2FA::fetchDirectoryFinished);
connect(m_reply, &QIODevice::readyRead, this, &ReportsWidget2FA::fetchDirectoryReadyRead);
}
void ReportsWidget2FA::fetchDirectoryReadyRead()
{
m_bytesReceived += m_reply->readAll();
}
void ReportsWidget2FA::fetchDirectoryFinished()
{
bool error = (m_reply->error() != QNetworkReply::NoError);
m_reply->deleteLater();
m_reply = nullptr;
if(!error){
QJsonDocument jsonResponse = QJsonDocument::fromJson(m_bytesReceived);
QJsonObject jsonObject = jsonResponse.object();
// object is a dictionary of category->entryName->entry
m_2faDirectoryEntries.clear();
for(auto category : jsonObject.keys()) {
if(!jsonObject.value(category).isObject())
goto error; // The schema appears to have changed
auto categoryEntries = jsonObject.value(category).toObject();
for(const auto& categoryEntriesName : categoryEntries.keys()){
auto jsonEntry = categoryEntries.value(categoryEntriesName);
if(!jsonEntry.isObject())
goto error;
auto entry = jsonEntry.toObject();
auto entryInstance = new MFADirectoryEntry(
entry.contains("tfa") && entry.value("tfa").toBool(),
entry.value("name").toString(),
entry.value("url").toString(),
category,
entry.value("doc").toString()
);
m_2faDirectoryEntries.append(*entryInstance);
}
}
make2FATable(true);
}else{
goto error;
}
m_ui->downloadingDirectory->hide();
return;
error:
m_ui->downloadDirectoryError->show();
make2FATable(false);
}
void ReportsWidget2FA::make2FATable(bool useDirectory)
{
m_directoryUsed = useDirectory;
m_referencesModel->clear();
if(useDirectory) {
m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Title") << tr("Path") << tr("2FA Enabled")
<< tr("2FA Supported") << tr("Matched Site")
<< tr("Documentation"));
m_ui->supportedFilter->show();
m_ui->notSupportedFilter->show();
}else{
m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Title") << tr("Path") << tr("2FA Enabled"));
m_ui->supportedFilter->hide();
m_ui->notSupportedFilter->hide();
}
for(auto entry : m_db->rootGroup()->entriesRecursive()){
if(!entry->isRecycled() && !entry->excludeFromReports()){
auto group = entry->group();
auto hasMFA = entry->hasTotp();
MFADirectoryEntry* matchedDirectoryEntry = nullptr;
auto row = QList<QStandardItem*>();
row << new QStandardItem(Icons::entryIconPixmap(entry), entry->title())
<< new QStandardItem(Icons::groupIconPixmap(group), group->hierarchy().join("/"))
<< new QStandardItem(hasMFA ? tr("Enabled") : tr("Disabled"));
if(hasMFA && !m_ui->enabledFilter->isChecked()) continue;
if(!hasMFA && !m_ui->disabledFilter->isChecked()) continue;
if(useDirectory){
for(auto directoryEntry : m_2faDirectoryEntries){
if(QUrl(entry->url()).host() == QUrl(directoryEntry.url()).host()){ // highly likely this is a match
matchedDirectoryEntry = &directoryEntry;
break;
}
}
if(matchedDirectoryEntry == nullptr){
row << new QStandardItem(tr("Unknown"))
<< new QStandardItem(tr("No match"));
if(!m_ui->notSupportedFilter->isChecked()) continue;
}else{
row << new QStandardItem(matchedDirectoryEntry->supported() ? tr("Supported") : tr("Not Supported"))
<< new QStandardItem(QString("%1 (%2)").arg(matchedDirectoryEntry->name(),
matchedDirectoryEntry->url()))
<< new QStandardItem(matchedDirectoryEntry->docs());
if(!m_ui->supportedFilter->isChecked()) continue;
}
}
m_referencesModel->appendRow(row);
m_tableData.append(new QPair(entry, matchedDirectoryEntry->docs()));
}
}
}
void ReportsWidget2FA::refresh2FATable()
{
make2FATable(m_directoryUsed);
}
void ReportsWidget2FA::cellDoubleClick(const QModelIndex& index)
{
if(!index.isValid()){
return;
}
auto clickedRow = m_tableData[index.row()];
if(index.column() == 5) { // docs column
auto directoryUrl = clickedRow->second;
QDesktopServices::openUrl(directoryUrl);
}else{
emit entryActivated(clickedRow->first);
}
}
bool MFADirectoryEntry::supported()
{
return m_2faSupported;
}
QString MFADirectoryEntry::url()
{
return m_url;
}
QString MFADirectoryEntry::name()
{
return m_name;
}
QString MFADirectoryEntry::category()
{
return m_category;
}
QString MFADirectoryEntry::docs()
{
return m_docs;
}
MFADirectoryEntry::MFADirectoryEntry(bool supported, QString name, QString url, QString category, QString docs)
{
m_2faSupported = supported;
m_name = std::move(name);
m_url = std::move(url);
m_category = std::move(category);
m_docs = std::move(docs);
}

View File

@ -0,0 +1,83 @@
//
// Created by Thomas on 1/04/2022.
//
#ifndef KEEPASSXC_REPORTSWIDGET2FA_H
#define KEEPASSXC_REPORTSWIDGET2FA_H
#include <QNetworkReply>
#include <QWidget>
#include <QIcon>
#include <QStandardItemModel>
#include <QJsonDocument>
class Database;
class Entry;
QT_BEGIN_NAMESPACE
namespace Ui
{
class ReportsWidget2FA;
}
QT_END_NAMESPACE
class MFADirectoryEntry {
public:
MFADirectoryEntry(bool supported, QString name, QString url, QString category, QString docs);
bool supported();
QString url();
QString name();
QString category();
QString docs();
private:
bool m_2faSupported;
QString m_url;
QString m_name;
QString m_category;
QString m_docs;
};
class ReportsWidget2FA : public QWidget
{
Q_OBJECT
public:
explicit ReportsWidget2FA(QWidget* parent = nullptr);
~ReportsWidget2FA() override;
void loadSettings(QSharedPointer<Database> db);
void saveSettings();
signals:
void entryActivated(Entry*);
private slots:
void fetchDirectoryFinished();
void fetchDirectoryReadyRead();
void refresh2FATable();
void cellDoubleClick(const QModelIndex& index);
private:
void make2FATable(bool useDirectory);
void fetchDirectory();
Ui::ReportsWidget2FA* m_ui;
bool m_directoryUsed = false;
QByteArray m_bytesReceived;
QList<QPair<Entry*, QString>*> m_tableData;
QList<MFADirectoryEntry> m_2faDirectoryEntries;
QScopedPointer<QStandardItemModel> m_referencesModel;
QSharedPointer<Database> m_db;
QNetworkReply* m_reply;
};
#endif // KEEPASSXC_REPORTSWIDGET2FA_H

View File

@ -0,0 +1,137 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ReportsWidget2FA</class>
<widget class="QWidget" name="ReportsWidget2FA">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>634</width>
<height>364</height>
</rect>
</property>
<property name="windowTitle">
<string>ReportsWidget2FA</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QTableView" name="mfaTableView">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="downloadDirectoryError">
<property name="text">
<string>Downloading the 2FA Directory Failed</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="downloadingDirectory">
<property name="text">
<string>Downloading 2FA Directory (provided by 2fa.directory)</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="noNetwork">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>This build of KeepassXC doesn't have networking functions. Networking is required to provide 2FA suggestions</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="hintLabel">
<property name="font">
<font>
<italic>true</italic>
</font>
</property>
<property name="text">
<string>Double-click entries to edit them</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Filter</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QCheckBox" name="supportedFilter">
<property name="text">
<string>Supported</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="notSupportedFilter">
<property name="text">
<string>Not Supported</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="enabledFilter">
<property name="text">
<string>Enabled</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="disabledFilter">
<property name="text">
<string>Disabled</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>