2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RCC (立命館コンピュータークラブ)Advent Calendar 2024

Day 9

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

Last updated at Posted at 2024-12-09

はじめに

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

今回の認証方式

  1. サインアップ時にアカウントをDBに保存
  2. DBに保存したIDuser_idとしてセッションに保存
  3. 秘密キーを使用して暗号化
  4. 暗号化したデータをクッキーとして保存

実装

必要なライブラリ

# セッション管理
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

ディレクトリ構成

.
├── 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

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

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

package model

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

domain/repository/iuser_repository.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にデータを保存するロジック部分

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

セッション作成

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)
}

セキュリティ設定の解説

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

application/user_usecase.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

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

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
}

セッションストアの初期化

store := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))

環境変数から秘密キーを読み込み、セッションストアを作成します。

db/000001_create_users_table.down.sql

DROP TABLE IF EXISTS `users`;

db/000001_create_users_table.up.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

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

おわりに

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?