Googleが, 2017/4/20あたりでWebViewを介したOAuth2.0での認証を弾くようになるらしいので, Qtで作成されるデスクトップアプリケーション向けに, 既定のブラウザを介して認証を通すようにするためのOAuth 2.0 for Mobile & Desktop Appsの実装方法を(自分のために)残します.
OAuth2.0サーバーにリクエストを送る
デスクトップアプリケーションとしてOAuth2.0でアクセストークンを取得するためには, redirect_uriにlocalhostを指定してブラウザ経由でループバックさせる方法を取ります. このため, アクセストークンが欲しいアプリケーション側では, QTcpServerのインスタンスを生成し, redirect_uriに指定したIPアドレスでlistenする必要があります.
// QTcpServerのインスタンスを作成し,
auto server = new QTcpServer(); // このまま放置するとメモリリーク
// listenしている間にコネクションが発生すると呼び出されるシグナルに対応するスロットを接続
connect(server,
SIGNAL(newConnection()),
INSTANCE_WHICH_IMPLEMENTS_SLOT, // newConnection()シグナルを待ち受けるQObjectのインスタンス
SLOT(on_TcpServer_newConnection()));
server->listen(QHostAddress(QHostAddress::LocalHost),
8080); // 待ち受けるポートは適宜環境にあったものを指定する.
OS既定のブラウザをアプリケーションから開き, 認証画面を表示させ, redirect_uriにリダイレクトされるのを待ちます. この時, stateというクエリにハッシュなり適当な識別用文字列なりを付与しておくと, リダイレクトされた時に, どのリクエストによるものなのかの判断がついて楽です.
QUrl url(u8"https://accounts.googleapis.com/o/oauth2/auth");
QUrlQuery query;
// お好きなスコープを設定します.
query.addQueryItem(u8"scope",
u8"https://www.googleapis.com/auth/drive");
query.addQueryItem(u8"redirect_uri",
u8"http://127.0.0.1:8080"); // listenするURI
query.addQueryItem(u8"response_type",
u8"code");
query.addQueryItem(u8"client_id",
CLIENT_ID);
query.addQueryItem(u8"state",
HASH_STRING);
url.setQuery(query);
auto open_process = new QProcess();
// 起動し終わったら自殺してもらうと楽なため, finished(int)シグナルにdeleteLater()スロットを接続している
connect(open_process,
SIGNAL(finished(int)),
open_process,
SLOT(deleteLater()));
QStringList options;
options << url.toString();
open_process->start(u8"open", // 外部プロセスを実行するコマンド (macOS Seirraでしか試していないので他のOS環境では適宜コマンドを変える事
options);
リダイレクトされるレスポンスを解析してAuthorization Codeを取得する.
ブラウザで開いた認証画面で許可または拒否する事で, 指定したredirect_uriにリダイレクトされます. すると, QTcpServerのnewConnection()シグナルに接続したスロットが呼び出されるので, そのスロットにAuthorization Codeを取得する処理を実装します.
QTcpServer::nextPendingConnection()メソッドで接続を待っているソケットを取得します. 接続直後にreadAll()してもデータが入っているとは限らないため, readの準備が整った段階でソケットからreadする必要があります. そこで, QTcpSocket::readyRead()シグナルにスロットを接続して, 必ず読み込み準備が整ってからreadするようにします.
// QtcpServer::newConnection() シグナルに接続したスロット
void
on_TcpServer_newConnection(void)
{
auto server = reinterpret_cast<QTcpServer*>(sender());
while (auto socket = server->nextPendingConnection())
{
connect(socket,
SIGNAL(readyRead()),
INSTANCE_WHICH_IMPLEMENTS_SLOT, // QTcpSocketのreadyRead()シグナルを受け取るスロットを有するインスタンス
SLOT(on_TcpSocket_readyRead()));
connect(socket,
SIGNAL(disconnected()),
socket,
SLOT(deleteLater()));
}
}
QTcpSocketからreadする準備が整ったら, 実際にreadして, 情報を取り出します. この時,
GET /?[パラメータ] HTTP/1.1
[ヘッダ]
のような形のデータになっている場合が認証画面からリダイレクトされた時に受け取るべき情報なので, それ以外の場合は処理を行わないようにします(延々待ち続けてしまうとよくないので適当にタイムアウトを設けるのも良いと思います).
エラーなどなくAuthorization Codeを受け取れていれば, アスセストークンとリフレッシュトークンを取得するリクエストを投げます.
void
on_TcpSocket_readyRead(void)
{
auto socket = reinterpret_cast<QTcpSocket*>(sender());
auto response = socket->readAll();
// レスポンスデータのヘッダ部分を取り出す.
auto response_header = QString(response.left(res.indexOf("\r\n\r\n")));
auto response_header_list = header.split(QString(u8"\r\n"));
// 求めるリダイレクトか否かを判別する. 先に分解したリストの最初の要素が以下の正規表現にマッチしていれば, 求めるリダイレクト.
QRegExp r(u8"^GET /\\?(.+) HTTP/1\\.1");
r.exactMatch(response_header_list[0]);
if (r.captureCount != 1)
{
return;
}
//パラメータは&で結合されているため, &毎に分離する.
QMap<QString, QString> params;
// captureCount()は1だが, capturedTexts()は要素数が2. 0番目にはテキストの全文が入っている.
auto match_list = r.capturedTexts()[1].split(u8"&");
for (const auto& param: match_list)
{
//さらにkey=valueの形式なので, 取り出し易いようにQMapに格納する.
auto eq_idx = param.indexOf(u8"=");
if (eq_idx > 0)
{
params.insert(param.left(eq_idx),
param.mid(eq_idx + 1));
}
}
// stateを指定している場合, 該当のstateでない場合は抜ける.
if (params[u8"state"] != HASH_STRING)
{
return;
}
// error要素があるならエラー処理を行う.
if (params[u8"error"].isEmpty() == false)
{
QMessageBox::warning(nullptr,
u8"error",
params[u8"error"]);
}
else if (params[u8"code"].isEmpty()) // code要素がない場合も異常なのでエラー処理を行う.
{
QMessageBox::warning(nullptr,
u8"error",
u8"code is empty.");
}
else
{
// code要素がある場合はそれがAuthorization Codeなのでアクセストークンとリフレッシュトークンを取得する処理を実行する.
request_token(params[u8"code"]);
}
// リダイレクトした結果の画面表示. これがなくてもアクセストークンは取得できるが, ブラウザ側の表示が悲しい事になるので優しさをみせる. HTMLは適当.
QString data(u8"(HTTP/1.1\nHost: 127.0.0.1:8080\n<!DOCTYPE html><html lang=\"ja\"><head><meta charset=\"utf-8\"><title>Authorization</title></head><body>redirect done.</body></html>)");
socket->write(data.toUtf8());
socket->disconnectFromHost();
}
コード例には示していませんが, Authorization Codeを手にいれるとQTcpServerでlistenする意味もないので, このあたりでcloseすると良いです.
Authorization Codeからアクセストークンとリフレッシュトークンを取得する
QNetworkAccessManagerを利用して, POSTメソッドでトークン取得のリクエストを投げます. レスポンスが来るとQNetworkReply::finished()シグナルが発行されるので, スロットを接続してトークンを取得します.
void
request_token(const QString& authorization_code)
{
QUrlQuery query;
query.addQueryItem(u8"code",
authorization_code);
query.addQueryItem(u8"grant_type",
u8"authorization_code");
query.addQueryItem(u8"client_id",
CLIENT_ID);
query.addQueryItem(u8"client_secret",
CLIENT_SECRET);
query.addQueryItem(u8"redirect_uri",
u8"http://127.0.0.1:8080");
QNetworkRequest request;
request.setRawHeader(u8"Content-Type",
u8"application/x-www-form-urlencoded");
request.setUrl(QUrl(u8"https://www.googleapis.com/oauth2/v4/token"));
// このままだとメモリリークしますので, ちゃんと実装する時はよしなにしてください.
auto network_access_manager = new QNetworkAccessManager();
auto reply = network_access_manager->post(request,
query.toString().toUtf8());
connect(reply,
SIGNAL(finished()),
INSTANCE_WHICH_IMPLEMENTS_SLOT,
SLOT(on_NetworkReply_finished()));
}
レスポンスはjsonで返ってくるので, QJsonDocumentからQJsonObjectを生成し, 各トークンを取得します.
void
on_NetworkReply_finished(void)
{
auto reply = reinterpret_cast<QNetworkReply*>(sender());
auto error_code = reply->error();
if (error_code == QNetworkReply::NetworkError::NoError)
{
auto json_document = QJsonDocument::fromJson(reply->readAll());
auto obj = json_document.object();
/*
obj[u8"access_token"].toString()
obj[u8"refresh_token"].toString()
obj[u8"expires_in"].toString().toInt()
*/
}
}