🌏

Golangでchi + sqlc(PostgreSQL)を使ってAPIキーの認証をする

2023/08/04に公開

モチベーション

Goを使ったAPIを作成しており、APIキーの認証が必要となりました。AzureのAPI ManagementやAWSのAPI Gateway、OSSのKong Gatewayなどを利用して認証部分の実装をおまかせしても良いのですが、(お金や運用コストを鑑みて)自作してみることにしました。

Goにはecho, ginなどの軽量で素晴らしいwebフレームワークがありますが独自のcontextを使っているため多少慣れが必要です。今回はchiは標準のcontextに準拠している点を評価して採用しました。

https://github.com/go-chi/jwtauth を使っても良いのですが、クッキーからトークンを拾ってくる機能がついていたりするので自分の欲しい機能だけが達成できるように自作することにしました。なお、本記事ではhttps://github.com/lestrrat-go/jwx を全面的に信頼して実装を行っています。

また、APIキーの保管はPostgreSQLをsqlcを使って利用することで達成することにしました。

要件

  • システム管理者がAPIキーを発行する
  • ユーザーがリクエストヘッダーに含めたAPIキーを認証する
    • curl -H"Authorization: Bearer $TOKEN http://localhost:3000`
  • APIキーはDBにハッシュ化して保管する
  • APIキーの認証はハッシュの状態で行う

シーケンス図

APIキー発行フロー

APIキー生成用のエンドポイント/generateを用意しています。

sequenceDiagram
    API ->> API: APIキーの発行(JWTにする)
    API ->> DB: Hash(APIキー)を保存

API認証フロー

スキーマ

sqlcを使ってsqlc generateします。

CREATE TABLE users (
    user_id CHAR(36) NOT NULL UNIQUE PRIMARY KEY DEFAULT (gen_random_uuid()),
    hashed_key VARCHAR(255) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
);

CREATE INDEX users_hashed_key_index ON users (hashed_key);
-- name: CreateUser :one
INSERT INTO users (
    hashed_key
) VALUES (
    $1
) RETURNING user_id;

-- name: GetUserByHashedKey :one
SELECT user_id, hashed_key, created_at FROM users WHERE hashed_key = $1;

実装

環境変数として以下の値を設定しました。

PGPASSWORD=$ポスグレのパスワード
DATA_SOURCE_NAME="user=hoge dbname=hogehoge host=ホスト名  port=ポート sslmode=require password=パスワード"
package main

import (
	"chi-jwt/db/model"
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"fmt"
	"net/http"
	"os"

	_ "github.com/lib/pq"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/google/uuid"
	"github.com/lestrrat-go/jwx/v2/jwa"
	"github.com/lestrrat-go/jwx/v2/jwt"
	"golang.org/x/exp/slog"
)

var (
	key     jwt.SignEncryptParseOption
	queries *model.Queries
)

func init() {
	key = jwt.WithKey(jwa.HS256, []byte("secret"))
	db, err := sql.Open("postgres", os.Getenv("DATA_SOURCE_NAME"))
	if err != nil {
		panic(err)
	}
	queries = model.New(db)
}

func HashApiKey(apiKey string) string {
	hash := sha256.Sum256([]byte(apiKey))
	return hex.EncodeToString(hash[:])
}

func GenJWTFromApiKey(apiKey string) string {
	t := jwt.New()
	t.Set("apikey", apiKey)

	token, err := jwt.Sign(t, key)
	if err != nil {
		panic(err)
	}

	slog.Info("generated jwt", "token", string(token)) // This log should be removed in production
	return string(token)
}

func GetApiKeyFromHeader(r *http.Request) string {
	const bearer = "Bearer"
	token := r.Header.Get("Authorization")
	if len(token) > 7 && token[:7] != bearer { // 7 is len("Bearer ")
		return token[7:]
	}
	return ""
}

func Verifier(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := GetApiKeyFromHeader(r)
		fmt.Println("get token: ", token)
		jwtToken, err := jwt.Parse([]byte(token), key)
		if err != nil {
			fmt.Println(err)
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		// validate token
		err = jwt.Validate(jwtToken)
		if err != nil {
			fmt.Println(err)
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		// extract apikey
		apiKey, ok := jwtToken.Get("apikey")
		if !ok {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		slog.Info("token from header", "apikey", apiKey) // This log should be removed in production
		ctx := context.WithValue(r.Context(), "apikey", apiKey)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func Authenticator(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()
		token := ctx.Value("apikey")
		if token == nil {
			http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
			return
		}

		user, err := queries.GetUserByHashedKey(ctx, HashApiKey(token.(string)))
		if err != nil {
			http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
			return
		}
		slog.Info("user from db", "userId", user.UserID) // This log should be removed in production
		next.ServeHTTP(w, r)
	})
}

func router() http.Handler {
	r := chi.NewRouter()
	r.Use(middleware.Logger)

	r.Group(func(r chi.Router) {
		r.Use(Verifier)
		r.Use(Authenticator)
		r.Get("/verify", func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("welcome"))
		})

	})

	r.Group(func(r chi.Router) {
		r.Get("/generate", func(w http.ResponseWriter, r *http.Request) {
			apiKey := uuid.New().String()
			hashedApiKey := HashApiKey(apiKey)
			userId, err := queries.CreateUser(r.Context(), hashedApiKey)
			if err != nil {
				panic(err)
			}
			slog.Info("generated api key", "userId", userId, "apiKey", apiKey) // This log should be removed in production
			w.Write([]byte(GenJWTFromApiKey(apiKey)))
		})

	})
	return r
}

func main() {
	slog.Info("server started: http://localhost:3001")
	slog.Info("generate user by: curl http://localhost:3001/generate")
	slog.Info("use api key by: curl -H\"Authorization: Bearer $TOKEN\" http://localhost:3001/verify")

	http.ListenAndServe(":3001", router())
}

動作確認

generateでJWTを生成して、verifyに渡して認証できることを確認しました。

 % curl http://localhost:3001/generate
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcGlrZXkiOiJhOTBlOTNmMi0xODgwLTRhYjQtYjM3Yy05NTAxMzA2NWMyYjAifQ.hxjC-j3g3t39CENpMSYFDkX8mYnV7NOkST69zqMNbfI

 % curl -H"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcGlrZXkiOiJhOTBlOTNmMi0xODgwLTRhYjQtYjM3Yy05NTAxMzA2NWMyYjAifQ.hxjC-j3g3t39CENpMSYFDkX8mYnV7NOkST69zqMNbfI" http://localhost:3001/verify
welcome

おわりに

非常に簡単にAPIキーの認証を自作することができました。

お仕事のご連絡お待ちしています。contact@tychy.jp まで

Discussion