← レッスン一覧に戻る

04.4 認証・ウェブセキュリティ¶

このレッスンでは、Web APIを守るための3つの技術を扱います。

  • SQLインジェクション対策: ユーザー入力をそのままSQLに埋め込むと、入力次第でクエリの意味が 乗っ取られる。プレースホルダでこれを防ぐ
  • パスワードハッシュ(bcrypt): パスワードを平文で保存せず、復号できない形で保存する
  • JWT(自作): ログイン後の「私は誰か」という主張を、改ざんされていないか検証できる形で やり取りする

このレッスンのゴール:

  • プレースホルダの有無でSQLインジェクションが成功/失敗することを実演で確認できる
  • bcryptで正しいパスワード/間違ったパスワードの照合結果が変わることを確認できる
  • 自作JWT(HMAC-SHA256署名)の生成・検証、および改ざん検知を実演できる

1. 非自明な点①: SQLインジェクションは「文字列結合」で起きる¶

fmt.Sprintf("SELECT * FROM users WHERE username = '%s'", input) のように、ユーザー入力を 文字列としてSQL文に埋め込むと、入力に応じてクエリの構造そのものが変わってしまいます。 例えば input = "' OR '1'='1" を渡すと、実際のクエリは:

SELECT * FROM users WHERE username = '' OR '1'='1'

となり、'1'='1' は常に真なので全行が返ってきます(ログイン認証をすり抜けられる)。

プレースホルダ(WHERE username = ?、引数は別に渡す)を使うと、入力は常に「1つの値」として 扱われ、クエリ構造には一切影響しません。

2. 非自明な点②: パスワードは「速いハッシュ」でも不十分。bcryptは意図的に遅い¶

SHA-256のような高速なハッシュ関数は、総当たり攻撃(ブルートフォース)に弱いという問題が あります(1秒間に数億回試せてしまう)。bcrypt はハッシュ計算に意図的に時間をかける (コストパラメータで調整可能)ことで、総当たりのコストを非現実的なレベルまで引き上げます。

3. 非自明な点③: JWTは「暗号化」ではなく「署名」¶

JWT(JSON Web Token)は ヘッダー.ペイロード.署名 の3部構成で、ペイロードは base64url エンコードされているだけ(暗号化されていない)なので、誰でも中身を読めます。 JWTが守っているのは「秘密」ではなく「改ざんされていないこと」です。署名(HMAC-SHA256)を 検証することで、「このトークンはサーバーの秘密鍵を知っている人が発行した」ことを確認できます。

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

	"github.com/janpfeifer/gonb/gonbui"
	"golang.org/x/crypto/bcrypt"

	_ "modernc.org/sqlite"
)

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

// openCourseDB は、このコース共通の SQLite 接続を開く(1セル完結で使う)。
func openCourseDB(path string) *sql.DB {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		panic(err)
	}
	db.SetMaxOpenConns(1)
	return db
}

const dbPath = "file:_04_4_auth_security.db"

// countByVulnerableQuery は、文字列結合でSQLを組み立てる「脆弱な」検索。教材としてのみ使う。
func countByVulnerableQuery(db *sql.DB, usernameInput string) (int, error) {
	query := fmt.Sprintf(`SELECT COUNT(*) FROM users WHERE username = '%s'`, usernameInput)
	var n int
	err := db.QueryRow(query).Scan(&n)
	return n, err
}

// countBySafeQuery は、プレースホルダを使う「安全な」検索。
func countBySafeQuery(db *sql.DB, usernameInput string) (int, error) {
	var n int
	err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE username = ?`, usernameInput).Scan(&n)
	return n, err
}

func renderRows(rows [][3]string) string {
	var b strings.Builder
	b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse">`)
	b.WriteString(`<tr><th>クエリ</th><th>入力</th><th>ヒット件数</th></tr>`)
	for _, r := range rows {
		b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td><code>%s</code></td><td>%s</td></tr>`, r[0], r[1], r[2]))
	}
	b.WriteString(`</table>`)
	return b.String()
}

4. SQLインジェクション: プレースホルダの有無で結果を比較する¶

users テーブルに2人だけ登録し、悪意ある入力 ' OR '1'='1 を脆弱なクエリと安全なクエリの 両方に投げて、ヒット件数を比較します。

In [2]:
%%
db := openCourseDB(dbPath)
defer db.Close()

db.Exec(`DROP TABLE IF EXISTS users`)
db.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password_hash TEXT)`)
db.Exec(`INSERT INTO users (username, password_hash) VALUES ('taro', 'dummy-hash-1'), ('hanako', 'dummy-hash-2')`)

