ECCUBE4にJWTを用いたAPIを実装する方法

[lexik/jwt-authentication-bundle](https://packagist.org/packages/lexik/jwt-authentication-bundle)を利用してECCUBE4にJWTを用いたAPIを実装しました。

[こちら](https://qiita.com/martini3oz/items/585e86a53a101080c1e1)の記事を参考にしております。

環境

	•	ECCUBEバージョン: 4.0.5
	•	PHPバージョン: 7.4
	•	Symfonyバージョン: 3.4.49

## jwt-authentication-bundleを導入
まず、JWT Authentication Bundleをプロジェクトに導入します。
```
composer require lexik/jwt-authentication-bundle:^2.1
```

### キーペアの作成
キーペアをECCUBEのディレクトリ構成に合わせて作成します。
```
mkdir -p app/config/eccube/jwt
```
```
openssl genrsa -out app/config/eccube/jwt/private.pem
```
```
openssl rsa -in app/config/eccube/jwt/private.pem -pubout > app/config/eccube/jwt/public.pem 
```


#### envファイルに追記
```
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/app/config/eccube/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/app/config/eccube/jwt/public.pem
JWT_PASSPHRASE=**********
###< lexik/jwt-authentication-bundle ###
```
#### lexik_jwt_authentication.yamlを作成
app/config/eccube/packages/lexik_jwt_authentication.yamlを作成し、以下を追加します。
以下の設定ではトークンの有効期限は1日です。
```
lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 86400
```



## ユーザー情報

API接続用のユーザーテーブルを追加しました。以下はサンプルのApiUserクラスです。


#### ApiUser.php
```
<?php

namespace Eccube\Entity;

use Eccube\Repository\ApiUserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

if (!class_exists('\Eccube\Entity\ApiUser')) {
    /**
     * ApiUser
     * @ORM\Table(name="dtb_api_user")
     * @ORM\Entity(repositoryClass=ApiUserRepository::class)
     * @ORM\InheritanceType("SINGLE_TABLE")
     * @ORM\DiscriminatorColumn(name="type", type="string", length=255)
     * @ORM\DiscriminatorMap({"api_user" = "ApiUser"})
     */
    class ApiUser implements UserInterface
    {
        /**
         * @ORM\Id
         * @ORM\GeneratedValue
         * @ORM\Column(type="integer")
         */
        private $id;

        /**
         * @ORM\Column(type="string", length=180, unique=true)
         */
        private $username;

        /**
         * @var string The hashed password
         * @ORM\Column(type="string")
         */
        private $password;

        public function getId(): ?int
        {
            return $this->id;
        }

        /**
         * A visual identifier that represents this user.
         *
         * @see UserInterface
         */
        public function getUsername(): string
        {
            return (string) $this->username;
        }

        public function setUsername(string $username): self
        {
            $this->username = $username;

            return $this;
        }

        /**
         * 使用しない
         * @see UserInterface
         */
        public function getRoles(): array
        {
            return [];
        }

        /**
         * @see UserInterface
         */
        public function getPassword(): string
        {
            return (string) $this->password;
        }

        public function setPassword(string $password): self
        {
            $this->password = $password;

            return $this;
        }

        /**
         * 使用しない
         * Returning a salt is only needed, if you are not using a modern
         * hashing algorithm (e.g. bcrypt or sodium) in your security.yaml.
         *
         * @see UserInterface
         */
        public function getSalt(): ?string
        {
            return null;
        }

        /**
         * 使用しない
         * @see UserInterface
         */
        public function eraseCredentials()
        {
            // If you store any temporary, sensitive data on the user, clear it here
            // $this->plainPassword = null;
        }
    }
}

```
#### ApiUserRepository.php
```
<?php

namespace Eccube\Repository;

use Eccube\Entity\ApiUser;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @method ApiUser|null find($id, $lockMode = null, $lockVersion = null)
 * @method ApiUser|null findOneBy(array $criteria, array $orderBy = null)
 * @method ApiUser[]    findAll()
 * @method ApiUser[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class ApiUserRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, ApiUser::class);
    }

    /**
     * @param int $id
     *
     * @return ApiUser
     */
    public function get($id = 1)
    {
        return $this->find($id);
    }
}

```
#### マイグレーション
差分を取りマイグレーションを実行します。
```
php bin/console doctrine:migrations:diff
```
```
php bin/console doctrine:migrations:migrate
```


ApiUserの登録画面を追加しましたが、ここでは割愛します。INSERT文で直接登録してもOKです。


## firewalls の設定
app/config/eccube/packages/security.yamlにfirewallsの設定を追記します。今回はget_token、api_1およびapi_2を追加します。

```
security:
    providers:
        api_get_token:
            pattern: ^/%api_route%/get_token
            stateless: true
            json_login:
                check_path: /%api_route%/get_token
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

        api_1:
            pattern:   ^/%api_route%/api_1
            stateless: true
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator
        api_2:
            pattern:   ^/%api_route%/api_2
            stateless: true
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator
    access_control:
        - { path: ^/%api_route%/get_token, roles: PUBLIC_ACCESS }
        - { path: ^/%api_route%/api_1, roles: PUBLIC_ACCESS }
        - { path: ^/%api_route%/api_2, roles: PUBLIC_ACCESS }
```
#### api_routeの設定
#### .envファイル
```
API_ROUTE="your_api_route"
```

#### app/config/eccube/packages/eccube.yaml
```
    # APIルート
    api_route: '%env(API_ROUTE)%'
```

## 認証の仕組み
今回はECCUBEで設定したApiUserのusernameとpasswordをget_tokenの認証に使用しtokenを発行します。
get_tokenで発行したtokenを利用しECCUBE上での処理を行います。

```mermaid
sequenceDiagram
    外部クライアント ->> ECCUBE : get_token 
    Note right of ECCUBE: username,passwordを照合
    ECCUBE -->> 外部クライアント : tokenを返す
    外部クライアント　->> ECCUBE :api_1 {token,パラメータ}
    ECCUBE -->> 外部クライアント : 
    Note right of ECCUBE: tokenを照合しパラメータを用い処理を行う
    外部クライアント　->> ECCUBE :api_2 {token,パラメータ}
    ECCUBE -->> 外部クライアント : 
```

## ApiControllerの設定
### get_token
json形式でapi_routeに設定したパスに{“username”:“hoge”,“password”:“huga”}をPOSTします。ApiUserと照合し、usernameとpasswordの組み合わせが正しければトークンを発行します。
#### ApiController
```
<?php

namespace Eccube\Controller;

use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;
use Eccube\Repository\ApiUserRepository;
use Lexik\Bundle\JWTAuthenticationBundle\Encoder\JWTEncoderInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTDecodeFailureException;



class ApiController extends AbstractController
{
    /**
     * @var JWTTokenManagerInterface
     */
    private $JWTManager;
    /**
     * @var ApiUserRepository
     */
    protected $apiUserRepository;
    /**
     * @var JWTEncoderInterface
     */
    private $JWTEncoder;

    public function __construct(
        ApiUserRepository $apiUserRepository,
        JWTTokenManagerInterface $JWTManager,
        JWTEncoderInterface $JWTEncoder
    ) {
        $this->apiUserRepository = $apiUserRepository;
        $this->JWTManager = $JWTManager;
        $this->JWTEncoder = $JWTEncoder;
    }


    /**
     * @Route("/%api_route%/get_token", name="get_token", methods={"POST"})
     */
    public function getToken(Request $request)
    {
        $data = json_decode($request->getContent(), true);
        $username = $data['username'] ?? null;
        $password = $data['password'] ?? null;

        if(!$username || !$password){
            $massage = !$username ? 'username is required' : 'password is required';

            return $this->json([
                'statusCode' => JsonResponse::HTTP_BAD_REQUEST,
                'error' => 'Bad Request',
                'message' => $massage,
            ], JsonResponse::HTTP_BAD_REQUEST);
        }

        $user = $this->apiUserRepository->findOneBy(['username' => $username]);
        if (!$user || $password !== $user->getPassword()) {
            return $this->json([
                'statusCode' => JsonResponse::HTTP_UNAUTHORIZED,
                'error' => 'Unauthorized',
                'message' => 'username or password is incorrect',
            ], JsonResponse::HTTP_UNAUTHORIZED);
        }

        $token = $this->JWTManager->create($user);

        return $this->json([
            'token' => $token,
        ]);
    }
```
#### リクエスト例
```
curl -k -X POST https://your_domain/your_api_route/get_token -H "Content-Type: application/json" -d '{"username":"hoge","password":"huga"}'
```

### トークンを検証しapiを実行
ヘッダーのBearer tokenを検証します。認証できた場合、リクエストボディのパラメータを利用して処理を行います。
#### ApiController
```
    /**
     * @Route("/%api_route%/api_1", name="api_1", methods={"POST"})
     */
    public function api_1(Request $request)
    {
        $token = $request->headers->get('Authorization');
        if (!$token) {
            return $this->json([
                'statusCode' => JsonResponse::HTTP_UNAUTHORIZED,
                'error' => 'Unauthorized',
                'message' => 'Token not found',
            ], JsonResponse::HTTP_UNAUTHORIZED);
        }

        $token = str_replace('Bearer ', '', $token);
        try {
            $this->JWTEncoder->decode($token);
        } catch (JWTDecodeFailureException $e) {
            return $this->json([
                'statusCode' => JsonResponse::HTTP_UNAUTHORIZED,
                'error' => 'Unauthorized',
                'message' => $e->getMessage(),
            ], JsonResponse::HTTP_UNAUTHORIZED);

        }

        // トークンが有効な場合の処理
        $data = json_decode($request->getContent());

        //$dataを利用した処理

        // 成功の処理
        $message = 'message';
        return new JsonResponse(['message' =>  $message]);
    }

    /**
     * @Route("/%api_route%/api_2", name="api_1", methods={"POST"})
     */
    public function api_2(Request $request)
    {
    //api_2の処理
    ...

```
#### リクエスト例
```
curl -k -X POST https://your_domain/your_api_route/api_1 -H "Authorization: Bearer ey......" -H "Content-Type: application/json" -d '{"hoge":"huga"}'
```