使用 React Native 與 Amazon Cognito 實作 Google & Facebook 登入的功能

Standard

繼續未完的 Amazon Cognito 設定

在設定 react-native-facebook-loginreact-native-google-signin 的過程中,我們一併把 Facebook & Google 應用程式設定好了,現在我們回過頭來設定之前未設定完畢的部份。

  1. 先來看 OpenID 的部份,切到 OpenID 頁籤會發現什麼也沒有:2017-05-24 12 06 20

    是說 OpenID Provider 必須在 Amazon IAM 中進行設定。

  2. IAM 是 Identity and Access Management 的縮寫,進到服務首頁後點擊 開始使用 Amazon IAM
  3. 在左方選項中點選 Identity providers,再點選 Create Provider

    2017-05-24 12 03 49

  4. Provider Type 選擇 OpenID ConnectProvider URL 填入 https://accounts.google.com,最後 Audience 的部份,請打開 Google Developers Console,切換至相應的應用程式,並在 憑證 頁下方找到 OAuth 2.0 用戶端 ID,這邊請先加任何一組,之後修改設定請通通加到 Audience

    2017-05-24 12 08 01

    2017-05-24 12 08 01

  5. 儲存之後,回到 Cognito 設定頁面重整,可以發現 OpenID 頁籤有 acctouns.google.com 了,勾選它。
  6. 切到 Facebook 頁籤,填入 Facebook App ID:

    2017-05-24 12 02 12

  7. Create pool 進到下一步,保持預設值即可,點 Allow
  8. Cognito 設定完成,不過 SDK 我們先前已經設定完畢,這邊就不用再做一次了。

    image

  9. 回到 IAM 編輯剛剛的 accounts.google.com,把所有 audience 加入。

前端程式

這邊的前端程式是指 React Native 的程式碼,本文同時會用它進行 iOS 與 Android 的實作。

主要流程可參考 https://github.com/awslabs/aws-sdk-react-native/tree/master/Core/example 這邊的範例程式碼。

