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

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

前回、Amplify CLI および Amplify UI を用いず、 Amazon Cognito を用いたソーシャルログイン基盤構築を調査しました。Flutter標準Widgetのみを用いているため、弊社のようにデザインに強みを持つ場合であっても、デザイン性の高いアプリ開発ができることがわかりました。

前回は Googleアカウント を用いたソーシャルログインのみ対応したため、今回は Appleアカウント に対応していきます。

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

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

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

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

【結論】

前回に引き続き、Appleアカウントを用いた認証についても動作確認できました。
Amazon Cognito を用いてソーシャルログインを構築する場合、Googleアカウントなど1つのソーシャルログインへ対応した後は、比較的容易に他のSNS連携ができることがわかりました。

本記事を読み、ソーシャルログイン対応のアカウントを追加していきましょう!

動作イメージ

目次

前提条件

本記事は前回作成した Flutter x Cognito の環境上へ、ソーシャルログイン(Appleアカウント)を実装するまでをゴールとします。

以下は対応済みの前提として説明を進めます。

  • 前回記事の環境があること
  • Apple ID 登録済みであること
  • Apple Developer アカウント登録済みであること
  • 本記事ではiOSのみ対応

バックエンド構築

「ソーシャルログインに必要なAppleの設定」については公式デベロッパーガイドに従います。

https://docs.amplify.aws/lib/auth/social/q/platform/flutter/

アプリ構築

パッケージ

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

dependencies:
 amplify_auth_cognito: ^1.0.1
 amplify_flutter: ^1.0.1

Cognito接続設定

flutterアプリケーションのルートにある amplifyconfiguration.dart ファイルを更新します。
Appleアカウントでのソーシャルログインに必要な内容について追記します。

amplifyconfiguration.dart

前回差分のみ記載

"socialProviders": ["GOOGLE",”APPLE”],

Flutter実装

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

  • 記事投稿用のソースコードにつき、下記の状態となっております。参考にされる際、その点ご留意ください。
  • 正常系以外の動作ではエラーが発生します
  • ファイル分割をしていません
  • リファクタリングしていません

このFlutterアプリは、ユーザー認証機能を持つアプリケーションです。具体的には、Amplifyというバックエンドサービスを使用して、ユーザーのログイン、サインアップ、ログアウト、ソーシャルログイン(GoogleアカウントおよびAppleアカウント)、認証コードの確認などの機能を提供します。

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: Padding.all16,
      child: Column(
        children: [
          _textFormField(
            controller: _emailTextController,
            label: 'email',
          ),
          Space.h16,
          _textFormField(
            controller: _passwordTextController,
            label: 'password',
          ),
          Space.h16,
          ElevatedButton(
            onPressed: isValid ? () async => await _login(context) : null,
            child: const Text('ログイン'),
          ),
          Border.w2,
          ElevatedButton(
            onPressed: () => _socialLogin(context, AuthProvider.google),
            child: const Text('Google アカウント'),
          ),
          ElevatedButton(
            onPressed: () => _socialLogin(context, AuthProvider.apple),
            child: const Text('Apple アカウント'),
          ),
        ],
      ),
    );
  }

  TextFormField _textFormField({
    required TextEditingController controller,
    required String label,
  }) {
    return TextFormField(
      controller: controller,
      decoration: InputDecoration(
        border: const OutlineInputBorder(),
        label: Text(label),
      ),
      onChanged: (_) => setState(() {}),
    );
  }

  Future<void> _login(BuildContext context) async {
    try {
      final result = await Amplify.Auth.signIn(
        username: _emailTextController.text,
        password: _passwordTextController.text,
      );
      if (!mounted) return;
      _onSuccessLogin(context, result);
    } catch (e) {
      safePrint(e);
    }
  }

  void _socialLogin(
    BuildContext context,
    AuthProvider provider,
  ) async {
    try {
      final result = await Amplify.Auth.signInWithWebUI(provider: provider);
      if (!mounted) return;
      _onSuccessLogin(context, result);
    } catch (e) {
      safePrint(e);
    }
  }

  void _onSuccessLogin(
    BuildContext context,
    SignInResult result,
  ) {
    if (result.isSignedIn) {
      ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text('ログインに成功しました!'),
      ),
    );

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

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),
      _codeConfirmStep(context),
    ];
    return Container(
      padding: Padding.all16,
      child: steps[_step],
    );
  }

  Widget _signUpStep(BuildContext context) {
    return Column(
      children: [
        _textFormField(controller: _emailTextController, label: 'email'),
        Space.h16,
        _textFormField(controller: _passwordTextController, label: 'password'),
        Space.h16,
        ElevatedButton(
          onPressed: isValid ? () async => await _signUp(context) : null,
          child: const Text('サインアップ'),
        ),
      ],
    );
  }

  Future<void> _signUp(BuildContext context) 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:
          if (!mounted) return;
          Navigator.of(context).pushReplacement(
            MaterialPageRoute(
              builder: (context) => const HomeScreen(),
            ),
          );
          break;
      }
    } catch (e) {
      safePrint(e);
    }
  }

  TextFormField _textFormField({
      required TextEditingController controller,
      required String label,
  }) {
    return TextFormField(
      controller: controller,
      decoration: InputDecoration(
        border: const OutlineInputBorder(),
        label: Text(label),
      ),
      onChanged: (_) => setState(() {}),
    );
  }

  Widget _codeConfirmStep(BuildContext context) {
    return Column(
      children: [
        _textFormField(controller: _codeTextController, label: 'code'),
        Space.h16,
        ElevatedButton(
          onPressed: () async => await _codeConfirm(context),
          child: const Text('認証'),
        ),
      ],
    );
  }

  Future<void> _codeConfirm(BuildContext context) async {
    final result = await Amplify.Auth.confirmSignUp(
      username: _emailTextController.text,
      confirmationCode: _codeTextController.text,
    );
    if (result.isSignUpComplete) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('認証に成功しました!'),
        ),
      );
      Navigator.of(context).pushReplacement(
        MaterialPageRoute(
          builder: (context) => const HomeScreen(),
        ),
      );
    }
  }
}

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

  @override
  State<TopScreen> createState() => _TopScreenState();
}

