diff --git a/src/auth/oauth2/core/qgsauthoauth2method.cpp b/src/auth/oauth2/core/qgsauthoauth2method.cpp index 4a130f79043d..ff5768d8be13 100644 --- a/src/auth/oauth2/core/qgsauthoauth2method.cpp +++ b/src/auth/oauth2/core/qgsauthoauth2method.cpp @@ -127,9 +127,9 @@ QgsO2 *QgsOAuth2Factory::createO2Private( const QString &authcfg, QgsAuthOAuth2C QgsAuthOAuth2Method::QgsAuthOAuth2Method() { setVersion( 1 ); - setExpansions( QgsAuthMethod::NetworkRequest | QgsAuthMethod::NetworkReply ); + setExpansions( QgsAuthMethod::NetworkRequest | QgsAuthMethod::NetworkReply | QgsAuthMethod::DataSourceUri ); setDataProviders( QStringList() << u"ows"_s << u"wfs"_s // convert to lowercase - << u"wcs"_s << u"wms"_s ); + << u"wcs"_s << u"wms"_s << u"postgres"_s ); const QStringList cachedirpaths = QStringList() << QgsAuthOAuth2Config::tokenCacheDirectory() @@ -336,57 +336,66 @@ bool QgsAuthOAuth2Method::updateNetworkRequest( QNetworkRequest &request, const QUrl url = request.url(); QUrlQuery query( url ); - switch ( accessmethod ) + if ( dataprovider == "postgres"_L1 ) { - case QgsAuthOAuth2Config::AccessMethod::Header: + // allow easy token "exfiltration" via QNetworkRequest, so that we can do without + // a postgres/oauth-specific update* method + request.setRawHeader( "Token"_ba, o2->token().toLatin1() ); + } + else + { + switch ( accessmethod ) { - const QString header = o2->oauth2config()->customHeader().isEmpty() ? QString( O2_HTTP_AUTHORIZATION_HEADER ) : o2->oauth2config()->customHeader(); - request.setRawHeader( header.toLatin1(), u"Bearer %1"_s.arg( o2->token() ).toLatin1() ); - - const QVariantMap extraTokens = o2->oauth2config()->extraTokens(); - if ( !extraTokens.isEmpty() ) + case QgsAuthOAuth2Config::AccessMethod::Header: { - const QVariantMap receivedExtraTokens = o2->extraTokens(); - const QStringList extraTokenNames = extraTokens.keys(); - for ( const QString &extraTokenName : extraTokenNames ) + const QString header = o2->oauth2config()->customHeader().isEmpty() ? QString( O2_HTTP_AUTHORIZATION_HEADER ) : o2->oauth2config()->customHeader(); + request.setRawHeader( header.toLatin1(), u"Bearer %1"_s.arg( o2->token() ).toLatin1() ); + + const QVariantMap extraTokens = o2->oauth2config()->extraTokens(); + if ( !extraTokens.isEmpty() ) { - if ( receivedExtraTokens.contains( extraTokenName ) ) + const QVariantMap receivedExtraTokens = o2->extraTokens(); + const QStringList extraTokenNames = extraTokens.keys(); + for ( const QString &extraTokenName : extraTokenNames ) { - request.setRawHeader( extraTokens[extraTokenName].toString().replace( '_', '-' ).toLatin1(), receivedExtraTokens[extraTokenName].toString().toLatin1() ); + if ( receivedExtraTokens.contains( extraTokenName ) ) + { + request.setRawHeader( extraTokens[extraTokenName].toString().replace( '_', '-' ).toLatin1(), receivedExtraTokens[extraTokenName].toString().toLatin1() ); + } } } - } #ifdef QGISDEBUG - msg = u"Updated request HEADER with access token for authcfg: %1"_s.arg( authcfg ); - QgsDebugMsgLevel( msg, 2 ); + msg = u"Updated request HEADER with access token for authcfg: %1"_s.arg( authcfg ); + QgsDebugMsgLevel( msg, 2 ); #endif - break; - } - case QgsAuthOAuth2Config::AccessMethod::Form: - // FIXME: what to do here if the parent request is not POST? - // probably have to skip this until auth system support is moved into QgsNetworkAccessManager - msg = u"Update request FAILED for authcfg %1: form POST token update is unsupported"_s.arg( authcfg ); - QgsMessageLog::logMessage( msg, AUTH_METHOD_KEY, Qgis::MessageLevel::Warning ); - break; - case QgsAuthOAuth2Config::AccessMethod::Query: - if ( !query.hasQueryItem( O2_OAUTH2_ACCESS_TOKEN ) ) - { - query.addQueryItem( O2_OAUTH2_ACCESS_TOKEN, o2->token() ); - url.setQuery( query ); - request.setUrl( url ); + break; + } + case QgsAuthOAuth2Config::AccessMethod::Form: + // FIXME: what to do here if the parent request is not POST? + // probably have to skip this until auth system support is moved into QgsNetworkAccessManager + msg = u"Update request FAILED for authcfg %1: form POST token update is unsupported"_s.arg( authcfg ); + QgsMessageLog::logMessage( msg, AUTH_METHOD_KEY, Qgis::MessageLevel::Warning ); + break; + case QgsAuthOAuth2Config::AccessMethod::Query: + if ( !query.hasQueryItem( O2_OAUTH2_ACCESS_TOKEN ) ) + { + query.addQueryItem( O2_OAUTH2_ACCESS_TOKEN, o2->token() ); + url.setQuery( query ); + request.setUrl( url ); #ifdef QGISDEBUG - msg = u"Updated request QUERY with access token for authcfg: %1"_s.arg( authcfg ); + msg = u"Updated request QUERY with access token for authcfg: %1"_s.arg( authcfg ); #endif - } - else - { + } + else + { #ifdef QGISDEBUG - msg = u"Updated request QUERY with access token SKIPPED (existing token) for authcfg: %1"_s.arg( authcfg ); + msg = u"Updated request QUERY with access token SKIPPED (existing token) for authcfg: %1"_s.arg( authcfg ); #endif - } - QgsDebugMsgLevel( msg, 2 ); - break; + } + QgsDebugMsgLevel( msg, 2 ); + break; + } } return true; @@ -588,9 +597,22 @@ void QgsAuthOAuth2Method::onAuthCode() bool QgsAuthOAuth2Method::updateDataSourceUriItems( QStringList &connectionItems, const QString &authcfg, const QString &dataprovider ) { - Q_UNUSED( connectionItems ) - Q_UNUSED( authcfg ) - Q_UNUSED( dataprovider ) + if ( dataprovider == "postgres"_L1 ) + { + QgsAuthOAuth2Config *config = getOAuth2Bundle( authcfg, true )->oauth2config(); + + connectionItems << u"oauth_issuer=%1"_s.arg( QUrl( config->requestUrl() ).toString( QUrl::RemovePath ) ) + << u"oauth_client_id=%1"_s.arg( config->clientId() ); + + if ( !config->clientSecret().isEmpty() ) + { + connectionItems << u"oauth_client_secret=%1"_s.arg( config->clientSecret() ); + } + if ( !config->scope().isEmpty() ) + { + connectionItems << u"oauth_scope=%1"_s.arg( config->scope().replace( ' '_L1, u"%20"_s ) ); + } + } return true; } diff --git a/src/providers/postgres/CMakeLists.txt b/src/providers/postgres/CMakeLists.txt index 89371a0c758d..3e96a22b5318 100644 --- a/src/providers/postgres/CMakeLists.txt +++ b/src/providers/postgres/CMakeLists.txt @@ -5,6 +5,7 @@ set(PG_SRCS qgspostgresutils.cpp qgspostgresprovider.cpp qgspostgresconn.cpp + qgspostgresconnoauthhandler.cpp qgspostgresconnpool.cpp qgspostgresdataitems.cpp qgspostgresfeatureiterator.cpp @@ -87,6 +88,7 @@ set(PGRASTER_SRCS raster/qgspostgresrastershareddata.cpp raster/qgspostgresrasterutils.cpp qgspostgresconn.cpp + qgspostgresconnoauthhandler.cpp qgspostgresconnpool.cpp qgspostgresprovidermetadatautils.cpp ) diff --git a/src/providers/postgres/qgspostgresconn.cpp b/src/providers/postgres/qgspostgresconn.cpp index 034ae22229ef..cc170090097d 100644 --- a/src/providers/postgres/qgspostgresconn.cpp +++ b/src/providers/postgres/qgspostgresconn.cpp @@ -22,6 +22,7 @@ #include #include "qgsapplication.h" +#include "qgsauthmanager.h" #include "qgscredentials.h" #include "qgsdatasourceuri.h" #include "qgsdbquerylog.h" @@ -29,6 +30,7 @@ #include "qgsjsonutils.h" #include "qgslogger.h" #include "qgsmessagelog.h" +#include "qgspostgresconnoauthhandler.h" #include "qgspostgresconnpool.h" #include "qgspostgresstringutils.h" #include "qgssettings.h" @@ -50,6 +52,11 @@ #include #endif +extern "C" +{ +#include +} + const int PG_DEFAULT_TIMEOUT = 30; static QString quotedString( const QString &v ) @@ -303,8 +310,30 @@ QgsPostgresConn::QgsPostgresConn( const QString &conninfo, bool readOnly, bool s }; addDefaultTimeoutAndClientEncoding( expandedConnectionInfo ); + bool isOAuth = false; + if ( !mUri.authConfigId().isEmpty() ) + { + QgsAuthMethod *method = QgsApplication::authManager()->configAuthMethod( mUri.authConfigId() ); + QStringList items; + if ( method->key() == "OAuth2"_L1 && method->updateDataSourceUriItems( items, mUri.authConfigId(), u"postgres"_s ) ) + { +#if PG_MAJORVERSION_NUM >= 18 + isOAuth = true; + expandedConnectionInfo += u" %1"_s.arg( items.join( " " ) ); +#else + QgsMessageLog::logMessage( + tr( "OAuth authentication requires PostgreSQL 18, this QGIS is built using the PostgreSQL client library version %1.%2, falling back to basic authentication" ).arg( PG_MAJORVERSION_NUM ).arg( PG_MINORVERSION_NUM ), + tr( "PostGIS" ) + ); +#endif + } + } + auto logWrapper = std::make_unique( u"libpq::PQconnectdb()"_s, expandedConnectionInfo.toUtf8(), u"postgres"_s, u"QgsPostgresConn"_s, QGS_QUERY_LOG_ORIGIN_PG_CON ); - mConn = PQconnectdb( expandedConnectionInfo.toUtf8() ); + { + const auto auth = QgsPostgresConnOAuthHandler( mUri.authConfigId() ); + mConn = PQconnectdb( expandedConnectionInfo.toUtf8() ); + } // remove temporary cert/key/CA if they exist QgsDataSourceUri expandedUri( expandedConnectionInfo ); @@ -343,7 +372,7 @@ QgsPostgresConn::QgsPostgresConn( const QString &conninfo, bool readOnly, bool s } // check the connection status - if ( PQstatus() != CONNECTION_OK ) + if ( PQstatus() != CONNECTION_OK && !isOAuth ) { QString username = mUri.username(); QString password = mUri.password(); diff --git a/src/providers/postgres/qgspostgresconnoauthhandler.cpp b/src/providers/postgres/qgspostgresconnoauthhandler.cpp new file mode 100644 index 000000000000..a984263f40db --- /dev/null +++ b/src/providers/postgres/qgspostgresconnoauthhandler.cpp @@ -0,0 +1,115 @@ +/*************************************************************************** + qgspostgresconnoauthhandler.cpp + + --------------------- + begin : 27.12.2025 + copyright : (C) 2025 by Jan Dalheimer + email : jan@dalheimer.de + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "qgspostgresconnoauthhandler.h" + +#include + +#include "qgsapplication.h" +#include "qgsauthmanager.h" +#include "qgsauthmethod.h" +#include "qgslogger.h" +#include "qgsmessagelog.h" + +extern "C" +{ +#include +#include +} + +#ifdef _WIN32 +#define SOCKTYPE uintptr_t /* avoids depending on winsock2.h for SOCKET */ +#else +#define SOCKTYPE int +#endif + +#if PG_MAJORVERSION_NUM >= 18 +std::once_flag postgresInitFlag; +int postgresAuthDataHook( PGauthData type, PGconn *conn, void *data ) +{ + if ( type != PQAUTHDATA_OAUTH_BEARER_TOKEN ) + { + return -1; + } + + PGoauthBearerRequest *request = static_cast( data ); + + if ( !QgsPostgresConnOAuthHandler::currentlyInScope() ) + { + // might happen when connecting from outside of QgsPostgresConn, such as from psycopg in Python + // in practice the connection will likely fail, but at least we won't make anything worse/more confusing this way + return PQdefaultAuthDataHook( type, conn, data ); + } + + const QString authcfg = QgsPostgresConnOAuthHandler::currentAuthCfg(); + QgsAuthMethod *method = QgsApplication::authManager()->configAuthMethod( authcfg ); + if ( method && method->key() == "OAuth2"_L1 ) + { + QNetworkRequest req; + // this _is_ a blocking call, however since it does run an event loop (so that the UI + // stays responsive, in case the connection is established from the main thread) and + // we never actually use the ability in libpq to connect asynchronously (at least not + // in QgsPostgresConn) we should be fine to keep this as a blocking call + const bool success = method->updateNetworkRequest( req, authcfg, u"postgres"_s ); + if ( success ) + { + const QByteArray token = req.rawHeader( "Token"_ba ); + char *tokenStr = new char[token.size() + 1]; + strncpy( tokenStr, token.constData(), token.size() + 1 ); + request->token = tokenStr; + // libpq does not deallocate the token for us + request->cleanup = []( PGconn *, PGoauthBearerRequest *request ) { delete static_cast( request->token ); }; + return 1; + } + else + { + QgsLogger::warning( u"PostgreSQL: OAuth flow failed"_s ); + return -1; + } + } + else + { + QgsMessageLog::logMessage( QgsPostgresConnOAuthHandler::tr( "PostgreSQL server request OAuth authentication, however connection in QGIS is not configured to use OAuth" ), QgsPostgresConnOAuthHandler::tr( "PostGIS" ) ); + return -1; + } + // Success is indicated by returning an integer greater than zero. + return 1; +} +#endif + +thread_local QString QgsPostgresConnOAuthHandler::sAuthCfg; +thread_local bool QgsPostgresConnOAuthHandler::sInScope = false; +QgsPostgresConnOAuthHandler::QgsPostgresConnOAuthHandler( const QString &authcfg ) +{ +#if PG_MAJORVERSION_NUM >= 18 + std::call_once( postgresInitFlag, []() { + PQsetAuthDataHook( postgresAuthDataHook ); + } ); +#endif + + if ( sInScope ) + { + QgsLogger::warning( u"starting second postgres connection attempt while previous is not yet finished"_s ); + } + sAuthCfg = authcfg; + sInScope = true; +} + +QgsPostgresConnOAuthHandler::~QgsPostgresConnOAuthHandler() +{ + sAuthCfg = QString(); + sInScope = false; +} diff --git a/src/providers/postgres/qgspostgresconnoauthhandler.h b/src/providers/postgres/qgspostgresconnoauthhandler.h new file mode 100644 index 000000000000..d0d12ab8290c --- /dev/null +++ b/src/providers/postgres/qgspostgresconnoauthhandler.h @@ -0,0 +1,61 @@ +/*************************************************************************** + qgspostgresconnoauthhandler.h + + --------------------- + begin : 27.12.2025 + copyright : (C) 2025 by Jan Dalheimer + email : jan@dalheimer.de + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#pragma once + +#include +#include + +/** + * Allows passing information about the authcfg to be used for a new database + * connection to the handler called by libpq. + * + * This is mostly a workaround since the PQconnect* functions do not allow + * passing any form of user data (which could otherwise have been used to pass + * the authcfg). + * + * It works by storing the current authcfg in a thread_local variable, which + * can then be retrieved by the handler called by libpq. The code flow from + * PQconnectdb to the handler is synchronous, so by using a thread_local + * variable this workaround should be fully safe against race conditions. + * + * An alternative approach would have been to use PQconnectStart in + * QgsPostgresConn, then storing information about the authcfg for the returned + * PGconn* somewhere where the handler (which is called after PQconnectStart + * has returned) could retrieve it. However, that would require replicating + * the functionality of pqConnectDBComplete which is a non-public function of + * libpq, so unless QgsPostgresConn is rewritten to use a non-blocking approach + * we can use this workaround. + * + * This is a RAII-style class, the only public interface is creation of an + * instance of this class. During the life time of that instance the given + * authcfg is used by the OAuth handler. + */ +class QgsPostgresConnOAuthHandler +{ + Q_DECLARE_TR_FUNCTIONS( QgsPostgresConnOAuthHandler ); + + public: + explicit QgsPostgresConnOAuthHandler( const QString &authcfg ); + ~QgsPostgresConnOAuthHandler(); + + static QString currentAuthCfg() { return sAuthCfg; } + static bool currentlyInScope() { return sInScope; } + + private: + thread_local static QString sAuthCfg; + thread_local static bool sInScope; +};