👁

GoでJWT認証を実装しよう

この章では、GoでJWT(JSON Web Token)による認証を扱い、ハンズオン形式で学習しながら実装します。これにより、ログインAPIからアクセストークンの発行・検証・リフレッシュ・ログアウトによる失効管理までを含む、実用的な認証機構が構築できるようになります。

1. 本章の概要

1.1 本章の目的

前章 GoでCORS対応をしよう までで、ブラウザから呼び出せるAPIサーバを準備できました。実際のアプリケーションでは、これに加えて「誰からのリクエストなのか」を判定する認証の仕組みが必要になります。本章では、認証方式としてよく使われるJWT(JSON Web Token)を採り上げます。JWTはトークン自体に検証可能な情報が含まれる仕組みで、サーバがセッション状態を保持しなくてもリクエスト元を判定できるため、REST APIとの相性が良い方式です。本章では、ユーザ登録・ログインでトークンを発行し、保護されたエンドポイントで検証したうえで、リフレッシュトークンによる再発行とログアウトによる失効管理までを一通り扱います。

1.2 ハンズオンの流れ

MySQLにusersテーブルとrefresh_tokensテーブルを用意し、GinとGORMで認証APIを実装します。まずサインアップ・ログインでアクセストークンとリフレッシュトークンを発行し、JWT検証ミドルウェアで保護されたGET /notesを叩けるところまで作ります。そのうえで、リフレッシュトークンでアクセストークンを再発行するPOST /refreshと、リフレッシュトークンを失効させるPOST /logoutを実装します。

本章で構築する認証フローを以下に示します。

sequenceDiagram
    participant C as クライアント
    participant S as APIサーバ
    participant DB as DB (users / refresh_tokens)
    C->>S: POST /login (email, password)
    S->>DB: パスワード検証
    S->>DB: refresh_token 保存
    S-->>C: access_token + refresh_token
    C->>S: GET /notes (Authorization: Bearer access_token)
    S-->>C: ノート一覧
    C->>S: POST /refresh (refresh_token)
    S->>DB: 旧 refresh_token を失効し、新規発行
    S-->>C: 新しい access_token + refresh_token
    C->>S: POST /logout (refresh_token)
    S->>DB: refresh_token を失効
    S-->>C: 204 No Content

1.3 事前準備

必要なツール

この章では、以下のツールを使用します。まだインストールしていない場合は、リンク先の手順に沿って準備をお願いします。

ツール名 関連箇所 理由
Visual Studio Code Visual Studio Codeのインストール 認証APIのコードを記述するエディタとして使用する
Go Goのインストール 認証APIの実装・実行環境として使用する
MySQL MySQLのインストール ユーザ情報とリフレッシュトークンを保存するデータベースとして使用する

2. JWT認証の仕組み

実装に入る前に、そもそもなぜ認証が必要かを押さえたうえで、JWTがどのように「サーバがセッションを持たずに認証を成立させるか」を確認します。JWTの構造、アクセストークンとリフレッシュトークンの役割分担、そしてサーバ側で持つべき責務の範囲を整理します。

2.1 認証の必要性

Webアプリケーションでは、リクエストの送信元が「誰なのか」を判定できないと、以下のような問題が発生します。

なりすましの防止

リクエストにユーザ本人であることを示す情報が付いていなければ、他人のIDを騙って自由にリクエストを送れる状態になります。攻撃者は他人のIDでAPIを呼び出すだけで、その人のデータを閲覧したり、勝手に変更・削除したりできてしまいます。SNSであれば他人になりすまして投稿する、ECサイトであれば他人のアカウントで注文するといった被害に直結するため、リクエスト元が本人であることを検証する認証の仕組みが必要になります。

責任追跡性の担保

誰がいつどの操作を行ったかを特定できない状態だと、不正な操作が発生しても追跡や責任の切り分けができません。監査ログ自体は残っていても、「その操作をしたのが本当に本人か」を保証する仕組みが無ければ、ログの信頼性が下がります。認証の仕組みを通してリクエスト元を特定できて初めて、操作ログが証跡として機能します。

機密情報の保護

個人情報・課金情報・非公開ドラフトなど、本来は本人だけが見られるべき情報を扱うAPIに認証が無いと、URLを推測されるだけで他人の情報を取得されてしまいます。公開情報しか扱わないサービスでも、ユーザごとの設定・履歴・下書きといった非公開情報を持つ場面はほぼ必ずあるため、認証を前提として本人以外からのアクセスを弾く設計が欠かせません。

こうした問題を防ぐために、Webアプリケーションでは「そのリクエストが誰から送られたものか」を検証する認証の仕組みが必要になります。認証が成立して初めて、ユーザごとに「何をしてよいか」を判定する認可の判断に進めます。

2.2 JWT認証とは

認証を成立させる方式には複数の選択肢があります。従来はサーバ側にセッション情報を保持し、Cookieでセッションキーを受け渡すステートフルな方式が使われてきました。この方式では、以下のようなリスクがありました。

  • ロードバランサー越しに複数のAPIサーバを並べる構成の場合、リクエストごとに違うサーバに振り分けられるため、認証情報を全台で共有する仕組みが別途必要になる
  • オートスケーリングでサーバが増減したり、サーバが再起動したりすると、そのサーバが持っていた認証情報が失われ、ユーザがログアウトされてしまう
  • Cookieを前提とした仕組みのため、Cookieを扱いにくい環境からは同じ方式で認証しにくい

JWTは、必要な情報をトークン自身に埋め込み、サーバの署名で改竄されていないことを検証できる形にしたステートレスな方式です。JWTを採用することで、上記のリスクは以下のように解消できます。

  • サーバ側で認証状態を保持しないため、ロードバランサーがどのサーバに振り分けても認証情報の共有が不要になる
  • 認証情報がトークン自体に含まれるため、オートスケーリングでのサーバの増減や再起動があっても認証状態が失われない
  • HTTPヘッダに載せてやり取りする方式なので、Cookieが扱いにくい環境からも同じ方式で認証できる

一方で、「サーバ側にセッションが無い=サーバから任意のトークンを即時に無効化できない」という性質もあります。これを補うために、後述のリフレッシュトークンと失効管理の仕組みを組み合わせて運用します。

2.3 JWTの構造

JWTは、以下の3つのパートを.で連結した文字列です。

ヘッダ.ペイロード.署名

実際に発行されたJWTは、以下のようにそれぞれのパートがBase64URLエンコードされた文字列で並びます。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImV4cCI6MTcyNDMzMTIwMCwiaWF0IjoxNzI0MzMwMzAwfQ.dQw4w9WgXcQ_1a2B3c4D5e6F7g8H9i0J

各パートを順に見ていきます。

ヘッダ

ヘッダは、署名アルゴリズムやトークンの種別を示すJSONを、Base64URLでエンコードしたものです。実際の値の例は以下のとおりです。

{
    "alg": "HS256",
    "typ": "JWT"
}

これをBase64URLエンコードすると、以下のようになります。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

algは署名アルゴリズム(ここではHMAC-SHA256)、typはトークンの種別(JWT)を示します。

ペイロード

ペイロードは、発行者・有効期限・ユーザIDなどのクレーム(Claims)を表すJSONを、Base64URLでエンコードしたものです。

クレーム(Claims)は、ペイロードに含まれる個々の情報要素です。「このトークンについて主張(claim)したい事実」をJSONのキーと値のペアで表現するもので、たとえば「このトークンはユーザID=1のもの」「有効期限はUNIX時刻1724331200まで」といった一つひとつの主張がクレームにあたります。JWTの仕様で名前が予約されているものを標準クレーム、アプリケーション独自に定義したものをプライベートクレームと呼びます。

ペイロードには、以下のような標準クレームがあります。

クレーム 意味
sub 認証対象の識別子(ユーザIDなど)
exp トークンの有効期限(UNIX時刻)
iat 発行時刻(UNIX時刻)
iss 発行者

実際のペイロードの例は以下のとおりです。

{
    "sub": 1,
    "exp": 1724331200,
    "iat": 1724330300
}

これをBase64URLエンコードすると、以下のようになります。

eyJzdWIiOjEsImV4cCI6MTcyNDMzMTIwMCwiaWF0IjoxNzI0MzMwMzAwfQ

ペイロードは暗号化されず、Base64URLでエンコードされているだけなので、誰でも中身を復号して見られます。パスワードなどの秘匿情報を入れてはいけません。改竄検知は署名で担保され、秘密鍵を知らない第三者が中身を書き換えると、署名の検証に失敗してサーバが弾きます。

署名

署名は、Base64URLエンコード済みのヘッダとペイロードを.で連結した文字列に対して、ヘッダで指定したアルゴリズム(例: HMAC-SHA256)とサーバの秘密鍵でハッシュ署名し、その結果をBase64URLエンコードしたものです。実際の値の例は以下のとおりです。

dQw4w9WgXcQ_1a2B3c4D5e6F7g8H9i0J

同じ秘密鍵を持っているサーバのみ、この署名を再計算して一致するかを検証できるため、トークンが改竄されていないことをステートレスに確認できます。

📝 JWTの仕様
JWTの標準仕様は、RFC 7519 - JSON Web Token (JWT)(IETF公式)に定義されています。標準クレーム(isssubexpiatなど)や、対応する署名アルゴリズム(JWS)についてもここが一次情報になります。

