用 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 錯誤。