🔥

OIDCとセッション管理を実装する(Go/echo/GORM+PostgreSQL16.4)

2024/09/29に公開

少し長くなりますがユーザーの認証とセッション管理を実装します。現代においてログインやセッション管理は非常に多くの選択肢があり、環境によって適切な方法を選ぶ必要があります。

今回は小規模な環境のため認証はGoogleのOIDCを実装して、セッション管理は専用のソフトウェアやサーバーなどを準備せず、DBサーバで管理してCookieで維持する形にしたいと思います。

GoのOIDC(OAuth2)は言語のパッケージ内にモジュールが取り込まれていて、サンプルも準備されているため特に困難なことはありません。OIDCのサンプルは以下を使います。

https://github.com/coreos/go-oidc

上記はGoの機能を使って実装しているため、echoの記載や設定管理方法に合わせて修正する必要があります。1クラスで認証のロジックは全て記載されています

以下でGoogle側にリダイレクトして認証を要求しています。
https://github.com/coreos/go-oidc/blob/v3/example/idtoken/app.go#L65-L80
以下でリクエストからリダイレクトされたものを受け取っています。
https://github.com/coreos/go-oidc/blob/v3/example/idtoken/app.go#L82-L140

認証が成功した場合Googleからメールアドレスや名前を共有していただけるため、DBに登録済みユーザーか確認した上で、未登録であれば登録する仕様にします。

まず echo においてrouteの設定をする。ログイン時には /sociallogin にアクセスする。ログイン後のcallbackは /auth/google/callback にする。

route.go
e.GET("/sociallogin", auth.SocialLogin)
e.GET("/auth/google/callback", auth.LoginCallback)

socialloginの実装。エラーハンドリングは500で統一しておき、認証用のCookieを作成してリダイレクトさせる。Cookieはsecure属性をつけるべきですが開発環境なのでhttpで動かす。

app.go
func SocialLogin(c echo.Context) error {
	state, err := randString(16)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, "{}")
	}
	nonce, err := randString(16)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, "{}")
	}
	writeCookie(c, "state", state)
	writeCookie(c, "nonce", nonce)
	return c.Redirect(http.StatusFound, conf.OauthConfig.AuthCodeURL(state, oidc.Nonce(nonce)))
}

func writeCookie(c echo.Context, name, value string) {
	cookie := new(http.Cookie)
	cookie.Name = name
	cookie.Value = value
	cookie.MaxAge = 60
	cookie.Path = "/"
	cookie.HttpOnly = true
	c.SetCookie(cookie)
}

続いてcallback部分。長いので一部省略します。認証が成功して、ユーザーの登録がなければDBにユーザーを登録する仕様としています。また、すでに登録済みの場合は通常のログインとして扱うため、Sessionを管理するテーブルにレコードを追加します。

app.go
func LoginCallback(c echo.Context) error {
	state, err := c.Cookie("state")
	if err != nil {
		return c.JSON(http.StatusInternalServerError, "{cookie error1}")
	}
	if c.QueryParam("state") != state.Value {
		return c.JSON(http.StatusInternalServerError, "{cookie error2}")
	}
・・・中略・・・
	var jsondata map[string]interface{}
	p, _ := json.Marshal(resp.IDTokenClaims)
	json.Unmarshal(p, &jsondata)

	if api.CheckUserExist(jsondata["email"].(string), jsondata["sub"].(string)) {
		session := api.AddSession(api.GetUser(jsondata["email"].(string), jsondata["sub"].(string)).Uuid)
		return c.JSON(http.StatusOK, "{login success login user :"+session.Userid+"}")
	}

	user := api.AddSocialUser(jsondata["name"].(string), jsondata["email"].(string), jsondata["sub"].(string), "1")
	return c.JSON(http.StatusOK, user)
}

ユーザーの登録〜ログイン(セッション簡易管理)までを実装しました。セッションを一意に特定する情報としてUUIDを割り振っていますが、それだと総当たり攻撃で一致する可能性があるため、難読化してブラウザにCookieとして返してセッションを維持する仕組みにする想定です。
(次回のアクセス以降でCookieの文字列を確認して、セッション管理のテーブル内にレコードがあればログイン中とみなす)

次回は、ログアウトやログイン中に利用する機能について実装します。

今日、実装中にすでに登録済みユーザーかどうかチェックする関数を作成しましたが「f」だけ入力するとCopilotが以下の関数を丸ごと提案してくれました。やりたいことに合致していて素晴らしかったです。

func CheckUserExist(mail string, sub string) bool {
	var result model.User
	conf.MainDB.Where("Mail = ? AND Sub = ?", mail, sub).First(&result)
	if len(result.Uuid) == 0 {
		return false
	}
	return true
}

Discussion