0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Symfony3でOpenID Connectしてみた

Posted at

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);
    }
}

おわり。

参考

  1. https://tools.ietf.org/html/rfc7519#section-7.2

  2. https://tools.ietf.org/html/rfc7516#section-9

  3. https://tools.ietf.org/html/rfc7515#section-3

  4. https://tools.ietf.org/html/rfc7515#section-3.3

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?