Redis
 Computer >> コンピューター >  >> プログラミング >> Redis

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

本記事では、Next.jsのAPI RoutesとUpstash Redisを活用して、最小限でありながら完全に機能する認証付きREST APIサービスを構築します。Upstash Redisは、データの保存はもちろん、ユーザー認証情報やJWT(JSON Web Token)の管理にも使用する超高速なストレージ兼キャッシュシステムとして活躍します。なお、このプロジェクトにはフロントエンドは含まれず、さまざまなクライアントからクエリできるAPIのみを公開する形になります。

前提条件

このチュートリアルを進めるには、以下が必要です。

  • Upstashアカウント — 無料プランにこちらからサインアップできます
  • Redisの基礎知識
  • Next.js API Routesの基礎知識
  • 認証・認可フローの基礎知識
  • HTTPリクエストを送信できるツール(Postmanなど)

Upstash Redisとは

UpstashはRedisベースのサーバーレス型インメモリクラウドデータベースです。APIが提供するデータの保存先として利用するほか、ユーザー情報やトークンの管理にも使用します。

構築するもの

クライアントアプリケーションがデータ(今回は映画のリスト)をリクエストできるREST APIサービスを作成します。エンドポイントはJWTで保護し、トークンを取得するためのログインAPIと、リフレッシュトークンのワークフローも実装します。

クライアント側の開発は対象外です(「オピニオンレス」なサービスを構築するため)。ただし、誰でもクライアントを実装できるよう、サービスの仕様は明確に提供します。

リポジトリとデモ

記事に沿って進める場合は、プロジェクトのリポジトリをクローンしてください。

GitHub上のソースコード

デモは以下のURLから試せます:

https://upstash-dwov9jbiq-popland.vercel.app/api/auth/signin

サービスに接続するには、ユーザー名(me@home.org)とパスワード(password)を指定してPOSTリクエストを送信します。以下はPostmanを使用した例です。

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

Redisデータベースのセットアップ

まず、Upstash Redisにサインアップします(テスト目的なら無料プランで十分です)。ログイン後、コンソールから新しいデータベースを作成しましょう。

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

「Create database」をクリックし、名前を「MovieManager」に設定して、グローバルタイプを選択します。次に、Upstash CLIを使ってダミーデータを追加していきます。

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

映画データはRedisハッシュ(いわゆるオブジェクト)として、HMSETコマンドで追加します。

 hmset movie:'Dr. Strangelove' director 'Stanley Kubrick' year 1964
 hmset movie:'2001: A Space Odyssey' director 'Stanley Kubrick' year 1968
 hmset movie:'Pulp Fiction' director 'Quentin Tarantino' year 1994
 hmset movie:'Django Unchained' director 'Quentin Tarantino' year 2012

さらに、データへのアクセスを許可するユーザーもハッシュとして登録します。

 hmset user:'me@home.org' password $2b$10$zctxUVDyy3jzvSp68oKpMOnkyra4R.NzOFVh9aii3Y43X7XtetoyK level 0

注意:パスワードはbcryptで暗号化されています(平文はpasswordです)。通常、APIへのアクセスが必要なユーザーはWebサイト経由で登録しますが、この例では登録用エンドポイントは用意していません。

Upstash CLIで入力した各コマンドに対してOKという応答が返れば成功です。すべて正しく入力できたら、Data Browserを開いてHashを選択すると、登録したデータの一覧が確認できます。

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

認可ワークフロー

前述のとおり、エンドポイントは公開されていないため、ユーザーを認証・認可する仕組みが必要です。認証にはログインエンドポイントを提供し、認可については保護されたエンドポイントがAuthorizationヘッダーを要求する形にします。ワークフローの詳細は以下のとおりです。

  • ユーザーがサインインエンドポイントにユーザー名とパスワードをPOSTする
  • サーバーがユーザーを認証しようとし、有効なユーザーであればJWTとリフレッシュトークンを生成して返す。リフレッシュトークンはUpstash Redisインスタンスにも保存される
  • クライアントはトークンを受け取り、任意の場所に保存する(保存方法はクライアント側の責任)
  • クライアントが保護されたエンドポイントへJWTをヘッダーに付けてリクエストする
  • サーバーはJWTを受信して検証し、有効であれば要求されたデータを返す
  • JWTの有効期限が切れるか切れそうになると、クライアントは再ログインなしで、専用エンドポイントにリフレッシュトークンを送ることで新しいJWTを取得できる
  • サーバーはリフレッシュトークンを受信して検証し、問題なければ新しいJWTとリフレッシュトークンを発行してクライアントに返すとともに、新しいリフレッシュトークンを再度保存する

