Go言語のGinフレームワークでセッション機能を作る: SignUp編

# はじめに
Webサービスでログイン機能を作ると必ずぶつかるセッション（Cookie）についての記事です。
認証機能にはAOuthやJWTなどもありますが、今回は`gorilla/sessions`を使用して実装する方法を記事にします。

# 今回の認証方式
1. サインアップ時にアカウントを`DB`に保存
2. `DB`に保存した`ID`を`user_id`としてセッションに保存
3. `秘密キー`を使用して**暗号化**
4. **暗号化したデータをクッキーとして保存**

```mermaid
sequenceDiagram
    participant User
    participant WebApp
    participant Database
User->>WebApp: サインアップ
WebApp->>Database: ユーザー情報保存
Database-->>WebApp: 保存完了
WebApp->>WebApp: セッション作成(秘密鍵で暗号化)
WebApp->>User: 暗号化されたCookieを返却

User->>WebApp: 以降のリクエスト
WebApp->>WebApp: Cookieを復号化
WebApp->>Database: ユーザー検証
Database-->>WebApp: 認証結果
WebApp->>User: リソース提供
```

# 実装

### 必要なライブラリ
```
# セッション管理
github.com/gorilla/sessions

# Webフレームワーク
github.com/gin-gonic/gin

# 自動マイグレートに使用
github.com/golang-migrate/migrate/v4 v4.18.1

# 環境変数の読み込みに使用
github.com/joho/godotenv v1.5.1
![スクリーンショット 2024-12-09 23.25.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/e3500e6b-4f8e-aad0-2bea-08824ffe16dc.png)

github.com/rollbar/rollbar-go v1.4.5
golang.org/x/crypto v0.30.0
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.25.12
```

### ディレクトリ構成
```bash
.
├── Dockerfile
├── application
│   └── user_usecase.go
├── db
│   ├── 000001_create_users_table.down.sql
│   └── 000001_create_users_table.up.sql
├── docker-compose.yml
├── domain
│   ├── model
│   │   └── user.go
│   └── repository
│       └── iuser_repository.go
├── go.mod
├── go.sum
├── infrastructure
│   ├── dao
│   │   └── user.go
│   └── session
│       └── session_manager.go
├── interface
│   └── handler
│       └── user_handler.go
└── main.go
```

### Dockerfile
```dockerfile
FROM golang:1.23-alpine

# 必要なパッケージをインストール
RUN apk add --no-cache git

# ワーキングディレクトリを設定
WORKDIR /app

# ソースコードをコピー
COPY . .

# 依存関係をダウンロード
RUN go mod download

# 本番用にビルド（CGOを無効にし、Linux用にビルド）
RUN CGO_ENABLED=0 GOOS=linux go build -o main ./main.go

EXPOSE 8080

# 実行
CMD ["/app/main"]
```

### docker-compose.yml
```yaml
services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root_password
      MYSQL_DATABASE: test_db
      MYSQL_USER: test_user
      MYSQL_PASSWORD: test_password
    ports:
      - "3306:3306"
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "test_user", "-p$$MYSQL_PASSWORD"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: always

  server:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: go
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy
    restart: on-failure

volumes:
  db_data:

networks:
  default:
    name: my-network

```

### domain/model/user.go
```go
package model

type User struct {
	ID        uint
	Name      string
	Email     string
	Password  string
}
```

### domain/repository/iuser_repository.go
```go
package repository

import (
	"context"
	"go-session/domain/model"
)

type IUserRepository interface {
	Create(ctx context.Context, user *model.User) (uint, error)
}
```

### infrastructure/dao/user.go
**実際にDBにデータを保存するロジック部分**
```go
package dao

import (
	"context"
	"go-session/domain/model"
	"gorm.io/gorm"
)

type userRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *userRepository {
	return &userRepository{db: db}
}

func (r *userRepository) Create(ctx context.Context, user *model.User) (uint, error) {
	err := r.db.WithContext(ctx).Create(user).Error
	if err != nil {
		return 0, err
	}
	return user.ID, nil
}
```

