Skip to content

Commit

Permalink
feat: LoginScreen biometrics integration using Keychain
Browse files Browse the repository at this point in the history
Required for #17085
  • Loading branch information
micieslak committed Feb 5, 2025
1 parent 21872d5 commit d9acd9b
Show file tree
Hide file tree
Showing 13 changed files with 232 additions and 114 deletions.
14 changes: 4 additions & 10 deletions storybook/pages/LoginScreenPage.qml
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ SplitView {

// password signals
signal accountLoginError(string error, bool wrongPassword)

// biometrics signals
signal obtainingPasswordSuccess(string password)
signal obtainingPasswordError(string errorDescription, string errorType /* Constants.keychain.errorType.* */, bool wrongFingerprint)
}

LoginScreen {
Expand All @@ -72,7 +68,9 @@ SplitView {

biometricsAvailable: ctrlBiometrics.checked
isBiometricsLogin: localAccountSettings.storeToKeychainValue === Constants.keychain.storedValue.store
onBiometricsRequested: biometricsPopup.open()

fetchSecretViaBiometrics: biometricsPopup.getHandlerFunction()

onLoginRequested: (keyUid, method, data) => {
logs.logEvent("onLoginRequested", ["keyUid", "method", "data"], arguments)

Expand All @@ -92,19 +90,15 @@ SplitView {
id: localAccountSettings
readonly property string storeToKeychainValue: ctrlTouchIdUser.checked ? Constants.keychain.storedValue.store : ""
}
onSelectedProfileKeyIdChanged: biometricsPopup.visible = Qt.binding(() => ctrlBiometrics.checked && ctrlTouchIdUser.checked)
}

BiometricsPopup {
id: biometricsPopup
visible: ctrlBiometrics.checked && ctrlTouchIdUser.checked

x: root.Window.width - width
password: ctrlPassword.text
pin: ctrlPin.text
selectedProfileIsKeycard: loginScreen.selectedProfileIsKeycard
onAccountLoginError: (error, wrongPassword) => driver.accountLoginError(error, wrongPassword)
onObtainingPasswordSuccess: (password) => driver.obtainingPasswordSuccess(password)
onObtainingPasswordError: (errorDescription, errorType, wrongFingerprint) => driver.obtainingPasswordError(errorDescription, errorType, wrongFingerprint)
}

LogsAndControlsPanel {
Expand Down
18 changes: 8 additions & 10 deletions storybook/pages/OnboardingLayoutPage.qml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import Models 1.0

SplitView {
id: root

orientation: Qt.Vertical

Logs { id: logs }
Expand Down Expand Up @@ -144,15 +145,12 @@ SplitView {

// password signals
signal accountLoginError(string error, bool wrongPassword)

// biometrics signals
signal obtainingPasswordSuccess(string password)
signal obtainingPasswordError(string errorDescription, string errorType /* Constants.keychain.errorType.* */, bool wrongFingerprint)
}

biometricsAvailable: ctrlBiometrics.checked
isBiometricsLogin: localAccountSettings.storeToKeychainValue === Constants.keychain.storedValue.store
onBiometricsRequested: biometricsPopup.open()

fetchSecretViaBiometrics: biometricsPopup.getHandlerFunction()

onFinished: (flow, data) => {
console.warn("!!! ONBOARDING FINISHED; flow:", flow, "; data:", JSON.stringify(data))
Expand Down Expand Up @@ -326,14 +324,14 @@ SplitView {

BiometricsPopup {
id: biometricsPopup
visible: onboarding.stack.currentItem instanceof LoginScreen && ctrlBiometrics.checked && ctrlTouchIdUser.checked

x: root.Window.width - width

password: mockDriver.password
pin: mockDriver.pin
selectedProfileIsKeycard: onboarding.stack.currentItem instanceof LoginScreen && onboarding.stack.currentItem.selectedProfileIsKeycard
onAccountLoginError: (error, wrongPassword) => store.accountLoginError(error, wrongPassword)
onObtainingPasswordSuccess: (password) => store.obtainingPasswordSuccess(password)
onObtainingPasswordError: (errorDescription, errorType, wrongFingerprint) => store.obtainingPasswordError(errorDescription, errorType, wrongFingerprint)

selectedProfileIsKeycard: onboarding.stack.currentItem instanceof LoginScreen
&& onboarding.stack.currentItem.selectedProfileIsKeycard
}

Component {
Expand Down
44 changes: 26 additions & 18 deletions storybook/qmlTests/tests/tst_OnboardingLayout.qml
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,16 @@ Item {

// password signals
signal accountLoginError(string error, bool wrongPassword)
}

// biometrics callback functions exposed to simulate biometrics popup
// function (bool aborted, string error, string secret)
property var biometricsCallback

// biometrics signals
signal obtainingPasswordSuccess(string password)
signal obtainingPasswordError(string errorDescription, string errorType /* Constants.keychain.errorType.* */, bool wrongFingerprint)
fetchSecretViaBiometrics: function (profileId, callback) {
biometricsCallback = callback
}

onLoginRequested: (keyUid, method, data) => {
// SIMULATION: emit an error in case of wrong password
if (method === Onboarding.LoginMethod.Password && data.password !== mockDriver.dummyNewPassword) {
Expand Down Expand Up @@ -976,31 +981,33 @@ Item {

const passwordInput = findChild(page, "loginPasswordInput")
verify(!!passwordInput)
tryCompare(passwordInput, "activeFocus", true)
tryCompare(passwordInput, "activeFocus", !data.biometrics)
if (data.biometrics) { // biometrics + password
if (data.password === mockDriver.dummyNewPassword) { // expecting correct fingerprint
// simulate the external biometrics signal
controlUnderTest.onboardingStore.obtainingPasswordSuccess(data.password)
// simulate the external biometrics response
verify(controlUnderTest.biometricsCallback)
controlUnderTest.biometricsCallback(false, "", data.password)

tryCompare(passwordBox, "biometricsSuccessful", true)
tryCompare(passwordBox, "biometricsFailed", false)
tryCompare(passwordBox, "validationError", "")

// this fills the password and submits it, emits the loginRequested() signal below
tryCompare(passwordInput, "text", data.password)
} else { // expecting wrong fingerprint
// simulate the external biometrics signal
controlUnderTest.onboardingStore.obtainingPasswordError("ERROR", Constants.keychain.errorType.keychain, true)
} else { // expecting failed fetching credentials via biometrics
// simulate the external biometrics response
verify(controlUnderTest.biometricsCallback)
controlUnderTest.biometricsCallback(false, "ERROR", "")

tryCompare(passwordBox, "biometricsSuccessful", false)
tryCompare(passwordBox, "biometricsFailed", true)
tryCompare(passwordBox, "validationError", "Fingerprint not recognised. Try entering password instead.")
tryCompare(passwordBox, "validationError", "ERROR")

// this fails and switches to the password method; so just verify we have an error and can enter the pass manually
tryCompare(passwordInput, "hasError", true)
tryCompare(passwordInput, "activeFocus", true)
tryCompare(passwordInput, "text", "")
expectFail(data.tag, "Wrong fingerprint, expected to fail to login")
expectFail(data.tag, "Biometrics failed, expected to fail to login")
}
} else { // manual password
keyClickSequence(data.password)
Expand Down Expand Up @@ -1032,25 +1039,26 @@ Item {

if (data.biometrics) { // biometrics + PIN
if (data.pin === mockDriver.existingPin) { // expecting correct fingerprint
// simulate the external biometrics signal
controlUnderTest.onboardingStore.obtainingPasswordSuccess(data.pin)
// simulate the external biometrics response
verify(controlUnderTest.biometricsCallback)
controlUnderTest.biometricsCallback(false, "", data.pin)

tryCompare(keycardBox, "biometricsSuccessful", true)
tryCompare(keycardBox, "biometricsFailed", false)

// this fills the password and submits it, emits the loginRequested() signal below
tryCompare(pinInput, "pinInput", data.pin)
} else { // expecting wrong fingerprint
// simulate the external biometrics signal
controlUnderTest.onboardingStore.obtainingPasswordError("Fingerprint not recognized",
Constants.keychain.errorType.keychain, true)
} else { // expecting failed fetching credentials via biometrics
// simulate the external biometrics response
verify(controlUnderTest.biometricsCallback)
controlUnderTest.biometricsCallback(false, "ERROR", data.pin)

tryCompare(keycardBox, "biometricsSuccessful", false)
tryCompare(keycardBox, "biometricsFailed", true)

// this fails and lets the user enter the PIN manually; so just verify we have an error and empty PIN
tryCompare(pinInput, "pinInput", "")
expectFail(data.tag, "Wrong fingerprint, expected to fail to login")
expectFail(data.tag, "Biometrics failed, expected to fail to login")
}
} else { // manual PIN
keyClickSequence(data.pin)
Expand Down
34 changes: 29 additions & 5 deletions storybook/src/Storybook/BiometricsPopup.qml
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,23 @@ Dialog {

required property string password
required property string pin
required property string selectedProfileIsKeycard

// password signals
signal accountLoginError(string error, bool wrongPassword)
required property bool selectedProfileIsKeycard

// biometrics signals
signal obtainingPasswordSuccess(string password)
signal obtainingPasswordError(string errorDescription, string errorType /* Constants.keychain.errorType.* */, bool wrongFingerprint)
signal cancelled()

function cancel() {
close()
cancelled()
}

function getHandlerFunction() {
return (profileId, callback) => {
connector.createObject(this, { callback })
}
}

width: 300
margins: 40

Expand Down Expand Up @@ -76,4 +78,26 @@ Dialog {
}
}
}

Component {
id: connector

Connections {
target: root

property var callback

function onObtainingPasswordSuccess(password) {
callback(false, "", password)
destroy()
}

function onCancelled() {
callback(true, "", "")
destroy()
}

Component.onCompleted: root.open()
}
}
}
10 changes: 3 additions & 7 deletions ui/StatusQ/include/StatusQ/keychain.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ class Keychain : public QObject {
Q_OBJECT

Q_PROPERTY(QString service READ service WRITE setService NOTIFY serviceChanged)
Q_PROPERTY(QString reason READ reason WRITE setReason NOTIFY reasonChanged)
Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged)

public:
Expand All @@ -34,14 +33,11 @@ class Keychain : public QObject {
QString service() const;
void setService(const QString &service);

QString reason() const;
void setReason(const QString& reason);

bool loading() const;

Q_INVOKABLE void requestSaveCredential(const QString &account, const QString &password);
Q_INVOKABLE void requestDeleteCredential(const QString &account);
Q_INVOKABLE void requestGetCredential(const QString &account);
Q_INVOKABLE void requestSaveCredential(const QString &reason, const QString &account, const QString &password);
Q_INVOKABLE void requestDeleteCredential(const QString &reason, const QString &account);
Q_INVOKABLE void requestGetCredential(const QString &reason, const QString &account);
Q_INVOKABLE void cancelActiveRequest();

signals:
Expand Down
39 changes: 39 additions & 0 deletions ui/StatusQ/src/StatusQ/Core/Utils/KeychainUtils.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
pragma Singleton

import QtQml 2.15

import StatusQ 0.1

QObject {
function getSecret(keychain, reason, profileId, callback) {
const params = {
target: keychain,
callback, reason, profileId
}

conntector.createObject(this, params)
}

Component {
id: conntector

Connections {
required property var callback
required property string reason
required property string profileId

function onGetCredentialRequestCompleted(status, password) {
if (status === Keychain.StatusSuccess)
callback(false, "", "")
else if (status === Keychain.StatusCancelled)
callback(true, "", "")
else
callback(false, qsTr("Fetching credentials failed."), "")

destroy()
}

Component.onCompleted: keychain.requestGetCredential(reason, profileId)
}
}
}
1 change: 1 addition & 0 deletions ui/StatusQ/src/StatusQ/Core/Utils/qmldir
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ SubscriptionBrokerCommunities 0.1 SubscriptionBrokerCommunities.qml
XSS 1.0 xss.js
singleton AmountsArithmetic 0.1 AmountsArithmetic.qml
singleton Emoji 0.1 Emoji.qml
singleton KeychainUtils 0.1 KeychainUtils.qml
singleton ModelUtils 0.1 ModelUtils.qml
singleton OperatorsUtils 0.1 OperatorsUtils.qml
singleton StringUtils 0.1 StringUtils.qml
Expand Down
1 change: 1 addition & 0 deletions ui/StatusQ/src/statusq.qrc
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@
<file>StatusQ/Core/Utils/DoubleFlickableWithFolding.qml</file>
<file>StatusQ/Core/Utils/Emoji.qml</file>
<file>StatusQ/Core/Utils/JSONListModel.qml</file>
<file>StatusQ/Core/Utils/KeychainUtils.qml</file>
<file>StatusQ/Core/Utils/LazyStackLayout.qml</file>
<file>StatusQ/Core/Utils/ModelChangeGuard.qml</file>
<file>StatusQ/Core/Utils/ModelChangeTracker.qml</file>
Expand Down
12 changes: 9 additions & 3 deletions ui/app/AppLayouts/Onboarding2/OnboardingFlow.qml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ SQUtils.QObject {

required property bool isBiometricsLogin // FIXME should come from the loginAccountsModel for each profile separately?

/*
\qmlproperty var OnboardingFlow::fetchSecretViaBiometrics
See LoginScreen::fetchSecretViaBiometrics for details
*/
property var fetchSecretViaBiometrics

required property bool biometricsAvailable
required property bool displayKeycardPromoBanner
required property bool networkChecksEnabled
Expand All @@ -41,7 +48,6 @@ SQUtils.QObject {
required property var tryToSetPinFunction
required property var tryToSetPukFunction

signal biometricsRequested
signal loginRequested(string keyUid, int method, var data)
signal keycardPinCreated(string pin)
signal enableBiometricsRequested(bool enable)
Expand Down Expand Up @@ -141,9 +147,9 @@ SQUtils.QObject {
loginAccountsModel: root.loginAccountsModel
biometricsAvailable: root.biometricsAvailable
isBiometricsLogin: root.isBiometricsLogin
onBiometricsRequested: root.biometricsRequested()
onLoginRequested: (keyUid, method, data) => root.loginRequested(keyUid, method, data)
fetchSecretViaBiometrics: root.fetchSecretViaBiometrics

onLoginRequested: (keyUid, method, data) => root.loginRequested(keyUid, method, data)
onOnboardingCreateProfileFlowRequested: root.stackView.push(createProfilePage)
onOnboardingLoginFlowRequested: root.stackView.push(loginPage)
onLostKeycard: root.stackView.push(keycardLostPage)
Expand Down
Loading

0 comments on commit d9acd9b

Please sign in to comment.