iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

モバイルアプリでソーシャルログインを実装

はじめに

iimonでエンジニアをしています。腰丸です。 今回はソーシャルログインについて触れてみようと思います。

ソーシャルログインとは

こういうやつです。

Googleとか、Facebookでログインできるよって画面があって、

ボタンを押すと、外部サービスのログイン画面に飛んで、

ポチポチボタンを押すと、Googleとかの認証情報を使ってログインができてしまう

実装イメージ

画像のような流れのログインフローです。サンプルはGoogleで、IOS環境での実装をしています。 多くの場合、OAuth2という規格に則って対象の認証プロバイダー(GoogleとかFacebook)からユーザーの認証情報を取得することになります。

OAuth認証プロバイダーの設定

  • 外部サービスを認証プロバイダーとして使用する場合は、事前に設定が必要になります。今回はGoogleをサンプルに実装するので、設定画面を簡単に記述します。

Googleの場合はGCPから設定します。

1 OAuthクライアントIDの作成

  • バンドルID: iosアプリの識別子らしく認証情報として設定が必要なようです。xcodeビルド時の設定画面から値を確認できます。

  • 設定が完了すると下記のような画面で設定情報が見れます。(モバイル側の設定で使用します)

2 OAuth同意画面の作成 - ユーザータイプを外部にして作成します。以降の設定もありスコープの設定などができるのですが、省略します。

おおまかな実装

使用技術

  • モバイル側: Flutterを使った実装です。認証プロバイダーへのリクエストは、appAuthというライブラリを使用しています。
  • サーバー側: FastAPI.

サンプルとして使用したフレームワークを記載していますが、実装そのものよりも大まかな流れを把握することがメインのため、実装手段自体は深くは記述しません。
(言語や技術が変わっても同じフローであれば、やることは大きく変わらないはず)

実装と大まかな解説

1 認証プロバイダーへ認可コードを取得するリクエストを送信(実装イメージの①~④までの部分)

      final AuthorizationResponse? result = await appAuth.authorize(
        AuthorizationRequest(
          clientId,
          redirectUri,
          discoveryUrl: discoveryUrl,
          scopes: ['email'],
          promptValues: ['login'],
        ),

このリクエストの送信でGoogle画面で入力する部分は終わりです。

注意事項(補足):

  • コード上ではわかりずらいですが、code_verifier(code_challenge)という値がリクエストに含まれています。 Google側に認可コードをもとにアクセストークンを取得する際リクエストを送信する際に、code_verifierを含める必要があります。 (認可コードの検証時に、code_verifierと認可コードの組み合わせで検証させるため)

  • Webブラウザと異なりモバイルアプリはリダイレクトを受け取るURLが存在しないため、リダイレクトURLではなく、スキームURLを設定します。コード上ではわかりにくいですが、resultの取得はスキームURLへのリダイレクトで取得されます。IOSの場合は、Info.plistに下記のような設定を入れるのかと思います

<key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>{プロバイダー指定のスキームURL}</string>
            </array>
        </dict>
    </array>

リクエスト内容詳細

  • 他にも付与されるパラメータはありますが、メインの部分のみを記述しています
パラメータ名 説明
client_id=クライアントID(認証情報で設定した値) Google APIコンソールで取得したクライアントIDです。このIDを使って、どのアプリケーションがリクエストを送信しているかを識別します。
response_type=code 認可コードを要求することを示します。OAuth2フローでは、これを使って後でアクセストークンを取得します。
code_challenge=ランダム文字列 PKCEフローの一部で、code_verifierをハッシュ化したものです。後でアクセストークンを取得するときに、code_verifierとこのcode_challengeが一致するかどうかがチェックされます。
code_challenge_method=S256 PKCE(Proof Key for Code Exchange)において、code_challengeを生成する方法としてSHA-256を使用することを指定しています。
redirect_uri=リダイレクト先のURI(認証情報で設定した値) 認可コードを返すためのリダイレクト先のURIです。ここでは、カスタムスキームが使われています。モバイルアプリでは、認証後にこのURIにリダイレクトされて認可コードが送られます。
scope=email ユーザーのメールアドレス情報にアクセスするために要求する権限の範囲です(スコープ)。Googleは、emailスコープに基づいてメールアドレスのアクセスを提供します。
state=ランダム文字列 リクエストの状態を表すランダムな文字列です。認可サーバーはこの値をリダイレクトの際に返すため、アプリケーションはリダイレクト時のstateのがリクエスト時と一致するかを検証することで、リクエストの一貫性を確認できます。

2 うまくいくと、認可コードが取得できます。

3 取得した認可コードを含めて自社サーバーへログインリクエスト(画像イメージの⑤の部分)

  # ↓このリクエストの部分
  Future<void> _sendAuthorizationCodeToServer(AuthorizationResponse googleAuthResponse) async {
    final response = await http.post(
      Uri.parse('http://localhost:8008/customer/auth/login/google'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'authorization_code': googleAuthResponse.authorizationCode,
        'redirect_uri': redirectUri,
        'client_id': clientId,
        'code_verifier': googleAuthResponse.codeVerifier,
      }),
    );

    if (response.statusCode == 200) {
      print('Login successful: ${response.body}');
      final responseData = jsonDecode(response.body);
      String accessToken = responseData['access_token'];
      String refreshToken = responseData['refresh_token'];

      // アクセストークンとリフレッシュトークンをセキュアストレージに保存
      await secureStorage.write(key: 'access_token', value: accessToken);
      await secureStorage.write(key: 'refresh_token', value: refreshToken);

      setState(() {
        _isLoggedIn = true; // ログイン状態を更新
      });

      print('Login successful: Access and Refresh tokens saved.');
    } else {
      print('Failed to log in: ${response.body}');
    }
  }

