Reactで、入力したパスワードの強さを表示する方法

​​React​Playwright,TypeScript,Web Worker,zxcvbn-ts,アクセシビリティ,フォーム

入力したパスワードの強さを、5本のバーと「とても弱い」〜「とても強い」の言葉で表示します。強さの判定は、ライブラリの zxcvbn-ts に任せます。文字の種類を数えるだけでは見逃す「Password1!」のようなよくあるパスワードも、弱いと判定できます。

React の側では、判定を Web Worker で動かします。ランダムな長い文字列を打つと、判定に数百ミリ秒かかることがあり、画面と同じスレッドで動かすと入力が止まるためです。メールアドレスの一部を使ったパスワードを弱いと判定させる方法も、あわせて示します。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、@zxcvbn-ts/core 4.2.0、@zxcvbn-ts/language-common 4.1.3、@zxcvbn-ts/language-en 4.1.1、@zxcvbn-ts/language-ja 4.1.1、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。スクリーンリーダーでの読み上げ、スマートフォン、Firefox と Safari、Vite や webpack でのビルドは試していません。

パスワードの欄に「kumo umi sora」を1文字ずつ打つと、1文字目で「とても弱い」とバー1本、4文字目で「弱い」、7文字目で「ふつう」、9文字目で「強い」、11文字目で「とても強い」とバー5本に変わる
「kumo umi sora」を1文字ずつ13回打つ様子。押したキーと、変わった「強さ:…」の文字の背景を、その瞬間だけピンクにしている(弱いときのバーと警告の文字のピンクは、部品そのものの色)。入力した文字、キーと回数の表示は GIF 用に重ねたもので、メールアドレスの欄は隠している

文字の種類を数えるだけの判定では、よくあるパスワードを見逃す

自分で判定を書くなら、「8文字以上で、大文字・小文字・数字・記号をすべて含めば強い」のような決まりが思いつきます。この決まりと zxcvbn-ts の判定を、同じパスワードで比べました。zxcvbn-ts の score は 0〜4 の数で、大きいほど強いという意味です。

text
パスワード | 文字の種類の判定 | zxcvbn(英語の辞書まで) | zxcvbn(日本語の辞書も)
Password1! | 強い | 1 | 1
P@ssw0rd2026 | 強い | 1 | 1
Qwerty123! | 強い | 1 | 1
sakura2026 | 弱い | 1 | 1
Tanaka@1985 | 強い | 3 | 2
kumo umi sora yama | 弱い | 4 | 4
velvet-cactus-orbit-lamp | 弱い | 4 | 4

文字の種類の決まりでは「Password1!」「P@ssw0rd2026」「Qwerty123!」が強いと出ます。zxcvbn-ts では、どれも 1 でした。反対に、小文字の単語を並べた「kumo umi sora yama」は、文字の種類の決まりでは弱く、zxcvbn-ts では 4 でした。

zxcvbn-ts は、よく使われるパスワードや単語、人名の辞書と照らし合わせます。キーボードの並び(qwerty)、日付、繰り返し、a を @ にするような置き換えも見つけます。そのうえで、当てるまでに何回くらい試す必要があるかを見積もります。zxcvbn-ts のドキュメントの Introductionでも、文字の種類の決まりは「P@ssword1」のような弱いパスワードを通し、強いパスワードをはじくことがある、と書かれています。OWASP の Authentication Cheat Sheet は、大文字・数字・記号などを必須にしないこと、強さを表示するメーターを置くことを勧めていて、その道具として zxcvbn-ts を挙げています。

表の右の列は、日本語の辞書(@zxcvbn-ts/language-ja)も足した結果です。この辞書には、ローマ字で書いた日本人の名前や言葉が入っています。「Tanaka@1985」は、英語の辞書までだと 3、日本語の辞書も足すと 2 になりました。

score の意味は、zxcvbn-ts のドキュメントの Getting started に、当てるまでに試す回数(推測回数)の目安として書かれています。

score 推測回数の目安 この記事での表示
0 10の3乗回未満 とても弱い
1 10の6乗回未満 弱い
2 10の8乗回未満 ふつう
3 10の10乗回未満 強い
4 10の10乗回以上 とても強い

表示の言葉は、この記事で決めたものです。同じページによると、score が 2 以下のときは、何がよくないかの警告(feedback.warning)と、直し方の提案(feedback.suggestions)が返ることがあります。警告がないときもあり、検証環境では「sakura2026」(score 1)の警告は null でした。日本語の辞書のパッケージの translations を渡すと、警告と提案が日本語になりました。

