WordPress にパスキーやマジックリンクを足そうとしている方へ。実装そのものより先に、確認しておいたほうがいいことがあります。
その経路がログインクッキーを直接発行するなら、wp_authenticate が通り道でやっていた検査は、一つも通りません。全部こちらでやり直すことになります。この記事は、自分のプラグインでそれを取りこぼしたあとに整理した実装メモです。WebAuthn そのものの手順ではなく、既存のログインと並べたときに見落とす場所を扱います。
検証環境:【0.13.70 / 8月9日】。手元の確認は Chrome DevTools の仮想認証器と macOS が中心で、実機とマルチサイトの網羅検証は済んでいません。
なぜ authenticate フィルターに載せないのか
authenticate はユーザー名とパスワードの検証チェーンです。パスキーには検証すべきパスワードがないので、ここには載せませんでした。REST でチャレンジを検証して、成功したらセッション側が直接クッキーを発行します。
設計としてはこれでいいと思っています。代わりに、wp_authenticate の道中にあった検査が全部抜けます。自分の場合はマルチサイトのスパム検査で、ネットワーク上でスパムとして扱われている利用者が、パスキー・QR承認・マジックリンク・リカバリーコードの4経路すべてで入れる状態になっていました。パスワードログインなら何もしなくても効いていたものです。
いまは専用のゲートを1本置いて、そこから wp_authenticate_spam_check() を明示的に呼んでいます。サイト側のフィルターより前に適用するのが要点で、順番を後にすると、サイト側が上書きした結果を検査してしまいます。
同じ理由で、do_action( 'wp_login', ... ) はコアのフックとして意図的に発火させています。独自経路でのサインインが、サイト上の他のすべてから見て普通のサインインに見える必要があるからです。Plugin Check は接頭辞を付けろと警告してきますが、付けると目的が消えるので、ここは抑制しています。
クッキーを直接発行する経路を足すときは、自分が飛ばしている検査の一覧を先に作ってください。実装より先です。
並行実行に耐えさせるための3つの置き換え
読んでから比べる形で書いていた3か所を、全部置き換えました。共通しているのは、答えを持っている当人に聞いていなかったという点です。
| 元の書き方 | 何が答えていたか | 置き換え後 |
|---|---|---|
| 書き込み後に SELECT で有効性を確認 | 遅れたレプリカ | 更新行数だけを見る compare-and-set |
| 件数を読んで上限未満なら insert | 同時実行の隙間 |
UNIQUE (user_id, slot_no) による制約 |
| 同名インデックスの有無を尋ねる | スキーマの違うレプリカ | 書き込みを受けるサーバー上で制約の存在を証明 |
sign count の巻き戻り検知は、比較をやめて更新条件に畳みました。更新行が1なら前進、0なら巻き戻りか、儀式の最中に停止・削除されたということ。DBエラーの場合も含めて fail-closed です。SQL は末尾にまとめてあります。
このとき、毎回書き換わる列を1つ混ぜておくのがコツでした。行の内容が必ず変化するので、更新行数1という返答が、書き込みを受けたサーバー自身による「有効な行がまだ存在した」という答えになります。ここで追加の SELECT を投げると、また遅れたレプリカに聞くことになります。
登録上限は、アプリ側の件数比較からデータベースの一意制約に移しました。専用の列を1つ作って、そこに UNIQUE (user_id, slot_no) を張るだけです。そのうえで、上限を適用する前に制約が本当に存在することを書き込み側で確かめ、確かめられなければ503を返します。インデックスの有無を尋ねるだけでは、スキーマの違うレプリカが答え得るためです。
ついでにもう1件。サインイン成功時に、そのアドレスの試行カウンタを丸ごと消していました。検査に向かっている最中の別リクエストの枠まで解放してしまうので、共有IPだと1人がログインし直すだけで全員の失敗記録が消えます。いまは自分が取った1件だけを、トークンで証明して返しています。
認証器まわりで決めた既定値
resident key と user verification は、どちらも preferred にしました。required にすると、discoverable credential を作れない認証器を登録時点で締め出します。古いセキュリティキーを持っている利用者を切るには早い、という判断です。
妥協したぶんは、後段で取り返しています。UV を伴わないパスキーログインは、サイトの2FAの第二要素として認めません。所持だけでは1要素だからです。逆に、UV を強制する経路は常に第二要素として通します。UV の要求は引き上げ方向にしか受け付けないようにもしました。弱める方向に使えると、要求そのものが意味を失います。
サインインはユーザー名を取らず、ブラウザのパスキーピッカーに任せています。resident key を持たない古いキーのために username 経路も残しましたが、既定はオフです。有効にしたときは応答を固定形に整形して、存在しないユーザー名にも、名前とサイトシークレットから導出したダミー項目を返します。そうしないと、ユーザー名を入れるだけでアカウントの存在とパスキーの有無が分かります。
保存側で1つ。資格情報IDの列は varchar(512) にしてください。255 で切っていた時期に、状態をIDに畳み込む非常駐型の認証器が base64url で 255 を超えるIDを出し、黙って切り詰められて以後その鍵で引けなくなりました。
検査しているのはソースか、出荷物か
ここが最後で、いちばん高くつきました。
ビルドが if ( ! defined( 'ABSPATH' ) ) を if (!\defined('ABSPATH')) に書き換えていて、配布パッケージの108ファイル全部が直アクセス保護なしと判定されていました。同じ再出力で、コード規約の抑制注釈171件がすべて1行下にずれ、意図した行を守っていません。リポジトリを見ても分かりません。ソースには正しく書いてあるからです。
対策として、テストの対象をソースからビルド後の成果物に移しました。いま検査しているのは4つです。
抑制注釈が単独行にあること。テンプレート内では範囲指定になっていること。注釈を重ねていないこと。重ねると最後の1行しか届きません。そして直アクセスのガードが、ビルドを通過する形式で先頭50行以内にあること。
composer や webpack の再出力を挟んでいるなら、一度だけでいいので配布用のzipを展開して、静的解析をそちらに掛けてみてください。
出口をゼロにしない
強制まわりの設計も書いておきます。パスワードは残しました。強制を入れても、モデルはパスワードの無効化ではなく登録の強制です。猶予期間が過ぎても入れはして、入った先のプロフィール画面で足止めされるだけにしてあります。
そのうえで安全網が3つ。最後の管理者は決して強制対象にしない。wp-config.php に定数を1つ書けば強制を丸ごと止められる。ワンタイムのリカバリーコードで1回スキップできる。定数のほうは外し忘れが怖いので、立っている間は管理画面通知を出し続けます。
パスワードを無効化する選択肢も用意していますが、その場合の出口はマジックリンクとリカバリーコードです。出口がゼロになる構成は作れないようにしました。
次の自分に渡すメモ
- 独自のログイン経路を足したら、
wp_authenticateの道中で受けていた検査を書き出す。実装より先 - 書き込みの直後に読み返さない。更新行数を答えとして扱う
- 上限や一意性はアプリで数えず、データベースの制約に持たせる
- 資格情報IDは 512。255 は足りない
- 静的解析は配布物に掛ける。ソースに掛けたものは検査したうちに入らない
コード
資格情報テーブル。
CREATE TABLE {prefix}rapls_passkey_credentials (
id bigint unsigned AUTO_INCREMENT,
user_id bigint unsigned NOT NULL,
slot_no bigint unsigned DEFAULT NULL,
credential_id varchar(512) NOT NULL,
credential_data longtext NOT NULL,
sign_count bigint unsigned NOT NULL DEFAULT 0,
touch_nonce varchar(32) DEFAULT NULL,
label varchar(191) DEFAULT NULL,
active tinyint(1) NOT NULL DEFAULT 1,
created_at datetime NOT NULL,
last_used_at datetime DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY credential_id (credential_id),
KEY user_id (user_id)
)
credential_data は資格情報レコード全体のJSONで、公開鍵・transports・AAGUID・trust path・カウンタが入ります。これが正本で、sign_count は表示と比較のために非正規化したものです。active は資格情報を破棄せずに使用だけ止めるための列。カウンタを持たない認証器(常に0を返すもの)は行IDで更新し、再生防止はワンタイムチャレンジが単独で担います。
sign count の更新。
UPDATE ... SET sign_count = %d, touch_nonce = %s
WHERE id = %d AND active = 1 AND sign_count < %d
認証器の選択条件。
AuthenticatorSelectionCriteria::create(
Settings::webauthn_attachment(),
Settings::webauthn_user_verification(), // 既定 preferred
AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED
);
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。