Files
strawberry/src/core/oauthenticator.cpp
2025-03-17 19:42:59 +01:00

608 lines
17 KiB
C++

/*
* Strawberry Music Player
* Copyright 2022-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <algorithm>
#include <QObject>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QDateTime>
#include <QTimer>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QCryptographicHash>
#include <QJsonDocument>
#include <QJsonValue>
#include <QJsonObject>
#include <QJsonParseError>
#include <QDesktopServices>
#include <QMessageBox>
#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<NetworkAccessManager> 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<int>(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 &param : 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);
const bool success = QDesktopServices::openUrl(url);
if (!success) {
QMessageBox messagebox(QMessageBox::Information, tr("Authentication"), tr("Please open this URL in your browser") + QStringLiteral(":<br /><a href=\"%1\">%1</a>").arg(url.toString()), QMessageBox::Ok);
messagebox.setTextFormat(Qt::RichText);
messagebox.exec();
}
}
QNetworkReply *OAuthenticator::CreateRequest(const ParamList &params) {
QUrlQuery url_query;
for (const Param &param : 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) << settings_group_ << "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<QSslError> &ssl_errors) {
for (const QSslError &ssl_error : ssl_errors) {
qLog(Debug) << settings_group_ << 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) << settings_group_ << "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) << settings_group_ << "Authentication was successful, login expires in" << expires_in_;
Q_EMIT AuthenticationFinished(true);
}