EcAuthDocs

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 IDEC サイトのドメイン(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)。

ClientSubjectType認証方式
管理画面用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_keyexternal_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 はテーブルが別なので、同じ値でも衝突しない

ユーザーの解決と統合のルール

認証が成功したら、次の順でユーザーを決める。

  1. identity が見つかる → その ecauth_user(通常のログイン)
  2. identity が無く、確認済みメールアドレスが同じ Client 内の既存ユーザーと一致 → そのユーザーに identity を足して統合する。統合するのは、今回のメールアドレスが確認済み(IdP の email_verified=true、または EC-CUBE の本会員)で、相手側の email_verified_at も埋まっている場合だけ
  3. それ以外 → 新しい 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 系の仮会員登録が同じ流れで組めるかは要確認

既存会員のパスキー追加

  1. 会員が EC-CUBE にログインした状態で、マイページの「パスキーを追加」を押す
  2. 本人確認(再認証)をする
  3. プラグインが register/options を client_secret 付きで呼ぶ(customer:{customer_id}、現在のメールアドレス)
  4. 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/optionsclient_id + client_secret登録チャレンジ。customer_id、email、email_verified、display_name、rp_id、device_name。上限強制の判定はここ
POST /v1/b2c/passkey/register/verifyclient_id + client_secret登録の検証。ユーザーと identity が無ければこの時点で作る
POST /v1/b2c/passkey/authenticate/optionsclient_id認証チャレンジ(rp_id のみ。識別子は受け取らない)
POST /v1/b2c/passkey/authenticate/verifyclient_id署名検証 → 認可コード発行(redirect_uri、state、PKCE の code_challenge)
GET /v1/b2c/passkey/listclient_id + client_secret会員のパスキー一覧(customer_id 指定)
DELETE /v1/b2c/passkey/{credentialId}client_id + client_secretパスキー削除(その会員のものに限る)
PUT /v1/b2c/users/emailclient_id + client_secretメールアドレス変更の反映
DELETE /v1/b2c/usersclient_id + client_secret退会(customer_id 指定)
  • customer_id は EcAuth 側で customer:{customer_id} を組み立ててハッシュ化する。呼び出し元に接頭辞を付けさせない
  • ソーシャルログインの「連携する」API は Phase 3 で決める
  • エラーの形は B2B と同じ(error / error_description)。上限強制は 402 payment_required(要件定義書 §5)

WebAuthn パラメータ

項目B2CB2B との違い
residentKeyrequiredB2B は preferred。B2B は後から discoverable に移ろうとして、既存登録がログイン不能になる問題で詰まっている(EcAuth#463)ため、B2C は最初から required
credProps登録時に clientExtensionResults.credProps.rk を保存B2B は保存していない(#463 の手順 1)
allowCredentials空B2B は b2b_subject 未指定のとき組織の全クレデンシャルを返している(#463、#105)
userVerificationpreferred同じ
attestationnone同じ
RP IDB2C 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(新設)

列内容
idPK
ecauth_subjectecauth_user.subject
issuer_keyclient:{client_id} / idp:{open_id_provider_id}
external_idSHA-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 を 402 payment_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)の両方

実装順序

  1. 準備(#164): 変更前のテスト整備(EcAuth#271 の一部)→ テナント分離の基盤修正(EcAuth#270 の F-1 / F-2 / F-4)
  2. 識別モデル(#122): ecauth_user.client_id / email_verified_at、ecauth_user_identity、一意制約の変更。既存のフェデレーションが同じ動きのまま通ること
  3. B2C パスキー API: b2c_passkey_credential、register / authenticate、一覧・削除、メールアドレス変更、退会
  4. 課金: b2c_passkey の AuthMethod、上限強制(B2B の PR-3 と同時でもよい)
  5. プラグイン: 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 系の仮会員登録で同じ流れが組めるか要確認

関連