B2Cパスキー アーキテクチャ設計書
EC サイトフロント(EC-CUBE 会員)向けパスキー認証(Phase 2)の識別モデル、登録・認証フロー、API、データモデル。
概要
EC サイトのエンドユーザー(EC-CUBE の会員 = dtb_customer)向けのパスキー認証を定義する。基本は B2B パスキーと同じ経路と API の形にそろえ、B2C 固有の要件(パスキーだけでの新規会員登録、ソーシャルログインとの同一人物扱い、MAU 課金)を足す。
準備作業の進み具合は EcAuthDocs#164 で管理する。
スコープ
- B2C パスキー API(EcAuth 側)
- B2C ユーザーの識別モデル(#122 のユーザープールを Client 単位に分ける変更を含む)
- EC-CUBE プラグイン(4 系 / 2 系)との連携
- 課金(MAU 計上と上限強制)との接続
対象外
- ソーシャルログイン側の「連携する」UI(Phase 3 のプラグイン)。データモデルはこの設計で先に用意する
- Refresh Token(EcAuth#339)。EC-CUBE フロントは EC-CUBE 自身のセッションで動くため不要
- Conditional UI(オートフィルにパスキーを出す
mediation: "conditional")。B2B も未対応で、将来検討する
決定事項
| 論点 | 決定 |
|---|---|
| 組み込み方式 | 埋め込み API 型。プラグインが EC サイトのページで navigator.credentials を呼び、EcAuth の /v1/b2c/passkey/* で検証する。EcAuth のページへ遷移させるホスト型は採らない |
| RP ID | EC サイトのドメイン(B2C Client の許可 RP ID)。パスキーはショップに紐づく |
| 呼び出し経路と API 認証 | B2B と同じ。ブラウザ → プラグインのサーバー → EcAuth。登録系は client_id + client_secret、認証系は client_id のみ。verify が認可コードを返し、プラグインが /v1/token で交換して ID トークンを検証する |
| 会員の識別子 | customer:{customer_id}(dtb_customer.customer_id 由来の不変キー)を基準にする。メールアドレスは識別子にしない |
| 同じメールアドレスの統合 | 確認済みのメールアドレス同士だけを自動で統合する(IdP が email_verified=true を返した場合と、EC-CUBE の本会員)。確認されていなければ別ユーザーとして作る |
| 異なるメールアドレスの統合 | Google / Facebook / EC-CUBE でメールアドレスが違っても、同じ customer_id にログインできるようにする。ログイン中の会員が明示的に連携したときだけ紐付ける |
| 新規会員登録 | パスキーだけでの新規会員登録を認める。メール確認は必須で、パスキーはメール確認の後に作る |
| 識別モデルの記法(#122) | B2B の b2b_user_identity と同じ形の子テーブル ecauth_user_identity(issuer_key, external_id)を設ける。ecauth_user の (organization_id, email_hash) 一意制約は外す |
| クレデンシャルのテーブル | B2C 専用のテーブル b2c_passkey_credential を新設する(b2b_passkey_credential を一般化しない) |
| WebAuthn のパラメータ | 基本的に B2B にそろえる。ただし最初から discoverable credential にする(後述) |
| パスキーの一覧・削除と退会 | どちらも提供する。EC-CUBE のマイページで一覧・削除し、EC-CUBE で退会したら EcAuth 側も削除する |
| 上限強制 | B2B と同じく B2C にも適用する。無料枠(50 MAU)以上で支払い方法が未登録の Client では、新規ユーザーの register/options を 402 にする |
| 対応プラグイン | EC-CUBE 4 系と 2 系の両方。4.4 系(#157、12 月リリース予定)の対応を間に挟む可能性がある |
| ボタンの配置 | ログイン画面、購入手続き中のログイン、マイページ(パスキーの管理) |
| auth-js の配布 | プラグイン同梱に変える。B2B(管理画面)も同じく同梱にし、CDN からの読み込みをやめる(#72 の Option B) |
| E2E テスト | Playwright の仮想認証器(CDP の WebAuthn.addVirtualAuthenticator)を使う |
全体構成
Client 構成
EC-CUBE 1 サイトは管理画面用の B2B Client とフロント用の B2C Client を持つ(要件定義書 §2.2.2)。B2C パスキーはソーシャルログインと同じ B2C Client の認証方式の 1 つとして実装する。別の Client に分けると、同じ顧客がパスキーで登録した後にソーシャルログインしたとき、別ユーザーになるため(#122)。
| Client | SubjectType | 認証方式 |
|---|---|---|
| 管理画面用 | B2B | 管理者パスキー(+ 将来の企業 SSO) |
| フロント用 | B2C | 顧客パスキー + ソーシャルログイン(複数 IdP) |
フロントと管理画面は同じドメインなので、B2B と B2C の RP ID は一致する。B2C は allowCredentials を空にする discoverable credential のため、認証器が候補として出すのはその RP ID の全パスキー(管理者のものも含む)になる。選ばれたクレデンシャルが B2C Client のものかを verify で必ず検証し、管理者のパスキーでは顧客としてログインできないようにする(セキュリティ)。
呼び出し経路
sequenceDiagram
participant B as ブラウザ(auth-js 同梱)
participant P as EC-CUBE プラグイン
participant E as EcAuth
B->>P: POST /ecauth/b2c/passkey/authenticate/options
P->>E: POST /v1/b2c/passkey/authenticate/options(client_id)
E-->>P: session_id + options(allowCredentials 空)
P-->>B: options
B->>B: navigator.credentials.get()
B->>P: POST /ecauth/b2c/passkey/authenticate/verify
P->>E: POST /v1/b2c/passkey/authenticate/verify(client_id, PKCE)
E-->>P: redirect_uri?code=...
P->>E: POST /v1/token(client_secret, code_verifier)
E-->>P: id_token(sub = EcAuth の subject)
P->>P: id_token 検証 → dtb_customer.ecauth_subject で会員を特定してログイン
ブラウザから EcAuth を直接呼ばない。client_secret はプラグインのサーバー側にだけ置く。
B2C ユーザーの識別モデル
方針
- 1 人の B2C ユーザー(
ecauth_user)に、複数の識別子(identity)がぶら下がる。EC-CUBE 会員(customer:{customer_id})、Google、Facebook などが同じecauth_userに解決される - 識別子は発行元(
issuer_key)と発行元における不変キー(external_id)の組で一意にする。B2B のb2b_user_identityと同じ形 - メールアドレスは識別子ではなく、統合の手がかりとして持つ。変わりうるため一意制約は張らない
- ユーザープールは Client(サイト)単位。別サイトの同じ顧客は別ユーザーになる(#122)
erDiagram
ecauth_user ||--o{ ecauth_user_identity : "1:N"
ecauth_user ||--o{ b2c_passkey_credential : "1:N"
ecauth_user {
string subject "UUID(ID トークンの sub)"
int organization_id
string client_id "所属する B2C Client(ユーザープールの単位)"
string email_hash "確認済みメールのハッシュ。一意ではない"
datetime email_verified_at
}
ecauth_user_identity {
string ecauth_subject
string issuer_key "client:{client_id} / idp:{open_id_provider_id}"
string external_id "SHA-256(customer:{id} / IdP の sub)"
string client_id
}
b2c_passkey_credential {
string ecauth_subject
bytes credential_id
string client_id
bool resident_key "credProps.rk"
}
issuer_key と external_id
| 識別子の種類 | issuer_key | external_id(ハッシュ前) |
|---|---|---|
| EC-CUBE 会員 | client:{client_id}(B2C Client) | customer:{customer_id} |
| ソーシャルログイン | idp:{open_id_provider.id} | IdP の sub |
external_idは B2B と同じく正規化 + SHA-256 で保持し、平文は持たない(ExternalIdHasher)UNIQUE (issuer_key, external_id)。EC-CUBE 会員の名前空間は Client ごとなので、別サイトのcustomer:1は衝突しない- ソーシャルログインの
issuer_keyは OpenIdProvider の行(Client に紐づく)単位にする。現行のExternalIdpMappingは(external_provider, external_subject)で全テナント一意で、ユーザープールを Client 単位にすると成り立たない - B2B の
B2BIssuerKey.ForClient(client:接頭辞)と同じ記法を使う。B2B と B2C はテーブルが別なので、同じ値でも衝突しない
ユーザーの解決と統合のルール
認証が成功したら、次の順でユーザーを決める。
- identity が見つかる → その
ecauth_user(通常のログイン) - identity が無く、確認済みメールアドレスが同じ Client 内の既存ユーザーと一致 → そのユーザーに identity を足して統合する。統合するのは、今回のメールアドレスが確認済み(IdP の
email_verified=true、または EC-CUBE の本会員)で、相手側のemail_verified_atも埋まっている場合だけ - それ以外 → 新しい
ecauth_userと identity を作る(JIT プロビジョニング)
メールアドレスの確認を保証しない IdP がある。未確認のメールアドレスで統合すると、他人のメールアドレスで IdP アカウントを作り、既存会員として入れてしまう経路になる。2 に当てはまる候補が複数あるときも統合しない(3 にする)。
現行のフェデレーション(GoogleUserNormalizer ほか)は email_verified を判定に使っていない(コードで確認)。統合ルールを入れるときに、各 IdP の normalizer が確認状態を返すように変える。
メールアドレスが異なる識別子の連携
Google と EC-CUBE でメールアドレスが違う顧客は、自動では統合されない。EC-CUBE にログイン中の会員が、マイページから「Google と連携する」操作をしたときに、その IdP の identity を会員の ecauth_user に足す。
- 連携の要求はプラグインのサーバーから
client_secret付きで出し、「どの会員(customer:{customer_id})に足すか」を EcAuth に伝える。ブラウザから渡された値だけでは紐付けない - 連携しようとした IdP の identity が既に別の
ecauth_userにある場合は、自動で付け替えずエラーにする(実際の扱いは未決定) - UI は Phase 3 のプラグイン。Phase 2 ではデータモデルだけこの形にしておく
メールアドレスの変更
- 識別子は
customer:{customer_id}なので、メールアドレスを変えても会員は変わらない - EC-CUBE で会員がメールアドレスを変えたら、プラグインが EcAuth の
email_hashを更新する(client_secret付き)。register/optionsも毎回現在のメールアドレスを送る - EC-CUBE 4 のマイページでのメールアドレス変更は、確認なしでその場で反映される(設定によって変更通知メールは送られるが、確認リンクではない。
Mypage/ChangeControllerで確認)。そのため変更後はemail_verified_atを空にし、変更後のメールアドレスでは自動統合しない(未確認のメールアドレスとして扱う)。再確認の仕組みは未決定 - ブラウザのパスキー一覧に出る名前は、ログイン時に
PublicKeyCredential.signalCurrentUserDetails()で新しい値に更新する(WebAuthn Level 3)
フロー
パスキーだけでの新規会員登録
EC-CUBE 標準の仮会員登録(メール確認)を使い、確認メールのリンクを開いた後にパスキーを作る。メールアドレスが確認されていないクレデンシャルは作らない。
sequenceDiagram
participant U as 顧客
participant P as EC-CUBE + プラグイン
participant E as EcAuth
U->>P: 会員登録フォーム(パスキーで登録を選ぶ。パスワード欄なし)
P->>P: 仮会員を作る(パスワードはランダム値)→ 確認メール送信
U->>P: 確認メールのリンクを開く
P->>P: 本会員にする(EC-CUBE 標準)→ ログイン状態に
P->>E: register/options(client_secret, customer:{id}, email, 新規フラグ)
E-->>P: options(上限強制なら 402)
U->>U: navigator.credentials.create()
P->>E: register/verify(client_secret)
E-->>P: 登録完了(ecauth_user / identity / credential を作成)
P->>P: dtb_customer.ecauth_subject に subject を保存
- EC-CUBE 4 の
dtb_customer.passwordは NOT NULL。パスキーだけの会員には、プラグインが推測できないランダム値を入れる - パスキーを失くしたときは、EC-CUBE 標準のパスワード再発行でパスワードを設定して入り直し、パスキーを登録し直す。EcAuth 側に復旧の仕組みは持たない
- 確認メールのリンクを別の端末で開いた場合は、その端末でパスキーを作る(同期パスキーなら他の端末でも使える)
- リンクを開いた後にパスキーの作成をやめた(キャンセルした、402 になった)場合は、本会員のまま通常のログイン手段(パスワード再発行)に案内する
- 2 系の仮会員登録が同じ流れで組めるかは要確認
既存会員のパスキー追加
- 会員が EC-CUBE にログインした状態で、マイページの「パスキーを追加」を押す
- 本人確認(再認証)をする
- プラグインが
register/optionsをclient_secret付きで呼ぶ(customer:{customer_id}、現在のメールアドレス) navigator.credentials.create()→register/verify
本人確認は B2B ではパスワードの再入力だが、パスキーだけの会員はパスワードを知らない。パスワードを持つ会員はパスワード再入力、パスキーだけの会員は既存パスキーでの再認証にする案がある(未決定)。
ログイン
- ログイン画面と、購入手続き中のログイン画面に「パスキーでログイン」ボタンを置く
- 誰がログインするかは未確定なので、
authenticate/optionsには識別子を渡さない。allowCredentialsは空で、認証器が候補を出す(discoverable credential) - verify が成功したら、プラグインは ID トークンの
subでdtb_customer.ecauth_subjectを引いてログインさせる - EcAuth が「その RP ID に存在しないクレデンシャル」と判定した場合は、
PublicKeyCredential.signalUnknownCredential()でブラウザに伝え、次回から候補に出さないようにする
パスキーの一覧・削除
- マイページに、自分のパスキーの一覧(端末名・登録日・最終使用日)と削除ボタンを出す
- プラグインのサーバーが
client_secretとcustomer:{customer_id}で EcAuth に問い合わせる。B2B の一覧 API は Bearer トークン認証だが、パスワードでログインした会員は EcAuth のトークンを持たないため、B2C はclient_secret方式にそろえる(B2B も EcAuth#265 で同じ方向) - 削除したら
signalAllAcceptedCredentials()でブラウザ側の一覧からも消す
退会
- EC-CUBE で退会したら、プラグインが EcAuth のユーザー削除 API を
client_secret付きで呼ぶ - パスキーと識別子(identity)は物理削除する。
ecauth_userの行と、monthly_active_userの記録(その月の請求根拠)をどう扱うかは未決定 - プラグインが削除 API を呼べなかった場合(EcAuth の障害など)の再試行の扱いも未決定
API 設計
B2B の /v1/b2b/passkey/* と同じ形にする。パスは /v1/b2c/passkey/*。
| エンドポイント | 認証 | 用途 |
|---|---|---|
POST /v1/b2c/passkey/register/options | client_id + client_secret | 登録チャレンジ。customer_id、email、email_verified、display_name、rp_id、device_name。上限強制の判定はここ |
POST /v1/b2c/passkey/register/verify | client_id + client_secret | 登録の検証。ユーザーと identity が無ければこの時点で作る |
POST /v1/b2c/passkey/authenticate/options | client_id | 認証チャレンジ(rp_id のみ。識別子は受け取らない) |
POST /v1/b2c/passkey/authenticate/verify | client_id | 署名検証 → 認可コード発行(redirect_uri、state、PKCE の code_challenge) |
GET /v1/b2c/passkey/list | client_id + client_secret | 会員のパスキー一覧(customer_id 指定) |
DELETE /v1/b2c/passkey/{credentialId} | client_id + client_secret | パスキー削除(その会員のものに限る) |
PUT /v1/b2c/users/email | client_id + client_secret | メールアドレス変更の反映 |
DELETE /v1/b2c/users | client_id + client_secret | 退会(customer_id 指定) |
customer_idは EcAuth 側でcustomer:{customer_id}を組み立ててハッシュ化する。呼び出し元に接頭辞を付けさせない- ソーシャルログインの「連携する」API は Phase 3 で決める
- エラーの形は B2B と同じ(
error/error_description)。上限強制は 402payment_required(要件定義書 §5)
WebAuthn パラメータ
| 項目 | B2C | B2B との違い |
|---|---|---|
residentKey | required | B2B は preferred。B2B は後から discoverable に移ろうとして、既存登録がログイン不能になる問題で詰まっている(EcAuth#463)ため、B2C は最初から required |
credProps | 登録時に clientExtensionResults.credProps.rk を保存 | B2B は保存していない(#463 の手順 1) |
allowCredentials | 空 | B2B は b2b_subject 未指定のとき組織の全クレデンシャルを返している(#463、#105) |
userVerification | preferred | 同じ |
attestation | none | 同じ |
| RP ID | B2C Client の許可 RP ID だけを受け付ける。別名ホストは許可しない | 同じ |
| チャレンジ | 5 分で失効。verify の成否にかかわらず 1 回で消費 | B2B は成功時だけ消費(EcAuth#545) |
user.id(user handle) | ecauth_user.subject のバイト列 | B2B は b2b_subject |
user.name / displayName | プラグインが渡すメールアドレス / 表示名。EcAuth は保存しない | — |
データモデル
ecauth_user(変更)
client_idを追加する(ユーザープールの単位。#122)UNIQUE (organization_id, email_hash)を外し、(client_id, email_hash)の通常のインデックスにする(統合の候補を引くため)email_verified_atを追加する(統合してよいかの判定に使う)- 現行の
UserService.GetOrCreateUserAsyncはEmailHashに空文字列を許している。空文字列のユーザーが同じ Organization に複数あったときに一意制約とどう両立しているかは要確認
ecauth_user_identity(新設)
| 列 | 内容 |
|---|---|
id | PK |
ecauth_subject | ecauth_user.subject |
issuer_key | client:{client_id} / idp:{open_id_provider_id} |
external_id | SHA-256(大文字 hex) |
client_id | 補助カラム(Client 解約時の一括無効化用。issuer_key をパースしない。B2B と同じ理由) |
created_at |
UNIQUE (issuer_key, external_id)。既存の ExternalIdpMapping はこのテーブルへ移す。移す時期(Phase 2 と同時か、Phase 3 の前か)は未決定。
b2c_passkey_credential(新設)
b2b_passkey_credential と同じ列(credential_id、public_key、sign_count、device_name、aa_guid、transports、created_at、last_used_at、client_id)に、ecauth_subject と resident_key(credProps.rk。不明なら null)を持つ。credential_id は一意。テナントフィルターは ecauth_user 経由で掛ける。
webauthn_challenge(変更なし)
既存の user_type 列に b2c を入れて使う。新規登録の register/options 時点ではユーザーが未作成なので、subject は null を許す(ユーザー管理の旧案どおり)。
マイグレーションは additive に限り、デプロイ順序は マイグレーションのデプロイ順序に従う。
セキュリティ
- クレデンシャルと Client の一致: verify では、見つけたクレデンシャルの
client_idとチャレンジの Client が一致することを明示的に確かめる。同じ RP ID の管理者パスキー(B2B のテーブルにある)は B2C のテーブルに無いので見つからないが、別の B2C Client(同じドメインに 2 つの B2C Client がある場合)との取り違えはこの検証で止める(EcAuth#270 F-2 と同じ形の穴を作らない) - 列挙オラクルを作らない:
authenticate/optionsは識別子を受け取らず、どの Client でも同じ形の応答を返す - レート制限とボディサイズの上限: 認証系はフロントから大量に呼ばれる。アプリ側でサイズ上限とレート制限を入れる(#120、#104)。具体的な値は未決定
- SignCount: B2B と同じく、保存値を Fido2.NetLib に渡して検証し、成功したら更新する(同期パスキーは 0 のまま増えないことがある)
- 個人情報: メールアドレスと
customer_idはハッシュでだけ持つ
課金との接続
- MAU:
authenticate/verify→/v1/tokenの認可コード交換で、ほかの経路と同じくTokenServiceの記録フックで計上される。TokenRequest.AuthMethodはb2c_passkeyを明示する。省略するとMonthlyActiveUserRecorder.InferAuthMethodが B2C を一律b2c_socialにしてしまう(コードで確認) - 上限強制: 当月 MAU(Client 単位)が無料枠の 50 以上で、支払い方法が未登録なら、新規ユーザーの
register/optionsを 402payment_requiredにする。既存ユーザーのパスキー追加は対象外。判定は B2B と同じIBillingPolicy(#119 の PR-3)を使う - パスキーだけでの新規登録が 402 になった場合、プラグインはパスキー登録をやめ、会員登録そのものは EC-CUBE の通常の手段で続けられるようにする(ショップの会員登録を止めない)
EC-CUBE プラグイン
- 4 系(
ec-cube4-ecauth)と 2 系(ec-cube2-ecauth)の両方に入れる。4.4 系対応(#157)を間に挟む可能性があるので、4 系の実装は 4.4 で動かなくなる書き方を避ける dtb_customerにecauth_subjectカラムを足す(dtb_memberと同じ形。nullable、unique)。B2B と違い、subject は EcAuth が発番してプラグインが保存する- ボタンの配置: ログイン画面、購入手続き中のログイン、マイページ(パスキーの管理)、会員登録画面(パスキーで登録)
- auth-js はプラグインに同梱する。B2B(管理画面)も同じく同梱に変える(#72)
- フロント用の B2C Client の
client_id/client_secretを設定画面で持つ(管理画面用の B2B Client とは別)
テスト
- ユニットテスト: 統合ルール(確認済み同士だけ / 候補が複数なら統合しない)、Client 不一致の拒否、チャレンジの単回消費、上限強制
- E2E: Playwright の仮想認証器(
WebAuthn.addVirtualAuthenticator、hasResidentKey: true)で、新規登録 → ログイン → 一覧・削除 → 退会を通す。EcAuth 単体と、プラグインとの結合 E2E(eccube_plugin_e2e.yml)の両方
実装順序
- 準備(#164): 変更前のテスト整備(EcAuth#271 の一部)→ テナント分離の基盤修正(EcAuth#270 の F-1 / F-2 / F-4)
- 識別モデル(#122):
ecauth_user.client_id/email_verified_at、ecauth_user_identity、一意制約の変更。既存のフェデレーションが同じ動きのまま通ること - B2C パスキー API:
b2c_passkey_credential、register / authenticate、一覧・削除、メールアドレス変更、退会 - 課金:
b2c_passkeyの AuthMethod、上限強制(B2B の PR-3 と同時でもよい) - プラグイン: 4 系 → 2 系(4.4 対応の時期と調整)、auth-js の同梱
未決定事項
| 論点 | 案 |
|---|---|
| 既存会員がパスキーを追加するときの本人確認 | パスワードを持つ会員はパスワード再入力、パスキーだけの会員は既存パスキーで再認証 |
| 連携しようとした IdP の identity が別ユーザーにある場合 | 自動で付け替えずエラーにする。Phase 3 で決める |
| メールアドレス変更後の再確認 | EC-CUBE は変更時に確認しないため、当面は変更後のメールアドレスを未確認として扱う |
退会時の ecauth_user と MAU 記録の扱い | ecauth_user は論理削除(請求の根拠になる当月分の MAU 記録を残すため) |
| 退会の削除 API が失敗したときの再試行 | プラグイン側で失敗を記録して再送する |
ExternalIdpMapping を ecauth_user_identity へ移す時期 | 識別モデルの変更(#122)と同時 |
| レート制限の具体値 | — |
| 2 系の仮会員登録で同じ流れが組めるか | 要確認 |