2.4 アクセストークン

JWT自体はトークンの形式であり、運用の設計は別途決める必要があります。本章では、以下の2種類のトークンを組み合わせる構成を採用します。

種別 有効期限 用途 失効の即時性
アクセストークン 短い(例: 15分) APIリクエストの認証に使う 有効期限までは失効させない前提
リフレッシュトークン 長い(例: 30日) アクセストークンの再発行に使う サーバ側で失効管理できる

アクセストークンは、APIリクエストの認証に使う短命なトークンです。JWT形式で発行し、以降のAPIリクエストごとにAuthorization: Bearer <アクセストークン>ヘッダに載せてサーバへ送ります。サーバは署名の検証と有効期限のチェックだけでリクエスト元を判定できるため、DBへの問い合わせなしにステートレスに扱えます。

アクセストークンを短命にすることで、盗まれた場合の影響時間を狭められます。ただし短命にすると再ログインの頻度が増えてユーザ体験が悪くなるため、後述のリフレッシュトークンを合わせて発行し、期限が切れる前にアクセストークンだけを再発行する構成にします。

2.5 リフレッシュトークン

リフレッシュトークンは、アクセストークンの再発行に使う長命なトークンです。JWT形式にはせず、crypto/randで生成した乱数文字列をサーバ側のDBに保管し、以下の管理を行います。

  • ログアウト時に該当のリフレッシュトークンを失効させる
  • リフレッシュのたびに古いリフレッシュトークンを失効させ、新しいものを発行する(ローテーション

この構成にすると、アクセストークンはステートレスに検証しつつ、リフレッシュトークンによって「特定のユーザ・端末のセッションを即時に切る」ことが可能になります。

2.6 認証の全体フロー

ここまで説明した仕組みを踏まえ、実際にクライアントとサーバがやり取りする流れを追います。

1. ユーザ登録

ユーザ登録を表すエンドポイント(例: POST /signup)を用意し、クライアントからメールアドレスとパスワードを受け取ってユーザレコードを作成します。この段階ではトークンは発行されません。

リクエスト (POST /signup):
{"email": "test@example.com", "password": "password12345"}

処理内容:
- パスワードをハッシュ化して users テーブルにユーザレコードを作成

レスポンス:
{"id": 1, "email": "test@example.com", "created_at": "2026-08-24T10:00:00Z"}

2. ログイン

ログインを表すエンドポイント(例: POST /login)を用意し、受け取ったパスワードを検証したうえで、2種類のトークンを返します。アクセストークンはJWT形式の文字列、リフレッシュトークンは乱数由来の16進文字列で、サーバはリフレッシュトークンの値を同じ内容でDBにも保管しておきます。

リクエスト (POST /login):
{"email": "test@example.com", "password": "password12345"}

処理内容:
- メールアドレスでユーザを検索
- 受け取ったパスワードと DB のハッシュを照合
- アクセストークン(JWT)を生成
- リフレッシュトークン(乱数)を生成して DB に保存

レスポンス:
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "3f4c8d2a6b..."
}

3. 保護されたAPI呼び出し

認証済みユーザだけが呼び出せるAPI(例: GET /notes)にアクセスする際は、アクセストークンをリクエストに添えて送ります。クライアントは受け取ったアクセストークンをAuthorizationヘッダにBearerスキームで載せて送り、サーバは検証ミドルウェアで署名と有効期限を検証したうえで、正当ならハンドラに処理を渡します。

リクエスト (GET /notes):
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

処理内容:
- Authorization ヘッダからアクセストークンを取り出す
- 署名と有効期限を検証
- 検証に成功したらハンドラに処理を渡し、対応するデータを返す

レスポンス:
[{"id": 1, "title": "買い物"}, {"id": 2, "title": "打ち合わせ資料"}]

4. アクセストークンの再発行

アクセストークンは短命なので、期限が切れそう/切れたら再発行が必要になります。再発行を表すエンドポイント(例: POST /refresh)を用意し、受け取ったリフレッシュトークンがDB上で失効していないことを確認したうえで、古いリフレッシュトークンを失効させて新しいアクセストークンとリフレッシュトークンを返します。以降クライアントは、新しく受け取ったトークンだけを使う運用になります。

リクエスト (POST /refresh):
{"refresh_token": "3f4c8d2a6b..."}

処理内容:
- DB からリフレッシュトークンを検索
- 失効・期限切れになっていないことを確認
- 古いリフレッシュトークンを失効させる(ローテーション)
- 新しいアクセストークンとリフレッシュトークンを発行

レスポンス:
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...新しい...",
  "refresh_token": "a91b7e3d..."
}

5. ログアウト

ログアウトを表すエンドポイント(例: POST /logout)を用意し、受け取ったアクセストークンとリフレッシュトークンの両方を無効化します。アクセストークンはJWT側で即時無効化ができない(ステートレスなので)ため、DBのブラックリスト(revoked_access_tokensテーブル)に登録し、認証ミドルウェアで照合することで即時拒否させます。リフレッシュトークンはrefresh_tokensテーブルのrevoked_atを更新して、以降の/refreshを拒否します。

リクエスト (POST /logout):
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{"refresh_token": "a91b7e3d..."}

処理内容:
- アクセストークンを revoked_access_tokens に登録して即時無効化
- 対応するリフレッシュトークンを無効化

レスポンス:
204 No Content

この流れを本章の後半で1エンドポイントずつ実装します。

💡 ポイント
認証で扱うパスワードは、DBに平文で保存せず、必ずハッシュ化して管理するのが基本です。ハッシュ化しておくと、万が一DBが漏洩してもパスワード自体は復元しにくいため、被害を最小限に抑えられます。本章のハンズオンでも、Goでよく使われるbcryptパッケージを使ってハッシュ化します。

3. 認証機能の実装準備

ここからハンズオンに入ります。プロジェクトフォルダの初期化、依存パッケージの導入、データベースとモデルの用意までを行います。

3.1 プロジェクトの準備

任意の場所にjwt-auth-hands-onフォルダを作成し、Visual Studio Codeの「ファイル」→「フォルダーを開く」から作成したjwt-auth-hands-onフォルダを開きます。以降の操作は、Visual Studio Codeのターミナルから行います。

jwt-auth-hands-on/  ← このフォルダを作成

以下のコマンドで、Goがインストールされていることを確認します。

go version

以下のようにGoのバージョンが表示されれば、インストールは確認できています。

go version go1.x.x darwin/arm64

バージョンが表示されない場合は、Goのインストールを先に実施してください。

インストールが確認できたら、モジュールを初期化します。

go mod init jwt-auth-hands-on

以下のような実行結果が表示されます。

go: creating new go.mod: module jwt-auth-hands-on

続いて、必要なパッケージを順に導入します。まず、GinとGORMを取得します。

go get github.com/gin-gonic/gin
go get gorm.io/gorm
go get gorm.io/driver/mysql

次に、JWT操作用のgolang-jwt/jwtと、パスワードハッシュ用のgolang.org/x/crypto/bcryptを取得します。

go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto/bcrypt

最後に、.envファイルから接続情報や署名鍵を読み込むためのgodotenvパッケージを導入します。DB接続用のユーザ名・パスワードやJWTの署名鍵といった秘匿情報をコードに直接書かず、.envファイルから環境変数として読み込むために使います。

go get github.com/joho/godotenv

以下のような実行結果が最後のコマンドで表示されます(バージョンは環境により変わります)。

go: added github.com/joho/godotenv v1.5.1

go.modにすべての依存関係が追記されていれば、パッケージの準備は完了です。

3.2 データベースとモデルの用意

データベースの作成

認証APIから接続するデータベースを先に作成します。ユーザとリフレッシュトークンの2種類のテーブルを扱うため、それらを保存するauth_hands_onデータベースを作ります。テーブル自体はGORMのAutoMigrateで作成するため、ここではデータベースだけ用意します。

まず、rootユーザでMySQLにログインします。

mysql -u root -p

パスワードを入力してMySQLのプロンプトに切り替わったら、以下のSQLを実行してデータベースを作成します。

CREATE DATABASE auth_hands_on;

以下のような実行結果が表示されます。

Query OK, 1 row affected (0.00 sec)

Query OKが返れば、データベースの作成コマンドは成功しています。念のため、SHOW DATABASES;auth_hands_onデータベースが一覧に含まれていることを確認します。

SHOW DATABASES;

以下のような実行結果が表示されます(既存のデータベース一覧は環境により変わります)。

+--------------------+
| Database           |
+--------------------+
| auth_hands_on      |
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+

auth_hands_onが一覧に含まれていれば、データベースが作成できています。確認できたら、exit;でMySQLのプロンプトを抜けます。

exit;

.env ファイルの作成

main.goの中にDB接続用のユーザ名・パスワードやJWTの署名鍵を直接書くと、コードを変更するたびに書き換える必要があり、うっかりGitに含めてしまうリスクもあります。ここでは、接続情報と署名鍵を.envファイルにまとめて、Goのコードからは環境変数として読み込む形にします。

Visual Studio Codeのエクスプローラーでjwt-auth-hands-onフォルダを右クリックし、「新しいファイル」を選択して.envファイルを作成します。

jwt-auth-hands-on/
├── go.mod
├── go.sum
└── .env  ← このファイルを作成

作成した.envファイルに以下の内容を記述して保存します。各項目の意味は下記のとおりです。

