Djangoはパスワードをどうやって保存しているのか
こんにちは、筒井です。このブログでは、日々の業務で得た知見や個人的に興味がある分野について記事を書いていきたいと思います。よろしくお願いします。
今回は、Djangoの内部構造についての解説記事を書きます。
Djnagoにはパスワードをセキュアに保存する仕組みがあります。
この仕組みのおかげで、プログラマーはあれこれ考えずにセキュリティ上のリスクを回避できます。この記事では、Djangoがプログラマーの代わりに考えてくれる「あれこれ」の部分について解説します。
ソースコードの中でパスワードをどのよう扱っているか、それがなぜセキュアなのかを明らかにします。
なお、この記事で対象としているDjangoのバージョンは1.11 LTSです。
Table of Contents
ウェブアプリケーションにおけるパスワード保存の原則
Djangoの話の前に、一般論としてウェブアプリケーションでパスワードをセキュアに保存する方法について解説します。
プログラマーの経験が浅いとありがちですが、ユーザーが入力したパスワードをデータベースにそのまま保存している(平文で保存している)ウェブアプリケーションは時々あります。そのような実装には以下の手段によりパスワードが外部に漏洩するリスクがあります。
- SQLインジェクション
- バックアップメディアの盗難・持ち出し
- ハードディスクの盗難・持ち出し
- 内部のオペレータによる持ち出し
これらを完璧に防げればいいのですが、人間にミスは付き物です。気をつけていてもトラブルは起こります。そうなってもアカウントを奪われることがないよう、パスワードは平文で保存しないことが重要です。
ウェブアプリケーションでパスワードをセキュアに保存するには、以下の方法が原則です。
- 「入力されたパスワード」と「ソルト値(ランダムな文字列)」を結合
- 「ハッシュ関数を一定回数繰り返し使って
1.
の値をハッシュ化」(ストレッチング)してからデータベースに保存
上記の方法により、データベースに保存されたパスワードは元の平文に戻すことができなくなり、たとえパスワードを盗まれてもアカウントを奪えません。
さらに詳細な解説は、「体系的に学ぶ 安全なWebアプリケーションの作り方 第2版 脆弱性が生まれる原理と対策の実践」471ページ「5.1.3パスワードの保存方法」を参照してください。
Djangoでパスワードを保存するロジックの場所と内容の解説
次に、Djangoでは前述の方法をどのような形で実装しているのかを見てみましょう。
Djangoでユーザーのパスワードを設定する際に呼ばれるのはdjango.contrib.auth.models.User.set_password
メソッドです。まずここから辿ってみます。
django.contrib.auth.hashers.make_password
という関数で何かをやっていそうです。中身はこうなっています。
最後の hasher.encode(password, salt)
は何をやっているのでしょうか? 前述の「ウェブアプリケーションにおけるパスワード保存の原則」に従うなら、入力されたパスワードとソルト値を結合してストレッチングしているはずですが、実際の定義を見てみましょう。
encode
メソッドの定義は複数箇所にあります。hasher
変数の中身は get_hasher
関数によって作られるオブジェクトで、複数のクラス定義があります。
(本記事では、このオブジェクトを「hasher」と呼ぶことにします)
クラスはPASSWORD_HASHERS
に設定されているものが使われます。
(本記事では、このクラスを「hasherクラス」と呼ぶことにします)get_hasher
関数は、先ほどコードを読んだdjango.contrib.auth.models.User.set_password
メソッド経由で呼ばれる場合は PASSWORD_HASHERS
の先頭のhasherクラスを使います。
PASSWORD_HASHERS
がデフォルト設定の場合、先頭のhasherクラスはdjango.contrib.auth.hashers.PBKDF2PasswordHasher
です。
今回はこのクラスの定義を見てみましょう。
django.utils.crypto.pbkdf2
という関数が「入力されたパスワードとソルト値を結合してストレッチング」をやっているコードの本丸のようです。ここでは何をしているのでしょうか?
Djangoでパスワードをセキュアに保存してくれる仕組みの正体は、Pythonの標準関数 hashlib.pbkdf2_hmac
であることが分かりました。
公式ドキュメントではhashlib.pbkdf2_hmac
について以下のように書かれています。
この関数は PKCS#5 のパスワードに基づいた鍵導出関数 2 を提供しています。疑似乱数関数として HMAC を使用しています。https://docs.python.org/ja/3/library/hashlib.html#hashlib.pbkdf2_hmac
「鍵導出関数 2」は英語名で「Password-Based Key Derivation Function 2(PBKDF2)」、RFC 2898(Password-Based Cryptography)の中で提案されているパスワード保存に関する仕様です。
RFC2898は前述の「ウェブアプリケーションにおけるパスワード保存の原則」をRFCとして規定したものです。
ただし、これはdjango.contrib.auth.hashers.PBKDF2PasswordHasher
の場合の話です。他のhasherクラスでは脆弱なアルゴリズムを使っているものもあります。基本的にはPASSWORD_HASHERS
はデフォルト設定を使うようにしましょう。
なお、Django1.11 LTSでは hashlib.pbkdf2_hmac
がないバージョンのPythonもサポートしているため、 hashlib.pbkdf2_hmac
を代替するコードを使ったdjango.utils.crypto.pbkdf2
関数も定義されています。
Djangoがパスワード保存時にハッシュ値以外も含める理由
これで、Djangoがパスワードをセキュアに保存する仕組みが分かりました。ただ、今までに読んできたコードに1点不可解な部分がありました。もう少し付き合ってください。
デフォルトのhasherクラスdjango.contrib.auth.hashers.PBKDF2PasswordHasher
の定義をもう一度見てみましょう。
最後の return
を見てください。文字列に「アルゴリズム名」・「ストレッチング回数」・「ソルト値」が含まれています。なぜこんなことをしているのでしょうか? 理由は2点あります。以下で詳しく解説します。
【理由1】
django.contrib.auth.hashers.PBKDF2PasswordHasher.verify
メソッドの定義を見てください。
保存した値 encode
から「アルゴリズム名」・「ストレッチング回数」・「ソルト値」を取り出して、ログイン時に入力されたパスワード password
を保存時と同じロジックでハッシュ化するようになっています。
入力されたパスワードが正しければ、最後に比較している encoded
と encoded_2
は全く同じ値になっているはずです。
ちなみに、最後の行の constant_time_compare
関数は「タイミング攻撃」対策のために使われています。今回のテーマからは外れるので詳しい解説は割愛しますが、パスワードのような秘密の値と入力値を検証するコードでは、 ==
や !=
は使ってはいけないことだけ覚えておいてください。
【理由2】
ユーザーが入力したパスワードの正しさを検証する関数 django.contrib.auth.hashers.check_password
の定義を見てください。
「保存時に使っていたアルゴリズム名(hasher.algorithm
)」と「現在使っているアルゴリズム名(preferred.algorithm
)」が異なる場合、「保存時に使っていたhasher」で検証した上で「現在使っているhasher」でハッシュ値を作り直してくれています。
実際にDjangoアプリケーションを作って検証してみましょう。まず、settings.py
に以下設定を加えたDjangoアプリケーションを用意します。
PASSWORD_HASHERS = (
'django.contrib.auth.hashers.MD5PasswordHasher',
)
この状態で作ったユーザーのパスワードはdjango.contrib.auth.hashers.MD5PasswordHasher
を使ってハッシュ値が作られます。
>>> from django.contrib.auth.models import User
>>> user = User.objects.get(username='example')
>>> user.set_password('my_password')
>>> user.save()
>>> user.password
'md5$SMuy5hYbT0UO$275f29884e10c547874c5c170ddf53cb'
次に、PASSWORD_HASHERS
の先頭にdjango.contrib.auth.hashers.PBKDF2PasswordHasher
を追加してみましょう。
PASSWORD_HASHERS = (
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.MD5PasswordHasher',
)
この状態でパスワードの検証を行うと、 django.contrib.auth.hashers.MD5PasswordHasher
で検証した上で、 django.contrib.auth.hashers.PBKDF2PasswordHasher
を使った値に更新してくれます。
>>> from django.contrib.auth.models import User
>>> user = User.objects.get(username='example')
>>> # check_passwordメソッドは中でdjango.contrib.auth.hashers.check_passwordを呼んでいる
>>> user.check_password('my_password')
True
>>> user.password
'pbkdf2_sha256$36000$ASrxdtCsw3E6$u1k+CFO1y2TpbgClMQFiVITT6pUIP+H9Ss8sDrfK+iU='
このように、Djangoには後からよりセキュアなアルゴリズムが登場してもスムーズに移行できる仕組みが用意されています。
まとめ
- ウェブアプリケーションでのパスワードは、ソルト値と結合してストレッチングしたものを保存するのが原則
- Djangoでは、デフォルト設定ではRFC2898を実装したPython標準関数
hashlib.pbkdf2_hmac
を使って前述の原則に従った実装にしている(1.11 LTSではhashlib.pbkdf2_hmac
がないバージョンのPythonでも動くように、代替するコードも用意されている) - また、ログイン時に入力されたパスワードは、ハッシュ値と一緒に保存された「アルゴリズム名」・「ストレッチング回数」・「ソルト値」を使って保存時と同じロジックでハッシュ化してから検証する
- さらに、後でハッシュアルゴリズムをよりセキュアなものに変更した場合にスムーズに移行できる仕組みも用意されている