認証はフレームワークの機能を使うのが最善ですが、
何が守られているのかを理解していないと、周辺の実装で穴を開けます。
パスワードの保存
平文はもちろん、sha256 のような高速なハッシュも不適切です。
総当たりが速すぎるためです。
パスワード用に設計されたハッシュ(計算コストを意図的に高くしたもの)を使います。
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]
自前で実装せず、フレームワークの set_password() / check_password() を使います。
ソルトの付与や繰り返し回数は、そこで担保されています。
強度チェックは「長さ」を優先する
記号必須のような複雑なルールより、長さのほうが効果があります。
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "...MinimumLengthValidator", "OPTIONS": {"min_length": 10}},
{"NAME": "...CommonPasswordValidator"}, # 流出済みの定番を弾く
{"NAME": "...UserAttributeSimilarityValidator"},
]
複雑さを強制すると Password1! のような予測しやすい形に収束します。
ログイン失敗の扱い
メッセージで存在を教えないのが基本です。
# × 「このメールアドレスは登録されていません」
# ○ 「メールアドレスまたはパスワードが正しくありません」
前者だと、登録済みアドレスの一覧を作られてしまいます。
試行回数の制限も必要です。ただしアカウント単位だけで制限すると、
第三者が意図的にロックさせる嫌がらせが可能になります。
IPとアカウントの組み合わせで見るのが現実的です。
key = f"login_fail:{ip}:{email}"
if cache.get(key, 0) >= 5:
raise TooManyAttempts
パスワードリセットの落とし穴
リセットは認証を迂回する導線なので、最も慎重に作ります。
- トークンは推測不能な乱数(連番やユーザーIDの変換は不可)
- 有効期限を短く(数十分)
- 一度使ったら無効化する
- リセット完了時に既存セッションを全て破棄する
- 「送信しました」は、登録の有無にかかわらず同じ文言にする
最後の点を忘れると、リセット画面がアカウント存在確認の手段になります。
セッションの扱い
SESSION_COOKIE_HTTPONLY = True # JavaScript から読めない
SESSION_COOKIE_SECURE = True # HTTPS のみ
SESSION_COOKIE_SAMESITE = "Lax" # 他サイトからの送信を制限
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
ログイン成功時にセッションIDを再生成し、
固定化攻撃(事前に用意したIDを使わせる手法)を防ぎます。
Django は login() の中でこれを行っています。
まとめ
- パスワード専用のハッシュを使う。自前実装しない
- 複雑さより長さ。流出済みパスワードを弾く
- 失敗メッセージで存在を教えない
- 試行制限は IP とアカウントの組み合わせで
- リセットは使い捨て・短期限・完了時に全セッション破棄