3 サーバー側で認証コードをもとにアクセストークンを取得(画像イメージの⑥~⑦の部分)
4 アクセストークンをもとに、Googleユーザーの情報を取得(画像イメージ⑧~⑨の部分)

class GoogleOAuthClient:
    TOKEN_URL = 'https://oauth2.googleapis.com/token'
    USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo'

    async def exchange_code_for_tokens(self,
                                       authorization_code: str,
                                       code_verifier: str,
                                       client_id: str,
                                       redirect_uri: str):
        headers = {
            'Content-Type': 'application/x-www-form-urlencoded'
        }

        token_data = {
            'code': authorization_code,
            'client_id': client_id,
            'redirect_uri': redirect_uri,
            'grant_type': 'authorization_code',
            'code_verifier': code_verifier,
            'scope': 'email'
        }

        async with httpx.AsyncClient() as client:
            response = await client.post(url=self.TOKEN_URL, headers=headers, data=token_data)
            response.raise_for_status()
            return response.json()

    async def get_user_info(self, access_token: str):
        headers = {'Authorization': f'Bearer {access_token}'}

        async with httpx.AsyncClient() as client:
            response = await client.get(self.USERINFO_URL, headers=headers)
            response.raise_for_status()
            return response.json()

アクセストークンを取得して、Googleのユーザー情報を取得します。

        token_response = await self.google_oauth_client.exchange_code_for_tokens(
            authorization_code=google_login_data.authorization_code,
            code_verifier=google_login_data.code_verifier,
            client_id=google_login_data.client_id,
            redirect_uri=google_login_data.redirect_uri)

        user_info_response = await self.google_oauth_client.get_user_info(access_token=token_response['access_token'])

5 ユーザー情報が自社サーバーで登録済みか検証し、未登録の場合はユーザー作成
6 JWT(アクセストークン、リフレッシュトークン)を自社サーバーで作成しレスポンスを返却

        customer: Customer = await self.customer_repository.get_by_email(db=self.db_session,
                                                                         email=user_info_response['email'])
        if not customer:
            customer = await self.customer_repository.create(db=self.db_session,
                                                             name='とりあえずテスト',
                                                             email=user_info_response['email'])

        customer_id = customer.id
        await self.db_session.commit()

        access_token = AuthUtil.create_access_token(user_id=customer_id, type='customer')
        refresh_token = AuthUtil.create_refresh_token(customer_id, type='customer')

        return {"access_token": access_token, "refresh_token": refresh_token}

7 アクセストークン、リフレッシュトークンをモバイル側で受け取れればログイン成功

    if (response.statusCode == 200) {
      print('Login successful: ${response.body}');
      final responseData = jsonDecode(response.body);
      String accessToken = responseData['access_token'];
      String refreshToken = responseData['refresh_token'];

      // アクセストークンとリフレッシュトークンをセキュアストレージに保存
      await secureStorage.write(key: 'access_token', value: accessToken);
      await secureStorage.write(key: 'refresh_token', value: refreshToken);

      setState(() {
        _isLoggedIn = true; // ログイン状態を更新
      });

      print('Login successful: Access and Refresh tokens saved.');
    } else {
      print('Failed to log in: ${response.body}');
    }

おわりに

  • toC向けのサービスで実装する機会の多いソーシャルログインの実装を記述しました。 Webブラウザでの実装はイメージがついていたのですが、モバイルだと想定しているフローで実装できるかがわからなかったので、 今回の記事で実装イメージがついて勉強になりました。
  • この記事を通じて、一通りのソーシャルログインの流れを理解する一助になれば幸いです。

弊社ではエンジニアを募集しております。 ご興味がありましたらカジュアル面談も可能ですので、是非ご応募ください!

Wantedly / Green