最近開始接觸 React Native 的專案了,能用前端技術跟知識同時開發 iOS/Android App 滿新奇的!雖然缺乏原生 App 的知識讓我踩了不少坑,其中不少還是非常低能的坑 XD!
這次實作的任務是要在 App 加上 Google/Facebook 登入的功能,而會員系統是建構在 Amazon Cognito 上面的,再加上 React Native 的相關套件 – react-native-facebook-login 及 react-native-google-signin 來實作 App 中的登入功能。其中有些設定上的眉角讓我花了不少時間嘗試,所以整理成本篇的筆記備忘囉~
本文大綱
本篇文章滿長的,所以先整理大綱在此,了解一下會介紹到的部份:
- 本文的套件版本
- AWS SDK React Native 與 Amazon Cognito
- react-native-facebook-login
- react-native-google-signin
- 繼續未完的 Amazon Cognito 設定
- 前端程式
- 疑難排解
- 用 Amazon Lambda 來做 Serverless API
- Amazon API Gateway
- 在 App 端補上註冊(register)的 function
- Source Code
- 參考資料
本文的套件版本
由於這是個升版可能就變天的世代,所以開始之前先將相關版本列於下方:
- react-native 0.44
- aws-sdk-react-native (5c9f30a21acdb0d50d3f5eee6e78b584e3419ea4)
- react-native-facebook-login 1.5.0
- react-native-google-signin 0.10.0
AWS SDK React Native 與 Amazon Cognito
首先,要建構會員系統的話,依過去的經驗,有很多部分要處理,像是寫會員註冊、忘記密碼、認證、更新資料等後端程式,還要建會員資料庫、思考怎麼跟第三方社群登入(像是本文主要目的的 Google、Facebook 登入)串接、怎麼讓桌機與手機都能 work… 等一拖拉庫的事情。雖然要一個個自己來也是可以,但像前端工程師如我,可能就要花上不少時間了,也有時間成本的考量,而 Amazon Cognito 提供了一套不錯的跨裝置會員系統解決方案。
Amazon Cognito 讓您輕鬆新增使用者註冊與登入功能到行動應用程式與 Web 應用程式。使用 Amazon Cognito,您也可以選擇透過 Facebook、Twitter 或 Amazon 等社交身分供應商、利用 SAML 身分解決方案,或使用自己的身分系統來驗證使用者。除此之外,Amazon Cognito 可讓您將資料儲存在使用者的本機裝置,這允許應用程式即使在裝置離線時也能運作。然後,您可以同步使用者不同裝置之間的資料,無論他們使用哪部裝置,都能獲得一致的應用程式體驗。
而此次的任務就是在 React Native 的架構下要導入以 Cognito 為基礎的社群會員(Google 與 Facebook)登入流程,當然也可以選用非第三方的身分認證,像是手機、E-mail 認證等方式,不過就不在本文介紹範圍中了。首先,假設我們已經設定好一個基本的 React Native 的專案了,接下來就要安裝相關的套件 – aws-sdk-react-native。
註:其實在本文撰寫過程中,官方就更新 aws-sdk-react-native 的說明了,是說官方比較推薦去使用 AWS JavaScript SDK with React Native support,不過本文還是維持原本使用的 aws-sdk-react-native 這套進行實作,因為這套實在太多坑啦!就算看了說明檔還是看不懂怎麼使用!😱
以下流程主要是參考 @chunghe 在該 repo Issues 貼出的 detail step-by-step guide 進行步驟:
打包
-
首先要做的是自己打包(pack)一份
aws-sdk-react-native,請先下載 AWS Mobile iOS SDK 2.4.16 版(官網下載頁目前放上的 iOS 2.5+ 版跟本文流程不相容),然後解壓縮 -
然後把 aws-sdk-react-native 專案下載下來,可以用
git clone https://github.com/awslabs/aws-sdk-react-native.git -
把步驟 1 中解壓縮的資料夾打開,進入
frameworks資料夾,找到AWSCore.framework,將它複製到aws-sdk-react-native資料夾中的Core/src/ios/Frameworks/資料夾底下 -
然後
aws-sdk-react-native/Core/src/AWSCognitoCredentials.js有個 JavaScript bug 😫 打包前要自己手動修正。請將第 48 行的:listener.addListener("LoginsRequestedEvent", async {callbackId} => {改為
listener.addListener("LoginsRequestedEvent", async ({callbackId}) => { -
接著進入
aws-sdk-react-native/Core/,鍵入以下指令進行打包(pack):npm pack -
好了,打包完就有個名為
aws-sdk-react-native.tgz的打包檔了,沒有操作錯誤的話,它大概有8.3MB。接下來把它 copy 到專案目錄下並改個名字吧,我是放在專案目錄/aws-sdk-react-native/底下,並更名為aws-sdk-react-native-core-0.0.2-with-awscore-framework.tgz。
安裝到專案中
-
透過
yarn安裝到專案中:yarn add ./aws-sdk-react-native/aws-sdk-react-native-core-0.0.2-with-awscore-framework.tgz -
然後 link 它:
react-native link aws-sdk-react-native-core -
打開專案的 Xcode 檔案(在
ios/底下可能是xxx.news.xcodeproj,有用 Pods 的話就是xxx.xcworkspace),確認Targets是目前的專案名稱:
然後切換到 General 的頁籤,往下捲動直到找到 Embedded Binaries,按下下方的
+按鈕。 -
在新跳出的視窗中,點擊左下角的
Add Other...,尋找專案目錄底下node_modules/aws-sdk-native-core/ios/Frameworks/中的AWSCore.framework檔案,選定之後按下Open。 -
在下一個跳出的視窗中,請確認 不要勾選
Destination: Copy items if needed的選項,因為這個檔案已經在專案目錄的node_modules/aws-sdk-native-core/ios/Frameworks/目錄底下囉。好了之後請按下Finish。
-
操作無誤的話會看到下圖這樣:

-
接下來請切換到 Build Phases 頁籤,並點擊左上角的
+按鈕,選擇New Run Script Phase。
-
請確認新增的
Run Script區塊是在Embed Frameworks區塊之下的,不是的話請拖曳改變順序。然後展開Run Script,確認Shell是/bin/sh,接著在下方文字框輸入:bash "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/AWSCore.framework/strip-frameworks.sh"接著要確認下面四個部分
Show environment variables in build log已勾選、Run script only when installing未勾選、Input Files與Output Files都是空的。如圖:
-
最後,請切換至
Build Settings頁籤,找到Search Paths底下的Framework Search Paths(如果覺得選項太多不好找,可利用右上角的搜尋框),加入$(SRCROOT)/../node_modules/aws-sdk-react-native-core/ios/Frameworks。
好了,這樣套件就安裝完成了,可以試著 Build 看看,若沒有遇到 Fail 的話應該就是沒問題的。
Amazon Cognito 設定
假設已經申請好 AWS 的帳號了,請連到 Amazon Cognito 開始接下來的步驟:
- 到首頁請選擇「開始使用 Amazon Cognito」,連過去之後又有兩顆按鈕,因為這次的任務是要用第三方社群身分驗證進行登入,所以選擇右邊的 Manage Federated Identities。
- 點擊
Create new identity pool建立新的身分池。 - 給這個 pool 一個名字吧,例如
My App。然後展開Authentication providers,可以看到各式各樣的 providers,本例要設定的是 Facebook 與 Google,但是請 注意!若你有多個平台需要使用同一組 Google 身分,在 Google 的部份請透過 OpenId 進行設定喔!否則就會在不同平台登入的同一組 Google 帳號(例如 Web 跟 iOS 裝置登入的 Google 帳號)會被視為不同的身分! - 由於下面介紹
react-native-facebook-login及react-native-google-signin時,會介紹 Facebook 與 Google 應用程式的設定步驟,因此這邊就先跳過,只要知道之後會回來這邊設定 Facebook 與 OpenID 的部份即可。先留著這個視窗,晚點回來繼續完成。
react-native-facebook-login
-
安裝
react-native-facebook-loginyarn add react-native-facebook-login -
打開套件目錄
open node_modules/react-native-facebook-login
iOS
-
在 Xcode 中,把步驟 2 中的
RCTFBLogin.xcodeproj檔案拖曳到專案中的Libraries目錄。
-
確定
Target是專案本身、而非react-native-facebook-login套件,接著切換到Build Phases頁籤,展開Link Binary With Libraries,點擊下方的+按鈕,鍵入libRCTFBLogin.a找到它,加入。
-
接下來要加入 Facebook SDK 到 Xcode 專案中。在專案目錄下透過
open node_modules/react-native-facebook-login/FacebookSDK指令打開FacebookSDK資料夾。 -
確定在 Project Navigator 項目(如圖)中:

然後在專案名稱上點右鍵,選擇
New Group,並輸入Frameworks(若已經有此資料夾的話,此步驟可省略)。 -
在步驟 5 中打開的資料夾中,找到
FBSDKCoreKit.framework、FBSDKLoginKit.framework與FBSDKShareKit.framework三個檔案,將他們拖曳到步驟 6 中建立的Frameworks目錄中。
-
切換到
Build Settings頁籤,捲動尋找Search Paths中的Framework Search Paths,加入$(SRCROOT)/../node_modules/react-native-facebook-login/FacebookSDK
-
接著編輯
ios/專案名稱/AppDelegate.m,在#import "AppDelegate.h"之下,加入:#import <FBSDKCoreKit/FBSDKCoreKit.h> #import <FBSDKLoginKit/FBSDKLoginKit.h> -
往下找到
didFinishLaunchingWithOptions這個 function,將最後return YES;的部份改為
return [[FBSDKApplicationDelegate sharedInstance] application:application didFinishLaunchingWithOptions:launchOptions]; -
然後在
didFinishLaunchingWithOptions這個 function 結束後,加上:// Facebook SDK - (void)applicationDidBecomeActive:(UIApplication *)application { [FBSDKAppEvents activateApp]; } - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation: (id)annotation { return [[FBSDKApplicationDelegate sharedInstance] application:application openURL:url sourceApplication:sourceApplication annotation:annotation]; } -
以上 FacebookSDK iOS 的安裝完畢,還剩一些設定 iOS 的部份就完成囉。接下來要進行 Facebook 應用程式相關的設定,先去 Facebook Apps – Quick Start for iOS,建立或是選擇既有的應用程式。在 Facebook 應用程式設定過程中,需要填入 bundle ID,可在
General中找到:
-
接著以該應用程式的資訊,請編輯
ios/專案名稱/Info.plist,在第一層<dict></dict>之中,適當的區塊加上:<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLSchemes</key> <array> <string>fb{your-app-id}</string> </array> </dict> </array> <key>FacebookAppID</key> <string>{your-app-id}</string> <key>FacebookDisplayName</key> <string>{your-app-name}</string> <key>LSApplicationQueriesSchemes</key> <array> <string>fbapi</string> <string>fb-messenger-api</string> <string>fbauth2</string> <string>fbshareextension</string> </array>其中
{your-app-id}是 Facebook 應用程式 ID、{your-app-name}則是 Facebook 應用程式名稱。請依據實際情況填入。
好了,接下來是 Android 的部份。
Android
-
請至 Facebook Apps – Quickstart for Android 完成設定。過程中需填入 package name,可在
AndroidManifest.xml中找到:
Key Hashes 則可透過步驟中的提示,使用
keytool指令找到。 -
確定已經透過
yarn或npm安裝了react-native-facebook-login。 -
編輯
android/settings.gradle,加入以下內容:include ':react-native-facebook-login' project(':react-native-facebook-login').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-facebook-login/android') -
編輯
android/app/build.gradle,在dependencies { }區塊中加入以下內容:compile project(':react-native-facebook-login') -
編輯
android/app/src/main/java/com/專案名稱/MainApplication.java,在上方 import 處加上:import com.magus.fblogin.FacebookLoginPackage;然後在
new MainReactPackage(),之後,加入new FacebookLoginPackage() -
編輯
android/app/src/main/res/values/strings.xml(字串變數檔,若想要用 react-native-config 也是可以的),加入以下內容:<resources> <string name="app_name">{Your_App_Name}</string> <string name="fb_app_id">{FB_APP_ID}</string> <string name="fb_login_protocol_scheme">fb{FB_APP_ID}</string> </resources>其中
{Your_App_Name}、{FB_APP_ID}、fb{FB_APP_ID}請依據實際情況填入。 -
編輯
android/app/src/main/AndroidManifest.xml,在<application></application>區塊中,加入以下內容:<!--add FacebookActivity--> <activity tools:replace="android:theme" android:name="com.facebook.FacebookActivity" android:configChanges="keyboard|keyboardHidden|screenLayout|screenSize|orientation" android:label="@string/app_name" android:theme="@android:style/Theme.Translucent.NoTitleBar"/> <!--add CustomTabActivity--> <activity android:name="com.facebook.CustomTabActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="@string/fb_login_protocol_scheme" /> </intent-filter> </activity> <!--reference your fb_app_id--> <meta-data android:name="com.facebook.sdk.ApplicationId" android:value="@string/fb_app_id"/>這邊的
@string/變數會去讀在步驟 6 中設定的字串。
終於 Android 的部份也設定完成囉!
緊接著我們來設定 react-native-google-signin!
react-native-google-signin
-
安裝
yarn add react-native-google-signin -
Link
react-native link react-native-google-signin
iOS
-
打開
node_modules/react-native-google-signin/,找到RNGoogleSignin.xcodeproj檔案,將它複製到 Xcode 專案的Libraries目錄底下。
-
切換至
Build Phases頁籤,找到Link Binary With Libraries展開它,點擊+並加入libRNGoogleSignin.a、AddressBook.framework、SafariServices.framework、SystemConfiguration.framework及libz.tbd。 -
一樣在
node_modules/react-native-google-signin/目錄下找到ios/GoogleSdk/,將它複製到 Xcode 專案下。(在跳出的視窗中請勾選Copy items if needed,以及確定Added folders為Create groups)

-
接著在
Link Binary With Libraries,一樣點擊+加入 GoogleSdk 的檔案(若沒有自動被加入的話),完成如圖:
-
連至 Add Google Services | Google Developers,往下捲動找到
Get a configuration file按鈕,點擊它。 -
設定 Google 應用程式。

-
啟用 Google Sign-in 功能,請依據實際情況填入 build ID。

-
最後就可以下載
GoogleService-Info.plist
-
打開剛下載的
GoogleService-Info.plist,找到REVERSED_CLIENT_ID與bundle id的值,在Info頁籤最下方的URL Types新增兩組分別填入:
-
接著編輯
ios/專案名稱/AppDelegate.m,在#import "AppDelegate.h"之後加入(如果發現已經在 link 階段加入過的話,請略過此步驟):#import <RNGoogleSignin/RNGoogleSignin.h> -
然後在
react-native-facebook-signin的步驟中曾經在下方加入過openURL的 function,請將最後return的那行改為:return [[FBSDKApplicationDelegate sharedInstance] application:application openURL:url sourceApplication:sourceApplication annotation:annotation ] || [RNGoogleSignin application:application openURL:url sourceApplication:sourceApplication annotation:annotation ];
再來換 Android 的設定。
Android
-
請確定 Android SDK 已經安裝這些套件:

-
打開 Start Integrating Google Sign-In into Your Android App,往下捲動找到
Get a configuration file,點擊前往 Google 應用程式設定。 -
類似 iOS 的步驟,請依據實際情況選擇應用程式名稱、填入 package name。

-
填入 Android 簽名憑證的 SHA-1:

若不知道怎麼找到這串碼的話,請參考 Authenticating Your Client。其中簽名金鑰的位置請依據實際情況填入。
-
最後就可以下載
google-services.json了,請將它放到專案目錄底下的android/app/目錄中。
-
編輯
android/settings.gradle,檢查是否有下方的 code,若無請加入。include ':react-native-google-signin', ':app' project(':react-native-google-signin').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-google-signin/android') -
編輯
android/build.gradle,在dependencies區塊中加入:classpath 'com.google.gms:google-services:3.0.0' -
編輯
android/app/build.gradle,請確認是否有下方的 code:在dependencies區塊中:compile(project(":react-native-google-signin")){ // 若已有此行,但沒有 exclude group 請自行加入 exclude group: "com.google.android.gms" // 務必加入 } compile 'com.google.android.gms:play-services-auth:10.0.1' // 至少需要 9.0.0 版本在
dependencies區塊後,整個檔案最後一行加入:apply plugin: 'com.google.gms.google-services' -
編輯
MainApplication.java,檢查是否有以下 code:import co.apptailor.googlesignin.RNGoogleSigninPackage;然後在
new MainReactPackage(),下方是否有:new RNGoogleSigninPackage() -
確認
android/gradle/wrapper/gradle-wrapper.properties中是否有此行:distributionUrl=https\://services.gradle.org/distributions/gradle-2.14-all.zip
這部份的設定大概就到這邊結束了。
繼續未完的 Amazon Cognito 設定
在設定 react-native-facebook-login 與 react-native-google-signin 的過程中,我們一併把 Facebook & Google 應用程式設定好了,現在我們回過頭來設定之前未設定完畢的部份。
-
先來看 OpenID 的部份,切到 OpenID 頁籤會發現什麼也沒有:

是說 OpenID Provider 必須在 Amazon IAM 中進行設定。
-
IAM 是 Identity and Access Management 的縮寫,進到服務首頁後點擊 開始使用 Amazon IAM。
-
在左方選項中點選 Identity providers,再點選 Create Provider。

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

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

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

-
回到 IAM 編輯剛剛的
accounts.google.com,把所有audience加入。
前端程式
這邊的前端程式是指 React Native 的程式碼,本文同時會用它進行 iOS 與 Android 的實作。
主要流程可參考 https://github.com/awslabs/aws-sdk-react-native/tree/master/Core/example 這邊的範例程式碼。
以下分段介紹:
-
先準備好基本 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;註:本例中我們不打算用套件提供的按鈕。
-
再來新開一個檔案
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'; -
接著宣告幾個常數,比較好的作法是將它們放在環境變數中,或是透過 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_ID與GOOGLE_SIGNIN_WEBCLIENT_ID可以從 Google Developers Console 中找到,在憑證那一頁。由於 Android 上面用的是 Web Client 的方式,所以請不要複製錯了哦。providers是我們這次會用到的 providers,這邊先定義好對應的名稱,之後會用到。 -
宣告其他變數
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; }); -
接著我們要定義幾個主要的 function:
getCredentials、fbGetCredentials、googleGetCredentials、getIdentityId、onLoginInvoked、getOpenIdToken。登入流程中,會先依據設定(例如 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()]);fbGetCredentials與googleGetCredentials: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); } } -
再來定義
initfunction: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。
最後就是執行上面定義好的
getCredentialsfunction 了。 -
再來分別實作
loginFB與loginGoogle兩個 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; } -
再來是登出相關的 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(); }); } -
最後將這些 functions export 出去,以便呼叫:
export { init, loginFB, loginGoogle, logout, }; -
好了,回到我們的主程式,加上以下部分:將剛剛寫好的程式 import 進來:
import * as AuthUtils from './utils/auth';在
componentWillMount時,呼叫初始化initfunction:componentWillMount() { AuthUtils.init(); }補上初始化的 state:
state = { loggedIn: false, isLoading: false, };實作
handleLoginfunction: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, }); }還有
handleLogoutfunction: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>} -
大概就是這樣哦,實際登入登出看看吧!
疑難排解
如果沒有辦法順利地達成功能,這邊蒐集了一些可能遇到的錯誤與解法。
在進行 Android 按下 Google 登入按鈕後,一直噴 12501 錯誤
這在 react-native-google-signin 的 FAQ 中也有提到,可前往參閱。
請確認兩個部分都有設對:
- 請確認是使用 WebClient 的 ID。
- 請確認 signing key 是對的,請參考此篇。
在 Cognito 登入的那段 trace 到 Invalid login token. Incorrect token audience 的錯誤訊息
通常是 Google 登入時發生,請在 Amazon IAM 設定 accounts.google.com 的 audiences 加上對應的 client ID,看看是不是少加了 Web Client ID 呢?
用 Amazon Lambda 來做 Serverless API
以上已經成功實作了登入的部份,那以下繼續來介紹註冊\存放會員資料的 API 部份。
這次來用 Amazon Lambda 加上 API Gateway 來實作看看,嘗試看看 Serverless 的解決方案。
註:若有個人偏好與時間的話,也可以用自己慣用的語言\環境實作,至於此節跟下一節就可以跳過啦~
請開另一個 repository 進行開發。
-
為了方便,先安裝 lambda 的佈署工具 – claudia
npm install -g claudia -
另外,透過
npm/yarn安裝一些會用到的套件:"aws-sdk": "^2.57.0", "axios": "^0.16.1", "jsonwebtoken": "^7.4.1", "jwk-to-pem": "^1.2.6" -
這邊我們一樣用 JavaScript 撰寫,不過 Lambda 目前最新支援到 Node.js 6.10 ,很可惜的像是
async/await之類的特性尚不支援。
export 出去的東西就會是 lambda 執行的 script 主體了,大概會像是這樣:
exports.handler = (event, context, callback) => {
// your script
};
-
這次要做的 API 目的是註冊\取得會員資料的 API。那麼先理一下思路,我們的流程大概是這樣子的:a. 從 request body 中取得
accessToken及openIdToken(這 request 以本例而言就是在 App 點擊按鈕後發送過來的)
b. 驗證並解開openIdToken
c. 自解開的openIdToken中得知 provider 類型,以及該 user 的 Identity ID
d. 得知 provider 類型後,使用accessToken取回 user 的資料(name、email 等資訊)
e. 存入\更新 Cognito 的 Identity pool 中,相應的 Identity ID 裡面的附加資料集(Datasets)中
f. API 輸出成功與否,以及 user 的相關資料 -
好,就來實作吧!先建立
index.js:引入套件,並設置好相關的參數,以及初始化相關物件。
const jwt = require('jsonwebtoken'); const axios = require('axios'); const jwkToPem = require('jwk-to-pem'); const AWS = require('aws-sdk'); const IDENTITY_POOLID = 'ap-northeast-1:4e86b831-da7f-47d5-8382-3d800cd28a25'; const COGNITO_DATASET_NAME = 'userData'; AWS.config.region = 'ap-northeast-1'; const cognitoidentity = new AWS.CognitoIdentity(); const cognitosync = new AWS.CognitoSync(); -
這次需實作下列 functions:
downloadKey– 下載 Cognito Identity JWT Public key,以便在後續解開 JWT 內容validateToken– 驗證並解開 JWT 內容retrieveProfile– 依據 provider,使用 access token 取回 user 的相關資料listData– 列出目前該 Identity ID 存放的資料內容addData– 更新該 Identity ID 的資料內容
downloadKey:const downloadKey = () => { return new Promise((resolve, reject) => { const url = 'https://cognito-identity.amazonaws.com/.well-known/jwks_uri'; // Download the JWKs and save it as PEM axios.get(url).then(response => { if (response.status === 200) { const pems = {}; const keys = response.data.keys; for (let i = 0; i < keys.length; i++) { // Convert each key to PEM const key_id = keys[i].kid; const modulus = keys[i].n; const exponent = keys[i].e; const key_type = keys[i].kty; const jwk = { kty: key_type, n: modulus, e: exponent, }; const pem = jwkToPem(jwk); pems[key_id] = pem; } resolve(pems); } }).catch(error => { console.log(error, 'error'); reject(error); }); }); };validateToken:const validateToken = (pems, event, context) => { return new Promise((resolve, reject) => { const iss = 'https://cognito-identity.amazonaws.com'; const token = JSON.parse(event.body).openIdToken; // Fail if the token is not jwt const decodedJwt = jwt.decode(token, { complete: true }); console.log(decodedJwt, 'jwt'); if (!decodedJwt) { reject('Not a valid JWT token'); return; } // Fail if token is not from your User Pool if (decodedJwt.payload.iss !== iss) { reject('invalid issuer'); return; } // Get the kid from the token and retrieve corresponding PEM const kid = decodedJwt.header.kid; const pem = pems[kid]; if (!pem) { reject('Invalid access token'); return; } // Verify the signature of the JWT token to ensure it's really coming from your User Pool jwt.verify(token, pem, { issuer: iss }, (err, payload) => { if (err) { reject('Unauthorized'); return; } else { const principalId = payload.sub; const provider = payload.amr && payload.amr[1]; const accessToken = JSON.parse(event.body).accessToken; console.log(payload, 'payload'); console.log(accessToken, 'accessToken'); if (!principalId || !provider || !accessToken) { reject('Wrong token or no access token.'); } else { // Using access token to retrieve user profile and save it with identityId in aws cognito resolve({ principalId, provider, accessToken }); } return; } }); }); };retrieveProfile:const retrieveProfile = (provider, accessToken) => { console.log(provider, accessToken); return new Promise((resolve, reject) => { if (provider === 'accounts.google.com') { axios.get(`https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=${accessToken}`) .then(response => { if (response.status === 200) { const profile = response.data; resolve({ name: profile.name, email: profile.email, }); } else { reject(response.status); } }) .catch(error => { reject(`Retrieve google user data failed! ${error}`); }); } else if (provider === 'graph.facebook.com') { axios.get(`https://graph.facebook.com/v2.9/me?fields=id%2Cname%2Cbirthday%2Cgender&access_token=${accessToken}`) .then(response => { if (response.status === 200) { const profile = response.data; resolve({ name: profile.name, facebookId: profile.id, birthday: profile.birthday, gender: profile.gender, email: profile.email, }); } else { reject(response.status); } }) .catch(error => { reject('error'); }); } else { reject('Unknown provider'); } }); };listData:const listData = ({ identityId, profile }) => { return new Promise(resolve => { if (!identityId) { resolve({ success: false }); return; } cognitosync.listRecords({ DatasetName: COGNITO_DATASET_NAME, IdentityId: identityId, IdentityPoolId: IDENTITY_POOLID }, (err, res) => { if (err) { console.log('err', err); resolve({ success: false }); return; } console.log('res', res); console.log('parsedData', parseData(res.Records)); resolve({ data: res, identityId, profile }); }); }); };addData:const addData = ({ data, identityId, profile }) => { return new Promise(resolve => { if (!identityId) { resolve({ success: false, }); return; } const params = { DatasetName: COGNITO_DATASET_NAME, IdentityId: identityId, IdentityPoolId: IDENTITY_POOLID, SyncSessionToken: data.SyncSessionToken, RecordPatches: [{ Key: 'facebookId', Op: 'replace', SyncCount: data.DatasetSyncCount, Value: profile.id, }, { Key: 'name', Op: 'replace', SyncCount: data.DatasetSyncCount, Value: profile.name, }, { Key: 'gender', Op: 'replace', SyncCount: data.DatasetSyncCount, Value: profile.gender, }, { Key: 'email', Op: 'replace', SyncCount: data.DatasetSyncCount, Value: profile.email, }, { Key: 'birthday', Op: 'replace', SyncCount: data.DatasetSyncCount, Value: profile.birthday, }], }; cognitosync.updateRecords(params, (err, data) => { if (err) { resolve({ success: false }); return; } resolve({ userData: parseData(data.Records), success: true, identityId, }); }); }); }最後還有一個 utils function,幫助我們把個人資料物件轉成資料集的格式:
parseData:const parseData = (rec) => { const obj = {}; rec.forEach(r => { obj[r.Key] = r.Value; }); return obj; }; -
至於程式進入點的判斷邏輯如下:
exports.handler = (event, context) => { const query = event || {}; const bodyData = JSON.parse(event.body); if (!bodyData.accessToken || !bodyData.openIdToken) { const result = { statusCode: 500, headers: {}, body: JSON.stringify({ error: 'Please input accessToken & openIdToken completely.' }), }; context.fail(result); } // Download Cognito's JWT first downloadKey().then(pems => { // Validate token validateToken(pems, event, context) .then(response => { const provider = response.provider; const accessToken = response.accessToken; const principalId = response.principalId; console.log(response, 'response'); retrieveProfile(provider, accessToken) .then(profile => { console.log(profile); listData({ identityId: principalId, profile, }) .then(addData) .then(res => { const result = { statusCode: 200, headers: {}, body: JSON.stringify(res), }; context.succeed(result); }); }) .catch(error => { console.log('retrieve profile error', error); const result = { statusCode: 500, headers: {}, body: JSON.stringify({ error }), }; context.fail(result); }); }) .catch(error => { console.log('validate token error', error); const result = { statusCode: 401, headers: {}, body: JSON.stringify({ error }), }; context.fail(result); }); }).catch(error => { console.log('download key error', error); const result = { statusCode: 500, headers: {}, body: JSON.stringify({ error }), }; context.fail(result); }); };
這邊差不多就完成了程式主體,剩下就是 deploy 到 Amazon Lambda 上啦!
佈署前置作業:
- 先至 Amazon IAM,點選左邊的 Users,然後按藍色按鈕的 Add user 以新增一個 User。
- 輸入使用者名稱,並勾選
Programmatic access,按下Next: Permissions。 - 三個項目中請選擇
Attach existing policies directly,然後在 policy 勾選AWSLambdaFullAccess與IAMFullAccess,最後按下Next: Review。 Create User- 建立完成後,點擊
Download .csv按鈕以下載這個使用者的憑證資訊,因為之後無法再看到這個資訊,所以建議還是下載保存吧。 - 依據憑證資訊的 key id 與 secret access key,設定 AWS 的帳號憑證資訊:
vim ~/.aws/credentials[default] aws_access_key_id=XXXXXXXXXXXXXXX aws_secret_access_key=XXXXXXXXXXXXXXXXXXXXXXX
前置作業完成後,開始佈署作業:
-
為求方便,定義幾個常用 scripts 在
packages.json中:"scripts": { "lambda:create": "claudia create --name cognitoLoginExample --region ap-northeast-1 --handler index.handler", "lambda:update": "claudia update", "lambda:destroy": "claudia destroy" }其中
lambda:create的部份就可以建立並佈署 code 到 lambda 上,其中的參數請參閱該 repo 的說明。更新的話透過
lambda:update即可,至於刪除就是lambda:destroy囉。 -
執行
lambda:create佈署完後,可以上到 Amazon Lambda,進入 Functions 頁面,剛剛建立的 function 就在右邊:
-
點擊 function 名稱,並切換到
Configuration頁籤,確認執行身分 (Existing role),待會會用到。以及 Advanced settings 中的 Timeout 秒數,建議可以調整預設的 3 秒,提升到 5 ~ 10 秒以免 API 太快就逾時。 -
回到 IAM 頁面,切換至 Roles 頁面,找到剛剛的執行身分,要新增一些權限給它。Managed Policies 的部份請新增
AWSLambdaFullAccess與AmazonCognitoDeveloperAuthenticatedIdentities。 -
Inline Policies 的部份,請新增
cognito-identity:*與cognito-sync:*。{ "Version": "2012-10-17", "Statement": [ { "Sid": "Stmt1495873848000", "Effect": "Allow", "Action": [ "cognito-sync:*", "cognito-identity:*" ], "Resource": [ "*" ] } ] } -
再切回 Lambda 的頁面,function 上方有 Test 的藍色按鈕,我們可以試著填入實際
accessToken與openIdToken的值先來測試看看:因為是讀取 request body 的值,所以要放在 body 中,值也是經過JSON.stringify的。
{ "body": "{\"accessToken\":\"eyXXXXXXXXXXXX.eyXXXXXXXXXXXXX.XXXXXXXXXXXX\",\"openIdToken\":\"eyXXXXXXXXXXXXXX\"}" } -
無論成功或失敗,都可以到 CloudWatch 去看 log 檔。
-
覺得這樣切換視窗實測很煩人的話,推薦安裝 lambda-local,這樣就可以在本地端快速實測了。
Amazon API Gateway
是說 Lambda 設好之後,還是無法讓外界直接訪問,那麼就要透過 Amazon API Gateway 了。(註:也有透過 Cognito 直接觸發的方式,不過本文不討論這塊)
-
從主控台進入 API Gateway 之後,請選擇
New API,並起個名字。 -
建立完成後,可看到這樣的畫面:

-
先建立 Resouce。Resouce 就當做是資料夾路徑吧,例如建立
/register的路徑這樣。點擊Actions>Create Resouce,然後輸入 Resouce 的名稱之後按Create Resouce藍色按鈕。
-
接著建立 Method,在
Actions中選擇Create Method。這邊建立的是POST。觸發的類型是Lambda Function,並請記得勾選Use Lambda Proxy integration,這樣才會把 request 的 body 資料轉給 Lambda 使用。接著選擇正確的 Lambda Region 及 Lambda Function。
-
再來要佈署才能真的給外面使用。一樣在
Actions,選擇Deploy API,必須先建立環境。
-
建立好之後,會得到一組可對外訪問的網址

在這組網址之後接上 Resouce 的名稱,再透過設定好的 Method 方式訪問(例如
POST),就能打到先前設定的 Lambda function 了。如果需要偵錯,可把 CloudWatch 相關的項目都勾選起來,並賦予相關的角色權限。請參閱此篇教學。
-
必須注意的是,傳入的 request 必須帶上 header
Content-Type:application/json,然後是透過 request.body 傳輸這些資料的。至於輸出的格式也有規定,必須包含statusCode、headers與body,不符合格式的話會直接噴 502 錯誤。
在 App 端補上註冊(register)的 function
上面忙了這麼一大段設定,為的就是讓 App 用戶在點擊登入按鈕後,可以將取得的身分資料存入 Identity ID 的 Datasets 中,並能夠在登入後取回並顯示使用者名稱。
-
先在
utils/auth.js補上registerfunction:async function register(passedInToken) { const openIdToken = passedInToken || (await openIdTokenPromise); const payload = { accessToken: globalAccessToken, openIdToken, }; try { const rsp = await fetch(`${剛剛的 API Gateway 網址}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); const json = await rsp.json(); if (json.statusCode === 401) { const newToken = await refreshToken(); // 這邊礙於篇幅略過不提,請直接見 repo source code return await register(newToken); } return json.userData; } catch (e) { console.log('Error: ', e); } }並一樣請加入到 export 中:
export { init, loginFB, loginGoogle, logout, register, // here }; -
在
app.js中:補上初始化的profilestate:state = { loggedIn: false, isLoading: false, profile: {}, };render的部份補上使用者登入後的資訊(name):{loggedIn && <View style={styles.welcome}> <Text style={styles.welcomeText}> Hello {profile.name}!{"\n"} You're logged in! </Text> <TouchableOpacity onPress={() => this.handleLogout()} style={styles.logoutButton}> <Text style={styles.logoutButtonText}>Logout</Text> </TouchableOpacity> </View>}修改
handleLogin,加上 register 與設定profile的部份: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, }); } if (result) { const profile = await AuthUtils.register(); this.setState({ loggedIn: true, isLoading: false, profile, }); } else { this.setState({ isLoading: false, profile: {}, }); } }
至此就完成這個 prototype 啦!附個完成圖:

Source Code
完整的程式碼請見我的 GitHub,只是概念驗證,code 寫的髒髒請不要見怪 😅😅😅
- React Native Cognito Login Example – 用 React Native 寫的登入範例
- Cognito Login Example API (with AWS Lambda) – 用 Lambda 寫的 API