【Flutter】Amazon Cognito を用いたデザイン自由度の高いソーシャルログイン基盤構築

こんにちは、株式会社Pentagonでアプリを開発している難波です。

前回、Amplify CLI x Amazon Cognito を用いた認証基盤構築について調査しましたが、弊社のようにデザイン品質にこだわりを持たせる場合、カスタマイズに制限がかかる点がネックであることがわかりました。

そのため、デザインカスタマイズ性が高い基盤構築を行えるよう、再調査を行いました。

【こんな人に読んで欲しい】

  • AWSでの認証機能実装が求められているFlutterアプリ開発エンジニア
  • AWSでのSNS認証が求められているFlutterアプリ開発エンジニア
  • デザインにこだわりのある画面が求められるFlutterアプリ開発エンジニア

【この記事を読むメリット】

  • Flutter x AWS の環境で認証機能が実現できる
  • 認証基盤を独自開発しなくてよくなるため、開発工数が削減できる
  • デザインをカスタマイズできるログイン画面が実装できる

【結論】

前回 とは異なり、AWSが提供する Amplify CLI を用いずに認証基盤を構築することができました。
また、Amplify UI を用いないことで、デザインへの訴求が必要となるログインUIにも対応できることがわかりました。

本記事を読み、デザイン自由度の高い認証機能をFlutterアプリへ導入していきましょう!

目次

前提条件

本記事は Flutter x Cognito の構築を対象とし、Eメール認証およびソーシャルログイン(Googleアカウント)の実装までをゴールとします。
本記事はFlutterアプリに ソーシャルログイン機能実現にフォーカスするため、以下は対応済みの前提として説明を進めます。

  • Flutter開発環境構築済みであること
  • AWSアカウント登録済みであること
  • Googleアカウント登録済みであること
  • 本記事ではiOSのみ対応

「Amazon Cognitoの環境構築手順」ならびに「ソーシャルログインに必要なGoogleの設定」については公式デベロッパーガイドに従います。
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-pools-identity-provider.html

アプリ構築

Cognito との認証連携を行うため、pubspec.yaml に以下を追加します。

pubspec.yaml

dependencies:
 amplify_auth_cognito: ^1.0.1
 amplify_flutter: ^1.0.1

flutterアプリケーションのルートに amplifyconfiguration.dart ファイルを新規追加します。
ソーシャルログインに必要な内容について定義します。

amplifyconfiguration.dart

const amplifyconfig = '''{
 "auth": {
   "plugins": {
     "awsCognitoAuthPlugin": {
       "CognitoUserPool": {
         "Default": {
           "PoolId": "ユーザープールID",
           "AppClientId": "アプリケーションクライアントID",
           "Region": "リージョン"
         }
       },
       "Auth": {
         "Default": {
           "authenticationFlowType": "USER_SRP_AUTH",
           "socialProviders": ["GOOGLE"],
           "usernameAttributes": [],
           "signupAttributes": [
             "email"
           ],
           "passwordProtectionSettings": {
             "passwordPolicyMinLength": 8,
             "passwordPolicyCharacters": []
           },
           "mfaConfiguration": "OFF",
           "mfaTypes": [
             "SMS"
           ],
           "verificationMechanisms": [
             "EMAIL"
           ],
           "OAuth": {
             "WebDomain": "アプリケーションクライアントのホストされたUIのドメイン",
             "AppClientId": "アプリケーションクライアントID",
             "SignInRedirectURI": "許可されているコールバックURL",
             "SignOutRedirectURI": "許可されているサインアウトURL",
             "Scopes": [
               "email",
               "openid"
             ]
           }
         }
       }
     }
   }
 }
}''';

アプリケーションを実装します。

記事投稿用のソースコードにつき、下記の状態となっております。参考にされる際、その点ご留意ください。

  • 正常系以外の動作ではエラーが発生します
  • ファイル分割をしていません
  • リファクタリングしていません

main.dart

import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:flutter/material.dart';

import 'amplifyconfiguration.dart';

void main() {
 runApp(const MyApp());
}

class MyApp extends StatefulWidget {
 const MyApp({super.key});

 @override
 State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
 @override
 void initState() {
   super.initState();
   _configureAmplify();
 }

