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

# はじめに
本記事は以下の記事で実装したSignUp機能を持つAPIに自動SignInとSignOutの機能を追加するものになります。

https://qiita.com/zono0013/items/1a5d2275499f1728d024


# 実装する機能
**1. サインイン**
**2. 自動サインイン (セッション情報の取得)**
**3. サインアウト (セッションの無効化)**

# コードの変更
前回の記事にて作成したAPIに変更を加えていきます。
## セッション管理の拡張
`infrastructure/session/session_manager.go`に新しいメソッドを追加します。
- `GetUserID()`: セッションからユーザーIDを取得
- `InvalidateSession()`: セッションを無効化

```go
package session
// 省略

type ISessionManager interface {
	CreateSession(w http.ResponseWriter, r *http.Request, userID uint) error
     
     // ↓↓追加部分(2行)
	GetUserID(r *http.Request) (uint, bool)
	InvalidateSession(w http.ResponseWriter, r *http.Request) error
}

// 省略

// 以下全て追加
func (sm *SessionManager) GetUserID(r *http.Request) (uint, bool) {
	session, err := sm.store.Get(r, "session-name")
	if err != nil {
		return 0, false
	}

	userID, ok := session.Values["user_id"].(uint)
	return userID, ok
}

func (sm *SessionManager) InvalidateSession(w http.ResponseWriter, r *http.Request) error {
	session, err := sm.store.Get(r, "session-name")
	if err != nil {
		return err
	}

	// セッションを無効化
	session.Options.MaxAge = -1
	return session.Save(r, w)
}

```


## ハンドラーの追加
`interface/handler/user_handler.go`に新しいメソッドを実装:

- `SignIn()`: ログイン処理
- `SignOut()`: ログアウト処理
- `GetCurrentUser()`: 現在のユーザー情報を取得

```go
type (
	SignUpRequest = application.CreateUserInput

     // ↓↓↓追加部分(1行)
	SignInRequest = application.SignInUserInput
)

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

     // ↓↓↓追加部分(3行)
	SignIn(ctx *gin.Context)
	SignOut(ctx *gin.Context)
	GetCurrentUser(ctx *gin.Context)
}

// 省略
// ログイン処理
func (h *UserHandler) SignIn(ctx *gin.Context) {
	var request SignInRequest
	if err := ctx.ShouldBindJSON(&request); err != nil {
		ctx.JSON(400, gin.H{"error": err.Error()})
	}

	id, err := h.userUsecase.SignIn(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
	}
}

// ログアウト処理
func (h *UserHandler) SignOut(ctx *gin.Context) {
	if err := h.sessionManager.InvalidateSession(ctx.Writer, ctx.Request); err != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Logout failed"})
		return
	}

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

// 現在のユーザー情報を取得
func (h *UserHandler) GetCurrentUser(ctx *gin.Context) {
	userID, exists := h.sessionManager.GetUserID(ctx.Request)
	if !exists {
		ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
		return
	}

	user, err := h.userUsecase.FindByID(ctx, userID)
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"error": "User not found"})
		return
	}

	ctx.JSON(http.StatusOK, gin.H{
		"id":    user.ID,
		"name":  user.Username,
		"email": user.Email,
	})
}
```

## ルーティングの更新
`main.go`に新しいエンドポイントを追加
```go
func main() {
	// 省略

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

    // ↓↓↓↓追加部分(3行)
	router.POST("/api/user/signin", userHandler.SignIn)
	router.POST("/api/user/signout", userHandler.SignOut)
	router.GET("/api/user/me", userHandler.GetCurrentUser)

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

## その他: DB操作部分
### ユースケースの追加
- `SignIn()`: EmailとPasswordを使って認証
- `FindByID()`: IDを使ってuserデータを取り出す

```go
package application

// 省略

// 追加部分（type２つ）
type SignInUserInput struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

type UserOutput struct {
	ID       uint   `json:"id"`
	Username string `json:"username"`
	Email    string `json:"email"`
}

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

     // 追加部分(2行)
	SignIn(ctx context.Context, input SignInUserInput) (uint, error)
	FindByID(ctx context.Context, id uint) (*UserOutput, error)
}

// 省略

// 以下を追加
func (u *UserUsecase) FindByID(ctx context.Context, id uint) (*UserOutput, error) {
	user, err := u.userRepo.FindByID(ctx, id)
	if err != nil {
		return nil, err
	}

	var userOutput UserOutput
	userOutput.ID = user.ID
	userOutput.Username = user.Name
	userOutput.Email = user.Email

	return &userOutput, nil
}

func (u *UserUsecase) SignIn(ctx context.Context, input SignInUserInput) (uint, error) {
	// ユーザー検索
	user, err := u.userRepo.FindByEmail(ctx, input.Email)
	if err != nil {
		return 0, err
	}

	// パスワード検証
	err = bcrypt.CompareHashAndPassword(
		[]byte(user.Password),
		[]byte(input.Password),
	)
	if err != nil {
		return 0, err
	}
	return user.ID, nil
}

```

### repository
`domain/repository/iuser_repository.go`で追加するDBアクセス部分のinterfaceを定義する
```go
package repository
// 省略

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

     // 追加部分（2行）
	FindByID(ctx context.Context, id uint) (*model.User, error)
	FindByEmail(ctx context.Context, email string) (*model.User, error)
}
```

### dao
`infrastructure/dao/user.go` でデータベース操作部分を実装する。

- `FindByID()`: IDを使ってDBからuserデータを取り出す
- `FindByEmail()`: Emailを使ってDBからuserデータを取り出す
```go
package dao

// 省略

// 以下を追加
func (r *userRepository) FindByID(ctx context.Context, id uint) (*model.User, error) {
	user := &model.User{}
	err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error
	if err != nil {
		return nil, err
	}
	return user, nil
}

func (r *userRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
	user := &model.User{}
	err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
	if err != nil {
		return nil, err
	}
	return user, nil
}
```
## 動作確認
### SignIn
**エンドポイント**
```bash
# POST
http://localhost:8080/api/user/signin
```

**リクエストボディ**
```bash
{
    "email": "test_email",
    "password": "test_password"
}
```

![スクリーンショット 2024-12-10 15.38.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/ee640fd9-e7f5-5855-d2cf-bb90d968b2e2.png)

### 自動SignIn
**エンドポイント**
```bash
# GET
http://localhost:8080/api/user/me
```

![スクリーンショット 2024-12-10 15.47.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/549aafe3-4b43-6c94-b4d3-a4e6bfbac0f2.png)

- SignInやSignUpを行うとCookieに`session-name`のキーとバリューが格納されています
- Cookieを使用してバックエンドからuserのデータがレスポンスとして返される

:::note info
フロントでこのエンドポイントを叩くことでuserが存在するか？存在する場合はログイン処理を飛ばすって感じです。
:::


### 自動SignOut
**エンドポイント**
```bash
# POST
http://localhost:8080/api/user/signout
```

![スクリーンショット 2024-12-10 15.51.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/26da3698-6d1d-b800-bd36-c7127ec4025d.png)

- SignOutを行うとCookieに保存されていた`session-name`のキーとバリューがなくなります
- これで次回以降は、SignInやSignUpを行う必要があります

# おわりに
今回の記事では、前回のSignUp機能に続き、サインイン、自動サインイン、サインアウト機能を実装しました。これによってユーザーの認証機能による基本的なBE実装が完了です。

パスワード変更の実装の仕方などを後日書くつもりです。