以下分段介紹:

  1. 先準備好基本 layout 吧,Android & iOS 都一樣,大概就是放個標題,擺 Facebook 與 Google 登入的按鈕。app.js
    import React, { PropTypes, Component } from 'react';
    import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
    
    const styles = StyleSheet.create({
      containerStyle: {
        flex: 1,
        alignItems: 'center',
        justifyContent: 'center',
        paddingHorizontal: 20,
      },
      headingText: {
        fontWeight: '500',
        fontSize: 18,
        color: 'rgb(38, 38, 38)',
        marginTop: 20,
        marginBottom: 12,
      },
      facebookLoginButton: {
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        width: 260,
        height: 42,
        backgroundColor: 'rgb(59, 90, 150)',
        borderRadius: 4,
        paddingVertical: 15,
        paddingHorizontal: 32,
      },
      facebookLoginButtonText: {
        fontWeight: 'normal',
        fontSize: 17,
        color: 'rgb(255, 255, 255)',
      },
      googleLoginButton: {
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        width: 260,
        height: 42,
        backgroundColor: 'rgb(234, 67, 53)',
        borderRadius: 4,
        marginTop: 10,
        paddingVertical: 15,
        paddingHorizontal: 32,
      },
      googleLoginButtonText: {
        fontWeight: 'normal',
        fontSize: 17,
        color: 'rgb(255, 255, 255)',
        marginLeft: 10,
      },
      logoutButton: {
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        width: 260,
        height: 42,
        backgroundColor: 'rgb(234, 67, 53)',
        borderRadius: 4,
        marginTop: 10,
        paddingVertical: 15,
        paddingHorizontal: 32,
      },
      logoutButtonText: {
        fontWeight: 'normal',
        fontSize: 17,
        color: 'rgb(255, 255, 255)',
        marginLeft: 10,
      },
    });
    
    class App extends Component {
      handleLogin = async (type) => {
      }
    
      handleLogout = () => {
      }
    
      render() {
        const { isLoading, loggedIn, profile } = this.state;
    
        return (
          <View style={styles.containerStyle}>
            <Text style={styles.headingText}>React-Native Cognito Login Example</Text>
    
        <View>
          <TouchableOpacity onPress={() => this.handleLogin('facebook')} style={styles.facebookLoginButton}>
            <Text style={styles.facebookLoginButtonText}>Login with Facebook</Text>
          </TouchableOpacity>
    
          <TouchableOpacity onPress={() => this.handleLogin('google')} style={styles.googleLoginButton}>
                <Text style={styles.googleLoginButtonText}>Login with Google</Text>
              </TouchableOpacity>
            </View>
          </View>
        );
      }
    }
    
    export default App;
    

    註:本例中我們不打算用套件提供的按鈕。

  2. 再來新開一個檔案 utils/auth.js,裡面放跟登入驗證有關的東西,先 import 幾個東西:
    import { AWSCognitoCredentials } from 'aws-sdk-react-native-core';
    import { FBLoginManager } from 'react-native-facebook-login';
    import { GoogleSignin } from 'react-native-google-signin';
    
  3. 接著宣告幾個常數,比較好的作法是將它們放在環境變數中,或是透過 react-native-config 管理。
    const IDENTITY_POOL_ID = 'ap-northeast-1:4e86b831-da7f-47d5-8382-3d800cd28a25';
    const REGION = 'ap-northeast-1';
    const GOOGLE_SIGNIN_IOS_CLIENT_ID = '469382905985-0ho9t3lc0g6ig7l69du9971vijdgv6fn.apps.googleusercontent.com';
    const GOOGLE_SIGNIN_WEBCLIENT_ID = '469382905985-sdi5a3gqtk1ikce7cb2f0u6h9ashculq.apps.googleusercontent.com';
    
    const providers = {
      'graph.facebook.com': 'FacebookProvider',
      'accounts.google.com': 'GoogleProvider',
    };
    

    其中 IDENTITY_POOL_ID 是在 Amazon Cognito 建立 Federated Identities 時的 Identity Pool 會給的 pool id。忘記的話可以到 Cognito > Federated Identities > Identity pool 名稱 > 右上角的 Edit identity pool 中找到。REGION 就是當初建立時選用的 region。

    GOOGLE_SIGNIN_IOS_CLIENT_IDGOOGLE_SIGNIN_WEBCLIENT_ID 可以從 Google Developers Console 中找到,在 憑證 那一頁。由於 Android 上面用的是 Web Client 的方式,所以請不要複製錯了哦。

    providers 是我們這次會用到的 providers,這邊先定義好對應的名稱,之後會用到。

  4. 宣告其他變數
    let identityId = null;
    let currentLoginMethod; // 目前是用哪個 provider
    let globalSupplyLogin = false; // 若已經有 id token / access token 就會登入的旗標
    let globalAccessToken = '';
    let globalOpenIdToken = '';
    
    let openIdResolve;
    let openIdTokenPromise = new Promise(resolve => {
      openIdResolve = resolve;
    });
    
  5. 接著我們要定義幾個主要的 function:getCredentialsfbGetCredentialsgoogleGetCredentialsgetIdentityIdonLoginInvokedgetOpenIdToken。登入流程中,會先依據設定(例如 app ID、scopes 等)初始化第三方 providers 並嘗試取得 Access Token、接著取得 Identity ID(每個身分都有一個 ID,而這些身分都存放在 Identity Pool 裡面,可以去 Amazon Cognito 的 Federated Identities 下的 Identity browser 中看到登入過的 Identity ID 哦)、再拿 Access Token 去換 OpenID Token。getCredentials
    const getCredentials = () => Promise.race([fbGetCredential(), googleGetCredential()]);
    

    fbGetCredentialsgoogleGetCredentials

    const fbGetCredential = () => {
      return new Promise(resolve => {
        FBLoginManager.getCredentials((err, data) => {
          if (
            data &&
            typeof data.credentials === 'object' &&
            typeof data.credentials.token === 'string' &&
            data.credentials.token.length > 0
          ) {
            currentLoginMethod = 'graph.facebok.com';
            resolve({
              accessToken: data.credentials.token,
            });
          } else {
            console.log('fbGetCredential fail', data, err);
          }
        });
      });
    };
    
    const googleGetCredential = () => {
      return new Promise(resolve => {
        GoogleSignin.hasPlayServices({ autoResolve: true })
          .then(() =>
            GoogleSignin.configure({
              iosClientId: GOOGLE_SIGNIN_IOS_CLIENT_ID,
              webClientId: GOOGLE_SIGNIN_WEBCLIENT_ID,
            })
          )
          .then(() => GoogleSignin.currentUserAsync())
          .then(user => {
            console.log('user', user);
    
            if (user && user.idToken && user.accessToken) {
              currentLoginMethod = 'accounts.google.com';
              resolve({
                idToken: user.idToken,
                accessToken: user.accessToken,
              });
            } else {
              console.log('user error');
            }
          })
          .catch(err => {
            console.log(err);
          });
      });
    };
    

    getIdentityId

    async function getIdentityId() {
      try {
        await AWSCognitoCredentials.getCredentialsAsync();
        const identity = await AWSCognitoCredentials.getIdentityIDAsync();
        // https://github.com/awslabs/aws-sdk-react-native/search?utf8=%E2%9C%93&q=identityid
        // aws-sdk-react-native return `identity.identityId` for ios, `identity.identityid` for android
        const _identityId = identity.identityId || identity.identityid;
    
        return _identityId;
      } catch (e) {
        console.log('Error: ', e);
      }
    }
    

    onLoginInvoked

    async function onLoginInvoked(isLoggingIn, accessToken, idToken) {
      if (isLoggingIn) {
        globalSupplyLogin = true;
        globalAccessToken = accessToken;
        globalOpenIdToken = idToken;
        const map = {};
    
        map[providers[currentLoginMethod]] = idToken || accessToken;
        AWSCognitoCredentials.setLogins(map); // ignored for iOS
        identityId = await getIdentityId();
        const token = await getOpenIdToken(idToken || accessToken);
    
        return {
          accessToken: globalAccessToken,
          openIdToken: token,
        };
      } else {
        globalSupplyLogin = false;
        globalAccessToken = '';
        globalOpenIdToken = '';
      }
    }
    

    getOpenIdToken

    async function getOpenIdToken(token) {
      const payload = {
        IdentityId: identityId,
        Logins: {
          [currentLoginMethod]: token,
        },
      };
    
      try {
        const rsp = await fetch('https://cognito-identity.ap-northeast-1.amazonaws.com/', {
          method: 'POST',
          headers: new Headers({
            'X-Amz-Target': 'AWSCognitoIdentityService.GetOpenIdToken',
            'Content-Type': 'application/x-amz-json-1.1',
            random: new Date().valueOf(),
            'cache-control': 'no-cache',
          }),
          body: JSON.stringify(payload),
        });
    
        if (!rsp.ok) {
          // rsp.status === 400, ok: false
          // message: "Invalid login token. Token is expired."
          logout();
        } else {
          const json = await rsp.json();
    
          openIdResolve(json.Token);
    
          return json.Token;
        }
      } catch (e) {
        console.log('Error of getOpenIdToken: ', e);
      }
    }
    
  6. 再來定義 init function:
    function init() {
      AWSCognitoCredentials.getLogins = () => {
        if (globalSupplyLogin) {
          const map = {};
    
          map[providers[currentLoginMethod]] = globalOpenIdToken || globalAccessToken;
    
          return map;
        }
    
        return '';
      };
    
      AWSCognitoCredentials.initWithOptions({
        region: REGION,
        identity_pool_id: IDENTITY_POOL_ID,
      });
    
      getCredentials().then(tokens => {
        onLoginInvoked(true, tokens.accessToken, tokens.idToken);
      });
    }
    

    其中必須自行實作 AWSCognitoCredentials.getLogins 的 function。

    然後傳入 region 與 identity_pool_id 以初始化 Amazon Cognito 的 credentials init function。

    最後就是執行上面定義好的 getCredentials function 了。

  7. 再來分別實作 loginFBloginGoogle 兩個 functions:
    function loginFB() {
      return new Promise((resolve, reject) => {
        FBLoginManager.loginWithPermissions(
          ['email', 'user_birthday'],
          async (error, data) => {
            if (!error) {
              const token = data.credentials.token;
    
              currentLoginMethod = 'graph.facebook.com';
              const result = await onLoginInvoked(true, token);
    
              resolve(result);
            } else {
              currentLoginMethod = null;
              reject(error);
            }
          });
      });
    }
    
    async function loginGoogle() {
      const user = await GoogleSignin.signIn().catch(error => {
        console.log('WRONG SIGNIN', err);
        currentLoginMethod = null;
      });
    
      if (user) {
        currentLoginMethod = 'accounts.google.com';
        return await onLoginInvoked(true, user.accessToken, user.idToken);
      }
    
      return;
    }
    
  8. 再來是登出相關的 functions:
    function cleanLoginStatus() {
      AWSCognitoCredentials.clearCredentials();
      AWSCognitoCredentials.clear(); // clear keychain
    
      identityId = null;
      globalSupplyLogin = false;
      globalAccessToken = '';
    
      openIdTokenPromise = new Promise(resolve => {
        openIdResolve = resolve;
      });
    }
    
    function logout() {
      FBLoginManager.logout(error => {
        if (!error) {
          cleanLoginStatus();
        }
      });
    
      GoogleSignin.signOut().then(() => {
        cleanLoginStatus();
      });
    }
    
  9. 最後將這些 functions export 出去,以便呼叫:
    export {
      init,
      loginFB,
      loginGoogle,
      logout,
    };
    
  10. 好了,回到我們的主程式,加上以下部分:將剛剛寫好的程式 import 進來:
    import * as AuthUtils from './utils/auth';
    

    componentWillMount 時,呼叫初始化 init function:

    componentWillMount() {
      AuthUtils.init();
    }
    

    補上初始化的 state:

    state = {
      loggedIn: false,
      isLoading: false,
    };
    

    實作 handleLogin function:

    handleLogin = async (type) => {
      let result = {};
    
      this.setState({
        isLoading: true,
      });
    
      try {
        if (type === 'facebook') {
          result = AuthUtils.loginFB().catch(() => {
        this.setState({
          isLoading: false,
        });
          });
        } else if (type === 'google') {
          result = await AuthUtils.loginGoogle();
        }
      } catch (err) {
        this.setState({
          isLoading: false,
        });
      }
    
      this.setState({
        loggedIn: true,
        isLoading: false,
      });
    }
    

    還有 handleLogout function:

    handleLogout = () => {
      AuthUtils.logout();
    
      this.setState({
        loggedIn: false,
      });
    }
    

    然後在按鈕上加上 onPress 事件,並設定只有在 loggedIn 為 false 的時候才 render 按鈕:

    {!this.state.loggedIn && <View>
        <TouchableOpacity onPress={() => this.handleLogin('facebook')} style={styles.facebookLoginButton}>
          <Text style={styles.facebookLoginButtonText}>Login with Facebook</Text>
        </TouchableOpacity>
    
        <TouchableOpacity onPress={() => this.handleLogin('google')} style={styles.googleLoginButton}>
          <Text style={styles.googleLoginButtonText}>Login with Google</Text>
        </TouchableOpacity>
      </View>}
    

    loggedIn 為 true 的時候顯示登出按鈕:

    {this.state.loggedIn && <View style={styles.welcome}>
        <Text style={styles.welcomeText}>
          You're logged in!
        </Text>
    
        <TouchableOpacity onPress={() => this.handleLogout()} style={styles.logoutButton}>
          <Text style={styles.logoutButtonText}>Logout</Text>
        </TouchableOpacity>
      </View>}
    

    再加上登入中的提示:

    {this.state.isLoading && <View>
        <Text>Logging In...</Text>
      </View>}
    
  11. 大概就是這樣哦,實際登入登出看看吧!

疑難排解

如果沒有辦法順利地達成功能,這邊蒐集了一些可能遇到的錯誤與解法。

在進行 Android 按下 Google 登入按鈕後,一直噴 12501 錯誤

這在 react-native-google-signin 的 FAQ 中也有提到,可前往參閱

請確認兩個部分都有設對:

  1. 請確認是使用 WebClient 的 ID。
  2. 請確認 signing key 是對的,請參考此篇

在 Cognito 登入的那段 trace 到 Invalid login token. Incorrect token audience 的錯誤訊息

通常是 Google 登入時發生,請在 Amazon IAM 設定 accounts.google.comaudiences 加上對應的 client ID,看看是不是少加了 Web Client ID 呢?

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *