EcAuthDocs

Account 管理アーキテクチャ

EcAuth サービスを利用する顧客(EC-CUBE 店舗オーナー等)のアカウントを管理する設計。accounts / stg-accounts の 2 つの Organization を本番 DB / 本番 App Service に同居させ、既存の B2B パスキー認証機構と既存テナント解決ロジックを無改修で流用する。

概要

EcAuth では既に B2C / B2B のユーザー管理 が定義されているが、これらは EcAuth を利用する顧客 EC-CUBE サイトのユーザー を扱うものであり、EcAuth サービスそのものの利用者(申込者・組織オーナー)を扱う層は未実装である。

本ドキュメントは、その第三の層である Account(EcAuth 管理者ユーザー) の設計を定義する。Issue #39 の申込 API 実装(Phase D)の前提となる。

ユーザー管理層の位置付け

対象 SubjectType エンティティ 認証方式
B2C EC-CUBE 顧客サイトのエンドユーザー B2C=0 EcAuthUser + ExternalIdpMapping 外部 IdP フェデレーション、パスキー
B2B EC-CUBE 管理画面ユーザー B2B=1 B2BUser + B2BPasskeyCredential パスキー
Account(本ドキュメント) EcAuth サービス利用者(組織オーナー) Account=2 Account + account_organization + magic_login_token パスキー(B2B 流用)。リカバリ動線としてマジックリンク併設。将来は B2B SSO
設計の核心

Account は Account 管理専用の Organization に所属する B2BUser として表現する。これにより、既存の B2BUser / B2BPasskeyCredential / B2BPasskeyController を一切改修せずに認証機構として流用できる。Account 固有の補助情報(メールアドレス、表示名など)と「ユーザー N : N Organization」の関係性のみ、新規テーブルで追加する。


設計の基本方針

1. Account 用 Organization の二重配置

Account の認証ドメインを 2 つの Organization として本番 DB に同居させ、両方を本番 App Service に配置する。

用途codetenant_name配置先 DB配置先 App Serviceホスト
本番運用accountsaccounts本番 DB本番 App Serviceaccounts.ec-auth.io
EcAuth 開発側の検証stg-accountsstg-accounts本番 DB(同居)本番 App Service(カスタムドメイン同居)stg-accounts.ec-auth.io

両方を本番 App Service に同居させる理由は、Azure App Service のステージングスロットや staging App Service がカスタムサブドメインを十分にサポートできず、TenantMiddleware のサブドメイン解決がデフォルトテナントにフォールバックしてしまうため。本番側のカスタムドメイン機能は問題なく複数サブドメインを 1 つの App Service に同居させられる。

さらに本番 App Service は既に *.ec-auth.io のワイルドカード Custom Domain(ecauth-infrastructure/environments/production/main.tfcustom_hostnames = ["*.ec-auth.io"])とワイルドカード SSL を持つため、accounts / stg-accounts のサブドメインは 個別の DNS / Custom Domain 追加なしに即座に解決される。詳細は DNS / Cloudflare Pages 構成 を参照。

staging EcAuth App Service の役割

staging EcAuth App Service では、Account 関連スキーマのマイグレーション通過確認と、AccountOrganization 等のテナントレベル動作確認のみ行う。Account 機能の E2E 検証(パスキー登録〜申込フロー〜トークン発行)は、PR-CI のローカルスタックに mailpit を組み込み、実メール経路(送信 → 本文 → トークン抽出 → 確認)を丸ごと検証する方式で行う(下記「Account E2E 検証戦略」参照)。本番 / staging の verify は healthz / discovery / B2B パスキーのスモークに留める。

2. 既存 B2B Passkey API の無改修流用

