← レッスン一覧に戻る

解答 04.4 — JWTの有効期限チェック(verifyTokenWithExpiry)¶

このノートブックは 04.4-auth-security.ipynb の練習問題の解答です。 まず問題の前提コードを再掲し、次に解答、最後にチェックを実行します。

先に自分の力で解いてから、答え合わせに使ってください。

前提コード(問題ノートブックと同じ定義)¶

In [1]:
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
)

// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")

const jwtSecret = "course-demo-secret-do-not-use-in-production"

func base64urlEncode(b []byte) string {
	return base64.RawURLEncoding.EncodeToString(b)
}

func signToken(claims map[string]any, secret string) string {
	header := base64urlEncode([]byte(`{"alg":"HS256","typ":"JWT"}`))
	payloadBytes, err := json.Marshal(claims)
	if err != nil {
		panic(err)
	}
	payload := base64urlEncode(payloadBytes)

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(header + "." + payload))
	signature := base64urlEncode(mac.Sum(nil))

	return header + "." + payload + "." + signature
}

func verifyToken(token, secret string) (map[string]any, error) {
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		return nil, errors.New("malformed token")
	}
	header, payload, signature := parts[0], parts[1], parts[2]

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(header + "." + payload))
	expectedSignature := base64urlEncode(mac.Sum(nil))

	if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
		return nil, errors.New("invalid signature")
	}

	payloadBytes, err := base64.RawURLEncoding.DecodeString(payload)
	if err != nil {
		return nil, err
	}
	var claims map[string]any
	if err := json.Unmarshal(payloadBytes, &claims); err != nil {
		return nil, err
	}
	return claims, nil
}

解答: verifyTokenWithExpiry¶

verifyToken にそのまま委譲したあと、claims["exp"] を型アサーションで取り出して比較するだけです。

In [2]:
func verifyTokenWithExpiry(token, secret string, now int64) (map[string]any, error) {
	claims, err := verifyToken(token, secret)
	if err != nil {
		return nil, err
	}
	if expRaw, ok := claims["exp"]; ok {
		if exp, ok := expRaw.(float64); ok && exp <= float64(now) {
			return nil, errors.New("token expired")
		}
	}
	return claims, nil
}

チェック(問題ノートブックと同じ期待値)¶

答え合わせ用のヘルパー mustEqual を定義します。

In [3]:
import "reflect"

func mustEqual(got, want any, name string) {
	if reflect.DeepEqual(got, want) {
		fmt.Printf("✅ Passed: %s\n", name)
		return
	}
	panic(fmt.Sprintf("❌ %s\n  got  = %v (%T)\n  want = %v (%T)", name, got, got, want, want))
}
In [4]:
%%
expiredToken := signToken(map[string]any{"sub": "taro", "exp": float64(1000)}, jwtSecret)
boundaryToken := signToken(map[string]any{"sub": "taro", "exp": float64(2000)}, jwtSecret)
freshToken := signToken(map[string]any{"sub": "taro", "exp": float64(9999999999)}, jwtSecret)
noExpToken := signToken(map[string]any{"sub": "taro"}, jwtSecret)

_, err1 := verifyTokenWithExpiry(expiredToken, jwtSecret, 2000)
if err1 == nil || err1.Error() != "token expired" {
	panic(fmt.Sprintf("❌ 期限切れトークンはエラーになるはず(got err=%v)", err1))
}
fmt.Println("✅ Passed: 期限切れトークンは token expired エラー")

// 境界値: exp == now もちょうど期限切れの瞬間として扱う(exp < now だけでは1秒分すり抜ける)。
_, errBoundary := verifyTokenWithExpiry(boundaryToken, jwtSecret, 2000)
if errBoundary == nil || errBoundary.Error() != "token expired" {
	panic(fmt.Sprintf("❌ exp == now は期限切れ扱いになるはず(got err=%v)", errBoundary))
}
fmt.Println("✅ Passed: exp == now も token expired エラー(境界値)")

claims2, err2 := verifyTokenWithExpiry(freshToken, jwtSecret, 2000)
mustEqual(err2, nil, "有効期限内のトークンはエラーなし")
mustEqual(claims2["sub"], "taro", "有効期限内のトークンのclaimsが取れる")

claims3, err3 := verifyTokenWithExpiry(noExpToken, jwtSecret, 2000)
mustEqual(err3, nil, "exp が無いトークンはエラーなし")
mustEqual(claims3["sub"], "taro", "exp が無いトークンのclaimsが取れる")

fmt.Println("🎉 すべてのチェックが通りました")
✅ Passed: 期限切れトークンは token expired エラー
✅ Passed: exp == now も token expired エラー(境界値)
✅ Passed: 有効期限内のトークンはエラーなし
✅ Passed: 有効期限内のトークンのclaimsが取れる
✅ Passed: exp が無いトークンはエラーなし
✅ Passed: exp が無いトークンのclaimsが取れる
🎉 すべてのチェックが通りました

解説¶

  1. JSONの数値は float64 にデコードされる: Go の encoding/json はデフォルトで数値を float64 として any/interface{} にデコードします。int64 として直接比較しようとすると 型アサーションが失敗するので注意が必要です
  2. 署名検証を先に行う: 有効期限チェックの前に verifyToken の署名検証を通すことで、 「改ざんされた exp」を信用してしまうことを防ぎます(順序が逆だと、署名が無効なトークンの exp を信じて判定してしまう可能性があります)
  3. 境界は <=(< ではない): exp < now だけで判定すると、exp == now(ちょうど期限の瞬間) のトークンが「まだ有効」と誤判定され、意図せず有効期限が1秒延長されます。「有効なのは exp より 厳密に前まで」と考え、exp <= now を期限切れの条件にします