EcAuthDocs

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非保持の課題

  1. OpenID Connectの基本原則との矛盾

    • IdPは認証時にユーザーの識別情報を管理し、IDトークンを発行する役割
    • クライアント側のデータベースに直接依存すると、IdPとしての独立性が損なわれる
  2. 認証フローでの問題

    • Authorization Code Flowでは、IdPがユーザー認証後にauthorization codeを発行
    • この時点でIdP側にユーザーの識別情報が必要
    • クライアント側のデータベースを参照するタイミングが適切でない
  3. マルチテナント対応の困難さ

    • 複数の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アーキテクチャ

設計方針

  1. B2BUser: EC-CUBE管理者の最小限情報を保持
  2. B2BPasskeyCredential: WebAuthn認証情報(公開鍵、SignCount等)
  3. 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を分離することで、それぞれのユースケースに最適化された認証体験を提供できます。