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