この章で行うこと

  • ユースケースの実装
  • JWTを理解する
  • トークンベース認証(JWT)の具体実装

認証方式について

この章では認証認可機能の実装を行なっていきます。

認証方式はいくつかありますが、今回はセッションベース認証の要素も取り入れたステートフルなトークンベース認証を行なっていきます。

セッションベース認証とトークンベース認証(ざっくり)

上の記事を読んでいただいた方が詳しいですが、ここでもざっくり述べておきます。

セッションベース

セッションIDを扱うステートフルな認証方式です。

  1. ログイン処理

    • ユーザーが正しい認証情報(例: メールアドレスとパスワード)を送信します
    • サーバーはセッションID(通常はランダムな文字列)を生成し、クライアントに送信します。
      • サーバーはセッションIDとユーザー情報を紐付けてDBなどに保存します。
    • クライアントはセッションIDをCookieやローカルストレージに保存します(一般的にはCookieに保存)。
  2. リクエスト時のセッションID送信

    • クライアントは以後のリクエストにセッションIDを含めて送信します。
  3. サーバーでのセッション確認

    • サーバーは受け取ったセッションIDをデータベースやメモリストア(例: Redis)で確認。
      • セッションIDが有効であれば、紐付けられたユーザー情報を基にリソースを提供します。
      • セッションIDが無効(存在しない、期限切れ、または破棄された)であれば、認証エラーを返します。

サーバー側でログイン状態を持っている(→セッションIDを保存しているところ)点がステートフルであると言えます。

トークンベース

アクセストークンを扱うステートレスな認証方式です。

  1. ログイン処理

    • ユーザーが正しい認証情報(例: メールアドレスとパスワード)を送信します。
    • サーバーは認証に必要な情報(ユーザーIDなど、アクセス可能なリソースのスコープ)を含めたアクセストークンを生成し、クライアントに送信します。
      • 改竄ができないようにJWT形式であることが多いです。
      • 有効期限などの情報もアクセストークンには含まれます。
    • クライアントはアクセストークンをCookieやローカルストレージに保存します(一般的にはCookieに保存)。
  2. リクエスト時のアクセストークン送信

    • クライアントは以後のリクエストにアクセストークンを含めて送信します。
  3. サーバーでのトークン検証

    • クライアントから送信されたアクセストークンを確認し、有効期限が切れていないか、また、改竄などが行われていないかを確認します
      • アクセストークンが有効であれば、トークンに含まれた情報(スコープ)を基にリソースを返します
      • アクセストークンが不正(有効期限切れや改竄が検出)な場合は、認証エラーを返します。

サーバーはユーザーのログイン状態を保持しているわけではなく、認証の処理はアクセストークンの検証のみとなっている点で、ステートレスといえます。

詳しくは以下の記事がわかりやすいです。

https://zenn.dev/tanaka_takeru/articles/3fe82159a045f7

トークンベース認証のデメリット

トークンベースの認証のメリットは、ステートレスに扱えて仕組み自体がシンプルになることです。また、状態を保持しない分サーバーへの負荷も少なく、スケーラブルであることが挙げられています。

しかし、サーバー側でユーザーのログイン状態を保持できないことは、ログアウト処理が実現できないことを意味します。

認証はトークンの検証のみとなるわけなので、トークンが有効な限りは認証に成功します。有効なトークンとは、例えば「改竄のされていない&有効期限がすぎていない」トークンなどを指します。アクセストークンをサーバーが「無効化」することはできません。つまり、ログアウトが不可能ということです。

ログアウトができないことは、アクセストークンの流出などを考えるとセキュリティ的に危険であると推測できます。

そこで、セッションベース認証の要素を取り入れ、ステートフルにアクセストークンを扱えるようにします。

ステートフルなトークンベース認証

サーバー側で「ログイン状態」を保持するようにします。具体的には、発行したトークン情報をサーバーに保存し、それをもとに認証を行います。

ログイン後、ヘッダーにトークン(署名ずみ)を含むリクエストが来た場合、

  • そのトークン情報がサーバー側に保存されているか
  • 改竄されていない
  • 有効期限がすぎていない

場合は、認証が成功するようにします。

そして、ログアウト時にはサーバー側に保存していたトークン情報を破棄するようにします。これによって、有効なトークンを含むリクエストがきても、ログアウト後は認証に失敗するという状況を作り出すことができます。

トークンベース認証の仕組みだけでは実現しづらいログアウト処理をこのようにして実装していきます。

じゃぁセッションベースでいいのでは?

「トークン情報を保存してログイン状態を保持している点」において、ステートフルなセッションベースを使っても、差し支えなくないか?と考えることもできます。

しかし、トークンベースであることは以下のメリットがあります。

  • 改竄検知とセキュリティ性
  • 有効期限の管理容易性
  • 場合によってはトークンの検証だけで不正なアクセスは弾ける

改竄検知とセキュリティ性

アクセストークンには、この後説明するJWTを使用しますが、トークンには「署名」を含めることができます。この「署名」によってトークンが改竄されてもそれを検知することができるので、不正なアクセス(なりすましなど)を防ぐことができます。

署名については、次節にて説明します。

有効期限の管理容易性

これも、JWTの特徴になりますが、トークン自体に有効期限を含めることができます。

場合によってはステートレスに扱える

「場合によっては」というのは、次のような場合です。

  • トークンが改竄されている
  • トークンの有効期限が切れている

セッションベースでは基本的に、Cookiesに保存されたセッションIDをキーとしてDB、KVSなどにアクセスして検証を行うステートフルなものです。ただ、上で説明したように、有効期限・改竄が原因で失効したトークンは検知できるので、DB、KVSにへアクセスするする前に不正なアクセスとして弾けます。

この点で、「場合によっては」、トークンの検証のみの処理が可能となり、ステートレスに扱えるというわけです。

トークンは有効だが、「ログイン状態かどうか」を判断したい場合のみ、DBまたはKVSにアクセスし、ステートフルに扱うことになります。必ずしも毎回アクセスする必要はないという点で、サーバー側の負荷は少なそうです。

つまり、ステートレスなトークンベース認証をステートフルに拡張することによって、セッションベース認証よりもサーバー負荷を軽減することができている、といえます。

今回は、トークン情報の保存にRedisを使用します。

JWT(JSON Web Token)について

アクセストークンとして、JWTを使用していきます。「トークンベース認証といえば、JWT」といっても差し支えないでしょう。

JWTについて詳しい&わかりやすい記事は以下です。

https://qiita.com/knaot0/items/8427918564400968bd2b https://qiita.com/asagohan2301/items/cef8bcb969fef9064a5c

ここでも、軽く説明をしておきます。 JWTは、以下のような構成の文字列となっています。

以下のヘッダとペイロードは、JSON形式だったのをエンコードした文字列となっています。

