今回は以下の拡張機能を作成したときのことについて書いていく。
以下の機能がどの様に動いているのかについて、それぞれコードを追いながら解説していきたいと思う。
- SignUp(メールアドレスなどの認証も含む登録)
- SignIn(ログイン)
- 経歴入力(自己PR、経験、今まで作った作品)
- 回答生成(埋め込み含む)
- SignOut(ログアウト)
構成は以下の様になっている。
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
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に入力してもらったユーザーネームとパスワードを投げて、サインインできるかどうかを判断するというものである。
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
注意点としては以下のところでパスワードが間違っているのか、ユーザーネームが間違っているのかを知らせてはいけないということだ。どちらかがわかるとブルートフォース攻撃などされてしまう可能性があるので、ユーザーネームかパスワードのどちらかが間違っていても、指定しないことに注意しよう。
if (response.ok) {
console.log("SignIn successful")
setLoginState("logged-in")
openProfileForm()
} else {
console.error("Sign in failed")
alert("ユーザーネームまたはパスワードが違います")//ここでどちらの間違いかに言及しない様にしよう
}
もしサインインに成功したらcookieにログインしていることを書き込む。こうすることでログイン状態を維持して、様々なログイン後のアクションを確認なしでしようすることができる。
そして次にバックエンドのルーティング処理が行われる
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層
などは今回は省略する。
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
というファイルに飛ぶ。
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メソッドを使用するのか
そしてAPIに送られた処理を同様にルーティング処理を行い、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からサインアウトすることで、サーバー側でユーザーのセッションを無効にし、アクセストークンやリフレッシュトークンが不正に使用されるリスクを減らす。
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.不正アクセス)なども防ぐことができる。