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

Standard

用 Amazon Lambda 來做 Serverless API

以上已經成功實作了登入的部份,那以下繼續來介紹註冊\存放會員資料的 API 部份。

這次來用 Amazon Lambda 加上 API Gateway 來實作看看,嘗試看看 Serverless 的解決方案。

註:若有個人偏好與時間的話,也可以用自己慣用的語言\環境實作,至於此節跟下一節就可以跳過啦~

請開另一個 repository 進行開發。

  1. 為了方便,先安裝 lambda 的佈署工具 – claudia
    npm install -g claudia
    
  2. 另外,透過 npm/yarn 安裝一些會用到的套件:
    "aws-sdk": "^2.57.0",
    "axios": "^0.16.1",
    "jsonwebtoken": "^7.4.1",
    "jwk-to-pem": "^1.2.6"
    
  3. 這邊我們一樣用 JavaScript 撰寫,不過 Lambda 目前最新支援到 Node.js 6.10 ,很可惜的像是 async/await 之類的特性尚不支援。

export 出去的東西就會是 lambda 執行的 script 主體了,大概會像是這樣:

exports.handler = (event, context, callback) => {
  // your script
};
  1. 這次要做的 API 目的是註冊\取得會員資料的 API。那麼先理一下思路,我們的流程大概是這樣子的:a. 從 request body 中取得 accessTokenopenIdToken(這 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 的相關資料
  2. 好,就來實作吧!先建立 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();
    
  3. 這次需實作下列 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;
    };
    
  4. 至於程式進入點的判斷邏輯如下:
    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 上啦!

佈署前置作業:

  1. 先至 Amazon IAM,點選左邊的 Users,然後按藍色按鈕的 Add user 以新增一個 User。
  2. 輸入使用者名稱,並勾選 Programmatic access,按下 Next: Permissions
  3. 三個項目中請選擇 Attach existing policies directly,然後在 policy 勾選 AWSLambdaFullAccessIAMFullAccess,最後按下 Next: Review
  4. Create User
  5. 建立完成後,點擊 Download .csv 按鈕以下載這個使用者的憑證資訊,因為之後無法再看到這個資訊,所以建議還是下載保存吧。
  6. 依據憑證資訊的 key id 與 secret access key,設定 AWS 的帳號憑證資訊:
    vim ~/.aws/credentials
    
    [default]
    aws_access_key_id=XXXXXXXXXXXXXXX
    aws_secret_access_key=XXXXXXXXXXXXXXXXXXXXXXX
    

前置作業完成後,開始佈署作業:

  1. 為求方便,定義幾個常用 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 囉。

  2. 執行 lambda:create 佈署完後,可以上到 Amazon Lambda,進入 Functions 頁面,剛剛建立的 function 就在右邊:

    image

  3. 點擊 function 名稱,並切換到 Configuration 頁籤,確認執行身分 (Existing role),待會會用到。以及 Advanced settings 中的 Timeout 秒數,建議可以調整預設的 3 秒,提升到 5 ~ 10 秒以免 API 太快就逾時。
  4. 回到 IAM 頁面,切換至 Roles 頁面,找到剛剛的執行身分,要新增一些權限給它。Managed Policies 的部份請新增 AWSLambdaFullAccessAmazonCognitoDeveloperAuthenticatedIdentities
  5. Inline Policies 的部份,請新增 cognito-identity:*cognito-sync:*
    {
        "Version": "2012-10-17",
        "Statement": [
        {
            "Sid": "Stmt1495873848000",
            "Effect": "Allow",
            "Action": [
            "cognito-sync:*",
            "cognito-identity:*"
            ],
            "Resource": [
            "*"
            ]
        }
        ]
    }
    
  6. 再切回 Lambda 的頁面,function 上方有 Test 的藍色按鈕,我們可以試著填入實際 accessTokenopenIdToken 的值先來測試看看:因為是讀取 request body 的值,所以要放在 body 中,值也是經過 JSON.stringify 的。

    image

    {
        "body": "{\"accessToken\":\"eyXXXXXXXXXXXX.eyXXXXXXXXXXXXX.XXXXXXXXXXXX\",\"openIdToken\":\"eyXXXXXXXXXXXXXX\"}"
    }
    
  7. 無論成功或失敗,都可以到 CloudWatch 去看 log 檔。
  8. 覺得這樣切換視窗實測很煩人的話,推薦安裝 lambda-local,這樣就可以在本地端快速實測了。

Amazon API Gateway

是說 Lambda 設好之後,還是無法讓外界直接訪問,那麼就要透過 Amazon API Gateway 了。(註:也有透過 Cognito 直接觸發的方式,不過本文不討論這塊)

  1. 從主控台進入 API Gateway 之後,請選擇 New API,並起個名字。
  2. 建立完成後,可看到這樣的畫面:

    image

  3. 先建立 Resouce。Resouce 就當做是資料夾路徑吧,例如建立 /register 的路徑這樣。點擊 Actions > Create Resouce,然後輸入 Resouce 的名稱之後按 Create Resouce 藍色按鈕。

    image

  4. 接著建立 Method,在 Actions 中選擇 Create Method。這邊建立的是 POST。觸發的類型是 Lambda Function,並請記得勾選 Use Lambda Proxy integration,這樣才會把 request 的 body 資料轉給 Lambda 使用。接著選擇正確的 Lambda Region 及 Lambda Function。

    image

  5. 再來要佈署才能真的給外面使用。一樣在 Actions,選擇 Deploy API,必須先建立環境。

    image

  6. 建立好之後,會得到一組可對外訪問的網址

    image

    在這組網址之後接上 Resouce 的名稱,再透過設定好的 Method 方式訪問(例如 POST),就能打到先前設定的 Lambda function 了。

    如果需要偵錯,可把 CloudWatch 相關的項目都勾選起來,並賦予相關的角色權限。請參閱此篇教學

  7. 必須注意的是,傳入的 request 必須帶上 header Content-Type:application/json,然後是透過 request.body 傳輸這些資料的。至於輸出的格式也有規定,必須包含 statusCodeheadersbody,不符合格式的話會直接噴 502 錯誤。

發佈留言

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