ヘッダ.ペイロード.署名
  • ヘッダ
    • 使用する署名アルゴリズムの種類を含めるなど、JWTの説明部分です
  • ペイロード
    • トークンには情報を含めることができると先述しましたが、それがここです
    • ユーザーid(sub)や、有効期限(exp)などの情報を含めます
      • subやexpといった含めるデータの項目をクレームといいます
  • 署名
    • 他人にトークンが改竄されたかどうかを検出できる部分です
    • ヘッダとペイロードを署名した文字列です

署名部分は、ヘッダとペイロードから成っています。ということはもちろん、署名部分を復号すれば、ヘッダとペイロードがわかります。

JWTは、「ヘッダ+ペイロード」と復号した署名部分を比較して、改ざんを検知するのです。

例えば、ペイロードが改竄された場合、復号した署名部分と一致しなくなります。

署名とは

「電子署名」という言葉を聞いたことがあるでしょうか?JWTの署名の仕組みは、それと少し似ています。

仕組みとしては、「秘密鍵」で署名を作成し、「公開鍵」でその署名を検証します。

  • 秘密鍵はシステム側で厳重に管理される機密情報です。
  • 公開鍵は、その名の通り外部に公開できるもので、誰でも利用可能です。

これらの鍵はペアで生成されるため、特定の秘密鍵で署名されたデータは、そのペアとなる公開鍵でしか検証できません。

これによって、以下のことを保証できます。

  • データの改ざん防止
    • 署名を検証することで、データが改ざんされていないことを確認できます。
  • 送信者の認証
    • 署名が秘密鍵で生成されているため、その鍵を持つ送信者本人からのものであることを確認できます。

一般的には公開鍵を外部に公開することで、第三者が署名を検証できる仕組みとなります。

ただし、今回は、公開鍵と秘密鍵をどちらもシステム内部で管理します。この点で、システム内で署名と検証が完結するため、公開鍵と秘密鍵の役割の違いが実質的に薄れているとはいえそうですね。

JWTの中身は誰でも見れる

誤解するべきではないのは、例えばJWTトークンが盗まれた場合、署名してるから大丈夫やんね...となることです!

見出しの通り、JWTの中身は誰でも見れます。 JWTはそもそも、ヘッダ.ペイロード.署名であり、ヘッダとペイロードは、JSON形式であったものがエンコードされているだけなので、誰でもデコードできてしまいます。

そしてそもそも「署名」は、その署名の発行者本人のものからであることを保証するためのものであり、改ざんを検知する仕組みです。誰でも使える「公開鍵」で復号するわけだから、言われてみればそうですね。つまり、署名でデータの盗聴などは防ぐことができません。

あくまでJWTはデータの改ざんを検知するためのものです。そのため万一、JWTが盗まれた時のためにペイロードには重要な情報(パスワードなど)を含めるべきではありません。

認証・認可の実装方針

認証・認可の処理の流れと、アーキテクチャのどこに認証を実装していくかの設計に関して述べます。

流れ

具体的にどういった処理の流れになるかを整理しておきます。なお、ログイン、ログアウト、認可制御の処理の流れは以下の記事を参考にさせていただきました。

https://zenn.dev/yuki_tu/articles/fd1bd44becebd5

ほぼ上の記事に即していますが、下に流れをまとめてみます。

ログイン

後の章にて、ハンドラとして実装します。

  1. クライアントは、メールアドレス・パスワードを入力する
  2. サーバーは、入力されたメールアドレスを持つユーザーが存在するか検索する
    • 存在しない場合:エラーを返す
  3. サーバーは、(上のメールアドレスを持つ)ユーザーのパスワードと入力されたパスワードを比較する
    • パスワードが一致しない場合:エラーを返す
  4. サーバーは、クレームとしてユーザーの持つidを含めたJWTトークンを生成する
    • この時、JWTトークンを識別するid(jti)を生成し、クレームとしてトークンに含める
  5. サーバーは、ユーザーの持つid(userID)をキー・jtiをバリューとしてKVSに保存
  6. サーバーは、JWTトークンに秘密鍵で署名を行う
  7. 署名済みJWTトークンをクライアントに返す

ログアウト

後の章にて、ハンドラとして実装します。

  1. (認可制御を行う)
  2. サーバーは、userIDをキーとしてレコードをKVSから削除する

認可制御

後の章にて、認可制御はミドルウェアとして実装します。

  1. クライアントは、署名済みJWTトークンをヘッダーに含めてサーバーに送信する(リクエスト)
  2. サーバーは、署名済みJWTトークンを公開鍵で検証する
    • 不正または改竄が検知されればエラーを返す
  3. サーバーは、トークンの有効期限を検証する
    • 有効期限が過ぎていればエラーを返す
  4. サーバーは、トークンからjtiを取り出す
  5. サーバーは、トークンからuserIDを取り出す
  6. サーバーは、userIDをキーにKVSに問い合わせて、jtiを取得する
    • 取得できなければエラーを返す(ログイン状態ではないということ)
  7. サーバーは、KVSから取得したjtiとトークンから取り出したjtiが一致するか比較
    • 一致しなければエラーを返す
  8. サーバーは後続の処理(ハンドラ)にuserIDを渡す

設計

第3章にて、「認証はドメインか」という話をしました。

結論、筆者の考えとしては、認証はドメイン知識に含めないことにしました。認証は、技術的要素であり、アプリケーションのシステム上必要になったものと考えたからです。

そのため、

  • 「ログイン/ログアウト/認可」はユースケースとしてアプリケーション層に定義し、JWTを使用した認証の具体実装はインフラストラクチャ層に実装する
  • ユースケースには、トークン認証機能インターフェースに依存させるようにして依存関係を守る

という設計にしたいと思います。

JWTは外部の技術要素であり、それを利用する「認証機能」は「実装の詳細」と位置付けられます。そのため、JwtAuthenticatorはインフラストラクチャ層におきます。ログイン/ログアウト/認可については、機能として提供するものであるため、ユースケースとしてアプリケーション層に配置します。ここで、ユースケースがJwtAuthenticatorを直接利用してしまうと、依存性のルールに反してしまうので、同じくアプリケーション層にJwtAuthenticatorインターフェースを定義して、それに依存するようにします。

なお、ログイン・ログアウトのユースケースはのちにハンドラが利用し、認可制御のユースケースはミドルウェアが利用します。

参考にした記事

https://zenn.dev/praha/articles/5c05ab671fb7ab

実装

以下の順番で実装していきます。

  1. リポジトリ(JwtAuthenticatorRepository) の実装
    • KVS操作の具体実装を抽象化するKvsCommanderインターフェースを定義
      • Redisを操作する具体実装RedisCommanderも実装
    • リポジトリはKvsCommanderに依存させる
    • リポジトリインターフェースも定義する
    • リポジトリのテストを記述する
  2. 各ユースケースの実装
    • 認証機能のインターフェースJwtAuthenticatorを定義する
    • ユースケースはリポジトリインターフェースに依存させる
    • ユースケースをテストする
  3. 認証機能の具体実装
    • jwtを使用する
    • JwtAuthenticator(具体型)をテストする

