🍛

OAuth2.0とGmail APIを使用して、メールワンタイムパスワード取得を自動化する。

2023/12/08に公開


こんにちは。 Accenture Japan SDETの中西です。

私の所属するSDETでは、APIテストやE2Eテストの自動化を支援する活動を行っています。

とあるプロジェクトで、メールによるワンタイムパスワード(OTP)を取得し、Webアプリケーションに入力して認証する一連の操作を自動化することになりました。
新規アカウント登録や未登録端末からのログインではよくある流れかと思います。
プロジェクト内で既にテスト用のGoogleアカウントが運用されていたため、Gmailを使用して自動的にOTPを取得するメソッドを構築することになりました。

受信したメールの本文からOTPを取得するだけであれば、IDとパスワードを使って、IMAPなどの標準的なプロトコルを使えばそれほど難しくなさそうに思えます。しかし、Gmailに関しては、IDとパスワードのみでアクセスする"安全性の低いアプリ"のサポートは既に終了しており、今後完全に利用できなくなる予定です。

  • 安全性の低いアプリと Google アカウント

    アカウントを安全に保つため、2022 年 5 月 30 日より、Google は、ユーザー名とパスワードのみで Google アカウントにログインするサードパーティ製のアプリとデバイスについてサポートを終了いたします。

  • 安全性の低いアプリから OAuth への移行

    2024年秋以降、管理者とユーザーは Gmail、Google カレンダー、Google コンタクトにアクセスする際にサードパーティ製アプリで OAuthを使用する必要があります。

本記事では、必須化されるOAuth2.0に対応した上で、Gmail APIを通して受信メールを取得し、そこからOTPを抽出する実装例を紹介したいと思います。

想定する読者

  • メール送信/受信を含むE2Eテストを自動化したい方
  • Gmail APIの使用を検討中の方
  • 既にID/パスワード形式でGmailの自動取得を実装されている方

1.OAuth2.0とは?

本論に入る前に、そもそもOAuth2.0ってなんだっけ?どうしてGmailで必須化されるんだっけ?という疑問に簡単にお答えしたいと思います。
OAuthは、Open Authorizationの略で、認可のための標準規格として策定されました。
セキュリティの文脈では、「その人が誰かを検証する」認証(Authentication)と、「その人(やアプリケーション)にリソースへのアクセス権を与える/検証する」認可(Authorization)があり、OAuth2.0は後者のための規格です。

例えば、あるメールクライアントを使ってGmailにアクセスするケースを考えると、そのメールクライアントにはユーザーの代わりにGmailにアクセスし、メールを取得し、送信する権限が必要です。
従来どおりのID/パスワード認証の場合、ユーザーはメールクライアントに対してIDとパスワードを提供する必要があります。この場合、メールクライアントはユーザーと同じ権限を得ることとなります。

これの何が問題か?
まず、メールクライアントがID/パスワードが適切に管理しなければ、第三者による不正アクセスされるリスクもあります。
そして、Gmailを例にとれば、GoogleアカウントのIDとパスワードを共有することになり、メールクライアントはGoogle Driveなど他のGoogleサービスへのアクセスも可能となってしまいます。

ID/パスワードそのものを共有するのではなく、適切な範囲でメールクライアントに権限を与える、つまり"認可"する仕組みが必要です。

そこで、OAuth2.0の出番です。


Google Developersより引用

OAuth2.0では、メールクライアントはID/パスワードではなく、トークンを使ってGmailにアクセスします。
メールクライアントがトークンをサーバーに要求すると、ユーザーにはログイン画面と、続いて同意画面が表示されます。同意画面には認可する範囲が表示され、同意すると、クライアントはトークンを取得することができます。
このトークンは事前に同意されたリソースへのアクセスのみに使用できます。つまり、このトークンでGmailにはアクセスできても、Google Driveなど他のサービスにはアクセスすることができません。

このように、OAuth2.0を使うことで、アプリケーションを適切に認可することが可能となります。
OAuth2.0が認可の仕組みのデファクトスタンダードとなる中で、セキュリティ向上の観点から、従来方式の認証と認可を行うアプリケーションは"安全性の低いアプリ"として、Googleから排除されることになりました。

