この記事は Djangoもくもく会: 3回目 で書いた記事です。
パスワードリセットの時に利用されているuid, tokenについて調べました。
そもそも先週書いた記事PasswordResetTokenGeneratorの時刻に関するテスト実装方法についてはtoken生成について調べていた時の発見だったので、ようやく本題(?)について調べたというところです。
確認したバージョンは Django 5.0 です。
背景
パスワードリセットのpathはreset/<uidb64>/<token>/
のようになっており、「この辺りはなんだろう?」と調べていたところ、現場で使えるDjangoの教科書の著者、akiyokoさんの書いたブログ記事を見つけました。
Django 組み込みのパスワード再設定(パスワードリセット)の仕組み
この記事の中ではuid, tokenについて以下のように解説されていました。
uid
このリンクの URL は、「password_reset_confirm」という URL パターンで URLconf に登録されている「reset/<uidb64>/<token>/」に相当します。<uidb64>の部分、すなわち上のメールの例の「MQ」は、Base64 でエンコードされたユーザーの PK で、Base64 でデコードすると「1」になります。
token
<token>の「-」の左側の部分(上の例では「awrev2」)は、メール送信時のタイムスタンプ(2001/1/1 00:00:00 からの経過秒数)を Base34 でエンコードしたもので、リンクの有効期限をチェックするために使われます。残りの右側の部分(上の例では「3a07a0471392cfbc3b885b713b2846d5」)は改ざん防止のためのハッシュです。*3
わかりやすい説明で理解が進みましたが、合わせて公式リファレンスも読んでみたところ、リファレンス上はあまり説明がありませんでした。
まず、当時のakiyokoさんブログ執筆時のDjangoのバージョンに合わせて3.2を見てみると、以下のような説明です。
Keyword arguments from the URL:
uidb64: The user's id encoded in base 64.
token: Token to check that the password is valid.
続いて5.0に切り替えてみると、以下のような説明です。
URL からのキーワード引数:
uidb64: Base 64 でエンコードされたユーザの ID 。
token: パスワードが有効かを確認するためのトークン。
どちらにせよトークンの内容の説明が無く、どのようになっているのか公式リファレンス上からはわからなかったので、コードを調べました(実際には、有効かどうかを確認するもの、という説明さえあれば内容を解説する必要はなく、リファレンスの説明としては事足りるのだとは思いますが)。
実際のコード
PasswordResetForm
のsave
メソッドで実装されていました。
PasswordResetForm
自体はPasswordResetView
の属性としてform_class
に設定されています。
以下はPasswordResetForm
でユーザーにメールを送るためのcontextを作成している部分です。以下、抜粋します。
context = { "email": user_email, "domain": domain, "site_name": site_name, "uid": urlsafe_base64_encode(force_bytes(user.pk)), "user": user, "token": token_generator.make_token(user), "protocol": "https" if use_https else "http", **(extra_email_context or {}), }
https://github.com/django/django/blob/stable/5.0.x/django/contrib/auth/forms.py#L370-L372
このように実装されており、akiyokoさんのブログのようになっていることがコード上から確認できました。
検証
続いて、自己学習のために自分自身でも検証してみます。
今回はdjango-extensionのshell_plus
を利用していますが、manage.py
のshell
でも同様に実行可能です。
その場合は自前でfrom django.contrib.auth.models import User
のようにimportする必要があります。
また、関係ないimport文が多いのでサンプルコードからは省略しています。
(venv) tatsuya: mysite/ % docker compose run web python mysite/manage.py shell_plus (git)-[admin-can-not-see] (import文は省略しています) Python 3.12.2 (main, Mar 12 2024, 08:01:18) [GCC 12.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. (InteractiveConsole) >>> from django.contrib.auth.tokens import default_token_generator as token_generator >>> from django.utils.encoding import force_bytes >>> from django.utils.http import urlsafe_base64_encode >>> user = User.objects.first() (0.002) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" ORDER BY "auth_user"."id" ASC LIMIT 1; args=(); alias=default >>> uid = urlsafe_base64_encode(force_bytes(user.pk)) >>> uid 'MQ' >>> token = token_generator.make_token(user) >>> token 'cb6v9i-1f1466d4ec4379c43cdcc6cf3870a217' >>> user.pk 1 >>> force_bytes(1) b'1' >>> urlsafe_base64_encode(b'1') 'MQ' >>>
のようになっていました。
結果より、uidは冒頭で引用したakiyokoさんの説明と同じ内容になったことが確認できます。
「MQ」は、Base64 でエンコードされたユーザーの PK で、Base64 でデコードすると「1」になります。
その他、処理上関連しているコードのリファレンスの該当箇所はこの辺りでした。
https://docs.djangoproject.com/ja/5.0/ref/utils/#django.utils.http.urlsafe_base64_encode
https://docs.djangoproject.com/ja/5.0/ref/utils/#django.utils.encoding.force_bytes
以上、ちょっと気になっていた処理が分かってスッキリしました。