リポジトリの実装

少しややこしいことを上で書いてしまいましたが、やることはDBリポジトリの時と変わりません。第8章では、ORMを実装の詳細として、リポジトリとORMの間にインターフェース(Querier)を定義して境界線を引きました。

ここでは、リポジトリとKVS(具体)の間にインターフェースを定義して境界線をひき、リポジトリに具体実装が混入しないようにします。

というのも、KVSはRedisの他にもMemcachedなどがあります。それに、今回はRedisを扱うのにgithub.com/redis/go-redis/v9パッケージを使用していますが、github.com/gomodule/redigoなど他にもあるようです。

https://github.com/gomodule/redigo

リポジトリはデータストアの操作を抽象化するものです。ですが、インターフェースアダプター層にデータストア操作を行う具体実装は配置したくありません。それはインフラストラクチャ層に実装すべきです。そこで、さらにKVS操作を抽象化する(KvsCommanderインターフェース)ことで、「実装の詳細」をインフラストラクチャ層に追いやろうということです。こうすることで、パッケージを乗り換えることも、KVSを乗り換えることもしやすくなると考えています。

それでは、実装に入ります。

KVS操作の具体実装

まずは、KVS操作の振る舞いを定義したKvsCommanderインターフェースを定義し、それを満たす具体実装としてRedisCommanderを実装します。

KVS操作の実装

リポジトリで利用するKvsCommanderインターフェース

// app/adapter/repository/interface_kvs_commander.go
package repository

import (
	"context"
	"time"
)

// KVSに対するインターフェース
type KvsCommander interface {
	Save(ctx context.Context, duration time.Duration, userID, jwtID string) error
	Load(ctx context.Context, userID string) (string, error)
	Delete(ctx context.Context, userID string) error
}

具体実装はインフラストラクチャ層に配置します

// app/infrastructure/kvs/redis_commander.go
package kvs

import (
	"context"

	"time"

	"github.com/kakkky/app/domain/errors"
	"github.com/redis/go-redis/v9"
)

type RedisCommander struct {
	cli *redis.Client
}

func NewRedisCommander() *RedisCommander {
	return &RedisCommander{
		cli: GetRedisClient(),
	}
}

func (rc *RedisCommander) Save(ctx context.Context, duration time.Duration, userID, jwtID string) error {
	// Redis クライアントが nil の場合はエラーを返す
	if rc.cli == nil {
		return errors.New("failed to get Redis client")
	}
	status := rc.cli.Set(ctx, userID, jwtID, duration)
	if status.Err() != nil {
		return status.Err()
	}
	return nil
}

func (rc *RedisCommander) Load(ctx context.Context, userID string) (string, error) {
	// Redis クライアントが nil の場合はエラーを返す
	if rc.cli == nil {
		return "", errors.New("failed to get Redis client")
	}
	status := rc.cli.Get(ctx, userID)
	if status.Err() != nil {
		// nilだったら空文字を返す
		if status.Err() == redis.Nil {
			return "", nil
		}
		return "", status.Err()
	}
	return status.Val(), nil
}

func (rc *RedisCommander) Delete(ctx context.Context, userID string) error {
	// Redis クライアントが nil の場合はエラーを返す
	if rc.cli == nil {
		return errors.New("failed to get Redis client")
	}
	status := rc.cli.Del(ctx, userID)
	if status.Err() != nil {
		if status.Err() == redis.Nil {
			return nil
		}
		return status.Err()
	}
	return nil
}

リポジトリの実装

定義したKVS操作の振る舞い(KvsCommander)を利用する形でリポジトリを実装します。 JWTトークンベース認証で使用するので、JwtAuthenticatorRepositoryと命名しておきました。

ユースケースにリポジトリのインターフェースを定義するので、app/application/usecase/auth/ディレクトリを作成してください。

リポジトリの実装

リポジトリのインターフェース

// app/application/usecase/auth/interface_jwt_authenticator_repository.go
package auth

import (
	"context"
	"time"
)

// トークンをKVSで操作するリポジトリインターフェース

//go:generate mockgen -package=auth -source=./interface_jwt_authenticator_repository.go -destination=./mock_jwt_authenticator_repository.go
type JwtAuthenticatorRepository interface {
	Save(ctx context.Context, duration time.Duration, userID, jti string) error
	Load(ctx context.Context, userID string) (string, error)
	Delete(ctx context.Context, userID string) error
}

リポジトリの具体実装

// app/adapter/repository/jwt_authenticator_repository.go
package repository

import (
	"context"

	"time"
)

type jwtAuthenticatorRepository struct {
	kvsCommander KvsCommander
}

func NewJwtAuthenticatorRepository(kvsCommander KvsCommander) *jwtAuthenticatorRepository {
	return &jwtAuthenticatorRepository{
		kvsCommander: kvsCommander,
	}
}

func (tar *jwtAuthenticatorRepository) Save(ctx context.Context, duration time.Duration, userID, jti string) error {
	return tar.kvsCommander.Save(ctx, duration, userID, jti)
}

// 存在しないKEYを指定した場合は空文字を返す
func (tar *jwtAuthenticatorRepository) Load(ctx context.Context, userID string) (string, error) {
	return tar.kvsCommander.Load(ctx, userID)
}

func (tar *jwtAuthenticatorRepository) Delete(ctx context.Context, userID string) error {
	return tar.kvsCommander.Delete(ctx, userID)
}

KvsCommanderに依存しており、KVSへの操作がかなり抽象化されています。特にビジネスロジックもなく、これならそもそもリポジトリをインフラストラクチャに置いちゃってもいいのでは?とも考えられます。

しかし、クリーンアーキテクチャを守って、リポジトリというデータストアへの窓口はインターフェースアダプターにおき、具体的なKVS操作をインフラストラクチャに切り離すというようにしてみました。

これは単に暗黙的にルールに従っているわけではありません。

現段階では、*jwtAuthenticatorRepository.Load()の返り値としてjtiを文字列として返しているだけですが、何らかの構造体に詰め替えて返したいという仕様変更があったとしましょう。リポジトリはインターフェースアダプター層に/KVS操作の具体はインフラストラクチャ層にというふうに分離させておけば、「構造体に詰めて返す」というビジネスロジックはリポジトリの方に追加すればいいのだなという判断をすることができます。

インフラストラクチャ層に実装したKvsCommander.Load()は、単にキーに紐づくバリューを返すという純粋なKVS操作だけを担えば良いのです。データ操作にロジックを書く必要が出てくれば、それはリポジトリに実装するようにします。