2.おおまかな構成

それでは、実際にOAuth2.0を使ってGmailAPIにアクセスしていきましょう。
まずは全体の流れを説明します。

  1. クライアントアプリケーション側の準備
    最初に、クライアントアプリケーションの登録を行います。例えるなら、Microsoft OutlookやApple Mail同様に、サードパーティメールクライアントとして、Googleに新規届け出を行うイメージです。GmailAPIは、事前に登録されたクライアントからのみOAuth2.0を使ってアクセスすることができます。まずはGoogle Cloudに登録を行い、トークンの取得に必要なクライアントIDとクライアントシークレットを取得しましょう。

  2. テストユーザー側の準備
    実際にテストに使用するユーザーのGmailアカウントから、1.で登録したクライアントアプリケーションに対してアクセスを許可します。その後、Googleが公開しているgmail-oauth2-tools使用し、アクセストークンを取得します。アクセストークンが取得できれば、ユーザーに代わってメールを取得する権限が得られたことになります。
    また、ここではアクセストークンを更新するためのリフレッシュトークンも取得します。アクセストークンの有効期間は1時間と短命なため、リフレッシュトークンを使って、定期的に更新する必要があります。

  3. メールへのアクセスとOTPの取得
    入手したアクセストークンを使って、GmailAPIを通じたメールの取得を行います。ここまで来れば、あとはメール本文のテキストを処理して、OTPを抽出するだけです。本記事では、Javaでの実装を紹介します。

3.クライアントアプリケーション側の準備

クライアントアプリケーションの準備として、Google Cloudにて、Gmail APIによるGmailのコンテンツの編集権限を持つアプリケーションの新規登録を行います。

Gmailアカウントの取得

Gmailアカウントを少なくとも1つ、準備してください。
後述の手順で、クライアント登録するためのアカウントと、ユーザーのアカウントが必要となりますが、同じアカウントを使用しても問題ありません。

Google CloudでのOAuth2.0クライアント登録

下記の手順でGoogle cloudに登録し、クライアントIDとクライアントシークレットを取得します。

  1. https://console.cloud.google.com/?hl=ja にアクセスします。

  2. 新規プロジェクトを作成します。

  3. ライブラリからGmail APIを検索し、有効化してください。

  4. 認証情報を作成します。

    • Gmailのデータの内容を読み込むために、ユーザーデータを選択します。
    • アプリ名、ユーザーサポートメール、デベロッパーの連絡先を入力します。今回は内部的な利用なので、適当なメールアドレスを入力すればOKです。
    • スコープの追加または削除から、Gmail APIの".../auth/gmail.modify"を追加します。
    • アプリケーションの種類は"Webアプリケーション"とし、適当な名前を入力後、リダイレクトURIに https://oauth2.dance/ を追加します。このリダイレクト先設定は後述するgmail-oauth2-toolsを使う上で必要となります。
    • 認証情報の作成が完了したら「クライアントID」と「クライアントシークレット」をダウンロードしてください。
  5. クライアント登録に使用したGoogleアカウントと、テストに使用するGoogleアカウントが異なる場合は、OAuth同意画面(OAuth consent screen)に移動し、テストユーザーの追加を行います。

以上でクライアント側の設定は完了です。

4.テストユーザー側の準備

続いて、テストユーザー側の設定を行います。
Googleから公開されているgmail-oauth2-toolsを利用して、トークンの取得を行います。

リフレッシュトークンの取得

  1. gmail-oauth2-toolsをCloneします。
  2. 事前に取得したクライアントIDとクライアントシークレットを使って、 python/oauth.pyを実行します。
      oauth2 --user=xxx@gmail.com \
        --client_id=1038[...].apps.googleusercontent.com \
        --client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \
        --generate_oauth2_token
    
  3. コンソールにURLが表示されるので、ブラウザでアクセスします。Googleのログイン画面に遷移するので、テスト用のGoogleアカウントでログインしてアクセス許可を与えます。
  4. リダイレクト先に表示されたAuthorisation Codeをコンソールに入力します。
  5. コンソールに出力されたリフレッシュトークンとアクセストークンを保存します。

