diff --git a/autotests/core/accountmanagertest.cpp b/autotests/core/accountmanagertest.cpp index 5352385..5138482 100644 --- a/autotests/core/accountmanagertest.cpp +++ b/autotests/core/accountmanagertest.cpp @@ -1,229 +1,253 @@ /* * Copyright (C) 2018 Daniel Vrátil * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) version 3, or any * later version accepted by the membership of KDE e.V. (or its * successor approved by the membership of KDE e.V.), which shall * act as a proxy defined in Section 6 of version 3 of the license. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. If not, see . */ #include #include #include #include #include #include "fakeaccountstorage.h" #include "fakeauthwidget.h" #include "fakenetworkaccessmanagerfactory.h" #include "fakenetworkaccessmanager.h" #include "fakenetworkreply.h" #include "accountmanager.h" #include "accountstorage_p.h" #include "account.h" #include "testutils.h" using namespace KGAPI2; namespace { const static auto ApiKey1 = QStringLiteral("Key1"); const static auto SecretKey1 = QStringLiteral("Secret1"); const static auto Account1 = QStringLiteral("jonh.doe@fakegmail.invalid"); } class TestableAccountManager : public AccountManager { Q_OBJECT public: explicit TestableAccountManager(QObject *parent = nullptr) : AccountManager(parent) { sInstance = this; } ~TestableAccountManager() override { sInstance = nullptr; } FakeAccountStorage *fakeStore() const { return mStorageFactory.fakeStore(); } private: FakeAccountStorageFactory mStorageFactory; }; class AccountManagerTest : public QObject { Q_OBJECT private Q_SLOTS: void initTestCase() { NetworkAccessManagerFactory::setFactory(new FakeNetworkAccessManagerFactory); } void testGetNewAccount() { FakeAuthWidgetFactory authFactory; FakeNetworkAccessManagerFactory::get()->setScenarios( { scenarioFromFile(QFINDTESTDATA("data/accountmanager_part1_request.txt"), QFINDTESTDATA("data/accountmanager_part1_response.txt"), false), scenarioFromFile(QFINDTESTDATA("data/accountinfo_fetch_request.txt"), QFINDTESTDATA("data/accountinfo_fetch_response.txt")) }); TestableAccountManager accountManager; const auto promise = accountManager.getAccount(ApiKey1, SecretKey1, Account1, { Account::contactsScopeUrl() }); QCOMPARE(promise->account(), {}); QSignalSpy spy(promise, &AccountPromise::finished); QVERIFY(spy.wait()); QCOMPARE(spy.count(), 1); const auto account = promise->account(); QVERIFY(account); QCOMPARE(account->accountName(), Account1); const QList expectedScopes = { Account::contactsScopeUrl(), Account::accountInfoEmailScopeUrl() }; QCOMPARE(account->scopes(), expectedScopes); QVERIFY(!account->accessToken().isEmpty()); QVERIFY(!account->refreshToken().isEmpty()); QVERIFY(account->expireDateTime().isValid()); const auto storeAccount = accountManager.fakeStore()->mStore.value(ApiKey1 + Account1); QVERIFY(storeAccount); QCOMPARE(*storeAccount, *account); } void testGetExistingAccount() { TestableAccountManager accountManager; const auto insertedAccount = AccountPtr::create(*accountManager.fakeStore()->generateAccount(ApiKey1, Account1, { Account::contactsScopeUrl() })); QVERIFY(insertedAccount); const auto promise = accountManager.getAccount(ApiKey1, SecretKey1, Account1, { Account::contactsScopeUrl() }); QCOMPARE(promise->account(), {}); QSignalSpy spy(promise, &AccountPromise::finished); QVERIFY(spy.wait()); QCOMPARE(spy.count(), 1); QVERIFY(promise->account()); QCOMPARE(*promise->account(), *insertedAccount); } void testMergeAccountScopes() { FakeAuthWidgetFactory authFactory; FakeNetworkAccessManagerFactory::get()->setScenarios( { scenarioFromFile(QFINDTESTDATA("data/accountmanager_part1_request.txt"), QFINDTESTDATA("data/accountmanager_part1_response.txt"), false), scenarioFromFile(QFINDTESTDATA("data/accountinfo_fetch_request.txt"), QFINDTESTDATA("data/accountinfo_fetch_response.txt")) }); TestableAccountManager accountManager; const auto insertedAccount = accountManager.fakeStore()->generateAccount(ApiKey1, Account1, { Account::contactsScopeUrl() }); QVERIFY(insertedAccount); auto expectedAccount = AccountPtr::create(*insertedAccount); expectedAccount->setScopes({ Account::contactsScopeUrl(), Account::calendarScopeUrl(), Account::accountInfoEmailScopeUrl() }); const auto promise = accountManager.getAccount(ApiKey1, SecretKey1, Account1, { Account::calendarScopeUrl() }); QCOMPARE(promise->account(), {}); QSignalSpy spy(promise, &AccountPromise::finished); QVERIFY(spy.wait()); QCOMPARE(spy.count(), 1); const auto account = promise->account(); QVERIFY(account); QCOMPARE(account->accountName(), Account1); QVERIFY(!account->accessToken().isEmpty()); QVERIFY(!account->refreshToken().isEmpty()); QVERIFY(account->expireDateTime().isValid()); QCOMPARE(account->scopes(), expectedAccount->scopes()); const auto storeAccount = accountManager.fakeStore()->mStore.value(ApiKey1 + Account1); QVERIFY(storeAccount); QCOMPARE(storeAccount->accountName(), Account1); QVERIFY(!storeAccount->accessToken().isEmpty()); QVERIFY(!storeAccount->refreshToken().isEmpty()); QVERIFY(storeAccount->expireDateTime().isValid()); QCOMPARE(storeAccount->scopes(), expectedAccount->scopes()); } void testRemoveAccountScopes() { TestableAccountManager accountManager; accountManager.fakeStore()->generateAccount(ApiKey1, Account1, { Account::contactsScopeUrl(), Account::calendarScopeUrl() }); accountManager.removeScopes(ApiKey1, Account1, { Account::contactsScopeUrl() }); const auto storeAccount = accountManager.fakeStore()->mStore.value(ApiKey1 + Account1); QVERIFY(storeAccount); QCOMPARE(storeAccount->accountName(), Account1); QVERIFY(storeAccount->accessToken().isEmpty()); QVERIFY(storeAccount->refreshToken().isEmpty()); QVERIFY(storeAccount->expireDateTime().isNull()); QCOMPARE(storeAccount->scopes(), QList{ Account::calendarScopeUrl() }); } void testRemoveAllScopes() { TestableAccountManager accountManager; accountManager.fakeStore()->generateAccount(ApiKey1, Account1, { Account::contactsScopeUrl(), Account::calendarScopeUrl() }); accountManager.removeScopes(ApiKey1, Account1, { Account::contactsScopeUrl(), Account::calendarScopeUrl() }); QVERIFY(!accountManager.fakeStore()->mStore.contains(ApiKey1 + Account1)); } void testFindInvalidAccount() { TestableAccountManager accountManager; const auto promise = accountManager.findAccount(ApiKey1, Account1); QCOMPARE(promise->account(), {}); QSignalSpy spy(promise, &AccountPromise::finished); QVERIFY(spy.wait()); QCOMPARE(spy.count(), 1); QCOMPARE(promise->account(), AccountPtr()); } void testFindValidAccount() { TestableAccountManager accountManager; const auto insertedAccount = accountManager.fakeStore()->generateAccount(ApiKey1, Account1, { Account::calendarScopeUrl() }); const auto promise = accountManager.findAccount(ApiKey1, Account1); QCOMPARE(promise->account(), {}); QSignalSpy spy(promise, &AccountPromise::finished); QVERIFY(spy.wait()); QCOMPARE(spy.count(), 1); QVERIFY(promise->account()); QCOMPARE(*promise->account(), *insertedAccount); } + + void testRefreshTokens() + { + FakeAuthWidgetFactory authFactory; + FakeNetworkAccessManagerFactory::get()->setScenarios( + { scenarioFromFile(QFINDTESTDATA("data/accountmanager_refresh_request.txt"), + QFINDTESTDATA("data/accountmanager_refresh_response.txt"), + false) + }); + + TestableAccountManager accountManager; + + auto insertedAccount = accountManager.fakeStore()->generateAccount(ApiKey1, Account1, { Account::calendarScopeUrl() }); + insertedAccount->setRefreshToken(QStringLiteral("FakeRefreshToken")); + + const auto promise = accountManager.refreshTokens(ApiKey1, SecretKey1, Account1); + QCOMPARE(promise->account(), {}); + QSignalSpy spy(promise, &AccountPromise::finished); + QVERIFY(spy.wait()); + QCOMPARE(spy.count(), 1); + QVERIFY(promise->account()); + QCOMPARE(promise->account()->accountName(), insertedAccount->accountName()); + QCOMPARE(promise->account()->accessToken(), QStringLiteral("NewAccessToken")); + } }; QTEST_MAIN(AccountManagerTest) #include "accountmanagertest.moc" diff --git a/autotests/core/data/accountmanager_refresh_request.txt b/autotests/core/data/accountmanager_refresh_request.txt new file mode 100644 index 0000000..8e89929 --- /dev/null +++ b/autotests/core/data/accountmanager_refresh_request.txt @@ -0,0 +1,4 @@ +POST https://accounts.google.com/o/oauth2/token +Content-Type: application/x-www-form-urlencoded + +client_id=Key1&client_secret=Secret1&refresh_token=FakeRefreshToken&grant_type=refresh_token \ No newline at end of file diff --git a/autotests/core/data/accountmanager_refresh_response.txt b/autotests/core/data/accountmanager_refresh_response.txt new file mode 100644 index 0000000..37bb488 --- /dev/null +++ b/autotests/core/data/accountmanager_refresh_response.txt @@ -0,0 +1,9 @@ +HTTP/1.1 200 OK +content-type: application/json; charset=UTF-8 + +{ + "access_token": "NewAccessToken", + "refresh_token": "FakeRefreshToken", + "expiresIn": 3600 +} + diff --git a/src/core/accountmanager.cpp b/src/core/accountmanager.cpp index 9dd9329..af0aaaa 100644 --- a/src/core/accountmanager.cpp +++ b/src/core/accountmanager.cpp @@ -1,310 +1,336 @@ /* Copyright (C) 2018 Daniel Vrátil This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) version 3, or any later version accepted by the membership of KDE e.V. (or its successor approved by the membership of KDE e.V.), which shall act as a proxy defined in Section 6 of version 3 of the license. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library. If not, see . */ #include "accountmanager.h" #include "authjob.h" #include "accountstorage_p.h" #include "../debug.h" #include #include #include namespace KGAPI2 { AccountManager *AccountManager::sInstance = nullptr; class AccountPromise::Private { public: Private(AccountPromise *q) : q(q) {} void setError(const QString &error) { this->error = error; emitFinished(); } void setAccount(const AccountPtr &account) { this->account = account; emitFinished(); } void setRunning() { mRunning = true; } bool isRunning() const { return mRunning; } QString error; AccountPtr account; private: void emitFinished() { QTimer::singleShot(0, q, [this]() { Q_EMIT q->finished(q); q->deleteLater(); }); } bool mRunning = false; AccountPromise * const q; }; class AccountManager::Private { public: Private(AccountManager *q) : q(q) {} void updateAccount(AccountPromise *promise, const QString &apiKey, const QString &apiSecret, const AccountPtr &account, const QList &requestedScopes) { if (!requestedScopes.isEmpty()) { auto currentScopes = account->scopes(); for (const auto &requestedScope : requestedScopes) { if (!currentScopes.contains(requestedScope)) { currentScopes.push_back(requestedScope); } } account->setScopes(currentScopes); } AuthJob *job = new AuthJob(account, apiKey, apiSecret); job->setUsername(account->accountName()); connect(job, &AuthJob::finished, [=]() { if (job->error() != KGAPI2::NoError) { promise->d->setError(tr("Failed to authenticate additional scopes")); return; } mStore->storeAccount(apiKey, job->account()); promise->d->setAccount(job->account()); }); } void createAccount(AccountPromise *promise, const QString &apiKey, const QString &apiSecret, const QString &accountName, const QList &scopes) { const auto account = AccountPtr::create(accountName, QString{}, QString{}, scopes); updateAccount(promise, apiKey, apiSecret, account, {}); } bool compareScopes(const QList ¤tScopes, const QList &requestedScopes) const { for (const auto &scope : qAsConst(requestedScopes)) { if (!currentScopes.contains(scope)) { return false; } } return true; } void ensureStore(const std::function &callback) { if (!mStore) { mStore = AccountStorageFactory::instance()->create(); } if (!mStore->opened()) { mStore->open(callback); } else { callback(true); } } AccountPromise *createPromise(const QString &apiKey, const QString &accountName) { - const auto key = apiKey + accountName; + const QString key = apiKey + accountName; auto promise = mPendingPromises.value(key, nullptr); if (!promise) { promise = new AccountPromise(q); QObject::connect(promise, &QObject::destroyed, - [key, this]() { + q, [key, this]() { mPendingPromises.remove(key); }); mPendingPromises.insert(key, promise); } return promise; } public: AccountStorage *mStore = nullptr; private: QHash mPendingPromises; AccountManager * const q; }; } using namespace KGAPI2; AccountPromise::AccountPromise(QObject *parent) : QObject(parent) , d(new Private(this)) { } AccountPromise::~AccountPromise() { } AccountPtr AccountPromise::account() const { return d->account; } bool AccountPromise::hasError() const { return !d->error.isNull(); } QString AccountPromise::errorText() const { return d->error; } AccountManager::AccountManager(QObject *parent) : QObject(parent) , d(new Private(this)) { } AccountManager::~AccountManager() { } AccountManager *AccountManager::instance() { if (!sInstance) { sInstance = new AccountManager; } return sInstance; } AccountPromise *AccountManager::getAccount(const QString &apiKey, const QString &apiSecret, const QString &accountName, const QList &scopes) { auto promise = d->createPromise(apiKey, accountName); if (!promise->d->isRunning()) { // Start the process asynchronously so that caller has a chance to connect // to AccountPromise signals. QTimer::singleShot(0, this, [=]() { d->ensureStore([=](bool storeOpened) { if (!storeOpened) { promise->d->setError(tr("Failed to open account store")); return; } const auto account = d->mStore->getAccount(apiKey, accountName); if (!account) { d->createAccount(promise, apiKey, apiSecret, accountName, scopes); } else { if (d->compareScopes(account->scopes(), scopes)) { promise->d->setAccount(account); } else { // Since installed apps can't keep the API secret truly a secret // incremental authorization is not allowed by Google so we need // to request a completely new token from scratch. account->setAccessToken({}); account->setRefreshToken({}); account->setExpireDateTime({}); d->updateAccount(promise, apiKey, apiSecret, account, scopes); } } }); }); promise->d->setRunning(); } return promise; } +AccountPromise *AccountManager::refreshTokens(const QString &apiKey, const QString &apiSecret, + const QString &accountName) +{ + auto promise = d->createPromise(apiKey, accountName); + if (!promise->d->isRunning()) { + QTimer::singleShot(0, this, [=]() { + d->ensureStore([=](bool storeOpened) { + if (!storeOpened) { + promise->d->setError(tr("Failed to open account store")); + return; + } + + const auto account = d->mStore->getAccount(apiKey, accountName); + if (!account) { + promise->d->setAccount({}); + } else { + d->updateAccount(promise, apiKey, apiSecret, account, {}); + } + }); + }); + promise->d->setRunning(); + } + return promise; +} + + AccountPromise *AccountManager::findAccount(const QString &apiKey, const QString &accountName, const QList &scopes) { auto promise = d->createPromise(apiKey, accountName); if (!promise->d->isRunning()) { QTimer::singleShot(0, this, [=]() { d->ensureStore([=](bool storeOpened) { if (!storeOpened) { promise->d->setError(tr("Failed to open account store")); return; } const auto account = d->mStore->getAccount(apiKey, accountName); if (!account) { promise->d->setAccount({}); } else { const auto currentScopes = account->scopes(); if (scopes.isEmpty() || d->compareScopes(currentScopes, scopes)) { promise->d->setAccount(account); } else { promise->d->setAccount({}); } } }); }); promise->d->setRunning(); } return promise; } void AccountManager::removeScopes(const QString &apiKey, const QString &accountName, const QList &removedScopes) { d->ensureStore([=](bool storeOpened) { if (!storeOpened) { return; } const auto account = d->mStore->getAccount(apiKey, accountName); if (!account) { return; } for (const auto &scope : removedScopes) { account->removeScope(scope); } if (account->scopes().isEmpty()) { d->mStore->removeAccount(apiKey, account->accountName()); } else { // Since installed apps can't keep the API secret truly a secret // incremental authorization is not allowed by Google so we need // to request a completely new token from scratch. account->setAccessToken({}); account->setRefreshToken({}); account->setExpireDateTime({}); d->mStore->storeAccount(apiKey, account); } }); } diff --git a/src/core/accountmanager.h b/src/core/accountmanager.h index 3236af3..680585f 100644 --- a/src/core/accountmanager.h +++ b/src/core/accountmanager.h @@ -1,141 +1,156 @@ /* Copyright (C) 2018 Daniel Vrátil This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) version 3, or any later version accepted by the membership of KDE e.V. (or its successor approved by the membership of KDE e.V.), which shall act as a proxy defined in Section 6 of version 3 of the license. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library. If not, see . */ #ifndef LIBKGAPI2_ACCOUNTMANAGER_H_ #define LIBKGAPI2_ACCOUNTMANAGER_H_ #include #include #include "account.h" #include "kgapicore_export.h" class QUrl; namespace KGAPI2 { class AccountManager; /** * @brief AccountPromise is a result of asynchronous operations of AccountManager * * The AccountPromise is returned by AccountManager and will emit @p finished() * signal when the asynchronous retrieval of the Account has finished. * * The object will delete itself once @p finished signal is emitted. */ class KGAPICORE_EXPORT AccountPromise : public QObject { Q_OBJECT public: ~AccountPromise() override; AccountPtr account() const; bool hasError() const; QString errorText() const; Q_SIGNALS: /** * @brief The retrieval has finished and the Account can be retrieved. * * The object is automatically scheduled for deletion after this signal * is emitted. */ void finished(AccountPromise *self); private: AccountPromise(QObject *parent = nullptr); Q_DISABLE_COPY(AccountPromise) friend class AccountManager; class Private; QScopedPointer const d; }; class KGAPICORE_EXPORT AccountManager : public QObject { Q_OBJECT public: ~AccountManager() override; static AccountManager *instance(); /** * @brief Asynchronously returns an authenticated account for given conditions * * The returned account is guaranteed to be authenticated against at least the * requested @p scopes, but it may be authenticated against more scopes. * If an account for the given @p apiKey and @p accountName already exists * but is not authenticated against all the scopes the user will be presented * with a prompt to confirm the missing scopes. * * If no such account exists, user will be prompted with full authentication * process. * * The returned account is guaranteed to be authenticated, howerver the tokens * may be expired. It's up to the caller to ensure the tokens are refreshed * using @p refreshTokens method. * * @p apiSecret is only used to authetnicate new account or missing scopes * and is not stored anywhere. * * @see refreshTokens, hasAccount */ AccountPromise *getAccount(const QString &apiKey, const QString &apiSecret, const QString &accountName, const QList &scopes); + + /** + * @brief Asynchronously refreshes tokens in given Account + * + * The returned account is guaranteed to be authenticated against at least + * the requested @p scopes, but it may be authenticated against more scopes. + * If an account does not exist, it will be created (see AccountManager::getAccount()). + * + * @p apiSecret is onyl used to authenticate a new account. + * + * @see getAccount + */ + AccountPromise *refreshTokens(const QString &apiKey, const QString &apiSecret, + const QString &accountName); + /** * @brief Asynchronously checks whether the specified account exists. * * Optionally, when non-empty list of @p scopes is provided this method also * checks whether, if the account exists, it is authenticated against the * provided scopes. * * The AccountPromise will have the discovered account set if it is found, * otherwise it's set to null. */ AccountPromise *findAccount(const QString &aipKey, const QString &accountName, const QList &scopes = {}); /** * @brief Asynchronously remove given scopes from authenticated account. * * Removes the given scopes from the account authenticated scopes, so that * next time the account is requested with any of the removed scopes the user * will be presented with a prompt to confirm access again. */ void removeScopes(const QString &apiKey, const QString &accountName, const QList &removeScopes); protected: explicit AccountManager(QObject *parent = nullptr); Q_DISABLE_COPY(AccountManager) static AccountManager *sInstance; class Private; QScopedPointer const d; }; } #endif