クリーンアーキテクチャに沿って設計すると、図らずとも責務の分離が可能になり、変更や拡張に強くなるのですね。ただ、コード量が増えるという欠点はあります。(柔軟になることを考えればコード量は目を瞑ってもいいのではないでしょうか....)

リポジトリのテスト

リポジトリのテストを書いていきます。これまではMySQLサーバーをdockertestにて起動し、接続するというやり方をとっていました。

今回はKVSとしてRedisを操作するリポジトリなので、手法は若干変わりますが、似たようなことを行います。

ここでは、miniredisというパッケージを使用して、Redisのテストクライアントを起動し、テストではそれに接続します。

https://github.com/alicebob/miniredis

Sometimes you want to test code which uses Redis, without making it a full-blown integration test. Miniredis implements (parts of) the Redis server, to be used in unittests.

訳)本格的な統合テストを行わずに、Redis を使用するコードをテストしたい場合があります。Miniredis は、ユニットテストで使用するために、Redis サーバー (の一部) を実装します。

Think of it as the Redis version of net/http/httptest.

It saves you from using mock code, and since the redis server lives in the test process you can query for values directly, without going through the server stack. 訳) net/http/httptestのRedis版と考えてください。モックコードを使わなくて済みますし、Redisサーバーがテストプロセス内にあるので、サーバースタックを経由せずに直接値を問い合わせることができます。

ドキュメントにある通りですが、例えばdocker内でサーバーを起動せずとも、Redisサーバーを扱えるというものです。

コマンドを叩いてパッケージをインストールします。

make get-app name="github.com/alicebob/miniredis"

テスト用redisサーバーを起動し、redis_client.goに定義したredisClientパッケージ変数にテスト用クライアントを設定するNewRedisTestClient()関数を定義します。シグネチャは、開発環境で使用するNewRedisClient()とほとんど同じにしています。

// app/infrastructure/kvs/redis_test_client.go
package kvs

import (
	"log"

	"github.com/alicebob/miniredis"
	"github.com/redis/go-redis/v9"
)

func NewRedisTestClient() *redis.Client {
	// redisサーバを作る
	s, err := miniredis.Run()
	if err != nil {
		log.Fatalf("failed to run miniredis :%v", err)
	}
	// *redis.Clientをパッケージ変数にセット
	redisClient = redis.NewClient(&redis.Options{
		Addr:     s.Addr(),
		Password: "",
		DB:       0,
	})
	return redisClient
}

次に、repository_test.goファイルに定義したTestMain()にNewRedisTestClient()を追加しましょう。

// app/infrastructure/repository_test/repository_test.go
func TestMain(m *testing.M) {
    // 省略

    // 以下を追加
	// テスト用のredisサーバーを起動
	cli := kvs.NewRedisTestClient()
	defer cli.Close()

	m.Run()
}

最後に、リポジトリのテストを記述します。

リポジトリのテスト
// app/infrastructure/repository_test/jwt_authenticator_repository_test.go
package repository_test

import (
	"context"
	"testing"
	"time"

	"github.com/kakkky/app/adapter/repository"
	"github.com/kakkky/app/infrastructure/kvs"
)

func TestJwtAuthenticatorRepository_Save_And_Load_And_Delete(t *testing.T) {
	jwtAuthenticatorRepository := repository.NewJwtAuthenticatorRepository(kvs.NewRedisCommander())
	type args struct {
		userID   string
		jti    string
		duration time.Duration
	}
	tests := []struct {
		name    string
		args    args
		want    string
		wantErr bool
	}{
		{
			name: "正常系: userID:jtiのペアでredisに保存できる",
			args: args{
				userID:   "1",
				jti:    "jti",
				duration: time.Duration(1 * time.Minute),
			},
			want:    "jti",
			wantErr: false,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			ctx := context.Background()
			if err := jwtAuthenticatorRepository.Save(ctx, tt.args.duration, tt.args.userID, tt.args.jti); (err != nil) != tt.wantErr {
				t.Errorf("jwtAuthenticatorRepository.Save() error=%v,wantErr %v", err, tt.wantErr)
			}
			got, err := jwtAuthenticatorRepository.Load(ctx, tt.args.userID)
			if (err != nil) != tt.wantErr {
				t.Errorf("jwtAuthenticatorRepository.Load() error=%v,wantErr %v", err, tt.wantErr)
			}
			if got != tt.want {
				t.Errorf("jwtAuthenticatorRepository.Load() got %v, want %v", got, tt.want)
			}
			if err := jwtAuthenticatorRepository.Delete(ctx, tt.args.userID); (err != nil) != tt.wantErr {
				t.Errorf("jwtAuthenticatorRepository.Delete() error=%v,wantErr %v", err, tt.wantErr)
			}
		})
	}
}

テストクライアントを使用する流れの概要

どういう流れでテスト環境ではテスト用クライアントを使用しているかが若干わかりにくいかなと思うので説明しておきます。

リポジトリには、KvsCommanderインターフェースの具体実装であるRedisCommanderをDI(依存性の注入)しています。

jwtAuthenticatorRepository := repository.NewJwtAuthenticatorRepository(kvs.NewRedisCommander())

kvs.NewRedisCommander()は、どういう処理になっていたかというと、

func NewRedisCommander() *RedisCommander {
	return &RedisCommander{
		cli: GetRedisClient(),
	}
}

クライアントを内部に持っているのですね。

Redisクライアントは、パッケージ変数で管理していました。そして、それをGetRedisClient()で取得できるようにしていました。これは開発環境も同じです。

var redisClient *redis.Client

// パッケージ変数を取得
func GetRedisClient() *redis.Client {
	return redisClient
}

TestMain()では、kvs.NewRedisTestClient()を呼び出して、テスト用のクライアントをパッケージ変数に設定しています。

func NewRedisTestClient() *redis.Client {
    // 省略

	// *redis.Clientをパッケージ変数にセット
	redisClient = redis.NewClient(&redis.Options{
		Addr:     s.Addr(),
		Password: "",
		DB:       0,
	})
	return redisClient
}

開発環境と流れは同じでした。 Redisクライアントをパッケージ変数に設定して、それをRedisCommanderが利用している、というただそれだけです。

そのパッケージ変数にをテスト用クライアントを設定するか、または開発環境用のクライアントを設定するかが異なるだけです。

以下の図に整理しておきました。

ユースケースの実装

ログイン、ログアウト、認可制御のそれぞれをユースケースとして実装していきます。

ユースケースの中では、トークン認証機能のインターフェース(JwtAuthenticator)とトークン認証リポジトリのインターフェース(JwtAuthenticatorRepository)を利用します。

トークン認証機能のインターフェースはまだ定義していないので、以下のように記述しておいてください。

JwtAuthenticatorインターフェース
// app/application/usecase/auth/interface_jwt_authenticator.go
package auth