 void _configureAmplify() async {
   final auth = AmplifyAuthCognito();
   await Amplify.addPlugin(auth);

   try {
     await Amplify.configure(amplifyconfig);
   } on AmplifyAlreadyConfiguredException catch (e) {
     safePrint(e);
   }
 }

 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     theme: ThemeData(
       primarySwatch: Colors.grey,
     ),
     home: const HomeScreen(),
   );
 }
}

class HomeScreen extends StatefulWidget {
 const HomeScreen({super.key});

 @override
 State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
 final tabBarViews = [
   const LoginScreen(),
   const SignUpScreen(),
 ];

 @override
 Widget build(BuildContext context) {
   return DefaultTabController(
     length: tabBarViews.length,
     child: Scaffold(
       appBar: AppBar(
         title: const Text('HOME'),
         bottom: const TabBar(
           tabs: [
             Tab(text: 'ログイン'),
             Tab(text: 'サインアップ'),
           ],
         ),
       ),
       body: TabBarView(children: tabBarViews),
     ),
   );
 }
}

class LoginScreen extends StatefulWidget {
 const LoginScreen({super.key});

 @override
 State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
 final _emailTextController = TextEditingController();
 final _passwordTextController = TextEditingController();

 bool get isValid =>
     _emailTextController.text.isNotEmpty &&
     _passwordTextController.text.isNotEmpty;

 void clear() {
   _emailTextController.text = '';
   _passwordTextController.text = '';
 }

 @override
 Widget build(BuildContext context) {
   return Container(
     padding: const EdgeInsets.all(16.0),
     child: Column(
       children: [
         TextFormField(
           controller: _emailTextController,
           decoration: const InputDecoration(
             border: OutlineInputBorder(),
             label: Text('email'),
           ),
           onChanged: (_) => setState(() {}),
         ),
         const SizedBox(height: 16),
         TextFormField(
           controller: _passwordTextController,
           decoration: const InputDecoration(
             border: OutlineInputBorder(),
             label: Text('password'),
           ),
           onChanged: (_) => setState(() {}),
         ),
         const SizedBox(height: 16),
         ElevatedButton(
           onPressed: isValid
               ? () {
                   _signIn(
                     context,
                     signIn: Amplify.Auth.signIn(
                       username: _emailTextController.text,
                       password: _passwordTextController.text,
                     ),
                   );
                 }
               : null,
           child: const Text('ログイン'),
         ),
         const Divider(thickness: 2.0),
         ElevatedButton(
           onPressed: () {
             _signIn(
               context,
               signIn: Amplify.Auth.signInWithWebUI(
                 provider: AuthProvider.google,
               ),
             );
           },
           child: const Text('Google アカウント'),
         ),
       ],
     ),
   );
 }

 Future<void> _signIn(
   BuildContext context, {
   required Future<SignInResult> signIn,
 }) async {
   try {
     final result = await signIn;
     if (result.isSignedIn) {
       ScaffoldMessenger.of(context).showSnackBar(
         const SnackBar(
           content: Text('ログインに成功しました!'),
         ),
       );

       Navigator.of(context).pushReplacement(
         MaterialPageRoute(
           builder: (context) => const TopScreen(),
         ),
       );
     }
   } catch (e) {
     safePrint(e);
   }
 }
}

class SignUpScreen extends StatefulWidget {
 const SignUpScreen({super.key});

 @override
 State<SignUpScreen> createState() => _SignUpScreenState();
}

class _SignUpScreenState extends State<SignUpScreen> {
 final _emailTextController = TextEditingController();
 final _passwordTextController = TextEditingController();
 final _codeTextController = TextEditingController();

 bool get isValid =>
     _emailTextController.text.isNotEmpty &&
     _passwordTextController.text.isNotEmpty;

 int _step = 0;

 @override
 void dispose() {
   super.dispose();
   _emailTextController.dispose();
   _passwordTextController.dispose();
   _codeTextController.dispose();
 }

 @override
 Widget build(BuildContext context) {
   final steps = [
     _signUpStep(context),
     _codeStep(context),
   ];

   return Container(
     padding: const EdgeInsets.all(16.0),
     child: steps[_step],
   );
 }

