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を使用した例です。
Redisデータベースのセットアップ
まず、Upstash Redisにサインアップします(テスト目的なら無料プランで十分です)。ログイン後、コンソールから新しいデータベースを作成しましょう。
「Create database」をクリックし、名前を「MovieManager」に設定して、グローバルタイプを選択します。次に、Upstash CLIを使ってダミーデータを追加していきます。
映画データは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を選択すると、登録したデータの一覧が確認できます。
認可ワークフロー
前述のとおり、エンドポイントは公開されていないため、ユーザーを認証・認可する仕組みが必要です。認証にはログインエンドポイントを提供し、認可については保護されたエンドポイントが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セクションから取得できます。
これでエンドポイントの設計に入れます。
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");
}
}
};
サインインエンドポイントは、userとpasswordの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オブジェクトが返ってくるはずです。
問題なければ、Redisデータベースに新しく作成されたリフレッシュトークンのハッシュエントリが追加されているのが確認できます。
次に、トークンリフレッシュ用のルート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.jsのtokenRefresh関数を利用します。まずトークンが有効でデコード可能かを検証し、続いてRedisにそのユーザーのリフレッシュトークン(前述のaddToListで保存したもの)が存在するかを確認します。すべて正しければ、新しいJWTと新しいリフレッシュトークンを生成し(Redisにも再保存し)、それらをクライアントに返します。
このエンドポイントもツールでテストできます。https://localhost:3000/api/auth/refreshにPOSTリクエストを送り、リフレッシュトークンをパラメータとして渡してみてください。
これで、想定上のクライアントはログインとトークンの更新ができるようになりました。次に、このトークンを使って認証付きリクエストを行う方法を見ていきましょう。
映画のリスト取得と個別詳細取得の両方に対応する新しい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.jsのverifyToken関数を使うことで、有効なトークンを提示したユーザーだけがAPIエンドポイントにアクセスできるように制限しています。サンプルとして2種類のクエリを用意しました。1つ目は映画リストの取得です。
const result = await redis.scan(0, { match: 'movie:*' });
2つ目は、URLのIDパラメータに基づいて単一の映画詳細を取得するものです。
const result = await redis.hgetall(id);
どちらのリクエストもverifyTokenによるユーザー状態のチェックに依存していますが、組み合わせは自由です。例えば、リストは公開し、詳細のみ保護することもできます。また、ユーザー情報(およびトークン内)にlevelを持たせているので、より細かい認可レベルの実装も可能です。 実際に映画のリストを取得してみましょう。
そして、単一の映画の詳細も取得してみます。
クライアントの視点
前述のとおり、私たちはサーバー側だけに焦点を当てました。これはAPIの本質的な役割です。APIは抽象的なものであり、Webサイトではありません。どのような方法(プログラミング言語、ライブラリなど)でデータをリクエストするかはクライアント開発者の自由であり、私たちはエンドポイントの一覧と、それぞれが期待する入力・返す出力の仕様を提供するだけです。データの扱い方やリフレッシュのタイミングなどの戦略は、すべてクライアント側に委ねられます。
次のステップ
ここまでの内容は、保護されたAPIのワークフローの基本的な例にすぎません。ここからは改善の余地しかありません。Redisでのデータ保存方法の最適化、ユーザーデータを別のRedisインスタンスに分離することによるログインセキュリティの強化、リクエスト受信時のデータ検証の徹底、エンドポイントの追加、GraphQL形式でのデータ返却、API用クライアントの構築、時間あたりの最大呼び出し回数によるアクセス制限、レベルによるアクセス権限の制御……拡張と改善の可能性は無限大です!
-
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を有
-
Redisで整数値をデクリメントする方法 – DECR・DECRBYコマンドの使い方
このチュートリアルでは、Redisデータストアに保存されたキーの整数値を表す文字列を減算(デクリメント)する方法について、DECRコマンドとDECRBYコマンドを使って学習します。 DECRコマンドとは DECRコマンドは、指定したキーに保存されている整数値を表す文字列を「1」だけ減算するために使用します。キーが存在しない場合は、まずキーが新規作成され、値が0に設定されてから減算処理が実行されます。 一方、キーが存在していても、保存されている値のデータ型が誤っている場合(文字列型以外の場合)や、整数として表現できない文字列が格納されている場合は、エラーが返されます。なお、この操作で扱える数値は