Symfony3でソーシャルログインの機能を実装してみました。
今回はGoogleのアカウントでログインできるようにしてみます。

画面上の流れ

flow.png

環境

  • Symfony3.3
  • PHP7.1
  • MariaDB10.2

OpenID Connectのフロー

OpenID Connectで使われているフローは以下の3つです。

  • Authorization Code Flow
  • Implicit Flow
  • Hybrid Flow

今回はAuthorization Code Flowで実装してきます。

参考: http://openid.net/specs/openid-connect-core-1_0.html#Authentication

事前準備

Googleの設定

以下ページの「Setting up OAuth 2.0」を終わらせている。(認証情報、クライアントID等の取得)
https://developers.google.com/identity/protocols/OpenIDConnect#appsetup

Symfony

プロジェクトの作成

以下ページにしたがってプロジェクトを作成する。
https://symfony.com/doc/3.4/setup.html

ログイン

以下ページを参考にログインを実装する。
https://symfony.com/doc/3.4/security/form_login_setup.html
https://symfony.com/doc/3.4/security/entity_provider.html

サインアップ

以下ページを参考にサインアップを実装する。
https://symfony.com/doc/3.4/doctrine/registration_form.html

サインアップ画面のtwigは以下のようにしておきます。

app/Resources/views/security/sign_up.html.twig
{% extends 'base.html.twig' %}

{% block body %}
    {{ form_start(form) }}
        {{ form_widget(form) }}
        <button type="submit">sign up</button>
    {{ form_end(form) }}
{% endblock %}

ディレクトリ構成

ディレクトリ構造.png

参考: https://symfony.com/doc/3.4/best_practices/creating-the-project.html#structuring-the-application

実装

設定

sessionの保存場所をPHPのsession保存先に変更します。
※以下設定はPHPのデフォルトでsessionが保存される場所を指定しています。

app/config/config.yml
framework:
    session:
        save_path: '/var/lib/php/session/%kernel.environment%'

ルーティング

ルーティングを以下のように定義します。

app/config/routing.yml
sign_up:
    path: /sign_up
    defaults: { _controller: AppBundle:Security:signUp }

login:
    path: /login
    defaults: { _controller: AppBundle:Security:login }

logout:
    path: /logout

google:
    path: /auth/google
    defaults: { _controller: AppBundle:Security:googleLogin }

redirect_google:
    path: /google
    defaults: { _controller: AppBundle:Security:redirectGoogleLogin }

google_sign_up:
    path: /google_sign_up
    defaults: { _controller: AppBundle:Security:googleSignUp }

security.yml

security.ymlを以下のように定義します。

app/config/security.yml
security:
    access_control:
        - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/sign_up, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/auth/*, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/google, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/, roles: ROLE_USER }

ログイン画面にリンクを追加

ログイン画面のtwigにGoogleアカウントでログインする用のリンクを追記します。

app/Resources/views/security/login.html.twig
{% block body %}
    //省略

    <a href="{{ path('google') }}">Sign in By Google</a>
{% endblock %}

Userテーブルにsubjectカラムを追加

OpenID Connectで使用するため、Userテーブルにsubjectカラムを追加します。
EntityのUserクラスに以下を追記します。

src/AppBundle/Entity/User.php
class User implements UserInterface, \Serializable
{
    //省略

    /**
     * @ORM\Column(type="string", length=255, unique=true, nullable=true)
     */
    private $subject;

    //省略    
}

以下コマンドを実行します。

console
php bin/console doctrine:generate:entities AppBundle/Entity/User
php bin/console doctrine:schema:update --force

OpenID Connect

ここから以下のサイトを参考にOpenID Connectでログインできるように実装していきます。
https://developers.google.com/identity/protocols/OpenIDConnect#authenticatingtheuser

実装ベースでのAuthorization Code Flowの流れは以下です。

Ⅰ. GoogleのAuthorization Endpointに認証リクエストを送信
Ⅱ. GoogleのToken Endpointにリクエストを送信
Ⅲ. 取得したID Tokenから情報を取得

Ⅰ. GoogoleのAuthorization Endpointに認証リクエストを送信

1. Create an anti-forgery state token

CSRF対策トークンを作成するところですが、今回はSymfonyのメソッドを使ってsessionに保存しています。

src/AppBundle/Controller/Security/SecurityController.php
class SecurityController extends Controller
{
    public function googleLoginAction(Request $request)
    {
        $session = $request->getSession();

        $uriSafeTokenGenerator = new UriSafeTokenGenerator();
        $state = $uriSafeTokenGenerator->generateToken();
        $session->set('state', $state);
    }
}

2. Send an authentication request to Google