const maliciousInput = `' OR '1'='1`

vulnCount, vulnErr := countByVulnerableQuery(db, maliciousInput)
fmt.Printf("脆弱なクエリ: ヒット件数=%d, エラー=%v\n", vulnCount, vulnErr)

safeCount, safeErr := countBySafeQuery(db, maliciousInput)
fmt.Printf("安全なクエリ(プレースホルダ): ヒット件数=%d, エラー=%v\n", safeCount, safeErr)

gonbui.DisplayHTML(renderRows([][3]string{
	{"文字列結合(脆弱)", maliciousInput, fmt.Sprintf("%d 件(全ユーザーが漏洩)", vulnCount)},
	{"プレースホルダ(安全)", maliciousInput, fmt.Sprintf("%d 件(該当ユーザーなし)", safeCount)},
}))
gonbui.Sync()
脆弱なクエリ: ヒット件数=2, エラー=<nil>
安全なクエリ(プレースホルダ): ヒット件数=0, エラー=<nil>
クエリ入力ヒット件数
文字列結合(脆弱)' OR '1'='12 件(全ユーザーが漏洩)
プレースホルダ(安全)' OR '1'='10 件(該当ユーザーなし)

脆弱なクエリは登録ユーザー全員(2件)がヒットし、安全なクエリは0件になります。 入力が「ただの文字列」として扱われるか、「SQLの一部」として解釈されるかの違いです。

5. bcrypt: パスワードのハッシュ化と照合¶

パスワードを bcrypt でハッシュ化し、正しいパスワードと間違ったパスワードで 照合結果がどう変わるかを確認します。

In [3]:
%%
password := "correct horse battery staple"

hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
	panic(err)
}
fmt.Printf("ハッシュ化されたパスワード(先頭30文字): %s...\n", string(hash)[:30])

correctErr := bcrypt.CompareHashAndPassword(hash, []byte(password))
fmt.Printf("正しいパスワードで照合: エラー=%v(nilなら一致)\n", correctErr)

wrongErr := bcrypt.CompareHashAndPassword(hash, []byte("wrong password"))
fmt.Printf("間違ったパスワードで照合: エラー=%v(nilでなければ不一致)\n", wrongErr)
ハッシュ化されたパスワード(先頭30文字): $2a$10$ryBTspdJamSTOxMJdNwp1.V...
正しいパスワードで照合: エラー=<nil>(nilなら一致)
間違ったパスワードで照合: エラー=crypto/bcrypt: hashedPassword is not the hash of the given password(nilでなければ不一致)

6. 自作JWT: ヘッダー.ペイロード.署名¶

crypto/hmac + crypto/sha256 だけを使って、JWTと同じ形式(HS256)のトークンを 自分で作ります。改ざんすると署名検証が失敗することも確認します。

In [4]:
const jwtSecret = "course-demo-secret-do-not-use-in-production"

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

// signToken は claims から HS256 署名付きトークンを作る。
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
}

// verifyToken は署名を検証し、正しければ claims を返す。改ざんされていればエラーを返す。
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))

	// hmac.Equal はタイミング攻撃に強い定数時間比較。
	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
}
In [5]:
%%
token := signToken(map[string]any{"sub": "taro", "role": "student"}, jwtSecret)
fmt.Println("発行したトークン:", token)

claims, err := verifyToken(token, jwtSecret)
fmt.Printf("正常なトークンの検証: claims=%v, err=%v\n", claims, err)

