Web アプリに 認証・認可機能を 追加する 機会が 今までに 何度か あった。 最も ベーシックな 認証方 法と して パスワード認証が あるのだが、 そこでは パスワードを どのように 入力させるべきかとか どれくらい 厳しく バリデーションすべきか、 は たまたどのように 保存すべきかと いう ことを 毎回 考えていた。
しかし、 そもそも パスワードに 関する 要件は プロジェクトに よって 左右されるべき事項でもないと 思うので、 この 際使いまわせるように ある 程度まとめてみる。 IdP (Identify Provider)や 多要素認証に ついては また別の 機会に まとめる 予定。
もちろん、 プロジェクトに よっては 管理者が 作成した アカウントしか ログインできないなどの 要件が ある ため全てに 当てはまる ガイドラインではない。 パスワード要件を 決めるに 当たっては 認証に 関する 標準である OWASP ASVS V2 を 参考に している。
OWASP は "Open Web Application Security Project" の 頭文字を 取った もので、 Web アプリの セキュリティを 高める ための ツールや 文書、 ベストプラクティスを 提供する 非営利団体の ことである。 Web アプリの セキュリティに 関する 基準を 定めている 団体は 他にも いくつか あるが、 OWASP は 広く 知られておりセキュリティ関連の 記事では 比較的よく 見る。
ASVS (Application Security Verification Standard)は、 OWASP が 提供する Web アプリの セキュリティに 関する 標準を 定義した フレームワークである。 これを 使う ことで 開発者や テスター、 企業などは、 アプリケーションの セキュリティの 要件を 明確に 定義、 評価、 検証する ことが 可能に なる。
OWASP の プロジェクトで 他に 気に なっているのは、 最も 現代的で 洗練された 安全で ない Web アプリを コンセプトに している OWASP Juice Shop である。 一見普通の サイトだが 中身は 脆弱性が ぎっしりと 詰まっている。 課題形式で ハッキングを 行えるので 良い 勉強に なりそう。
さて、 パスワード関連の 要件に ついて まとめていく。 OWASP ASVS 5.0 の 他に NIST SP800-63B や OWASP Cheat Sheet Series も 参考に している。
当然だが、 パスワードの 最小長は アプリケーションに よって 強制される 必要が ある。 8 文字未満の パスワードで あれば クライアント側の バリデーションで 弾くように する。
例えば、 メジャーな IDaaS である Firebase Authentication の 場合、 パスワードは 6 文字以上であれば 登録できるようになっている。 しかも Firebase 側で この パスワードポリシーを 変更する ことは できないため注意が 必要だ1 。
ちなみに クレジットカード情報の 取り扱いに 関する 国際的な セキュリティ基準である PCIDSS v4.0 に おける 要件では 12 文字以上が 推奨されている。 長ければ 長い ほど セキュアに なるが、 ユーザが 手動で 入力する ことを 考えると サービスに よって 要件は 変わるかもしれない。
今の 時代は コンピュータの 性能が 上がっている ことも あり、 8 文字程度の ランダムな パスワードであっても ブルートフォースアタックや 辞書攻撃を すると ボット対策を 設けていなければ 3 日で 解析されてしまう。 12 文字で あれば 3 年は かかる 計算に なる。 これが 14 文字に なると 解析に 数世紀かかる ためパスワードは 長ければ 長い ほどよい。
現時点では 14 文字以上だと 大して 変わらないように 思えるが 今後量子コンピュータが 実用化されれば 一気に 状況が 変わるかもしれない ( 広く 使われている 暗号が 安全ではなくなる )。
以下の サイトに パスワードを 打ち込むと パスワードが 解析されるまでの 時間を 大まかに 算出してくれる。
パスワードの 最小長は ある 程度 決めやすいが、 パスワードの 最大長は どれくらいに すれば 良いのか意外と 迷う。 ASVS には 最低でも 64 文字と 記してある。 ただ、 なぜ 64 文字なのかは わからなかった。 64 は 確かに キリが 良いけど。
現在、 パスワードを データベースに 保存するのであれば ソルト化ハッシュで 保存するのが ベストプラクティスである。 だから こそ 結局 ハッシュ化するなら パスワードの 最大長はとにかく 長ければ 長い方が 良いと 考えが ちだ。
しかし、 無制限に 長い パスワードを 許すと ハッシュ化する 際の 計算時間が 膨大に なって Dos 脆弱性を 生み出す 可能性が ある。
パスワードの ハッシュ化を する 際は 枯れた 技術である bcrypt を 使う 場面が 多いと 思う (ハッシュ化ライブラリと しては 多言語に 対応しているので)。 bcrypt の パスワード文字数は 基本的に どの 言語の ラッパーでも 72 文字までと いう 制限が ある (参考 )。 その 制限を 解除する 方 法も あるには あるが 2 、 わざわざ危険を 冒してまで パスワードの 最大長を 伸ばすには そこまで 意味が ない。
と なると、 bcrypt を 使う 場合は 64 文字を 最大長に するのが 良さそう。 可能で あれば Argon2id のような 比較的新しい ハッシュ関数を 使って 100 文字以上の 最大長に するべき。
当たり前の ことだが パスワード文字列の 切り 捨てや 大文字、 小文字の 変換を してはならない。 今ではさすがに そのような サービスを 見かける ことはないけど、 昔は そういった サービス が あったとか なかったとか。
巷に 溢れた サービスでは 基本的に 半角英数字、 気の 利いた サービスだと 記号まで パスワード文字列に 含んで 良いと 定義してある。
しかし、 OWASP ASVS 的には これだけでは 不十分であり、 印刷可能な Unicode 文字を パスワードと して 使用できなければならない と 記してある。 もちろん、 全ての Unicode 文 字や 空白を 含む文字を パスワードに 許可する ことは 技術的には 可能だが、 実装上の 課題や UX に 影響を 及ぼす 可能性が あり、 なかなか 難しい。
Unicode を 正確に エンコードしたりデコードしたりするのは 意外と 落とし穴が 多かったりして バグの 温床に なる。 また、 モバイルデバイスでは 特殊な Unicode 文字や 記号を 入力するのは 面倒なので ユーザの ことを 考えた 実装とは 言えない。
現実的には 大文字、 小文字、 数字、 および 一般的な 記号 (た とえば !@#$%^&*()
)の 組み合わせを 許可するのが 最も 理に 適っていると 思う。
これは 意外だった。 少なくとも 1 文字以上の 数字、 大文字、 記号のような 文字種を 組み合わせて パスワードを 作成するよう 求める サービスは 今までに 何度も 遭遇した。
しかし、 漏洩した パスワードデータベースの 分析に より、 そのような ルールでは ユーザビリティと 記憶難易度へ 与える 影響は あれど実際は そこまで パスワード強度に 影響が ないことが わかってきているらしい (参考 )。 むしろ逆効果に さえなりうる。 ほとんどの 人は 似たような パターン (最初の 文字は 大文字、 最後の 文字は 記号、 最後の 2 つは 数字)を 使用する 傾向が あるので ハッカーは 辞書攻撃を 仕掛けやすい。
複雑で 短い パスワードよりも 長めで シンプルな パスフレーズの 方が 強度と しては 高くなる。
前提と して ユーザは アクティブな セッションで 認証されている 状態である。 もしパスワード変更時に 現在の パスワードを 要求しないと 何が 起こるかを イメージしてみよう。
仮に 正当な ユーザが 公共の パソコンで ログインしていたとする。 この ユーザが ログアウトするのを 忘れてしまった 場合、 その後に 公共の パソコンを 利用する ユーザに よって パスワードを 変更されてしまう 可能性が ある。
稀な ケースでは あるが ハッキングされてパスワードが 変更される 可能性も あるので、 それを 防ぐ ためにも 現在の パスワードは 要求するように すると、 永久に ログインできない ユーザを 生み出さずに 済む。 その際は パスワードを 忘れた 人用の フローも 用意する。
良い パスワードを 選択させると いうのは なかなか 難しいしある 程度限界が ある。 かと いって 簡単な パスワードを 許してしまっては、 仮に アカウントが 侵害された 場合は その 対応コストを 運営側が 支払わないと いけない。
あくまで パスワードを 決めるのは ユーザである。 が、 破られるであろう パスワードを 拒否する 権利も サービス側には ある。
OWASP ASVS では 頻繁に 使われる パスワードの 少なくとも 上位 3,000 個を 照合するように 勧めている。 その際は 上位 100,000 件の 侵害された パスワードが まとめて ある Pwned Passwords の データセットを 使う 方 法が おすすめ (PwnedPasswordsTop100k.json )。 ただ、 文字数が 少ない パスワードも 含まれているので 計算回数を 少なく する ためにも 使用前は 以下のように フィルタリングして 別ファイルを 作成するのが 良い。
fetch ( ' https://www.ncsc.gov.uk/static-assets/documents/PwnedPasswordsTop100k.json ' )
. then ( response => response . json ())
. then ( data => data . filter ( item => item . length >= 8 ))
. then ( result => console . log ( result ))
. catch ( error => console . error ( ' Error: ' , error ));
上記の コードを 実行する ことで 8 文字以上の パスワードを 抽出して 47,350 件まで 絞り込みを 行えた。 もし API を 使いたい 場合は Pwned Password API を 使えば OK。 そこは 実際の プロジェクトの 要件に よって 決める。
他には SecLists の パスワードリストを 使う方 法も ある。
また 上述した パスワードリストの 他にも サービス固有の コンテキスト (この サイトで あれば kkhys.me
)などが 含まれていないかも 確認して、 あれば 登録を ブロックする。
パスワード入力フィールドの 属性に type=password
を 指定する ことで テキストが 読み取られないように 記号に 置き換えられる。 そうする ことで ショルダーハッキングを 防げる。
ただ、 それだけだと 入力した パスワードを 確認したい 場合に 困るので パスワードの 表示・非 表示を 切り 替えられる オプションを 提供する。 そう すれば スクリーンが 盗み見られる 可能性が 低い 場所に いる 場合に 入力内容を 確認できる。 または モバイルデバイス向けに 個々に 入力した 文字を 入力後に 短時間だけ 表示されるように する 方 法も ある。
稀に パスワードの ペーストが 許可されていない サイトが ある。 そうする ことで パスワードの 再利用を 防ぐ 目的が あるのかもしれないが、 今や パスワードマネージャや Apple の パスワード自動生成機能を 積極的に 使っている 人が 多くなってきている。 ペーストが 許可されていないと それらの ツールを 使えずに 結果と して 貧弱な パスワードを 設定してしまうことが 想定される。
そのような 本末転倒な 事態を 防ぐ ためにも パスワードの ペーストは 必ず許可する。 また、 パスワードマネージャの 使用を 想定して autocomplete 属性 を 適切に 設定しておくと ユーザビリティが 高まる。
パスワード認証の 場合は ログイン ID と 合わせて 入力を 求める ことが 多いと 思うが、 その際の エラーメッセージに 注意しないと ログイン ID の 列挙攻撃を 許してしまう。 例えば ログイン ID が 正しくない 場合と ログイン ID は 正しいが パスワードが 間違っている 場合に 異なる メッセージを 表示すると (以下 例)、 その 違いに よって 攻撃者は 必要な 認証資格情報の 半分を 簡単に 取得できてしまう。
ログイン ID が 正しくない かつパスワードは 正しい → ログイン ID が 不明です ログイン ID は 正しいが パスワードが 正しくない → パスワードが 正しく ありません ログイン ID が メールアドレスの 場合、 パスワードほど 特定するのは 難しくないため 有効性が 確認されれば 悪用される 可能性も ある。 対策と しては 「ユーザーIDまたは パスワードが 間違っています」と いうように どちらの 認証資格情報が 間違っているのかわからないように する。
これらは 初歩的な 要件だが、 注意しないと いけないのは タイミング攻撃 である。
以下に 参考と なる コードを TypeScript で 書いてみた。
* Checks if a user exists in the system.
* @ param username - The username of the user.
* @ returns A promise that resolves to `true` if the user exists, otherwise resolves to `false`.
const userExists = async ( username : string ) => {
* Hashes the given password.
* @ param password - The password to be hashed.
* @ returns The hashed password.
const hash = ( password : string ) => {
* Looks up the credentials in the store.
* @ param username - The username to lookup.
* @ param passwordHash - The hashed password to compare.
* @ returns A promise that resolves to a boolean value indicating whether the credentials exist in the store.
const lookupCredentialsInStore = async ( username : string , passwordHash : string ) => {
* Authenticates a user with a given username and password.
* @ param username - The username of the user to authenticate.
* @ param password - The password of the user to authenticate.
* @ throws If the username or password is invalid.
* @ returns A promise that resolves if the authentication is successful.
const authenticate = async ( username : string , password : string ) => {
if ( await userExists ( username )) {
const passwordHash = hash ( password );
const isValid = await lookupCredentialsInStore ( username , passwordHash );
throw new Error ( " Invalid Username or Password! " );
throw new Error ( " Invalid Username or Password! " );
上記の コードでは まずログイン ID に 紐づく ユーザが データベースに 存在するかを 検索している。 ログイン ID が 合致しなかった 場合に 早期リターンする ことで 比較的重い パスワードの ハッシュ化計算を 行わないと いう パフォーマンス面での 意図が ある。
一見良さそうに 思えるが 致命的な 欠点と して、 ログイン ID が 存在しなければ すぐに レスポンスが 返ってくるが ログイン ID が 存在する 場合は 前者よりも 遅れてレスポンスが 返ってきてしまう。 そうなると ユーザ名列挙攻撃が 可能に なってしまう。
そうならないためにも 以下の コードのように ログイン ID や パスワードに 関係なく 同じ プロセスを 踏むように する。
const authenticate = async ( username : string , password : string ) => {
const passwordHash = hash ( password );
const isValid = await lookupCredentialsInStore ( username , passwordHash );
throw new Error ( " Invalid Username or Password! " );
パスワードを リセットする ときに ユーザしか 知り得ないような 質問の 回答を 要求する 方 法が ある (秘密の 質問 )。 この 方法は NIST SP 800-63 で 許容される 認証要素と して 認められなくなった ため 使用しないように する。
Google の セキュリティブログ に 秘密の 質問に 関する 問題点が わかりやすく まとめて ある。 中でも 以下の 部分が 興味深い。
Many different users also had identical answers to secret questions that we’d normally expect to be highly secure, such as "What’s your phone number?" or "What’s your frequent flyer number?". We dug into this further and found that 37% of people intentionally provide false answers to their questions thinking this will make them harder to guess. However, this ends up backfiring because people choose the same (false) answers, and actually increase the likelihood that an attacker can break in.
電話番号のような 他人が 推測できないような 秘密の 質問でも 多くの ユーザが 同じ 回答を していたらしい。 そういった 人たちは 推測されにくくなると 考えて 意図的に 偽の 回答を していたのだが、 これが 裏目に 出て 逆に ハッキングされる 危険性を 高めて いたとの こと。
推測されないような 秘密の 質問の 回答を 考えても 結局 思い出せずに アカウントの 復旧が できなければ 意味が ない。 アカウントを 回復させる プロセスと しては 以下の 3 パターンが ある。
URL トークン付きの メールを 送信する PIN 番号付きの SMS を 送信する バックアップコードを 登録時に 発行しておく 他にも OTP トークンを 使った 認証や FIDO を 使った 認証など あるが、 実装が 複雑に なる ため、 まずは メールアドレスか SMS を 使った アカウント復旧プロセスを 構築した ほうが 良いかもしれない。
ブルートフォースアタックや クレデンシャルスタッフィング 、 パスワードスプレー と いった 攻撃は 基本的に ボットを 使って 自動的に 行われる。 多要素認証 (MFA)を 実装すれば パスワード関連の 攻撃の 大半を 防げるが、 サービスに よっては MFA の 使用を 強制する ことが 難しい 場合も ある。 そこで 代替案と して ログインスロットリングと CAPTCHA を 使う 方法が ある。
ログインスロットリングは 攻撃者が 対話型の 手段で パスワードを 推測しようと 試行を 何度も 行うのを 防ぐ ための 方法である。 ログイン時の アクションを 計測して おき最大試行回数を 上回ったら アカウントを ロックする。
その際の 注意点と して 攻撃者が 異なる IP アドレスから ログインを 試行しても 問題ないように アクションは IP アドレスではなく アカウントと 紐づけるように する。
ログインスロットリングは 有効な 手段では あるが、 設計が 難しいため慎重に 導入しなければならない。 例えば、 ユーザが 誤って 他の ユーザの アカウントを ロックしてしまった ときは どうするかとか アカウントロックしてから ロックアウトされるまでの 期間を どれくらいに すべきかなど 実装上 考える ことが 多い。
CAPTCHA は Google の 提供する reCAPTCHA が 有名なので 知っている 人も 多いと 思う。 画像を 選択させる ことで それが ボットではなく 生身の 人間である ことを 確認している。
最近では invisible 型の reCAPTCHA が 主流に なってきたことも あり、 UX を 妨げる こともなくなってきた (画像や テキストの 読み取りは 結構ストレスに なるので)。 比較的導入は しやすいが、 自動化された 手法を 使用して 解決できる 弱点や、 解決できる サービスに アウトソーシングできる 弱点が ある ため予防策と してではなく、 防御を 多層に する ものと 考えた方が 良い。
これも ハッキング対策だが、 最低でも 以下の ログを 出力しておく こと。
ログイン途中で 発生した 全ての エラー 全ての パスワードエラー (入力した パスワード) 全ての アカウントロック そうする ことで 攻撃や 障害を リアルタイムに 検出できる。 ただ、 ログを 出力しても 気づかなければ 意味が ないので 攻撃を 検知したら Slack などに アラートを 送信する フローを 構築しておく。
みなさん、 パスワードマネージャを 使いましょう。 多要素認証も できれば 追加してください。