Google Apps ScriptでCloud Functionsに実行制限付でアクセス【GAS】

シリーズでお送りしているわけではないのですが、1つずつが非常に大きなセクションであるため、分割していますが、前回までで「Google Cloud Functionsを使ってパスワードPDF生成」をGASからできるようになりました。しかし、Cloud Functionsはそのままでは、関数実行URLを知ってさえいれば、誰でも実行ができてしまいます。

そこで、他のGoogle CloudのAPIと同様にAccess Tokenを利用した実行制限をつけてみたいと思います。こうする事で、事前に認証用Access Tokenを取得できていなければ、実行が弾かれる仕組みをCloud Functionsの関数に加える事が可能です。

今回使用するスプレッドシート

前回のサンプルシートにコードを追加し、メニューを追加しています。今回実行するのは、gcf_check関数になります。

事前準備

OAuth2.0認証ライブラリの追加

今回のサービスは、OAuth2.0認証が必要です。以下の手順でOAuth2 for Apps Scriptライブラリを追加しましょう。

  1. スクリプトエディタを開きます。
  2. メニューより「リソース」⇒「ライブラリ」を開きます。
  3. ライブラリを追加欄に「1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF」を追加します。
  4. 現時点ではバージョンは30が最新ですので、それを選択しておきます。
  5. 保存ボタンを押して完了

これで、OAuth2.0認証にまつわる様々な関数を手軽に利用できるようになります。

図:ライブラリを追加した様子

プロジェクトを移動

今回の発表直前の2019年4月8日より、Google Apps ScriptからCloud Platform Projectへ直接アクセスが出来なくなりました。これまでにデプロイしてるものについては、これまで通り「リソース」⇒「Google Cloud Platform API ダッシュボード」からアクセスが可能です。

今回の変更はスプレッドシート上で動かすスクリプトやGoogleの拡張サービスを利用しないタイプのスクリプトであれば特に問題はありませんが、「Apps Script API」や「Google Picker API」、「Cloud SQL接続」などGCP上のAPIを利用する場合には以下の手順を踏んで、Google Apps Scriptにプロジェクトを連結する必要があります。これまでは、自動的にGCP上にGoogle Apps Script用のプロジェクトが生成されていたのですが、今後は自分の組織(もしくはGCPプロジェクト)上で作成されたプロジェクトでなければならないということです。詳細はこちらのページを見てください。

連結する手順は以下の通り

  1. Google Cloud Consoleを開く
  2. 左上にある▼をクリックする
  3. ダイアログが出てくるので、新規プロジェクトを作るか?既存のプロジェクトを選択する。この時、G Suiteであれば選択元は「自分のドメイン」を選択する必要があります。
  4. プロジェクト情報パネルから「プロジェクト番号」をコピーする
  5. 対象のGoogle Apps Scriptのスクリプトエディタを開く
  6. 「リソース」⇒「Cloud Platform プロジェクト」を開く
  7. 4.で入手した番号をプロジェクトを変更のテキストボックスに入れて、プロジェクトを設定ボタンをクリックする
  8. 無事に移動が完了すればメッセージが表示されます。
  9. この時、元の自動作成されたプロジェクトはシャットダウンされて消えます。これで設定完了です。

今回のこの変更だと1つ作ったプロジェクトに集約する必要があるので、クォータについてプロジェクト毎のカウントだったので問題なかったものが、集約されることで、クォータに引っ掛かる可能性があります。

図:プロジェクト番号をコピーしておきます

図:プロジェクトを他のプロジェクトに紐付けしました。

図:GCPの拡張サービスを使うには手順が必要になった

サービスアカウントの作成(GCS専用)

今回のスクリプトの準備で最も面倒なのはこのサービスアカウントの作成です。サービスアカウントの作成自体は以前Google Cloud Consoleを弄ってみるの回で紹介しています。今回はサービスアカウント方式を利用しています。請求アカウントは事前に作ってある事としてここでは、サービスアカウントの作成のみを紹介します。

※但しこのサービスアカウントは、GCSアクセス専用のサービスアカウントなので、GCFにアクセスするサービスアカウントとは別です。GCF用は別途用意する必要がありますよ。

  1. スクリプトエディタを開き、「リソース」⇒「Googleの拡張サービス」を開く
  2. ダイアログ下にある「Google Cloud Platform API ダッシュボード」を開く
  3. IAMと管理を開き、サービスアカウントを開きます。
  4. サービスアカウントを作成をクリックし、サービスアカウントの名前を入力。わかりやすい名前をつけましょう。キーのタイプはJSONを選択
  5. サービスアカウントの役割では、今回は何も付与しませんでした。
  6. JSONファイルがダウンロードされるので、これを誰とも共有しない形で、Google Driveにアップロードします。流出すると後で課金で痛い目を見るので絶対に共有はしないでください。今回はファイル名は、authcode.jsonとしました。
  7. アップロードしたJSONファイルのIDを取得する
  8. 次の項目のJSONキーファイルを取得して認証するにて、冒頭の変数jsonの場所にこのIDを入れてあげる。
  9. このアカウント名も控えて於いて、後でCloud Storageのアクセス権限に追加します。