text
--- Password1!(score 1、推測回数 10^4.3)
warning: よく使われるパスワードに似ています
suggestions: ["一般的ではない単語を増やしてください","最初の文字以外も大文字にしてください"]

判定は Web Worker で動かす

zxcvbn-ts の判定にかかる時間は、パスワードによって大きく変わります。Node.js で1回の判定にかかる時間を測りました(5回の中央値)。

text
Password1!(10文字): 11.5ms
velvet-cactus-orbit-lamp(24文字): 11.6ms
velvet-cactus-orbit-lamp-river-stone(36文字): 40.9ms
xq7vplmrz2kd9wtb(16文字): 41.7ms
xq7vplmrz2kd9wtbhy3nfgcs8jaeu4oi(32文字): 494.6ms
xqvplmrzkdwtbhynfgcsjaeuoixqvplm(32文字): 15.3ms

zxcvbn-ts のドキュメントの Introduction には、25文字くらいなら 5〜20ms ほどと書かれています。検証環境でも、単語を並べた36文字までは 41ms 以内でした。ところが、数字の混じったランダムな32文字は 495ms かかりました。同じ32文字でも、数字を抜くと 15ms でした。パスワードマネージャーが作るような文字列を打つと、1文字ごとにこの重い判定が走ります。

画面と同じスレッドで判定する版も作り、Chromium でこの32文字を 50ms 間隔で1文字ずつ打ちました。画面が 50ms 以上止まったところ(MDN の説明でいう long task)を、PerformanceObserver で数えた結果です。

text
画面と同じスレッドで調べる版: 打ち終わるまで 4169ms、50ms 以上画面が止まった回数 14回、いちばん長く止まった時間 373ms、合計 2223ms、最後の表示 強さ:とても強い
Web Worker 版: 打ち終わるまで 1765ms、50ms 以上画面が止まった回数 0回、いちばん長く止まった時間 0ms、合計 0ms、最後の表示 強さ:とても強い

重い計算を Web Worker に移して入力を止めない方法そのものは、このブログの「ReactでWeb Workerを使い、重い計算中も入力を止めない方法」で扱いました。その記事では、入力のたびに Worker を作り直し、古い計算を terminate() で止めています。この記事では、Worker を作り直さずに使い続けます。zxcvbn-ts の Worker は、起動のたびに大きな辞書を読み込むからです。ページを開いてすぐに「Password1!」を入れたとき、検証環境で最初の表示が出るまでに 343〜849ms かかりました。入力のたびに作り直すと、毎回この時間を待つことになると考えました(作り直す書き方では測っていません)。

text
1回目: 849ms(強さ:弱い)
2回目: 343ms(強さ:弱い)
3回目: 351ms(強さ:弱い)

Worker のファイル(strength-worker.ts)には、辞書を読み込んで判定する処理だけを置きます。判定したパスワードも結果に入れて返します。

ts
// パスワードの強さを、画面の処理とは別のスレッド(Web Worker)で調べる
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
import * as common from '@zxcvbn-ts/language-common';
import * as en from '@zxcvbn-ts/language-en';
import * as ja from '@zxcvbn-ts/language-ja';
import type { StrengthRequest, StrengthResult } from './strength-types';

const zxcvbn = new ZxcvbnFactory({
  dictionary: { ...common.dictionary, ...en.dictionary, ...ja.dictionary },
  graphs: common.adjacencyGraphs,
  translations: ja.translations,
});

onmessage = (e: MessageEvent<StrengthRequest>) => {
  const { password, userInputs } = e.data;
  const r = zxcvbn.check(password, userInputs);
  const result: StrengthResult = {
    password,
    score: r.score,
    warning: r.feedback.warning,
    suggestions: r.feedback.suggestions,
  };
  postMessage(result);
};

やりとりする値の型は、別のファイル(strength-types.ts)にまとめました。

ts
import type { Score } from '@zxcvbn-ts/core';

export type StrengthRequest = { password: string; userInputs: string[] };
export type StrengthResult = { password: string; score: Score; warning: string | null; suggestions: string[] };

Worker のファイルは、esbuild で画面のコードとは別の入口として strength-worker.js に書き出しました。画面のコードからは、new Worker() にその URL と { type: 'module' } を渡して起動します。

判定している間に入力が変わったら、最新の値だけを調べる

Worker の中の check は同期の処理なので、1つの判定が終わるまで、次のメッセージは処理されません。判定に数百ミリ秒かかるパスワードを打っていると、判定待ちが積み上がり、表示が入力に追いつかなくなります。

