452 lines
15 KiB
C++
452 lines
15 KiB
C++
#include <QList>
|
|
#include <QPair>
|
|
#include <QDebug>
|
|
#include <QTcpServer>
|
|
#include <QMap>
|
|
#include <QNetworkRequest>
|
|
#include <QNetworkReply>
|
|
#include <QNetworkAccessManager>
|
|
#include <QDateTime>
|
|
#include <QCryptographicHash>
|
|
#include <QTimer>
|
|
#include <QVariantMap>
|
|
#include <QUuid>
|
|
#include <QDataStream>
|
|
|
|
#include <QUrlQuery>
|
|
|
|
#include "katabasis/DeviceFlow.h"
|
|
#include "katabasis/PollServer.h"
|
|
#include "katabasis/Globals.h"
|
|
|
|
#include "JsonResponse.h"
|
|
|
|
namespace {
|
|
// ref: https://tools.ietf.org/html/rfc8628#section-3.2
|
|
// Exception: Google sign-in uses "verification_url" instead of "*_uri" - we'll accept both.
|
|
bool hasMandatoryDeviceAuthParams(const QVariantMap& params)
|
|
{
|
|
if (!params.contains(Katabasis::OAUTH2_DEVICE_CODE))
|
|
return false;
|
|
|
|
if (!params.contains(Katabasis::OAUTH2_USER_CODE))
|
|
return false;
|
|
|
|
if (!(params.contains(Katabasis::OAUTH2_VERIFICATION_URI) || params.contains(Katabasis::OAUTH2_VERIFICATION_URL)))
|
|
return false;
|
|
|
|
if (!params.contains(Katabasis::OAUTH2_EXPIRES_IN))
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
QByteArray createQueryParameters(const QList<Katabasis::RequestParameter> ¶meters) {
|
|
QByteArray ret;
|
|
bool first = true;
|
|
for( auto & h: parameters) {
|
|
if (first) {
|
|
first = false;
|
|
} else {
|
|
ret.append("&");
|
|
}
|
|
ret.append(QUrl::toPercentEncoding(h.name) + "=" + QUrl::toPercentEncoding(h.value));
|
|
}
|
|
return ret;
|
|
}
|
|
}
|
|
|
|
namespace Katabasis {
|
|
|
|
DeviceFlow::DeviceFlow(Options & opts, Token & token, QObject *parent, QNetworkAccessManager *manager) : QObject(parent), token_(token) {
|
|
manager_ = manager ? manager : new QNetworkAccessManager(this);
|
|
qRegisterMetaType<QNetworkReply::NetworkError>("QNetworkReply::NetworkError");
|
|
options_ = opts;
|
|
}
|
|
|
|
bool DeviceFlow::linked() {
|
|
return token_.validity != Validity::None;
|
|
}
|
|
void DeviceFlow::setLinked(bool v) {
|
|
qDebug() << "DeviceFlow::setLinked:" << (v? "true": "false");
|
|
token_.validity = v ? Validity::Certain : Validity::None;
|
|
}
|
|
|
|
void DeviceFlow::updateActivity(Activity activity)
|
|
{
|
|
if(activity_ == activity) {
|
|
return;
|
|
}
|
|
|
|
activity_ = activity;
|
|
switch(activity) {
|
|
case Katabasis::Activity::Idle:
|
|
case Katabasis::Activity::LoggingIn:
|
|
case Katabasis::Activity::LoggingOut:
|
|
case Katabasis::Activity::Refreshing:
|
|
// non-terminal states...
|
|
break;
|
|
case Katabasis::Activity::FailedSoft:
|
|
// terminal state, tokens did not change
|
|
break;
|
|
case Katabasis::Activity::FailedHard:
|
|
case Katabasis::Activity::FailedGone:
|
|
// terminal state, tokens are invalid
|
|
token_ = Token();
|
|
break;
|
|
case Katabasis::Activity::Succeeded:
|
|
setLinked(true);
|
|
break;
|
|
}
|
|
emit activityChanged(activity_);
|
|
}
|
|
|
|
QString DeviceFlow::token() {
|
|
return token_.token;
|
|
}
|
|
void DeviceFlow::setToken(const QString &v) {
|
|
token_.token = v;
|
|
}
|
|
|
|
QVariantMap DeviceFlow::extraTokens() {
|
|
return token_.extra;
|
|
}
|
|
|
|
void DeviceFlow::setExtraTokens(QVariantMap extraTokens) {
|
|
token_.extra = extraTokens;
|
|
}
|
|
|
|
void DeviceFlow::setPollServer(PollServer *server)
|
|
{
|
|
if (pollServer_)
|
|
pollServer_->deleteLater();
|
|
|
|
pollServer_ = server;
|
|
}
|
|
|
|
PollServer *DeviceFlow::pollServer() const
|
|
{
|
|
return pollServer_;
|
|
}
|
|
|
|
QVariantMap DeviceFlow::extraRequestParams()
|
|
{
|
|
return extraReqParams_;
|
|
}
|
|
|
|
void DeviceFlow::setExtraRequestParams(const QVariantMap &value)
|
|
{
|
|
extraReqParams_ = value;
|
|
}
|
|
|
|
QString DeviceFlow::grantType()
|
|
{
|
|
if (!grantType_.isEmpty())
|
|
return grantType_;
|
|
|
|
return OAUTH2_GRANT_TYPE_DEVICE;
|
|
}
|
|
|
|
void DeviceFlow::setGrantType(const QString &value)
|
|
{
|
|
grantType_ = value;
|
|
}
|
|
|
|
// First get the URL and token to display to the user
|
|
void DeviceFlow::login() {
|
|
qDebug() << "DeviceFlow::link";
|
|
|
|
updateActivity(Activity::LoggingIn);
|
|
setLinked(false);
|
|
setToken("");
|
|
setExtraTokens(QVariantMap());
|
|
setRefreshToken(QString());
|
|
setExpires(QDateTime());
|
|
|
|
QList<RequestParameter> parameters;
|
|
parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8()));
|
|
parameters.append(RequestParameter(OAUTH2_SCOPE, options_.scope.toUtf8()));
|
|
QByteArray payload = createQueryParameters(parameters);
|
|
|
|
QUrl url(options_.authorizationUrl);
|
|
QNetworkRequest deviceRequest(url);
|
|
deviceRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
|
|
QNetworkReply *tokenReply = manager_->post(deviceRequest, payload);
|
|
|
|
connect(tokenReply, &QNetworkReply::finished, this, &DeviceFlow::onDeviceAuthReplyFinished, Qt::QueuedConnection);
|
|
}
|
|
|
|
// Then, once we get them, present them to the user
|
|
void DeviceFlow::onDeviceAuthReplyFinished()
|
|
{
|
|
qDebug() << "DeviceFlow::onDeviceAuthReplyFinished";
|
|
QNetworkReply *tokenReply = qobject_cast<QNetworkReply *>(sender());
|
|
if (!tokenReply)
|
|
{
|
|
qDebug() << "DeviceFlow::onDeviceAuthReplyFinished: reply is null";
|
|
return;
|
|
}
|
|
if (tokenReply->error() == QNetworkReply::NoError) {
|
|
QByteArray replyData = tokenReply->readAll();
|
|
|
|
// Dump replyData
|
|
// SENSITIVE DATA in RelWithDebInfo or Debug builds
|
|
//qDebug() << "DeviceFlow::onDeviceAuthReplyFinished: replyData\n";
|
|
//qDebug() << QString( replyData );
|
|
|
|
QVariantMap params = parseJsonResponse(replyData);
|
|
|
|
// Dump tokens
|
|
qDebug() << "DeviceFlow::onDeviceAuthReplyFinished: Tokens returned:\n";
|
|
foreach (QString key, params.keys()) {
|
|
// SENSITIVE DATA in RelWithDebInfo or Debug builds, so it is truncated first
|
|
qDebug() << key << ": "<< params.value( key ).toString();
|
|
}
|
|
|
|
// Check for mandatory parameters
|
|
if (hasMandatoryDeviceAuthParams(params)) {
|
|
qDebug() << "DeviceFlow::onDeviceAuthReplyFinished: Device auth request response";
|
|
|
|
const QString userCode = params.take(OAUTH2_USER_CODE).toString();
|
|
QUrl uri = params.take(OAUTH2_VERIFICATION_URI).toUrl();
|
|
if (uri.isEmpty())
|
|
uri = params.take(OAUTH2_VERIFICATION_URL).toUrl();
|
|
|
|
if (params.contains(OAUTH2_VERIFICATION_URI_COMPLETE))
|
|
emit openBrowser(params.take(OAUTH2_VERIFICATION_URI_COMPLETE).toUrl());
|
|
|
|
bool ok = false;
|
|
int expiresIn = params[OAUTH2_EXPIRES_IN].toInt(&ok);
|
|
if (!ok) {
|
|
qWarning() << "DeviceFlow::startPollServer: No expired_in parameter";
|
|
updateActivity(Activity::FailedHard);
|
|
return;
|
|
}
|
|
|
|
emit showVerificationUriAndCode(uri, userCode, expiresIn);
|
|
|
|
startPollServer(params, expiresIn);
|
|
} else {
|
|
qWarning() << "DeviceFlow::onDeviceAuthReplyFinished: Mandatory parameters missing from response";
|
|
updateActivity(Activity::FailedHard);
|
|
}
|
|
}
|
|
tokenReply->deleteLater();
|
|
}
|
|
|
|
// Spin up polling for the user completing the login flow out of band
|
|
void DeviceFlow::startPollServer(const QVariantMap ¶ms, int expiresIn)
|
|
{
|
|
qDebug() << "DeviceFlow::startPollServer: device_ and user_code expires in" << expiresIn << "seconds";
|
|
|
|
QUrl url(options_.accessTokenUrl);
|
|
QNetworkRequest authRequest(url);
|
|
authRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
|
|
|
|
const QString deviceCode = params[OAUTH2_DEVICE_CODE].toString();
|
|
const QString grantType = grantType_.isEmpty() ? OAUTH2_GRANT_TYPE_DEVICE : grantType_;
|
|
|
|
QList<RequestParameter> parameters;
|
|
parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8()));
|
|
if ( !options_.clientSecret.isEmpty() ) {
|
|
parameters.append(RequestParameter(OAUTH2_CLIENT_SECRET, options_.clientSecret.toUtf8()));
|
|
}
|
|
parameters.append(RequestParameter(OAUTH2_CODE, deviceCode.toUtf8()));
|
|
parameters.append(RequestParameter(OAUTH2_GRANT_TYPE, grantType.toUtf8()));
|
|
QByteArray payload = createQueryParameters(parameters);
|
|
|
|
PollServer * pollServer = new PollServer(manager_, authRequest, payload, expiresIn, this);
|
|
if (params.contains(OAUTH2_INTERVAL)) {
|
|
bool ok = false;
|
|
int interval = params[OAUTH2_INTERVAL].toInt(&ok);
|
|
if (ok) {
|
|
pollServer->setInterval(interval);
|
|
}
|
|
}
|
|
connect(pollServer, &PollServer::verificationReceived, this, &DeviceFlow::onVerificationReceived);
|
|
connect(pollServer, &PollServer::serverClosed, this, &DeviceFlow::serverHasClosed);
|
|
setPollServer(pollServer);
|
|
pollServer->startPolling();
|
|
}
|
|
|
|
// Once the user completes the flow, update the internal state and report it to observers
|
|
void DeviceFlow::onVerificationReceived(const QMap<QString, QString> response) {
|
|
qDebug() << "DeviceFlow::onVerificationReceived: Emitting closeBrowser()";
|
|
emit closeBrowser();
|
|
|
|
if (response.contains("error")) {
|
|
qWarning() << "DeviceFlow::onVerificationReceived: Verification failed:" << response;
|
|
updateActivity(Activity::FailedHard);
|
|
return;
|
|
}
|
|
|
|
// Check for mandatory tokens
|
|
if (response.contains(OAUTH2_ACCESS_TOKEN)) {
|
|
qDebug() << "DeviceFlow::onVerificationReceived: Access token returned for implicit or device flow";
|
|
setToken(response.value(OAUTH2_ACCESS_TOKEN));
|
|
if (response.contains(OAUTH2_EXPIRES_IN)) {
|
|
bool ok = false;
|
|
int expiresIn = response.value(OAUTH2_EXPIRES_IN).toInt(&ok);
|
|
if (ok) {
|
|
qDebug() << "DeviceFlow::onVerificationReceived: Token expires in" << expiresIn << "seconds";
|
|
setExpires(QDateTime::currentDateTimeUtc().addSecs(expiresIn));
|
|
}
|
|
}
|
|
if (response.contains(OAUTH2_REFRESH_TOKEN)) {
|
|
setRefreshToken(response.value(OAUTH2_REFRESH_TOKEN));
|
|
}
|
|
updateActivity(Activity::Succeeded);
|
|
} else {
|
|
qWarning() << "DeviceFlow::onVerificationReceived: Access token missing from response for implicit or device flow";
|
|
updateActivity(Activity::FailedHard);
|
|
}
|
|
}
|
|
|
|
// Or if the flow fails or the polling times out, update the internal state with error and report it to observers
|
|
void DeviceFlow::serverHasClosed(bool paramsfound)
|
|
{
|
|
if ( !paramsfound ) {
|
|
// server has probably timed out after receiving first response
|
|
updateActivity(Activity::FailedHard);
|
|
}
|
|
// poll server is not re-used for later auth requests
|
|
setPollServer(NULL);
|
|
}
|
|
|
|
void DeviceFlow::logout() {
|
|
qDebug() << "DeviceFlow::unlink";
|
|
updateActivity(Activity::LoggingOut);
|
|
// FIXME: implement logout flows... if they exist
|
|
token_ = Token();
|
|
updateActivity(Activity::FailedHard);
|
|
}
|
|
|
|
QDateTime DeviceFlow::expires() {
|
|
return token_.notAfter;
|
|
}
|
|
void DeviceFlow::setExpires(QDateTime v) {
|
|
token_.notAfter = v;
|
|
}
|
|
|
|
QString DeviceFlow::refreshToken() {
|
|
return token_.refresh_token;
|
|
}
|
|
|
|
void DeviceFlow::setRefreshToken(const QString &v) {
|
|
#ifndef NDEBUG
|
|
qDebug() << "DeviceFlow::setRefreshToken" << v << "...";
|
|
#endif
|
|
token_.refresh_token = v;
|
|
}
|
|
|
|
namespace {
|
|
QByteArray buildRequestBody(const QMap<QString, QString> ¶meters) {
|
|
QByteArray body;
|
|
bool first = true;
|
|
foreach (QString key, parameters.keys()) {
|
|
if (first) {
|
|
first = false;
|
|
} else {
|
|
body.append("&");
|
|
}
|
|
QString value = parameters.value(key);
|
|
body.append(QUrl::toPercentEncoding(key) + QString("=").toUtf8() + QUrl::toPercentEncoding(value));
|
|
}
|
|
return body;
|
|
}
|
|
}
|
|
|
|
bool DeviceFlow::refresh() {
|
|
qDebug() << "DeviceFlow::refresh: Token: ..." << refreshToken().right(7);
|
|
|
|
updateActivity(Activity::Refreshing);
|
|
|
|
if (refreshToken().isEmpty()) {
|
|
qWarning() << "DeviceFlow::refresh: No refresh token";
|
|
onRefreshError(QNetworkReply::AuthenticationRequiredError, nullptr);
|
|
return false;
|
|
}
|
|
if (options_.accessTokenUrl.isEmpty()) {
|
|
qWarning() << "DeviceFlow::refresh: Refresh token URL not set";
|
|
onRefreshError(QNetworkReply::AuthenticationRequiredError, nullptr);
|
|
return false;
|
|
}
|
|
|
|
QNetworkRequest refreshRequest(options_.accessTokenUrl);
|
|
refreshRequest.setHeader(QNetworkRequest::ContentTypeHeader, MIME_TYPE_XFORM);
|
|
QMap<QString, QString> parameters;
|
|
parameters.insert(OAUTH2_CLIENT_ID, options_.clientIdentifier);
|
|
if ( !options_.clientSecret.isEmpty() ) {
|
|
parameters.insert(OAUTH2_CLIENT_SECRET, options_.clientSecret);
|
|
}
|
|
parameters.insert(OAUTH2_REFRESH_TOKEN, refreshToken());
|
|
parameters.insert(OAUTH2_GRANT_TYPE, OAUTH2_REFRESH_TOKEN);
|
|
|
|
QByteArray data = buildRequestBody(parameters);
|
|
QNetworkReply *refreshReply = manager_->post(refreshRequest, data);
|
|
timedReplies_.add(refreshReply);
|
|
connect(refreshReply, &QNetworkReply::finished, this, &DeviceFlow::onRefreshFinished, Qt::QueuedConnection);
|
|
return true;
|
|
}
|
|
|
|
void DeviceFlow::onRefreshFinished() {
|
|
QNetworkReply *refreshReply = qobject_cast<QNetworkReply *>(sender());
|
|
|
|
auto networkError = refreshReply->error();
|
|
if (networkError == QNetworkReply::NoError) {
|
|
QByteArray reply = refreshReply->readAll();
|
|
QVariantMap tokens = parseJsonResponse(reply);
|
|
setToken(tokens.value(OAUTH2_ACCESS_TOKEN).toString());
|
|
setExpires(QDateTime::currentDateTimeUtc().addSecs(tokens.value(OAUTH2_EXPIRES_IN).toInt()));
|
|
QString refreshToken = tokens.value(OAUTH2_REFRESH_TOKEN).toString();
|
|
if(!refreshToken.isEmpty()) {
|
|
setRefreshToken(refreshToken);
|
|
}
|
|
else {
|
|
qDebug() << "No new refresh token. Keep the old one.";
|
|
}
|
|
timedReplies_.remove(refreshReply);
|
|
refreshReply->deleteLater();
|
|
updateActivity(Activity::Succeeded);
|
|
qDebug() << "New token expires in" << expires() << "seconds";
|
|
} else {
|
|
// FIXME: differentiate the error more here
|
|
onRefreshError(networkError, refreshReply);
|
|
}
|
|
}
|
|
|
|
void DeviceFlow::onRefreshError(QNetworkReply::NetworkError error, QNetworkReply *refreshReply) {
|
|
QString errorString = "No Reply";
|
|
if(refreshReply) {
|
|
timedReplies_.remove(refreshReply);
|
|
errorString = refreshReply->errorString();
|
|
}
|
|
|
|
switch (error)
|
|
{
|
|
// used for invalid credentials and similar errors. Fall through.
|
|
case QNetworkReply::AuthenticationRequiredError:
|
|
case QNetworkReply::ContentAccessDenied:
|
|
case QNetworkReply::ContentOperationNotPermittedError:
|
|
case QNetworkReply::ProtocolInvalidOperationError:
|
|
updateActivity(Activity::FailedHard);
|
|
break;
|
|
case QNetworkReply::ContentGoneError: {
|
|
updateActivity(Activity::FailedGone);
|
|
break;
|
|
}
|
|
case QNetworkReply::TimeoutError:
|
|
case QNetworkReply::OperationCanceledError:
|
|
case QNetworkReply::SslHandshakeFailedError:
|
|
default:
|
|
updateActivity(Activity::FailedSoft);
|
|
return;
|
|
}
|
|
if(refreshReply) {
|
|
refreshReply->deleteLater();
|
|
}
|
|
qDebug() << "DeviceFlow::onRefreshFinished: Error" << (int)error << " - " << errorString;
|
|
}
|
|
|
|
}
|