項目 説明
MYSQL_USER 接続先MySQLのユーザ名を指定する。ここではrootを使う
MYSQL_PASSWORD 上記ユーザのパスワードを指定する。先ほどmysql -u root -pで入力した自分のMySQLパスワードに書き換える
MYSQL_HOST 接続先MySQLのホスト名またはIPアドレスを指定する。ローカルで動作しているMySQLに接続するため127.0.0.1を使う
MYSQL_PORT 接続先MySQLの待ち受けポートを指定する。MySQLのデフォルトである3306を使う
MYSQL_DATABASE 接続先のデータベース名を指定する。先ほど作成したauth_hands_onを指定する
JWT_SECRET JWTの署名に使う秘密鍵を指定する。本番環境では十分な長さのランダム文字列に置き換える
MYSQL_USER=root
MYSQL_PASSWORD=あなたのパスワード
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DATABASE=auth_hands_on
JWT_SECRET=dev-secret-please-change

MYSQL_PASSWORD=あなたのパスワードの右辺は、必ず先ほどmysql -u root -pで入力した自分のMySQLパスワードに書き換えてください。書き換えを忘れるとGoからのDB接続に失敗し、次の起動時にエラーになります。

モデルの定義

続いて、ユーザとリフレッシュトークンを表すモデルを定義します。この後に作るmain.godb.AutoMigrate(&User{}, &RefreshToken{})でテーブルを自動作成)や、後続で追加するヘルパー・ハンドラがUserRefreshToken構造体を参照するため、参照される側の型を先に用意しておきます。

Visual Studio Codeのエクスプローラーでjwt-auth-hands-onフォルダを右クリックし、「新しいファイル」を選択してmodel.goを作成します。

jwt-auth-hands-on/
├── .env
├── go.mod
├── go.sum
└── model.go  ← このファイルを作成

作成したmodel.goに以下の内容を記述して保存します。これは、ユーザとリフレッシュトークンの2種類のモデル定義です。

package main

import "time"

type User struct {
	ID           uint      `gorm:"primaryKey" json:"id"`
	Email        string    `gorm:"size:255;uniqueIndex;not null" json:"email"`
	PasswordHash string    `gorm:"size:255;not null" json:"-"`
	CreatedAt    time.Time `json:"created_at"`
}

type RefreshToken struct {
	ID        uint       `gorm:"primaryKey" json:"-"`
	UserID    uint       `gorm:"index;not null" json:"-"`
	Token     string     `gorm:"size:255;uniqueIndex;not null" json:"-"`
	ExpiresAt time.Time  `json:"-"`
	RevokedAt *time.Time `json:"-"`
	CreatedAt time.Time  `json:"-"`
}

コードを解説します。

Email        string    `gorm:"size:255;uniqueIndex;not null" json:"email"`

UserモデルのEmailにはuniqueIndexを付けて重複登録を防いでいます。

PasswordHash string    `gorm:"size:255;not null" json:"-"`

PasswordHashにはJSONタグ-を付けて、レスポンスに含まれないようにしています。パスワード関連の値は、ハッシュ化されていてもレスポンスに乗せない構成が基本です。

Token     string     `gorm:"size:255;uniqueIndex;not null" json:"-"`

RefreshTokenモデルのTokenカラムに、発行済みのトークン文字列を保存します。uniqueIndexを付けて同じ値の重複を防いでいます。

ExpiresAt time.Time  `json:"-"`

リフレッシュトークンの有効期限を保持します。/refreshのときに現在時刻と比較して期限切れを判定します。

RevokedAt *time.Time `json:"-"`

リフレッシュトークンの失効時刻を保持します。ポインタ型(*time.Time)にすることで、「まだ失効していない状態(NULL)」と「失効済み(時刻あり)」を区別できます。

main.go の初期実装

続いて、エントリポイントとなるmain.goを作成します。データベース接続・.env読み込み・テーブルの自動作成・Ginサーバの起動までを実装します。エンドポイントは以降の節で少しずつ追加します。

jwt-auth-hands-onフォルダにmain.goを作成します。

jwt-auth-hands-on/
├── .env
├── go.mod
├── go.sum
├── main.go   ← このファイルを作成
└── model.go

作成したmain.goに以下の内容を記述して保存します。DB接続とサーバ起動をまとめて実装します。

package main

import (
	"fmt"
	"log"
	"os"

	"github.com/gin-gonic/gin"
	"github.com/joho/godotenv"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

var (
	db        *gorm.DB
	jwtSecret []byte
)

func main() {
	if err := godotenv.Load(); err != nil {
		fmt.Println(".envファイル読み込み失敗:", err)
		return
	}

	dsn := fmt.Sprintf(
		"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
		os.Getenv("MYSQL_USER"),
		os.Getenv("MYSQL_PASSWORD"),
		os.Getenv("MYSQL_HOST"),
		os.Getenv("MYSQL_PORT"),
		os.Getenv("MYSQL_DATABASE"),
	)

	secret := os.Getenv("JWT_SECRET")
	if secret == "" {
		log.Fatal("JWT_SECRET が設定されていません")
	}
	jwtSecret = []byte(secret)

	var err error
	db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		log.Fatal("DB接続失敗:", err)
	}
	if err := db.AutoMigrate(&User{}, &RefreshToken{}); err != nil {
		log.Fatal("AutoMigrate失敗:", err)
	}

	r := gin.Default()
	r.Run(":8080")
}

main.gomodel.goと同じmainパッケージなので、model.goで定義したUserRefreshToken型はそのままmain.goから参照できます。パッケージレベルの変数dbjwtSecretもここで宣言し、以降で追加するヘルパー・ハンドラから共通で使えるようにしています。

コードを解説します。

if err := godotenv.Load(); err != nil {
    fmt.Println(".envファイル読み込み失敗:", err)
    return
}

godotenv.Load()で、カレントディレクトリの.envを読み込んで、それぞれのキーをプロセスの環境変数として登録します。以降のos.Getenv("MYSQL_USER")os.Getenv("JWT_SECRET")で各値を取り出せるようになります。.envが見つからない場合は、エラーメッセージを表示してmain関数を早期returnで抜けます。DB接続情報を読めない状態で先に進んでも失敗するため、その時点で終了させる構成にしています。

dsn := fmt.Sprintf(
    "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
    os.Getenv("MYSQL_USER"),
    os.Getenv("MYSQL_PASSWORD"),
    os.Getenv("MYSQL_HOST"),
    os.Getenv("MYSQL_PORT"),
    os.Getenv("MYSQL_DATABASE"),
)

.envから読み込んだ環境変数を組み合わせて、MySQLへの接続文字列(DSN)をfmt.Sprintfで作ります。パスワードなどの秘匿情報を直接コードに書かないため、Gitに含めるファイル(main.go)と含めないファイル(.env)を分けて管理できます。末尾のcharset=utf8mb4&parseTime=True&loc=LocalはGORMが日本語文字列と日時を扱う際の推奨設定です。

secret := os.Getenv("JWT_SECRET")
if secret == "" {
    log.Fatal("JWT_SECRET が設定されていません")
}

JWTの署名鍵を環境変数JWT_SECRETから読み込みます。ここをコードにハードコードすると、リポジトリを見られた時点でトークンの偽造が可能になるため、.envや本番環境の環境変数として外部から注入します。未設定時にlog.Fatalで即座に停止させることで、鍵が空のまま起動してしまう事故を防ぎます。

jwtSecret = []byte(secret)

読み込んだ文字列を[]byteに変換して、パッケージレベルの変数jwtSecretに代入します。JWTライブラリの署名関数は[]byteで秘密鍵を受け取るため、この形にしておくことで、以降のトークン発行・検証で毎回変換せずにそのまま渡せます。

if err := db.AutoMigrate(&User{}, &RefreshToken{}); err != nil {
    log.Fatal("AutoMigrate失敗:", err)
}

GORMのAutoMigrateで、UserRefreshTokenのモデル定義からusersrefresh_tokensテーブルを自動的に作成・更新します。既に存在する場合は差分だけが適用されます。マイグレーションに失敗すると以降のDB操作が全て失敗するため、log.Fatalで即座に停止させます。

起動とテーブル作成の確認

サーバを一度起動してテーブルが作成されることを確認します。以下のコマンドは、カレントディレクトリのGoパッケージ全体(main.gomodel.go)をまとめてコンパイルして実行します。

go run .

以下のような実行結果が表示されます。

[GIN-debug] Listening and serving HTTP on :8080
⚠️ ./main.go:XX:XX: undefined: User というエラーが出る場合
go run main.goのようにファイル名を指定して実行している可能性が高いです。本章はマルチファイル構成(main.gomodel.go、以降のセクションでhelper.gohandler.gomiddleware.goが追加されます)で、main.goからmodel.goUserRefreshToken型を参照しているため、単一ファイル指定ではコンパイルが通りません。go run .としてカレントディレクトリのGoパッケージ全体をまとめてコンパイル・実行してください。
⚠️ Access denied for user 'root'@'localhost'と表示される場合
.envMYSQL_PASSWORDが実際のMySQLパスワードと一致していない可能性が高いです。.envを、mysql -u root -pでログインできたパスワードに書き換えてから、再度go run .を実行してください。

AutoMigrateによって、この起動のタイミングでusersテーブルとrefresh_tokensテーブルが自動作成されています。MySQL CLIから実際にテーブルが作られていることを確認します。別のターミナルを開き、rootユーザでMySQLにログインします。

