Flutter で dio を使って、自己署名証明書のWebフォームにCSRFトークンつきでログインする

Webアプリの認証がWebフォームになっているサービスに、Flutter のアプリでログインしたい(ログインセッションを作りたい) と思って調べてコードを書きました。

サーバサイドのSSL証明書が自己署名証明書でなければ、WebView系のプラグインでログインフォームを表示させ、ログイン完了後にセッションクッキーをもらえば良さそうです。が、今回は検証目的のアプリで、サーバサイドは自己署名証明書なのでWebViewでの表示が難しそうだったため、コード中でHTTPリクエストを行ってログインすることにしました。

HTTPライブラリについて、python の requests session のような使用感のライブラリが無いか調べた所、dio + cookie-jar が良さそうでしたので使ってみました。

FlutterでWebスクレイピングしたい場合などでも使える方法だと思います。

自己署名証明書の署名検証回避については、署名の検証をせず信用していますので、中間者攻撃などに脆弱であり、一般配布するアプリには使えない方法です。

# Dio
httpクライアント。機能やプラグインが豊富。cookie_jar を注入するとクッキーの取り回しをしてくれるので、python の requests のような使い勝手で使えて良いです。

https://pub.dev/packages/dio


# サンプルコード

```yaml:pubspeck.yaml
dependencies:
  dio: ^3.0.7
  dio_cookie_manager: ^1.0.0
  cookie_jar: ^1.0.1
```

```dart:main.dart
import 'dart:io';

import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:flutter/material.dart';
import 'package:cookie_jar/cookie_jar.dart';

// ※1 署名検証回避
// refs. https://github.com/flutterchina/dio/issues/32
class MyHttpOverrides extends HttpOverrides {
  @override
  HttpClient createHttpClient(SecurityContext context) {
    HttpClient client = super.createHttpClient(context)
      ..badCertificateCallback =
        (X509Certificate cert, String host, int port) => host == 'mydomain.example.com';
    return client;
  }
}

void main() {
  HttpOverrides.global = MyHttpOverrides();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'My Demo App',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: MyScaffold(),
      );
}

class MyScaffold extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(
          title: const Text('Dio login example'),
        ),
        body: MyBody(),
      );
}

// このサンプルでは、StatelessWidgetで良かったですね
class MyBody extends StatefulWidget {
  @override
  _MyBodyState createState() => _MyBodyState();
}

class _MyBodyState extends State<MyBody> {
  @override
  Widget build(BuildContext context) => FutureBuilder(
      future: loadData(),
      builder: (BuildContext context, AsyncSnapshot<Response> snapshot) =>
          snapshot.hasData
              ? Container(
                  child: Text(
                    '${snapshot.data}',
                  ),
                )
              : Container(
                  child: const Text('loading'),
                ));

  Future<Response> loadData() async {
    var cookieJar = CookieJar();
    var dio = Dio();
    // ※2 CookieJar を注入
    dio.interceptors.add(CookieManager(cookieJar));

    var response = await dio.get('$apiServerPrefix/accounts/login/');

    // ※3 レスポンスクッキーからCSRFトークンを探す
    List<Cookie> cookies =
        cookieJar.loadForRequest(Uri.parse(apiServerPrefix));
    String csrfToken = cookies.firstWhere((c) => c.name == 'csrftoken')?.value;

    response = await dio.post(
      '$apiServerPrefix/accounts/login/',
      // ※4 FormData の送信
      data: FormData.fromMap({
        'username': loginUsername,
        'password': loginPassword,
        'csrfmiddlewaretoken': csrfToken,
      }),
      options: Options(
        headers: {
          'Referer': '$apiServerPrefix/accounts/login/',
        },
        followRedirects: false,
        // ※5 リダイレクトレスポンスの時エラーにしない。
        validateStatus: (status) => status < 400,
      ),
    );

    // ログインセッションが作れたのでAPIリクエスト。なじむ。
    response = await dio.get('$apiServerPrefix/api/rss/all/');
    return response;
  }
}
```

## ※1 自己署名SSL証明書の検証をしない

自己署名証明書を使っているサーバにリクエストすると通常はこのような例外が出ます。

```
HandshakeException: Handshake error in client (OS Error: 
	CERTIFICATE_VERIFY_FAILED: ok(handshake.cc:354))
```

特定のドメインのみ、署名検証をしないことでこれを回避します。(中間者攻撃などに対して脆弱ゥになります)

HttpClient を使う場合

```dart
    HttpClient httpClient = HttpClient()
      ..badCertificateCallback =
      ((X509Certificate cert, String host, int port) =>
      host == 'mydomain.example.com');

    IOClient ioClient = IOClient(httpClient);

    final response = await ioClient.get('https://...');
```
このようなコードで、自己署名のSSL証明書の警告を回避できます。

Dioの場合は、例のコードのように `HttpOverrides.global` に回避コードを入れると回避できます。この方法は Dio を使わず HttpClient を使う場合でも使えます。


## ※2 CookieJar を注入

cookie_jar のドキュメントには、cookie_jar のインスタンスをそのまま dio に注入できるみたいに書いてありますが、dio 3.0.7 時点ではそれはできずに、dio_cookie_manager を介して注入します。

```dart
    var cookieJar = CookieJar();
    var dio = Dio();
    dio.interceptors.add(CookieManager(cookieJar));
```
CookieJar の代わりに PersistCookieJar を使うとクッキーの永続化ができます。ンッン〜簡単!

https://pub.dev/packages/dio_cookie_manager

https://github.com/flutterchina/cookie_jar

## ※3 レスポンスクッキーからCSRFトークンを探す

今回のサービス (サーバサイドはDjango) は、Webフォームへの POST 時に CSRF トークンの検証をしています。

そのCSRF トークンは、Webフォームを Get した時にレスポンスヘッダに Set-Cookie ヘッダで送られてきます。そのトークンを、POSTデータとHTTPリクエストヘッダで送ります。

そのため、一度WebフォームをGETし、レスポンスヘッダで Set-Cookie されたクッキーから csrftoken クッキーを探し出し、次の POST リクエストで使っています。

また、リクエスト時に referer ヘッダも必須なので送ります。

## ※4 FormData の送信

```dart
      data: FormData.fromMap({
        'username': loginUsername,
        'password': loginPassword,
        'csrfmiddlewaretoken': csrfToken,
      }),
```
data 引数に Map<String, dynamic> をそのまま入れるとJSONで送られます。
フォームデータとして送信したい場合は、FormData インスタンスにします。

## ※5 リダイレクトレスポンスの時エラーにしない
dio のリクエストは、レスポンスコードが HTTP 302 なんかでも例外を出しますボギャア。
Options の validateStatus を指定することで回避できます。

 
# 最後にPR
Dio様の全巻セットはうちの会社で取り扱っているので買ってください。 ▶[ジョジョの奇妙な冒険SET](https://www.mangazenkan.com/item/8.html)