はじめに
はじめまして。今回は、Honoを使ってAPIサーバーを爆速で構築したいと思います。
JWTを使ったユーザー認証も実装してみましょう。
環境構築
この記事では、ランタイム及びパッケージ管理にBunを用います。
インストールが済んでいなければ先に済ませてください。Node.js及びNode Package Manager (npm)を使っている場合は、適宜読み替えてください。
プロジェクトを作成する
基本的にはHono の公式ドキュメントに沿って進めていく形になります。
まずはターミナルを開き、honoのテンプレートを使ってプロジェクトをセットアップします。
$ bun create hono@latest
Target directory
には任意の名前を(これがディレクトリ名になります)、
Which template do you want to use?
ではbun
を、
Do you want to install project dependencies?
ではY
を選択、
Which package manager do you want to use?
ではbun
を選択します。
create-hono version 0.15.3
? Target directory my-app
? Which template do you want to use? bun
? Do you want to install project dependencies? yes
? Which package manager do you want to use? bun
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd my-app
cd my-app
するか、任意のエディターでディレクトリを開きます。
APIサーバーを立ち上げる
プロジェクトルートで以下を実行します。
$ bun run dev
Started development server: http://localhost:3000
と出力されればOKです。
PostmanやAdvanced REST ClientなどのAPIクライアントを使って、http://localhost:3000
にGET
リクエストを送ります。
Hello Hono!
と返ってくれば問題ありません。
既定ではホットリロードが有効になっているはずです。そのため、開発用サーバーを都度再起動する必要はありません。
以後、開発用サーバーは起動されていることを前提に話が進みます。
(ホットリロード: ソースコードの変更をすぐに実行中のアプリケーションに反映させる機能)
認証機能を作る
新しいエンドポイントを追加する
今回の目標はユーザー認証機能を持ったAPIサーバーを構築することですので、早速認証機能の制作に取り掛かりましょう。
幸いHonoにはJWT ヘルパーが標準搭載されているので、こちらを有効活用することにします。
まずは、新しいエンドポイントを定義しましょう。
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello Hono!')
})
// 追加
app.post("/token", async (c) => {
return c.text("Logged in!");
});
// ここまで
export default app
スキーマを定義する
src/schema.ts
を作成し、型を記述していきます。
まずはユーザーオブジェクトを定義します。
export interface User {
username: string;
password: string;
}
ユーザーのCRUDを作成する
src/crud.ts
を作成し、ユーザーオブジェクトに関するCRUD機能をまとめたUserCrud
クラスを作成していきましょう。
ただし、今回はデータベースについて話すことが目的ではないので、あらかじめハードコードしたダミーデータを用いることにします。
まずは、ユーザーのCRUDを表す抽象クラスを作成します。
import { User } from "./schema";
/**
* ユーザーのCRUDを表す抽象クラス。
*/
export abstract class AbstractUserCrud {
}
CRUDですので、最低限の読み書きはできるようにしたいところです。
ただし、今回は学習用ということで、ひとまず読み取りだけができるようにします。
すべてのユーザーを返すgetAllUsers
抽象メソッドと、ユーザー名からユーザーを取得するgetUserByUsername
抽象メソッドを作成します。
/**
* ユーザーのCRUDを表す抽象クラス。
*/
export abstract class AbstractUserCrud {
/**
* 登録されているすべてのユーザーを返します。
*/
abstract getAllUsers(): Promise<User[]>;
/**
* 指定されたユーザー名をもとに、Userオブジェクトを返します。
* 見つからない場合は `undefined` が返されます。
* @param username 検索するユーザー名。
*/
abstract getUserByUsername(username: string): Promise<User | undefined>;
}
では、テスト用のダミーユーザーデータのCRUDを作成します。
AbstractUserCrud
を継承して作成しましょう。
/**
* テスト用のダミーユーザーを読み取るためのCRUD。
*/
class DummyUserCrud extends AbstractUserCrud {
}
学習用ということで、ユーザー名とパスワードをコンストラクタでハードコードします。
/**
* テスト用のダミーユーザーを読み取るためのCRUD。
*/
class DummyUserCrud extends AbstractUserCrud {
users: User[];
constructor() {
super();
this.users = [
{
username: "admin",
password: "admin",
},
{
username: "user1",
password: "password1",
},
{
username: "user2",
password: "password2",
},
{
username: "user3",
password: "password3",
},
{
username: "user4",
password: "password4",
},
];
}
}
配列をゴニョゴニョするだけですので、getAllUsers
とgetUserByUsername
はとてもシンプルな実装になるはずです。
async getAllUsers() {
return this.users;
}
async getUserByUsername(username: string) {
return this.users.find((user) => user.username === username);
}
最後に、DummyUserCrud
のインスタンスをexportします。
export const userCrud: AbstractUserCrud = new DummyUserCrud();
全体のコードは以下のようになりました。
import { User } from "./schema";
/**
* ユーザーのCRUDを表す抽象クラス。
*/
export abstract class AbstractUserCrud {
/**
* 登録されているすべてのユーザーを返します。
*/
abstract getAllUsers(): Promise<User[]>;
/**
* 指定されたユーザー名をもとに、Userオブジェクトを返します。
* 見つからない場合は `undefined` が返されます。
* @param username 検索するユーザー名。
*/
abstract getUserByUsername(username: string): Promise<User | undefined>;
}
/**
* テスト用のダミーユーザーを読み取るためのCRUD。
*/
class DummyUserCrud extends AbstractUserCrud {
users: User[];
constructor() {
super();
this.users = [
// 省略
];
}
async getAllUsers() {
return this.users;
}
async getUserByUsername(username: string) {
return this.users.find((user) => user.username === username);
}
}
export const userCrud: AbstractUserCrud = new DummyUserCrud();
認証サービスを作る
先ほど作成したCRUDを使って、ユーザー認証をするサービスを作成しましょう。
まずは、トークンの有効期限と、JWTの署名に用いるシークレットを定義します。
今回は、有効期限を5分に、シークレットを環境変数から読み出すようにします。
const TOKEN_EXPIRATION_MINUTES = 5;
const SECRET = process.env.SECRET;
.env
に環境変数のリストを記載します。(今回はシークレット一つだけです)
SECRET="ThisIsMySecret"
Bunでは.env
は自動で読み込まれるため、特段の処理は不要です。
必要であれば読み込むコードを書かなければなりませんが、今回の主題ではないので割愛します。
次にsrc/auth.ts
を作成し、認証を行うためのサービスを表す抽象クラスを作成します。
/**
* 認証を行うためのサービスを表す抽象クラス。
*/
export abstract class AbstractAuthService {
}
ユーザーの読み取りに使うCRUDを、コンストラクタから受け取るようにします。
/**
* 認証を行うためのサービスを表す抽象クラス。
*/
export abstract class AbstractAuthService {
userCrud: AbstractUserCrud;
/**
* 認証を行うサービスを初期化します。
* @param userCrud ユーザーの読み取りに使うUserCrud。
*/
constructor(userCrud: AbstractUserCrud) {
this.userCrud = userCrud;
}
}
次に、認証を行ってトークンを返すlogin
抽象メソッドと、トークンからユーザーオブジェクトを返すgetUser
抽象メソッド、及びトークンの有効性を確認するcheck
抽象メソッドを作成します。
CRUDと同様、抽象認証サービスから派生させてJWT認証サービスを作成します。
class JWTAuthService extends AbstractAuthService {
constructor(userCrud: AbstractUserCrud) {
super(userCrud);
}
}
ログインメソッドを書きましょう。ユーザー名とパスワードを受け取り、その正当性を評価し、有効であればトークンを生成して返します。
async login(username: string, password: string): Promise<string> {
const user = await this.userCrud.getUserByUsername(username);
if (user === undefined) {
// ユーザーが見つからなかったときの処理
}
if (password !== user.password) {
// パスワードが合わなかったときの処理
}
// ここでトークンを生成する
}
ここで、password !== user.password
と記述していますが、これにはセキュリティ上のリスクがあります。
実運用では、パスワードはデータベース等にハッシュ化して保存し、必要に応じてソルトやペッパーなどの処理を施しましょう。
今回は、学習用であるため無視します。
ユーザーが見つからないときは、UserNotFoundError
をスローすることにしましょう。
ログインに関するエラーであることを表すAbstractLoginError
抽象クラスを作り、そこから派生してUserNotFoundError
を作成します。
export abstract class AbstractLoginError extends Error {}
export class UserNotFoundError extends AbstractLoginError {
userName: string;
constructor(userName: string) {
super();
this.userName = userName;
this.message = `User ${userName} not found.`;
}
}
ユーザーが見つからなかったときは、これをスローするようにします。
const user = await this.userCrud.getUserByUsername(username);
if (user === undefined) {
throw new UserNotFoundError(username);
}
パスワードが合わなかったときの例外も同様に作成します。
export class PasswordNotMatchedError extends AbstractLoginError {
user: User;
constructor(user: User) {
super();
this.user = user;
this.message = `User ${user.username} login failed: mismatched password`;
}
}
// 中略
const user = await this.userCrud.getUserByUsername(username);
if (user === undefined) {
throw new UserNotFoundError(username);
}
if (password !== user.password) {
throw new PasswordNotMatchedError(user);
}
次に、トークンの生成機構を書きます。
まずはJWTの本体(ペイロード)を作成しましょう。
schema.ts
にToken
のスキーマを作成し、JWTトークンに含めたい情報を記載します。
export interface Token extends JWTPayload {
sub: string;
exp: number;
// 必要に応じて、roleやpermissionなどの情報をば・・・
}
auth.ts
に戻り、ペイロードを作成します。
function getTokenExp() {
return Math.floor(Date.now() / 1000) + 60 * TOKEN_EXPIRATION_MINUTES;
}
const payload: Token = {
sub: user.username,
exp: getTokenExp(),
};
Honoのヘルパーを使って、ペイロードに署名しJWTトークンを生成します。
const token = await sign(payload, SECRET);
最終的に、login
メソッドは以下のようになりました。
async login(username: string, password: string): Promise<string> {
const user = await this.userCrud.getUserByUsername(username);
if (user === undefined) {
throw new UserNotFoundError(username);
}
if (password !== user.password) {
// TODO: 本来はハッシュ化やソルトなどを用いるべきであるが、今回は勉強用なのでパス
throw new PasswordNotMatchedError(user);
}
// getTokenExpはloginの外に逃がした
const payload: Token = {
sub: user.username,
exp: getTokenExp(),
};
const token = await sign(payload, SECRET);
return token;
}
次にgetUser
及びcheck
を実装します。
まずは、署名したトークンからペイロードを入手するgetPayload
メソッドを作成します。
private async getPayload(token: string): Promise<Token> {
return (await verify(token, SECRET)) as Token;
}
これを使い、getUser
を実装します。
まずはgetPayload
を使ってペイロードを入手します。
async getUser(token: string): Promise<User> {
const payload = await this.getPayload(token);
}
認証サービスのコンストラクターで受け取ったAbstractUserCrud
を使って、payload.sub
からユーザーオブジェクトを取得します。
async getUser(token: string): Promise<User> {
const payload = await this.getPayload(token);
const user = await this.userCrud.getUserByUsername(payload.sub);
if (user === undefined) {
throw new UserNotFoundError(payload.sub);
}
return user;
}
check
はもっと簡単で、Honoのverify
関数は検証に失敗した時点で例外を吐く仕様になっているため、
愚直にtry-catchでreturnしてあげれば済みます。
async check(token: string): Promise<boolean> {
try {
await this.getPayload(token);
return true;
} catch (e) {
return false;
}
}
認証サービスが完成したら、最後にexportしてあげれば完成です。
最終的に、auth.ts
は以下のようになりました。
import { AbstractUserCrud, userCrud } from "./crud";
import { Token, User } from "./schema";
import { sign, verify } from "hono/jwt";
const TOKEN_EXPIRATION_MINUTES = 5;
const SECRET = process.env.SECRET;
function getTokenExp() {
return Math.floor(Date.now() / 1000) + 60 * TOKEN_EXPIRATION_MINUTES;
}
/**
* 認証を行うためのサービスを表す抽象クラス。
*/
export abstract class AbstractAuthService {
userCrud: AbstractUserCrud;
/**
* 認証を行うサービスを初期化します。
* @param userCrud ユーザーの読み取りに使うUserCrud。
*/
constructor(userCrud: AbstractUserCrud) {
this.userCrud = userCrud;
}
/**
* 指定されたユーザー名とパスワードで認証を行い、トークンを返します。
* 認証に失敗した場合、例外がスローされます。
* @param username 認証するユーザーの名前。
* @param password 認証するユーザーのパスワード。
*/
abstract login(username: string, password: string): Promise<string>;
/**
* トークンが有効かどうかを検査し、有効である場合はユーザーを返します。
* @param token 検査するトークン。
*/
abstract getUser(token: string): Promise<User>;
/**
* トークンが有効かどうかを検査し、有効である場合はtrueを、そうでない場合はfalseを返します。
* @param token 検査するトークン。
*/
abstract check(token: string): Promise<boolean>;
}
export abstract class AbstractLoginError extends Error {}
export class UserNotFoundError extends AbstractLoginError {
userName: string;
constructor(userName: string) {
super();
this.userName = userName;
this.message = `User ${userName} not found.`;
}
}
export class PasswordNotMatchedError extends AbstractLoginError {
user: User;
constructor(user: User) {
super();
this.user = user;
this.message = `User ${user.username} login failed: mismatched password`;
}
}
class JWTAuthService extends AbstractAuthService {
constructor(userCrud: AbstractUserCrud) {
super(userCrud);
}
async login(username: string, password: string): Promise<string> {
const user = await this.userCrud.getUserByUsername(username);
if (user === undefined) {
throw new UserNotFoundError(username);
}
if (password !== user.password) {
// TODO: 本来はハッシュ化やソルトなどを用いるべきであるが、今回は勉強用なのでパス
throw new PasswordNotMatchedError(user);
}
const payload: Token = {
sub: user.username,
exp: getTokenExp(),
};
const token = await sign(payload, SECRET);
return token;
}
async getUser(token: string): Promise<User> {
const payload = await this.getPayload(token);
const user = await this.userCrud.getUserByUsername(payload.sub);
if (user === undefined) {
throw new UserNotFoundError(payload.sub);
}
return user;
}
async check(token: string): Promise<boolean> {
try {
await this.getPayload(token);
return true;
} catch (e) {
return false;
}
}
private async getPayload(token: string): Promise<Token> {
return (await verify(token, SECRET)) as Token;
}
}
export const AuthService = new JWTAuthService(userCrud);
APIの処理を記述する
index.ts
の話に戻ります。
/token
ではユーザー名とパスワードを受け取り、それをAuthService.login
に渡したいですね。
ですので、今回はリクエストボディをzod
でパースし、バリデーションまで行いたいと思います。
まずは、zod
とバリデーターをインストールします。
bun add zod
bun add @hono/zod-validator
schema.ts
で、ログインの際に受け取るリクエストの形式(スキーマ)を決定します。
import { z } from "zod";
export const LoginRequest = z.object({
username: z.string(),
password: z.string(),
});
index.ts
では、json形式で受け取ったリクエストボディをzod
でバリデーションし、受け取ります。
app.post("/token", zValidator("json", LoginRequest), async (c) => {
const validated = c.req.valid("json");
});
token
はAuthService.login
にユーザー名とパスワードを渡すだけで済みましたね。
app.post("/token", zValidator("json", LoginRequest), async (c) => {
const validated = c.req.valid("json");
const token = await AuthService.login(
validated.username,
validated.password
);
return c.text(token);
});
これでトークンが取得できるはずです。
/token
に以下のリクエストを送ります。
POST /token HTTP/1.1
content-type: application/json
{
"username": "admin",
"password": "admin"
}
トークンが返ってくれば完了です。
APIエンドポイントを保護する
認証機能を作りましたが、認証されていなければアクセスできないエンドポイントがなければ意味がありません。
ここでは、HonoのBearer 認証ミドルウェアを使って、APIルートを保護したいと思います。
ミドルウェアを使用するために、app.use
を使用します。
app.use(
"/protected/*",
bearerAuth({
verifyToken: async (token, c) => {
},
})
);
このコードは、/protected
以下すべてのエンドポイントに対して、bearerAuth
ミドルウェアを適用するよ、というコードです。
ここで、verifyToken
はtrueかfalseのいずれかを返す関数でなければなりません。
幸いにも、AuthService
には、そのトークンが有効かどうかを返すcheck
メソッドを実装していました。
app.use(
"/protected/*",
bearerAuth({
verifyToken: async (token, c) => {
return await AuthService.check(token);
},
})
);
これはただのミドルウェアなので、/protected/
配下にエンドポイントを実装してあげます。
app.get("/protected/me", async (c) => {
return c.text(`Hello from protected API!`);
});
エラーハンドリング
最後に、Honoのエラーハンドリング機能を使って、各例外に対して適切なレスポンスを返してあげるように修正します。
app.onError
を使うことで、エラーハンドリングができます。
app.onError((error, c) => {
if (error instanceof UserNotFoundError) {
return c.text("User not found", 401);
}
if (error instanceof PasswordNotMatchedError) {
return c.text("Password not matched", 403);
}
if (error instanceof JwtTokenExpired) {
return c.text("Token expired", 401);
}
if (error instanceof JwtTokenInvalid) {
return c.text("Token invalid", 401);
}
if (error instanceof HTTPException) {
return error.getResponse();
}
throw error;
});
動作確認
1. トークンを取得する
/token
にPOSTリクエストを送ると、トークンが返されることを確認します。
2. トークンを使って保護されたエンドポイントにアクセスする
Authorization: Bearer <Token>
ヘッダーを使って、/protected/me
にGET
リクエストを送ると、きちんとレスポンスが返ってくることを確認します。
3. トークンがないと保護されたエンドポイントにはアクセスできないことを確認する
Authorizationヘッダーなしで/protected/me
にアクセスしても、401 Unauthorized
エラーになることを確認します。
今後のステップ
これは本当に最低限のAPIです。実際にデータベースへアクセスすることもなければ、セキュリティ的にかなり脆弱な部分が存在します。
ですので、ここで終わりにするのではなくて、足りない機能をもっと足していってみるのはいかがでしょうか。例えば:
- データベースに接続する
- セキュリティを意識する
- パスワードのハッシュ化、ソルト、ペッパーについて学ぶ
- 実際に何かしらのアプリケーションを作ってみる
それでは、お疲れ様でした!