セキュリティ

認証まわりを安全に作る — パスワード保存・ロックアウト・リセット

認証はフレームワークの機能を使うのが最善ですが、
何が守られているのかを理解していないと、周辺の実装で穴を開けます

パスワードの保存

平文はもちろん、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 とアカウントの組み合わせで
  • リセットは使い捨て・短期限・完了時に全セッション破棄