From a124f46861d549ddc13485536962e34d80de939a Mon Sep 17 00:00:00 2001 From: Osmium Sorcerer Date: Sun, 22 Mar 2026 18:55:26 +0000 Subject: Add authentication dialog Introduce start_auth_flow, a function invoked by typing `/auth username` in OOC. It sends an public-key authentication request to the server, starting the entire flow. The flow invoves two dialogs: to select the key, and to enter the passphrase to unlock the key. For convenience, each successful unlock also remembers the key for that username on the server, storing this in `saved_auth.json` (I chose JSON because I wanted it to stay human-editable; INI would be better, but it suffers from bad platform quirks in Qt). --- src/aoapplication.h | 3 + src/auth_flow.cpp | 159 ++++++++++++++++++++++++++++++++++++++++++++ src/auth_flow.h | 66 ++++++++++++++++++ src/courtroom.cpp | 13 ++++ src/courtroom.h | 1 + src/main.cpp | 1 + src/packet_distribution.cpp | 2 + src/saved_auth.cpp | 124 ++++++++++++++++++++++++++++++++++ src/saved_auth.h | 17 +++++ src/serverdata.h | 3 + 10 files changed, 389 insertions(+) create mode 100644 src/auth_flow.cpp create mode 100644 src/auth_flow.h create mode 100644 src/saved_auth.cpp create mode 100644 src/saved_auth.h (limited to 'src') diff --git a/src/aoapplication.h b/src/aoapplication.h index 548d4d0..d8d0c00 100644 --- a/src/aoapplication.h +++ b/src/aoapplication.h @@ -6,6 +6,7 @@ #include "discord_rich_presence.h" #include "ext_packet.h" #include "keyring.h" +#include "saved_auth.h" #include "serverdata.h" #include "widgets/aooptionsdialog.h" @@ -345,6 +346,8 @@ public: ma_decoder_config mus_decoder_config; KeyringModel keyring_model; + SavedAuth saved_auth; + QString ex_auth_username; private: QVector server_list; diff --git a/src/auth_flow.cpp b/src/auth_flow.cpp new file mode 100644 index 0000000..07544e8 --- /dev/null +++ b/src/auth_flow.cpp @@ -0,0 +1,159 @@ +#include +#include +#include +#include +#include + +#include "auth_flow.h" +#include "keyring.h" + +#include "file_functions.h" + +// This function is supposed to open the authentication dialog with various +// fields like method selection and fields to enter password or select a key, +// but for now, it'll simply submit a public key auth request. Hostname +// parameter is unused. +void start_auth_flow(AOApplication *ao_app, QString username) +{ + ao_app->ex_auth_username = username; + AuthRequest req; + req.username = username; + req.method = AuthMethod::certificate; + ao_app->send_ex_message(serializeAuthRequest(req)); +} + +KeySelectDialog::KeySelectDialog(KeyringModel *model, QStringView username, QWidget *parent) + : QDialog(parent) + , m_model(model) +{ + this->setAttribute(Qt::WA_DeleteOnClose); + this->setWindowTitle(QString("Select key for %1").arg(username)); + auto view = new QTableView(this); + view->setModel(m_model); + view->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + view->setSelectionBehavior(QAbstractItemView::SelectRows); + view->setSelectionMode(QAbstractItemView::SingleSelection); + QDialogButtonBox *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttons->button(QDialogButtonBox::Ok)->setEnabled(false); + QVBoxLayout *layout = new QVBoxLayout(this); + layout->addWidget(view); + layout->addWidget(buttons); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, this, [this, view] { + auto rows = view->selectionModel()->selectedRows(); + if (rows.isEmpty()) + { + return; + } + + QByteArray key_id = m_model->data(rows.first(), KeyringModel::KeyIDRole).toByteArray(); + QString key_name = m_model->data(rows.first()).toString(); + + emit key_selected(key_id, key_name); + }); + connect(view->selectionModel(), &QItemSelectionModel::selectionChanged, this, [view, buttons](const QItemSelection &, const QItemSelection &) { + bool selected = !view->selectionModel()->selectedRows().isEmpty(); + buttons->button(QDialogButtonBox::Ok)->setEnabled(selected); + }); +} + +KeyPassphraseDialog::KeyPassphraseDialog(QStringView key_name, QWidget *parent) + : QDialog(parent) +{ + this->setWindowTitle(QString("Enter passphrase for key %1").arg(key_name)); + QVBoxLayout *pw_layout = new QVBoxLayout(this); + pw_layout->addWidget(new QLabel(QStringLiteral("Passphrase:"), this)); + m_pw_line = new QLineEdit(this); + m_pw_line->setEchoMode(QLineEdit::Password); + pw_layout->addWidget(m_pw_line); + QDialogButtonBox *pw_buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + pw_layout->addWidget(pw_buttons); + m_err_lbl = new QLabel(this); + m_err_lbl->setVisible(false); + pw_layout->addWidget(m_err_lbl); + connect(pw_buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(pw_buttons, &QDialogButtonBox::accepted, this, [this] { + emit passphrase_submitted(m_pw_line->text().toUtf8()); + m_pw_line->clear(); + // zero the buffer + }); +} + +void KeyPassphraseDialog::display_error(ResponseResult error) const +{ + QString error_text; + switch (error) + { + case ResponseResult::unsupported_version: + error_text = QStringLiteral("Unsupported key version."); + break; + case ResponseResult::decryption_failed: + error_text = QStringLiteral("Wrong passphrase (or corrupted key)."); + break; + default: + error_text = QString("Error unlocking key (code %1). Catastrophic failure.").arg((int)error); + break; + } + m_err_lbl->setText(error_text); + m_err_lbl->setVisible(true); +} + +AuthFlow::AuthFlow(AOApplication *ao_app, const AuthChallenge &challenge, QWidget *parent) + : QObject(parent) + , m_ao_app(ao_app) + , m_challenge(challenge) +{ + auto saved_key = ao_app->saved_auth.lookup(ao_app->m_serverdata.m_server_hostname.toUtf8(), ao_app->ex_auth_username.toUtf8()); + if (!saved_key.isEmpty()) + { + m_mode = FlowMode::Saved; + m_key_dlg = nullptr; + on_key_selected(saved_key, QString("%1@%2 (saved)").arg(ao_app->ex_auth_username, ao_app->m_serverdata.m_server_hostname)); + } + else + { + m_mode = FlowMode::Default; + m_key_dlg = new KeySelectDialog(&ao_app->keyring_model, ao_app->ex_auth_username, parent); + connect(m_key_dlg, &KeySelectDialog::key_selected, this, &AuthFlow::on_key_selected); + m_key_dlg->open(); + } +} + +void AuthFlow::on_key_selected(QByteArrayView key_id, QStringView key_name) +{ + m_pwd_dlg = new KeyPassphraseDialog(key_name, m_key_dlg); + connect(m_pwd_dlg, &KeyPassphraseDialog::passphrase_submitted, this, [this, key_id](QByteArrayView passphrase) { + AuthResponse response; + ResponseResult result = unlock_and_auth(key_id, passphrase, m_challenge.challenge, m_ao_app->ex_auth_username.toUtf8(), response); + if (result == ResponseResult::success) + { + m_ao_app->send_ex_message(serializeAuthResponse(response)); + if (m_mode == FlowMode::Default) + { + m_key_dlg->accept(); + m_ao_app->saved_auth.insert(m_ao_app->m_serverdata.m_server_hostname.toUtf8(), m_ao_app->ex_auth_username.toUtf8(), key_id); + } + else + { + m_pwd_dlg->accept(); + } + deleteLater(); + } + else + { + m_pwd_dlg->display_error(result); + } + }); + connect(m_pwd_dlg, &QDialog::rejected, this, [this] { + if (m_mode == FlowMode::Saved) + { + m_ao_app->saved_auth.remove(m_ao_app->m_serverdata.m_server_hostname.toUtf8(), m_ao_app->ex_auth_username.toUtf8()); + m_mode = FlowMode::Default; + + m_key_dlg = new KeySelectDialog(&m_ao_app->keyring_model, m_ao_app->ex_auth_username); + connect(m_key_dlg, &KeySelectDialog::key_selected, this, &AuthFlow::on_key_selected); + m_key_dlg->open(); + } + }); + m_pwd_dlg->open(); +} diff --git a/src/auth_flow.h b/src/auth_flow.h new file mode 100644 index 0000000..942e1aa --- /dev/null +++ b/src/auth_flow.h @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include "aoapplication.h" +#include "ext_packet.h" +#include "keyring.h" + +class KeySelectDialog : public QDialog +{ + Q_OBJECT + +public: + explicit KeySelectDialog(KeyringModel *model, QStringView username, QWidget *parent = nullptr); + +private: + KeyringModel *m_model; + +signals: + void key_selected(QByteArrayView key_id, QStringView key_name); +}; + +class KeyPassphraseDialog : public QDialog +{ + Q_OBJECT + +public: + explicit KeyPassphraseDialog(QStringView key_name, QWidget *parent = nullptr); + void display_error(ResponseResult error) const; + +private: + QLabel *m_err_lbl; + QLineEdit *m_pw_line; + +signals: + void passphrase_submitted(QByteArrayView passphrase); +}; + +// The only reason this inherits QObject is so I can call deleteLater() +// when the authentication finishes. Auth flow involves coordinating two +// asynchronous dialogs emitting signals, so I'd rather let Qt's event loop +// clean everything up to be safe. +class AuthFlow : public QObject +{ + Q_OBJECT + +public: + explicit AuthFlow(AOApplication *ao_app, const AuthChallenge &challenge, QWidget *parent = nullptr); + +private: + enum class FlowMode + { + Default, + Saved + }; + + FlowMode m_mode; + AOApplication *m_ao_app; + AuthChallenge m_challenge; + KeySelectDialog *m_key_dlg; + KeyPassphraseDialog *m_pwd_dlg; + + void on_key_selected(QByteArrayView key_id, QStringView key_name); +}; + +void start_auth_flow(AOApplication *ao_app, QString username); diff --git a/src/courtroom.cpp b/src/courtroom.cpp index 1eaa747..a2dca24 100644 --- a/src/courtroom.cpp +++ b/src/courtroom.cpp @@ -5118,6 +5118,19 @@ void Courtroom::on_ooc_return_pressed() ui_ooc_chat_message->clear(); return; } + // Only works on CSDWASASH. + else if (ooc_message.startsWith("/auth")) + { + QStringList command = ooc_message.split(" ", Qt::SkipEmptyParts); + if (command.size() != 2) + { + append_server_chatmessage("CLIENT", "Usage: /auth ", "1"); + return; + } + start_auth_flow(ao_app, command[1]); + ui_ooc_chat_message->clear(); + return; + } QStringList packet_contents; packet_contents.append(ui_ooc_chat_name->text()); diff --git a/src/courtroom.h b/src/courtroom.h index df8f30c..4d32ab4 100644 --- a/src/courtroom.h +++ b/src/courtroom.h @@ -16,6 +16,7 @@ #include "aosfxplayer.h" #include "aotextarea.h" #include "aotextboxwidgets.h" +#include "auth_flow.h" #include "chatlogpiece.h" #include "datatypes.h" #include "debug_functions.h" diff --git a/src/main.cpp b/src/main.cpp index e998eed..0bc4e80 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -104,6 +104,7 @@ int main(int argc, char *argv[]) return EXIT_FAILURE; } main_app.keyring_model.load_keys(); + main_app.saved_auth.load(); main_app.construct_lobby(); main_app.net_manager->get_server_list(); diff --git a/src/packet_distribution.cpp b/src/packet_distribution.cpp index 2ff35ce..90a6758 100644 --- a/src/packet_distribution.cpp +++ b/src/packet_distribution.cpp @@ -133,6 +133,7 @@ void AOApplication::server_packet_received(AOPacket packet) server_name = info.name; server_address = QString("%1:%2").arg(info.address, QString::number(info.port)); window_title = server_name; + m_serverdata.m_server_hostname = info.address; } break; @@ -145,6 +146,7 @@ void AOApplication::server_packet_received(AOPacket packet) server_name = info.name; server_address = QString("%1:%2").arg(info.address, QString::number(info.port)); window_title = server_name; + m_serverdata.m_server_hostname = info.address; } } break; diff --git a/src/saved_auth.cpp b/src/saved_auth.cpp new file mode 100644 index 0000000..aa1c696 --- /dev/null +++ b/src/saved_auth.cpp @@ -0,0 +1,124 @@ +#include "saved_auth.h" + +#include +#include +#include +#include + +#include "file_functions.h" + +// I wish I could use INI for settings to let users edit it manually if they +// wanted to, like ~/.ssh/config. Unfortunately, due to adherence to idiotic +// conventions, INI keys are case-insensitive on Windows, while the usernames +// aren't. Rather than defining my own stupid encoding to work around this +// issue (thus ruining convenience I originally strived for), I'll ditch INI +// altogether. I could use CBOR for consistency, but I'm using JSON because +// it's supposed to be manually editable, and I don't want to complicate +// things by creating a new settings format. +bool SavedAuth::load() +{ + QFile saved_auth_file(QDir(get_app_path()).filePath("saved_auth.json")); + if (!saved_auth_file.open(QIODevice::ReadOnly)) + { + return false; + } + QByteArray raw_data = saved_auth_file.readAll(); + saved_auth_file.close(); + + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(raw_data, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) + { + return false; + } + + m_table.clear(); + QJsonObject root = doc.object(); + for (auto host_it = root.begin(); host_it != root.end(); ++host_it) + { + if (!host_it.value().isObject()) + { + continue; + } + + QByteArray host = host_it.key().toUtf8(); + QJsonObject users = host_it.value().toObject(); + + for (auto user_it = users.begin(); user_it != users.end(); ++user_it) + { + if (!user_it.value().isString()) + { + continue; + } + + QByteArray user = user_it.key().toUtf8(); + QByteArray saved_key = QByteArray::fromBase64(user_it.value().toString().toUtf8()); + + m_table[host + '\0' + user] = saved_key; + } + } + + return true; +} + +QByteArray SavedAuth::lookup(QByteArrayView host, QByteArrayView user) const +{ + return m_table.value(flatten_key(host, user)); +} + +void SavedAuth::insert(QByteArrayView host, QByteArrayView user, QByteArrayView saved_key) +{ + m_table[flatten_key(host, user)] = QByteArray(saved_key); + save(); +} + +void SavedAuth::remove(QByteArrayView host, QByteArrayView user) +{ + m_table.remove(flatten_key(host, user)); + save(); +} + +bool SavedAuth::save() const +{ + QJsonObject root; + + for (auto it = m_table.begin(); it != m_table.end(); ++it) + { + const QByteArray &flat = it.key(); + const QByteArray &key_id = it.value(); + + int sep = flat.indexOf('\0'); + if (sep < 0) + { + continue; + } + + QString host = QString::fromUtf8(flat.left(sep)); + QString user = QString::fromUtf8(flat.mid(sep + 1)); + + QJsonObject host_obj = root.value(host).toObject(); + + host_obj[user] = QString::fromUtf8(key_id.toBase64(QByteArray::OmitTrailingEquals)); + + root[host] = host_obj; + } + + QJsonDocument doc(root); + + QFile f(QDir(get_app_path()).filePath("saved_auth.json")); + if (!f.open(QIODevice::WriteOnly)) + return false; + + f.write(doc.toJson(QJsonDocument::Indented)); + return true; +} + +QByteArray SavedAuth::flatten_key(QByteArrayView host, QByteArrayView user) const +{ + QByteArray key; + key.reserve(host.size() + 1 + user.size()); + key.append(host); + key.append('\0'); + key.append(user); + return key; +} diff --git a/src/saved_auth.h b/src/saved_auth.h new file mode 100644 index 0000000..d37a8fd --- /dev/null +++ b/src/saved_auth.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +class SavedAuth +{ +public: + bool load(); + QByteArray lookup(QByteArrayView host, QByteArrayView user) const; + void insert(QByteArrayView host, QByteArrayView user, QByteArrayView key); + void remove(QByteArrayView host, QByteArrayView user); + +private: + QHash m_table; + bool save() const; + QByteArray flatten_key(QByteArrayView host, QByteArrayView user) const; +}; diff --git a/src/serverdata.h b/src/serverdata.h index 9935185..4f4e572 100644 --- a/src/serverdata.h +++ b/src/serverdata.h @@ -134,6 +134,9 @@ public: */ void set_asset_url(const QString &f_asset_url); + // There's no need for accessors here. + QString m_server_hostname; + private: /// The features available on the server. Determines what QStringList m_features; -- cgit