mysql -u root -p

ログイン後、auth_hands_onデータベースを使用します。

USE auth_hands_on;

続いて、テーブル一覧を表示します。

SHOW TABLES;

以下のような実行結果が表示されます。

+-------------------------+
| Tables_in_auth_hands_on |
+-------------------------+
| refresh_tokens          |
| users                   |
+-------------------------+

usersrefresh_tokensが並んでいれば、モデルからのテーブル作成は成功しています。確認できたら、GinサーバをCtrl + Cで停止しておきます。

💡 ポイント
本章では開発用に.envファイルから環境変数を読み込んでいますが、本番環境ではDocker・ECS・Kubernetesなど、それぞれのプラットフォームが提供する環境変数の注入機能を使うのが基本です。.envは開発中の手元でだけ使い、Gitには含めない構成が一般的です。

4. ユーザ登録

ユーザ登録を行うPOST /signupエンドポイントを実装します。メールアドレスとパスワードを受け取り、ハッシュ化してusersテーブルにユーザレコードを作成する処理です。

4.1 パスワードハッシュヘルパーの追加

パスワードをハッシュ化するヘルパー関数を追加します。

📝 ヘルパー関数とは
ヘルパー関数(helper function)は、他の関数から共通で使うために切り出した補助的な小さな関数のことです。同じ処理を各所で書き重ねずに済み、呼び出し元のコードを読みやすく保てます。

パスワードのハッシュ化はsignupハンドラだけで呼び出す処理ですが、以降で追加するパスワード検証(checkPassword)やJWT発行と合わせて「認証まわりの共通処理」として、helper.goという専用ファイルにまとめて置きます。main.gohandler.goから独立させておくと、認証ロジックの読みやすさが上がり、bcryptから別のハッシュアルゴリズムへの入れ替えなどの変更も1箇所で済むようになります。

Visual Studio Codeのエクスプローラーでjwt-auth-hands-onフォルダを右クリックし、「新しいファイル」を選択してhelper.goを作成します。

jwt-auth-hands-on/
├── .env
├── go.mod
├── go.sum
├── helper.go  ← このファイルを作成
├── main.go
└── model.go

作成したhelper.goに以下の内容を記述して保存します。

package main

import (
	"golang.org/x/crypto/bcrypt"
)

func hashPassword(password string) (string, error) {
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	return string(bytes), err
}

helper.gomain.gomodel.goと同じmainパッケージに属するので、以降で追加するhandler.goからhashPasswordをそのまま呼び出せます。

コードを解説します。

func hashPassword(password string) (string, error) {

関数のシグネチャです。引数として生のパスワード文字列を受け取り、戻り値としてハッシュ化した文字列とエラーを返します。エラーは呼び出し元でハンドリングします。

bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)

bcrypt.GenerateFromPasswordは、生のパスワードからソルト付きのハッシュを生成します。bcryptstringではなく[]byteを受け取るため、[]byte(password)で変換して渡しています。ハッシュ化にはコスト(計算量)が伴い、DefaultCost(現行では10)程度の負荷がかけられます。DefaultCostbcryptパッケージが推奨する既定値で、CPU性能の変化に合わせて追従されます。

return string(bytes), err

生成されたハッシュは[]byteで返るため、DBにstringとして保存できるよう変換して返します。エラーが発生した場合はそのまま呼び出し元に返します。

📝 パスワードハッシュに関する仕様
bcryptによるパスワードハッシュの仕様と使い方は、golang.org/x/crypto/bcrypt(Go公式ドキュメント)に記載があります。ハッシュ化されていないパスワードや、SHA-1・SHA-256などの単純なハッシュ(ソルトなし・ストレッチングなし)でパスワードを保存する構成は、パスワードリスト攻撃・レインボーテーブル攻撃に対して脆弱です。パスワード保存には必ず、bcryptのようなソルト付き・計算コスト調整可能なハッシュ方式を使うようにします。

4.2 signup ハンドラの追加

続いて、HTTPハンドラを配置するhandler.goを作成します。以降のエンドポイントのハンドラもすべてこのファイルに追記します。jwt-auth-hands-onフォルダにhandler.goを作成します。

jwt-auth-hands-on/
├── .env
├── go.mod
├── go.sum
├── handler.go  ← このファイルを作成
├── helper.go
├── main.go
└── model.go

作成したhandler.goに以下の内容を記述して保存します。signupハンドラを含む形です。

package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func signup(c *gin.Context) {
	var input struct {
		Email    string `json:"email" binding:"required,email"`
		Password string `json:"password" binding:"required,min=8"`
	}
	if err := c.ShouldBindJSON(&input); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	hash, err := hashPassword(input.Password)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	user := User{Email: input.Email, PasswordHash: hash}
	if err := db.Create(&user).Error; err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusCreated, user)
}

handler.goも同じmainパッケージなので、helper.gohashPasswordmodel.goUsermain.godbをそのまま参照できます。

続けて、main.gomain関数のr := gin.Default()r.Run(":8080")の間に、ルート登録を追加します。

	r.POST("/signup", signup)

4.3 コードの解説

func signup(c *gin.Context) {

Ginのハンドラの標準シグネチャです。引数の*gin.Contextから、リクエストの読み取りとレスポンスの書き込みの両方を行います。

Email    string `json:"email" binding:"required,email"`

リクエストボディのemailフィールドを受け取る定義です。binding:"required,email"で、必須かつメールアドレス形式であることを宣言しています。

Password string `json:"password" binding:"required,min=8"`

同じくリクエストボディのpasswordフィールドを受け取る定義です。min=8でパスワードの最小文字数を制約しています。

if err := c.ShouldBindJSON(&input); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
}

c.ShouldBindJSONは、リクエストボディをJSONとしてデコードし、同時にbindingタグのバリデーションも実行します。デコード失敗やバリデーション違反があれば、400 Bad Requestでエラーメッセージを返して以降の処理を中断します。

hash, err := hashPassword(input.Password)
if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
}

受け取ったパスワードを先ほど定義したhashPasswordヘルパーでハッシュ化します。ハッシュ生成に失敗するのはbcrypt側のシステムエラー相当なので、500 Internal Server Errorで返します。

user := User{Email: input.Email, PasswordHash: hash}
if err := db.Create(&user).Error; err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
}

メールアドレスとハッシュ化したパスワードからUserレコードを作り、db.CreateでDBに挿入します。同じメールアドレスがすでに登録されているとEmailuniqueIndex制約に引っかかってエラーになるため、400 Bad Requestで返します。

c.JSON(http.StatusCreated, user)

作成したユーザを201 Createdで返します。PasswordHashにはJSONタグ-を付けているため、レスポンスには含まれません。

4.4 動作確認

サーバを起動します。

go run .

以下のような実行結果が表示されます。

[GIN-debug] Listening and serving HTTP on :8080

別のターミナルから、ユーザを作成します。

curl -X POST http://localhost:8080/signup -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"password12345"}'

以下のような実行結果が表示されます(IDと日時は環境により変わります)。

{"id":1,"email":"test@example.com","created_at":"2026-08-22T12:00:00Z"}

PasswordHashはJSONタグ-を付けているため、レスポンスに含まれません。念のため、MySQL側でもusersテーブルに実際にレコードが登録されていることを確認します。別のターミナルを開き、rootユーザでMySQLにログインします。

mysql -u root -p

ログイン後、auth_hands_onデータベースを使用します。

USE auth_hands_on;

続いて、usersテーブルの中身を確認します。

SELECT id, email, password_hash, created_at FROM users;

以下のような実行結果が表示されます(password_hashの値はハッシュ化された結果なので毎回異なります)。

+----+-------------------+--------------------------------------------------------------+---------------------+
| id | email             | password_hash                                                | created_at          |
+----+-------------------+--------------------------------------------------------------+---------------------+
|  1 | test@example.com  | $2a$10$abc...(bcryptのハッシュ文字列)                       | 2026-08-22 12:00:00 |
+----+-------------------+--------------------------------------------------------------+---------------------+

password_hashカラムに$2a$10$から始まる文字列が入っていれば、bcryptによるハッシュ化と保存が正しく行えています。確認できたら、exit;でMySQLのプロンプトを抜けます。

exit;

5. ログイン

ログインを行うPOST /loginエンドポイントを実装します。メールアドレスとパスワードを検証し、成功時にアクセストークン(JWT)とリフレッシュトークンを返す処理です。

5.1 helper.go の import 書き換え

このセクションで追加するトークン発行系ヘルパー群が使うパッケージを、まとめてhelper.goimportに追加します。helper.goimportを以下のように書き換えます。既存のbcryptはそのまま残し、timecrypto/randencoding/hexgolang-jwt/jwt/v5の4つを追加する形です。

import (
	"crypto/rand"
	"encoding/hex"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
)

timeは有効期限とトークン発行時刻の扱い、crypto/randencoding/hexはリフレッシュトークンの乱数生成、golang-jwt/jwt/v5はJWTの発行で使います。この時点で追加したimportはまだ関数側で使っていないので、go run .を実行するとimported and not usedのコンパイルエラーが出ます。以降のヘルパー関数(定数追加・checkPasswordgenerateAccessTokengenerateRefreshToken)まで追加し終えてから起動してください。

