qr-send

スマホとPCのChrome間で、URLやテキストをQRコード1回のペアリングで送り合える自作Chrome拡張。アカウント不要・無料。

使ってみる →

SeleniumでログインCookieを保存・復元してログインを省略する(Rubyの実装とハマりどころ)

Selenium・自動化

毎回ログインし直すのはコストが高い

Rubyで書いたSeleniumの自動化スクリプトを定期的に動かしていると、「実行のたびにログインフォームへID・パスワードを入力する」処理がボトルネックになってきます。ログイン処理自体に数秒かかるだけでなく、頻繁にログインを繰り返すとサービス側のセキュリティ判定に引っかかりやすくなり、追加認証を求められることもあります。

一度ログインしてしまえば、あとはブラウザが保持しているCookieをそのまま次回以降のセッションに使い回せれば十分なケースがほとんどです。そこで、ログイン成功後にCookieをファイルへ保存しておき、次回起動時にはログインフォームを経由せずCookie復元だけでログイン済み状態を再現する仕組みを作りました。考え方自体はシンプルですが、実装するといくつか踏み抜きやすい罠があります。

Cookieはそのまま保存・復元できない

SeleniumでCookieを取得するにはdriver.manage.all_cookiesを使いますが、返ってくるCookieオブジェクトにはexpiressame_sitehttpOnlyといったフィールドまで含まれています。これをそのままJSONに保存して、復元時にadd_cookieへ丸ごと渡すとエラーになります。add_cookieが受け付けるのはnamevaluedomainpathsecure程度で、それ以外のキーが混ざっているだけで例外を投げるドライバ実装があるためです。

そのため、保存時点で必要なフィールドだけを抽出してクリーニングしておきます。

def clean_cookie_for_save(raw_cookie)
  hash = raw_cookie.is_a?(Hash) ? raw_cookie : raw_cookie.to_h
  name = hash['name'] || hash[:name]
  value = hash['value'] || hash[:value]
  return nil unless name && !name.empty? && value

  cleaned = { 'name' => name, 'value' => value }
  domain = hash['domain'] || hash[:domain]
  cleaned['domain'] = domain if domain

  path = hash['path'] || hash[:path]
  cleaned['path'] = path if path

  secure = hash['secure'] || hash[:secure]
  cleaned['secure'] = secure if [true, false].include?(secure)

  cleaned
end

復元側でも同様に、保存ファイルから読み込んだハッシュのうちadd_cookieが確実に受け付けるキーだけを組み立て直してから渡します。

def build_cookie_for_restore(saved)
  cookie = { name: saved['name'], value: saved['value'] }
  cookie[:domain] = saved['domain'] if saved['domain'] && !saved['domain'].empty?
  cookie[:path] = saved['path'] if saved['path'] && !saved['path'].empty?
  cookie[:secure] = true if saved['secure'] == true
  cookie
end

expiressame_siteを残したくなる気持ちはわかりますが、復元後のセッション維持にはほとんど寄与しない一方、エラーの温床になります。復元に必要な最小限のフィールドだけを渡すのが安全です。

保存はアトミックに書き込む

Cookie保存ファイルを直接上書きしていると、書き込みの途中でプロセスが強制終了した場合に壊れたJSONが残ってしまい、次回起動時にパースエラーで詰まります。定期実行しているバッチだと「気づいたら保存ファイルが壊れていて、以降ずっとログインし直しになっていた」ということが起こり得ます。

これを避けるため、一時ファイルに書き込んでからFile.renameで本来のパスに差し替える、いわゆるアトミック書き込みにしています。

def save_cookies(driver, session_key)
  cleaned = driver.manage.all_cookies.map { |c| clean_cookie_for_save(c) }.compact
  return false if cleaned.empty?

  data = {
    'cookies' => cleaned,
    'saved_at' => Time.now.to_s,
    'session_hash' => session_hash_for(session_key)
  }

  tmp_path = SESSION_FILE + '.tmp'
  File.write(tmp_path, JSON.pretty_generate(data))
  File.rename(tmp_path, SESSION_FILE)
  true
rescue => e
  File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
  warn "Cookie保存に失敗しました: #{e.message}"
  false
end

同じファイルパスに対するrenameはファイルシステム上ほぼ瞬間的に完了するため、書き込み途中の中途半端な状態が外から見えることがありません。設定ファイルやキャッシュファイルなど、次回起動時に読み込む前提のファイルは基本的にこの形で保存しておくと安全です。

