0
1

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

Posted at

はじめに

42Tokyoの学生です。
課題の一環として、2FA(Two Factor Authentication)をDjangoで実装する必要が生じたので実装した内容を紹介します(前の記事の続きです)。
2FAは様々な方法がありますが、ここではJWTを活用したTOTP(Google Authenticator)を利用した方法を紹介します。

ここで紹介するコードはGithubから参照できます。

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コードを表示させる(サーバー側)
  2. ユーザーはGoogle Authenticatorでそれを登録する
  3. ユーザーはログイン時に、Google Authenticator上で表示されるコードを打ち込む
  4. サーバーはそのコードを検証して、問題なければ認証する
  5. ログイン完了

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

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

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

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

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

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

TOTP.jpg

なぜTOTPが認証に利用できるのか?

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

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

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

Djangoで実装する

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

実装時に迷ったこと

今回はログイン時もサインアップ(ユーザー登録)時も2FAを実装しようと思ったのですが、その際に2点ほど悩んだことがあります。

  1. サインアップ時、2FAによる認証が通る前にユーザー情報をDBに保持するべきか?
  2. ユーザーの状態として、ログイン前、パスワード送信後(2FA認証前)、2FA認証後の3つの状態があるが、それをどのように区別するか?

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

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

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

なので、対策として次のようにしました

  1. 仮登録用のテーブルと本登録用のテーブルを分ける
  2. 2FAの前の状態はJWT(JSON Token Web)に管理させる

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

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

処理フロー

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

ユーザー登録.jpg

ログイン時のフローは次の通りです
ユーザー登録よりもDBに登録する処理がない分、簡単になります
ログイン.jpg

コーディング

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

また、前の記事で最低限のsignup/login処理とJWTは完了しているので、これをベースにして実装しました。

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

modelの作成

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

本登録用のmodelsを今回はFtUserとして次のように定義します
なお、emailでログインしたいので、USERNAME_FIELD = "email"としています

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として定義します。
フィールドはすべて共通です(同じ内容のコピペになるので継承とか使えないのかと試したがダメでした)

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で使用する秘密鍵を作成して保存します

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

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

backgroundの作成

続いて認証バックエンドの作成です。
このあたりを参考に解釈すると、どうやら認証時に背後で動いているシステムのようです。

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

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

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

ドキュメントを参考にして仮登録用のバックエンドを書くとこうなります。
あくまでも仮登録用の認証バックエンドなので、FtTmpUserを使っています。

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

なお、今回のシステムでは必要ありませんが、ドキュメントによれば独自のバックエンドを作成した場合、settings.pyで次のように設定すると勝手に参照してくれるようです。

settings.py
AUTHENTICATION_BACKENDS = [
    "accounts.backend.FtTmpUserBackend",
    "django.contrib.auth.backends.ModelBackend",
]

今回は明示的に認証バックエンドを指定するので、これがなくても動作しました

Formの作成

カスタマイズしたModelと連携させるために、Formクラスも作成します。
入力フォームのバリデードに使用します。

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ページに飛び、そこで認証コードを入力したらログイン完了です。

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の作成

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

長いので要点だけ説明します。

form_class

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

これにより、たとえば同じユーザーがすでに本登録されていた場合、エラーとなります。

views.py
class FtSignupView(CreateView):
    form_class = SignUpForm

form_valid()

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

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

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

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

authenticate()

authenticateを実行して認証に成功するか確かめます。
カスタマイズした認証バックエンドなので、仮登録用のユーザーとして認証を行います。
上述した通り、form_invalid()に成功していればフォームに記入したユーザー情報がDBに登録されています。
失敗したらuserはNoneになります。

views.py
                backend = FtTmpUserBackend()
                user = backend.authenticate(
                    self.request, email=email, password=password
                )
                if user is None:
                    return HttpResponseServerError(f"Server Error:{e}")

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

認証バックエンドを明示的に指定し、authenticate()を実行することで認証

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は使えなくなります。

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に渡します。

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

signup画面から、次の画面に遷移します。
image.png

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

2FA認証画面

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であれば、この認証コードを送信したユーザーは、ユーザー登録を実施したユーザーであることが確認できます。

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からユーザー情報を取り出します。

views.py
            id = request.session["user_id"]
            code = request.POST.get("code")
            user = FtTmpUser.objects.get(id=id)

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

views.py
            if TwoFA().verify_app(user, code):
                self.copy_user(user)
            else:
                return HttpResponseBadRequest()

DBのユーザー情報をコピー

FtTmpUser(仮登録ユーザー)情報を取得し、これをもとにFtUser(本登録ユーザー)を新規に作成および保存します。

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,
        )

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

ログイン

本登録ユーザーの作成が終わったら、あらためてDBから本登録ユーザーを取得し、これをもとにログインを行います。

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",
            )

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

仮登録ユーザーの削除

最後に仮登録したユーザーを削除しようと思っていたのですが、これは取りやめました。
詳細は後述します。

views.py
#user.delete()

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

JWTを使った意義

今回、仮登録の認証としてJWTを使いましたが、利点が2つあります。

  1. ユーザーIDを保持できること
  2. 有効期限を設定できること

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

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

セキュリティリスク

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

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

まず1点目ですが、仮登録した際は以下のようにis_provisional_signupをTrueにしています。

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点目は、本登録が完了した時点で仮登録ユーザーのデータを削除したことです。

views.py
#user.delete()

仮登録のユーザーを削除しても、JWT自体は5分間有効です。当然、user_idも保持したままなので、削除した後別の誰かがユーザー登録を実行し、そのuser_idが重複してしまった場合、2FAの画面を開くことができます。こちらも同様に2FAによる認証チェックを通さなければなりませんが、やはり危険です。

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

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

終わりに

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

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1