図:サービスアカウントの作成

図:JSONキーファイルをDriveにアップしておく

JSONキーファイルを取得して認証する

//認証用各種変数
var tokenurl = "https://accounts.google.com/o/oauth2/token"
var json = "ここにJSONキーファイルのIDを入力";   //JSON Keyファイルを指定

//OAuth2認証を実行する
function startoauth(){
  //UIを取得する
  var ui = SpreadsheetApp.getUi();
  
  //認証を実行する
  var service = checkOAuth();
  ui.alert("認証が完了し、Access Tokenを取得しました。")
}

//Google DriveにあるサービスアカウントキーのJSONファイルを取得する
function getServiceAccKey(){
  //JSONファイルの中身を取得する
  var content = DriveApp.getFileById(json).getAs("application/json").getDataAsString();
  return JSON.parse(content);
}

//アクセストークン取れてるかどうかチェック
function acctokencheck(){
  var ui = SpreadsheetApp.getUi();
  var service = checkOAuth();
  ui.alert(service.getAccessToken());
}

//OAuth2.0認証を実行する
function checkOAuth() {
  //JSONファイルの中身を取得する
  var privateKeys = getServiceAccKey();
  
  return OAuth2.createService('gcf_authorize:' + Session.getActiveUser().getEmail())
  //アクセストークンの取得用URLをセット
  .setTokenUrl(tokenurl)
  
  //プライベートキーとクライアントIDをセットする
  .setPrivateKey(privateKeys['private_key'])
  .setIssuer(privateKeys['client_email'])
  
  //Access Tokenをスクリプトプロパティにセットする
  .setPropertyStore(PropertiesService.getScriptProperties())
  
  //スコープを設定する
  .setScope('https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/devstorage.read_write');
}
  • この認証方式はサービスアカウントが認証を行うので、いつものユーザが認証するものとは異なり、Access Token取得は自動で行われます。
  • 取得したアクセストークン等の塊は、OAuth2ライブラリ最新版より、スクリプトプロパティではなくユーザプロパティに格納されているので、より安全になっています。塊は、var service = checkOAuth();で呼び出せます。
  • スコープは半角スペースで切ると、複数付加することが可能です。今回はcloud-platformdevstorage.read_writeを繋げています。
  • 本番のプログラムは必ず事前にこの認証を済ませておいて、Access Tokenを取得してから使用開始になります。

Cloud Storageにバケットを用意する

ここまで準備ができたら、あとはCloud Storage側にバケットを用意します。以下の手順でバケットを用意して、アップロード用コードの為のバケット名を取得しておきます。

  1. Cloud Consoleにて、Cloud Storageを開き、バケットの作成をクリック
  2. 名前に半角英数字で命名する。これがバケット名になる。今回は、gcs_proxyという名前にしました。
  3. 作成をクリックして完了
  4. アクセス制御モデルがバケットポリシーのみであったら、ACLもオンにしておく。
  5. このバケットの権限に、予め作成しておいたGCS専用サービスアカウントを登録する。
  6. 5.の権限は、ストレージのレガシーバケットの読み取りのみにします。
  7. 但しこのバケットは認証用に利用するだけなので、何もファイルをアップしません。なのでノー課金です。

図:ストレージのレガシーバケット読み取り権限のみでOK

図:役割追加画面

ソースコード

GAS側コード