JWTとリフレッシュトークンは同じ形式で、ほぼ同じ情報を持ちますが、異なる秘密鍵(.envファイルで設定)を使用し、有効期限も異なります。JWTはセッション中に最も頻繁に使われるトークンのため短め(傍受されても被害を最小限に抑えるため)、リフレッシュトークンは長めに設定します。それぞれの期間は必要なセキュリティレベルによりますが、一般的にJWTは1時間以内、リフレッシュトークンは1ヶ月程度です。両方のトークンが失効した場合は、ユーザーは再度ログインする必要があります。

プロジェクトのセットアップ

Upstash Redisデータベースの準備ができたら、プロジェクトを初期化しましょう。まず、新しいNext.jsプロジェクトを作成します。

 npx create-next-app upstash-jwt

次に、作成されたupstash-jwtフォルダに移動し、必要なモジュールをインストールします。

 npm i bcrypt jsonwebtoken @upstash/redis

キーを保存するための.env.localファイルを作成し、正しい値を記入します。

 SECRET_TOKEN=
 SECRET_RTOKEN=
 UPSTASH_REDIS_REST_URL=
 UPSTASH_REDIS_REST_TOKEN=

SECRET_TOKENとSECRET_RTOKENはJWTの生成に使うキーです。これらは厳重に管理し、推測されにくい十分にランダムな文字列にしてください(64ビットのHex文字列などを利用できます)。UPSTASH_REDIS_REST_URLとUPSTASH_REDIS_REST_TOKENは、Upstashコンソールの「Details」タブにあるRest APIセクションから取得できます。

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

これでエンドポイントの設計に入れます。

POST /auth/signin
ユーザーをログインさせます。{"email":"email", "password": "password"}という形式のJSONオブジェクトでメールアドレスとパスワードを受け取り、ユーザー情報・JWT・リフレッシュトークンを含むJSONオブジェクトを返します。

GET /movies/
映画のリストをJSONオブジェクトで返します。ヘッダーに以下の形式で有効なJWTを渡す必要があります。
Authorization: Bearer xxx

GET /movies/$ID
IDが$IDの映画の詳細情報を返します。

POST /auth/refresh
新しいJWTを生成して返します。refreshTokenパラメータとしてリフレッシュトークンを渡す必要があります。

APIルートのコード

まずはサインインエンドポイントから始めます。pages/api/auth/signin.jsファイルを以下のように作成してください。

import bcrypt from "bcrypt";
 
import {
 addToList,
 generateAccessToken,
 generateRefreshToken,
 redis,
} from "../../../utils";
 
export default async (req, res) => {
 if (req.method === "GET") {
 res.status(405).send("Not Allowed");
 } else {
 console.log(req.body.user);
 try {
 const user = await redis.hgetall(`user:${req.body.user}`);
 if (user) {
 const validPassword = bcrypt.compare(req.body.password, user.password);
 if (validPassword) {
 const token = generateAccessToken(req.body.user, user.level);
 const refreshToken = generateRefreshToken(req.body.user, user.level);
 const refresh = await addToList(req.body.user, refreshToken);
 const content = {
 user: req.body.user,
 level: user.level,
 };
 res.status(200).json({
 message: "Logged in",
 content: content,
 JWT: token,
 refresh: refreshToken,
 });
 } else {
 res.status(400).json({ error: "Invalid Password" });
 }
 } else {
 res.status(401).json({ error: "User not found" });
 }
 } catch (error) {
 res.status(500).send("Internal Server Error");
 }
 }
};

サインインエンドポイントは、userpasswordの2つのパラメータを持つPOSTリクエストのみを受け付けます。まず、以下のコードでユーザーがRedisデータベースに存在するかを確認します。

 const user = await redis.hgetall(`user:${req.body.user}`);

ユーザーが存在すれば、暗号化されたパスワードを比較します。

 const validPassword = bcrypt.compare(req.body.password, user.password);

パスワードが一致すれば、ユーザーは認証されたとみなし、JWTとリフレッシュトークンを返却します。同時に、リフレッシュトークンはRedisインスタンスにも保存されます。これらの処理には、utils.jsという外部ファイルに定義した関数を使います。

