GmailのIMAPでOAuth2.0を使う in Perl

GmailIMAPログインが失敗するようになってしまった。

Google アカウントが5月30日にセキュリティ強化、Gmailの外部メールアプリ利用などが使えなくなる可能性 - ケータイ Watch

これのことらしい。困りますね。

use strict;
use warnings;
use utf8;

use IO::Socket::SSL;
use Net::IMAP::Client;

my $imap = Net::IMAP::Client->new(
  server => 'imap.gmail.com',
  port   => 993,
  ssl    => 1,
  user   => '[user gmail address]',
  pass   => '[password]',
);
die "Could not connect to IMAP server" unless $imap;

$imap->login or die('Login failed: ' . $imap->last_error);
$ perl gmail_test.pl
Login failed: [AUTHENTICATIONFAILED] Invalid credentials (Failure) at gmail_test.pl line 17.

ユーザーID+パスワードではなく、OAuth2.0を使ってログインする必要があるそうです。
しかしOAuthとかすっかり忘れてしまった。
自分の過去記事でもまとめてるけど、何しろ10年くらい前の話(!)なので勉強し直しですな。。

大まかな手順としては以下のような感じ。

  1. Googleのproject(application)を作成
  2. OAuth2.0用のClient IDを作成
  3. ↑のClient IDから使用するGmailのアカウントでOAuth2.0認証してアクセストークンを取得
  4. アクセストークンを使ってIMAP認証

とりあえず公式ドキュメントのこの辺を読んでおけばよさそう。

動くPerlのコードサンプルとしてはこちらが参考になりました。

lowreal.net

しかしやっぱりWeb周りに関してはPerlよりPythonの方が簡単だなあ。Googleが提供してるライブラリもPerlはないし干されている。

project作成

Google API Consoleを開いてプロジェクトを作成する。
色々と項目があって混乱するけど、実際やることはほとんどない。

  1. project作成
  2. APIs & Servicesを選択
  3. OAuth consent screenを設定
    • Scopesは何も指定しない
    • Publishing status を productionにする
  4. CredentialsからOAuth 2.0 Client IDを作成
    • Application typeはDesktop app
  5. client_secret.json をダウンロード
メモ

Gmail APIを有効にする必要があるかな?と思ったけど特にAPIの有効化やscopeの設定は必要ない。
今回はAPIを使うわけではなく認証するだけ(実際の処理はIMAP/SMTPで行う)だからなのかな?