そこで、判定している間に届いた値は1つだけ取っておき、新しい値が来たら上書きします。判定が終わったとき、取っておいた値があれば、その結果は使わずに取っておいた値を調べます。欄が空になったら結果を消し、判定中だった結果が後から届いても使いません。

ts
import { useEffect, useRef, useState } from 'react';
import type { StrengthRequest, StrengthResult } from './strength-types';

type WorkerState = { worker: Worker; busy: boolean; next: StrengthRequest | null; latest: string };

export function usePasswordStrength(password: string, userInputs: string[]) {
  const [result, setResult] = useState<StrengthResult | null>(null);
  const ref = useRef<WorkerState | null>(null);

  useEffect(() => {
    const state: WorkerState = {
      worker: new Worker('/strength-worker.js', { type: 'module' }),
      busy: false,
      next: null,
      latest: '',
    };
    state.worker.onmessage = (e: MessageEvent<StrengthResult>) => {
      if (state.next) {
        // 調べているあいだに入力が変わったら、この結果は使わずに最新の値を調べる
        state.worker.postMessage(state.next);
        state.next = null;
      } else {
        state.busy = false;
        // 調べているあいだに欄が空になっていたら、この結果は使わない
        if (e.data.password === state.latest) setResult(e.data);
      }
    };
    ref.current = state;
    return () => state.worker.terminate();
  }, []);

  const inputsKey = userInputs.join('\n');
  useEffect(() => {
    const state = ref.current!;
    state.latest = password;
    if (password === '') {
      // 前の結果を消しておき、次に打ち始めたときに古い強さが出ないようにする
      state.next = null;
      setResult(null);
      return;
    }
    const request: StrengthRequest = { password, userInputs: inputsKey.split('\n').filter(Boolean) };
    if (state.busy) {
      state.next = request;
    } else {
      state.busy = true;
      state.worker.postMessage(request);
    }
  }, [password, inputsKey]);

  return password === '' ? null : result;
}

userInputs は配列なので、再レンダリングのたびに別の配列になります。useEffect の公式リファレンスにあるとおり、React は依存配列の値を Object.is で前の値と比べます。配列をそのまま入れると、中身が同じでも毎回違う値とみなされ、エフェクトが動きます。そこで、改行でつないだ文字列を依存配列に入れました。

Worker の読み込みに失敗したときの処理(onerror)は入れていません。読み込めなかったときは、強さが表示されないままになります。

Chromium では、入力が終わって表示が落ち着いたときに、最後に入れた値の結果が出ているかを確かめました。途中で一瞬だけ古い結果が出ていないかまでは、記録していません。

ランダムな32文字を、間隔を空けずに打ってから全部消し、「Password1!」を打ちました。最後の表示は「Password1!」の結果でした。

text
最後の表示: 強さ:弱い、警告: よく使われるパスワードに似ています

「とても強い」と出ているパスワードを消して、すぐに「a」を打ちました。打ち終わったあとの表示は、「a」の結果の「とても弱い」でした。

text
消す前: 強さ:とても強い
a を打った直後: 強さ:とても弱い
落ち着いたあと: 強さ:とても弱い

ランダムな32文字を打ってすぐに欄を空にし、1.5秒待ちました。1.5秒たっても、強さの表示もバーの色も出ませんでした。

text
1.5秒後: 強さの表示=0個、色の付いたバー 0本

useEffect の公式リファレンスにあるとおり、StrictMode の開発ビルドでは、エフェクトの準備と後片付けが1回多く動きます。開発ビルドで試しても、動いている Worker は1つだけでした。Worker を作るほうのエフェクトの後片付けで、terminate() を呼んでいるためです。

Worker に置くと、大きな辞書を画面のコードから切り離せる

zxcvbn-ts のドキュメントの Lazy loading のページによると、辞書は大きいので、あとから読み込むことが勧められています。Worker に置くと、辞書は画面のコードとは別のファイルになります。esbuild で縮めたあとの大きさは次のとおりでした。

text
2092868 dist/main-thread.js
225477 dist/main.js
1868207 dist/strength-worker.js
main.js gzip: 69859
strength-worker.js gzip: 944237
main-thread.js gzip: 1013549

画面と同じスレッドで判定する版(main-thread.js)は、React と辞書が1つのファイルに入って約 2.1MB でした。Worker 版の画面のコード(main.js)は約 225KB で、辞書を含む strength-worker.js は約 1.9MB(gzip で約 0.9MB)でした。

score を、5本のバーと言葉で表示する

表示の部品は、受け取った score に合わせて、5本のバーのうち score + 1 本に色を付けます。警告と提案があれば、その下に並べます。

