Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions Controller/Admin/AgentCommerceClientController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

/*
* This file is part of EC-CUBE
*
* Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
*
* http://www.ec-cube.co.jp/
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Plugin\Api44\Controller\Admin;

use Eccube\Controller\AbstractController;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\Client;
use League\Bundle\OAuth2ServerBundle\OAuth2Grants;
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
use Plugin\Api44\Form\Type\Admin\AgentCommerceClientType;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

/**
* エージェントコマース (ACP/UCP) 用 OAuth2 クライアントの登録画面 (#188)。
*
* protocol ごとに入口を分ける理由は {@link AgentCommerceClientType} を参照。
* grant は client_credentials 固定、 scope は protocol のものだけを付与する。
*/
class AgentCommerceClientController extends AbstractController
{
public function __construct(
private readonly ClientManagerInterface $clientManager,
) {
}

#[Route(path: '/%eccube_admin_route%/api/oauth/acp/new', name: 'admin_api_oauth_acp_new', methods: ['GET', 'POST'])]
public function createAcp(Request $request): RedirectResponse|Response
{
return $this->createClient($request, 'acp');
}

#[Route(path: '/%eccube_admin_route%/api/oauth/ucp/new', name: 'admin_api_oauth_ucp_new', methods: ['GET', 'POST'])]
public function createUcp(Request $request): RedirectResponse|Response
{
return $this->createClient($request, 'ucp');
}

private function createClient(Request $request, string $protocol): RedirectResponse|Response
{
$form = $this->createForm(AgentCommerceClientType::class, null, ['protocol' => $protocol]);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
try {
$client = new Client(
(string) $form->get('name')->getData(),
(string) $form->get('identifier')->getData(),
(string) $form->get('secret')->getData()
);
$client->setActive(true);
// エージェントは会員でもブラウザでもなく同意画面を経由できないため、 M2M の
// client_credentials に固定する (authorization_code / refresh_token は付与しない)。
$client->setGrants(new Grant(OAuth2Grants::CLIENT_CREDENTIALS));
$client->setScopes(...array_map(
static fn (string $scope): Scope => new Scope($scope),
$form->get('scopes')->getData()
));

$this->clientManager->save($client);

$this->addSuccess('admin.common.save_complete', 'admin');

// league はシークレットを保存時にハッシュ化せず、 初回のトークン取得成功時に
// bcrypt へ日和見アップグレードする (ClientRepository::validateClient)。
// つまり一度使われた後の一覧表示はハッシュ値で、 事業者へ渡す値を復元できない。
// 発行直後のこの画面でだけ平文を提示する (redirect すると失われるため render する)。
$response = $this->render('@Api44/admin/OAuth/agent_commerce_client_issued.twig', [
Comment thread
ttokoro20240902 marked this conversation as resolved.
'name' => (string) $form->get('name')->getData(),
'identifier' => (string) $form->get('identifier')->getData(),
'secret' => (string) $form->get('secret')->getData(),
]);
// 認証情報を HTML に埋め込む画面なので、 ブラウザや共有端末のキャッシュに残さない
$response->headers->set('Cache-Control', 'no-store, private');

return $response;
} catch (\Exception $e) {
$this->addError(trans('admin.common.save_error'), 'admin');
log_error('エージェントコマース OAuth2 Client 登録エラー', ['exception' => $e, 'protocol' => $protocol]);
}
}

return $this->render('@Api44/admin/OAuth/agent_commerce_client.twig', [
'form' => $form->createView(),
'protocol' => $protocol,
]);
}
}
34 changes: 32 additions & 2 deletions Controller/Admin/OAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
use Plugin\Api44\Form\Type\Admin\AgentCommerceClientType;
use Plugin\Api44\Form\Type\Admin\ClientType;
use Plugin\Api44\Repository\McpTokenRepository;
use Plugin\Api44\Service\McpTokenService;
Expand Down Expand Up @@ -86,6 +87,13 @@ public function index(Request $request): Response
return $this->render('@Api44/admin/OAuth/index.twig', [
'clients' => $clients,
'mcpTokens' => $this->mcpTokenRepository->findAllOrderByCreateDate(),
// エージェントコマース用クライアントのシークレットは一覧で再表示しない (#188)。
// league は初回のトークン取得成功時に保存値を bcrypt へ差し替えるため、 一覧の値は
// そのまま事業者へ渡せない。 平文は発行直後の画面でだけ提示する。
'agentCommerceClientIds' => array_values(array_map(
Comment thread
ttokoro20240902 marked this conversation as resolved.
static fn (ClientInterface $client): string => $client->getIdentifier(),
array_filter($clients, self::isAgentCommerceClient(...))
)),
]);
}

Expand All @@ -99,15 +107,14 @@ public function index(Request $request): Response
#[Route(path: '/%eccube_admin_route%/api/oauth/new', name: 'admin_api_oauth_new', methods: ['GET', 'POST'])]
public function create(Request $request): RedirectResponse|Response
{
$name = '';

$builder = $this->formFactory
->createBuilder(ClientType::class);

$form = $builder->getForm();
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$name = (string) $form->get('name')->getData();
$identifier = $form->get('identifier')->getData();
$secret = $form->get('secret')->getData();

Expand Down Expand Up @@ -184,6 +191,29 @@ public function clearExpiredTokens(Request $request): RedirectResponse
return $this->redirectToRoute('admin_api_oauth');
}

