Go で JWT を使うユースケースとその実装例

# はじめに

JWT{JSON Web Token} は RFC7519 で規定されている、JSON をセキュアにやり取りするための仕様です。特徴は次の通り。

- 鍵になる文字列（以降 secret ）か公開鍵ペアのどちらかを使って署名することで「セキュアにやり取り」できるようにしている。前者は HMAC アルゴリズム、後者は RSA か ECDSA が使われる。
- 「セキュアにやり取り」の意味はこの JSON が適切な者によって発行されて、改ざんもされていないことが保証されるということ
- JSON は署名されるだけで暗号化されるわけではないので、中身は誰でも閲覧可能な点に注意が必要

JWT は `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c` のような文字列です。ドットで区切られた 3 つのパートから構成されます。

- Base64-URL 文字列である。
- それぞれ Header.Payload.Signature を表す。
- Header には署名に利用されるアルゴリズムが記載される。例えば HS256 は `HMAC using SHA-256 hash` のこと。

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

- Payload は実際に JSON としてやり取りしたい内容が入る。JSON のキーに相当する値のことを claims と呼んでいる。claims には種類があって、特に Registerd claims と Private claims は理解しておく必要がある。Registered claims は RFC で規定されている claim で必須ではないが推奨される。go-jwt などのライブラリは Registered claims を渡すと規定にしたがってよしなに処理してくれる。Private claims は利用者が自由に追加できる claim のこと。例えば、以下にある claim の意味は次の通り。
 - iat と exp は Registered claim。iat は Issued At の略で JWT の発行時間。exp は Expiration Time の略で JWT の expire 時間。exp の時間を過ぎた JWT は処理してはいけないと決まっている。
 - user_id は Private claim

```
{
  "iat": "1516239022",
  "exp": "1234567890",
  "user_id": "1",
}
```

- Signature は署名で、Header/Payload/鍵情報/指定したアルゴリズムの情報を利用して生成される。HS256 の場合の署名生成の式は次の通り。

```
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)
```

# 署名に secret を使うか、公開鍵ペアを使うか

secret を使う場合は、JWT をやり取りする当事者間両方がその鍵を持っていないと、署名の検証ができません。一方で、secret を使うのは公開鍵ペアよりも実装がシンプルです。そのため、例えば次のように使い分けると良いです。

- JWT の生成と検証が 1 つのアプリケーション内部で完結している場合は secret を使う。
- JWT の生成と検証が別々のアプリケーション間で行われる場合は公開鍵ペアを使う。生成元で秘密鍵を、検証先で公開鍵を使う。

以降では 1 つのアプリケーション内部で完結する機能の実装に利用する例を示すので、 secret を使っていきます。

# ユースケース

大きくは次の 2 パターンがあります。

認証・認可

- ユーザーがログイン後に、JWT トークンをセッションに保存。以後の通信ではそのトークンをやり取りする。詳細は後述。
- メリットは次の通り
 - パスワードなどを毎回やり取りしなくて済むのでセキュア。exp を短くしておけば JWT トークンが外部に漏洩しても被害が最小限に抑えられる
 - 認証のための DB アクセスを署名検証で置き換えられる。一般に署名検証のほうが高速で省エネ。
 - 認可情報などを Private claims に含めておけばさらに DB アクセスなどを省略できる

セキュアな情報のやり取り

- Private（or Public） claims を利用すると、例えば、パスワードリセットのためのメールに含まれる URL トークンとして使える。詳細は後述。
- メリットは次の通り
 - JWT トークンに必要な情報を含めた上で有効期限も設定できるので DB などを利用しないで実現できる

# 実装例

### 認証に使う JWT の生成

ログインに成功したら、iat, exp, user_id を含めた JWT トークンを生成する。以後のリクエストではクライアントからは user_id と JWT トークンを受け取って、署名が検証できた JSON の user_id と受け取った user_id が一致していたら認証成功とする。

exp を過ぎたら認証エラーとしてクライアントは再度ログインを行う必要がある。また iat よりあとにパスワードを更新したら、この場合も認証エラーにする。