//セキュアアクセスチェック
function gcf_check() {
  //uiを取得
  var ui = SpreadsheetApp.getUi();
  
  //Access Tokenを取得(スプレッドシート)
  var accessToken = ScriptApp.getOAuthToken();

  //GCSアクセス用Access Tokenを取得
  var service = checkOAuth();
  var checkacc = service.getAccessToken();
  
  //スプレッドシート情報を取得する
  var sheetname = "送信シート";
  var sheetid = SpreadsheetApp.openById(ssid).getSheetByName(sheetname).getSheetId();

  //送信パラメータを組み立てる
  var payload = {
      "key": ssid,
      "sheetid" : sheetid,
      "sheetname" : sheetname,
      "accesstoken": accessToken,   //スプレッドシートアクセス用のAccess Tokenだよ
      "checkacc" : checkacc,  //GCSアクセス用Access Tokenだよ
      "passwd": "tomatoman",   //PDFのパスワードを設定します。
  };
  
  //POSTで関数を実行する
  var response = UrlFetchApp.fetch(url, {
    method: 'POST',
    headers: {
          Authorization: 'Bearer ' + checkacc
    },
    contentType: "application/json",
    payload : JSON.stringify(payload),
    muteHttpExceptions: true
  });

  ui.alert(response);

}
  • 今回は、前回のコード類は省略しています。認証が通っているかどうかだけを確認します。確認していると、ステータスコード200が返ってくるはずです。(よって、送信パラメータで重要なのは、checkaccの値だけ。
  • 認証でアクセス権限(GCS専用サービスアカウント)がなければ、403が返ってきて、アクセス拒否されます。
  • 但し実際に前回の記事のコードと併用する場合には、スプレッドシートアクセスのAccess TokenとGCSアクセス用のAccess Tokenの2つが必要になるので、取扱い注意!!
  • GCSアクセス用のAccess TokenはBearerとしてheadersに含めて処理をします。

GCF側コード

//追加モジュールを読み込み
const Google = require('googleapis').google;
const BUCKET = 'ここにバケット名を入れる'; 

// メインで実行したい関数
function authorized(res) {
    res.status(200).send("アクセス認証が完了しましたよ。");
}

function getAccessToken(header) {
    if (header) {
        var match = header.match(/^Bearer\s+([^\s]+)$/);
        if (match) {
            return match[1];
        }
    }

    return null;
}

exports.secureFunction = function secureFunction(req, res) {
    var accessToken = getAccessToken(req.get('Authorization'));
    var oauth = new Google.auth.OAuth2();
    oauth.setCredentials({access_token: accessToken});

    var permission = 'storage.buckets.get';
    var gcs = Google.storage('v1');
    gcs.buckets.testIamPermissions(
        {bucket: BUCKET, permissions: [permission], auth: oauth}, {},
        function (err, response) {
            //レスポンスデータを取得する(JSON形式)
            try{
                if(response != 'undefined'){
                  //アクセス権限データを参照する
                  var tempdata = response.data.permissions;

                  if(tempdata.includes(permission)){
                      //アクセス権限があるので、メインの関数を実行する
                      authorized(res);
                  }else{
                      //403エラーを返す
                      res.status(403).send("アクセスが拒否されました。");
                  }
                }else{
                  //403エラーを返す(レスポンスが空の場合)
                  res.status(403).send("アクセスが拒否されました。");
                }
            }catch(err){
              //403エラーを返す
              res.status(403).send("アクセスが拒否されました。");
            }
        }
    );
};
  • 今回はアクセス認証だけを目的としているので、追加モジュールは、googleapisだけです。
  • secureFunctionが実行する関数になります。
  • リクエストヘッダーからAccess Tokenを取り出し、oauth.setCredentialsにてセットしています。
  • GCSへ認証を実行すると、レスポンスの中にstorage.buckets.getのアクセス権のあるものがあれば、認証通過となります。ここでGCS専用サービスアカウントのAccess Tokenがなければ、当然アクセスは拒否されます。
  • 認証が通過したら、authorized関数が実行される仕組みなので、前回のコードはこの関数内に記述することで、OAuth2.0認証でラッピングしたCloud Functionsが完成です。
  • GCFで実行するサービスアカウントは、GCS専用サービスアカウントではないので注意!!一緒にしてはいけません。
  • GCSに対して、サービスアカウントのアクセス権がある場合だけ、dataの中のpermissions[storage.buckets.get]の値が入ってるので、これを持って、GCFの実行権の有無を決める。
  • GCSのバケット権限からサービスアカウントを削除すれば、403が返り、入れれば200が返ってくる仕組みです。
  • Google公式サイトのコードで試してたのですが、なぜか全部403が返ってくるので、上記のようなコードにしています。
  • try - catchをつけている理由は、dataにpermissionsがない場合にエラーとなるため、これを捕捉403エラーを返しています。
{
  "name": "sample-http",
  "version": "0.0.1",
  "dependencies": {
  	"googleapis": "^39.1.0"
  }
}

図:package.jsonの内容はこちら

"{
    "config":{
        "url":"https://www.googleapis.com/storage/v1/b/バケット名/iam/testPermissions?permissions=storage.buckets.get",
        "method":"GET",
        "headers":{
            "Accept-Encoding":"gzip",
            "User-Agent":"google-api-nodejs-client/0.7.2 (gzip)",
            "Authorization":"Bearer accessToken",
            "Accept":"application/json"
        },
        "params":{
            "permissions":["storage.buckets.get"]
        },
        "responseType":"json"
    },
    "data":{
        "kind":"storage#testIamPermissionsResponse",
        "permissions":["storage.buckets.get"]
    },
    "headers":{
        "alt-svc":"quic=\":443\"; ma=zxxxx; v=\"00,00,00,00\"",
            "cache-control":"private, max-age=0, must-revalidate, no-transform","connection":"close",
            "content-length":"96",
            "content-type":"application/json; charset=UTF-8",
            "date":"日付",
            "expires":"失効日",
            "server":"UploadServer","vary":"Origin, X-Origin",
            "x-guploader-uploadid":"xxxxxx"
    },
    "status":200,
    "statusText":"OK"
}"

図:dataの中のpermissionsの有無が重要

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)