復元は「対象ドメインを開いてから」行う

Cookieの復元でつまずきやすいのが、ドライバがまだどのページも開いていない(about:blankのような)状態でadd_cookieを呼んでしまうケースです。ブラウザはCookieをドメインに紐づけて管理しているため、対象ドメインのページを一度開いてからでないと、正しいドメインにCookieを関連付けることができずSeleniumがエラーを返します。

そのため復元処理では、必ず先に対象サービスのトップページなどへ一度アクセスしてからadd_cookieを呼ぶ手順を踏みます。

def restore_session(driver, session_key)
  return false unless File.exist?(SESSION_FILE)

  data = JSON.parse(File.read(SESSION_FILE))
  return false unless data['session_hash'] == session_hash_for(session_key)

  cookies = data['cookies'] || []
  return false if cookies.empty?

  driver.get('https://example.com/') # 先にドメインを開いておく

  restored = 0
  cookies.each do |saved|
    begin
      driver.manage.add_cookie(build_cookie_for_restore(saved))
      restored += 1
    rescue Selenium::WebDriver::Error::InvalidCookieDomainError
      next
    end
  end

  restored.positive? && important_cookies_present?(driver)
end

ここでInvalidCookieDomainErrorを個別にrescueしているのもポイントです。保存したCookieの中には、サブドメインをまたいで発行されたものなど、現在開いているページのドメインと厳密には一致しないものが混ざることがあります。1件のエラーで処理全体を止めるのではなく、失敗したCookieだけスキップして残りの復元を続けます。

「重要Cookieが1つも無い」をセッション無効の合図にする

add_cookieが例外を投げなかったからといって、そのCookieが実際にログイン状態を再現しているとは限りません。ドメインの微妙な違いや有効期限切れなどで、見た目上は追加できても実質的に無意味なCookieになっていることがあります。

そこで、復元後にブラウザへ実際に反映されたCookie一覧を取得し、そのサービスのログイン状態維持に必須と分かっている特定の名前のCookieが一つも含まれていない場合は、復元自体が失敗したと判定するようにしました。

IMPORTANT_COOKIE_NAMES = ['session_id', 'auth_token'].freeze # サービスごとに実際の名前へ置き換える

def important_cookies_present?(driver)
  actual_names = driver.manage.all_cookies.map { |c| c['name'] || c[:name] }
  (actual_names & IMPORTANT_COOKIE_NAMES).any?
end

重要なCookie名は、事前に手動ログインしてブラウザの開発者ツールなどで「ログイン状態のときだけ存在するCookie」を特定しておく必要があります。全Cookieのうち1つでもこの名前が含まれていれば復元成功、1つも無ければ「Cookieの数はゼロではないが実質的にセッションは無効」と判断してログイン処理にフォールバックします。件数だけを見て判定すると、期限切れの不要なCookieが大量に残っているだけのケースを成功と誤判定してしまうため、名前で中身を確認するのが確実です。

アカウントとセッションファイルを紐付ける

複数アカウントを扱う自動化では、保存したCookieファイルが「どのアカウントのものか」を区別できないと、別アカウントのCookieを誤って復元してしまう事故につながります。今回はアカウントを特定する文字列(メールアドレスなど)のハッシュ値をセッションファイルに埋め込み、復元時にハッシュが一致するかを必ず確認するようにしています。

require 'digest/md5'

def session_hash_for(session_key)
  Digest::MD5.hexdigest(session_key)
end

平文のメールアドレスをそのままファイルに残さず、ハッシュ化した値だけを持たせることで、セッションファイル単体が漏洩した場合の情報量も抑えられます。

まとめ

  • add_cookieは必須フィールド(name/value/domain/path/secure程度)だけを渡す。expiressame_sitehttpOnlyを含めるとエラーになることがある
  • Cookie保存はtmpファイルへ書き込んでからrenameするアトミック書き込みにして、書き込み途中のクラッシュによるファイル破損を防ぐ
  • Cookie復元は対象ドメインのページを開いた後に行う。ドメイン未確定の状態では正しく関連付けられない
  • 復元後、ログイン状態を裏付ける特定Cookie名が1つも見つからなければ「復元失敗」と判定し、通常ログインにフォールバックする
  • アカウント識別子のハッシュをセッションファイルに埋め込み、別アカウントのCookieを誤って使い回さないようにする

コメント

タイトルとURLをコピーしました