返却されたトークンの保存、必要に応じた認可への利用、期限切れ時の更新は、すべてクライアント側の責任です。

utils.jsには、トークンを生成するgenerateAccessToken、リフレッシュトークンを生成するgenerateRefreshToken、リフレッシュトークンをRedisに保存するaddToListの各関数を定義します。このファイルには、Redis接続やトークンの検証・更新など、その他のユーティリティ関数や参照もまとめておきます。

import { Redis } from "@upstash/redis";
import jwt from "jsonwebtoken";
 
export const redis = new Redis({
 url: process.env.UPSTASH_REDIS_REST_URL,
 token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
export function generateAccessToken(username, email, level) {
 return jwt.sign(
 { user: username, email: email, level: level },
 process.env.SECRET_TOKEN,
 {
 expiresIn: "1h",
 },
 );
}
 
export function generateRefreshToken(username, email, level) {
 return jwt.sign(
 { user: username, email: email, level: level },
 process.env.SECRET_RTOKEN,
 {
 expiresIn: "30d",
 },
 );
}
 
export async function addToList(user, refresher) {
 try {
 await redis.hset("refresh:" + user, { refresh: refresher });
 } catch (error) {
 console.log(error);
 }
}
 
export async function tokenRefresh(refreshtoken, res) {
 var decoded = "";
 try {
 decoded = jwt.verify(refreshtoken, process.env.SECRET_RTOKEN);
 } catch (error) {
 return res.status(401).send("Can't refresh. Invalid Token");
 }
 if (decoded) {
 try {
 const rtoken = await redis.hget("refresh:" + decoded.user, "refresh");
 console.log(rtoken);
 if (rtoken !== refreshtoken) {
 return res.status(401).send("Can't refresh. Invalid Token");
 } else {
 const user = await redis.hgetall(`user:${decoded.user}`);
 console.log(user);
 const token = generateAccessToken(decoded.user, user.level);
 const refreshToken = generateRefreshToken(decoded.user, user.level);
 
 const refresh = await addToList(decoded.user, refreshToken);
 
 const content = {
 user: decoded.user,
 level: user.level,
 };
 return {
 message: "Token Refreshed",
 content: content,
 JWT: token,
 refresh: refreshToken,
 };
 }
 } catch (error) {
 console.log(error);
 }
 }
}
 
export async function verifyToken(token, res) {
 try {
 const decoded = jwt.verify(token, process.env.SECRET_TOKEN);
 return decoded;
 } catch (err) {
 return res.status(405).send("Token is invalid");
 }
}

ここで、Postmanなどのツールを使ってサインイン処理をテストできます。https://localhost:3000/api/auth/signinにPOSTリクエストを送り、ユーザー名(me@home.org)とパスワード(password)を渡すと、ユーザー詳細とJWT、リフレッシュトークンを含むJSONオブジェクトが返ってくるはずです。

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

問題なければ、Redisデータベースに新しく作成されたリフレッシュトークンのハッシュエントリが追加されているのが確認できます。

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

次に、トークンリフレッシュ用のルートrefresh.jsを実装して、認証プロセスを完成させましょう。

import { redis, tokenRefresh } from "../../../utils";
 
export default async (req, res) => {
 if (req.method === "GET") {
 res.status(405).send("Not Allowed");
 } else {
 console.log(req.body.refresh);
 const refresp = await tokenRefresh(req.body.refresh, res);
 res.status(200).json(refresp);
 }
};

このエンドポイントはutils.jstokenRefresh関数を利用します。まずトークンが有効でデコード可能かを検証し、続いてRedisにそのユーザーのリフレッシュトークン(前述のaddToListで保存したもの)が存在するかを確認します。すべて正しければ、新しいJWTと新しいリフレッシュトークンを生成し(Redisにも再保存し)、それらをクライアントに返します。

このエンドポイントもツールでテストできます。https://localhost:3000/api/auth/refreshにPOSTリクエストを送り、リフレッシュトークンをパラメータとして渡してみてください。

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

これで、想定上のクライアントはログインとトークンの更新ができるようになりました。次に、このトークンを使って認証付きリクエストを行う方法を見ていきましょう。

映画のリスト取得と個別詳細取得の両方に対応する新しいAPIルートapi/movies/[[...id]].jsを作成します。

import { redis, verifyToken } from "../../../utils";
 
export default async (req, res) => {
 var id;
 console.log(req.query);
 if (req.query.id) {
 id = req.query.id[0];
 }
 
 var decoded = "";
 const authHeader = req.headers["authorization"];
 const token = authHeader && authHeader.split(" ")[1];
 if (!token) {
 return res.status(403).send("A token is required for authentication");
 } else {
 decoded = await verifyToken(token, res);
 }
 if (decoded) {
 if (id) {
 try {
 const result = await redis.hgetall(id);
 console.log(result);
 return res.status(200).json(result);
 } catch (error) {
 return res.status(500).send("Internal Server Error");
 }
 } else {
 try {
 const result = await redis.scan(0, { match: "movie:*" });
 return res.status(200).json(result);
 } catch (error) {
 return res.status(500).send("Internal Server Error");
 }
 }
 }
};

utils.jsverifyToken関数を使うことで、有効なトークンを提示したユーザーだけがAPIエンドポイントにアクセスできるように制限しています。サンプルとして2種類のクエリを用意しました。1つ目は映画リストの取得です。

 const result = await redis.scan(0, { match: 'movie:*' });

2つ目は、URLのIDパラメータに基づいて単一の映画詳細を取得するものです。

const result = await redis.hgetall(id);

どちらのリクエストもverifyTokenによるユーザー状態のチェックに依存していますが、組み合わせは自由です。例えば、リストは公開し、詳細のみ保護することもできます。また、ユーザー情報(およびトークン内)にlevelを持たせているので、より細かい認可レベルの実装も可能です。 実際に映画のリストを取得してみましょう。

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

そして、単一の映画の詳細も取得してみます。

Next.js API RoutesとUpstash Redisで構築する、高速かつセキュアな認証付きREST API

クライアントの視点

前述のとおり、私たちはサーバー側だけに焦点を当てました。これはAPIの本質的な役割です。APIは抽象的なものであり、Webサイトではありません。どのような方法(プログラミング言語、ライブラリなど)でデータをリクエストするかはクライアント開発者の自由であり、私たちはエンドポイントの一覧と、それぞれが期待する入力・返す出力の仕様を提供するだけです。データの扱い方やリフレッシュのタイミングなどの戦略は、すべてクライアント側に委ねられます。

次のステップ

ここまでの内容は、保護されたAPIのワークフローの基本的な例にすぎません。ここからは改善の余地しかありません。Redisでのデータ保存方法の最適化、ユーザーデータを別のRedisインスタンスに分離することによるログインセキュリティの強化、リクエスト受信時のデータ検証の徹底、エンドポイントの追加、GraphQL形式でのデータ返却、API用クライアントの構築、時間あたりの最大呼び出し回数によるアクセス制限、レベルによるアクセス権限の制御……拡張と改善の可能性は無限大です!

  1. Upstash Prod Packとは?月額200ドルでRedisをエンタープライズグレードに強化するアドオン

    昨年、私たちはRedis向けの新しいアドオンProd Packを発表しました。Prod Packは、有料のRedisデータベース(現在ではQStashインスタンスにも対応)に月額200ドルで追加できるアドオンです。既存の従量課金(Pay-as-you-go)プランや固定(Fixed)プランはそのまま維持しながら、本番運用に必要な機能を上乗せできます。Prod PackとEnterpriseの違いは適用範囲です。Prod Packはデータベース単位、Enterpriseはアカウント単位で提供されます。たとえば10個のデータベースがあっても、重要なトラフィックを扱う2つだけにProd Packを有

  2. Redisで整数値をデクリメントする方法 – DECR・DECRBYコマンドの使い方

    このチュートリアルでは、Redisデータストアに保存されたキーの整数値を表す文字列を減算(デクリメント)する方法について、DECRコマンドとDECRBYコマンドを使って学習します。 DECRコマンドとは DECRコマンドは、指定したキーに保存されている整数値を表す文字列を「1」だけ減算するために使用します。キーが存在しない場合は、まずキーが新規作成され、値が0に設定されてから減算処理が実行されます。 一方、キーが存在していても、保存されている値のデータ型が誤っている場合(文字列型以外の場合)や、整数として表現できない文字列が格納されている場合は、エラーが返されます。なお、この操作で扱える数値は