はじめに
42Tokyoの学生です。
課題の一環で、JWTによる認証をDjango環境で実装する必要が生じました。
この記事はその際に調べたことをまとめたものです。主に自分が悩んだところを中心に書いているので誤りがあれば教えてほしいです。
また、次の記事が大変参考になりました。
この記事で紹介されている「セッションIDをJWTに内包」をDjangoに落とし込んだ内容がこの記事の主題となります。
今回の記事で作成したコードはgithubにあげています。記事内でコードはすべて記していますが読みにくい場合はこちらを参照してください。
そもそもJWT(JSON Web Token)とは?
ひとことで言えばJSONをBase64URLエンコードして署名を付けたものです。
例えば以下のJSONがあるとします。
{
"user": "user42",
"email":"user42@gmail.com",
}
これをJWTに変換すると次のようになります。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidXNlcjQyIiwiZW1haWwiOiJ1c2VyNDJAZ21haWwuY29tIn0.2V314JZqwHS5V0bGRRk551Cx_cwsFEPz01SMfJZICfE
これをよく見るとドット(.
)で3つに区切られていることがわかります。
わかりやすいように改行を入れると次のようになります。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyIjoidXNlcjQyIiwiZW1haWwiOiJ1c2VyNDJAZ21haWwuY29tIn0.
2V314JZqwHS5V0bGRRk551Cx_cwsFEPz01SMfJZICfE
この状態で最初の2つをBase64でデコードします。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
-> {"alg":"HS256","typ":"JWT"}
eyJ1c2VyIjoidXNlcjQyIiwiZW1haWwiOiJ1c2VyNDJAZ21haWwuY29tIn0.
-> {"user":"user42","email":"user42@gmail.com"}
ここからわかるように、JWTは単にJSONをBase64URLでエンコードしただけのものです。
ちなみに以下のPythonコードで作成しています。
import jwt
key = 'Pfjaowei@fa)3Jiw&2jawiCjawlI;qAjalwiefG?kailwefawefwaefjila;'
jwt_session_key = jwt.encode(
{
"user": "user42",
"email": "user42@gmail.com",
},
key,
algorithm="HS256",
)
print(f'{jwt_session_key=}')
試すには事前にPyJWTをインストールしてください。
ちなみにkeyの部分は外部に流出してはダメな部分です。
ここではその場限りの適当な値を入れています
JWTの構造について
話が前後しましたが、.
で区切られた最初の領域がヘッダー、次の領域がペイロード、最後の領域が署名部分になります。
ヘッダー: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
ペイロード:eyJ1c2VyIjoidXNlcjQyIiwiZW1haWwiOiJ1c2VyNDJAZ21haWwuY29tIn0.
署名: 2V314JZqwHS5V0bGRRk551Cx_cwsFEPz01SMfJZICfE
ペイロードはもともとのJSONデータですが、署名部分はヘッダーとペイロードをBase64URLでエンコードしたものを暗号化したものになります。ヘッダーはどのようなアルゴリズムで暗号化するのかを示したJSONデータになります。今回はHS256を利用しています。
図示すると次のようになります。
あらためてJSONからJWT変換の手順をまとめると次のようになります
- ペイロード用のJSONを作成する
- 署名のアルゴリズムを決定してヘッダー用のJSONを作成する
- 作成したJSONをそれぞれBase64URLでエンコードする
- ドット(
.
)で連結する - 3で作成したものをヘッダーで指定したアルゴリズムでエンコードし、さらにBase64URLでエンコード
- 3と4で作成した文字列をドット(
.
)で連結して完成
なぜ署名が必要なのか?
JWTのペイロード部分は上述したようにただのBase64でエンコードしただけなので、デコードも簡単にできますし、書き直すことも簡単です。しかし、署名部分は秘密鍵を持っている人間にしか作成することはできません。もし悪意を持って書き直されたとしても署名を検証することで編集を検知することができます。
なぜJWTは認証に利用できるのか?
JWTによる認証の前にセッションIDによる認証について説明します
セッションIDによる認証
認証とは、ある人がその人であることを確認する行為です。
パスワードが必要なサイトでは、その人しか知らないパスワードを送信することでその人であることを確認します。
しかし、毎回パスワードを送信すると、流出の可能性が高くなるので、一度認証が通ればあとはセッションIDをクライアントに渡すことでパスワードの代わりにしています。
クライアント側はセッションIDをCookieに保存し、サーバー側はDBに保存します。
クライアント側はリクエスト毎にセッションIDをサーバーに渡し、サーバーは受け取ったセッションIDをDB内にあるものと照らし合わせることで、その人がすでにパスワードによる認証を通っていることを確認します。
つまり、正当なセッションIDを持っていることが認証されていることの証明になります。
逆にいうとセッションIDが流出したら、なりすましが可能になります。
これはJWTも同じです。
JWT(ステートレス)による認証
JWTもセッションIDと同じ流れです。
クライアントがパスワードを送信し、サーバー側はそれを受けてJWT作成し、これをクライアントに渡します。
クライアントはそれ以降、JWTをサーバーに渡すことで認証されます。
ただ、セッションIDはDBに保存しますが、JWTはDBに保存する必要がありません。
なぜなら、署名があるからです。
セッションIDをDBに保存するのは、そのIDは自分(サーバー)が作成したIDであることを思い出すためです。
しかし、JWTの場合はJWT自体にサーバー自身が署名をしているのでDBに保存する必要がありません。
繰り返しになりますが、署名はヘッダーとペイロードから作成されますが、その作成の過程で秘密鍵を使います。そのため、そのヘッダーとペイロードから出来上がる署名は、秘密鍵を持っているサーバーにしか作成できないのです。
なので、JWTの正当性を確認したければ、ヘッダーとペイロードを手元の秘密鍵でエンコードし、これが署名部分と一致すれば、同じ秘密鍵で作成したJWTであることがわかります。これで自分が作成した署名であるとわかります。
つまり、正しい署名を持っていること自体が、認証を通っていることの証左になるのです。
JWT(ステートレス)による認証の問題点
上記のようにDBに保持しない方法でJWTを利用するとログアウトができなくなります。
なぜなら、JWT自体が認証されていることの証であるので、このデータそのものを無効化することはできないからです。
セッションIDの場合、DBから削除すれば該当セッションIDは使えなくなります
ではどうしたのか?
冒頭でも書いた通り、こちらの記事を参考にして実装することにしました。
要約すると、セッションIDをJWT内のデータに組込み、さらにJWTデータをセッションIDとして利用することだと思います。
なので、JWTを認証に用いていますが、ステートレスではありません。
DjangoによるJWT
前置きが長くなりましたが、ここからが本番です。
環境
Django 5.0.4
Python 3.12.1
Djangoデフォルトの認証処理
まずprojectを作成し、Djangoデフォルトの認証ができるようにします。
django-admin startproject myjwt
cd myjwt/
python manage.py startapp accounts
python manage.py makemigrations
python manage.py migrate
何も考えずに次のようにファイルを編集・作成します。
Djangoデフォルトのsignup,login,logout処理をできるようにしているだけなので飛ばしても大丈夫です。
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
+ "accounts",
]
+ LOGIN_REDIRECT_URL = "accounts:login"
+ LOGOUT_REDIRECT_URL = "accounts:login"
- from django.urls import path
+ from django.urls import path, include
+ import accounts.urls
urlpatterns = [
path("admin/", admin.site.urls),
+ path("accounts/", include(accounts.urls)),
]
from django.urls import path
from . import views
from django.contrib.auth.views import LoginView, LogoutView
app_name = "accounts"
urlpatterns = [
path("signup/", views.SignupView.as_view(), name="signup"),
path("login/", LoginView.as_view(), name="login"),
path("logout/", LogoutView.as_view(), name="logout"),
]
from django.shortcuts import render
+ from django.views.generic import CreateView
+ from django.contrib.auth.forms import UserCreationForm
+ from django.urls import reverse_lazy
# Create your views here.
+ class SignupView(CreateView):
+ success_url = reverse_lazy("accounts:login")
+ template_name = "accounts/signup.html"
+ form_class = UserCreationForm
{% load static %}
<html lang="ja">
<body>
<form method="POST" action="{% url 'accounts:signup' %}">
{% csrf_token %} {{ form }}
<button type="submit">認証</button>
</form>
</body>
</html>
{% load static %}
<html lang="ja">
<body>
{% if user.is_authenticated %}
<form action="{% url 'accounts:logout' %}" method="post" name="logout">
{% csrf_token %}
<button type="submit">Log_out</button>
</form>
{% else %}
<form method="post" id="login-form" action="{% url 'accounts:login' %}">
{% csrf_token %} {{ form }}
<button type="submit">Log in</button>
</form>
{% endif %}
</body>
</html>
編集が終わったらrunserverを実行し、http://localhost:8000/accounts/signup/
にアクセスして適当なユーザーを作成します。作成に成功すれば、自動でloginページに飛びます。
loginページはログインしていないときは次のようになり、
これでloginされていることが視覚的にわかるようになりました。
JWTを組み込む
やりたいことを再確認します。
現在セッションIDによる認証は実現しています。
このセッションIDをJWTに内包し、かつセッションIDをJWTに置き換えることを目的としています。
セッションIDはDevToolなどからリクエストヘッダーのCookieを覗き見ることで確認できます
Djangoによるセッション管理
今回の目的のために、セッション管理をカスタマイズする必要があります。
Djangoのデフォルトのセッション管理はミドルウエアであるSessionMiddlewareクラスで行っています。
Djangoのミドルウエアは、クライアントとサーバー間の通信におけるリクエストとレスポン処理の実装をつかさどる部分です。ひとつのミドルウエアでリクエストに対する処理もレスポンスに対する処理も実装できます。
なので、このSessionMiddlewareを継承して必要な部分だけ書き直すことにします。
ミドルウェアの実装方法として__call__を使ったほうがいいのかもしれませんが、今回は既存のものを置き換えることが目的なので継承を使いました
まず、accountsアプリ内にmiddleware.pyを新規作成し、SessionMiddlewareクラス内のコードをコピペします。必要なものを追加でimportし、__init__は編集しないのでここでは削除します。
from django.utils.http import http_date
+ from django.contrib.sessions.middleware import SessionMiddleware
+ from datetime import datetime, timezone, timedelta
+ import time, jwt
+
- class SessionMiddleware(MiddlewareMixin):
- def __init__(self, get_response):
- super().__init__(get_response)
- engine = import_module(settings.SESSION_ENGINE)
- self.SessionStore = engine.SessionStore
+ class CustomSessionMiddleware(SessionMiddleware):
def process_request(self, request):
レスポンス処理
SessionMiddleware内のprocess_response()内でセッションIDをCookieに付与していることがソースコードから類推できます。なので、セッションIDの部分を次のようにJWTに内包し、これに差し替えます。
def process_response(self, request, response):
#略
if response.status_code < 500:
try:
request.session.save()
except UpdateError:
raise SessionInterrupted(
"The request's session was deleted before the "
"request completed. The user may have logged "
"out in a concurrent request, for example."
)
# ここでJWTを作成する
+ jwt_session_key = jwt.encode(
+ {
+ "session_key": request.session.session_key, #従来のセッションID
+ "exp": datetime.now(tz=timezone.utc)
+ + timedelta(seconds=14400),
+ "iat": datetime.now(tz=timezone.utc),
+ },
+ getattr(settings, "JWT_SECRET_KEY", None), # 適当な文字列
+ algorithm="HS256",
+ )
response.set_cookie(
settings.SESSION_COOKIE_NAME,
- request.session.session_key, #従来のセッションID
+ jwt_session_key,
max_age=max_age,
expires=expires,
#略
リクエスト処理
レスポンス時に渡したJWTを今度はリクエスト時に受け取る必要があります。
セッションIDを受け取る処理と、セッションストアに保存する処理は従来通りです。
やっていることはJWTをデコードして、内部のセッションIDを抽出しているだけです。
そしてセッションIDは従来通りにセッションストアにより管理されます。
def process_request(self, request):
tmp_session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
session_key = tmp_session_key
try:
if tmp_session_key is not None:
jwt_decode = jwt.decode(
tmp_session_key, #もともとのセッションID
getattr(settings, "JWT_SECRET_KEY", None), # 適当な文字列
leeway=5, # 通信上の遅延で5秒遅くても大丈夫ないように
algorithms=["HS256"],
)
session_key = jwt_decode["session_key"]
except jwt.ExpiredSignatureError:
pass
except jwt.exceptions.DecodeError:
pass
request.session = self.SessionStore(session_key)
settings.pyの編集
カスタマイズしたミドルウエアは完成したので、これを適用するために、settings.pyを次のように編集します。
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
- "django.contrib.sessions.middleware.SessionMiddleware", #従来のもの
+ "accounts.middleware.CustomSessionMiddleware", #新規作成したもの
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
+ JWT_SECRET_KEY = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" #秘密鍵(適当)
これですべて完了です。
runserverを起動してログインしてみます。
セッションIDを確認すると、無事JWTに変換されていることが確認できます。
本当にJWTとして動いているのか?
これを確かめるために、有効期限を2時間から1秒に変更してみます。
jwt_session_key = jwt.encode(
{
"session_key": request.session.session_key,
"iss": "http://localhost",
"exp": datetime.now(tz=timezone.utc)
- + timedelta(seconds=14400), #2時間
+ + timedelta(seconds=1), #1秒
"iat": datetime.now(tz=timezone.utc),
},
getattr(settings, "JWT_SECRET_KEY", None),
algorithm="HS256",
)
再度runserverを実行し、6秒以上たった後にlogin画面を更新します。
すると、logout状態になりました。これによりjwtの有効期限が正しく動作していることがわかります。
なぜ1秒に設定したのに6秒待つ必要があるのかというと、デコード時にleewayを設定し、5秒間遅延があっても期限内とみなすように設定しているからです。
jwt_decode = jwt.decode(
tmp_session_key,
getattr(settings, "JWT_SECRET_KEY", None), # 適当な文字列
leeway=5, # 通信上の遅延で5秒遅くても大丈夫ないように
algorithms=["HS256"],
)
同様にセッションID(JWT)を書き直すとどうなるでしょうか?
Chromeのdevtoolのアプリケーションタグ->ストレージ->Cookieからsessionidを変更することができます。
変更した後で再度loginページを更新すると、ログアウトされていることが確認できました。
これによって悪意ある編集を検知できることもわかります。
終わりに
42Tokyoの課題の一環としてJWTをDjango上で使えるようにしましたが、正直JWTを使用する必要性がわかりませんでした。
-> (追記)2FAを組みあわせて使用することで利点がわかりました(続き)
実はこの課題はマイクロサービスの実装も任意でやっていいことになっているので、出題者はマイクロサービスと組み合わせて使うことを想定しているのかもしれません。今回、マイクロサービスはやらないことを事前に決めているのでJWTの調査もこれでいったん区切ろうと思います。
この手の記事を書くのは初めてなのでめちゃくちゃ時間がかかりましたが、書いているうちに署名の作成方法を勘違いしていることに気づけたので、やはりアウトプットは大切なのだと実感できました。
今後も継続してアウトプットしていこうと思います。