// トークンを生成する処理するインターフェース
//
//go:generate mockgen -package=auth -source=./interface_jwt_authenticator.go -destination=./mock_jwt_authenticator.go
type JwtAuthenticator interface {
	GenerateJwtToken(sub, jti string) (string, error)
	VerifyJwtToken(jwtToken string) (sub string, jti string, err error)
}

GenerateJwtToken()は、ペイロードに含めたい情報(クレーム)を引数に入れ、署名済みのトークン文字列を返します。VerifyJwtToken()は、署名済みトークンを引数に取り、トークンを検証(改竄・有効期限)し、クレームを取り出して返すようにします。

また、ユースケースのテストでは、それぞれのモックを使用するので以下のコマンドでgo generateしてモックを生成しておいてください(インターフェース定義の上にgo:generateディレクティブを記述しています)

ログインユースケース
// app/application/usecase/auth/login_usecase.go
package auth

import (
	"context"
	"time"

	"github.com/kakkky/app/domain/user"
	"github.com/kakkky/pkg/ulid"
)

type LoginUsecase struct {
	userRepository             user.UserRepository
	jwtAuthenticatorRepository JwtAuthenticatorRepository
	jwtAuthenticator           JwtAuthenticator
}

func NewLoginUsecase(
	userRepository user.UserRepository,
	jwtAuthenticatorRepository JwtAuthenticatorRepository,
	jwtAuthenticator JwtAuthenticator,
) *LoginUsecase {
	return &LoginUsecase{
		userRepository:             userRepository,
		jwtAuthenticatorRepository: jwtAuthenticatorRepository,
		jwtAuthenticator:           jwtAuthenticator,
	}
}

func (lu *LoginUsecase) Run(ctx context.Context, input LoginUsecaseInputDTO) (
	*LoginUsecaseOutputDTO, error,
) {
	// emailの検証
	email, err := user.NewEmail(input.Email)
	if err != nil {
		return nil, err
	}
	// ユーザー検索
	u, err := lu.userRepository.FindByEmail(ctx, email)
	if err != nil {
		return nil, err
	}
	// パスワードは正しいか
	if err := u.ComparePassword(input.Password); err != nil {
		return nil, err
	}
	// トークンを生成
	jti := ulid.NewUlid() //JWTトークンを識別するID
	jwtToken, err := lu.jwtAuthenticator.GenerateJwtToken(u.GetID(), jti)
	if err != nil {
		return nil, err
	}
	// トークンをkvsに保存
	if err := lu.jwtAuthenticatorRepository.Save(ctx, time.Duration(24*time.Hour), u.GetID(), jti); err != nil {
		return nil, err
	}
	return &LoginUsecaseOutputDTO{
		JwtToken: jwtToken,
	}, nil
}

入力・出力用のDTOを用意します。

// app/application/usecase/auth/login_usecase_dto.go
package auth

type LoginUsecaseInputDTO struct {
	Email    string
	Password string
}

type LoginUsecaseOutputDTO struct {
	JwtToken string
}

テストコード

// app/application/usecase/auth/login_usecase_test.go
package auth

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/kakkky/app/domain/user"
	"go.uber.org/mock/gomock"
)

func TestAuth_LoginUsecase_Run(t *testing.T) {
	t.Parallel()
	user1, _ := user.NewUser("[email protected]", "user1", "password")
	tests := []struct {
		name    string
		mockFn  func(mur *user.MockUserRepository, ma *MockJwtAuthenticator, mar *MockJwtAuthenticatorRepository)
		input   LoginUsecaseInputDTO
		want    *LoginUsecaseOutputDTO
		wantErr bool
	}{
		{
			name: "正常系:ユーザーにJWTトークンが返る",
			mockFn: func(mur *user.MockUserRepository, ma *MockJwtAuthenticator, mar *MockJwtAuthenticatorRepository) {
				mur.EXPECT().FindByEmail(gomock.Any(), gomock.Any()).Return(user1, nil)
				ma.EXPECT().GenerateJwtToken(gomock.Any(), gomock.Any()).Return("jwtToken", nil)
				mar.EXPECT().Save(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
			},
			input: LoginUsecaseInputDTO{
				Email:    "[email protected]",
				Password: "password",
			},
			want: &LoginUsecaseOutputDTO{
				JwtToken: "jwtToken",
			},
			wantErr: false,
		},
		{
			name: "準正常系:パスワードが異なる",
			mockFn: func(mur *user.MockUserRepository, ma *MockJwtAuthenticator, mar *MockJwtAuthenticatorRepository) {
				mur.EXPECT().FindByEmail(gomock.Any(), gomock.Any()).Return(user1, nil)
			},
			input: LoginUsecaseInputDTO{
				Email:    "[email protected]",
				Password: "invalid",
			},
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctrl := gomock.NewController(t)
			mockUserRepository := user.NewMockUserRepository(ctrl)
			mockAuthenticator := NewMockJwtAuthenticator(ctrl)
			mockAuthenticatorRepository := NewMockJwtAuthenticatorRepository(ctrl)
			tt.mockFn(mockUserRepository, mockAuthenticator, mockAuthenticatorRepository)
			sut := NewLoginUsecase(mockUserRepository, mockAuthenticatorRepository, mockAuthenticator)
			ctx := context.Background()
			got, err := sut.Run(ctx, tt.input)
			if (err != nil) != tt.wantErr {
				t.Errorf("LoginUsecase.Run error=%v,but wantErr%v", err, tt.wantErr)
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("LoginUsecase.Run -got,+want :%v", diff)
			}
		})
	}
}
ログアウトユースケース
// app/application/usecase/auth/logout_usecase.go
package auth

import "context"

type LogoutUsecase struct {
	jwtAuthenticator           JwtAuthenticator
	jwtAuthenticatorRepository JwtAuthenticatorRepository
}

func NewLogoutUsecase(
	jwtAuthenticator JwtAuthenticator,
	jwtAuthenticatorRepository JwtAuthenticatorRepository,
) *LogoutUsecase {
	return &LogoutUsecase{
		jwtAuthenticator:           jwtAuthenticator,
		jwtAuthenticatorRepository: jwtAuthenticatorRepository,
	}
}

func (lu *LogoutUsecase) Run(ctx context.Context, input LogoutUsecaseInputDTO) error {
	if err := lu.jwtAuthenticatorRepository.Delete(ctx, input.UserID); err != nil {
		return err
	}
	return nil
}

DTOを定義します

// app/application/usecase/auth/logout_usecase_dto.go
package auth

type LogoutUsecaseInputDTO struct {
	UserID string
}

テストコード

// app/application/usecase/auth/logout_usecase_test.go
package auth

import (
	"context"
	"fmt"
	"testing"

	"go.uber.org/mock/gomock"
)

