はじめに
この記事は、AWS Lambda と Serverless Advent Calendar 2021 3日目の投稿です!
「最近サーバーレスでこんなことやったぜっ!」と意気込んでサーバーレスのアドベントカレンダーにエントリーさせていただいたのですが、
いざ記事を書いてみると「これってサーバーレスじゃなくてもできるのでは」と気づきました。。
Serverless Framework 上で動かしているということは事実なので、お許しいただけると嬉しいです!!
構成
front / back 構成の Web アプリケーションを、以下のような組み合わせで構築しました。
front: Vue.js (Vue3) (S3でホスティングする予定)
back: Serverless Framework を利用して Express on Lambda + API Gateway
認証: Cognito
サンプルコードは TypeScript ですが、 JavaScript でも参考にできると思います。
そもそも Cognito とは
Amazon Cognito は、ウェブおよびモバイルアプリの認証、承認、およびユーザー管理機能を提供します。ユーザーは、ユーザー名とパスワードを使用して直接サインインするか、Facebook、Amazon、Google、Apple などのサードパーティーを通じてサインインできます。
認証/認可を簡単にしてくれるやつ。いわゆる IDaaS。
Firebase Authentication や Auth0 の仲間、という理解です。
ユーザープール(認証)に加えてIDプール(認可)も使えば、 DynamoDB や S3 など、AWSリソースの認可が簡単にできるはず。
今回はユーザープールのみ試しました。
手順
0. APIを準備する
Serverless Framework を利用して、 Lambda 上の Express を呼び出せる API Gateway のエンドポイントを作成しておきます。
参考情報がたくさんあるので割愛。
クラスメソッドさんの記事や、公式のサンプルリポジトリがわかりやすいです😊
1. Cognito をセットアップする
Cognito のユーザープールを作成し、アプリクライアントを追加しておきます。
こちらも細かい手順は割愛。
以下の記事を参考にしました。
Amazon Cognitoを使ったサインイン画面をつくってみる
2. ログイン画面を作る
今回のフロントは Vue.js。
サーバーレスのテーマから離れるのですが、ここが1番ハマりました😇
静的なフロントで SDK を使うサンプルコードはたくさんあったのですが、 Vue.js は Amplify で実現している事例が多く、なかなかズバリな資料にたどり着けず。(Amplify は今回の要件に too much だったので見送りました)
Cognito 関連のライブラリがいくつかあり色々試してみたところ、最終的には amazon-cognito-identity-js でうまくいきました。
本題からは離れますが、どなたかのお役に立つこともあるかもしれないのでソースコードを貼っておきます。
<template>
<div id="signup">
<h1>Sign In</h1>
<form name="form-signup">
<span>User ID(Email)</span>
<input
v-model="state.email"
type="text"
placeholder="Email Address"
>
<br>
<span>Password</span>
<input
v-model="state.password"
type="password"
placeholder="Password"
>
<br><br>
<input
id="createAccount"
type="button"
value="サインイン"
@click="onSigninButtonClick"
>
</form>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import * as AmazonCognitoIdentity from 'amazon-cognito-identity-js';
export default defineComponent({
name: 'SigninPage',
setup() {
const router = useRouter();
const state = reactive({
email: '',
password: '',
});
const onSigninButtonClick = () => {
const authenticationDetails = new AmazonCognitoIdentity.AuthenticationDetails({
Username: state.email,
Password: state.password,
});
const userPool = new AmazonCognitoIdentity.CognitoUserPool({
UserPoolId: 'ユーザープールID',
ClientId: 'アプリクライアントのID',
});
const cognitoUser = new AmazonCognitoIdentity.CognitoUser({
Username: state.email,
Pool: userPool,
});
// 認証処理
cognitoUser.authenticateUser(authenticationDetails, {
onSuccess(result) {
// とりあえず idToken を console.log() しているけど、実際には storage に保存しよう
console.log(`idToken: ${result.getIdToken().getJwtToken()}`);
// サインイン成功の場合、次の画面へ遷移
router.push('/');
},
newPasswordRequired(userAttributes, requiredAttributes) {
// パスワード再設定画面へ
router.push('/new-password');
},
onFailure(err) {
console.error(err);
},
});
};
return {
state,
onSigninButtonClick,
};
},
});
</script>
あらかじめ Cognito のダッシュボードで登録しておいたユーザーでログイン→パスワード再設定→再度ログイン、で idToken
を取得できました。
3. トークンを検証する
ここからは API 側、Serverless Framework 側のプロジェクトを触ります。
まず、JWT トークンの検証用に、jsonwebtoken と jwks-rsa を追加しておきます。
npm install jsonwebtoken jwks-rsa
検証用のミドルウェアを作成します。
import { Request, Response, NextFunction } from 'express';
import jwt, { JwtHeader, SigningKeyCallback } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
// 作成したユーザープールの JWKS を指定
const client = jwksClient({
jwksUri:
`https://cognito-idp.ap-northeast-1.amazonaws.com/${process.env.USER_POOL_ID}/.well-known/jwks.json`,
});
const getKey = (header: JwtHeader, callback: SigningKeyCallback) => {
if (!header.kid) throw new Error('not found kid!');
client.getSigningKey(header.kid, (err, key) => {
if (err) throw err;
callback(null, key.getPublicKey());
});
}
export default (req: Request, res: Response, next: NextFunction) => {
// Authorization ヘッダーから idToken 部分を取り出す
const authHeader = req.headers.authorization;
if (authHeader == null) {
res.status(401).json({ error: 'Missing authorization header.' });
return;
}
const [bearerText, idToken] = authHeader.split(' ');
if (bearerText !== 'Bearer') {
res.status(401).json({ error: 'Illegal authorization header format.' });
return;
}
// JWT を検証して、OKだったら Express の Request オブジェクトに格納しておく
jwt.verify(idToken, getKey, (err, decoded) => {
if (err) {
res.status(401).json({ error: err.message });
return;
}
req.auth = decoded;
next();
});
};
req.auth = decoded;
の箇所で TypeScript の型エラーが発生してしまったので、以下のように Request インターフェースを拡張しておきました。
import { JwtPayload } from 'jsonwebtoken';
declare global {
namespace Express {
export interface Request {
auth?: JwtPayload | undefined;
}
}
}
Router 側で、上記で作成したミドルウェアを指定します。
import express, { Request, Response } from 'express';
import verifyToken from '../../middlewares/verifyToken';
const router = express();
router.get('/', verifyToken, (req: Request, res: Response) => {
if (!req.auth) {
res.json({ message: 'Something went wrong...' });
return;
}
// verifyToken ミドルウェアを通過しているので、 `req.auth` に情報がセットされているはず
res.json({ message: `Hello! Your sub: ${req.auth.sub}` });
});
export default router;
req.auth.sub
にはユーザープール上で一意なユーザーIDがセットされています。
この値を DB に保管しておけば、認可の機能を持たせることもできます。
(e.g, ログインユーザーが保存した写真のみを表示する)
また、 Cognito 自身にも IDプール という認可の仕組みがあるので、構成やユースケースに応じて使い分けたいですね。
4. フロントから呼び出してみる
とりあえず curl から呼んでみましょう。
まずは serverless offline でAPIを起動。
serverless offline
2.で作成した画面で Cognito にログインし、 console.log()
された idToken をコピーします。
curl http://localhost:3000/auth-test -H "Authorization:Bearer コピーしたidToken
すると
{"message":"Hello! Your sub:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}
ちゃんとミドルウェアを通過して、値が返ってきました。
試しに idToken を適当に変更してみると
{"error":"invalid signature"}
ちゃんとエラーになりました😊
あとは Vue.js 側でログイン時に idToken を保存して、各種 API 呼び出し時のヘッダーにセットすれば、認証機能付きアプリケーションの完成です。
よかったよかった。
おわりに
Cognito を使うと、「いつも同じような実装してるけど、いつもそれなりに気を遣うな〜」という認証機能をおまかせできちゃうので、開発の負担が減りますね!
IDプール(認可の機能)を使うとより本領を発揮する気がするので、もっといろいろ試してみたいな〜。