また、ScopeにGmail API(https://mail.google.com/)を指定するとpublishing statusをproductionにする際verificationが必要になる。verificationには色々手順が必要らしく面倒そう。
support.google.com

しかしpublishing statusがtestのままだと7日ごとにrefresh tokenの取り直し(OAuth2.0の再認証?)が必要になるのでやっぱり面倒そう。

ということで、scopeを空にしてpublishing statusをproductionにする。これだとverificationが不要なので楽だと思う。

Client IDデータ(client_secret.json)

ダウンロードしたclient_secret.jsonはこういう感じのデータになってるので、これをそのまま使います。

redirect_urisはlocalhostになっていますが、"urn:ietf:wg:oauth:2.0:oob"に書き換えておきます。

{
  "installed": {
    "client_id": "XXXXXXXX",
    "project_id": "YYYYYYYY",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://oauth2.googleapis.com/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_secret": "ZZZZZZZZ",
    "redirect_uris": [
      "localhost"
    ]
  }
}

redirect URIの詳細な説明はこちらのドキュメントに載ってます。

github.com

GoogleのOAuth2.0認証

というわけで、参考サイトのドキュメントやサンプルコードを眺めつつOAuth2.0の認証とアクセストークンのrefreshを実装してみました。

use strict;
use warnings;
use utf8;
use Getopt::Std;

use JSON::XS;
use Path::Class qw/file/;
use URI;
use LWP::UserAgent;

my $opt = {};
getopts("r:", $opt);

my $client_secret = JSON::XS->new->utf8->decode(scalar file('client_secret.json')->slurp)->{installed};

if($opt->{r}){
  refresh($client_secret, $opt->{r});
}
else{
  auth($client_secret);
}

sub auth{
  my $client_secret = shift;

  my $auth_uri = URI->new($client_secret->{auth_uri});
  $auth_uri->query_form(
    response_type => 'code',
    client_id     => $client_secret->{client_id},
    redirect_uri  => $client_secret->{redirect_uris}->[0],
    scope         => 'https://mail.google.com/',
  );

  print "To authorize token, visit this url and follow the directions:\n$auth_uri\n";
  print "Enter verification code: ";

  my $authorization_code = <>;
  $authorization_code =~ tr/\x0A\x0D//d;

  my $ua = LWP::UserAgent->new();
  my $token_uri = URI->new($client_secret->{token_uri});
  my $res = $ua->post($token_uri, {
    client_id     => $client_secret->{client_id},
    client_secret => $client_secret->{client_secret},
    code          => $authorization_code,
    redirect_uri  => $client_secret->{redirect_uris}[0],
    grant_type    => 'authorization_code',
  });

  my $res_text = $res->decoded_content;
  my $res_json = JSON::XS->new->utf8->decode($res_text);

  print "\n";
  print "Refresh Token: $res_json->{refresh_token}\n";
  print "Access Token: $res_json->{access_token}\n";
  print "Access Token Expiration Seconds: $res_json->{expires_in}\n";
}

sub refresh{
  my ($client_secret, $refresh_token) = @_;
  my $ua = LWP::UserAgent->new();
  my $token_uri = URI->new($client_secret->{token_uri});
  my $res = $ua->post($token_uri, {
    client_id     => $client_secret->{client_id},
    client_secret => $client_secret->{client_secret},
    refresh_token => $refresh_token,
    grant_type    => 'refresh_token',
  });
  
  my $res_text = $res->decoded_content;
  my $res_json = JSON::XS->new->utf8->decode($res_text);

  print "\n";
  print "Access Token: $res_json->{access_token}\n";
  print "Access Token Expiration Seconds: $res_json->{expires_in}\n";
}

メール受信(IMAP

use strict;
use warnings;
use utf8;

use MIME::Base64;
use IO::Socket::SSL;
use Net::IMAP::Client;
use Data::Dumper;
use Time::Out qw /timeout/;

my $imap = Net::IMAP::Client->new(
  server => 'imap.gmail.com',
  port   => 993,
  ssl    => 1,
  timeout => 10,
);
die "Could not connect to IMAP server" unless $imap;

my $access_token = '[access token]';
my $user = '[user mail]';
my $auth_string = sprintf("user=%s\001auth=Bearer %s\001\001", $user, $access_token);

my $capability = $imap->capability;

my $ok;
timeout 5 => sub {
  ($ok) = $imap->_tell_imap('AUTHENTICATE' => 'XOAUTH2 ' . encode_base64($auth_string, ''));
};
if ($@){
  die("AUTHENTICATE timed out");
}
unless($ok){
  die('Login failed: ' . $imap->last_error);
}
$imap->select('INBOX');

Net::IMAP::Client->_tell_imap()というのが生のIMAPコマンドを投げる内部関数らしいので、これを使ってOAuthの認証情報を送ります。

ちなみにオリジナルのNet::IMAP::Client->loginはこんな感じ。

sub login {
    my ($self, $user, $pass) = @_;
    $user ||= $self->{user};
    $pass ||= $self->{pass};
    $self->{user} = $user;
    $self->{pass} = $pass;
    _string_quote($user);
    _string_quote($pass);
    my ($ok) = $self->_tell_imap(LOGIN => "$user $pass");
    return $ok;
}

このAUTHENTICATEコマンドで間違ったアクセストークンを投げるとエラーを返すと思っていたけど、
実際は応答がなくなってしまう(ハングする)のでTime::Outを使って強制的にタイムアウトさせてる。
コンストラクタにtimeoutオプションがあるけど、これはTCP connectのときのタイムアウトっぽくてコマンドのタイムアウトはしなかった。

メール送信(SMTP

SMTPの場合もOAuth2.0のやり方が書いてあるけどPerlでどう実装するかは要調査。
developers.google.com

とりあえず暫定対応としてユーザーとパスワードでSMTP AUTHができる別なメールサービスを使うことにした。
今後もこういうGoogle独自の認証が増えてくると面倒なので、シンプルなSMTP/IMAPが使えるメールサービスを契約しておくというのは現実的にアリかもしれない。

追記

できた。
kkobayashi-a.hatenablog.com