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)を
検証することで、「このトークンはサーバーの秘密鍵を知っている人が発行した」ことを確認できます。
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 を脆弱なクエリと安全なクエリの
両方に投げて、ヒット件数を比較します。
%%
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'='1 | 2 件(全ユーザーが漏洩) |
| プレースホルダ(安全) | ' OR '1'='1 | 0 件(該当ユーザーなし) |
脆弱なクエリは登録ユーザー全員(2件)がヒットし、安全なクエリは0件になります。 入力が「ただの文字列」として扱われるか、「SQLの一部」として解釈されるかの違いです。
5. bcrypt: パスワードのハッシュ化と照合¶
パスワードを bcrypt でハッシュ化し、正しいパスワードと間違ったパスワードで 照合結果がどう変わるかを確認します。
%%
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)のトークンを
自分で作ります。改ざんすると署名検証が失敗することも確認します。
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
}
%%
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エラー)
// YOUR CODE HERE
// verifyTokenWithExpiry を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func verifyTokenWithExpiry(token, secret string, now int64) (map[string]any, error) {
return nil, ErrUnanswered
}
チェックのためのヘルパー¶
答え合わせに使う小さなヘルパー mustEqual を定義します。
(GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)
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))
}
%%
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 で「フレームワークと自作」を扱います。