🐁

【夏休みの自由研究】無料 Vercel × Go でパスキーを実装してみよう!

2024/08/12に公開

はじめに

これまで Vercel に無料で Go サーバーを構築できることをはじめとしていくつか Vercel × Go に関する記事を書いてきました。

https://zenn.dev/otakakot/articles/9e9269a87aafeb
https://zenn.dev/otakakot/articles/d723a4688d3e20
https://zenn.dev/otakakot/articles/2110176751b895

今回はもっと Vercel × Go のイメージを持ってもらうためにちょっと実践的な実装を紹介しようと思います。

パスキーとは

以下の記事にて詳しく解説されています。

https://blog.agektmr.com/2023/12/passkey-mythbusting

パスキーはひとことで言うと デバイスの所有者がデバイス内に保存された鍵を使ってウェブサイトやアプリにログインできる仕組み です。

とのことです。
ピンとこない方は GitHub がパスキーに対応しているのでぜひ試してみてください。

https://docs.github.com/ja/authentication/authenticating-with-a-passkey/signing-in-with-a-passkey

今回はこの機能を Go で実装して Vercel にデプロイしていきます。

go-webauthn/webauthn

Go で手軽にパスキーの実装を行うには go-webauthn/webauthn というライブラリが有力です。

https://github.com/go-webauthn/webauthn

サンプル実装 ( go-webauthn/example ) が提供されていたり、teamhanko/hankozitadel/zitadel といった OSS の認証ミドルウェアで採用されています。

ざっくり機能解説

解説しようかと思いましたがすでに説明されている方がいらっしゃいましたのでそちらの記事をご参考くださいmm

https://zenn.dev/hakoten/books/c9718954916f82/viewer/5449ef

記事にて解説されている登録フローと認証フローを実装します。

実装

API や テーブル名などは teamhanko/hankozitadel/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 KVVercel Postgres を利用します。

Vercel KV の Quickstart は以下をご参照ください。

https://vercel.com/docs/storage/vercel-kv/quickstart

Vercel Postgres の Quickstart は以下をご参照ください。

https://vercel.com/docs/storage/vercel-postgres/quickstart

Vercel KV で 登録フロー、認証フローそれぞれのセッションを管理します。
Go からは redis/ruedis を用いて接続/操作します。
https://github.com/redis/rueidis
Vercel Postgres でユーザーおよびクレデンシャルを保存します。
Go からは jackc/pgx を用いて接続/操作します。
https://github.com/jackc/pgx

それぞれのストレージに Vercel Functions から接続するために KV_URLPOSTGRES_URL を Project Settings > Environment Variables にて設定します。

マイグレーション

マイグレーションツールとして sqldef/sqldef を使います。

https://github.com/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/httptestory/dockertest を組み合わせて Handler に対して貫通でテストが実行できるように実装します。

https://pkg.go.dev/net/http/httptest

https://github.com/ory/dockertest

登録フローおよび認証フローの完了は 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 紹介シリーズもネタが尽きたのでこれで最後ですかね。
今回実装したコードは以下に置いておきます。

https://github.com/otakakot/vercel-go-passkey

パスキー実装は厳密ではないのであくまで参考程度にご覧いただければと思います。

脚注
  1. https://docs.sqlc.dev/en/stable/howto/ddl.html#golang-migrate ↩︎

Discussion