diff --git a/CMakeLists.txt b/CMakeLists.txt index d3df33a89..89821e22b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -465,6 +465,7 @@ set(SOURCES src/core/standardpaths.cpp src/core/httpbaserequest.cpp src/core/jsonbaserequest.cpp + src/core/oauthenticator.cpp src/utilities/strutils.cpp src/utilities/envutils.cpp @@ -862,6 +863,7 @@ set(HEADERS src/core/songmimedata.h src/core/httpbaserequest.h src/core/jsonbaserequest.h + src/core/oauthenticator.h src/tagreader/tagreaderclient.h src/tagreader/tagreaderreply.h diff --git a/src/core/oauthenticator.cpp b/src/core/oauthenticator.cpp new file mode 100644 index 000000000..8964ca381 --- /dev/null +++ b/src/core/oauthenticator.cpp @@ -0,0 +1,609 @@ +/* + * Strawberry Music Player + * Copyright 2022-2025, Jonas Kvinge + * + * Strawberry 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 3 of the License, or + * (at your option) any later version. + * + * Strawberry 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "constants/timeconstants.h" +#include "utilities/randutils.h" +#include "logging.h" +#include "settings.h" +#include "networkaccessmanager.h" +#include "localredirectserver.h" +#include "oauthenticator.h" + +using namespace Qt::Literals::StringLiterals; +using std::make_shared; +using namespace std::chrono_literals; + +namespace { +constexpr char kTokenType[] = "token_type"; +constexpr char kAccessToken[] = "access_token"; +constexpr char kRefreshToken[] = "refresh_token"; +constexpr char kExpiresIn[] = "expires_in"; +constexpr char kLoginTime[] = "login_time"; +constexpr char kUserId[] = "user_id"; +constexpr char kCountryCode[] = "country_code"; +constexpr int kMaxPortInc = 20; +} // namespace + +OAuthenticator::OAuthenticator(const SharedPtr network, QObject *parent) + : QObject(parent), + network_(network), + timer_refresh_login_(new QTimer(this)), + type_(Type::Authorization_Code), + use_local_redirect_server_(true), + random_port_(true), + expires_in_(0LL), + login_time_(0LL), + user_id_(0) { + + timer_refresh_login_->setSingleShot(true); + QObject::connect(timer_refresh_login_, &QTimer::timeout, this, &OAuthenticator::RenewAccessToken); + +} + +OAuthenticator::~OAuthenticator() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->abort(); + reply->deleteLater(); + } + +} + +void OAuthenticator::set_settings_group(const QString &settings_group) { + + settings_group_ = settings_group; + +} + +void OAuthenticator::set_type(const Type type) { + + type_ = type; + +} + +void OAuthenticator::set_authorize_url(const QUrl &authorize_url) { + + authorize_url_ = authorize_url; + +} + +void OAuthenticator::set_redirect_url(const QUrl &redirect_url) { + + redirect_url_ = redirect_url; + +} + +void OAuthenticator::set_access_token_url(const QUrl &access_token_url) { + + access_token_url_ = access_token_url; + +} + +void OAuthenticator::set_client_id(const QString &client_id) { + + client_id_ = client_id; + +} + +void OAuthenticator::set_client_secret(const QString &client_secret) { + + client_secret_ = client_secret; + +} + +void OAuthenticator::set_scope(const QString &scope) { + + scope_ = scope; + +} + +void OAuthenticator::set_use_local_redirect_server(const bool use_local_redirect_server) { + + use_local_redirect_server_ = use_local_redirect_server; + +} + +void OAuthenticator::set_random_port(const bool random_port) { + + random_port_ = random_port; + +} + +QByteArray OAuthenticator::authorization_header() const { + + if (token_type_.isEmpty() || access_token_.isEmpty()) { + return QByteArray(); + } + + return token_type().toUtf8() + " " + access_token().toUtf8(); + +} + +QString OAuthenticator::GrantType() const { + + switch (type_) { + case Type::Authorization_Code: + return u"authorization_code"_s; + break; + case Type::Client_Credentials: + return u"client_credentials"_s; + break; + } + + return QString(); + +} + +void OAuthenticator::LoadSession() { + + Settings s; + s.beginGroup(settings_group_); + token_type_ = s.value(kTokenType).toString(); + access_token_ = s.value(kAccessToken).toString(); + refresh_token_ = s.value(kRefreshToken).toString(); + expires_in_ = s.value(kExpiresIn, 0LL).toLongLong(); + login_time_ = s.value(kLoginTime, 0LL).toLongLong(); + country_code_ = s.value(kCountryCode).toString(); + user_id_ = s.value(kUserId).toInt(); + s.endGroup(); + + StartRefreshLoginTimer(); + +} + +void OAuthenticator::ClearSession() { + + token_type_.clear(); + access_token_.clear(); + refresh_token_.clear(); + expires_in_ = 0; + login_time_ = 0; + country_code_.clear(); + user_id_ = 0; + + Settings s; + s.beginGroup(settings_group_); + s.remove(kTokenType); + s.remove(kAccessToken); + s.remove(kRefreshToken); + s.remove(kExpiresIn); + s.remove(kLoginTime); + s.remove(kCountryCode); + s.remove(kUserId); + s.endGroup(); + + if (timer_refresh_login_->isActive()) { + timer_refresh_login_->stop(); + } + +} + +void OAuthenticator::StartRefreshLoginTimer() { + + if (login_time_ > 0 && !refresh_token_.isEmpty() && expires_in_ > 0) { + const qint64 time = std::max(1LL, expires_in_ - (QDateTime::currentSecsSinceEpoch() - login_time_)); + qLog(Debug) << settings_group_ << "Refreshing login in" << time << "seconds"; + timer_refresh_login_->setInterval(static_cast(time * kMsecPerSec)); + if (!timer_refresh_login_->isActive()) { + timer_refresh_login_->start(); + } + } + +} + +void OAuthenticator::Authenticate() { + + if (type_ == Type::Client_Credentials) { + RequestAccessToken(); + return; + } + + QUrl redirect_url(redirect_url_); + + if (use_local_redirect_server_) { + local_redirect_server_.reset(new LocalRedirectServer(this)); + bool success = false; + if (random_port_) { + success = local_redirect_server_->Listen(); + } + else { + const int max_port = redirect_url.port() + kMaxPortInc; + for (int port = redirect_url.port(); port < max_port; ++port) { + local_redirect_server_->set_port(port); + if (local_redirect_server_->Listen()) { + success = true; + break; + } + } + } + if (!success) { + Q_EMIT AuthenticationFinished(false, local_redirect_server_->error()); + local_redirect_server_.reset(); + return; + } + QObject::connect(&*local_redirect_server_, &LocalRedirectServer::Finished, this, &OAuthenticator::RedirectArrived); + redirect_url.setPort(local_redirect_server_->port()); + } + + code_verifier_ = Utilities::CryptographicRandomString(44); + code_challenge_ = QString::fromLatin1(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding)); + if (code_challenge_.lastIndexOf(u'=') == code_challenge_.length() - 1) { + code_challenge_.chop(1); + } + + ParamList params = ParamList() << Param(u"response_type"_s, u"code"_s) + << Param(u"redirect_uri"_s, redirect_url.toString()) + << Param(u"state"_s, code_challenge_) + << Param(u"code_challenge_method"_s, u"S256"_s) + << Param(u"code_challenge"_s, code_challenge_); + + if (!client_id_.isEmpty()) { + params << Param(u"client_id"_s, client_id_); + } + + if (!scope_.isEmpty()) { + params << Param(u"scope"_s, scope_); + } + + std::sort(params.begin(), params.end()); + + QUrlQuery url_query; + for (const Param ¶m : std::as_const(params)) { + url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first, ";")), QString::fromLatin1(QUrl::toPercentEncoding(param.second, ";"))); + } + + QUrl url(authorize_url_); + url.setQuery(url_query); + + qLog(Debug) << "OAuthenticator" << url.toDisplayString(); + + const bool success = QDesktopServices::openUrl(url); + if (!success) { + QMessageBox messagebox(QMessageBox::Information, tr("Authentication"), tr("Please open this URL in your browser") + QStringLiteral(":
%1").arg(url.toString()), QMessageBox::Ok); + messagebox.setTextFormat(Qt::RichText); + messagebox.exec(); + } + +} + +QNetworkReply *OAuthenticator::CreateRequest(const ParamList ¶ms) { + + QUrlQuery url_query; + for (const Param ¶m : std::as_const(params)) { + url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second))); + } + + QNetworkRequest network_request(access_token_url_); + network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + network_request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s); + if (type_ == Type::Client_Credentials && !client_id_.isEmpty() && !client_secret_.isEmpty()) { + const QString authorization_header = client_id_ + u':' + client_secret_; + network_request.setRawHeader("Authorization", "Basic " + authorization_header.toUtf8().toBase64()); + } + + QNetworkReply *reply = network_->post(network_request, url_query.toString(QUrl::FullyEncoded).toUtf8()); + replies_ << reply; + QObject::connect(reply, &QNetworkReply::sslErrors, this, &OAuthenticator::HandleLoginSSLErrors); + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AccessTokenRequestFinished(reply); }); + + return reply; + +} + +void OAuthenticator::RedirectArrived() { + + if (local_redirect_server_.isNull()) { + return; + } + + if (local_redirect_server_->success()) { + QUrl redirect_url(redirect_url_); + redirect_url.setPort(local_redirect_server_->port()); + AuthorizationUrlReceived(local_redirect_server_->request_url(), redirect_url); + } + else { + Q_EMIT AuthenticationFinished(false, local_redirect_server_->error()); + } + + local_redirect_server_.reset(); + +} + +void OAuthenticator::ExternalAuthorizationUrlReceived(const QUrl &request_url) { + + AuthorizationUrlReceived(request_url, redirect_url_); + +} + +void OAuthenticator::AuthorizationUrlReceived(const QUrl &request_url, const QUrl &redirect_url) { + + if (!request_url.isValid()) { + Q_EMIT AuthenticationFinished(false, tr("Received invalid reply from web browser.")); + return; + } + + if (!request_url.hasQuery()) { + Q_EMIT AuthenticationFinished(false, tr("Redirect URL is missing query.")); + return; + } + + qLog(Debug) << "Authorization URL Received" << request_url.toDisplayString(); + + QUrlQuery url_query(request_url); + + if (url_query.hasQueryItem(u"error_description"_s)) { + Q_EMIT AuthenticationFinished(false, url_query.queryItemValue(u"error_description"_s, QUrl::FullyDecoded)); + return; + } + + if (url_query.hasQueryItem(u"error"_s)) { + Q_EMIT AuthenticationFinished(false, url_query.queryItemValue(u"error"_s)); + return; + } + + if (!url_query.hasQueryItem(u"code"_s)) { + Q_EMIT AuthenticationFinished(false, tr("Request URL is missing code!")); + return; + } + + if (!url_query.hasQueryItem(u"state"_s)) { + Q_EMIT AuthenticationFinished(false, tr("Request URL is missing state!")); + return; + } + + if (url_query.queryItemValue(u"state"_s) != code_challenge_) { + Q_EMIT AuthenticationFinished(false, tr("Request URL has wrong state %1 != %2").arg(url_query.queryItemValue(u"state"_s), code_challenge_)); + return; + } + + RequestAccessToken(url_query.queryItemValue(u"code"_s), redirect_url); + +} + +void OAuthenticator::RequestAccessToken(const QString &code, const QUrl &redirect_url) { + + if (timer_refresh_login_->isActive()) { + timer_refresh_login_->stop(); + } + + ParamList params = ParamList() << Param(u"grant_type"_s, GrantType()); + + if (!code.isEmpty()) { + params << Param(u"code"_s, code); + } + + if (!code_verifier_.isEmpty()) { + params << Param(u"code_verifier"_s, code_verifier_); + } + + if (!code.isEmpty()) { + params << Param(u"redirect_uri"_s, redirect_url.toString()); + } + + if (!client_id_.isEmpty()) { + params << Param(u"client_id"_s, client_id_); + } + + if (!client_secret_.isEmpty()) { + params << Param(u"client_secret"_s, client_secret_); + } + + std::sort(params.begin(), params.end()); + + CreateRequest(params); + +} + +void OAuthenticator::RenewAccessToken() { + + if (timer_refresh_login_->isActive()) { + timer_refresh_login_->stop(); + } + + if (client_id_.isEmpty() || refresh_token_.isEmpty()) { + return; + } + + ParamList params = ParamList() << Param(u"grant_type"_s, u"refresh_token"_s) + << Param(u"client_id"_s, client_id_) + << Param(u"refresh_token"_s, refresh_token_); + + if (!client_secret_.isEmpty()) { + params << Param(u"client_secret"_s, client_secret_); + } + + CreateRequest(params); + +} + +void OAuthenticator::HandleLoginSSLErrors(const QList &ssl_errors) { + + for (const QSslError &ssl_error : ssl_errors) { + qLog(Debug) << ssl_error.errorString(); + } + +} + +void OAuthenticator::AccessTokenRequestFinished(QNetworkReply *reply) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { + const QString error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + Q_EMIT AuthenticationFinished(false, error_message); + } + + if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { + const QByteArray data = reply->readAll(); + if (!data.isEmpty()) { + QJsonParseError json_error; + const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_error); + if (json_error.error == QJsonParseError::NoError && !json_document.isEmpty() && json_document.isObject()) { + const QJsonObject json_object = json_document.object(); + if (json_object.contains("error"_L1) && json_object.contains("error_description"_L1)) { + const QString error = json_object["error"_L1].toString(); + const QString error_description = json_object["error_description"_L1].toString(); + Q_EMIT AuthenticationFinished(false, QStringLiteral("%1 (%2)").arg(error, error_description)); + return; + } + qLog(Debug) << "Unknown Json reply" << json_object; + } + } + if (reply->error() == QNetworkReply::NoError) { + Q_EMIT AuthenticationFinished(false, QStringLiteral("Received HTTP status code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt())); + } + else { + Q_EMIT AuthenticationFinished(false, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + } + return; + } + + const QByteArray data = reply->readAll(); + + QJsonParseError json_error; + const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_error); + if (json_error.error != QJsonParseError::NoError) { + Q_EMIT AuthenticationFinished(false, QStringLiteral("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString())); + return; + } + + if (json_document.isEmpty()) { + Q_EMIT AuthenticationFinished(false, u"Authentication reply from server has empty Json document."_s); + return; + } + + if (!json_document.isObject()) { + Q_EMIT AuthenticationFinished(false, u"Authentication reply from server has Json document that is not an object."_s); + return; + } + + const QJsonObject json_object = json_document.object(); + if (json_object.isEmpty()) { + Q_EMIT AuthenticationFinished(false, u"Authentication reply from server has empty Json object."_s); + return; + } + + if (!json_object.contains("token_type"_L1)) { + Q_EMIT AuthenticationFinished(false, u"Authentication reply from server is missing token type."_s); + return; + } + + if (!json_object.contains("access_token"_L1)) { + Q_EMIT AuthenticationFinished(false, u"Authentication reply from server is missing access token."_s); + return; + } + + token_type_ = json_object["token_type"_L1].toString(); + access_token_ = json_object["access_token"_L1].toString(); + + if (json_object.contains("refresh_token"_L1)) { + refresh_token_ = json_object["refresh_token"_L1].toString(); + } + else { + refresh_token_.clear(); + } + + if (json_object.contains("expires_in"_L1)) { + expires_in_ = json_object["expires_in"_L1].toInt(); + } + else { + expires_in_ = 0; + } + + login_time_ = QDateTime::currentSecsSinceEpoch(); + country_code_.clear(); + user_id_ = 0; + + if (json_object.contains("user"_L1) && json_object["user"_L1].isObject()) { + const QJsonObject object_user = json_object["user"_L1].toObject(); + if (object_user.contains("countryCode"_L1) && object_user.contains("userId"_L1)) { + country_code_ = object_user["countryCode"_L1].toString(); + user_id_ = object_user["userId"_L1].toInt(); + } + } + + Settings s; + + s.beginGroup(settings_group_); + s.setValue(kTokenType, token_type_); + s.setValue(kAccessToken, access_token_); + s.setValue(kLoginTime, login_time_); + + if (refresh_token_.isEmpty()) { + s.remove(kRefreshToken); + } + else { + s.setValue(kRefreshToken, refresh_token_); + } + + if (expires_in_ == 0) { + s.remove(kExpiresIn); + } + else { + s.setValue(kExpiresIn, expires_in_); + } + + if (country_code_.isEmpty()) { + s.remove(kCountryCode); + } + else { + s.setValue(kCountryCode, country_code_); + } + + if (user_id_ == 0) { + s.remove(kUserId); + } + else { + s.setValue(kUserId, user_id_); + } + + s.endGroup(); + + StartRefreshLoginTimer(); + + qLog(Debug) << "OAuthenticator: Authentication was successful, login expires in" << expires_in_; + + Q_EMIT AuthenticationFinished(true); + +} diff --git a/src/core/oauthenticator.h b/src/core/oauthenticator.h new file mode 100644 index 000000000..714eeba03 --- /dev/null +++ b/src/core/oauthenticator.h @@ -0,0 +1,127 @@ +/* + * Strawberry Music Player + * Copyright 2022-2025, Jonas Kvinge + * + * Strawberry 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 3 of the License, or + * (at your option) any later version. + * + * Strawberry 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef OAUTHENTICATOR_H +#define OAUTHENTICATOR_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "includes/shared_ptr.h" + +class QTimer; +class QNetworkReply; +class NetworkAccessManager; +class LocalRedirectServer; + +class OAuthenticator : public QObject { + Q_OBJECT + + public: + explicit OAuthenticator(const SharedPtr network, QObject *parent = nullptr); + ~OAuthenticator() override; + + enum class Type { + Authorization_Code, + Client_Credentials + }; + + void set_settings_group(const QString &settings_group); + void set_type(const Type type); + void set_authorize_url(const QUrl &auth_url); + void set_redirect_url(const QUrl &redirect_url); + void set_access_token_url(const QUrl &access_token_url); + void set_client_id(const QString &client_id); + void set_client_secret(const QString &client_secret); + void set_scope(const QString &scope); + void set_use_local_redirect_server(const bool use_local_redirect_server); + void set_random_port(const bool random_port); + + QString token_type() const { return token_type_; } + QString access_token() const { return access_token_; } + qint64 expires_in() const { return expires_in_; } + QString country_code() const { return country_code_; } + quint64 user_id() const { return user_id_; } + bool authenticated() const { return !token_type_.isEmpty() && !access_token_.isEmpty(); } + + QByteArray authorization_header() const; + + void Authenticate(); + void ClearSession(); + void LoadSession(); + void ExternalAuthorizationUrlReceived(const QUrl &request_url); + + private: + using Param = QPair; + using ParamList = QList; + + QString GrantType() const; + void StartRefreshLoginTimer(); + QNetworkReply *CreateRequest(const ParamList ¶ms); + void RequestAccessToken(const QString &code = QString(), const QUrl &redirect_url = QUrl()); + void RenewAccessToken(); + void AuthorizationUrlReceived(const QUrl &request_url, const QUrl &redirect_url); + + Q_SIGNALS: + void Error(const QString &error); + void AuthenticationFinished(const bool success, const QString &error = QString()); + + private Q_SLOTS: + void RedirectArrived(); + void HandleLoginSSLErrors(const QList &ssl_errors); + void AccessTokenRequestFinished(QNetworkReply *reply); + + private: + const SharedPtr network_; + QScopedPointer local_redirect_server_; + QTimer *timer_refresh_login_; + + QString settings_group_; + Type type_; + QUrl authorize_url_; + QUrl redirect_url_; + QUrl access_token_url_; + QString client_id_; + QString client_secret_; + QString scope_; + bool use_local_redirect_server_; + bool random_port_; + + QString code_verifier_; + QString code_challenge_; + + QString token_type_; + QString access_token_; + QString refresh_token_; + qint64 expires_in_; + qint64 login_time_; + QString country_code_; + quint64 user_id_; + + QList replies_; +}; + +#endif // OAUTHENTICATOR_H