diff --git a/.clang-tidy b/.clang-tidy index 1da445c..41de1fd 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -14,6 +14,7 @@ Checks: > -cppcoreguidelines-avoid-const-or-ref-data-members, -cppcoreguidelines-non-private-member-variables-in-classes, -cppcoreguidelines-avoid-goto, + -cppcoreguidelines-pro-bounds-array-to-pointer-decay, google-build-using-namespace. google-explicit-constructor, google-global-names-in-headers, diff --git a/CMakeLists.txt b/CMakeLists.txt index 7af6b6c..606256b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,7 @@ option(HYPRLAND_FOCUS_GRAB "Hyprland Focus Grabbing" ON) option(SERVICE_STATUS_NOTIFIER "StatusNotifierItem service" ON) option(SERVICE_PIPEWIRE "PipeWire service" ON) option(SERVICE_MPRIS "Mpris service" ON) +option(SERVICE_PAM "Pam service" ON) message(STATUS "Quickshell configuration") message(STATUS " Jemalloc: ${USE_JEMALLOC}") @@ -39,6 +40,7 @@ message(STATUS " Services") message(STATUS " StatusNotifier: ${SERVICE_STATUS_NOTIFIER}") message(STATUS " PipeWire: ${SERVICE_PIPEWIRE}") message(STATUS " Mpris: ${SERVICE_MPRIS}") +message(STATUS " Pam: ${SERVICE_PAM}") message(STATUS " Hyprland: ${HYPRLAND}") if (HYPRLAND) message(STATUS " IPC: ${HYPRLAND_IPC}") diff --git a/README.md b/README.md index 4def09e..bf494c3 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ quickshell.packages..default.override { withWayland = true; withX11 = true; withPipewire = true; + withPam = true; withHyprland = true; } ``` diff --git a/default.nix b/default.nix index 01624c4..e77109f 100644 --- a/default.nix +++ b/default.nix @@ -13,6 +13,7 @@ wayland-protocols, xorg, pipewire, + pam, gitRev ? (let headExists = builtins.pathExists ./.git/HEAD; @@ -31,6 +32,7 @@ withWayland ? true, withX11 ? true, withPipewire ? true, + withPam ? true, withHyprland ? true, }: buildStdenv.mkDerivation { pname = "quickshell${lib.optionalString debug "-debug"}"; @@ -55,6 +57,7 @@ ++ (lib.optional withQtSvg qt6.qtsvg) ++ (lib.optionals withWayland [ qt6.qtwayland wayland ]) ++ (lib.optional withX11 xorg.libxcb) + ++ (lib.optional withPam pam) ++ (lib.optional withPipewire pipewire); QTWAYLANDSCANNER = lib.optionalString withWayland "${qt6.qtwayland}/libexec/qtwaylandscanner"; @@ -74,6 +77,7 @@ ++ lib.optional (!withJemalloc) "-DUSE_JEMALLOC=OFF" ++ lib.optional (!withWayland) "-DWAYLAND=OFF" ++ lib.optional (!withPipewire) "-DSERVICE_PIPEWIRE=OFF" + ++ lib.optional (!withPam) "-DSERVICE_PAM=OFF" ++ lib.optional (!withHyprland) "-DHYPRLAND=OFF"; buildPhase = "ninjaBuildPhase"; diff --git a/src/services/CMakeLists.txt b/src/services/CMakeLists.txt index 4915762..e8c05f4 100644 --- a/src/services/CMakeLists.txt +++ b/src/services/CMakeLists.txt @@ -9,3 +9,7 @@ endif() if (SERVICE_MPRIS) add_subdirectory(mpris) endif() + +if (SERVICE_PAM) + add_subdirectory(pam) +endif() diff --git a/src/services/pam/CMakeLists.txt b/src/services/pam/CMakeLists.txt new file mode 100644 index 0000000..1a7b29b --- /dev/null +++ b/src/services/pam/CMakeLists.txt @@ -0,0 +1,17 @@ +#find_package(PAM REQUIRED) + +qt_add_library(quickshell-service-pam STATIC + qml.cpp + conversation.cpp +) +qt_add_qml_module(quickshell-service-pam + URI Quickshell.Services.Pam + VERSION 0.1 +) + +target_link_libraries(quickshell-service-pam PRIVATE ${QT_DEPS} pam ${PAM_LIBRARIES}) + +qs_pch(quickshell-service-pam) +qs_pch(quickshell-service-pamplugin) + +target_link_libraries(quickshell PRIVATE quickshell-service-pamplugin) diff --git a/src/services/pam/conversation.cpp b/src/services/pam/conversation.cpp new file mode 100644 index 0000000..d73c426 --- /dev/null +++ b/src/services/pam/conversation.cpp @@ -0,0 +1,185 @@ +#include "conversation.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(logPam, "quickshell.service.pam", QtWarningMsg); + +QString PamError::toString(PamError::Enum value) { + switch (value) { + case ConnectionFailed: return "Failed to connect to pam"; + case TryAuthFailed: return "Failed to try authenticating"; + default: return "Invalid error"; + } +} + +QString PamResult::toString(PamResult::Enum value) { + switch (value) { + case Success: return "Success"; + case Failed: return "Failed"; + case Error: return "Error occurred while authenticating"; + case MaxTries: return "The authentication method has no more attempts available"; + // case Expired: return "The account has expired"; + // case PermissionDenied: return "Permission denied"; + default: return "Invalid result"; + } +} + +void PamConversation::run() { + auto conv = pam_conv { + .conv = &PamConversation::conversation, + .appdata_ptr = this, + }; + + pam_handle_t* handle = nullptr; + + qCInfo(logPam) << this << "Starting pam session for user" << this->user << "with config" + << this->config << "in configdir" << this->configDir; + + auto result = pam_start_confdir( + this->config.toStdString().c_str(), + this->user.toStdString().c_str(), + &conv, + this->configDir.toStdString().c_str(), + &handle + ); + + if (result != PAM_SUCCESS) { + qCCritical(logPam) << this << "Unable to start pam conversation with error" + << QString(pam_strerror(handle, result)); + emit this->error(PamError::ConnectionFailed); + this->deleteLater(); + return; + } + + result = pam_authenticate(handle, 0); + + // Seems to require root and quickshell should not run as root. + // if (result == PAM_SUCCESS) { + // result = pam_acct_mgmt(handle, 0); + // } + + switch (result) { + case PAM_SUCCESS: + qCInfo(logPam) << this << "ended with successful authentication."; + emit this->completed(PamResult::Success); + break; + case PAM_AUTH_ERR: + qCInfo(logPam) << this << "ended with failed authentication."; + emit this->completed(PamResult::Failed); + break; + case PAM_MAXTRIES: + qCInfo(logPam) << this << "ended with failure: max tries."; + emit this->completed(PamResult::MaxTries); + break; + /*case PAM_ACCT_EXPIRED: + qCInfo(logPam) << this << "ended with failure: account expiration."; + emit this->completed(PamResult::Expired); + break; + case PAM_PERM_DENIED: + qCInfo(logPam) << this << "ended with failure: permission denied."; + emit this->completed(PamResult::PermissionDenied); + break;*/ + default: + qCCritical(logPam) << this << "ended with error:" << QString(pam_strerror(handle, result)); + emit this->error(PamError::TryAuthFailed); + break; + } + + result = pam_end(handle, result); + if (result != PAM_SUCCESS) { + qCCritical(logPam) << this << "Failed to end pam conversation with error code" + << QString(pam_strerror(handle, result)); + } + + this->deleteLater(); +} + +void PamConversation::abort() { + qCDebug(logPam) << "Abort requested for" << this; + auto locker = QMutexLocker(&this->wakeMutex); + this->mAbort = true; + this->waker.wakeOne(); +} + +void PamConversation::respond(QString response) { + qCDebug(logPam) << "Set response for" << this; + auto locker = QMutexLocker(&this->wakeMutex); + this->response = std::move(response); + this->hasResponse = true; + this->waker.wakeOne(); +} + +int PamConversation::conversation( + int msgCount, + const pam_message** msgArray, + pam_response** responseArray, + void* appdata +) { + auto* delegate = static_cast(appdata); + + { + auto locker = QMutexLocker(&delegate->wakeMutex); + if (delegate->mAbort) { + return PAM_ERROR_MSG; + } + } + + // freed by libc so must be alloc'd by it. + auto* responses = static_cast(calloc(msgCount, sizeof(pam_response))); // NOLINT + + for (auto i = 0; i < msgCount; i++) { + const auto* message = msgArray[i]; // NOLINT + auto& response = responses[i]; // NOLINT + + auto msgString = QString(message->msg); + auto messageChanged = true; // message->msg_style != PAM_PROMPT_ECHO_OFF; + auto isError = message->msg_style == PAM_ERROR_MSG; + auto responseRequired = + message->msg_style == PAM_PROMPT_ECHO_OFF || message->msg_style == PAM_PROMPT_ECHO_ON; + + qCDebug(logPam) << delegate << "got new message message:" << msgString + << "messageChanged:" << messageChanged << "isError:" << isError + << "responseRequired" << responseRequired; + + delegate->hasResponse = false; + emit delegate->message(msgString, messageChanged, isError, responseRequired); + + { + auto locker = QMutexLocker(&delegate->wakeMutex); + + if (delegate->mAbort) { + free(responses); // NOLINT + return PAM_ERROR_MSG; + } + + if (responseRequired) { + if (!delegate->hasResponse) { + delegate->waker.wait(locker.mutex()); + + if (delegate->mAbort) { + free(responses); // NOLINT + return PAM_ERROR_MSG; + } + } + + if (!delegate->hasResponse) { + qCCritical(logPam + ) << "Pam conversation requires response and does not have one. This should not happen."; + } + + response.resp = strdup(delegate->response.toStdString().c_str()); // NOLINT (include error) + } + } + } + + *responseArray = responses; + return PAM_SUCCESS; +} diff --git a/src/services/pam/conversation.hpp b/src/services/pam/conversation.hpp new file mode 100644 index 0000000..ff58980 --- /dev/null +++ b/src/services/pam/conversation.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +/// The result of an authentication. +class PamResult: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum { + /// Authentication was successful. + Success = 0, + /// Authentication failed. + Failed = 1, + /// An error occurred while trying to authenticate. + Error = 2, + /// The authentication method ran out of tries and should not be used again. + MaxTries = 3, + // The account has expired. + // Expired 4, + // Permission denied. + // PermissionDenied 5, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(PamResult::Enum value); +}; + +/// An error that occurred during an authentication. +class PamError: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum Enum { + /// Failed to initiate the pam connection. + ConnectionFailed = 1, + /// Failed to try to authenticate the user. + /// This is not the same as the user failing to authenticate. + TryAuthFailed = 2, + }; + Q_ENUM(Enum); + + Q_INVOKABLE static QString toString(PamError::Enum value); +}; + +class PamConversation: public QThread { + Q_OBJECT; + +public: + explicit PamConversation(QString config, QString configDir, QString user) + : config(std::move(config)) + , configDir(std::move(configDir)) + , user(std::move(user)) {} + +public: + void run() override; + + void abort(); + void respond(QString response); + +signals: + void completed(PamResult::Enum result); + void error(PamError::Enum error); + void message(QString message, bool messageChanged, bool isError, bool responseRequired); + +private: + static int conversation( + int msgCount, + const pam_message** msgArray, + pam_response** responseArray, + void* appdata + ); + + QString config; + QString configDir; + QString user; + + QMutex wakeMutex; + QWaitCondition waker; + bool mAbort = false; + bool hasResponse = false; + QString response; +}; diff --git a/src/services/pam/module.md b/src/services/pam/module.md new file mode 100644 index 0000000..2f99400 --- /dev/null +++ b/src/services/pam/module.md @@ -0,0 +1,67 @@ +name = "Quickshell.Services.Pam" +description = "Pam authentication" +headers = [ + "qml.hpp", + "conversation.hpp", +] +----- + +## Writing pam configurations + +It is a good idea to write pam configurations specifically for quickshell +if you want to do anything other than match the default login flow. + +A good example of this is having a configuration that allows entering a password +or fingerprint in any order. + +### Structure of a pam configuration. +Pam configuration files are a list of rules, each on a new line in the following form: +``` + [options] +``` + +Each line runs in order. + +PamContext currently only works with the `auth` type, as other types require root +access to check. + +#### Control flags +The control flags you're likely to use are `required` and `sufficient`. +- `required` rules must pass for authentication to succeed. +- `sufficient` rules will bypass any remaining rules and return on success. + +Note that you should have at least one required rule or pam will fail with an undocumented error. + +#### Modules +Pam works with a set of modules that handle various authentication mechanisms. +Some common ones include `pam_unix.so` which handles passwords and `pam_fprintd.so` +which handles fingerprints. + +These modules have options but none are required for basic functionality. + +### Examples + +Authenticate with only a password: +``` +auth required pam_unix.so +``` + +Authenticate with only a fingerprint: +``` +auth required pam_fprintd.so +``` + +Try to authenticate with a fingerprint first, but if that fails fall back to a password: +``` +auth sufficient pam_fprintd.so +auth required pam_unix.so +``` + +Require both a fingerprint and a password: +``` +auth required pam_fprintd.so +auth required pam_unix.so +``` + + +See also: [Oracle: PAM configuration file](https://docs.oracle.com/cd/E19683-01/816-4883/pam-32/index.html) diff --git a/src/services/pam/qml.cpp b/src/services/pam/qml.cpp new file mode 100644 index 0000000..be34410 --- /dev/null +++ b/src/services/pam/qml.cpp @@ -0,0 +1,238 @@ +#include "qml.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "conversation.hpp" + +PamContext::~PamContext() { + if (this->conversation != nullptr && this->conversation->isRunning()) { + this->conversation->abort(); + } +} + +void PamContext::componentComplete() { + this->postInit = true; + + if (this->mTargetActive) { + this->startConversation(); + } +} + +void PamContext::startConversation() { + if (!this->postInit || this->conversation != nullptr) return; + + QString user; + + { + auto configDirInfo = QFileInfo(this->mConfigDirectory); + if (!configDirInfo.isDir()) { + qCritical() << "Cannot start" << this << "because specified config directory" + << this->mConfigDirectory << "is not a directory."; + this->mTargetActive = false; + return; + } + + auto configFilePath = QDir(this->mConfigDirectory).filePath(this->mConfig); + auto configFileInfo = QFileInfo(configFilePath); + if (!configFileInfo.isFile()) { + qCritical() << "Cannot start" << this << "because specified config file" << configFilePath + << "is not a file."; + this->mTargetActive = false; + return; + } + + auto pwuidbufSize = sysconf(_SC_GETPW_R_SIZE_MAX); + if (pwuidbufSize == -1) pwuidbufSize = 8192; + char pwuidbuf[pwuidbufSize]; // NOLINT + + passwd pwuid {}; + passwd* pwuidResult = nullptr; + + if (this->mUser.isEmpty()) { + auto r = getpwuid_r(getuid(), &pwuid, pwuidbuf, pwuidbufSize, &pwuidResult); + if (pwuidResult == nullptr) { + qCritical() << "Cannot start" << this << "due to error in getpwuid_r: " << r; + this->mTargetActive = false; + return; + } + + user = pwuid.pw_name; + } else { + auto r = getpwnam_r( + this->mUser.toStdString().c_str(), + &pwuid, + pwuidbuf, + pwuidbufSize, + &pwuidResult + ); + + if (pwuidResult == nullptr) { + if (r == 0) { + qCritical() << "Cannot start" << this + << "because specified user was not found: " << this->mUser; + } else { + qCritical() << "Cannot start" << this << "due to error in getpwnam_r: " << r; + } + + this->mTargetActive = false; + return; + } + + user = pwuid.pw_name; + } + } + + this->conversation = new PamConversation(this->mConfig, this->mConfigDirectory, user); + QObject::connect(this->conversation, &PamConversation::completed, this, &PamContext::onCompleted); + QObject::connect(this->conversation, &PamConversation::error, this, &PamContext::onError); + QObject::connect(this->conversation, &PamConversation::message, this, &PamContext::onMessage); + emit this->activeChanged(); + this->conversation->start(); +} + +void PamContext::abortConversation() { + if (this->conversation == nullptr) return; + this->mTargetActive = false; + + QObject::disconnect(this->conversation, nullptr, this, nullptr); + if (this->conversation->isRunning()) this->conversation->abort(); + this->conversation = nullptr; + emit this->activeChanged(); + + if (!this->mMessage.isEmpty()) { + this->mMessage.clear(); + emit this->messageChanged(); + } + + if (this->mMessageIsError) { + this->mMessageIsError = false; + emit this->messageIsErrorChanged(); + } + + if (this->mIsResponseRequired) { + this->mIsResponseRequired = false; + emit this->responseRequiredChanged(); + } +} + +void PamContext::respond(QString response) { + if (this->isActive() && this->mIsResponseRequired) { + this->conversation->respond(std::move(response)); + } else { + qWarning() << "PamContext response was ignored as this context does not require one."; + } +} + +bool PamContext::start() { + this->setActive(true); + return this->isActive(); +} + +void PamContext::abort() { this->setActive(false); } + +bool PamContext::isActive() const { return this->conversation != nullptr; } + +void PamContext::setActive(bool active) { + if (active == this->mTargetActive) return; + this->mTargetActive = active; + + if (active) this->startConversation(); + else this->abortConversation(); +} + +QString PamContext::config() const { return this->mConfig; } + +void PamContext::setConfig(QString config) { + if (config == this->mConfig) return; + + if (this->isActive()) { + qCritical() << "Cannot set config on PamContext while it is active."; + return; + } + + this->mConfig = std::move(config); + emit this->configChanged(); +} + +QString PamContext::configDirectory() const { return this->mConfigDirectory; } + +void PamContext::setConfigDirectory(QString configDirectory) { + if (configDirectory == this->mConfigDirectory) return; + + if (this->isActive()) { + qCritical() << "Cannot set configDirectory on PamContext while it is active."; + return; + } + + auto* context = QQmlEngine::contextForObject(this); + if (context != nullptr) { + configDirectory = context->resolvedUrl(configDirectory).path(); + } + + this->mConfigDirectory = std::move(configDirectory); + emit this->configDirectoryChanged(); +} + +QString PamContext::user() const { return this->mUser; } + +void PamContext::setUser(QString user) { + if (user == this->mUser) return; + + if (this->isActive()) { + qCritical() << "Cannot set user on PamContext while it is active."; + return; + } + + this->mUser = std::move(user); + emit this->userChanged(); +} + +QString PamContext::message() const { return this->mMessage; } +bool PamContext::messageIsError() const { return this->mMessageIsError; } +bool PamContext::isResponseRequired() const { return this->mIsResponseRequired; } + +void PamContext::onCompleted(PamResult::Enum result) { + emit this->completed(result); + this->abortConversation(); +} + +void PamContext::onError(PamError::Enum error) { + emit this->error(error); + emit this->completed(PamResult::Error); + this->abortConversation(); +} + +void PamContext::onMessage( + QString message, + bool messageChanged, + bool isError, + bool responseRequired +) { + if (messageChanged) { + if (message != this->mMessage) { + this->mMessage = std::move(message); + emit this->messageChanged(); + } + + if (isError != this->mMessageIsError) { + this->mMessageIsError = isError; + emit this->messageIsErrorChanged(); + } + } + + if (responseRequired != this->mIsResponseRequired) { + this->mIsResponseRequired = responseRequired; + emit this->responseRequiredChanged(); + } + + emit this->pamMessage(); +} diff --git a/src/services/pam/qml.hpp b/src/services/pam/qml.hpp new file mode 100644 index 0000000..6f9df4d --- /dev/null +++ b/src/services/pam/qml.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "conversation.hpp" + +///! Connection to pam. +/// Connection to pam. See [the module documentation](../) for pam configuration advice. +class PamContext + : public QObject + , public QQmlParserStatus { + Q_OBJECT; + // clang-format off + /// If the pam context is actively performing an authentication. + /// + /// Setting this value behaves exactly the same as calling `start()` and `abort()`. + Q_PROPERTY(bool active READ isActive WRITE setActive NOTIFY activeChanged); + /// The pam configuration to use. Defaults to "login". + /// + /// The configuration should name a file inside `configDirectory`. + /// + /// This property may not be set while `active` is true. + Q_PROPERTY(QString config READ config WRITE setConfig NOTIFY configChanged); + /// The pam configuration directory to use. Defaults to "/etc/pam.d". + /// + /// The configuration directory is resolved relative to the current file if not an absolute path. + /// + /// This property may not be set while `active` is true. + Q_PROPERTY(QString configDirectory READ configDirectory WRITE setConfigDirectory NOTIFY configDirectoryChanged); + /// The user to authenticate as. If unset the current user will be used. + /// + /// This property may not be set while `active` is true. + Q_PROPERTY(QString user READ user WRITE setUser NOTIFY userChanged); + /// The last message sent by pam. + Q_PROPERTY(QString message READ message NOTIFY messageChanged); + /// If the last message should be shown as an error. + Q_PROPERTY(bool messageIsError READ messageIsError NOTIFY messageIsErrorChanged); + /// If pam currently wants a response. + /// + /// Responses can be returned with the `respond()` function. + Q_PROPERTY(bool responseRequired READ isResponseRequired NOTIFY responseRequiredChanged); + // clang-format on + QML_ELEMENT; + +public: + explicit PamContext(QObject* parent = nullptr): QObject(parent) {} + ~PamContext() override; + Q_DISABLE_COPY_MOVE(PamContext); + + void classBegin() override {} + void componentComplete() override; + + void startConversation(); + void abortConversation(); + + /// Start an authentication session. Returns if the session was started successfully. + Q_INVOKABLE bool start(); + + /// Abort a running authentication session. + Q_INVOKABLE void abort(); + + /// Respond to pam. + /// + /// May not be called unless `responseRequired` is true. + Q_INVOKABLE void respond(QString response); + + [[nodiscard]] bool isActive() const; + void setActive(bool active); + + [[nodiscard]] QString config() const; + void setConfig(QString config); + + [[nodiscard]] QString configDirectory() const; + void setConfigDirectory(QString configDirectory); + + [[nodiscard]] QString user() const; + void setUser(QString user); + + [[nodiscard]] QString message() const; + [[nodiscard]] bool messageIsError() const; + [[nodiscard]] bool isResponseRequired() const; + +signals: + /// Emitted whenever authentication completes. + void completed(PamResult::Enum result); + /// Emitted if pam fails to perform authentication normally. + /// + /// A `completed(false)` will be emitted after this event. + void error(PamError::Enum error); + + /// Emitted whenever pam sends a new message, after the change signals for + /// `message`, `messageIsError`, and `responseRequired`. + void pamMessage(); + + void activeChanged(); + void configChanged(); + void configDirectoryChanged(); + void userChanged(); + void messageChanged(); + void messageIsErrorChanged(); + void responseRequiredChanged(); + +private slots: + void onCompleted(PamResult::Enum result); + void onError(PamError::Enum error); + void onMessage(QString message, bool messageChanged, bool isError, bool responseRequired); + +private: + PamConversation* conversation = nullptr; + + bool postInit = false; + bool mTargetActive = false; + QString mConfig = "login"; + QString mConfigDirectory = "/etc/pam.d"; + QString mUser; + QString mMessage; + bool mMessageIsError = false; + bool mIsResponseRequired = false; +};