-
Notifications
You must be signed in to change notification settings - Fork 19
feat(agent-commerce): エージェントコマース用 OAuth2(client_credentials / scope / AccessTokenHandler / クライアント登録導線)を追加 (#188) #191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
4dba251
feat(agent-commerce): client_credentials + agentic scope + AccessToke…
nanasess 1ed64c7
Merge remote-tracking branch 'upstream/4.4' into feature/agentic-comm…
nanasess a2c388d
feat(agent-commerce): ACP/UCP 用のクライアント登録導線を分離する (#188)
nanasess efc050d
fix(agent-commerce): レビュー指摘のうち低コストな 5 件を反映する (#191)
nanasess File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', [ | ||
| '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, | ||
| ]); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| <?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 = [ | ||
|
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, [ | ||
|
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, [ | ||
|
ttokoro20240902 marked this conversation as resolved.
|
||
| 'mapped' => false, | ||
| 'data' => hash('sha512', random_bytes(32)), | ||
| 'constraints' => [ | ||
| new Assert\NotBlank(), | ||
| new Assert\Length(['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 をフォームバイパスで送っても弾く | ||
| new Assert\All([new Assert\Choice(['choices' => $scopes])]), | ||
|
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'; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.