⚠️ no required module provides package github.com/golang-jwt/jwt/v5 と表示される場合
go.modgolang-jwt/jwt/v5が登録されていません。プロジェクトの準備でgo get github.com/golang-jwt/jwt/v5を実行済みかを確認し、実行していない場合は以下のコマンドで追加してから、再度go run .を実行してください。
go get github.com/golang-jwt/jwt/v5

5.2 有効期限の定数追加

サーバが動いていれば、いったんCtrl + Cで停止します。停止したら、helper.goの末尾(hashPassword関数の下)に、アクセストークンとリフレッシュトークンの有効期限をパッケージレベルの定数として追加します。

const (
	accessTokenTTL  = 15 * time.Minute
	refreshTokenTTL = 30 * 24 * time.Hour
)

コードを解説します。

const (
    accessTokenTTL  = 15 * time.Minute
    refreshTokenTTL = 30 * 24 * time.Hour
)

アクセストークンとリフレッシュトークンの有効期限を定数で定義します。アクセストークンは短命(15分)、リフレッシュトークンは長め(30日)にすることで、盗難時の影響と再ログイン頻度のバランスを取ります。time.Minutetime.Hourtimeパッケージが提供する定数で、可読性の高い形で期間を表せます。

5.3 パスワード検証ヘルパーの追加

helper.goの末尾(先ほど追加した定数の下)に、DBに保存したパスワードハッシュと入力パスワードを照合するヘルパーを追加します。

func checkPassword(hash, password string) error {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}

コードを解説します。

func checkPassword(hash, password string) error {

checkPasswordの関数シグネチャです。DBに保存されているハッシュ文字列と、ログイン試行で受け取った平文パスワードの2つを受け取り、比較結果をエラーで返します。

return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))

bcrypt.CompareHashAndPasswordは、ハッシュと入力パスワードを照合する関数です。bcrypt側は[]byteを受け取るため[]byte()で変換して渡します。ハッシュ側にソルトが埋め込まれているため、呼び出し側でソルトを別途保持・受け渡しする必要はありません。一致すればnil、不一致ならbcrypt.ErrMismatchedHashAndPasswordが返ります。

5.4 アクセストークン発行ヘルパーの追加

helper.goの末尾(checkPassword関数の下)に、JWTのアクセストークンを発行するヘルパーを追加します。

func generateAccessToken(userID uint) (string, error) {
	claims := jwt.MapClaims{
		"sub": userID,
		"exp": time.Now().Add(accessTokenTTL).Unix(),
		"iat": time.Now().Unix(),
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(jwtSecret)
}

コードを解説します。

func generateAccessToken(userID uint) (string, error) {

generateAccessTokenの関数シグネチャです。ユーザIDを受け取り、そのユーザに紐づくJWT文字列とエラーを返します。

claims := jwt.MapClaims{
    "sub": userID,
    "exp": time.Now().Add(accessTokenTTL).Unix(),
    "iat": time.Now().Unix(),
}

アクセストークンのペイロード(クレーム)を用意します。subにユーザID、expに有効期限(現在時刻+accessTokenTTL)、iatに発行時刻を入れます。expiatはJWTの標準クレームで、Unix()でUNIX時刻の整数値として渡します。

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

クレームと署名アルゴリズムを指定して*jwt.Tokenを用意します。SigningMethodHS256HMAC-SHA256の共通鍵署名で、同じ秘密鍵を持っているサーバのみ、後から署名を検証できる方式です。

return token.SignedString(jwtSecret)

用意した*jwt.Tokenを、パッケージレベルで持っている秘密鍵jwtSecretで署名し、ヘッダ.ペイロード.署名の3パートをドットで連結したJWT文字列として返します。

5.5 リフレッシュトークン発行ヘルパーの追加

helper.goの末尾(generateAccessToken関数の下)に、リフレッシュトークンを生成してDBに保存するヘルパーを追加します。

func generateRefreshToken(userID uint) (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	tokenStr := hex.EncodeToString(b)
	rt := RefreshToken{
		UserID:    userID,
		Token:     tokenStr,
		ExpiresAt: time.Now().Add(refreshTokenTTL),
	}
	if err := db.Create(&rt).Error; err != nil {
		return "", err
	}
	return tokenStr, nil
}

コードを解説します。

func generateRefreshToken(userID uint) (string, error) {

generateRefreshTokenの関数シグネチャです。アクセストークンと同じくユーザIDを受け取り、リフレッシュトークン文字列とエラーを返します。

b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
    return "", err
}

crypto/randで32バイトの乱数を生成します。rand.ReadはOSが提供する暗号学的に安全な乱数源から値を取り出し、bに書き込みます。乱数取得に失敗するのはOS側の異常なので、そのままエラーとして呼び出し元に返します。

tokenStr := hex.EncodeToString(b)

生成した乱数バイト列を16進文字列にエンコードします。この文字列が、クライアントに渡すリフレッシュトークンの値になります。

rt := RefreshToken{
    UserID:    userID,
    Token:     tokenStr,
    ExpiresAt: time.Now().Add(refreshTokenTTL),
}
if err := db.Create(&rt).Error; err != nil {
    return "", err
}
return tokenStr, nil

トークン文字列・所有ユーザ・有効期限をRefreshTokenレコードとしてDBに保存し、成功時に生成した文字列を返します。JWTのように自己完結した情報をトークンに持たせず、DBに保存された値と一致するかで判定するため、失効管理をDB操作だけで完結できます。

5.6 login ハンドラの追加

handler.goの末尾(signup関数の下)に、以下のloginハンドラを追加します。

func login(c *gin.Context) {
	var input struct {
		Email    string `json:"email" binding:"required"`
		Password string `json:"password" binding:"required"`
	}
	if err := c.ShouldBindJSON(&input); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	var user User
	if err := db.Where("email = ?", input.Email).First(&user).Error; err != nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "メールアドレスまたはパスワードが不正です"})
		return
	}
	if err := checkPassword(user.PasswordHash, input.Password); err != nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "メールアドレスまたはパスワードが不正です"})
		return
	}

	accessToken, err := generateAccessToken(user.ID)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	refreshTokenStr, err := generateRefreshToken(user.ID)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{
		"access_token":  accessToken,
		"refresh_token": refreshTokenStr,
	})
}

続けて、main.gomain関数のルート登録に、既存のr.POST("/signup", signup)の下に以下を追加します。

	r.POST("/login", login)

5.7 コードの解説

func login(c *gin.Context) {

loginハンドラのシグネチャです。signupと同じくGinの標準ハンドラ形式で、*gin.Contextからリクエストとレスポンスを扱います。

var input struct {
    Email    string `json:"email" binding:"required"`
    Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
}

リクエストボディを受け取る構造体を定義し、c.ShouldBindJSONでデコードとバリデーションを行います。signupと異なり、min=8email形式のチェックは付けません。ここでバリデーションで弾いてしまうと「登録済みのパスワードが短い(=古いユーザ)」ケースでログインできなくなるためです。

var user User
if err := db.Where("email = ?", input.Email).First(&user).Error; err != nil {
    c.JSON(http.StatusUnauthorized, gin.H{"error": "メールアドレスまたはパスワードが不正です"})
    return
}

入力されたメールアドレスでusersテーブルを検索します。Firstは該当行が無ければgorm.ErrRecordNotFoundを返します。ここではエラーの種類を区別せず、次のパスワード検証と同じメッセージを返して401 Unauthorizedで終了します。

if err := checkPassword(user.PasswordHash, input.Password); err != nil {
    c.JSON(http.StatusUnauthorized, gin.H{"error": "メールアドレスまたはパスワードが不正です"})
    return
}

先ほど作ったcheckPasswordで、DBに保存したハッシュと入力パスワードを照合します。不一致の場合も、上のユーザ検索失敗と同じメッセージ・同じステータスコードで返します。

c.JSON(http.StatusUnauthorized, gin.H{"error": "メールアドレスまたはパスワードが不正です"})

「ユーザが存在しない」場合と「パスワードが違う」場合の両方で、この共通メッセージを返しています。区別できるメッセージを返してしまうと、「そのメールアドレスがサービスに登録されているか」を第三者に推測されやすくなります。認証に関わる失敗は、原因を明示しないメッセージにそろえるのが基本です。

accessToken, err := generateAccessToken(user.ID)
if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
}

パスワード検証を通過したユーザに対して、generateAccessTokenでJWTのアクセストークンを発行します。JWT署名の失敗はサーバ側の問題なので、500 Internal Server Errorで返します。

refreshTokenStr, err := generateRefreshToken(user.ID)
if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
}

続けて、generateRefreshTokenでリフレッシュトークンを発行しつつ、そのトークンをDBに保存します。乱数生成やDB書き込みの失敗も、サーバ側の問題として500で返します。

c.JSON(http.StatusOK, gin.H{
    "access_token":  accessToken,
    "refresh_token": refreshTokenStr,
})

200 OKでアクセストークンとリフレッシュトークンをJSONにまとめて返します。クライアントはこの2つを受け取り、以降のAPIリクエストではアクセストークンを、期限切れ時にはリフレッシュトークンを使います。

5.8 動作確認

サーバを起動します。

go run .

先ほど作成したユーザでログインしてトークンを取得します。別のターミナルから以下を実行します。

curl -X POST http://localhost:8080/login -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"password12345"}'

