Honoでユーザー認証機能つきAPIを爆速構築する

# はじめに

はじめまして。今回は、[Hono](https://hono.dev/)を使ってAPIサーバーを爆速で構築したいと思います。
JWTを使ったユーザー認証も実装してみましょう。

# 環境構築

この記事では、ランタイム及びパッケージ管理に[Bun](https://bun.sh/)を用います。
インストールが済んでいなければ先に済ませてください。[Node.js](https://nodejs.org/ja)及び[Node Package Manager (npm)](https://www.npmjs.com/)を使っている場合は、適宜読み替えてください。

# プロジェクトを作成する

基本的には[Hono の公式ドキュメント](https://hono.dev/docs/)に沿って進めていく形になります。

まずはターミナルを開き、honoのテンプレートを使ってプロジェクトをセットアップします。

```bash
$ 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サーバーを立ち上げる

プロジェクトルートで以下を実行します。

```bash
$ bun run dev
```

`Started development server: http://localhost:3000`
と出力されればOKです。

[Postman](https://www.postman.com/)や[Advanced REST Client](https://www.advancedrestclient.com/)などのAPIクライアントを使って、`http://localhost:3000`に`GET`リクエストを送ります。
`Hello Hono!`と返ってくれば問題ありません。

:::note
既定ではホットリロードが有効になっているはずです。そのため、開発用サーバーを都度再起動する必要はありません。
以後、開発用サーバーは起動されていることを前提に話が進みます。

（**ホットリロード**: ソースコードの変更をすぐに実行中のアプリケーションに反映させる機能）
:::

# 認証機能を作る

## 新しいエンドポイントを追加する

今回の目標はユーザー認証機能を持ったAPIサーバーを構築することですので、早速認証機能の制作に取り掛かりましょう。
幸いHonoには[JWT ヘルパー](https://hono.dev/docs/helpers/jwt)が標準搭載されているので、こちらを有効活用することにします。

まずは、新しいエンドポイントを定義しましょう。

```ts
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`を作成し、型を記述していきます。
まずはユーザーオブジェクトを定義します。

```ts
export interface User {
    username: string;
    password: string;
}
```

## ユーザーのCRUDを作成する

`src/crud.ts`を作成し、ユーザーオブジェクトに関するCRUD機能をまとめた`UserCrud`クラスを作成していきましょう。
ただし、今回はデータベースについて話すことが目的ではないので、あらかじめハードコードしたダミーデータを用いることにします。

まずは、ユーザーのCRUDを表す抽象クラスを作成します。

```ts
import { User } from "./schema";

/**
 * ユーザーのCRUDを表す抽象クラス。
 */
export abstract class AbstractUserCrud {

}
```

CRUDですので、最低限の読み書きはできるようにしたいところです。
ただし、今回は学習用ということで、ひとまず読み取りだけができるようにします。

すべてのユーザーを返す`getAllUsers`抽象メソッドと、ユーザー名からユーザーを取得する`getUserByUsername`抽象メソッドを作成します。

```ts
/**
 * ユーザーのCRUDを表す抽象クラス。
 */
export abstract class AbstractUserCrud {
    /**
     * 登録されているすべてのユーザーを返します。
     */
    abstract getAllUsers(): Promise<User[]>;

    /**
     * 指定されたユーザー名をもとに、Userオブジェクトを返します。
     * 見つからない場合は `undefined` が返されます。
     * @param username 検索するユーザー名。
     */
    abstract getUserByUsername(username: string): Promise<User | undefined>;
}
```

では、テスト用のダミーユーザーデータのCRUDを作成します。
`AbstractUserCrud`を継承して作成しましょう。

```ts
/**
 * テスト用のダミーユーザーを読み取るためのCRUD。
 */
class DummyUserCrud extends AbstractUserCrud {

}
```

学習用ということで、ユーザー名とパスワードをコンストラクタでハードコードします。

```ts
/**
 * テスト用のダミーユーザーを読み取るための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`はとてもシンプルな実装になるはずです。

```ts
    async getAllUsers() {
        return this.users;
    }

    async getUserByUsername(username: string) {
        return this.users.find((user) => user.username === username);
    }
```

最後に、`DummyUserCrud`のインスタンスをexportします。

```ts
export const userCrud: AbstractUserCrud = new DummyUserCrud();
```

全体のコードは以下のようになりました。

```ts
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分に、シークレットを環境変数から読み出すようにします。

```ts
const TOKEN_EXPIRATION_MINUTES = 5;
const SECRET = process.env.SECRET;
```

`.env`に環境変数のリストを記載します。（今回はシークレット一つだけです）

```
SECRET="ThisIsMySecret"
```

Bunでは`.env`は自動で読み込まれるため、特段の処理は不要です。
必要であれば読み込むコードを書かなければなりませんが、今回の主題ではないので割愛します。

次に`src/auth.ts`を作成し、認証を行うためのサービスを表す抽象クラスを作成します。

```ts
/**
 * 認証を行うためのサービスを表す抽象クラス。
 */
export abstract class AbstractAuthService {

}
```

ユーザーの読み取りに使うCRUDを、コンストラクタから受け取るようにします。

```ts
/**
 * 認証を行うためのサービスを表す抽象クラス。
 */
export abstract class AbstractAuthService {
    userCrud: AbstractUserCrud;

    /**
     * 認証を行うサービスを初期化します。
     * @param userCrud ユーザーの読み取りに使うUserCrud。
     */
    constructor(userCrud: AbstractUserCrud) {
        this.userCrud = userCrud;
    }
}
```

次に、認証を行ってトークンを返す`login`抽象メソッドと、トークンからユーザーオブジェクトを返す`getUser`抽象メソッド、及びトークンの有効性を確認する`check`抽象メソッドを作成します。

CRUDと同様、抽象認証サービスから派生させてJWT認証サービスを作成します。

```ts
class JWTAuthService extends AbstractAuthService {
    constructor(userCrud: AbstractUserCrud) {
        super(userCrud);
    }
}
```

ログインメソッドを書きましょう。ユーザー名とパスワードを受け取り、その正当性を評価し、有効であればトークンを生成して返します。

```ts
    async login(username: string, password: string): Promise<string> {
        const user = await this.userCrud.getUserByUsername(username);
        if (user === undefined) {
            // ユーザーが見つからなかったときの処理
        }
        if (password !== user.password) {
            // パスワードが合わなかったときの処理
        }
        // ここでトークンを生成する
    }
```

:::note warn
ここで、`password !== user.password`と記述していますが、これにはセキュリティ上のリスクがあります。
実運用では、パスワードはデータベース等にハッシュ化して保存し、必要に応じてソルトやペッパーなどの処理を施しましょう。

今回は、学習用であるため無視します。
:::

ユーザーが見つからないときは、`UserNotFoundError`をスローすることにしましょう。
ログインに関するエラーであることを表す`AbstractLoginError`抽象クラスを作り、そこから派生して`UserNotFoundError`を作成します。

```ts
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.`;
    }
}
```

ユーザーが見つからなかったときは、これをスローするようにします。

```ts
        const user = await this.userCrud.getUserByUsername(username);
        if (user === undefined) {
            throw new UserNotFoundError(username);
        }
```

パスワードが合わなかったときの例外も同様に作成します。

```ts
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トークンに含めたい情報を記載します。

```ts
export interface Token extends JWTPayload {
    sub: string;
    exp: number;

    // 必要に応じて、roleやpermissionなどの情報をば・・・
}
```

`auth.ts`に戻り、ペイロードを作成します。

```ts
        function getTokenExp() {
            return Math.floor(Date.now() / 1000) + 60 * TOKEN_EXPIRATION_MINUTES;
        }

        const payload: Token = {
            sub: user.username,
            exp: getTokenExp(),
        };
```

Honoのヘルパーを使って、ペイロードに署名しJWTトークンを生成します。

```ts
        const token = await sign(payload, SECRET);
```

最終的に、`login`メソッドは以下のようになりました。

```ts
    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`メソッドを作成します。

```ts
    private async getPayload(token: string): Promise<Token> {
        return (await verify(token, SECRET)) as Token;
    }
```

これを使い、`getUser`を実装します。

まずは`getPayload`を使ってペイロードを入手します。

```ts
    async getUser(token: string): Promise<User> {
        const payload = await this.getPayload(token);
    }
```

認証サービスのコンストラクターで受け取った`AbstractUserCrud`を使って、`payload.sub`からユーザーオブジェクトを取得します。

```ts
    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してあげれば済みます。

```ts
    async check(token: string): Promise<boolean> {
        try {
            await this.getPayload(token);
            return true;
        } catch (e) {
            return false;
        }
    }
```

認証サービスが完成したら、最後にexportしてあげれば完成です。

最終的に、`auth.ts`は以下のようになりました。

```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`とバリデーターをインストールします。

```bash
bun add zod
bun add @hono/zod-validator
```

`schema.ts`で、ログインの際に受け取るリクエストの形式（スキーマ）を決定します。

```ts
import { z } from "zod";

export const LoginRequest = z.object({
    username: z.string(),
    password: z.string(),
});
```

`index.ts`では、json形式で受け取ったリクエストボディを`zod`でバリデーションし、受け取ります。

```ts
app.post("/token", zValidator("json", LoginRequest), async (c) => {
    const validated = c.req.valid("json");
});
```

`token`は`AuthService.login`にユーザー名とパスワードを渡すだけで済みましたね。

```ts
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`に以下のリクエストを送ります。

```http
POST /token HTTP/1.1
content-type: application/json

{
  "username": "admin",
  "password": "admin"
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2155106/930f9a46-2b9c-49e4-89eb-b4e7000a5084.png)

トークンが返ってくれば完了です。

# APIエンドポイントを保護する

認証機能を作りましたが、認証されていなければアクセスできないエンドポイントがなければ意味がありません。
ここでは、Honoの[Bearer 認証ミドルウェア](https://hono.dev/docs/middleware/builtin/bearer-auth)を使って、APIルートを保護したいと思います。

ミドルウェアを使用するために、`app.use`を使用します。

```ts
app.use(
    "/protected/*",
    bearerAuth({
        verifyToken: async (token, c) => {
        
        },
    })
);
```

このコードは、`/protected`以下すべてのエンドポイントに対して、`bearerAuth`ミドルウェアを適用するよ、というコードです。

ここで、`verifyToken`はtrueかfalseのいずれかを返す関数でなければなりません。
幸いにも、`AuthService`には、そのトークンが有効かどうかを返す`check`メソッドを実装していました。

```ts
app.use(
    "/protected/*",
    bearerAuth({
        verifyToken: async (token, c) => {
            return await AuthService.check(token);
        },
    })
);
```

これはただのミドルウェアなので、`/protected/`配下にエンドポイントを実装してあげます。

```ts
app.get("/protected/me", async (c) => {
    return c.text(`Hello from protected API!`);
});
```

# エラーハンドリング

最後に、Honoのエラーハンドリング機能を使って、各例外に対して適切なレスポンスを返してあげるように修正します。
`app.onError`を使うことで、エラーハンドリングができます。

```ts
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です。実際にデータベースへアクセスすることもなければ、セキュリティ的にかなり脆弱な部分が存在します。
ですので、ここで終わりにするのではなくて、足りない機能をもっと足していってみるのはいかがでしょうか。例えば:

* データベースに接続する
* セキュリティを意識する
    * パスワードのハッシュ化、ソルト、ペッパーについて学ぶ
* 実際に何かしらのアプリケーションを作ってみる

それでは、お疲れ様でした！

# 参考文献

[Hono 公式ドキュメント](https://hono.dev/docs)
[Zod 公式ドキュメント](https://zod.dev/)
[Bun 公式ドキュメント](https://bun.sh/docs/)