【夏休みの自由研究】無料 Vercel × Go でパスキーを実装してみよう!
はじめに
これまで Vercel に無料で Go サーバーを構築できることをはじめとしていくつか Vercel × Go に関する記事を書いてきました。
今回はもっと Vercel × Go のイメージを持ってもらうためにちょっと実践的な実装を紹介しようと思います。
パスキーとは
以下の記事にて詳しく解説されています。
パスキーはひとことで言うと デバイスの所有者がデバイス内に保存された鍵を使ってウェブサイトやアプリにログインできる仕組み です。
とのことです。
ピンとこない方は GitHub がパスキーに対応しているのでぜひ試してみてください。
今回はこの機能を Go で実装して Vercel にデプロイしていきます。
go-webauthn/webauthn
Go で手軽にパスキーの実装を行うには go-webauthn/webauthn
というライブラリが有力です。
サンプル実装 ( go-webauthn/example
) が提供されていたり、teamhanko/hanko
や zitadel/zitadel
といった OSS の認証ミドルウェアで採用されています。
ざっくり機能解説
解説しようかと思いましたがすでに説明されている方がいらっしゃいましたのでそちらの記事をご参考くださいmm
記事にて解説されている登録フローと認証フローを実装します。
実装
API や テーブル名などは teamhanko/hanko
や zitadel/zitadel
を参考に実装しています。
バージョン情報
go version
go version go1.22.6 darwin/arm64
vercel --version
Vercel CLI 35.2.3
35.2.3
API 定義
登録フローと認証フローの機能を提供するためにそれぞれ 2 つずつ API が必要となります。またログイン画面を払い出す画面として 1 つ API を実装します。合計 5 つの API を以下のように定義します。
この API へとルーティングするための vercel.json
下記のように設定します。
vercel.json
{
"build": {
"env": {
"GO_BUILD_FLAGS": "-ldflags '-s -w'"
}
},
"routes": [
{
"src": "/",
"dest": "/api",
"methods": [
"GET"
]
},
{
"src": "/attestation",
"dest": "/api/attestation",
"methods": [
"GET",
"POST"
]
},
{
"src": "/assertion",
"dest": "/api/assertion",
"methods": [
"GET",
"POST"
]
}
]
}
ストレージ
データ保存として Vercel KV と Vercel Postgres を利用します。
Vercel KV の Quickstart は以下をご参照ください。
Vercel Postgres の Quickstart は以下をご参照ください。
Vercel KV で 登録フロー、認証フローそれぞれのセッションを管理します。
Go からは redis/ruedis
を用いて接続/操作します。
Vercel Postgres でユーザーおよびクレデンシャルを保存します。
Go からは jackc/pgx
を用いて接続/操作します。
それぞれのストレージに Vercel Functions から接続するために KV_URL
と POSTGRES_URL
を Project Settings > Environment Variables にて設定します。
マイグレーション
マイグレーションツールとして sqldef/sqldef
を使います。
sqldef
を選定している理由は ORM の sqlc
と相性が良いと考えているからです。
ただ、最近 up
down
形式の DDL でも sqlc
の管理ができることに気づいたので golang-migrate/migrate
でもよいかなと思いはじめています。[1]
以下の値を .env
として保存し Makefile から実行できるようにします。
※ .env はパブリックリポジトリの場合コミットしないようにしましょう。
.env
POSTGRES_USER=xxxxx
POSTGRES_HOST=xxxxx
POSTGRES_PASSWORD=xxxxx
POSTGRES_DATABASE=xxxxx
Makefile
SHELL := /bin/bash
include .env
.PHONY: migrate
migrate:
@psqldef --user=${POSTGRES_USER} --password=${POSTGRES_PASSWORD} --host=${POSTGRES_HOST} --port=5432 ${POSTGRES_DATABASE} < schema/schema.sql
make migrate
ディレクトリ構成
.
├── openapi # OpenAPI 定義
│ └── openapi.yaml
├── schema # DDL
│ └── schema.sql
├── api # Vercel Functions のエントリーポイント
│ ├── assertion
│ │ └── index.go
│ ├── attestation
│ │ └── index.go
│ ├── _index.html
│ └── index.go
├── pkg # Go コード群
│ ├── domain
│ │ └── webauthn.go
│ ├── kv
│ │ └── kv.go
│ ├── postgres
│ │ └── postgres.go
│ └── schema
│ ├── db.go
│ ├── models.go
│ ├── query.sql
│ └── query.sql.go
├── .docker # ローカル開発用ファイル
│ ├── Dockerfile
│ ├── compose.yaml
│ └── main.go
├── go.mod
├── go.sum
├── sqlc.yaml
└── vercel.json
個人的に Go コードは internal
ディレクトリを作って集約したいのですが Vercel Functions のデプロイ時に internal
だとライブラリの依存関係の解決に失敗するので pkg
ディレクトリを作って集約しています。
vendor
ディレクトリは存在すると Vercel Functions へのデプロイに失敗するので作成しません。
ローカル開発
.docker
ディレクトリ配下に各種ファイルを用意します。
Vercel Postgres として PostgreSQL, Vercel KV として Redis の Docker を使います。
compose.yaml
services:
postgres:
container_name: ${APP_NAME}-postgres
image: postgres:16-alpine
ports:
- 5432:5432
volumes:
- ../schema:/docker-entrypoint-initdb.d
environment:
TZ: UTC
LANG: ja_JP.UTF-8
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
POSTGRES_HOST_AUTH_METHOD: trust
command: ["postgres", "-c", "log_statement=all"]
restart: always
redis:
container_name: ${APP_NAME}-redis
image: redis:7-alpine
ports:
- 6379:6379
restart: always
api:
container_name: ${APP_NAME}-api
build:
context: .
dockerfile: Dockerfile
args:
- workdir=.docker
ports:
- 8080:8080
environment:
ENV: local
PORT: 8080
POSTGRES_URL: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
KV_URL: redis:6379
volumes:
- ../:/app
depends_on:
- postgres
- redis
restart: always
また、ローカル用のサーバーを下記の main.go にて用意します。
main.go
package main
import (
"cmp"
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/otakakot/vercel-go-passkey/api"
assertion "github.com/otakakot/vercel-go-passkey/api/assertion"
attestation "github.com/otakakot/vercel-go-passkey/api/attestation"
)
func main() {
port := cmp.Or(os.Getenv("PORT"), "8080")
hdl := http.NewServeMux()
hdl.HandleFunc("/", api.Handler)
hdl.HandleFunc("/attestation", attestation.Handler)
hdl.HandleFunc("/assertion", assertion.Handler)
srv := &http.Server{
Addr: ":" + port,
Handler: hdl,
ReadHeaderTimeout: 30 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
slog.Info("start server listen")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
}()
<-ctx.Done()
slog.Info("start server shutdown")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
panic(err)
}
slog.Info("done server shutdown")
}
api
ディレクトリで定義している Vercel Functions のエントリーポイントを ServeMux
に対して設定します。
hdl := http.NewServeMux()
hdl.HandleFunc("/", api.Handler)
hdl.HandleFunc("/attestation", attestation.Handler)
hdl.HandleFunc("/assertion", assertion.Handler)
こうすることで Vercel Functions のための実装でもローカル環境(通常の Go サーバー)でも動作させることができます。
テスト
net/http/httptest
と ory/dockertest
を組み合わせて Handler に対して貫通でテストが実行できるように実装します。
登録フローおよび認証フローの完了は Web API の Credential Management API に依存しているためテストは諦めました .....
ブラウザに依存しないような機能であればこのテスト方式で完結するかと思います。
PostgreSQL および Redis の Docker を用意する処理は下記のように実装します。
setup
package testx
import (
"context"
"fmt"
"net"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/redis/rueidis"
)
func SetupPostgres(
t *testing.T,
ddlpath string,
) {
pool, err := dockertest.NewPool("")
if err != nil {
t.Fatalf("failed to create new pool: %v", err)
}
if err := pool.Client.Ping(); err != nil {
t.Fatalf("failed to ping: %v", err)
}
opt := dockertest.RunOptions{
Repository: "postgres",
Tag: "16-alpine",
Env: []string{
"POSTGRES_USER=postgres",
"POSTGRES_PASSWORD=postgres",
"POSTGRES_DB=test",
"listen_addresses='*'",
},
Mounts: []string{
ddlpath + ":/docker-entrypoint-initdb.d",
},
}
hcOpt := func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
}
hcOpts := []func(*docker.HostConfig){
hcOpt,
}
resource, err := pool.RunWithOptions(&opt, hcOpts...)
if err != nil {
t.Fatalf("failed to run with options: %v", err)
}
port := resource.GetPort("5432/tcp")
dsn := "postgres://postgres:postgres@localhost:" + port + "/test?sslmode=disable"
if err := pool.Retry(func() error {
conn, err := pgxpool.ParseConfig(dsn)
if err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
pool, err := pgxpool.NewWithConfig(context.Background(), conn)
if err != nil {
return fmt.Errorf("failed to create pool: %w", err)
}
if err := pool.Ping(context.Background()); err != nil {
return fmt.Errorf("failed to ping: %w", err)
}
return nil
}); err != nil {
t.Fatalf("failed to connect to postgres: %v", err)
}
t.Cleanup(func() {
if err := pool.Purge(resource); err != nil {
t.Logf("failed to purge: %v", err)
}
})
t.Setenv("POSTGRES_URL", dsn)
}
func SetupRedis(
t *testing.T,
) {
t.Helper()
pool, err := dockertest.NewPool("")
if err != nil {
t.Fatalf("failed to create new pool: %v", err)
}
resource, err := pool.Run("redis", "7-alpine", nil)
if err != nil {
t.Fatalf("failed to run redis: %v", err)
}
addr := net.JoinHostPort("localhost", resource.GetPort("6379/tcp"))
if err := pool.Retry(func() error {
cli, err := rueidis.NewClient(rueidis.ClientOption{InitAddress: []string{addr}})
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
ping := cli.B().Ping().Build()
if err := cli.Do(context.Background(), ping).Error(); err != nil {
return fmt.Errorf("failed to ping: %w", err)
}
return nil
}); err != nil {
t.Fatalf("Failed to ping Redis: %+v", err)
}
t.Cleanup(func() {
if err := pool.Purge(resource); err != nil {
t.Logf("failed to purge: %v", err)
}
})
t.Setenv("KV_URL", addr)
}
テストコードは以下のように実装します。
index_test.go
package api_test
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
api "github.com/otakakot/vercel-go-passkey/api/assertion"
"github.com/otakakot/vercel-go-passkey/pkg/testx"
)
func TestAssertionHandler(t *testing.T) {
pwd, _ := os.Getwd()
ddl := strings.Replace(pwd, "api/assertion", "schema", 1)
testx.SetupPostgres(t, ddl)
testx.SetupRedis(t)
type want struct {
status int
}
type args struct {
rw *httptest.ResponseRecorder
req *http.Request
}
tests := []struct {
name string
args args
want want
}{
{
name: "GET",
args: args{
rw: httptest.NewRecorder(),
req: httptest.NewRequest(http.MethodGet, "/assertion", nil),
},
want: want{
status: http.StatusOK,
},
},
{
name: "DELETE",
args: args{
rw: httptest.NewRecorder(),
req: httptest.NewRequest(http.MethodDelete, "/assertion", nil),
},
want: want{
status: http.StatusMethodNotAllowed,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
api.Handler(tt.args.rw, tt.args.req)
if tt.args.rw.Code != tt.want.status {
t.Errorf("got: %v, want: %v. msg: %s", tt.args.rw.Code, tt.want.status, tt.args.rw.Body.String())
}
})
}
}
動作確認
実際に Vercel にデプロイしてアクセスした画面は以下のようになります。
ログイン画面
登録フロー開始
登録フロー完了
認証フロー開始
認証フロー完了
この処理は Docker を使ってローカルマシンでも動作確認できます。
おわりに
夏休みやることないよーという方はぜひ Vercel × Go で遊んでみてください!
Vercel × Go 紹介シリーズもネタが尽きたのでこれで最後ですかね。
今回実装したコードは以下に置いておきます。
パスキー実装は厳密ではないのであくまで参考程度にご覧いただければと思います。
Discussion