【Rxjs基礎講座】rxjs#ajaxでCookie付きのログイン認証を行う


2020/02/27

Nodejs利用してWebスクレイピングをajaxに行おうとする場合には、
axiosなどのhttpクライアントライブラリを利用した記事を良く見かけます。axiosはPromiseベースでコーディングするスタイルですので、rxjsベースのプログラミングで用いる際は、fromPromiseのようなメソッドを利用してラップする必要があります。

そこまでせずとも、rxjs単体でajaxな操作が使える
ajaxメソッドが存在します。

今回はこの
ajaxメソッドを利用して、他のhttpクライアントなしでもRxjsだけでWebスクレイピングが行えることを検証してみます。


前置き(ビルド環境)

以前の記事:Typescript & Babel で rxjs が手軽に試せる砂場(sandbox)を構築するでご紹介したように、rxjsのお手軽に試せるビルド環境の構築します。実際にお手元のPCで動かしてみたい方は、そちらの方からお試しください。


Requirement

クロスドメインやCookieを正しく操作する場合には、node-XMLHttpRequestが最低限必要です。これをプロジェクトへ導入する場合は、

            
            $ yarn add xmlhttprequest
        
でインストールされます。


rxjs/ajaxの基本

まずは本題に入る前に、ajaxメソッドの基本的な使い方をおさらいします。

以前の記事: Typescript & Babel で rxjs が手軽に試せる砂場(sandbox)を構築するでお見せした環境は最初、以下のようになってるとします。

            
            $ tree -I node_modules
.
├── dist
│   ├── index.d.ts
│   ├── index.js
│   └── index.js.map
├── index.ts
├── package.json
├── tsconfig.json
└── yarn.lock
        
ここから、ソースコード用にsrcフォルダを作成し、ajax.tsという名前をつけます。

            
            $ mkdir src && touch src/ajax.ts
$ tree -I node_modules
.
├── dist
│   ├── index.d.ts
│   ├── index.js
│   └── index.js.map
├── index.ts
├── package.json
├── src
│   └── ajax.ts
├── tsconfig.json
└── yarn.lock
        
ソースコードファイルを追加したら、tsconfig.jsonにソースコードの場所がきちんとインクルードされてるか確認・追加します。

            
            {
    "compilerOptions": {
        "target": "es6",
        "module": "commonjs",
        "declaration": true,
        "sourceMap": true,
        "outDir": "./dist",
        "strict": true,
        "moduleResolution": "node",
        "esModuleInterop": true
    },
    "include": [
        "./index.ts",
        "./src/*" // 👈 Add it here
    ],
    "compileOnSave": false
}
        
そして、ajax.tsに以下の内容で編集・保存します。

            
            import { ajax } from 'rxjs/ajax';

const XMLHttpRequest = require("xmlhttprequest").XMLHttpRequest;

export const ajax$ = ajax({
    createXHR: () => new XMLHttpRequest(),
    url: 'https://www.google.com/',
    method: 'GET',
    responseType: 'text'
});
        
次にルートのindex.tsは以下の内容にします。

            
            import { Observable } from 'rxjs';
import { ajax$ } from './src/ajax';

ajax$.subscribe(
    res => console.log(res),
    err => console.log(err)
);
        
それでは、ビルドしてプログラムを走らせてみます。

            
            $ tsc && babel-node dist/index.js