 Widget _signUpStep(BuildContext context) {
   return Column(
     children: [
       TextFormField(
         controller: _emailTextController,
         decoration: const InputDecoration(
           border: OutlineInputBorder(),
           label: Text('email'),
         ),
         onChanged: (_) => setState(() {}),
       ),
       const SizedBox(height: 16),
       TextFormField(
         controller: _passwordTextController,
         decoration: const InputDecoration(
           border: OutlineInputBorder(),
           label: Text('password'),
         ),
         onChanged: (_) => setState(() {}),
       ),
       const SizedBox(height: 16),
       ElevatedButton(
         onPressed: isValid
             ? () async {
                 try {
                   final result = await Amplify.Auth.signUp(
                     username: _emailTextController.text,
                     password: _passwordTextController.text,
                   );
                   switch (result.nextStep.signUpStep) {
                     case AuthSignUpStep.confirmSignUp:
                       setState(() => _step++);
                       break;
                     case AuthSignUpStep.done:
                       Navigator.of(context).pushReplacement(
                         MaterialPageRoute(
                           builder: (context) => const HomeScreen(),
                         ),
                       );
                       break;
                   }
                 } catch (e) {
                   safePrint(e);
                 }
               }
             : null,
         child: const Text('サインアップ'),
       ),
     ],
   );
 }

 Widget _codeStep(BuildContext context) {
   return Container(
     padding: const EdgeInsets.all(16.0),
     child: Column(
       children: [
         TextFormField(
           controller: _codeTextController,
           decoration: const InputDecoration(
             border: OutlineInputBorder(),
             label: Text('code'),
           ),
         ),
         const SizedBox(height: 16),
         ElevatedButton(
           onPressed: () async {
             final result = await Amplify.Auth.confirmSignUp(
               username: _emailTextController.text,
               confirmationCode: _codeTextController.text,
             );

             if (result.isSignUpComplete) {
               ScaffoldMessenger.of(context).showSnackBar(
                 const SnackBar(
                   content: Text('認証に成功しました!'),
                 ),
               );

               Navigator.of(context).pushReplacement(
                 MaterialPageRoute(
                   builder: (context) => const HomeScreen(),
                 ),
               );
             }
           },
           child: const Text('認証'),
         ),
       ],
     ),
   );
 }
}

class TopScreen extends StatelessWidget {
 const TopScreen({super.key});

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(
       title: const Text('トップページ'),
     ),
     body: Container(
       padding: const EdgeInsets.all(16.0),
       alignment: Alignment.center,
       child: Column(
         children: [
           const SizedBox(height: 16),
           const Text('ここはログイン後のトップページです'),
           const SizedBox(height: 16),
           ElevatedButton(
             onPressed: () async {
               try {
                 await Amplify.Auth.signOut();

                 ScaffoldMessenger.of(context).showSnackBar(
                   const SnackBar(
                     content: Text('ログアウトしました!'),
                   ),
                 );

                 Navigator.of(context).pushReplacement(
                   MaterialPageRoute(
                     builder: (context) => const HomeScreen(),
                   ),
                 );
               } on AuthException catch (e) {
                 safePrint(e);
               }
             },
             child: const Text('ログアウト'),
           ),
         ],
       ),
     ),
   );
 }
}

動作イメージ

「Google アカウント」を押します。

許可ダイアログの「続ける」を押します。
(初回のみGoogle認可画面が表示されます)

まとめ

FlutterアプリからAmazon Cognitoを利用することで、ソーシャルログイン(Google認証)を実現することができます。
また、Flutter標準Widgetのみを用いているため、バックエンド構築にAWSを利用した上で、デザイン自由度の高いアプリ開発ができることがわかりました。

本記事の内容をもとに、デザイン性の高いUIを認証フローに適用していきましょう!

おまけ

Flutterに関する知識を深めたい方には、『Flutterの特徴・メリット・デメリットを徹底解説』という記事がおすすめです。

この記事では、Flutter アプリ開発の基本から、flutter とは何か、そして実際のflutter アプリ 事例を通じて、その将来性やメリット、デメリットまで詳しく解説しています。
Flutterを使ったアプリ開発に興味がある方、またはその潜在的な可能性を理解したい方にとって、必見の内容となっています。

ぜひ一度ご覧ください。

採用情報はこちら
目次