🌏
Golangでchi + sqlc(PostgreSQL)を使ってAPIキーの認証をする
モチベーション
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