func TestLogoutUsecase(t *testing.T) {
	tests := []struct {
		name    string
		mockFn  func(ma *MockJwtAuthenticator, mar *MockJwtAuthenticatorRepository)
		input   LogoutUsecaseInputDTO
		wantErr bool
	}{
		{
			name: "正常系:redisにあるレコードを削除してログアウトする",
			mockFn: func(ma *MockJwtAuthenticator, mar *MockJwtAuthenticatorRepository) {
				mar.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil)
			},
			input: LogoutUsecaseInputDTO{
				UserID: "1",
			},
			wantErr: false,
		},
		{
			name: "異常系:redis内でエラーが起きた場合、エラーを返す",
			mockFn: func(ma *MockJwtAuthenticator, mar *MockJwtAuthenticatorRepository) {
				mar.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(fmt.Errorf("err"))
			},
			input: LogoutUsecaseInputDTO{
				UserID: "1",
			},
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctrl := gomock.NewController(t)
			mockAuthenticator := NewMockJwtAuthenticator(ctrl)
			mockAuthenticatorRepository := NewMockJwtAuthenticatorRepository(ctrl)
			tt.mockFn(mockAuthenticator, mockAuthenticatorRepository)
			sut := NewLogoutUsecase(mockAuthenticator, mockAuthenticatorRepository)
			ctx := context.Background()
			err := sut.Run(ctx, tt.input)
			if (err != nil) != tt.wantErr {
				t.Errorf("LogoutUsecase.Run error=%v,but wantErr%v", err, tt.wantErr)
			}
		})
	}
}
認可制御ユースケース
// app/application/usecase/auth/authorization_usecase.go
package auth

import (
	"context"

	"github.com/kakkky/app/domain/errors"
)

type AuthorizationUsecase struct {
	jwtAuthenticator           JwtAuthenticator
	jwtAuthenticatorRepository JwtAuthenticatorRepository
}

func NewAuthorizationUsecase(
	jwtAuthenticator JwtAuthenticator,
	jwtAuthenticatorRepository JwtAuthenticatorRepository,
) *AuthorizationUsecase {
	return &AuthorizationUsecase{
		jwtAuthenticator:           jwtAuthenticator,
		jwtAuthenticatorRepository: jwtAuthenticatorRepository,
	}
}

func (au *AuthorizationUsecase) Run(ctx context.Context, input AuthorizationInputDTO) (
	*AuthorizationOutputDTO,
	error,
) {
	// 公開鍵で署名済みトークンを検証する
	userID, jti, err := au.jwtAuthenticator.VerifyJwtToken(input.JwtToken)
	if err != nil {
		return nil, err
	}
	// KVS から保存された jti を取得
	// ログアウトしていた場合は、nilが返る
	jtiFromKVS, err := au.jwtAuthenticatorRepository.Load(ctx, userID)
	if err != nil {
		return nil, err
	}
	// jti が一致しない場合はエラー
	// ログアウトしている場合は、ここでエラーとなる
	if jti != jtiFromKVS {
		return nil, errors.New("invalid JWT ID")
	}
	return &AuthorizationOutputDTO{
		UserID: userID,
	}, nil
}

入力・出力用のDTOを用意します。

// app/application/usecase/auth/authorization_usecase_dto.go
package auth

type AuthorizationInputDTO struct {
	JwtToken string
}

type AuthorizationOutputDTO struct {
	UserID string
}

テストコード

// app/application/usecase/auth/authorization_usecase_test.go
package auth

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"go.uber.org/mock/gomock"
)

func TestAuthorizationUsecase_Run(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name    string
		input   AuthorizationInputDTO
		mockFn  func(ma *MockJwtAuthenticator, mar *MockJwtAuthenticatorRepository)
		want    *AuthorizationOutputDTO
		wantErr bool
	}{
		{
			name: "正常系:トークンの認証に成功し、userIDを返す",
			input: AuthorizationInputDTO{
				JwtToken: "jwtToken",
			},
			mockFn: func(ma *MockJwtAuthenticator, mar *MockJwtAuthenticatorRepository) {
				ma.EXPECT().VerifyJwtToken(gomock.Any()).Return("userID", "jti", nil)
				mar.EXPECT().Load(gomock.Any(), "userID").Return("jti", nil)
			},
			want: &AuthorizationOutputDTO{
				UserID: "userID",
			},
			wantErr: false,
		},
		{
			name: "準正常系:kvsから得たjtiとトークンから得たjtiが一致しない",
			input: AuthorizationInputDTO{
				JwtToken: "jwtToken",
			},
			mockFn: func(ma *MockJwtAuthenticator, mar *MockJwtAuthenticatorRepository) {
				ma.EXPECT().VerifyJwtToken(gomock.Any()).Return("userID", "jti", nil)
				mar.EXPECT().Load(gomock.Any(), "userID").Return("", nil)
			},
			want: &AuthorizationOutputDTO{
				UserID: "userID",
			},
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctrl := gomock.NewController(t)
			mockAuthenticator := NewMockJwtAuthenticator(ctrl)
			mockAuthenticatorRepository := NewMockJwtAuthenticatorRepository(ctrl)
			tt.mockFn(mockAuthenticator, mockAuthenticatorRepository)

			sut := NewAuthorizationUsecase(mockAuthenticator, mockAuthenticatorRepository)
			ctx := context.Background()
			got, err := sut.Run(ctx, tt.input)
			if (err != nil) != tt.wantErr {
				t.Errorf("AuthorizationUsecase.Run error=%v,but wantErr%v", err, tt.wantErr)
			}
			// エラーが起きた場合はoutputを返さない
			if got == nil {
				return
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("AuthorizationUsecase.Run -got,+want :%v", diff)
			}
		})
	}
}

処理の流れに沿ってビジネスロジックを書いています。

認証機能の具体実装

ユースケース内ではJwtAuthenticatorインターフェースを定義していました。

type JwtAuthenticator interface {
	GenerateJwtToken(sub, jti string) (string, error)
	VerifyJwtToken(jwtToken string) (sub string, jti string, err error)
}

ここでは、インターフェースを満たすJwtAuthenticator具体型を実装していきます。 golangでJWTを扱うために、github.com/golang-jwt/jwtパッケージを利用します。インストールしておいてください。

make get-app name="github.com/golang-jwt/jwt"

公式ドキュメントは以下です。

https://github.com/golang-jwt/jwt https://golang-jwt.github.io/jwt/

秘密鍵・公開鍵の生成

JWTの署名アルゴリズムには、非対称鍵方式であるRSA-SHA256アルゴリズムを使用していきます。まずは、秘密鍵・公開鍵を生成しましょう。app/infrastructure/auth/certificate/ディレクトリを作成し、それぞれ以下のコマンドを打ってください。

  • 秘密鍵の生成
