service/pam: add pam service

This commit is contained in:
outfoxxed 2024-06-17 18:32:13 -07:00
parent f655875547
commit 7e5d128a91
Signed by: outfoxxed
GPG Key ID: 4C88A185FB89301E
11 changed files with 740 additions and 0 deletions

View File

@ -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,

View File

@ -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}")

View File

@ -45,6 +45,7 @@ quickshell.packages.<system>.default.override {
withWayland = true;
withX11 = true;
withPipewire = true;
withPam = true;
withHyprland = true;
}
```

View File

@ -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";

View File

@ -9,3 +9,7 @@ endif()
if (SERVICE_MPRIS)
add_subdirectory(mpris)
endif()
if (SERVICE_PAM)
add_subdirectory(pam)
endif()

View File

@ -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)

View File

@ -0,0 +1,185 @@
#include "conversation.hpp"
#include <utility>
#include <qlogging.h>
#include <qloggingcategory.h>
#include <qmutex.h>
#include <qobject.h>
#include <qstring.h>
#include <qtmetamacros.h>
#include <security/_pam_types.h>
#include <security/pam_appl.h>
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<PamConversation*>(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<pam_response*>(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;
}

View File

@ -0,0 +1,95 @@
#pragma once
#include <utility>
#include <qmutex.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qthread.h>
#include <qtmetamacros.h>
#include <qwaitcondition.h>
#include <security/pam_appl.h>
/// 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;
};

View File

@ -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:
```
<type> <control_flag> <module_path> [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)

238
src/services/pam/qml.cpp Normal file
View File

@ -0,0 +1,238 @@
#include "qml.hpp"
#include <utility>
#include <pwd.h>
#include <qdir.h>
#include <qfileinfo.h>
#include <qlogging.h>
#include <qobject.h>
#include <qqmlcontext.h>
#include <qqmlengine.h>
#include <qtmetamacros.h>
#include <unistd.h>
#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();
}

126
src/services/pam/qml.hpp Normal file
View File

@ -0,0 +1,126 @@
#pragma once
#include <qobject.h>
#include <qqmlintegration.h>
#include <qqmlparserstatus.h>
#include <qtclasshelpermacros.h>
#include <qthread.h>
#include <qtmetamacros.h>
#include <security/_pam_types.h>
#include <security/pam_appl.h>
#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;
};