polidog lab

Top About Rss
2021年12月19日

Symfony PassportでFirebase Authentication認証を使う

Symfony Advent Calendar 2021 の19日目の記事です。 機能は@ippey_s さんのSymfonyのパスポートでした。

今日はパスポートを使って実際にFirebase Authenticationで認証してみようと思います。

手順

  1. kreait/firebase-bundleを入れる
  2. UserInterfaceを継承したクラスを用意する
  3. 認証クラスを作成する
  4. security.yamlで設定する

kreait/firebase-bundleを入れる

まずはkreait/firebase-bundleをcompsoerで入れます。

$ composer require kreait/firebase-bundle

あとはサービスアカウントのFirebase Admin SDKから秘密鍵を生成して設定します。

#config/packages/firebase.yaml
kreait_firebase:
    projects:
        test_app:
            credentials: '%kernel.project_dir%/config/firebase_credentials.json'

UserInterfaceを継承したクラスを用意する

Symfony\Component\Security\Core\User\UserInterface を継承したUserClassを作ります。

<?php

declare(strict_types=1);

namespace App\Firebase;

use Symfony\Component\Security\Core\User\UserInterface;

final class FirebaseUser implements UserInterface
{
    public function __construct(private string $id, private string $email)
    {
    }

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

    public function getEmail(): string
    {
        return $this->email;
    }

    public function getRoles(): array
    {
        return ['ROLE_USER'];
    }

    public function getPassword(): string
    {
        throw new \RuntimeException('No support method');
    }

    public function getSalt(): string
    {
        throw new \RuntimeException('No support method');
    }

    public function eraseCredentials(): void
    {
        // TODO: Implement eraseCredentials() method.
    }

    public function getUsername(): string
    {
        return $this->email;
    }

    public function getUserIdentifier(): ?string
    {
        return $this->id;
    }
}

認証用クラスを作成する

フロントエンドから送られてきたFirebaseのidTokenを検証して、問題なければパスポートを発行するためのクラスを作ります。

<?php

declare(strict_types=1);

namespace App\Firebase;

use Kreait\Firebase\Auth;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

final class FirebaseTestAuthenticator extends AbstractAuthenticator
{
    public function __construct(private Auth $auth)
    {
    }


    public function supports(Request $request): ?bool
    {
        return null !== $request->headers->get('authorization');
    }

    public function authenticate(Request $request): PassportInterface
    {
        $idToken = $this->parseToken($request->headers->get('authorization'));
        $token = $this->auth->verifyIdToken($idToken);

        if ($token->isExpired(new \DateTimeImmutable())) {
            throw new CustomUserMessageAuthenticationException('API Token is expired');
        }

        return new SelfValidatingPassport(new UserBadge($token->getId(), static function () use ($token) {
            $id = (string) $token->claims()->get('sub');
            $email = (string) $token->claims()->get('email');
            return FirebaseUser($id, $email);
        }));
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse([
            'status' => 'error',

        ], Response::HTTP_UNAUTHORIZED);
    }

    public function parseToken(string $token): string
    {
        if (preg_match('/Bearer\s(\S+)/i', $token, $matches)) {
            return $matches[1];
        }
        throw new \RuntimeException('token parse error');
    }

}

security.yamlで設定する

最後にsecurity.yamlに設定を書きます。 ほぼ今まで通りかなとは思います。

firewallsのmainでstatelessをtrueにするのを忘れないように注意してください。

security:
    # https://symfony.com/doc/current/security/experimental_authenticators.html
    enable_authenticator_manager: true
    # https://symfony.com/doc/current/security.html#c-hashing-passwords
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        users_in_memory: { memory: null }
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            lazy: true
            stateless: true
            custom_authenticators:
                1: App\Security\FirebaseUserAuthenticator

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
         - { path: ^/mansions, roles: PUBLIC_ACCESS }
         - { path: ^/status, roles: PUBLIC_ACCESS }
         - { path: ^/admin, roles: ROLE_API_ADMIN }
         - { path: ^/, roles: ROLE_API_USER }

おまけ

Firebase Authで認証してしまうとAPIの動作チェックとかめんどくさくなります。 そこで自分はdev環境ではdev用のAuthenticatorを用意して環境変数でいい感じにダミーな値が設定出来るようにしています。

<?php

declare(strict_types=1);

namespace App\Dev\Firebase;

use App\Firebase\FirebaseUser;
use App\Security\FirebaseUserAuthenticator;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

final class DebugFirebaseUserAuthenticator extends AbstractAuthenticator
{
    public function __construct(
        private FirebaseUserAuthenticator $parentAuthenticator,
        private bool $debugFirebaseAuth,
        private string $debugFirebaseUserId,
        private string $debugFirebaseEmail,
        private bool $debugFirebaseAdmin
    ) {
    }

    public function supports(Request $request): ?bool
    {
        if (false === $this->debugFirebaseAuth) {
            return $this->parentAuthenticator->supports($request);
        }

        return true;
    }

    public function authenticate(Request $request): PassportInterface
    {
        if (false === $this->debugFirebaseAuth) {
            return $this->parentAuthenticator->authenticate($request);
        }

        return new SelfValidatingPassport(
            new UserBadge(
                $this->debugFirebaseUserId,
                fn () => new FirebaseUser(
                    $this->debugFirebaseUserId,
                    $this->debugFirebaseEmail,
                    $this->debugFirebaseAdmin
                )
            )
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        if (false === $this->debugFirebaseAuth) {
            return $this->parentAuthenticator->onAuthenticationSuccess($request, $token, $firewallName);
        }

        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        if (false === $this->debugFirebaseAuth) {
            return $this->parentAuthenticator->onAuthenticationFailure($request, $exception);
        }

        return new JsonResponse([
            'status' => 'error',
        ], Response::HTTP_UNAUTHORIZED);
    }
}

とはconfig/packages/dev/security.yaml に以下のように書きます。

security:
    firewalls:
        main:
            lazy: true
            stateless: true
            custom_authenticators:
                1: App\Dev\Firebase\DebugFirebaseUserAuthenticator

最後にservices.yamlを作成して以下のように設定します。


    App\Dev\Firebase\DebugFirebaseUserAuthenticator:
        arguments:
            $debugFirebaseAuth: '%env(bool:AUTH_DEBUG_FIREBASE)%'
            $debugFirebaseUserId: '%env(string:AUTH_DEBUG_FIREBASE_ID)%'
            $debugFirebaseEmail: '%env(string:AUTH_DEBUG_FIREBASE_EMAIL)%'
            $debugFirebaseAdmin: '%env(bool:AUTH_DEBUG_FI REBASE_ADMIN)%'

こうすることで環境変数でいい感じにdevでダミーユーザーを使うことができます。

例:

AUTH_DEBUG_FIREBASE=false
AUTH_DEBUG_FIREBASE_ID="YclgOD4nYieqUf6AzPwv9gst8Gf1"
AUTH_DEBUG_FIREBASE_EMAIL="polidogs+local1@gmail.com"
AUTH_DEBUG_FIREBASE_ADMIN=false

この方法はSymfonyで開発時に使いたいコードを分離する と組み合わせることでプロダクションコードと綺麗に分離することができます。

最後に

昔に比べて認証まわりは非常に拡張しやすくなっています。印象としてはGuardでだいぶ拡張しやすく感じましたがそれ以上に拡張子やすさを感じました。 みなさんも年末年始ぜひSymfonyの認証周りで遊んでみてはいかがでしょうか?

明日は@77webさんの記事ですー!

comments powered by Disqus