はじめに
Webサービスでログイン機能を作ると必ずぶつかるセッション(Cookie)についての記事です。
認証機能にはAOuthやJWTなどもありますが、今回はgorilla/sessions
を使用して実装する方法を記事にします。
今回の認証方式
- サインアップ時にアカウントを
DB
に保存 -
DB
に保存したID
をuser_id
としてセッションに保存 -
秘密キー
を使用して暗号化 - 暗号化したデータをクッキーとして保存
実装
必要なライブラリ
# セッション管理
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)
}
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
動作確認
- コンテナの立ち上げ
今回のルートディレクトリで以下のコマンドを実行してください
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
が追加されます
おわりに
なんとなくの実装の仕方を理解していただけたでしょうか?次回はログアウト時のセッションの削除と自動ログインするための実装をできたらいいなと思います。
次回も読んでいただけると幸いです。