以上でユーザー側の準備は完了となります。

5.いざ、自動化

ここまでで取得したアクセストークンを使えば、いよいよGmail APIを叩いてGmailの操作が可能です。
しかし、アクセストークンの有効期限は1時間です。自動的にOTPを取得するためには、定期的にアクセストークンを更新する仕組みも必要です。
先程のpython/oauth2.pyを使えば、リフレッシュトークンからアクセストークンを再生成することができます。

  oauth2 --user=xxx@gmail.com \
    --client_id=1038[...].apps.googleusercontent.com \
    --client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \
    --refresh_token=1/Yzm6MRy4q1xi7Dx2DuWXNgT6s37OrP_DW_IoyTum4YA

このスクリプトを毎度実行しても良いのですが、今回、私が携わったプロジェクトでは主にJavaを使ってテスト自動化を行っていたため、このアクセストークンの取得についてもJavaで実装したいと思います。

アクセストークンの取得自動化

python/oauth2.pyの実装を見ると、https://accounts.google.com/o/oauth2/tokenにPostリクエストを投げ、アクセストークンを取得しているようです。Javaで同様に実装してみます。

    private static String getRefreshedAccessToken(String clientId, String clientSecret, String refreshToken) {
        Map<String, Object> params = new HashMap<>();
        params.put("client_id", clientId);
        params.put("client_secret", clientSecret);
        params.put("refresh_token", refreshToken);
        params.put("grant_type", "refresh_token");

        String requestUrl = "https://accounts.google.com/o/oauth2/token";

        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
            HttpPost httpPost = new HttpPost(requestUrl);
            httpPost.setHeader("Content-type", "application/json");
            httpPost.setEntity(new StringEntity(new Gson().toJson(params)));

            HttpResponse response = httpClient.execute(httpPost);

            if (response.getStatusLine().getStatusCode() != 200) {
                throw new RuntimeException("Received error response from server: " + EntityUtils.toString(response.getEntity()));
            }

            String responseBody = EntityUtils.toString(response.getEntity());
            return new Gson().fromJson(responseBody, Map.class).get("access_token").toString();

        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

このメソッドに、クライアントID、クライアントシークレット、リフレッシュトークンを入力することで、アクセストークンをStringで取得できるようになりました。

Gmailにアクセスし、メール本文からOTPを取得する。

続いて、Gmailから本文を取得し、OTPを取得したいと思います。
今回は簡単のため、最新のメール1件のみを取得し、正規表現でOTPを抽出する単純な処理とします。
下記のようなメールを受信し、そこからOTP部分を取り出す想定です。

Subject: Your One Time Password
Your one time password for accessing the portal is as follows:
[OTP] 123456
This password will expire in 10 minutes.

import com.google.gson.Gson;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.*;
import org.apache.http.util.EntityUtils;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.mail.*;
import javax.mail.internet.MimeMultipart;

public class Main {

    public static void main(String[] args) throws IOException, MessagingException {
        String username = "YOUR_GOOGLE_ACCOUNT@gmail.com";
        String clientId = "YOUR_CLIENT_ID.apps.googleusercontent.com";
        String clientSecret = "YOUR_CLIENT_SECRET";
        String refreshToken = "YOUR_REFRESH_TOKEN";

        //  アクセストークンの取得
        String oauth2_access_token = getRefreshedAccessToken(clientId, clientSecret, refreshToken);

        //  Gmailへの接続
        Properties props = new Properties();
        props.put("mail.imap.ssl.enable", "true"); // required for Gmail
        props.put("mail.imap.auth.mechanisms", "XOAUTH2");
        Session session = Session.getInstance(props);
        Store store = session.getStore("imap");
        store.connect("imap.gmail.com", username, oauth2_access_token);

        //  メールの取得とOTPの抽出
        Folder inbox = store.getFolder("inbox");
        inbox.open(Folder.READ_ONLY);
        int messageCount = inbox.getMessageCount();
        Message message = inbox.getMessage(messageCount);
	String content = getMessageContent(message);
	String oneTimeKey = getOneTimeKey(content);
	System.out.println("From: " + message.getFrom()[0]);
	System.out.println("Subject: " + message.getSubject());
	System.out.println("Received date: " + message.getReceivedDate());
	System.out.println("One Time Key: " + oneTimeKey);

        inbox.close(false);
        store.close();

    }

    private static String getRefreshedAccessToken(String clientId, String clientSecret, String refreshToken) {
        Map<String, Object> params = new HashMap<>();
        params.put("client_id", clientId);
        params.put("client_secret", clientSecret);
        params.put("refresh_token", refreshToken);
        params.put("grant_type", "refresh_token");

        String requestUrl = "https://accounts.google.com/o/oauth2/token";

        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
            HttpPost httpPost = new HttpPost(requestUrl);
            httpPost.setHeader("Content-type", "application/json");
            httpPost.setEntity(new StringEntity(new Gson().toJson(params)));

            HttpResponse response = httpClient.execute(httpPost);

            if (response.getStatusLine().getStatusCode() != 200) {
                throw new RuntimeException("Received error response from server: " + EntityUtils.toString(response.getEntity()));
            }

            String responseBody = EntityUtils.toString(response.getEntity());
            return new Gson().fromJson(responseBody, Map.class).get("access_token").toString();

        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private static String getMessageContent(Message message) throws MessagingException, IOException {
        if (message.getContent() instanceof MimeMultipart) {
            MimeMultipart mimeMultipart = (MimeMultipart) message.getContent();
            for (int i = 0; i < mimeMultipart.getCount(); i++) {
                BodyPart bodyPart = mimeMultipart.getBodyPart(i);
                if (bodyPart.isMimeType("text/plain")) {
                    return (String) bodyPart.getContent();
                }
            }
        } else if (message.getContent() instanceof String) {
            return (String) message.getContent();
        }
        return null;
    }

    private static String getOneTimeKey(String text) {
        Pattern keyPattern = Pattern.compile("\\[OTP\\]\\s*(\\d+)");
        Matcher keyMatcher = keyPattern.matcher(text);
        if (keyMatcher.find()) {
            return keyMatcher.group(1);
        }
        return "";
    }
}

実行結果

From: OTP SERVICE <dummy@example.com>
Subject: Your One Time Password
Received date: Wed Nov 22 10:22:40 JST 2023
One Time Key: 123456

無事、OTPが取得できましたね。

6.まとめ

本記事では、Gmailからワンタイムパスワード(OTP)を自動的に取得する方法を紹介しました。

昨今、セキュリティ向上のためにOAuth2.0の使用を義務化するサービスは少なくありません。そもそも今回の話の起点であるOTPの導入も、セキュリティ向上の一環です。これらはセキュリティの観点から言えば歓迎すべきなのですが、私のようにUIテストを自動化したい人間にとっては、考慮するべき事項が増えたとも感じます。

私が経験した実プロジェクトでも、記事内で紹介したものと同様のメソッドを構築し、OTP入力を含むE2Eテストシナリオを自動化しています。テスト環境用のメールサーバーなどを準備しない場合、簡易的にGoogleアカウントを取得してテストユーザー用に使う事例はままあるのではないでしょうか。今回の方法では、7日ごとにリフレッシュトークンを更新するする必要がありますが、適切にID/パスワードを管理できる前提であれば、リフレッシュトークンの更新自体も自動化する手もあるかと思います。

この記事が皆様のテスト構築の一助になれば幸いです。

それでは皆様、Happy Testing!

仲間募集

Accenture Japan QE&Aでは、テスト自動化を通じてお客様の変革を支援しています。
同じビジョンを共有する仲間を募集しています!
https://www.accenture.com/jp-ja/careers/jobdetails?id=R00091947_ja

Accenture Japan SDET (Voluntary)

Discussion