EcAuth ユーザー管理アーキテクチャ(B2C/B2B統合設計)
B2C / B2B を統合するユーザー管理設計。JIT プロビジョニング、ハッシュ化メール、マルチテナント分離を扱う。
概要
EcAuthにおけるユーザー管理について、B2C(エンドユーザー向け)とB2B(管理者向け)の2つの体系を定義する。
ユーザー管理体系
| 体系 | 対象 | 認証方式 | データモデル |
|---|---|---|---|
| B2C | ECサイト顧客 | 外部IdP連携(Google、LINE等)、パスキー | EcAuthUser, ExternalIdpMapping |
| B2B | EC-CUBE管理者 | パスキー、将来的に企業SSO | B2BUser, B2BPasskeyCredential |
B2Cユーザー管理(エンドユーザー向け)
課題と制約
OpenID ConnectでのIdP内ユーザーDB非保持の課題
OpenID Connectの基本原則との矛盾
- IdPは認証時にユーザーの識別情報を管理し、IDトークンを発行する役割
- クライアント側のデータベースに直接依存すると、IdPとしての独立性が損なわれる
認証フローでの問題
- Authorization Code Flowでは、IdPがユーザー認証後にauthorization codeを発行
- この時点でIdP側にユーザーの識別情報が必要
- クライアント側のデータベースを参照するタイミングが適切でない
マルチテナント対応の困難さ
- 複数のEC-CUBEサイト(Organization)に対応予定
- 各クライアントのデータベースを直接参照する設計では、テナント管理が複雑化
B2Cアーキテクチャ
1. 最小限のユーザー情報保持設計
EcAuth側: ユーザー識別用の最小限情報(ハッシュ化メールアドレス、UUID等)
EC-CUBE側: 詳細なユーザー情報(customerテーブル)
2. JIT(Just-In-Time)プロビジョニング
- 初回ログイン時にIdP側で最小限のユーザーレコードを作成
- EC-CUBE側では受け取ったsubjectをキーにcustomerテーブルと紐付け
3. 外部IdP連携の活用
EcAuthはフェデレーションハブとして機能し、実際のユーザー認証は外部IdP(Google、LINE等)に委譲する。
B2Cユーザー情報生成タイミング
Authorization Code Flowでの適切なタイミング
最適タイミング: Authorization Endpoint(認可エンドポイント)でのユーザー認証完了直後
詳細フロー
1. クライアントからの認可リクエスト受信
2. ユーザーを外部IdP(Google、LINE等)にリダイレクト
3. 外部IdPでの認証完了後、callback受信
4. 【ここで最小限ユーザー情報生成】
- 外部IdPから取得したsubject、email等を基に
- EcAuth内部でのユーザー識別子(UUID)を生成
- ハッシュ化メールアドレス等の最小限情報を保存
5. authorization codeを生成・発行
6. クライアント(EC-CUBE)にリダイレクト
実装例
// 外部IdPからのcallback処理内
public async Task<IActionResult> ExternalCallback(string provider)
{
var externalUser = await GetExternalUserInfo();
// EcAuth内部でのユーザー情報生成・取得
var ecAuthUser = await GetOrCreateUserAsync(new UserCreationRequest
{
ExternalProvider = provider,
ExternalSubject = externalUser.Subject,
EmailHash = HashEmail(externalUser.Email),
EcAuthSubject = Guid.NewGuid().ToString() // 内部識別子
});
// authorization code生成
var authCode = await GenerateAuthorizationCode(ecAuthUser.Subject);
return Redirect($"{redirectUri}?code={authCode}&state={state}");
}このタイミングが適切な理由
1. OpenID Connect仕様への準拠
- authorization code発行時点で、IdP側にユーザーの識別情報が確定している必要
- Token EndpointでのIDトークン生成時に、確実にsubjectクレームを設定可能
2. JIT(Just-In-Time)プロビジョニングの実現
- 初回ログイン時のみユーザー情報生成
- 2回目以降は既存情報を利用
- 事前のユーザー登録が不要
3. エラーハンドリングの観点
- ユーザー情報生成に失敗した場合、認可フロー全体を適切にエラー終了可能
- クライアント側に不整合な状態を返すリスクを回避
4. 外部IdP連携との整合性
要件書の「外部IdPのsubjectとの紐付け管理」要件に対して、外部認証完了直後が最も自然なタイミング
B2Cデータモデル
EcAuthUser(B2Cエンドユーザー)
public class EcAuthUser
{
public string Subject { get; set; } // EcAuth内部識別子(UUID)
public string EmailHash { get; set; } // ハッシュ化メールアドレス
public int OrganizationId { get; set; } // テナント識別
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
// Navigation properties
public Organization Organization { get; set; }
public ICollection<ExternalIdpMapping> ExternalIdpMappings { get; set; }
public ICollection<AuthorizationCode> AuthorizationCodes { get; set; }
}
public class ExternalIdpMapping
{
public int Id { get; set; }
public string EcAuthSubject { get; set; }
public string ExternalProvider { get; set; } // "google", "line", etc.
public string ExternalSubject { get; set; } // 外部IdPのsubject
public DateTimeOffset CreatedAt { get; set; }
// Navigation property
public EcAuthUser EcAuthUser { get; set; }
}EC-CUBE側連携
// EC-CUBE customerテーブルに追加カラム
ALTER TABLE dtb_customer ADD COLUMN ecauth_subject VARCHAR(255) UNIQUE;B2Bユーザー管理(管理者向け)
概要
EC-CUBE管理画面へのパスキー認証を提供するためのB2Bユーザー管理設計。B2Cとは別のデータモデルで管理し、将来的な企業SSO(Azure Entra ID等)への拡張を考慮する。
B2BとB2Cの分離理由
| 観点 | B2B(管理画面) | B2C(フロント) |
|---|---|---|
| ユーザー規模 | 数人〜数十人 | 数百〜数万人 |
| 認証頻度 | 低頻度(業務時間) | 高頻度(24時間) |
| 認証方式 | パスキー、企業SSO | 外部IdP、パスキー |
| 課金モデル | 無料 | MAU課金 |
| ライフサイクル | 明示的な登録 | JITプロビジョニング |
| セキュリティ要件 | 厳格(フィッシング対策必須) | 利便性重視 |
B2Bアーキテクチャ
設計方針
- B2BUser: EC-CUBE管理者の最小限情報を保持
- B2BPasskeyCredential: WebAuthn認証情報(公開鍵、SignCount等)
- WebAuthnChallenge: 認証チャレンジ管理(5分で期限切れ)
認証フロー
1. 管理者がEC-CUBE管理画面にアクセス
2. 「パスキーでログイン」ボタンをクリック
3. EC-CUBEプラグインがEcAuth APIを呼び出し(/v1/b2b/passkey/authenticate/options)
4. EcAuthがチャレンジを生成
5. EC-CUBE側でWebAuthn API呼び出し(navigator.credentials.get())
6. EcAuthで署名検証(/v1/b2b/passkey/authenticate/verify)
7. 認可コード発行
8. EC-CUBE管理画面にログイン
B2Bデータモデル
以下のデータモデルは概念設計を示すものです。実装時には、EF
Coreの規約に従い、適切な外部キープロパティ(例:
B2BUserId)やFluent
API設定が追加されます。ナビゲーションプロパティが定義されている場合、EF
Coreは外部キーを自動生成します。
B2BUser(管理者ユーザー)
public class B2BUser
{
public int Id { get; set; }
public string Subject { get; set; } // UUID(EcAuth内部識別子)
public string? ExternalId { get; set; } // EC-CUBEのlogin_id等
public string UserType { get; set; } // "admin", "staff" 等
public int OrganizationId { get; set; } // マルチテナント
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
// Navigation properties
public Organization Organization { get; set; }
public ICollection<B2BPasskeyCredential> PasskeyCredentials { get; set; }
public ICollection<B2BExternalIdpMapping> ExternalIdpMappings { get; set; } // 将来:企業SSO
}B2BPasskeyCredential(パスキー認証情報)
public class B2BPasskeyCredential
{
public int Id { get; set; }
public string B2BSubject { get; set; } // B2BUser.Subject
public byte[] CredentialId { get; set; } // WebAuthn credential ID
public byte[] PublicKey { get; set; } // COSE形式公開鍵
public uint SignCount { get; set; } // リプレイ攻撃防止カウンター
public string? DeviceName { get; set; } // "MacBook Pro", "iPhone" 等
public Guid AaGuid { get; set; } // Authenticator Attestation GUID
public string[] Transports { get; set; } // ["internal", "usb", "nfc", "ble"] 等
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? LastUsedAt { get; set; }
// Navigation property
public B2BUser B2BUser { get; set; }
}WebAuthnChallenge(認証チャレンジ)
public class WebAuthnChallenge
{
public int Id { get; set; }
public string Challenge { get; set; } // Base64URL形式
public string SessionId { get; set; } // セッション識別子
public string Type { get; set; } // "registration" or "authentication"
public string UserType { get; set; } // "b2b" or "b2c"
public string? Subject { get; set; } // ユーザーSubject
// - UserType == "b2b": 登録・認証ともに必須(既存ユーザーへのパスキー追加)
// - UserType == "b2c" && Type == "authentication": 必須
// - UserType == "b2c" && Type == "registration": null許容(JITプロビジョニング)
public string? RpId { get; set; } // EC-CUBEサイトのドメイン
public int ClientId { get; set; } // クライアントID
public DateTimeOffset ExpiresAt { get; set; } // 5分で期限切れ
public DateTimeOffset CreatedAt { get; set; }
}将来の企業SSO連携用
public class B2BExternalIdpMapping
{
public int Id { get; set; }
public string B2BSubject { get; set; } // B2BUser.Subject
public string Provider { get; set; } // "azure-entra", "google-workspace", "okta"
public string ExternalSubject { get; set; } // 外部IdPのsubject
public string? TenantId { get; set; } // 企業テナントID
public DateTimeOffset CreatedAt { get; set; }
// Navigation property
public B2BUser B2BUser { get; set; }
}EC-CUBE側連携
// EC-CUBE memberテーブルに追加カラム
ALTER TABLE dtb_member ADD COLUMN ecauth_subject VARCHAR(255) UNIQUE;本人確認フロー(パスキー登録時)
パスキー登録時は、EC-CUBEプラグイン側でパスワード再入力を要求して本人確認を実施する。
1. 管理者が既存のID/パスワードでログイン
2. 設定 > パスキー管理 画面を開く
3. 「パスキーを追加」ボタンをクリック
4. パスワード再入力を要求(本人確認)
5. EcAuth API でチャレンジ生成
6. WebAuthn API でパスキー作成
7. EcAuth に公開鍵を登録
8. 登録完了通知メール送信(推奨)
共通:アクセストークン管理
セキュリティ設計
EcAuthでは、アクセストークンをJWT(JSON Web Token)形式で発行し、メタデータをデータベースで管理することで、トークンの検証・無効化を効率的に行います。
アクセストークンの特徴
- 生成方式: JWT(RSA 2048bit署名、RS256アルゴリズム)
- 有効期限: 1時間
- Issuer: リクエストのホスト名から動的に決定(テナント別)
- DB保存: メタデータのみ(jti、有効期限、クライアントID、Subject、SubjectType、スコープ、失効状態)
- 必須クレーム: sub, sub_type, jti, client_id, org_id, iss, iat, exp
JWTクレーム構造
| クレーム | 説明 | 例 |
|---|---|---|
sub |
ユーザーSubject(UUID) | "550e8400-e29b-..." |
sub_type |
Subject種別 | "b2c" or "b2b" |
org_id |
OrganizationID | 1 |
client_id |
クライアント識別子 | "test-client" |
iss |
Issuer(テナントホスト名) | "https://auth.ec-auth.io" |
iat |
発行日時 | Unix timestamp |
exp |
有効期限 | Unix timestamp |
jti |
トークン一意識別子 | UUID |
scope |
許可スコープ | "openid profile" |
TokenServiceの実装
public class TokenService : ITokenService
{
// JWTアクセストークン生成・メタデータDB保存
public async Task<string> GenerateAccessTokenAsync(TokenRequest request)
{
var jti = Guid.NewGuid().ToString();
var now = DateTime.UtcNow;
var expiresAt = now.AddHours(1);
// JWT を生成(RSA署名)
string accessTokenJwt;
using (var rsa = RSA.Create())
{
rsa.ImportRSAPrivateKey(Convert.FromBase64String(rsaKeyPair.PrivateKey), out _);
var signingCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, subject),
new("sub_type", subjectTypeString),
new("org_id", request.Client.OrganizationId.Value.ToString()),
new("client_id", request.Client.ClientId),
new(JwtRegisteredClaimNames.Iss, GetIssuer()),
new(JwtRegisteredClaimNames.Jti, jti)
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = expiresAt,
SigningCredentials = signingCredentials
};
var tokenHandler = new JwtSecurityTokenHandler();
accessTokenJwt = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
}
// メタデータをDBに保存(Token カラムには jti のみ)
_context.AccessTokens.Add(new AccessToken
{
Token = jti,
ExpiresAt = expiresAt,
ClientId = request.Client.Id,
Subject = subject,
SubjectType = request.SubjectType,
CreatedAt = now,
Scopes = scopes
});
await _context.SaveChangesAsync();
return accessTokenJwt;
}
// アクセストークン検証(6段階)
public async Task<AccessTokenValidationResult> ValidateAccessTokenWithTypeAsync(string token)
{
// 1. JWT をデコードして client_id を取得
// 2. client_id から Client を検索
// 3. Client に紐づく RSA 公開鍵を取得
// 4. JWT 署名検証 + Issuer 検証 + 有効期限チェック
// 5. jti で失効チェック(IsRevoked フラグ)
// 6. 必須クレーム(sub, sub_type, jti)の存在チェック
}
// アクセストークン無効化(論理削除)
public async Task<bool> RevokeAccessTokenAsync(string token)
{
// JWT をデコードして jti を取得
// jti で AccessToken レコードを検索
// IsRevoked = true, RevokedAt = DateTime.UtcNow を設定
}
// Issuer をリクエストのホスト名から動的に決定
private string GetIssuer()
{
var request = _httpContextAccessor.HttpContext?.Request;
return $"{request.Scheme}://{request.Host}";
}
}検証フロー詳細
1. JWT デコード → client_id クレーム取得
2. DB: Client テーブルから client_id で検索
3. DB: RsaKeyPair テーブルから Client に紐づく公開鍵を取得
4. JWT 署名検証(RSA公開鍵) + Issuer 検証 + 有効期限チェック
→ 失敗時: IsValid = false
5. jti で AccessToken テーブルの IsRevoked チェック
→ 失効済み: IsValid = false
6. 必須クレーム検証(sub, sub_type, jti が存在するか)
→ 欠落時: IsValid = false
7. 検証成功 → Subject, SubjectType, ClientId, OrganizationId, Scopes を返却
AccessTokenモデル
public class AccessToken
{
public int Id { get; set; }
public string Token { get; set; } // jti(JWT Token ID)
public DateTime ExpiresAt { get; set; }
public int ClientId { get; set; }
public string Subject { get; set; }
public SubjectType SubjectType { get; set; } // B2C=0, B2B=1, Account=2
public DateTime CreatedAt { get; set; }
public string? Scopes { get; set; }
public bool IsRevoked { get; set; } // 論理削除フラグ
public DateTime? RevokedAt { get; set; } // 失効日時
}マルチテナント対応
B2C/B2B両方のエンティティに、テナントフィルターが適用され、Organization単位でのデータ分離が実現されています。
// EcAuthDbContext.OnModelCreating内
modelBuilder.Entity<EcAuthUser>()
.HasQueryFilter(u => u.Organization != null &&
u.Organization.TenantName == _tenantService.TenantName);
modelBuilder.Entity<B2BUser>()
.HasQueryFilter(u => u.Organization != null &&
u.Organization.TenantName == _tenantService.TenantName);パフォーマンス最適化
- Token(jti)一意インデックス: 高速なトークン検索・失効チェック
- ExpiresAtインデックス: 期限切れトークンの効率的なクリーンアップ処理
- クライアント・ユーザー外部キー: 関連データの整合性保証
- JWT自己完結型検証: client_id → RSA公開鍵の取得のみでDB依存を最小化
まとめ
B2C(エンドユーザー向け)
- 外部IdP連携(JITプロビジョニング)
- 最小限のユーザー情報保持(EcAuthUser)
- MAU課金
B2B(管理者向け)
- パスキー認証(WebAuthn/FIDO2)
- 明示的なユーザー登録(B2BUser)
- 無料提供
- 将来的な企業SSO対応
このアプローチにより、OpenID Connectの仕様に準拠しつつ、個人情報保護法要件を満たした最小限のユーザー情報のみでIdPを運用できます。B2CとB2Bを分離することで、それぞれのユースケースに最適化された認証体験を提供できます。