社内で運用するオウンドメディア開発にあたり、Jamstack構成でのプレビュー機能の開発で得た知見などをまとめておきます。構成はNext.js + Contentfulで、ホスティングサービスはAmplify Consoleを使っていますが、上記スタックに特有の機能を使ったとかではないので、他の構成でも使えるかと思います。
最初にざっくりまとめ
- プレビューデータを取得するためのAPIをサーバレス構成で作成した
- APIはAPI Gateway + Lambdaで作成し、リソース構築にはServerless Frameworkを利用した
- APIの認可処理にはCognitoを利用した
Jamstackでのプレビュー機能の設計検討
ブログやオウンドメディアをJamstack構成で構築する際、基本的に各ページは事前ビルドしておき静的ページとして配信するSSGをベースに開発することになると思います。
ただ、執筆中の記事を確認する機能であるプレビュー機能については、SSGでは不可能ではないもののいまいち使い勝手が悪いものになりがちです。プレビューを確認するたびにアプリのビルドを待たなければならず、装飾などを確認したいだけなのに数分待たされるのはユーザーからすると体験は最悪です。いくら社内メンバーしか使わないといっても、不満が噴出すること間違いなしです。
SSRを使って解決する?
解決策の一つとして、プレビューページについてはSSRやクライアント側からリクエストで動的に内容を取得する方法があります。
Next.jsを使うならば、Vercelにデプロイするのが一番簡単にSSRを利用できて、手間もかからない方法です。
ただ、どんなケースでもVercelを使えるというわけではなく、弊社のケースではインフラ構成は基本的にAWSに寄せているため、今回構築するオウンドメディアについてもAmplify Consoleに乗せたいという要件がありました。
現時点ではAmplify Consoleは公式にSSRの利用をサポートしておらず(開発はしているようです: https://github.com/aws-amplify/amplify-js/issues/5435 )、うまいことやれば似たようなことはやれるのかもしれませんが、運用面を考えるとあまりハック的なことはやりたくありません。
上記の理由から、今回はSSRは採用しない方針となりました。
クライアント側から直接Contentfulなどを叩く?
クライアント側からプレビューデータを取得する方法では、ContentfulやmicroCMSのアクセスキーがブラウザに渡ってしまうという問題があります。Contentfulの場合はDelivery keyとManagement keyが分かれていて、Delivery keyが漏洩してもデータが変更されたり、破壊されたりという被害は起きないはずですが、たとえば大量のリクエストを送信してrate limitに到達させるといった悪用方法は考えられます。
Next.jsはデフォルトでバンドルファイルのチャンクをサポートしてくれるので、該当するファイルが別のファイル内から参照されない限りはチャンク後のファイルはダウンロードしないで済みます。そのため、プレビューページへのリンクをどこからも貼らないようにすれば、ブラウザにアクセスキーが渡ってしまうという問題は回避できます。
とはいえ、こういったリポジトリ特有の制約はできれば作りたくないですし、サーバーにjsファイル自体は配置されるため、URLを直接叩かれた場合は結局アクセキーの漏洩につながってしまいます(可能性自体は低いとはいえ)
上記のような懸念点が生まれるのはそもそもクライアント側だけで問題を解決しようとしているためで、素直にプレビューデータを返却するAPIを作れば万事解決するのではないかということで、今回の開発においてはプレビューデータを取得する用のエンドポイントを作成することにしました。
プレビューAPI利用におけるユーザー認証について
プレビューデータは基本的に公開してはいけないものであるため、社内の人間以外からは見えないようにする必要があります。そこでエンドポイントには認可処理を挟むこととし、それに失敗した場合は401を返却するような設計としました。
技術構成の検討
ここまでの検討で、プレビューAPIに求められる要件は以下のようなものです。
- プレビューデータを返すエンドポイントを一つだけ実装すれば十分
- ユーザー認証機能、およびエンドポイントでの認可処理が必要
Djangoなどを使って自前でサーバーを実装するのが素直な方法かもしれませんが、プレビュー機能のためだけに認証機能を含むサーバーを開発し、加えてインフラを整えるのも面倒です。
そこでエンドポイントについてはAPI Gateway + Lambdaで実装することとし、認証、認可機能についてはCognitoを使うことにしました。API GatewayとCognitoは簡単に連携できるのも嬉しいポイントです。
プレビューデータ取得の処理を簡単なフローチャートにすると以下のような図になります。
また、これらのAWSリソース構築についてはServerless Frameworkを使うのがいいよというアドバイスをもらったので、リソース構築、管理についてはServerless Frameworkを採用することとしました。
Serverless FrameworkによるAWSリソース構築
今回の開発における必要なAWSリソースはAPI Gateway、Lambda、Cognitoの3つです。CognitoはユーザープールとIDプールがあり、「Serverless Framework Cognito」などと検索するとIDプールまで含めた構築方法の説明が多く出てきます。
ただ、今回のユースケースではユーザーの権限によって認可の範囲が異なるわけではなく、認証されているかどうかのtrue or falseの判定で十分なため、ユーザープールの作成のみで十分です。
serverless.yml
上記スタックをまとめて構築するymlファイルは以下のようなものになります。
service: sample-serverless
plugins:
- serverless-domain-manager
custom:
stage: ${opt:stage, self:provider.stage}
domains:
dev: dev-preview.sample.jp
prod: prod-preview.sample.jp
customDomain:
domainName: ${self:custom.domains.${self:custom.stage}}
stage: ${self:custom.stage}
certificateName: sample.jp
createRoute53Record: true
provider:
name: aws
runtime: nodejs12.x
stage: dev
region: ap-northeast-1
endpointType: REGIONAL
iamRoleStatements:
- Effect: Allow
Action:
- cognito-idp:ListUsers
- cognito-idp:AdminListGroupsForUser
Resource: "arn:aws:cognito-idp:*"
functions:
preview:
handler: handler.preview
name: ${self:service}-${self:custom.stage}
events:
- http:
path: /
method: get
cors: true
integration: lambda
authorizer:
name: ${self:custom.stage}-sample-authorizer
type: COGNITO_USER_POOLS
arn:
Fn::GetAtt: [CognitoUserPool, Arn]
request:
template:
application/json: '{ "slug" : "$input.params(''slug'')" }'
resources:
- ${file(./resources/cognito-user-pool.yml)}
- ${file(./resources/authorizer.yml)}
いくつかの箇所について解説します。
plugins:
- serverless-domain-manager
上記の例ではAWSリソース構築とともに独自ドメインでの公開 + SSL化を行っています。それに必要なのがserverless-domain-manager
で、デプロイ実行前にnpm i --save dev
しておけばOKです。
具体的な使い方は公式ドキュメントを参考にしてください。
functions:
preview:
handler: handler.preview
name: ${self:service}-${self:custom.stage}
events:
- http:
path: /
method: get
cors: true
integration: lambda
authorizer:
name: ${self:custom.stage}-sample-authorizer
type: COGNITO_USER_POOLS
arn:
Fn::GetAtt: [CognitoUserPool, Arn]
request:
template:
application/json: '{ "slug" : "$input.params(''slug'')" }'
API Gateway、およびLambdaの構築に関する記述です。authorizer
には同時に生成されるCognito User Pool
のArnを指定しています。これだけでエンドポイントに認可機能が設定されます。めちゃくちゃ便利ですね。
また、プレビューデータを取得する際、クエリパラメータに記事のスラッグをつけてリクエストする設計にしています。Lambda関数内でそのクエリパラメータを取得できるようにするため、request
以下の記述をしています。
resource
の箇所に記述しているcognito-user-pool.yml
やauthorizer.yml
は以下のようになっています。
Resources:
CognitoUserPool:
Type: AWS::Cognito::UserPool
Properties:
AutoVerifiedAttributes:
- email
MfaConfiguration: "OFF"
Policies:
PasswordPolicy:
MinimumLength: 8
RequireLowercase: true
RequireNumbers: true
RequireSymbols: false
RequireUppercase: true
UserPoolName: ${self:custom.stage}-sample-userpool
UsernameAttributes:
- email
CognitoUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: ${self:custom.stage}-sample-client
ExplicitAuthFlows:
- ALLOW_USER_PASSWORD_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
GenerateSecret: false
UserPoolId:
Ref: CognitoUserPool
CognitoUserPool
ではパスワードの条件や各ユーザーが持つパラメータ(メールアドレスや名前など)を指定しています。今回はそれらは重要ではないので、メールアドレスだけ持っておけば十分かなと思います。
CognitoUserPoolClient
では認証手段についての設定を記述しています。重要なのはExplicitAuthFlows
で、いくつかの認証フローがあります。このへんは公式ドキュメントを見るのが早いです。
Resources:
GatewayResponseDefault4XX:
Type: 'AWS::ApiGateway::GatewayResponse'
Properties:
ResponseParameters:
gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
ResponseType: DEFAULT_4XX
RestApiId:
Ref: 'ApiGatewayRestApi
API Gatewayのオーソライザーに関する記述です。ハマったのがGatewayResponseDefault4XX
の箇所で、これを記述しておかないとオーソライザーを設定した際に必ずCORSのエラーレスポンスが帰ってきてしまうため、忘れずに設定しておく必要があります。
handler.js
Lambdaにデプロイする関数を記述するファイルです。今回の例ではこんな感じになります。
"use strict";
const axios = require("axios");
module.exports.preview = async (event, context, callback) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Credentials": true,
"Access-Control-Allow-Headers":
"Origin, X-Requested-With, Content-Type, Accept",
};
const path = `https://preview.contentful.com/spaces/${process.env.CONTENTFUL_SPACE_ID}/environments/${process.env.CONTENTFUL_ENVIRONMENT}/entries/?access_token=${process.env.CONTENTFUL_PREVIEW_KEY}&content_type=xxx%&fields.slug=${event.slug}`;
try {
const res = await axios.get(path);
if (!res.data.items[0]) {
callback(JSON.stringify({
status: "[404]",
message: "Not Found"
}))
}
const image = res.data.items[0].fields.image
? res.data.includes.Asset[0].fields
: null;
return {
status: 200,
headers: headers,
body: JSON.stringify({
data: res.data.items[0].fields,
image: image,
}),
};
} catch (err) {
callback(JSON.stringify({
status: "[500]",
message: "Internal Server Error"
}))
}
};
とくにややこしいことはしておらず、Contentfulのプレビューデータを取得するAPIを叩いて、帰ってきたデータを多少フォーマットして返却するだけの関数となっています。
一つ気をつける必要があるのは、エラーレスポンスの返却方法です。たとえば、以下のように記述してもクライアントには200 OKが返ってしまいます。
return {
status: 500,
headers: headers,
message: "Internal Server Error"
};
これはAPI Gatewayを経由する際に、Lambdaからのレスポンスがエラーレスポンスとして認識されないため、API Gatewayが200 OKを返してしまうためです。
API Gatewayにはレスポンス内容をチェックしてレスポンスステータスを決定する仕組みがあるので、それを使ってエラーレスポンスが返るようにします。上記の例で
callback(JSON.stringify({
status: "[500]",
message: "Internal Server Error"
}))
とstatus: "[500]"
のようにしているのは、API Gatewayのデフォルト設定がそのようになっているためです。status: 500
のようにしたい場合は、API Gateway側の設定を変更する必要があります。
デプロイの実行
serverless.ymlとhandler.jsを準備できたら、sls deploy -v
と入力するだけでstageがdevの各リソースが構築されます。
package.json
のスクリプトに
"scripts": {
"deploy:dev": "sls deploy -v --stage dev",
"deploy:prod": "sls deploy -v --stage prod"
}
などと記述しておけばより便利です。
クライアント側での認証機能の実装
Cognitoでの認証処理の実装については、公式がSDKを配布しているのでそれを使います(amazon-cognito-identity-js)。
各目的ごとのサンプルコードも記載されており、ほぼそのまま使うだけで認証処理ができるので便利です。今回のケースで言えば、社内の限られた人のみが使うというものなのでサインアップ機能は必要なく、ログイン機能だけあれば十分です(あらかじめユーザーは作成しておく)。
ログイン処理のサンプルコードは以下のようなものです。
import {
CognitoUserPool,
CognitoUser,
AuthenticationDetails,
} from "amazon-cognito-identity-js";
const poolData = {
UserPoolId: process.env.COGNITO_USER_POOL_ID,
ClientId: process.env.COGNITO_CLIENT_ID,
};
const userPool = new CognitoUserPool(poolData);
export const login = async (
email: string,
password: string,
newPassword: string
) => {
if (!password) {
return Promise.reject();
}
const authenticationDetails = new AuthenticationDetails({
Username: email,
Password: password,
});
const userData = {
Username: email,
Pool: userPool,
};
const cognitoUser = new CognitoUser(userData);
cognitoUser.setAuthenticationFlowType("USER_PASSWORD_AUTH");
return new Promise((resolve, reject) => {
cognitoUser.authenticateUser(authenticationDetails, {
onSuccess: () => {
resolve();
},
onFailure: () => {
reject();
},
// 初回ログイン時のみ呼ばれるcallback
newPasswordRequired: (userAttributes) => {
delete userAttributes.email_verified;
cognitoUser.completeNewPasswordChallenge(newPassword, userAttributes, {
onSuccess: () => {
resolve();
},
onFailure: () => {
reject();
},
});
},
});
});
};
amazon-cognito-identity-js
のようにやや巨大なライブラリを扱う場合はラッパー関数を用意する方法が便利です。テスト時のモックも簡単に実装できるようになります。
ほぼ公式ドキュメントのサンプルコードそのままなのですが、cognitoUser.authenticateUser
をPromiseでラップして、関数に戻り値を設定しています。
cognitoUser.authenticateUser
は最終的に引数として渡したcallback関数を実行する仕様で、戻り値がvoidなのですが、Promiseでラップするおくことで関数外部でログイン成功、失敗時の処理を分岐させることができます。イメージはこんな感じです。
try {
await cognitoLogin(email, password, newPassword);
setResultMessage(
"ログインに成功しました。"
);
} catch (err) {
setResultMessage("ログインに失敗しました。");
}
また、cognitoUser.authenticateUser
のcallbackにnewPasswordRequired
という関数を置いていますが、これはCognitoで新規作成したユーザーの有効化のためです。新規作成したユーザーは一度パスワード変更を行わないと有効化されず、初回ログイン処理の場合はこちらのnewPasswordRequired
が呼ばれるようになっています。
認証トークンの取得
上記のSDKを利用すると、ログインに成功した場合に自動的にlocalStorageに認証トークンが保存されます。それを取得するための関数も用意されています。
export const getAccessToken = () => {
let token = "";
const currentUser = userPool.getCurrentUser();
if (currentUser) {
currentUser.getSession((err: unknown, result: CognitoUserSession) => {
if (result) {
token = result.getIdToken().getJwtToken()
}
}
return token;
};
こちらもラッパー関数を作成しました。やっていることは単純で、ログイン状態かどうかを判定し、ログイン状態であればlocalStorageからトークンを取得して返却しているだけです。
あとはAPIへのリクエスト時にヘッダに上記トークンを付加して送信すればOKです。
まとめ
プレビュー機能を作るためだけに上記のような構成を考えたりするのはなかなか大変でしたが、運用面での課題もとくになさそうで、そこそこ綺麗に設計、実装できたように感じています。
Jamstack構成はカスタマイズ性が高く、フロントエンドエンジニアにとっては魅力的ですが、多くの機能を自身で実装しなければならないのはちょっと大変ですね。Wordpressだと認証やプレビュー機能がデフォルトで備わっているので、そういった点はやっぱり便利だなと思いました。