diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index bf4ff1bc..fb4d50c6 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -505,6 +505,7 @@ Singleton { property bool enableFprint: false property int maxFprintTries: 15 + property bool lockFaillockSupported: false property bool fprintdAvailable: false property bool lockFingerprintCanEnable: false property bool lockFingerprintReady: false diff --git a/quickshell/Common/settings/Processes.qml b/quickshell/Common/settings/Processes.qml index a80cf18c..d42378ba 100644 --- a/quickshell/Common/settings/Processes.qml +++ b/quickshell/Common/settings/Processes.qml @@ -33,6 +33,7 @@ Singleton { property int pamSupportProbeExitCode: 0 property bool pamFprintSupportDetected: false property bool pamU2fSupportDetected: false + property bool pamFaillockSupportDetected: false readonly property string homeDir: Quickshell.env("HOME") || "" readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : "" @@ -70,14 +71,13 @@ Singleton { fingerprintProbeState = forcedFprintAvailable ? "ready" : "probe_failed"; } - if (forcedFprintAvailable === null || forcedU2fAvailable === null) { - pamFprintSupportDetected = false; - pamU2fSupportDetected = false; - pamSupportProbeOutput = ""; - pamSupportProbeStreamFinished = false; - pamSupportProbeExited = false; - pamSupportDetectionProcess.running = true; - } + pamFprintSupportDetected = false; + pamU2fSupportDetected = false; + pamFaillockSupportDetected = false; + pamSupportProbeOutput = ""; + pamSupportProbeStreamFinished = false; + pamSupportProbeExited = false; + pamSupportDetectionProcess.running = true; recomputeAuthCapabilities(); } @@ -321,6 +321,7 @@ Singleton { return; recomputeFingerprintCapabilities(); recomputeU2fCapabilities(); + settingsRoot.lockFaillockSupported = pamFaillockSupportDetected; settingsRoot.fprintdAvailable = settingsRoot.lockFingerprintReady || settingsRoot.greeterFingerprintReady; settingsRoot.u2fAvailable = settingsRoot.lockU2fReady || settingsRoot.greeterU2fReady; } @@ -338,6 +339,7 @@ Singleton { pamFprintSupportDetected = false; pamU2fSupportDetected = false; + pamFaillockSupportDetected = false; const lines = (pamSupportProbeOutput || "").trim().split(/\r?\n/); for (let i = 0; i < lines.length; i++) { @@ -348,6 +350,8 @@ Singleton { pamFprintSupportDetected = parts[1] === "true"; else if (parts[0] === "pam_u2f.so") pamU2fSupportDetected = parts[1] === "true"; + else if (parts[0] === "pam_faillock.so") + pamFaillockSupportDetected = parts[1] === "true"; } if (forcedFprintAvailable === null && fingerprintProbeState === "missing_pam_support") @@ -401,7 +405,7 @@ Singleton { } property var pamSupportDetectionProcess: Process { - command: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"] + command: ["sh", "-c", "for module in pam_fprintd.so pam_u2f.so pam_faillock.so; do found=false; for dir in /usr/lib64/security /usr/lib/security /lib/security /lib/x86_64-linux-gnu/security /usr/lib/x86_64-linux-gnu/security /usr/lib/aarch64-linux-gnu/security /run/current-system/sw/lib/security; do if [ -f \"$dir/$module\" ]; then found=true; break; fi; done; printf '%s:%s\\n' \"$module\" \"$found\"; done"] running: false stdout: StdioCollector { diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 026d252c..cd3bb1be 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -328,6 +328,7 @@ var SPEC = { lockAtStartup: { def: false }, enableFprint: { def: false }, maxFprintTries: { def: 15 }, + lockFaillockSupported: { def: false, persist: false }, fprintdAvailable: { def: false, persist: false }, lockFingerprintCanEnable: { def: false, persist: false }, lockFingerprintReady: { def: false, persist: false }, diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index 886cbf90..386a3bab 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -39,6 +39,38 @@ Item { lockerReadyArmed = true; unlocking = false; pamState = ""; + if (pam) + pam.lockMessage = ""; + } + + function currentAuthFeedbackText() { + if (!pam) + return ""; + if (pam.u2fState === "insert" && !pam.u2fPending) + return I18n.tr("Insert your security key..."); + if (pam.u2fState === "waiting" && !pam.u2fPending) + return I18n.tr("Touch your security key..."); + if (pam.lockMessage && pam.lockMessage.length > 0) + return pam.lockMessage; + if (pam.fprintState === "error") { + const detail = (pam.fprint.message || "").trim(); + return detail.length > 0 ? I18n.tr("Fingerprint error: %1").arg(detail) : I18n.tr("Fingerprint error"); + } + if (pam.fprintState === "max") + return I18n.tr("Maximum fingerprint attempts reached. Please use password."); + if (pam.fprintState === "fail") + return I18n.tr("Fingerprint not recognized (%1/%2). Please try again or use password.").arg(pam.fprint.tries).arg(SettingsData.maxFprintTries); + if (root.pamState === "error") + return I18n.tr("Authentication error - try again"); + if (root.pamState === "max") + return I18n.tr("Too many attempts - locked out"); + if (root.pamState === "fail") + return I18n.tr("Incorrect password - try again"); + return ""; + } + + function authFeedbackIsHint() { + return pam && (pam.u2fState === "waiting" || pam.u2fState === "insert") && !pam.u2fPending; } Component.onCompleted: { @@ -1025,24 +1057,18 @@ Item { } StyledText { + id: authFeedbackText + Layout.fillWidth: true - Layout.preferredHeight: 20 - text: { - if (root.pamState === "error") { - return "Authentication error - try again"; - } - if (root.pamState === "max") { - return "Too many attempts - locked out"; - } - if (root.pamState === "fail") { - return "Incorrect password - try again"; - } - return ""; - } - color: Theme.error + Layout.preferredHeight: text.length > 0 ? Math.min(implicitHeight, Math.ceil(Theme.fontSizeSmall * 4.5)) : 0 + text: root.currentAuthFeedbackText() + color: root.authFeedbackIsHint() ? Theme.outline : Theme.error font.pixelSize: Theme.fontSizeSmall horizontalAlignment: Text.AlignHCenter - opacity: root.pamState !== "" ? 1 : 0 + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + opacity: text.length > 0 ? 1 : 0 Behavior on opacity { NumberAnimation { diff --git a/quickshell/Modules/Lock/Pam.qml b/quickshell/Modules/Lock/Pam.qml index 7f79d476..af36d693 100644 --- a/quickshell/Modules/Lock/Pam.qml +++ b/quickshell/Modules/Lock/Pam.qml @@ -31,14 +31,14 @@ Scope { u2fPendingTimeout.running = false; passwdActiveTimeout.running = false; unlockRequestTimeout.running = false; - u2fPending = false; - u2fState = ""; - unlockInProgress = false; + root.u2fPending = false; + root.u2fState = ""; + root.unlockInProgress = false; } function recoverFromAuthStall(newState: string): void { resetAuthFlows(); - state = newState; + root.state = newState; flashMsg(); stateReset.restart(); fprint.checkAvail(); @@ -46,16 +46,16 @@ Scope { } function completeUnlock(): void { - if (!unlockInProgress) { - unlockInProgress = true; + if (!root.unlockInProgress) { + root.unlockInProgress = true; passwd.abort(); fprint.abort(); u2f.abort(); errorRetry.running = false; u2fErrorRetry.running = false; u2fPendingTimeout.running = false; - u2fPending = false; - u2fState = ""; + root.u2fPending = false; + root.u2fState = ""; unlockRequestTimeout.restart(); unlockRequested(); } @@ -70,13 +70,13 @@ Scope { } function cancelU2fPending(): void { - if (!u2fPending) + if (!root.u2fPending) return; u2f.abort(); u2fErrorRetry.running = false; u2fPendingTimeout.running = false; - u2fPending = false; - u2fState = ""; + root.u2fPending = false; + root.u2fState = ""; fprint.checkAvail(); } @@ -87,6 +87,13 @@ Scope { printErrors: false } + FileView { + id: loginConfigWatcher + + path: "/etc/pam.d/login" + printErrors: false + } + FileView { id: u2fConfigWatcher @@ -94,17 +101,22 @@ Scope { printErrors: false } + readonly property string bundledPasswdConfig: SettingsData.lockFaillockSupported ? "login-faillock" : "login" + PamContext { id: passwd - config: dankshellConfigWatcher.loaded ? "dankshell" : "login" - configDirectory: dankshellConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" + config: dankshellConfigWatcher.loaded ? "dankshell" : root.bundledPasswdConfig + configDirectory: (dankshellConfigWatcher.loaded || loginConfigWatcher.loaded) ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" onMessageChanged: { - if (message.startsWith("The account is locked")) + if (message.startsWith("The account is locked")) { root.lockMessage = message; - else if (root.lockMessage && message.endsWith(" left to unlock)")) + } else if (root.lockMessage && message.endsWith(" left to unlock)")) { root.lockMessage += "\n" + message; + } else if (root.lockMessage && message && message.length > 0) { + root.lockMessage = ""; + } } onResponseRequiredChanged: { diff --git a/quickshell/assets/pam/login-faillock b/quickshell/assets/pam/login-faillock new file mode 100644 index 00000000..04fc949d --- /dev/null +++ b/quickshell/assets/pam/login-faillock @@ -0,0 +1,7 @@ +#%PAM-1.0 +auth required pam_env.so +auth required pam_faillock.so preauth +auth [success=1 default=bad] pam_unix.so try_first_pass nullok +auth [default=die] pam_faillock.so authfail +auth required pam_faillock.so authsucc +account required pam_unix.so