### infrastructure/session/session_manager.go
**セッション作成**
```go
package session

import (
	"fmt"
	"github.com/gorilla/sessions"
	"net/http"
)

type SessionManager struct {
	store *sessions.CookieStore
}

type ISessionManager interface {
	CreateSession(w http.ResponseWriter, r *http.Request, userID uint) error
}

func NewSessionManager(secretKey string) *SessionManager {
	return &SessionManager{
		store: sessions.NewCookieStore([]byte(secretKey)),
	}
}

func (sm *SessionManager) CreateSession(w http.ResponseWriter, r *http.Request, userID uint) error {
	session, err := sm.store.Get(r, "session-name")
	if err != nil {
		return fmt.Errorf("session creation error: %w", err)
	}

	session.Values["user_id"] = userID
	session.Options = &sessions.Options{
		Path:     "/",
		MaxAge:   3600 * 24, // 24時間
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteStrictMode, // より厳格なセキュリティ
	}

	return session.Save(r, w)
}

```
:::note info
### セキュリティ設定の解説

`HttpOnly`: `true` でクライアントサイドスクリプトからCookieへのアクセスを防止
`Secure`: `true` でHTTPS接続のみでCookieを送信
`SameSite`: `http.SameSiteNoneMode` でクロスサイトリクエストの制御
`MaxAge`: セッションの有効期限を設定
:::


### application/user_usecase.go
```go
package application

import (
	"context"
	"fmt"
	"go-session/domain/model"
	"go-session/domain/repository"
	"golang.org/x/crypto/bcrypt"
)

type CreateUserInput struct {
	Username string `json:"username"`
	Email    string `json:"email"`
	Password string `json:"password"`
}

type IUserUsecase interface {
	Create(ctx context.Context, input CreateUserInput) (uint, error)
}

type UserUsecase struct {
	userRepo repository.IUserRepository
}

func NewUserUsecase(userRepo repository.IUserRepository) IUserUsecase {
	return &UserUsecase{
		userRepo: userRepo,
	}
}

func (u *UserUsecase) Create(ctx context.Context, input CreateUserInput) (uint, error) {
	// パスワードをハッシュ化
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
	if err != nil {
		return 0, fmt.Errorf("failed to hash password: %v", err)
	}

	// userデータをDBに保存する
	id, err := u.userRepo.Create(ctx, &model.User{
		Name:     input.Username,
		Email:    input.Email,
		Password: string(hashedPassword),
	})

	if err != nil {
		return 0, err
	}

	return id, nil
}

```

### interface/user_handler.go
```go
package handler

import (
	"github.com/gin-gonic/gin"
	"go-session/application"
	"go-session/infrastructure/session"
	"net/http"
)

type (
	SignUpRequest = application.CreateUserInput
)

type IUserHandler interface {
	SignUp(ctx *gin.Context)
}

type UserHandler struct {
	userUsecase    application.IUserUsecase
	sessionManager session.ISessionManager
}

func NewUserHandler(userUsecase application.IUserUsecase, sessionManager session.ISessionManager) *UserHandler {
	return &UserHandler{
		userUsecase:    userUsecase,
		sessionManager: sessionManager,
	}
}

func (h *UserHandler) SignUp(ctx *gin.Context) {
	var request SignUpRequest
	if err := ctx.ShouldBindJSON(&request); err != nil {
		ctx.JSON(400, gin.H{"error": err.Error()})
		return
	}

	id, err := h.userUsecase.Create(ctx, request)

	if err != nil {
		ctx.JSON(500, gin.H{"error": err.Error()})
	}

	if err := h.sessionManager.CreateSession(ctx.Writer, ctx.Request, id); err != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Session creation failed"})
		return
	}

	ctx.JSON(http.StatusCreated, gin.H{"message": "sign in successful"})
}

```
**userのデータの保存の関数の呼び出しとセッション作成の関数を呼び出す**



