1 /* 2 * This software is in the public domain, furnished "as is", without technical 3 * support, and with no warranty, express or implied, as to its usefulness for 4 * any purpose. 5 * 6 */ 7 8 #include <QtTest/QtTest> 9 #include <QDesktopServices> 10 11 #include "libsync/creds/oauth.h" 12 #include "syncenginetestutils.h" 13 #include "theme.h" 14 #include "common/asserts.h" 15 16 using namespace OCC; 17 18 class DesktopServiceHook : public QObject 19 { 20 Q_OBJECT 21 signals: 22 void hooked(const QUrl &); 23 public: DesktopServiceHook()24 DesktopServiceHook() { QDesktopServices::setUrlHandler("oauthtest", this, "hooked"); } 25 }; 26 27 static const QUrl sOAuthTestServer("oauthtest://someserver/owncloud"); 28 29 30 class FakePostReply : public QNetworkReply 31 { 32 Q_OBJECT 33 public: 34 std::unique_ptr<QIODevice> payload; 35 bool aborted = false; 36 bool redirectToPolicy = false; 37 bool redirectToToken = false; 38 FakePostReply(QNetworkAccessManager::Operation op,const QNetworkRequest & request,std::unique_ptr<QIODevice> payload_,QObject * parent)39 FakePostReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request, 40 std::unique_ptr<QIODevice> payload_, QObject *parent) 41 : QNetworkReply{parent}, payload{std::move(payload_)} 42 { 43 setRequest(request); 44 setUrl(request.url()); 45 setOperation(op); 46 open(QIODevice::ReadOnly); 47 payload->open(QIODevice::ReadOnly); 48 QMetaObject::invokeMethod(this, "respond", Qt::QueuedConnection); 49 } 50 respond()51 Q_INVOKABLE virtual void respond() { 52 if (aborted) { 53 setError(OperationCanceledError, "Operation Canceled"); 54 emit metaDataChanged(); 55 emit finished(); 56 return; 57 } else if (redirectToPolicy) { 58 setHeader(QNetworkRequest::LocationHeader, "/my.policy"); 59 setAttribute(QNetworkRequest::RedirectionTargetAttribute, "/my.policy"); 60 setAttribute(QNetworkRequest::HttpStatusCodeAttribute, 302); // 302 might or might not lose POST data in rfc 61 setHeader(QNetworkRequest::ContentLengthHeader, 0); 62 emit metaDataChanged(); 63 emit finished(); 64 return; 65 } else if (redirectToToken) { 66 // Redirect to self 67 QVariant destination = QVariant(sOAuthTestServer.toString()+QLatin1String("/index.php/apps/oauth2/api/v1/token")); 68 setHeader(QNetworkRequest::LocationHeader, destination); 69 setAttribute(QNetworkRequest::RedirectionTargetAttribute, destination); 70 setAttribute(QNetworkRequest::HttpStatusCodeAttribute, 307); // 307 explicitly in rfc says to not lose POST data 71 setHeader(QNetworkRequest::ContentLengthHeader, 0); 72 emit metaDataChanged(); 73 emit finished(); 74 return; 75 } 76 setHeader(QNetworkRequest::ContentLengthHeader, payload->size()); 77 setAttribute(QNetworkRequest::HttpStatusCodeAttribute, 200); 78 emit metaDataChanged(); 79 if (bytesAvailable()) 80 emit readyRead(); 81 emit finished(); 82 } 83 abort()84 void abort() override { 85 aborted = true; 86 } bytesAvailable() const87 qint64 bytesAvailable() const override { 88 if (aborted) 89 return 0; 90 return payload->bytesAvailable(); 91 } 92 readData(char * data,qint64 maxlen)93 qint64 readData(char *data, qint64 maxlen) override { 94 return payload->read(data, maxlen); 95 } 96 }; 97 98 // Reply with a small delay 99 class SlowFakePostReply : public FakePostReply { 100 Q_OBJECT 101 public: 102 using FakePostReply::FakePostReply; respond()103 void respond() override { 104 // override of FakePostReply::respond, will call the real one with a delay. 105 QTimer::singleShot(100, this, [this] { this->FakePostReply::respond(); }); 106 } 107 }; 108 109 110 class OAuthTestCase : public QObject 111 { 112 Q_OBJECT 113 DesktopServiceHook desktopServiceHook; 114 public: 115 enum State { StartState, BrowserOpened, TokenAsked, CustomState } state = StartState; 116 Q_ENUM(State); 117 bool replyToBrowserOk = false; 118 bool gotAuthOk = false; done() const119 virtual bool done() const { return replyToBrowserOk && gotAuthOk; } 120 121 FakeQNAM *fakeQnam = nullptr; 122 QNetworkAccessManager realQNAM; 123 QPointer<QNetworkReply> browserReply = nullptr; 124 QString code = generateEtag(); 125 OCC::AccountPtr account; 126 127 QScopedPointer<OAuth> oauth; 128 test()129 virtual void test() { 130 fakeQnam = new FakeQNAM({}); 131 account = OCC::Account::create(); 132 account->setUrl(sOAuthTestServer); 133 account->setCredentials(new FakeCredentials{fakeQnam}); 134 fakeQnam->setParent(this); 135 fakeQnam->setOverride([this](QNetworkAccessManager::Operation op, const QNetworkRequest &req, QIODevice *device) { 136 if (req.url().path().endsWith(".well-known/openid-configuration")) 137 return this->wellKnownReply(op, req); 138 OC_ASSERT(device); 139 OC_ASSERT(device->bytesAvailable() > 0); // OAuth2 always sends around POST data. 140 return this->tokenReply(op, req); 141 }); 142 143 QObject::connect(&desktopServiceHook, &DesktopServiceHook::hooked, 144 this, &OAuthTestCase::openBrowserHook); 145 146 oauth.reset(new OAuth(account.data(), nullptr)); 147 QObject::connect(oauth.data(), &OAuth::result, this, &OAuthTestCase::oauthResult); 148 oauth->startAuthentication(); 149 QTRY_VERIFY(done()); 150 } 151 openBrowserHook(const QUrl & url)152 virtual void openBrowserHook(const QUrl &url) { 153 QCOMPARE(state, StartState); 154 state = BrowserOpened; 155 QCOMPARE(url.path(), QString(sOAuthTestServer.path() + "/index.php/apps/oauth2/authorize")); 156 QVERIFY(url.toString().startsWith(sOAuthTestServer.toString())); 157 QUrlQuery query(url); 158 QCOMPARE(query.queryItemValue(QLatin1String("response_type")), QLatin1String("code")); 159 QCOMPARE(query.queryItemValue(QLatin1String("client_id")), Theme::instance()->oauthClientId()); 160 QUrl redirectUri(query.queryItemValue(QLatin1String("redirect_uri"))); 161 QCOMPARE(redirectUri.host(), QLatin1String("localhost")); 162 redirectUri.setQuery(QStringLiteral("code=%1&state=%2").arg(code, query.queryItemValue(QStringLiteral("state")))); 163 createBrowserReply(QNetworkRequest(redirectUri)); 164 } 165 createBrowserReply(const QNetworkRequest & request)166 virtual QNetworkReply *createBrowserReply(const QNetworkRequest &request) { 167 browserReply = realQNAM.get(request); 168 QObject::connect(browserReply, &QNetworkReply::finished, this, &OAuthTestCase::browserReplyFinished); 169 return browserReply; 170 } 171 browserReplyFinished()172 virtual void browserReplyFinished() { 173 QCOMPARE(sender(), browserReply.data()); 174 QCOMPARE(state, TokenAsked); 175 browserReply->deleteLater(); 176 QCOMPARE(QNetworkReply::NoError, browserReply->error()); 177 QCOMPARE(browserReply->rawHeader("Location"), QByteArray("owncloud://success")); 178 replyToBrowserOk = true; 179 } 180 tokenReply(QNetworkAccessManager::Operation op,const QNetworkRequest & req)181 virtual QNetworkReply *tokenReply(QNetworkAccessManager::Operation op, const QNetworkRequest &req) 182 { 183 OC_ASSERT(state == BrowserOpened); 184 state = TokenAsked; 185 OC_ASSERT(op == QNetworkAccessManager::PostOperation); 186 OC_ASSERT(req.url().toString().startsWith(sOAuthTestServer.toString())); 187 OC_ASSERT(req.url().path() == sOAuthTestServer.path() + "/index.php/apps/oauth2/api/v1/token"); 188 std::unique_ptr<QBuffer> payload(new QBuffer()); 189 payload->setData(tokenReplyPayload()); 190 return new FakePostReply(op, req, std::move(payload), fakeQnam); 191 } 192 wellKnownReply(QNetworkAccessManager::Operation op,const QNetworkRequest & req)193 virtual QNetworkReply *wellKnownReply(QNetworkAccessManager::Operation op, const QNetworkRequest &req) 194 { 195 return new FakeErrorReply(op, req, fakeQnam, 404); 196 } 197 tokenReplyPayload() const198 virtual QByteArray tokenReplyPayload() const { 199 // the dummy server provides the user admin 200 QJsonDocument jsondata(QJsonObject{ 201 { "access_token", "123" }, 202 { "refresh_token" , "456" }, 203 { "message_url", "owncloud://success"}, 204 { "user_id", "admin" }, 205 { "token_type", "Bearer" } 206 }); 207 return jsondata.toJson(); 208 } 209 oauthResult(OAuth::Result result,const QString & user,const QString & token,const QString & refreshToken)210 virtual void oauthResult(OAuth::Result result, const QString &user, const QString &token , const QString &refreshToken) { 211 QCOMPARE(state, TokenAsked); 212 QCOMPARE(result, OAuth::LoggedIn); 213 QCOMPARE(user, QString("admin")); 214 QCOMPARE(token, QString("123")); 215 QCOMPARE(refreshToken, QString("456")); 216 gotAuthOk = true; 217 } 218 }; 219 220 class TestOAuth: public QObject 221 { 222 Q_OBJECT 223 224 private slots: testBasic()225 void testBasic() 226 { 227 OAuthTestCase test; 228 test.test(); 229 } 230 231 testWrongUser()232 void testWrongUser() 233 { 234 struct Test : OAuthTestCase { 235 QByteArray tokenReplyPayload() const override { 236 // the dummy server provides the user admin 237 QJsonDocument jsondata(QJsonObject{ 238 { "access_token", "123" }, 239 { "refresh_token" , "456" }, 240 { "message_url", "owncloud://success"}, 241 { "user_id", "wrong_user" }, 242 { "token_type", "Bearer" } 243 }); 244 return jsondata.toJson(); 245 } 246 247 void browserReplyFinished() override { 248 QCOMPARE(sender(), browserReply.data()); 249 QCOMPARE(state, TokenAsked); 250 browserReply->deleteLater(); 251 QCOMPARE(QNetworkReply::AuthenticationRequiredError, browserReply->error()); 252 } 253 254 bool done() const override{ 255 return true; 256 } 257 }; 258 Test test; 259 test.test(); 260 } 261 262 // Test for https://github.com/owncloud/client/pull/6057 testCloseBrowserDontCrash()263 void testCloseBrowserDontCrash() 264 { 265 struct Test : OAuthTestCase { 266 QNetworkReply *tokenReply(QNetworkAccessManager::Operation op, const QNetworkRequest & req) override 267 { 268 OC_ASSERT(browserReply); 269 // simulate the fact that the browser is closing the connection 270 browserReply->abort(); 271 // don't process network events, as it messes up the execution order and 272 // causes an Qt internal crash 273 QCoreApplication::processEvents(QEventLoop::ExcludeSocketNotifiers); 274 275 OC_ASSERT(state == BrowserOpened); 276 state = TokenAsked; 277 278 std::unique_ptr<QBuffer> payload(new QBuffer); 279 payload->setData(tokenReplyPayload()); 280 return new SlowFakePostReply(op, req, std::move(payload), fakeQnam); 281 } 282 283 void browserReplyFinished() override 284 { 285 QCOMPARE(sender(), browserReply.data()); 286 QCOMPARE(browserReply->error(), QNetworkReply::OperationCanceledError); 287 replyToBrowserOk = true; 288 } 289 } test; 290 test.test(); 291 } 292 testRandomConnections()293 void testRandomConnections() 294 { 295 // Test that we can send random garbage to the litening socket and it does not prevent the connection 296 struct Test : OAuthTestCase { 297 QNetworkReply *createBrowserReply(const QNetworkRequest &request) override { 298 QTimer::singleShot(0, this, [this, request] { 299 auto port = request.url().port(); 300 state = CustomState; 301 QVector<QByteArray> payloads = { 302 "GET FOFOFO HTTP 1/1\n\n", 303 "GET /?code=invalie HTTP 1/1\n\n", 304 "GET /?code=xxxxx&bar=fff", 305 QByteArray("\0\0\0", 3), 306 QByteArray("GET \0\0\0 \n\n\n\n\n\0", 14), 307 QByteArray("GET /?code=éléphant\xa5 HTTP\n"), 308 QByteArray("\n\n\n\n"), 309 }; 310 foreach (const auto &x, payloads) { 311 auto socket = new QTcpSocket(this); 312 socket->connectToHost("localhost", port); 313 QVERIFY(socket->waitForConnected()); 314 socket->write(x); 315 } 316 317 // Do the actual request a bit later 318 QTimer::singleShot(100, this, [this, request] { 319 QCOMPARE(state, CustomState); 320 state = BrowserOpened; 321 this->OAuthTestCase::createBrowserReply(request); 322 }); 323 }); 324 return nullptr; 325 } 326 327 QNetworkReply *tokenReply(QNetworkAccessManager::Operation op, const QNetworkRequest &req) override 328 { 329 if (state == CustomState) 330 return new FakeErrorReply{op, req, this, 500}; 331 return OAuthTestCase::tokenReply(op, req); 332 } 333 334 void oauthResult(OAuth::Result result, const QString &user, const QString &token , 335 const QString &refreshToken) override { 336 if (state != CustomState) 337 return OAuthTestCase::oauthResult(result, user, token, refreshToken); 338 QCOMPARE(result, OAuth::Error); 339 } 340 } test; 341 test.test(); 342 } 343 testTokenUrlHasRedirect()344 void testTokenUrlHasRedirect() 345 { 346 struct Test : OAuthTestCase { 347 int redirectsDone = 0; 348 QNetworkReply *tokenReply(QNetworkAccessManager::Operation op, const QNetworkRequest & request) override 349 { 350 OC_ASSERT(browserReply); 351 // Kind of reproduces what we had in https://github.com/owncloud/enterprise/issues/2951 (not 1:1) 352 if (redirectsDone == 0) { 353 std::unique_ptr<QBuffer> payload(new QBuffer()); 354 payload->setData(""); 355 SlowFakePostReply *reply = new SlowFakePostReply(op, request, std::move(payload), this); 356 reply->redirectToPolicy = true; 357 redirectsDone++; 358 return reply; 359 } else if (redirectsDone == 1) { 360 std::unique_ptr<QBuffer> payload(new QBuffer()); 361 payload->setData(""); 362 SlowFakePostReply *reply = new SlowFakePostReply(op, request, std::move(payload), this); 363 reply->redirectToToken = true; 364 redirectsDone++; 365 return reply; 366 } else { 367 // ^^ This is with a custom reply and not actually HTTP, so we're testing the HTTP redirect code 368 // we have in AbstractNetworkJob::slotFinished() 369 redirectsDone++; 370 return OAuthTestCase::tokenReply(op, request); 371 } 372 } 373 } test; 374 test.test(); 375 } 376 testWellKnown()377 void testWellKnown() { 378 struct Test : OAuthTestCase { 379 int redirectsDone = 0; 380 QNetworkReply * wellKnownReply(QNetworkAccessManager::Operation op, const QNetworkRequest & req) override { 381 OC_ASSERT(op == QNetworkAccessManager::GetOperation); 382 QJsonDocument jsondata(QJsonObject{ 383 { "authorization_endpoint", QJsonValue( 384 "oauthtest://openidserver" + sOAuthTestServer.path() + "/index.php/apps/oauth2/authorize") }, 385 { "token_endpoint" , "oauthtest://openidserver/token_endpoint" } 386 }); 387 return new FakePayloadReply(op, req, jsondata.toJson(), fakeQnam); 388 } 389 390 void openBrowserHook(const QUrl & url) override { 391 OC_ASSERT(url.host() == "openidserver"); 392 QUrl url2 = url; 393 url2.setHost(sOAuthTestServer.host()); 394 OAuthTestCase::openBrowserHook(url2); 395 } 396 397 QNetworkReply *tokenReply(QNetworkAccessManager::Operation op, const QNetworkRequest & request) override 398 { 399 OC_ASSERT(browserReply); 400 OC_ASSERT(request.url().toString().startsWith("oauthtest://openidserver/token_endpoint")); 401 auto req = request; 402 req.setUrl(request.url().toString().replace("oauthtest://openidserver/token_endpoint", 403 sOAuthTestServer.toString() + "/index.php/apps/oauth2/api/v1/token")); 404 return OAuthTestCase::tokenReply(op, req); 405 } 406 } test; 407 test.test(); 408 } 409 }; 410 411 412 QTEST_GUILESS_MAIN(TestOAuth) 413 #include "testoauth.moc" 414