以下のような実行結果が表示されます(トークンの値は環境により変わります)。

{"access_token":"eyJhbGciOiJIUzI1NiIs...","refresh_token":"3f4c8d2a6b..."}

access_tokenはJWT(.で3つに区切られた文字列)、refresh_tokencrypto/randで生成した16進文字列です。次のセクションで、このアクセストークンをAuthorizationヘッダに載せて保護されたエンドポイントを叩きます。

6. 保護されたAPI呼び出し

認証済みユーザだけが叩けるGET /notesエンドポイントを実装します。あわせて、発行したアクセストークンを検証する共通のミドルウェアも追加します。

6.1 helper.go の import 書き換え

トークン検証時のエラー生成に必要なerrorsパッケージを追加するため、helper.goimportを以下のように書き換えます。前のimporterrorsを追加した形です。

import (
	"crypto/rand"
	"encoding/hex"
	"errors"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"
)

errorsはトークン検証失敗時のエラー生成で使います。

6.2 アクセストークン検証ヘルパーの追加

サーバをいったんCtrl + Cで停止します。停止したら、helper.goの末尾(generateRefreshToken関数の下)に、JWTのアクセストークンを検証してユーザIDを取り出すヘルパーparseAccessTokenを追加します。

func parseAccessToken(tokenStr string) (uint, error) {
	token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, errors.New("unexpected signing method")
		}
		return jwtSecret, nil
	})
	if err != nil || !token.Valid {
		return 0, errors.New("invalid token")
	}
	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return 0, errors.New("invalid claims")
	}
	sub, ok := claims["sub"].(float64)
	if !ok {
		return 0, errors.New("invalid sub")
	}
	return uint(sub), nil
}

コードを解説します。

func parseAccessToken(tokenStr string) (uint, error) {

parseAccessTokenのシグネチャです。JWT文字列を受け取り、検証に成功したらペイロードから取り出したユーザIDを返し、失敗すればエラーを返します。

token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
    if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
        return nil, errors.New("unexpected signing method")
    }
    return jwtSecret, nil
})

jwt.Parseは、トークン文字列と「署名検証に使う鍵を返すコールバック」を受け取ります。コールバック内でトークンの署名アルゴリズムを確認し、想定と異なるアルゴリズム(例: none)の場合はエラーで弾いています。これを省略すると、algフィールドを偽装したトークンを受け入れてしまう脆弱性につながります。想定どおりのHMAC系アルゴリズムであれば、パッケージレベルの秘密鍵jwtSecretを返して署名検証を進めます。

if err != nil || !token.Valid {
    return 0, errors.New("invalid token")
}

jwt.Parse自体のエラー、またはtoken.Validfalse(署名不一致・期限切れなど)の場合は、まとめて「無効トークン」として扱います。呼び出し元にどの理由で失敗したかは伝えず、抽象化したエラーだけを返します。

claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
    return 0, errors.New("invalid claims")
}

token.ClaimsClaimsインタフェース型なので、こちらで指定したjwt.MapClaims(マップ型)に型アサーションで取り出します。想定外の型が入っていることは通常無いですが、防御的にチェックしています。

sub, ok := claims["sub"].(float64)
if !ok {
    return 0, errors.New("invalid sub")
}
return uint(sub), nil

subにはユーザIDを入れていましたが、JSONの数値はfloat64としてデコードされます。取り出すときはfloat64で受け取ってから、目的の型(uint)に変換します。値が存在しない場合はエラーで返します。

6.3 認証ミドルウェアの追加

続いて、認証ミドルウェアを配置するmiddleware.goを作成します。ミドルウェアは複数のハンドラの前後に挟む横断的な処理なので、helper.gohandler.goとは分けて専用ファイルに置くと役割が明確になります。

Visual Studio Codeのエクスプローラーでjwt-auth-hands-onフォルダを右クリックし、「新しいファイル」を選択してmiddleware.goを作成します。

jwt-auth-hands-on/
├── .env
├── go.mod
├── go.sum
├── handler.go
├── helper.go
├── main.go
├── middleware.go  ← このファイルを作成
└── model.go

作成したmiddleware.goに以下の内容を記述して保存します。Authorizationヘッダからトークンを取り出して検証するミドルウェアauthMiddlewareを定義しています。

package main

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
)

func authMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		header := c.GetHeader("Authorization")
		if !strings.HasPrefix(header, "Bearer ") {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization ヘッダが必要です"})
			return
		}
		tokenStr := strings.TrimPrefix(header, "Bearer ")
		userID, err := parseAccessToken(tokenStr)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "トークンが無効です"})
			return
		}
		c.Set("userID", userID)
		c.Next()
	}
}

middleware.goも同じmainパッケージなので、helper.goparseAccessTokenをそのまま呼び出せます。

コードを解説します。

func authMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {

authMiddlewaregin.HandlerFuncを返す関数です。ミドルウェアとしてr.Use(...)に渡すため、実際のハンドラは戻り値の無名関数として用意します。

header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
    c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization ヘッダが必要です"})
    return
}
tokenStr := strings.TrimPrefix(header, "Bearer ")

AuthorizationヘッダからBearer <トークン>形式で値を取り出します。Bearerは「Bearer認証(このトークンを持っている人=認証済み)」を示すスキーム名で、慣例的に用いられます。プレフィックスが無ければAbortWithStatusJSON401 Unauthorizedを返し、以降のハンドラを実行させずに終了します。

userID, err := parseAccessToken(tokenStr)
if err != nil {
    c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "トークンが無効です"})
    return
}

先ほど定義したparseAccessTokenでトークンを検証し、失敗すればここも401 Unauthorizedで早期終了します。

c.Set("userID", userID)
c.Next()

トークン検証に成功したユーザIDをc.Setgin.Contextに格納し、後続のハンドラでc.GetUint("userID")として参照できるようにしています。c.Next()で保護対象のハンドラに処理を渡します。

6.4 notes ハンドラの追加

handler.goの末尾(login関数の下)に、認証済みユーザのIDに紐づくノート一覧を返すnotesハンドラを追加します。ここではデモとして固定のノート一覧を返す形にしています。

func notes(c *gin.Context) {
	userID := c.GetUint("userID")
	items := []gin.H{
		{"id": 1, "title": "買い物", "user_id": userID},
		{"id": 2, "title": "打ち合わせ資料", "user_id": userID},
	}
	c.JSON(http.StatusOK, items)
}

続けて、main.gomain関数のルート登録に、保護されたエンドポイントのグループを追加します。既存のr.POST("/login", login)の下に以下を追加します。

	authorized := r.Group("/")
	authorized.Use(authMiddleware())
	{
		authorized.GET("/notes", notes)
	}

6.5 コードの解説

func notes(c *gin.Context) {

notesハンドラのシグネチャです。Ginの標準ハンドラ形式です。

userID := c.GetUint("userID")

authMiddlewarec.Setで入れたuserIDを取り出します。c.GetUintuint型で値を取り出すヘルパーです。ミドルウェアを通過している時点で必ず値は入っています。

items := []gin.H{
    {"id": 1, "title": "買い物", "user_id": userID},
    {"id": 2, "title": "打ち合わせ資料", "user_id": userID},
}

本章では認証の動作確認が主眼のため、DBからノートを取得する処理は入れず、固定のノート一覧を作って返します。実際のアプリケーションでは、userIDをキーにnotesテーブルから該当ユーザのレコードを取得する形になります。

c.JSON(http.StatusOK, items)

作ったノート一覧を200 OKのJSONで返します。認証ミドルウェアを通過したリクエストだけがここに到達するため、ユーザIDに応じた個別データを返す実装が安全に書けます。

authorized := r.Group("/")
authorized.Use(authMiddleware())
{
    authorized.GET("/notes", notes)
}

Group("/")Use(authMiddleware())の組み合わせで、認証を必要とするルート専用のグループを作ります。このグループに登録したエンドポイントは、すべてauthMiddlewareを通過してからハンドラが呼ばれます。波カッコの{ }はGoの構文としてはブロックスコープを作るだけですが、視覚的に「このグループに登録するルート一覧」を分かりやすく示すために使う書き方です。認証不要のルート(/signup/login/refresh/logout)は、この外側に登録することで対象外にできます。

6.6 動作確認

サーバを起動します。

go run .

まず、Authorizationヘッダを付けずに/notesを叩いて、401が返ることを確認します。

curl -i http://localhost:8080/notes

以下のような実行結果が表示されます。

HTTP/1.1 401 Unauthorized
...
{"error":"Authorization ヘッダが必要です"}

ミドルウェアが働き、Authorizationヘッダの無いリクエストが弾かれています。

次に、先ほど/loginで取得したアクセストークンをAuthorizationヘッダに付けて再度叩きます。トークンの値は自分のログイン結果に置き換えてください。

curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." http://localhost:8080/notes

以下のような実行結果が表示されます。

[{"id":1,"title":"買い物","user_id":1},{"id":2,"title":"打ち合わせ資料","user_id":1}]

トークンが検証を通過し、c.Set("userID", userID)で埋め込まれたIDを使ってノート一覧が取得できています。認証済みユーザのみが叩けるエンドポイントの雛形ができました。

⚠️ トークンが「invalid token」で弾かれる場合
Authorization: Bearer 〜のスペース位置と、トークン文字列を最後まで正しくコピーできているかを確認してください。改行や末尾の空白が入っていると、parseAccessToken側で署名検証に失敗します。また、アクセストークンの有効期限は15分に設定しているため、時間が経ちすぎている場合は再度/loginを叩いて発行し直します。

7. アクセストークンの再発行

アクセストークンの再発行を行うPOST /refreshエンドポイントを実装します。リクエストで受け取ったリフレッシュトークンがDBに存在し、失効・期限切れになっていないことを確認したうえで、古いリフレッシュトークンを失効させて新しいアクセストークンとリフレッシュトークンを返す処理です。

7.1 refresh ハンドラの追加

サーバをいったんCtrl + Cで停止します。停止したら、handler.goの末尾(notes関数の下)にrefreshハンドラを追加します。refreshハンドラではtimeパッケージを使うため、handler.goimportにも"time"を追加します。

import (
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
)
func refresh(c *gin.Context) {
	var input struct {
		RefreshToken string `json:"refresh_token" binding:"required"`
	}
	if err := c.ShouldBindJSON(&input); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	var rt RefreshToken
	if err := db.Where("token = ?", input.RefreshToken).First(&rt).Error; err != nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token が無効です"})
		return
	}
	if rt.RevokedAt != nil || time.Now().After(rt.ExpiresAt) {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token が失効しています"})
		return
	}

	now := time.Now()
	rt.RevokedAt = &now
	if err := db.Save(&rt).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	accessToken, err := generateAccessToken(rt.UserID)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	newRefreshToken, err := generateRefreshToken(rt.UserID)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{
		"access_token":  accessToken,
		"refresh_token": newRefreshToken,
	})
}

