JWTを活用してTOTPによる二要素認証をDjangoで実装する

# はじめに
42Tokyoの学生です。
課題の一環として、2FA(Two Factor Authentication)をDjangoで実装する必要が生じたので実装した内容を紹介します([前の記事](https://qiita.com/hsano43/items/509544025882f852aa62)の続きです)。
2FAは様々な方法がありますが、ここではJWTを活用したTOTP(Google Authenticator)を利用した方法を紹介します。

ここで紹介するコードは[Github](https://github.com/hsano333/two-fa-django)から参照できます。


# 2FA(Two Factor Authentication)とはなにか？
日本語だと2要素認証ですが、最初2段階認証と勘違いしていました。
2段階認証はあくまでも認証の手順を2つにわけただけなので、異なるパスワードを2回入力することでも2段階認証と言えますが、この場合2要素認証とは言いません。

2FAは知識・所有・生体の3つの要素から2つを利用した認証方法です。
具体例をいうと、パスワードによる認証は知識、eメールやSMSによる認証は所有、指紋や顔認証は生体になります。

今回の課題ではパスワードに加えてワンタイムパスワード(認証コード)をemail, SMS, Appなどを利用して実現するという内容なので、AppとしてGoogle Authenticatorを利用しています。そして、Google AuthenticatorはTOTP(Time-Based One-Time Password)を利用した認証なのでこれをDjango上で実装する必要があります。

# Google Authenticatorによる認証手順
1. ユーザー登録時にQRコードを表示させる(サーバー側）
1. ユーザーはGoogle Authenticatorでそれを登録する
1. ユーザーはログイン時に、Google Authenticator上で表示されるコードを打ち込む
1. サーバーはそのコードを検証して、問題なければ認証する
1. ログイン完了

自分が最初勘違いしていたのは、WebAPIを利用してコードを送信したり、コードの検証を確認したりするものだと思っていたことです。

ここでは紹介しませんが、SMSによる2FAを先に実装していました。これはTwilioというサービスを利用し、ここが提供しているWebAPIを利用することで、SMSの送信とコードの検証をすることができます。

しかし、Google Authenticatorを利用すれば、外部サービスやWebAPIは一切利用せずに認証することができます。
これを実現させているのが、TOTP(Time-Based One-Time Password)という技術です。


# TOTP(Time-Based One-Time Password)とは

名前の通り時間をベース要素にして認証コードを発行する仕組みです。
現在時刻をベースにしているので、時間ごとに認証コードが変わります。
もちろん1秒ごとに切り替わっては入力が追い付かないので、切り替わり時間は任意に設定が可能です。

現在時刻だけではなく、秘密鍵(トークン）も作成に利用します。
この秘密鍵によって、作成された認証コードの正当性が担保されます。

![TOTP.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/338221/079b90a6-3240-a32a-1910-6799230978ee.jpeg)

## なぜTOTPが認証に利用できるのか？
Google Authenticatorは初回にQRコードを撮影します。
このQRコード内に秘密鍵、アプリケーション名、ユーザー名、切り替わり時間などを内包します。
サーバー側は、秘密鍵をDB内に保持します。
これでGoogle Authenticator(スマホ側）とサーバー側は同じ秘密鍵を持つことになります。

login時は、時刻と秘密鍵に従って、Google Authenticatorとサーバーが同じ手順で認証コードを計算します。材料(時刻と秘密鍵)と計算手法が同じなので、当然同じ結果になります。

逆にいうと、秘密鍵さえ異なれば認証コードは別の値になります。
なので、認証コードが一致することは同じ秘密鍵を共有していることと同義となり、その秘密鍵はユーザー固有のものであるため、認証が可能となります。




# Djangoで実装する
2FA用のライブラリなどがあるのかもしれませんが、課題の制約上すべてを一括で実装できるものは使ってはならないので、ここでは地道に実装していきます。

## 実装時に迷ったこと
今回はログイン時もサインアップ（ユーザー登録）時も2FAを実装しようと思ったのですが、その際に2点ほど悩んだことがあります。
1. サインアップ時、2FAによる認証が通る前にユーザー情報を**DBに保持する**べきか？
2. **ユーザーの状態**として、ログイン前、パスワード送信後(2FA認証前)、2FA認証後の３つの状態があるが、それを**どのように区別するか？**

これについてChatGPTに相談したところ、1についてはDBに保持し、2については仮登録用のカラムを追加で作成し、それでフラグ管理するという回答が得られました。

しかし、この方法には若干問題があります。
まず、ユーザー登録時の2FAより前にデータをDBに保持した場合、ユーザー検索時に**仮登録**なのか**本登録**なのかをチェックする必要が生じます。まあ、フィルターかければ終わる話ですが、個人的に許容できないと感じました。

同様に2についても同じで、Djangoの場合、認証が通ったらis_authenticatedというカラムが自動で付与されます。ログインしているかどうかのチェックを、さらに加えたくはありませんでした。

なので、対策として次のようにしました
1. 仮登録用のテーブルと本登録用のテーブルを分ける
1. 2FAの前の状態はJWT(JSON Token Web)に管理させる

1の対策で本登録したかどうかのフラグはなくなります。
なぜなら、仮登録のユーザーも本登録のユーザーも**同じテーブル**に入れるからチェックが必要になるので、**別々のテーブル**に保存すればよいのです。

2の対策により、ログインされたかどうかは前述のis_authenticatedだけを参照すればよくなります。
パスワードを送信したら、JWTで仮登録用のフラグをONにし、2FAの認証はそのフラグをONの時だけ実行できるようにします。そして、2FAも通ったとき、はじめてログインを完了させます。これでログインしたかどうかは、従来通りis_authenticatedだけを参照すればよくなります。


## 処理フロー
上述した２つの対策を考慮し、ユーザー登録時の処理フローを書くと次のようになります。

![ユーザー登録.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/338221/b15d5453-15ff-5d26-0bb7-8cdd8d089951.jpeg)



ログイン時のフローは次の通りです
ユーザー登録よりもDBに登録する処理がない分、簡単になります
![ログイン.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/338221/76571395-9a79-5511-ea37-10757e3bf183.jpeg)

## コーディング
実装コードをすべて載せるのはさすがに量が多すぎるので重要そうなところだけ解説します。

また、[前の記事](https://qiita.com/hsano43/items/509544025882f852aa62)で最低限のsignup/login処理とJWTは完了しているので、これをベースにして実装しました。

完成したものは[Github](https://github.com/hsano333/two-fa-django)に上げているので、必要ならこちらを参照してください（こちらのコードはあくまで説明用に最小限のコードで作り直したものなので、実際に課題として提出するものとは大きく異なっています)。

## modelの作成
まずmodelを作成します。
ユーザー登録時に2FAを実施する前の状態ではまだ本登録はしたくないので、仮登録用のmodel(テーブル)が別途必要になります。

本登録用のmodelsを今回は**FtUser**として次のように定義します
なお、emailでログインしたいので、USERNAME_FIELD = "email"としています
```python:models.py
class FtUser(AbstractBaseUser, PermissionsMixin):

    groups = models.ManyToManyField(Group, related_name="ft_user_groups")
    user_permissions = models.ManyToManyField(
        Permission, related_name="ft_user_permissions"
    )
    #　略

     USERNAME_FIELD = "email"
```

続いて仮登録用のmodelsです。
仮登録用のmodelなので、**FtTmpUser**として定義します。
フィールドはすべて共通です(同じ内容のコピペになるので継承とか使えないのかと試したがダメでした)
```python:models.py
class FtTmpUser(AbstractBaseUser, PermissionsMixin):
    groups = models.ManyToManyField(Group, related_name="ft_tmpuser_groups")
    user_permissions = models.ManyToManyField(
        Permission, related_name="ft_tmpuser_permissions"
    )

    username = models.CharField(verbose_name="ユーザー名", max_length=32, unique=False)
    email = models.CharField(verbose_name="email", max_length=256, unique=True)
    first_name = models.CharField(
        verbose_name="姓",
        max_length=150,
    )
    last_name = models.CharField(
        verbose_name="名",
        max_length=150,
    )
    app_secret = models.CharField(
        verbose_name="App鍵",
        max_length=32,
        null=True,
        blank=True,
    )

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["username"]
    objects = BaseUserManager()

    def save(self, *args, **kwargs):
        # 一度だけ実行するように
        if not self.app_secret:
            totp = pyotp.TOTP(pyotp.random_base32())
            secret = totp.secret
            self.app_secret = secret
        super().save(*args, **kwargs)
        if self._password is not None:
            password_validation.password_changed(self._password, self)
            self._password = None
```

ここでsave()について説明します。
これは、実際にテーブルにデータが追加されるタイミングで実行される関数です。この中で、TOTPで使用する秘密鍵を作成して保存します

:::note info
生データのまま保存しているので、実際はハッシュ化したほうがいいと思います
:::


これで本登録用のmodelと仮登録用のmodelが作成されたので、DBのテーブルを分離することができました。

## backgroundの作成
続いて認証バックエンドの作成です。
[このあたり](https://docs.djangoproject.com/ja/5.1/topics/auth/customizing/#other-authentication-sources)を参考に解釈すると、どうやら認証時に背後で動いているシステムのようです。

Djangoは何もしなくても認証システムが動作するので、それをそのまま使う限り意識する必要はないのですが、今回は本登録用のユーザーと仮登録用のユーザーの2人が存在することになるので、仮登録用の認証のためにこれを追加する必要があります。

これをカスタマイズする際は、BaseBackendクラスを継承し、authenticate()とget_user()の2つのメソッドをオーバーライドする必要があります。

authenticate()は認証時に利用するメソッドで、基本的にはユーザー名とパスワードを渡して、それらが**正しければユーザーオブジェクトを返す**というものです。つまり、これを実行してUserオブジェクトが返れば**認証に成功した**とみなせますし、これをカスタマイズすることで、パスワードが不要の認証にすることもできます。

ドキュメントを参考にして仮登録用のバックエンドを書くとこうなります。
あくまでも仮登録用の認証バックエンドなので、**FtTmpUser**を使っています。
```python:backend.py
class FtTmpUserBackend(ModelBackend):
    def authenticate(self, request, email=None, password=None):
        if email is None or password is None:
            return
        user = FtTmpUser.objects.get(email=email)
        pwd_valid = user.check_password(password)
        if pwd_valid:
            return user
        return None

    def get_user(self, user_id):
        try:
            return FtTmpUser.objects.get(pk=user_id)
        except FtTmpUser.DoesNotExist:
            return None

```

なお、今回のシステムでは必要ありませんが、[ドキュメント](https://docs.djangoproject.com/ja/5.1/topics/auth/customizing/#specifying-authentication-backends)によれば独自のバックエンドを作成した場合、settings.pyで次のように設定すると勝手に参照してくれるようです。
```python:settings.py
AUTHENTICATION_BACKENDS = [
    "accounts.backend.FtTmpUserBackend",
    "django.contrib.auth.backends.ModelBackend",
]
```
:::note info
今回は明示的に認証バックエンドを指定するので、これがなくても動作しました
:::
## Formの作成
カスタマイズしたModelと連携させるために、Formクラスも作成します。
入力フォームのバリデードに使用します。
```python:forms.py
class SignUpForm(UserCreationForm):
    class Meta:
        model = FtUser
        fields = (
            "username",
            "email",
            "first_name",
            "last_name",
        )


class SignUpTmpForm(UserCreationForm):
    class Meta:
        model = FtTmpUser
        fields = (
            "username",
            "email",
            "first_name",
            "last_name",
        )
```

## Viewの作成
ルーティング設定は次の通りです。
sigmupページでユーザー登録をしたら、signup-two-faページに飛び、そこで認証コードを入力したらユーザー登録およびログイン完了です。
ログインも同様で、loginページでパスワードを入力したらlogin-two-faページに飛び、そこで認証コードを入力したらログイン完了です。
```python:accounts/urls.py
urlpatterns = [
    path("signup/", views.FtSignupView.as_view(), name="signup"),
    path("signup-two-fa/", views.SignupTwoFAView.as_view(), name="signup-two-fa"),
    path("login/", views.FtLoginView.as_view(), name="login"),
    path("login-two-fa/", views.LoginTwoFAView.as_view(), name="login-two-fa"),
    path("logout/", LogoutView.as_view(), name="logout"),
    path("", RedirectView.as_view(url="/accounts/login/")),
]
```
### FtSignupViewの作成

```python:views.py
@method_decorator(login_not_required, name="dispatch")
class FtSignupView(CreateView):

    form_class = SignUpForm
    template_name = "accounts/signup.html"
    success_url = reverse_lazy("accounts:signup-two-fa")
    usable_password = None

    def form_invalid(self, form):
        res = super().form_invalid(form)
        res.status_code = 400
        return res

    def form_valid(self, form):
        try:
            form = SignUpTmpForm(self.request.POST)
            rval = super().form_valid(form)
            if rval.status_code >= 300 and rval.status_code < 400:
                email = form.cleaned_data["email"]
                password = form.cleaned_data["password1"]
                backend = FtTmpUserBackend()
                user = backend.authenticate(
                    self.request, email=email, password=password
                )
                if user is None:
                    return HttpResponseServerError(f"Server Error:{e}")

                self.request.session["is_provisional_signup"] = True
                self.request.session["user_id"] = user.id
                tmp_time = datetime.now(tz=timezone.utc) + timedelta(seconds=300)
                self.request.session["exp"] = str(tmp_time.timestamp())  # 5minutes

                url = TwoFA().app(user)
                data = {"qr": make_qr(url)}
                return render(self.request, "registration/signup-two-fa.html", data)

            else:
                rval.satus_code = 400
                return rval
        except Exception as e:
            return HttpResponseServerError(f"Server Error:{e}")
```

### Signup画面
Signup用の画面は次の通りです。
この画面の下部にある認証ボタンを押すと、上述の```form_invalid()```、あるいは```form_valid()```に入ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/338221/6ed32b41-3e22-d5d4-0d95-fc75ebc5fbbd.png)


長いので要点だけ説明します。
#### form_class
CreateViewクラスがSignup用のクラスで、これを継承することでSignupをカスタマイズすることができます。このとき、form_classにSignUpFormを設定することで、**SignUpForm**で設定したバリデードを自動で実行してくれます。

これにより、たとえば**同じユーザー**がすでに**本登録**されていた場合、**エラー**となります。
```python:views.py
class FtSignupView(CreateView):
    form_class = SignUpForm
```

#### form_valid()
**SignUpTmpForm**は**FtTmpUser**をモデルに指定しているので、```super().form_valid(form)```を実行することで仮登録ユーザーに対するバリデートを実行し、さらに**DBにユーザーが追加**されます。


```python:views.py
form = SignUpTmpForm(self.request.POST)
rval = super().form_valid(form)
if rval.status_code >= 300 and rval.status_code < 400:
```

:::note info
なぜ、バリデート用のメソッド内でユーザー作成も同時に行っているのかわかりませんが、この仕様により多少混乱しました。
:::


本登録用のバリデートが成功した後、form_valid()メソッドに入ります(エラーの場合はform_invalid()に入ります)。

#### authenticate()
authenticateを実行して**認証**に成功するか確かめます。
カスタマイズした認証バックエンドなので、仮登録用のユーザーとして認証を行います。
上述した通り、form_invalid()に成功していればフォームに記入したユーザー情報がDBに登録されています。
失敗したらuserはNoneになります。
```python:views.py
                backend = FtTmpUserBackend()
                user = backend.authenticate(
                    self.request, email=email, password=password
                )
                if user is None:
                    return HttpResponseServerError(f"Server Error:{e}")
```

:::note warn
authenticate()を実行してもloginはされません
:::


認証バックエンドを明示的に指定し、authenticate()を実行することで認証
```python:views.py
                backend = FtTmpUserBackend()
                user = backend.authenticate(
                    self.request, email=email, password=password
                )
```

#### JWT設定
JWT用に設定を行います。
```is_provisional_signup```は仮登録したかどうかを判定するためのフラグ
```user_id```は、DBのidであり、このidを利用して、DBから情報を引っ張ることができます。
```exp```はJWTの有効期限です。300秒以内に2FAを完了しなければ、このJWTは使えなくなります。
```python:views.py
                self.request.session["is_provisional_signup"] = True
                self.request.session["user_id"] = user.id
                tmp_time = datetime.now(tz=timezone.utc) + timedelta(seconds=300)
                self.request.session["exp"] = str(tmp_time.timestamp())  # 5minutes
```

#### QRコード作成
QRコードを作成し、```signup-two-fa.html```に渡します。
```python:views.py
                url = TwoFA().app(user)
                data = {"qr": make_qr(url)}
                return render(self.request, "registration/signup-two-fa.html", data)
```
signup画面から、次の画面に遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/338221/4609f338-5b03-027f-7228-3eb0637e8f15.png)

送信ボタンを押すと、下記コードの```post()```メソッドに入ります。

### 2FA認証画面
```python:views.py
@method_decorator(login_not_required, name="dispatch")
class SignupTwoFAView(TemplateView):
    def copy_user(self, user):
        src_user = FtTmpUser.objects.get(email=user.email)
        if src_user is None:
            raise Exception()
        FtUser.objects.create(
            username=src_user.username,
            password=src_user.password,
            email=src_user.email,
            first_name=src_user.first_name,
            last_name=src_user.last_name,
            app_secret=src_user.app_secret,
        )

    def post(self, request):
        is_provisional_signup = False
        if "is_provisional_signup" in request.session:
            is_provisional_signup = request.session["is_provisional_signup"]
        if is_provisional_signup is False:
            return HttpResponseForbidden()

        try:
            id = request.session["user_id"]
            code = request.POST.get("code")
            user = FtTmpUser.objects.get(id=id)
            if user is None:
                return HttpResponseForbidden()
            if TwoFA().verify_app(user, code):
                self.copy_user(user)
            else:
                return HttpResponseBadRequest()

            new_user = FtUser.objects.get(email=user.email)
            login(
                request,
                new_user,
                backend="django.contrib.auth.backends.ModelBackend",
            )
            user.delete()

            return render(self.request, "registration/login.html")
        except IntegrityError as e:
            return HttpResponseServerError(f"Server Error:{e}")
        except Exception as e:
            return HttpResponseServerError(f"Server Error:{e}")
```

### JWTにより認証チェック
省略していますが、JWTで設定した変数は```request.session```に保持しています。
なので、is_provisional_signupがTrueであれば、この**認証コードを送信したユーザー**は、**ユーザー登録を実施したユーザー**であることが確認できます。
```python:views.py
        is_provisional_signup = False
        if "is_provisional_signup" in request.session:
            is_provisional_signup = request.session["is_provisional_signup"]
        if is_provisional_signup is False:
            return HttpResponseForbidden()
```

### DBよりユーザー情報取得
JWTに登録したuser_idを利用して、DBからユーザー情報を取り出します。
```python:views.py
            id = request.session["user_id"]
            code = request.POST.get("code")
            user = FtTmpUser.objects.get(id=id)
```

ユーザー情報と、送信された認証コードからTOTPをチェックします。
TOTPに関しては内部でpyotpを利用しているだけなので省略します。
問題なければ```copy_user()```を実行します。

```python:views.py
            if TwoFA().verify_app(user, code):
                self.copy_user(user)
            else:
                return HttpResponseBadRequest()
```
### DBのユーザー情報をコピー
FtTmpUser(**仮登録ユーザー**)情報を取得し、これをもとにFtUser(**本登録ユーザー**)を新規に作成および保存します。

```python:views.py
    def copy_user(self, user):
        src_user = FtTmpUser.objects.get(email=user.email)
        if src_user is None:
            raise Exception()
        FtUser.objects.create(
            username=src_user.username,
            password=src_user.password,
            email=src_user.email,
            first_name=src_user.first_name,
            last_name=src_user.last_name,
            app_secret=src_user.app_secret,
        )
```

:::note info
create()だけで作成で保存ができます
:::

#### ログイン
本登録ユーザーの作成が終わったら、あらためてDBから本登録ユーザーを取得し、これをもとにログインを行います。
```python:views.py
            new_user = FtUser.objects.get(email=user.email)
            if new_user is None:
                return HttpResponseForbidden()
            login(
                request,
                new_user,
                backend="django.contrib.auth.backends.ModelBackend",
            )
```

:::note info
ユーザビリティーを考慮して、ユーザー登録とログインを同時に行っていますが、セキュリティ的に何か問題があるのなら指摘してほしいです
:::

#### 仮登録ユーザーの削除
最後に仮登録したユーザーを削除しようと思っていたのですが、これは取りやめました。
詳細は後述します。
```python:views.py
#user.delete()
```

なお、ログイン処理に関しては、サインアップからDBにまつわる処理をなくす程度なので省略します。

## JWTを使った意義
今回、仮登録の認証としてJWTを使いましたが、利点が２つあります。
1. ユーザーIDを保持できること
1. 有効期限を設定できること

ユーザーIDを保持できるのでDBから簡単に該当ユーザーの情報を取得することができ、JWTを利用していることから**改竄**の心配もありません。

また、２FAを通さない**仮登録・仮ログイン**したユーザーを**無期限に有効のまま**にするわけにはいかないので、そういう意味でJWTの有効期限は役に立ちました。JWTがなければ登録した時間を参照したりしなければなりませんが、そうした処理を無視できたのはありがたかったです。

## セキュリティリスク
自分なりに考慮してセキュリティリスクはなくしているつもりですが、少なくともこの記事を書いている最中に２つほどセキュリティリスクになりそうな箇所を見つけたのでコードを訂正しました。

1. JWTで仮登録・仮ログインした際のフラグを共通化していた
1. JWTの有効期限が来る前にDBから仮登録ユーザーを削除していた

まず1点目ですが、仮登録した際は以下のように```is_provisional_signup```をTrueにしています。
```python:views.py
                self.request.session["is_provisional_signup"] = True
                self.request.session["user_id"] = user.id
                tmp_time = datetime.now(tz=timezone.utc) + timedelta(seconds=300)
                self.request.session["exp"] = str(tmp_time.timestamp())  # 5minutes
```
つまり、**仮登録**した時も、**仮ログイン**した時も**同じフラグ**をTrueにしていました。
この時、悪意を持つユーザーがいる場合、まず仮登録を行います。すると、```is_provisional_signup```がTrueとなり、該当のuser_idもセットされます。そのため、**signup**用の2FA画面だけでなく、**login**用の2FA画面を開くことができてしまいます(**仮登録用**のuser_idと**本登録用**のuser_idは異なるので、該当のuser_idが本登録用のテーブルに保存されていれば**なりすまし**が可能)。もちろん、2FAによる認証コードのチェックがあるので、このままではloginできませんが、6桁の数字なので突破される危険はあります。

### deleteによりセキュリティリスク
2点目は、**本登録**が完了した時点で**仮登録**ユーザーのデータを**削除**したことです。
```python:views.py
#user.delete()
```
仮登録のユーザーを削除しても、JWT自体は5分間有効です。当然、user_idも保持したままなので、削除した後別の誰かがユーザー登録を実行し、そのuser_idが**重複**してしまった場合、2FAの画面を開くことができます。こちらも同様に2FAによる認証チェックを通さなければなりませんが、やはり危険です。

:::note info
sqliteで確認した限り、このようにデータを削除した直後にデータを追加しても、同じidにはなりませんでした。ただ、すべてのDBがそのような仕様なのかわからないので、安全を考慮してdelteは削除して方が無難だと思います
:::

実装していませんが、JWTの有効期限が終わるタイミングでデータ削除が望ましいと思います。

# 終わりに
JWTだけを実装した際は、なぜJWTが必要なのかがいまいち理解できなかったのですが、2FAと組み合わせることでJWTの利点を理解することができました。
また、同時に認証システムは気を抜くと簡単にセキュリティホールができることを理解しました。
よくユーザー登録した直後にあらためてloginしなければならないサイトがあるのですが、自分で実装して確かにその方が安全だと実感できました。
探せばまだありそうなので、ほかのセキュリティリスクが見つかったら、ぜひ教えてほしいです。