AjaxResponse {
  originalEvent: undefined,
  xhr: {
    UNSENT: 0,
    OPENED: 1,
    HEADERS_RECEIVED: 2,
    LOADING: 3,
    DONE: 4,
    readyState: 4,
...中略
    </body></html>\"
}
        
レスポンスはAjaxResponseのオブジェクトとして応答し、その中身にGoogleのトップページの生htmlが返ってきているのが確認できます。

ajaxメソッドの基本的な利用方法はこんな感じです。


ajaxによるCookieによるログイン

ここからは本題のajaxでCookie認証をどう突破させるのかを順を追ってやっていきます。

ログイン機能をもつウェブサイトへajaxでスクレイピングをするには、少し工夫が必要になります。

また、今回の方法を用いれば、JWTによるログイン認証を行う際にも同じように応用が効くものと思います。

テストサイト

サクッと簡単にCookieログイン試験を行えるよう、以下のサイトを利用させていただきます。

Web Scraper Testing Ground

ユーザー名(
admin)とパスワード(12345)でCookieが発行され、それ以降はそのCookieでログイン状態を保持できる仕様です。

実装

src`src`フォルダにlogin.tsというファイルを作成します。

            
            $ touch src/login.ts
        
このlogin.tsに以下のソースコードで編集保存します。まず、このソースコードでは、パスワードを空にしてログインを試みて、失敗させる時のレスポンスを見てみます。

            
            import { Observable } from 'rxjs';
import { ajax } from 'rxjs/ajax';
import { map } from 'rxjs/operators';

const XMLHttpRequest = require("xmlhttprequest").XMLHttpRequest;

export function login$(): Observable<any> {
    const usr = 'admin';
    const pwd = ''; // 👈 It's wrong on purpose...
    // const pwd = '12345';
    const playgroud = `http://testing-ground.scraping.pro/login?mode=login`;
    const xhr = () => {
        const _xhr = new XMLHttpRequest();
        return _xhr;
    };
    const login$ = ajax({
        createXHR: xhr,
        url: playgroud,
        crossDomain: true,
        withCredentials: true,
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: {
            usr: `${usr}`,
            pwd: `${pwd}`,
        },
        responseType: 'text',
        timeout: 3000,
        async: false // 👈 Important to recieve cookie!
    }).pipe(
        map(res => res.response)
    );
    return login$;
}
        
そして、index.tsを修正します。

            
            import { Observable } from 'rxjs';
import { login$ } from './src/login';

login$().subscribe(
    res => console.log(res),
    err => console.log(err),
);
        
再度ビルドし、プログラムを走らせてみます。

            
            $ tsc && babel-node dist/index.js
<!DOCTYPE html>
#......中略
<div id="case_login">
<h3 class='error'>ACCESS DENIED!</h3><a href='login'>&lt;&lt;&nbsp;GO BACK</a></div>
<br/><br/><br/>
                </div>
    </body>
</html>
        
応答したhtmlの内容にもあるように、ACCESS DENIED!でログインを弾かれているのが分かります。

では、パスワードを再度正しいものに設定します。

上の
login.tsのソースコードで、

            
            //...
    // const pwd = ''; // 👈 It's wrong on purpose...
    const pwd = '12345';
//...
        
と、正しいパスワードに差し替えます。

プログラムを再度走らせると、

            
            $ tsc && babel-node dist/index.js
<!DOCTYPE html>
#....中略
<div id="case_login">
<h3 class='success'>REDIRECTING...</h3><a href='login'>&lt;&lt;&nbsp;GO BACK</a></div>
<br/><br/><br/>
                </div>
    </body>
</html>
        
今度は、REDIRECTING...となり一応はログインできた状態になります。

ただし、ajaxはログイン成功後のリダイレクト先までは面倒を見てくれませんので、手動でリダイレクト先に遷移させる必要があります。

Cookieを受け取る

リダイレクトするためには、発行されたCookieを受けとる必要があります。

発行されたCookieを受け取るために、先ほどの
login.tsを以下のように修正します。

            
            //......中略
export function login$(): Observable<any> {
    const usr = 'admin';
    const pwd = '12345';
    const playgroud = `http://testing-ground.scraping.pro/login?mode=login`;
    const xhr = () => {
        const _xhr = new XMLHttpRequest();
        return _xhr;
    };
    const login$ = ajax({
        createXHR: xhr,
        url: playgroud,
        crossDomain: true,
        withCredentials: true,
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: {
            usr: `${usr}`,
            pwd: `${pwd}`,
        },
        responseType: 'text',
        timeout: 3000,
        async: false // 👈 Important to recieve cookie!
    }).pipe(
        map(res => {
            const cookie: any = res.xhr.getResponseHeader('Set-Cookie');
            const token: string = cookie[0];
            return token;
        })
    );
    return login$;
}
        
Cookieはサーバー側から返ってきたレスポンスヘッダの中に付与されております。

ここでのポイントして、レスポンスインスタンスから
xhr.getResponseHeader('Set-Cookie')でCookieが取得できる点です。

この補正したソースコードでは、
login$ストリームにパイプライン処理の中で、クッキーだけをmapさせて下工程に流します。

再び一旦プログラムを、走らせてこれを確認してみましょう。

            
            $ tsc && babel-node dist/index.js
tdsess=TEST_DRIVE_SESSION
        
と言うことで、tdsess=TEST_DRIVE_SESSIONがCookie値として得られているようです。

サーバー-クライアント間の同期処理

Cookieを付けてAjaxにリダイレクトさせる前に、クッキーを受け取る際のサラッと重要な設定の解説をしておきます。

通常ブラウザは実に様々なHTTP処理をバックグラウンドで行っており、
リダイレクトなどバックグラウンドで同期処理をやってくれている訳ですが、Ajaxで処理する場合には、サーバー側がどのように応答するのかをクライアント側で判断する必要があります。

今回利用させてもらっているテストサイトでは、上のソースコード内のように、ajaxメソッドの設定に
async:falseを指定して、同期的な処理をするようにしなければ、cookieを受け取る前に処理が終了してしまうので注意が必要です。

Cookie送信とログイン

さて、ようやく本題に戻ります。

ログインページから発行されたcookieを使って、リダイレクトページへ手動で遷移させてみましょう。

srcフォルダにredirect.tsと言う名前でソースファイルを追加します。

            
            $ touch src/redirect.ts
        
そして、以下の内容で編集します。

            
            import { Observable } from 'rxjs';
import { ajax } from 'rxjs/ajax';
import { map } from 'rxjs/operators';

const XMLHttpRequest = require("xmlhttprequest").XMLHttpRequest;

export function redirect$(_cookie: string): Observable<any> {
    const playgroud = `http://testing-ground.scraping.pro/login?mode=welcome`;
    const xhr = () => {
        const _xhr = new XMLHttpRequest();
        _xhr.setDisableHeaderCheck(true); // important for redirect to send cookie!
        return _xhr;
    };
    const enter_page$ = ajax({
        createXHR: xhr,
        url: playgroud,
        crossDomain: true,
        withCredentials: true,
        method: 'GET',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Cookie': _cookie
        },
        responseType: 'text',
        timeout: 3000
    }).pipe(
        map(res => res.response)
    );
    return enter_page$;
}
        