/**
* エージェントコマース (ACP/UCP) 用に発行されたクライアントか判定する.
*
* scope の protocol 接頭辞で判定する ({@link AgentCommerceClientType} が付与する scope)。
*/
private static function isAgentCommerceClient(ClientInterface $client): bool
{
$prefixes = array_map(
static fn (string $protocol): string => $protocol.':',
array_keys(AgentCommerceClientType::PROTOCOL_SCOPES)
);

foreach ($client->getScopes() as $scope) {
foreach ($prefixes as $prefix) {
if (str_starts_with((string) $scope, $prefix)) {
return true;
}
}
}

return false;
}

/**
* @param Client $client
* @param FormInterface $form
Expand Down
131 changes: 131 additions & 0 deletions Form/Type/Admin/AgentCommerceClientType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

/*
* This file is part of EC-CUBE
*
* Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
*
* http://www.ec-cube.co.jp/
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Plugin\Api44\Form\Type\Admin;

use Eccube\Common\EccubeConfig;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

/**
* エージェントコマース (ACP/UCP) 用 OAuth2 クライアントの登録フォーム (#188)。
*
* 汎用の {@link ClientType} と分けているのは、 エージェントコマースでは grant と scope の
* 組み合わせが一意に決まるためである。 エージェントは会員でもブラウザでもないので同意画面を
* 経由できず、 grant は client_credentials 固定・redirect_uri は不使用になる。 汎用フォームで
* 全 scope と全 grant を並べると、 成立しない組み合わせ (例: acp:checkout × authorization_code)
* を作れてしまうため、 protocol ごとに入口を分けて画面側で整合を保証する。
*
* 1 クライアントに ACP と UCP を混在させないのも本フォームの目的である。 受注に記録される
* `Order.agent_id` は OAuth2 クライアント識別子なので、 事業者ごとにクライアントを分けないと
* 受注の帰属・失効・レート制御を事業者単位で扱えない。
*/
class AgentCommerceClientType extends AbstractType
{
/**
* protocol => この画面から付与できる scope。
*
* `Resource/config/services.yaml` の `scopes.available` と同期させること
* (league は available に無い scope を拒否する)。
*
* `ucp:identity` は**意図的に含めない**。 会員本人の同意のもとで発行する capability であり、
* Customer を subject とする authorization_code が前提になるため client_credentials では
* 成立しない (eccube-api4#189)。 #189 landing 後に会員同意を伴う別導線として追加する。
*
* @var array<string, list<string>>
*/
public const PROTOCOL_SCOPES = [
Comment thread
ttokoro20240902 marked this conversation as resolved.
'acp' => ['acp:checkout', 'acp:catalog'],
'ucp' => ['ucp:checkout', 'ucp:cart', 'ucp:catalog'],
];

public function __construct(
private readonly EccubeConfig $eccubeConfig,
) {
}

/**
* {@inheritdoc}
*
* @throws \Exception
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$scopes = self::PROTOCOL_SCOPES[$options['protocol']];

$builder
// どのエージェント事業者向けのクライアントかを後から追えるようにする (Client::name)
->add('name', TextType::class, [
'mapped' => false,
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(['max' => $this->eccubeConfig['eccube_stext_len']]),
],
])
->add('identifier', TextType::class, [
Comment thread
ttokoro20240902 marked this conversation as resolved.
'mapped' => false,
'data' => hash('md5', random_bytes(16)),
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(['max' => 32]),
new Assert\Regex(['pattern' => '/^[0-9a-zA-Z]+$/']),
],
])
->add('secret', TextType::class, [
Comment thread
ttokoro20240902 marked this conversation as resolved.
'mapped' => false,
'data' => hash('sha512', random_bytes(32)),
'constraints' => [
new Assert\NotBlank(),
// client_credentials ではシークレットが唯一の認証情報なので下限を設ける
// (既定値は sha512 hex = 128 文字なので、 手で書き換えた場合にだけ効く)。
new Assert\Length(['min' => 32, 'max' => 128]),
new Assert\Regex(['pattern' => '/^[0-9a-zA-Z]+$/']),
],
])
->add('scopes', ChoiceType::class, [
'choices' => array_combine($scopes, $scopes),
'expanded' => true,
'multiple' => true,
'mapped' => false,
'constraints' => [
new Assert\NotBlank(),
// choices 外の scope を弾いているのは実際には ChoiceType 自身である。
// PRE_SUBMIT で未知値を submitted data から除去し、 POST_SUBMIT で FormError を
// 積む (`ChoiceType::buildForm()`)。 本制約はその機構に依存しないための多層防御で、
// 制約評価時にはデータが choices 済みに絞られているため通常は発火しない。
new Assert\All([new Assert\Choice(['choices' => $scopes])]),
Comment thread
ttokoro20240902 marked this conversation as resolved.
],
]);
}

/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired('protocol');
$resolver->setAllowedValues('protocol', array_keys(self::PROTOCOL_SCOPES));
}

/**
* {@inheritdoc}
*/
public function getBlockPrefix(): string
{
return 'api_admin_agent_commerce_client';
}
}
17 changes: 16 additions & 1 deletion Form/Type/Admin/ClientType.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public function __construct(
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// 一覧でクライアントの用途を識別できるようにする (Client::name)
->add('name', TextType::class, [
'mapped' => false,
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(['max' => $this->eccubeConfig['eccube_stext_len']]),
],
])
->add('identifier', TextType::class, [
'mapped' => false,
'data' => hash('md5', random_bytes(16)),
Expand All @@ -61,10 +69,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'data' => hash('sha512', random_bytes(32)),
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(['max' => 128]),
// confidential クライアントは token エンドポイントの認証材料がシークレットのみ
// なので下限を設ける ({@link AgentCommerceClientType} と同条件)。
new Assert\Length(['min' => 32, 'max' => 128]),
new Assert\Regex(['pattern' => '/^[0-9a-zA-Z]+$/']),
],
])
// エージェントコマース (ACP/UCP) の scope はここに並べない。 grant と scope の
// 組み合わせが protocol ごとに決まるため、 専用の登録導線
// ({@link AgentCommerceClientType}) から付与する (#188)。
->add('scopes', ChoiceType::class, [
'choices' => [
'read' => 'read',
Expand All @@ -85,6 +98,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
new Assert\Url(),
],
])
// client_credentials はエージェントコマース専用の登録導線で固定付与するため、
// 汎用フォームでは選ばせない (#188)。
->add('grants', ChoiceType::class, [
'choices' => [
'Authorization code' => OAuth2Grants::AUTHORIZATION_CODE,
Expand Down
20 changes: 18 additions & 2 deletions Resource/config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ league_oauth2_server:
encryption_key: '%env(ECCUBE_OAUTH2_ENCRYPTION_KEY)%'

# Whether to enable the client credentials grant
enable_client_credentials_grant: false
# エージェントコマース (ACP/UCP) の machine-to-machine インバウンド認証で使用する (#188)。
enable_client_credentials_grant: true
Comment thread
nanasess marked this conversation as resolved.

# Whether to enable the password grant
enable_password_grant: false
Expand All @@ -48,7 +49,8 @@ league_oauth2_server:
# 既存の `read` / `write` は GraphQL 用。 `mcp:*:read` は MCP サーバ機能 (本体同梱) 用の領域別 read scope。
# role 変換時はそれぞれ `ROLE_OAUTH2_MCP:PRODUCT:READ` 等 (`role_prefix: ROLE_OAUTH2_` で大文字化)。
# GraphQL の認可と MCP の認可を独立させるため名前空間 `mcp:` で分離する。
available: ['read', 'write', 'mcp:product:read', 'mcp:order:read', 'mcp:customer:read', 'mcp:plugin:read']
# `acp:*` / `ucp:*` はエージェントコマース (#188) 用で、 `<protocol>:<capability>` 規約に従う。
available: ['read', 'write', 'mcp:product:read', 'mcp:order:read', 'mcp:customer:read', 'mcp:plugin:read', 'acp:checkout', 'acp:catalog', 'ucp:checkout', 'ucp:cart', 'ucp:catalog', 'ucp:identity']
Comment thread
ttokoro20240902 marked this conversation as resolved.
default: ['read']

persistence:
Expand Down Expand Up @@ -82,6 +84,20 @@ services:
tags:
- { name: kernel.event_listener, event: kernel.response, method: onKernelResponse }

# エージェントコマース (#188): EC-CUBE 本体の AgentCommerceOAuth2Authenticator が依存する
# Symfony 標準 AccessTokenHandlerInterface を提供する。league の ResourceServer で
# Bearer トークン (JWT) を検証 (公開鍵での署名/期限/失効) し、付与 scope を UserBadge の
# attributes['scopes'] に載せて返す。本体は handler が無ければ 503 を返す疎結合設計。
#
# 注意: 下の alias はアプリ全体に効く (この ID で解決されるサービスは常に 1 つだけ)。
# 将来 access_token firewall や他プラグインが同インターフェイスを autowire / alias すると、
# 無言で本ハンドラに解決される、 あるいは alias が上書きされて ACP 側が壊れる。
# 実例: EC-CUBE 本体の app/config/eccube/services_test.yaml は同じ ID にテスト用スタブを
# 登録するため、 本プラグインを入れた環境では本体側テストの解決先が入れ替わる。
Plugin\Api44\Security\AgentCommerceAccessTokenHandler:
autowire: true
Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface: '@Plugin\Api44\Security\AgentCommerceAccessTokenHandler'
Comment thread
ttokoro20240902 marked this conversation as resolved.

Plugin\Api44\EventListener\UserResolveListener:
arguments:
- '@Eccube\Security\Core\User\MemberProvider'
Expand Down
Loading