ES自動生成AIを作ってた　~コード解説編~　(SignIn & SignOut)

今回は以下の拡張機能を作成したときのことについて書いていく。

https://github.com/Yutosaki/es-writer/tree/main?tab=readme-ov-file

https://chromewebstore.google.com/detail/es-writer-extension/jkencchebhkbaomammmgbhnpalgkchkm?hl=ja

以下の機能がどの様に動いているのかについて、それぞれコードを追いながら解説していきたいと思う。

* [SignUp(メールアドレスなどの認証も含む登録）](https://qiita.com/Yutosaki/items/5fec33696105678aa416)
* SignIn(ログイン)
* [経歴入力(自己PR、経験、今まで作った作品)](https://qiita.com/Yutosaki/items/b3c1a35a6e3c1306cd3f)
* [回答生成(埋め込み含む)](https://qiita.com/Yutosaki/items/b3c1a35a6e3c1306cd3f)
* SignOut(ログアウト)


構成は以下の様になっている。
```bat:フロントエンド
frontend   (モジュールやビルドパッケージについては省略)
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── README.md
├── src
│   ├── components
│   │   ├── Profile.tsx
│   │   └── ProfileForm.tsx
│   ├── contents
│   │   └── index.tsx
│   ├── popup
│   │   ├── index.tsx
│   │   └── routes
│   │       ├── App.tsx
│   │       ├── checkEmail.tsx
│   │       ├── genAnswer.tsx
│   │       ├── generating.tsx
│   │       ├── home.tsx
│   │       ├── logOut.tsx
│   │       ├── openProfileForm.tsx
│   │       ├── signIn.tsx
│   │       └── signUp.tsx
│   └── tabs
│       ├── profile.html
│       └── profile.tsx
├── style.css
├── tailwind.config.js
└── tsconfig.json
```

```bat:バックエンド
backend
├── controller
│   ├── auth_controller.go
│   ├── controllerUtils
│   │   └── controllerUtils.go
│   ├── generate_controller.go
│   └── user_controller.go
├── db
│   └── db.go
├── docker-compose.yml
├── Dockerfile
├── docs
│   └── swagger.yml
├── go.mod
├── go.sum
├── handler
│   └── handler.go
├── infrastructure
│   └── infrastructure.go
├── main.go
├── middleware
│   ├── auth
│   │   └── auth.go
│   └── cors
│       └── cors.go
├── migrate
│   └── migrate.go
├── model
│   ├── generate.go
│   └── user.go
├── README.md
├── repository
│   ├── auth_repository.go
│   ├── generate_usecase.go
│   └── user_repository.go
├── start.sh
├── terraform
│   ├── alb.tf
│   ├── cognito.tf
│   ├── docker-compose.yml
│   ├── ec2.tf
│   ├── provider.tf
│   ├── rds.tf
│   ├── route53.tf
│   ├── variables.tf
│   └── vpc.tf
├── usecase
│   ├── auth_usecase.go
│   ├── generate_usecase.go
│   ├── usecaseUtils
│   │   └── usecaseUtils.go
│   └── user_usecase.go
└── validator
    └── validator.go
```

 # SignIn (ログイン)

 まずはフロントから見ていく。
 `home.tsx`というファイルでサインインというボタンを押すと`signIn.tsx`というファイルにナビゲートされる。このファイルではバックエンドのサインイン用のAPIに入力してもらったユーザーネームとパスワードを投げて、サインインできるかどうかを判断するというものである。

 ```tsx:signIn.tsx
 import React from "react"
import { useForm } from "react-hook-form"
import { useNavigate } from "react-router-dom"

import { useStorage } from "@plasmohq/storage/hook"

import { api_endpoint } from "../../contents/index"
import openProfileForm from "./openProfileForm"

const SignIn = () => {
  const navigate = useNavigate()
  const [loginState, setLoginState] = useStorage<string>("loginState")
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm()

  const onSubmit = async (data) => {
    console.log("SignIn form submitted")
    const response = await fetch(api_endpoint + "/auth/login", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify(data)
    })

    if (response.ok) {
      console.log("SignIn successful")
      setLoginState("logged-in")
      openProfileForm()
    } else {
      console.error("Sign in failed")
      alert("ユーザーネームまたはパスワードが違います")
    }
  }

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="flex flex-col space-y-1.5 w-40 items-center mb-2 mt-2">
      <input
        type="text"
        placeholder="Username"
        {...register("username", {
          required: "ユーザー名は必須です"
        })}
        className="border border-gray-300 rounded-md px-4 py-1 w-5/6"
      />
      {errors.username && typeof errors.username.message === "string" && (
        <span className="text-red-500 text-xs">{errors.username.message}</span>
      )}

      <input
        type="password"
        placeholder="Password"
        {...register("password", {
          required: "パスワードは必須です"
        })}
        className="border border-gray-300 rounded-md px-4 py-1 w-5/6"
      />
      {errors.password && typeof errors.password.message === "string" && (
        <span className="text-red-500 text-xs">{errors.password.message}</span>
      )}

      <div className="flex justify-center space-x-4">
        <button
          type="submit"
          className="bg-blue-500 text-white rounded-md px-3.5 py-2 hover:bg-blue-700">
          Sign In
        </button>
        <button
          onClick={() => {
            setLoginState("not-logged-in")
            navigate("/")
          }}
          className="bg-gray-500 text-white rounded-md px-3 py-2 hover:bg-gray-700">
          Back
        </button>
      </div>
    </form>
  )
}

export default SignIn

```

注意点としては以下のところでパスワードが間違っているのか、ユーザーネームが間違っているのかを知らせてはいけないということだ。どちらかがわかるとブルートフォース攻撃などされてしまう可能性があるので、ユーザーネームかパスワードのどちらかが間違っていても、指定しないことに注意しよう。
```tsx:APIからのレスポンスの処理
if (response.ok) {
      console.log("SignIn successful")
      setLoginState("logged-in")
      openProfileForm()
    } else {
      console.error("Sign in failed")
      alert("ユーザーネームまたはパスワードが違います")//ここでどちらの間違いかに言及しない様にしよう
    }
```

もしサインインに成功したらcookieにログインしていることを書き込む。こうすることでログイン状態を維持して、様々なログイン後のアクションを確認なしでしようすることができる。

そして次にバックエンドのルーティング処理が行われる
```go:handler.go
package handler

import (
	"es-app/controller"
	"es-app/middleware/auth"
	"es-app/middleware/cors"
	"es-app/validator"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	"github.com/labstack/gommon/log"
)

func NewRouter(
	ac controller.IAuthController,
	am auth.IAuthMiddleware,
	uc controller.IUserController,
	gc controller.IGenerateController,
) *echo.Echo {
	e := echo.New()
	e.Validator = validator.NewValidator()
	e.Use(middleware.Logger())
	e.Logger.SetLevel(log.INFO)

	authGroup := e.Group("/auth")
	{
		authGroup.Use(cors.SetupAuthCORS())
		authGroup.POST("/signup", ac.SignUp)
		authGroup.POST("/checkEmail", ac.CheckEmail)
		authGroup.POST("/resendEmail", ac.ResendEmail)
		authGroup.POST("/login", ac.Login)
		authGroup.POST("/logout", ac.LogOut)
	}
	appGroup := e.Group("/app")
	{
		appGroup.Use(cors.SetupUserCORS())
		appGroup.Use(am.JwtMiddleware())
		userGroup := appGroup.Group("/profile")
		{
			userGroup.GET("/getProfile", uc.GetProfile)
			userGroup.PATCH("/updateProfile", uc.UpdateProfile)
		}
		generateGroup := appGroup.Group("/generate")
		{
			generateGroup.POST("/generateAnswers", gc.GenerateAnswers)
		}
	}
	return e
}
```
そして`controller層`のLoginという関数に飛ばされる。各層が何をしているのかなどは`SignUp`の記事を見てもらえればわかると思う。

前回の記事のSignUpの際にもLoginしているかどうかを確認するためにこの関数について解説したので今回は簡単にだけ説明する。この層より下の`usecase層`などは今回は省略する。

```go:Login
func (ac *authController) Login(c echo.Context) error {
	loginUser := model.LoginUser{}
	accessToken, err := c.Cookie("accessToken")
	if err == nil {
		res, err := ac.authUsecase.AccessToken(c, accessToken.Value)
		if err == nil {
			c.Logger().Debug("🟡 Use access token")
			return c.JSON(http.StatusOK, res)
		}
	}

	refreshToken, err := c.Cookie("refreshToken")
	if err == nil {
		cookieRes, userRes, err := ac.authUsecase.RefreshToken(c, refreshToken.Value)
		if err == nil {
			c.Logger().Debug("🟡 Use refresh token")
			controllerUtils.SetLoginCookie(c, cookieRes.IDToken, cookieRes.AccessToken, cookieRes.RefreshToken)
			return c.JSON(http.StatusOK, userRes)
		}
	}

	if err := c.Bind(&loginUser); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	if err := c.Validate(loginUser); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	loginRes, err := ac.authUsecase.LogIn(c, loginUser)
	if err != nil {
		return echo.NewHTTPError(http.StatusForbidden, err.Error())
	}

	c.Logger().Debug("🟡 Use username and password")

	controllerUtils.SetLoginCookie(c, loginRes.IDToken, loginRes.AccessToken, loginRes.RefreshToken)

	return c.JSON(http.StatusOK, loginUser)
}
```

ここでは、まず`アクセストークン`をクッキーから取得し、それが有効ならそれを使用してサインインを済ませる。
もし有効でなく、レフレッシュトークンが有効ならばそれを使用して新しいアクセストークンをクッキーにセットしてログインを完了する。
これらのどちらも有効でない場合はパスワードとユーザーネームを利用してログインをし、アクセストークンとリフレッシュトークンをクッキーにセットする。

これらのクッキーのセットを行うことで次回以降ユーザーネームなどを入力することなく別の画面に飛んだりしても再ログインせずに拡張機能を同じように使用することができる

これでサインインが完了した。
前回にサインアップの記事でサインインの処理のところも詳しく説明していたので、とても簡単に終わってしまった。

# SignOut (ログアウト)

フロントの`home.tsx`でログアウトというボタンを押すと`logOut.tsx`というファイルに飛ぶ。
```tsx:logOut.tsx
import React from "react"
import { useNavigate } from "react-router-dom"

import { useStorage } from "@plasmohq/storage/hook"

import { api_endpoint } from "../../contents"

function LogOut() {
  const navigate = useNavigate()
  const [_, setLoginState] = useStorage<string>("loginState")

  const handleLogout = async () => {
    try {
      const response = await fetch(api_endpoint + "/auth/logout", {
        method: "POST",
        credentials: "include"
      })
      if (response.ok) {
        await setLoginState("not-logged-in")
        navigate("/")
      } else {
        console.error("Logout failed")
      }
    } catch (error) {
      console.error("An error occurred during logout", error)
    }
  }

  return (
    <button
      className="block mx-auto bg-gray-500 hover:bg-gray-700 text-white rounded-md w-24 h-8 p-2 mt-1 mb-4"
      onClick={handleLogout}>
      ログアウト
    </button>
  )
}

export default LogOut
```

この関数内では特に難しいことはしておらず、`signIn.tsx`のファイルと同様にログアウト用のAPIを叩いているだけである。ログアウトはクッキーを消すだけなので何かdataを送る必要はない。ここで少しだけなぜ`POSTメソッド`を使用したのかについて記す

* なぜPOSTメソッドを使用するのか
    * 状態変更の意図を伝える：
    REST APIの設計原則の様なものがある。これはWeb上での通信を効率的かつ安全に行うためのガイドラインだ。[参考記事1](https://qiita.com/yuuu1654/items/ce1199f047b9cbc41f9a) [参考記事2](https://learn.microsoft.com/ja-jp/azure/architecture/best-practices/api-design)
    これらを簡単に読めばわかるのだが、一般的に、リソースの作成やサーバーの状態変更などの操作を行う時にはPOSTメソッドを使用することが一般的である。


そしてAPIに送られた処理を同様にルーティング処理を行い、`auth_controller.go`に処理が送られる

```go:auth_controller.go
func (ac *authController) LogOut(c echo.Context) error {
	accessToken, err := c.Cookie("accessToken")
	if err != nil {
		return echo.NewHTTPError(http.StatusUnauthorized, "Access token not found")
	}

	// Cognitoからのサインアウト
	err = ac.authUsecase.LogOut(c, accessToken.Value)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
	}

	// クライアント側でクッキーを削除するためのセット
	controllerUtils.ClearLoginCookie(c)

	c.Logger().Debug("🟡 Logged out")

	return c.NoContent(http.StatusOK)
}
```

ここではCognitoからサインアウトを行い、クッキーからもアクセストークンやリフレッシュトークン、IDトークンなどを削除(何もセットしない)を行う。以下のように`MaxAge`を-1(過去)にすることでブラウザ側でそのクッキーの中身を削除してくれる。また、Cognitoからサインアウトすることで、サーバー側でユーザーのセッションを無効にし、アクセストークンやリフレッシュトークンが不正に使用されるリスクを減らす。

```go:controllerUtils.go（controller層）
func ClearLoginCookie(c echo.Context) {
	c.SetCookie(&http.Cookie{
		Name:     "accessToken",
		Value:    "",
		MaxAge:  -1,
		HttpOnly: true,
		Secure:  false,
		SameSite: http.SameSiteStrictMode,
		Path:     "/",
	})
	c.SetCookie(&http.Cookie{
		Name:     "refreshToken",
		Value:    "",
		MaxAge:  -1,
		HttpOnly: true,
		Secure:  false,
		SameSite: http.SameSiteStrictMode,
		Path:     "/",
	})
	c.SetCookie(&http.Cookie{
		Name:     "idToken",
		Value:    "",
		MaxAge:  -1,
		HttpOnly: true,
		Secure:  false,
		SameSite: http.SameSiteStrictMode,
		Path:    "/",
	})
}
```

これにてサインアウトの処理も完了した。

# 最後に
前回の記事でサインインの処理を詳しく書いていたため割と簡潔な記事になった。サインアウトではCognitoでもサインアウトすることで作成したトークンが全て無効になり、悪用(e.g.不正アクセス)なども防ぐことができる。