次に、index.tsも以下のように書き換えます。

            
            import { Observable } from 'rxjs';
import { concatMap } from 'rxjs/operators';

import { login$ } from './src/login';
import { redirect$ } from './src/redirect';

login$().pipe(
    concatMap(cookie => redirect$(cookie))
).subscribe(
    res => console.log(res),
    err => console.log(err)
);
        
これは、login$ストリームで流したcookie値を利用して、concatMapで後段のredirect$ストリームに流します。

concatMapに関しては、以前のブログで解説しましたので、ご興味がありましたら、
ここのページもご参照ください。

では、実行してみます。

            
            $ tsc && babel-node dist/index.js
<!DOCTYPE html>
#.......中略
<div id="case_login">
<h3 class='success'>WELCOME :)</h3><a href='login'>&lt;&lt;&nbsp;GO BACK</a></div>
<br/><br/><br/>
                </div>
    </body>
</html>
        
無事にWELCOME :)が表示され、ログイン後のリダイレクト先のページへ到達できたようです。

setDisableHeaderCheckメソッド

ここのサイト | Why cookies and set-cookie headers can't be set while making xmlhttprequest using setRequestHeader?で詳しく議論されているように、node-XMLHttpRequestの実装では、セキュリティー強化の観点から、リクエストヘッダーで以下のものが利用不可にされているようです。

            
            var forbiddenRequestHeaders = [
    "accept-charset",
    "accept-encoding",
    "access-control-request-headers",
    "access-control-request-method",
    "connection",
    "content-length",
    "content-transfer-encoding",
    "cookie",
    "cookie2",
    "date",
    "expect",
    "host",
    "keep-alive",
    "origin",
    "referer",
    "te",
    "trailer",
    "transfer-encoding",
    "upgrade",
    "via"
];
        
その中にはcookieも含まれているので、ajaxのheadersで送信指定してもそのままでは送信禁止のエラーが出てしまいます。

そこで救済措置として、
setDisableHeaderCheckを利用して、通常は禁止になっているリクエストヘッダも送信できます。

            
            //...
    const xhr = () => {
        const _xhr = new XMLHttpRequest();
        _xhr.setDisableHeaderCheck(true); // important for redirect to send cookie!
        return _xhr;
    };
//...
        


まとめ

今回は、rxjsのビルドインajaxメソッドで、cookieによるログイン認証の手順を掘り下げてみました。

ajaxメソッドだけでスッキリとhttp通信のストリーム処理ができて、本番用SPAへ開発を@angular/common/httpのモジュールへ切り替える時もシームレスにソースコードを実装できそうです。

あとはrxjsにはcache操作のビルドイン機能があればより実用性のあるhttp処理が可能になります。

ですが、そこまでの複雑な処理を実現しようとおもうとpuppeteerのようなヘッドレスブラウザを操作するライブラリを用いたほうが良いと思います。

pupperteerを導入してみたときの記事は以下を参考ください。

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。