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 に配置する。
| 用途 | code | tenant_name | 配置先 DB | 配置先 App Service | ホスト |
|---|---|---|---|---|---|
| 本番運用 | accounts | accounts | 本番 DB | 本番 App Service | accounts.ec-auth.io |
| EcAuth 開発側の検証 | stg-accounts | stg-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.tf の custom_hostnames = ["*.ec-auth.io"])とワイルドカード SSL を持つため、accounts / stg-accounts のサブドメインは 個別の DNS / Custom Domain 追加なしに即座に解決される。詳細は DNS / Cloudflare Pages 構成 を参照。
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 つの B2BUser と Subject (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.email と B2BUser.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: パスキー未登録時は登録誘導バナー表示
(強制はしない)
メインフォームはパスキー認証(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.io と stg-accounts.ec-auth.io の両方に露出する。サブドメインに応じて配下に作られる Account / Organization は自動的に対応するテナント(accounts or stg-accounts)にスコープされる。申込フローの E2E 検証は、本番 stg-accounts への直接実行ではなく、PR-CI のローカルスタック(accounts テナント + mailpit)で実メール経路ごと検証する(下記「Account E2E 検証戦略」参照)。
確認トークンはアカウント本登録の唯一のシークレットであり、有効期限(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 を直接叩かず、フロントエンドの確認ページを経由する設計を採用する。
- 確認リンクは 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 のホスト)とは別オリジンである。 - 確認ページがトークンを抽出し、ユーザーの「確定」操作で POST する。確認ページがクエリから
tokenを取り出し、ユーザーが画面上で「確定」を行ったときにPOST /api/signup/confirm(ボディは{ "token": "..." }の JSON)を発行して、Account / Organization の作成を確定する。
メールセキュリティスキャナ(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.io、stg_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 も本番に作成する)。
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_url と test_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 コード |
|---|---|---|
email | RFC 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 のホスト名から導出される code(www. 除去・サブドメイン保持・英数以外をハイフン化。例: 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 点からバリデーションエラーで申込者に再入力を求める方針を採用する:
- 顧客責任の明確化: 同じ顧客が複数申込していないか、または既存 Organization が他社のものか、申込者自身が確認する責務を明確にできる
- 維持コストの排除: サフィックス付与ロジックは「衝突時にどのサフィックスを試すか」「いつ諦めるか」「重複しないことをどう保証するか」など仕様の複雑さを生み、テスト・運用コストを引き上げる
codeの予測可能性:codeが常にホスト名から一意に決まることで、運用上のトラブルシュート(DB 検索、ログ追跡、サポート対応)が容易になる
confirm 後に作成されるレコード
Account:email=owner@example.jp,subject=UUID,organization_id=accounts または stg-accounts の idB2BUser:subject=Account.Subject,external_id=SHA-256("owner@example.jp")(正規化後ハッシュ。平文メールは保持しない),user_type="account_owner",organization_id=accounts または stg-accounts の idOrganization(本番、production_site_url指定時):code=example-jp,is_sandbox=falseOrganization(テスト、test_site_url指定時):is_sandbox=trueClient(作成した 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-describedby、autocomplete/inputmode)。 - 課金: 申込時はカード情報・請求先を取得しない(フリーミアム無料開始)。将来の機能追加で無料枠を超えた場合のみ、別途クレジットカード決済(Square 基盤)の請求フローを案内する。
テナント解決とホスト設計
ホスト名割り当て
静的サイト(申込ページ)と認証 API でホスト名を分離する。これは現行 TenantMiddleware の「サブドメイン先頭セグメント → tenant_name」ロジックを無改修で活用するための設計である。
| 用途 | 申込サイト | 認証 / 管理画面 API | 解決される tenant_name | 配置先 |
|---|---|---|---|---|
| 本番 Account 管理 | ec-auth.io(Cloudflare Pages) | accounts.ec-auth.io | accounts | 本番 App Service |
| EcAuth 開発側の検証 | (同上 or 別途) | stg-accounts.ec-auth.io | stg-accounts | 本番 App Service(同居) |
| ローカル開発 | — | 本番 stg-accounts.ec-auth.io を呼ぶ | stg-accounts | — |
Azure App Service の staging スロット / staging 専用 App Service は、カスタムサブドメインの追加にコスト・運用負荷がかかり、TenantMiddleware がサブドメインを抽出できない場合 DEFAULT_ORGANIZATION_TENANT_NAME にフォールバックしてしまう。本番 App Service には複数のカスタムドメインを併設できるため、accounts.ec-auth.io と stg-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 | 所属 Org | RP ID | AllowedRpIds |
|---|---|---|---|
ecauth-admin-console | accounts | accounts.ec-auth.io | ["accounts.ec-auth.io"] |
ecauth-admin-console-stg | stg-accounts | stg-accounts.ec-auth.io | ["stg-accounts.ec-auth.io"] |
RP ID を ec-auth.io(eTLD+1)にすると accounts と stg-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_sandbox | Org A (本番店舗) / Org B (テスト店舗、本番 Account から見える) |
| 試用期間(将来) | 別カラム(例: trial_until) | 未実装 |
各 Organization の is_sandbox 値
accountsOrg →false(実運用の認証ドメイン)stg-accountsOrg →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 連携として)。
現フェーズで矛盾を生まないための準備
Client.subject_typeカラム導入により、AuthorizationCallbackが将来 B2C / B2B / Account を分岐させる責務の置き場所を確立SubjectType.Accountのトークン発行経路をTokenControllerに明示ExternalIdpMappingを B2BUser に後付け拡張せず、新規b2b_external_idp_mappingを追加する方針を明記(既存テーブル改修を避ける)
マイグレーション計画
必要なマイグレーション
UpdateAccountTablepasswordカラム削除subject,organization_id,display_name,email_verified_atカラム追加(organization_id, email)複合ユニークインデックスsubjectユニークインデックス(Alternate Key)- FK:
account.organization_id → organization.id
AddAccountOrganizationTableaccount_organizationテーブル新規作成- 主キー:
(account_subject, organization_id) - FK:
account_subject → account.subject(Cascade)、organization_id → organization.id(Restrict)
AddSubjectTypeToClientclient.subject_typeカラム追加(デフォルトB2C=0)- 既存 B2B 用 Client は別途 SQL で
B2B=1に更新
SeedAccountsOrganizationsaccountsOrganization レコード投入stg-accountsOrganization レコード投入ecauth-admin-consoleClient 投入(accountsOrg,subject_type=Account,allowed_rp_ids=["accounts.ec-auth.io"])ecauth-admin-console-stgClient 投入(stg-accountsOrg,subject_type=Account,allowed_rp_ids=["stg-accounts.ec-auth.io"])
AddMagicLoginTokenTablemagic_login_tokenテーブル新規作成- 主キー:
id(Identity) - カラム:
account_subject(nullable, FK)、requested_email_hash(NOT NULL, 64 chars)、token_hash、expires_at、used_at、requested_ip、requested_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.subject(OnDelete=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 機能を扱わないため)。
本番 App Service は既に *.ec-auth.io ワイルドカード Custom Domain / Cloudflare DNS ワイルドカード / ワイルドカード SSL を保持しているため、Phase E のインフラ作業は environments/production/main.tf の app_settings に ACCOUNTS_* / 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:Provider を Smtp に切り替え、MailKit ベースの SmtpEmailService が mailpit(Smtp:Host / Smtp:Port)へ送信する。IEmailService の本文生成は共通テンプレートに集約し、両プロバイダで同一本文を保証する。
検証フロー
- ローカルスタック(
accountsテナント + SQL Server + mailpit)を起動。 - 申込:
POST /api/signup/request→ アプリが確認メールを mailpit へ送信。 - テストが mailpit REST API(
GET /api/v1/messages→GET /api/v1/message/{id})で本文を取得し、確認 URL からトークンを抽出。 POST /api/signup/confirmでトークンを検証 → パスキー登録(既存 B2B 仮想認証器を流用)→ ログイン → トークン発行 →managed_orgsclaim を検証。- マジックリンク:
POST /api/account/magic-link/request→ mailpit からリンク抽出 →POST /api/account/magic-link/verify→ 認可コード → トークン。second-use / expired / レート制限の異常系も検証。 - run ごとに UUID でメールアドレス / subject を一意化(冪等化)、
DELETE /api/v1/messagesで後処理。
メールのリンク先はフロント(Cloudflare Pages = ecauth-website)の確認ページだが、第一段の E2E では UI 操作は行わず、メールから抽出したトークンを /confirm / /verify へ直接 POSTしてバックエンド + メール経路を検証する。実 UI まで含めた検証は、CI で ecauth-website を配信できるようになった段階で追加する。
検証環境の役割分担
| 検証の場 | メール経路 | カバー範囲 |
|---|---|---|
| PR-CI / ローカル docker | アプリ → SMTP → mailpit | 申込・マジックリンクのフル E2E(実メール経路含む) |
| 本番 / staging deploy verify | — | healthz / 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 共有のため整合性を保つ)AccountOrganizationはCascadeで自動削除- 管理対象
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_agent を magic_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 インデックスで効率化 |
| パスキー併用 | マジックリンク経由ログイン後は通常セッションを発行。パスキー登録は強制しないが、画面バナーで誘導する |
権限境界
| ロール | 権限 |
|---|---|
owner | Org 削除、課金設定、他 Account 招待・削除、すべての操作 |
admin | B2BUser 管理、Client 設定、他 Account 招待(owner 除く) |
member | 閲覧のみ(将来用、MVP では未使用) |
未決事項 / TODO
- 招待フロー: owner が他 Account を招待する API の設計(Phase E 以降)
- Account 課金プラン:
accountsOrg に紐づく課金エンティティの設計(別ドキュメント化予定)。stg-accounts配下は除外 - 監査ログ: Account の操作ログをどこに保存するか(既存 AccessToken 監査と統合するか別系統か)
- EcAuth 自身の管理者(superadmin): EcAuth 運営者が顧客 Account をサポート目的で代行操作する必要があるか。必要なら
account.role=superadmin等の特別フラグが必要 - stg-accounts のデータクリーンアップ運用: 自動削除スクリプトの設計(cron + Azure Functions 等)