GoogleのAuthorization Endpointに認証リクエストを送ります。

src/AppBundle/Controller/Security/SecurityController.php
class SecurityController extends Controller
{
    public function googleLoginAction(Request $request)
    {
        //省略

        $url = "https://accounts.google.com/o/oauth2/v2/auth?"
            . "client_id=xxxxxx.apps.googleusercontent.com&" //取得したクライアントID
            . "response_type=code&" //Authorization Code Flowなので
            . "scope=openid%20email&" //取得したい情報を指定する
            . "redirect_uri=http://localhost/google&" //リダイレクトされるURL
            . "state=$state&" //先ほど作ったstate
            . "prompt=select_account&"
            . "nonce=0394852-3190485-2490358&"; //リプレイアタック対策用

        return $this->redirect($url);
    }
}

Ⅱ. GoogleのToken Endpointにリクエストを送信

3. Confirm anti-forgery state token

認証後リダイレクトされるのでstateの検証を行います。

src/AppBundle/Controller/Security/SecurityController.php
class SecurityController extends Controller
{
    public function redirectGoogleLoginAction(Request $request)
    {
        if ($request->query->get('state') != $request->getSession()->get('state')) {
            return new Response('Invalid state parameter', 401);
        }
    }
}

4. Exchange code for access token and ID token

stateの確認後、取得したAuthorization Codeを使ってToken EndpointにPOSTします。
成功するとレスポンスボディにAccess TokenID Tokenが含まれたJSONがかえってきます。

src/AppBundle/Controller/Security/SecurityController.php
class SecurityController extends Controller
{
    private $openIdConnectService;

    public function __construct(OpenIdConnectService $openIdConnectService)
    {
        $this->openIdConnectService = $openIdConnectService;
    }

    public function redirectGoogleLoginAction(Request $request)
    {
        // 省略

        $token = $this->openIdConnectService->getToken($request->query->get('code'));
    }
}
src/AppBundle/Service/OpenIdConnectService.php
class OpenIdConnectService
{
    public function getToken($code)
    {
        $postData = array(
            'code' => $code, //取得したAuthorization Code
            'client_id' => 'xxxxxx.apps.googleusercontent.com',
            'client_secret' => 'xxxxxx',
            'redirect_uri' => 'http://localhost/google',
            'grant_type' => 'authorization_code'
        );

        $curlOpts = array(
            CURLOPT_URL => "https://www.googleapis.com/oauth2/v4/token",
            CURLOPT_HEADER => array("Content-type: application/x-www-form-urlencoded"),
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => http_build_query($postData),
            CURLOPT_RETURNTRANSFER => true
        );

        $ch = curl_init();
        curl_setopt_array($ch, $curlOpts);
        $response = curl_exec($ch);
        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        $body = substr($response, $headerSize);
        curl_close($ch);

        return json_decode($body, true);
    }
}

Ⅲ. 取得したID Tokenから情報を取得

5. Obtain user information from the ID token

OpenID ConnectではID Tokenの検証は必須ですが、
HTTPSを使って直接Googleと通信をし、自分のclient secretを使っていれば
検証する必要はないらしいです。

ID TokenJWTという形式で、.が区切り文字になっています。
その区切られた2番目の文字列をPayloadと言い、Googleから送られてきたemailなどの値が入っています。
PayloadはBase64でエンコードされていて、デコードするとJSONが出てきます。

参考: https://tools.ietf.org/html/rfc7519#section-3.1

Payloadの中のSubを使って登録済みかを判定し、
登録済みだった場合、ログイン状態へ、
登録済みではなかった場合、リダイレクトしています。

src/AppBundle/Controller/Security/SecurityController.php
class SecurityController extends Controller
{
    private $openIdConnectService;
    private $uriSafeBase64Service;

    public function __construct(OpenIdConnectService $openIdConnectService, UriSafeBase64Service $uriSafeBase64Service)
    {
        $this->openIdConnectService = $openIdConnectService;
        $this->uriSafeBase64Service = $uriSafeBase64Service;
    }

    public function redirectGoogleLoginAction(Request $request)
    {
        // 省略

        list($headerBase64, $payloadBase64, $signatureBase64) = explode(".", $token['id_token']);

        $payloadJson = json_decode($this->uriSafeBase64Service->decode($payloadBase64), true);

        $subject = $payloadJson['sub'];
        /** @var User $user */
        $user = $this->getDoctrine()->getRepository('AppBundle:User')->findOneBySubject($subject);
        if ($user != null) {
            $providerKey = "main";
            $loginToken = new UsernamePasswordToken($user, null, $providerKey, $user->getRoles());
            $securityTokenStorage = $this->get('security.token_storage');
            $securityTokenStorage->setToken($loginToken);

            return $this->redirectToRoute('homepage');
        }

        $session = $request->getSession();
        $session->set('subject', $subject);
        $session->set('email', $payloadJson['email']);

        return $this->redirectToRoute('google_sign_up');
    }
}
src/AppBundle/Service/Utilities/UriSafeBase64Service.php
class UriSafeBase64Service
{
    public function encode($data)
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'),'=');
    }

    public function decode($data)
    {
        return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT));
    }
}