main.gomain関数のルート登録に、既存のr.POST("/login", login)の下(認証必須グループより前が読みやすい位置)に以下を追加します。

	r.POST("/refresh", refresh)

7.2 コードの解説

func refresh(c *gin.Context) {

refreshハンドラのシグネチャです。ここでもGinの標準ハンドラ形式に沿っています。

var input struct {
    RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return
}

リクエストボディのrefresh_tokenフィールドを受け取ります。値が無ければbinding:"required"のバリデーションで弾き、400 Bad Requestを返します。

var rt RefreshToken
if err := db.Where("token = ?", input.RefreshToken).First(&rt).Error; err != nil {
    c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token が無効です"})
    return
}

DBからリフレッシュトークンを検索します。存在しなければgorm.ErrRecordNotFoundが返るため、まとめて401 Unauthorizedで扱います。ここも「存在しない」と「失効している」でメッセージを分けず、詳細を漏らさない構成にしています。

if rt.RevokedAt != nil || time.Now().After(rt.ExpiresAt) {
    c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token が失効しています"})
    return
}

RevokedAtが非nil(=失効済み)か、ExpiresAtを過ぎている場合は、DBに残っていても再発行を拒否します。両方をチェックすることで、明示ログアウトと自然な期限切れの両方をカバーします。

now := time.Now()
rt.RevokedAt = &now
if err := db.Save(&rt).Error; err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
}

古いリフレッシュトークンのRevokedAtに現在時刻を入れて失効させます。再発行のたびに古いトークンを失効させるローテーション運用にしておくと、盗まれた古いトークンで永続的に新しいアクセストークンを取得され続けるリスクを下げられます。DB更新に失敗した場合はサーバ側の問題として500を返します。

accessToken, err := generateAccessToken(rt.UserID)
if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
}
newRefreshToken, err := generateRefreshToken(rt.UserID)
if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
}

古いトークンを失効させたうえで、新しいアクセストークンとリフレッシュトークンをloginと同じヘルパー(generateAccessTokengenerateRefreshToken)で発行します。それぞれの失敗はサーバ側の問題として500で返します。

c.JSON(http.StatusOK, gin.H{
    "access_token":  accessToken,
    "refresh_token": newRefreshToken,
})

新しいアクセストークンとリフレッシュトークンを200 OKでJSONにまとめて返します。クライアント側は、以降のリクエストで受け取った最新のリフレッシュトークンだけを使う運用になります。

7.3 動作確認

サーバを起動します。

go run .

ログイン時に取得しておいたrefresh_token/refreshに投げて、新しいアクセストークンとリフレッシュトークンを取得します。

curl -X POST http://localhost:8080/refresh -H "Content-Type: application/json" -d '{"refresh_token":"3f4c8d2a..."}'

以下のような実行結果が表示されます。

{"access_token":"eyJhbGciOi...新しい...","refresh_token":"a91b7e3d...新しい..."}

アクセストークンとリフレッシュトークンの両方が新しい値になっています。ここで、古いリフレッシュトークンで再度リフレッシュを試みると失効エラーになることを確認します。

curl -X POST http://localhost:8080/refresh -H "Content-Type: application/json" -d '{"refresh_token":"3f4c8d2a..."}'

以下のような実行結果が表示されます。

{"error":"refresh token が失効しています"}

/refreshのローテーションが働き、古いリフレッシュトークンは1度しか使えない状態になっています。次のセクションのログアウトでは、この新しく取得したリフレッシュトークン(a91b7e3d...)を使います。

8. ログアウト

ログアウトを行うPOST /logoutエンドポイントを実装します。JWTはステートレスな設計上、発行済みのアクセストークンをサーバ側から即時に無効化することは本来できません。「ログアウトしても、そのアクセストークンが有効期限まで生き残ってしまう」という状態は本来のログアウトの意図とかみ合わないため、DBにアクセストークンの無効化リスト(ブラックリスト)を持ち、認証ミドルウェアで照合する形で即時無効化を実現します。

本章のログアウトでは、以下の2つを同時に行います。

  • アクセストークンをrevoked_access_tokensテーブルに登録し、認証ミドルウェアで拒否させる
  • リフレッシュトークンをrefresh_tokensテーブルのrevoked_atで失効させ、以降の/refreshを拒否させる

8.1 無効化用モデルの追加

まず、失効させたアクセストークンを管理するモデルを追加します。JWTは自己完結型で、通常はサーバがDBに保存しません。ですが「そのトークンをブラックリスト入りにしたか」を判定するには、失効させたトークン文字列だけをDBに保管しておく必要があります。

model.goの末尾に、以下のRevokedAccessTokenモデルを追加します。

type RevokedAccessToken struct {
	ID        uint      `gorm:"primaryKey" json:"-"`
	Token     string    `gorm:"size:512;uniqueIndex;not null" json:"-"`
	ExpiresAt time.Time `json:"-"`
	RevokedAt time.Time `json:"-"`
}

続いて、main.goAutoMigrate呼び出しに&RevokedAccessToken{}を追加します。

	if err := db.AutoMigrate(&User{}, &RefreshToken{}, &RevokedAccessToken{}); err != nil {
		log.Fatal("AutoMigrate失敗:", err)
	}

コードを解説します。

Token     string    `gorm:"size:255;uniqueIndex;not null" json:"-"`

TokenにはJWTの文字列そのものを保管します。JWTはヘッダ.ペイロード.署名の3パートを連結した長めの文字列なので、size:512で十分な長さを確保します。uniqueIndexにより、同じトークンを重複登録しないよう制約をかけています。

ExpiresAt time.Time `json:"-"`

このトークンの有効期限(JWT側のexpと同じ値)を保存します。今回のハンズオンでは使いませんが、有効期限を過ぎたレコードは検証時に既に弾かれるため、バッチ処理で古いレコードを削除するためのカラムになります。

if err := db.AutoMigrate(&User{}, &RefreshToken{}, &RevokedAccessToken{}); err != nil {

AutoMigrateRevokedAccessTokenを追加することで、次回のサーバ起動時にrevoked_access_tokensテーブルが自動作成されます。

8.2 認証ミドルウェアへの無効化チェック追加

続いて、認証ミドルウェアに「アクセストークンがブラックリストに載っていないか」を確認する処理を追加します。middleware.goauthMiddlewareを、以下のように書き換えます。

func authMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		header := c.GetHeader("Authorization")
		if !strings.HasPrefix(header, "Bearer ") {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization ヘッダが必要です"})
			return
		}
		tokenStr := strings.TrimPrefix(header, "Bearer ")
		userID, err := parseAccessToken(tokenStr)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "トークンが無効です"})
			return
		}

		var revoked RevokedAccessToken
		if err := db.Where("token = ?", tokenStr).First(&revoked).Error; err == nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "トークンが無効化されています"})
			return
		}

		c.Set("userID", userID)
		c.Set("accessToken", tokenStr)
		c.Next()
	}
}

コードを解説します。

var revoked RevokedAccessToken
if err := db.Where("token = ?", tokenStr).First(&revoked).Error; err == nil {
    c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "トークンが無効化されています"})
    return
}

JWTの検証(署名・有効期限)を通過したあとに、revoked_access_tokensテーブルにそのトークンが登録されていないかを確認します。Firsterr == nilで返るのは「レコードが見つかった」場合なので、その場合は無効化済みとして401 Unauthorizedで拒否します。gorm.ErrRecordNotFound(見つからなかった場合)はerr != nilとなり、そのまま通過します。

c.Set("accessToken", tokenStr)

userIDに加えて、アクセストークン文字列そのものもgin.Contextに格納しておきます。後述のlogoutハンドラで、このトークン文字列をブラックリストに登録するためです。

8.3 logout ハンドラの追加

