【付録付き】認証機能をAuth0に差し替える時に全ユーザー移行した話

# はじめに

[株式会社hokan](https://hkn.jp)でプロダクトマネージャーやエンジニアをしている宮といいます。

先日Auth0 JapanのTwitterで[ユーザー移行について解説している動画についてツイート](https://twitter.com/auth0_jp/status/1407503635915984898?s=20)されていました。
ちょうどhokanでも今年の頭にAuth0にDjangoの認証機能から移行したのですが、その時に割と悩んだのでその辺りの知見を整理しようと思います。

結構分量のある記事になってるので、時間がない方は各セクションの概要のみ読んでいただけると良いかと思います！

最後の方にシレッと移行計画立てる為の簡単なフォーマットも付録として用意したので、ご参考にしてください。

# この記事の前提

### Auth0の概要について
Auth0は、サービスごとに分散するアカウント情報の管理を一元化したり、認証方法の強化に関する統合認証基盤の一種です。
Auth0の概要については[こちらの記事](https://qiita.com/furuth/items/68c3caa3127cbf4f6b77)が詳しく解説してくれているので、Auth0をご存知ない方はそちらをまずはご覧ください。

### 認証方法について
今回は基本的にユーザー・パスワードによる認証を取り扱います。
ソーシャルアカウントによる認証などはこの記事の限りではないのでご留意ください。

### 移行方式について
Auth0では下記３つのデータ移行手段があり、今回の記事はこの中でも **一括インポート** について解説します。

**Auth0の移行手段**

- カスタムデータベースのみ（移行せずに既存認証基盤を使う）
- カスタムデータベース 兼 自動インポート
- 一括インポート

## Auth0を採用した理由
hokanでは、今年の頭にAuth0に切り替えを行いました。
Auth0の採用の目的としては、

- 将来のマイクロサービス化を見据えた時に、この時点で切り替えとくと後々楽になる
- 流石に認証情報はハッシュ化しているものの、そもそもサービス内に認証情報を持ちたくない
- Djangoの認証機能で二要素認証やAD連携など機能強化する上で開発効率が悪い

というところでした。
認証機能を外出しできる上にちょうど日本リージョンも登場したこともあってAuth0の導入に至りました。

## Auth0のユーザー＆パスワードによる認証フロー

現在hokanでは、Auth0による認証に下図のような認証フローを踏みます。
ポイントは、
① Auth0への認証要求時にて、フロントエンドからAuth0に認証済みか検証
② Auth0認証画面でユーザー認証するため、認証情報はhokanのサービス側では通過しない
③ JWTの検証による透過的な不正アクセスの防止できる
ってな感じです。

<details>
<summary>認証フロー（長くなったので折り畳み）</summary>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239882/6a545b02-6dcf-985f-a18a-88db84aa7042.png)
</details>

## ユーザー情報の移行方法

### 概要

既存サービスの認証基盤を維持した状態で先ほどの認証フローを実現する場合、既存サービス内のユーザー情報を生かしたくなると思います。
ユーザーのパスワードが一括でリセットされるのも微妙だし、認証周りのUXをさげるのももっと微妙なので、Auth0ではいくつかのユーザーデータの移行方法が定義されています。

### カスタムデータベース、自動インポート

こちらは、特に先日Auth0 JapanのTwitterで[ユーザー移行について解説している動画についてツイート](https://twitter.com/auth0_jp/status/1407503635915984898?s=20)詳しく言及されています。
もしくは、[こちらの記事](https://qiita.com/furuth/items/c80be851e304baf6d1cb)でも詳しく解説されています。

簡単に説明すると、Auth0にユーザーデータがなくても、既存サービスの認証機能を外からコールすることによって、認証の成功可否を判定する機能です。
自動インポートを有効にすると、既存サービスで認証に成功した場合、Auth0のユーザーデータにその認証情報が1件インポートされます。

自動インポートによって、カスタムデータベースで認証したユーザーは次回以降Auth0の認証機能によって認証が行われることになり、徐々にAuth0にユーザー移行されていく方式です。

**自動インポートの流れ**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239882/ac55ae36-92f4-3209-35df-83e557d3d88b.png)
なお、hokanでは、次に紹介する一括インポートを採用しています。
アクティブ/非アクティブユーザーが混在する状態では、全ユーザーの移行完了時期が見通せなかったのと、ユーザーストアのマスタが複数存在する状態はアーキテクチャ上も気持ち悪いからです。

### 一括インポート

一括インポートはシンプルに既存認証機能からユーザーデータを全量Auth0に移行する方法です。
hokanではこの方法でDjangoの認証機能（django-rest-auth）によって作成されたユーザーリストを一括でAuth0に移行しています。

## 一括インポート時の仕様

一括インポートは、Auth0が用意しているAPIに指定されたフォーマットでjsonファイルをアップロードすることによって、Auth0へのインポートが行われる仕組みです。

Auth0には[Management API](https://auth0.com/docs/api/management/v2#!/Jobs/post_users_imports)という、Auth0内の機能をREST APIでコールできる仕組みが多数用意されています。
今回の一括インポートも　`Create import users job` のAPIをコールすることで実現します。

ただこのAPI自体は非同期で動作するため、APIが成功してもユーザーのインポートまで成功したわけではないのでご注意ください。
この辺も後述しています。

### Auth0 Management APIを利用するための事前認証の方法

Auth0 Management APIを使用するには、APIの各機能を実行できる権限を持ったJWTトークンを取得しなければなりません。
今回はデータ移行の一回こっきりなので相当雑な手順ですが、本来はユーザーインポートを実行できる権限を持ったアカウントでM2M認証をします。

**手順**

Auth0のダッシュボードから、「APIs」サイドメニュー → 「Auth0 Management API」リンク → 「API Explorer」タブに遷移します。
そうすると下図のに「Token」と書いてある場所でJWTトークンをコピーすることができます。
これはManagement APIの全てのAPIをコールできる権限を持ったトークンになっていて、デフォルトでは有効期限が24時間です。
有効期限内であればこのトークンを使ってManagement APIをコールすることが可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239882/fb0d9e40-23d9-36d9-5c5c-77fc93ff224c.png)


### インポートAPIの実行方法
インポートAPIは前述の通りManagement APIから実行します。
その際にどこのユーザーストアにデータをインポートするか決めておきます。

**1. connection_idを取得　**
Auth0のダッシュボードから、「Authentication」サイドメニュー → 「Database」リンク → 移行対象のユーザーストアに遷移し、Identifier（Connection ID）をコピー

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239882/89306f2a-082d-bd01-7342-4e82a188b76d.png)



**2. APIをコールし、インポートジョブを実行**
下記のAPIをコールし、インポートジョブを起動します。
この時、user_jsonを指定してファイルをアップロードするのですが、ファイルにハッシュ化したパスワードを含めて送信します。
※ ファイルフォーマットは複雑なので後述します。

```
POST https://<AUTH0_DOMAIN>/api/v2/jobs/users-imports
Content-Type: application/json
Files: {"users": @<user_json_file_path>}
{
  "connection_id": "<CONNECTION_ID>",  // インポート先のユーザーストア
  "upsert": "false",                   //ユーザーが重複してる場合に更新するかどうか。falseの場合は無視
  "send_completion_email": "true"      //インポート完了した時に管理者にメール通知を飛ばすどうか
}
```
  

なお、このAPIは結構鬱陶しいエラーを吐くので、下記のエラーは注意が必要です。

- 413:　"Request Entity Too Large"　
500KB以上のJsonファイルをアップロードした場合、下記の様なエラーが発生します。
ファイルは500KB以内のサイズに抑えなければなりません。

- 429: Too Many Requests  
このAPIは3つ以上並行して走らせることはできません。
上述した413のエラーとかち合って大量のユーザーを移行する場合、並列でインポートを実行したくなるのですが、２本までしか並行できないので注意が必要です。

### インポート結果の確認方法
上記ユーザーインポートのAPIを実行すると `job_id` が返却されます。
このIDはAuth0へのユーザーインポートの状況を確認するために必要なもので、ジョブの進捗やエラーの詳細を取得できます。

#### 実行状況の確認方法
下記APIを実行すると、現在のジョブの進捗を確認できます。
`status` がcompleteかerrorになっていればジョブが完了しています。
なお、もしエラーが発生していてもこのAPIでは詳細はわかりません。

```
GET https://<AUTH0_DOMAIN>/api/v2/jobs/<:job_id>
```

#### エラー詳細の確認方法
下記APIを実行すると、ジョブのエラー詳細が確認できます。
割とエラー内容は親切なので、見ればわかるものが結構多いです。

```
GET https://<AUTH0_DOMAIN>/api/v2/jobs/<:job_id>/errors
```

### user_jsonのフォーマットについて
#### jsonスキーマ全体について
基本的に[インポートのデータスキーマのページ](https://auth0.com/docs/users/bulk-user-import-database-schema-and-examples)を見ていただければよいのですが、全編英語なので、jsonスキーマだけ翻訳しておきます。

なお、実際には複数件のユーザーをインポートするため、下記のスキーマの配列がuser_jsonのフォーマットになります。
**ちなみに多くの項目が任意なので、実際にはもっと簡素なjsonになります**

<details>
<summary>一件あたりのユーザー情報のjsonスキーマ(※表示が汚くなってしまうのでjson viewerなどで確認ください)</summary>  
```
{
    "type": "object",
    "properties": {
        "email": {
            "type": "string",
            "description": "ユーザーのメールアドレス（重複した場合エラー）",
            "format": "email"
        },
        "email_verified": {
            "type": "boolean",
            "default": false,
            "description": "メールアドレスを確認済みにするかどうか"
        },
        "user_id": {
            "type": "string",
            "description": "ユーザーIDを指定する場合。指定した場合、auth0|<指定したユーザーID>　という形式としてインポートされる。"
        },
        "username": {
            "type": "string",
            "description": "ユーザーネーム。ちなみにメールアドレス認証だけでなく、ユーザーネームでの認証も設定次第で可能"
        },
        "given_name": {
            "type": "string",
            "description": "ユーザーの名"
        },
        "family_name": {
            "type": "string",
            "description": "ユーザーの姓"
        },
        "name": {
            "type": "string",
            "description": "フルネーム。自分で設定する場合は指定"
        },
        "nickname": {
            "type": "string",
            "description": "ニックネーム"
        },
        "picture": {
          "type": "string",
          "description": "ユーザーのプロファイル用の写真のURL"
        },
        "blocked": {
            "type": "boolean",
            "description": "ブロックした状態（認証成功してもログインできない状態）にする場合にTrueを指定"
        },
        "password_hash": {
            "type": "string",
            "description":"ハッシュ化されたパスワード。$2a$ か $2b$　のバージョンのbcryptかつ１０回ソルトでハッシュ化されていることを前提にする。ハッシュ化が他の方法の場合はcustom_password_hashで指定。"
        },
        "custom_password_hash": {
            "type": "object",
            "description": "password_hashよりも汎用的な方法でハッシュ化されたパスワード。password_hashを指定している場合、このパラメータの指定は不可",
            "properties": {
                "algorithm": {
                    "type": "string",
                    "enum": ["argon2", "bcrypt", "hmac", "ldap", "md4", "md5", "sha1", "sha256", "sha512", "pbkdf2"],
                    "description": "パスワードのハッシュアルゴリズム"
                },
                "hash": {
                    "type": "object",
                    "properties": {
                        "value": {
                            "type": "string",
                            "description": "パスワードのハッシュ"
                        },
                        "encoding": {
                            "type": "string",
                            "enum": ["base64", "hex", "utf8"],
                            "description": "パッシュのエンコード形式。"
                        },
                        "digest": {
                            "type": "string",
                            "description": "HMACを使用している場合のダイジェストの生成アルゴリズム",
                            "enum": ["md4", "md5", "ripemd160", "sha1", "sha224", "sha256", "sha384", "sha512", "whirlpool"]
                        },
                        "key": {
                            "type": "object",
                            "description": "HMACハッシュ生成時のキー情報",
                            "required": ["value"],
                            "properties": {
                                "value": {
                                    "type": "string",
                                    "description": "キー値"
                                },
                                "encoding": {
                                    "type": "string",
                                    "enum": ["base64", "hex", "utf8"],
                                    "default": "utf8",
                                    "description": "キーのエンコード形式"
                                }
                            }
                        }
                    }
                },
                "salt": {
                    "type": "object",
                    "properties": {
                        "value": {
                            "type": "string",
                            "description": "ハッシュ生成に使用したソルト"
                        },
                        "encoding": {
                            "type": "string",
                            "enum": ["base64", "hex", "utf8"],
                            "default": "utf8",
                            "description": "ソルトのエンコード形式"
                        },
                        "position": {
                            "type": "string",
                            "enum": ["prefix", "suffix"],
                            "description": "ソルトをパスワードの前に付けるか後ろにつけるか。前につける場合はprefix"
                        }
                    },
                    "required": ["value", "position"]
                },
                "password": {
                    "type": "object",
                    "properties": {
                        "encoding": {
                            "type": "string",
                            "enum": ["ascii", "utf8", "utf16le", "ucs2", "latin1", "binary"],
                            "default": "utf8",
                            "description": "ハッシュ生成前にパスワードがエンコードされる場合にエンコード形式を指定"
                        }
                    }
                }
            },
            "required": ["algorithm", "hash"],
            "additionalProperties": false
        },
        "app_metadata": {
            "type": "object",
            "description": "アプリケーション横断なカスタマイズデータ。key: valueで任意の項目を設定可能"
        },
        "user_metadata": {
            "type": "object",
            "description": "アプリケーションのカスタマイズデータ。key: valueで任意の項目を設定可能"
        },
        "mfa_factors": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "totp": {
                        "type": "object",
                        "properties": {
                        "secret": {
                            "type": "string",
                                "pattern": "^[A-Z2-7]+$",
                                "description": "OTPシークレット。パディングなしのBase32エンコードで提供する"
                            },
                        },
                        "additionalProperties": false,
                        "required": ["secret"]
                    },
                    "phone": {
                        "type": "object",
                        "properties": {
                        "value": {
                            "type": "string",
                            "pattern": "^\\+[0-9]{1,15}$",
                            "description": "SMSを利用したMFA（多要素認証）のための電話番号。電話番号の最初は国コードから始めるので、日本の場合は+81XXXXXXとなる"
                        },
                        },
                        "additionalProperties": false,
                        "required": ["value"]
                    },
                    "email": {
                        "type": "object",
                        "properties": {
                            "value": {
                                "type": "string",
                                "format": "email",
                                "description": "MFA用のメールアドレス"
                            },
                        },
                        "additionalProperties": false,
                        "required": ["value"]
                    },
                },
                "maxProperties": 1,
                "additionalProperties": false
            },
            "minItems": 1,
            "maxItems": 10
        }
    },
    "required": ["email"],
    "additionalProperties": false
}
```
</details>


#### hokanで使用しているuser_jsonのフォーマット（Django用）
上記のJSONスキーマは結構複雑なのでギョッとするんですが、とはいえ実際に使用するデータはそこまで複雑ではありません。
hokanではDjangoから移行しているのですが、Djangoのパスワードのハッシュアルゴリズム(`django.contrib.auth.hashers`の`make_password`)は `pbkdf２` なので、custom_password_hashを使用しています。

おそらくどのサービスでも大体このくらいになるかと思っていて、追加するとしたら `username`くらいかなと思います。
また、Auth0移行後はAuth0のユーザーIDとサービスのユーザーを紐づけないといけないため、初期移行時のみはユーザーIDを指定してあげると紐付けがやりやすいので吉です。
（ただしユーザーIDを指定する場合は類推されにくいようにしましょう）

```
{
  "name": "補完 太郎",
  "email": "hokantarou@hkn.jp",
  "email_verified": true,
  "blocked": false,
  "user_id": "123456789",
  "family_name": "補完",
  "given_name": "太郎",
  "picture": "https://<S３のURL>",
  "custom_password_hash": {
    "algorithm": "pbkdf2",
    "hash": {
      "value": "ハッシュパスワード",
      "encoding": "utf8"
    }
  },
  "user_metadata": {
    "original_data_a": "何かオリジナルなやつ",
    "original_data_b": "何かオリジナルなやつ〜その２",
  }
}
```

## 【補足１】各Webアプリフレームワークでのパスワードハッシュの取り扱い（ざっくり版）
上記のhokanの移行ファイルフォーマットは割とわかりやすいと思うのですが、このうちcustom_password_hash.hashのみAuth0が期待するフォーマットに合わせることが結構大変なので補足的に解説しておきます。
なお、このセクションは調査できたものについて随時更新していく予定です。

### Django

#### Djangoのパスワードハッシュ化
`django.contrib.auth.hashers`の`make_password`を使用した場合を想定します。
make_passwordによってハッシュ化されたパスワードは`pbkdf2_sha256$150000$m2yhb2CsaCvI$c7xYccsE+P3rCynVyf6KC5xEqvhjSo3O9riB/lhiD4w=`のようなデータになっているはずです。

このハッシュは$でそれぞれ区切られていて、各セクションの意味としては、、、
- pbkdf2_sha256：ハッシュアルゴリズム
- 150000：ハッシュ化時の繰り返し回数
- m2yhb2CsaCvI：ソルト（ハッシュ生成時にパスワードに付加するランダム文字列）
- c7xYccsE+P3rCynVyf6KC5xEqvhjSo3O9riB/lhiD4w=：パスワードハッシュ
となっています。

#### Auth0に連携するためのパスワードハッシュ変換

結構ややこしいのですが、前述のハッシュであれば、　`$pbkdf2-sha256$i=150000,l=32$bTJ5aGIyQ3NhQ3ZJ$c7xYccsE+P3rCynVyf6KC5xEqvhjSo3O9riB/lhiD4w` にで変換します。
[このページ](https://auth0.com/docs/users/bulk-user-import-database-schema-and-examples#pbkdf2)に小さ〜く書いてあるのですが、Base64でエンコードしたら `=` は抜け、みたいな指定がかなり混乱を招くので要注意です。。

```python
def _make_auth0_hash(pass_hash: str):
    pass_hashes = pass_hash.split('$')

    # ハッシュアルゴリズムは pbkdf2_sha256 から　pbkdf2-sha256 に変換
    hash_alg = pass_hashes[0].replace('_', '-')

    # ソルトはbase64エンコードして、=抜く
    salt = base64.b64encode(pass_hashes[2].encode('utf8')).decode().replace('=', '')

    # ハッシュは既にbase64エンコードされているので、=だけ抜く
    hash_value = pass_hashes[3].replace('=', '')

    # l= はソルトのバッファ長だが、Djangoでは32バイト固定でOK
    return f'${hash_alg}$i={pass_hashes[1]},l=32${salt}${hash_value}'
```

### Ruby on Rails(Devise)
認証にDeviseを利用している場合、都合の良いことに　password_hash　の要件である　`$2a$ か $2b$　のバージョンのbcryptかつ１０回ソルトでハッシュ化されていること` に合致します。
したがって、custom_password_hashは使用せず、password_hashにそのままハッシュを設定します。



## 【補足2】ユーザー移行の設計・計画策定について
取り急ぎユーザー移行の方法について技術的な側面で粛々と書いてきたのですが、一括インポートの最後の障壁は失敗時の恐怖だと思います。
助けになるかは分かりませんが、hokanでユーザー移行を行なったときの移行計画をテンプレートっぽく仕立てたので、良ければ使ってください。

付録：[移行設計サンプル](https://docs.google.com/document/d/1bEzRuhENVTXLSfcCG0Wwc9uarwdnmSUN4eVlgUB1iWg/edit?usp=sharing)

大体スムーズに本番への移行もでき、その後「ログインできない！」みたいなことは一件も起きてないですし、概ね順調なのかと思います。
ユーザー移行は大変な作業ですが、Auth0は割と移行しやすいので、ぜひトライしてみてください！

## さいごに
hokanでは今年頭に認証基盤をAuth0に移行して、外部API用のトークン発行の機能、多要素認証の機構や社内システムへのSSO機能など、すごいスピードで色々な機能を実装できています。
また、さらに最近はADやLDAPとの接続の話なんかも話が出始めています。

一昔前では考えられなかったようなスピード感で認証基盤の拡張ができる程Auth0の拡張性は相当高く、単純にものごとのスピードを上げてくれます。
ユーザー移行でネックになることなく、こういった恩恵を日本のサービスでももっと享受できれば良いなと思ってます！