登録されていないときの処理

今回は登録されていなかった場合、登録画面に遷移してusernameだけ入力してもらうようにしてみました。
データベースにはID TokenのpayloadのSubを保存するようにしています。

src/AppBundle/Controller/Security/SecurityController.php
class SecurityController extends Controller
{
    public function googleSignUpAction(Request $request, UserPasswordEncoderInterface $passwordEncoder)
    {
        $session = $request->getSession();
        $user = new User();
        $user->setSubject($session->get('subject'));
        $user->setEmail($session->get('email'));
        $user->setPlainPassword(random_bytes(10));

        $form = $this->createForm(GoogleSignUpType::class, $user);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $password = $passwordEncoder->encodePassword($user, $user->getPlainPassword());
            $user->setPassword($password);

            $entityManager = $this->getDoctrine()->getManager();
            $entityManager->persist($user);
            $entityManager->flush();

            return $this->redirectToRoute('homepage');
        }

        return $this->render('security/sign_up.html.twig', array(
            'form' => $form->createView()
        ));
    }
}
src/AppBundle/Form/GoogleSignUpType.php
class GoogleSignUpType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('email', EmailType::class)
            ->add('username', TextType::class);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\User'
        ));
    }
}

アクセス

http://localhost/login にアクセスして画面上の流れをやってみるとGoogleアカウントで登録、ログインできると思います。

以上で終わりです。

最後に

Githubにソースコードあげておきました。

おまけ

5. Obtain user information from the ID tokenID Tokenの検証はいらないという話でしたが、少しだけやってみました。

やらなければいけないことは以下のページに書いてあります。
https://developers.google.com/identity/protocols/OpenIDConnect#validatinganidtoken
このページに書いてある署名の検証だけやってみました。

GoogleのOpenID ConnectのID TokenはJWSのJWTです。1 2
JWSJOSE Header、JWS Payload、JWS Signatureで構成されています。3
JWS SignatureJOSE HeaderとJWS Payloadを署名し、Base64でエンコードしたものです。4

JOSE Headerには署名に使われたアルゴリズムと鍵についての情報(kid)が入っています。
鍵についてはGoogleが公開していて、署名の検証をするためにJOSE Headerに含まれるkidに該当する鍵を取得します。
平文、署名、取得した鍵、アルゴリズムを使って署名の検証を行います。

src/AppBundle/Controller/Security/SecurityController.php
class SecurityController extends Controller
{
    public function redirectGoogleLoginAction(Request $request)
    {
        // 省略

        list($headerBase64, $payloadBase64, $signatureBase64) = explode(".", $token['id_token']);

        $header = json_decode($this->uriSafeBase64Service->decode($headerBase64), true);

        $certificates = $this->openIdConnectService->getCerts(); //公開鍵を取得

        $kid = $header['kid'];
        $certificate = $certificates[$kid]; //kidに該当する公開鍵を選択
        $success = openssl_verify("$headerBase64.$payloadBase64", $this->uriSafeBase64Service->decode($signatureBase64), $certificate, OPENSSL_ALGO_SHA256); //検証

        if ($success === 1) {
            $payloadJson = json_decode($this->uriSafeBase64Service->decode($payloadBase64), true);
        } elseif ($success === 0) {
            return new Response('Invalid signature', 401);
        } else {
            echo openssl_error_string();
            return new Response('verify error', 401);
        }
    }
}
src/AppBundle/Service/OpenIdConnectService.php
class OpenIdConnectService
{
    public function getCerts()
    {
        $curlGetOptions = array(
            CURLOPT_URL => "https://www.googleapis.com/oauth2/v1/certs",
            CURLOPT_HEADER => true,
            CURLOPT_RETURNTRANSFER => true
        );

        $curlCh = curl_init();
        curl_setopt_array($curlCh, $curlGetOptions);
        $keysResponse = curl_exec($curlCh);
        $keysHeaderSize = curl_getinfo($curlCh, CURLINFO_HEADER_SIZE);
        $cBody = substr($keysResponse, $keysHeaderSize);
        curl_close($curlCh);

        return json_decode($cBody, true);
    }
}

おわり。

参考

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.