openssl genrsa 4096 > ./app/infrastructure/auth/certificate/private.pem
  • 公開鍵の生成秘密鍵をもとに、公開鍵を生成しています。
openssl rsa -pubout < ./app/infrastructure/auth/certificate/private.pem > ./app/infrastructure/auth/certificate/public.pem
https://christina04.hatenablog.com/entry/2017/04/15/042646

また、今回はあくまで開発環境であり、単なるハンズオンなので公開鍵・秘密鍵が記述されているpemファイルをそのままgithubのリポジトリにあげますが、実際それはNG行為です。

本番環境においては、特に秘密鍵を機密に保持しておく必要があるので、鍵は環境変数として管理するなど、何らかの方法をとる必要があります。

鍵をgolangで扱えるようにパースする

上で生成された鍵のファイルを見てみてもらえればわかるのですが、単なる文字列です。これをgolangで扱うためには、それぞれ*rsa.PrivateKey、*rsa.PublicKey型にパースする必要があります。

// app/infrastructure/auth/key_parse.go
package auth

import (
	"crypto/rsa"
	"crypto/x509"
	"encoding/pem"

	"github.com/kakkky/app/domain/errors"
)

func parsePrivateKey(pemData []byte) (*rsa.PrivateKey, error) {
	block, _ := pem.Decode(pemData)
	key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err != nil {
		return nil, err
	}
	privateKey, ok := key.(*rsa.PrivateKey)
	if !ok {
		return nil, errors.New("unknown key type")
	}
	return privateKey, nil
}
func parsePublicKey(pemData []byte) (*rsa.PublicKey, error) {
	block, _ := pem.Decode(pemData)
	key, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return nil, err
	}
	publicKey, ok := key.(*rsa.PublicKey)
	if !ok {
		return nil, errors.New("unknown key type")
	}
	return publicKey, nil
}

PEM形式のデータからデコードしてpem.Block構造体を取得し、それぞれパースしたのもを、型アサーションしています。

トークン認証の実装

JWTAuthenticatorには、パースした公開鍵・秘密鍵を持たせて処理するようにしています。

JwtAuthenticatorの実装
// app/infrastructure/auth/jwt_authenticator.go
package auth

import (
	"crypto/rsa"
	_ "embed"

	"fmt"
	"log"
	"time"

	"github.com/golang-jwt/jwt"
	"github.com/kakkky/app/domain/errors"
)

type jwtAuthenticator struct {
	privateKey *rsa.PrivateKey
	publicKey  *rsa.PublicKey
}

//go:embed certificate/private.pem
var rawPrivateKey []byte

//go:embed certificate/public.pem
var rawPublicKey []byte

func NewJwtAuthenticator() *jwtAuthenticator {
	// *rsa.PrivateKeyにパース
	privateKey, err := parsePrivateKey(rawPrivateKey)
	if err != nil {
		log.Fatalf("private key parse error :%v", err)
	}
	publicKey, err := parsePublicKey(rawPublicKey)
	if err != nil {
		log.Fatalf("public key parse error :%v", err)
	}
	return &jwtAuthenticator{
		privateKey: privateKey,
		publicKey:  publicKey,
	}
}

func (ja *jwtAuthenticator) GenerateJwtToken(sub, jti string) (string, error) {
	claims := jwt.StandardClaims{
		Id:        jti,
		ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
		IssuedAt:  time.Now().Unix(),
		Subject:   sub,
	}
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
	jwtToken, err := token.SignedString(ja.privateKey)
	if err != nil {
		return "", err
	}
	return jwtToken, nil
}

// 署名済みのトークンを公開鍵によって検証する
// クレームから取り出した値を返す
func (ja *jwtAuthenticator) VerifyJwtToken(jwtToken string) (sub string, jti string, err error) {
	token, err := jwt.Parse(jwtToken, func(t *jwt.Token) (interface{}, error) {
		// トークンの署名アルゴリズムをチェック
		if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
			return nil, fmt.Errorf("unexpected signing method")
		}
		return ja.publicKey, nil
	})
	if err != nil {
		return "", "", err
	}
	// トークンは有効か
	if !token.Valid {
		return "", "", fmt.Errorf("token is invalid")
	}
	// 有効期限は過ぎていないか
	if err := verifyExpiresAt(token); err != nil {
		return "", "", err
	}
	// subを取得
	sub, err = getSubFromClaim(token)
	if err != nil {
		return "", "", err
	}
	// jtiを取得
	jti, err = getJtiFromClaim(token)
	if err != nil {
		return "", "", err
	}
	return sub, jti, nil
}

func getJtiFromClaim(token *jwt.Token) (string, error) {
	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return "", errors.New("invalid token claims")
	}
	return claims["jti"].(string), nil
}
func getSubFromClaim(token *jwt.Token) (string, error) {
	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return "", errors.New("invalid token claims")
	}
	return claims["sub"].(string), nil
}
func verifyExpiresAt(token *jwt.Token) error {
	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return errors.New("invalid token claims")
	}
	exp := claims["exp"].(float64)
	if time.Now().Unix() > int64(exp) {
		return errors.New("token has expired")
	}
	return nil
}

テストコード

// app/infrastructure/auth/jwt_authenticator_test.go
package auth

import "testing"

func TestJWTAuthenticator(t *testing.T) {
	type args struct {
		sub string
		jti string
	}
	tests := []struct {
		name string
		args args
	}{
		{
			name: "subとjtiを元に署名済みjwtトークンを生成し、解析してsubとjtiを取り出せる",
			args: args{
				sub: "userID",
				jti: "jti",
			},
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			sut := NewJwtAuthenticator()
			jwtToken, err := sut.GenerateJwtToken(tt.args.sub, tt.args.jti)
			if err != nil {
				t.Errorf("GenerateJwtToken() error=%v", err)
			}
			gotSub, gotJti, err := sut.VerifyJwtToken(jwtToken)
			if err != nil {
				t.Errorf("VerifyToken() error = %v", err)
				return
			}
			// 同値性の確認
			if gotSub != tt.args.sub {
				t.Errorf("VerifyToken() got = %v, want = %v", gotSub, tt.args.sub)
			}
			if gotJti != tt.args.jti {
				t.Errorf("VerifyToken() got = %v, want = %v", gotJti, tt.args.jti)
			}
		})
	}
}

//go:embedディレクティブにより、指定のファイルを読み込んでバイトスライスに格納してくれます。JwtAuthenticatorの初期化関数NewJwtAuthenticator()にて先ほど実装した鍵のパース処理を呼び出しています。

//go:embed certificate/private.pem
var rawPrivateKey []byte

//go:embed certificate/public.pem
var rawPublicKey []byte

