1Passwordに保管した認証情報を使ってPuppeteerでスクレイピングする

概要

ユーザーID、パスワード、ワンタイムパスワードによる認証が必要なWebサイトの情報をスクレイピングで取得したくなったので、比較的安全そうなやり方として1Passwordから情報を引っ張りながら認証を突破する方法を考えてみました。
WLS2上で動くUbuntu20.04.1で実行しています。
利用するツールは以下の通り。

  • node.js
  • Puppeteer

基本となる上記2つに加えて、node.jsの中からchild_process.execSyncを叩いて以下のOSコマンドを利用します。

4つともググれば相当数の解説記事がヒットするので、個別の説明は控えます。

実装

準備

1Password CLIを入手してパスの通ったところにopという実行ファイルを配置した後、サインインしておきます。

op signin <1Passwordの認証ドメイン> <メールアドレス> <SECRET KEY> 

node.jsの中からopコマンドを呼び出して1Passwordのロックを解除する

まずは1Passwordのロックを解除し、1Passwordのセッショントークンを取得します。
execSyncじゃなくてspawnを使えばexpectに頼らずに対話式の認証を突破できそうな雰囲気があったけど、少し理解に手間取ったので諦めてexpectを使いました。。。(分かる人いたら教えてください)
1Passwordのロック解除に使うパスワードはスクリプト実行時の引数として渡す想定です。結局このパスワードの管理という新しい問題が出てくるのですが、今は1PasswordのパスワードをRundeckのKey Storageに入れて、Rundeckからスクリプトを実行するようにしています。

import { execSync } from 'child_process';

const opPassword = process.argv[2];
const opSession = execSync(`expect -c 'spawn op signin --raw; expect -re Enter; send -- "${opPassword}\\r"; expect EOF exit 0'`, { shell: '/bin/bash' }).toString().split(/[\r\n]+/)[2];

1Passwordから認証情報を取得する

↑で取得したセッショントークンを使って以下のように認証情報を取得できます。

// 1Password上の全アイテムを取得
const items = JSON.parse(execSync(`op list items --session ${opSession} --categories Login`).toString());

// 目的のアイテムを探し、アイテムのuuidを取得
const uuid = items.filter(e => e.overview.title === '目的のアイテムのタイトル')[0].uuid;

// ユーザ名を取得
const username = execSync(`op get item ${uuid} --session ${opSession} --fields username`).toString();

// パスワードを取得
const password = execSync(`op get item ${uuid} --session ${opSession} --fields password`).toString();

// ワンタイムパスワードを取得(標準出力そのままだと改行が含まれてしまうので除去)
const otp = execSync(`op get totp ${uuid} --session ${opSession}`).toString().replace(/[\r\n]/g, '');

Puppeteerでログインする

認証情報が取得できるところまで行ったら、あとはPuppeteerに頑張ってもらうだけです。

// Puppeteerの準備
import * as puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
            args: ['--no-sandbox', '--disable-setuid-sandbox'],
            headless: true,
        });
const page = await browser.newPage();
page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36");
const gotoOpt = { waitUntil: ['load', 'networkidle0', 'domcontentloaded'] };

// ログインボタン等をクリックして次の画面に遷移するのに以下のような関数を用意しておくと便利
async function clickAndWaitForNavigation(selector) {
        const wait = page.waitForNavigation();
        const elem = await page.$(selector);
        await elem.click();
        await wait;
}

// ログインページを開く
await page.goto('ログインページのURL', gotoOpt);

// ユーザ名を1Passwordから取得して入力
const username = execSync(`op get item ${uuid} --session ${opSession} --fields username`).toString();
await (await page.$('input[type=email]')).type(username); // セレクタは適宜変更してください

// パスワードを1Passwordから取得して入力
const password = execSync(`op get item ${uuid} --session ${opSession} --fields password`).toString();
await (await page.$('input[type=password]')).type(password); // セレクタは適宜変更してください

// ↑で定義した関数を使ってログインボタンをクリックし、ワンタイムパスワード入力画面に遷移
await clickAndWaitForNavigation('input[type=submit]'); // セレクタは適宜変更してください

// ワンタイムパスワードを1Passwordから取得して入力
const otp = execSync(`op get totp ${uuid} --session ${opSession}`).toString().replace(/[\r\n]/g, '');
await (await page.$('input[type=tel]')).type(otp); // 今回試したサービスではなぜか`type=tel`だった。セレクタは適宜変更してください

// もう一度ログインボタンをクリック
await clickAndWaitForNavigation('input[type=submit]'); // セレクタは適宜変更してください

あとは目的のページに移動して煮るなり焼くなりするだけなのですが、この状態だとサービスによってはスクレイピングバッチ処理を動かすたびにログインしたよメールが届くので、Cookieを保管してログインを省略するようにしたほうがいいかもしれません。

ログイン後にCookieを保存し、次回以降のログインを省略する

// Cookieの保存先
import * as fs from 'fs'
const cookie_path = 'puppeteer/cookie.txt;

// Puppeteerの準備(省略)

// Cookieのファイルが存在していれば内容をpageにセットする
if (fs.existsSync(cookie_path )) {
    const savedCookies = JSON.parse(fs.readFileSync(cookie_path, 'utf-8'));
        for (let cookie of savedCookies ) {
            await page.setCookie(cookie);
        }
}

// ログアウトのリンクが存在しているか否かによってログイン状態を判定する関数を作成
async function isNotLoggedIn() {
    const logoutLink = await page.$('a[href="/sign_out"]'); // セレクタは適宜変更してください
    return !logoutLink;
}

// スクレイピングしたいページに移動
await page.goto('目的のURL');

if (isNotLoggedIn()) {
    // ログイン処理(省略)
    // ログイン後にCookieを保存
    const cookies = await page.cookies();
    fs.writeFileSync(cookie_path , JSON.stringify(cookies));
}

// スクレイピングの処理