// 改ざん: ペイロード部分を別の(正当に見える)文字列にすり替える。
parts := strings.SplitN(token, ".", 3)
tamperedPayload := base64urlEncode([]byte(`{"sub":"taro","role":"admin"}`))
tamperedToken := parts[0] + "." + tamperedPayload + "." + parts[2]

_, tamperErr := verifyToken(tamperedToken, jwtSecret)
fmt.Printf("改ざんされたトークンの検証: err=%v(署名が一致せずエラーになる)\n", tamperErr)
発行したトークン: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic3R1ZGVudCIsInN1YiI6InRhcm8ifQ.m6V7NWiFXUaIrZgsSy3d9ApgiH0qsyO-NTPqZs-xQHY
正常なトークンの検証: claims=map[role:student sub:taro], err=<nil>
改ざんされたトークンの検証: err=invalid signature(署名が一致せずエラーになる)

role を "student" から "admin" に書き換えても、署名(3番目の部分)は元のままなので、 検証時に再計算した署名と一致せず invalid signature になります。これが JWT の「改ざん防止」です。

7. 直感・類推¶

  • プレースホルダは「入力欄と命令文を別の紙に分けて渡す」ことに似ています。相手(DB)は 「命令の紙」だけを命令として読み、「入力の紙」に何が書いてあっても命令としては解釈しません
  • bcrypt は「金庫の鍵を1個ずつ試すのに、意図的に鍵穴を渋くする」ようなものです。1回の試行に 時間がかかれば、総当たりは現実的な時間で終わらなくなります
  • JWT は「封蝋(ふうろう)付きの手紙」に似ています。手紙の中身(ペイロード)は誰でも読めますが、 封蝋(署名)が壊れていれば「本物ではない」とすぐ分かります

練習問題 4.4: verifyToken を「有効期限」対応にした verifyTokenWithExpiry を実装しよう¶

claims に "exp"(UNIXタイムスタンプ、float64。JSONの数値はデフォルトで float64 に デコードされます)というキーがある場合、それが現在時刻以前なら期限切れとみなしてエラーを 返すようにしてください。🔴 境界に注意: exp == now(ちょうど期限の瞬間)も期限切れ扱いです (「有効なのは exp より厳密に前まで」という一般的なJWTの解釈に合わせます。exp ちょうどを 有効と誤判定すると、意図せず有効期限を1秒延長してしまいます)。

仕様:

func verifyTokenWithExpiry(token, secret string, now int64) (map[string]any, error)
  • まず verifyToken(token, secret) と同じ検証(署名チェック)を行う(失敗すればそのエラーを返す)
  • claims["exp"] が存在し、float64 として now 以下(exp <= now)なら errors.New("token expired") を返す
  • "exp" が無い、または now より大きいなら claims をそのまま返す(nil エラー)
In [6]:
// YOUR CODE HERE
// verifyTokenWithExpiry を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func verifyTokenWithExpiry(token, secret string, now int64) (map[string]any, error) {
	return nil, ErrUnanswered
}

チェックのためのヘルパー¶

答え合わせに使う小さなヘルパー mustEqual を定義します。 (GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)

In [7]:
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 [8]:
%%
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 errors.Is(err1, ErrUnanswered) {
	fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
	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("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください

まとめ¶

  • SQLインジェクションは文字列結合で起き、プレースホルダで構造的に防げる
  • パスワードは bcrypt のような意図的に遅いハッシュで保存する(速いハッシュは総当たりに弱い)
  • JWTは暗号化ではなく署名。ペイロードは誰でも読めるが、署名で改ざんを検知できる
  • CSRF(サイト間リクエスト偽造)・XSS(クロスサイトスクリプティング)はブラウザ・DOMが関わる 攻撃のため、このコースではJupyter上で実行できる範囲(SQLi・パスワード・JWT)に絞って扱いました

Module 4(API設計とアーキテクチャ)はこれで終わりです。次は Module 5 で「フレームワークと自作」を扱います。