```go 
package authtoken

import (
	"time"

	"github.com/dgrijalva/jwt-go"
)

const (
	// secret は openssl rand -base64 40 コマンドで作成した。
	secret = "2FMd5FNSqS/nW2wWJy5S3ppjSHhUnLt8HuwBkTD6HqfPfBBDlykwLA=="

	// userIDKey はユーザーの ID を表す。
	userIDKey = "user_id"

	// iat と exp は登録済みクレーム名。それぞれの意味は https://tools.ietf.org/html/rfc7519#section-4.1 を参照。{
	iatKey = "iat"
	expKey = "exp"
	// }

	// lifetime は jwt の発行から失効までの期間を表す。
	lifetime = 30 * time.Minute
)

// Auth は署名前の認証トークン情報を表す。
type Auth struct {
	UserID    string
	Iat       int64
}

// Generate は認証に利用する JWT トークンを生成して返す。
func Generate(userID string, now time.Time) (string, error) {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		userIDKey: userID,
		iatKey:    now.Unix(),
		expKey:    now.Add(lifetime).Unix(),
	})

	return token.SignedString([]byte(secret))
}

// Parse は jwt トークンから元になった認証情報を取り出す。
func Parse(signedString string) (*Auth, error) {
	token, err := jwt.Parse(signedString, func(token *jwt.Token) (interface{}, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return "", err.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return []byte(secret), nil
	})

	if err != nil {
		if ve, ok := err.(*jwt.ValidationError); ok {
			if ve.Errors&jwt.ValidationErrorExpired != 0 {
				return nil, err.Wrapf(err, "%s is expired", signedString)
			} else {
				return nil, err.Wrapf(err, "%s is invalid", signedString)
			}
		} else {
			return nil, err.Wrapf(err, "%s is invalid", signedString)
		}
	}

	if token == nil {
		return nil, err.Errorf("not found token in %s:", signedString)
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return nil, err.Errorf("not found claims in %s", signedString)
	}
	userID, ok := claims[userIDKey].(string)
	if !ok {
		return nil, err.Errorf("not found %s in %s", userIDKey, signedString)
	}
	iat, ok := claims[iatKey].(float64)
	if !ok {
		return nil, err.Errorf("not found %s in %s", iatKey, signedString)
	}

	return &Auth{
		UserID:    userID,
		Iat:       int64(iat),
	}, nil
}
```

```go
func authenticate(userID, authorization string) error {
	// 正しい認証トークンであるかを確認する {
	auth, err := authtoken.Parse(authorization)
	if err != nil {
		return err.Wrapf(err, "failed to parse authorization")
	}

	if userID != auth.UserID {
		return err.Errorf("failed authentication, userID=%s, userIDPayload=%s", userID, auth.UserID)
	}
	// }

	// 認証トークン発行後にパスワードが更新されていたら認証エラーとする {
	passwordUpdateUnixTime, err := GetUserPasswordUpdateTime(ctx, userID)
	if auth.Iat <= passwordUpdateUnixTime {
		return err.Errorf("need to login, iat=%d, passwordUpdateUnixTime=%d", auth.Iat, passwordUpdateUnixTime)
	}
	// }

	return nil
}
```

### パスワードリセット URL の生成

URL パラメーターに JWT トークンを使う。JWT トークンにはユーザーを特定するメールアドレスと iat と exp を含める。署名に成功した JSON のメールアドレスでパスワードを変更するユーザーを特定して、パスワード変更リクエストを処理すればいい。

exp を過ぎていたらパスワードリセットを認めない。iat とパスワード変更時間を比較して、iat のあとにパスワードを変更していたらパスワードリセットを認めない。同じ URL で 1 回以上パスワードリセットを認めないということ。こうするとセキュアで良い。

パスワードリセット状態でメールアドレスが変わることはあまり想定できないが、いちおうその場合のエラー処理も考慮しておくと良い。ちなみにメールアドレスの代わりに user_id 相当のものを使わないのはユーザーが間違ったメールアドレス（自身が所有していない）にパスワードリセット URL を送った場合にリセットできないようにするため。

余談として、パスワード変更が完了したら変更完了のメールを送るとなお良い。

実装的には認証に使う JWT 生成とほぼ同じ。違う点は以下。

- user_id の代わりに email を使う
- IsExpired を含む PasswordResetToken を Parse の返り値にする。これは expire 時に呼び出し元でエラーメッセージを変えるため。