class _TopScreenState extends State<TopScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('トップページ'),
      ),
      body: Container(
      padding: Padding.all16,
      alignment: Alignment.center,
      child: Column(
        children: [
          Space.h16,
          const Text('ここはログイン後のトップページです'),
          Space.h16,
          ElevatedButton(
            onPressed: () async => await _logout(context),
              child: const Text('ログアウト'),
            ),
          ],
        ),
      ),
    );
  }

  Future<void> _logout(BuildContext context) async {
    try {
      await Amplify.Auth.signOut();
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('ログアウトしました!'),
        ),
      );
      Navigator.of(context).pushReplacement(
        MaterialPageRoute(
          builder: (context) => const HomeScreen(),
        ),
      );
    } on AuthException catch (e) {
      safePrint(e);
    }
  }
}

class Space {
  static get h16 => const SizedBox(height: 16);
}

class Border {
  static get w2 => const Divider(thickness: 2);
}

class Padding {
  static get all16 => const EdgeInsets.all(16);
}

アプリの構造は以下のようになっています:

  • main.dart:アプリのエントリーポイントで、Amplifyの設定を行います。
  • MyApp:ルートウィジェットで、HomeScreenを表示します。
  • HomeScreen:ログインとサインアップのタブを持つホーム画面を表示します。
  • LoginScreen:ユーザーのログイン情報を入力するための画面です。ログインボタンを押すと、Amplifyを使用してログインを試みます。
  • SignUpScreen:新しいユーザーのサインアップ情報を入力するための画面です。サインアップボタンを押すと、Amplifyを使用してサインアップを試みます。必要に応じて認証コードの確認ステップに進みます。
  • TopScreen:ログイン後のトップページを表示します。ログアウトボタンを押すとログアウトし、HomeScreenに戻ります。

また、いくつかの補助的なクラスがあります:

  • Space:ウィジェット間の垂直方向のスペースを提供します。
  • Border:区切り線を提供します。
  • Paddng:ウィジェットのパディングを提供します。

まとめ

FlutterアプリからAmazon Cognitoを利用することで、前回の環境へAppleアカウントのソーシャルログイン追加を簡単に実現できました。

ほかにもFacebookアカウント、AmazonアカウントもAmazon Cognitoから連携する手順が用意されているため、必要に応じて実装していくことができますね!

https://docs.amplify.aws/lib/auth/social/q/platform/flutter/

  • Facebook Login
  • Login with Amazon

上記以外のSNS連携についても機会があれば調査していこうと思います!

おまけ

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

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

ぜひ一度ご覧ください。

採用情報はこちら
目次