### main.go
```go
package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/golang-migrate/migrate/v4"
	migrate_mysql "github.com/golang-migrate/migrate/v4/database/mysql"
	_ "github.com/golang-migrate/migrate/v4/source/file"
	"github.com/joho/godotenv"
	"github.com/rollbar/rollbar-go"
	"go-session/application"
	"go-session/infrastructure/dao"
	"go-session/infrastructure/session"
	"go-session/interface/handler"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
	"log"
	"net/http"
	"os"
)

func main() {
	if err := godotenv.Load(); err != nil {
		log.Printf("Warning: .env file not found")
	}

	// データベース接続の初期化
	db, err := initDB()
	if err != nil {
		log.Fatal(err)
	}

	sessions := session.NewSessionManager(os.Getenv("SESSION_KEY"))

	userRepo := dao.NewUserRepository(db)

	userUsecase := application.NewUserUsecase(userRepo)

	userHandler := handler.NewUserHandler(userUsecase, sessions)

	// ルーティング
	router := gin.Default()
	router.POST("/api/user/signup", userHandler.SignUp)

	log.Fatal(http.ListenAndServe(":8080", router))
}

func initDB() (*gorm.DB, error) {
	// .envファイルの読み込み
	if err := godotenv.Load(); err != nil {
		log.Printf("Warning: .env file not found")
	}

	// 環境変数から接続情報を取得
	dbName := os.Getenv("DB_NAME")
	dbUser := os.Getenv("DB_USER")
	dbPassword := os.Getenv("DB_PASSWORD")
	dbHost := os.Getenv("DB_HOST")
	dbPort := os.Getenv("DB_PORT")

	// 接続文字列の構築
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=true&multiStatements=true",
		dbUser, dbPassword, dbHost, dbPort, dbName,
	)

	// データベースに接続
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		return nil, fmt.Errorf("failed to connect database: %w", err)
	}

	sqlDB, err := db.DB()
	if err != nil {
		rollbar.Error(err)
		panic(err)
	}
	dbDriver, err := migrate_mysql.WithInstance(sqlDB, &migrate_mysql.Config{})
	if err != nil {
		rollbar.Error(err)
		panic(err)
	}
	m, err := migrate.NewWithDatabaseInstance("file://db/migrations", "mysql", dbDriver)
	if err != nil {
		rollbar.Error(err)
		panic(err)
	}
	if err := m.Up(); err != nil && err != migrate.ErrNoChange {
		rollbar.Error(err)
		panic(err)
	}

	db.Logger = db.Logger.LogMode(logger.Info)

	fmt.Println("DB migrated")

	return db, nil
}

```

**セッションストアの初期化** 
```go
store := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))
```
環境変数から秘密キーを読み込み、セッションストアを作成します。

### db/000001_create_users_table.down.sql
```sql
DROP TABLE IF EXISTS `users`;
```

### db/000001_create_users_table.up.sql
```sql
CREATE TABLE IF NOT EXISTS `users` (
    `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, -- 自動インクリメントの主キー
    `name` VARCHAR(255) NOT NULL,
    `email` VARCHAR(255) NOT NULL UNIQUE, -- Emailはユニーク制約付き
    `password` VARCHAR(255) NOT NULL -- パスワードのハッシュを保存
);
```

### .env
```
SESSION_KEY=IjKCyXIwlMMyWbata25tXSRG5nguG+co1D1zFHhD52Q=
DB_USER=test_user
DB_PASSWORD=test_password
DB_NAME=test_db
DB_HOST=db
DB_PORT=3306
```
:::note info
SESSION_KEYは以下のコマンドで生成した文字列が推奨
```
openssl rand -base64 32
```
:::

## 動作確認
1. コンテナの立ち上げ
今回のルートディレクトリで以下のコマンドを実行してください
```
docker-compose up --build
```

2.Postmanなどで確認
**エンドポイント**
```
http://localhost:8080/api/user/signup
```
**リクエストボディー**
```
{
    "username": "test_name",
    "email": "test_email",
    "password": "test_password"
}
```
Postmanで正しく動作すると以下のようにCookiesの欄に新しく`session-name`が追加されます

![スクリーンショット 2024-12-09 23.37.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/7c77584b-495c-adaf-998e-7094907b7e32.png)


# おわりに
なんとなくの実装の仕方を理解していただけたでしょうか？次回はログアウト時のセッションの削除と自動ログインするための実装をできたらいいなと思います。
次回も読んでいただけると幸いです。