```go
package userpasswordresettoken

import (
	"time"

	"github.com/dgrijalva/jwt-go"
)

const (
	// secret は openssl rand -base64 40 コマンドで作成した。
	secret = "2FMd5FNSqS/nW2wWJy5S3ppjSHhUnLt8HuwBkTD6HqfPfBBDlykwLA=="

	// emailKey はメールアドレスを表す。
	emailKey = "email"

	// iat と exp は登録済みクレーム名。それぞれの意味は https://tools.ietf.org/html/rfc7519#section-4.1 を参照。{
	iatKey = "iat"
	expKey = "exp"
	// }

	// lifetime は jwt の発行から失効までの期間を表す。
	lifetime = 24 * time.Hour
)

// PasswordResetToken は署名前のパスワードリセットトークン情報を表す。
type PasswordResetToken struct {
	Email     string
	Iat       int64
	IsExpired bool
}

// Generate はパスワードリセットに利用する jwt トークンを生成して返す。
func Generate(email string, now time.Time) (string, error) {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		emailKey: email,
		iatKey:   now.Unix(),
		expKey:   now.Add(lifetime).Unix(),
	})

	return token.SignedString([]byte(secret))
}

// Parse は jwt トークンから元になったパスワードリセット情報を取り出す。
func Parse(signedString string) (*PasswordResetToken, error) {
	token, err := jwt.Parse(signedString, func(token *jwt.Token) (interface{}, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return "", err.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return []byte(secret), nil
	})

	isExpired := false
	if err != nil {
		// 有効期限が切れたことを理由とするエラーはスキップする {
		if ve, ok := err.(*jwt.ValidationError); ok {
			if ve.Errors&jwt.ValidationErrorExpired != 0 {
				isExpired = true
			} else {
				return nil, err.Wrapf(err, "%s is invalid", signedString)
			}
		} else {
			return nil, err.Wrapf(err, "%s is invalid", signedString)
		}
		// }
	}

	if token == nil {
		return nil, err.Errorf("not found token in %s:", signedString)
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return nil, err.Errorf("not found claims in %s", signedString)
	}
	email, ok := claims[emailKey].(string)
	if !ok {
		return nil, err.Errorf("not found %s in %s", emailKey, signedString)
	}
	iat, ok := claims[iatKey].(float64)
	if !ok {
		return nil, err.Errorf("not found %s in %s", iatKey, signedString)
	}

	return &PasswordResetToken{
		Email:     email,
		Iat:       int64(iat),
		IsExpired: isExpired,
	}, nil
}
```

```go
// SendMailResetUserPassword メソッドはユーザーのパスワードを再設定するためのメールを送信する
func SendMailResetUserPassword(ctx context.Context, email string) error {
	passwordResetToken, err := userpasswordresettoken.Generate(email, time.Now())
	if err != nil {
		return err.Wrapf(err, "[BUG] failed to generate token from email=%s", email)
	}
	// リセット　URL のパラメーターに passwordResetToken を含めてメールを送信する
	...
}

// ResetUserPassword メソッドはユーザーのパスワードをリセットする
func ResetUserPassword(ctx context.Context, token, new string) error {
	// トークンをパースする {
	resetPassword, err := userpasswordresettoken.Parse(token)
	if err != nil {
		return nil, &dnerrors.InvalidPasswordResetTokenErr{
			Token: token,
			Err:   err.Wrap(err),
		}
	}
	if resetPassword.IsExpired {
		return nil, &dnerrors.ExpiredPasswordResetTokenErr{
			Token: token,
			Err:   err.Wrap(err),
		}
	}
	// }

	// ユーザー情報を取得する {
	auth, err := GetUserAuthFromEmail(ctx, resetPassword.Email)
	if err != nil {
		return nil, err.Wrapf(err, "failed to get auth from email=%s", resetPassword.Email)
	}

	passwordUpdateUnixTime := auth.PasswordUpdateTime
	if resetPassword.Iat < passwordUpdateUnixTime {
		return nil, &dnerrors.AlreadyChangedAfterPasswordResetTokenIssueErr{
			Iat:                resetPassword.Iat,
			PasswordUpdateUnix: passwordUpdateUnixTime,
		}
	}
	// }

	// 新パスワードに更新する
	...
}
```

# 参考

- https://jwt.io/introduction/
- https://tools.ietf.org/html/rfc7519#section-4.1