はじめに
ある案件の要件に「Googleでログインしているユーザーで、スプレッドシートに帳票を出力してほしい」とありましたので、その実現のためにはまったポイントについて、refresh_tokenの「取り方」「保管の仕方」「使い方」の3つの観点で整理しようと思います。
サーバー側のロジックはGoで記述し、Webフレームワークは一部echoを使用しています。
全体概要
解説のためmain.goに全て記述しました。
著書『Go言語によるWebアプリケーション開発』(以下、「教科書」)の内容を元に実装しています。
package main
import (
"fmt"
"github.com/gomodule/redigo/redis"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/labstack/echo"
"github.com/stretchr/gomniauth"
"github.com/stretchr/gomniauth/providers/google"
"github.com/stretchr/objx"
"golang.org/x/oauth2"
google2 "golang.org/x/oauth2/google"
"log"
"net/http"
"os"
"strings"
)
// 認証情報はRedisに保存する
var conn redis.Conn
/*
CLIENT_ID プロジェクトのクライアントID
SECRET_VALUE プロジェクトのシークレットValue
REDIRECT_URL プロジェクトで設定したリダイレクトURL
SECURITY_KEY 任意の文字列
*/
func main(){
log.Println("server start...")
_ = godotenv.Load()
var err error
conn, err = redis.Dial("tcp", os.Getenv("REDIS_CONN"))
if err != nil{
panic(err)
}
// google認証機能の設定
gomniauth.SetSecurityKey(os.Getenv("SECURITY_KEY"))
gomniauth.WithProviders(
google.New(os.Getenv("CLIENT_ID"), os.Getenv("SECRET_VALUE"), os.Getenv("REDIRECT_URL")),
)
e := echo.New()
// ハンドラの登録
e.GET("/login", Login)
e.GET("/callback", Callback)
g := e.Group("")
g.Use(AuthGuard())
// ログインしていないと見れないページ
g.GET("/private", Private)
// echoサーバーの起動
e.Logger.Fatal(e.Start(":1323"))
}
// Login GoogleログインのURLの発行処理
func Login(c echo.Context) error{
log.Println("Login is invoked")
// ! refresh_tokenを取り出すときのポイント
// provider, err := gomniauth.Provider("google") 教科書ではここでこれを使っているが、それだとScorpesをいじれないし、refresh_tokenも取れない。
// url, err := provider.GetBeginAuthURL(nil, nil)
// 代わりにoauth2でconfigを直接いじる。
config := oauth2.Config{
ClientID: os.Getenv("CLIENT_ID"),
ClientSecret: os.Getenv("SECRET_VALUE"),
Endpoint: google2.Endpoint,
RedirectURL: os.Getenv("REDIRECT_URL"),
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/spreadsheets", // スプレッドシートの使用権限を追加
},
}
// こうするとログイン後にスプレッドシートの操作権限を渡して良いかの確認ダイアログが出るurlを取得でき、「refreshToken」が取れる。
url := config.AuthCodeURL(os.Getenv("SECURITY_KEY"), oauth2.AccessTypeOffline, oauth2.ApprovalForce)
// 生成したurlを返す。フロントはこれにリクエスト、リダイレクトしてもらう。
return c.String(http.StatusOK, url)
}
/*
googleログイン後はリダイレクトURLに対してクエリ「?code=XXXX」がついた状態でリダイレクトする
教科書では直接GoのCallback関数にリダイレクトさせるように記述されているが、
フロントとAPIでソースコードが別れている場合ではうまくいかなかった。
そのため、一度フロントにURLを返して、改めて再度queryにcodeの値をセットして、Goの/callbackにリクエストを投げてもらっている。
*/
// Callback ログイン後のコールバック処理
func Callback(c echo.Context) error{
log.Println("Callback is invoked")
provider, err := gomniauth.Provider("google")
if err != nil{
return c.String(http.StatusInternalServerError, err.Error())
}
// codeの値を取り出す。Googleの認可処理
code := c.QueryParam("code")
cred, err := provider.CompleteAuth(objx.MustFromURLQuery("code=" + code))
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
// 変数「user」にGoogleで保持しているユーザー情報が取れる。access_token、emailと、アバターの画像、名前、jwtトークンに加えて、上記の対応によりここでrefresh_tokenが含まれるようになる。
user, err := provider.GetUser(cred)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
// refresh_tokenを保管するときのポイント
// redisにその情報を保存するため、keyを生成し、cred,userからほしい情報だけ取り出して保存する。
uuID, err := uuid.NewRandom()
if err != nil {
panic(err)
}
uuidStr := uuID.String()
refreshToken := fmt.Sprintf("%v", cred.Get("refresh_token"))
jsonStr := fmt.Sprintf(`{"refresh_token":"%s","email":"%s"}`,
refreshToken, user.Email())
log.Println(jsonStr)
_, err = conn.Do("SET", uuidStr, jsonStr, "NX")
if err != nil{
return c.String(http.StatusInternalServerError, err.Error())
}
// フロントにredisのキーを返す。フロントは今後Header["Authorization"]に入れてリクエストしてもらう。
return c.String(http.StatusOK, uuidStr)
}
// Private ログインしていない場合、"success"は表示されない。
func Private(c echo.Context) error{
log.Println("Private is invoked")
// context からトークンを取り出す
token := c.Get("token").(string)
log.Println(token)
// tokenを表示させる。
return c.JSON(http.StatusOK, token)
//return c.String(http.StatusOK, "success")
}
// AuthGuard ログイン状態の確認
func AuthGuard() echo.MiddlewareFunc{
return func(next echo.HandlerFunc) echo.HandlerFunc{
return func(c echo.Context) error{
log.Println("AuthGuard is invoked")
// Header["Authorization"]からredisのキーを取り出す。
token := c.Request().Header.Get("Authorization")
key := strings.ReplaceAll(token, "Bearer ", "")
// redisから情報を取り出す。
res, _ := redis.String(conn.Do("GET", key))
if res == ""{
// ない場合、ログインが必要だと表示
return c.String(http.StatusUnauthorized, "login required")
}
// contextに値を詰めておく。
c.Set("token", res)
// ログインしているので、その関数を実行する。
if err := next(c); err != nil{
return c.String(http.StatusInternalServerError, err.Error())
}
return nil
}
}
}
// ! refresh_tokenの使い方のポイント
// リフレッシュトークンを元にアクセストークンを取得する
func getClient(refreshToken string) *http.Client {
log.Println(model.IsInvoked())
urlValue := url.Values{
"client_id": {os.Getenv("CLIENT_ID")},
"client_secret": {os.Getenv("SECRET_VALUE")},
"refresh_token": {refreshToken},
"grant_type": {"refresh_token"},
}
resp, err := http.PostForm("https://www.googleapis.com/oauth2/v3/token", urlValue)
if err != nil {
log.Fatalf("Error when renew token %v", err)
}
body, err := ioutil.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
log.Fatal(err)
}
var token oauth2.Token
_ = json.Unmarshal(body, &token)
config := oauth2.Config{}
return config.Client(context.Background(), &token)
}
refresh_tokenの「取り方」のポイント
にてログイン機能の実装は解説されているのですが、その通りに実装すると、refresh_tokenを変数credの中から取得することができませんでした。(Login関数内のポイント①を参照)
そのため、config.AuthCodeURLのnilを渡している二箇所にそれぞれ、oauth2.AccessTypeOffline, oauth2.ApprovalForceを引数に渡すと、ユーザーがGoogleログインするときに「〇〇 が Google アカウントへのアクセスをリクエストしています」というダイアログを出すようになります。
すると、Callback関数のprovider.GetUser(cred)の返り値のなかに、refresh_tokenが含まれるようになります。
refresh_tokenの「保管の仕方」のポイント
refresh_tokenが流出した場合、その人の権限でスコープで記述した範囲のことができてしまうので、ユーザーに直接見える形で管理するというのは避けなければなりません。
そのため、今回はuuID.String()で生成した値をキーに、メールアドレスと一緒にredisに保管しています。キーをフロントとやり取りするときはAuthorizationヘッダー内に入れます。
refresh_tokenの「使い方」のポイント
いざ使うときにはrefresh_tokenをそのまま使うのではなく、その都度access_tokenを生成しなければなりません。
getClient関数にて、その記述をしました。
さいごに
まだまだ経験の浅いエンジニアです。上記がベストプラクティスなのかは確証がありません・・・。
他にもっと良い方法があった場合、ぜひぜひおしえてください。