tsx
import type { StrengthResult } from './strength-types';

const LABELS = ['とても弱い', '弱い', 'ふつう', '強い', 'とても強い'];
const COLORS = ['#d85878', '#d85878', '#e0a030', '#00c8e8', '#067a98'];

type Props = { id: string; result: StrengthResult | null };

export function StrengthMeter({ id, result }: Props) {
  // score は 0〜4。5本のバーのうち、score + 1 本に色を付ける
  const filled = result ? result.score + 1 : 0;
  return (
    <div className="strength">
      <div className="bars" aria-hidden="true">
        {[0, 1, 2, 3, 4].map((i) => (
          <span key={i} style={{ background: i < filled ? COLORS[result!.score] : '#dde3e6' }} />
        ))}
      </div>
      <div id={id}>
        {result && <p className="label">強さ:{LABELS[result.score]}</p>}
        {result?.warning && <p className="warning">{result.warning}</p>}
        {result && result.suggestions.length > 0 && (
          <ul>
            {result.suggestions.map((s) => (
              <li key={s}>{s}</li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}

バーは色だけで強さを表すので、aria-hidden="true" を付けました。MDN の aria-hidden のページによると、この属性を付けた要素は、その子の要素ごとアクセシビリティツリーから外れます。代わりに、言葉の表示を囲む要素に id を付け、パスワードの欄の aria-describedby でその id を指します。MDN の aria-describedby のページにあるとおり、この属性は、説明の文を持つ要素の id を並べて、その欄と結びつけます。Chromium のアクセシビリティツリーを書き出すと、パスワードの欄の説明は次のようになっていました。

text
name: パスワード
description: 強さ:弱い 個人情報やページに関する情報は含めるべきではありません 一般的ではない単語を増やしてください

入力のたびに読み上げさせる aria-live は付けていません。1文字ごとに読み上げが割り込むおそれがあるためです。

メールアドレスの @ より前の部分も userInputs に渡す

check の2つ目の引数(userInputs)に、パスワードに使ってほしくない言葉を渡せます。zxcvbn-ts の元になった zxcvbn の README では、この引数は追加の辞書として扱われ、名前やメールアドレスなど、フォームのほかの欄に入力された値を渡すためのものと説明されています。ただし、メールアドレスを丸ごと渡しただけでは、@ より前の部分をパスワードに使っても見つかりませんでした。

text
mikanbatake#7 userInputs=[] → score 4、10^11.7、warning: null
mikanbatake#7 userInputs=["mikanbatake@example.com"] → score 4、10^11.7、warning: null
mikanbatake#7 userInputs=["mikanbatake@example.com","mikanbatake"] → score 1、10^4.3、warning: 個人情報やページに関する情報は含めるべきではありません

@ より前の部分も別の言葉として渡すと、score は 4 から 1 に下がりました。フォームでは、メールアドレスとその @ より前の部分を渡します。

tsx
import { useId, useState } from 'react';
import { usePasswordStrength } from './usePasswordStrength';
import { StrengthMeter } from './StrengthMeter';

export function SignupForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const strengthId = useId();

  // メールアドレスと、その @ より前の部分を、パスワードに使ってほしくない言葉として渡す
  const userInputs = [email, email.split('@')[0]].filter(Boolean);
  const result = usePasswordStrength(password, userInputs);

  return (
    <form>
      <label>
        メールアドレス
        <input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      </label>
      <label>
        パスワード
        <input
          type="password"
          autoComplete="new-password"
          aria-describedby={strengthId}
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </label>
      <StrengthMeter id={strengthId} result={result} />
    </form>
  );
}

パスワードを先に入れてからメールアドレスを入れても、表示は変わります。userInputs が変わると、エフェクトの依存配列の inputsKey が変わり、もう一度判定するからです。Chromium で、パスワードに「mikanbatake#7」を入れたあとにメールアドレスを入れると、「とても強い」から「弱い」に変わりました。

text
メールアドレスが空: 強さ:とても強い、警告: (なし)
メールアドレスを入れたあと: 強さ:弱い、警告: 個人情報やページに関する情報は含めるべきではありません
メールアドレスの欄に「mikanbatake@example.com」、パスワードの欄に「mikanbatake#7」を入れると、バーは5本中2本がピンクになり、「強さ:弱い」と「個人情報やページに関する情報は含めるべきではありません」「一般的ではない単語を増やしてください」が表示される
メールアドレスの @ より前の部分を使ったパスワードの表示(「入力した文字」の行は、画像用に重ねたもの)

​Posted by 86