サーバをいったんCtrl + Cで停止します。停止したら、handler.goの末尾(refresh関数の下)にlogoutハンドラを追加します。アクセストークンを検証してから無効化リストに登録する必要があるため、このハンドラは認証ミドルウェアを通過させます(後述のルート登録でauthorizedグループに入れます)。

func logout(c *gin.Context) {
	var input struct {
		RefreshToken string `json:"refresh_token" binding:"required"`
	}
	if err := c.ShouldBindJSON(&input); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	accessToken := c.GetString("accessToken")
	revoked := RevokedAccessToken{
		Token:     accessToken,
		ExpiresAt: time.Now().Add(accessTokenTTL),
		RevokedAt: time.Now(),
	}
	if err := db.Create(&revoked).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	now := time.Now()
	res := db.Model(&RefreshToken{}).
		Where("token = ? AND revoked_at IS NULL", input.RefreshToken).
		Update("revoked_at", now)
	if res.Error != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": res.Error.Error()})
		return
	}
	c.Status(http.StatusNoContent)
}

続けて、main.goのルート登録を書き換えてlogoutauthorizedグループの中に入れます。既存のauthorizedグループの中にauthorized.POST("/logout", logout)を追加し、以前あったr.POST("/logout", logout)は削除します(初めて追加する場合は、単にauthorizedグループに追加するだけで問題ありません)。

	authorized := r.Group("/")
	authorized.Use(authMiddleware())
	{
		authorized.GET("/notes", notes)
		authorized.POST("/logout", logout)
	}

8.4 コードの解説

accessToken := c.GetString("accessToken")

先ほどauthMiddlewarec.Set("accessToken", tokenStr)で入れておいたアクセストークン文字列を取り出します。認証ミドルウェアを通過している時点で、必ずこの値が入っています。

revoked := RevokedAccessToken{
    Token:     accessToken,
    ExpiresAt: time.Now().Add(accessTokenTTL),
    RevokedAt: time.Now(),
}
if err := db.Create(&revoked).Error; err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
    return
}

取り出したアクセストークンをrevoked_access_tokensテーブルに登録します。以降このトークンで保護されたAPIを叩くと、authMiddlewareのブラックリスト照合で弾かれるようになります。認証ミドルウェアを通過したリクエストしかここに到達しないため、同じトークンが二重登録される心配はありません。

now := time.Now()
res := db.Model(&RefreshToken{}).
    Where("token = ? AND revoked_at IS NULL", input.RefreshToken).
    Update("revoked_at", now)

続けて、リクエストボディで受け取ったリフレッシュトークンをrefresh_tokensテーブルのrevoked_atで失効させます。revoked_at IS NULLの条件で、まだ失効していないレコードだけを対象にします。

c.Status(http.StatusNoContent)

ログアウトの成功時はレスポンスボディを持たず、204 No Contentだけを返します。この時点で、アクセストークンとリフレッシュトークンの両方がサーバ側から無効化されている状態になります。

8.5 動作確認

サーバを起動します。

go run .

前のセクションで取得した最新のアクセストークン(eyJhbGci...)とリフレッシュトークン(a91b7e3d...)を/logoutに送り、両方を失効させます。アクセストークンはAuthorizationヘッダに、リフレッシュトークンはリクエストボディに載せます。

curl -i -X POST http://localhost:8080/logout \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"a91b7e3d..."}'

以下のような実行結果が表示されます。

HTTP/1.1 204 No Content

204 No Contentが返り、アクセストークンはrevoked_access_tokensテーブルに登録され、リフレッシュトークンはrefresh_tokensテーブルのrevoked_atに時刻が入りました。

即時無効化が働いていることを確認するため、先ほどログアウトに使ったアクセストークンで、もう一度/notesを叩いてみます

curl -i -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." http://localhost:8080/notes

以下のような実行結果が表示されます。

HTTP/1.1 401 Unauthorized
...
{"error":"トークンが無効化されています"}

アクセストークン自体はまだ有効期限内ですが、authMiddlewareのブラックリスト照合で拒否されました。JWTが本来持てなかった「即時無効化」を、DBのブラックリストで実現できていることが確認できます。

念のため、リフレッシュトークン側も無効化されていることを確認します。同じリフレッシュトークンで/refreshを叩くと、refresh token が失効していますで拒否されます。

curl -X POST http://localhost:8080/refresh -H "Content-Type: application/json" -d '{"refresh_token":"a91b7e3d..."}'
{"error":"refresh token が失効しています"}

アクセストークン・リフレッシュトークンの両方が無効化された状態になり、そのセッションからは新規のトークン発行もAPI呼び出しもできなくなりました。これで、DB管理方式による即時無効化を含めたログアウトの仕組みが一通り動くことが確認できます。

💡 ポイント
revoked_access_tokensテーブルは、放置すると失効済みトークンのレコードが増え続けます。ExpiresAtを過ぎたレコードは有効期限側でどのみち弾かれるため、日次のバッチ処理などでExpiresAt < NOW()のレコードをDELETEする運用にすると、テーブルサイズを一定に保てます。本章では簡素化のためクリーンアップ処理は入れていません。

9. マネージド認証サービスの活用

本章ではJWT認証を独自に実装しましたが、実運用のアプリケーションでは、パスワードの保存・多要素認証(MFA)・パスワードリセット・ソーシャルログイン(Google・Appleなど)といった機能をゼロから実装せず、マネージドな認証サービスを使うことも一般的です。代表的な選択肢は以下のとおりです。

  • Amazon Cognito: AWSが提供する認証サービス。ユーザプール機能でサインアップ・ログイン・MFA・ソーシャルログインを一通り扱える
  • Auth0: 認証・認可のマネージドサービスとして広く使われる
  • Firebase Authentication: GoogleのBaaS。モバイルとの相性が良い
  • Google Identity Platform: エンタープライズ向けのGCPマネージド認証

これらのサービスを使うと、本章で実装したパスワードハッシュ・JWT発行・リフレッシュトークン管理・失効管理などをサービス側に委ねられます。ただし、サービス側から発行されるIDトークン・アクセストークンの検証はAPIサーバ側で行う必要があるため、本章で扱ったJWT検証ミドルウェアの考え方はそのまま活かせます。

独自に実装するか、マネージドサービスに寄せるかは、要件やチームの状況次第で判断します。認証まわりに独自の要件が多い場合や既存システムと密に連携する場合は独自実装、逆に短期間で本番運用を立ち上げたい場合やチームに認証実装の経験者がいない場合は、実装ミスによる脆弱性を避けるためにもマネージドサービスの利用が現実的です。

本章の内容自体は、いずれのケースでも「トークンベース認証の仕組み」を理解するための基礎知識として役立ちます。

10. 不要リソースの削除

ハンズオンで使ったauth_hands_onデータベースは、残しておく必要はないため削除します。

rootユーザでMySQLにログインします。

mysql -u root -p

パスワードを入力してMySQLのプロンプトに切り替わったら、以下のSQLを実行してデータベースを削除します。

DROP DATABASE auth_hands_on;

以下のような実行結果が表示されます。

Query OK, 0 rows affected (0.01 sec)

Query OKが返れば削除完了です。削除が確認できたら、exit;でMySQLのプロンプトを抜けます。

exit;

作業用のフォルダ(jwt-auth-hands-on/)はそのまま残しておいても支障ありませんが、不要であれば削除してください。

11. まとめ

この章では、JWT認証の仕組みを学びつつ、実際にサインアップ・ログイン・保護されたエンドポイント・リフレッシュ・ログアウトまでの一通りをGoで実装しました。

  • JWTは、トークン自体に検証可能な情報を埋め込むステートレスな認証方式である
  • JWTはヘッダ・ペイロード・署名の3パートを.で連結した文字列で、ペイロードは暗号化されず誰でも復号できる
  • パスワードはbcryptでソルト付きハッシュ化して保存し、レスポンスに含めない
  • 認証系のエラーメッセージは、原因を区別しない共通の文言にそろえる
  • アクセストークンは短命(例: 15分)にして盗難時の影響を狭め、リフレッシュトークンで再発行する構成にする
  • リフレッシュトークンはDBで管理し、ローテーションと失効管理ができる
  • JWT検証ミドルウェアでBearer <トークン>を検証し、認証済みユーザのIDをgin.Contextに載せて後続ハンドラに渡せる
  • ログアウトはリフレッシュトークンをrevoked_atで失効させることで実現し、以降の/refreshを拒否できる
  • JWTの即時無効化を実現するため、revoked_access_tokensテーブルでアクセストークンのブラックリストを管理し、認証ミドルウェアで照合することでログアウト直後から拒否できる
  • コードは責務ごとにmain.gomodel.gohelper.gomiddleware.gohandler.goの5ファイルに分割し、Goのマルチファイル構成ではgo run .でパッケージ全体をまとめてコンパイル・実行できる
  • DB接続情報とJWTの署名鍵は.envファイルに外出しし、godotenvで環境変数として読み込むことで、秘匿情報をコードから分離できる

次の章では、Goの静的解析ツールをハンズオン形式で体験します。

この教材は役に立ちましたか?

いいねをたくさんいただけると、制作者の励みになり、より多くのセクションが作れるようになります。

感想を一言(任意)

いただいたコメントは次の制作のヒントになります。ぜひお気軽にご投稿ください。

このコメントは他の受講生には公開されません。DevOps Camp運営が、教材改善のために確認します。

0 / 2000