func NewJWTAuthenticator() *JwtAuthenticator {
	// *rsa.PrivateKeyにパース
	privateKey, err := parsePrivateKey(rawPrivateKey)
	if err != nil {
		log.Fatalf("private key parse error :%v", err)
	}
	publicKey, err := parsePublicKey(rawPublicKey)
	if err != nil {
		log.Fatalf("public key parse error :%v", err)
	}
	return &JwtAuthenticator{
		privateKey: privateKey,
		publicKey:  publicKey,
	}
}

これで認証機能を実装することができました。ユースケースやリポジトリなど、多くのコンポーネントを実装したので、以下でテストを回しておいてください。

make test-app

現段階のディレクトリ

.
├── .github
│   └── workflows
│       └── test.yml
├── .gitignore
├── Dockerfile.app
├── Makefile
├── app
│   ├── .air.toml
│   ├── adapter
│   │   ├── queryservice
│   │   │   ├── interface_querier.go
│   │   │   └── task_queryservice.go
│   │   └── repository
│   │       ├── interface_kvs_commander.go
│   │       ├── interface_querier.go
│   │       ├── task_repository.go
│   │       ├── token_authenticator_repository.go
│   │       └── user_repository.go
│   ├── application
│   │   └── usecase
│   │       ├── auth
│   │       │   ├── authorization_usecase.go
│   │       │   ├── authorization_usecase_dto.go
│   │       │   ├── authorization_usecase_test.go
│   │       │   ├── interface_jwt_authenticator.go
│   │       │   ├── interface_jwt_authenticator_repository.go
│   │       │   ├── login_usecase.go
│   │       │   ├── login_usecase_dto.go
│   │       │   ├── login_usecase_test.go
│   │       │   ├── logout_usecase.go
│   │       │   ├── logout_usecase_dto.go
│   │       │   ├── logout_usecase_test.go
│   │       │   ├── mock_jwt_authenticator.go
│   │       │   └── mock_jwt_authenticator_repository.go
│   │       ├── task
│   │       │   ├── create_task_usecase.go
│   │       │   ├── create_task_usecase_dto.go
│   │       │   ├── create_task_usecase_test.go
│   │       │   ├── delete_task_usecase.go
│   │       │   ├── delete_task_usecase_dto.go
│   │       │   ├── delete_task_usecase_test.go
│   │       │   ├── fetch_task_usecase.go
│   │       │   ├── fetch_task_usecase_dto.go
│   │       │   ├── fetch_task_usecase_test.go
│   │       │   ├── fetch_tasks_usecase.go
│   │       │   ├── fetch_tasks_usecase_test.go
│   │       │   ├── fetch_user_tasks_usecase.go
│   │       │   ├── fetch_user_tasks_usecase_dto.go
│   │       │   ├── fetch_user_tasks_usecase_test.go
│   │       │   ├── interface_task_query_service.go
│   │       │   ├── mock_task_query_service.go
│   │       │   ├── update_task_state_usecase.go
│   │       │   ├── update_task_state_usecase_dto.go
│   │       │   └── update_task_state_usecase_test.go
│   │       └── user
│   │           ├── fetch_users_usecase.go
│   │           ├── fetch_users_usecase_dto.go
│   │           ├── fetch_users_usecase_test.go
│   │           ├── register_usecase.go
│   │           ├── register_usecase_dto.go
│   │           ├── register_usecase_test.go
│   │           ├── unregister_usecase.go
│   │           ├── unregister_usecase_dto.go
│   │           ├── unregister_usecase_test.go
│   │           ├── update_profile_usecase.go
│   │           ├── update_profile_usecase_dto.go
│   │           └── update_profile_usecase_test.go
│   ├── config
│   │   ├── config.go
│   │   └── config_test.go
│   ├── domain
│   │   ├── errors
│   │   │   ├── errors.go
│   │   │   └── errors_test.go
│   │   ├── task
│   │   │   ├── interface_task_repository.go
│   │   │   ├── mock_task_repository.go
│   │   │   ├── task.go
│   │   │   ├── task_content.go
│   │   │   ├── task_state.go
│   │   │   └── task_test.go
│   │   └── user
│   │       ├── interface_user_domain_service.go
│   │       ├── interface_user_repository.go
│   │       ├── mock_user_domain_service.go
│   │       ├── mock_user_repository.go
│   │       ├── user.go
│   │       ├── user_domain_service.go
│   │       ├── user_domain_service_test.go
│   │       ├── user_email.go
│   │       ├── user_hashed_password.go
│   │       ├── user_test.go
│   │       └── users.go
│   ├── go.mod
│   ├── go.sum
│   ├── infrastructure
│   │   ├── auth
│   │   │   ├── certificate
│   │   │   │   ├── private.pem
│   │   │   │   └── public.pem
│   │   │   ├── jwt_authenticator.go
│   │   │   ├── jwt_authenticator_test.go
│   │   │   └── key_parse.go
│   │   ├── db
│   │   │   ├── connect.go
│   │   │   ├── container
│   │   │   │   ├── db.go
│   │   │   │   └── dockertest.go
│   │   │   ├── migrations
│   │   │   │   ├── 000001_create_users_table.down.sql
│   │   │   │   ├── 000001_create_users_table.up.sql
│   │   │   │   ├── 000002_create_tasks_table.down.sql
│   │   │   │   └── 000002_create_tasks_table.up.sql
│   │   │   ├── mysql
│   │   │   │   └── conf.d
│   │   │   │       └── my.conf
│   │   │   ├── sqlc
│   │   │   │   ├── db.go
│   │   │   │   ├── models.go
│   │   │   │   ├── queries
│   │   │   │   │   ├── tasks.sql
│   │   │   │   │   └── users.sql
│   │   │   │   ├── sqlc.yml
│   │   │   │   ├── sqlc_querier.go
│   │   │   │   ├── tasks.sql.go
│   │   │   │   └── users.sql.go
│   │   │   └── testhelper
│   │   │       └── fixture.go
│   │   ├── kvs
│   │   │   ├── redis_client.go
│   │   │   ├── redis_commander.go
│   │   │   └── redis_test_client.go
│   │   ├── queryservice_test
│   │   │   ├── queryservice_test.go
│   │   │   ├── task_queryservice_test.go
│   │   │   └── testdata
│   │   │       └── fixtures
│   │   │           ├── tasks.yml
│   │   │           └── users.yml
│   │   └── repository_test
│   │       ├── jwt_authenticator_repository_test.go
│   │       ├── repository_test.go
│   │       ├── task_repository_test.go
│   │       ├── testdata
│   │       │   └── fixtures
│   │       │       ├── tasks.yml
│   │       │       └── users.yml
│   │       └── user_repository_test.go
│   ├── main.go
│   └── tmp
│       ├── build-errors.log
│       └── main
├── compose.yml
├── go.work
├── go.work.sum
└── pkg
    ├── go.mod
    ├── go.sum
    ├── hash
    │   ├── hash.go
    │   └── hash_test.go
    └── ulid
        ├── ulid.go
        └── ulid_test.go