Account 用の認証 API は新規実装せず、既存の /v1/b2b/passkey/* エンドポイントを呼び出す。accounts.ec-auth.io または stg-accounts.ec-auth.io に対してリクエストすれば、既存の TenantMiddleware がサブドメインから tenant_name を抽出し、対応する Organization に解決される。

  • B2BPasskeyController — 無改修
  • B2BPasskeyService / B2BUserService — 無改修
  • B2BUser / B2BPasskeyCredential テーブル — 無改修
  • TenantMiddleware — 無改修

3. account テーブルの位置付け

既存の空 account テーブルを「Account の補助情報テーブル」として再定義する。認証情報は B2BUser + B2BPasskeyCredential が保持し、account は次の責務に絞る:

  • メールアドレス(EC-CUBE の dtb_member.login_id と同様の位置付け)
  • 表示名・確認状態など、認証本体に含まれない補助情報

1 つの Account は 1 つの B2BUserSubject (UUID) を共有する 1:1 関係とする。

4. account_organization 中間テーブル(N:N)

1 つの Account が複数の顧客 Organization を管理できる。account_organization 中間テーブルで「Account ↔ Organization のロール」を表現する。テナントクエリフィルター対象外(IgnoreQueryFilters で参照)として、Account から自分の管理対象 Org を横断的に引けるようにする。


データモデル

ER 図

erDiagram
    organization ||--o{ b2b_user : "has"
    organization ||--o{ account : "has (accounts / stg-accounts only)"
    organization ||--o{ client : "has"
    b2b_user ||--o{ b2b_passkey_credential : "has"
    b2b_user ||--|| account : "shares subject (accounts / stg-accounts only)"
    account ||--o{ account_organization : "owns"
    organization ||--o{ account_organization : "managed by"
    account ||--o{ magic_login_token : "issues"

    organization {
        int id PK
        string code UK
        string tenant_name
        string name
        bool is_sandbox
    }
    b2b_user {
        int id PK
        string subject UK "UUID"
        string external_id "SHA-256(正規化 email) for Account"
        string user_type "account_owner / admin / staff"
        int organization_id FK
    }
    b2b_passkey_credential {
        int id PK
        string b2b_subject FK
        bytes credential_id UK
        bytes public_key
        uint sign_count
    }
    account {
        int id PK
        string subject UK "= b2b_user.subject"
        string email
        int organization_id FK "= accounts or stg-accounts"
        string display_name
        datetimeoffset email_verified_at
    }
    account_organization {
        string account_subject PK,FK
        int organization_id PK,FK
        string role "owner / admin / member"
        datetimeoffset created_at
    }
    client {
        int id PK
        string client_id UK
        int organization_id FK
        string subject_type "B2C / B2B / Account"
        string allowed_rp_ids
    }
    magic_login_token {
        int id PK
        string account_subject FK "nullable (Account 不在時は null)"
        string requested_email_hash "SHA-256(正規化 email)。レート制限の判定キー"
        string token_hash UK "SHA-256 hash (平文非保存)"
        datetimeoffset expires_at
        datetimeoffset used_at "nullable"
        string requested_ip
        string requested_user_agent
        datetimeoffset created_at
    }
        

Account テーブル拡張仕様

現状の account テーブル(id / email / password / created_at / updated_at)に対し、以下の変更を加える。

カラム変更備考
id維持int PK Identity
subject追加UUID。b2b_user.subject と同値。Alternate Key
email制約強化nvarchar(255)(organization_id, email) 複合ユニーク
password削除パスキー / SSO に統一。互換性を保つ必要なし(未使用)
organization_id追加accounts または stg-accounts Org を指す FK。OnDelete=Restrict
display_name追加nvarchar(255) nullable。管理画面表示用
email_verified_at追加datetimeoffset nullable。申込メール確認完了時刻
created_at / updated_at維持

EC-CUBE の dtb_member.login_id と同様、Account.emailB2BUser.external_id同一のメールアドレスに対応づける運用ルールとする(accounts / stg-accounts Org スコープに限る)。ただし B2BUser.external_id個人情報非保持要件(要件定義 3.2.1)に従い、メールアドレスを正規化(lowercase + trim)したうえで SHA-256 ハッシュ化した値を保持する。したがって両者の関係は B2BUser.external_id == SHA-256(正規化(Account.email)) であり、値そのものは一致しない(Account.email は画面表示・メール送信のため平文を保持)。

ハッシュ化は EcAuth サーバー側で行う(SignupService の account_owner 生成、および B2BPasskeyService の JIT プロビジョニング/external_id 同期/検索の各経路で同一の正規化+ハッシュ関数を適用する)。EC-CUBE プラグインは従来どおり平文の login_id を送信し、ハッシュ化には関与しない。B2BUser 側の (organization_id, external_id) 複合ユニーク制約は、ハッシュがメールから決定的に導出されるため、ハッシュ化後もそのまま email のユニーク性を担保する。

Account C# モデル

[Table("account")]
public class Account : ISubjectProvider
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("id")]
    public int Id { get; set; }

    [Column("subject")]
    [MaxLength(255)]
    [Required]
    public string Subject { get; set; } = string.Empty;  // B2BUser.Subject と一致

    [Column("email")]
    [MaxLength(255)]
    [Required]
    public string Email { get; set; } = string.Empty;

    [Column("organization_id")]
    [Required]
    public int OrganizationId { get; set; }              // accounts / stg-accounts Org

    [Column("display_name")]
    [MaxLength(255)]
    public string? DisplayName { get; set; }

    [Column("email_verified_at")]
    public DateTimeOffset? EmailVerifiedAt { get; set; }

    [Column("created_at")]
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;

    [Column("updated_at")]
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;

    public Organization? Organization { get; set; }
    public ICollection<AccountOrganization> ManagedOrganizations { get; }
        = new List<AccountOrganization>();
}

AccountOrganization C# モデル

[Table("account_organization")]
public class AccountOrganization
{
    [Column("account_subject")]
    [MaxLength(255)]
    [Required]
    public string AccountSubject { get; set; } = string.Empty;  // Account.Subject FK

    [Column("organization_id")]
    [Required]
    public int OrganizationId { get; set; }                      // 管理対象 Org FK

    [Column("role")]
    [MaxLength(50)]
    [Required]
    public string Role { get; set; } = "owner";                  // owner / admin / member

    [Column("created_at")]
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;

    public Account? Account { get; set; }
    public Organization? Organization { get; set; }
}

主キーは (account_subject, organization_id) の複合キー。Fluent API で HasKey を指定する。

MagicLoginToken C# モデル

パスキー紛失時のリカバリ手段として、メール経由のマジックリンクログインを併設する。常時利用可能な認証手段だが、UI 動線上はメインのパスキーログインフォーム下部の「ログインできない方はこちら」リンクからのみ到達できるセカンダリ動線とする(リテラシーの低いユーザーがパスキー紛失時に離脱しないため)。

[Table("magic_login_token")]
public class MagicLoginToken
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    [Column("id")]
    public int Id { get; set; }

    [Column("account_subject")]
    [MaxLength(255)]
    public string? AccountSubject { get; set; }                 // Account.Subject FK。Account 不在のリクエストは null

    [Column("requested_email_hash")]
    [MaxLength(64)]
    [Required]
    public string RequestedEmailHash { get; set; } = string.Empty;  // SHA-256(lowercase + trim 済み email)。レート制限の判定キー

    [Column("token_hash")]
    [MaxLength(128)]
    [Required]
    public string TokenHash { get; set; } = string.Empty;       // SHA-256 (Base64URL)、平文は DB に保存しない

    [Column("expires_at")]
    [Required]
    public DateTimeOffset ExpiresAt { get; set; }               // 発行から 10 分

    [Column("used_at")]
    public DateTimeOffset? UsedAt { get; set; }                 // 単発使用後にマーク

    [Column("requested_ip")]
    [MaxLength(45)]
    public string? RequestedIp { get; set; }                    // IPv6 対応 (45 = INET6_ADDRSTRLEN)

    [Column("requested_user_agent")]
    [MaxLength(1000)]
    public string? RequestedUserAgent { get; set; }  // 拡張ブラウザ/in-app browser で 500 超過事例あり

    [Column("created_at")]
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;

    public Account? Account { get; set; }
}
  • 平文トークンは DB に保存しない。32 byte URL-safe random をメール送信し、SHA-256 ハッシュのみ token_hash に保存する。
  • 有効期限 10 分。発行後 10 分以内にメール内リンクをクリックしないと無効化される。
  • 単発使用used_at が non-null になった行は再利用不可。
  • IP / User-Agent ログrequested_ip / requested_user_agent に記録し、後日の監査・異常検知に使用。

DbContext 設定

// EcAuthDbContext.OnModelCreating に追加

// Account: 所属 Org のテナント(accounts / stg-accounts)のみ参照可能
modelBuilder.Entity<Account>()
    .HasQueryFilter(a => a.Organization != null
        && a.Organization.TenantName == _tenantService.TenantName);

modelBuilder.Entity<Account>()
    .HasAlternateKey(a => a.Subject);

modelBuilder.Entity<Account>()
    .HasOne(a => a.Organization)
    .WithMany()
    .HasForeignKey(a => a.OrganizationId)
    .OnDelete(DeleteBehavior.Restrict);

modelBuilder.Entity<Account>()
    .HasIndex(a => new { a.OrganizationId, a.Email })
    .IsUnique();

// AccountOrganization: テナント横断(クエリフィルター対象外)
modelBuilder.Entity<AccountOrganization>()
    .HasKey(ao => new { ao.AccountSubject, ao.OrganizationId });

modelBuilder.Entity<AccountOrganization>()
    .HasOne(ao => ao.Account)
    .WithMany(a => a.ManagedOrganizations)
    .HasForeignKey(ao => ao.AccountSubject)
    .HasPrincipalKey(a => a.Subject)
    .OnDelete(DeleteBehavior.Cascade);

modelBuilder.Entity<AccountOrganization>()
    .HasOne(ao => ao.Organization)
    .WithMany()
    .HasForeignKey(ao => ao.OrganizationId)
    .OnDelete(DeleteBehavior.Restrict);
// ※ AccountOrganization にはテナントクエリフィルターを設定しない

// MagicLoginToken: テナント横断のレート制限判定を行うため QueryFilter は設定しない
// (Account 不在のリクエストも email_hash で記録されるため)
modelBuilder.Entity<MagicLoginToken>()
    .HasOne(t => t.Account)
    .WithMany()
    .HasForeignKey(t => t.AccountSubject)
    .HasPrincipalKey(a => a.Subject)
    .IsRequired(false)               // Account 不在のリクエストは AccountSubject = null
    .OnDelete(DeleteBehavior.Cascade);

modelBuilder.Entity<MagicLoginToken>()
    .HasIndex(t => t.TokenHash)
    .IsUnique();

modelBuilder.Entity<MagicLoginToken>()
    .HasIndex(t => t.ExpiresAt);   // 期限切れ削除バッチ用

// レート制限判定用の複合インデックス(Redis 不使用、DB ベース実装)
modelBuilder.Entity<MagicLoginToken>()
    .HasIndex(t => new { t.RequestedEmailHash, t.CreatedAt });

modelBuilder.Entity<MagicLoginToken>()
    .HasIndex(t => new { t.RequestedIp, t.CreatedAt });

Client.subject_type カラム追加

TokenController が認可コードを発行する際に発行先 SubjectType を決定するため、Client に種別カラムを追加する。これは将来の B2B SSO 拡張時にも有用である。

// Client.cs
[Column("subject_type")]
[Required]
public SubjectType SubjectType { get; set; } = SubjectType.B2C;
  • 既存の B2C 用 Client → SubjectType.B2C(デフォルト)
  • EC-CUBE 管理画面用 Client → SubjectType.B2B
  • ecauth-admin-console(後述)→ SubjectType.Account

認証フロー

Account 登録フロー(申込時)

sequenceDiagram
    autonumber
    participant U as 申込者
    participant W as ec-auth.io
(Cloudflare Pages) participant A as accounts.ec-auth.io
(本番 IdentityProvider) participant DB as 本番 EcAuth DB U->>W: 申込フォーム入力
(email, organization_name, 本番/テスト URL,
EC プラットフォーム) Note over W,A: Cloudflare マネージドチャレンジ(エッジ)を通過 W->>A: POST /api/signup/request
{email, organization_name, production_site_url?,
test_site_url?, ec_cube_version} A->>A: バリデーション(使い捨てメール / URL 片方必須 /
組織コード導出 / code 重複) A->>A: 平文トークン生成
SHA-256 ハッシュ計算 A->>DB: SignupRequest 作成 (confirm_token_hash) A-->>U: メール送信 (SendGrid)
確認リンク付き(フロントエンド確認ページ URL) U->>W: GET /signup/confirm?token=...
(メールのリンク。状態変更しない) W-->>U: 確認ページ表示(「確定」ボタン) U->>W: 「確定」操作 W->>A: POST /api/signup/confirm
{ "token": "..." } A->>A: SHA-256(token) 計算 → token_hash 照合
+ 再バリデーション(code 重複 → 409) A->>DB: Account 作成 (Subject=UUID) A->>DB: B2BUser 作成 (Subject 共有, external_id=SHA-256(email),
user_type="account_owner", org=accounts) A->>DB: 顧客 Organization 作成
(入力 URL に応じ 1〜2 件: 本番=is_sandbox false /
テスト=is_sandbox true) A->>DB: Client 作成 (作成した Org ごとに 1 件) A->>DB: AccountOrganization 作成
(role=owner, Org ごとに 1 行) A-->>U: パスキー登録画面へ U->>A: POST /v1/b2b/passkey/register/options
(client_id=ecauth-admin-console) Note over A: 既存 B2BPasskeyController が処理
tenant=accounts として解決 A-->>U: 登録オプション返却 U->>A: POST /v1/b2b/passkey/register/verify A->>DB: B2BPasskeyCredential 作成 A-->>U: 完了 → 管理画面ログインへ

Account ログインフロー

sequenceDiagram
    autonumber
    participant U as Account
    participant C as accounts.ec-auth.io
(管理画面 SPA) participant A as IdentityProvider participant DB as EcAuth DB U->>C: アクセス C->>A: POST /v1/b2b/passkey/authenticate/options
(client_id=ecauth-admin-console) A->>DB: WebAuthnChallenge 作成 A-->>C: 認証オプション返却 C->>U: WebAuthn API 呼び出し U-->>C: 認証応答 (assertion) C->>A: POST /v1/b2b/passkey/authenticate/verify A->>DB: 署名検証 / SignCount 更新 A->>DB: AuthorizationCode 発行
(SubjectType=Account) A-->>C: redirect_uri?code=... C->>A: POST /v1/token (authorization_code) A->>DB: AccountOrganization 引き当て A-->>C: AccessToken + IDToken
(claims: managed_orgs=[...], role=...) Note over C: アクセストークンで管理画面 API へ
既存実装との差分

登録/認証 API 自体は完全に既存の B2BPasskeyController を流用する。差分が必要なのは (1) TokenController での SubjectType.Account 分岐の有効化(2) IDToken / AccessToken の claims に managed_orgs(管理対象 Org 一覧)を含める拡張 の 2 点のみ。

マジックリンクログインフロー(パスキー不可時の代替動線)

パスキー紛失・OS 再インストール・別デバイス利用などでパスキー認証が使えなくなったユーザー向けに、メール経由のマジックリンクログインを提供する。機能としては常時利用可能だが、UI 動線上はパスキーログインフォーム下部の「ログインできない方はこちら」リンクからのみ到達できるセカンダリ動線とする。これは Yahoo Japan / メルカリの運用方針に倣ったハイブリッド型(通常はパスキー、リカバリ動線は常時開いている)である。

sequenceDiagram
    autonumber
    participant U as Account
    participant F as フロント (Cloudflare Pages)
ec-auth.io participant A as IdentityProvider
(accounts.ec-auth.io) participant DB as EcAuth DB participant M as メール (SendGrid) participant C as 管理コンソール
(OAuth クライアント) Note over U,F: メイン UI はパスキー認証。
「ログインできない方はこちら」からマジックリンク要求へ U->>F: マジックリンク要求画面でメアド入力 F->>A: POST /api/account/magic-link/request
{email}(クロスオリジン) A->>DB: Account 検索 (email) Note over A: Email enumeration 対策で
該当なしでも同じレスポンス A->>A: トークン生成 (32 byte URL-safe random)
SHA-256 ハッシュ計算 A->>DB: MagicLoginToken 作成
(token_hash, expires_at=now+10min,
requested_ip, requested_user_agent) A->>M: SendGrid でメール送信
(本文に {MagicLink:BaseUrl}/signin/magic-link?token=... リンク) A-->>F: 200 OK (常に同じレスポンス) F-->>U: 「メールを送信しました」表示 U->>F: メール内リンクをクリック
GET /signin/magic-link?token=...
(フロント=Cloudflare Pages、状態変更なし) Note over F: メールスキャナの先読みで誤消費されないよう
GET は safe。ユーザー操作で初めて消費する U->>F: 「ログイン」操作 F->>A: POST /api/account/magic-link/verify
{token}(クロスオリジン) A->>A: SHA-256(token) 計算 A->>DB: Compare-And-Set: token_hash 一致かつ used_at IS NULL の行を
used_at=now に原子的 UPDATE(影響行数1で成功) A->>DB: AuthorizationCode 発行
(SubjectType=Account, Subject=Account.Subject) A-->>F: { location: redirect_uri?code=... } F->>C: redirect_uri?code=... へ遷移 C->>A: POST /v1/token (authorization_code) A-->>C: AccessToken + IDToken Note over C: パスキー未登録時は登録誘導バナー表示
(強制はしない)
UI 動線とリカバリ後フロー

メインフォームはパスキー認証(Conditional UI / WebAuthn)に固定する。動線として「ログインできない方はこちら」リンクをフォーム下部に配置し、リテラシーの低いユーザーでもパスキー紛失時に離脱せず代替手段に辿り着けるようにする。

マジックリンクログイン後はパスキー再登録を強制しない。通常セッションを発行し、画面上部に「パスキーを登録すると次回からスムーズにログインできます」のバナーを表示するだけに留める(強制したいユーザーには煩雑、UI の摩擦になる)。

マジックリンクエンドポイント

メソッドパス用途
POST/api/account/magic-link/requestマジックリンク発行(メール送信)。Email enumeration 対策のため常に同じレスポンス。フロント(Cloudflare Pages)からクロスオリジンで呼ぶ
GET{MagicLink:BaseUrl}/signin/magic-link?token=...フロントエンドのページ(Cloudflare Pages)。token をクエリから取り出すのみで状態変更しない(safe)。メールセキュリティスキャナの先読み(プリフェッチ)による誤消費を防ぐ(D-1 確認ページと同方針)
POST/api/account/magic-link/verifyフロントの「ログイン」操作で発行。トークンを単発消費(Compare-And-Set)し、認可コードを発行してクライアントのリダイレクト先({ "location": "..." })を返す
POST /api/account/magic-link/request
{
  "email": "owner@example.jp"
}

// Response (常に同じ。Account の存在有無を漏らさない)
HTTP 200
{
  "message": "メールアドレスが登録されている場合、ログインリンクをお送りしました。"
}

// --- フロントの /signin/magic-link ページがユーザー操作で発行 ---
POST /api/account/magic-link/verify
{
  "token": "<メールリンクの token>"
}

// Response: 認可コードを付与したクライアントのリダイレクト先
HTTP 200
{
  "location": "https://accounts.ec-auth.io/auth/callback?code=..."
}

TokenController での SubjectType.Account 対応

現状 TokenController.cs:314 に「Account 等のサポートされていない SubjectType」コメントがある分岐を有効化する。

// TokenController で SubjectType.Account 分岐を追加
switch (authCode.SubjectType)
{
    case SubjectType.B2C:
        // 既存 EcAuthUser からトークン発行
        break;
    case SubjectType.B2B:
        // 既存 B2BUser からトークン発行
        break;
    case SubjectType.Account:
        var account = await _context.Accounts
            .FirstOrDefaultAsync(a => a.Subject == authCode.Subject);
        var managedOrgs = await _context.AccountOrganizations
            .IgnoreQueryFilters()
            .Where(ao => ao.AccountSubject == authCode.Subject)
            .Select(ao => new { ao.OrganizationId, ao.Role })
            .ToListAsync();
        return await _tokenService.IssueAccountTokenAsync(account, managedOrgs);
}

申込 API 設計(Phase D)

Issue #39 Phase D の申込フローを Account + Organization 自動作成として実装する。

エンドポイント

メソッドパス用途
POST/api/signup/request申込リクエスト(メール確認トークン発行 + SendGrid 送信)
POST/api/signup/confirmメール確認の確定(フロントエンド確認ページから { "token": "..." } を受け取り、Account + Organization + Client + AccountOrganization を一括作成)
POST/api/signup/status申込状況確認(フロントエンドのポーリング用。確認トークンを { "token": "..." } でボディ受け取り)

これらの API は accounts.ec-auth.iostg-accounts.ec-auth.io の両方に露出する。サブドメインに応じて配下に作られる Account / Organization は自動的に対応するテナント(accounts or stg-accounts)にスコープされる。申込フローの E2E 検証は、本番 stg-accounts への直接実行ではなく、PR-CI のローカルスタック(accounts テナント + mailpit)で実メール経路ごと検証する(下記「Account E2E 検証戦略」参照)。

status は GET ではなく POST(確認トークンを URL に載せない)

確認トークンはアカウント本登録の唯一のシークレットであり、有効期限(24h)内であれば POST /api/signup/confirm に再送して本登録を完了できてしまう。GET /api/signup/status/{token} のようにトークンを URL(パス/クエリ)へ載せると、Azure Monitor の requests.url に生トークンがフル URL ごと記録され(クエリ文字列も記録対象)、テレメトリ読み取り権限を持つ者が再利用できる。これを避けるため、status はフロントエンドからのポーリング前提で POST + リクエストボディ{ "token": "..." })でトークンを受け取り、URL への露出を防ぐ。status はメールから直接クリックされる導線を持たない(メールリンクは確認ページ /signup/confirm のみ)。

メール確認フロー(フロントエンド仲介 + POST 確定)

メール確認は API を直接叩かず、フロントエンドの確認ページを経由する設計を採用する。

  1. 確認リンクは GET でフロントエンドの確認ページを指す。メール本文のリンクは https://ec-auth.io/signup/confirm?token=... のようなフロントエンド(Cloudflare Pages)の確認ページ URL であり、API(/api/signup/confirm)を直接叩かない。この GET は状態を変更しない(safe / 冪等)。確認ページは申込フォームと同じ Cloudflare Pages サイトから配信され、accounts.ec-auth.io / stg-accounts.ec-auth.io(B2B パスキー org 用テナント = API のホスト)とは別オリジンである。
  2. 確認ページがトークンを抽出し、ユーザーの「確定」操作で POST する。確認ページがクエリから token を取り出し、ユーザーが画面上で「確定」を行ったときに POST /api/signup/confirm(ボディは { "token": "..." } の JSON)を発行して、Account / Organization の作成を確定する。
なぜ GET 直叩きではなくフロントエンド仲介 + POST か

メールセキュリティスキャナ(Microsoft Safe Links / Outlook Safe Links 等)はリンク先を事前にプリフェッチ(先読み)することがある。確認リンクが状態変更を伴う GET API を直接指していると、この先読みだけで確認トークンが誤って消費され、ユーザーがクリックする前に申込が確定(または無効化)されてしまう。状態変更を POST /api/signup/confirm に隔離し、メールのリンクは状態を変更しない GET の確認ページに限定することで、これを防止する。HTTP の GET は safe / 冪等であるべきという原則にも沿う。

Signup:ConfirmBaseUrl:{tenant} の指す先

確認メールに埋め込む URL のベースは構成キー Signup:ConfirmBaseUrl:{tenant} から取得する。この値は API のベース URL ではなく、フロントエンド(Cloudflare Pages の確認ページ)のベース URL を指す。テナント別に設定し、accounts テナントは本番フロント https://ec-auth.iostg_accounts テナントは専用のプレビュー Pages(https://<web_app_suffix>.ec-auth.io、staging App Service の web_app_suffix を 1Password から流用)を指す。メール生成時はこのベース URL に確認ページのパス(/signup/confirm)とトークンを付与する。accounts.ec-auth.io / stg-accounts.ec-auth.io は B2B パスキー org 用テナント(= API のホスト)であってフロントの配信元ではない点に注意。

環境変数名の正規化: Azure Linux App Service は app settings を環境変数としてエクスポートするため、設定名にハイフンを含められない。テナント名にハイフンを含む場合(例: stg-accounts)、キーのテナント部は [A-Za-z0-9_] 以外を _ に正規化する。例: テナント stg-accounts → 参照キー Signup:ConfirmBaseUrl:stg_accounts(環境変数 Signup__ConfirmBaseUrl__stg_accounts)。accounts / stg-accounts はいずれも本番 App Service 上のテナント(staging App Service は F プランでサブドメイン不可のため、stg 用 Org も本番に作成する)。

confirm_token はハッシュ保存

DB(SignupRequest)には生のトークンを保存せず、SHA-256 ハッシュ(confirm_token_hashのみを保存する。確認時は受信した平文トークンを同じく SHA-256 でハッシュ化し、confirm_token_hash と照合する。これにより、万一 DB が流出してもハッシュから生トークンを復元できず、確認トークンの悪用を防げる(マジックリンクの MagicLoginToken.token_hash と同方針)。

申込リクエスト例

POST /api/signup/request
{
  "email": "owner@example.jp",
  "organization_name": "サンプル株式会社",
  "contact_name": "山田 太郎",
  "production_site_url": "https://example.jp",
  "test_site_url": "https://test.example.jp",
  "ec_cube_version": "4"
}

production_site_urltest_site_urlいずれか一方が必須(本番未構築の申込者はテストサイトのみで申し込める)。contact_name は任意。ec_cube_version"4" / "2" / "other"

申込リクエストのバリデーション

POST /api/signup/request はリクエスト時点で以下のバリデーションを実行し、エラーがあれば HTTP 422 Unprocessable Entity を返す。POST /api/signup/confirm でも Race Condition 対策として同じバリデーションを再実行し、申込から confirm の間に他の申込で code が確定した場合は HTTP 409 Conflict を返す。

項目ルール違反時の error コード
emailRFC 5322 準拠形式。正規化 (lowercase + trim) 後にユニーク。使い捨て(一時)メールドメインはブロックリスト照合で拒否invalid_email / disposable_email
organization_name必須。1〜100 文字invalid_organization_name
production_site_url / test_site_url少なくとも一方が必須。入力された URL は HTTPS スキーム必須invalid_site_url
Organization.code 衝突各 URL のホスト名から導出される codewww. 除去・サブドメイン保持・英数以外をハイフン化。例: shop.example.jp → shop-example-jp)が既存 Organization と衝突しないこと。本番・テストが同一ドメインの場合は本番のみ採用しテスト Org は作らない。衝突時はサフィックス付与せず申込者にエラーで返すorganization_already_exists
ec_cube_version"2" / "4" / "other"unsupported_version
HTTP 422 Unprocessable Entity
{
  "error": "organization_already_exists",
  "error_description": "このドメインは既に EcAuth に登録されています。別のサイト URL でお申し込みください。",
  "field": "production_site_url"
}
なぜサフィックス付与ではなくバリデーションエラーか

当初案として「example-jp が既存なら example-jp-2 等のサフィックスを自動付与する」も検討したが、以下の 3 点からバリデーションエラーで申込者に再入力を求める方針を採用する:

  1. 顧客責任の明確化: 同じ顧客が複数申込していないか、または既存 Organization が他社のものか、申込者自身が確認する責務を明確にできる
  2. 維持コストの排除: サフィックス付与ロジックは「衝突時にどのサフィックスを試すか」「いつ諦めるか」「重複しないことをどう保証するか」など仕様の複雑さを生み、テスト・運用コストを引き上げる
  3. code の予測可能性: code が常にホスト名から一意に決まることで、運用上のトラブルシュート(DB 検索、ログ追跡、サポート対応)が容易になる

confirm 後に作成されるレコード

  • Account: email=owner@example.jp, subject=UUID, organization_id=accounts または stg-accounts の id
  • B2BUser: subject=Account.Subject, external_id=SHA-256("owner@example.jp")(正規化後ハッシュ。平文メールは保持しない), user_type="account_owner", organization_id=accounts または stg-accounts の id
  • Organization(本番、production_site_url 指定時): code=example-jp, is_sandbox=false
  • Organization(テスト、test_site_url 指定時): is_sandbox=true
  • Client(作成した Org ごとに 1 件): allowed_rp_ids に各サイトドメインを設定
  • AccountOrganization(作成した Org ごとに 1 行): role=owner

作成される顧客 Organization入力された URL に応じて 1〜2 件(本番のみ/テストのみ/両方)。本番・テストが同一ドメインの場合は本番のみ。これらは 1 つの DB トランザクション内で原子的に作成する。途中で失敗した場合はロールバックし、再申込を許可する。

ボット対策・同意取得・アクセシビリティ

  • ボット対策: accounts.ec-auth.io は Cloudflare proxy 配下のため、/api/signup/*Cloudflare マネージドチャレンジ(Managed Challenge)をエッジ(WAF / Bot ルール)で適用する。Turnstile ウィジェットの埋め込みやアプリ側の siteverify 実装は不要で、疑わしいリクエストのみエッジでチャレンジされる(正規ユーザーには基本不可視)。ルール設定は Phase E(インフラ)で行う。
  • 同意取得: 申込フォームの送信(「無料ではじめる」)をもって、利用規約・プライバシーポリシー・Cookie ポリシーへの暗黙同意とみなす。同意した各ポリシーのバージョンを SignupRequest に記録する。
  • アクセシビリティ: 申込フォーム(ecauth-website 側)は WCAG 2.2 AA 準拠(インラインバリデーション、可視ラベル、aria-invalid / role="alert" / aria-describedbyautocomplete / inputmode)。
  • 課金: 申込時はカード情報・請求先を取得しない(フリーミアム無料開始)。将来の機能追加で無料枠を超えた場合のみ、別途クレジットカード決済(Square 基盤)の請求フローを案内する。

テナント解決とホスト設計

ホスト名割り当て

静的サイト(申込ページ)と認証 API でホスト名を分離する。これは現行 TenantMiddleware の「サブドメイン先頭セグメント → tenant_name」ロジックを無改修で活用するための設計である。

用途申込サイト認証 / 管理画面 API解決される tenant_name配置先
本番 Account 管理ec-auth.io
(Cloudflare Pages)
accounts.ec-auth.ioaccounts本番 App Service
EcAuth 開発側の検証(同上 or 別途)stg-accounts.ec-auth.iostg-accounts本番 App Service(同居)
ローカル開発本番 stg-accounts.ec-auth.io を呼ぶstg-accounts
なぜ staging App Service を使わないか

Azure App Service の staging スロット / staging 専用 App Service は、カスタムサブドメインの追加にコスト・運用負荷がかかり、TenantMiddleware がサブドメインを抽出できない場合 DEFAULT_ORGANIZATION_TENANT_NAME にフォールバックしてしまう。本番 App Service には複数のカスタムドメインを併設できるため、accounts.ec-auth.iostg-accounts.ec-auth.io を同じ App Service 上に置けば、サブドメイン解決が staging でも素直に機能する。staging EcAuth App Service ではマイグレーション通過確認とテナントレベル動作確認のみ行う。

DNS / Cloudflare Pages 構成

ec-auth.io               CNAME → ecauth-website.pages.dev    (Cloudflare Pages)
www.ec-auth.io           CNAME → ecauth-website.pages.dev    (Cloudflare Pages)
*.ec-auth.io             CNAME → <本番 EcAuth Azure Web App>  (既存ワイルドカード)
  ├─ accounts.ec-auth.io       (ワイルドカード経由で解決)
  ├─ stg-accounts.ec-auth.io   (ワイルドカード経由で解決)
  └─ <tenant>.ec-auth.io       (各顧客 Org の tenant_name もワイルドカード経由で解決)
ワイルドカードによる運用負荷削減

本番環境では既に *.ec-auth.ioワイルドカード DNS / Azure App Service Custom Domain / SSL 証明書ecauth-infrastructure/environments/production/main.tf に設定済み(module.web_app_identityprovider.custom_hostnames = ["*.ec-auth.io"] および cloudflare_dns_record.wildcard)。このため accounts.ec-auth.io / stg-accounts.ec-auth.io 用の DNS レコード追加や Custom Domain バインディングは 不要。Phase E のインフラ作業は本番 Terraform app_settings への環境変数追加のみで完結する。

顧客 Organization が新規に作成された場合も、Organization.tenant_name を DB に INSERT するだけで <tenant>.ec-auth.io が即座に到達可能になり、顧客 Org が増えるたびに DNS / Custom Domain / 証明書を逐次追加する運用は発生しない

例外として api.ec-auth.io(Platform API 用)のような apex 直下のホストは個別の azurerm_app_service_custom_hostname_binding として production/main.tf に明示登録されているが、これは Account 管理ドメインの追加で再現する必要はない(accounts / stg-accounts はサブドメイン形式のためワイルドカードで吸収される)。

パスキー RP ID 設計

本番用 Client(ecauth-admin-console)と stg-accounts 用 Client それぞれに AllowedRpIds を設定する:

Client所属 OrgRP IDAllowedRpIds
ecauth-admin-consoleaccountsaccounts.ec-auth.io["accounts.ec-auth.io"]
ecauth-admin-console-stgstg-accountsstg-accounts.ec-auth.io["stg-accounts.ec-auth.io"]

RP ID を ec-auth.io(eTLD+1)にすると accountsstg-accounts でパスキー credential が共有されてしまうため、RP ID はサブドメイン単位で分離する。これにより本番 Account のパスキーが誤って stg-accounts 検証で使えないようガードできる。


is_sandbox の取り扱い

Organization.is_sandbox「EcAuth 本番 DB 内で、EC-CUBE 顧客側のテスト店舗 Organization を本番店舗 Organization と区別する」ためのフラグである。試用期間や課金状態の表現には使わない(必要なら別カラムを設ける)。

3 層のテスト/本番分離

分離手段
EcAuth 自体の検証(開発チーム)Organization code プレフィックス stg-stg-accounts Org(本番 DB 同居)
EC-CUBE 顧客店舗の環境(本番 DB 内)Organization.is_sandboxOrg A (本番店舗) / Org B (テスト店舗、本番 Account から見える)
試用期間(将来)別カラム(例: trial_until未実装

各 Organization の is_sandbox 値

  • accounts Org → false(実運用の認証ドメイン)
  • stg-accounts Org → false(EcAuth 開発チームの検証用だが、認証品質は production スペックなので sandbox 扱いではない)
  • 申込で作成される顧客 Org(本番サイト = production_site_url)→ false
  • 申込で作成される顧客 Org(テストサイト = test_site_url)→ true

顧客側 staging 店舗(staging.example.jp)の認証は 本番 accounts.ec-auth.io 経由で行う(顧客側に EcAuth staging エンドポイントは公開しない)。顧客の「本番店舗 / staging 店舗」の区別は、本番 accounts Org 配下に is_sandbox=true の子 Org として保持する。


B2B SSO 拡張余地

Account の認証は当面パスキーのみで提供するが、将来的に Azure Entra ID / Google Workspace 等の B2B SSO を追加する余地を残す。

B2C ではなく B2B SSO を採用する理由

  • Account の利用者は組織オーナーであり、企業 IdP(Entra ID 等)からの SSO ログインが自然
  • B2C フェデレーション(EcAuthUser + ExternalIdpMapping)はエンドユーザー向け JIT プロビジョニング設計のため、組織オーナーには不適
  • EC-CUBE 管理者の B2B SSO 化(要件定義 Phase 4)と同一機構を共有可能

将来追加するもの(本フェーズでは実装しない)

-- 新規テーブル(Phase 4 想定)
CREATE TABLE b2b_external_idp_mapping (
    id                INT IDENTITY PRIMARY KEY,
    b2b_subject       NVARCHAR(255) NOT NULL,    -- FK → b2b_user.subject
    external_provider NVARCHAR(100) NOT NULL,    -- azure_entra_id / google_workspace / ...
    external_subject  NVARCHAR(255) NOT NULL,
    created_at        DATETIMEOFFSET NOT NULL,
    CONSTRAINT UQ_b2b_external_idp UNIQUE (external_provider, external_subject)
);

このテーブルが追加されれば、Account も同じ仕組みで Entra ID 等を経由した SSO ログインが可能になる(accounts / stg-accounts Org の B2BUser に対する SSO 連携として)。

現フェーズで矛盾を生まないための準備

  1. Client.subject_type カラム導入により、AuthorizationCallback が将来 B2C / B2B / Account を分岐させる責務の置き場所を確立
  2. SubjectType.Account のトークン発行経路を TokenController に明示
  3. ExternalIdpMapping を B2BUser に後付け拡張せず、新規 b2b_external_idp_mapping を追加する方針を明記(既存テーブル改修を避ける)

マイグレーション計画

必要なマイグレーション

  1. UpdateAccountTable
    • password カラム削除
    • subject, organization_id, display_name, email_verified_at カラム追加
    • (organization_id, email) 複合ユニークインデックス
    • subject ユニークインデックス(Alternate Key)
    • FK: account.organization_id → organization.id
  2. AddAccountOrganizationTable
    • account_organization テーブル新規作成
    • 主キー: (account_subject, organization_id)
    • FK: account_subject → account.subject(Cascade)、organization_id → organization.id(Restrict)
  3. AddSubjectTypeToClient
    • client.subject_type カラム追加(デフォルト B2C=0
    • 既存 B2B 用 Client は別途 SQL で B2B=1 に更新
  4. SeedAccountsOrganizations
    • accounts Organization レコード投入
    • stg-accounts Organization レコード投入
    • ecauth-admin-console Client 投入(accounts Org, subject_type=Account, allowed_rp_ids=["accounts.ec-auth.io"]
    • ecauth-admin-console-stg Client 投入(stg-accounts Org, subject_type=Account, allowed_rp_ids=["stg-accounts.ec-auth.io"]
  5. AddMagicLoginTokenTable
    • magic_login_token テーブル新規作成
    • 主キー: id(Identity)
    • カラム: account_subject(nullable, FK)、requested_email_hash(NOT NULL, 64 chars)、token_hashexpires_atused_atrequested_iprequested_user_agent(1000 chars)、created_at
    • token_hash ユニークインデックス
    • expires_at インデックス(期限切れ削除バッチ用)
    • (requested_email_hash, created_at) 複合インデックス(レート制限判定用、Redis 不使用)
    • (requested_ip, created_at) 複合インデックス(IP レート制限判定用)
    • FK: account_subject → account.subjectOnDelete=Cascade、ただし account_subject は nullable)
マイグレーション設計ルール

EcAuth リポジトリの CLAUDE.md に記載のとおり、migrationBuilder.Sql() でカラムを参照する DML は EXEC() 動的 SQL でラップすること。password カラム削除前に他のマイグレーションで参照が残っていないか確認すること。

Seeder と環境変数

OrganizationClientSeeder を拡張、または専用 AccountsOrganizationSeeder を新設して accounts / stg-accounts の 2 Org とそれぞれの Client を投入する。1Password の項目構成は ecauth-production-app に以下を追加:

# 本番 accounts Org
ACCOUNTS_CLIENT_ID=ecauth-admin-console
ACCOUNTS_CLIENT_SECRET=<1Password 管理>
ACCOUNTS_ALLOWED_RP_IDS=accounts.ec-auth.io
ACCOUNTS_REDIRECT_URI=https://accounts.ec-auth.io/auth/callback

# 開発チーム検証用 stg-accounts Org
STG_ACCOUNTS_CLIENT_ID=ecauth-admin-console-stg
STG_ACCOUNTS_CLIENT_SECRET=<1Password 管理>
STG_ACCOUNTS_ALLOWED_RP_IDS=stg-accounts.ec-auth.io
STG_ACCOUNTS_REDIRECT_URI=https://stg-accounts.ec-auth.io/auth/callback

環境変数の配線ルールは リポジトリガイド の通り 4 箇所すべて(.env.dev.tpl / .env.staging.tpl / GitHub Actions / Terraform app_settings)に追加する。本番 Terraform app_settings のみに設定し、staging Terraform には設定しない(staging EcAuth App Service では Account 機能を扱わないため)。

Phase E(インフラ)のスコープは環境変数のみ

本番 App Service は既に *.ec-auth.io ワイルドカード Custom Domain / Cloudflare DNS ワイルドカード / ワイルドカード SSL を保持しているため、Phase E のインフラ作業は environments/production/main.tfapp_settingsACCOUNTS_* / STG_ACCOUNTS_* を 1Password から注入する設定を追加するだけで完結する。新規 DNS レコード追加・新規 Custom Domain バインディング・新規 SSL 証明書バインドはいずれも不要。


Account E2E 検証戦略(mailpit ベース)

申込フローとマジックリンクログインは、確認トークン / ログイントークンが メール本文にしか存在しない(DB には SHA-256 ハッシュのみ保存)。このため E2E でフローを完走するには メール本文からトークンを取得する手段が必須となる。当初計画では本番 stg-accounts に対して直接 Playwright を走らせる想定だったが、本番では SendGrid が実 inbox にメールを送るため CI 内でトークンを取得できず、フローを完結できなかった。

これを解決するため、検証は PR-CI のローカルスタックに mailpit(SMTP 受信 + REST API のメールテストツール)を組み込み、実メール経路を丸ごと検証する方式に再構成する。

メール送信プロバイダの切替

本番 / staging は従来どおり SendGrid(HTTP API)を使用する。ローカル開発 / CI E2E では設定キー Email:ProviderSmtp に切り替え、MailKit ベースの SmtpEmailService が mailpit(Smtp:Host / Smtp:Port)へ送信する。IEmailService の本文生成は共通テンプレートに集約し、両プロバイダで同一本文を保証する。

検証フロー

  1. ローカルスタック(accounts テナント + SQL Server + mailpit)を起動。
  2. 申込: POST /api/signup/request → アプリが確認メールを mailpit へ送信。
  3. テストが mailpit REST API(GET /api/v1/messagesGET /api/v1/message/{id})で本文を取得し、確認 URL からトークンを抽出。
  4. POST /api/signup/confirm でトークンを検証 → パスキー登録(既存 B2B 仮想認証器を流用)→ ログイン → トークン発行 → managed_orgs claim を検証。
  5. マジックリンク: POST /api/account/magic-link/request → mailpit からリンク抽出 → POST /api/account/magic-link/verify → 認可コード → トークン。second-use / expired / レート制限の異常系も検証。
  6. run ごとに UUID でメールアドレス / subject を一意化(冪等化)、DELETE /api/v1/messages で後処理。
フロント UI はスコープ外(第一段)

メールのリンク先はフロント(Cloudflare Pages = ecauth-website)の確認ページだが、第一段の E2E では UI 操作は行わず、メールから抽出したトークンを /confirm / /verify へ直接 POSTしてバックエンド + メール経路を検証する。実 UI まで含めた検証は、CI で ecauth-website を配信できるようになった段階で追加する。

検証環境の役割分担

検証の場メール経路カバー範囲
PR-CI / ローカル dockerアプリ → SMTP → mailpit申込・マジックリンクのフル E2E(実メール経路含む)
本番 / staging deploy verifyhealthz / discovery / JWKS / B2B パスキーのスモーク

セキュリティと運用上の注意

本番 DB 内に staging 検証用 Org が同居することの運用注意

項目運用方針
データ純度本番運用クエリ・統計は Organization.code NOT LIKE 'stg-%' で stg-accounts 配下を除外
課金stg-accounts 配下の Account / 子 Org は MAU 課金対象外。判定ロジックは code プレフィックスで実装
データクリーンアップstg-accounts 配下のテスト Account / Org は定期的にクリーンアップ運用が必要。自動削除スクリプトを別途用意
監査ログ本番監査ログに stg-accounts の操作が混入するため、運用アラート条件は tenant=stg-accounts を除外して設定
顧客側 staging 店舗顧客の staging 店舗(例: staging.example.jp)の認証は本番 accounts Org の is_sandbox=true 子 Org を使う。顧客側に stg-accounts は公開しない

Account 削除時の挙動

  • Account 削除時、対応する B2BUser も削除(Subject 共有のため整合性を保つ)
  • AccountOrganizationCascade で自動削除
  • 管理対象 Organization 本体は 削除しない(owner 1 件削除で顧客サイトが消えると事故になる)
  • 最後の owner Account を削除しようとした場合は API エラーとする(権限移譲を強制)

Account 管理画面の CSRF / CORS

  • 申込 API(/api/signup/*)は ec-auth.io / www.ec-auth.io からの CORS のみ許可
  • 管理画面 API(/v1/account/* 等、Phase E 以降)は accounts.ec-auth.io / stg-accounts.ec-auth.io のみ許可
  • SubjectType.Account の AccessToken でないと管理画面 API は呼び出せない
  • パスキー RP ID 分離(accounts.ec-auth.io / stg-accounts.ec-auth.io)により、本番 credential が stg-accounts で使用不可

マジックリンクのセキュリティ要件

項目方針
トークン仕様32 byte(256 bit)の URL-safe random(RandomNumberGenerator)。メールには平文を載せ、DB には SHA-256 ハッシュ(token_hash)のみ保存。平文トークンを DB / ログに残さない
有効期限10 分expires_at で管理し、検証時に now < expires_at をチェック
単発使用used_at non-null の行は再利用不可。検証時は Compare-And-Set 方式UPDATE magic_login_token SET used_at = SYSDATETIMEOFFSET() WHERE token_hash = @hash AND used_at IS NULL を実行し、影響行数 1 を成功判定とする。3 ステップの read → check → update では Race Condition で複数回ログインが成立しうるため、必ずアトミックな単発 UPDATE で実装する
Email enumeration 対策POST /api/account/magic-link/request は Account 存在有無に関わらず HTTP 200 + 同一メッセージを返す。レスポンス時間の均一化も図る(存在しない場合もダミーの SHA-256 計算で時間を合わせる)。ただしメール送信(SendGrid ネットワーク, ms オーダー)とダミー SHA-256(μs オーダー)の差は残るため、タイミングサイドチャネルの完全な解消(メール送信の fire-and-forget 化等)は後続課題とする(リスクは中〜低: 存在列挙の偵察に留まりレート制限で抑制)
Rate limit判定キーは正規化 email(lowercase + trim)の SHA-256 ハッシュを magic_login_token.requested_email_hash カラムに記録。Account 不在のリクエストも同テーブルに記録するため、判定は SELECT COUNT(*) FROM magic_login_token WHERE requested_email_hash = @hash AND created_at > DATEADD(MINUTE, -5, SYSUTCDATETIME()) の COUNT ≥ 1 で 5 分に 1 回を強制。同一 IP からは requested_ip ベースで 1 時間 10 回。超過時は HTTP 429(Account 存在に関わらず同じ閾値)。追加インフラ(Redis 等)は導入せず、既存 EcAuth DB のみで完結する
監査ログrequested_ip / requested_user_agentmagic_login_token に記録。検証成功時の IP と発行時 IP の不一致は警告ログ(地理 IP の極端な乖離は将来的にアラート対象)
URL 基底 / HTTPSメール内リンクは MagicLink:BaseUrl:{tenant}(テナント別のフロント配信元。accounts→https://ec-auth.io、stg_accounts→専用プレビュー Pages)を基底に {base}/signin/magic-link?token=... 形式。必ず HTTPS。accounts.ec-auth.io は API ホストであってフロントの配信元ではない。Host ヘッダ偽装によるトークン窃取を防ぐため Request.Host にはフォールバックせず、設定値のみを使用する(未設定/非 HTTPS は例外で停止)
実クライアント IP(リバースプロキシ)本番 API ホスト(*.ec-auth.io)は Cloudflare プロキシ配下のため HttpContext.Connection.RemoteIpAddress は Cloudflare のエッジ IP になる。UseForwardedHeaders(パイプライン先頭)で CF-Connecting-IP から実クライアント IP を復元し、レート制限・監査ログ・Application Insights の client_IP に反映する。偽装防止のため信頼する Cloudflare エッジ CIDR を KnownNetworks に登録し、最終的な信頼境界は本番 App Service のオリジンロック(Azure access restriction = Cloudflare IP 限定)で担保する
ログ出力アプリケーションログ・customDimensions 等に token を絶対に記録しない。トークン操作ログは token_hash 先頭 8 文字のみ記録
クリーンアップ期限切れ・使用済みトークンは日次バッチで物理削除(保持期間 7 日)。expires_at インデックスで効率化
パスキー併用マジックリンク経由ログイン後は通常セッションを発行。パスキー登録は強制しないが、画面バナーで誘導する

権限境界

ロール権限
ownerOrg 削除、課金設定、他 Account 招待・削除、すべての操作
adminB2BUser 管理、Client 設定、他 Account 招待(owner 除く)
member閲覧のみ(将来用、MVP では未使用)

未決事項 / TODO

  1. 招待フロー: owner が他 Account を招待する API の設計(Phase E 以降)
  2. Account 課金プラン: accounts Org に紐づく課金エンティティの設計(別ドキュメント化予定)。stg-accounts 配下は除外
  3. 監査ログ: Account の操作ログをどこに保存するか(既存 AccessToken 監査と統合するか別系統か)
  4. EcAuth 自身の管理者(superadmin): EcAuth 運営者が顧客 Account をサポート目的で代行操作する必要があるか。必要なら account.role=superadmin 等の特別フラグが必要
  5. stg-accounts のデータクリーンアップ運用: 自動削除スクリプトの設計(cron + Azure Functions 等)

関連ドキュメント